(如果您想使用它,我已经在此答案中列出了所有代码的要点)
在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;
}
并且不要执行trippleC ++方式,而是执行以下操作:
- 将
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  
希望,在阅读了这篇文章之后,汇编看起来不像以前那样神秘:)
以下是该帖子正文的链接以及更多阅读内容: