Исключения в Windows x64. Как это работает. Часть 1
Статья Анатолия Михайлова, руководителя группы разработки Secret Disk Linux компании "Аладдин Р.Д."
Ранее мы обсуждали прикладное применение механизма обработки исключений вне среды Windows. Теперь мы более подробно рассмотрим, как это работает в Windows x64. Материал будет описан последовательно, начиная с самых основ. Поэтому многое может оказаться вам знакомым, и в этом случае такие моменты можно просто пропустить.
Реализация механизма находится в папке exceptions хранилища git по этому адресу.
1. Функция, её пролог, тело, эпилог и кадр функции
Любая функция имеет пролог, тело и эпилог. Подробнее остановимся на прологе и эпилоге, т.к. с самим телом никаких вопросов не возникает, поскольку именно ради него все и затевается.
В прологе функции располагается код, выполняющий предварительные действия, которые необходимы перед работой тела функции. В них входит сохранение регистров общего назначения, значения которых могли быть установлены вызывающей функцией, выделение памяти в стеке для локальных переменных функции, установление указателя кадра (frame pointer) и сохранение XMM регистров процессора. В прологе установлены строгие правила по отношению к действиям, которые он может выполнять, и их последовательности. Сначала, если требуется, пролог сохраняет первые 4 параметра в области регистровых параметров (более подробно об этой области и всем, что с ней связано, будет написано в разделе 3), затем заталкиваются регистры общего назначения, выделяется память в стеке, опционально устанавливается указатель кадра функции и сохраняются XMM регистры процессора. Любое из перечисленных действий может отсутствовать, но описанный порядок выполнения строго соблюдается. Такие строгие правила позволяют анализировать действия эпилога по его программному коду, о чем будет рассказано более подробно ниже. Рисунок 1 иллюстрирует пролог функции, которая сохраняет первые 4 переданных параметра, сохраняет три регистра общего назначения, выделяет память и сохраняет XMM регистр.
Рисунок 1
Также следует отметить, что сохранение регистров общего назначения может выполняться и после выделения памяти в стеке (а также и после установления кадра функции, если таковое имеет место), но в таком случае выполняется оно не при помощи заталкивания, а обычной записью в память, как это показано в предыдущем примере. Также следует отметить, что в очень редких случаях допускается выделение 8 байт в стеке перед заталкиванием регистров общего назначения в прологе функции. Такой пролог наиболее характерен для обработчиков исключений, для которых процессор не заталкивает в стек код ошибки, и такое 8-ми байтное выделение позволяет его симулировать.
Сохранённые регистры общего назначения, выделенная память в стеке и сохранённые регистры XMM все вместе формируют так называемый кадр (frame) функции, который есть у каждой вызванной функции. Ниже, на рисунке 2, представлен стек, состоящий из трёх кадров. Первый кадр — это кадр функции, в контексте которой произошло исключение. Для краткости на рисунке отражена только та область кадра, которая заталкивается процессором в момент исключения. Второй кадр — это кадр обработчика исключения, который состоит из пустого кода ошибки (пусть в данном примере исключение было вызвано делением на ноль, которое не заталкивает в стек код ошибки, и наш обработчик, как и обработчик Windows, для единообразия формирует пустой код), сохранённых регистров RAX, RCX, RDX, R8, R9, R10, R11, сохранённых регистров XMM0, XMM1, XMM2, XMM3, XMM4, XMM5 и адреса возврата. Эти сохраняемые регистры общего назначения и XMM регистры перечислены неспроста, об этом мы ещё поговорим в разделе 3. Третий кадр — это кадр функции, которую вызвал обработчик исключения. Её кадр состоит из сохранённых регистров RBP, RBX, XMM и выделенного пространства для локальных переменных функции. Стрелкой указано направление роста стека.
Рисунок 2
Функция может иметь указатель кадра. В таком случае доступ к кадру выполняется через этот указатель. В первую очередь это нужно в тех случаях, когда в процессе выполнения функции выделяемое пространство в стеке может динамически изменяться (т.е. выделение памяти в стеке дополнительно выполняется в теле функции, а не в прологе). А поскольку это влечёт за собой изменение указателя стека, то он не будет указывать на кадр функции. В случае если функция не имеет указателя кадра, она не может динамически выделять память в стеке, следовательно, указатель стека статичен и является также указателем кадра функции. Рисунок 3 иллюстрирует такой пролог. После сохранения всех регистров и выделения памяти, в теле функции вызывается функция, которая в RAX возвращает размер структуры, этот размер выделяется в стеке и далее указатель стека используется как указатель буфера, в который считываются данные.
Рисунок 3
Если пролог выделяет область в стеке размером, превышающую одну страницу (т.е. больше 4Кб), тогда вероятно, что такое выделение будет охватывать больше одной виртуальной страницы памяти и, следовательно, такое выделение должно быть проверено перед фактическим его выполнением. С этой целью пролог функции вызывает специальную функцию, выполняющую данную проверку. Имя функции _chkstk. Также эта функция не изменяет значений регистров, в которых передаются параметры (об этих регистрах подробно будет написано в разделе 3). На рисунке 4 изображён пример пролога функции, который выделяет 4Кб памяти в стеке.
Рисунок 4
Эпилог выполняет противоположные по отношению к прологу действия: восстанавливает XMM регистры и регистры общего назначения, которые были сохранены после выделения памяти в стеке, освобождает память в стеке (а если использовался указатель кадра, то и динамически выделенную в том числе), выталкивает регистры общего назначения, выполняет возврат в вызывающую функцию или передаёт управление на начало текущей функции, либо другой функции. На рисунке 5 изображён эпилог, соответствующий прологу из примера на рисунке 1. Из рисунка видно, что выполняются действия, противоположные действиям пролога. Также обратите внимание на тот факт, что переданные параметры не восстанавливаются, объяснение этому вы найдёте в разделе 3.
Рисунок 5
У эпилога, как и у пролога, есть строгие правила в отношении используемых инструкций процессора. Если функция не использовала указатель кадра, то память в стеке, как отражено в предыдущем примере, освобождается посредствам add rsp, константа инструкции, а если использовала, то посредствам lea rsp, [указатель кадра + константа]. Затем следуют инструкции выталкивания регистров общего назначения из стека, инструкция возврата или инструкция безусловного перехода на другую функцию или на начало текущей функции. На рисунке 6 изображён эпилог, соответствующий прологу из примера на рисунке 3. Обратите внимание на то, что вместо инструкции ret используется jmp для вызова другой функции.
Рисунок 6
Что же касается инструкций перехода, то только ограниченный набор из них допускается. Несмотря на то, что эпилог сначала восстанавливает XMM регистры и регистры общего назначения, началом эпилога, при раскрутке, считается освобождение памяти из стека через add rsp, константа или lea rsp, [указатель кадра + константа] инструкции. Объяснение этому будет дано в третьей части данной статьи, а первые сведения о раскрутке будут приведены в следующей части данной статьи.
Все вышеописанное относительно эпилога справедливо для функций, версия структуры UNWIND_INFO которых равна 1 (подробно об UNWIND_INFO будет написано в следующей части данной статьи). Выполнял ли процессор эпилог функции в момент прерывания/исключения, определяется по коду самой функции. Это возможно, поскольку, как уже было неоднократно отмечено, на действия пролога и эпилога наложен строгий порядок действий, а на эпилог ещё и ограничения, касающиеся используемых им инструкций процессора. Структуры UNWIND_INFO версии 2 могут также описывать расположение эпилога функции. Об этом мы более детально поговорим в следующей части данной статьи, здесь стоит только упомянуть, что эпилоги функций, которые описываются структурами UNWIND_INFO версии 2, могут после выталкивания регистров общего назначения освобождать 8 байт из стека, о которых мы уже говорили во время обсуждения пролога. Такое же освобождение 8 байт из стека после выталкивания регистров общего назначения не ожидается от эпилогов функций, которые описываются структурами UNWIND_INFO версии 1. Следовательно, в существующих Windows-реализациях проверка наличия этого освобождения в программном коде эпилога функций, которые описываются структурами UNWIND_INFO версии 1, не выполняется. В прилагаемой к статье реализации данного механизма такая проверка также не выполняется.
Как минимум, функция имеет один эпилог.
2. Типы функций
Есть два типа функций: кадровые функции (frame function) и простые функции (leaf function). Кадровые функции — это те, которые имеют свой кадр в стеке и они не имеют никаких ограничений по части их действий. Они могут вызывать другие функции, выделять память в стеке, сохранять и использовать любые регистры процессора. Если функция не вызывает других функций, то её стек не имеет ограничений в выравнивании, если вызывает, то стек должен быть выровнен по 16-байтной границе. Также у кадровой функции есть соответствующие записи по раскрутке её кадра (об этом мы поговорим подробнее в следующей части данной статьи).
Простые функции — это те функции, которые не имеют своего кадра в стеке, поэтому они не могут выполнять все то, что могут кадровые функции, в том числе использовать любые регистры процессора. Поскольку простая функция не может вызывать других функций, она не выравнивает свой стек. Также у простой функции нет записей по раскрутке, т.к. она не имеет кадра.
3. Соглашение о вызовах
Первые 4 параметра передаются функции через регистры. Если их больше, то остальные передаются через стек. Также, вызывающей функцией для первых 4 параметров выделяется область в стеке, называемая областью регистровых параметров (register parameters area или home location). Вызванная функция может использовать эту область для сохранения параметров, как это делал пролог из рисунка 1, либо в любых других целях. Даже если функция принимает меньше 4 параметров или не принимает их вообще, область регистровых параметров всегда выделяется в стеке. Параметры, передаваемые через стек, располагаются в области, называемой областью стековых параметров (stack parameters area). Эта область, в отличие от области регистровых параметров, может отсутствовать, а её размер равен размеру всех параметров, которые она включает. Один параметр в области регистровых и стековых параметров всегда занимает 8 байт. Если же размер параметра больше 8, то вместо него передаётся указатель на него. Если же размер параметра меньше 8 байт, то старшие неиспользуемые байты в соответствующих областях игнорируются. Ниже на рисунке 7 изображены вызовы двух функций, одна из которых принимает 6 параметров, а другая 1, слева и справа от стрелки направления роста стека соответственно.
Рисунок 7
На дне стека всегда располагается область регистровых параметров, выше которой следует область стековых параметров. В случае вызова функции адрес возврата будет располагаться сразу ниже области регистровых параметров. В разделе 2 было упомянуто, что если функция вызывает другие функции, то её стек должен быть выровнен по 16-байтной границе. На этой 16-байтной границе всегда начинается область регистровых параметров.
Первые 4 параметра передаются через регистры RCX, RDX, R8 и R9, если это целое число или пользовательский тип, размер которого 1, 2, 4 или 8 байт. В противном случае передаётся указатель на соответствующий параметр. Для строк и массивов всегда передаётся их указатель. Если параметр является числом с плавающей точкой, то для его передачи используются XMM0, XMM1, XMM2, XMM3 регистры при условии, что размер параметра не превышает 8 байт, иначе передаётся указатель на него. Если передаётся указатель на параметр вместо самого параметра, то сам параметр размещается во временной памяти на 16-байтной границе. На рисунке 8 представлены примеры передачи параметров в функции.
Рисунок 8
Когда используется XMM для передачи параметра, используется тот XMM регистр, который по номеру соответствует одному из регистров RCX, RDX, R8 или R9. Например, на рисунке 8, параметр 3 функции func1 несёт в себе число с плавающей точкой, в этом случае будет использоваться XMM2 регистр. Если бы этот параметр был бы целым числом, как в функции func2, тогда использовался бы регистр R8.
Функция возвращает результат через RAX или XMM0. Числа с плавающей точкой и вектора размером до 16 байт (например, _m128) возвращаются в XMM0. Целые числа и пользовательские типы, размер которых 1, 2, 4 или 8 байт, возвращаются в RAX. Если возвращаемое значение меньше 8 байт, то старшие неиспользуемые байты не определены. Во всех остальных случаях первый параметр функции является указателем на область, куда возвращается значение, а в RAX возвращается этот указатель. Также следует отметить, что в таком случае передаваемые параметры сдвигаются на один параметр вправо, т.е. первый параметр будет передаваться не в RCX, а в RDX регистре, а 4-й параметр будет передаваться не в R9, а в стеке. На рисунке 9 представлены примеры возврата результата.
Рисунок 9
C++ компилятор накладывает дополнительные ограничения на пользовательские типы. Если результат возвращается не статичной функцией (которая является членом класса, структуры и т.д.), или сам тип имеет конструктор, деструктор, оператор присваивания, приватные или защищённые нестатичные члены, нестатичные члены типа ссылка, унаследованного родителя, виртуальные функции или члены, содержащие любое из перечисленного, то результат возвращается не в RAX, а в область памяти, указатель на которую передан в первом параметре.
Регистры RBX, RBP, RDI, RSI, RSP, R12, R13, R14 и R15 считаются постоянными (nonvolatile или callee-saved), т.е. вызываемая функция должна сохранять их перед использованием и восстанавливать перед возвратом, а вызывающая функция может полагаться на значения этих регистров после вызова функций.
Регистры RAX, RCX, RDX, R8, R9, R10 и R11 считаются непостоянными (volatile или caller-saved), т.е. вызываемая функция не должна сохранять их перед использованием, а вызывающая функция не должна полагаться на значения этих регистров после вызова функций. По этой причине эпилог, изображённый на рисунке 5, соответствующий прологу из примера на рисунке 1, не восстанавливает регистры RCX, RDX, R8, R9, сохранённые прологом. И по этой же причине обработчик исключения, упомянутый в разделе 1, сохраняет только их, т.к. эти регистры не восстанавливаются вызываемыми функциями перед возвратом.
Подобно регистрам общего назначения, регистры XMM0 — XMM5 считаются непостоянными, а регистры XMM6 — XMM15 постоянными.
Заключение
В этой части статьи мы разобрали базовые понятия, определения и процессы, которые, на первый взгляд, хоть и не имеют явного отношения к обсуждаемой теме, но, тем не менее, знание и понимание которых необходимо для рассмотрения последующего материала, т.к. это является основной, на которой строится обсуждаемый механизм. Продолжение статьи будет посвящено описанию тех областей PE образа, которые задействуются в процессе обработки исключений.