嵌入式开发学习:关于函数
本文解析了C/C++中函数的定义、类型及底层实现机制。
1. C/C++中函数的定义与类型
一、C/C++中函数的定义与类型
在C/C++中,函数是一个执行特定任务的代码块。它使得代码可以被重用、组织和模块化。
1、函数的构成要素
一个标准的函数定义包含以下几个部分:
-
返回类型 (Return Type): 函数执行完毕后返回给调用者的数据的类型。如果不需要返回任何值,则使用
void。 -
函数名 (Function Name): 用于唯一标识函数的名称。
-
参数列表 (Parameter List): 传递给函数的数据。每个参数都包含一个类型和一个名称。如果函数不接受任何参数,则列表为空或使用
void。 -
函数体 (Function Body): 包含在
{}中的一系列指令,定义了函数要执行的具体操作。
// 这是一个完整的函数定义
int add(int a, int b) { // 返回类型是 int, 函数名是 add, 参数列表是 (int a, int b)
int sum = a + b; // 函数体
return sum; // 返回值
}
2、函数的常见类型
除了上面的标准函数,还有一些特殊的函数形式:
-
内联函数 (Inline Functions): 使用
inline关键字建议编译器在调用点将函数体展开,以减少函数调用的开销。这是一种空间换时间的策略。 -
函数指针 (Function Pointers): 一种特殊的指针,它存储的不是数据的地址,而是函数的入口地址。这使得我们可以像传递普通变量一样传递函数。
-
成员函数 (Member Functions): 在C++的类(
class)或结构体(struct)中定义的函数,它们可以访问该对象的成员变量。
二、函数的底层实现 (汇编与内存视角)
当我们调用一个函数时,计算机底层发生了一系列复杂但有序的操作。这主要涉及到 调用栈 (Call Stack) 和 CPU寄存器 (CPU Registers)。我们将以x86-64架构(现代64位PC的标准)为例来解释这个过程。
1、什么是调用栈 (The Call Stack)?
调用栈是内存中的一块特殊区域,遵循“后进先出”(LIFO, Last-In, First-Out)的原则。它主要用于:
-
存储函数参数:当参数数量过多,无法全放入寄存器时。
-
存储返回地址:记录函数执行完毕后应该回到哪里继续执行。
-
存储局部变量:函数内部定义的变量都存放在这里。
每次调用一个函数,都会在栈顶创建一个新的 栈帧 (Stack Frame)。这个栈帧包含了该次函数调用所需的所有信息。当函数返回时,它的栈帧就会被销毁。
2、关键的寄存器
在函数调用过程中,CPU中的一些寄存器扮演着至关重要的角色:
-
RSP (Stack Pointer): 栈指针,永远指向调用栈的 栈顶。
-
RBP (Base Pointer): 基址指针,指向当前栈帧的 底部。通过
RBP加上一个偏移量,可以稳定地访问局部变量和函数参数,即使RSP随着push/pop操作在不断变化。 -
RIP (Instruction Pointer): 指令指针,存储下一条将要执行的CPU指令的地址。
-
RAX: 通常用于存储函数的 返回值。
-
RDI, RSI, RDX, RCX, R8, R9: 在x86-64 System V AMD64 ABI (Linux/macOS) 调用约定中,用于传递前六个整型或指针参数。
3、一个函数调用的生命周期 (以c = add(a, b);为例)
假设我们有以下C++代码:
#include <stdio.h>
int add(int a, int b) {
int sum = a + b;
return sum;
}
int main() {
int a = 10;
int b = 20;
int c = add(a, b); // 我们关注这一行
printf("a + b = %d\n", c);
return 0;
}
我们在int b = 20;处下一个断点,同时把确保整个编译模式处于Debug模式下,如图所示:
按下F5开启调试后,程序在第十行中断的之后,按下快捷键Alt+8,仔细观察程序的汇编代码:
int main() {
00007FF6DE6418F0 push rbp
00007FF6DE6418F2 push rdi
00007FF6DE6418F3 sub rsp,148h
00007FF6DE6418FA lea rbp,[rsp+20h]
00007FF6DE6418FF lea rcx,[__0CE2AB12_Test@cpp (07FF6DE652008h)]
00007FF6DE641906 call __CheckForDebuggerJustMyCode (07FF6DE641370h)
00007FF6DE64190B nop
int a = 10;
00007FF6DE64190C mov dword ptr [a],0Ah
int b = 20;
00007FF6DE641913 mov dword ptr [b],14h
int c = add(a, b); // 我们关注这一行
00007FF6DE64191A mov edx,dword ptr [b]
00007FF6DE64191D mov ecx,dword ptr [a]
00007FF6DE641920 call add (07FF6DE64131Bh)
00007FF6DE641925 mov dword ptr [c],eax
printf("a + b = %d\n", c);
00007FF6DE641928 mov edx,dword ptr [c]
00007FF6DE64192B lea rcx,[string "a + b = %d\n" (07FF6DE64AC28h)]
00007FF6DE641932 call printf (07FF6DE641195h)
00007FF6DE641937 nop
return 0;
00007FF6DE641938 xor eax,eax
}
00007FF6DE64193A lea rsp,[rbp+128h]
00007FF6DE641941 pop rdi
00007FF6DE641942 pop rbp
00007FF6DE641943 ret
当 main 函数调用 add(a, b) 时,底层大致会发生以下步骤
①参数传递
-
调用者 (
main) 将第一个参数a(值10) 放入RDI寄存器。 -
调用者将第二个参数
b(值20) 放入RSI寄存器。
int a = 10;
00007FF6DE64190C mov dword ptr [a],0Ah //把十六进制的10传到地址a
int b = 20;
00007FF6DE641913 mov dword ptr [b],14h //把十六进制的20传到地址b
int c = add(a, b);
00007FF6DE64191A mov edx,dword ptr [b] //传递b到edx
00007FF6DE64191D mov ecx,dword ptr [a] //传递a到ecx
00007FF6DE641920 call add (07FF6DE64131Bh) //call调用add函数入口
00007FF6DE641925 mov dword ptr [c],eax //把结果从eax拷贝到地址c
按下F11,逐步语句执行到 00007FF6DE641920 call add (07FF6DE64131Bh) 处,进去看看。
注意:执行到call add这一步的时候暂停,观察寄存器如下:
RAX = 0000000000000001 RBX = 0000000000000000 RCX = 000000000000000A
RDX = 0000000000000014 RSI = 0000000000000000 RDI = 0000000000000000
R8 = 000001B3CD037DC0 R9 = 00000021E89DFB48 R10 = 0000000000000012
R11 = 00000021E89DFC00 R12 = 0000000000000000 R13 = 0000000000000000
R14 = 0000000000000000 R15 = 0000000000000000 RIP = 00007FF6DE641920
RSP = 00000021E89DFB00 RBP = 00000021E89DFB20 EFL = 00000202
当我们再次按下F11,执行call指令的时候,再次观察寄存器情况:
RAX = 0000000000000001 RBX = 0000000000000000 RCX = 000000000000000A
RDX = 0000000000000014 RSI = 0000000000000000 RDI = 0000000000000000
R8 = 000001B3CD037DC0 R9 = 00000021E89DFB48 R10 = 0000000000000012
R11 = 00000021E89DFC00 R12 = 0000000000000000 R13 = 0000000000000000
R14 = 0000000000000000 R15 = 0000000000000000 RIP = 00007FF6DE6417B0
RSP = 00000021E89DFAF8 RBP = 00000021E89DFB20 EFL = 00000202
②调用指令 (call)
-
CPU执行
call add指令。 -
这个指令会做两件事:
-
保存返回地址: 将
call指令的下一条指令的地址(即add返回后应该执行的代码地址)压入栈顶。 -
跳转: 修改
RIP指令指针,使其指向add函数的第一条指令。CPU即将开始执行add函数的代码。
-
③核心部分
int add(int a, int b) {
00007FF6DE6417B0 mov dword ptr [rsp+10h],edx
00007FF6DE6417B4 mov dword ptr [rsp+8],ecx
00007FF6DE6417B8 push rbp
00007FF6DE6417B9 push rdi
00007FF6DE6417BA sub rsp,108h
00007FF6DE6417C1 lea rbp,[rsp+20h]
00007FF6DE6417C6 lea rcx,[__0CE2AB12_Test@cpp (07FF6DE652008h)]
00007FF6DE6417CD call __CheckForDebuggerJustMyCode (07FF6DE641370h)
00007FF6DE6417D2 nop
int sum = a + b;
00007FF6DE6417D3 mov eax,dword ptr [b]
00007FF6DE6417D9 mov ecx,dword ptr [a]
00007FF6DE6417DF add ecx,eax
00007FF6DE6417E1 mov eax,ecx
00007FF6DE6417E3 mov dword ptr [sum],eax
return sum;
00007FF6DE6417E6 mov eax,dword ptr [sum]
}
00007FF6DE6417E9 lea rsp,[rbp+0E8h]
00007FF6DE6417F0 pop rdi
00007FF6DE6417F1 pop rbp
00007FF6DE6417F2 ret
Part 1: 函数前奏 (Prologue) - 建立栈帧和环境
这部分代码的目的是在调用栈上为 add 函数建立一个专属的工作空间(栈帧)。
; --- 参数传递与入栈 ---
00007FF6DE6417B0 mov dword ptr [rsp+10h], edx
00007FF6DE6417B4 mov dword ptr [rsp+8], ecx
-
调用约定: 在 Windows x64 调用约定中,前四个整型/指针参数按顺序由
RCX,RDX,R8,R9寄存器传递。因此,参数a在ecx中,参数b在edx中。 -
mov dword ptr [rsp+8], ecx: 将寄存器ecx的值(也就是参数a)保存到栈上,位置是当前栈顶指针rsp向上偏移 8 字节的地方。 -
mov dword ptr [rsp+10h], edx: 将寄存器edx的值(也就是参数b)保存到栈上,位置是rsp向上偏移 16 字节(10h= 16)的地方。 -
为什么这么做? 这就是Debug模式的特点。虽然参数已经通过寄存器高效地传进来了,但为了方便调试器随时查看,编译器还是把它们复制到了内存(栈)里。这块区域被称为“参数的home space”或“shadow space”。
; --- 保存调用者的栈帧信息 ---
00007FF6DE6417B8 push rbp
00007FF6DE6417B9 push rdi
-
push rbp:rbp寄存器保存着 调用者(比如main函数)的栈帧基址。这里将其压入栈中,是为了在add函数执行完毕后能够完美地恢复main函数的栈帧。 -
push rdi:rdi是一个“非易失性”寄存器,意味着如果函数要使用它,必须在返回前恢复它的原始值。编译器在这里保存它,以备后用。
; --- 为局部变量和调试信息分配空间 ---
00007FF6DE6417BA sub rsp, 108h
00007FF6DE6417C1 lea rbp, [rsp+20h]
-
sub rsp, 108h: 这是为add函数的局部变量等数据分配栈空间。108h等于 264 字节。虽然我们的函数只有一个局部变量sum(4字节),但编译器在Debug模式下会分配远超需求的内存,用于存放调试信息、栈保护cookie等。 -
lea rbp, [rsp+20h]:lea(Load Effective Address) 指令用于计算地址。这行代码的意思是,将rsp+20h(即rsp向上偏移32字节) 这个地址本身,作为add函数新的栈帧基址,存入rbp寄存器。从此以后,rbp就固定地指向add函数栈帧的“底部”,所有局部变量的访问都将通过rbp进行。 -
小结: 此时,一个完整的栈帧已经建立。参数
a和b被存放在栈上,调用者的rbp被保存,并且为add函数自己分配了充足的内存空间。
; --- 调试器支持代码 ---
00007FF6DE6417C6 lea rcx, [__0CE2AB12_Test@cpp (07FF6DE652008h)]
00007FF6DE6417CD call __CheckForDebuggerJustMyCode (07FF6DE641370h)
00007FF6DE6417D2 nop
-
这三行是纯粹的 Debug 代码,与你的
add函数逻辑无关。它在调用一个内部函数__CheckForDebuggerJustMyCode,这是 Visual Studio 实现“仅我的代码”(Just My Code)调试功能的一部分。 -
nop(No Operation) 是一个空指令,通常用于对齐内存地址或为调试器提供一个设置断点的稳定位置。
Part 2: 函数体 (Body) - 执行核心逻辑
00007FF6DE6417D3 mov eax, dword ptr [b]
00007FF6DE6417D9 mov ecx, dword ptr [a]
00007FF6DE6417DF add ecx, eax
00007FF6DE6417E1 mov eax, ecx
00007FF6DE6417E3 mov dword ptr [sum], eax
-
这里的
[a],[b],[sum]是调试器为了方便你阅读,为你显示的符号名。在底层,它们实际上是相对于rbp的某个内存地址,例如[rbp-8]。 -
mov eax, dword ptr [b]: 从内存中读取变量b的值,放入eax寄存器。 -
mov ecx, dword ptr [a]: 从内存中读取变量a的值,放入ecx寄存器。 -
add ecx, eax: 计算ecx + eax(即a + b),结果存回ecx。 -
mov eax, ecx: 将计算结果从ecx复制到eax。 -
mov dword ptr [sum], eax: 将eax中的最终结果存入为局部变量sum分配的内存地址中。 -
为何如此冗长? 在Release(优化)模式下,
a + b可能只需要一条lea eax, [rcx+rdx]指令就能完成。但Debug模式为了保证每一步都能被跟踪,宁愿使用多条指令,频繁地在寄存器和内存之间读写数据。
Part 3: 准备返回值
00007FF6DE6417E6 mov eax, dword ptr [sum]
-
根据调用约定,函数的返回值(如果是整型或指针)必须放在
rax(或eaxfor 32-bit) 寄存器中。 -
这行代码将局部变量
sum的值从内存中再次读取到eax寄存器,准备返回给调用者。
Part 4: 函数尾声 (Epilogue) - 销毁栈帧并返回
00007FF6DE6417E9 lea rsp, [rbp+0E8h]
00007FF6DE6417F0 pop rdi
00007FF6DE6417F1 pop rbp
00007FF6DE6417F2 ret
-
这部分操作与函数前奏 (Prologue) 完全相反,目的是恢复到调用
add之前的状态。 -
lea rsp, [rbp+0E8h]: 快速恢复rsp。这行指令的效果等同于add rsp, 108h加上其他一些偏移,它直接将栈顶指针rsp指向add函数分配栈帧之前的位置,从而一次性“释放”所有为局部变量分配的空间。 -
pop rdi: 从栈顶弹出一个值,恢复rdi寄存器。 -
pop rbp: 从栈顶弹出一个值,恢复rbp寄存器。现在rbp重新指向了调用者(main函数)的栈帧基址。 -
ret: 这是函数返回指令。它会从栈顶弹出 返回地址(这个地址在main函数执行call add指令时被自动压入栈中),然后跳转到这个地址继续执行。
至此,add 函数的整个生命周期结束,程序控制权回到了 main 函数,并且 main 函数可以从 eax 寄存器中得到计算结果。
4、函数内再调用其他函数的情况
让我们看一个具体关于调用栈 (Call Stack)的例子:
void functionB() {
// ... 做一些事情 ...
// B结束
}
void functionA() {
// ... 做一些事情 ...
functionB(); // A 调用 B
// ... A 在 B 返回后继续做一些事情 ...
// A结束
}
int main() {
functionA(); // main 调用 A
return 0;
// main结束
}
①执行流程与栈的变化
-
main函数开始执行:-
程序的调用栈上会为
main创建第一个栈帧。 -
栈状态:
[ Stack Frame for main ]
-
-
main调用functionA:-
main的当前状态被保存。 -
call functionA指令会将返回地址(main函数中的下一行代码)压入栈中。 -
一个新的栈帧为
functionA在 栈顶 创建。 -
栈状态:
[ Stack Frame for functionA ]<-- 栈顶[ Stack Frame for main ]
-
-
functionA调用functionB:-
functionA的当前状态被保存。 -
call functionB指令会将返回地址(functionA中的下一行代码)压入栈中。 -
一个新的栈帧为
functionB在 栈顶 再次创建。 -
栈状态:
[ Stack Frame for functionB ]<-- 栈顶[ Stack Frame for functionA ][ Stack Frame for main ]
-
现在,functionB 位于栈顶,是当前正在执行的函数。
②返回流程:后进先出
-
functionB执行完毕:-
functionB的栈帧被销毁(从栈顶弹出)。 -
ret指令读取栈顶的返回地址,程序跳转回functionA继续执行。 -
栈状态:
[ Stack Frame for functionA ]<-- 栈顶[ Stack Frame for main ]
-
-
functionA执行完毕:-
functionA的栈帧被销毁(从栈顶弹出)。 -
ret指令读取栈顶的返回地址,程序跳转回main继续执行。 -
栈状态:
[ Stack Frame for main ]<-- 栈顶
-
-
main执行完毕:-
main的栈帧被销毁。 -
栈变为空,程序结束。
-
③总结:为什么必须是 LIFO?
因为函数调用是一个嵌套的、有依赖关系的结构。
main 函数的执行依赖于 functionA 的完成,而 functionA 的执行又依赖于 functionB 的完成。计算机需要一种机制来记住这个“调用链”,以便在内层函数执行完毕后,能够准确地回到它被调用的地方继续执行。
调用栈 (Call Stack) 完美地解决了这个问题。通过在栈顶添加和移除栈帧,它确保了程序总是从最内层的调用返回到上一层,这个“后进先出”的机制是实现现代编程语言中函数顺序调用与返回的根本。
如果不是 LIFO,比如 functionA 在 functionB 还没结束时就返回了,那么 functionB 执行完毕后该回到哪里去呢?程序的执行流就会彻底混乱。
更多推荐
所有评论(0)