Когда драйвер падает раз в неделю на сервере заказчика и не оставляет после себя ничего, кроме синего экрана с кодом 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 стек на текущей позиции
Порядок работы сводится к короткому списку:
- Команда p- отматывает назад одну инструкцию, как обычный p идёт вперёд;
- Команда g- вызывает обратный бег до ближайшей точки останова, что неожиданно для новичков, которые пытаются вешать точки на call и jump;
- Переход по времени делают командой !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 активными потоками файл может пухнуть со скоростью гигабайт в минуту. Выход состоит в том, чтобы сужать запросы фильтрами прямо в модели данных, например отбирать события по диапазону времени и по потоку: