Каким должен быть язык программирования? Анализ и критика Описание языка Компилятор
Отечественные разработки Cтатьи на компьютерные темы Компьютерный юмор Новости и прочее

Особенности реализации структурной обработки исключений в Win64

Введение

Введение

            В процессе перевода системы программирования [1] на платформу x86-64 потребовалось перевести и встроенный интерактивный отладчик. В отличие от подключаемых отладчиков данный отладчик находится, так сказать, непосредственно «на борту» каждой исполняемой программы. При этом он имеет сравнительно небольшие размеры (около 44 Кбайт, большую часть которых занимает дисассемблер). Я так привык к этому отладчику, что уже совершенно не могу без него обходиться, и поэтому перевод в 64-разрядную среду стал настоятельно необходимым.

            Однако формальный перевод с Win32 в Win64 дал такие странные результаты, что пришлось потратить много сил и времени, чтобы разобраться, почему то, что ранее работало в Windows-XP, перестало нормально работать в Windows 7. Виной всему оказалась структурная обработка исключений, практически не задействованная ранее в среде Win32.

            В процессе разбирательства я прочел множество обсуждений на эту тему на компьютерных форумах. Задаваемые вопросы (и особенно ответы) показали, что многие программисты смутно представляют конкретную реализацию структурной обработки исключений в Windows 7. Поэтому кажется полезным пояснить некоторые особенности поведения системы на примере конкретной задачи, в результате чего и появилась данная статья.

Проблема при переводе отладчика на Win64

            Внешне проблема после перевода в среду Win64 выглядела так:
  • Интерактивный встроенный отладчик, основанный на установке UnhandledExceptionFilter, заработал в Windows 7, но, например, в пошаговом режиме при выполнении очередной, и, казалось бы, безобидной команды «отцеплялся», после чего программа заканчивалась с надоедливым предложением отправить отчет в 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 имеет адрес команды, где случилось исключение. Если заранее сообщить ей какая подпрограмма занимает диапазон адресов, в который попало место исключения, и как в этом случае обрабатывать исключения, то можно автоматически вызывать именно нужный в данный момент обработчик, а не какой-то универсальный и всеобъемлющий, но безликий и неинформативный.

            Вот только реализация этой идеи в Windows 7 вызывает массу нареканий.

            Причем первый шаг обработки в Win64 вполне нормален. Диспетчер по адресу прерванной команды пытается «узнать» подпрограмму с помощью вызова RtlLookupFunctionEntry. Если информация о подпрограмме имеется во внутренней таблице (а зарегистрировать ее можно, указав прямо внутри заголовка EXE-файла или обратившись к RtlAddFunctionTable), то дело в шляпе, становится известен обработчик исключений для данной подпрограммы и диспетчер вызывает его. Но вот, если по адресу исключения подпрограмма не опознана…

            Здесь начинаются неочевидные вещи. Не найдя в какой из известных ему подпрограмм произошло исключение, диспетчер не сдается и начинает анализировать текущий стек программы дальше. В Microsoft назвали это «раскруткой стека».

            Диспетчер берет следующие 8 байт «вглубь» стека, надеясь, что это очередной адрес возврата, и пытается по ним все же определить, какая из известных, т.е. зарегистрированных подпрограмм выполнялась. Но это очень шаткая и сомнительная технология.

            Во-первых, исключения бывают в результате разных ошибок. При некоторых ошибках далеко не факт, что стек вообще содержит что-то разумное.

            Во-вторых, при этом начисто забыли о пошаговом режиме отладки. Например, при вызове какой-нибудь процедуры API Windows подготовка к вызову начинается с команды типа sub esp,20h. При выполнении этой команды в режиме трассировки, т.е. при очередном пошаговом исключении в вершине стека временно оказываются четыре совершенно случайных значения, не имеющих отношения к программе.

            В-третьих, по «теории пакости» когда-нибудь в стеке попадется значение, случайно похожее на адрес возврата, и диспетчер может вызвать обработчик, который как говорится «ни сном, ни духом» и не слышал о заданном исключении.

            Несмотря на это, анализ стека, проводимый в диспетчере исключений, включает даже чтение кода команды (в предположении, что очередные 8 байт – это адрес возврата), «дисассемблирование» этого кода и моделирование работы этой команды с точки зрения изменения стека. Да-да, большую часть диспетчера исключений занимает дисассемблер (как и в моем интерактивном отладчике). Кстати замечу, что постепенно дисассемблирование превращается в кошмар – по моим подсчетам в современном процессоре x86 более 670 разных команд, да еще со всевозможными «префиксами».

Особенности обработки исключения

            Вернемся к рассматриваемому случаю интерактивного отладчика. Поскольку сначала я не знал о структурной обработке исключений и принудительном анализе стека в Win64, я, конечно, не регистрировал никаких «внутрипоточных» обработчиков, интуитивно считая, что уже установленный UnhandledExceptionFilter будет вызван в любом случае (как в Win32). В результате этого непонимания отладчик начинал работу пошаговой отладки со случайным содержимым стека. Впрочем, один адрес в самой глубине стека был не случайным. Это был адрес возврата, установленный в результате работы подпрограммы RtlUserThreadStart, которая собственно и запускала мою программу. Именно эта подпрограмма оказалась зарегистрирована в Windows и именно ее обработчик все-таки вызывал UnhandledExceptionFilter, и в конце-концов мой встроенных отладчик.

            Таким образом, вся работа отладчика, переделанного для Win64, висела на тоненькой ниточке процесса исследования стека. Если диспетчер доходил до адреса, входящего в RtlUserThreadStart, очередное обращение RtlLookupFunctionEntry давало успех и на данном шаге мой отладчик вызывался.

            Но стоило, например, в трассировке выполнить команду dec esp, как диспетчер немедленно прекращал поиск в стеке адресов возврата (считая, что они должны лежать только по адресам, кратным 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.

            Для этого мощного и универсального способа поиска ошибок и нужны отладочные регистры. В Win32 все работало нормально. Что же в Win64 опять не так? И здесь причина в «новой» структурной обработке исключений. Поскольку ядро больше не вызывает UnhandledExceptionFilter, все сводится к работе диспетчера исключений и в случае успеха к вызову RtlRestoreContext. Легко убедится, что эта подпрограмма восстанавливает большинство регистров, но не DR0-DR7. На уровне привилегий пользователя она просто не может этого сделать.

            Пришлось внутри отладчика внести еще одну доработку. В Win64 перед возвратом в диспетчер отладчику с помощью обращения к SetThreadContext нужно отдельно установить требуемые значения DR0-DR7 для текущего потока. В общем случае устанавливать контекст для текущего потока, конечно, нельзя. Но это как раз тот частный случай, когда устанавливаются «безвредные» регистры. Для этого флаг контекста задается равным 100010H, т.е. требуется изменить только регистры DR0-DR7, не трогая остальных. Остальные изменит последующее выполнение RtlRestoreContext. При этой доработке я, подобно многим другим бедолагам, наступил на все грабли и, конечно же, получил пресловутое сообщение об ошибке №998. И как многие, я тоже сначала воспринял его как какое-то нарушение прав доступа. Хотя оно сообщает всего-навсего о том, что адрес контекста при вызове SetThreadContext был не кратен 16.

Заключение

            Подведем итоги. После всех мытарств отладчик заработал в Win64, наконец, так же как в Win32, кроме случаев, когда указатель стека в момент исключения был не кратен 8, такое исключение не перехватывается. Между прочим, это означает, что не все ошибки можно перехватить в самой программе. Конечно, и в Win32 может быть ситуация, когда вместо вызова обработчика выдается системное сообщение об ошибке. Но в Win64 из-за ограничения на указатель стека таких ситуаций стало больше. Т.е. из-за введения структурной обработки исключений может не получиться никакая обработка исключения вообще.

            Для того чтобы правильно организовать обработку исключений в Win64 пришлось исследовать код диспетчера исключений. В целом, информации и документации по структурной обработке исключений довольно много, например [2]. Но ни в какой документации не написано, что в Win64 UnhandledExceptionFilter теперь вызывается через обработчик, указанный для RtlUserThreadStart, и при этом он не может автоматически изменить DR0-DR7.

            Нигде не написано, что два одинаковых 8-байтных значения в стеке подряд приводят к прекращению анализа стека в диспетчере исключений.

            Предполагаю, что в исходном тексте Windows 7 вызов RtlUserThreadStart просто обрамили какими-нибудь __try и __except, применив структурную обработку к самой Windows. Поэтому и возникли такие отличия Win64 от Win32 в обработке исключений. Однако разработчики не приняли во внимание, что не все программы пишутся на C++, и может потребоваться совсем другая обработка исключений, например, для целей отладки. В этом смысле более простая обработка в Win32, в частности не требующая обязательного выравнивания стека, оказалась и более универсальной, т.е. позволяющей по-разному организовывать работу программ.

Литература

1. Караваев Д.Ю. К вопросу о совершенствовании языка программирования. RSDN Magazine #4, 2011
2. Обработка исключений Win32 для программистов на ассемблере. Оригинал на англ., http://www.jorgon.freeserve.co.uk/ExceptFrame.htm

Автор: Д.Ю.Караваев. 14.07.2015

Последняя правка: 2018-12-16    12:46

Оцените

Написать отзыв

Написать автору можно на электронную почту mail(аt)compiler.su

Авторизация

Регистрация

Выслать пароль

Карта сайта


Каким должен быть язык программирования?

Анализ и критика

Описание языка

Компилятор

Отечественные разработки

Cтатьи на компьютерные темы

Двадцать тысяч строк кода, которые потрясут мир?

Почему владение/заимствование в Rust такое сложное?

Масштабируемые архитектуры программ

Почему Хаскелл так мало используется в отрасли?

Бесплатный софт в мышеловке

Исповедь правового нигилиста

Русской операционной системой должна стать ReactOS

Почему обречён язык Форт

Программирование без программистов — это медицина без врачей

Электроника без электронщиков

Программисты-профессионалы и программирующие инженеры

Статьи Дмитрия Караваева

●  Идеальный транслятор

●  К вопросу о совершенствовании языка программирования

●  О реализации метода оптимизации при компиляции

●  О реализации метода распределения регистров при компиляции

●  О распределении памяти при выполнении теста Кнута

●  Опыты со стеком или «чемпионат по выполнению теста Кнута»

●  О размещении переменных в стеке

●  Сколько проходов должно быть у транслятора?

●  Чтение лексем

●  Экстракоды при синтезе программ

●  Об исключенных командах или за что «списали» инструкцию INTO?

●  Типы в инженерных задачах

●  Непрерывное компилирование

●  Об одной реализации специализированных операторов ввода-вывода

●  Особенности реализации структурной обработки исключений в Win64

●  О русском языке в программировании

●  Формула расчета точности для умножения

●  Права доступа к переменным

●  Скорость в попугаях

●  Крах операции «Инкогнито»

●  Предопределенный результат

Компьютерный юмор

Новости и прочее

Последние комментарии

2018/12/16 17:17 ••• Геннадий Тышов
✎ Программирование без программистов — это медицина без врачей

2018/12/07 08:57 ••• Автор сайта
✎ Почему обречён язык Форт

2018/12/07 08:36 ••• Автор сайта
✎ Нужны ли беззнаковые целые?

2018/12/03 13:51 ••• kt
✎ Экстракоды при синтезе программ

2018/11/30 17:56 ••• Freeman
✎ Изменение приоритетов операций

2018/11/30 17:20 ••• Автор сайта
✎ Почему языки с синтаксисом Си популярнее языков с синтаксисом Паскаля?

2018/11/26 14:23 ••• Автор сайта
✎ Так ли нужны операции «&&», «||» и «^^»?

2018/11/18 15:21 ••• Freeman
✎ Устарел ли текст как форма представления программы

2018/11/17 03:28 ••• Comdiv
✎ Изменение длины объекта в стеке во время исполнения

2018/11/16 12:53 ••• Автор сайта
✎ Помеченные комментарии

2018/11/11 14:01 ••• Александр Коновалов aka Маздайщик
✎ Нерабочий код

2018/11/11 13:39 ••• Александр Коновалов aka Маздайщик
✎ О русском языке в программировании