Lecture 7: C 函数:定义、调用、参数传递与返回值 (Introduction to Functions in C)

目录 · ← l6 · l8 →

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;)。
    • 作用域与存储期:返回值是临时值,由调用者立刻使用或存进自己的变量,不占用被调用者的栈帧。
  • 多文件、头文件与分别编译 (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 里的 argmain 里的 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 < 1guessing_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 打印 xy 的地址与值;try_to_swap (x, y) 在函数内部确实完成了交换,但回到 mainxy 完全没有变化add_one_by_value (x) 返回 8,而 x 仍是 7。

【底层机制透视】

  • 地址就是证据main&x = 0x7ffe8f859438try_to_swap&first = 0x7ffe8f85940c——两者相差 44 字节,属于不同的栈帧firstx 的副本,交换副本当然不会影响原件。ysecond 之间同理。
  • 被调用者的参数区由调用者准备: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_valuesprint_arrayarray_sumarray_meanl07_stats.c 提供全部实现,两个文件都靠 #include "l07_stats.h" 拿到签名; read_valuesscanf 反复读整数直到输入结束或数组满。打印出的 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.oarray_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_arrayarray_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 的阶乘再乘 nfib 用最朴素的 fib(n-1) + fib(n-2) 递归,并用静态存储期的 fib_calls 统计调用次数(每次调用前用单独的语句清零,避免求值顺序问题)。

【底层机制透视】

  • 基准情形是刹车:两个 base case 都必须在递归调用之前检查,否则永远到不了出口;打印出的缩进层级就是同时存活的栈帧数(factorial(5) 最深时 5 个帧)。
  • 每层都有自己的副本:每层递归的 nresult 都在新的栈帧里,这正是”按值传递 + 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 与调用者变量的地址就能看出是两个不同的地址; gdbbreak swapp &ap &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 ./progrun,崩溃时 bt 20 会打印几十层重复的递归帧; 也可以在函数开头打印参数(像本例那样),观察它是否朝基准情形前进。
  • 数组越界写坏调用者的栈帧capacity 判错或忘了判断时,多读进来的元素会覆盖相邻变量甚至返回地址。调试gcc -g -fsanitize=address -std=c99 -Wall file.c -o file 会在越界那一刻精确报错;valgrind --leak-check=full --track-origins=yes ./prog 也能定位; gdbp countp capacity 检查边界条件。

关键要点

  • 函数是 C 的基本分解单元:签名定义接口(名字、参数、返回类型),函数体定义实现;只要签名不变,实现的改动不会影响任何调用点。
  • C 只有按值传递:被调用者拿到参数的拷贝,改它不会改调用者;想改调用者的数据必须传”指向它的地址”(指针),这也是下一讲栈帧机制的出发点。
  • 数组作为参数会退化为指针:函数里 sizeof 得到的是指针大小,因此长度必须单独传;这也意味着函数拥有读写调用者数组元素的能力。
  • 多文件程序 = 声明与实现分离:头文件放原型(带包含卫士),.c 放定义,gcc 一次列出所有 .c 交给链接器把未定义符号接上。
  • 递归 = 每层一个新栈帧 + 一个可靠的基准情形:先写停止条件并确认每次调用都在向它靠近;深度过大时改用循环以免栈溢出。

思考题(带答案)

  1. 下面两次调用之后 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,再打印 6bump 改的是副本 nreal_bump 收到的虽然是”地址的副本”,但顺着这个地址改的是 x 本身。 这说明”按值传递”限制的是参数本身,而不是参数所指向的对象。

  2. 同一个数组在 mainsizeof 得 24,在被调函数的参数上 sizeof 得 8,解释这两个数字。 答案main 里它是真正的数组(6 个 int32_t,6 × 4 = 24 字节);作为实参传给函数后它退化为指向首元素的指针,所以 sizeof 得到指针大小(64 位机器 8 字节)。 这也说明函数无法从参数得知数组长度,必须另传长度参数;在 ECE 220 的编译选项下,gcc 会用 -Werror=sizeof-array-argument 把这类写法直接判为错误。

  3. 为什么课程建议”函数尽量少返回 void“?不返回 void 的函数该怎么设计返回值? 答案void 意味着”调用者没有地方检查失败”;一旦这个函数将来需要处理错误,你就得回头修改所有调用点。更好的做法是返回 int32_t(0 成功、非 0 是错误码)或 bool, 并在函数开头检查参数、在文档注释里写清每个返回值的含义——这样调用点从第一天起就能写 if (0 != print_square (size)) { ... }