Рубрика:
БИТ. Бизнес & Информационные технологии /
Хранение данных
|
Facebook
Мой мир
Вконтакте
Одноклассники
Google+
|
Владимир Мешков
Что мы знаем о стеке?
Предпримем попытку систематизировать собственные знания о стеке и максимально подробно рассмотрим, какую роль играет стек в процедурном программировании.
Стек – это область памяти, предназначенная для временного хранения данных. Стек адресуется при помощи пары регистров SS:ESP. Регистр SS (Stack Segment) содержит селектор сегмента стека при работе процессора в защищенном режиме, ESP (Stack Pointer, указатель стека) – смещение относительно базового адреса сегмента стека. Упрощенная схема адресации данных в стеке, без привязки к какой-либо конкретной операционной системе, показана на рис. 1. Схема адресации сегментов команд и данных идентична изображенной на рисунке схеме адресации сегмента стека. При подготовке статьи использовались ОС Linux, компилятор gcc-4.1.2, отладчик gdb-6.6.
![Рисунок 1. Адресация стека Рисунок 1. Адресация стека](../../../../uploads/articles/2007/11/80_84_Stack/image001.gif)
Рисунок 1. Адресация стека
Для помещения данных в стек и извлечения данных из него используются инструкции процессора push и pop. Стек работает по принципу FILO (First Input – Last Output, первым вошел – последним вышел). Каждый вызов инструкции push/pop изменяет значение регистра ESP. Инструкция push размещает данные по адресу, на который указывает ESP, и сдвигает верхушку стека в сторону младших адресов, уменьшая значение ESP. Инструкция pop поступает наоборот – снимает данные с верхушки стека и увеличивает значение ESP.
Стек играет важнейшую роль в процедурном программировании. Процедурное программирование подразумевает использование функций. Функции позволяют выделить часто используемые фрагменты кода в отдельные блоки и повторно использовать их из любой точки программы, тем самым повышая эффективность разработки. При использовании функций линейный (последовательный) ход выполнения программы нарушается – в любой момент мы можем передать управление на функцию. Для этого используется инструкция процессора call. Адрес функции передается как параметр этой команды:
call < адрес функции >
Передача параметров функции осуществляется в зависимости от используемого соглашения. В случае соглашения fastcall максимум параметров передается через регистры, что значительно ускоряет процесс вызова функции. В контексте данной статьи рассматривается соглашение stdcall/cdecl, при котором для передачи параметров используется стек. Перед вызовом функции параметры помещаются в стек при помощи инструкции push. В виде псевдокода вызов функции с двумя параметрами с использованием соглашения языка Си выглядит так (адресация показана условно, без учета размеров инструкций):
Условный адрес Инструкция процессора
.... ....
< N-2 > push < параметр 2 >
< N-1 > push < параметр 1 >
< N > call < адрес функции >
< N+1 > < сюда будет возвращено управление >
В языке Си первый параметр загружается в стек последним, последний параметр – первым. Получив управление, функция считывает из стека параметры вызова, и выполняет свою работу. Парметры остаются в стеке, благодаря чему их можно использовать как переменные в ходе выполнения функции. Завершается функция инструкцией ret (эту инструкцию мы обсудим ниже). После этого управление будет передано на команду, которая следует за инструкцией call. Схематически это отображено на рис. 2.
![Рисунок 2. Вызов функции и передача ей параметров Рисунок 2. Вызов функции и передача ей параметров](../../../../uploads/articles/2007/11/80_84_Stack/image002.gif)
Рисунок 2. Вызов функции и передача ей параметров
Чтобы такая схема сработала, необходимо запомнить адрес возврата из функции, т.е. адрес следующей за call команды. Этот адрес сохраняет в стеке команда call. Если привязать все эти команды к изменениям стека, то получим картину как на рис. 3. Видно, как изменяют стек команды push и call. Сразу после вызова команды call в стеке будет сохранен адрес возврата – адрес следующей за call инструкции, и ESP будет указывать на этот адрес, т.е. *ESP == адрес возврата.
![Рисунок 3. Изменения стека при вызове функции Рисунок 3. Изменения стека при вызове функции](../../../../uploads/articles/2007/11/80_84_Stack/image003.gif)
Рисунок 3. Изменения стека при вызове функции
Чтобы узнать, что происходит со стеком дальше, напишем небольшой тестовый пример:
Листинг 1. Файл test.c
#include <stdio.h>
#include <stdlib.h>
int foo(int a, int b)
{
return a+b;
}
int main()
{
int c, a, b;
c = 0, a = 1, b = 2;
c = foo(a, b);
return c;
}
Посмотрим на дизассемблерный листинг функции foo:
bash-3.1# gcc -Wall -g -o test test.c
bash-3.1# gdb -q ./test
Using host libthread_db library "/lib/tls/libthread_db.so.1".
(gdb) disas foo
Dump of assembler code for function foo:
0x08048384 <foo+0>: push %ebp
0x08048385 <foo+1>: mov %esp,%ebp
0x08048387 <foo+3>: mov 0xc(%ebp),%eax
0x0804838a <foo+6>: add 0x8(%ebp),%eax
0x0804838d <foo+9>: pop %ebp
0x0804838e <foo+10>: ret
End of assembler dump.
|
Две первые инструкции образуют пролог функции. Первая инструкция сохраняет в стеке содержимое регистра EBP, вторая присваивает регистру EBP значение ESP. Чтобы понять смысл этих манипуляций, изобразим (см. рис. 4) все изменения стека после двух этих инструкций функции.
![Рисунок 4. Состояние стека после пролога функции Рисунок 4. Состояние стека после пролога функции](../../../../uploads/articles/2007/11/80_84_Stack/image004.gif)
Рисунок 4. Состояние стека после пролога функции
Что же мы все таки получили? А вот что – мы зафиксировали верхушку стека в регистре EBP и получили возможность перемещаться по стеку относительно этой точки. Другими словами, мы создали кадр стека, или стековый фрейм (stack frame). Смотрим на рис. 5.
![Рисунок 5. Кадр стека Рисунок 5. Кадр стека](../../../../uploads/articles/2007/11/80_84_Stack/image005.gif)
Рисунок 5. Кадр стека
При помощи EBP мы можем адресовать параметры функции и ее локальные данные (про область локальных данных я расскажу позже). Запустим в отладчике нашу тестовую программу, установив точку останова на функцию foo:
(gdb) b foo
Breakpoint 1 at 0x8048387: file test.c, line 6.
(gdb) r
Starting program: test
Breakpoint 1, foo (a=1, b=2) at test.c:6
6 return a+b;
|
Смотрим, какие у нас есть кадры стека:
(gdb) fr
#0 foo (a=1, b=2) at test.c:6
6 return a+b;
|
Текущий кадр принадлежит функции foo. Получим значение регистра EBP:
(gdb) info reg ebp
ebp 0xbf92f588 0xbf92f588
|
Согласно рис. 5, по смещению EBP+0x4 будет находиться адрес возврата из функции foo, по смещению EBP+0x8 – первый параметр функции, EBP+0xC – второй параметр функции. Проверим это:
(gdb) x/w 0xbf92f588+4
0xbf92f58c: 0x080483c7 <--- адрес возврата из foo
(gdb) x/w 0xbf92f588+8
0xbf92f590: 0x00000001 <--- первый параметр (a=1)
(gdb) x/w 0xbf92f588+0xC
0xbf92f594: 0x00000002 <--- второй параметр (b=2)
|
Адресация [EBP+0x8] в синтаксисе AT&T выглядит как 0x8(%ebp). Подробнее о различиях синтаксиса ассемблеров Intel и AT&T можно прочитать в статье http://www.ibm.com/developerworks/linux/library/l-gas-nasm.html.
С параметрами все правильно, если посмотреть на листинг 1 (a=1, b=2). Чтобы убедиться в правильности адреса возврата, дизассемблируем функцию main, потому что foo вызывается из нее:
(gdb) disas main
Dump of assembler code for function main:
0x0804838f <main+0>: lea 0x4(%esp),%ecx
0x08048393 <main+4>: and $0xfffffff0,%esp
0x08048396 <main+7>: pushl 0xfffffffc(%ecx)
0x08048399 <main+10>: push %ebp
0x0804839a <main+11>: mov %esp,%ebp
0x0804839c <main+13>: push %ecx
0x0804839d <main+14>: sub $0x24,%esp <--- резерв места для локальных переменных
0x080483a0 <main+17>: movl $0x0,0xfffffff0(%ebp) <--- локальная переменная c = 0
0x080483a7 <main+24>: movl $0x1,0xfffffff4(%ebp) <--- локальная переменная a = 1
0x080483ae <main+31>: movl $0x2,0xfffffff8(%ebp) <--- локальная переменная b = 2
0x080483b5 <main+38>: mov 0xfffffff8(%ebp),%eax
0x080483b8 <main+41>: mov %eax,0x4(%esp)
0x080483bc <main+45>: mov 0xfffffff4(%ebp),%eax
0x080483bf <main+48>: mov %eax,(%esp)
0x080483c2 <main+51>: call 0x8048384 <foo> <--- вызов функции foo
0x080483c7 <main+56>: mov %eax,0xfffffff0(%ebp) <--- сюда вернемся из foo
0x080483ca <main+59>: mov 0xfffffff0(%ebp),%eax
0x080483cd <main+62>: mov %eax,0x4(%esp)
0x080483d1 <main+66>: movl $0x8048504,(%esp)
0x080483d8 <main+73>: call 0x80482b8 <printf@plt>
0x080483dd <main+78>: mov 0xfffffff0(%ebp),%eax
0x080483e0 <main+81>: add $0x24,%esp
0x080483e3 <main+84>: pop %ecx
0x080483e4 <main+85>: pop %ebp
0x080483e5 <main+86>: lea 0xfffffffc(%ecx),%esp
0x080483e8 <main+89>: ret
End of assembler dump.
|
Сравните адрес в дизассемблерном листинге и адрес, который мы при помощи отладчика извлекли из стека – они идентичны.
На что еще стоит обратить внимание в дизассемблерном листинге функции main, так это на порядок адресации локальных переменных. У main локальных переменных три: a, b и c. Также как и для любой другой функции, для main создается кадр стека, о чем красноречиво свидетельствует пролог. Прежде чем присваивать значения локальным переменным, в стеке резервируется для них место при помощи команды «sub $0x24,%esp». Эта команда сдвигает верхушку стека вниз на 36 байт. Далее следуют три команды movl, которые присваивают значения локальным переменным. Пусть вас не смущают странные значения типа 0xfffffff0, 0xfffffff4 и 0xfffffff8 – большое положительное число суть всего лишь маленькое отрицательное, просто отладчик не указывает знак числа. Всю эту тройку команд movl можно переписать так:
movl $0x0,0xfffffff0(%ebp) ==> movl $0x0,-16(%ebp)
movl $0x1,0xfffffff4(%ebp) ==> movl $0x1,-12(%ebp)
movl $0x2,0xfffffff8(%ebp) ==> movl $0x2,-8(%ebp
Для доказательства нашей правоты проведем проверку при помощи отладчика:
bash-3.1$ gdb -q test
Using host libthread_db library "/lib/tls/libthread_db.so.1".
|
Ставим точку останова на main:
(gdb) b main
Breakpoint 1 at 0x80483a0: file test.c, line 12.
(gdb) r
Starting program: ~/TEST/STACK/test
Breakpoint 1, main () at test.c:12
12 c = 0;
(gdb) s
13 a = 1;
(gdb) s
14 b = 2;
(gdb) s
16 c = foo(a, b);
|
Остановились перед вызовом foo. Получаем значение регистра EBP:
(gdb) info reg ebp
ebp 0xbfaf46d8 0xbfaf46d8
|
Определяем значение локальных переменных, указывая положительное смещение относительно EBP:
(gdb) x/xw 0xbfaf46d8+0xfffffff0 <---- локальная переменная c = 0
0xbfaf46c8: 0x00000000
(gdb) x/xw 0xbfaf46d8+0xfffffff4 <---- локальная переменная a = 1
0xbfaf46cc: 0x00000001
(gdb) x/xw 0xbfaf46d8+0xfffffff8 <---- локальная переменная b = 2
0xbfaf46d0: 0x00000002
|
А теперь будем задавать отрицательное смещение и сравнивать результаты:
(gdb) x/xw 0xbfaf46d8-16 <---- локальная переменная c = 0
0xbfaf46c8: 0x00000000
(gdb) x/xw 0xbfaf46d8-12 <---- локальная переменная a = 1
0xbfaf46cc: 0x00000001
(gdb) x/xw 0xbfaf46d8-8 <---- локальная переменная b = 2
0xbfaf46d0: 0x00000002
|
Что еще бросается в глаза в дизассемблерном листинге функции main? Наверное, отсутствие команды push для размещения в стеке параметров функции foo, т.е. локальных переменных a и b функции main? На самом деле для размещения параметров в стеке компилятор gcc не использует push. Он использует mov. Локальная переменная b попадает в стек в результате следующей последовательности команд:
0x080483b5 <main+38>: mov 0xfffffff8(%ebp),%eax <-- локальная переменная b=2 в EAX
0x080483b8 <main+41>: mov %eax,0x4(%esp) <-- помещаем параметр b в стек
Здесь первая команда «mov 0xfffffff8(%ebp),%eax» загружает в регистр EAX локальный параметр b (согласно синтаксису ассемблера AT&T загрузка аргументов выполняется слева направо), а затем вторая команда «mov %eax,0x4(%esp)» размещает этот параметр в стеке, выполняя работу инструкции push.
Аналогично в стек попадает локальный параметр a:
0x080483bc <main+45>: mov 0xfffffff4(%ebp),%eax <-- локальная переменная a=1 в EAX
0x080483bf <main+48>: mov %eax,(%esp) <-- помещаем параметр a в стек
Первой в стеке оказывается переменная b=2, последней a=1 (см. рис. 6). Далее следует вызов call <адрес foo>, и в стеке окажется адрес возврата.
![Рисунок 6. Размещение параметров в стеке перед вызовом foo Рисунок 6. Размещение параметров в стеке перед вызовом foo](../../../../uploads/articles/2007/11/80_84_Stack/image006.gif)
Рисунок 6. Размещение параметров в стеке перед вызовом foo
При возврате из функции main команда «add $0x24,%esp» выполняет выравнивание (балансировку) стека. Балансировка стека – это очень важная операция. Если мы перед выходом из функции не сбалансируем стек, то нас будут ждать сюрпризы, самый безобидный из которых – аварийное завершение программы по сигналу Segmentation Fault. Разберемся, почему так.
Возврат из функции выполняет инструкция ret. При помощи утилиты objdump можно установить, что в нашей программе используется инструкция ret с опкодом C3 (опкод – это код операции, operation code).
В документе [1] в описании команды RET с таким опкодом сказано, что она выполняет ближний возврат в вызывающую процедуру, near return to calling procedure (ближний, потому что команда действует в пределах текущего сегмента кода и не изменяет значение селектора сегмента).
Для возврата инструкция снимает с верхушки стека значение и заносит его в регистр EIP (EIP <-- Pop(), см. [1]). Регистр EIP содержит адрес инструкции для выполнения. Если стек сбалансирован, то в момент вызова ret регистр ESP будет указывать на адрес возврата из функции. Неверное значение ESP приведет к неправильной работе программы.
Проверим, какие значения будут находиться в регистрах ESP и EIP при выходе из функции foo.
bash-3.1# gdb -q test
Using host libthread_db library "/lib/tls/libthread_db.so.1".
(gdb) disas foo
Dump of assembler code for function foo:
0x08048384 <foo+0>: push %ebp
0x08048385 <foo+1>: mov %esp,%ebp
0x08048387 <foo+3>: mov 0xc(%ebp),%eax
0x0804838a <foo+6>: add 0x8(%ebp),%eax
0x0804838d <foo+9>: pop %ebp
0x0804838e <foo+10>: ret
End of assembler dump.
|
Первую точку останова ставим на инструкцию ret:
(gdb) b *0x0804838e
Breakpoint 1 at 0x804838e: file test.c, line 7.
|
Вторая точка останова – сразу после вызова call <адрес foo> в функции main:
(gdb) b *0x080483c7
Breakpoint 2 at 0x80483c7: file test.c, line 16.
(gdb) r
Starting program: ~TEST/STACK/test
Breakpoint 1, 0x0804838e in foo (a=0, b=-1209000736) at test.c:7
7 }
|
Достигли первой точки останова, смотрим, что находится в ESP:
(gdb) info reg esp
esp 0xbff583ac 0xbff583ac
(gdb) x/xw 0xbff583ac
0xbff583ac: 0x080483c7
(gdb) s
|
В ESP – адрес возврата из foo, при выходе из foo он должен оказаться в EIP:
Breakpoint 2, 0x080483c7 in main () at test.c:16
16 c = foo(a, b);
(gdb) info reg eip
eip 0x80483c7 0x80483c7 <main+56>
|
Тут видно, что находится в EIP – адрес, куда будет передано управление при возврате из foo. Это уже знакомый нам 0x80483c7.
Опция gcc-компилятора fomit-frame-pointer позволяет выполнять вызовы функций без создания фиксированных кадров стека. Использование этой опции освобождает регистр EBP, и компилятор может использовать его для своих внутренних потребностей. Недостаток – затрудняется отладка, т.к. значение ESP постоянно варьируется, и нужно быть очень внимательным, чтобы отследить все его изменения.
Скомпилируем наше тестовое приложение (см. листинг 1) с использованием опции fomit-frame-pointer и посмотрим на его дизассемблерный дамп:
bash-3.1$ gcc -Wall -g -fomit-frame-pointer -o test test.c
bash-3.1$ gdb -q ./test
Using host libthread_db library "/lib/tls/libthread_db.so.1".
(gdb) disas main
Dump of assembler code for function main:
0x0804838d <main+0>: lea 0x4(%esp),%ecx
0x08048391 <main+4>: and $0xfffffff0,%esp
0x08048394 <main+7>: pushl 0xfffffffc(%ecx)
0x08048397 <main+10>: push %ecx
0x08048398 <main+11>: sub $0x18,%esp
0x0804839b <main+14>: movl $0x3,0xc(%esp)
0x080483a3 <main+22>: movl $0x1,0x10(%esp)
0x080483ab <main+30>: movl $0x2,0x14(%esp)
0x080483b3 <main+38>: mov 0x14(%esp),%eax
0x080483b7 <main+42>: mov %eax,0x4(%esp)
0x080483bb <main+46>: mov 0x10(%esp),%eax
0x080483bf <main+50>: mov %eax,(%esp)
0x080483c2 <main+53>: call 0x8048384 <foo>
0x080483c7 <main+58>: mov %eax,0xc(%esp)
0x080483cb <main+62>: mov 0xc(%esp),%eax
0x080483cf <main+66>: mov %eax,0x4(%esp)
0x080483d3 <main+70>: movl $0x8048504,(%esp)
0x080483da <main+77>: call 0x80482b8 <printf@plt>
0x080483df <main+82>: mov 0xc(%esp),%eax
0x080483e3 <main+86>: add $0x18,%esp
0x080483e6 <main+89>: pop %ecx
0x080483e7 <main+90>: lea 0xfffffffc(%ecx),%esp
0x080483ea <main+93>: ret
End of assembler dump.
(gdb) disas foo
Dump of assembler code for function foo:
0x08048384 <foo+0>: mov 0x8(%esp),%eax
0x08048388 <foo+4>: add 0x4(%esp),%eax
0x0804838c <foo+8>: ret
End of assembler dump.
|
Видите? Ни одного намека на использование регистра EBP. Вся адресация в стеке только через ESP. Таким способом создаются «плавающие» фреймы стека, и называются они так из-за постоянного изменения значения регистра ESP.
- Intel® 64 and IA-32 Architectures Software Developer’s Manual. Volume 2B: Instruction Set Reference, N-Z (www.intel.com).
Facebook
Мой мир
Вконтакте
Одноклассники
Google+
|