第 1 章 · Getting Started with C

来源: Effective C (Seacord, 2020) — Chapter 1, pp. 1–11 笔记日期: 2026-08-27 六段式阅读框架: 概述 / Takeaways / 工程视角 / AI 视角 / 行动 / 思考


一、章节概述

  1. C 的定位:系统编程语言,靠近硬件,2020 年仍是 TIOBE 前两名;IoT/嵌入式/操作系统底层仍主要使用 C。
  2. “The Spirit of C” 五条信念:Trust the programmer / Don’t prevent / Keep small and simple / One way to do it / Make it fast。
  3. 标准的权威性:C Standard (ISO/IEC 9899) 是行为唯一权威;“implementation” = 编译器 + 标准库 + 命令行选项的特定组合。同一编译器换 flag 就是不同 implementation。
  4. C 标准的演进:C89 → C90 → C99 → C11 → C17/C18 → C2x(C23 已正式发布,本书未覆盖)。GCC 默认 gnu17。生产环境推荐显式 -std=c17 而非默认。
  5. 第一个程序 hello.c 的每一行:<stdio.h> 提供 puts<stdlib.h> 提供 EXIT_SUCCESSint main(void) 声明入口;puts 返回 int,EOF 表示失败。
  6. 必须检查函数返回值puts 失败返回 EOFprintf 失败返回负值;这是作者认为第一个程序”其实有 bug”的原因。return 之后的代码是 dead code。
  7. printf 的格式化字符串漏洞:禁止把用户输入作为第一个参数(format string vulnerability);输出简单字符串用 puts 更安全。
  8. 编译器选型:GCC / Clang / MSVC;嵌入式编译器很多仍只支持 C89/C90。Linux 默认 GCC,Windows MSVC 必须勾选 /TC(按 C 编译)而非 /TP(按 C++)。
  9. 可移植性五类问题(Annex J):implementation-defined / unspecified / undefined / locale-specific / common extensions。
  10. undefined behavior 不是 bug 而是设计:标准委员会有意留白,给实现优化空间——三类来源(违反 shall / 显式声明 / 显式未定义),UB 后果是编译器可”完全忽略”。

二、核心 Takeaways

Takeaway 1: main 的签名 int main(void) 是契约,不是建议

  • 是什么:入口函数签名固定为 int main(void)int main(int argc, char *argv[])
  • 为什么重要:返回值是给宿主环境(操作系统/调用脚本)看的退出码EXIT_SUCCESS(=0)和 EXIT_FAILURE(≠0)是 <stdlib.h> 约定的语义。
  • 解决什么问题:让 shell/CI/服务管理器能区分”成功”与”失败”。
  • 适用场景:所有 hosted environment(带 OS 的程序);freestanding(裸机/嵌入式)的入口由实现定义,本书假设 hosted。

Takeaway 2: 函数返回值永远要检查

  • 是什么puts 返回 EOF 表写失败;printf 返回负数表失败;malloc 返回 NULL 表失败。
  • 为什么重要:把返回值当”用过了” = 默认程序永远成功 = 错误被静默吞掉。
  • 解决什么问题:写出”看似工作、实际是错误状态”的程序——这是真实世界 C 程序缺陷的最大单一来源之一。
  • 适用场景:所有 I/O、所有内存分配、所有系统调用。

Takeaway 3: 显式声明 C 标准版本

  • 是什么:用 gcc -std=c17 hello.c 而不是默认的 gnu17
  • 为什么重要gnu17 含 GCC 私有扩展;不同版本默认不同;同代码今天编过明天可能不编(默认升级)。
  • 解决什么问题:跨编译器/跨平台/跨时间的”可重复构建”。
  • 适用场景:所有团队项目、嵌入式 BSP、长期维护代码。

Takeaway 4: printf(s)puts(s)printf("%s\n", s)——安全上不等价

  • 是什么:把用户可控字符串传给 printf 第一个参数 = 格式串漏洞;攻击者可写 %s%s%s 触发内存读。
  • 为什么重要:CERT C 列为高危;这类漏洞在 CVE 数据库占了相当比例。
  • 解决什么问题:避免格式化输出被滥用为信息泄漏 / 写任意内存的跳板。
  • 适用场景:所有从外部(CLI 参数 / 网络 / 文件)读到的字符串,绝对只做参数 2,不做 format。

Takeaway 5: undefined behavior = 编译器获准”做任何事”

  • 是什么:标准故意不定义某些情形(signed 整数溢出、解引用野指针、修改字符串字面量……),编译器可以:忽略、行为替换、终止。
  • 为什么重要:现代优化器(GCC/Clang)会基于 UB 不发生 做激进优化(常量传播、死代码消除)。一个 x+1 > xINT_MAX 时被识别为恒真,把循环条件直接删掉。
  • 解决什么问题:用 Sanitizer(UBSan)兜底;用 -Wall -Wextra -Werror;写代码时假设”UB 不发生”是错误假设。
  • 适用场景:任何想进生产环境的 C 代码。

Takeaway 6: “Host vs Freestanding” 是 C 的两种世界观

  • 是什么:hosted(有 OS,有完整标准库,main 固定);freestanding(裸机,可能无 OS,只有 <float.h> / <iso646.h> / <limits.h> / <stdarg.h> / <stdbool.h> / <stddef.h> / <stdint.h> 子集)。
  • 为什么重要:嵌入式工程师读本书要心里清楚——很多例子(putsEXIT_SUCCESS)在裸机根本不工作。
  • 解决什么问题:决定工程里能不能直接用 stdio,能不能跑完整测试。
  • 适用场景:选芯片、选 RTOS、选编译器扩展时。

三、工程实践视角

嵌入式开发

  • 入口:freestanding 环境下入口不是 main,是实现定义的 Reset_Handler / _start。Seacord 的例子不能直接搬到裸机,需要把 puts 替换成 UART 字符发送。
  • printf 的代价:在 Cortex-M 上,printf 会引入 10~30KB 代码(格式化器 + 浮点支持);很多团队裁剪 stub 版本(只支持 %d %x %s)以节省 flash。puts 通常轻得多。
  • EXIT_SUCCESS 的来源:freestanding 没 <stdlib.h>;退出码语义由 RTOS/BSP 接管(uC/OS 的 OS_ERR_NONE、FreeRTOS 的 pdPASS/pdFAIL),不要硬编码 0
  • C 标准版本:很多 MCU 厂商 IDE(IAR/Keil)默认仍按 C99 甚至 C99 with extensions;启用 C11/C17 前先查编译器手册,否则 static_assert_Generic_Atomic 不可用。

Linux 系统开发

  • 编译命令模板(本书可执行化的工程版本):
    cc -std=c17 -Wall -Wextra -Werror -Wpedantic -O2 -o hello hello.c
  • -Wpedantic 等价于”严格按标准”:会拒绝 GCC 扩展;写可移植代码必开。
  • Sanitizer 三件套(编译期就铺好):
    cc -std=c17 -fsanitize=address,undefined -g -O1 hello.c
    ASan 抓内存错(越界、UAF、double-free),UBSan 抓 UB。开发期永远开
  • MSVC 的 /TC vs /TP:扩展名 .c 自动走 /TC,但文件名错时(比如误命名为 .cpp)会按 C++ 编译,C/C++ 在 ABI 上有微妙差异——这是真实踩过的坑。

机器人软件(ROS / ROS2)

  • ROS2 客户端库 rclcpp/rclpy 的 C 底层:用 C 写的;理解 UB 与内存布局对调试 micro-ROS、CORBA 序列化很有帮助。
  • 实时性考量printf 在 Linux 用户空间大多带锁(FILE* 互斥)+ 堆分配,不可用于实时循环;机器人控制环里要走 fprintf(stderr, ...) + unbuffered,或直接 write(2, ...)
  • 跨平台:ROS2 同时支持 Linux/macOS/Windows;某些模块的”format string”问题在 Windows 上更隐蔽。

汽车电子软件(AUTOSAR / ISO 26262)

  • MISRA C 与本节直接相关:MISRA 是 CERT C 的严格子集(+汽车特定规则),所有 UB 都是 MISRA Required violation
  • ASIL-D 等级:禁用全部动态分配 → 第 6 章的 malloc/free 直接被禁;要走静态内存池(memory section)+ 启动期一次性分配。
  • format string 漏洞:AUTOSAR 规范(SWS_LogAndTrace)已规定 log API 必须是变参但 format 由调用方控制,禁止用户字符串做 format。
  • 可移植性 = 安全要求:汽车 ECU 跨多家 MCU 部署;本书”conforming program”的概念是 ASIL 评级的基础前提。

初级 vs 高级工程师的关注差

维度初级高级
写 hello worldprintf("...")puts("...") + 检查返回值 + 走 CI
编译命令cc hello.ccc -std=c17 -Wall -Wextra -Werror -Wpedantic -fsanitize=...
标准版本不知道有 C17在团队里强制 -std=c17 + 文档化
UB”哦,编译器会处理""我会用 UBSan + 代码评审系统化地排除”
编译器警告当噪音当 daily driver;CI 卡 -Werror

四、AI 时代视角

这个知识今天是否仍然重要?

仍然重要,但权重发生变化

  • 仍然不可替代:UB 的理解与防御、Sanitizer 的使用、跨编译器可移植性、format string 漏洞模式——这些是AI 也无法替你保证正确的领域,因为它们依赖具体实现行为,AI 的训练语料里只能学”大概”。
  • AI 大幅削弱的hello.c 样板代码、Makefile 模板、CMakeLists、configure script——AI 生成正确率 95%+,不再需要人手写。
  • AI 改变学习曲线:以前要”读完 K&R”才能写 C;现在 chat 一个 prompt 就出代码。理解 ≠ 会写这件事更突出——能读懂编译器警告、Sanitizer 输出变得比以前更重要。

AI 能帮助完成什么

  • ✅ 生成样板代码(hello world、Makefile、CMake)
  • ✅ 把 K&R 风格代码改写为现代 C(// 注释、static_assert_Generic
  • ✅ 解释编译器警告和 Sanitizer 输出
  • ✅ 对照 CERT C / MISRA C 规则审计代码片段
  • ✅ 给出不同编译器对同一段代码的处理差异(GCC vs Clang vs MSVC)

AI 无法替代什么

  • 判断 UB 是否真的发生:AI 给出的”应该没问题”在 INT_MAX+1 场景下毫无意义,必须 UBSan 跑。
  • 跨平台真实验证:AI 没在本机的 TI C2000 / NXP S32K / Renesas RH850 上跑过,它说”等价”你不信。
  • 架构选型:hosted vs freestanding / 实时性 / 安全等级 → 不是 prompt 能解的,是工程权衡。
  • 理解 Sanitizer 误报与漏报的 trade-off:依赖代码上下文。
  • 决定 UB 是否”故意利用”(如 GCC 的 -fwrapv 抑制 signed overflow UB)——这是项目级政策。

工程师必须掌握的核心能力

  1. 能在 mental model 里运行 UB:知道 i++ + ++i 是 UB,知道 memcpy(p, p+1, n)n>0 时是 UB。
  2. 会读 Sanitizer 输出:ASan 的 stack trace、UBSan 的 trap reason → 比 AI 解释快 10 倍。
  3. 会选编译选项:知道 -O2 会激进优化、可能让 UB 优化成崩溃;知道 -O0 又会丢失真实性能信息。
  4. 理解 ABI 与可执行文件布局:ELF 段、栈帧、调用约定 → 这是 LLM 经常出错的领域。
  5. 质疑 AI 生成的代码:尤其涉及指针、内存、并发时。

五、实践行动项

行动 1: 用 -std=c17 + 全部 sanitizer 编译 hello world

mkdir -p /home/koh/dev/notes/effective-c-2020/samples/ch01
cd /home/koh/dev/notes/effective-c-2020/samples/ch01
cat > hello.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
int main(void) {
    if (puts("Hello, world!") == EOF) return EXIT_FAILURE;
    return EXIT_SUCCESS;
}
EOF
cc -std=c17 -Wall -Wextra -Werror -Wpedantic \
   -fsanitize=address,undefined -g -O1 \
   -o hello hello.c
./hello && echo "exit=$?"

验收exit=0、Sanitizer 无报告。

行动 2: 主动触发一个 UB,亲眼看看 ASan/UBSan 怎么报

cat > /tmp/mc.c <<'EOF'
#include <limits.h>
#include <stdio.h>
int main(void) { int x = INT_MAX; x = x + 1; printf("x = %d\n", x); }
EOF
cc -std=c17 -Wall -Wextra -fsanitize=undefined -g -O0 -o /tmp/ub /tmp/mc.c && /tmp/ub

观察 UBSan 输出 runtime error: signed integer overflow目的:把”UB 是模糊概念”转成”看得见的崩溃报告”

行动 3: 对比 puts vs printf("%s\n", s) 的代码大小与时间

下面命令需要在 Bash(Linux、macOS 或 WSL)中运行,并需要安装 C 编译器和 size。在 Windows PowerShell 中不能直接运行这段 Bash。

set -eu
 
CC="${CC:-cc}"
command -v "$CC" >/dev/null 2>&1 || {
    echo "找不到 C 编译器:$CC(可设置 CC=gcc 或 CC=clang)" >&2
    exit 1
}
command -v size >/dev/null 2>&1 || {
    echo "找不到 size 命令:请安装 binutils(macOS 通常已自带)" >&2
    exit 1
}
 
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/puts-vs-printf.XXXXXX")"
trap 'rm -rf "$tmpdir"' EXIT
 
cat > "$tmpdir/hello_puts.c" <<'EOF'
#include <stdio.h>
#include <stdlib.h>
int main(void) { if (puts("Hello, world!") == EOF) return EXIT_FAILURE; return EXIT_SUCCESS; }
EOF
 
cat > "$tmpdir/hello_printf.c" <<'EOF'
#include <stdio.h>
#include <stdlib.h>
int main(void) { if (printf("%s\n", "Hello, world!") < 0) return EXIT_FAILURE; return EXIT_SUCCESS; }
EOF
 
for f in puts printf; do
    "$CC" -std=c17 -Wall -Wextra -O2 \
        -o "$tmpdir/test_$f" "$tmpdir/hello_$f.c"
    printf '%s:\n' "$f"
    size "$tmpdir/test_$f"
done
 
echo "可选:使用 time 或 hyperfine 对两个可执行文件分别进行多次基准测试。"

size 输出的是可执行文件的 text/data/bss 等段大小,不等于运行时加载的全部代码;动态链接、编译器、优化级别和 C 运行库都会影响结果。因此,这个实验用于建立直觉,不应预先断言 printf 一定比 puts 大多少。

行动 4: 用 grep 在你现有项目里搜 format string 漏洞

# 反模式:把变量直接作为第一个参数
grep -rnE 'printf\(([a-z_][a-z0-9_]*[ \t]*[,)]' src/ 2>/dev/null | head

如果有命中 → 立刻修。理由:作者 Seacord 把它放在第 1 章,意味着这是从入门就该有的习惯。

行动 5: 装一遍 GCC/Clang 默认标准

# GCC 部分 (clang 部分取决于系统装没装; 没装就跳过)
echo "--- GCC 默认标准 ---"
gcc -dM -E - < /dev/null | grep -E "__STDC_VERSION__|__GNUC__"
echo "--- Clang (若装了) ---"
if command -v clang >/dev/null 2>&1; then
    clang -dM -E - < /dev/null | grep -E "__STDC_VERSION__|__clang_major__"
else
    echo "clang: command not found (apt install clang 即可补全)"
fi

看默认 __STDC_VERSION__ 是多少(很可能不是 201710L = C17)。这影响每个未来你写的 .c 文件。


六、值得深入思考的问题

Q1: 如果编译器可以利用 UB 做激进优化,那么”修复 UB”还是”加 __attribute__((no_sanitize_undefined)) 跳过”——哪个更专业?

涉及 trade-off:前者修根因但要重写逻辑;后者绕过检查但留隐患。思考:有没有第三种方案——重新审视是否需要那段 UB 代码?

Q2: 本书说”应该用最新编译器”,但嵌入式世界里很多 MCU 厂商 IDE 卡在 GCC 4.x / C99。“升级编译器”在生产环境是技术决定还是采购/合规决定?

如果 ISO 26262 要求工具链 TÜV 认证,编译器不能随便升。这本书的工程建议在汽车/航空场景如何打折扣?

Q3: C 标准允许 freestanding 与 hosted 两种世界观共存。这种”一个标准两种语义”的设计——是 C 的灵活性,还是历史包袱?

Rust 走 hosted-first 路线(no_std 是后期扩展)。如果当年 Ritchie 选择同样的策略,C 是否还能流行 50 年?

Q4: 假设你是一名嵌入式 BSP 工程师,要把第 1 章的 hello.c 跑到 Cortex-M4 上,最小改动是什么?最大改动是什么?

最小puts → UART 寄存器轮询;return EXIT_SUCCESS → 不返回(裸机是 while(1);)。最大:要重写 startup(设置栈指针、.data .bss 初始化)、链接脚本、时钟初始化。问题:这个改造里哪些是”语言层”,哪些是”工具链层”?

Q5: AI 已经能生成 95%+ 的样板 C 代码。对于一个嵌入式工程师,3 年后还需要读懂 K&R 第 1 章吗?还是说”会用 AI + 会看 Sanitizer 输出”就够了?

我的判断:仍然需要。但原因不是”AI 写不出对”,而是”AI 写错时你得能看出来”。这一章的 UB 与可移植性知识,是判断 AI 输出”对不对”的尺子。


下一章预告: Chapter 2 — Objects, Functions, and Types —— 用 swap 函数两版失败的例子,建立 object/pointer/call-by-value/reference 的 mental model。这是后续所有指针/数组/字符串讨论的基础。