Каким должен быть язык программирования? Анализ и критика Описание языка Компилятор
Отечественные разработки 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.11.15, последняя правка: 2019.01.28    20:52

ОценитеОценки посетителей
   ████████████████████████████ 2 (66.6%)
   ▌ 0
   ▌ 0
   ██████████████ 1 (33.3%)

Добавить свой отзыв

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

Авторизация

Регистрация

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

Карта сайта


Содержание

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

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

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

Компилятор

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

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

●  О превращении кибернетики в шаманство

●  Про лебедей, раков и щук

●  О русском ассемблере

●  Арифметика синтаксиса-3

●  Концепция владения в Rust на примерах

●●  Концепция владения в Rust на примерах, часть 2

●●  Концепция владения в Rust на примерах, часть 3

●  Суть побочных эффектов в чисто функциональных языках

●  О неулучшаемой архитектуре процессоров

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

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

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

●  О создании языков

●●  Джоэл Спольски о функциональном программировании

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

●  Программирование исчезнет. Будет дрессировка нейронных сетей

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

●  Десятка худших фич C#

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

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

●  ЕС ЭВМ — это измена, трусость и обман?

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

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

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

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

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

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

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

●●  В защиту PL/1

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

●●  Опыт самостоятельного развития средства программирования в РКК «Энергия»

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

●●  Заметки о выходе из функции без значения и зеркальности get и put

●●  Модификация исполняемого кода как способ реализации массивов с изменяемыми границами

●●  Ошибка при отсутствии выполняемых действий

●●  О PL/1 и почему в нём не зарезервированы ключевые слова

●●  Не поминайте всуе PL/1

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

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

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

●●  Поддержка профилирования кода программы на низком уровне

●●  К вопросу о парадигмах

●  Следующие 7000 языков программирования

●●  Что нового с 1966 года?

●●  Наблюдаемая эволюция языка программирования

●●  Ряд важных языков в 2017 году

●●  Слоны в комнате

●●  Следующие 7000 языков программирования: заключение

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

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




Последние отзывы

2024/03/19 02:19 ••• Ivan
Энтузиасты-разработчики компиляторов и их проекты

2024/03/18 23:25 ••• Автор сайта
Надёжные программы из ненадёжных компонентов

2024/03/18 22:44 ••• Автор сайта
О многократном резервировании функций

2024/03/17 17:18 ••• Городняя Лидия Васильевна
Раскрутка компилятора

2024/03/10 18:33 ••• Бурановский дедушка
Русской операционной системой должна стать ReactOS

2024/03/07 14:16 ••• Неслучайный читатель
«Двухмерный» синтаксис Python

2024/03/03 16:49 ••• Автор сайта
О неправомерном доступе к памяти через указатели

2024/02/28 18:59 ••• Вежливый Лис
Про лебедей, раков и щук

2024/02/24 18:10 ••• Бурановский дедушка
ЕС ЭВМ — это измена, трусость и обман?

2024/02/22 15:57 ••• Автор сайта
Русский язык и программирование

2024/02/19 17:58 ••• Сорок Сороков
О русском языке в программировании

2024/02/16 16:33 ••• Клихальт
Избранные компьютерные анекдоты

2024/02/10 22:40 ••• Автор сайта
Все языки эквивалентны. Но некоторые из них эквивалентнее других