智能程序设计(C语言)


多文件组织

计算机学院    杨已彪

yangyibiao@nju.edu.cn

提纲

  • 预处理与宏

  • 带参宏

  • 源文件与头文件

  • #include、共享宏/类型/函数原型

  • 保护头文件

  • 构建多文件程序与 makefile

  • 课堂编码: 拆成多文件


预处理: 编译前的文本加工

编译前的预处理阶段处理以 # 开头的命令: #include 把文件内容插入指令处; #define 做文本替换; #if/#ifdef/#ifndef 按条件选择参与编译的代码.

宏 = 纯文本替换, 用 gcc -E 观察展开前后:

/* celsius.c */
#define FREEZING_PT 32.0
#define SCALE_FACTOR (5.0 / 9.0)
double to_celsius(double fahr)
{
  return (fahr - FREEZING_PT) * SCALE_FACTOR;
}

(fahr - FREEZING_PT) * SCALE_FACTOR 预处理后变成 (fahr - 32.0) * (5.0 / 9.0).

  • 记住“就是文本替换”, 宏的各种看似异常的现象都能由此解释

  • 经典错误 ①: #define N = 100——多了等号, 替换进代码后成为非法表达式

  • 经典错误 ②: #define N 100;——多了分号, 展开后把分号带进表达式

  • 宏行末没有分号, 宏名习惯全大写


宏定义: 带参宏

为 int、double 各写一个 max 函数太重复, 能否用文本替换解决?

#define MAX(a, b) ((a) > (b) ? (a) : (b))

x = MAX(p + q, r + s);
/* 展开为 x = ((p + q) > (r + s) ? (p + q) : (r + s)); */
  • 每个参数和整个替换列表都要加括号: 写成 #define MAX(a,b) a>b?a:b 时, MAX(x & 1, y) 会展开成 x & 1>y?x & 1:y, 运算顺序完全改变

  • 宏在预处理阶段只做文本替换: 实参不会先求值, 也没有类型; 实参原样替换, 展开后可能求值多次, 也可能一次也不求值——MAX(i++, j) 在 i++ > j 时会自增两次, 所以不要传带副作用的实参

  • 宏名与左括号之间不能有空格: #define MAX (a,b) ... 会被当成“无参数的 MAX”, 报错位置与宏定义相距较远


源文件

C 程序可分割成任意数量的源文件 (扩展名 .c), 每个源文件包含程序的一部分 (主要是函数和变量的定义); 其中一个必须包含 main 函数, 作为程序的起点.

例: 逆波兰记法 (RPN) 计算器 (30 5 - 7 * → 175)——读取操作数与运算符, 用栈跟踪中间结果, 下图是程序划分方案.

RPN 计算器: 按功能拆成三个源文件 calc.c main · 主循环 token.c get_token() stack.c push / pop / ... 表示栈的变量 (contents/top) 也放这里 调用 调用 每个 .c 单独编译; 相关的函数和变量放在一起
源文件 (续)

读取记号的函数可以与任何需要用到记号的函数一起放入一个源文件(例如token.c).

与栈相关的函数, 如push, pop, make_empty, is_empty和is_full可以放入另一个文件stack.c中.

代表栈的变量也放入stack.c.

main函数放入另一个文件calc.c中.

把程序拆成多个源文件的好处:

  • 将相关函数和变量分组到一个文件中可以使程序的结构清晰.

  • 每个源文件都可以单独编译, 节省时间.

  • 将函数分组放在不同的源文件中更利于复用.


头文件

程序拆成几个源文件后, 随之而来的问题:

  • 一个文件中的函数如何调用另一个文件中定义的函数?

  • 函数如何访问另一个文件中的外部变量?

  • 两个文件如何共享相同的宏定义或类型定义?

使用 #include 指令可以在任意数量的源文件之间共享信息.


#include指令告诉预处理器插入指定文件的内容.

可以将要在多个源文件之间共享的信息放入这样的文件中.

#include然后可用于将该文件的内容带入每个源文件.

以这种方式包含的文件称为头文件(有时称为包含文件).

按照惯例, 头文件的扩展名为.h.


#include 指令

代码
#include <文件名>     /* 尖括号: 搜索系统头文件目录 */
#include "文件名"     /* 引号: 先搜源文件目录, 再搜系统目录 */
#include "utils.h"              /* 推荐: 相对路径 */
#include "../include/utils.h"   /* 推荐: 相对路径 (UNIX) */
#include "/cprogs/utils.h"      /* 不推荐: 绝对路径 */
要点
  • <文件名> 搜索系统头文件目录; "文件名" 先搜源文件所在目录, 再搜系统目录

  • -I 目录 追加尖括号的搜索路径; -iquote 目录 追加引号的搜索路径

  • 自己写的头文件用引号, 不要用尖括号; 路径推荐相对路径——项目移动或换机器后仍然有效

  • .. 表示上一级目录; 相对路径相对于源文件所在目录; Windows 可写 ..\include\utils.h

  • 路径中的 \ 原样保留, 不按转义序列处理; 跨平台统一写 /


#include 指令 (续)

#include 标记            /* 标记是宏名, 展开后须形如 <文件名> 或 "文件名" */
#if defined(IA32)
  #define CPU_FILE "ia32.h"
#elif defined(IA64)
  #define CPU_FILE "ia64.h"
#elif defined(AMD64)
  #define CPU_FILE "amd64.h"
#endif

#include CPU_FILE

#include 后面也可以是一个宏名: 预处理器先展开宏, 再按展开结果包含文件.

条件编译常用来根据平台选择不同的头文件.

#if / #elif / #else / #endif 按常量表达式选择分支; #ifdef / #ifndef 检查宏是否已定义.

defined(名字) 在宏已定义时为真, 所以 #if defined(IA32) 与 #ifdef IA32 等价.


共享宏定义和类型定义

/* boolean.h —— 宏版 */
#define BOOL int
#define TRUE 1
#define FALSE 0
/* boolean.h —— typedef 版 */
typedef int Bool;
#define TRUE 1
#define FALSE 0
/* prog.c */
#include "boolean.h"

大型程序常有多个源文件共享的宏定义和类型定义; 把它们放进头文件, 其他文件只需一行 #include 即可使用.

typedef 版比宏版更好: 有类型检查, 调试器能看到类型名, 也遵守作用域规则.

把宏和类型定义放头文件的好处:

  • 不必把定义复制到每个源文件, 改一处即可生效

  • 避免各源文件包含的定义不一致


共享函数原型

/* stack.h */
void make_empty(void);
int is_empty(void);
int is_full(void);
void push(int i);
int pop(void);

左列以 stack.h 为例. 源文件调用另一个文件里定义的函数时, 不声明会直接报错——C99 起标准不再支持隐式声明; 现代 GCC 14+/Clang 16+ 默认报错, 旧版本仅警告. 即使编译通过, 编译器对返回类型与默认实参提升的假设也可能在运行时出错.

到处声明又造成维护负担. 更好的方案: 原型放头文件, 调用处都包含它; 定义文件也包含该头文件, 让编译器核对原型与定义一致.

对外提供的函数在头文件中声明; 只在文件内部使用的函数不应写进头文件 (会误导), 应加 static.


共享函数原型 (续)

RPN 计算器示例: stack.c 定义 make_empty / is_empty / is_full / push / pop, 原型放 stack.h; calc.c 包含 stack.h 检查调用, stack.c 也包含它核对定义一致:


文件作用域的 static

代码
/* stack.c */
static int top;                 /* 只在本文件可见 */

static void stack_error(void)   /* 内部辅助函数, 不写进 stack.h */
{
  /* 只在 stack.c 内部使用 */
}
/* token.c */
static void stack_error(void)   /* 与 stack.c 中的同名函数互不冲突 */
{
  /* 只在本文件内部使用 */
}
要点
  • 文件作用域的 static 把名字改为内部链接: 只在本翻译单元 (一个 .c 连同它包含的头文件) 内可见, 链接器看不到.

  • 用途: 隐藏实现——内部辅助函数不必写进头文件; 避免重名——不同 .c 可以有同名的 static 函数或变量.

  • 与之前学过的 static 局部变量区分: 函数内的 static 改变的是存储期 (程序运行期间一直存在); 文件作用域的 static 改变的是可见性.

  • 只在文件内部使用的函数应加 static, 不写进头文件.


共享变量声明

/* foo.h */
extern int i;             /* 声明: i 在别处定义 */
extern int a[];           /* 数组声明可省略长度 */
/* file1.c */
#include "foo.h"
int i;                    /* 定义: 为 i 分配空间 */
int a[10];                /* 定义: 数组 */
/* file2.c */
#include "foo.h"          /* 需要访问 i / a 的文件包含 foo.h */
/* file3.c (错误示例) */
extern long i;            /* 与 foo.h 中 int 的类型不一致! */

共享函数: 定义放在一个源文件, 声明放在头文件中供其他文件包含; 共享外部变量的方式相同——extern 声明放进头文件 (如 foo.h), 定义 (int i;) 只在一个 .c 文件中; 定义文件也包含 foo.h, 编译器才能核对类型.

文件作用域下, 不带初始化式的 extern 声明不分配空间; 带初始化式则是定义 (如 extern int i = 0;).

类型必须一致: 某文件定义 int i;, 另一文件却写 extern long i;, 编译器可能查不出, 运行时行为异常.


嵌套包含

/* stack.h */
int is_empty(void);
int is_full(void);
/* stack.h */
Bool is_empty(void);
Bool is_full(void);

头文件可能包含 #include 指令.

stack.h 里的原型:

这些函数只返回 0 或 1, 返回类型最好声明成 Bool:

在 stack.h 中包含 boolean.h, 以便在编译时可以使用 Bool 的定义.

传统上, C 程序员避免使用嵌套包含.

如今, 嵌套包含在 C/C++ 中都很常见; 配合头文件保护即可安全使用.


保护头文件

/* file3.h (未加保护) */
#define TRUE 1
#define FALSE 0
typedef int Bool;

如果一个源文件两次包含相同的头文件, 可能会导致编译错误.

以 file3.h 为例: file1.h 和 file2.h 都包含 file3.h, prog.c 又同时包含 file1.h 和 file2.h——编译 prog.c 时 file3.h 被包含两次:

  • 只含宏定义/函数原型/变量声明时, 重复包含不会出错; 含结构体/枚举定义、变量定义或 typedef时就可能编译错误

  • 安全起见, 所有头文件都加保护: 以后往头文件里加类型定义也不用担心忘记保护


保护头文件: ifndef 写法

/* file3.h */
#ifndef FILE3_H
#define FILE3_H

#define TRUE 1
#define FALSE 0
typedef int Bool;

#endif

把文件的内容包在 #ifndef - #endif 对里:

  • 第一次包含: FILE3_H 未定义 → 定义它 + 展开内容

  • 之后再包含: FILE3_H 已定义 → 直接跳过全部内容

宏名照头文件名取 (FILE3.H 不能作宏名, 用 FILE3_H), 避免与其他宏冲突

prog.c file1.h file2.h file3.h 第 1 次: 展开 第 2 次: 跳过 同一份 file3.h, 编译 prog.c 时被包含两次 第一次: FILE3_H 未定义 → 定义它, 展开内容 第二次: FILE3_H 已定义 → 整段跳过
将程序划分为文件

设计程序需确定程序需要什么函数并将函数分为逻辑相关的组.

一旦设计好程序, 就有一种简单的方法可以将其分成文件.


每组函数都将放入一个单独的源文件(foo.c).

为每个对外提供函数的源文件创建一个同名的头文件 (foo.h); 只含 main 的文件通常不需要.

  • foo.h将包含foo.c中定义的函数的原型.

  • 仅在foo.c中使用的函数不应在foo.h中声明.

foo.h将包含在需要调用foo.c中定义的函数的每个源文件中.

foo.c也将包含foo.h, 编译器检查函数原型与定义是否匹配.

main函数将放入一个名称与程序名称匹配的文件中.

main所在的文件中也可以有其他函数, 只要程序中的其他文件不会调用它们.


构建多文件程序

以 RPN 计算器为例: 它由 calc.c、stack.c、token.c 三个源文件组成.

  • 构建大型程序与小型程序步骤相同: 编译 + 链接

  • 每个源文件各自形成一个翻译单元, 编译器分别编译; 头文件不单独编译, 随包含它的源文件一起编译; 每个源文件生成一个目标文件 (.o / .obj)

  • gcc -c calc.c 只编译不链接, 生成目标文件 calc.o

  • 链接器把目标文件与库函数代码组合成可执行文件, 并解决外部引用 (一个文件调用另一个文件中的函数或变量)

  • 也可以用一条命令自动完成编译 + 链接: gcc -o calc calc.c stack.c token.c——三个源文件先编译成目标文件, 自动传给链接器; -o 指定可执行文件名

⚡ 在 VS Code 中打开 calc.c (多文件框架) ① 编译 ② 链接 calc.c stack.c token.c calc.o stack.o token.o gcc -c calc 库函数 (libc) 头文件 (.h) 不单独编译, 随包含它的 .c 源文件一起编译
makefile

calc: calc.o stack.o token.o
	gcc -o calc calc.o stack.o token.o
calc.o: calc.c stack.h token.h
	gcc -c calc.c
stack.o: stack.c stack.h
	gcc -c stack.c
token.o: token.c token.h
	gcc -c token.c

为了更容易构建大型程序, UNIX 发明 makefile 的概念.

makefile 不仅列出了作为程序一部分的文件, 而且还描述了文件之间的依赖关系.

假设文件 foo.c 包含文件 bar.h.

我们说 foo.c "依赖于" bar.h, 因为修改 bar.h 之后需要重新编译 foo.c.

针对 calc 程序的 UNIX 系统的 makefile:

有 4 组代码行; 每个组称为一条规则.

每条规则的第一行给出目标 (target)和它所依赖的文件.

第二行是当依赖文件发生改变、需要重新构建目标时要执行的命令.

⚡ 在 VS Code 中打开 Makefile

makefile (续)

calc: calc.o stack.o token.o
        gcc -o calc calc.o stack.o token.o

上面第二行开头是 8 个空格, 不是 TAB——这就是错误所在.

在第一条规则中, calc (最终可执行文件) 是目标 (target):

第一行说明 calc 依赖于文件 calc.o, stack.o 和 token.o.

如果自上次构建程序以来这些文件中的任何一个发生了更改, 则需要重新构建 calc.

下一行的命令显示了如何进行重新构建.

每个命令行必须以 TAB 开头——写 8 个空格, make 会报 missing separator 错误 (上面就是错误示范).


课堂编码: 拆成多文件

任务: 把 RPN 计算器拆成 calc.c / stack.c / token.c + 各自的 .h + Makefile; code/ 中已备好 TODO 框架, 补全各函数后构建.

  • calc.c: main 主循环——读记号; 数字入栈; 运算符弹出两个操作数, 计算后入栈; 结束后打印栈顶

  • stack.c: make_empty / is_empty / is_full / push / pop; 栈变量 contents、top 用文件作用域 static

  • token.c: get_token——跳过空白, 读整数或运算符, 到 EOF 返回 EOF

  • stack.h / token.h: 函数原型与 STACK_SIZE, 保护宏已写好

  • Makefile: 4 条规则已给出, 注意命令行行首是 TAB

验收: make 构建成功; 运行 ./calc, 输入 30 5 - 7 *, 输出 175

$ make
$ ./calc
30 5 - 7 *
175

提示:

  • 头文件只放声明, 定义放 .c; 内部辅助函数加 static

  • 先 make 看链接错误, 再回去补函数


课堂编码: 参考代码与要点

拆分后的文件清单:

code/
  calc.c      main: 读记号、计算、输出
  stack.c     push/pop/... + static 变量
  stack.h     原型 + STACK_SIZE
  token.c     get_token()
  token.h     get_token 原型
  Makefile    4 条规则

#include 关系:

calc.c  ──→ stack.h, token.h
stack.c ──→ stack.h
token.c ──→ token.h

Makefile 三条要点:

  • 规则写成 目标: 依赖, 下一行是重建命令

  • 命令行的行首必须是 TAB, 8 个空格会报 missing separator

  • 头文件也要写进依赖: 改 stack.h 会重编 calc.o 与 stack.o

关键点:

  • 头文件只放声明 (原型/宏/extern), 定义放 .c

  • stack.c 的 contents / top 加 static, 其他文件不可见

  • 只被本文件调用的辅助函数也加 static, 不写进 .h

  • 先编译后链接: gcc -c 生成 .o, 再 gcc -o calc *.o


链接期间的错误

一些编译时检测不到的错误会在链接时发现.

如果程序中缺少函数或变量的定义, 链接器将无法解析对其的外部引用

导致出现诸如"undefined symbol"或"undefined reference"之类的消息.

链接过程中常见的错误原因:

  • 拼写错误. 如果变量或函数的名称拼写错误, 链接器将报告它丢失.

  • 缺少文件. 忘记把 foo.c 编译出的目标文件交给链接器, 链接器就找不到其中的函数.

  • 缺少库. 链接器可能无法找到程序中使用的所有库函数.

在 UNIX 中, 当程序使用 <math.h> 中的数学函数时, 可能需要加 -lm 选项——-l 表示链接库, m 是数学库 libm.


课堂小测

/* a.c */
#include <stdio.h>
extern int n;
int main(void) { printf("%d\n", n); return 0; }

/* b.c */
int n = 1;

/* c.c */
static int n = 2;

概念 1: 头文件里通常放什么?源文件 (.c) 里放什么?

概念 2: 为什么头文件要加保护宏 (#ifndef … #define … #endif)?

阅读: 左列是三个源文件. 把 a.c、b.c、c.c 一起编译链接, 输出什么?如果漏掉 b.c, 只链接 a.c 和 c.c, 会在哪一步出错?为什么?

纠错: make 报 missing separator, 最可能是什么原因?应该改成什么?



未完待续

🔒 本课件尚未发布