Развивая идею доверенного языка программирования я пришел к выводу, что за счет ограничений синтаксиса и создания соответствующих проверок в статическом анализаРазвивая идею доверенного языка программирования я пришел к выводу, что за счет ограничений синтаксиса и создания соответствующих проверок в статическом анализа

Избавляемся от ошибок Segmentation fault из-за переполнения стека в С++

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

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

Существует ли хотя бы теоретическая возможность защититься от ошибок переполнения стека и сделать из нее обычную ошибку (исключение), которую можно поймать (обработать) в самом приложении, чтобы была возможность продолжить выполнение программы без боязни последующей ошибки сегментации (segmentation fault) или повреждения стека (stack smashing)?

Немного технической информации о стеке

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

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

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

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

  • Размер стека у функции может зависеть от входных данных функции (например alloca и VLA в языке С)

  • Компилятор может добавлять неиспользуемое пространство между переменными для их выравнивания (Padding).

  • На стеке сохраняются регистры, которые функция должна восстановить перед выходом (callee-saved registers), адрес возврат из функции и возвращаемые данные. Причем это все зависит не только от особенностей конкретной аппаратной платформы, но и от соглашения о вызовах функции.

  • Компилятор может создавать безымянные временные переменные на стеке для хранения промежуточных результатов вычислений.

  • Оптимизатор может полностью удалить некоторые переменные или хранить их только в регистрах, не используя для этого память в стеке.

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

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

Причины переполнения стека и механизмы защиты

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

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

Поэтому размер стека всегда задается заранее:

  • При компоновке/загрузке процесса в оперативную память или при создании потока приложения.

  • Максимальный размер стека резервируется на уровне ОС и не может быть увеличен в ходе жизни потока (хотя иногда и предпринимаются попытки динамически увеличивать размер стека во время работы приложения, Segmented Stacks in LLVM и Split Stacks in GCC)

  • В некоторых языка программирования рантаймы могут устанавливать пользовательские размеры стеков, но это не изменяет ограничений для системного потока и всё равно упирается в общий предел памяти.

Существует множество механизмов защиты стека:

Программные, на уровне компилятора:

  • Стековые «канареечные» значения (stack canaries, stack cookies): вставка защитных маркеров в прологах функций, проверяемых в эпилоге.

  • Пробинг/пошаговое «прощупывание» стека для больших кадров: гарантирует, что ОС успеет активировать сторожевые страницы, предотвращая «столкновение» с другими регионами памяти.

  • Проверки на столкновение стеков (stack-clash protection) при крупных локальных аллокациях.

  • Санитайзеры времени выполнения: детектирование переполнений локальных буферов, выходов за его границы

  • Разделение «безопасного» и общего стека (safe stack) для минимизации возможностей перезаписи чувствительных данных

  • Tail-call оптимизация и устранение хвостовой рекурсии: преобразование хвостовых вызовов в итеративные переходы без роста стека.

  • Альтернативный стек для обработчиков сигналов/исключений, чтобы корректно записать аварийный отчёт при переполнении основного стека.

Аппаратные и системные механизмы защиты стека:

  • MMU/страничная защита и сторожевые страницы на границе стека: раннее обнаружение выхода за пределы.

  • Разделение исполнения и данных: запрет выполнения кода со стека (NX/DEP), чтобы переполнение не превращалось в исполнение произвольного кода.

  • Рандомизация адресного пространства (ASLR): усложняет предсказуемость расположения стека, снижая риск целевых атак.

  • Теневой стек на аппаратном уровне для защиты возвратов (shadow stack), предотвращающий подмену адресов возврата.

  • Контроль потока управления на уровне процессора, снижающий вероятность эксплуатации переполнений.

  • Политики ОС для лимитов стека на поток и процесс, обеспечивающие предсказуемое поведение и аварийное завершение до повреждения соседних регионов памяти.

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

Реализация защиты стека от переполнения для С++

Исходный код проекта для предотвращения аварийного завершения работы приложения из-за переполнения стека можно посмотреть на GitHub/stack-check.

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

Код проекта реализован в виде одного заголовочного файле stack_check.h, в котором находятся все необходимые программные примитивы для ручного использования. Так же есть плагин для Clang, который на этапе генерации IR-кода автоматически вставляет вызовы функций контроля стека от переполнения перед защищаемыми функциями.

Для ручной маркировки функций и методов классов, перед вызовом которых требуется проверка свободного места на стеке, используются пользовательские атрибуты C++ которые раскрываются с помощью макросов STACK_CHECK_SIZE(size) и STACK_CHECK_LIMIT :

  • Атрибут STACK_CHECK_SIZE(size) принимает один аргумент в виде целого числа - размер свободного пространства на стеке, который будет автоматически проверяться перед вызовом защищаемой функции.

  • Атрибут STACK_CHECK_LIMIT тоже проверяет размер свободного пространства на стеке, который задаётся при компиляции приложения. Размер использования стека для каждой функции можно выяснить, указав при компиляции опцию -fstack-usage, которая сохраняет в файле *.su список всех функций и требуемый для них размер стека.

Автоматическую вставку кода перед защищаемой функцией можно отменить. Для этого требуется вставить в C++ коде вызов вспомогательного статического метода ignore_next_check(const size_t), которому передаётся количество следующих вставок кода, которые будут пропущены (удалены) из генерируемого (исполняемого) файла.

Пример кода для использования библиотеки:

#include "stack_check.h" using namespace trust; const thread_local stack_check stack_check::info; // Функция без автоматической проверки стека от переполнения int func() { ... } // Перед каждым вызовом функции будет вставлен код проверки указанного свободного места на стеке [[stack_check_size(100)]] int guard_size_func() { char data[92]; ... } // Перед каждым вызовом функции будет проверяться минимальный размер свободного места на стеке STACK_CHECK_LIMIT int guard_limit_func() { ... } int main() { // Тут будет автоматически добавлен код для контроля стека от переполнения guard_size_func(); stack_check::ignore_next_check(1); // Следующая автоматическая вставка проверки стека будет проигнорирована guard_size_func(); // Тут будет автоматически добавлен код для проверки минимального размера свободного места на стеке guard_limit_func(); stack_check::check_overflow(10000); // Ручная проверка свободного места на стеке func(); return 0; }

После чего файл компилируется с подключением clang плагина:

$ clang++ -std=c++20 -Xclang -load -Xclang stack_check_clang.so -Xclang -add-plugin -Xclang stack_check -lpthread filename.cpp

Детали реализации

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

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

Основная функциональность контроля переполнения стека находится в классе trust::stack_check. Информация о размере стека хранится в статических полях класса, индивидуально для каждого потока (Thread Local - локальное хранилище потоков, TLS), что позволяет однократно запрашивать параметры стека для каждого потока при инициализации структуры, а при проверке размера свободного места на стеке с помощью метода stack_check::check_overflow(N) сравнивать текущий указатель стека с нижней границей выделенной под стек области памяти.

Для использования библиотеки в приложении необходимо определить статическую переменную const thread_local trust::stack_check trust::stack_check::info, а для указания минимального лимита свободного пространства на стеке присвоить соответствующее значение макросу STACK_SIZE_LIMIT.

Накладные расходы и влияние на скорость работы программы

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

В качестве "измерителя скорости" использовал программу нахождения простых чисел рекурсивным методом из файла prime_check.cpp (в качестве тестов также пробовал ханойские башни и рекурсивный алгоритм подсчёта суммы цифр у длинного числа, но глубина стека для проверки переполнения в первом случае требует очень большой продолжительности работы алгоритма, а запись длинного числа, при котором возникает переполнение стека, занимает несколько экранов, что тоже неудобно для целей тестирования).

$ ./prime-check-O3 Usage: ./prime-check-O3 <start_number> [count] $ ./prime-check-O3 9999999999999999 Stack overflow exception at: 104588 call depth. Stack top: 0x7fffdc634000 bottom: 0x7fffdbe36000 (stack size: 8380416) Query size: 10000 end frame: 0x7fffdbe386a0 (free space: 9888) $ ./prime-check-O3 10000000000 1000 10000000019 10000000033 10000000061 ... 10000022899 10000022909 Max recursion depth SAFE: 100000 Number of recursive calls SAFE: 117539009 Execution time SAFE: 18227634 microseconds Max recursion depth: 100000 Number of recursive calls: 117539009 Execution time: 17925080 microseconds Difference in execution time: 1.68788 %

Оценка влияния контроля переполнения стека на скорость работы приложения: без оптимизации (-O0) - время выполнения увеличивается примерно на 1%-5%, а при максимальной оптимизации (-O3) - примерно на 0,5-2% (общее время выполнения приложения около 20 секунд).

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

Причем тут ИИ ?

Честно признаюсь, что даже имея за плечами некоторый опыт разработки плагинов для clang, без использования LLM я бы такой проект не осилил за столь короткий срок (около месяца работы в фоне).

Как оказалось, функциональность AST плагинов clang не позволяет подключать плагины для LLVM напрямую без некоторого "шаманства", а внести изменения на этапе генерации объектного кода с помощью плагина оказалось и вообще не возможно без изменения исходников LLVM. Из-за этого пришлось отказаться от автоматического вычисления размера стека, так как патчить LLVM только для проверки концепции мне показалось чрезмерным.

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

Все попытки организовать работу по другому на полной кодовой базе закончились фиаско. Любые LLM начинали терять контекст, переключались на второстепенные, а часто и не существующие проблемы. После чего проекта и вовсе ломался, так как LLM решала проверить систему сборки, для чего делала "заглушку", т.е. удаляла существующий рабочий код, убеждалась, что сборка проходит нормально и все начиналось по кругу.

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

6fmzgnxwtrkj7uvxoniqsrzhuye.png

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

Я сейчас вообще перестал вручную писать скрипты, править конфигурационные файлы для VSCode или разбираться с ошибками в CMakeLists.txt. Конечно я не думаю, что LLM в скором времени и в самом деле смогут заменить программиста, но реально облегчить работу разработчиков они могут уже сейчас.

Источник

Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу service@support.mexc.com для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.

Вам также может быть интересно

BitMine увеличила объем стейкинга Ethereum до рекордных $3,7 млрд

BitMine увеличила объем стейкинга Ethereum до рекордных $3,7 млрд

«Казначейская» компания BitMine увеличила объем заблокированных активов в сети Ethereum до 1,08 млн монет. Стоимость портфеля превысила $3 млрд. Источник: A
Поделиться
Forklog2026/01/12 18:16
Глава ФРС Пауэлл резко раскритиковал расследование Минюста Трампа как политическое давление

Глава ФРС Пауэлл резко раскритиковал расследование Минюста Трампа как политическое давление

Глава Федеральной резервной системы Джером Пауэлл выступил в воскресенье с редким телевизионным заявлением, обвинив администрацию Трампа в использовании уголовных угроз для оказания давления на центральный
Поделиться
CryptoNews2026/01/12 17:49
Шерлок Холмс и квантовая запутанность: Ролевое моделирование с «неэкспертными» экспертами

Шерлок Холмс и квантовая запутанность: Ролевое моделирование с «неэкспертными» экспертами

Когда мне нужен свежий подход к какой-либо проблеме, я прибегаю к помощи нетиповых экспертов. Точнее, использую нейросети с назначенными ролями. Это довольно ра
Поделиться
ProBlockChain2026/01/12 14:17