Как я за backtrace-ом ходил

    Не так давно мы в компании задумали дать возможность пользователям посылать нам уведомления о произошедших ошибках в нашем ПО. Сказано — сделано. Но тут возникла задача получения backtrace-а текущего стека вызовов программы прямо в рантайме. Оказалось, что есть несколько способов решения этой задачи. Данная статья — результат моих исследований вопроса получения бэктрейса для программ написанных на С/C++ и работающих на Linux и FreeBSD.


    Немного теории


    В принципе, получить цепочку вызовов довольно просто. Вся необходимая информация хранится в стеке программы. Современные компиляторы для вызова функции формируют так называемые фреймы стека (stack frame). В начале каждого фрейма находится адрес предыдущего. А непосредственно перед фреймом сохранен адрес возврата, т.е. адрес инструкции, которая будет выполнена следующей, после завершения функции. Таким образом, все, что необходимо сделать — это пройти по списку фреймов и распечатать адреса возврата.
    Например, это можно сделать так (пример для amd64):
    void * GetReturnAddress(int depth) {
        void *res;
        asm (
            "mov %1, %%rcx\n"
            "MOVE: mov 0x0(%%rbp), %%rax\n"
            "loop MOVE\n"
            "mov 0x8(%%rax), %rax\n"
            "mov %%rax, %0\n" : "=m" (res) : "g" (depth) : "rax", "rcx");
        return res;
    }
    

    Функцию можно было написать и короче, т.к. возвращаемое значение кладется в регистр rax, можно было обойтись и без переменной res.
    Но, лично для меня, наличие ассемблерных вставок не есть истинный путь джедая. Поэтому я пошел искать другое решение.

    Расширения gcc


    Первое, на что я набрел, это функция __builtin_return_address добродушно предоставленная нам создателями gcc. Вот выдержка из ее полного описания:
    void * __builtin_return_address (unsigned int level) — возвращает адрес возврата функции. Для level=0 функция вернет адрес возврата текущей функции, для level=1 адрес возврата функции вызвавшей текущую функцию и т.д.
    При ее использовании есть только одно но: функция компилируясь разворачиваются в кучу строк ассемблерного кода (чем дальше по стеку идем, тем больше строк) и в связи с этим, она не умеют принимать переменную в качестве параметра. Поэтому вместо красивой записи вида:
    return __builtin_return_address(i);
    приходится писать некрасивые:
    switch(level) { 
        case 0: return __builtin_return_address(1); 
        case 1: return __builtin_return_address(2);
        …. 
    }
    

    Уже лучше. Идем дальше.

    backtrace


    В Linux стандартная библиотека предоставляет программисту целый набор функций, позволяющих получать нужную нам информацию. В FreeBSD для этих целей необходимо установить библиотеку libexecinfo. Вот они:
    int backtrace(void **buffer, int size) — функция, заполняющая buffer backtrace-ом вызывающей программы.
    char **backtrace_symbols(void *const *buffer, int size) — функция, принимающая результат первой функции и транслирующая адреса функций в текстовое представление.
    void backtrace_symbols_fd(void *const *buffer, int size, int fd) — делает то же самое, что и предыдущая, только вместо выделения памяти под строки через malloc пишет инфу напрямую в файл.
    Для каждой функции, вошедшей в стек вызовов, backtrace_symbols возвращает строку следующего вида:

    ./prog(_Z6myfunci+0x1a) [0x8048840]
    где: prog — имя бинарника
    _Z6myfunci — закодированное имя функции
    0x1a — смещение внутри функции
    0x8048840 — адрес функции

    Найти более подробную информацию, а также пример их использования можно в man backtrace. Хочу лишь заметить, что для того, чтобы backtrace_symbols корректно отработала, компилировать программу надо с опцией -rdynamic. Это связано с тем, что информацию об имени функции backtrace_symbols берет из таблицы динамической линковки. А по умолчанию туда попадают только функции, подгружаемые из динамических библиотек. Для принудительного добавления всех функций в эту таблицу и нужен вышеупомянутый ключ.

    dladdr


    Недостатком функции backtrace_symbols является то, что результат своей работы она представляет в виде текста. Т.е. если мы захотим произвести какие-либо манипуляции, например, с именем функции, то придется парсить эту строку. Опять не по-джедайски! Зачем это надо, будет понятно чуть позже.
    Тут на помощь нам приходит функция dladdr. Собственно именно ее и зовет backtrace_symbols внутри себя. Её сигнатура очень проста — на вход подаем адрес функции, а на выходе получаем структуру типа Dl_info:
    int dladdr(void *addr, Dl_info *info);
    В случае успешного исхода вызова dladdr в структуре будет лежать все те же данные, что и в случае с backtrace_symbols.
    Ну что ж, почти отлично. Теперь у нас есть адреса возвратов и даже имена функций, хоть и в закодированном формате (о решении этот вопроса поговорим чуть позже). Посмотрим, какую информацию можно еще вытащить. Может имя файла с исходным кодом и даже адрес строки где находится функция? Реально, хоть и придется заморочиться!

    Что с этим делать?!


    В принципе, тех данных, которые у нас уже есть, достаточно. Имея адрес, можно всегда выяснить номер строки, который породил вызов функции. Самый простой способ — использовать команду list отладчика gdb. Если у вас есть та же версия программы, собранная с дебагом, то list *<адрес> — покажет вам номер строки. А если же у вас рядом еще и исходники лежат, то вы «о чудо!» эту строку увидите.
    Но идея хранить две версии программы (с дебагом и без) не соответствует джедайским стремлениям к идеальному, и поэтому я решил изучить strip. Я давно знал, что он умеет хранить бинарные файлы и отладочную информацию отдельно. Оказалось все довольно просто:
    1. Собираем программу с дебагом как обычно (ключ -g или, для фанатов, -g3 — тогда в дебаг будут включены inline функции и всевозможные макросы).
    2. Выполняем objcopy --only-keep-debug a.out a.out.sym. Теперь вся инфа, необходимая для комфортной работы в gdb, находится в файле a.out.sym.
    3. Выполняем strip a.out. Т.е. удаляем дебаг из a.out.


    Теперь мы можем:
    1. Связать наш a.out.sym с a.out командой objcopy --add-gnu-debuglink=a.out.sym a.out. Тогда дебагер автоматически подгрузит всю необходимую инфу из a.out.sym, если найдет его в той же папке, в которой расположен бинарник.
    2. Загрузить файл a.out.sym из gdb вручную командой symbol-file a.out.sym
      Теперь у нас появляется возможность собирать отладочную информацию для своего ПО, но не отдавать его клиенту. Это может быть сделано из сострадания (дебаг занимает довольно внушительный объем), или из соображений безопасности (усложняем хакерам реверс инженеринг). Зато когда необходимо что-нибудь поотлаживать прямо у клиента, можно просто залить ему несколько недостающих .sym файлов.

    Но, если же есть желание видеть не только номера строк, но и сам исходный код, но при этом не хотите его заливать клиенту (вполне законное желание для коммерческого ПО), можно воспользоваться gdbserver, который позволяет отлаживать программу удаленно. Для этого нужно:
    1. На стороне клиента запустить gdbserver 127.0.0.1:2345 a.out
    2. А со своей стороны запускаем gdb и выполняем команду target remote 127.0.0.1:2345. При этом все исходные файлы должны быть доступны с теми же путями, которые использовались в момент компиляции.

    mangling/demangling


    Напоследок скажу пару слов о формате записи имен функций. В двух словах подобное искажения имен функций необходимо компоновщику, чтобы решать коллизии именования. Ну, или еще проще, если в программе существуют две функции с одинаковым именем, но различными параметрами (перегруженные), то компоновщику необходимо точно знать с какой из них работать. Для этого компилятор и кодирует имя функции по особому алгоритму, назначая ей новое уникальное имя. По-английский это процесс называется mangling, а обратный ему – demangling.
    Для решения задачи преобразования закодированного имени функции в оригинальный формат можно опять воспользоваться gcc-шным расширением:
    char* abi::__cxa_demangle(const char* mangled_name, char* output_buffer, size_t* length, int* status)
    Эта функция, принимая на вход закодированное имя функции и буфер, на выходе выдает раскодированное имя. Пример ее использования можно найти здесь.

    Ну и напоследок


    Самым забавным способом получения нужной информации оказалось просто спросить её у gdb. Благо последний позволяет нам это сделать (пример функции взят отсюда).
    void print_trace() {
    	char pid_buf[30];
    	sprintf(pid_buf, "%d", getpid());
    	char name_buf[512];
    	name_buf[readlink("/proc/self/exe", name_buf, 511)]=0;
    	int child_pid = fork();
    	if (!child_pid) {
    		dup2(2,1); // redirect output to stderr
    		fprintf(stdout,"stack trace for %s pid=%s\n",name_buf,pid_buf);
    		execlp("gdb", "gdb", "--batch", "-n", "-ex", "thread", "-ex", "bt", name_buf, pid_buf, NULL);
    		abort(); /* If gdb failed to start */
    	} else {
    		waitpid(child_pid,NULL,0);
    	}
    }
    

    Все что нам нужно будет сделать, это вызвать функцию print_trace и, вуаля, стек вызовов распечатается в stdout. В принципе, вариант работающий, но очень медленный и требующий установки gdb.

    Вот и все.
    Приятной отладки!
    • +8
    • 11.5k
    • 6
    ISPsystem
    145.57
    Софт для хостинга: ISPmanager, BILLmanager и др.
    Share post

    Comments 6

      0
      А из обработчика сигнала GetReturnAddress() и backtrace() работают?
        0
        Да, должны (но я не пробовал. У меня задача была отлаживать возникающие exception).
        По крайней мере, gdb из обработчика сигнала стек показывает, а он по нему ходит теми же методами.
        +1
        Готовый вариант: www.nongnu.org/libunwind/
        +1
        Листинг программ в картинках. На IT-ресурсе.

        Only users with full accounts can post comments. Log in, please.