4.1 函数的定义、声明与调用约定


4.1 函数的定义、声明与调用约定

本节摘要:函数由接口(原型)与实现(定义)两部分构成,声明与定义分离是多文件工程的基石。机器层面,函数间靠"调用约定"协作:参数谁走寄存器谁走栈、返回值放哪、栈由谁清理,双方必须完全一致——不一致就是未定义行为的温床。本节把契约与机制两面都讲清。

声明与定义:告诉与做到

int add(int a, int b); /* 声明(原型):告诉编译器"有这个函数" */ /* 只说参数与返回类型,不说怎么做 */ int add(int a, int b) /* 定义:真正的实现,占据代码段内存 */ { return a + b; }

编译器处理调用时只需要原型:检查参数类型、生成正确的调用指令;实现由链接器负责接上。这就是头文件存在的理由——把一组原型放进头文件,所有包含它的源文件都能通过编译检查。第 1 章说过,声明可以重复,定义只能有一份,链接器按这个规矩裁决。

一个常见的多文件布局:

/* 数学工具的头文件:只放原型与类型 */ int add(int a, int b); long factorial(int n);
/* 数学工具的实现文件 */ #include "mathutil.h" int add(int a, int b) { return a + b; } long factorial(int n) { long r = 1; for (int i = 2; i <= n; i++) r *= i; return r; }

不写原型直接调用会怎样?C99 之前编译器默认返回 int 且不查参数(隐式声明),C99 起直接报错。但有一种错误编译器拦不住:原型与定义不一致——原型说两个 int,定义写两个 double,两个文件分别编译都通过,链接也通过,运行时按错误契约传参,结果错乱。头文件统一原型就是为了掐死这类事故:实现文件也包含自己的头文件,编译器即可交叉核对。

调用约定:函数之间的通信协议

调用约定回答四个问题:参数按什么顺序传、走寄存器还是栈、返回值放哪、调用完栈由谁恢复。主流 64 位平台(System V AMD64,即 Linux/macOS 的 x86-64)的答案:

  1. 前 6 个整数参数依次走 rdi、rsi、rdx、rcx、r8、r9 寄存器;超出部分压栈;
  2. 浮点参数走 xmm0 到 xmm7;
  3. 返回值:整数/指针放 rax(128 位用 rdx 辅助),浮点放 xmm0;
  4. 栈恢复:调用者负责——被调函数只需保证栈指针回到进来的位置。
int add(int a, int b) { return a + b; } int main(void) { int s = add(3, 4); return s - 7; }

编译后的关键指令:

main: ... mov edi, 3 ; 第一个参数 mov esi, 4 ; 第二个参数 call add ; 压入返回地址并跳转 ... ; 返回值已在 eax add: lea eax, [rdi + rsi] ; 一条指令完成加法 ret ; 弹出返回地址跳回

call 指令做两件事:把下一条指令的地址(返回地址)压栈,然后跳转。ret 从栈顶弹出返回地址跳回去。调用与返回的全部机制就是压栈、跳转、弹栈、跳回——第 4.2 节的栈帧就建立在这两步之上。

结构体参数是个特例:小的结构体(两个寄存器装得下)拆开走寄存器,大的压栈整块复制。这解释了一个工程常识——传大结构体时传指针比传值便宜:传值复制整块,传指针只复制 8 字节地址。

⚠️ 常见坑:跨语言或跨编译器混链时调用约定不一致(比如 Windows 上 cdecl 与 stdcall 混用),栈恢复方式不同导致调用返回后栈错位,症状是"一调用就崩"。声明函数指针与 API 时必须确认约定一致。

参数与返回值设计

接口设计直接影响机器效率与正确性:

  • 参数传值:默认方式,实参被复制。小对象(int、指针、小结构体)没问题;大数组传值在 C 里做不到(数组名会退化成指针,第 6 章),大结构体传值会有整块复制的开销。
  • 返回值:C89 只能返回一个值,多值结果的传统方案是出参指针(传入结果地址,函数往里写)。返回大结构体时编译器暗中转换成"调用者分配空间、传隐藏指针"的模式(RVO 的雏形),所以按值返回大结构体并不像看上去那么贵。
  • void 返回:函数不返回值。但注意 return; 与漏写 return 的区别——非 void 函数漏写 return 且调用方使用返回值,是未定义行为,垃圾值来自 rax 寄存器的残留。

可变参数函数(printf 家族)依赖另一套约定:stdarg.h 的宏按声明顺序从寄存器保存区/栈上依次取参数。这解释了第 2 章的现象——格式串是唯一知道参数类型与个数的地方,机器层面无人核对。

本节要点回顾

  • 声明告诉编译器,定义交给链接器:头文件统一原型,实现文件包含自己的头,让编译器交叉核对一致性。
  • 调用约定是双方的通信协议:寄存器分配、返回值位置、栈恢复责任,错配即未定义行为。
  • call 与 ret 的本质:压返回地址、跳转、弹返回地址、跳回。
  • 大对象传指针不传值:8 字节地址与整块复制的差距。
  • 非 void 函数漏写 return 而调用方用返回值是未定义行为:垃圾来自寄存器残留。

下一节进入本章核心:call 与 ret 之间,栈上发生了什么——栈帧的搭建、使用与拆除。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U