多文件项目、编译链接与构建系统
本节目标
查询 C23 多文件项目组织、编译链接、Make 与构建配置。
一个可维护的 C 程序把接口、实现、可执行程序和构建产物分开管理。本章用一个文本统计工具说明从源文件到可执行文件的路径:接口在公开头文件中声明,实现独立编译,应用程序链接库,并由 Make 描述可重复的依赖关系。语言规则仍以 ISO C23 为边界;构建工具负责组织这些规则,不能替代它们。
工程目录与构建产物地图
本章的项目将可被调用方包含的接口放在 include/,库实现放在 src/,命令行入口放在 app/;生成的目标文件、归档库和可执行文件全放到 build/。因此清理构建产物不会删除源码,也不会把头文件误当成某个 .c 文件的私有附属物。
engineering-workflow/
├── include/text_metrics.h
├── src/text_metrics.c
├── app/main.c
├── tests/test_text_metrics.c
├── Makefile
└── build/
├── text_metrics.o
├── libtextmetrics.a
└── text-metrics
build/ 中的名字只是本项目的约定;目标文件和静态库的格式、扩展名及宿主工具的细节不属于 ISO C 的文件系统模型。把产物集中到单独目录可使源码审阅、增量构建和清理操作各有明确边界。
翻译单元、声明与定义
每个 .c 文件连同递归包含的头文件形成一个翻译单元。预处理后的每个翻译单元独立接受语法和类型检查,之后才由链接阶段组合其外部定义。并非每个声明都是定义;对象定义会为对象保留存储,函数定义则包含函数体。
text_metrics.h 只声明状态枚举、结果结构和函数接口;text_metrics.c 提供 text_metrics_measure 的函数体;main.c 包含同一个头文件并调用该接口。这样的分工让调用方依赖声明,而不是复制实现细节。
#ifndef TEXT_METRICS_H
#define TEXT_METRICS_H
#include <stddef.h>
enum text_metrics_status {
TEXT_METRICS_OK = 0,
TEXT_METRICS_INVALID_ARGUMENT
};
struct text_metrics {
size_t bytes;
size_t lines;
size_t words;
};
enum text_metrics_status text_metrics_measure(
const char *text,
struct text_metrics *result
);
#endif
#include "text_metrics.h"
#include <ctype.h>
#include <stdbool.h>
enum text_metrics_status text_metrics_measure(
const char *text,
struct text_metrics *result
) {
if (text == NULL || result == NULL) {
return TEXT_METRICS_INVALID_ARGUMENT;
}
struct text_metrics measured = {0};
const unsigned char *cursor = (const unsigned char *)text;
bool in_word = false;
if (*cursor != '\0') {
measured.lines = 1;
}
while (*cursor != '\0') {
bool is_space = isspace(*cursor) != 0;
++measured.bytes;
if (*cursor == '\n' && cursor[1] != '\0') {
++measured.lines;
}
if (!is_space && !in_word) {
++measured.words;
}
in_word = !is_space;
++cursor;
}
*result = measured;
return TEXT_METRICS_OK;
}
#include "text_metrics.h"
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc != 2) {
fputs("usage: text-metrics TEXT\n", stderr);
return 2;
}
struct text_metrics result;
if (text_metrics_measure(argv[1], &result) != TEXT_METRICS_OK) {
fputs("measurement-error\n", stderr);
return 1;
}
printf("bytes=%zu\n", result.bytes);
printf("lines=%zu\n", result.lines);
printf("words=%zu\n", result.words);
return 0;
}
以参数 C23 tools build safely 执行命令行程序,固定输出为:
bytes=22
lines=1
words=4
公开头文件与内部头文件
公开头文件必须能被调用方独立包含:它应自行包含声明所需的标准头文件,并使用 include guard 防止同一翻译单元的重复包含。调用方只要配置 -Iinclude,便能在不知道实现文件位置的情况下写出 #include "text_metrics.h"。
内部头文件则只服务于实现文件,不应因为“暂时方便”而被应用程序或其他库直接包含。公开接口中出现的类型、宏和前置条件都构成调用方的编译期契约;实现专用的 static 辅助函数和私有结构不必放入公开头。头文件、声明和预处理器的基本规则见声明、定义、头文件与预处理器。
预处理、编译、汇编与链接
构建可按预处理、编译、汇编与链接理解。预处理展开 #include 和条件编译;编译检查 C 语言规则并产生汇编表示;汇编器生成目标文件;链接器解析跨目标文件的符号并产生可执行文件或共享库。实际驱动程序可以把中间阶段合并到一次命令中,但失败信息仍应先按阶段归类。
下面是观察项目中间产物的手动分阶段命令;它们采用当前项目的编译选项,且不替代 Make 的依赖追踪:
mkdir -p build
cc -std=c23 -Iinclude -E src/text_metrics.c -o build/text_metrics.i
cc -std=c23 -Iinclude -S src/text_metrics.c -o build/text_metrics.s
cc -std=c23 -Iinclude -c src/text_metrics.c -o build/text_metrics.o
cc -std=c23 -Iinclude -c app/main.c -o build/main.o
ar rcs build/libtextmetrics.a build/text_metrics.o
cc build/main.o build/libtextmetrics.a -o build/text-metrics
若某个头找不到或语法不成立,问题在链接之前;若所有目标文件都已生成、最后却不能组成程序,才转向符号和链接顺序。单文件编译与运行的最小入口见第一个 C23 程序。
目标文件、符号与链接诊断
目标文件记录已编译代码以及它定义或引用的符号。undefined reference 通常表示链接器没有在参与链接的目标文件或库中,为某个引用的名字和链接找到定义;先核对实现是否被编译、库是否加入链接命令,以及引用的名字和链接是否与预期定义对应。同一实体的声明若类型不兼容,违反的是 C 的声明规则,不能把它等同于链接器找不到符号。
duplicate symbol 则表示多个目标文件为同一外部符号提供了定义。不要通过在头文件中复制非 static 函数体或对象定义来“共享”实现;应把一次外部定义放进一个实现文件,其他翻译单元只包含声明。外部链接和内部链接的区别见作用域、链接与存储期。
静态库与归档工具
静态库常是由 ar 归档多个目标文件得到的文件,例如本项目的 build/libtextmetrics.a。归档器不会把静态库变成 ISO C 语言设施:归档格式、ar 命令和链接器选项均由工具链约定,C 语言只规定翻译单元、声明、定义和程序行为。
在常见的单遍链接模型中,链接顺序很重要:先放使用库中符号的目标文件,再放提供符号的库。因此 Makefile 用 $(APP_OBJECT) $(LIBRARY) 链接应用程序。不同链接器可有额外分组功能,但不要以此掩盖循环依赖或不清楚的库边界。
Make 目标、前置条件与配方
Make 规则由目标、前置条件与配方组成:目标是要得到的文件或伪目标,前置条件是它依赖的输入,配方是 Make 在目标落后于前置条件时执行的命令。下面的原始 Makefile 将库、命令行程序、测试程序和清理动作都明确写为规则。
| 目标 | 前置条件 | 配方 |
|---|---|---|
build/text_metrics.o | src/text_metrics.c、include/text_metrics.h | 编译库实现 |
build/main.o | app/main.c、include/text_metrics.h | 编译命令行入口 |
build/libtextmetrics.a | build/text_metrics.o | 用归档器创建库 |
build/text-metrics | build/main.o、build/libtextmetrics.a | 按对象在前、库在后的顺序链接 |
test | 测试可执行文件 | 执行测试程序 |
clean | 无 | 删除 build/ |
CC ?= cc
AR ?= ar
ARFLAGS = rcs
CPPFLAGS ?= -Iinclude
CFLAGS ?= -std=c23 -Wall -Wextra -Wpedantic -Werror
LDFLAGS ?=
override BUILD_DIR := build
LIB_OBJECT := $(BUILD_DIR)/text_metrics.o
APP_OBJECT := $(BUILD_DIR)/main.o
TEST_OBJECT := $(BUILD_DIR)/test_text_metrics.o
LIBRARY := $(BUILD_DIR)/libtextmetrics.a
APP := $(BUILD_DIR)/text-metrics
TEST_APP := $(BUILD_DIR)/test-text-metrics
.PHONY: all test clean
all: $(LIBRARY) $(APP)
$(BUILD_DIR):
mkdir -p $@
$(LIB_OBJECT): src/text_metrics.c include/text_metrics.h | $(BUILD_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(APP_OBJECT): app/main.c include/text_metrics.h | $(BUILD_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(TEST_OBJECT): tests/test_text_metrics.c include/text_metrics.h | $(BUILD_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(LIBRARY): $(LIB_OBJECT)
$(AR) $(ARFLAGS) $@ $^
$(APP): $(APP_OBJECT) $(LIBRARY)
$(CC) $(LDFLAGS) $(APP_OBJECT) $(LIBRARY) -o $@
$(TEST_APP): $(TEST_OBJECT) $(LIBRARY)
$(CC) $(LDFLAGS) $(TEST_OBJECT) $(LIBRARY) -o $@
test: $(TEST_APP)
./$(TEST_APP)
clean:
rm -rf $(BUILD_DIR)
.PHONY 目标不以同名文件是否存在判断完成;clean 的删除范围应只指向项目生成目录,不能把任意路径拼进配方。Make 的变量允许调用方覆盖编译器和选项,但可重复构建仍要求项目本身给出完整依赖关系。
头文件依赖与增量构建
增量构建的正确性取决于依赖图,而非“上次修改了哪个 .c 文件”的猜测。头文件变更必须触发依赖它的翻译单元重新编译;本项目把 include/text_metrics.h 列为库实现、应用程序和测试目标文件的前置条件,所以接口一变更,这些目标文件都会重建。
当一个项目的包含层次变深,可由编译器生成依赖文件并由 Make 包含它们;关键仍是让每个翻译单元的实际包含关系反映在构建图中。只执行全量重建会掩盖遗漏依赖,错误复用陈旧目标文件则可能让源码和二进制不对应。
Debug、Release 与构建配置
Debug 与 Release 是构建配置,不是不同语言版本。两者都编译同一份 C 源码;差别通常是调试信息、优化、断言控制、诊断选项和产物目录。将配置映射为明确的变量和输出目录,避免调试对象文件与发布对象文件相互覆盖。
例如,调试构建可在保持严格警告的同时加入调试信息,发布构建可选择适合测量后的优化级别;二者都应保留相同的接口契约和测试输入。警告、调试器与消毒器如何帮助定位问题会在后续专题中单独说明,不应把构建配置误写成语言语义。
CMake 目标模型入口
CMake 以目标及其使用需求描述关系。下面的最小映射与 Makefile 的库和应用程序对应:公开 include 目录随 textmetrics 目标传播,应用程序显式链接该库。
add_library(textmetrics STATIC src/text_metrics.c)
target_include_directories(textmetrics PUBLIC include)
add_executable(text-metrics app/main.c)
target_link_libraries(text-metrics PRIVATE textmetrics)
其中 add_library、add_executable 和 target_link_libraries 建立的是构建工具的目标关系,不改变 C 的翻译单元或声明—定义规则。项目扩展时优先表达目标自己的源文件、包含目录和链接依赖,而不是在目录范围累计全局选项。
常见构建陷阱
| 现象 | 常见原因 | 先做什么 |
|---|---|---|
| 修改接口后程序行为像旧版本 | 头文件依赖没有进入构建图,复用了陈旧目标文件 | 检查受影响翻译单元的前置条件并重建 |
出现 undefined reference | 实现目标或库遗漏,或引用的名字与链接没有对应定义 | 从调用点追到唯一应提供定义的实现 |
出现 duplicate symbol | 多个翻译单元提供相同外部定义 | 把定义收敛到一个实现文件,头文件只保留声明 |
| 链接库失败 | 链接顺序错误,或库之间的责任不清 | 将使用者置于提供者之前并审查依赖方向 |
| 清理后源码丢失 | 清理规则目标过宽 | 只删除约定的构建产物目录 |
排查时先确定错误发生在预处理、编译还是链接,再检查对应的翻译单元、符号和依赖边。不要把工具链的诊断文本当成跨平台协议;应固定项目自己的输入、输出和规则,并让构建描述可重放。