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

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 вкрай важко і доводиться вдаватися до милиць. Але в будь-якому випадку, спасибі за прочитання даної статті.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.