汇编语言中堆栈是如何工作的?
汇编语言中堆栈是如何工作的?
我目前正在尝试了解堆栈的工作原理,所以我决定自学一些汇编语言,我正在使用这本书:
http://savannah.nongnu.org/projects/pgubook/
我正在使用Gas ,并在Linux Mint上进行开发。
我有点困惑:
据我所知,堆栈只是一种数据结构。所以我认为如果我在汇编中编写代码,我必须自己实现堆栈。然而,事实并非如此,因为有像
pushl popl
这样的指令。
因此,在使用Gas语法编写x86 架构的汇编代码时,堆栈只是一个已经实现的数据结构吗?还是它实际上是在硬件层面实现的?还是其他什么?此外,其他芯片组的大多数汇编语言是否都已经实现了堆栈?
我知道这是一个有点愚蠢的问题,但我实际上对此感到非常困惑。
(如果您想要对本答案中的所有代码进行尝试,请访问此处进行查看)
我只在2003年的计算机科学101课程中学习过寄存器中最基本的内容。直到我意识到,它基本上像在C或C++中编程一样,但没有本地变量、参数和函数。我从未真正理解过汇编语言和堆栈的工作原理。也许这还不容易理解。:) 让我为您展示(使用Intel语法的x86汇编语言)
1.栈是什么
在每个线程开始之前,堆栈通常都被分配了连续的一块内存空间。你可以在这里储存任何你想要的东西。以C++术语表示(代码段#1):
const int STACK_CAPACITY = 1000; thread_local int stack[STACK_CAPACITY];
2.栈顶和栈底
原则上,您可以将值存储在随机单元格的堆栈数组中(代码段#2.1):
stack[333] = 123; stack[517] = 456; stack[555] = stack[333] + stack[517];
但一个问题是,我们如何记住那些在使用的堆栈单元格,哪些是“free”?这就是为什么我们将新值相邻地存储在堆栈中的原因。
(x86)asm堆栈中奇怪的一件事是,您从最后一个索引开始添加内容,向低索引移动:stack[999],然后是stack[898],依此类推(代码段#2.2):
stack[999] = 123; stack[998] = 456; stack[997] = stack[999] + stack[998];
遗憾的是,(请注意,您可能会感到困惑)“官方”名称为stack[999]
是堆栈底部。
在上述示例中,最后使用的单元格(stack[997]
)称为堆栈顶部(请参阅Where the top of the stack is on x86)。
3. 栈指针 (SP)
为了讨论方便,我们假设 CPU 寄存器被表示为全局变量(参见通用寄存器)。
int AX, BX, SP, BP, ...; int main(){...}
有一个特殊的 CPU 寄存器(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; // move pointer to unused cell, where we can store what we need stack[SP] = a * 3; return stack[SP]; }
并查看如何调用它(片段#4.3):
// SP == 1000 someVar = triple_noLocals(11); // now SP == 999, but we don't need the value at stack[999] anymore // and we will move the stack index back, so we can reuse this cell later SP += 1; // SP == 1000 again
5. 推入 / 弹出
将新元素添加到堆栈顶部是如此频繁的操作,以至于 CPU 有一个专门的指令,push
。
我们将像这样实现它(片段5.1):
void push(int value) { --SP; stack[SP] = value; }
同样地,获取栈顶元素的常用方式(片段5.2):
void pop(int& result) { result = stack[SP]; ++SP; // note that `pop` decreases stack's size }
推入或弹出的常见用途是临时保存一些值。比如,变量myVar
中存储了有用的信息,但是由于某种原因,我们需要进行计算,修改它的值(片段5.3):
int myVar = ...; push(myVar); // SP == 999 myVar += 10; ... // do something with new value in myVar pop(myVar); // restore original value, SP == 1000
6. 函数参数
现在让我们使用堆栈参数传递(片段#6):
int triple_noL_noParams() { // `a` is at index 999, SP == 999 SP -= 1; // SP == 998, stack[SP + 1] == a stack[SP] = stack[SP + 1] * 3; return stack[SP]; } int main(){ push(11); // SP == 999 assert(triple(11) == triple_noL_noParams()); SP += 2; // cleanup 1 local and 1 parameter }
7. return
语句
让我们使用AX寄存器返回值(片段#7):
void triple_noL_noP_noReturn() { // `a` at 998, SP == 998 SP -= 1; // SP == 997 stack[SP] = stack[SP + 1] * 3; AX = stack[SP]; SP += 1; // finally we can cleanup locals right in the function body, SP == 998 } void main(){ ... // some code push(AX); // save AX in case there is something useful there, SP == 999 push(11); // SP == 998 triple_noL_noP_noReturn(); assert(triple(11) == AX); SP += 1; // cleanup param // locals were cleaned up in the function body, so we don't need to do it here pop(AX); // restore AX ... }
8. 堆栈基指针(BP)(也称为框架指针)和堆栈帧
让我们使用更为“高级”的函数,并将其重写为类似汇编的C++(片段#8.1):
int myAlgo(int a, int b) { int t1 = a * 3; int t2 = b * 3; return t1 - t2; } void myAlgo_noLPR() { // `a` at 997, `b` at 998, old AX at 999, SP == 997 SP -= 2; // SP == 995 stack[SP + 1] = stack[SP + 2] * 3; stack[SP] = stack[SP + 3] * 3; AX = stack[SP + 1] - stack[SP]; SP += 2; // cleanup locals, SP == 997 } int main(){ push(AX); // SP == 999 push(22); // SP == 998 push(11); // SP == 997 myAlgo_noLPR(); assert(myAlgo(11, 22) == AX); SP += 2; pop(AX); }
现在想象一下,我们决定引入一个新的本地变量,以在返回前将结果存储在那里,就像在tripple
(片段#4.1)中一样。函数主体将是(片段#8.2):
SP -= 3; // SP == 994 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() { // `a` at 997, `b` at 998, SP == 997 push(BP); // save old BP, SP == 996 BP = SP; // create anchor, stack[BP] == old value of BP, now BP == 996 SP -= 2; // SP == 994 stack[BP - 1] = stack[BP + 1] * 3; stack[BP - 2] = stack[BP + 2] * 3; AX = stack[BP - 1] - stack[BP - 2]; SP = BP; // cleanup locals, SP == 996 pop(BP); // SP == 997 }
堆栈的片段属于函数的堆栈帧,由函数完全控制。例如,myAlgo_noLPR_withAnchor
的堆栈帧为stack [996 .. 994]
(两个索引都包括在内)。
帧从函数的BP开始(在函数内部更新之后),并持续到下一个堆栈帧。因此,堆栈中的参数是调用者的堆栈帧的一部分(参见注释8a)。
注:
8a. 维基百科对于参数有所不同,但我遵循英特尔软件开发手册,请参阅第1卷,第6.2.4.1节栈帧基指针以及第6.3.2节远程CALL和RET操作中的图6-2。函数的参数和堆栈框架是函数活动记录的一部分(请参见关于函数后记的通用描述)。
8b. BP的正偏移量指向函数参数,负偏移量指向局部变量。这对于调试非常方便
8c. stack [BP]
存储前一个堆栈帧的地址,stack [stack [BP]]
存储前一个堆栈帧的地址,依此类推。沿着这个链,您可以发现程序中尚未返回的所有函数的帧。这就是调试器显示调用堆栈的方式
8d.在myAlgo_noLPR_withAnchor
的前3条指令中,我们设置帧(保存旧BP,更新BP,为局部变量保留空间),称为函数序言
9. 调用约定
在代码片段8.1中,我们从右到左推入了myAlgo
的参数,并在AX
中返回结果。
我们也可以从左到右传递参数,并在BX
中返回。或者在BX和CX中传递参数,并在AX中返回。显然,调用方(main()
)和被调用函数必须就存储在哪里以及以什么顺序存储达成一致。
“调用约定”是一组有关如何传递参数和返回结果的规则。
在上面的代码中,我们使用了cdecl调用约定:
- 参数通过栈传递,第一个参数在调用时堆栈的最低地址(最后推入),调用者在调用后负责将参数从堆栈中弹出。
- 返回值放在AX中。
- EBP和ESP必须由被调用方(在我们的例子中是“myAlgo_noLPR_withAnchor”函数)保存,以便调用者(“main”函数)可以确保这些寄存器在调用时未被更改。
- 所有其他寄存器(EAX等)可以由被调用方自由修改;如果调用方希望在函数调用之前和之后保留某个值,它必须将该值保存在其他地方(我们用AX实现这一点)。
(源码:来自Stack Overflow Documentation的“32位cdecl”示例;版权所有2016年由icktoofay和Peter Cordes创作,根据CC BY-SA 3.0许可。完整的Stack Overflow Documentation内容存档可以在archive.org中找到,其中此示例由主题ID 3261和示例ID 11196索引。)
10. 函数调用
现在是最有趣的部分。就像数据一样,可执行代码也存储在内存中(与堆栈内存完全无关),每个指令都有一个地址。
如果没有给出其他命令,CPU会按照存储在内存中的顺序依次执行指令。但是我们可以命令CPU“跳转”到内存中的另一个位置,并从那里执行指令。在汇编语言中,它可以是任何地址,在更高级别的语言(如C++)中,您只能跳转到由标签标记的地址(有解决方法,但它们不太美观,可以这么说)。
让我们来看看这个函数(片段#10.1):
int myAlgo_withCalls(int a, int b) { int t1 = triple(a); int t2 = triple(b); return t1 - t2; }
现在我们来执行不同的操作,而不是用C++的方式调用triple
函数:
- 将
triple
的代码复制到myAlgo
函数的开头 - 在
myAlgo
函数的入口跳过triple
的代码,使用goto
语句 - 当我们需要执行
triple
的代码时,将代码行保存在堆栈中,在triple
函数调用后的代码行处,以便以后回到此处并继续执行(下面的PUSH_ADDRESS
宏) - 跳转到第一行的地址(
triple
函数),并执行它到结束(3和4一起是CALL
宏) - 在
triple
函数的末尾(在清理掉局部变量之后),从堆栈顶部获取返回地址并跳转到该地址(RET
宏)
因为在C++中没有简单的方法跳转到特定的代码地址,我们将使用标签来标记跳转的位置。我不会详细介绍下面的宏如何工作,只要相信它们会执行我说的事情(片段#10.2):
// pushes the address of the code at label's location on the stack // NOTE1: this gonna work only with 32-bit compiler (so that pointer is 32-bit and fits in int) // NOTE2: __asm block is specific for Visual C++. In GCC use https://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html #define PUSH_ADDRESS(labelName) { \ void* tmpPointer; \ __asm{ mov [tmpPointer], offset labelName } \ push(reinterpret_cast(tmpPointer)); \ } // why we need indirection, read https://stackoverflow.com/a/13301627/264047 #define TOKENPASTE(x, y) x ## y #define TOKENPASTE2(x, y) TOKENPASTE(x, y) // generates token (not a string) we will use as label name. // Example: LABEL_NAME(155) will generate token `lbl_155` #define LABEL_NAME(num) TOKENPASTE2(lbl_, num) #define CALL_IMPL(funcLabelName, callId) \ PUSH_ADDRESS(LABEL_NAME(callId)); \ goto funcLabelName; \ LABEL_NAME(callId) : // saves return address on the stack and jumps to label `funcLabelName` #define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__) // takes address at the top of stack and jump there #define RET() { \ int tmpInt; \ pop(tmpInt); \ void* tmpPointer = reinterpret_cast (tmpInt); \ __asm{ jmp tmpPointer } \ } void myAlgo_asm() { goto my_algo_start; triple_label: push(BP); BP = SP; SP -= 1; // stack[BP] == old BP, stack[BP + 1] == return address stack[BP - 1] = stack[BP + 2] * 3; AX = stack[BP - 1]; SP = BP; pop(BP); RET(); my_algo_start: push(BP); // SP == 995 BP = SP; // BP == 995; stack[BP] == old BP, // stack[BP + 1] == dummy return address, // `a` at [BP + 2], `b` at [BP + 3] SP -= 2; // SP == 993 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; // cleanup locals, SP == 997 pop(BP); } int main() { push(AX); push(22); push(11); push(7777); // dummy value, so that offsets inside function are like we've pushed return address myAlgo_asm(); assert(myAlgo_withCalls(11, 22) == AX); SP += 1; // pop dummy "return address" SP += 2; pop(AX); }
注意:
10a. 因为返回地址存储在堆栈上,原则上我们可以更改它。这就是堆栈缓冲区溢出攻击的工作原理
10b.在triple_label
的“末尾”的最后3条指令(清理局部变量,还原旧的BP,返回)称为函数的收尾工作
11.汇编
现在让我们来看看myAlgo_withCalls
的真正汇编代码。在Visual Studio中执行以下操作:
- 将构建平台设置为x86(不是x86_64)
- 构建类型:Debug
- 在myAlgo_withCalls的某个地方设置断点
- 运行,并在执行停止在断点时按下Ctrl + Alt + D
我们的类似汇编的C ++与asm的区别在于,asm的堆栈操作字节而不是整数。因此,为了保留一个int
的空间,SP将减少4个字节。
这是它(片段#11.1,注释中的行号来自gist):
; 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
tripple
的asm如下(片段#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
希望阅读完这篇文章后,汇编代码看起来不再像以前那样神秘。
以下是帖子主体和一些进一步阅读的链接:
- Eli Bendersky,x86堆栈顶部在哪里 - 顶部/底部,push/pop,SP,堆栈帧,调用约定
- Eli Bendersky,x86-64上的堆栈帧布局 - x64上的args传递,堆栈帧,红色区域
- 马里兰大学,理解堆栈 - 一个非常良好的堆栈概念介绍。 (它是用GAS语法的MIPS(不是x86),但这对于该主题来说不重要)。 如果感兴趣,请参见其他有关MIPS ISA编程的说明。
- x86汇编wikibook,通用寄存器
- x86反汇编wikibook,堆栈
- x86反汇编wikibook,函数和堆栈帧
- 英特尔软件开发人员手册 - 我预计它非常专业,但令人惊讶的是,它很容易阅读(尽管信息量很大)
- Jonathan de Boyne Pollard,关于函数perilogues的gen - prologue/epilogue,stack frame/activation record,red zone
我认为你主要是混淆了一个程序的堆栈和任何旧堆栈之间的区别。
一个堆栈
是一个由后进先出系统组成的抽象数据结构。你把任意对象放到堆栈中,然后再取出它们,就像一个进出盘一样,顶部的物品总是被取出的,你总是放到顶部。
一个程序的堆栈
是一个堆栈,它是在执行过程中使用的一部分内存,通常每个程序的大小是静态的,并经常用于存储函数参数。当您调用一个函数时,您将参数推入堆栈,函数要么直接访问堆栈,要么从堆栈中弹出变量。
程序的堆栈通常不是硬件 (尽管它在内存中保留,因此可以被认为是硬件),但是指向当前堆栈区域的栈指针通常是CPU寄存器。这使得它比LIFO堆栈更灵活,因为您可以更改堆栈寻址的点。
您应该阅读并确保您理解维基百科文章,因为它对您正在处理的硬件堆栈给出了很好的描述。
还有这个教程,它用旧的16位寄存器来解释堆栈,但可能会有帮助,以及另一个专门介绍堆栈的。
来自尼尔斯·皮彭布林克的消息:
值得一提的是,一些处理器并没有实现所有操作栈的指令(push,pop,堆栈指针等),但是由于x86的使用频率,它实现了所有指令。在这些情况下,如果您需要一个堆栈,则必须自己实现它(一些MIPS和一些ARM处理器是没有堆栈的)。
例如,在MIPs中,push指令是这样实现的:
addi $sp, $sp, -4 # Decrement stack pointer by 4 sw $t0, ($sp) # Save $t0 to stack
而pop指令看起来像:
lw $t0, ($sp) # Copy from stack to $t0 addi $sp, $sp, 4 # Increment stack pointer by 4