Переносная трассировка стека С++ при исключении

Я пишу библиотеку, которая хотела бы быть переносной. Таким образом, он не должен зависеть от расширений glibc или Microsoft или чего-либо еще, что не соответствует стандарту. У меня хорошая иерархия классов, полученных из std:: exception, которые я использую для обработки ошибок в логике и вводе. Зная, что конкретный тип исключения был брошен в конкретный файл, и номер строки полезен, но знание того, как получившееся выполнение было потенциально намного более ценным, поэтому я рассматриваю способы получения трассировки стека.

Я знаю, что эти данные доступны при создании против glibc с использованием функций в execinfo.h(см. вопрос 76822) и через интерфейс StackWalk в реализации Microsoft С++ (см. вопрос 126450), но я очень хотел бы избежать всего, что не переносится.

Я думал о реализации этой функции в этой форме:

class myException : public std::exception
{
public:
  ...
  void AddCall( std::string s )
  { m_vCallStack.push_back( s ); }
  std::string ToStr() const
  {
    std::string l_sRet = "";
    ...
    l_sRet += "Call stack:\n";
    for( int i = 0; i < m_vCallStack.size(); i++ )
      l_sRet += "  " + m_vCallStack[i] + "\n";
    ...
    return l_sRet;
  }
private:
  ...
  std::vector< std::string > m_vCallStack;
};

ret_type some_function( param_1, param_2, param_3 )
{
  try
  {
    ...
  }
  catch( myException e )
  {
    e.AddCall( "some_function( " + param_1 + ", " + param_2 + ", " + param_3 + " )" );
    throw e;
  }
}

int main( int argc, char * argv[] )
{
  try
  {
    ...
  }
  catch ( myException e )
  {
    std::cerr << "Caught exception: \n" << e.ToStr();
    return 1;
  }
  return 0;
}

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

Моя главная проблема - это возможность того, что это приведет к замедлению, даже если на самом деле нет исключений. Все ли эти блоки try/catch требуют дополнительной настройки и срыва при каждом вызове функции или как-то обрабатываются во время компиляции? Или есть другие проблемы, которые я не рассматривал?

Ответы

Ответ 1

Я думаю, что это действительно плохая идея.

Переносимость - очень достойная цель, но не тогда, когда это приводит к навязчивому решению, ухудшению производительности и более низкой реализации.

Каждая платформа (Windows/Linux/PS2/iPhone/и т.д.), над которой я работал, предложила способ ходить по стеку, когда возникает исключение, и сопоставлять адреса с именами функций. Да, ни один из них не является переносимым, но структура отчетности может быть такой, и обычно требуется меньше одного или двух дней, чтобы написать версию кода стека для конкретной платформы.

Мало того, что это меньше времени, чем при создании/поддержке кросс-платформенного решения, но результаты намного лучше,

  • Нет необходимости изменять функции
  • Ловушки сбой в стандартных или сторонних библиотеках
  • Нет необходимости в попытке/уловке в каждой функции (медленная и интенсивная память)

Ответ 2

Посмотрите Nested Diagnostic Context один раз. Вот небольшой намек:

class NDC {
public:
    static NDC* getContextForCurrentThread();
    int addEntry(char const* file, unsigned lineNo);
    void removeEntry(int key);
    void dump(std::ostream& os);
    void clear();
};

class Scope {
public:
    Scope(char const *file, unsigned lineNo) {
       NDC *ctx = NDC::getContextForCurrentThread();
       myKey = ctx->addEntry(file,lineNo);
    }
    ~Scope() {
       if (!std::uncaught_exception()) {
           NDC *ctx = NDC::getContextForCurrentThread();
           ctx->removeEntry(myKey);
       }
    }
private:
    int myKey;
};
#define DECLARE_NDC() Scope s__(__FILE__,__LINE__)

void f() {
    DECLARE_NDC(); // always declare the scope
    // only use try/catch when you want to handle an exception
    // and dump the stack
    try {
       // do stuff in here
    } catch (...) {
       NDC* ctx = NDC::getContextForCurrentThread();
       ctx->dump(std::cerr);
       ctx->clear();
    }
}

Накладные расходы - это реализация НДЦ. Я играл с лениво оцененной версией, а также с той, которая содержала только фиксированное количество записей. Ключевым моментом является то, что если вы используете конструкторы и деструкторы для обработки стека, чтобы вам не нужны все эти неприятные блоки try/catch и явные манипуляции повсюду.

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

Если вы более ориентированы на производительность и живете в мире файлов журналов, измените область действия, чтобы удерживать указатель на имя файла и номер строки и вообще опустить предмет NDC:

class Scope {
public:
    Scope(char const* f, unsigned l): fileName(f), lineNo(l) {}
    ~Scope() {
        if (std::uncaught_exception()) {
            log_error("%s(%u): stack unwind due to exception\n",
                      fileName, lineNo);
        }
    }
private:
    char const* fileName;
    unsigned lineNo;
};

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

Ответ 3

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

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

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

Ответ 4

В отладчике:

Чтобы получить трассировку стека, где исключение выбрасывается, я просто stcik точку останова в std:: exception конструкторе.

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

Ответ 5

Управление стеками - одна из тех простых вещей, которые усложняются очень быстро. Лучше оставить его для специализированных библиотек. Вы пробовали либнвинд? Отлично работает и AFAIK это портативный, хотя я никогда не пробовал его в Windows.

Ответ 6

Это будет медленнее, но похоже, что он должен работать.

Из того, что я понимаю, проблема в создании быстрой, переносимой трассировки стека заключается в том, что реализация стека - это как ОС, так и ЦП, поэтому это неявно проблема конкретной платформы. Альтернативой может быть использование функций MS/glibc и использование #ifdef и соответствующих препроцессоров (например, _WIN32) для реализации конкретных решений платформы в разных сборках.

Ответ 7

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

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

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

Тем не менее, если вы решите использовать механизм catch/throw, не забывайте, что даже С++ все еще имеет препроцессор C и что макросы __FILE__ и __LINE__ определены. Вы можете использовать их для включения имени исходного файла и номера строки в информацию о трассировке.