(У цій відповіді я склав суть всього коду на випадок, якщо ви хочете з ним пограти)
Я лише коли-небудь робив основні речі в 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
вже використовуються, а які „вільні”. Ось чому ми зберігаємо нові значення в стеку поруч один з одним.
Одна дивна річ щодо стека (x86) asm полягає в тому, що ви додаєте туди речі, починаючи з останнього індексу, і переходите до нижчих індексів: стек [999], потім стек [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. Локальні змінні
Давайте подивимось на цю спрощену функцію ( фрагмент No4.1 ):
int triple(int a) {
int result = a * 3;
return result;
}
і перепишіть його без використання локальної змінної ( фрагмент No4.2 ):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
і подивіться, як це називається ( фрагмент No 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 функції (після того, як ми оновили його всередині функції) і триває до наступного кадру стека. Отже, параметри стека є частиною кадру стека абонента (див. Примітку 8а).
Примітки:
8a. Про параметри Вікіпедія говорить інакше , але тут я дотримуюсь інструкції розробника програмного забезпечення Intel , див. Т. 1, розділ 6.2.4.1 Базовий вказівник стека та малюнок 6-2 у розділі 6.3.2 Далекі операції CALL та RET . Параметри функції та кадр стека є частиною запису активації функції (див . Генерацію в перилогах функції ).
8б. позитивні зсуви від точки BP до функціональних параметрів, а негативні - до локальних змінних. Це дуже зручно для налагодження
8c. stack[BP]
зберігає адресу попереднього кадру стека,stack[stack[BP]]
зберігає попередній кадр стека тощо. Слідуючи цьому ланцюжку, ви можете виявити фрейми всіх функцій програми, які ще не повернулися. Ось як налагоджувачі показують вам стек дзвінків
8d. перші 3 інструкції myAlgo_noLPR_withAnchor
, де ми встановлюємо фрейм (зберегти старий BP, оновити BP, зарезервувати місце для місцевих жителів), називаються функційним прологом
9. Виклик конвенцій
У фрагменті 8.1 ми просунули параметри для myAlgo
справа наліво і повернули результат в AX
. Ми могли б також передати параметри зліва направо та повернутися BX
. Або передайте параметри в BX і CX і поверніть в AX. Очевидно, що main()
функція caller ( ) та викликана функція повинні узгодити, де і в якому порядку зберігається все це.
Конвенція викликів - це набір правил передачі параметрів та повернення результату.
У наведеному вище коді ми використовували умову виклику cdecl :
- Параметри передаються в стек, з першим аргументом за найнижчою адресою в стеку під час виклику (натискання останнього <...>). Абонент несе відповідальність за вискакування параметрів, повернутих із стеку після дзвінка.
- повернене значення розміщується в AX
- EBP та ESP повинні зберігатись у абонента (
myAlgo_noLPR_withAnchor
функція в нашому випадку) таким чином, щоб абонент ( main
функція) міг покладатися на ті регістри, які не були змінені під час виклику.
- Усі інші регістри (EAX, <...>) можуть вільно змінюватись абонентом; якщо абонент хоче зберегти значення до і після виклику функції, він повинен зберегти значення в іншому місці (ми робимо це за допомогою AX)
(Джерело: приклад «32-біта Cdecl» від переповнення стека документації, авторське право 2016 з icktoofay і Пітер Кордес ., Під ліцензією CC BY-SA 3.0 An архів повного змісту Stack Overflow документації можна знайти на 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
код «s на початку myAlgo
тіла
- при
myAlgo
вході перескочити tripple
код зgoto
- коли нам потрібно виконати
tripple
код, збережіть адресу стека рядка коду відразу після tripple
виклику, щоб ми могли повернутися сюди пізніше і продовжити виконання ( PUSH_ADDRESS
макрос нижче)
- перейти до адреси 1-го рядка (
tripple
функції) і виконати її до кінця (3. та 4. разом - це CALL
макрос)
- в кінці
tripple
(після того, як ми прибрали місцевих жителів), візьміть адресу звернення з вершини стека і стрибніть туди ( RET
макрос)
Оскільки в C ++ немає простого способу перейти до певної адреси коду, ми будемо використовувати мітки для позначення місць стрибків. Я не буду вдаватися в подробиці, як працюють макроси нижче, просто повірте мені, що вони роблять те, що я кажу ( фрагмент No 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
(очищення місцевих жителів, відновлення старого АТ, повернення) називаються епілогом функції
11. Збірка
А тепер давайте розглянемо справжній asm для myAlgo_withCalls
. Для цього у Visual Studio:
- встановити платформу збірки на x86 ( не x86_64)
- тип збірки: налагодження
- встановити точку розриву десь усередині myAlgo_withCalls
- запуску, і коли виконання зупиняється в точці розриву, натисніть Ctrl + Alt + D
Одна відмінність нашого подібного до asm C ++ полягає в тому, що стек asm працює на байтах, а не на ints. Отже, щоб зарезервувати місце для одного 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, кадр стека, червона зона
- Університет Маріленду, Розуміння стека - справді добре написаний вступ до концепцій стеку. (Це для MIPS (не x86) та у синтаксисі GAS, але це незначно для теми). Див. Інші примітки щодо програмування MIPS ISA, якщо зацікавлені.
- x86 Вікікнига Asm, Реєстри загального призначення
- x86 Розбирання wikibook, стек
- x86 Розбирання wikibook, функції та фрейми стека
- Посібники розробника програмного забезпечення Intel - я очікував, що це буде по-справжньому жорстким, але на диво його досить легко читати (хоча кількість інформації надзвичайна)
- Джонатан де Бойн Поллард, ген про перилоги функцій - пролог / епілог, кадр стека / запис активації, червона зона