(Я понял суть всего кода в этом ответе на случай, если вы захотите поиграть с ним)
Я когда-либо делал только самые простые вещи в asm во время моего курса CS101 в 2003 году. И я никогда не понимал, как работают asm и стек, пока не понял, что все это в основном как программирование на C или C ++ ... но без локальных переменных, параметров и функций. Наверное, пока непросто :) Позвольте мне показать вам (для x86 asm с синтаксисом Intel ).
1. Что такое стек
Стек обычно представляет собой непрерывный кусок памяти, выделяемый для каждого потока перед его запуском. Вы можете хранить там все, что захотите. В терминах C ++ ( фрагмент кода # 1 ):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2. Верх и низ стека
В принципе, вы можете хранить значения в случайных ячейках stack
массива ( фрагмент 2.1 ):
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
Но представьте, как трудно запомнить, какие ячейки stack
уже используются, а какие «свободны». Вот почему мы храним новые значения в стеке рядом друг с другом.
Одна странность в стеке asm (x86) заключается в том, что вы добавляете туда вещи, начиная с последнего индекса, и переходите к более низким индексам: стек [999], затем stack [998] и так далее ( фрагмент 2.2 ):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
И все же (осторожно, вы сейчас запутаетесь) "официальное" название stack[999]
- это нижняя часть стека .
Последняя использованная ячейка ( stack[997]
в приведенном выше примере) называется вершиной стека (см. Где находится вершина стека на x86 ).
3. Указатель стека (SP)
Для целей этого обсуждения предположим, что регистры ЦП представлены как глобальные переменные (см. Регистры общего назначения ).
int AX, BX, SP, BP, ...;
int main(){...}
Есть специальный регистр ЦП (SP), который отслеживает вершину стека. SP - указатель (содержит адрес памяти, например 0xAAAABBCC). Но для целей этого поста я буду использовать его как индекс массива (0, 1, 2, ...).
Когда поток запускается, SP == STACK_CAPACITY
а затем программа и ОС изменяют его по мере необходимости. Правило состоит в том, что вы не можете писать в ячейки стека за пределами вершины стека, и любой индекс меньше SP недействителен и небезопасен (из-за системных прерываний ), поэтому вы
сначала уменьшаете SP, а затем записываете значение в новую выделенную ячейку.
Если вы хотите поместить несколько значений в стек подряд, вы можете заранее зарезервировать место для всех ( фрагмент 3 ):
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
Запись. Теперь вы можете понять, почему распределение в стеке происходит так быстро - это всего лишь декремент на один регистр.
4. Локальные переменные
Давайте посмотрим на эту упрощенную функцию ( фрагмент 4.1 ):
int triple(int a) {
int result = a * 3;
return result;
}
и перепишем без использования локальной переменной ( фрагмент 4.2 ):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
и посмотрите, как он вызывается ( фрагмент 4.3 ):
someVar = triple_noLocals(11);
SP += 1;
5. Толкать / хлопать
Добавление нового элемента на вершину стека - настолько частая операция, что у ЦП есть специальная инструкция для этого push
,. Мы реализуем это так ( фрагмент 5.1 ):
void push(int value) {
--SP;
stack[SP] = value;
}
Аналогичным образом, взяв верхний элемент стека ( фрагмент 5.2 ):
void pop(int& result) {
result = stack[SP];
++SP;
}
Обычный шаблон использования push / pop временно сохраняет некоторую ценность. Скажем, у нас есть что-то полезное в переменной myVar
и по какой-то причине нам нужно произвести вычисления, которые перезапишут ее ( фрагмент 5.3 ):
int myVar = ...;
push(myVar);
myVar += 10;
...
pop(myVar);
6. Параметры функции
Теперь передадим параметры с помощью стека ( фрагмент 6 ):
int triple_noL_noParams() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11);
assert(triple(11) == triple_noL_noParams());
SP += 2;
}
7. return
заявление
Вернем значение в регистре AX ( фрагмент 7 ):
void triple_noL_noP_noReturn() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1;
}
void main(){
...
push(AX);
push(11);
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1;
pop(AX);
...
}
8. Базовый указатель стека (BP) (также известный как указатель кадра ) и кадр стека.
Давайте возьмем более "продвинутую" функцию и перепишем ее на нашем asm-подобном C ++ ( фрагмент № 8.1 ):
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() {
SP -= 2;
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2;
}
int main(){
push(AX);
push(22);
push(11);
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
Теперь представьте, что мы решили ввести новую локальную переменную для сохранения результата перед возвратом, как мы это сделали в tripple
(фрагмент № 4.1). Тело функции будет ( фрагмент # 8.2 ):
SP -= 3;
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
Видите ли, нам пришлось обновить каждую ссылку на параметры функции и локальные переменные. Чтобы этого избежать, нам нужен индекс привязки, который не меняется при увеличении стека.
Мы создадим якорь сразу после входа в функцию (перед тем, как выделить место для локальных), сохранив текущую вершину (значение SP) в регистре BP. Фрагмент № 8.3 :
void myAlgo_noLPR_withAnchor() {
push(BP);
BP = SP;
SP -= 2;
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
Срез стека, который принадлежит функции и полностью контролирует ее, называется кадром стека функции . Например myAlgo_noLPR_withAnchor
, кадр стека равен stack[996 .. 994]
(включая оба идентификатора).
Кадр начинается с BP функции (после того, как мы обновили его внутри функции) и длится до следующего кадра стека. Таким образом, параметры в стеке являются частью кадра стека вызывающей стороны (см. Примечание 8a).
Примечания:
8а. Википедия говорит иначе о параметрах, но здесь я придерживаюсь руководства разработчика программного обеспечения Intel , см. Т. 1, раздел 6.2.4.1 Указатель базы стека-кадра, и рисунок 6-2 в разделе 6.3.2 Операции удаленного вызова и возврата . Параметры функции и фрейм стека являются частью записи активации функции (см. «Проблемы генерации функции» ).
8b. положительные смещения от точки BP к параметрам функции и отрицательные смещения указывают на локальные переменные. Это очень удобно для отладки
8c. stack[BP]
хранит адрес предыдущего кадра стека,stack[stack[BP]]
сохраняет предыдущий кадр стека и так далее. Следуя этой цепочке, вы можете обнаружить фреймы всех функций в программе, которые еще не вернулись. Вот как отладчики показывают стек вызовов
8d. первые 3 инструкции myAlgo_noLPR_withAnchor
, в которых мы настраиваем фрейм (сохранить старый BP, обновить BP, зарезервировать место для локальных), называются прологом функции
9. Соглашения о вызовах
Во фрагменте 8.1 мы поместили параметры для myAlgo
справа налево и вернули результат в формате AX
. Мы могли бы также передать параметры слева направо и вернуться BX
. Или передайте параметры в BX и CX и вернитесь в AX. Очевидно, что caller ( main()
) и вызываемая функция должны согласовать, где и в каком порядке хранятся все эти данные.
Соглашение о вызове - это набор правил о том, как передаются параметры и возвращается результат.
В приведенном выше коде мы использовали соглашение о вызовах cdecl :
- Параметры передаются в стек, причем первый аргумент находится по наименьшему адресу в стеке во время вызова (помещается последним <...>). Вызывающий отвечает за извлечение параметров из стека после вызова.
- возвращаемое значение помещается в AX
- EBP и ESP должны быть сохранены вызываемым пользователем (
myAlgo_noLPR_withAnchor
функцией в нашем случае), чтобы вызывающий объект ( main
функция) мог полагаться на то, что эти регистры не были изменены вызовом.
- Все остальные регистры (EAX, <...>) могут быть изменены вызываемым пользователем; если вызывающий желает сохранить значение до и после вызова функции, он должен сохранить значение в другом месте (мы делаем это с помощью AX)
(Источник: пример «32-bit cdecl» из документации по переполнению стека; авторское право 2016 г. принадлежит icktoofay и Peter Cordes ; под лицензией CC BY-SA 3.0. Архив полного содержания документации по переполнению стека можно найти на сайте archive.org, в котором этот пример проиндексирован по ID темы 3261 и ID примера 11196.)
10. Вызов функций
Теперь самое интересное. Как и данные, исполняемый код также хранится в памяти (совершенно не связанной с памятью для стека), и каждая инструкция имеет адрес.
Если не указано иное, ЦП выполняет инструкции одну за другой в том порядке, в котором они хранятся в памяти. Но мы можем приказать процессору «перейти» в другое место в памяти и выполнить инструкции оттуда. В asm это может быть любой адрес, а в более высокоуровневых языках, таких как C ++, вы можете переходить только к адресам, отмеченным метками ( есть обходные пути, но они, мягко говоря, не очень хороши).
Возьмем эту функцию ( фрагмент 10.1 ):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
И вместо вызова tripple
C ++ сделайте следующее:
- скопировать
tripple
код в начало myAlgo
тела
- при
myAlgo
входе перепрыгивайте через tripple
код с помощьюgoto
- когда нам нужно выполнить
tripple
код, сохраните в стеке адрес строки кода сразу после tripple
вызова, чтобы мы могли вернуться сюда позже и продолжить выполнение ( PUSH_ADDRESS
макрос ниже)
- перейти к адресу 1-й строки (
tripple
функции) и выполнить ее до конца (3. и 4. вместе составляютCALL
макрос)
- в конце
tripple
(после того, как мы очистили локальных), возьмите адрес возврата из вершины стека и перейдите туда ( RET
макрос)
Поскольку в C ++ нет простого способа перейти к конкретному адресу кода, мы будем использовать метки, чтобы отмечать места переходов. Я не буду вдаваться в подробности того, как работают макросы ниже, просто поверьте мне, они делают то, что я говорю ( фрагмент 10.2 ):
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP);
BP = SP;
SP -= 2;
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777);
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1;
SP += 2;
pop(AX);
}
Примечания:
10а. поскольку адрес возврата хранится в стеке, в принципе мы можем его изменить. Вот как работает атака с разбиванием стека
10b. последние 3 инструкции в "конце" triple_label
(очистка локальных переменных, восстановление старого BP, возврат) называются эпилогом функции
11. Сборка
Теперь посмотрим на настоящий asm для myAlgo_withCalls
. Для этого в Visual Studio:
- установите платформу сборки на x86 ( не x86_64)
- тип сборки: отладка
- установить точку останова где-нибудь внутри myAlgo_withCalls
- запустить, а когда выполнение остановится в точке останова, нажмите Ctrl + Alt + D
Одно отличие от нашего asm-подобного C ++ состоит в том, что стек asm работает с байтами, а не с целыми числами. Таким образом, чтобы зарезервировать место для одного int
, SP будет уменьшено на 4 байта.
Вот и все ( фрагмент 11.1 , номера строк в комментариях взяты из сути ):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
И asm для tripple
( фрагмент 11.2 ):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
Надеюсь, после прочтения этого поста сборка не выглядит такой загадочной, как раньше :)
Вот ссылки из тела сообщения и некоторые материалы для дальнейшего чтения:
- Эли Бендерски , где верх стека находится на x86 - верх / низ, push / pop, SP, кадр стека, соглашения о вызовах
- Эли Бендерски , Макет кадра стека на x86-64 - передача аргументов на x64, кадр стека, красная зона
- Университет Мариленда, Understanding the Stack - действительно хорошо написанное введение в концепции стека. (Это для MIPS (не x86) и в синтаксисе GAS, но для темы несущественно). Если интересно, просмотрите другие заметки по программированию MIPS ISA .
- x86 Asm wikibook, Регистры общего назначения
- wikibook по разборке x86, The Stack
- Викиучебник по разборке x86, Функции и фреймы стека
- Руководства разработчика программного обеспечения Intel - я ожидал, что оно будет действительно хардкорным, но, на удивление, его довольно легко читать (хотя объем информации огромен)
- Джонатан де Бойн Поллард, Опасности генерации функций - пролог / эпилог, кадр стека / запись активации, красная зона