Как стать автором
Обновить

Stack Trace в C++ или велосипедирование, уровень «Быдлокод»

Время на прочтение9 мин
Количество просмотров32K

DISCLAMER


Статья является шуточной, но с долей правды (программирование, же). Данная статья также содержит код, который может смертельно навредить вашему зрению. Читайте на ваш риск.

Вступление


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

Исключение

Исключения — это очень мощная система обработки исключительных ситуаций, возникающих в программе. Но если исключение не было обработано — то оно роняет программу через std::terminate. Поэтому в хорошо написанных программах, исключение которое не было обработано зачастую означает баг в программе, который надо исправлять.

Данный вид ошибок является самым информативным, так как метод исключения what() выводится в stderr автоматически при падении программы.

Assert

Отключаемый метод контроля правильности использования функций. Отключение предоставляется в качестве инструмента увеличения производительности в функциях, в которых каждая наносекунда на счету. Если программа упала по assertу, значит программист где-то накосячил при использовании интерфейса какого то модуля. Но вполне возможно, что он просто не предусмотрел каких-то критических значений и по этой причине вышел из условий assertа.

Данный вид ошибок является не самым информативным, но при падении, выводит условие, которое было нарушено.

SIGSEGV

Вы, как профессионал своего дела разыменовали нулевой указатель и радостно записали в него какое-то значение. Программа не особо сопротивляясь упала.

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

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

Смотрим по сторонам


Для начала надо понять, каким образом вообще отслеживать вызовы функций. Гуглинг выдал крайне неутешительные результаты. Очевидно, что кроссплатформенного решения нет. Под Linux и Mac OS есть заголовочный файл execinfo.h с помощью которого можно получить связный список стека вызовов. Под Windows есть функция WinAPI CaptureStackBackTrace, которая позволяет прогуляться по стеку и получить вызовы из фреймов. Но мы пойдем путем С++. Не будем использовать платформозависимые функции.

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

А какие конкретно нам нужны данные? Ну для красоты конечно было бы не плохо иметь файл, строку и имя функции. Так же еще было бы не плохо иметь аргументы этой функции, что бы можно было конкретизировать вызываемую функцию при перегрузке.

Но какой использовать интерфейс? Как написать более менее красивый код и при этом получить требуемую функциональность.
Единственное решение которое я смог найти — это макросы (возможно, это все также можно как-то реализовать через шаблоны, но я с шаблонами знаком крайне поверхностно и поэтому делаю так как умею).

Реализация


Для начала реализуем синглетон, который будет использоваться для работы со стеком. В качестве интерфейса пользователя реализуем только метод для получения строкового представления stack traceа.

class StackTracer
{
    friend class CallHolder;

public:

    static StackTracer& i()
    {
        static StackTracer s;
        return s;
    }

    std::string getStackTrace() const
    {
        std::stringstream ss;

        for (auto iterator = m_data.begin(), end = m_data.end();
             iterator != end;
             ++iterator)
            ss << iterator->file << ':' << iterator->line << " -> " << iterator->name << std::endl;

        return ss.str();
    }

private:
    void push(const std::string &name, const char *file, int line)
    {
        m_data.push_front({name, file, line});
    }

    void pop()
    {
        m_data.pop_front();
    }

    struct CallData
    {
        std::string name;
        const char *file;
        int line;
    };

    StackTracer() :
            m_data()
    {}

    std::list<CallData> m_data;
};

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

Из проблем данного класса — полная потоковая небезопасность. Но с этим мы разберемся позже, а сейчас PoC.

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

class CallHolder
{
public:
    CallHolder(const std::string &name, const char *file, int line)
    {
        StackTracer::i().push(name, file, line);
    }

    ~CallHolder()
    {
        StackTracer::i().pop();
    }
};

Довольно нетривиальный код не так ли? Опять же, данный «регистратор» не учитывает многопоточность.

Теперь попробуем накидать небольшой пример, что бы проверить работоспособность такого Франкенштейна.

void func1();

void func2()
{
    CallHolder __f("func2()", __FILE__, __LINE__);

    func1();
}

void func1()
{
    CallHolder __f("func1()", __FILE__, __LINE__);

    static int i = 1;
    if (i-- == 1)
        func2();
    else
        std::cout << StackTracer::i().getStackTrace() << std::endl;
}

int main()
{
    func1();

    return 0;
}

Результат:


Рисунок 3.1 — «Оно живое!!»

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

Для реализаций функций и методов получился такой вот макрос:

#define MEM_IMPL(func_name, args)\
func_name args\
{\
    CallHolder __f("" #func_name #args "", __FILE__, __LINE__);

Теперь нашего Франкенштейна можно модифицировать и получить что-то вроде этого. Уже более похоже на «обычный» код:

void func1();

void MEM_IMPL(func2, ())

    func1();
}

void MEM_IMPL(func1, ())

    static int i = 1;
    if (i-- == 1)
        func2();
    else
        std::cout << StackTracer::i().getStackTrace() << std::endl;
}

int main()
{
    func1();

    return 0;
}

Результат выполнения ровно такой же, как и ранее. Но в данном подходе есть явная проблема. Пропадает открывающая фигурная скобка, которую скрывает макрос. Это усложняет чтение кода. Хотя люди, которые придерживаются идеологии с открывающей фигурной скобкой в строке с заголовком не сочтут это сильным минусом. Более сильный минус, что среда разработки, которой я пользуюсь не умеет работать с такими изворотливыми случаями и считает только фигурные скобки вне макросов.

Но мы отвлеклись от нашей вакханалии. Что же делать, если у нас класс? Ну если реализация вне класса — то ничего. Пример:

void func1();

void MEM_IMPL(func2, ())

    func1();
}

void MEM_IMPL(func1, ())

    static int i = 1;
    if (i-- == 1)
        func2();
    else
        std::cout << StackTracer::i().getStackTrace() << std::endl;
}

class EpicClass
{
public:
    void someFunc();
};

void MEM_IMPL(EpicClass::someFunc, ())
    func1();
}

int main()
{
    EpicClass a;
    
    a.someFunc();

    return 0;
}

Результат:


Рисунок 3.2 — Вывод из класса

А что, если вы пишете реализацию прямо в объявлении класса? Тогда требуется другой макрос:

#define CLASS_IMPL(class_name, func_name, args)\
func_name args\
{\
    CallHolder __f("" #class_name "::" #func_name "", __FILE__, __LINE__);

Но у такого подхода есть проблема. В нем надо отдельно указывать имя класса, что не очень хорошо. Это можно обскакать, если мы используем С++11. Я использую найденное на stack overflow решение. Это type_name<decltype(i)>(). Где type_name это

#include <type_traits>
#include <typeinfo>
#ifndef _MSC_VER
#   include <cxxabi.h>
#endif
#include <memory>
#include <string>
#include <cstdlib>

template <class T>
std::string
type_name()
{
    typedef typename std::remove_reference<T>::type TR;
    std::unique_ptr<char, void(*)(void*)> own
            (
#ifndef _MSC_VER
            abi::__cxa_demangle(typeid(TR).name(), nullptr,
                                nullptr, nullptr),
#else
            nullptr,
#endif
            std::free
    );
    std::string r = own != nullptr ? own.get() : typeid(TR).name();
//    if (std::is_const<TR>::value)
//        r += " const";
//    if (std::is_volatile<TR>::value)
//        r += " volatile";
//    if (std::is_lvalue_reference<T>::value)
//        r += "&";
//    else if (std::is_rvalue_reference<T>::value)
//        r += "&&";
    return r;
}

Часть с модификаторами закомментирована по той причине, что результат обработки (*this) тогда будет в конце иметь знак ссылки — амперсанд (&).

Хитрожопый макрос выглядит так:

#define CLASS_IMPL(func_name, args)\
func_name args\
{\
    CallHolder __f(type_name<decltype(*this)>() + "::" + #func_name + #args, __FILE__, __LINE__);

Подредактируем нашего франка и посмотрим на результат:

void func1();

void MEM_IMPL(func2, ())

    func1();
}

void MEM_IMPL(func1, ())

    static int i = 1;
    if (i-- == 1)
        func2();
    else
        std::cout << StackTracer::i().getStackTrace() << std::endl;
}

class EpicClass
{
public:
    void someFunc();

    void CLASS_IMPL(insideFunc, ())
        func1();
    }
};

void MEM_IMPL(EpicClass::someFunc, ())
    func1();
}

int main()
{
    EpicClass a;

//    a.someFunc();
    a.insideFunc();

    return 0;
}

Результат:


Рисунок 3.3 — Объявленный внутри метод класса

Хорошо, но что там с информативностью? Каким образом можно получить хоть какую-нибудь полезную информацию при падении. Ведь сейчас при возникновении того же Seg Fault все просто упадет. Ну для начала реализуем свой int main, который будет ловить ошибки. В заголовке объявляем:

int safe_main(int argc, char *argv[]);

В cpp реализуем наш «безопасный» main, который уже вызовет safe_main.

void signal_handler(int signum)
{
    std::cerr << "Death signal has been taken. Stack trace:" << std::endl << StackTracer::i().getStackTrace() << std::endl;
    signal(signum, SIG_DFL);
    exit(3);
}

int MEM_IMPL(main, (int argc, char * argv[]))
    signal(SIGSEGV, signal_handler);
    signal(SIGTERM, signal_handler);
    signal(SIGABRT, signal_handler);

    return safe_main(argc, argv);
}

Думаю стоит объясниться. Функцией signal мы устанавливаем обработчик, который вызовется при появлении сигналов SIGSEGV, SIGTERM и SIGABRT. В котором уже будет выведен в stderr stack trace. (Последний требуется для assert).

Попробуем сломать программу SIGSEGV. Опять изменим наш «тестовый стенд»:

void func1();

void MEM_IMPL(func2, ())

    func1();
}

void MEM_IMPL(func1, ())

    static int i = 1;
    if (i-- == 1)
        func2();
    else
    {
        int *i = nullptr;
        (*i) = 12;
    }
}

class EpicClass
{
public:
    void someFunc();

    void CLASS_IMPL(insideFunc, ())
        func1();
    }
};

void MEM_IMPL(EpicClass::someFunc, ())
    func1();
}

int MEM_IMPL(safe_main, (int argc, char *argv[]))
    EpicClass a;

//    a.someFunc();
    a.insideFunc();

    return 0;
}

Результат:


Рисунок 3.4 — Работа безопасного main

Но как обстоят дела с исключениями? Ведь если вызывать исключение — то оно просто поразрушает все имеющиеся CallHolder и в stack trace мы не получим ничего обстоятельного. Для этого создаем собственный THROW макрос, который бы получал stack trace в момент выброса исключения:

#define THROW(exception, explanation)\
throw exception(explanation + std::string("\n\rStack trace:\n\r") + StackTracer::i().getStackTrace());

Так же модифицируем немного наш «тестовый стенд»:

void func1();

void MEM_IMPL(func2, ())

    func1();
}

void MEM_IMPL(func1, ())

    static int i = 1;
    if (i-- == 1)
        func2();
    else
    {
//        int *i = nullptr;
//        (*i) = 12;
        THROW(std::runtime_error, "Some cool error");
    }
}

class EpicClass
{
public:
    void someFunc();

    void CLASS_IMPL(insideFunc, ())
        func1();
    }
};

void MEM_IMPL(EpicClass::someFunc, ())
    func1();
}

int MEM_IMPL(safe_main, (int argc, char *argv[]))
    EpicClass a;

//    a.someFunc();
    a.insideFunc();

    return 0;
}

И получаем результат:


Рисунок 3.5 — THROW не прощает

Хорошо. Мы добились полного базового функционала, но что там с многопоточностью? Будем ли мы с ней что-то делать?
Ну по крайней мере попробуем!

Для начала редактируем StackTracer, что бы он начал работать с разными потоками:

class StackTracer
{
    friend class CallHolder;

public:

    static StackTracer& i()
    {
        static StackTracer s;
        return s;
    }

    std::string getStackTrace() const
    {
        std::stringstream ss;

        std::lock_guard<std::mutex> guard(m_readMutex);
        for (auto mapIterator = m_data.begin(), mapEnd = m_data.end();
             mapIterator != mapEnd;
             ++mapIterator)
        {
            ss << "Thread: 0x" << std::hex << mapIterator->first << std::dec << std::endl;

            for (auto listIterator = mapIterator->second.begin(), listEnd = mapIterator->second.end();
                 listIterator != listEnd;
                 ++listIterator)
                ss << listIterator->file << ':' << listIterator->line << " -> " << listIterator->name << std::endl;
            ss << std::endl;
        }

        return ss.str();
    }

private:
    void push(const std::string &name, const char *file, int line, std::thread::id thread_id)
    {
        m_data[thread_id].push_front({name, file, line});
    }

    void pop(std::thread::id thread_id)
    {
        m_data[thread_id].pop_front();
    }

    struct CallData
    {
        std::string name;
        const char *file;
        int line;
    };

    StackTracer() :
            m_data()
    {}

    mutable std::mutex m_readMutex;
    std::map<std::thread::id, std::list<CallData> > m_data;
};

Аналогично меняем CallHolder, что бы в него передавался thread_id:

class CallHolder
{
public:
    CallHolder(const std::string &name, const char *file, int line, std::thread::id thread_id)
    {
        StackTracer::i().push(name, file, line, thread_id);
        m_id = thread_id;
    }

    ~CallHolder()
    {
        StackTracer::i().pop(m_id);
    }

private:
    std::thread::id m_id;
};

Ну и модифицируем немного макросы:

#define CLASS_IMPL(func_name, args)\
func_name args\
{\
    CallHolder __f(type_name<decltype(*this)>() + "::" + #func_name + #args, __FILE__, __LINE__, std::this_thread::get_id());


#define MEM_IMPL(func_name, args)\
func_name args\
{\
    CallHolder __f("" #func_name #args "", __FILE__, __LINE__, std::this_thread::get_id());


Тестируем. Подготовим такой «стенд»:
void MEM_IMPL(sleepy, ())
    std::this_thread::sleep_for(std::chrono::seconds(3));

    THROW(std::runtime_error, "Thread exception");
}

void MEM_IMPL(thread_func, ())
    sleepy();
}

int MEM_IMPL(safe_main, (int argc, char *argv[]))

    std::thread th(&thread_func);
    th.detach();

    std::this_thread::sleep_for(std::chrono::seconds(20));

    return 0;
}

И попробуем запустить:


Рисунок 3.6 — Смерть наступила в 1:10 по московскому времени

Вот мы и получили многопоточный stack trace. Эксперимент окончен, подопытный мертв. Из очевидных проблем данной реализации
  • Мы не можем получить вызовы из библиотек не написанных нами;
  • Дополнительные накладные расходы на каждый вызов функции.

Заключение


К сожалению без серьезной компиляторной поддержки реализовать отладочный stack trace крайне затруднительно и приходится прибегать к костылям. Но в любом случае, спасибо за прочтение данной статьи.
Теги:
Хабы:
Всего голосов 18: ↑12 и ↓6+6
Комментарии19

Публикации

Работа

Программист C++
104 вакансии
QT разработчик
3 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань