Что мы знаем о стеке?::Журнал СА 11.2007
www.samag.ru
     
Поиск   
              
 www.samag.ru    Web  0 товаров , сумма 0 руб.
E-mail
Пароль  
 Запомнить меня
Регистрация | Забыли пароль?
Журнал "Системный администратор"
Журнал «БИТ»
Наука и технологии
Подписка
Где купить
Авторам
Рекламодателям
Магазин
Архив номеров
Вакансии
Контакты
   

  Опросы

Какие курсы вы бы выбрали для себя?  

Очные
Онлайновые
Платные
Бесплатные
Я и так все знаю

 Читать далее...

1001 и 1 книга  
20.12.2019г.
Просмотров: 5466
Комментарии: 0
Dr.Web: всё под контролем

 Читать далее...

04.12.2019г.
Просмотров: 6643
Комментарии: 1
Особенности сертификаций по этичному хакингу

 Читать далее...

28.05.2019г.
Просмотров: 7926
Комментарии: 2
Анализ вредоносных программ

 Читать далее...

28.05.2019г.
Просмотров: 8216
Комментарии: 2
Микросервисы и контейнеры Docker

 Читать далее...

28.05.2019г.
Просмотров: 7210
Комментарии: 0
Django 2 в примерах

 Читать далее...

Друзья сайта  

Форум системных администраторов  

sysadmins.ru

 Что мы знаем о стеке?

Архив номеров / 2007 / Выпуск №11 (60) / Что мы знаем о стеке?

Рубрика: БИТ. Бизнес & Информационные технологии /  Хранение данных

Владимир Мешков

Что мы знаем о стеке?

Предпримем попытку систематизировать собственные знания о стеке и максимально подробно рассмотрим, какую роль играет стек в процедурном программировании.

Стек – это область памяти, предназначенная для временного хранения данных. Стек адресуется при помощи пары регистров SS:ESP. Регистр SS (Stack Segment) содержит селектор сегмента стека при работе процессора в защищенном режиме, ESP (Stack Pointer, указатель стека) – смещение относительно базового адреса сегмента стека. Упрощенная схема адресации данных в стеке, без привязки к какой-либо конкретной операционной системе, показана на рис. 1. Схема адресации сегментов команд и данных идентична изображенной на рисунке схеме адресации сегмента стека. При подготовке статьи использовались ОС Linux, компилятор gcc-4.1.2, отладчик gdb-6.6.

Рисунок 1. Адресация стека

Рисунок 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. Вызов функции и передача ей параметров

Чтобы такая схема сработала, необходимо запомнить адрес возврата из функции, т.е. адрес следующей за call команды. Этот адрес сохраняет в стеке команда call. Если привязать все эти команды к изменениям стека, то получим картину как на рис. 3. Видно, как изменяют стек команды push и call. Сразу после вызова команды call в стеке будет сохранен адрес возврата – адрес следующей за call инструкции, и ESP будет указывать на этот адрес, т.е. *ESP == адрес возврата.

Рисунок 3. Изменения стека при вызове функции

Рисунок 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. Состояние стека после пролога функции

Что же мы все таки получили? А вот что – мы зафиксировали верхушку стека в регистре EBP и получили возможность перемещаться по стеку относительно этой точки. Другими словами, мы создали кадр стека, или стековый фрейм (stack frame). Смотрим на рис. 5.

Рисунок 5. Кадр стека

Рисунок 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

При возврате из функции 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.

  1. Intel® 64 and IA-32 Architectures Software Developer’s Manual. Volume 2B: Instruction Set Reference, N-Z (www.intel.com).

Комментарии отсутствуют

Добавить комментарий

Комментарии могут оставлять только зарегистрированные пользователи

               Copyright © Системный администратор

Яндекс.Метрика
Tel.: (499) 277-12-41
Fax: (499) 277-12-45
E-mail: sa@samag.ru