目录
[0 相关内容](#0 相关内容)
[1 RISC-V架构下的标准C函数](#1 RISC-V架构下的标准C函数)
[2 标准C函数的函数头和函数尾](#2 标准C函数的函数头和函数尾)
[2. 栈帧(Stack Frame):函数的私人工作区](#2. 栈帧(Stack Frame):函数的私人工作区)
[3. 函数头(Prologue):建立栈帧](#3. 函数头(Prologue):建立栈帧)
[4. RISC-V函数栈帧结构](#4. RISC-V函数栈帧结构)
[5. 函数尾(Epilogue):销毁栈帧](#5. 函数尾(Epilogue):销毁栈帧)
[6. 优化与 fp 的省略](#6. 优化与 fp 的省略)
0 相关内容
【笔记】RISC-V架构:fp寄存器与backtrace栈回溯
【外部链接】RISC-V函数栈帧结构与backtrace
1 RISC-V架构下的标准C函数
在 RISC-V 架构中,一个"标准 C 函数"不仅仅是你用 C 语言写的代码块,它在被编译器翻译成汇编指令后,会严格遵循一套称为应用程序二进制接口(ABI,Application Binary Interface) 的规则。这套规则确保了不同函数之间可以正确、高效地协同工作。而函数头(Prologue) 和函数尾(Epilogue),就是编译器在每个函数的开头和结尾自动插入的、用于维护这些规则的指令序列。
2 标准C函数的函数头和函数尾
1.为什么需要函数头和尾?
当一个函数(称为被调用者,callee)被另一个函数(称为调用者,caller)调用时,会发生几件事情:
-
控制权转移:CPU 需要跳转到被调用者的代码。
-
数据共享:调用者可能需要传递参数给被调用者。
-
资源使用:被调用者会使用寄存器来运算,但它不能随意破坏调用者正在使用的寄存器内容。
-
正确返回:被调用者执行完毕后,必须把控制权准确地交还给调用者,并返回结果。
函数头和函数尾就是为了解决"资源使用"和"正确返回"这两个问题而生的。它们共同负责建立和销毁当前函数的栈帧(Stack Frame)。
2. 栈帧(Stack Frame):函数的私人工作区
栈帧是当前函数在栈上独占的一块内存区域。它就像一个临时的"办公室",用来存放:
-
返回地址:函数执行完后,应该回到哪里。
-
调用者的帧指针:用于在函数返回后恢复调用者的栈帧。
-
被调用者需要保存的寄存器 :当前函数打算使用的、但又不允许破坏的寄存器(在 RISC-V 中主要是
s0-s11和sp、gp、tp等)。 -
局部变量:函数内部定义的变量。
-
传递给其他函数的参数(当参数太多,寄存器放不下时)。
在 RISC-V 的 ABI 中,有两个寄存器对栈帧管理至关重要:
-
sp(栈指针) :始终指向栈帧的顶部(也就是当前可用栈空间的最低地址,因为栈是向下生长的)。它像一个移动的标记,随着函数内的压栈和出栈操作而变化。 -
fp(帧指针) :指向当前栈帧的底部 (也就是一个固定的参考点)。它通常指向刚进入函数时,保存了调用者fp的那个位置。有了fp,即使在函数执行过程中sp上下移动,也可以通过fp加上固定的偏移量,稳定地访问局部变量和调用者的信息。
3. 函数头(Prologue):建立栈帧
当一个函数被调用时,它的第一条指令序列(函数头)就开始着手建立自己的"办公室"。通常包含以下步骤:
-
保存返回地址 :如果当前函数还需要调用其他函数,它必须把
ra(返回地址)保存到栈上,否则再次调用时ra会被覆盖。 -
保存调用者的帧指针 :将调用者的
fp值压入栈中。这样,当本函数退出时,可以恢复调用者的fp。 -
设置自己的帧指针 :将当前的
sp值复制到fp。现在,fp就指向了这个新栈帧的底部。 -
分配局部变量空间 :将
sp向下移动(减去一个值),为局部变量和临时存储腾出空间。
一个 RISC-V 汇编的例子:
假设有一个简单的 C 函数:
int add_and_double(int a, int b) {
int c = a + b;
return c * 2;
}
如果使用帧指针(编译选项 -fno-omit-frame-pointer),编译器可能生成类似如下的汇编,其中函数头就是标注的部分:
add_and_double:
# 函数头 (Prologue) 开始
addi sp, sp, -16 # 1. 分配栈空间 (16字节)
sw ra, 12(sp) # 2. 保存返回地址 (ra)
sw s0, 8(sp) # 3. 保存调用者的帧指针 (s0)
addi s0, sp, 16 # 4. 设置新的帧指针 (s0 指向旧栈顶)
# 函数头结束
# 函数体
add a0, a0, a1 # c = a + b (结果在 a0)
addi t0, zero, 2 # t0 = 2
mul a0, a0, t0 # a0 = c * 2 (返回值)
# 函数尾 (Epilogue) 开始
lw s0, 8(sp) # 1. 恢复调用者的帧指针
lw ra, 12(sp) # 2. 恢复返回地址
addi sp, sp, 16 # 3. 释放栈空间
ret # 4. 返回 (跳转到 ra)
# 函数尾结束
4. RISC-V函数栈帧结构

地址从高到低扩展,fp 指向函数帧的首地址,sp 是栈指针,与函数帧无关。fp-8 指向返回地址(对应图中 Return Address ),fp-16 指向上一函数帧的首地址(对应图中 To Prev. Frame )
5. 函数尾(Epilogue):销毁栈帧
当函数执行完毕,准备返回时,函数尾负责清理现场,确保调用者能无缝衔接。它的操作与函数头完全对称:
-
释放局部变量空间 :将
sp增加回刚进入函数时的值(通常使用addi sp, sp, XX)。 -
恢复调用者的帧指针 :从栈上加载之前保存的调用者
fp值到fp寄存器。 -
恢复返回地址 :从栈上加载之前保存的
ra值到ra寄存器。 -
执行返回指令 :在 RISC-V 中通常是
ret(它是jalr x0, x1, 0的伪指令)。
6. 优化与 fp 的省略
你可能会想,既然有了 sp,为什么还需要 fp?因为 sp 在函数执行中会不断变化(比如在调用其他函数前要压栈参数),用它来访问局部变量会变得复杂和低效。fp 提供了一个稳定的锚点。
然而,在开启优化(如 -O1 或更高)时,编译器可以追踪 sp 的变化,仍然可以通过 sp 加上动态计算的偏移量来访问局部变量。如果同时使用 -fomit-frame-pointer 选项,编译器就会省略掉帧指针 。此时,函数头中就不再保存和设置 fp,而是直接使用 sp 来管理栈帧。这会节省一点栈空间(少保存一个寄存器)和一个寄存器(fp/s0 可以被用作普通变量寄存器),但会让调试时的栈回溯变得更复杂一些(因为没有了稳定的帧指针链)。
总结
-
函数头(Prologue) :一个标准 C 函数的"开场白",由编译器自动生成,负责建立栈帧,保存返回地址和调用者寄存器,为局部变量腾出空间。
-
函数尾(Epilogue) :这个函数的"结束语",同样由编译器生成,负责销毁栈帧,恢复之前保存的寄存器和返回地址,然后交还控制权。
-
核心目的 :确保函数调用的嵌套 和递归可以正确进行,同时保证每个函数的局部数据不会互相干扰,并且调用者的状态在函数返回后能完美恢复。
正是因为有这样一套严谨的、由编译器和 ABI 共同保障的机制,我们才能用 C 语言安全地编写出无限嵌套的函数调用。而【【笔记】RISC-V架构:fp寄存器与backtrace栈回溯】中讨论的 FreeRTOS 异常入口(手写汇编)之所以不需要函数头和尾,是因为它不遵循标准的函数调用约定,它直接操作硬件状态,目标是保存整个任务上下文,而不是作为一个普通函数被调用。