Что такое переполнение стека
Как происходит «переполнение стека» и как его предотвратить?
Как происходит переполнение стека и каковы наилучшие способы убедиться, что этого не происходит, или способы предотвратить это, особенно на веб-серверах, но другие примеры также были бы интересны?
9 ответов
при вызове функции в коде следующая инструкция после вызова функции сохраняется на стек и любое пространство хранения, которое может быть перезаписано вызовом функции. Вызываемая функция может использовать больше стека для собственных локальных переменных. Когда это сделано, он освобождает пространство стека локальной переменной, которое он использовал, а затем возвращается к предыдущей функции.
переполнение стека
многие программисты делают эту ошибку, вызывая функцию A, которая затем вызывает функцию B, которая затем вызывает функцию C, которая затем вызывает функцию A. Это может работать большую часть времени, но только один раз неправильный ввод заставит его идти по этому кругу навсегда, пока компьютер не распознает, что стек раздут.
рекурсивные функции также являются причиной этого, но если вы пишете рекурсивно (т. е. ваша функция вызывает себя), вам нужно знать об этом и использовать статические/глобальные переменные для предотвращения бесконечной рекурсии.
Как правило, ОС и язык программирования, который вы используете, управляют стеком, и это не в ваших руках. Вы должны посмотреть на ваш звонок graph (древовидная структура, которая показывает из вашего main, что вызывает каждая функция), чтобы увидеть, как глубоко ваши вызовы функций идут, и обнаружить циклы и рекурсию, которые не предназначены. Преднамеренные циклы и рекурсия должны быть искусственно проверены на ошибку, если они вызывают друг друга слишком много раз.
помимо хороших методов программирования, статического и динамического тестирования, вы мало что можете сделать в этих системах высокого уровня.
встраиваемых систем
во встроенном мир, особенно в коде высокой надежности (автомобильный, самолет, космос) Вы делаете обширные обзоры кода и проверку, но вы также делаете следующее:
языки и системы высокого уровня
но в языках высокого уровня, работающих в операционных системах:
веб-сервера
переполнение стека в реальном коде происходит очень редко. Большинство ситуаций, в которых это происходит, являются рекурсиями, в которых завершение было забыто. Однако это может редко происходить в сильно вложенных структурах, например, особенно больших XML-документах. Единственная реальная помощь здесь-рефакторинг кода для использования явного объекта стека вместо стека вызовов.
некоторые опции в этом случае:
обычно переполнение стека является результатом бесконечного рекурсивного вызова (учитывая обычный объем памяти в настоящее время для обычных компьютеров).
при вызове метода, функции или процедуры «стандартный» способ или вызов состоит в следующем:
таким образом, обычно это занимает несколько байтов, уменьшающих количество и тип параметров, а также архитектуру машины.
вы увидите, что если вы начнете делать рекурсивные вызовы, стек начнет расти. Сейчас, стек обычно резервируется в памяти таким образом, что он растет в противоположном направлении к куче, поэтому при большом количестве вызовов без «возврата» стек начинает заполняться.
теперь, в старые времена переполнение стека может произойти просто потому, что вы exausted всю доступную память, просто так. С моделью виртуальной памяти (до 4 ГБ в системе X86), которая была вне области, поэтому обычно, если вы получаете ошибку переполнения стека, ищите бесконечный рекурсивный вызов.
переполнение стека происходит, когда Джефф и Джоэл хотите дать миру лучшее место, чтобы получить ответы на технические вопросы. Слишком поздно предотвращать переполнение стека. Этот «другой сайт» мог бы предотвратить это, не будучи scuzzy. 😉
Помимо формы переполнения стека, которую вы получаете от прямой рекурсии (например, Fibonacci(1000000) ), более тонкой формой этого, которую я испытывал много раз, является косвенная рекурсия, где функция вызывает другую функцию, которая вызывает другую, а затем одна из этих функций снова вызывает первую.
это обычно может происходить в функциях, которые вызываются в ответ на события, но которые сами могут генерировать новые события, например:
в этом случае вызов ResizeWindow может привести к WindowSizeChanged() обратный вызов должен быть запущен снова, который вызывает ResizeWindow снова, пока у вас не закончится стек. В таких ситуациях вам часто нужно отложить ответ на событие до тех пор, пока не вернется кадр стека, например, отправив сообщение.
что? Никто не любит тех, кого окружает бесконечная петля?
Что такое стек
И почему так страшен стек-оверфлоу.
Постепенно осваиваем способы организации и хранения данных. Уже было про деревья, попробуем про стеки. Это для тех, кто хочет в будущем серьёзно работать в ИТ: одна из фундаментальных концепций, которая влияет на качество вашего кода, но не касается какого-то конкретного языка программирования.
👉 Стек — это одна из структур данных. Структура данных — это то, как хранятся данные: например, связанные списки, деревья, очереди, множества, хеш-таблицы, карты и даже кучи (heap).
Как устроен стек
Стек хранит последовательность данных. Связаны данные так: каждый элемент указывает на тот, который нужно использовать следующим. Это линейная связь — данные идут друг за другом и нужно брать их по очереди. Из середины стека брать нельзя.
👉 Главный принцип работы стека — данные, которые попали в стек недавно, используются первыми. Чем раньше попал — тем позже используется. После использования элемент стека исчезает, и верхним становится следующий элемент.
Классический способ объяснения принципов стека звучит так: представьте, что вы моете посуду и складываете одинаковые чистые тарелки стопкой друг на друга. Каждая новая тарелка — это элемент стека, а вы просто добавляете их по одной в стек.
Когда кому-то понадобится тарелка, он не будет брать её снизу или из середины — он возьмёт первую сверху, потом следующую и так далее.
🤔 Есть структура данных, похожая на стек, — называется очередь, или queue. Если в стеке кто последний пришёл, того первым заберут, то в очереди наоборот: кто раньше пришёл, тот раньше ушёл. Можно представить очередь в магазине: кто раньше её занял, тот первый дошёл до кассы. Очередь — это тоже линейный набор данных, но обрабатывается по-другому.
Стек вызовов
В программировании есть два вида стека — стек вызовов и стек данных.
Когда в программе есть подпрограммы — процедуры и функции, — то компьютеру нужно помнить, где он прервался в основном коде, чтобы выполнить подпрограмму. После выполнения он должен вернуться обратно и продолжить выполнять основной код. При этом если подпрограмма возвращает какие-то данные, то их тоже нужно запомнить и передать в основной код.
Чтобы это реализовать, компьютер использует стек вызовов — специальную область памяти, где хранит данные о точках перехода между фрагментами кода.
Допустим, у нас есть программа, внутри которой есть три функции, причём одна из них внутри вызывает другую. Нарисуем, чтобы было понятнее:
Программа запускается, потом идёт вызов синей функции. Она выполняется, и программа продолжает с того места, где остановилась. Потом выполняется зелёная функция, которая вызывает красную. Пока красная не закончит работу, все остальные ждут. Как только красная закончилась — продолжается зелёная, а после её окончания программа продолжает свою работу с того же места.
А вот как стек помогает это реализовать на практике:
Программа дошла до синей функции, сохранила точку, куда ей вернуться после того, как закончится функция, и если функция вернёт какие-то данные, то программа тоже их получит. Когда синяя функция закончится и программа получит верхний элемент стека, он автоматически исчезнет. Стек снова пустой.
С зелёной функцией всё то же самое — в стек заносится точка возврата, и программа начинает выполнять зелёную функцию. Но внутри неё мы вызываем красную, и вот что происходит:
При вызове красной функции в стек помещается новый элемент с информацией о данных, точке возврата и указанием на следующий элемент. Это значит, что когда красная функция закончит работу, то компьютер возьмёт из стека адрес возврата и вернёт управление снова зелёной функции, а красный элемент исчезнет. Когда и зелёная закончит работу, то компьютер из стека возьмёт новый адрес возврата и продолжит работу со старого места.
Переполнение стека
Почти всегда стек вызовов хранится в оперативной памяти и имеет определённый размер. Если у вас будет много вложенных вызовов или рекурсия с очень большой глубиной вложенности, то может случиться такая ситуация:
Переполнение — это плохо: данные могут залезать в чужую область памяти и записывать себя вместо прежних данных. Это может привести к сбою в работе других программ или самого компьютера. Ещё таким образом можно внедрить в оперативную память вредоносный код: если программа плохо работает со стеком, можно специально вызвать переполнение и записать в память что-нибудь вредоносное.
Стек данных
Стек данных очень похож на стек вызовов: по сути, это одна большая переменная, похожая на список или массив. Его чаще всего используют для работы с другими сложными типами данных: например, быстрого обхода деревьев, поиска всех возможных маршрутов по графу, — и для анализа разветвлённых однотипных данных.
Стек данных работает по такому же принципу, как и стек вызовов — элемент, который добавили последним, должен использоваться первым.
Что дальше
А дальше поговорим про тип данных под названием «куча». Да, такой есть, и с ним тоже можно эффективно работать. Стей тюнед.
Как защититься от переполнения стека (на Cortex M)?
Если вы программируете на «большом» компьютере, то у вас такой вопрос, скорее всего, вообще не возникает. Стека много, чтобы его переполнить, нужно постараться. В худшем случае вы нажмёте ОК на окошке вроде этого и пойдете разбираться, в чем дело.
Но вот если вы программируете микроконтроллеры, то проблема выглядит немного иначе. Для начала нужно заметить, что стек переполняется.
В этой статье я расскажу о собственных изысканиях на эту тему. Поскольку я программирую в основном под STM32 и под Миландр 1986 — на них я и фокусировался.
Введение
Представим самый простой случай — мы пишем простой однопоточный код без всяких операционных систем, т.е. стек у нас всего один. И если вы, как и я, программируете в uVision Keil, то память распределяется как-то так:
А если вы, как и я, считаете динамическую память на микроконтроллерах злом, то вот так:
Если вы хотите запретить использование кучи, то можно сделать вот так:
Окей, и в чем проблема? Проблема в том, что Keil размещает стек сразу за областью статических данных. А стек в Cortex-M растет в сторону уменьшения адресов. И когда он переполняется, то он просто вылезает за пределы отведенного ему куска памяти. И перезаписывает какие-нибудь статические или глобальные переменные.
Особенно здорово, если стек переполняется только при заходе в прерывание. Или, еще лучше, во вложенное прерывание! И тихо портит какую-нибудь переменную, которая используется совершенно в другом участке кода. И программа падает на ассерте. Если вам повезет. Сферический гейзенбаг, такой можно целую неделю с фонарём искать.
Сразу оговорюсь, что если вы используете кучу, то проблема никуда не уходит, просто вместо глобальных переменных портится куча. Не сильно лучше.
Окей, проблема понятна. Что делать?
Самое простое и очевидное — использовать MPU (сиречь, Memory Protection Unit). Позволяет назначать разным кускам памяти разные атрибуты; в частности можно окружить стек регионами «только для чтения» и ловить MemFault при записи туда.
Например, в stm32f407 MPU есть. К сожалению, во многих других «младших» stm его нет. И в Миландровском 1986ВЕ1 его тоже нет.
Т.е. решение хорошее, но не всегда доступное.
Ручной контроль
Причем в самом начале отчета написан путь, приводящий к максимальному использованию стека:
Проблема в том, что если в вашем коде есть вызовы функций по указателям или виртуальные методы (а у меня они есть), то этот отчет может сильно недооценивать максимальную глубину стека. Ну и прерывания, разумеется, не учитываются. Не очень надежный способ.
Хитрое размещение стека
Об этом способе я узнал из вот этой статьи. Статья про rust, но основная идея состоит в следующем:
При использовании gcc это возможно сделать с помощью «двойной линковки».
А в Keil’е расположение областей можно изменить с помощью своего скрипта для линкера (scatter file в терминологии Keil’a). Для этого нужно открыть опции проекта и снять галку «Use memory layout from target dialog». Тогда в поле «Scatter file» появится файл по-умолчанию. Он выглядит примерно так:
Что делать дальше? Возможны варианты. Официальная документация предлагает определить секции с зарезервированными именами — ARM_LIB_HEAP и ARM_LIB_STACK. Но это влечет за собой неприятные последствия, по крайней мере, для меня — размеры стека и кучи придется задавать в scatter-файле.
Во всех проектах, которые я использую, размер стека и кучи задается в ассемблерном startup-файле (который Keil генерирует при создании проекта). Менять его не очень хочется. Хочется, чтобы я просто включил в проект новый scatter-файл, и все стало хорошо. Поэтому я пошел немного другим путем:
Тут я сказал, что все объекты по имени STACK должны размещаться в регионе REGION_STACK, а все объекты HEAP — в регионе REGION_HEAP. А все остальное — в регионе RW_IRAM1. И расположил регионы в таком порядке — начало оперативы, стек, куча, все остальное. Расчет на то, что в ассемблерном startup-файле стек и куча задаются с помощью вот такого кода (т.е. как массивы с названиями STACK и HEAP):
Окей, возможно спросите вы, но что это нам дает? А вот что. Теперь при выходе за пределы стека процессор пытается записать (или прочитать) память, которой нет. И на STM32 при этом возникает прерывание по исключительной ситуации — HardFault.
Это не так удобно, как MemFault из-за MPU, потому что HardFault может возникать из-за множества причин, но, по крайней мере, ошибка получается громкой, а не тихой. Т.е. она возникает сразу, а не через неизвестный промежуток времени, как было раньше.
Что самое классное, мы ничем за это не заплатили, никакого оверхеда времени выполнения! Здорово. Но есть одна проблема.
Это не работает на Миландре.
Да. Конечно, на Миландрах (меня интересуют в основном 1986ВЕ1 и ВЕ91) карта памяти выглядит иначе. В STM32 до начала оперативы нет ничего, а на Миландрах до оперативы лежит область внешней шины.
Но даже если вы не используете внешнюю шину, то никакого HardFault’a вы не получите. А может и получите. А может быть, получите, но не сразу. Я не смог найти никакой информации на этот счет (что для Миландра неудивительно), а эксперименты не дали никаких внятных результатов. HardFault иногда возникал, если размер стека был кратен 256. Иногда HardFault возникал, если стек углублялся уж очень далеко в несуществующую память.
Но это даже неважно. Если HardFault не возникает каждый раз, то простое перемещение стека в начало RAM нас уже не спасает. И если уж совсем честно, STM тоже не обязан генерировать исключение при этом, спецификация ядер Cortex-M ничего конкретного на этот счет вроде бы не говорит.
Так что даже на STM это скорее хак, просто не очень грязный.
Значит, нужно искать какой-то другой способ.
Access breakpoint на запись
Но это не очень надежный способ. Этот брейкпоинт будет срабатывать каждый раз при инициализации стека. Его легко случайно прибить, нажав «Kill all breakpoints». А еще он будет вас защищать только в присутствии отладчика. Не годится.
Динамическая защита от переполнений
Быстрый поиск на этот счет вывел меня к опциям Keil’a «—protect_stack» и «—protect_stack_all». Опции полезные, к сожалению, защищают они не от переполнения всего стека, а от залезания в стековый кадр другой функции. Например, если ваш код выходит за границы массива или неудачно работает с переменным числом параметров. Gcc, разумеется, тоже так умеет (-fstack-protector).
Суть этой опции в следующем: в каждый стековый кадр добавляется «guard variable», то бишь, сторожевое число. Если после выхода из функции это число изменилось, то вызывается функция-обработчик ошибки. Подробности тут.
Полезная штука, но не совсем то, что мне нужно. Мне нужна гораздо более простая проверка — чтобы при входе в каждую функцию значение регистра SP (Stack Pointer) сверялось с заранее известным минимальным значением. Но не писать же эту проверку руками на входе в каждую функцию?
Динамический контроль SP
К счастью, gcc имеет чудесную опцию «-finstrument-functions», которая позволяет вызывать пользовательскую функцию при входе в каждую функцию и при выходе из каждой функции. Обычно это используется для вывода отладочной информации, но какая разница?
К еще большему счастью, Keil вполне сознательно копирует функционал gcc, и там эта же опция доступна под названием «—gnu_instrument» (подробности).
После этого нужно всего лишь написать вот такой код:
И вуаля! Теперь при входе в каждую функцию (в том числе в обработчики прерываний) будет выполняться проверка на переполнение стека. И если стек переполнился — будет ассерт.
Идеально ли полученное решение? Конечно, нет.
Во-первых, эта проверка далеко не бесплатна, код от нее распухает процентов на 10. Ну и работать код будет медленнее (хотя я не измерял). Критично это или нет — решать вам; на мой взгляд, это разумная цена за безопасность.
Во-вторых, это, скорее всего, не будет работать при использовании прекомпилированных библиотек (но т.к. я их не использую вообще, я не проверял).
Зато это решение потенциально пригодно и для многопоточных программ, поскольку проверку мы делаем полностью сами. Но эту мысль я еще не додумал толком, поэтому пока придержу.
Подведем итоги
Получилось найти работающие решения для stm32 и для Миландра, хотя для последнего пришлось заплатить некоторым оверхедом.
Для меня же самым главным была небольшая смена парадигмы мышления. До вышеупомянутой статьи я вообще не задумывался, что от переполнения стека можно как-то защититься. Я не воспринимал это как проблему, которую нужно решить, а скорее как некое стихийное явление — иногда идет дождь, а иногда переполняется стек, ну, тут ничего не поделаешь, надо стиснуть зубы и потерпеть.
И я вообще достаточно часто замечаю за собой (да и за другими людьми) такое — вместо того, чтобы потратить 5 минут в гугле и найти тривиальное решение — живу со своими проблемами годами.
На этом у меня все. Я понимаю, что ничего кардинально нового я не открыл, но готовых статей с таким решением мне не попадалось (по крайней мере, сам Джозеф Ю в статье на эту тему прямым текстом это не предлагает). Надеюсь, в комментариях мне подскажут, прав я или нет, и каковы подводные камни такого подхода.
UPD: Если при добавлении scatter-файла Keil начинает выдавать непонятный warning аля «AppData\Local\Temp\p17af8-2(33): warning: #1-D: last line of file ends without a newline» — но сам этот файл не открывается, ибо он временный, — то просто добавьте перенос строки последним символом в scatter-файл.
Урок №105. Стек и Куча
На этом уроке мы рассмотрим стек и кучу в языке C++.
Сегменты
Память, которую используют программы, состоит из нескольких частей — сегментов:
Сегмент кода (или «текстовый сегмент»), где находится скомпилированная программа. Обычно доступен только для чтения.
Сегмент bss (или «неинициализированный сегмент данных»), где хранятся глобальные и статические переменные, инициализированные нулем.
Сегмент данных (или «сегмент инициализированных данных»), где хранятся инициализированные глобальные и статические переменные.
Куча, откуда выделяются динамические переменные.
Стек вызовов, где хранятся параметры функции, локальные переменные и другая информация, связанная с функциями.
Сегмент кучи (или просто «куча») отслеживает память, используемую для динамического выделения. Мы уже немного поговорили о куче на уроке о динамическом выделении памяти в языке С++.
В языке C++ при использовании оператора new динамическая память выделяется из сегмента кучи самой программы:
Адрес выделяемой памяти передается обратно оператором new и затем он может быть сохранен в указателе. О механизме хранения и выделения свободной памяти нам сейчас беспокоиться незачем. Однако стоит знать, что последовательные запросы памяти не всегда приводят к выделению последовательных адресов памяти!
При удалении динамически выделенной переменной, память возвращается обратно в кучу и затем может быть переназначена (исходя из последующих запросов). Помните, что удаление указателя не удаляет переменную, а просто приводит к возврату памяти по этому адресу обратно в операционную систему.
Куча имеет свои преимущества и недостатки:
Выделение памяти в куче сравнительно медленное.
Выделенная память остается выделенной до тех пор, пока не будет освобождена (остерегайтесь утечек памяти) или пока программа не завершит свое выполнение.
Доступ к динамически выделенной памяти осуществляется только через указатель. Разыменование указателя происходит медленнее, чем доступ к переменной напрямую.
Поскольку куча представляет собой большой резервуар памяти, то именно она используется для выделения больших массивов, структур или классов.
Стек вызовов
Стек вызовов (или просто «стек») отслеживает все активные функции (те, которые были вызваны, но еще не завершены) от начала программы и до текущей точки выполнения, и обрабатывает выделение всех параметров функции и локальных переменных.
Стек вызовов реализуется как структура данных «Стек». Поэтому, прежде чем мы поговорим о том, как работает стек вызовов, нам нужно понять, что такое стек как структура данных.
Стек как структура данных
Структура данных в программировании — это механизм организации данных для их эффективного использования. Вы уже видели несколько типов структур данных, например, массивы или структуры. Существует множество других структур данных, которые используются в программировании. Некоторые из них реализованы в Стандартной библиотеке C++, и стек как раз является одним из таковых.
Например, рассмотрим стопку (аналогия стеку) тарелок на столе. Поскольку каждая тарелка тяжелая, а они еще и сложены друг на друге, то вы можете сделать лишь что-то одно из следующего:
Посмотреть на поверхность первой тарелки (которая находится на самом верху).
Взять верхнюю тарелку из стопки (обнажая таким образом следующую тарелку, которая находится под верхней, если она вообще существует).
Положить новую тарелку поверх стопки (спрятав под ней самую верхнюю тарелку, если она вообще была).
В компьютерном программировании стек представляет собой контейнер (как структуру данных), который содержит несколько переменных (подобно массиву). Однако, в то время как массив позволяет получить доступ и изменять элементы в любом порядке (так называемый «произвольный доступ»), стек более ограничен.
В стеке вы можете:
Посмотреть на верхний элемент стека (используя функцию top() или peek() ).
Вытянуть верхний элемент стека (используя функцию pop() ).
Добавить новый элемент поверх стека (используя функцию push() ).
Стек — это структура данных типа LIFO (англ. «Last In, First Out» = «Последним пришел, первым ушел»). Последний элемент, который находится на вершине стека, первым и уйдет из него. Если положить новую тарелку поверх других тарелок, то именно эту тарелку вы первой и возьмете. По мере того, как элементы помещаются в стек — стек растет, по мере того, как элементы удаляются из стека — стек уменьшается.
Например, рассмотрим короткую последовательность, показывающую, как работает добавление и удаление в стеке:
Stack: empty
Push 1
Stack: 1
Push 2
Stack: 1 2
Push 3
Stack: 1 2 3
Push 4
Stack: 1 2 3 4
Pop
Stack: 1 2 3
Pop
Stack: 1 2
Pop
Stack: 1
Стопка тарелок довольно-таки хорошая аналогия работы стека, но есть лучшая аналогия. Например, рассмотрим несколько почтовых ящиков, которые расположены друг на друге. Каждый почтовый ящик может содержать только один элемент, и все почтовые ящики изначально пустые. Кроме того, каждый почтовый ящик прибивается гвоздем к почтовому ящику снизу, поэтому количество почтовых ящиков не может быть изменено. Если мы не можем изменить количество почтовых ящиков, то как мы получим поведение, подобное стеку?
Во-первых, мы используем наклейку для обозначения того, где находится самый нижний пустой почтовый ящик. Вначале это будет первый почтовый ящик, который находится на полу. Когда мы добавим элемент в наш стек почтовых ящиков, то мы поместим этот элемент в почтовый ящик, на котором будет наклейка (т.е. в самый первый пустой почтовый ящик на полу), а затем переместим наклейку на один почтовый ящик выше. Когда мы вытаскиваем элемент из стека, то мы перемещаем наклейку на один почтовый ящик ниже и удаляем элемент из почтового ящика. Всё, что находится ниже наклейки — находится в стеке. Всё, что находится в ящике с наклейкой и выше — находится вне стека.
Сегмент стека вызовов
Сегмент стека вызовов содержит память, используемую для стека вызовов. При запуске программы, функция main() помещается в стек вызовов операционной системой. Затем программа начинает свое выполнение.
Когда программа встречает вызов функции, то эта функция помещается в стек вызовов. При завершении выполнения функции, она удаляется из стека вызовов. Таким образом, просматривая функции, добавленные в стек, мы можем видеть все функции, которые были вызваны до текущей точки выполнения.
Наша аналогия с почтовыми ящиками — это действительно то, как работает стек вызовов. Стек вызовов имеет фиксированное количество адресов памяти (фиксированный размер). Почтовые ящики являются адресами памяти, а «элементы», которые мы добавляем или вытягиваем из стека, называются фреймами (или «кадрами») стека. Кадр стека отслеживает все данные, связанные с одним вызовом функции. «Наклейка» — это регистр (небольшая часть памяти в ЦП), который является указателем стека. Указатель стека отслеживает вершину стека вызовов.
Единственное отличие фактического стека вызовов от нашего гипотетического стека почтовых ящиков заключается в том, что, когда мы вытягиваем элемент из стека вызовов, нам не нужно очищать память (т.е. вынимать всё содержимое из почтового ящика). Мы можем просто оставить эту память для следующего элемента, который и перезапишет её. Поскольку указатель стека будет ниже этого адреса памяти, то, как мы уже знаем, эта ячейка памяти не будет находиться в стеке.
Стек вызовов на практике
Давайте рассмотрим детально, как работает стек вызовов. Ниже приведена последовательность шагов, выполняемых при вызове функции:
Программа сталкивается с вызовом функции.
Создается фрейм стека, который помещается в стек. Он состоит из:
адреса инструкции, который находится за вызовом функции (так называемый «обратный адрес»). Так процессор запоминает, куда ему возвращаться после выполнения функции;
памяти для локальных переменных;
сохраненных копий всех регистров, модифицированных функцией, которые необходимо будет восстановить после того, как функция завершит свое выполнение.
Процессор переходит к точке начала выполнения функции.
Инструкции внутри функции начинают выполняться.
После завершения функции, выполняются следующие шаги:
Регистры восстанавливаются из стека вызовов.
Фрейм стека вытягивается из стека. Освобождается память, которая была выделена для всех локальных переменных и аргументов.
Обрабатывается возвращаемое значение.
ЦП возобновляет выполнение кода (исходя из обратного адреса).
Возвращаемые значения могут обрабатываться разными способами, в зависимости от архитектуры компьютера. Некоторые архитектуры считают возвращаемое значение частью фрейма стека, другие используют регистры процессора.
Знать все детали работы стека вызовов не так уж и важно. Однако понимание того, что функции при вызове добавляются в стек, а при завершении выполнения — удаляются из стека, дает основы, необходимые для понимания рекурсии, а также некоторых других концепций, которые полезны при отладке программ.