本节摘要:函数由接口(原型)与实现(定义)两部分构成,声明与定义分离是多文件工程的基石。机器层面,函数间靠"调用约定"协作:参数谁走寄存器谁走栈、返回值放哪、栈由谁清理,双方必须完全一致——不一致就是未定义行为的温床。本节把契约与机制两面都讲清。
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)的答案:
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 时必须确认约定一致。
接口设计直接影响机器效率与正确性:
return; 与漏写 return 的区别——非 void 函数漏写 return 且调用方使用返回值,是未定义行为,垃圾值来自 rax 寄存器的残留。可变参数函数(printf 家族)依赖另一套约定:stdarg.h 的宏按声明顺序从寄存器保存区/栈上依次取参数。这解释了第 2 章的现象——格式串是唯一知道参数类型与个数的地方,机器层面无人核对。
下一节进入本章核心:call 与 ret 之间,栈上发生了什么——栈帧的搭建、使用与拆除。