从编译流程到 Makefile / CMake,再到 POSIX 与标准库里的坑。

一、构建

C编译流程

C 编译大致经过预处理、编译、汇编和链接。预处理器展开头文件和宏;编译器生成汇编或中间表示;汇编得到目标文件;链接器把目标文件与库合成可执行文件或共享库。

理解这个流程才能解释“改了宏为何要全量编”、未定义引用出在哪一步。-E/-S/-c 可以停在中间产物。头文件只放声明,重复定义会在链接期爆炸。静态库与动态库解析时机不同。调试符号、优化级别和 LTO 都会改变最终代码。构建系统只是把这些步骤编排起来,出错时按阶段定位比盯着一行 gcc 命令更快。

Makefile

Makefile 用目标和依赖描述构建图,Make 根据时间戳决定重编什么。规则写成目标、依赖和配方,配方必须用 Tab。它仍是很多 C/C++ 和固件项目的底层构建语言。

变量、模式规则和自动变量能减少重复。并行 make -j 要求依赖写完整,否则会偶发失败。PHONY 用于不是文件的目标。递归 Make 难维护,优先一个图。调试用 make -n 看将执行的命令。新项目更常见 CMake 生成 Makefile/Ninja,但读懂 Makefile 仍能排查链接顺序和头文件依赖漏写。

CMake

CMake 用 CMakeLists.txt 描述源码如何生成原生工程,再调用 Ninja 或 Make 真正编译。它把平台差异抽象成生成器,因此同一份脚本能出 Visual Studio、Xcode 和 Unix Makefile。

现代写法用 target_link_libraries 传播依赖,少用全局 include_directories。找包用 find_package 与 Config 文件。缓存变量会留在 build 目录,改选项后必要时清缓存。交叉编译靠工具链文件。安装规则与导出配置决定别人能不能 find 到你。和 FetchContent 拉依赖时要钉版本。出错先看生成阶段还是构建阶段,两者日志完全不同。

二、语言细节

C语言整形提升

C 的整型提升指在表达式里,比 int 小的整数类型会先变成 int 或 unsigned int 再运算。这能解释为什么 unsigned char 相减得到的是 int,以及混合有符号无符号时比较结果反直觉。

典型坑是 uint8_t a,b; 若 a-b 期望得到无符号回绕,提升后可能变成负数。位移、位运算同样受影响。写底层协议时显式转换成目标宽度。编译器警告 -Wconversion 能抓住一部分。规则以标准为准,不要靠某次运行结果猜测。嵌入式上 int 宽度固定为 32 时更要小心 8/16 位运算。

OOP in C

在 C 里做面向对象,靠的是结构体加函数指针,而不是语言关键字。对象把数据放结构体,方法是接收该结构体指针的函数。继承用嵌套基类结构体,多态用虚表:基类持有一组函数指针,子类填入自己的实现。Linux 内核和许多嵌入式框架都是这种风格。

要注意对象生命周期和谁负责 free。虚表不要每实例一份重复常量,可做成静态表。类型转换要克制,用容器宏从成员取回对象。没有析构语法,错误路径必须自己配对清理。这种 OOP 适合驱动和协议栈的可替换实现,不必引入 C++ 运行时。读代码时先找 ops 表,再找数据字段,层次会清楚很多。

三、标准库与系统接口

POSIX

POSIX 是操作系统接口的可移植标准,覆盖进程、文件、信号、线程和终端。Linux、BSD 和部分嵌入式系统大体遵循它,因此同一套 API 能跨 Unix 系复用。它不是一种语言,而是 C 库与系统调用的约定。

写可移植代码时优先用 POSIX 接口,少依赖 GNU 扩展。线程用 pthread,文件偏移注意大文件接口。信号处理要了解异步安全函数集合。时间、路径和权限模型也在标准范围内。Windows 不完全兼容,跨平台仍需抽象层。读 man 页时注意 POSIX.1 版本,老代码可能依赖已废弃行为。

C 文件IO

C 的文件 IO 分标准库 FILE* 与 POSIX 的 open/read/write。fopen 带缓冲,适合文本;底层描述符适合精确控制偏移和锁。模式字符串决定读写与截断,搞错会清空文件。

读写后检查返回值,不能假设一定成功。二进制用 fread/fwrite,文本注意换行在不同系统上的翻译。定位用 fseek/ftell,大文件要考虑 64 位偏移。用完 fclose,并在 fork 后理解缓冲复制问题。错误用 ferror 与 errno 区分。能用现成库解析格式就不要手写字节解析,文件 IO 层只负责可靠读写。

C stdin、out和err

C 语言里 stdin、stdout、stderr 是三条标准流,对应文件描述符 0、1、2。正常输出走 stdout,诊断信息走 stderr,这样重定向时日志不会和结果混在一起。printf 默认 stdout,perror 和未指定的错误应写 stderr。

缓冲策略不同:stderr 通常无缓冲或行缓冲,stdout 在管道里可能全缓冲,因此崩溃前看不到 printf 是常见现象,调用 fflush 或改用 stderr。freopen 可以重绑定,但多线程要小心。写 CLI 工具时保持“结果在 1、错误在 2”,方便脚本判断退出码和抓日志。

C语言-标准库:

setjmp.h 提供 setjmp 与 longjmp,能从深层函数跳回先前保存的栈环境,类似非局部 goto。解析器、脚本引擎和需要统一错误出口的 C 库会用。setjmp 保存上下文,出错时 longjmp 带一个值返回。它不调用中间函数的析构,C++ 对象和锁不能指望它清理。

使用限制多:不能跳过已经返回的函数,volatile 局部变量才可靠。信号处理里 longjmp 更要小心。现代代码优先用错误码或 goto cleanup。读老代码时,把 setjmp 点当成 catch。调试这种跳转很难跟栈。它是 C 的应急能力,不是日常控制流。能把资源释放写成线性清理,就不要引入跨函数跳跃。

四、第三方库

C语言:libjpeg

libjpeg 是广泛使用的 JPEG 编解码库,把像素压成 MCU 块并做熵编码。质量参数、色度子采样和渐进式扫描决定体积与观感。嵌入式和 Android 早期很多自定义压缩都基于它或 libjpeg-turbo。turbo 用 SIMD 加速,移动端更常见。调用时要处理错误回调,损坏文件不能让进程直接 abort。

压缩前先按显示尺寸缩放,比只降 quality 更能省内存。注意 YUV 与 RGB 转换的色彩范围。专利和许可证相对宽松,商业产品仍要核对。和系统 Bitmap 压缩对比时,看兼容性和速度而不是一味自己编。libjpeg 是编解码器,不是完整图片管道。缩放、缓存和格式选择要一起设计,才能避免 OOM。