Fork me on GitHub

C++ 栈帧 StackFrame

C++ 栈帧 Stack Frame

参考文献:

1. 栈帧是什么

栈帧是调用栈上的一个数据结构,用于存储函数调用的相关信息,包括:

  1. 函数的参数

    传递给函数的参数

  2. 返回地址

    函数返回时应该跳转到的地址

  3. 局部变量

    函数内部定义的变量

  4. 保存的寄存器

    函数调用过程中需要保存和恢复的寄存器值

2. 栈帧准备

在函数调用之前,编译器生成代码以分配新的栈帧,并进行必要的初始化。包括:

  1. 传递参数

    将函数参数压入栈或放入寄存器

  2. 保存返回地址

    将返回地址压入栈

  3. 保存调用者的栈指针

    保存当前帧指针(通常是 EBPRBP

  4. 设置新的栈指针

    设置新的栈指针指向当前栈顶

  5. 分配局部变量的空间

    调整栈指针(ESPRSP) 为局部变量分配空间

在 x86 和 x86-64 体系结构中,EBP , RBP, ESP, RSP 是一些重要的 寄存器 名称,他们的缩写和作用如下:

EBP 和 RBP

  • EBP
    • Extended Base Pointer
  • RBP
    • Register Base Pointer
  • 这两个寄存器用于保存当前栈帧的 基地址,通常用于访问函数的 参数和局部变量。在 x86 架构中使用 EBP,而在 x86-64 (也称为 AMD64 或 x64) 架构中使用 RBP

ESP 和 RSP

  • ESP
    • Extended Stack Pointer
  • RSP
    • Register Stack Pointer
  • 这两个寄存器指向栈的 顶部。在 x86 架构中使用 ESP,而在 x86-64 架构中使用 RSP栈指针 用于管理函数调用时的栈帧,指向当前栈的顶端。

具体作用

EBP / RBP (Base Pointer)

  • 用途: 作为栈帧基址指针,用于访问函数的参数和局部变量。
  • 典型操作
    • 在函数开始时保存调用者的基址指针。
    • 设置新的栈帧基址。
    • 在函数结束时恢复调用者的基址指针。

ESP / RSP (Stack Pointer)

  • 用途: 作为栈指针,指向当前栈帧的顶端。
  • 典型操作
    • 用于压栈和出栈操作。
    • 调整以分配或释放栈空间。

栈帧操作示例

以下是一个函数调用时使用这些寄存器的典型操作流程:

push   rbp        ; 保存调用者的基址指针
mov    rbp, rsp   ; 设置当前栈帧基址指针
sub    rsp, 16    ; 为局部变量分配栈空间
...
add    rsp, 16    ; 清理栈上的局部变量空间
pop    rbp        ; 恢复调用者的基址指针
ret               ; 返回

在上述示例中,rbp(基址指针)用于访问函数的参数和局部变量,而 rsp(栈指针)用于管理栈的顶端。rbp 指向栈帧的基址,rsp 指向栈帧的顶端。

x86 和 x86-64 寄存器对比

  • x86 (32-bit)
    • EBP: 32-bit base pointer register.
    • ESP: 32-bit stack pointer register.
  • x86-64 (64-bit)
    • RBP: 64-bit base pointer register.
    • RSP: 64-bit stack pointer register.

在 64 位架构中,RBPRSP 的作用与 32 位架构中的 EBPESP 类似,但它们是 64 位寄存器,可以处理更大的地址空间。

了解这些寄存器的作用和操作,有助于理解函数调用过程中的栈帧管理和调试程序中的栈帧相关问题。

3. 具体示例

以下是一个具体的例子,用于解释函数调用时栈帧的准备过程

void bar(int x)
{
    int y = x * 2;
    // 栈帧准备
}

如果有以下的 main 函数调用 bar:

int main()
{
    int a = 5;
    bar(a);
    return 0;
}

编译器生成的汇编代码可能类似如下(这是一个简化示例,具体实现取决于编译器和目标平台):

  1. main 函数的栈帧准备

    main:
    	push	rbp					; 保存当前帧指针
    	mov		rbp, rsp			; 将当前栈指针 rsp 的值复制到基址指针 rbp,设置新的帧指针
    	sub		rsp, 16				; 为局部变量 a 分配空间,并对齐栈指针(假设 a 占用 4 字节,另 12 字节为对齐)
    	mov		dword ptr [rbp-4], 5	; 将值 5 存入局部变量 a 的内存位置
    	mov 	eax, dword ptr [rbp-4]	; 将局部变量 a 的值加载到寄存器 eax 中
    	mov 	edi, eax			; 准备第一个参数 x
    	call	bar					; 调用 bar 函数
    	add		rsp, 16				; 清理栈上的局部变量
    	pop		rbp					; 恢复帧指针
    	ret							; 返回
    
  2. bar 函数的栈帧准备

    bar:
    	push	rbp					; 保存当前帧指针
    	mov		rbp, rsp			; 设置新的帧指针
    	sub		rsp, 16				; 为局部变量 y 分配空间,并对齐栈指针(假设 y 占用 4 字节,另 12 字节为对齐)
    	mov 	dword ptr [rbp-4], edi	; 将传入的参数 x 存入局部变量的空间
    	mov 	eax, dword ptr [rbp-4]	; 将 x 的值加载到寄存器 eax 中
    	lea		ecx, [rax*2]		; 计算 y = x * 2
    	mov 	dword ptr [rbp-8], ecx	; 将结果存入局部变量 y 的位置
    	; 函数体执行其他代码
    	add		rsp, 16				; add 加 16,清理栈上的局部变量,恢复到调用函数之前的状态
    	pop		rbp					; 恢复帧指针
    	ret							; 返回
    

4. 详细解释

4.1 汇编指令和寄存器解释

  • pushpop

    • push

      • 压栈操作,将 register 中的值保存到栈中,并减少栈指针 rsp
    • pop

      • 出栈操作,从 栈 中取出值,并增加栈指针 rsp
    • 例如:

      push rbp
      

      这条指令将当前 rbp 寄存器的值保存到栈中

  • mov

    • mov

      • 数据传承指令,用于将数据从一个位置传送到另一个位置
    • 例如:

      mov rbp, rsp
      

      这条指令将栈指针 rsp 的当前值复制到基址指针 rbp

  • sub

    • sub

      • 减法操作 subtraction
      • 用于从一个寄存器中减去一个 立即数 或另一个寄存器的值
    • 例如:

      sub rsp, 16
      

      这条指令将栈指针 rsp 减去 16 个 字节,以分配 16 个字节的栈空间 (用于局部变量)

    • 为什么要对齐到 16 个字节

      • 见下一章 内存对齐
  • dword

    • dword

      • Double Word 缩写,表示 32 位(4 字节)的数据类型
    • 例如:

      mov dword ptr [rbp-4], 5
      

      这条指令将立即数 5 存储到 基址指针rbp 减去 4 字节的位置(即局部变量的存储位置)。rbp-4 是因为 栈 向下增长,减去 4 字节表示向栈的底部方向移动。

    • 为什么减去 4 字节?

      • 因为 32 位系统中,一个 int 通常占用 4 个字节,因此当存储局部变量时,编译器会根据变量的类型和大小来调整栈指针的位置
    • eaxedi

      • eax: Extended Accumulator Register,x86-64 架构下的 32 位累加器寄存器,通常用于算术运算和函数返回值。
      • edi: Extended Destination Index Register,x86-64 架构下的 32 位目的地址寄存器,通常用于字符串或数组操作。在函数调用中,常作为参数寄存器之一。

4.2 栈操作解释

  • add

    • 加法操作,用于将一个立即数或另一个 register 的值加到另一个 register 中

    • 例如:

      add rsp, 16
      

      这条指令将栈指针 rsp 加上 16 个字节,回复栈指针到调用函数之前的状态。

      这是为了清理函数调用过程中位局部变量分配的栈空间

  • ret

    • Return,从函数返回,取出栈顶的返回地址,并跳转到改地址继续执行

5. 内存对齐

参考文献:

上面的汇编中有一行指令 sub rsp, 16

表示将栈指针 rsp 减去 16 个字节,以分配 16 个字节的栈空间(用于局部变量)

对齐到 16 bytes 的原因主要是出于性能优化的考虑。以下是详细解释:

5.1 内存对齐的背景

5.1.1 内存对齐

  • 是将数据存储在特定的内存地址上,这些地址通常是数据大小的倍数(如 4 bytes, 8 bytes 或 16 bytes)。

  • 对现代 CPU 来说,内存对齐能够提高内存访问的效率,因为未对齐的内存访问可能需要额外的 CPU 周期。

img

读取非对齐内存的过程示例

为了提高数据读取的效率,程序分配的内存并不是连续存储的,而是按首地址为k的倍数的方式存储;这样就可以一次性读取数据,而不需要额外的操作。

在某些平台下,不进行内存对齐会崩溃

这里介绍到此为止,更多细节见 https://blog.csdn.net/ZJU_fish1996/article/details/108858577

5.1.2 16 字节对齐的原因

  1. SIMD 指令集支持

    • SIMD(Single Instruction, Multiple Data)
    • 用一个指令 并行 地对多个数据进行运算,是 CPU 基本指令集的扩展
      • 处理器 的 register 通常是 32 位或 64 位的,而图像的一个像素点可能只有 8 bits,如果一次只能处理一个数据比较浪费空间
      • 此时可以将 64 bits 寄存器拆成 8 个 8 bits 寄存器,就可以 并行 完成 8 个操作,提升效率。
    • 指令集 (如 SSE, AVX) 常用于处理向量操作。这些指令集通常要求操作数的内存地址是 16 字节(128位)或 32 字节(256位)对齐的,以便于高效的向量处理。
      • SSE 指令采用 128 位寄存器,我们通常将 4 个 32 位 浮点值 打包到 128 位寄存器中,单个指令可以完成 4 对浮点数的运算,这对于矩阵/向量操作非常友好(除此之外,还有 Neon/FPU 等寄存器)
  2. 缓存行对齐

    CPU 缓存的典型缓存行大小是 64 字节。16 字节对齐的数据可以更好地利用缓存行,从而提高缓存命中率,减少内存访问延迟。

  3. ABI (应用二进制接口)要求

    许多系统的 ABI 要求栈帧对齐到 16 字节,以确保兼容性和性能。例如,x86-64 System VABI 要求函数调用时栈必须对齐到 16 字节

5.1.2.1 示例
void func()
{
    int a = 5;
    double b = 3.14;
}

为了对齐到 16 字节,汇编代码可能如下:

push rbp					; 保存当前帧指针
mov rbp, rsp				; 设置新的帧指针
sub rsp, 16					; 为局部变量分配空间(对齐到 16 字节)
mov dword ptr [rbp-4], 5	; 将 5 存储到局部变量 a 的位置
mov qword ptr [rbp-12], 0x40091EB8512EB851F	; 将 3.14 的 64 位表示存储到局部变量 b 的位置
5.1.2.2 栈帧布局

在上面的示例中,栈帧布局如下:

[高地址]
rbp + 0		(保存的旧帧指针)
rbp - 4		(int a, 4 字节)
rbp - 12	(double b, 8 字节)
[低地址]
5.1.2.3 具体解释
  • push rbpmov rbp, rsp
    • 保存当前帧指针并设置新的帧指针
  • sub rsp, 16
    • 分配 16 字节的栈空间。这不仅是为了局部变量(int a 4 字节,double b 8 字节),而且还为了确保栈帧对齐到 16 字节
  • mov dword ptr [rbp-4], 5
    • 将整数 5 存储到 rbp-4 的位置
  • mov qword ptr [rbp-12], 0x40091EB851EB851F
    • 将 double 类型的 3.14 的二进制表示存储到 rbp-12 的位置

5.2 内存对齐的实际影响

  1. 高速缓存优化

    对齐的数据在缓存中更容易被管理和访问

  2. 内存访问效率

    未对齐的内存访问可能需要多个内存访问操作,而对齐的内存访问通常只需要一次

  3. 硬件要求

    一些硬件(如 SIMD 寄存器)要求数据对齐,否则可能会产生异常或性能下降

总的来说,16 字节(128 位)对齐是为了利用现代 CPU 架构中的各种优化,提高内存访问效率和整体性能。在编写高性能代码时,特别是在处理 SIMD 操作或遵循特定的 ABI 要求时,确保数据对齐是非常重要的。

这里再扩展一点,然后到此为止,SIMD 具体是什么,在这里是怎么使用的,我也是没搞懂

多个局部变量的对齐

假设你的函数有多个局部变量,包括 intdouble,汇编代码需要确保栈帧对齐。示例代码:

assembly复制代码push rbp                     ; 保存当前帧指针
mov rbp, rsp                 ; 设置新的帧指针
sub rsp, 32                  ; 为局部变量分配32字节的空间(对齐到16字节)
mov dword ptr [rbp-4], 5     ; 将5存储到局部变量a的位置(int a)
mov qword ptr [rbp-12], 0x40091EB851EB851F ; 将3.14的64位表示存储到局部变量b的位置(double b)
mov qword ptr [rbp-20], 0x4014000000000000 ; 将5.0的64位表示存储到局部变量c的位置(double c)

在这个例子中,sub rsp, 32 分配了 32 字节的栈空间以对齐到 16 字节边界。这里为了对齐到16字节边界并为局部变量提供足够的空间,减去32字节。

SIMD 和单条指令的并行计算

  • 将4个32位浮点值打包到128位寄存器中,单个指令可完成4对浮点数的计算。

这意味着 SIMD 指令可以并行处理多个数据。例如,使用 SSE 指令集,你可以用一条指令对四个 32 位浮点数进行加法、减法、乘法或其他操作。这种并行处理大大提高了处理速度。

栈操作与 SIMD 并行计算

sub rsp, 16mov dword ptr [rbp-4], 5mov qword ptr [rbp-12], 0x40091EB851EB851F 这些操作是普通的栈操作,与 SIMD 指令并行计算的关系如下:

  • 栈操作:这些指令在设置和管理函数栈帧,为局部变量分配空间。
  • SIMD 并行计算:这是在寄存器层面进行的优化,与内存对齐有关。对齐到 16 字节是为了满足 SIMD 指令对齐要求,但栈操作本身并不直接使用 SIMD。

是否在一个128位寄存器中

  • sub rsp, 16:这是为局部变量分配空间,并确保栈对齐到16字节边界。
  • mov dword ptr [rbp-4], 5mov qword ptr [rbp-12], 0x40091EB851EB851F:这些是普通的存储操作,将整数和浮点数存储到栈中对应的位置。

这些操作与 SIMD 并行计算无关。SIMD 并行计算使用的是寄存器,而不是内存位置。因此,它们不会将一个 int 和一个 double 放在同一个 128 位寄存器中,也不会用一条指令对这两个数进行运算。SIMD 指令通常对相同类型的数据进行并行处理。

总结

  • SIMD 是指在寄存器中并行处理多个数据。
  • 对齐到 16 字节 是为了满足 SIMD 指令集的要求以及提高内存访问效率。
  • 栈帧对齐 是为了满足 ABI 要求和优化性能。
  • 栈操作与 SIMD 并行计算:普通的栈操作和 SIMD 并行计算是不同的概念,栈操作是为了函数调用和局部变量管理,而 SIMD 是在寄存器中并行处理多个相同类型的数据。
posted @ 2024-06-17 10:12  icewalnut  阅读(28)  评论(0编辑  收藏  举报