Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Обратная трассировка стека в ваших программах

Оригинал: Stack Backtracing Inside Your Program
Автор: Gianluca Insolvibile
Дата публикации: 11 августа 2003 г.
Перевод: А.Панин
Дата перевода: 14 декабря 2012 г.

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

Если вы обычно работаете с нетривиальными исходными кодами программ на языке C, вам наверняка часто приходилось выяснять, каким путем (точнее, в результате какой последовательности вызовов функций) программа в ходе исполнения достигала того или иного участка кода. Эта информации особо полезна в том случае, когда ваша хорошо отлаженная программа внезапно завершается с ошибкой, а у вас нет доступа к отладчику. Для этих целей и проводится обратная трассировка стека, причем, благодаря малоизвестной возможности библиотеки GNU C, эта операция относительна проста.

Стековые фреймы и обратная трассировка стека

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

Вложенные вызовы функций
Рисунок 1. Вложенные вызовы функций

Обратная трассировка стека при помощи отладчика GDB

Получение обратной трассировки стека при помощи GDB (или с использованием графического интерфейса для него) для аварийно завершившейся при выполнении программы осуществляется тривиально: вы просто используете команду bt, которая возвращает список функций, которые вызывались до того, как процесс выполнения достиг точки аварийного завершения. Так как эта практика является стандартной, мы не будем приводить подробностей в рамках данной статьи; обратитесь к информационной странице GDB, если хотите узнать больше о ней (используйте команду info gdb stack).

Обратная трассировка стека с использованием libc

Если по какой-либо причине вы не используете отладчик, вам доступны два варианта получения информации о том, какие функции вызываются при выполнении программы. Первый метод заключается в расстановке вызовов функций для вывода и сохранения в журнале информации, отражающей последовательность вызовов функций. Для сложных программ процесс расстановки этих вызовов обременителен и утомителен, даже с учетом того, что существуют макросы для упрощения этих действий, специфичные для компилятора GCC. В качестве примера рассмотрим следующий макрос, предназначенный для отладки программ
#define TRACE_MSG fprintf(stderr, __FUNCTION__     \
                                         "() [%s:%d] here I am\n", \
                         __FILE__, __LINE__)

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

Более элегантным методом получения обратной трассировки является использование специальных функций, предоставляемых библиотекой glibc. Ключевой функцией этого метода является функция backtrace(), которая отслеживает стековые фреймы от точки вызова до начала программы и возвращает массив адресов возврата. Вы можете сопоставить каждый из возвращаемых адресов с функцией в коде программы, исследовав объектный файл при помощи программы nm. Или вы можете получить тот же более простым путем - использовав функцию backtrace_symbols(). Эта функция преобразует список адресов возврата, такой, как возвращает функция backtrace() в список строк, каждая из которых содержит имя функции и адрес возврата. Память для списка строк резервируется из кучи (также, как и в том случае, когда вы вызываете функцию malloc()), поэтому вы должны освободить память при помощи вызова функции free() как только вы закончите работу со списком.

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

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

В примере выше демонстрируется процесс использования функций обратной трассировки стека. Функция test() вызывает или функцию func_low(), или функцию func_high(), а обе эти функции в свою очередь вызывают функцию show_stackframe() для вывода последовательности вызовов функций. Программа компилируется при помощи команды:
gcc -rdynamic listing1.c -o listing1
Вывод программы должен быть аналогичен следующему:
Execution path:
./listing1(show_stackframe+0x2e) [0x80486de]
./listing1(func_high+0x11) [0x8048799]
./listing1(test+0x43) [0x80487eb]
./listing1(main+0x13) [0x8048817]
/lib/libc.so.6(__libc_start_main+0xbd) [0x4003e17d]
./listing1(backtrace_symbols+0x31) [0x80485f1]
First call: 167
Execution path:
./listing1(show_stackframe+0x2e) [0x80486de]
./listing1(func_low+0x11) [0x8048779]
./listing1(test+0x21) [0x80487c9]
./listing1(main+0x33) [0x8048837]
/lib/libc.so.6(__libc_start_main+0xbd) [0x4003e17d]
./listing1(backtrace_symbols+0x31) [0x80485f1]
Second call: -3

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

Шаг вперед

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

Еще более полезным применением этой техники является помещение функции для трассировки стека в обработчик сигналов с захватом всех нежелательных сигналов, которые программа может принять (SIGSEGV, SIGBUS, SIGILL, SIGFPE и аналогичные). В этом случае, даже если ваша программа неожиданно аварийно завершится и вы не будете использовать отладчик, в вашем распоряжении будет трассировка стека, на основании которой можно будет сделать вывод о том, в какой функции произошла ошибка. Эта техника также может быть использована для того, чтобы понять, к какой функции программа находится в бесконечном цикле в том случае, когда она перестает отвечать на пользовательский ввод. Все, что вам нужно - это установить обработчик сигналов SIGUSR1/2 и отправить сигнал в случае необходимости. Перед рассмотрением примера, необходимо дать пояснения по поводу обработки сигналов.

Обработка сигналов и стековые фреймы

Получение обратной трассировки стека внутри обработчика сигналов требует разрешения любопытных неопределенностей, что вынуждает нас использовать обходные пути при осуществлении доставки сигналов процессам. Детальное обсуждение этих аспектов выходит за рамки этой статьи, но мы все же кратко резюмируем их следующим образом:
  • Когда ядро сталкивается с необходимостью уведомления процесса о поступившем сигнале, оно данные в форме структур, присоединяемых к структуре task, содержащей информацию о процессе, после чего устанавливает бит, указывающий на наличие ожидающего обработки сигнала.
  • После этого, когда получающий сигнал процесс планируется к исполнению, его стековый фрейм модифицируется ядром таким образом, чтобы указатель инструкции (EIP) указывал на адрес функции для обработки сигналов. Таким образом, когда процесс выполняется, он ведет себя так, как будто он сам вызвал свою функцию для обработки сигналов перед тем, как его выполнение было приостановлено планировщиком.
  • Начальные шаги по управлению обработкой сигналов производятся в пространстве пользователя внутри библиотеки libc, из которой в конечном счете производится вызов функции реального процесса для обработки сигналов, в которой, в свою очередь, выполняется наша функция обратной трассировки стека.
В результате работы этого механизма двумя первыми элементами с цепочке стековых фреймов во время входа в обработчик сигналов оказываются, соответственно, адрес возврата внутри вашего обработчика сигналов и адрес возврата внутри функции sigaction() из библиотеки libc. Стековый фрейм последней функции, вызываемой до обработки сигнала (которая, в случае сигналов, указывающих на неполадки, также может быть их источником) теряется. Таким образом, если функция B вызвала функцию A, которая в свою очередь привела к ошибке сегментирования и сигналу SIGSEGV, обычная обратная трассировка стека будет включать в себя следующие точки входа:
your_sig_handler()
sigaction() in libc.so
func_B()
main()

При этом вызов функции A невозможно обнаружить. Для получения более подробной информации обратитесь к руководствам, описывающим функции signal() и sigaction().

Вернемся к трассировке стека

На самом деле, для того, чтобы получить трассировку стека в корректном виде, нам необходимо использовать обходной путь. К счастью, в случаях, когда есть доступ к исходным кодам ядра и библиотеки libc, можно найти обходной путь практически для всего. В исходном коде, представленном ниже, мы используем недокументированный параметр типа sigcontext, который передается обработчику сигнала (смотрите раздел "Недокументированное" в руководстве sigaction) и содержит среди других параметров значение указателя инструкции (EIP) во время обработки сигнала. После вызова backtrace() мы используем значение этого параметра для перезаписи ненужного элемента, являющегося адресом возврата функции sigaction() в массиве трассировки стека. Когда мы вызовем функцию backtrace_symbols(), вставленный нами адрес будет преобразован в имя функции аналогично всем другим. Наконец, когда мы будем выводить результат трассировки, начнем со второго элемента (i=1 в цикле), потому что первым элементом всегда будет обработчик сигналов.

С момента релиза ядра версии 2.2 недокументированный параметр, передаваемый обработчику сигналов, был объявлен устаревшим в соответствии со стандартом POSIX.1b. Более корректным методом получения дополнительной информации в обработчике сигналов является использование параметра SA_SIGINFO при установке обработчика, как показано в примере ниже и описано в руководстве. К сожалению, структура siginfo_t передаваемая обработчику в качестве параметра, не содержит значения указателя исполнения (EIP), которое нам необходимо, поэтому мы вынуждены снова прибегнуть к недокументированному методу получения информации: третьему параметру функции обработки сигналов. Страницы руководства не расскажут вам о том, что этот параметр является указателем на структуру типа ucontext_t, содержащую значения регистров центрального процессора при обработке сигнала. Из этой структуры мы можем извлечь значение EIP и выполнить действия, описанные выше.

Риски и ограничения

При использовании функций обратной трассировки стека необходимо помнить о нескольких особенностях. Во-первых, функция backtrace_symbols() вызывает функцию malloc() и, таким образом, может работать некорректно при повреждении памяти - это может случиться при обработке сигнала, указывающего на неполадки. Если вам необходимо получить названия функций в такой ситуации, безопаснее вызывать функцию backtrace_symbols_fd(), поскольку она осуществляет запись имен функций напрямую в заданный дескриптором файл без резервирования памяти. По этой же причине безопаснее использовать статические или автоматические (но не динамические) переменные для массива, передаваемого функции backtrace().

Также существуют ограничения, препятствующие выполнению автоматической трассировки последовательности вызовов функций. Особенно часто такие ограничения возникают из-за применения компилятором оптимизаций, которые тем или иным образом модифицируют содержимое стекового фрейма или даже предотвращают создание такового (имеются в виду inline-функции). Очевидно, что для макросов не существует стекового фрейма, так как они вообще не являются функциями и о вызове функций не идет речи. Наконец, обратная трассировка стека невозможна в том случае, если стек был поврежден при неправильной работе с памятью.

В отношении получения имен функций следует отметить, что текущая версия библиотеки glibc (версия 2.3.1 на момент написания статьи) позволяет получать имена функций и сдвиги только в системах, работающих с форматом исполняемых файлов ELF. Кроме того, имена статических функций не могут быть определены, потому что они не доступны для методик, лежащих в основе динамического связывания. В этом случае внешняя программа addr2line может быть использована в качестве альтернативы.

Внутренняя реализация

В случае, если у вас возникнет вопрос о том, как осуществить доступ к информации о состоянии стека в программе на языке C, ответ будет очень простым: вы не сможете получить доступа к этой информации. На самом деле управление стеком в значительной мере зависит от платформы, на которой выполняется приложение и язык C не предоставляет никаких возможностей для воздействия на этот процесс стандартным образом. Реализация функции backtrace() в библиотеке glibc содержит различные участки кода для разных платформ, использующие либо внутренние переменные GCC (__builtin_frame_address и __builtin_return_address), либо ассемблерный код.

В случае использования платформы i386 (реализация находится в файле glibc-x.x.x/sysdeps/i386/backtrace.c) несколько строк ассемблерного кода используются для доступа к содержимому регистров ebp и esp центрального процессора, которые содержат адрес текущего стекового фрейма и указателя стека для каждой выполняющейся функции:
register void *ebp __asm__ ("ebp");
register void *esp __asm__ ("esp");

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

На этом этапе вам все еще требуется преобразовать адреса возврата в имена функций, а эта операция зависит от формата исполняемых файлов, который вы используете. В случае использования формата ELF эта операция осуществляется при помои внутренней функции для динамического связывания (_dl_addr(), смотрите файл glibc-x.x.x/sysdeps/generic/elf/backtracesyms.c).

Заключение

Вам приходится работать со сложными программами, содержащими множество путей исполнения, что заставляет вас растерянно блуждать среди сотен функций, отчаянно пытаясь понять, какая из них вызывает какую? Хватит блуждать, просто распечатайте обратную трассировку стека. Это бесплатно, быстро и просто. Когда вам удастся это сделать, сделайте себе одолжение и используйте эту функцию также внутри обработчика сигналов, указывающих на неполадки - это гарантированно поможет вам разобраться с надоедливыми ошибками, проявляющимися один раз за тысячу запусков.