汇编语言简易教程(11):函数与栈帧

544 阅读22分钟

汇编语言简易教程(11):函数与栈帧

需要注意. 这里的函数指的是汇编语言(yasm)的函数, 但对理解其他高级语言的函数在汇编中的实现会有很大的帮助.

函数和过程(即空函数)有助于将程序分解为更小的部分,从而更容易编码、调试和维护。函数调用涉及两个主要操作:

  1. 函数链接

    由于可以从代码中的多个不同位置调用该函数,因此该函数必须能够返回到最初调用它的正确位置

  2. 参数传递

    该函数必须能够访问参数以进行操作或返回结果(即访问按引用调用参数)。

前言 / 总结

栈上的操作直接影响到了所有高级语言的程序设计, 主要是函数调用

牢记

  1. 每一个核心都有自己的寄存器
  2. rsp​和rbp​是非常紧密的两个寄存器, 阅读时请一定牢记 rbp 的前处理和后处理

rbp

在接下里的内容里你会不断的看到rbp

rbp​(Base Pointer)寄存器在函数调用中主要用于维护一个稳定的参考点,以便无论rsp​(Stack Pointer)寄存器如何变化,都能够可靠地访问函数的局部变量和参数。

在函数调用的过程中,rsp​通常会不断变化,因为它用于指向栈顶,并且在进行数据推送(push)和弹出(pop)、调用其他函数(call)或返回(ret)时会发生变化。如果直接使用rsp​来访问局部变量和参数,那么每次栈结构改变时,访问这些数据的代码都需要相应地进行调整,这会使得代码复杂且容易出错。

相反,通过在函数入口处将rsp​的值复制到rbp​,并在整个函数调用过程中保持rbp​不变,可以通过rbp​加上或减去一个固定偏移量来可靠地访问局部变量和参数。这种方法简化了代码,并使得即使在栈指针变化的情况下,也能够保持对函数局部环境的稳定引用。

这种使用rbp​作为固定基点的技术称为栈帧(Stack Frame)技术,它是函数调用约定的一部分,尤其是在需要支持复杂的栈操作(如局部变量的动态分配)或者调试时。不过,需要注意的是,在某些情况下,尤其是在优化代码时,编译器可能会选择省略rbp​的使用,直接通过rsp​来访问数据,这被称为“栈指针省略”(Stack Pointer Omission)或“Leaf Function Optimization”。

section .text
global _start

_start:
    ; 函数调用的示例
    call example_function

example_function:
    push rbp          ; 保存调用者的rbp值
    mov rbp, rsp      ; 设置当前函数的栈帧基址

    ; 函数体可以在这里执行任何操作
    ; 示例中没有实际操作

    pop rbp           ; 恢复调用者的rbp值
    ret               ; 返回到调用者

总结

函数或者在汇编上称之为栈帧技术.

主要有三部分需要理解

  1. 参数传递, 优先使用寄存器, 超过时使用rsp
  2. 栈帧前处理, 标准操作: 1. push rbp​ 2. mov rbp, rsp
  3. 栈帧后处理, pop rbp ; 恢复调用者的rbp值

如果能够把握以上几点, 你会很轻松的理解下面的内容, 加油.

栈上动态本地变量

在高级语言中,函数中声明的非静态局部变量默认是堆栈动态局部变量。一些 C++ 文本将此类变量称为自动变量。

这意味着局部变量是通过在堆栈上分配空间并将这些堆栈位置分配给变量来创建的。

当函数完成时,空间被回收并重新用于其他目的。这需要少量额外的运行时开销,但可以更有效地整体使用内存。

如果从未调用具有大量局部变量的函数,则永远不会分配局部变量的内存。这有助于减少程序的整体内存占用,这通常有助于提高程序的整体性能。

相比之下,静态声明的变量在程序的整个执行过程中都分配有内存位置。

即使相关函数没有被执行,这也会使用内存。但是,分配空间不需要额外的运行时开销,因为空间分配已经执行(当程序最初加载到内存中时).

BTW, 对全局变量的写行为是导致出现程序异常的一个常见原因, 关注你的全局变量, 如果可以尽量将他们定义为**const**​.

函数定义

必须先编写函数才能使用它。函数位于代码段中。一般格式为:

image

一个函数只能定义一次。函数的定义方式没有特定的顺序要求。但是,函数不能嵌套。一个函数定义应该在下一个函数定义开始之前开始和结束.

标准调用约定

为了编写汇编程序,需要一个在函数之间传递参数、返回值和分配寄存器的标准过程。如果每个函数以不同的方式执行这些操作,事情很快就会变得非常混乱,并且需要程序员尝试记住每个函数如何处理参数以及使用了哪些寄存器。

为了解决这个问题,定义并使用了一个标准流程,通常称为标准调用约定。实际上有许多不同的标准调用约定。 64 位 C 调用约定(称为 System V AMD64 ABI 参考1 参考2

默认情况下,此调用约定也用于 C/C++ 程序。这意味着由于使用相同的调用约定,因此可以轻松实现汇编语言代码和 C/C++ 代码的接口。

在x86架构下,有几种常见的调用约定(calling conventions),每种约定都有其特定的规则集,用于确定函数参数如何传递、返回值如何处理以及栈帧如何管理。以下是一些常见的x86调用约定:

  1. cdecl(C Declaration)

    • 参数从右到左推入栈。
    • 调用者(caller)负责在函数调用后清理栈。
    • 返回值通常通过EAX寄存器传递。
    • 用于大多数C语言编译器。
  2. stdcall

    • 参数传递方式与cdecl相同,从右到左推入栈。
    • 被调用者(callee)负责清理栈。
    • 返回值同样通过EAX寄存器传递。
    • 常用于Windows API。
  3. fastcall

    • 前两个(或更多,取决于具体实现)整数或指针参数通过寄存器传递,通常是ECX和EDX,剩余的参数从右到左推入栈。
    • 被调用者负责清理栈。
    • 返回值通过EAX寄存器传递。
    • 旨在减少函数调用的开销。
  4. thiscall

    • 用于C++类成员函数。
    • this​指针通常通过ECX寄存器传递,其余参数从右到左推入栈。
    • 被调用者负责清理栈。
    • 返回值通过EAX寄存器传递。
  5. Pascal

    • 参数从左到右推入栈,这与其他约定相反。
    • 被调用者负责清理栈。
    • 主要用于早期的Borland编译器和Windows API。
  6. Register

    • 尽可能多的参数通过寄存器传递,而不使用栈。
    • 这种调用约定不是特别常见,也不是标准化的。

函数链接

在汇编语言中,链接(Linkage)是指正确地进入和返回函数调用。有两条指令处理链接,即 call <funcName>​ 和 ret​ 指令。call​ 指令将控制权转移到命名的函数,而 ret​ 指令将控制权返回给调用例程。

  • call​ 指令通过保存函数完成时返回的地址(称为返回地址)来工作。这是通过将 rip​ 寄存器的内容放在栈上实现的。回想一下,rip​ 寄存器指向下一条要执行的指令(即 call​ 之后的立即指令)。
  • ret​ 指令在过程中用于返回。ret​ 指令将栈顶(rsp)的当前值弹出到 rip​ 寄存器中。因此,适当的返回地址被恢复。

由于栈被用来支持链接,因此在函数内部不得破坏栈是很重要的。具体来说,任何被推入(push)的项都必须被弹出(pop)。推入一个值而不弹出会导致该值被弹出栈并放入 rip​ 寄存器中。这将导致处理器尝试在该位置执行代码。最有可能的是,无效的位置会导致进程崩溃。

image

参数传递

参数传递是指将信息(变量等)发送给函数,并根据特定函数的要求获取结果。

将值传递给函数的标准术语称为按值调用(call-by-value)。将地址传递给函数的标准术语称为按引用调用(call-by-reference)。这应该是从高级语言中熟悉的话题。

有多种方式可以将参数传递给函数和/或从函数传回:

  • 将值放在寄存器中

    • 最简单,但有限制(即,寄存器的数量)。
    • 用于前六个整数参数。
    • 用于系统调用。
  • 全局定义变量

    • 通常是不良实践,可能会造成混淆,在许多情况下不会起作用。
    • 在有限的情况下偶尔有用。
  • 将值和/或地址放在栈上

    • 传递的参数数量没有具体限制。
    • 导致更高的运行时开销。

一般来说,调用例程被称为调用者(caller),被调用的例程被称为被调用者(callee)。

调用约定

函数的序言(prologue)是函数开头的代码,而函数的尾声(epilogue)是函数结束时的代码。序言和尾声执行的操作通常由标准调用约定指定,并涉及栈、寄存器、传递的参数(如果有的话)以及栈动态局部变量(如果有的话)。

总体思路是保存程序状态(即特定寄存器和栈的内容),执行函数,然后恢复状态。当然,函数通常需要广泛使用寄存器和栈。序言代码帮助保存状态,尾声代码恢复状态。

参数传递

如前所述,参数传递给函数和/或从函数传回使用的是寄存器和栈的组合。

前六个整数参数通过以下寄存器传递:

参数编号64位参数32位参数16位参数8位参数
1rdiedididil
2rsiesisisil
3rdxedxdxdl
4rcxecxcxcl
5r8r8dr8wr8b
6r9r9dr9wr9b

第七个和之后的任何额外参数都通过栈传递。标准调用约定要求,当通过栈传递参数(值或地址)时,应该以相反的顺序推送参数。也就是说,“someFunc(one, two, three, four, five, six, seven, eight, nine)”将意味着推送的顺序是:nine, eight, 然后是 seven。

对于浮点参数,浮点寄存器 xmm0 到 xmm7 按顺序用于前八个浮点参数。

此外,在函数执行完毕后,调用例程负责从栈中清除参数。而不是执行一系列的 pop 指令,栈指针 rsp 将根据需要调整以清除栈上的参数。由于每个参数是 8 字节,调整将是向 rsp 添加 [(参数数量) * 8]。

对于返回值的函数,根据返回值的大小,结果将放在 A 寄存器中。

image

只要在返回之前正确设置返回值,就可以根据需要在函数中使用 rax 寄存器

寄存器使用

标准调用约定规定了在进行函数调用时寄存器的使用方式。

具体来说,某些寄存器在函数调用过程中应保持其值不变。这意味着,如果一个值被放置在一个需要保留的寄存器或已保存的寄存器中,并且函数必须使用该寄存器,那么原始值必须通过将其放在栈上来保留,根据需要进行修改,然后在返回调用例程之前恢复其原始值。

这种寄存器的保护通常在序言中执行,而恢复通常在尾声中执行。

下表总结了寄存器的使用情况:

寄存器使用情况
rax返回值
rbx被调用者保存
rcx第四个参数
rdx第三个参数
rsi第二个参数
rdi第一个参数
rbp被调用者保存
rsp栈指针
r8第五个参数
r9第六个参数
r10临时寄存器
r11临时寄存器
r12被调用者保存
r13被调用者保存
r14被调用者保存
r15被调用者保存

临时寄存器(rax、r10 和 r11)和参数寄存器(rdi、rsi、rdx、rcx、r8 和 r9)在函数调用过程中不保留。这意味着这些寄存器中的任何一个都可以在函数中使用,而无需保留原始值。此外,所有浮点寄存器在函数调用过程中也都不保留。

更多关于浮点操作的信息,请参考第18章。

调用帧

作为函数调用一部分的栈上的项目被称为调用帧(也称为激活记录或栈帧)。基于标准调用约定,如果栈上有项目,它们将具有特定的一般格式。

调用帧中可能包含的项目包括:

  • 返回地址(必需的)。
  • 被保留的寄存器(如果有的话)。
  • 传递的参数(如果有的话)。
  • 基于栈的动态局部变量(如果有的话)。

其他项目也可能被放置在调用帧中,例如动态作用域语言的静态链接。这些主题超出了本文的范围,在此不做讨论。

对于某些函数,可能不需要完整的调用帧。例如,如果函数:

  • 是叶函数(即,不调用其他函数)。
  • 仅通过寄存器传递其参数(即,不使用栈)。
  • 不更改任何已保存的寄存器。
  • 不需要基于栈的局部变量。

这种情况可能发生在更简单、更小的叶函数中。

然而,如果这些条件中的任何一个不成立,就需要一个完整的调用帧。

对于更多的非叶函数或更复杂的函数,需要一个更完整的调用帧。

标准调用约定并没有明确要求使用帧指针寄存器 rbp。编译器被允许优化调用帧而不使用帧指针。为了简化和澄清访问基于栈的参数(如果有的话)和基于栈的动态局部变量,本文将使用帧指针寄存器。这类似于许多其他架构使用帧指针寄存器的方式。

因此,如果函数内部需要任何基于栈的参数或任何局部变量,应当推入帧指针寄存器 rbp,并将其设置为指向自身。随着额外的推入和弹出操作(从而改变 rsp),rbp 寄存器将保持不变。这允许使用 rbp 寄存器作为访问在栈上传递的参数(如果有的话)或基于栈的动态局部变量(如果有的话)的参考。

例如,假设一个函数调用有八个(8)参数,并且假设函数使用 rbx、r12 和 r13 寄存器(因此必须被推入),调用帧将如下所示:

image

基于栈的参数相对于 rbp 来访问。每个推入的项是一个四字节,占用 8 字节。例如,[rbp+16] 是第一个传递参数(第七个整数参数)的位置,而 [rbp+24] 是第二个传递参数(第八个整数参数)的位置。

此外,调用帧将包含局部变量的指定位置(如果有的话)。关于局部变量的章节详细说明了分配和使用局部变量的具体情况。

红区(Red Zone)

在 Linux 标准调用约定中,栈指针 rsp 之后的前 128 字节是保留的。例如,延续前面的例子,调用帧将如下所示:

image

这个红色区域可以被函数使用,而无需对栈指针进行任何调整。其目的是允许编译器优化局部变量的分配。

这不会直接影响直接用汇编语言编写的程序。

示例:统计函数(叶函数)

这个简单的示例将演示调用一个简单的 void 函数来找出一个数字数组的总和和平均值。C/C++的高级语言(HLL)调用如下:

stats1(arr, len, sum, ave);

按照 C/C++ 约定,数组 arr​ 是按引用调用,长度 len​ 是按值调用。参数 sum​ 和 ave​ 都是按引用调用(因为目前还没有值)。在这个示例中,数组 arr​、sum​ 和 ave​ 变量都是有符号双字整数。当然,在上下文中,len​ 必须是无符号的。

12.9.1 调用者

在这个案例中,有 4 个参数,所有参数都是按照标准调用约定通过寄存器传递的。对 stats​ 函数的调用,调用例程中的汇编语言代码如下:

; stats1(arr, len, sum, ave);
mov rcx, ave    		; 第四个参数,ave 的地址
mov rdx, sum    		; 第三个参数,sum 的地址
mov esi, dword [len] 	; 第二个参数,len 的值
mov rdi, arr    		; 第一个参数,arr 的地址
call stats1

设置参数寄存器的顺序没有具体要求。这个示例是按逆序设置它们,为下一个扩展示例做准备。

注意,设置 esi​ 寄存器也会将高序双字置零,从而确保 rsi​ 寄存器为这个特定用途正确设置,因为长度是无符号的。

这个 void 函数不提供返回值。如果函数是返回值的函数,返回的值将在 A 寄存器中(适当大小)。

被调用者

被调用的函数,即被调用者,在执行函数目标的代码之前和之后,必须执行序言和尾声操作(按照标准调用约定指定)。在这个示例中,函数必须执行数组中值的求和,计算整数平均值,返回总和和平均值。

以下代码实现了 stats1​ 示例:

; 简单示例函数,找到并返回
; 一个数组的总和和平均值。
; HLL 调用:
; stats1(arr, len, sum, ave);
; -----
; 参数:
; arr, 地址 - rdi
; len, 双字值 - esi
; sum, 地址 - rdx
; ave, 地址 - rcx

global stats1
stats1:
    push r12         ; 序言
    mov r12, 0       ; 计数器/索引
    mov rax, 0       ; 运行总和
sumLoop:
    add eax, dword [rdi+r12*4] ; sum += arr[i]
    inc r12
    cmp r12, rsi
    jl sumLoop
    mov dword [rdx], eax ; 返回 sum
    cqo
    idiv esi            ; 计算平均值
    mov dword [rcx], eax ; 返回 ave
    pop r12             ; 尾声
    ret

对于这个函数,调用帧将如下所示:

...    ← 更高的地址
rip    (返回地址)
r12    ← rsp
...    ← 更低的地址

栈的最小化使用有助于减少函数调用的运行时开销。

在上述汇编代码示例中,使用 r12​ 寄存器是一个设计选择。选择 r12​ 寄存器是任意的,但是选择了一个“被保存的寄存器”:

  1. 被调用者保存寄存器(Callee-saved Register) :在 x86-64 架构中,某些寄存器被定义为在函数调用过程中应由被调用者(callee)保存其值。这意味着如果这些寄存器在函数中被修改了,函数需要在返回之前恢复它们的原始值。r12​ 就是这样的一个寄存器。因此,如果函数中间需要使用一个临时变量,而又不想影响调用者(caller)的状态,使用 r12​ 是合适的。
  2. 避免寄存器冲突:在函数调用中,rdi​、rsi​、rdx​、rcx​、r8​ 和 r9​ 通常用于传递参数。如果这些寄存器已经被用于传递参数,使用 r12​ 可以避免与参数传递发生冲突。
  3. **保留原始状态:使用 r12​ 可以在不影响函数外部状态的情况下,在函数内部保留一个计数器或临时值。在函数的序言(prologue)中将 r12​ 压栈,在函数的尾声(epilogue)中将其弹出,可以确保函数执行前后 r12​ 的值保持不变。
  4. 优化考虑**:某些情况下,编译器可能会优化代码以使用这些被调用者保存的寄存器,因为它知道这些寄存器在函数调用后会保持原值。这可能有助于改善性能,尤其是在递归调用或多层函数调用的情况下。

示例二, 非页函数

调用者部分

; 调用 stats2 函数
push ave    ; 8th arg, 地址 of ave
push sum    ; 7th arg, 地址 of sum
mov r9, max ; 6th arg, 地址 of max
mov r8, med2 ; 5th arg, 地址 of med2
mov rcx, med1 ; 4th arg, 地址 of med1
mov rdx, min ; 3rd arg, 地址 of min
mov esi, dword [len] ; 2nd arg, 值 of len
mov rdi, arr ; 1st arg, 地址 of arr
call stats2
add rsp, 16 ; 清除传递的参数

被调用者部分

; 简单的示例函数,计算并返回数组的最小值、最大值、总和、中位数和平均值。
; -----
; 高级语言调用:
; stats2(arr, len, min, med1, med2, max, sum, ave);
; 
; 参数:
; arr, 地址 - rdi
; len, dword 值 - esi
; min, 地址 - rdx
; med1, 地址 - rcx
; med2, 地址 - r8
; max, 地址 - r9
; sum, 地址 - 栈 (rbp+16)
; ave, 地址 - 栈 (rbp+24)

global stats2
stats2:
    push rbp          ; prologue
    mov rbp, rsp
    push r12          ; 保存 r12 寄存器的值

    ; 获取最小值和最大值
    mov eax, dword [rdi] ; 获取数组的第一个元素作为最小值
    mov dword [rdx], eax ; 返回最小值
    mov r12, rsi         ; 获取 len 的值
    dec r12              ; len - 1
    mov eax, dword [rdi + r12 * 4] ; 获取数组的最后一个元素作为最大值
    mov dword [r9], eax  ; 返回最大值

    ; 获取中位数
    mov rax, rsi
    mov rdx, 0
    mov r12, 2
    div r12              ; rax = len / 2
    cmp rdx, 0           ; 判断长度是偶数还是奇数
    je evenLength
    mov r12d, dword [rdi + rax * 4] ; 获取 arr[len/2]
    mov dword [rcx], r12d ; 返回 med1
    mov dword [r8], r12d  ; 返回 med2
    jmp medDone
evenLength:
    mov r12d, dword [rdi + rax * 4] ; 获取 arr[len/2]
    mov dword [r8], r12d  ; 返回 med2
    dec rax
    mov r12d, dword [rdi + rax * 4] ; 获取 arr[len/2 - 1]
    mov dword [rcx], r12d ; 返回 med1
medDone:

    ; 计算总和
    mov r12, 0          ; 索引/计数器
    mov rax, 0          ; 总和
sumLoop:
    add eax, dword [rdi + r12 * 4] ; sum += arr[i]
    inc r12
    cmp r12, rsi
    jl sumLoop
    mov r12, qword [rbp + 16] ; 获取 sum 的地址
    mov dword [r12], eax      ; 返回 sum

    ; 计算平均值
    cqo
    idiv rsi                 ; average = sum / len
    mov r12, qword [rbp + 24] ; 获取 ave 的地址
    mov dword [r12], eax     ; 返回 ave

    pop r12                  ; epilogue
    pop rbp
    ret

实现分析

  1. 调用者部分:调用者将参数准备好并按照x86-64的调用约定传递给stats2​函数。前六个整数参数通过寄存器传递,剩余的参数通过栈传递。调用stats2​函数后,调用者清理了栈上的参数。

  2. 被调用者部分stats2​函数):

    • Prologue:函数开始时保存了rbp​和r12​的值。
    • 计算最小值和最大值:函数取数组的第一个元素作为最小值,最后一个元素作为最大值,并将这些值存储在提供的地址中。
    • 计算中位数:函数根据数组长度是奇数还是偶数来计算中位数,并将结果存储在提供的地址中。
    • 计算总和:通过循环遍历数组并累加每个元素的值来计算总和。
    • 计算平均值:使用总和除以数组长度来计算平均值,并将结果存储在提供的地址中。
    • Epilogue:在函数结束时恢复r12​和rbp​的值,并返回到调用者。

这个函数假设数组是已经排序的,因为它直接取第一个和最后一个元素作为最小值和最大值,而不进行任何比较。同样,中位数的计算也假设数组是有序的。

image

总结

调用方总结

  • 前六个整数参数在寄存器中传递

    • rdi, rsi, rdx, rcx, r8, r9
  • 第 7 个及第 7 个参数基于栈传递

    • 以相反的顺序将参数压入堆栈(从右到左,以便函数调用中指定的第一个堆栈参数最后压入)
    • 推送的参数作为四字传递。
    • 调用者执行调用指令将控制权传递给函数(被调用者)。(通过rbp来实现)
    • 从堆栈中清除基于堆栈的参数: add rsp, <argCount*8>

被调用方总结

  • 函数序言(Prologue) (非叶函数)

    • 如果参数是通过栈传递的,被调用者必须将rbp保存到栈上,并将rsp的值移动到rbp。这允许被调用者使用rbp作为帧指针,以统一的方式访问栈上的参数。

      • 被调用者可以相对于rbp访问其参数。位于[rbp]的四字节保存了之前的rbp值,因为它被推送了;下一个四字节,在[rbp+8]处,保存了由call指令推送的返回地址。参数从那之后开始,在[rbp+16]处。
    • 如果需要局部变量,被调用者将rsp进一步减小,以在栈上分配空间给局部变量。局部变量可以通过rbp的负偏移量访问。

    • 如果被调用者希望向调用者返回一个值,应该根据返回值的大小,将值留在al、ax、eax、rax中。

      • 浮点结果返回在xmm0中。
    • 如果有变化,寄存器rbx、r12、r13、r14、r15和rbp必须保存在栈上。

  • 函数执行

    • 执行函数代码。
  • 函数尾声(Epilogue)

    • 恢复任何被推送的寄存器。
    • 如果使用了局部变量,被调用者从rbp恢复rsp,以清除基于栈的局部变量。
    • 被调用者恢复(即弹出)之前的rbp值。
    • 通过ret指令返回(return)。