Archipelago

Lec 2C 语言基础

讲义完成更新于 2026-09-29

Lec 2 C 语言基础

TL;DR

  • C 语言的对象管理通过三个正交维度进行调节:作用域(代码可见性)、生命周期(内存存活期)与链接属性(跨编译单元可见性)。
  • 外部变量(extern)具备全局作用域与静态生存期,承担跨函数数据共享职责,但容易引入全局状态污染与并发竞争。
  • static 关键字具有双重语义:修饰文件级全局对象时限定为内部链接(隐藏符号);修饰函数局部变量时赋予其跨调用的持久生存期。
  • 变量声明与定义存在本质区分:extern int x; 仅引入类型签名供编译器类型检查,实际存储空间仅在无 extern 的唯一定义处分配。
  • C 预处理器在纯文本层面运行,宏展开需严格处理操作数括号防结合优先级反转,且利用 do { ... } while(0) 保障语句吞分号安全性。

本讲主线:C 程序由一组外部对象(external objects,即变量与函数)组成。语言提供了一套机制,让我们能控制每个对象的三个属性——作用域、生命周期、链接——从而决定数据如何在函数之间、文件之间共享与隐藏。最后再补上一个独立于编译的文本处理阶段:预处理器。


0. 统一框架:作用域、生命周期、链接

理解本讲的关键,是把变量/函数的三个正交属性分开看:

属性含义决定了什么
作用域(scope)名字在源码中何处可见谁能引用它
生命周期(lifetime / storage duration)对象在内存中存活多久值是否跨调用保留
链接(linkage)不同文件中的同名引用是否指向同一对象能否跨文件共享

本讲涉及的四类对象,全部可以挂到这张表上——后面的所有内容,本质都是在调这三个旋钮:

种类作用域生命周期链接
自动变量(块内声明)块 / 函数内自动(进入时创建,退出时销毁)无
外部变量声明点 → 文件末尾(可经 extern 跨文件)静态(程序全程存活)外部(external)
外部 static(变量/函数)当前文件内静态内部(internal)
内部 static(函数内局部变量)块 / 函数内静态(程序全程存活)无

抓住一句话:外部变量管"共享",static 管"隐藏 / 持久"。下面分别展开。

1. 外部变量:跨函数共享数据

1.1 基本概念

"外部"(external)是相对于"内部"(internal,即函数内部的参数和局部变量)而言的。外部变量的特性,正好对应框架里的三个旋钮:

  • 作用域:定义在所有函数之外,从声明点起到文件末尾全局可访问,可作为函数间通信的替代手段(代替参数和返回值)。
  • 生命周期:永久——从程序启动到结束,值在函数调用之间持续保留。
  • 链接:默认外部链接(external linkage),不同源文件中对同一名字的引用指向同一个对象(类似 Fortran 的 COMMON 块)。

外部变量方便共享,但要谨慎使用——过多的外部变量会让函数之间产生过度的数据耦合,破坏程序结构。共享是它的优点,也是它的陷阱。

1.2 实例:逆波兰计算器

K&R 用一个逆波兰表示法(Reverse Polish Notation)计算器,来演示"何时该用外部变量"的设计决策。

逆波兰表示法:运算符跟在操作数后面,不需要括号。

中缀:    (1 - 2) * (4 + 5)
逆波兰:  1 2 - 4 5 + *

实现思路:用栈(stack)驱动——操作数入栈,遇到运算符就弹出操作数、计算、把结果压回去。

主循环伪代码:
while (还有输入)
    if (数字)    → push
    if (运算符)  → pop 操作数, 运算, push 结果
    if (换行)    → pop 并打印栈顶
    else         → 报错

设计决策一:栈放在哪里?

方案做法评价
放在 main 里把栈和栈指针作为参数传给 push / popmain 不应该知道栈的内部细节
用外部变量栈和栈指针定义为外部变量,push / pop 直接访问书中采用:main 只调用 push / pop,完全不接触栈的表示

程序整体结构如下。注意声明顺序本身就实现了隐藏:

#includes
#defines
函数声明

main() { ... }

/* 栈的外部变量——push/pop 可见,main 不可见 */
int sp = 0;
double val[MAXVAL];

void push(double f) { ... }
double pop(void)    { ... }
int getop(char s[]) { ... }

/* getop 的辅助函数 */
int getch(void)   { ... }
void ungetch(int) { ... }

sp 和 val 定义在 main 之后、push / pop 之前。由于外部变量的作用域从声明点开始,main 看不到它们,而 push / pop 可以直接使用——通过声明顺序天然实现了隐藏。(这正是 1.2.1 节作用域规则的直接应用。)

设计决策二:- 和 / 的求值顺序陷阱

C 标准不保证函数参数的求值顺序,因此下面这行是错的:

push(pop() - pop());   // ❌ 两个 pop() 的求值顺序未定义

对于不满足交换律的 - 和 /,必须用临时变量固定顺序:

op2 = pop();           // 先弹出右操作数
push(pop() - op2);     // 再弹出左操作数,确保顺序正确

+ 和 * 满足交换律,所以顺序无所谓。

设计决策三:getch / ungetch 回退字符

读数字时,必须多读一个字符才能知道数字在哪里结束(遇到第一个非数字字符),多读的那个字符需要"放回去":

#define BUFSIZE 100
char buf[BUFSIZE];      // 回退缓冲区
int bufp = 0;           // 缓冲区当前位置

int getch(void) {       // 有缓冲就从缓冲取,否则 getchar()
    return (bufp > 0) ? buf[--bufp] : getchar();
}

void ungetch(int c) {   // 把字符推回缓冲区
    if (bufp >= BUFSIZE)
        printf("ungetch: too many characters\n");
    else
        buf[bufp++] = c;
}

buf 和 bufp 之所以是外部变量:它们必须在 getch / ungetch 之间共享,且跨调用保持值——这正是外部变量"全局可访问 + 永久生命周期"两个旋钮同时起作用的场景。

💡 记住这个例子:第 3 节会用内部 static 把它改写得更干净(练习 4-11)。


2. 作用域规则:控制"何处可见"

2.1 自动变量 vs 外部变量

自动变量(函数内声明)外部变量 / 函数
作用域仅限声明它的函数从声明点开始,到当前源文件末尾
同名冲突不同函数中的同名变量互不相关同一文件内是同一个对象

这就是为什么上面计算器里 main 看不到 sp 和 val——它们定义在 main 之后。

2.2 声明 vs 定义(关键区分)

定义(definition)声明(declaration)
作用宣布类型 + 分配存储空间只宣布类型,不分配空间
数量限制整个程序中只能有一个可以有多个
示例int sp; double val[MAXVAL];extern int sp; extern double val[];
初始化可以初始化不能初始化
数组大小必须指定可以省略

一句话:定义"造"对象,声明只是"指"对象。

2.3 多文件组织:用 extern 跨文件

当程序拆分为多个源文件时,靠 extern 声明来跨文件访问同一个对象(即激活"外部链接"这个旋钮):

// file1.c —— 使用 sp 和 val
extern int sp;           // 声明:告诉编译器这些变量存在于别处
extern double val[];

void push(double f) { ... }
double pop(void)    { ... }
// file2.c —— 定义 sp 和 val
int sp = 0;              // 定义:分配空间 + 初始化
double val[MAXVAL];

extern 声明放在文件顶部、所有函数之外,就对整个文件中的所有函数生效。


3. static:控制"隐藏"与"持久"

static 的本质是调整作用域和生命周期这两个旋钮。根据声明位置不同,调的旋钮也不同——这是理解它最容易混淆的地方,务必分清两种用法。

3.1 外部 static:文件级隐藏(调"链接"旋钮)

用于文件作用域的变量或函数,把链接从"外部"降为"内部",可见性限制在当前源文件内:

// getch.c
static char buf[BUFSIZE];   // 仅本文件可见
static int  bufp = 0;       // 仅本文件可见

int  getch(void)    { ... } // 全局可见
void ungetch(int c) { ... } // 全局可见

buf 和 bufp 必须是外部变量(因为 getch 和 ungetch 要共享),但加上 static 后,其他文件无法访问,名字也不会与其他文件中的同名变量冲突。

函数同样可以声明为 static,使其在文件外不可见:

static int helper(int x) { ... }  // 仅本文件内可调用

这是 C 在没有 class / namespace 的条件下实现模块封装的主要手段,可类比其他语言中的 private。

3.2 内部 static:函数内持久存储(调"生命周期"旋钮)

用于函数内部的局部变量,作用域不变,但生命周期从"自动"变为"静态":

特性普通局部变量(automatic)static 局部变量
作用域函数内函数内(相同)
生命周期调用时创建,返回时销毁程序整个运行期间持续存在
初始化每次调用都重新初始化仅在程序启动时初始化一次
void counter(void) {
    static int count = 0;  // 只初始化一次
    printf("%d\n", ++count);
}
// 连续调用输出: 1, 2, 3, ...

内部 static 为单个函数提供了私有的、持久的存储空间——它有外部变量的"持久性",但没有外部变量的"全局可见性"。

3.3 练习 4-11:用内部 static 干掉 ungetch

回到 1.2 节的 getop 词法分析函数。在逆波兰计算器中,读取数字操作数时无法预知数字何时结束,必须持续读入字符,直到读到一个非数字字符(如运算符 + 或换行符)才能判定当前操作数结束。此时,这个多读出来的字符必须被“退回”,以便下一次词法分析能正确识别为下一个运算符。

为什么想去掉 ungetch? 原始方案为了支持这种“预读后退回”机制,在模块级别引入了全局缓冲区 buf[BUFSIZE] 和栈顶指针 bufp,并配套编写了 getch() 与 ungetch() 两个外部函数。这种设计的严重缺陷在于:它将本属于 getop 内部的一个微小暂存需求,放大成了具有外部状态的全局共享设施,造成了模块间的数据耦合与命名空间污染。如果其他函数也调用 getch/ungetch,就会相互篡改缓冲区数据。

设计方案一:依赖外部缓冲区的传统实现

在没有私有持久变量时,代码必须依赖全局共享数组暂存退回字符:

// 原始方案:依赖外部缓冲区 buf[] 和 ungetch/getch 这对函数
int getop(char s[]) {
    int c;
    // 跳过前导空白字符,若为普通运算符则直接返回
    while ((s[0] = c = getch()) == ' ' || c == '\t')
        ;
    s[1] = '\0';
    if (!isdigit(c) && c != '.')
        return c;

    // 收集整数部分
    int i = 0;
    if (isdigit(c))
        while (isdigit(s[++i] = c = getch()))
            ;
    // 循环结束时,c 是第一个"不是数字"的字符(如 '+')
    if (c != EOF)
        ungetch(c);   // 放回去,依赖外部 bufp 与 buf[],下次 getch() 会再读到它
    return NUMBER;
}

设计方案二:利用内部 static 实现私有单字符缓存

仔细审视业务逻辑可以发现:getop 每次最多只会多读一个字符。因此,根本不需要一个容量为 BUFSIZE 的全局通用数组,只需要一个私有的 1-slot 单字符暂存区。而“私有可见性 + 跨调用持久存在”正是内部 static 的核心特性:

// 改进方案:内部 static 变量,自己记住多读的字符
int getop(char s[]) {
    static int lastc = 0;  // 内部静态变量:私有于 getop,生命周期贯穿程序全程,初值为 0
    int c;

    // 第一步:优先检查上次调用是否有未消费的暂存字符
    if (lastc != 0) {
        c = lastc;      // 消费暂存字符
        lastc = 0;      // 消费后立即清零重置
    } else {
        c = getch();    // 无暂存字符时,才真正从标准输入读取
    }

    while (c == ' ' || c == '\t')
        c = getch();
    s[0] = c;
    s[1] = '\0';
    if (!isdigit(c) && c != '.')
        return c;

    int i = 0;
    if (isdigit(c))
        while (isdigit(s[++i] = c = getch()))
            ;

    // 将多读出的那个非数字字符存入内部静态变量,无需通知任何外部设施
    if (c != EOF)
        lastc = c;      // 不放回全局缓冲区了,自己存着(直接取代了 ungetch(c))

    return NUMBER;
}

架构权衡与工程反思

  1. 信息隐藏收益:改进后的 getop 将退回逻辑完全封装在函数内部,彻底消除了外部 buf 和 bufp 两个全局变量,降低了跨文件链接污染与误用风险。
  2. 不可重入代价(Non-reentrancy):内部 static 变量本质上是在数据段中分配的持久内存。这意味着该函数不再具备可重入性与多线程安全性。若两个线程并发调用 getop,或者发生信号中断嵌套调用,两者会相互踩踏 lastc,导致字符遗失。因此在现代工程实践中,更优解是将此类暂存状态显式封装进调用者维护的解析器上下文结构体(Context Struct)中。

4. C 预处理器:编译前的文本处理阶段

定位:预处理器是编译过程中单独进行的第一个步骤,纯粹的文本替换,与前三节的"语言机制"是不同层面的东西。最常用的两条指令是 #include 和 #define。

4.1 宏定义与 #undef

格式为 #define 名字 替换文本。若替换文本分成若干行,只需在延续行末尾加反斜杠 \:

#define max(A, B) ( (A) > (B) ? (A) : (B) )

#undef 取消对宏名字的定义,通常是为了保证某个调用调用的是真正的函数而不是宏:

#undef getchar
int getchar(void) { ... };

4.2 字符串化(#)

引号内的文本不会被宏参数替换。但若参数名前加上 #,该参数会被展开成一个带引号的字符串,其中嵌入实参。配合相邻字符串自动拼接,可写出调试打印宏:

#define dprint(expr) printf(#expr " = %g\n", expr)

dprint(x/y)
// 展开为: printf("x/y" " = %g\n", x/y);
// 拼接后: printf("x/y = %g\n", x/y);

实参中的每个 " 会被替换为 \"、每个 \ 替换为 \\,以保证结果是合法的字符串常量。

4.3 记号粘贴(##)

## 用于在宏展开时把两个实参拼接成一个记号。规则:与 ## 相邻的参数先被替换为实参,然后 ## 及其周围空白被删除,拼接结果再被重新扫描一次:

#define paste(front, back) front ## back

paste(name, 1)   // 生成记号 name1

嵌套使用 ## 的规则较为晦涩,细节见 K&R 附录 A。

4.4 条件编译

用在预处理阶段求值的条件语句来选择性地包含代码:

  • #if:求值一个常量整型表达式(不能含 sizeof、强制类型转换或 enum 常量)。非零则包含其后内容,直到 #endif / #elif / #else。
  • #elif:相当于 else-if。
  • defined(name):名字已定义返回 1,否则返回 0。
  • #ifdef / #ifndef:专门测试名字是否已定义的简写形式。

应用一:头文件保护(include guard)——确保 hdr.h 只被包含一次:

#if !defined(HDR)
#define HDR
/* hdr.h 内容写在这里 */
#endif

首次包含时定义 HDR,后续包含会发现它已定义而直接跳到 #endif。坚持这种写法,每个头文件就能自由 #include 它所依赖的其他头文件,无需使用者去处理依赖关系。等价的简写:

#ifndef HDR
#define HDR
/* hdr.h 内容写在这里 */
#endif

应用二:按平台选择头文件

#if   SYSTEM == SYSV
  #define HDR "sysv.h"
#elif SYSTEM == BSD
  #define HDR "bsd.h"
#elif SYSTEM == MSDOS
  #define HDR "msdos.h"
#else
  #define HDR "default.h"
#endif
#include HDR

4.5 练习 4-14:swap 宏

利用块结构交换两个 t 类型的参数:

#define swap(t, x, y) do { t _z = x; x = y; y = _z; } while (0)

do { ... } while (0) 的写法,保证宏在任何语句上下文(尤其是 if-else)中都能像单条语句一样安全使用。

如果直接用裸 { ... } 而省略 do-while,分号会引发问题:

if (a > b)
    swap(int, a, b);   // 展开后 {...}; ← 分号导致 if 提前结束
else
    foo();             // 编译报错:else 找不到匹配的 if

核心机制思考与底层洞察

::: insight C 语言对象模型与链接器视角下的物理映射

  1. 链接器眼中的“符号”与“存储”:在编译器与链接器眼中,所谓的“全局变量”和“文件级 static”在底层最终都落入 ELF 文件的 .data 或 .bss 段,拥有完全相同的物理生存期(程序加载即创建,退出即释放)。二者的唯一区别在于符号表(Symbol Table)的属性——extern 产生全局绑定(STB_GLOBAL),而 static 产生局部绑定(STB_LOCAL)。理解这一点,就不会把“作用域限制”误认为是“内存释放”。
  2. 内部 static 与不可重入性的历史代价:内部 static 变量虽然优雅地封装了状态,但它本质上是硬编码在静态内存区的一块私有全局空间。这种设计直接导致函数失去可重入性(Reentrancy)与线程安全性(Thread Safety)。像标准库中的 strtok() 就是因为依赖内部 static 缓冲区,导致多线程环境下必须引入 strtok_r() 重构。在现代高并发与多核体系下,更推荐将状态由调用者显式传入上下文结构体。
  3. 宏是双刃剑:文本替换对抽象的破坏:宏不尊重类型系统,不尊重作用域,甚至会导致参数被多次求值(如 max(x++, y) 产生的著名副作用 bug)。虽然 do { ... } while (0) 解决了语法分号的展开陷阱,但现代系统编程更推荐使用 static inline 函数来获得内联加速的同时享受编译器的严格类型检查。 :::