Когда драйвер падает раз в неделю на сервере заказчика и не оставляет после себя ничего, кроме синего экрана с кодом 0x139, классическая отладка бессильна. Разработчик знает, где искать дамп, но не понимает, что происходило за десять секунд до порчи памяти. Техника record and replay, которая пришла в отладку Windows под именем Time Travel Debugging, переворачивает привычный порядок работы: вместо того чтобы гонять тест тысячу раз в надежде случайно попасть в точку останова на нужной ветке, инженер записывает исполнение один раз и потом прокручивает его вперёд и назад сколько угодно. Проблема в том, что запись работает только над кодом пользовательского режима, а драйвер живёт в ядре. О том, как всё же прикрутить временную отладку к ядерным драйверам и где она даёт реальный выигрыш, и идёт речь дальше.

Что реально умеет запись исполнения в WinDbg и где её пределы

Time Travel Debugging, или TTD, записывает каждую инструкцию процесса пользовательского режима в trace-файл, который потом можно проигрывать вперёд и назад командами p- для шага назад и g- для исполнения до цели в обратную сторону. Запись запускается либо при загрузке процесса, либо через присоединение к уже работающему приложению. Требования просты, но жёсткие: WinDbg стартует с повышенными привилегиями, а запись ведётся только за одним конкретным процессом.

Ключевое ограничение кроется в архитектуре ядра Windows. Драйвер исполняется в адресном пространстве ядра с уровнем привилегий IRQL, где никакая трассировка в стиле TTD невозможна без остановки всей системы. Процессор не может "открутить назад" состояние кэшей, списков IRP и страниц физической памяти. Поэтому прямого TTD для кода ядра не существует, и любая статья, которая обещает иное, вводит читателя в заблуждение. Формулировка в документации однозначна: запись захватывает исполнение процесса, и именно процесс остаётся границей трейса.

Отдельно стоит сказать про EXDI, интерфейс расширяемости отладчика, через который к WinDbg подключаются эмуляторы и целевые платформы вроде гипервизора. Он решает задачу доступа к целевой машине и к её ядерному контексту, но сам по себе не превращает ядерную сессию в записываемую: трейс с возможностью обратной прокрутки по-прежнему снимается с процесса пользовательского режима. Полносистемная обратимая отладка с покрытием кода ядра остаётся областью сторонних решений, а не штатной возможностью комплекта отладочных инструментов.

Что тогда остаётся? Инженерный подход из трёх движений: перенести максимум логики драйвера в пользовательский тестовый код, записать этот код через TTD и сопоставить с ядерными событиями через WPP, ETW или логи Driver Verifier. Практика показывает, что 70 процентов логики драйвера - это разбор буферов, проверка длин и сборка ответов на IRP. Всё это можно детерминированно прогонять в пользовательском режиме на тех же потоковых сценариях.

Как инженер укладывает тест драйвера в записываемый процесс

Рабочая методика построена вокруг тестового стенда, который выступает клиентом драйвера. Драйвер остаётся обычным KMDF или WDM модулем, а вся сложная логика выносится в статически компонуемую библиотеку, которую одновременно использует и драйвер как вспомогательный модуль через Kernel Mode Library, и пользовательский EXE через обычный импорт. Разницы между исполнением в кольце 0 и кольце 3 почти нет, если логика не трогает прямой доступ к памяти и не держит собственных блокировок.

После этого инженер запускает тест в WinDbg с включённой записью. Запись стартуют либо из меню File, пункт Record process, либо запуском отладчика с ключом на конкретный исполняемый файл:

windbg -z testdriver.exe

Отладчик при этом обязательно запускают от имени администратора, иначе запись процесса не поднимется. Во время записи важно не наступить на грабли с производительностью: TTD сбрасывает на диск мегабайты на каждую миллисекунду насыщенного кода, и на интенсивных циклах файл растёт до десятков гигабайт за минуты. Поэтому тест держат минимальным, но с боевыми данными: тот же поток байт, та же последовательность вызовов DeviceIoControl, тот же криптографический блок. Когда запись сделана, на диске остаётся файл с расширением .run, и дальше отладчик начинает жить в записанном времени:

0:000> !ttdext.ttd
Time Travel Trace loaded: C:\Traces\testdriver.run
0:000> !tt
TTD: Position 0:0, Trace covers 1 thread(s), 412837 instructions

Позиция в TTD записывается парой "номер потока : шестнадцатеричный номер инструкции", и именно эти метки потом фигурируют во всех переходах по времени.

Командам шагать назад учат буквально за десять минут, весь обратный набор выглядит симметрично обычному:

0:000> t-          шаг назад на одну инструкцию
0:000> p-          шаг назад с заходом в вызов
0:000> g-          обратный бег до ближайшей точки останова
0:000> !tt 0:2F41  прыжок в конкретную позицию времени
0:000> !tt 0:0     возврат в самое начало записи
0:000> r           регистры на текущей позиции
0:000> k           стек на текущей позиции

Порядок работы сводится к короткому списку:

  1. Команда p- отматывает назад одну инструкцию, как обычный p идёт вперёд;
  2. Команда g- вызывает обратный бег до ближайшей точки останова, что неожиданно для новичков, которые пытаются вешать точки на call и jump;
  3. Переход по времени делают командой !tt с явной позицией, а содержимое памяти и вызовы на этой позиции спрашивают через модель данных, например dx @$cursession.TTD.Memory.

Механика сопоставления пользовательской записи с событиями ядра

Самой тонкой частью работы остаётся связка между записанным временем и ядерными событиями. Пользовательский EXE видит только результат вызова DeviceIoControl, а драйвер внутри отрабатывал ещё десять подсистем с процедурами dispatch, DPC и завершением IRP через обработчик. Взаимосвязь инженер строит через корреляционные идентификаторы: каждому входу в драйвер присваивается номер, который пишется в ETW-лог через TraceLogging и одновременно выводится в вывод тестового процесса через OutputDebugString как маркер времени.

Схема выглядит просто. Тест печатает в лог метку "START IRP 417", потом выполняет DeviceIoControl. Драйвер при входе в DispatchRead пишет в ETW ту же метку 417 плюс текущий указатель IRP и IRQL. В момент падения в ядре инженер смотрит в kernel debugger через kd -z dumpfile и видит, что IRP 417 держали 0,4 миллисекунды, а потом пул памяти под ним перезаписала другая нить. Возврат в TTD к 417-й метке позволяет пройти назад от точки, где пользовательский код ещё думал, что буфер принадлежит ему, и найти тот самый момент, когда драйвер отдал буфер на запись, а потом тестовый поток попытался его освободить. Такой сценарий раз за разом всплывает при ошибках STATUS_INVALID_HANDLE и 0xC0000008 в пользовательском коде, который на самом деле виноват тем, что драйвер вернул буфер раньше, чем тот был готов.

Инструменты вокруг WinDbg для временной отладки драйвера

Помимо записи исполнения, инженер держит под рукой набор инструментов, которые компенсируют отсутствие TTD в самом ядре. Эти приёмы не панацея, но в сумме дают заметный эффект. Driver Verifier с включёнными флагами Special Pool и Force IRQL Checking ловит порчу памяти в момент записи, а не через сотню тактов после. ETW-провайдеры, например Microsoft-Windows-Kernel-Power или собственный класс TraceLogging, сохраняют временные метки событий с высокой точностью и проигрываются в WPA рядом с TTD-файлом. Ядерная сессия отладчика, поднятая через именованный канал, запоминает порядок вызовов в ядре, пока пользовательская запись крутится параллельно:

windbg -k com:pipe,port=\\.\pipe\kd_com_1,baud=115200,reconnect

На целевой машине канал включают заранее, командой bcdedit /debug on и bcdedit /dbgsettings net с параметрами соединения. Комбинация этих источников даёт схему, при которой можно по шагам выйти от крэш-дампа к первопричине, хотя самого отката в коде драйвера нет.

Чтобы всё это срасталось, инженер следит за версией отладчика: TTD заметно лучше работает на свежих сборках, где ускорены запросы к модели данных и добавлена поддержка объёмных trace-файлов. Более старые версии теряют данные при обращении к большим записям, а часть расширений в них просто отсутствует, и переход по времени приходится делать вручную.

Ловушки при записи и как их обходить без потери данных

Три типичные ситуации раз за разом портят запись. Первая - это слишком много потоков: TTD держит в памяти псевдо-образ каждого потока, и на 16-ядерной машине с 32 активными потоками файл может пухнуть со скоростью гигабайт в минуту. Выход состоит в том, чтобы сужать запросы фильтрами прямо в модели данных, например отбирать события по диапазону времени и по потоку:

CODE4 Вторая проблема - потеря данных при обращении к устройству: ioctl на самом деле уходит в ядро, и TTD фиксирует только return path, а код драйвера не записывается. Поэтому стены между режимами видны в трейсе как резкие скачки данных через буфер, и инженер обязан отмечать границы через метки. Третья ловушка - это рекурсивная запись записываемого процесса вторым отладчиком: TTD категорически не дружит с Nested Debugging и падает с STATUS_BREAKPOINT, поэтому отладчик ядра и клиент всегда держат на разных консолях.

Проверенный приём - писать техническую карту событий заранее. Перед записью прогоняют тест в dry-run, чтобы понять, какие IRP и какие функции драйвера вступают в игру. После этого запись включают только на конкретный диапазон адресов или конкретные вызовы DeviceIoControl, а не на весь процесс целиком. Экономия выходит в пять-десять раз по объёму и ускоряет повторное проигрывание.

Ещё одна деталь, которую часто упускают, связана с переносимостью записи. Trace-файл не привязан к конкретной машине: смещения виртуальных адресов и код инструкций воспроизводятся бит в бит на любой другой Windows той же разрядности. Это делает запись идеальным средством передачи бага коллеге. Вместо многостраничного письма "у меня падает на четырнадцатой итерации под нагрузкой" инженер отдаёт файл .run размером в пару гигабайт плюс символы, и получатель видит ту же историю до последней инструкции. Для команд сопровождения драйверов, где дефект часто живёт на стыке администраторской машины и тестового стенда, такой артефакт экономит дни переписки.

Отдельного слова заслуживает бюджет накладных расходов. На типичном десктопе с SSD запись нагруженного клиента драйвера замедляет исполнение в пять-пятнадцать раз и съедает от одного до трёх гигабайт дискового пространства на минуту активного кода. Поэтому тестовый сценарий ужимают до сути: убирают красивый вывод в консоль, отключают лишние проверки и оставляют только путь, который воспроизводит дефект. Если тест после сжатия занимает секунды, а не минуты, объём trace держится в разумных пределах, и к нему удобно возвращаться десятки раз, перебирая гипотезы одну за другой.

Запросы к записанному trace через модель данных WinDbg

Главная сила записанного trace не в пошаговом шагании назад, а в запросах к модели данных. Отладчик хранит всю историю как таблицу событий, и по ней можно ходить обычными LINQ-запросами через префикс dx. Классический пример - найти все места, где кто-то писал в конкретный адрес памяти, который позже оказался испорчен:

0:000> dx -g @$cursession.TTD.Memory(0x00b7f000, 0x00b7f000 + 0x100, "w")
@$cursession.TTD.Memory(0x00b7f000, 0x00b7f000 + 0x100, "w")
    [0x00] TimeStart  : 0:1F22   TimeEnd : 0:1F2A   Value : 0x41 ...
    [0x01] TimeStart  : 0:2B80   TimeEnd : 0:2B88   Value : 0x0  ...
    [0x02] TimeStart  : 0:2F41   TimeEnd : 0:2F49   Value : 0x7f ...

0:000> !tt 0:2F41
0:000> r
0:000> k

Запрос возвращает список событий с временными позициями, и по каждой из них команда !tt прыгает ровно к той инструкции. Дальше инженер смотрит регистры и стек через r и k и понимает, кто именно и когда тронул память. Для драйверных сценариев это решающий приём: пользовательский тест эмулирует тот же буфер, что и драйвер, и порча буфера в тесте почти всегда повторяет порчу пула в ядре, потому что логика проверки длин одна и та же.

Второй полезный запрос ищет вызовы функций:

0:000> dx @$cursession.TTD.Calls("ntdll!NtDeviceIoControlFile")
    [0x00] TimeStart  : 0:18C0   FunctionName : ntdll!NtDeviceIoControlFile
    [0x01] TimeStart  : 0:2A10   FunctionName : ntdll!NtDeviceIoControlFile
    [0x02] TimeStart  : 0:2A13   FunctionName : ntdll!NtDeviceIoControlFile

Через этот запрос инженер получает каждый факт обращения к ioctl с параметрами и временем и видит ту самую последовательность запросов, которую потом отрабатывал драйвер. Если в последовательности два потока подали на один и тот же handle команды WRITE и CLEANUP с интервалом в три микросекунды, искать гонку в ядре уже не нужно - она задана самим входным сценарием. Остаётся воспроизвести её в standalone тесте и добавить недостающую блокировку в диспетчер драйвера.

Третья группа запросов касается вызовов, связанных с обработкой ошибок, и загрузки модулей. Записанный trace помнит каждое событие загрузки DLL и каждое структурное исключение, поэтому их тоже вытаскивают через dx, отбирая вызовы нужных функций ядра и библиотек. На больших файлах запросы работают медленно, и опытные инженеры сначала сужают область поиска фильтром Where по полям TimeStart и TimeEnd, а уже потом гоняют полный перебор. Экономия времени на такой последовательности достигает десяти раз.

Когда временная отладка не нужна и что делать вместо неё

Есть ситуации, где TTD приносит больше шума, чем пользы. Если драйвер падает при загрузке системы, никакой пользовательский тест ещё не запущен, и записывать нечего. Если сбой вызван повреждением списка пула от двух разных драйверов и проявляется через часы, запись теряется под объёмом данных. В таких случаях в ход идут другие приёмы: включённый Special Pool с немедленным чтением каждой страницы, сетевой отладчик kd на отдельной машине и полный дамп памяти при каждой остановке, который включают в реестре:

reg add HKLM\SYSTEM\CurrentControlSet\Control\CrashControl ^
    /v CrashDumpEnabled /t REG_DWORD /d 1 /f
reg add HKLM\SYSTEM\CurrentControlSet\Control\CrashControl ^
    /v DumpFile /t REG_EXPAND_SZ /d "C:\Windows\MEMORY.DMP" /f
gflags /p /enable testdriver.sys /full

Значение CrashDumpEnabled равное единице переключает систему на полный дамп, а gflags с ключом /full ставит подозрительный драйвер в специальный пул, где выход за границу блока приводит к остановке мгновенно, а не через сотню тактов.

Впрочем, даже там, где TTD не даёт прямого ответа, он учит уму-разуму: инженер быстрее понимает, что привычка смотреть только в точку крэша - это ошибка, а реальная причина лежит в двухстах тактах раньше. После трёх-четырёх сессий с записью разработчик начинает по-другому писать код драйвера: добавляет осмысленных ETW-событий, вводит корреляционные номера в каждом IRP и держит логику вызовов максимально плоской, чтобы она была видна в trace без длинных инлайн-цепочек. И тогда даже без "волшебной кнопки" kernel TTD отладка становится быстрее и надёжнее, а недельный квест с неуловимым багом превращается в двадцатиминутный сеанс с WinDbg.