Введение
В процессе перевода своих средств программирования на платформу x86-64 потребовалось перевести и встроенный интерактивный отладчик. В отличие от подключаемых отладчиков, например, WinDbg, данный отладчик находится, так сказать, непосредственно «на борту» каждой исполняемой программы. При этом он имеет сравнительно небольшие размеры (около 44 Кбайт, большую часть которых занимает дизассемблер). Я так привык к этому отладчику, что уже совершенно не могу без него обходиться, и поэтому его перевод в 64-разрядную среду стал настоятельно необходимым.
Однако формальный перевод отладчика с Win32 в Win64 дал такие странные результаты, что пришлось потратить много сил и времени, чтобы разобраться, почему то, что ранее работало в Windows XP, перестало нормально работать в Windows 7/10. Виной всему оказалась структурная обработка исключений, мало задействованная ранее в среде Win32.
В процессе разбирательства я прочел множество обсуждений на эту тему на компьютерных форумах. Задаваемые вопросы (и особенно ответы) показали, что многие программисты смутно представляют конкретную реализацию структурной обработки исключений в Win64. Поэтому кажется полезным пояснить некоторые особенности поведения системы на примере конкретной задачи, в результате чего и появилась данная статья.
Проблема при переводе отладчика на Win64
Внешне проблема после перевода в среду Win64 выглядела так:
Интерактивный встроенный отладчик, основанный на установке обработчика с помощью UnhandledExceptionFilter, заработал в Windows 7/10, но, например, в пошаговом режиме при выполнении очередной, и, казалось бы, безобидной команды «отцеплялся», после чего программа заканчивалась с надоедливым предложением отправить отчет в Microsoft, причем отчет содержал сообщение об исключении с кодом 80000004 (т.е. как раз об исключении пошаговой отладки, которое, по сути, и генерировал сам отладчик).
Кроме этого, перестали меняться отладочные регистры DR0-DR7. Все остальное работало так же как в Win32.
Особенно загадочным выглядел крах программы при записи в стек подряд двух одинаковых значений.
Пришлось анализировать коды библиотеки NTDLL.DLL в части работы диспетчера исключений. К счастью, объем кода оказался небольшим.
Общий алгоритм обработки исключения в Windows
В целом процесс обработки исключения и в среде Win32 и в среде Win64 реализован одинаково. При возникновении исключения в программе, управление, естественно, попадает в ядро Windows. Ядро опять возвращает управление на уровень пользователя и вызывает подпрограмму KiUserExceptionDispatcher, которая является маленькой оболочкой для подпрограммы собственно диспетчера исключений. Диспетчер исключений пытается найти в программе пользователя подходящий «внутрипоточный» обработчик для данного исключения. Если таковой находится и после своего запуска возвращает ответ, что исключение обработано, диспетчер оканчивает свою работу. В этом случае управление уже больше не «ныряет» в ядро, а просто восстанавливается контекст с помощью обращения к RtlRestoreContext. В конце этой подпрограммы стоит команда iret (iretq для Win64), по которой управление возвращается в прерванное место или в то место, которое обработчик установил в EIP (RIP для Win64) в измененном контексте.
Если же диспетчер так и не нашел в программе нужных «внутрипоточных» обработчиков, исключение опять «взводится» с помощью вызова NtRaiseException и, тем самым, передается на обработку уже самой Windows.
Отличия в обработке исключения Win32 и Win64
Вот на этом этапе и начинаются отличия Win32 и Win64. В Win32 для обработки исключения в программе пользователя еще остается последний шанс в виде UnhandledExceptionFilter. Если программа установила обработчик, обращаясь к SetUnhandledExceptionFilter, то из ядра Win32 опять происходит переход на уровень пользователя и вызывается этот обработчик, в моем случае интерактивный отладчик, который может изменить контекст потока, включая отладочные регистры DR0-DR7. Измененный (или не измененный) контекст устанавливается опять-таки через ядро с помощью SetThreadContext, и затем управление, наконец, попадает в прерванное место или в место, назначенное обработчиком. Если такой «финальный» обработчик в программе не установлен или сообщил, что не может обработать исключение с данным кодом – только тогда в Win32 вызывается всем знакомое окно с предложением потревожить Microsoft отчетом об ошибке.
В Win64 если диспетчер исключений не нашел «внутрипоточного» обработчика, то никакого «последнего шанса» нет, и сразу выдается системное сообщение об ошибке с прекращением работы программы.
Идея и реализация структурной обработки исключений
Но позвольте, как же тогда в Win64 вообще вызывается обработчик, установленный по SetUnhandledExceptionFilter? Именно здесь и вступает в игру структурная обработка исключений.
Сама идея структурной обработки исключений, заключающаяся в попытке выдать информацию об исключении и обработать исключение на возможно более высоком уровне, весьма здрава. В самом деле, ведь Windows имеет адрес команды, где случилось исключение. Если заранее сообщить ей какая подпрограмма занимает диапазон адресов, в который попало место исключения, и как в этом случае обрабатывать исключения, то можно автоматически вызывать именно нужный в данный момент обработчик, а не какой-то универсальный и всеобъемлющий, но безликий и неинформативный.
Вот только реализация этой идеи в Win64 вызывает массу нареканий.
Причем первый шаг обработки в Win64 вполне нормален. Диспетчер по адресу прерванной команды пытается «узнать» подпрограмму с помощью вызова RtlLookupFunctionEntry. Если информация о подпрограмме имеется во внутренней таблице (а зарегистрировать ее можно, указав прямо внутри заголовка EXE-файла или обратившись к RtlAddFunctionTable), то дело в шляпе, становится известен обработчик исключений именно для данной подпрограммы и диспетчер вызывает его. Но вот, если по адресу исключения подпрограмма не опознана…
Здесь начинаются совсем неочевидные вещи. Не найдя в какой из известных ему подпрограмм произошло исключение, диспетчер не сдается и начинает анализировать текущий стек программы дальше. В Microsoft назвали это «раскруткой стека».
Диспетчер берет следующие 8 байт «вглубь» стека, надеясь, что это очередной адрес возврата, и пытается по ним все же определить, какая из известных, т.е. зарегистрированных подпрограмм выполнялась. Но это очень шаткая и сомнительная технология.
Во-первых, исключения бывают в результате разных ошибок. При некоторых ошибках далеко не факт, что в вершине стека вообще содержит что-то разумное.
Во-вторых, при этом начисто забыли о пошаговом режиме отладки. Например, при вызове какой-нибудь процедуры API Windows подготовка к вызову начинается с команды типа sub rsp,20h. При выполнении этой команды в режиме трассировки, т.е. при очередном пошаговом исключении в вершине стека временно оказываются четыре совершенно случайных значения, не имеющих прямого отношения к программе.
В-третьих, по «теории пакости» когда-нибудь в стеке попадется значение, случайно похожее на адрес возврата, и диспетчер может вызвать обработчик, который, как говорится, «ни сном, ни духом» и не слышал о заданном исключении.
Несмотря на это, анализ стека, проводимый в диспетчере исключений, включает даже чтение кода команды (в предположении, что очередные 8 байт – это адрес возврата), «дизассемблирование» этого кода и моделирование (!) работы этой команды с точки зрения изменения стека. Да-да, большую часть диспетчера исключений занимает дизассемблер (как и в моем интерактивном отладчике). Кстати, замечу, что постепенно дизассемблирование превращается в кошмар – по моим подсчетам в современном процессоре x86 более 670 разных команд, да еще со всевозможными «префиксами».
Особенности обработки исключения
Вернемся к рассматриваемому случаю интерактивного отладчика. Поскольку сначала я не знал о структурной обработке исключений и принудительном анализе стека в Win64, я, конечно, не регистрировал никаких «внутрипоточных» обработчиков, интуитивно считая, что уже установленный UnhandledExceptionFilter будет вызван в любом случае (как в Win32). В результате этого непонимания отладчик начинал работу пошаговой отладки со случайным содержимым стека. Впрочем, один адрес в самой глубине стека был не случайным. Это был адрес возврата, установленный в результате работы подпрограммы RtlUserThreadStart, которая собственно и запускала мою программу. Именно эта подпрограмма оказалась зарегистрирована в Windows и именно ее обработчик все-таки вызывал UnhandledExceptionFilter, и в конце-концов мой встроенных отладчик.
Таким образом, вся работа отладчика, переделанного для Win64, висела на тоненькой ниточке процесса исследования стека. Если диспетчер при анализе стека доходил до адреса, входящего в RtlUserThreadStart, очередное обращение RtlLookupFunctionEntry давало успех и на данном шаге мой отладчик вызывался.
Но стоило, например, в трассировке выполнить команду dec rsp, как диспетчер немедленно прекращал поиск в стеке адресов возврата (считая, что они должны лежать исключительно по адресам, кратным 8) и я вместо очередного шага получал на экране стандартное сообщение от системы об исключении именно пошагового режима (что сделало бы честь самому Капитану Очевидность).
Кроме не кратности указателя стека 8, много крови мне испортил вот этот фрагмент диспетчера исключений (напомню, сам диспетчер находится в NTDLL.DLL):
76dddda9 4c8b842478010000 mov r8,qword ptr [rsp+178h]
76ddddb1 498b00 mov rax,qword ptr [r8]
76ddddb4 4c3be0 cmp r12,rax
76ddddb7 0f8445120600 je 76e3f002
76ddddbd 4983c008 add r8,8
76ddddc1 48898424d8010000 mov qword ptr [rsp+1D8h],rax
76ddddc9 4c89842478010000 mov qword ptr [rsp+178h],r8
76ddddd1 e92bb30000 jmp 76de9101
Здесь достается очередное значение из стека (моделируемое значение указателя стека находится в R8) и сравнивается с предыдущим анализируемым значением (оно находится в R12). Причем в это место диспетчера управление попадает, если для очередного значения стека вызов RtlLookupFunctionEntry ничего не нашел. Т.е. если для очередного значения в стеке ничего не найдено, а следующее значение – точно такое же, диспетчер прекращает исследование стека вообще. Смысл этой проверки для меня непонятен. В моем случае два одинаковых значения – это были просто два нуля, т.е. уж точно не адреса возврата, тем не менее, два подряд любых одинаковых значения в стеке являются для диспетчера сигналом, что он заблудился, и анализ стека надо прекратить. Тем самым, объяснилось странное поведение программы при записи двух одинаковых значений в стек подряд.
Но все же самым неприятным для меня оказался другой фрагмент диспетчера. Вот этот:
76de90d2 40f6c507 test bpl,7
76de90d6 0f85225f0500 jne 76e3effe
76de90dc 483b6c2478 cmp rbp,qword ptr [rsp+78h]
76de90e1 0f82175f0500 jb 76e3effe
76de90e7 483b6c2468 cmp rbp,qword ptr [rsp+68h]
76de90ec 0f830c5f0500 jae 76e3effe
Здесь в RBP находится текущий указатель стека в момент самого исключения и уже найден нужный обработчик. Перед тем как его вызвать проверяется, что при исключении указатель стека остался в допустимых пределах. Это разумно и правильно. Если при ошибке не только случилось исключение, но и указатель стека испортился, обработчик вызывать нельзя – он может все разрушить.
Но вот дополнительная проверка указателя стека на кратность 8 – это безобразие. Какое собственно Windows дело кратен или не кратен указатель стека 8 в программе, раз обработчик все равно уже найден и стек дальше исследовать не надо? Пусть обработчик сам и разбирается с кратностью указателя стека. Из-за этой маленькой и вредной команды test bpl,7 мне пришлось сильно переделывать все свои библиотеки.
Обидно, что сам процессор и программы нормально работают с любым указателем стека. И только при отладке встроенным отладчиком невозможно пошагово пройти команды, делающие (хотя бы даже временно) стек не кратным 8. В Win32 я привык свободно обращаться с указателем стека. Конечно, есть доводы насчет эффективности выполнения по кратным адресам. Но вот простой контрпример: в стеке лежит текстовая строка, от которой нужно отрезать слева число байт, записанное, скажем, в EAX. В Win32 я мог делать это единственной командой:
add esp,eax
А теперь нужно еще двигать всю строку в стеке, что бы ее новое начало легло по адресу, кратному 8. Причем в общем случае может потребоваться и сдвиг обрезанной строки вправо и сдвиг влево. Разве это эффективно? И все лишь для того, чтобы встроенный отладчик вызывался при пошаговом режиме! Справедливости ради, следует отметить, что подключаемый отладчик не имеет таких проблем (и WinDbg тому доказательство). Но мне нужен не подключаемый, а собственный встроенный отладчик!
Доработки отладчика для Win64
Поскольку все прояснилось, можно было, наконец, правильно дорабатывать встроенный отладчик для Win64.
С помощью RtlAddFunctionTable я установил свой обработчик (по сути, просто вызов отладчика) для всего диапазона адресов 0-FFFFFFFF, тем самым полностью отключив вмешательство всяких обработчиков от RtlUserThreadStart и попутно отключив попытки любого анализа стека (кроме проклятой проверки кратности 8) в диспетчере. Я предпочитаю, чтобы при работе моих программ диспетчер исключений не пытался дизассемблировать команды по непонятно какому содержимому стека. Кстати, эффективность обработки исключения при этом тоже возрастает.
Немного отвлекаясь, хочу заметить, что собственная обработка исключений нужна была не только из-за отладчика. В используемом мною языке PL/1 структурная обработка исключений c успехом применялась лет так на 40 раньше, чем в Win64. Поэтому работающая PL-программа просто обязана перехватывать все исключительные ситуации, генерируемые и аппаратурой и Windows. После этого собственный диспетчер исключений языка PL/1 определяет, что делать в программе при той или иной ситуации. Например, при исключениях с кодами 80000003 и 80000004 нужно вызывать тот самый встроенный отладчик.
Использование отладочных регистров
Однако я рано радовался. Отладчик-то заработал и если стек оставался кратен 8, работал, как положено, но регистры DR0-DR7 он изменять не мог, и они по-прежнему оставались нулевыми. А ведь это у нас один из основных механизмов поиска ошибок. Например, моя программа ведет себя ненормально. Я исследую ее переменные и вижу, что в переменной, скажем, X1 вдруг находится неверное значение, скажем, 5. Откуда оно там взялось? Я заново запускаю программу и задаю директиву встроенному отладчику:
K .X1 W1 =5
Что означает поставить аппаратную контрольную точку по записи байта в переменную X1. Если эта точка срабатывает, уже сам отладчик проверяет, стало ли равно значение 5. Если нет – отладчик возвращается обратно в программу, не снимая этой аппаратной контрольной точки. Когда значение становится равным 5, отладчик останавливается, не возвращаясь в программу. Поэтому внешне для меня программа запускается и останавливается в отладчике, когда X1 стала равна 5, показывая в программе точку записи в заданную переменную заданного значения (на самом деле начало следующей команды). И я сразу вижу, кто посмел испортить переменную X1 значением 5.
Для этого мощного и универсального способа поиска ошибок и нужны отладочные регистры. В Win32 все работало нормально. Что же в Win64 опять не так? И здесь причина в «новой» структурной обработке исключений. Поскольку ядро больше не вызывает UnhandledExceptionFilter, все сводится к работе диспетчера исключений и в случае успеха к вызову RtlRestoreContext. Легко убедится, что эта подпрограмма восстанавливает большинство регистров, но не DR0-DR7. На уровне привилегий пользователя она просто не может этого сделать.
Пришлось внутри отладчика внести еще одну доработку. В Win64 перед возвратом в диспетчер исключений отладчику с помощью обращения к SetThreadContext нужно отдельно установить требуемые значения DR0-DR7 для текущего потока. В общем случае устанавливать контекст для текущего потока, конечно, нельзя. Но это как раз тот частный случай, когда устанавливаются «безвредные» регистры. Для этого флаг контекста задается равным 100010H, т.е. требуется изменить только регистры DR0-DR7, не трогая остальных. Остальные изменит последующее выполнение RtlRestoreContext.
При этой доработке я, подобно многим другим бедолагам, наступил на все грабли и, конечно же, получил пресловутое сообщение об ошибке №998. И как многие, я тоже сначала воспринял его как какое-то нарушение прав доступа. Хотя оно сообщает всего-навсего о том, что адрес контекста при вызове SetThreadContext был не кратен 16, а нужно обязательно кратно, иначе и выдается ошибка 998.
Заключение
Подведем итоги. После всех мытарств отладчик заработал в Win64, наконец, так же как в Win32, кроме случаев, когда указатель стека в момент исключения был не кратен 8, такое исключение не перехватывается. Между прочим, это означает, что не все ошибки можно перехватить в самой программе. Конечно, и в Win32 может быть ситуация, когда вместо вызова обработчика выдается системное сообщение об ошибке. Но в Win64 из-за ограничения на указатель стека таких ситуаций стало больше. Т.е. из-за введения структурной обработки исключений может не получиться никакая обработка исключения в программе вообще.
Для того чтобы правильно организовать обработку исключений в Win64 пришлось исследовать код диспетчера исключений. Хотя в целом, информации и документации по структурной обработке исключений довольно много, например [1].
Но ни в какой документации я не нашел, что в Win64 обработчик, установленный с помощью UnhandledExceptionFilter теперь вызывается через обработчик, указанный для RtlUserThreadStart, и при этом он не может автоматически изменить DR0-DR7.
Нигде не написано, что два одинаковых 8-байтных значения в стеке подряд приводят к прекращению анализа стека в диспетчере исключений.
Предполагаю, что в исходном тексте Windows вызов RtlUserThreadStart просто обрамили какими-нибудь __try и __except, применив структурную обработку к самой Windows. Поэтому и возникли такие отличия Win64 от Win32 в обработке исключений. Однако разработчики не приняли во внимание, что не все программы пишутся на C++, и может потребоваться совсем другая обработка исключений, например, для целей отладки. В этом смысле более простая обработка в Win32, в частности не требующая обязательного выравнивания стека, оказалась и более универсальной, т.е. позволяющей по-разному организовывать работу программ.
Литература
1. Обработка исключений Win32 для программистов на ассемблере. Оригинал на англ.