Skip to content

Lec 2 C 语言基础

本讲主线: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,完全不接触栈的表示

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

c
#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) { ... }

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

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

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

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

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

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

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

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

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

c
#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;
}

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

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


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

2.1 自动变量 vs 外部变量

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

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

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

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

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

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

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

c
// 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:文件级隐藏(调"链接"旋钮)

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

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

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

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

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

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

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

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

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

特性普通局部变量(automaticstatic 局部变量
作用域函数内函数内(相同
生命周期调用时创建,返回时销毁程序整个运行期间持续存在
初始化每次调用都重新初始化仅在程序启动时初始化一次
c
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 放回。

为什么想去掉 ungetch 因为 ungetch 依赖一套外部状态(buf[]bufp),引入了额外的全局变量和函数依赖。如果 getop自己记住那个多读的字符,这套机制就不需要了——而"私有 + 跨调用持久"正是内部 static 的拿手好戏。

c
// 原始方案:依赖外部缓冲区 buf[] 和 ungetch/getch 这对函数
int getop(char s[]) {
    int c;
    // ... 跳过空格,读数字 ...
    while (isdigit(s[++i] = c = getch()))
        ;
    // 循环结束时,c 是第一个"不是数字"的字符(如 '+')
    if (c != EOF)
        ungetch(c);   // 放回去,下次 getch() 会再读到它
    return NUMBER;
}
// 改进方案:内部 static 变量,自己记住多读的字符
int getop(char s[]) {
    static int lastc = 0;  // 私有、持久,记住上次多读的字符
    int c;

    // 第一步:看看上次有没有"存货"
    if (lastc != 0) {
        c = lastc;      // 用上次存下来的
        lastc = 0;      // 取走了,清空
    } else {
        c = getch();    // 没存货,正常读
    }

    // ... 跳过空格、判断是否是数字 ...
    while (isdigit(s[++i] = c = getch()))
        ;

    if (c != EOF)
        lastc = c;      // 不放回了,自己存着(← 原来是 ungetch(c))

    return NUMBER;
}

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

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

4.1 宏定义与 #undef

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

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

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

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

4.2 字符串化(#

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

c
#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 记号粘贴(##

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

c
#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 只被包含一次:

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

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

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

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

c
#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 类型的参数:

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

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

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

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