Вебинар: Использование статических анализаторов кода при разработке безопасного ПО - 19.12
Переполнение стека — программная ошибка времени выполнения, при которой программа захватывает всю память, выделенную ей под стек, что обычно приводит к аварийному завершению её работы.
Стек программы — это специальная области памяти, организованная по принципу очереди LIFO (Last in, first out - последним пришел, первым ушел). Название "стек" произошло из-за аналогии принципа его построения со стопкой (англ. stack) тарелок - можно класть тарелки друг на друга (метод добавления в стек, "заталкивание", "push"), а затем забирать их, начиная с верхней (метод получения значения из стека, "выталкивание", "pop"). Стек программы также называют стек вызовов, стек выполнения, машинным стеком (чтобы не путать его со "стеком" - абстрактной структурой данных).
Для чего нужен стек? Он позволяет удобно организовать вызов подпрограмм. При вызове функция получает некоторые аргументы; также она должна где-то хранить свои локальные переменные. Кроме того, надо учесть, что одна функция может вызвать другую функцию, которой тоже надо передавать параметры и хранить свои переменные. Используя стек, при передаче параметров нужно просто положить их в стек, тогда вызываемая функция сможет их оттуда "вытолкнуть" и использовать. Локальные переменные тоже можно хранить там же - в начале своего кода функция выделяет часть памяти стека, при возврате управления - очищает и освобождает. Программисты на высокоуровневых языках обычно не задумываются о таких вещах - весь необходимый рутинный код за них генерирует компилятор.
Теперь мы подошли почти вплотную к проблеме. В абстрактном виде стек представляет собой бесконечное хранилище, в которое можно бесконечно добавлять новые элементы. К сожалению, в нашем мире все конечно - и память под стек не исключение. Что будет, если она закончится, когда в стек заталкиваются аргументы функции? Или функция выделяет память под свои переменные?
Произойдет ошибка, называемая переполнением стека. Поскольку стек необходим для организации вызова пользовательских функций (а практически все программы на современных языках, в том числе объектно-ориентированных, так или иначе строятся на основе функций), больше они вызываться не смогут. Поэтому операционная система забирает управление, очищает стек и завершает программу. Здесь можно подчеркнуть различие между переполнением буфера и переполнением стека - в первом случае ошибка происходит при обращении к неверной области памяти, и если защита на этом этапе отсутствует, в этот момент не проявляет себя - при удачном стечении обстоятельств программа может отработать нормально. Если только память, к которой шло обращение, была защищена, происходит ошибка сегментации. В случае со стеком программа непременно завершается.
Чтобы быть совсем точным, следует отметить, что подобное описание событий верно лишь для компиляторов, компилирующих в "родной" (native) код. В управляемых языках у виртуальной машины есть свой стек для управляемых программ, за состоянием которого гораздо проще следить, и можно даже позволить себе при возникновении переполнения передать программе исключение. В языках Си и Си++ на подобную "роскошь" рассчитывать не приходится.
Что же может привести к такой неприятной ситуации? Исходя из описанного выше механизма, один из вариантов - слишком большое число вложенных вызовов функций. Особенно вероятен такой вариант развития событий при использовании рекурсии. Бесконечная рекурсия (при отсутствии механизма "ленивых" вычислений) прерывается именно таким образом, в отличие от бесконечного цикла, который иногда имеет полезное применение. Впрочем, при небольшом объеме памяти, отведенной под стек (что, например, характерно для микроконтроллеров), достаточно может быть и простой последовательности вызовов.
Другой вариант - локальные переменные, требующие большого количества памяти. Заводить локальный массив из миллиона элементов, или миллион локальных переменных (мало ли что бывает) - не самая лучшая идея. Даже один вызов такой "жадной" функции легко может вызвать переполнение стека. Для получения больших объемов данных лучше воспользоваться механизмами динамической памяти, которая позволит обработать ошибку её нехватки.
Однако динамическая память является довольно медленной в плане выделения и освобождения (поскольку этим занимается операционная система), кроме того, при прямом доступе приходится вручную выделять её и освобождать. Память же в стеке выделяется очень быстро (по сути, надо лишь изменить значение одного регистра), кроме того, у объектов, выделенных в стеке, автоматически вызываются деструкторы при возврате управления функцией и очистке стека. Разумеется, тут же возникает желание получить память из стека. Поэтому третий путь к переполнению - самостоятельное выделение в стеке памяти программистом. Специально для этой цели библиотека языка Си предоставляет функцию alloca. Интересно заметить, что если у функции для выделения динамической памяти malloc есть свой "близнец" для её освобождения free, то у функции alloca его нет - память освобождается автоматически после возврата управления функцией. Возможно, это только осложняет ситуацию - ведь до выхода из функции освободить память не получится. Даже несмотря на то, что согласно man-странице "функция alloca зависит от машины и компилятора; во многих системах ее реализация проблематична и содержит много ошибок; ее использование очень несерьезно и не одобряется" - она все равно используется.
В качестве примера рассмотрим код для рекурсивного поиска файлов, расположенный на MSDN:
void DirSearch(String* sDir)
{
try
{
// Find the subfolders in the folder that is passed in.
String* d[] = Directory::GetDirectories(sDir);
int numDirs = d->get_Length();
for (int i=0; i < numDirs; i++)
{
// Find all the files in the subfolder.
String* f[] = Directory::GetFiles(d[i],textBox1->Text);
int numFiles = f->get_Length();
for (int j=0; j < numFiles; j++)
{
listBox1->Items->Add(f[j]);
}
DirSearch(d[i]);
}
}
catch (System::Exception* e)
{
MessageBox::Show(e->Message);
}
}
Эта функция получает список файлов указанной директории, а затем вызывает себя же для тех элементов списка, которые оказались директориями. Соответственно, при достаточно глубоком дереве файловой системы, мы получим закономерный результат.
Пример второго подхода, взятый из вопроса "Почему происходит переполнение стека?" с сайта под названием Stack Overflow (сайт является сборником вопросов и ответов на любые программистские темы, а не только по переполнению стека, как может показаться):
#define W 1000
#define H 1000
#define MAX 100000
//...
int main()
{
int image[W*H];
float dtr[W*H];
initImg(image,dtr);
return 0;
}
Как видно, в функции main выделяется память в стеке под массивы типов int и float по миллиону элементов каждый, что в сумме дает чуть менее 8 мегабайт. Если учесть, что по умолчанию Visual C++ резервирует под стек лишь 1 мегабайт, то ответ становится очевидным.
А вот пример, взятый из GitHub-репозитория проекта Flash-плеера Lightspark:
DefineSoundTag::DefineSoundTag(/* ... */)
{
// ...
unsigned int soundDataLength = h.getLength()-7;
unsigned char *tmp = (unsigned char *)alloca(soundDataLength);
// ...
}
Можно надеятся, что h.getLength()-7 не будет слишком большим числом, чтобы на следующей строчке не произошло переполнения. Но стоит ли сэкономленное на выделении памяти время "потенциального" вылета программы?
Переполнение стека - фатальная ошибка, которой наиболее часто страдают программы, содержащие рекурсивные функции. Однако даже если программа не содержит таких функций, переполнение все равно возможно из-за большого объема локальных переменных или ошибки в ручном выделении памяти в стеке. Все классические правила остаются в силе: если есть возможность выбора, вместо рекурсии лучше предпочесть итерацию, а также не заниматься ручной работой вместо компилятора.
0