汇编语言中堆栈是如何工作的?

52 浏览
0 Comments

汇编语言中堆栈是如何工作的?

我目前正在尝试了解堆栈的工作原理,所以我决定自学一些汇编语言,我正在使用这本书:

http://savannah.nongnu.org/projects/pgubook/

我正在使用Gas ,并在Linux Mint上进行开发。

我有点困惑:

据我所知,堆栈只是一种数据结构。所以我认为如果我在汇编中编写代码,我必须自己实现堆栈。然而,事实并非如此,因为有像

pushl
popl

这样的指令。

因此,在使用Gas语法编写x86 架构的汇编代码时,堆栈只是一个已经实现的数据结构吗?还是它实际上是在硬件层面实现的?还是其他什么?此外,其他芯片组的大多数汇编语言是否都已经实现了堆栈?

我知道这是一个有点愚蠢的问题,但我实际上对此感到非常困惑。

admin 更改状态以发布 2023年5月20日
0
0 Comments

(如果您想要对本答案中的所有代码进行尝试,请访问此处进行查看)

我只在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函数:

  1. triple的代码复制到myAlgo函数的开头
  2. myAlgo函数的入口跳过triple的代码,使用goto语句
  3. 当我们需要执行triple的代码时,将代码行保存在堆栈中,在triple函数调用后的代码行处,以便以后回到此处并继续执行(下面的PUSH_ADDRESS宏)
  4. 跳转到第一行的地址(triple函数),并执行它到结束(3和4一起是CALL宏)
  5. 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  

希望阅读完这篇文章后,汇编代码看起来不再像以前那样神秘。


以下是帖子主体和一些进一步阅读的链接:

0
0 Comments

我认为你主要是混淆了一个程序的堆栈和任何旧堆栈之间的区别。

一个堆栈

是一个由后进先出系统组成的抽象数据结构。你把任意对象放到堆栈中,然后再取出它们,就像一个进出盘一样,顶部的物品总是被取出的,你总是放到顶部。

一个程序的堆栈

是一个堆栈,它是在执行过程中使用的一部分内存,通常每个程序的大小是静态的,并经常用于存储函数参数。当您调用一个函数时,您将参数推入堆栈,函数要么直接访问堆栈,要么从堆栈中弹出变量。

程序的堆栈通常不是硬件 (尽管它在内存中保留,因此可以被认为是硬件),但是指向当前堆栈区域的栈指针通常是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  

0