16. 可移植构建
wordfreq 项目迎来了一位新的外部贡献者。在研读 src/counter.c 的源码实现时,他注意到私有函数 dup_string 仅有区区数行,无非是依次执行 strlen 计算长度、malloc 分配堆空间以及 memcpy 复制字符这三步操作。既然 C 标准库以及各平台环境广泛提供了现成的 strdup 函数,他便顺理成章地删去了该函数,将原调用点直接替换为 strdup(word),随后向代码仓库提交了一个拉取请求(Pull Request)。
这位贡献者在本地的 Windows 环境下拉起构建,整个编译流程顺畅无阻,运行 ctest 后三组自动化测试全部亮起绿灯。然而,当远端代码仓库在 Linux 环境(Debian 13)由维护者触发持续集成时,原本看似微不足道的修改却遭遇了当头棒喝:counter.c 甚至未能越过编译阶段的语法检查。
这个典型的跨平台构建事故,直观呈现了 C 语言工程在面对异构环境时的隐性挑战:表面上随处可见的标准或准标准函数,其可见性与语义可能横跨了 ISO C 语言标准演进、POSIX 扩展规范、各操作系统 C 运行时(libc)的实现策略、以及编译器的版本分水岭。构建坚固可靠的跨平台可移植 C 语言软件,绝非仅仅保证代码在某一台机器上能够顺利生成目标文件,而是需要系统性建立起可预测的工程适配体系:从严格标准模式下的特性测试宏,到基于 CMake 的配置期能力探测;从针对不同编译器方言的警告隔离,到底层平台差异的文件级抽象与构建预设管理。
本章中涉及的所有实验输出均在两台典型宿主机上采得并完成交叉验证:一台为搭载 Windows 11 的物理机(配备 MinGW-w64 GCC 13.2 与 MSVC 14.51),另一台为运行 Debian 13 的 Linux 系统(配备 GCC 14.2)。各终端清单中均明确标注了具体的执行环境与工具链版本。
1. 一次失败的移植
首先审视贡献者所在的 Windows 本地构建现场。他将复现该改动的 bugs/counter_strdup.c 覆盖至 src/counter.c,在本地重新执行配置、构建与测试(Windows 11,MinGW-w64 GCC 13.2,Ninja 生成器):
$ cmake -S . -B build-strdup -G Ninja
-- The C compiler identification is GNU 13.2.0
-- Detecting C compiler ABI info - done
-- Check for working C compiler: D:/Software/Strawberry/c/bin/gcc.exe - skipped
-- Detecting C compile features - done
-- Looking for getline
-- Looking for getline - not found
-- Configuring done (1.1s)
-- Generating done (0.0s)
-- Build files have been written to: C:/work/wordfreq/build-strdup
$ cmake --build build-strdup
[10/12] Linking C executable test_token.exe
[11/12] Linking C executable wordfreq.exe
[12/12] Linking C executable test_counter.exe
$ ctest --test-dir build-strdup
Test project C:/work/wordfreq/build-strdup
Start 1: token
1/3 Test #1: token ............................ Passed 0.03 sec
Start 2: counter
2/3 Test #2: counter .......................... Passed 0.04 sec
Start 3: e2e
3/3 Test #3: e2e .............................. Passed 0.08 sec
100% tests passed, 0 tests failed out of 32
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
同样的源码被推送至 Linux 维护者的环境展开验证(Debian 13,GCC 14.2,Unix Makefiles 生成器):
$ cmake -S . -B build-strdup -G "Unix Makefiles"
$ cmake --build build-strdup -j 1
[ 8%] Building C object CMakeFiles/wf.dir/src/token.c.o
[ 16%] Building C object CMakeFiles/wf.dir/src/counter.c.o
/home/minsecrus/tutorial/work/ch16/wordfreq/src/counter.c: In function ‘wf_counter_add’:
/home/minsecrus/tutorial/work/ch16/wordfreq/src/counter.c:87:18: error: implicit declaration of function ‘strdup’; did you mean ‘strcmp’? [-Wimplicit-function-declaration]
87 | char *copy = strdup(word);
| ^~~~~~
| strcmp
/home/minsecrus/tutorial/work/ch16/wordfreq/src/counter.c:87:18: error: initialization of ‘char *’ from ‘int’ makes pointer from integer without a cast [-Wint-conversion]
gmake[2]: *** [CMakeFiles/wf.dir/build.make:93: CMakeFiles/wf.dir/src/counter.c.o] Error 1
gmake[1]: *** [CMakeFiles/Makefile2:96: CMakeFiles/wf.dir/all] Error 2
gmake: *** [Makefile:101: all] Error 22
3
4
5
6
7
8
9
10
11
12
13
Linux 终端反馈的第一条错误异常明确:strdup 函数在当前翻译单元中缺少显式原型声明,编译器按照经典 C 规则将其隐式推定为一个返回 int 类型的外部函数。紧随其后的第二条错误则是第一条缺陷在类型检查阶段引发的次生反应:将推定为 int 的整型返回值直接赋给字符指针对象 char *copy,构成了指针与整型之间的非法隐式转换。只要根治第一条原型缺失的问题,第二条类型转换错误便会迎刃而解。
这一现象的深层根源,深植于语言标准规范与操作系统运行时库的实现差异。strdup 最初脱胎于 POSIX 标准,直到 C23 标准才被正式纳入 ISO C 的基础标准库中。在第 9 章初始化 CMake 工程时,项目全局配置了 CMAKE_C_EXTENSIONS OFF,驱动编译器以严格的 -std=c17 标准模式工作。作为 Linux 核心基础库的 GNU C Library(glibc)在遭遇该严格模式指令时,会主动屏蔽所有非 ISO C 标准所规定的符号声明,导致包含 <string.h> 时 strdup 的原型根本未暴露给当前翻译单元。反观 Windows 平台的 MinGW-w64 运行库(基于 UCRT),在头文件管理上并未完全与严格 ANSI 模式联动,依然默认暴露了该符号的原型声明,这才导致同一段代码在 Windows 宿主上得以蒙混过关。在第 21.5 节中,专门收录了各标准模式下 strdup、strndup 以及 stpcpy 的兼容性探测矩阵:此类扩展函数的可见性,严密受制于 C 运行库版本、标准编译模式以及功能特性测试宏的协同控制。
除此之外,编译器自身的版本更迭也放大了这种断裂感:在 GCC 13 及稍早的版本中,调用未声明函数默认仅触发 -Wimplicit-function-declaration 告警,构建过程仍会继续前行,往往直到链接阶段遭遇未解析外部符号时才告失败;而从 GCC 14 开始,隐式函数声明已被编译器提升为不可逾越的硬性错误,构建在编译阶段便立即终止。一段潜藏隐患的代码,可能在旧版编译器中带着几条未受重视的黄色警告顺利生成二进制文件,而一旦迁移至新世代工具链就会立即崩塌。
最初在第 7 章中特意编写的 dup_string 辅助函数,其初衷正是基于可移植性考量:它完全基于 ISO C 核心语言能力实现,不依赖任何特定操作系统的专有扩展,用寥寥数行简洁逻辑换取在任何合规 C17 实现中的无缝通用。保留这种手写实现,实质上是将“软件是否引入操作系统级外部扩展”这一决策牢牢掌控在工程自身手中。将该函数轻率替换为 strdup 之后,代码在三套主流工具链环境下的实际表现呈现出鲜明割裂:
| 工具链 | strdup 改动版本 |
|---|---|
| Windows 11,MinGW-w64 GCC 13.2,Ninja | 构建通过,ctest 3/3 全部通过 |
| Windows 11,MSVC 14.51,Ninja | 构建通过,ctest 3/3 全部通过 |
| Debian 13,GCC 14.2,Unix Makefiles | 编译阻断,报错提示 strdup 声明缺失 |
三套异构环境中有两套未加拦截,而一套直接宣告失败。代码贡献者仅凭单一平台下的成功测试便推断软件具备普适性,这正是“移植失败”这一工程困境的典型缩影。
2. 不可移植从哪里来
“在我本地机器上明明能正常构建”这句口头禅之所以充满欺骗性,是因为 C 语言软件的最终构建状态是由五个互相交织的系统抽象层共同塑造的。任何一层的基底发生变动,构建结论便可能遭遇颠覆:
| 抽象层级 | 典型平台差异表现 | 工程应对准则 |
|---|---|---|
| 语言核心层 | int 的位宽规格、char 符号性(有符号/无符号)、long 在 64 位 Windows 下仍保持 32 位 | 需精确固定位宽时选用 <stdint.h> 中的 int32_t、uint64_t;表示内存跨度与偏移时严格选用 size_t 与 ptrdiff_t,避免泛用 long |
| C 运行库层 | strdup、getline、fileno 等 POSIX 接口在严格 ISO 模式下隐匿;MSVC 运行时提供带下划线的前缀版本(如 _strdup) | 显式声明所需遵循的 POSIX 规范版本宏,或将非标扩展封装为自持接口(详见第 21.5 节) |
| 编译器方言层 | 命令行选项体系割裂(-Wall 对比 /W4)、厂商内建属性语法各异、内联汇编语法不兼容 | 在构建系统中依据编译器类型实施条件分支注入,或利用预处理器宏封装特定内建扩展(详见第 21.6 节) |
| 操作系统环境层 | 文件名大小写敏感性、路径正反斜杠分隔符、换行符差异(CRLF 对比 LF)、动态链接库装载协议 | 源码与资源路径统一遵循小写与正斜杠 /;非文本文件强制以二进制模式打开;利用 .gitattributes 固化换行符 |
| 构建体系层 | Makefile 配方依赖宿主 shell 环境;CMake 生成器(Ninja、Makefiles、MSBuild)在各平台的可用性各异 | 统一采用 CMake 声明跨平台抽象构建模型,将底层操作系统命令行调用局限在极少数受控脚本中 |
语言核心层在不同体系架构下的微观分歧,只需借助一段十余行的探测代码便可一览无余:
#include <limits.h>
#include <stdio.h>
int main(void) {
printf("sizeof(int) = %d, sizeof(long) = %d, sizeof(void *) = %d\n",
(int)sizeof(int), (int)sizeof(long), (int)sizeof(void *));
printf("CHAR_MIN = %d, CHAR_MAX = %d\n", CHAR_MIN, CHAR_MAX);
printf("LONG_MAX = %ld\n", LONG_MAX);
return 0;
}2
3
4
5
6
7
8
9
10
sizeof(int) = 4, sizeof(long) = 4, sizeof(void *) = 8
CHAR_MIN = -128, CHAR_MAX = 127
LONG_MAX = 21474836472
3
sizeof(int) = 4, sizeof(long) = 8, sizeof(void *) = 8
CHAR_MIN = -128, CHAR_MAX = 127
LONG_MAX = 92233720368547758072
3
两份终端输出对比展现了深刻的数据模型差异:在 64 位的 Windows 操作系统中,出于对 Win32 历史生态的兼容考量,数据模型遵循 LLP64 规范,long 类型依然紧锁在 4 字节(32 位),其能够表示的最大上限与 32 位 int 毫无二致;而在 64 位 Linux 生态中,数据模型则遵循 LP64 规范,long 的宽度与系统指针一并拓展到了 8 字节(64 位)。如果开发者惯性地使用 long 类型承载文件偏移量、时间戳差值或超大数组索引,并在跨网络或跨磁盘交换结构体二进制数据,或者以 %ld 格式化打印这些数值时,程序逻辑就会产生难以排查的精度截断或格式化崩塌。只有坚持使用 <stdint.h> 提供的定宽整型,或在描述物理内存尺寸时使用规范的 size_t 和 ptrdiff_t,才能彻底切断此类体系结构陷阱。而针对 char 的符号性问题,虽然两台测试机默认均将 char 视作有符号整型,但 C 语言标准明确将其符号属性交由实现定义;在许多 ARM 架构编译器中,char 默认被赋予无符号属性,若业务逻辑依赖其表示小于 0 的值或处理大于 127 的字节流,平台迁移时极易诱发静默错误。处理原始字节流时,必须养成显式书写 unsigned char 或 uint8_t 的工程习惯。
坚持 ISO C 基线并显式受控开启扩展
纯粹的 ISO C 语言子集配合完备的标准库实现,具有在任何符合标准的现代平台无障碍编译的通用属性。一旦工程不可避免地需要借助 POSIX 或特定平台的拓展接口,务必在对应翻译单元中通过标准宏明确宣示所需遵循的具体规范版本,切忌心存侥幸假设“这行代码在开发机环境默认可用”。将系统平台强绑定的底层逻辑收拢到极少数隔离模块中,主体业务流仅依赖这些模块抽象出的中立接口,这一工程设计思想在后续各节中将一以贯之。
3. 标准模式与功能特性测试宏
在第 9 章构建基础工程时,配置脚本中显式声明了 set(CMAKE_C_EXTENSIONS OFF)。彼时仅将其定性为“尽早暴露非标准依赖”,如今结合前文的构建故障,可以更清晰地审视这一配置在底层引发的连带连锁反应。以一个空工程为例,仅仅切换该选项的开启状态,构建系统传递给底层编译器的 -std 方言参数便截然不同(Windows 11,MinGW-w64 GCC):
$ cmake --build build-on --verbose
[1/2] D:\Software\Strawberry\c\bin\gcc.exe -std=gnu17 -MD -MT CMakeFiles/on.dir/main.c.obj ...
$ cmake --build build-off --verbose
[1/2] D:\Software\Strawberry\c\bin\gcc.exe -std=c17 -MD -MT CMakeFiles/off.dir/main.c.obj ...2
3
4
选项 -std=gnu17 是 GCC 在该版本下的默认编译模式,即在 ISO C17 标准的基础上默认放开 GNU 专属语言扩展与 POSIX 扩展声明;而 -std=c17 则代表严格一致的标准模式,它严密约束编译器自身的语法解析,同时在预处理阶段自动注入宏定义 __STRICT_ANSI__。以 Linux 下的 glibc 为代表的严谨运行库头文件,正是通过检测该宏的存在与否,来决定是否对当前翻译单元隐藏非 ISO C 的扩展接口:在 gnu17 模式下,包含 <string.h> 时 strdup、getline、fileno 等符号悉数就位;而在 -std=c17 严格模式下,头文件则收敛至纯粹的 ISO C 子集。在 CMake 体系中,C_EXTENSIONS 属性的默认值恰恰是 ON。倘若开发者未在工程中主动关闭扩展,Linux 宿主在默认情况下将以 gnu17 编译,strdup 原型缺失的隐患将被静默掩盖。主动将其置为 OFF,是用工程规约倒逼隐式依赖在本地开发期浮出水面。
在严格标准模式下,若翻译单元确实需要引入某项被 POSIX 规范认可的接口,合乎规范的做法是在源码中通过功能特性测试宏(Feature Test Macro)明确宣告当前代码所依托的规范版本:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
int use_fileno(void) { return fileno(stdin); }2
3
4
5
特别需要强调的是,功能特性测试宏的定义位置具有严苛的时序要求:它必须置于当前翻译单元包含任何头文件之前。因为当预处理器展开第一个标准头文件时,库内部的条件分支便已依据当刻宏环境完成了状态裁定;如果将宏写在 #include 之后,系统头文件早已按既有的默认配置处理完毕,后续补写将毫无效果。在 Linux 环境下对两份仅有宏定义位置差异的代码进行对比实测(Debian 13,GCC 14.2,-std=c17):
$ gcc -std=c17 -Wall -Wextra -c late.c -o late.o
late.c: In function ‘f’:
late.c:5:43: error: implicit declaration of function ‘strdup’; did you mean ‘strcmp’? [-Wimplicit-function-declaration]
5 | int f(const char *s) { return (int)strlen(strdup(s)); }
| ^~~~~~
| strcmp
late.c:5:43: error: passing argument 1 of ‘strlen’ makes pointer from integer without a cast [-Wint-conversion]
$ gcc -std=c17 -Wall -Wextra -c early.c -o early.o
$ echo $?
02
3
4
5
6
7
8
9
10
测试源码 late.c 将 #define _POSIX_C_SOURCE 200809L 置于 #include <string.h> 之后,预处理器展开头文件时未能捕获生效版本,导致符号缺失报错;而写在源文件最顶端的 early.c 则得以顺利完成编译。在 wordfreq 的入口实现 app/main.c 中,正是遵循了在所有包含语句之前定义该宏的准则,并通过 #if !defined(_WIN32) 对 Windows 环境实施了针对性规避,因为 Windows 并不依赖 POSIX 特性宏体系。
扩展接口必须由源文件显式打开
一个 C 翻译单元在编译时能够合法访问哪些符号声明,完全由其自身包含的头文件集合以及在包含前显式设定的预处理宏共同决议,与“底层二进制动态库中是否存在该符号导出”没有必然联系。在严格模式下 glibc 主动屏蔽 POSIX 函数,是标准赋予实现库的合法合规行为;MinGW 在 -std=c17 下依旧宽容声明,同样属于其自身的运行库策略。同一份源码在不同运行库下暴露出的可见符号面存在差异,这正是“在某处能编译成功”绝不能直接等同于“具备代码可移植性”的核心原因。
在 Windows 平台的 MSVC 环境下,情况则更为微妙。MSVC 的头文件为了兼容性同时声明了 strdup 与带下划线的 _strdup;但直接调用前者会触发编译器的 C4996 弃用警告,微软官方推荐开发者统一使用下划线前缀符号 _strdup;类似的配对还包括 fileno 与 _fileno、stricmp 与 _stricmp 等。然而,盲目地在源码中全局改用 _strdup 只不过是将兼容性天平推向了另一极端——在 Linux 与标准 Unix 平台下并不存在带前缀的 _strdup 符号。对于体积仅有数行的辅助逻辑,最稳妥且低成本的方案依然是自持纯标准实现:dup_string 不依赖任何非标扩展与平台宏,在任何合规的 C17 编译器上都能直接工作,其维护成本远低于在构建脚本中织就繁复的名字映射网络。更加工程化的多平台自适应封装体系,可参阅 第 21.5 节 易于移植的扩展函数,其中的 mdr_strdup 展现了兼顾底层性能与全面降级回退的工业级实现范式。
glibc 2.41 上,用 -std=c17 编译一份调用 strdup 的源文件,报 implicit declaration of function 'strdup'。下面哪种改法能让它通过编译?
4. 配置时检测
特性测试宏能够解决的前提是目标平台确实支持该扩展标准。然而在真实世界中,部分函数在特定操作系统或运行库上属于物理性缺失。典型代表如按行读取文件的 getline:该接口在 POSIX.1-2008 中完成标准化并在 glibc 中原生提供,但在经典 MSVC 以及 MinGW-w64 默认的 UCRT 运行库中均不复存在。面对此类物理缺失,绝不能在源码中通过平台宏一刀切地写死分支,而应借助构建系统的配置阶段主动向当前工具链发起探测:“当前编译环境是否具备这一符号能力?”,并将探测结论以常量宏的形式写入自动生成的头文件中。
CMake 提供的内置模块 CheckSymbolExists 便是执行此类能力探测的标配工具:它在后台尝试编译并链接一段仅引用指定符号的极简微程序,若该程序能够顺利通过编译与符号链接,即行判定该符号可用。wordfreq 项目根目录的 CMakeLists.txt 中对应的配置如下:
include(CheckSymbolExists)
option(WF_HAVE_GETLINE "Assume getline is available (set OFF to force the fgets path)" ON)
set(WF_CONFIG_HAVE_GETLINE OFF)
if(WF_HAVE_GETLINE)
set(CMAKE_REQUIRED_DEFINITIONS -D_POSIX_C_SOURCE=200809L)
check_symbol_exists(getline "stdio.h" WF_HAVE_GETLINE_FOUND)
unset(CMAKE_REQUIRED_DEFINITIONS)
if(WF_HAVE_GETLINE_FOUND)
set(WF_CONFIG_HAVE_GETLINE ON)
endif()
endif()
configure_file(src/config.h.in ${PROJECT_BINARY_DIR}/include/wf/config.h)2
3
4
5
6
7
8
9
10
11
12
13
14
15
此处潜藏着一个极易引发误判的配置陷阱:符号探测自身同样是在严格标准模式下运转的。前文在第 9 章设置了 C_EXTENSIONS OFF,导致 CMake 在后台生成探测临时代码时同样注入了 -std=c17。若直接探测,glibc 在严格模式下因未见特性测试宏而对探测代码隐匿 getline 原型,导致 CMake 判定“符号缺失”,即便底层运行库明明具备该函数。通过显式向 CMAKE_REQUIRED_DEFINITIONS 注入 -D_POSIX_C_SOURCE=200809L,为探测程序补齐必要的上下文,才能获得真实的探测结论。在同一台 Linux 宿主机上,仅差这一行定义,CMake 产生的探测判定截然相反(Debian 13,GCC 14.2):
$ cmake -S . -B b1 # 不带 CMAKE_REQUIRED_DEFINITIONS
-- Looking for getline
-- Looking for getline - not found
$ cmake -S . -B b2 # 带 -D_POSIX_C_SOURCE=200809L
-- Looking for getline
-- Looking for getline - found2
3
4
5
6
配置头文件模板 src/config.h.in 仅需维护一行关键指令:
#cmakedefine WF_CONFIG_HAVE_GETLINE 1宏指令 #cmakedefine 会在对应的 CMake 变量求值为真时替换为 #define WF_CONFIG_HAVE_GETLINE 1;反之若变量为假,则生成注释化的未定义占位行 /* #undef WF_CONFIG_HAVE_GETLINE */。CMake 随后通过 configure_file 指令将生成的目标头文件安置在与源码拓扑对齐的构建目录中(build/include/wf/config.h)。业务代码仅需编写统一的包含语句 #include "wf/config.h",使用体验与包含项目自身的公共头文件 wf/token.h 保持完全一致:
#ifndef WF_CONFIG_H
#define WF_CONFIG_H
#define WF_CONFIG_HAVE_GETLINE 1
#endif2
3
4
5
6
#ifndef WF_CONFIG_H
#define WF_CONFIG_H
/* #undef WF_CONFIG_HAVE_GETLINE */
#endif2
3
4
5
6
业务入口 app/main.c 依据这一配置宏选择具体的流式输入实现。两套实现共享同一个静态函数签名 count_stream,构建时由预处理器条件分支裁定仅编译其中之一:
#ifdef WF_CONFIG_HAVE_GETLINE
/*
* 按行读入:getline 按需增长缓冲区。换行是分隔符,单词不会跨行,
* 所以逐行分词与整文件分词的结果相同。
*/
static int count_stream(wf_counter *counter, FILE *in, const char *name) {
char *line = NULL;
size_t cap = 0;
int status = 0;
ssize_t got;
while ((got = getline(&line, &cap, in)) >= 0) {
if (count_text(counter, line, (size_t)got) != 0) {
status = out_of_memory();
break;
}
}
if (status == 0 && ferror(in)) {
fprintf(stderr, "wordfreq: %s: %s\n", name, strerror(errno));
status = 1;
}
free(line);
return status;
}
#else
/* 用 fgets 分块读入:缓冲区大小固定,长行会分成多段,结果仍然相同 */
static int count_stream(wf_counter *counter, FILE *in, const char *name) {
char chunk[4096];
int status = 0;
while (fgets(chunk, sizeof chunk, in) != NULL) {
size_t len = strlen(chunk);
if (count_text(counter, chunk, len) != 0) {
status = out_of_memory();
break;
}
}
if (status == 0 && ferror(in)) {
fprintf(stderr, "wordfreq: %s: %s\n", name, strerror(errno));
status = 1;
}
return status;
}
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
在系统架构设计上,两条分支路径的分词结果必须达成严格的语义等价:分词器将换行符视作天然的分隔符,单词不可能跨越换行边界,因此无论是以动态行缓冲区逐行送入,还是以 4KB 定长切片分块送入,分词器在状态机流转中解析出的单词序列完全相同。这一契约等价性必须由自动化集成测试提供坚实担保。CMakeLists 中定义的 WF_HAVE_GETLINE 缓存变量,既用于自动探测,也承担了“强制降级”的工程测试通道:将其显式覆写为 OFF,配置脚本便会跳过检测并生成未定义宏,强制编译并运行基于 fgets 的通用回退分支:
$ cmake -S . -B build-nogetline -DWF_HAVE_GETLINE=OFF -G "Unix Makefiles"
-- The C compiler identification is GNU 14.2.0
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features - done
-- Configuring done (0.3s)
-- Generating done (0.0s)
-- Build files have been written to: /home/minsecrus/tutorial/work/ch16/wordfreq/build-nogetline
$ cmake --build build-nogetline -j 2
[100%] Built target test_counter
$ ctest --test-dir build-nogetline
1/3 Test #1: token ............................ Passed 0.00 sec
2/3 Test #2: counter .......................... Passed 0.00 sec
3/3 Test #3: e2e .............................. Passed 0.01 sec
100% tests passed, 0 tests failed out of 32
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在具备 getline 的 Linux 环境下,常规构建默认激活高性能的动态行读取分支;而显式指定 -DWF_HAVE_GETLINE=OFF 则迫使 Linux 平台对通用回退逻辑执行全量回归。在 Windows 环境下,探测机制自然落入缺失判定,日常开发构建默认运行回退路径。两条代码路径各自在至少一套物理环境中获得端到端的完备检验,跨平台实现的“行为一致”才具有事实依据。
配置检测出的每条路径都要有测试
配置阶段探测出某项环境能力,代码库中便会必然衍生出一条条件编译分支。常规的自动化构建流水线往往只能覆盖当前操作系统命中的单一路径,另一条备用路径通常要在冷门异构平台上才会生效,极易在日后重构时沦为年久失修的死代码。为探测特性预留显式覆写开关(例如本例中的 WF_HAVE_GETLINE),使开发者能够在主力开发机上按需对两条路径实施无差别的编译与测试回归,是守卫分支可移植性的有效手段。
自 C23 标准起,预处理器原生引入了更轻量级的内建探测宏 __has_include,主流的 GCC、Clang 与 MSVC 早已作为编译扩展普遍支持。它允许在源码内部直接探测目标头文件是否就绪:
#if defined(__has_include)
#if __has_include(<stdbit.h>)
#include <stdbit.h>
#define WF_HAVE_STDBIT 1
#endif
#endif2
3
4
5
6
需要清醒界定的是,__has_include 仅能裁决“头文件物理上是否存在于搜索路径中”,无法探知“头文件内部是否导出了某个特定函数”。它非常适用于整模块能力的可选引入(如 C23 新增的标准位操作头文件 <stdbit.h>),但无法应对类似 getline 这样“头文件 <stdio.h> 历来存在、但特定函数可能被运行库阉割”的复杂情形。实测表明,MinGW-w64 GCC 13.2 环境尚未配备 <stdbit.h>,而 Debian 13 上的 GCC 14.2 则已完整收录,借助上述预处理探测能够平滑完成跨平台的平稳过渡。
5. 编译器差异
不同编译器家族之间的参数语法体系有着深刻的历史鸿沟:GCC 与 Clang 遵循经典的 Unix 工具链惯例,采用 -Wall 激活广泛警告;MSVC 则继承 Windows 风格的斜杠选项,以 /W4 表达相当强度的严苛警告等级。类似地,指定语言标准模式时,前者书写 -std=c17,后者对应 /std:c17;注入预处理宏定义时,前者使用 -DNAME=VALUE,而后者则使用 /DNAME=VALUE。如果在构建脚本中针对每一种编译器单独编写整套独立的构建工程,维护成本将呈乘积膨胀。现代 CMake 提供了生成器表达式(Generator Expression),其核心机制在于延迟展开——在配置阶段生成最终构建系统的底层脚本(如 build.ninja 或 Makefile)的瞬间,依据当前编译器 ID、宿主平台以及构建类型动态求值,从而在同一套声明式脚本中自适应各编译器方言。
在 wordfreq 的工程配置中,通常依据底层编译器类型分流警告级别参数:
if(MSVC)
set(WF_WARNINGS /W4)
else()
set(WF_WARNINGS -Wall -Wextra -Wshadow -Wformat=2 -Wmissing-prototypes
-Wstrict-prototypes -Wvla)
endif()2
3
4
5
6
若需要在特定目标级别施加更精细的按需注入,直接使用形如 $<...> 的生成器表达式尤为便捷。下面这段构建声明演示了如何针对 MSVC 注入 /W4、/utf-8 与 /D_CRT_SECURE_NO_WARNINGS,同时针对其他兼容 GCC 语法的编译器注入经典警告三件套:
add_executable(demo main.c)
target_compile_options(demo PRIVATE
$<$<C_COMPILER_ID:MSVC>:/W4>
$<$<C_COMPILER_ID:MSVC>:/utf-8>
$<$<C_COMPILER_ID:MSVC>:/D_CRT_SECURE_NO_WARNINGS>
$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall>
$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wextra>
$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wpedantic>)2
3
4
5
6
7
8
在调用构建时追加 --verbose 选项,可以清晰核验生成器表达式求值后的实际底层指令流。GCC 工具链仅接收到为其量身定制的选项集(Windows 11,MinGW-w64):
$ cmake --build build --verbose
[1/2] D:\Software\Strawberry\c\bin\gcc.exe -Wall -Wextra -Wpedantic -MD -MT CMakeFiles/demo.dir/main.c.obj -MF CMakeFiles\demo.dir\main.c.obj.d -o CMakeFiles/demo.dir/main.c.obj -c C:/work/genexp/main.c2
而 MSVC 则如期接收到了专有的 Windows 选项序列(同一宿主机,cl.exe):
[1/2] D:\Software\VS\1\VC\Tools\MSVC\14.51.36231\bin\Hostx64\x64\cl.exe /nologo /DWIN32 /D_WINDOWS /Zi /Ob0 /Od /RTC1 -MDd /W4 /utf-8 /D_CRT_SECURE_NO_WARNINGS /showIncludes /FoCMakeFiles\demo.dir\main.c.obj /FdCMakeFiles\demo.dir\ /FS -c C:\work\genexp\main.c此处包含一条关于 CMake 生成器表达式分词规则的关键细节:一个独立的表达式字符串在展开时默认对应一个单一的命令行参数项。在前述示例中,之所以将 -Wall、-Wextra 与 -Wpedantic 拆分为三条并列的表达式,正是为了确保求值后它们能够作为独立的命令行参数被正确传递。若误将其粗暴拼合成单个带空格的字符串 "$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall -Wextra -Wpedantic>",CMake 在转译时会默认将其整体加上双引号传递给底层编译器,直接诱发编译指令解析失败:
$ cmake --build build-q
[1/2] Building C object CMakeFiles/demo.dir/main.c.obj
FAILED: CMakeFiles/demo.dir/main.c.obj
D:\Software\Strawberry\c\bin\gcc.exe "-Wall -Wextra -Wpedantic" -MD -MT CMakeFiles/demo.dir/main.c.obj ...
gcc.exe: error: unrecognized command-line option '-Wall -Wextra -Wpedantic'2
3
4
5
另一种符合规范的合流写法是在单个表达式内部以分号作为元素分隔符:形如 $<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wpedantic> 的表达式在 CMake 展开时会被自动拆解为由三个独立字符串构成的参数列表。这两种书写方式在功能上完全等价,在工程中按行分立书写通常具备更高的可读性与审查友好度。
在针对 MSVC 编译器的适配过程中,有两个专有选项与代码的可移植性紧密相连。其一是 /utf-8 选项。该选项指示 cl.exe 统一采用 UTF-8 编码读取源文件并生成执行字符集:wordfreq 的头文件中包含了大量中文注释与技术契约说明,若未注入该选项,MSVC 默认将采用宿主系统的活动 ANSI 代码页(简体中文 Windows 下通常为代码页 936/GBK)解析源文件,针对每一个包含非 ASCII 字符的头文件密集抛出 C4819 告警:
report.c
include\wf/report.h(1): warning C4819: 该文件包含不能在当前代码页(936)中表示的字符。请将该文件保存为 Unicode 格式以防止数据丢失
include\wf/counter.h(1): warning C4819: 该文件包含不能在当前代码页(936)中表示的字符。请将该文件保存为 Unicode 格式以防止数据丢失
include\wf/export.h(1): warning C4819: 该文件包含不能在当前代码页(936)中表示的字符。请将该文件保存为 Unicode 格式以防止数据丢失2
3
4
一旦注入 /utf-8 选项,编译器将原生以 UTF-8 解码字节流,所有虚假告警立即完全平息。现代跨平台项目源码普遍统一固化为无 BOM 的 UTF-8 编码,开启此选项能够确保 MSVC 与 GCC、Clang 遵循完全一致的文本解析规则。
另一个是安全警告抑制项。MSVC 运行库单方面将一批标准 C 函数标记为“不安全(Unsafe)”,标准库函数 strerror 亦赫然在列:
app\main.c(100): warning C4996: 'strerror': This function or variable may be unsafe. Consider using strerror_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.微软在该警告中给出了两条取舍路径:要么将代码重构为微软专有的 strerror_s,要么定义宏 _CRT_SECURE_NO_WARNINGS 屏蔽此类弃用警告。如果选择前一条路,无异于将原本中立的 C 源码锁死在微软专有的私有 API 上;选择定义宏 _CRT_SECURE_NO_WARNINGS,则代表明确宣示“此处严格调用 ISO C 标准接口”。在追求跨平台可移植的工程体系中,必然选择后者,并在 CMake 构建逻辑中专门针对 MSVC 注入该宏。
在 MinGW-w64 GCC 环境下,同样存在一处关于 %zu 格式说明符的虚假告警。在第 3 章中曾深入探讨过其历史成因:MinGW 在默认配置下沿用了 Windows 宿主的旧版运行库格式化逻辑,-Wformat 静态检查器未能识别 C99 引入的 z 长度修饰符,导致 report.c 中打印统计数据的 printf("%7zu %s\n", ...) 会遭遇如下格式化警告:
$ gcc -std=c17 -Wall -Wextra -Iinclude -c src/report.c -o report.o
src/report.c: In function 'wf_report_print':
src/report.c:5:25: warning: unknown conversion type character 'z' in format [-Wformat=]
5 | fprintf(out, "%7zu %s\n", entries[i].count, entries[i].word);
| ^
src/report.c:5:29: warning: format '%s' expects argument of type 'char *', but argument 3 has type 'size_t' {aka 'long long unsigned int'} [-Wformat=]
src/report.c:5:22: warning: too many arguments for format [-Wformat-extra-args]2
3
4
5
6
7
解决这一环境缺陷的标准方案是注入预定义宏 -D__USE_MINGW_ANSI_STDIO=1,指示 MinGW 全面接入其自主实现的、符合 POSIX/C99 规范的 ANSI stdio 引擎。注入该宏后,格式化告警得以彻底消除。在 CMake 中通过 if(MINGW) 条件分支进行精准注入:
if(MINGW)
target_compile_definitions(wf PUBLIC __USE_MINGW_ANSI_STDIO=1)
endif()2
3
将可见性声明为 PUBLIC 至关重要:这确保了目标库 wf 自身与所有链接该库的下游应用(如命令行入口 wordfreq 以及全部测试用例)均能无缝继承此项定义,彻底根除测试套件中打印 size_t 时引发的次生告警。
除编译选项外,各类编译器对函数与对象属性(Attributes)的语法扩展同样存在显著割裂。例如 GCC 和 Clang 广泛支持以 __attribute__((format(printf, 2, 3))) 辅助检查自定义日志函数的格式串合法性,而 MSVC 则依赖另一套基于 SAL(Source Annotation Language)的注释声明体系。在实际工程中,通常借助诸如 WF_PRINTF_LIKE 等统一的自制抽象宏进行层层封装,调用端仅依赖抽象宏而无需触碰底层分支。具体规范与优雅降级策略详见 第 21.6 节 可回退的编译器内建功能。
6. 隔离平台相关代码
底层操作系统的接口差异无法完全消除,但在架构层面,完全有能力将其严格收束进极少数受控的隔离边界中。以常见需求为例:假设需要为 wordfreq 增加终端感知能力——当标准输出连接到交互式终端或伪终端(PTY)时,主动打印友好的统计表头;而当输出被管道连接或重定向至外部文件时,静默省略表头以保持下游数据消费的纯净。在 POSIX 体系下,该检测依赖 <unistd.h> 中的 isatty 与文件描述符 STDOUT_FILENO;而在 Win32 体系下,则依赖 <io.h> 中的 _isatty 以及基于文件流的转换调用 _fileno(stdout)。两者的系统头文件、宏常量以及函数名称均完全割裂。
一种常见的直觉写法是将平台判定直接内嵌到业务调用点:
#include <stdio.h>
#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif
static void print_header(void) {
#ifdef _WIN32
int is_terminal = _isatty(_fileno(stdout));
#else
int is_terminal = isatty(STDOUT_FILENO);
#endif
if (is_terminal) {
printf("-- wordfreq 结果 --\n");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* app/main.c:调用方只看到这一个名字 */
static void print_header(void) {
if (wf_stdout_is_terminal()) {
printf("-- wordfreq 结果 --\n");
}
}2
3
4
5
6
左栏的写法在代码库初建、仅存在单一调用点时看似直接;然而一旦业务规模扩大,散落各处的 #ifdef _WIN32 会导致上层业务逻辑充斥着平台噪声。阅读代码者被迫在两套体系之间频繁跳跃,任何微小的签名重构都需要地毯式搜寻所有文件。右栏的设计则将底层探测提炼为纯正的 C 接口,业务调用端仅需包含抽象头文件 platform.h,所见即为一个中立且具备明确契约的函数 int wf_stdout_is_terminal(void)。
在实现层面,采用文件级分立策略,各自实现平台专属逻辑:
#ifndef PLATFORM_H
#define PLATFORM_H
/* 标准输出是否接在终端上:接终端返回 1,重定向到文件或管道时返回 0 */
int wf_stdout_is_terminal(void);
#endif2
3
4
5
6
7
#define _POSIX_C_SOURCE 200809L
#include "platform.h"
#include <unistd.h>
int wf_stdout_is_terminal(void) { return isatty(STDOUT_FILENO) != 0; }2
3
4
5
6
7
#include "platform.h"
#include <io.h>
#include <stdio.h>
int wf_stdout_is_terminal(void) { return _isatty(_fileno(stdout)) != 0; }2
3
4
5
6
构建系统 CMake 承担起按需编排源文件的责任,在目标定义中直接裁决纳入哪个具体文件:
add_executable(platform_demo main.c)
if(WIN32)
target_sources(platform_demo PRIVATE platform_win32.c)
else()
target_sources(platform_demo PRIVATE platform_posix.c)
endif()2
3
4
5
6
启用 --verbose 查看构建指令,可以清晰验证这种文件级路由的生效结果。在 Linux 宿主机上,仅有 platform_posix.c 被派发给 GCC:
$ cmake --build build --verbose
[ 66%] Building C object CMakeFiles/platform_demo.dir/platform_posix.c.o
/usr/bin/cc -std=c17 -MD -MT CMakeFiles/platform_demo.dir/platform_posix.c.o -MF CMakeFiles/platform_demo.dir/platform_posix.c.o.d -o CMakeFiles/platform_demo.dir/platform_posix.c.o -c /home/minsecrus/tutorial/work/ch16/platform/platform_posix.c2
3
在 Windows 宿主机上执行同一构建指令,则精准分发 platform_win32.c:
$ cmake --build build --verbose
[2/3] D:\Software\Strawberry\c\bin\gcc.exe -std=c17 -MD -MT CMakeFiles/platform_demo.dir/platform_win32.c.obj -MF CMakeFiles\platform_demo.dir\platform_win32.c.obj.d -o CMakeFiles/platform_demo.dir/platform_win32.c.obj -c C:/work/platform/platform_win32.c2
异构平台对应的实现源文件完全被排除在当前编译任务之外。这种物理隔离避免了交叉包含所带来的头文件污染——无需担心在 Linux 编译单元中因错误包含 <io.h> 或缺失 _isatty 原型而引发编译中止。
运行结果完全遵循终端设备感知的预期行为。在 Linux 环境下,直接在交互式控制台中执行会渲染表头,而将标准输出重定向到文件后则仅输出纯数据:
$ script -qec "./build/platform_demo" /dev/null
-- wordfreq 结果 --
the 4
$ ./build/platform_demo > out.txt
$ cat out.txt
the 42
3
4
5
6
命令 script 借助操作系统底层分配伪终端(PTY)环境,使得 isatty 判定为真并触发格式化表头;而在管道或普通文件重定向场景下,isatty 正确返回 0。Windows 下的 platform_win32.c 借助 _isatty(_fileno(stdout)) 达成完全等价的判别效果。实施文件级平台隔离的工程代价,仅仅是增加了数个遵循中立约定的 .c 文件和几行 CMake 分支;而换来的收益则是所有核心业务模块彻底告别 #ifdef _WIN32 的侵蚀。
这一模式并不局限于终端感知,在工业级 C 软件中,目录树遍历(POSIX 的 opendir/readdir 对比 Win32 的 FindFirstFile/FindNextFile)、动态库动态加载(dlopen 对比 LoadLibrary)、高精度计时器以及线程原语封装中,均应遵循这一规范:一旦某段逻辑强依赖特定操作系统的专有系统调用或系统头文件,便将其沉淀为以操作系统或规范命名的专属源文件,仅通过中立头文件 platform.h 向外暴露统一的抽象契约。该设计的完整可运行代码可参考 code/开发与工程基础/16_可移植构建/platform/。
7. 文件系统与文本
在跨平台迁移中,文件系统命名与路径处理是一处高频潜伏的暗礁。最容易被忽视的隐患是文件名的大小写敏感性。wordfreq 的公共头文件安置在 include/wf/ 目录下,磁盘上的实际物理文件名是纯小写的 counter.h。倘若某位开发者在源文件中不慎写成了大写开头的包含语句 #include "wf/Counter.h":
$ # Windows,MinGW-w64 GCC 13.2
$ gcc -std=c17 -Wall -Wextra -Iinclude -c icm.c -o icm.o
$ echo $?
0
$ # Linux,GCC 14.2
$ gcc -std=c17 -Wall -Wextra -Iinclude -c icm.c -o icm.o
icm.c:9:10: fatal error: wf/Counter.h: No such file or directory
9 | #include "wf/Counter.h"
| ^~~~~~~~~~~~~~
compilation terminated.2
3
4
5
6
7
8
9
10
Windows 默认的 NTFS 文件系统以及 macOS 默认的 APFS 文件系统均属于大小写保留但不敏感(Case-Preserving, Case-Insensitive)的文件系统,Counter.h 与 counter.h 在寻址时指向同一物理节点;而绝大多数 Linux 发行版采用的 ext4、XFS 或 Btrfs 文件系统则具备严格的大小写敏感特性,节点查找失败即刻中止编译。正因如此,此类错误在开发者本地工作站可能潜伏数月之久,直到首次推送至 Linux CI 服务器才暴露出致命缺陷。跨平台工程防范这一问题的规则极度简明:源码树内的全部文件名一律严格小写,包含指令 #include 中的拼写必须与磁盘物理文件名保持逐字符完全一致。错误用例可参考 bugs/include_case_mismatch.c。
第二项常见差异在于路径分隔符的选择。在编写跨平台 C 代码时,源码字面量中表达路径应一律使用正斜杠 /。Windows 平台的 C 运行时库底层已原生支持解析正斜杠路径(如 tests/data/sample.txt),仅在极少数需要与特定第三方 Win32 原生 API 或外部 Shell 交互的场景下才需进行转换。在 C 字符串中直接书写反斜杠无法改善兼容性,反而会直接触发字符转义机制。形如 "C:\work\f.txt" 的字面量中,\w 会被编译器判定为非法转义序列,而 \f 则被静默替换为换页符(ASCII 0x0C),最终落入内存的字节序列早已面目全非(Windows 11,MinGW-w64):
$ gcc -std=c17 -Wall -Wextra esc.c -o esc.exe
esc.c: In function 'main':
esc.c:4:25: warning: unknown escape sequence: '\w'
4 | puts("C:\work\f.txt");
| ^
$ ./esc.exe | od -c
0000000 C : w o r k \f . t x t \r \n2
3
4
5
6
7
必须双写反斜杠 "C:\\work\\f.txt" 才能还原路径本义。为了彻底隔绝此类无谓转义与平台分支,在跨平台源码中统一采用正斜杠是成本最低的最佳实践。
第三项关键差异涉及跨平台的文本换行编码。在 Windows 体系下,传统文本文件的行尾以回车加换行(CRLF,即 \r\n)终结;而在 POSIX 体系下,标准换行符仅为单字节换行(LF,即 \n)。在 wordfreq 的核心分词算法中,\r 被列为合法空白字符进行切分,因此无论文本载体采用哪种换行风格,解析出的单词流完全等价:
$ # Linux,GCC 14.2:sample_crlf.txt 由 sample.txt 改成 CRLF 换行得到
$ ./build/wordfreq -n 3 tests/data/sample_crlf.txt
4 the
2 on
2 sat
$ ./build/wordfreq -n 3 tests/data/sample.txt
4 the
2 on
2 sat2
3
4
5
6
7
8
9
实现这一行为对称性的幕后保障,在于文件打开模式的谨慎选择:除非有特殊需求,文件 I/O 一律强制以二进制模式 "rb" 打开。
/* Windows 上 \r\n 被翻译成 \n,Linux 上不翻译:
同一个文件在两个平台上拿到的字节序列不同 */
FILE *in = fopen(path, "r");2
3
/* 两个平台都按原始字节读入,分词结果只由字节决定 */
FILE *in = fopen(path, "rb");2
在 Windows 运行库中,以普通文本模式 "r" 读取文件时,底层 I/O 层会自动执行隐式转译——将流中的 \r\n 剔除回车并折叠为 \n;而在 Linux 环境下则原样直通。这种平台专属的流式篡改,会导致同一份物理磁盘文件在不同操作系统中向应用层交出的字节序列各不相同。wordfreq 在 count_file 内部统一坚持以 fopen(path, "rb") 读取数据,彻底切断了运行时库的人为干预。即便分词器自身对 \r 具有包容性,保持二进制读取依旧能确保两端内存快照逐字节吻合,降低跨平台联合调试的排查心智成本。
在输出端,两者的差异同样需要被工程化包容:Windows 下的标准输出默认处于文本模式,向 stdout 输出 \n 会被运行时库自动补全为 \r\n 发往终端或重定向流;而在 Linux 下则始终维持单一的 \n。正因如此,自动化集成测试切忌直接对捕获的原始文本实施字节级 cmp 比对。在 tests/run_e2e.cmake 测试脚手架中,比对前统一执行 string(REPLACE "\r" "" actual "${actual}") 过滤回车字符,消除因底层平台默认行规约差异而带来的测试虚警,聚焦验证可见内容的一致性。
仓库里的换行符由 .gitattributes 统一,谁在什么系统上检出都得到同一套字节,做法见 第 17 章 的最后一节。
8. 动态库
在先前的章节与演进示例中,核心库 wf 始终作为静态库(Static Library)进行构建与交付,符号目标文件在链接阶段被直接解包并嵌入可执行二进制镜像中。CMake 提供了标准开关 BUILD_SHARED_LIBS:当工程声明未显式锁定 STATIC 或 SHARED 时,将该选项置为 ON 便能统领所有未标注的目标默认编译为动态共享库(Shared Library):
cmake -S . -B build-shared -DBUILD_SHARED_LIBS=ON
cmake --build build-shared2
由于各宿主操作系统在可执行文件格式与动态链接装载器上的历史沿革,共享库的产物形态、导入链接协议以及运行时定位机制呈现出鲜明的平台特质:
| 平台与工具链 | 共享库二进制产物 | 导入库 (Import Library) | 运行时依赖寻址协议 |
|---|---|---|---|
| Linux,GCC | libwf.so | 无(直接基于 ELF 符号表链接) | 编译期固化 RUNPATH,或依赖环境变量 LD_LIBRARY_PATH 及系统缓存 /etc/ld.so.cache 由 ld.so 装载 |
| macOS,Apple Clang | libwf.dylib | 无(直接基于 Mach-O 链接) | 编译期记录安装名(Install Name)与 LC_RPATH,由 dyld 动态检索 |
| Windows,MinGW-w64 | libwf.dll | libwf.dll.a | 链接期依赖导入库,运行时依据 Windows DLL 搜索序列(同级目录、系统路径、PATH)寻址 |
| Windows,MSVC | wf.dll | wf.lib | 链接期依赖导入库,运行时依据 Windows DLL 搜索序列寻址 |
在 Linux 平台下,ELF 格式的设计哲学推崇简洁:共享库本身即是完备的链接与装载载体,以 lib 为前缀、以 .so 为后缀,链接器直接自 .so 中提取动态符号清单:
$ cmake -S . -B build-shared -DBUILD_SHARED_LIBS=ON -G "Unix Makefiles"
$ cmake --build build-shared -j 2
[100%] Built target test_counter
$ ls build-shared/libwf.so
build-shared/libwf.so
$ ./build-shared/wordfreq -n 3 tests/data/sample.txt
4 the
2 on
2 sat2
3
4
5
6
7
8
9
而在 Windows 平台下,PE/COFF 格式则将链接与运行时装载解耦为两组文件。以 MinGW 为例,编译完成后产物目录下会同时出现两份物理文件:
build-shared/libwf.dll
build-shared/libwf.dll.a2
其中扩展名为 .dll.a 的是导入库(Import Library),其本质是一份轻量级的静态档案,内部仅记录了“哪些导出符号驻留在哪个 DLL 镜像内”的重定位跳转目录,专供编译下游应用时提供给链接器解析;而运行时实际被操作系统装载进进程虚拟地址空间的则是 .dll 文件。MSVC 环境遵循同样的机制,但命名上去除了 Unix 风格的 lib 前缀,其生成的导入库扩展名为 .lib,与常规静态库同名,仅能通过存放路径与元数据加以区分。
更为棘手的是符号可见性的默认策略。在 Linux 环境下,所有非 static 函数默认对外界完全开放导出;而在 Windows PE 架构中,DLL 内部的符号默认全部对外部封闭隐蔽。当 MSVC 生成 wf.dll 时,若未在源码中为接口函数显式标注 __declspec(dllexport),链接器在导出表中将找不到任何可暴露条目,从而根本不会生成配套的导入库 wf.lib。若尝试在头文件中将导出宏 WF_API 置为空,MSVC 虽然能勉强生成无导出的 wf.dll,但紧接着链接下游可执行文件时便会全面崩溃:
[9/12] Linking C shared library wf.dll
[10/12] Linking C executable wordfreq.exe
FAILED: wordfreq.exe
LINK : fatal error LNK1104: 无法打开文件“wf.lib”2
3
4
倘若不是全盘缺失,而仅仅是不小心遗漏了某个函数的导出标注,链接器虽会勉强产出 wf.lib,但一旦上层应用调用了未导出的接口,就会抛出经典的未解析符号错误(LNK2019)。若将 include/wf/counter.h 中 wf_counter_top 原型前面的 WF_API 摘除,重新编译便会立即暴露该问题:
[9/12] Linking C shared library wf.dll
[10/12] Linking C executable wordfreq.exe
FAILED: wordfreq.exe
main.c.obj : error LNK2019: 无法解析的外部符号 wf_counter_top,函数 print_top 中引用了该符号
wordfreq.exe : fatal error LNK1120: 1 个无法解析的外部命令2
3
4
5
MinGW 工具链在默认配置下掩盖了这一差异:它在构建 DLL 时沿用了 Unix 习惯,默认将所有非静态函数悉数强行导出。同一份去除了全部 WF_API 的代码,在 MinGW 下能顺利完成构建与测试,而换至 MSVC 便寸步难行。许多开源 C 库初次移植到 MSVC 时遭遇的链接阻碍,几乎全源于对 Windows 导出契约的疏忽。
工程化的标准解法,是在公共头文件中构建一套兼顾“自身编译时导出、外部引用时导入、以及静态链接时静默”的条件导出宏。wordfreq 项目定义在 include/wf/export.h 中的标准实现如下:
/*
* wf 库的导出标记:共享库在 Windows 上默认不导出符号,
* 需要 __declspec(dllexport) 声明哪些是公开接口。
*
* - 构建 wf 自身时定义 WF_BUILDING;
* - 使用共享库时定义 WF_SHARED;
* - 静态库构建两者都不需要,WF_API 展开为空。
*/
#ifndef WF_EXPORT_H
#define WF_EXPORT_H
#if defined(WF_SHARED) && defined(_WIN32)
#ifdef WF_BUILDING
#define WF_API __declspec(dllexport)
#else
#define WF_API __declspec(dllimport)
#endif
#elif defined(WF_SHARED) && defined(__GNUC__)
#define WF_API __attribute__((visibility("default")))
#else
#define WF_API
#endif
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
每个公开 API 声明的前缀均统一冠以 WF_API,如 WF_API size_t wf_counter_top(...)。构建系统通过精细的宏可见性传播驱动其语义切换:内部宏 WF_BUILDING 仅以 PRIVATE 模式注入给库本身;而环境宏 WF_SHARED 则仅在开启共享库构建时,通过 PUBLIC 属性同时广播给库自身与所有下游消费者:
target_compile_definitions(wf PRIVATE WF_BUILDING)
if(BUILD_SHARED_LIBS)
target_compile_definitions(wf PUBLIC WF_SHARED)
endif()2
3
4
5
当工程作为静态库编译时,WF_SHARED 未定义,WF_API 平滑消解为空白标记,声明形式退回纯粹的经典 C 风格;而在动态库编译过程中,wf 库的源码将其展开为 __declspec(dllexport) 通知编译器向符号表写入导出项,外部调用者引入同一头文件时则展开为 __declspec(dllimport) 指示编译器生成高效的导入跳转指令。在 GCC/Clang 体系下,该宏映射为 visibility("default"),能够在开启全局符号隐藏编译时精确暴露接口。注入该宏体系后,MSVC 共享库顺利完成全部构建与测试闭环:
[9/12] Linking C shared library wf.dll
[10/12] Linking C executable test_token.exe
[11/12] Linking C executable wordfreq.exe
[12/12] Linking C executable test_counter.exe
.\wordfreq.exe -n 3 ..\tests\data\sample.txt
4 the
2 on
2 sat2
3
4
5
6
7
8
CMake 提供了辅助模块 GenerateExportHeader 用于按目标自动生成类似的导出头文件;针对无需精细控制可见性的项目,亦可开启 CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS 强制 CMake 解析目标文件并自动合成导出列表。然而后者对包含导出全局对象的复杂 C 库存在局限,对于架构清晰、仅包含有限导出接口的系统级 C 模块,手工维护一份自持且含义明确的 export.h 是更健壮的工程方案。
共享库的公开接口需要显式导出
在 Windows PE 体系下,动态库符号默认处于隐藏隔离状态,MSVC 依赖导入库提供符号解析;MinGW 默认全量导出的行为只是其特定的兼容妥协。合乎工业规范的跨平台 C 库应当在公共头文件中设计结构严谨的导出宏,使得同一套函数原型声明在“自身编译”、“外部动态调用”以及“静态链接”三种构建模式下均能精确求值为合法的方言属性。在 Linux 环境下,这套宏在常规构建中通常平滑消解,不引入任何运行时损耗。
另一项关键细节是共享库在运行期的动态加载与寻址。在 Linux 下,CMake 在编译可执行文件时默认会将构建目录绝对路径作为 RUNPATH 写入 ELF 标头的动态段,从而保证在构建目录下直接执行二进制文件时动态装载器能够顺利定位依赖库。借助 readelf -d 可以审视这一底层属性:
0x000000000000001d (RUNPATH) Library runpath: [/home/minsecrus/tutorial/work/ch16/wordfreq/build-shared]如果将生成的可执行程序剥离至外部独立目录,或是将构建目录中的共享库移走,操作系统在启动进程并试图装载动态库时便会立刻阻断运行:
$ # Windows,MinGW:把 wordfreq.exe 复制到没有 libwf.dll 的目录
$ ./wordfreq.exe -n 3 ../wordfreq/tests/data/sample.txt
C:/work/moved/wordfreq.exe: error while loading shared libraries: libwf.dll: cannot open shared object file: No such file or directory
$ echo $?
127
$ # Linux,手工链接一个没有 RUNPATH 的可执行文件
$ ./moved/wordfreq -n 3 tests/data/sample.txt
./moved/wordfreq: error while loading shared libraries: libwf.so: cannot open shared object file: No such file or directory2
3
4
5
6
7
8
在 Windows 环境下,最稳妥的部署约定是将所需的 DLL 直接放置在调用端 .exe 同级目录下,或将其路径注册至系统环境变量 PATH 中;而在 Linux/Unix 生产部署中,则依赖 RPATH 相对路径(如 $ORIGIN/../lib)或是将库安装至标准系统搜索路径中。这些构建布局与最终二进制分发的进阶策略,在后续章节中将结合安装规范与打包流程做进一步延伸。
把 libwf 构建成共享库后,MSVC 链接 wordfreq 报 LNK2019: 无法解析的外部符号 wf_counter_top,而 MinGW 用同一份源码构建正常。最可能的原因是什么?
9. 预设
在同一个跨平台项目中,日常开发往往需要频繁切换构建画像:本地编码时依赖带调试符号的 Debug 选项;交付与基准测试时需要启用深度优化的 Release 选项;排查堆内存越界时需挂载 AddressSanitizer(ASan);在 Windows 平台上还需在 MinGW 与 MSVC 之间来回比对。每一种组合在底层都对应着一长串复杂的 cmake -S . -B ... -D... 命令行参数。如果将这些参数碎片化地记录在 README 中,既容易在人工复制时出现遗漏,也会导致本地开发环境与持续集成(CI)流水线产生难以觉察的参数漂移。
现代 CMake 引入了标准化的预设规范(CMake Presets),将各类构建配置固化沉淀为源码根目录下的 CMakePresets.json(Version 6 规范,要求 CMake 3.25 以上)。开发者在本地终端与自动化流水线中均可通过统一的预设名称调用相同的构建流。
在 wordfreq 项目中,CMakePresets.json 声明了四个核心配置预设:dev-gcc、release、asan 以及 msvc,并为之配套了同名的构建与测试预设,最后通过工作流预设 ci 将全流程串联闭环。其主体结构如下(此处隐去重复的构建与测试子段):
{
"version": 6,
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
"configurePresets": [
{
"name": "dev-gcc",
"displayName": "Debug (GCC/Clang)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
},
{
"name": "asan",
"displayName": "Debug + Sanitizer",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build-asan",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"WF_SANITIZE": "ON"
}
},
{
"name": "msvc",
"displayName": "MSVC (Ninja)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build-msvc",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" },
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
}
],
"workflowPresets": [
{
"name": "ci",
"steps": [
{ "type": "configure", "name": "dev-gcc" },
{ "type": "build", "name": "dev-gcc" },
{ "type": "test", "name": "dev-gcc" }
]
}
]
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
配置项中的 ${sourceDir} 是 CMake 预设宏变量,在求值时动态展开为项目源码的绝对路径;每一个配置预设独立绑定互不相干的构建输出目录 binaryDir,杜绝不同编译器或构建模式下的目标文件相互污染。字段 condition 允许声明动态环境约束,例如在此处约束 msvc 预设仅在 Windows 宿主(hostSystemName 为 Windows)下暴露,避免在 Linux 终端下干扰可用预设列表。工作流预设(Workflow Preset)中的 steps 数组按序编排配置、构建与自动化测试,各阶段精确复用同名的预设属性。
借助 cmake --list-presets 系列指令,可以快速核验当前宿主环境下已注册的各类构建预设(Windows 11):
$ cmake --list-presets
Available configure presets:
"dev-gcc" - Debug (GCC/Clang)
"release" - Release
"asan" - Debug + Sanitizer
"msvc" - MSVC (Ninja)
$ cmake --list-presets=workflow
Available workflow presets:
"ci"2
3
4
5
6
7
8
9
10
11
一旦配置了预设,繁复的构建与测试命令被高度提炼为对称的参数表达:
$ cmake --preset dev-gcc
Preset CMake variables:
CMAKE_BUILD_TYPE="Debug"
-- The C compiler identification is GNU 13.2.0
-- Detecting C compile features - done
-- Looking for getline
-- Looking for getline - not found
-- Configuring done (2.2s)
-- Generating done (0.0s)
-- Build files have been written to: C:/work/wordfreq/build
$ cmake --build --preset dev-gcc
[10/12] Linking C executable test_token.exe
[11/12] Linking C executable wordfreq.exe
[12/12] Linking C executable test_counter.exe
$ ctest --preset dev-gcc
Test project C:/work/wordfreq/build
Start 1: token
1/3 Test #1: token ............................ Passed 0.01 sec
Start 2: counter
2/3 Test #2: counter .......................... Passed 0.01 sec
Start 3: e2e
3/3 Test #3: e2e .............................. Passed 0.03 sec
100% tests passed, 0 tests failed out of 32
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
工作流预设进一步将这三条原子命令融合成单一的自动化管线:
$ cmake --workflow --preset ci
Executing workflow step 1 of 3: configure preset "dev-gcc"
Executing workflow step 2 of 3: build preset "dev-gcc"
Executing workflow step 3 of 3: test preset "dev-gcc"
100% tests passed, 0 tests failed out of 32
3
4
5
需要留意生成器的环境契约问题:若预设将底层生成器显式锁定为 Ninja,在未安装 Ninja 的极简 Linux 宿主上直接调用预设会抛出未定位生成器的错误:
$ cmake --preset dev-gcc
CMake Error: CMake was unable to find a build program corresponding to "Ninja". CMAKE_MAKE_PROGRAM is not set. You probably need to select a different build tool.Preset CMake variables:
CMAKE_BUILD_TYPE="Debug"
CMake Error: CMAKE_C_COMPILER not set, after EnableLanguage
-- Configuring incomplete, errors occurred!2
3
4
5
6
7
在命令行末尾追加 -G "Unix Makefiles" 能够临时覆盖预设声明的生成器。若需长期兼容缺乏 Ninja 的特定容器环境,工程化的做法是在预设文件中定义一个继承自 dev-gcc 的衍生预设(例如继承所有编译宏与参数、仅覆写生成器与独立构建目录),这正是第 18 章持续集成中配置流水线所采纳的标准实践。
对于特定开发人员本地独有的私有配置(例如特殊的工具链路径或专属性能分析选项),CMake 提供了独立的配置清单 CMakeUserPresets.json。该文件支持直接继承团队共享的 CMakePresets.json,且在版本控制中通过 .gitignore 规则显式予以忽略。如此一来,既能确保主干预设的一致性与整洁性,又赋予了本地调试充分的定制自由度。
10. 交叉编译与工具链文件
前述探讨的所有编译与构建实践,均隐含了一个前提假设:编译环境(Host,宿主机)与最终目标执行环境(Target,目标机)属于同构体系。然而在嵌入式设备、IoT 终端或跨架构服务器部署场景中,往往需要在高性能的 x86_64 宿主机上,为 ARM64 或 RISC-V 目标架构交叉编译二进制镜像。此时,交叉编译器、目标机系统头文件(Sysroot)、运行时库以及查找环境均不再位于宿主机的默认系统路径下,CMake 依赖工具链文件(Toolchain File)显式获取目标环境的拓扑信息。
工具链文件是一份专职描述目标体系特征的 CMake 脚本,在构建系统的配置初期即被最先载入。一份面向 64 位 ARM Linux 目标机的典型工具链配置文件如下:
# aarch64-linux-gnu.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)2
3
4
5
6
7
8
9
10
11
变量 CMAKE_SYSTEM_NAME 赋值为目标操作系统名称(如 Linux),CMake 依据该声明判定当前任务属于交叉编译范式;CMAKE_C_COMPILER 则指定交叉编译链的可执行文件名或绝对路径。指令组 CMAKE_FIND_ROOT_PATH_MODE_* 承担着避免“架构混淆”的防火墙重任:可执行工具程序(如代码生成脚本所依赖的解释器)保持在宿主机环境中检索(NEVER);而目标库、头文件以及依赖包则严格被限制在目标机根目录(Sysroot)内搜索(ONLY),坚决防范在链接阶段误将宿主机 x86_64 的动态库混入 ARM64 产物中。
若在未安装对应交叉工具链的宿主机上强行挂载工具链文件执行配置:
$ cmake --toolchain aarch64-linux-gnu.cmake -S . -B build-arm64
-- Building for: Ninja
-- The C compiler identification is unknown
CMake Error at CMakeLists.txt:2 (project):
The CMAKE_C_COMPILER:
aarch64-linux-gnu-gcc
is not a full path and was not found in the PATH. Perhaps the extension is
missing?
Tell CMake where to find the compiler by setting either the environment
variable "CC" or the CMake cache entry CMAKE_C_COMPILER to the full path to
the compiler, or to the compiler name if it is in the PATH.2
3
4
5
6
7
8
9
10
11
12
13
14
两台当前测试机并未预装该交叉编译套件,因此终端直接呈现预期中的编译器未定位阻断信息。这一阻断机制恰恰体现了工具链文件的严肃性:CMake 在配置最初期便对编译器的可用性完成硬性校验,一旦发现交叉编译器不存在于系统环境便即刻终止,避免生成损坏的中间构建镜像。自 CMake 3.21 起,使用 --toolchain 选项是加载工具链文件的现代标准语法,其在功能上与传统的 -DCMAKE_TOOLCHAIN_FILE=... 完全等价。
工具链文件与预设可以组合:在 CMakePresets.json 的 cacheVariables 里写 "CMAKE_TOOLCHAIN_FILE": "aarch64-linux-gnu.cmake",就得到一个专门做交叉构建的预设。XMake 里对应的是 xmake f -p mingw -a x86_64 这类平台参数,粒度比 CMake 的工具链文件粗,但常见的嵌入式与 MinGW 目标都覆盖到了。嵌入式部分的交叉编译在 1.5 开发环境与交叉编译 里从工具链安装讲起,sysroot 与根文件系统的组织见 5.3 交叉工具链、sysroot 与根文件系统。
11. 习题
11.1 巩固
同一份源码、同一个 strdup 改动,在三套环境里得到三种结果:
| 工具链 | 结果 |
|---|---|
Windows 11,MinGW-w64 GCC 13.2,gnu17 | 构建通过,测试 3/3 |
| Windows 11,MSVC 14.51 | 构建通过,测试 3/3 |
Debian 13,GCC 14.2,-std=c17 且 C_EXTENSIONS OFF | implicit declaration of function 'strdup' |
(1) 三套环境里,哪一套的通过是「头文件不管标准模式、照样声明」,哪一套的通过是「默认用了 GNU 扩展模式」?
(2) 把 -std=c17 换成 -std=gnu17,Linux 上那条错误会消失吗?为什么这不是一个可移植的修法。
(3) 正文说「GCC 13 只警告、GCC 14 才报错」。如果手头只有 GCC 13,加哪个选项能让它在编译阶段就停下?写出命令,并说明这个选项在真实项目里通常和什么一起用。
limits.c 打印三个宽度和一个范围。在你能用到的两套环境(Windows + MinGW、Linux/WSL)上各编译运行一次,记录输出。
(1) 填一张表:sizeof(int)、sizeof(long)、sizeof(void *)、CHAR_MIN/CHAR_MAX、LONG_MAX 在两边的值。哪一项不同?
(2) 写一个程序,把文件大小存进 long 再用 %ld 打印。在 Windows 上处理一个 5 GiB 的文件会发生什么?改用 size_t/ptrdiff_t 或 <stdint.h> 的定宽类型,给出两种修法,并说明各自的适用场合。
(3) 正文说「char 是否有符号在本次两台机器上恰好一致」。写一个程序,在运行期打印 (char)-1 < 0 的结果,并说明为什么不能靠它决定用 char 还是 unsigned char 存字节。
(4) 用 _Static_assert 把「sizeof(long) == 8」写成编译期断言,在 Windows 上构建一次,记录诊断。说明这类断言在跨平台代码里该放在哪里,以及为什么它比运行时检查更早失败。
复现「宏的位置决定成败」。
(1) 写两份只差一行位置的源文件(early.c 把 #define _POSIX_C_SOURCE 200809L 放在最前面,late.c 放在 #include <string.h> 之后),都调用 strdup。在 Linux 上用 gcc -std=c17 -Wall -Wextra -c 各编译一次,记录结果。
(2) 把两份文件原样拿到 Windows 的 MinGW 上编译。结果是否相同?解释原因,并据此说明「在我机器上能过」为什么不能作为可移植性的证据。
(3) 把宏换成 _GNU_SOURCE,再做一次 (1)。两者都能让 strdup 可见,它们声明的 POSIX 版本有什么区别,_GNU_SOURCE 还额外打开了什么。
(4) 这一节讲的机制由谁实现:编译器的标准模式,还是 C 库的头文件?用 GCC 的 -std=c17 与 -std=gnu17 下 __STRICT_ANSI__ 是否定义来说明。
11.2 编程
写一个小 CMake 工程,只含一个 main.c,用三种写法给 GCC 加 -Wall -Wextra -Wpedantic:
# A:每个选项一条表达式
target_compile_options(demo PRIVATE
$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall>
$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wextra>
$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wpedantic>)
# B:一条表达式,选项用空格分隔并加引号
target_compile_options(demo PRIVATE
"$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall -Wextra -Wpedantic>")
# C:一条表达式,选项用分号分隔
target_compile_options(demo PRIVATE
"$<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wpedantic>")2
3
4
5
6
7
8
9
10
11
12
13
(1) 三种写法各用 cmake --build --verbose 构建一次,把 GCC 的实际命令行贴出来。哪一种失败,失败信息是什么?
(2) B 为什么要加引号才暴露问题;不加引号时 CMake 会怎么处理这个参数?用自己的话说明「一个表达式只能产生一个列表元素」的含义,并对照:直接写 target_compile_options(demo PRIVATE -Wall -Wextra -Wpedantic)(不加任何生成器表达式)为什么反而正确。
(3) 用 $<C_COMPILER_ID:MSVC> 为 MSVC 加 /utf-8 与 /D_CRT_SECURE_NO_WARNINGS。手头没有 MSVC 时,改用 $<PLATFORM_ID:Windows> 之类可观察的条件,验证条件为真、为假时命令行各少了什么。
(4) 把 -Wall 改成不存在的 -WALL 再构建一次,记录诊断。两次失败都由编译器报出,但成因不同:B 传给编译器的是一整个带空格的参数,D 传的是拼错的选项名。用 --verbose 的命令行说明这个区别,并指出哪一种错误在 CI 里更常见。
code/开发与工程基础/16_可移植构建/platform/ 里用 wf_stdout_is_terminal 演示了平台的隔离。给它加第二个函数:
/* 返回系统临时目录的路径,写进 buf;成功返回 0,失败返回 -1 */
int wf_temp_dir(char *buf, size_t cap);2
(1) 写 platform_posix.c 与 platform_win32.c 两份实现。Linux 用 TMPDIR//tmp,Windows 用 _tempnam 或 GetTempPathA。两者都必须遵守同一个接口,调用方只包含 platform.h。
(2) 把两份实现同时编进链接,记录链接器诊断。指出这是「同一符号定义两次」,而不是「平台代码没选对」。
(3) 用 CMakeLists.txt 的 if(WIN32) 只编一份,构建后在两个平台上各运行一次,打印路径。Windows 上路径里是 \,Linux 上是 /;说明调用方要不要关心这个差别。
(4) 给 wf_temp_dir 写一个不依赖平台的测试:传入容量为 0、容量很小的缓冲区各一次,检查返回值与缓冲区是否被写坏。说明为什么这类测试放回 tests/ 就能在两边跑。
文本模式与二进制模式的差别在字节层面可以量出来。写一个程序 bytes.c:
- 用
fopen(path, "rb")读一个 CRLF 文件,统计读到的字节总数与其中\r的个数; - 换成
fopen(path, "r")再读一次,同样统计; - 用
"wb"与"w"各写一次"a\nb\n",用od -c看文件内容。
测试文件自己造:在 Windows 上用 printf "a\r\nb\r\n" > t.txt 生成。
(1) 把四个数字填成表。在 Windows 上,文本模式读到多少字节,\r 有几个?在 Linux 上呢?
(2) code/开发与工程基础/16_可移植构建/wordfreq/tests/run_e2e.cmake 在比较前做了 string(REPLACE "\r" "" ...)。先预测删掉这一行后 e2e 在 Windows 上的结果,再实测。若实测与预测不符,去查 CMake 的 execute_process 捕获输出时对换行的处理,并说明这行 string(REPLACE) 到底在防什么、什么时候才确切需要它。
(3) 正文说 wordfreq 的 count_file 用 "rb"。既然分词器把 \r 当分隔符、两种模式结果相同,为什么还要统一成二进制模式?用「调试时少一层解释」展开说两句。
(4) 如果输出确实需要逐字节一致(例如做哈希),Windows 上除了改用 fwrite,还有什么办法让 stdout 不再把 \n 翻译成 \r\n?
共享库的导出规则按平台不同。用 code/开发与工程基础/16_可移植构建/wordfreq/ 做下面的实验。
(1) 打开 BUILD_SHARED_LIBS,在 MinGW 上构建,列出产物文件名。用 objdump -p 查看 libwf.dll 的导出表,数一数导出了几个 wf_ 开头的符号,与 include/wf/*.h 里声明的公开函数对照。
(2) 把 export.h 里的 WF_API 改成空宏,重新构建并运行。构建是否成功?导出表里有几个符号?这个结果与正文里 MSVC 的 LNK1104 有什么不同,原因是什么。
(3) 把 WF_API 从 wf_counter_top 的声明上删掉(其余保留),在 MinGW 上构建并运行。程序还能跑吗?如果手头有 MSVC,在同一份源码上做一次,记录 LNK2019 的原文。
(4) 解释 export.h 里三种情形为什么都要有分支:构建库(WF_BUILDING)、使用共享库(WF_SHARED)、静态链接(两者都不定义)。把 WF_SHARED 打开却仍做静态构建,会在链接期出现什么?实测一次并贴出诊断。
给 wordfreq 写一份自己的 CMakePresets.json(version 6):
- 配置预设
dev(Ninja、Debug、build/dev); release(继承dev,Release、build/release);asan(Debug+WF_SANITIZE=ON、build/asan);msvc(只在 Windows 上出现);- 一个继承
dev、只改生成器为Unix Makefiles与构建目录的dev-make; - 与配置预设同名的构建预设和测试预设,以及把它们串起来的工作流预设
ci。
(1) cmake --list-presets、--list-presets=build、--list-presets=test、--list-presets=workflow 各输出什么?在 Linux 上 msvc 是否出现,由哪个字段决定。
(2) 用 cmake --workflow --preset ci 跑一遍,贴出三步的输出。把 ci 的 steps 改成先 build 再 configure,会发生什么?
(3) 在一台没有 Ninja 的机器上(或临时把 Ninja 从 PATH 里拿掉)运行 cmake --preset dev,记录错误。写出两种修法:命令行临时覆盖生成器,以及在预设文件里加一个继承出的预设。
(4) CMakeUserPresets.json 与 CMakePresets.json 的分工是什么?为什么前者要写进 .gitignore?举一个只能放在前者的例子。
11.3 综合与挑战
这一题需要一套 ARM 交叉工具链(例如 arm-none-eabi-gcc),没有的话只做 (1)(4) 的读写部分。
(1) 写一份 arm-none-eabi.cmake 工具链文件,面向 Cortex-M3:CMAKE_SYSTEM_NAME 设为 Generic,CMAKE_C_COMPILER 写编译器名,CMAKE_C_FLAGS_INIT 加 -mcpu=cortex-m3 -mthumb。
(2) 只写上面这些,配置一个最简单的 project(... LANGUAGES C)。记录失败输出:CMake 的编译器检测链接了 C 库,_exit、_write、_sbrk 一类符号未定义。加上 CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY 之后是否通过?
(3) 编一个小模块,用 arm-none-eabi-size 比较 -O0、-O2、-Os 的 text 段。本机一个二十行的示例是 108、60、48 字节。三个级别各自适合什么场合,与第 15 章桌面程序的结论是否一致。
(4) 说明 CMAKE_FIND_ROOT_PATH_MODE_* 四项各自控制什么。把 CMAKE_FIND_ROOT_PATH_MODE_PROGRAM 也设成 ONLY 会出什么问题(提示:CMake 自己调用的工具也要在本机找)。
这一题把本章的几件事串成一个可移植的功能。
给 wordfreq 加一个「按行读入并统计」的函数,要求同一份源码在 Linux 与 Windows 上都能构建,且只走一条路径:
/* 读入 in 的全部行,返回行数;出错返回 -1 */
long wf_count_lines(FILE *in);2
(1) 先写一个「直接调 getline」的版本,在 Windows 上构建,记录失败信息。判断它属于本章五层差异里的哪一层。
(2) 改成配置时检测:在 CMakeLists.txt 里用 CheckSymbolExists 检测 getline,用 configure_file 生成 wf/config.h,源文件里根据宏选择 getline 或 fgets 实现。给检测加 CMAKE_REQUIRED_DEFINITIONS,并说明不加会在 Linux 上得到什么错误结论。
(3) 给检测留一个强制开关(对应正文的 WF_HAVE_GETLINE),在开发机上把两条路径各配置、构建、测试一次。设计至少三个测试:空文件、单行无换行、超过 4096 字节的长行。长行在两条路径下都必须得到同一个行数,实测并贴出结果。
(4) 把 long 换成 size_t 或 int64_t 的理由写清楚,并说明返回值 -1 与无符号类型冲突时该怎么设计接口。
(5) 写一份不超过十行的「移植检查清单」,至少覆盖:标准模式、功能特性宏的位置、配置检测是否带定义、两条路径是否都测过、文本与二进制模式、共享库导出。每一条后面用一个本章的例子说明不照做会出什么错。
12. 参考资料
- CMake 文档《C_EXTENSIONS》,
C_EXTENSIONS属性与-std=gnu17、-std=c17的对应关系:https://cmake.org/cmake/help/latest/prop_tgt/C_EXTENSIONS.html - CMake 文档《CheckSymbolExists》,检测符号是否可用的原理与
CMAKE_REQUIRED_DEFINITIONS:https://cmake.org/cmake/help/latest/module/CheckSymbolExists.html - CMake 文档《configure_file》,
#cmakedefine的展开规则:https://cmake.org/cmake/help/latest/command/configure_file.html - CMake 文档《cmake-generator-expressions(7)》,
$<C_COMPILER_ID:...>、$<NOT:...>与列表展开:https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html - CMake 文档《cmake-presets(7)》,预设文件的结构、继承、条件与工作流:https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html
- CMake 文档《cmake-toolchains(7)》,交叉编译变量与
CMAKE_FIND_ROOT_PATH_MODE_*:https://cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html - CMake 文档《GenerateExportHeader》,自动生成导出宏头文件:https://cmake.org/cmake/help/latest/module/GenerateExportHeader.html
- CMake 文档《BUILD_SHARED_LIBS》,全局共享库开关:https://cmake.org/cmake/help/latest/variable/BUILD_SHARED_LIBS.html
- Linux man-pages《feature_test_macros(7)》,
_POSIX_C_SOURCE、_GNU_SOURCE与 glibc 的声明条件:https://man7.org/linux/man-pages/man7/feature_test_macros.7.html - GCC 文档《Porting to GCC 14》,隐式函数声明由警告变为错误:https://gcc.gnu.org/gcc-14/porting_to.html
- Microsoft Learn《/utf-8》,让 cl 按 UTF-8 读取源文件:https://learn.microsoft.com/en-us/cpp/build/reference/utf-8-set-source-and-executable-character-sets-to-utf-8
- Microsoft Learn《Compiler option warning level》,
/W4与警告级别:https://learn.microsoft.com/en-us/cpp/build/reference/compiler-option-warning-level - msys2/MINGW-packages 议题 #10125,MinGW 对
%zu的-Wformat误报与__USE_MINGW_ANSI_STDIO:https://github.com/msys2/MINGW-packages/issues/10125 - Microsoft Learn《Exporting from a DLL》,
__declspec(dllexport)与导入库:https://learn.microsoft.com/en-us/cpp/build/exporting-from-a-dll - POSIX.1-2024,
strdup与getline的规范:https://pubs.opengroup.org/onlinepubs/9799919799/functions/strdup.html、https://pubs.opengroup.org/onlinepubs/9799919799/functions/getline.html