Lecture 7: C 函数:定义、调用、参数传递与返回值 (Introduction to Functions in C)
Lecture 7: C 函数:定义、调用、参数传递与返回值 (Introduction to Functions in C)
概述
本讲把”一个 main 打天下”的程序拆成多个函数,回答函数如何成为 C 里最基本的分解工具这一问题。 我们引入函数签名 (signature) 与原型 (prototype)、按值传递 (call by value)、返回值、多文件编译与头文件、数组参数退化为指针、void, 并预览递归 (recursion)。函数是”系统分解”落地的单元:它把接口(签名)与实现(函数体)分开,让不同的人、不同的源文件可以独立开发与测试, 而”参数是值的拷贝”这条规则又直接决定了下一讲的栈帧布局——调用者把自己的值压到栈上,被调用者拿到的永远是一份副本。
核心概念与底层机制图解
- 函数签名 (Function Signature):函数的名字、参数的个数与类型、返回值的类型。
- 直观解释:签名就是”插座规格”——几孔、什么形状、能出多少电;插头(调用)不合规格,编译器直接拒绝。
- 底层机制图解:签名让编译器① 核对实参个数、② 做必要的类型转换(或拒绝)、③ 决定返回值放哪里。LC-3 上调用函数固定四步:
LDR R0,R5,#0 ; ① 求值实参到 R0 → 压栈(先 ADD R6,R6,#-1 再 STR) ADD R6,R6,#-1 STR R0,R6,#0 JSR MY_FUNC ; ② 调用(R7 ← 返回地址) LDR R0,R6,#0 ; ③ 从栈顶读返回值 ADD R6,R6,#2 ; ④ 弹出返回值与实参 - 作用域与存储期:函数名具有 file scope 或 global scope(加
static则只在本文件可见);函数代码的存储期是整个程序,与调用次数无关。
- 声明 (Declaration) 与定义 (Definition)、原型 (Prototype):声明只给签名(以分号结束),定义还给函数体。直观解释:声明是”菜单上写着有这道菜”,定义是”厨房里真的会做”。
- 底层机制图解:
int32_t f (int32_t h, int32_t w);是声明,去掉分号并跟上{ ... }就是定义。 声明里的参数名一定要写:int32_t f (int32_t, int32_t);语法合法,但两个类型相同,谁也看不出哪个是 height。 早期 C 允许不声明就调用,编译器只能假设”整型实参转int、浮点转double、返回值是int“——这些假设在 64 位机器上经常是错的,而编译器没有签名就无法提醒你。 - 作用域与存储期:声明不分配存储,只往符号表里放一条”名字 → 签名”的记录。
- 底层机制图解:
- 按值传递 (Call by Value):C 把实参的值拷贝给被调用者,被调用者拿到的是自己的副本。直观解释:像把文件复印一份交给同事——他可以在复印件上随意写字,你的原件不会有任何变化。
- 底层机制图解:① 调用者求值实参;② 把值压栈;③ 副本构成被调用者栈帧的参数区;④ 被调用者随便改自己那份;⑤ 返回时调用者把副本弹掉(
ADD R6,R6,#3= 2 个实参 + 1 个返回值)。 关键推论:若参数是”指向某物”的指针,被调用者改不了指针本身,却能顺着指针改它所指向的对象——这正是数组能被函数修改的原因。 - 作用域与存储期:形参的作用域是整个函数体,存储期是 automatic,函数一返回副本即消失。绝不要返回指向形参或局部变量的地址。
- 底层机制图解:① 调用者求值实参;② 把值压栈;③ 副本构成被调用者栈帧的参数区;④ 被调用者随便改自己那份;⑤ 返回时调用者把副本弹掉(
- 返回值 (Return Value):函数用
return <表达式>;交回一个值,类型由签名规定。直观解释:像外卖员把餐送到门口——送到那一刻(return)服务就结束,之后的代码都不会执行。- 底层机制图解:LC-3 上
return与调用约定严格对应——求值 → 写返回值槽(R5+3)→ 恢复R7/R5→ 弹出局部与 linkage →RET。 函数可以有多个return(”发现就提前返回”),但每条控制路径都必须返回值,否则返回的是寄存器里剩下的位;返回类型是void时不能返回值(可以return;)。 - 作用域与存储期:返回值是临时值,由调用者立刻使用或存进自己的变量,不占用被调用者的栈帧。
- 底层机制图解:LC-3 上
- 多文件、头文件与分别编译 (Separate Compilation):接口放
.h,实现放.c,各自独立编译再由链接器拼起来。- 直观解释:头文件是”对外公布的产品说明书”,
.c是”车间图纸”;客户只看说明书,车间改工艺不影响客户。 - 底层机制图解:
#include "x.h"是预处理器的文本替换,把头文件原样插进当前文件,于是每个.c都是自包含的编译单元; 头文件必须加包含卫士 (include guard)(#ifndef / #define / #endif)防止重复插入。链接器看到的是符号而不是源码:; main.o : U array_sum (未定义,需要在别处找) ; stats.o : T array_sum (已定义,地址确定) ; 链接器把 U 填成 T 的地址——这就是多文件能"拼"起来的原因。 - 作用域与存储期:
.h里只放声明(原型、类型、宏);放定义会让每个包含它的.c各生成一份实体,链接时报”重复定义”。static函数只在本编译单元可见,不会与其它文件冲突。
- 直观解释:头文件是”对外公布的产品说明书”,
- 数组作为参数:退化为指针 (decay to pointer):形参写
int32_t values[]时会被编译器立刻转换成int32_t *values。- 直观解释:把一整排储物柜搬进函数太贵,所以只把”第一排柜子的门牌号”抄一份递进去。
- 底层机制图解:课程参考明确写出这条例外:
void foo(int x[4]);会被立刻转换成void foo(int * x);。两个直接后果: ① 函数里sizeof (values)得到指针大小(8 字节)而不是数组大小,gcc -Wall会用-Wsizeof-array-argument直接报错; ② 数组长度无法从地址推出,必须另传一个长度参数。访问values[i]的机器码就是”指针 + i × 元素大小”:LDR R1,R5,#4 ; R1 <- values(只是个地址,占 1 个字) ADD R1,R1,R2 ; 加上 i × 元素大小(LC-3 上 int32_t 是 2 个字) ADD R1,R1,R2 LDR R3,R1,#0 ; R3 <- values[i] - 作用域与存储期:数组元素本身属于调用者的栈帧;被调用者只拿到首元素地址,因此可以读写调用者的数据,但调用者一返回这些元素就失效。
void:C 里”什么都不是”的类型:void f (void)表示不收参数,返回类型写void表示不返回值。直观解释:像一台只打印、不找零的售货机——用了它就知道”没有回执可查”。- 底层机制图解:课程建议尽量少用
void返回类型。理由很实际:函数现在总能成功,100 个调用点就都不检查失败;将来它需要处理失败,你得改 100 处。 让函数返回int32_t(0 成功、非 0 是错误码)或bool,调用点从第一天起就有地方检查:void print_slot (int32_t slot); /* 只输出,永远"成功",可以用 void */ int32_t print_square (int32_t size); /* 参数非法时返回 -1,调用者必须检查 */ - 作用域与存储期:
void只是类型信息,不涉及存储;返回void的函数在 LC-3 上不写返回值槽,栈顶剩下的就是实参。
- 底层机制图解:课程建议尽量少用
- 参数与局部变量的作用域与存储期:形参与局部变量都属函数/块作用域 + automatic 存储期。
- 直观解释:它们住在”临时工位”上:上班(进入函数)时分配,下班(返回)时收回,第二天来的是另一个人。
- 底层机制图解:被调用者执行期间的栈帧(自高地址向低地址):
高地址 +--------------------------+ \| 调用者的栈帧 \| +--------------------------+ \| 参数副本(实参的拷贝) \| ← R5+4, R5+5, ... +--------------------------+ \| 返回值槽 \| ← R5+3(返回后位于栈顶) +--------------------------+ \| 返回地址(R7) \| ← R5+2 +--------------------------+ \| 上一个帧指针(R5) \| ← R5+1 +--------------------------+ \| 局部变量 \| ← R5+0, R5-1, ... 低地址 +--------------------------+同名不冲突:
f里的arg与main里的arg是两个完全不同的内存位置,一个在f的帧里、一个在main的帧里。 - 作用域与存储期:作用域决定”名字能在哪段代码里使用”,存储期决定”这块内存何时存在”;局部变量的名字只在函数体内可见,内存只在函数执行期间有效。
- 递归 (Recursion) 预览:函数直接或间接调用自己。
- 直观解释:像俄罗斯套娃:打开一个里面还有一个同样结构的小娃娃,直到最小的那个(基准情形 base case)为止。
- 底层机制图解:每次递归都再压一个新栈帧(新的参数副本、新的局部变量)。课程的递归策略与循环五步法同构:
______ recursive ( ______ ) { // 1. 检查停止条件(base case) // 2. 处理当前这一个节点 // 3. 处理"孩子"(递归调用) }递归、数学归纳法与硬件位切片 (bit-slicing) 是同一思想的三种形式:先解决一小块,再与”剩余同类问题”的解组合。 忘记 base case 会无限递归,每次调用消耗一个栈帧,最终栈溢出 (stack overflow)。
- 作用域与存储期:每层递归都有独立的 automatic 存储;递归深度就是同时存活的栈帧数,深递归会消耗大量栈空间(LC-3 的栈从
xFE00向下生长,空间有限)。
- 函数设计准则 (Function Design Guidelines):把”能跑的代码”变成”可维护的代码”。
- 直观解释:一个函数应该像一件称手的工具——只干一件事、拿起来就知道怎么用、坏了能单独送去修。
- 底层机制图解:① 单一职责:
read_values只负责读、array_sum只负责求和; ② 小而可测:短到能在脑子里跑完,每个函数都能用几组输入单独验证; ③ 在边界处检查参数:print_square先判size < 1、guessing_game先判取值区间,非法输入立即返回错误码,不要”过度解释”含义(print_square(-10)不该被理解成”画三角形”); ④ 文档化:按 ECE 220 约定在定义上方写清 INPUTS / OUTPUTS / RETURN VALUE / SIDE EFFECTS;⑤ 优先返回值而不是void,给调用者留出检查失败的位置。 - 作用域与存储期:良好的函数边界让每个函数的 automatic 变量都”短命”——状态不会悄悄地跨越很远的地方存活,这正是减少 bug 的来源。
代码示例与底层机制分析
代码 (C) — 按值传递:被调用者改不动调用者的变量:
/* * ECE220 Lecture 7 demo -- C passes arguments by value.
* Build: gcc -g -std=c99 -Wall -Werror l07_byvalue.c -o l07_byvalue
*/
#include <stdint.h>
#include <stdio.h>
/* The address of the callee's first parameter, kept so that main can
compare it with the address of its own variable. */
static intptr_t callee_first_address;
/* This function tries to swap its two parameters. It cannot succeed:
first and second are copies that live in this function's stack frame. */
static void
try_to_swap (int32_t first, int32_t second)
{
int32_t temp;
callee_first_address = (intptr_t)&first;
printf (" inside try_to_swap: &first=%p &second=%p\n",
(void *)&first, (void *)&second);
temp = first;
first = second;
second = temp;
printf (" inside try_to_swap: first=%d second=%d\n",
(int)first, (int)second);
}
/* The parameter n is also a copy; the caller's variable is untouched. */
static int32_t
add_one_by_value (int32_t n)
{
n = n + 1;
return n;
}
int
main ()
{
int32_t x = 7;
int32_t y = 42;
int32_t result;
printf ("in main: &x=%p &y=%p\n", (void *)&x, (void *)&y);
printf ("in main: x=%d y=%d\n", (int)x, (int)y);
printf ("x and y are %ld bytes apart inside main\n",
(long)((intptr_t)&x - (intptr_t)&y));
try_to_swap (x, y);
printf ("x is %ld bytes above the callee's first parameter\n",
(long)((intptr_t)&x - callee_first_address));
printf ("in main after call: x=%d y=%d\n", (int)x, (int)y);
result = add_one_by_value (x);
printf ("add_one_by_value(x) returned %d, x is still %d\n",
(int)result, (int)x);
return 0;
}
实际编译运行结果(./l07_byvalue;栈地址每次运行都会变,这里给出一次真实运行的输出):
in main: &x=0x7ffe8f859438 &y=0x7ffe8f859434
in main: x=7 y=42
x and y are 4 bytes apart inside main
inside try_to_swap: &first=0x7ffe8f85940c &second=0x7ffe8f859408
inside try_to_swap: first=42 second=7
x is 44 bytes above the callee's first parameter
in main after call: x=7 y=42
add_one_by_value(x) returned 8, x is still 7
【代码做什么?】 main 打印 x、y 的地址与值;try_to_swap (x, y) 在函数内部确实完成了交换,但回到 main 后 x、y 完全没有变化;add_one_by_value (x) 返回 8,而 x 仍是 7。
【底层机制透视】
- 地址就是证据:
main里&x = 0x7ffe8f859438,try_to_swap里&first = 0x7ffe8f85940c——两者相差 44 字节,属于不同的栈帧。first是x的副本,交换副本当然不会影响原件。y与second之间同理。 - 被调用者的参数区由调用者准备:LC-3 上就是”求值 → 压栈 →
JSR“;x86-64 上前几个实参走寄存器、再由被调用者存进自己的帧,所以&first落在被调用者的帧里。 - 想让函数改变调用者的数据只能传地址:把”门牌号”(指针)按值传进去,函数改不了门牌号本身,却能按门牌号改房间里的东西;返回值同样是临时值。
【内存布局图解】
高地址 +-------------------------------+ ← main 的栈帧
| int32_t x = 7 (0x...438) | try_to_swap 的帧在更低 44 字节处:
| int32_t y = 42 (0x...434) | 参数副本 first (0x...40c)
+-------------------------------+ 参数副本 second (0x...408)
| ... try_to_swap 的栈帧 ... | 局部变量 temp
低地址 +-------------------------------+ x、y 与 first、second 是完全不同的内存
交换只发生在 first / second 上,x / y 一动不动。
【与汇编的对应】(LC-3:按值传递的完整调用序列与”改不到原件”的事实)
; main 里:try_to_swap (x, y) —— 压入的是 x、y 的"值"
LDR R0,R5,#0 ; R0 <- x 的值
ADD R6,R6,#-1
STR R0,R6,#0 ; 压入 x 的副本(第一个实参最后压,地址最低)
LDR R0,R5,#-1 ; 同理压入 y 的副本
ADD R6,R6,#-1
STR R0,R6,#0
JSR TRY_TO_SWAP
ADD R6,R6,#2 ; 返回类型是 void,只弹掉两个实参
; TRY_TO_SWAP 内部只读写自己帧里的 R5+4、R5+5(副本),
; 交换完成后 RET;main 帧里的 x、y 从未被写过,所以值不变。
代码 (C) — 三文件程序:头文件 + 两个源文件:
l07_stats.h(只有声明,带包含卫士):
/* ECE220 Lecture 7 -- header for the array-statistics module (declarations only). */
#ifndef L07_STATS_H
#define L07_STATS_H
#include <stdint.h>
/* read_values: reads integers into values[] (room for capacity elements);
returns how many were read, or -1 if none could be read. */
int32_t read_values (int32_t values[], int32_t capacity);
/* print_array: prints the first count elements of values. */
void print_array (const int32_t values[], int32_t count);
/* array_sum: returns the sum of the first count elements (0 if count <= 0). */
int32_t array_sum (const int32_t values[], int32_t count);
/* array_mean: returns the mean of the first count elements (0.0 if count <= 0). */
double array_mean (const int32_t values[], int32_t count);
/* pointer_size_in_callee: reports sizeof() of an array parameter, to show that
an array parameter is really a pointer. */
int32_t pointer_size_in_callee (const int32_t values[]);
#endif /* L07_STATS_H */
l07_stats.c(实现):
/* ECE220 Lecture 7 -- implementation of the array-statistics module. */
#include <stdint.h>
#include <stdio.h>
#include "l07_stats.h"
int32_t
read_values (int32_t values[], int32_t capacity)
{
int32_t count = 0;
if (1 > capacity) { /* check arguments first */
return -1;
}
while (capacity > count && 1 == scanf ("%d", &values[count])) {
count = count + 1; /* keep reading until input ends */
}
if (0 == count) {
return -1;
}
return count;
}
void
print_array (const int32_t values[], int32_t count)
{
int32_t i;
printf ("values:");
for (i = 0; count > i; i++) {
printf (" %d", (int)values[i]);
}
printf ("\n");
}
int32_t
array_sum (const int32_t values[], int32_t count)
{
int32_t i;
int32_t total = 0;
for (i = 0; count > i; i++) {
total = total + values[i];
}
return total;
}
double
array_mean (const int32_t values[], int32_t count)
{
if (1 > count) {
return 0.0;
}
/* The (double) cast forces the division to be done in floating point. */
return (double)array_sum (values, count) / (double)count;
}
int32_t
pointer_size_in_callee (const int32_t values[])
{
/* Writing sizeof (values) here is a bug that gcc catches with
-Werror=sizeof-array-argument; an array parameter IS a pointer. */
const int32_t *as_pointer = values;
return (int32_t)sizeof (as_pointer);
}
l07_main.c(调用者):
/* ECE220 Lecture 7 -- the main file of a three-file program. */
#include <stdint.h>
#include <stdio.h>
#include "l07_stats.h"
#define MAX_VALUES 6
int
main ()
{
int32_t numbers[MAX_VALUES];
int32_t count;
printf ("enter up to %d integers: ", MAX_VALUES);
count = read_values (numbers, MAX_VALUES);
if (0 > count) {
printf ("no numbers were read\n");
return 1;
}
print_array (numbers, count);
printf ("sum = %d\n", (int)array_sum (numbers, count));
printf ("mean = %f\n", array_mean (numbers, count));
/* sizeof() is answered by the compiler, and the answer depends on
whether the name is still an array or has decayed to a pointer. */
printf ("sizeof(numbers) in main = %d bytes\n", (int)sizeof (numbers));
printf ("sizeof(values) in the callee = %d bytes\n",
(int)pointer_size_in_callee (numbers));
return 0;
}
精确的编译命令与实际的链接结果(三个文件一起编译,链接成功):
$ gcc -g -std=c99 -Wall -Werror -o l07_stats_demo l07_main.c l07_stats.c
$ printf '4 8 15 16 23 42' | ./l07_stats_demo
enter up to 6 integers: values: 4 8 15 16 23 42
sum = 108
mean = 18.000000
sizeof(numbers) in main = 24 bytes
sizeof(values) in the callee = 8 bytes
$ printf '5 5 5' | ./l07_stats_demo
enter up to 6 integers: values: 5 5 5
sum = 15
mean = 5.000000 (两次运行的 sizeof 两行完全相同:24 与 8)
【代码做什么?】 l07_main.c 声明数组并调用 read_values、print_array、array_sum、array_mean;l07_stats.c 提供全部实现,两个文件都靠 #include "l07_stats.h" 拿到签名; read_values 用 scanf 反复读整数直到输入结束或数组满。打印出的 24 与 8 分别证明”数组名在 main 里是数组(6 × 4 字节)”与”在函数里已退化成指针(8 字节)”。
【底层机制透视】
#include是文本替换:预处理器把l07_stats.h原样插进两个.c,让两个编译单元都看到完整原型;包含卫士保证它被多次包含时声明只出现一次。- 分别编译 + 链接:
gcc -c l07_stats.c会产出l07_stats.o,其中array_sum是已定义符号 (T);l07_main.c编译出的l07_main.o里array_sum是未定义符号 (U)。 链接器的工作就是把所有U接到对应的T上;缺一个就是undefined reference,多一个(同名定义两次)就是multiple definition。 - 数组退化与长度:
read_values (numbers, MAX_VALUES)传入的是”首元素地址 + 长度”两个值。read_values里的values[count]被编译成”从values出发、偏移count × sizeof(int32_t)“,所以它写进去的正是main的数组元素—— 这就是”数组可以被函数修改”的机制。 const的用处在签名里:const int32_t values[]承诺”我不修改你的数组”,于是print_array、array_sum这些只读函数不会意外写入调用者的数据; 同时const也让”传字符串字面量”这样的调用成为合法。- 两处
printf的证据:sizeof (numbers)在main里是 24(6 个int32_t),在函数里对数组参数得 8(int32_t *)。
【内存布局图解】
main 的栈帧(进入 main 后) read_values 执行期间的栈
+------------------------+ +------------------------------+
| int32_t numbers[6] | | main 的帧:numbers[6] |
| [0]4 [1]8 [2]15 | | (24 字节,连续存放) |
| [3]16 [4]23 [5]42 | +------------------------------+
+------------------------+ | read_values 的帧: |
| int32_t count = 6 | | 参数 values = 首元素地址 | ← 只是一个指针
低地址 +------------------------+ | 参数 capacity = 6 |
| 局部 count |
+------------------------------+
被调用者通过 values 指针写入的正是 main 帧里那 6 个元素;地址本身是"按值"传进去的。
【与汇编的对应】(LC-3:传”数组指针 + 长度”并按下标访问)
; main 里调用 array_sum (numbers, count):传"首元素地址 + 长度"
LEA R0,numbers ; 第一个实参:数组首元素地址(只占 1 个字)
ADD R6,R6,#-1
STR R0,R6,#0 ; 注意:压的是地址,不是 6 个元素
LDR R0,R5,#3 ; 第二个实参:count
ADD R6,R6,#-1
STR R0,R6,#0
JSR ARRAY_SUM
LDR R0,R6,#0 ; 返回值(和)
ADD R6,R6,#3 ; 弹出返回值 + 2 个实参
; array_sum 内部:values 在 R5+4、count 在 R5+5;
; LDR R1,R5,#4 / ADD R1,R1,R2 / ADD R1,R1,R2 / LDR R3,R1,#0
; 就是 values[i](指针 + i × 2 个字),所以它写进的正是 main 的数组。
代码 (C) — 递归预览:
/* ECE220 Lecture 7 demo -- a preview of recursion.
Build: gcc -g -std=c99 -Wall -Werror l07_recursion.c -o l07_recursion */
#include <stdint.h>
#include <stdio.h>
static int32_t fib_calls = 0; /* static storage: survives between calls */
/* factorial prints its own call chain: each level adds two spaces. */
static int32_t
factorial (int32_t n, int32_t depth)
{
int32_t result;
printf ("%*sfactorial(%d)\n", 2 * (int)depth, "", (int)n);
if (1 >= n) {
return 1; /* base case: stop recursing */
}
result = n * factorial (n - 1, depth + 1);
printf ("%*sfactorial(%d) = %d\n", 2 * (int)depth, "", (int)n,
(int)result);
return result;
}
/* naive Fibonacci: the number of calls grows exponentially */
static int32_t
fib (int32_t n)
{
fib_calls = fib_calls + 1;
if (2 > n) {
return n; /* base cases: fib(0) = 0, fib(1) = 1 */
}
return fib (n - 1) + fib (n - 2);
}
int
main ()
{
int32_t i;
int32_t value;
printf ("factorial(5) = %d\n", (int)factorial (5, 0));
for (i = 0; 11 > i; i++) {
fib_calls = 0;
value = fib (i); /* one statement per call: argument
evaluation order is unspecified */
printf ("fib(%2d) = %4d, using %d calls\n",
(int)i, (int)value, (int)fib_calls);
}
return 0;
}
实际编译运行结果(./l07_recursion):
factorial(5)
factorial(4)
factorial(3)
factorial(2)
factorial(1)
factorial(2) = 2
factorial(3) = 6
factorial(4) = 24
factorial(5) = 120
factorial(5) = 120
fib( 0) = 0, using 1 calls
fib( 5) = 5, using 15 calls
fib(10) = 55, using 177 calls
【代码做什么?】 factorial 先打印当前层(缩进表示深度),到基准情形 n <= 1 就返回 1,否则求出 n-1 的阶乘再乘 n; fib 用最朴素的 fib(n-1) + fib(n-2) 递归,并用静态存储期的 fib_calls 统计调用次数(每次调用前用单独的语句清零,避免求值顺序问题)。
【底层机制透视】
- 基准情形是刹车:两个 base case 都必须在递归调用之前检查,否则永远到不了出口;打印出的缩进层级就是同时存活的栈帧数(
factorial(5)最深时 5 个帧)。 - 每层都有自己的副本:每层递归的
n、result都在新的栈帧里,这正是”按值传递 + automatic 存储”的直接结论。 - 调用次数指数增长:1, 1, 3, 5, 9, 15, 25, 41, 67, 109, 177——同一子问题被反复求解,这就是”记忆化”与动态规划的动机。
fib_calls必须是 static 存储期:它要在两次调用之间保留值;若声明成循环体内的 automatic 变量,计数永远是 1。- 递归与迭代等价:任何递归都能改写成”显式栈 + 循环”;但树、图这类结构本身递归的问题,递归写法几乎总是更短更清晰。
【内存布局图解】
factorial(5, 0) 执行到最深处的栈(自高地址向低地址):
高地址 +----------------------------+ 返回顺序与调用顺序相反(后进先出):
| main 的栈帧 | 1 → 2 → 6 → 24 → 120,与打印结果一致
+----------------------------+
| factorial(5,0):n=5 |
+----------------------------+
| factorial(4,1):n=4 |
+----------------------------+
| factorial(3,2):n=3 |
+----------------------------+
| factorial(2,3):n=2 … 直到 factorial(1,4):n=1(基准情形,最先销毁)
低地址 +----------------------------+
fib_calls 是 static 存储期,放在全局数据区,不属于任何一层栈帧。
【与汇编的对应】(LC-3:递归调用的骨架——与普通调用完全相同,被调用者就是自己)
FACTORIAL ; 参数 n 在 R5+4,depth 在 R5+5
ADD R6,R6,#-4 ; 1 个局部变量(result)+ 3 个 linkage 字
STR R5,R6,#1
ADD R5,R6,#0
STR R7,R5,#2 ; 必须保存 R7:递归调用会覆盖它!
LDR R0,R5,#4
ADD R0,R0,#-1 ; if (1 >= n) → base case
BRnz BASE_CASE
LDR R0,R5,#4 ; 压入实参 n-1(depth+1 同理),然后:
ADD R6,R6,#-1
STR R0,R6,#0
JSR FACTORIAL ; 递归调用自己,被调用者就是本函数
LDR R1,R6,#0 ; R1 <- factorial(n-1)
ADD R6,R6,#3 ; 弹出返回值 + 2 个实参
BASE_CASE
LDR R7,R5,#2 ; 每层恢复的是"本层保存的"返回地址
LDR R5,R5,#1
ADD R6,R6,#3
RET
常见错误与调试技巧
- 以为按值传递能改变调用者的变量:写了
void swap (int32_t a, int32_t b)却发现没换成功。调试:打印&a与调用者变量的地址就能看出是两个不同的地址;gdb里break swap后p &a与p &x对比,或up到调用者帧看x的值。 - 忘记声明(原型):C99 会给
implicit declaration of function警告,并假设返回值是int、实参按默认规则转换,在 64 位机器上常导致崩溃。调试:gcc -std=c99 -Wall -Werror直接拦住;gcc -E l07_main.c \| grep array_sum确认头文件真的被包含;nm -u l07_main.o查还有哪些未解析符号。 sizeof用在数组参数上:在函数里写sizeof (values)得到 8 而不是 24。调试:$ gcc -g -std=c99 -Wall -Werror -c l07_stats.c l07_stats.c:60:28: error: 'sizeof' on array function parameter 'values' will return size of 'const int32_t *' {aka 'const int *'} [-Werror=sizeof-array-argument]修法:在数组仍然”是数组”的地方(如
main)算好长度,再作为参数传进去。- 头文件缺少包含卫士 / 在头文件里放定义:前者导致
redefinition或重复声明,后者导致链接时报multiple definition of 'array_sum'。调试:gcc -E -H l07_main.c 2>&1 \| head -20打印实际的头文件包含树;nm l07_main.o \| grep array_sum看符号是T(定义)还是U(引用)—— 头文件里只留声明,定义放回.c。 - 递归缺少基准情形或基准情形太晚:程序跑一会儿以
Segmentation fault结束(栈溢出)。调试:gdb ./prog后run,崩溃时bt 20会打印几十层重复的递归帧; 也可以在函数开头打印参数(像本例那样),观察它是否朝基准情形前进。 - 数组越界写坏调用者的栈帧:
capacity判错或忘了判断时,多读进来的元素会覆盖相邻变量甚至返回地址。调试:gcc -g -fsanitize=address -std=c99 -Wall file.c -o file会在越界那一刻精确报错;valgrind --leak-check=full --track-origins=yes ./prog也能定位;gdb里p count、p capacity检查边界条件。
关键要点
- 函数是 C 的基本分解单元:签名定义接口(名字、参数、返回类型),函数体定义实现;只要签名不变,实现的改动不会影响任何调用点。
- C 只有按值传递:被调用者拿到参数的拷贝,改它不会改调用者;想改调用者的数据必须传”指向它的地址”(指针),这也是下一讲栈帧机制的出发点。
- 数组作为参数会退化为指针:函数里
sizeof得到的是指针大小,因此长度必须单独传;这也意味着函数拥有读写调用者数组元素的能力。 - 多文件程序 = 声明与实现分离:头文件放原型(带包含卫士),
.c放定义,gcc一次列出所有.c交给链接器把未定义符号接上。 - 递归 = 每层一个新栈帧 + 一个可靠的基准情形:先写停止条件并确认每次调用都在向它靠近;深度过大时改用循环以免栈溢出。
思考题(带答案)
下面两次调用之后
x分别是多少?为什么? (static void bump (int32_t n) { n = n + 1; }与static void real_bump (int32_t *n) { *n = *n + 1; }; 调用序列:int32_t x = 5; bump (x); printf ("%d\n", x); real_bump (&x); printf ("%d\n", x);) 答案:先打印5,再打印6。bump改的是副本n;real_bump收到的虽然是”地址的副本”,但顺着这个地址改的是x本身。 这说明”按值传递”限制的是参数本身,而不是参数所指向的对象。同一个数组在
main里sizeof得 24,在被调函数的参数上sizeof得 8,解释这两个数字。 答案:main里它是真正的数组(6 个int32_t,6 × 4 = 24 字节);作为实参传给函数后它退化为指向首元素的指针,所以sizeof得到指针大小(64 位机器 8 字节)。 这也说明函数无法从参数得知数组长度,必须另传长度参数;在 ECE 220 的编译选项下,gcc 会用-Werror=sizeof-array-argument把这类写法直接判为错误。为什么课程建议”函数尽量少返回
void“?不返回void的函数该怎么设计返回值? 答案:void意味着”调用者没有地方检查失败”;一旦这个函数将来需要处理错误,你就得回头修改所有调用点。更好的做法是返回int32_t(0 成功、非 0 是错误码)或bool, 并在函数开头检查参数、在文档注释里写清每个返回值的含义——这样调用点从第一天起就能写if (0 != print_square (size)) { ... }。
