Гонка данных редко выглядит как гонка. В логах она прячется под видом случайного крэша, в дампе маскируется под ошибку чужого модуля, а при попытке поймать её обычным breakpoint исчезает, потому что остановка одного ядра меняет порядок событий на всех остальных. Инженеры называют такой дефект heisenbug: стоит включить отладчик с пошаговым проходом, и воспроизведение ускользает. Выход из этой ловушки дают аппаратные точки останова на доступ к памяти, которые в Windows задаются командой ba и не меняют хода программы до самого момента наблюдаемого события. Дальше разбирается, как именно ими ловить race conditions в kernel-mode и где прячутся подводные камни.
Чем аппаратная точка останова отличается от программной
Обычный программный breakpoint устроен просто: отладчик записывает в код байт 0xCC, то есть int 3, и процессор при исполнении этой инструкции генерирует исключение. Приём надёжный, но у него два критических изъяна. Во-первых, он требует модификации кода, что на страницах ядра с защитой от записи провоцирует дополнительные события. Во-вторых, он срабатывает на инструкции, а не на данных, поэтому не отвечает на главный вопрос при гонке: кто и когда трогал конкретную ячейку памяти.
Аппаратный breakpoint работает иначе. Процессор архитектуры x86 и x64 содержит восемь отладочных регистров DR0-DR7, и распределение ролей у них фиксированное:
DR0-DR3 линейные адреса наблюдаемых ячеек, по одному адресу на слот
DR4-DR5 зарезервированы, пока в CR4 выставлен бит DE
DR6 регистр состояния, биты B0-B3 показывают, какой слот сработал
DR7 регистр условий, по паре полей RW и LEN на каждый слот
RW: 00 исполнение, 01 запись, 10 чтение или запись, 11 ввод-вывод
LEN: 00 один байт, 01 два байта, 10 восемь байт, 11 четыре байта
L0-L3: включение слота локально или глобально
Когда любой код на любой нити обращается к наблюдаемому адресу заданным способом, процессор сам генерирует исключение #DB до того, как запись завершится. Никаких изменений в коде, никаких задержек до момента события, никакой перестройки планировщика. Именно поэтому heisenbug, который убегает от int 3, не может убежать от ba.
Ограничение тоже есть, и оно жёсткое: таких слотов ровно четыре на процессор. В многопроцессорной системе Windows регистры задаются на каждом логическом процессоре через контекст потока, поэтому ядерный отладчик аккуратно раскатывает одну точку на все CPU. Но даже с этой механикой больше четырёх адресов одновременно наблюдать нельзя, и инженеру приходится расставлять приоритеты.
Постановка эксперимента в WinDbg против гонок в ядерном коде
Типовой сценарий выглядит так. Драйвер держит глобальную структуру контекста устройства, и под нагрузкой раз в несколько часов поле списка оказывается повреждено. Обычный анализ даёт только факт: блоки пула перепутаны, кто виноват не видно. Инженер подключает ядерный отладчик и ставит наблюдение за конкретным полем. Полный сеанс выглядит так:
windbg -k net:port=50000,key=1a2b3c4d
kd> !process 0 0
kd> dt mydriver!_DEVICE_CONTEXT ffffd10a`12345000
+0x000 Signature : 0x44455643
+0x008 SpinLock : 0
+0x010 ListHead : _LIST_ENTRY [ 0xffffd10a`12345010 - 0xffffd10a`12345010 ]
+0x020 EntryCount : 0n17
+0x028 LastWriter : 0xffffd10a`77aa3300
kd> ba w8 ffffd10a`12345028
kd> bl
0 e Disable Clear ffffd10a`12345028 w 8 0001 (ffffd10a`12345028) (0)
kd> g
Буква w означает срабатывание на запись, размер 8 байт соответствует указателю на 64-разрядной системе. Отныне любая инструкция, которая изменит это поле, остановит систему в отладчике ровно на следующей машинной команде. Команда k покажет стек виновника, dt по типу покажет состояние контекста, а встроенный анализатор пригодится, если событие уже вылилось в остановку:
kd> k L6
kd> !thread 0 0
kd> !irql
kd> !analyze -v
Управление слотами ничем не отличается от управления обычными точками:
kd> bl список всех точек с их номерами
kd> bd 0 временно отключить точку 0
kd> be 0 включить её обратно
kd> bc 0 снять точку 0
kd> bc * снять все точки
Удобный приём для гонок на чтение - вариант ba r, где r означает срабатывание на чтение или запись:
kd> ba r1 ffffd10a`12345020
В ядре его применяют осторожно: поля вроде счётчика ссылок читаются сотнями нитей, и отладчик утонет в остановках. В таких случаях точку сужают условием на адрес возврата или обёртывают командной строкой, которая пропускает обращения не из интересующего стека и продолжает исполнение через g.
Методика сужения области поиска при множественных писцах
Первая неприятность, с которой сталкивается инженер, - поток остановок от легального кода. Объект, к которому подозрительно обращается конкурент, обычно обращается и законный владелец. Чтобы не тонуть в шуме, применяется план из трёх шагов:
- Сначала ставится точка на все записи в поле и собирается статистика через логирование каждой остановки с командой в кавычках, чтобы увидеть характерные стеки и частоту обращений;
- Затем по этой статистике выбирается стек-шаблон виновника, например обращение только из функции завершения запроса, и breakpoint обвязывается условием на адрес возврата;
- В финале наблюдение переводится в режим автоматического продолжения с записью аргументов в лог, и инженер получает хронологию событий без ручного участия.
Такая методика превращает глухое утверждение "кто-то портит список" в точную последовательность: поток A взял объект из списка, поток B освободил его, поток C прочёл уже висячий указатель и записал мусор. Гонка перестаёт быть туманом и становится конкретным окном в две машинные инструкции между чтением указателя и захватом спинлока. Как только окно названо по имени, остаётся только выбрать, чем его закрыть - более ранним захватом замка или атомарной операцией.
Точки на стек и на пул и типичные ошибки размещения
Выбор адреса для ba имеет свои тонкости. Ставить точку на локальную переменную в стеке потока бессмысленно, если создатель потока другой: стек имеет смысл только в контексте своей нити, и в пользовательском режиме отладчику приходится указывать нить явно. В ядерном режиме просто убеждаются, что адрес лежит в глобальном пространстве или в контексте нужного процесса, а отладчик сам отзеркалит регистры на все процессоры.
Пул ядра - другая история. Объект из nonpaged pool живёт по фиксированному виртуальному адресу, и ba на нём работает безупречно. Но paged pool может быть вытеснен на диск, и тогда аппаратная точка молчит, пока страница не вернётся. Для paged объектов инженер либо временно фиксирует страницу через MmProbeAndLockPages в тестовом сценарии, либо держит копию наблюдаемых полей в несбрасываемой памяти. Пропускать этот шаг нельзя: тишина отладчика на вытесненной странице выглядит как отсутствие записей, хотя писатель просто работал через страничный обмен. Проверить резидентность адреса перед постановкой точки помогает одна команда:
kd> !address ffffd10a`12345028
Usage: <unknown>
Base Address: ffffd10a`12345000
State: MEM_COMMIT
Protect: PAGE_READWRITE
Если состояние отличается от MEM_COMMIT или адрес попадает в диапазон подкачиваемого пула, точку ставить рано.
Третья ошибка - точка на выровненный четырёхбайтный счётчик посреди структуры, в которую соседнее поле пишется восьмибайтовой операцией. Аппаратный блок срабатывает ровно по диапазону адресов, и запись мимо по соседним байтам не замечается, даже если логически это порча того же объекта. Поэтому размер точки выбирают не по размеру переменной, а по максимальной ширине записей вокруг, то есть почти всегда 8 байт на x64.
Комбинация ba с другими средствами для упрямых сценариев
Четырёх слотов мало, когда гонка затрагивает три объекта и буфер. На помощь приходят окружающие механизмы ядра. Driver Verifier с включённым Special Pool размещает каждое выделение на отдельной странице и ловит выход за границы немедленно, превращая скрытую гонку на чужие данные в громкий крэш 0xD6 прямо в момент ошибки. Когда Special Pool действует параллельно с ba, верификатор показывает внешнее повреждение, а аппаратная точка - внутреннее состояние.
Вторая пара - это ba и логирование событий. Точка останова настраивается с командной строкой, которая пишет в лог время, номер процессора и вершину стека, потом продолжает исполнение. Из пары сотен таких событий складывается хронология, которую можно укладывать на временную шкалу рядом с ETW-трассой. Если виновник - процедура обработки прерывания или DPC на высоком IRQL, останавливать всю систему на каждое обращение дорого, и логирующий вариант ba становится единственным способом наблюдения без искажения таймингов.
Третий помощник - команда !thread и переключение контекста. Пойманная запись часто происходит в произвольной нити чужого системного процесса, и без явного указания контекста стеки и локальные переменные смотрятся криво. Сочетание переключения контекста и повторного обхода стека даёт честную картину того, кто держал блокировки и какие DPC стояли в очереди:
kd> !thread 0 0
kd> .thread /w ffffd10a`66bb2080
Implicit thread is now ffffd10a`66bb2080
kd> k L12
kd> !dpcs
Голая точка на запись останавливает систему при каждом обращении, и на горячем поле это превращается в тысячи вхождений в час. Инженер экономит время заранее, прописывая условие прямо в команду установки точки. Сначала смотрят, где в памяти лежит модуль подозреваемого:
kd> lm m mydriver
start end module name
fffff807`2a410000 fffff807`2a41c000 mydriver
Дальше точку ставят с командной строкой в кавычках: всё, что записано внутри, отладчик исполняет при каждом срабатывании вместо остановки.
kd> ba w8 ffffd10a`12345028 ".if (poi(@rsp) >= 0xfffff807`2a410000 & poi(@rsp) < 0xfffff807`2a41c000) { .echo SUSPECT; !irql; k L8 } .else { g }"
Выражение poi(@rsp) читает адрес возврата текущего кадра, сравнение идёт с границами модуля, а ветка .else автоматически продолжает исполнение. Вместо ручной верификации десятков стеков получается фильтр, который оставляет только попадания из нужного диапазона. Вариант совсем без остановки, для долгого сбора статистики, пишется ещё проще:
kd> ba w8 ffffd10a`12345028 ".printf \"WRITE ret=%p\\n\", poi(@rsp); g"
Такая точка не тормозит систему на остановку, а складывает адреса возврата в окно отладчика, и из пары сотен строк уже видно распределение писателей.
Полезный навык - комбинировать ba с условными конструкциями MASM, которые поддерживает WinDbg: выражение poi(@rsp) читает адрес возврата текущего кадра, сравнение с диапазоном модулей делается через операторы больше и меньше, а результат возвращается командой .if внутри строки breakpoint. На первый взгляд синтаксис выглядит сурово, но после двух-трёх сессий инженер набирает такие условия быстрее, чем щёлкает мышью в графических отладчиках. Вознаграждение - лог, где каждая строка выглядит как попадание, а не как шум.
Второй приём из того же ряда - серийные точки. Пока свободны три слота из четырёх, на один объект ставят сразу три точки: на запись указателя, на запись соседнего поля длины и на чтение того же указателя. Совокупность событий показывает полную транзакцию записи. Если в нормальном коде между установкой указателя и длины есть ровно две команды и обе идут под спинлоком, то любой чужой порядок сразу бросается в глаза. Именно на таких сериях всплывают случаи, когда запись частей структуры идёт не атомарно: один поток обновил указатель, второй успел прочитать старую длину, и сочетание новых данных со старой метаинформацией дало классическое повреждение.
Отдельной темой стоит цена наблюдения и влияние на тайминги. Многие полагают, что аппаратный breakpoint бесплатен, и это полуправда. Само сравнение адресов выполняет процессор без единого лишнего такта, но срабатывание порождает исключение, а обработчик исключения в connected kernel debugging сессии останавливает все ядра. Если точка стоит на поле, которое пишется десять раз в секунду, система на время остановки замерзает, и тайминги всех DPC и таймеров съезжают. Для гонок, зависящих от микросекундных окон, даже краткая остановка меняет картину. Поэтому в первом приближении точку настраивают на сбор статистики без остановки, лог ведут в кольцевой буфер, а уже во втором приближении включают полную остановку только у подтверждённого паттерна.
Бюджетирование мероприятий выглядит примерно так. Сухой прогон без отладчика даёт базовую частоту сбоев - раз в три часа под нагрузкой. Прогон с ba в логирующем режиме показывает, виноват ли конкретный поток вообще: если за десять часов точка молчит, гипотеза отбрасывается и адрес смещается на следующее подозрительное поле. Прогон с останавливающим режимом на финальном этапе ловит момент с точным стеком и регистрами. Такая пирамида экономит недели, потому что сужение круга идёт первым делом, а глубокий захват - в самом конце, когда уже известно, что ловить.
Заметная деталь из практики касается виртуализации. На виртуальных машинах отладочные регистры эмулируются гипервизором, и в некоторых конфигурациях гипервизор молча игнорирует регистры либо добавляет задержки на переключение контекста. Поэтому финальную проверку методики делают на реальном железе или на виртуальной машине с гарантированной поддержкой hardware debug registers. В противном случае ложная тишина приводит инженера к выводу "гонки нет", хотя на самом деле нет наблюдения.
Практика извлечения выводов после пойманного события
Момент срабатывания - только половина работы. Вторая половина - корректное чтение стека виновника и сопоставление его с логикой блокировок. Инженер в первую очередь смотрит IRQL через !irql: обращение к списку на уровне DISPATCH_LEVEL требует спинлока, и его отсутствие в стеке говорит о гонке снятия объекта. Дальше идёт проверка владения замком: для мьютекса пользовательского режима помогает !cs -l, а для спин-блокировки ядра достаточно прочитать её значение по адресу:
kd> !irql
Current IRQL 2
kd> dq ffffd10a`12345008 L1
ffffd10a`12345008 ffffd10a`66bb2080
Ненулевое значение в этой ячейке означает, что блокировка захвачена, а само значение это адрес потока владельца. Пустой ноль рядом с IRQL равным двум и записью в список и есть та самая подпись гонки: код работает на DISPATCH_LEVEL, но ничем не защищён.
Когда виновник найден, исправление обычно сводится к одному из трёх движений: взять спинлок чуть раньше, на размер чтения указателя; заменить ручное поле ссылки на ExInterlockedIncrementUlong; либо перевести объект на схему со счётчиком ссылок через ObReferenceObject и ObDereferenceObject. Каждое из этих движений проверяется тем же ba: точку ставят заново, гоняют нагрузку втрое дольше прежнего срока жизни бага и убеждаются, что остановок от неавторизованных записей больше нет. Только после этого баг считается закрытым, а методика с аппаратными регистрами в очередной раз доказывает: редчайшая гонка укладывается в несколько строк ассемблерной хронологии, если наблюдать за ней в правильной системе координат.