Последовательность вызова функций

Метки:  , ,

Многие специалисты, по роду своей деятельности сталкивающиеся с исследованием различных модулей, анализом причин тех или иных сбоев и проводящие иную аналогичную работу, в своей практике часто сталкиваются с таким понятием как стек вызовов. Очевидно, что он фактически представляет собой очередность вызова функций в контексте выполняющегося потока. С первого взгляда может показаться, что стек вызовов достаточно тривиален, тем не менее, в нем далеко не все так очевидно, как хотелось бы, и поэтому я решил набросать (прежде всего для себя самого) небольшую шпаргалку относительно некоторых важных деталей. Помимо последовательности вызова функций, так же неплохо было бы обсудить формат описания функций в стеке, да и некоторые другие нюансы. Тем, кому доводилось работать с различного рода задачами в отладчиках, к примеру, над изучением различного рода дампов памяти, или отладкой приложения, понимали, что одним из центральных аспектов является так называемый стек вызовов потока, который представляет собой список, состоящий из записей вида:

portcls!PcHandlePropertyWithTable+0x1b

Очевидно, что приведен всего-лишь общий вид записи, но нам интересна сегодня, в первую очередь, сама структура данной записи. В приведенном выше формате отладчик выводит данные о вызываемых в ходе выполнения кода функциях. Формат записи в стеке вызовов следующий:

callsite names

Как можно увидеть по рисунку, формат записи таков: имя_модуля!имя_функции+смещение, где:

  • имя_модуля -- имя библиотеки (исполняемого файла), содержащей функцию;
  • имя_функции -- наименование (имя) функции, в составе которой размещается инструкция, вызывающая вышестоящую (следующую по стеку вызовов) функцию;
  • смещение -- количество байт (в шестнадцатеричной системе счисления) от адреса точки входа функции до начала инструкции, следующей за инструкцией, вызвавшей вышестоящую (по стеку вызовов) функцию. Смещение высчитывается от стартового адреса функции (от первого её байта).

В ряде ситуаций (при отсутствии символов) имя функции может отсутствовать, тогда адрес отображается не вполне корректно: имя_модуля+смещение либо имя_модуля!имя_ближайшей_определенной_функции+смещение.
По большому счету, как раз совокупность подобных записей и составляет собой стек вызовов.

Стек вызовов (call stack) — это структура данных, хранящая информацию о подпрограммах (процедурах, функциях) выполняющегося приложения (программы).

По идее, из названия следует, что стек вызовов должен представлять собой только часть (отдельные ячейки) традиционного стека потока, хранящую исключительно адреса возврата (информацию для возврата) управления в вышестоящую (вызывающую, вызвавшую) подпрограмму. Тем не менее, поскольку в современном программировании на языках высокого уровня подпрограммы (функции) оперируют стековыми фреймами, то подразумевается, что стек вызовов хранит всю информацию о подпрограммах (локальные данные, параметры, значения регистров и прч.).
То есть стек вызовов это не весь стек (потока), поскольку в последнем могут храниться "сторонние" данные (локальные переменные функций, значения регистров и прч.), а исключительно информация, относящаяся к вызываемым подпрограммам (функциям). Эти значения формируют своего рода последовательность (цепочку) вложенных вызовов подпрограмм, которая является достаточно важной частью при исследовании процесса выполнения кода потока (при различного рода сбоях), поскольку по цепочке вызовов можно определить как именно код вел себя на протяжении всего времени выполнения (до момента сбоя/контролируемого останова) и даже определить области кода, где предположительно произошла ошибка. Поэтому, для реверсивного инженера чрезвычайно важно как можно подробнее изучить последовательность вызова функций при выполнении кода.

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

Последовательность вызова функций, как уже говорилось, можно наблюдать в стеке вызовов. Давайте посмотрим, как именно стек вызовов выглядит в отладчике Windbg, для этого в программе присутствует класс команд k* (например: knL). Вот типичный пример:

Чем вам не последовательность? Правда читается она достаточно своеобразно: снизу вверх. Объясняется это принципами работы самого стека: последним пришел, первым вышел первым пришел (Last In First Out), или иначе первым пришел, последним вышел. То есть, самый первый вызов процедуры помещает в стек адрес возврата первым (на самую вершину стека), затем следующий вторым и так далее. В итоге первый адрес оказывается как бы "на дне" стека. Стек растет в сторону уменьшения адресов. Таким образом, со временем в стеке получается эдакая своеобразная "башня" из находящихся друг над другом адресов возврата (можно отследить по столбцу Child-SP).

Стек вызовов начинается, традиционно, снизу, и (упрощенно) читается как: "эта функция вызывает функцию, которая размещается выше по списку (находится над). Та, в свою очередь, вызывает вышестоящую функцию, и так далее". Это достаточно важно для понимания того, что на самом деле происходит в стеке вызовов.

В блоке можно наблюдать столбец с именем Call site. Это обозначение можно перевести как Область вызовов, и оно отражает символическое, осмысленное имя, соответствующее адресу возврата (указанного в колонке RetAddr), сформированное с использованием (если доступны) символов для представления более осмысленного вывода. Каждая запись области вызовов содержит в себе наименование модуля, функции и смещение, для упрощенной идентификации как самой вызывающей функции, так и входящей в ее состав инструкции. Грубо говоря она представляет собой точный указатель (адрес) на инструкцию внутри функции. Для лучшего понимания уместно провести провести аналогию с почтовым адресом: имя модуля это город, имя функции - улица, и смещение - это адрес дома. Эта абстракция заметно упрощает понимание принципов адресации той или иной инструкции в адресном пространстве исследуемого процесса.
Теперь давайте вернемся к базовым принципам. В операционной системе присутствует такое понятие как поток выполнения, в котором выполняются разнообразные операции, или, говоря простыми словами, код.

Поток выполнения (нить, thread) - ход обработки информации (выполнения набора команд), разделяющий инструкции процесса (код) и его контекст (регистры, переменные, указатели). Данный механизм обеспечивает параллельное выполнение (на процессоре) различных задач.

В каждом потоке имеется начальная (главная, стартовая) функция, код которой размещается с точки входа, и начинает выполняться при старте потока (приложения). Вы думаете, что этой главной функцией все в программе и ограничивается, то есть приложение состоит из единственной функции? Нет же, не существует такой одной единой, "волшебной" функции, которая выполнила бы всю необходимую любому приложению работу (исключение составляют лишь крайне простые приложения). Очевидно, что для выполнения даже самых простейших задач этой функции необходима помощь множества других внутренних или внешних (системных) функций. Из этого следует, что:

систему можно представить в виде огромного количества функций, которые выполняют задачи разной степени сложности.

Для того, что бы не порождать хаос, наборы упомянутых функций классифицированы в разнообразные модули (файлы), называемые библиотеками и систематизированы по роду деятельности. И вот эти то самые функции постоянно вызываются выполняемым в данный момент на процессоре кодом.
Например, ваша программа хочет вывести на рабочий стол окно. Уже представили насколько сложна эта задача на уровне системы и на сколько подзадач она "подразделяется"? Вы думали, что вызовется одна единственная функция, которая спокойно нарисует окно и вернет управление? Не тут то было!! Подобная задача разбивается на множество составляющих (подзадач). Соответственно, наша начальная функция вызывает другую функцию что бы сделать какую-то часть общей работы, например указать библиотеке DirectX отрисовать окно. Функция DirectX получает управление и в ходе своей работы обращается к какой-либо другой функции, потом та, в свою очередь, обращается к следующей, что бы сделать уже требуемую именно ей на произвольном этапе выполнения, работу и так далее и так далее. Получается такая своеобразная "матрешка" из вложенных (или вызывающих) друг в друга функций.

Суть потока выполнения и заключается в том, как именно задача фрагментируется на составные и проходит через весь процесс событий.

Однако, давайте опять посмотрим на стек вызовов, который мы привели выше. Нужно помнить, что:

Каждая функция, указанная в стеке вызовов, вызывает функцию, которая размещена над ней в стеке.

Но тут не все так просто, как кажется, надо понимать как именно она это делает. Дьявол, как всегда, кроется в деталях. Реализует она это не со смещения, которое указано в столбце Call site (вызовы), и не с адреса, который указан в столбце RetAddr (адрес возврата). На самом деле, нижестоящая функция просто вызывает "вышестоящую", а это означает, что управление передается в самое начало, то есть на самый первый байт вызываемой функции. Это регламентирует базовый принцип вызова функций (или подпрограмм), который испокон веков применяется в программировании. Теперь понятно, почему смещения, указанные сразу за именем модуля/функции в списке вызовов (столбец Call site), могут ввести в заблуждение?
Поэтому:

  • смещение от начала модуля/функции в списке вызовов (столбец Call site) показывает исключительно смещение (в байтах, от начала) инструкции, с которой продолжится выполнение, если вышестоящая функция вернет это самое управление обратно.
  • адрес возврата (столбец RetAddr) показывает адрес, куда вернется управление в нижестоящую функцию, когда текущая функция отработает и вернет управление.

Повторение - мать учения, закрепим:

Наименование модуля, функции и инструкции в столбце RetAddr (например: nt!KiPageFault+0x16e), указывает на команду, которая начнет выполняться в случае возврата управления из вышестоящей функции.

Эдакие своеобразные перекрестные ссылки на соседа в цепочке вызовов. Да, в этом и заключается некоторая неинтуитивность списка, и не все так просто, как хотелось бы, но со временем вы осознаете, что все выстроено выполнено логично. И вот еще, что значит выражение "функция вернула управление"? Это означает ровно то, что функция выполнила ожидаемую от неё работу, и затем управление вернулось (чаще при помощью подкласса команд ret) в функцию ниже по списку в стеке вызовов, которая и продолжила выполнение, и так далее.

Пример

Давайте рассмотрим конкретный пример. Я возьму произвольный файл дампа памяти, созданный при возникновении критической системной ошибки (BSOD). Открою его в отладчике Windbg, введу команду knL и получу такой вот вывод:

Рассмотрение потока выполнения мы начнем, традиционно, снижу вверх. Системный поток стартует с функции nt!KiStartSystemThread, видите её в самом низу стека вызовов? Эта функция ядра и предназначена для создания потока и инициирования его выполнения. Затем, в ходе выполнения стартовой функции nt!KiStartSystemThread, ей вдруг потребовалось вызвать функцию, находящуюся в стеке вызовов выше по списку. Это тоже ядерная функция, имеющая имя nt!PspSystemThreadStartup. Честно говоря, не сильно я пока разбираюсь в функциональных особенностях инициализации системных потоков, но могу предположить, что она тоже выполняет какие-то подготовительные действия для создания и выполнения потока. Затем уже функция nt!PspSystemThreadStartup и вызывает функцию nt!ExpWorkerThread, ну и так далее по списку.
Теперь отступим 3 стековых фрейма от начала (снизу) списка и остановимся на рассмотрении функции nt!PopUserPresentSetWorker. Для начала выведем на экран её содержимое в дизассемблированном виде. С этой целью я открою окно дизассемблирования и просто вставлю название функции, ну или введу команду:

uf nt!PopUserPresentSetWorker

В блоке кода выше маркером я пометил инструкцию, куда ссылается указатель nt!PopUserPresentSetWorker+0x62. Эта инструкция отстоит на 0x62 (62 в шестнадцатеричной, или 98 в десятичной) байт от начала функции nt!PopUserPresentSetWorker. Давайте посмотрим на инструкцию, которая предшествует той, которую мы только что рассмотрели. Это инструкция вызова функции nt!PopNotifyConsoleUserPresent (строка 22), которую вы видите в общем списке вызовов следующей (выше по списку). Так что же происходит? При вызове nt!PopUserPresentSetWorker она получает управление, выполняется до определенного момента, затем в какой то момент времени заявляет, что "мне необходимо вызвать nt!PopNotifyConsoleUserPresent потому как мне нужно с помощью неё что-то сделать", и, соответственно, вызывает её. Теперь посмотрим на список вызова. Неужели наша функция передает управление по адресу nt!PopNotifyConsoleUserPresent+0x6b, как у нас обозначено в стеке вызовов? Нет!! Вместо этого, она передает управление строго в начало функции PopNotifyConsoleUserPresent, а именно по адресу fffff800`0301b490. Давайте в этом убедимся. Дизассемблируем код по указанному только что адресу:

Вот вам и весомое доказательство. Адрес fffff800`0301b490 соответствует первой инструкции функции PopNotifyConsoleUserPresent, но никак не тому, что у нас указано в стеке вызовов: nt!PopNotifyConsoleUserPresent+0x6b, и нет тут никакого смещения, поскольку по смещению 0x6b от начала функции находится совершенно другая инструкция. Что у нас тут происходит, функция PopNotifyConsoleUserPresent делает какую-то свою работу, пока она, в свою очередь, не захочет вызвать другую, потребовавшуюся ей для каких-то там своих нужд, функцию. Давайте проверим, куда ведет указатель nt!PopNotifyConsoleUserPresent+0x6b, расположенный в стеке вызовов в столбце Call site:

Ну, смещение 6b указывает на некую инструкцию. А что стоит непосредственно перед ней? А тут опять идет инструкция вызова следующей функции. Таким образом, далее мы попадаем на очередную функцию в стеке с именем nt!PopWin32InfoCallout.

Тут не все так очевидно, как хотелось бы, дело в том, что функция то в дизассемблированном блоке называется nt!PopWin32InfoCallout, а в списке вызовов уже именуется как win32k!UserPowerInfoCallout (к тому же без смещения инструкции). Скорее всего это объясняется тем, что nt!PopWin32InfoCallout является "переходником".

Что же происходит, когда последняя функция win32k!UserPowerInfoCallout возвращает управление? Вот тогда управление возвращается по адресу nt!PopNotifyConsoleUserPresent+0x6b, затем возвращается к nt!PopUserPresentSetWorker+0x62 и так далее обратно по стеку.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *