概述
C++中的函数调用,本质上就是通过函数地址定位到进程地址空间的代码区中对应的指令序列,然后跳转执行
编译阶段
编译时 - 生成符号和调用指令
- 函数声明与符号生成
- 编译器看到函数
func的声明(或定义)时,会将其名称作为一个符号(Symbol) 记录在目标文件(.obj或.o)的符号表中 - 同时,编译器将函数
func的C++源代码翻译成对应的机器指令序列,并将这些指令放入目标文件的代码段中
- 编译器看到函数
- 函数调用翻译
- 当编译器遇到
func();这行调用代码时,它并不知道func函数最终在内存中的绝对地址 - 因为该函数可能定义在另一个编译单元(另一个
.cpp文件)中 - 因此,编译器会生成一条调用指令(在
x86上类似call),但此时目标地址是未知的 - 编译器会创建一个重定位条目(
Relocation Entry) - 等到链接时,找到了
func这个符号的地址,然后会把这个调用指令中的空缺地址给填上
- 当编译器遇到
链接阶段
链接时 - 解析符号地址
- 符号解析
- 链接器收集所有目标文件中的符号。它建立一个全局的符号表
- 链接器开始“解决”所有未定义的符号引用
它遍历所有目标文件,寻找func这个符号的定义
- 地址分配与重定位
- 链接器会为整个程序分配虚拟内存地址
它决定代码段、数据段等各自在进程地址空间中的起始位置 - 假设链接器决定程序的代码段从虚拟地址
0x400000开始,并且计算出func函数位于代码段内偏移0x1000的位置
那么,func的绝对虚拟地址就确定为0x401000 - 链接器会找到之前编译器留下的所有关于
func的重定位条目,并将调用指令中的那个“空洞”填上计算好的地址0x401000(或者是基于该地址的相对偏移量)
- 链接器会为整个程序分配虚拟内存地址
运行阶段
运行时 - 加载执行与跳转
- 创建进程与地址空间
- 当你双击或在命令行启动程序时,操作系统会为其创建一个新的进程
- 每个进程都有自己独立的、完整的虚拟地址空间(在
32位系统上是4GB,64位系统则巨大无比) - 操作系统的加载器(
Loader)读取可执行文件,将代码段、数据段等内容映射到进程虚拟地址空间的相应区域 - 关键点在于:
链接时确定的地址(如0x401000)就是这个函数在进程虚拟地址空间中的地址
- 指令执行与函数调用
- 见函数执行流程
函数执行流程
参数入栈与返回地址
- 当程序运行到函数调用语句时(例如
int result = add(x, y);),以下步骤在极短时间内发生: - 参数入栈(从右向左)
- 调用者(
Caller)会按照从右向左的顺序,将函数参数压入系统栈(Stack) - 例如,对于
add(x, y),会先将y的值压栈,再将x的值压栈 - 如果参数是复杂对象或大小超过寄存器容量,其内容可能会被拷贝到栈上
- 调用者(
- 压入返回地址
- 接着,
CPU执行call指令
这个指令会做两件关键的事: - 将当前指令指针(
EIP/RIP)的下一条指令地址(即返回地址)压入栈顶
这确保了函数执行完毕后能知道该回到哪里继续执行 - 跳转(
Jump)到add函数在代码区的起始内存地址
- 接着,
新函数 —栈帧的建立
- 跳转到目标函数后,首要任务是为这个函数的执行创建一个独立的上下文环境,即栈帧(
Stack Frame)- 保存调用者的栈帧基址:
push ebp
将当前栈底指针寄存器(EBP/RBP)的值压栈保存
这个值指向的是调用函数栈帧的底部 - 设置新栈帧的基址:
mov ebp, esp
将当前栈顶指针(ESP/RSP)的值赋给EBP
此时,EBP就指向了新栈帧的底部 - 为局部变量分配空间:
sub esp, XXh
将栈顶指针ESP向上(低地址方向)移动一定字节(XXh),从而在栈上为当前函数的局部变量预留出空间
- 保存调用者的栈帧基址:
- 栈帧的建立使得函数可以通过固定的偏移量(相对于
EBP)来访问参数和局部变量- 例如,
[ebp+8]通常是第一个参数,[ebp-4]可能是第一个局部变量
- 例如,
执行函数体
- 栈帧建立好后,便开始执行函数体内的代码
- 此时,对局部变量的操作都是在刚刚分配的栈空间上进行
- 如果函数有返回值,对于较小的返回值(如基本数据类型),通常会通过
EAX/RAX寄存器来传递
函数返回与栈帧销毁
- 函数执行到
return语句或结束括号}时,需要清理自己的栈帧,并返回调用者:- 返回值存入寄存器
如果有返回值,会将其放入EAX寄存器 - 拆除栈帧
mov esp, ebp:将栈顶ESP移回栈底EBP处,这步操作释放了当前函数的所有局部变量空间
pop ebp:从栈中弹出之前保存的调用者的EBP值,将其恢复EBP寄存器
这样,EBP就指回了调用函数栈帧的底部 - 返回到调用者
执行ret指令。该指令会从栈顶弹出之前保存的返回地址,并跳转到该地址继续执行
CPU的控制权就此交还给调用函数 - 清理参数空间
返回后,调用者负责清理之前压入栈的函数参数
这通常通过直接调整栈指针完成(例如add esp, 8,假设两个4字节参数)
- 返回值存入寄存器
关键
三种参数传递方式
- 传值调用(
Call by Value)- 将实参的
值副本传给形参 - 函数内修改形参不影响实参
- 将实参的
- 传址调用(
Call by Pointer/Address)- 将实参的地址传给指针类型的形参
- 函数内通过该地址间接操作实参,从而影响实参
- 引用调用(
Call by Reference)- C++特有方式,形参是实参的别名
- 语法上像传值,但底层实现通常类似于传址,能直接修改实参
函数指针:间接调用的桥梁
- 函数指针(
Function Pointer)是理解函数地址概念的绝佳例子- 它是一个指针变量,但其值不是数据地址,而是
函数的入口地址
- 它是一个指针变量,但其值不是数据地址,而是
- 声明与赋值
- 例如,
int (*funcPtr)(int, int);声明了一个函数指针,它可以指向任何接受两个int参数并返回int的函数 - 通过
funcPtr = add;可以将add函数的地址赋给它
- 例如,
- 间接调用
- 通过
funcPtr(1, 2);或(*funcPtr)(1, 2);即可调用它所指向的函数 - 这个过程和直接调用
add(1, 2)在底层机制上完全一致:
查找指针保存的地址 -> 压参入栈 -> 跳转执行
- 通过
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ C++并发编程 _ 无锁数据结构09/18
- ♥ C++_关于Async和Future在异步任务中的使用总结05/18
- ♥ Soui一03/17
- ♥ Boost 程序库完全开发指南:工具与字符串08/22
- ♥ Effective C++_第五篇07/02
- ♥ C++并发编程 _ 基于锁的数据结构08/19