Перехват

Какой лучший способ поймать переполнение стека в C?

Более конкретно:

Программа C содержит интерпретатор для языка сценариев.

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

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

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

Есть ли лучший способ сделать это? В частности, я не ожидаю лучшего портативного решения, но если бы у меня было системное решение для Linux, а другое для Windows, это было бы нормально.

Я видел ссылки на что-то, называемое структурированной обработкой исключений в Windows, хотя ссылки, которые я видел, касались перевода этого в механизм обработки исключений С++; можно ли получить доступ из C, и если это так полезно для этого сценария?

Я понимаю, что Linux позволяет поймать сигнал сбоя сегментации; можно ли надежно превратить это в longjmp обратно в ваш основной цикл?

Java, похоже, поддерживает перехват на всех платформах; как это реализовать?

Ответы

Ответ 1

Сверху моей головы один из способов избежать чрезмерного роста стека - проверить относительную разницу в адресах кадров стека:

#define MAX_ROOM    (64*1024*1024UL)    // 64 MB

static char *   first_stack = NULL;

void foo(...args...)
{
    char    stack;

    // Compare addresses of stack frames
    if (first_stack == NULL)
        first_stack = &stack;
    if (first_stack > &stack  &&  first_stack - &stack > MAX_ROOM  ||
        &stack > first_stack  &&  &stack - first_stack > MAX_ROOM)
        printf("Stack is larger than %lu\n", (unsigned long)MAX_ROOM);

    ...code that recursively calls foo()...
}

Это сравнивает адрес первого кадра стека для foo() с текущим адресом кадра стека, а если разница превышает MAX_ROOM, он пишет сообщение.

Это предполагает, что вы используете архитектуру, которая, конечно же, использует линейный стежок, постоянно растущий или всегда растущий.

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

Ответ 2

AFAIK, все механизмы для обнаружения будут нести некоторую стоимость исполнения. Вы можете позволить CPU обнаруживать seg-faults, но это уже слишком поздно; вы наверняка уже набросали что-то важное.

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

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

Ответ 3

(я не буду беспокоить эти методы в зависимости от конкретных платформ для "лучших" решений. Они создают проблемы, ограничивая дизайн языка и удобство использования с небольшим усилением. Для ответов "просто работайте", на Linux и Windows, см. выше.)

Прежде всего, в смысле C, вы не можете сделать это переносимым способом. Фактически, ISO C не требует "стека" вообще. Педантично даже кажется, что когда распределение автоматических объектов не удавалось, поведение буквально undefined, согласно пункту 4p2 - просто нет гарантии, что произойдет, когда вызовы будут вложены слишком глубоко. Вы должны полагаться на некоторые дополнительные предположения о реализации (ISA или OS ABI), чтобы сделать это, так что вы заканчиваете с С++ чем-то другим, а не только C. Генерация машинного кода Runtime также не переносима на уровне C.

(BTW, ISO С++ имеет понятие разворачивания стека, но только в контексте обработки исключений. И по-прежнему нет гарантии переносимого поведения при переполнении стека, хотя оно кажется неуказанным, а не undefined.)

Кроме того, чтобы ограничить глубину вызова, все способы имеют некоторую дополнительную стоимость исполнения. Стоимость будет довольно легко наблюдаться, если нет некоторых аппаратных средств, чтобы амортизировать ее (например, ходить по страницам). К сожалению, теперь это не так.

Единственный переносимый способ, который я нахожу, - не полагаться на собственный стек базовой архитектуры. Это в общем случае означает, что вы должны выделять кадры записи активации как часть свободного хранилища (в куче), а не собственный стек, предоставляемый ISA. Это не только работает для интерпретируемых реализаций языка, но также и для скомпилированных, например. SML/NJ. Такой подход к программному стеку не всегда несет худшую производительность, поскольку позволяет предоставлять более высокий уровень абстракции на языке объектов, поэтому у программ может быть больше возможностей для оптимизации, хотя это маловероятно для наивного интерпретатора.

У вас есть несколько вариантов для этого. Один из способов - написать виртуальную машину. Вы можете выделить память и построить в ней стек.

Другой способ - написать сложный код асинхронного стиля (например, трамплины или преобразование CPS) в вашей реализации, вместо этого, полагаясь на меньшие исходные кадры вызова, насколько это возможно. Как правило, трудно получить право, но оно работает. Дополнительные возможности, обеспечиваемые таким способом, - это упрощенная оптимизация хвостовых вызовов и более простой захват продолжения первого класса.