(如果您想使用它,我已经在此答案中列出了所有代码的要点)
在2003年的CS101课程中,我只做过关于asm的最基本的事情。直到我意识到这基本上就像用C或C ++编程一样,我才真正真正了解过asm和堆栈的工作原理。但没有局部变量,参数和函数。可能听起来还不容易:)让我告诉你(对于采用Intel语法的x86 asm )。
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堆栈的一件奇怪的事情是,您从最后一个索引开始添加内容,然后移到较低的索引:stack [999],然后stack [998],依此类推(代码段#2.2):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
而且(警告,您现在会感到困惑)“官方”名称stack[999]
仍然是堆栈的底部。
最后使用的单元格(stack[997]
在上面的示例中)称为堆栈顶部(请参阅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;
stack[SP] = a * 3;
return stack[SP];
}
并查看其调用方式(摘要#4.3):
someVar = triple_noLocals(11);
SP += 1;
5.推/弹出
在堆栈顶部添加新元素的操作非常频繁,以至于CPU对此有一条特殊指令push
。我们将像这样(片段5.1)来鼓励它:
void push(int value) {
--SP;
stack[SP] = value;
}
同样,采用堆栈的顶部元素(代码段5.2):
void pop(int& result) {
result = stack[SP];
++SP;
}
推送/弹出的常用用法是暂时保存一些价值。说,我们在变量中有一些有用的东西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]
(包括IDexes)。
帧从函数的BP开始(在函数内部更新后),一直持续到下一个堆栈帧。因此,堆栈上的参数是调用方堆栈框架的一部分(请参见注释8a)。
注意:
8a。 维基百科对参数另有说明,但在这里我遵守英特尔软件开发人员手册,请参见第211页。1,第6.2.4.1节“堆栈框架基本指针”和第6.3.2节的“图6-2 ”。函数的参数和堆栈框架是函数的激活记录的一部分(请参见函数perilogues的gen)。
8b。从BP点到函数参数的正偏移量和从局部变量开始的负偏移量。这对于调试
8c 非常方便。stack[BP]
存储前一个堆栈帧的地址,stack[stack[BP]]
存储先前的堆栈帧,依此类推。遵循此链,您可以发现程序中所有函数的框架,但尚未返回。这就是调试器向您显示如何调用堆栈
8d的方式。的前3条指令(myAlgo_noLPR_withAnchor
我们在其中设置框架(保存旧的BP,更新BP,为本地人保留空间))称为函数序言
9.调用约定
在代码段8.1中,我们myAlgo
从右向左推送了参数,并在中返回了结果AX
。我们也可以从左到右传递参数并返回BX
。或在BX和CX中传递参数并在AX中返回。显然,调用者(main()
)和被调用函数必须同意所有这些东西的存储位置和顺序。
调用约定是关于如何传递参数和返回结果的一组规则。
在上面的代码中,我们使用了cdecl调用约定:
- 参数在堆栈上传递,第一个参数在调用时位于堆栈的最低地址(按入最后一个<...>)。调用者负责在调用之后从堆栈弹出参数。
- 返回值放在AX中
- EBP和ESP必须由被调用方(
myAlgo_noLPR_withAnchor
在我们的情况下为main
函数)保留,以便调用方(函数)可以依赖那些未被调用更改的寄存器。
- 被调用者可以自由修改所有其他寄存器(EAX,<...>);如果调用者希望在函数调用之前和之后保留一个值,则它必须将该值保存在其他位置(我们使用AX进行此操作)
(来源:Stack Overflow文档中的示例“ 32位cdecl”;icktoofay和Peter Cordes的版权2016 ;在CC BY-SA 3.0下获得许可。完整Stack Overflow文档内容的存档可在archive.org中找到,其中本示例由主题ID 3261和示例ID 11196索引。)
10.函数调用
现在最有趣的部分。就像数据一样,可执行代码也存储在内存中(与堆栈内存完全无关),每个指令都有一个地址。
如果没有其他命令,CPU将按照存储在存储器中的顺序依次执行一条指令。但是我们可以命令CPU“跳转”到内存中的另一个位置并从那里执行指令。在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
下面的宏)
- 跳到第一行的地址(
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);
}
注意:
10a。因为返回地址存储在堆栈中,原则上我们可以更改它。这就是堆栈溢出攻击的工作
10B。triple_label
(清理本地,恢复旧BP,返回)“结束”处的最后3条指令称为函数的结尾
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
以及tripple
(片段#11.2)的asm :
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
希望,在阅读了这篇文章之后,汇编看起来不像以前那样神秘:)
以下是该帖子正文的链接以及更多阅读内容: