第 1 章 · Getting Started with C
来源: Effective C (Seacord, 2020) — Chapter 1, pp. 1–11 笔记日期: 2026-08-27 六段式阅读框架: 概述 / Takeaways / 工程视角 / AI 视角 / 行动 / 思考
一、章节概述
- C 的定位:系统编程语言,靠近硬件,2020 年仍是 TIOBE 前两名;IoT/嵌入式/操作系统底层仍主要使用 C。
- “The Spirit of C” 五条信念:Trust the programmer / Don’t prevent / Keep small and simple / One way to do it / Make it fast。
- 标准的权威性:C Standard (ISO/IEC 9899) 是行为唯一权威;“implementation” = 编译器 + 标准库 + 命令行选项的特定组合。同一编译器换 flag 就是不同 implementation。
- C 标准的演进:C89 → C90 → C99 → C11 → C17/C18 → C2x(C23 已正式发布,本书未覆盖)。GCC 默认 gnu17。生产环境推荐显式
-std=c17而非默认。 - 第一个程序
hello.c的每一行:<stdio.h>提供puts,<stdlib.h>提供EXIT_SUCCESS;int main(void)声明入口;puts返回 int,EOF表示失败。 - 必须检查函数返回值:
puts失败返回EOF;printf失败返回负值;这是作者认为第一个程序”其实有 bug”的原因。return之后的代码是 dead code。 printf的格式化字符串漏洞:禁止把用户输入作为第一个参数(format string vulnerability);输出简单字符串用puts更安全。- 编译器选型:GCC / Clang / MSVC;嵌入式编译器很多仍只支持 C89/C90。Linux 默认 GCC,Windows MSVC 必须勾选
/TC(按 C 编译)而非/TP(按 C++)。 - 可移植性五类问题(Annex J):implementation-defined / unspecified / undefined / locale-specific / common extensions。
- 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 > x在INT_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>子集)。 - 为什么重要:嵌入式工程师读本书要心里清楚——很多例子(
puts、EXIT_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 三件套(编译期就铺好):
ASan 抓内存错(越界、UAF、double-free),UBSan 抓 UB。开发期永远开。cc -std=c17 -fsanitize=address,undefined -g -O1 hello.c - MSVC 的
/TCvs/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 world | printf("...") | puts("...") + 检查返回值 + 走 CI |
| 编译命令 | cc hello.c | cc -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)——这是项目级政策。
工程师必须掌握的核心能力
- 能在 mental model 里运行 UB:知道
i++ + ++i是 UB,知道memcpy(p, p+1, n)当n>0时是 UB。 - 会读 Sanitizer 输出:ASan 的 stack trace、UBSan 的 trap reason → 比 AI 解释快 10 倍。
- 会选编译选项:知道
-O2会激进优化、可能让 UB 优化成崩溃;知道-O0又会丢失真实性能信息。 - 理解 ABI 与可执行文件布局:ELF 段、栈帧、调用约定 → 这是 LLM 经常出错的领域。
- 质疑 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。这是后续所有指针/数组/字符串讨论的基础。