C1 · C EXECUTION MODEL
先把 C 程序看成数据与控制流
进入编译与链接之前,读者必须能读懂一个小型 C 程序:值存在哪里、语句何时执行、函数怎样交接数据、无限循环为什么在 MCU 上合理。
C1.1 固定宽度整数与范围
嵌入式程序经常把整数写入寄存器、协议帧或持久化结构,宽度必须明确。优先使用 uint8_t、uint16_t、uint32_t 和对应有符号类型;计数和长度使用能覆盖实际上界的类型。不要假定 int 永远是某个宽度,也不要忽略无符号下溢。
C · INTEGER CONTRACT#include <stdint.h> uint32_t period_ms = 500u; uint8_t frame[4] = {0x55u, 0x02u, 0x01u, 0xA8u};
C1.2 分支、循环与 MCU 主循环
if 决定当前输入触发哪条路径,for 适合有限次数处理,while (1) 表达固件持续提供服务。无限循环不是失控;没有阻塞边界、看门狗服务和退出到低功耗的无限循环才危险。
C1.3 函数合同
函数名不够说明行为。为每个函数写清输入范围、返回值、会修改的状态、最长执行时间和能否在中断中调用。void led_write(bool on) 隐藏引脚极性,调用者不应直接猜寄存器。
C1.4 从翻译单元到执行路径
C 文件不是按文件名直接运行的。预处理器先展开 #include 和宏,每个 .c 文件形成一个翻译单元,编译器为它生成目标代码,链接器再把多个目标文件与启动代码合成固件。读者在看函数时要同时问两个问题:这个名字在哪个翻译单元定义,运行时它访问的是哪个对象。只看 main() 的文本,容易漏掉启动文件、链接脚本和中断入口。
在主机练习中,main() 可以返回操作系统;在 MCU 中,启动代码完成栈、数据段和时钟的准备后,main() 通常进入长期循环。这个差异不是“C 语法在嵌入式里变了”,而是执行环境的边界不同。函数合同因此要写清楚是否会阻塞、是否依赖时钟、是否允许在中断上下文调用。
C · TWO TRANSLATION UNITS/* led.h */ #ifndef LED_H #define LED_H void led_toggle(void); #endif /* led.c */ #include "led.h" static unsigned state; void led_toggle(void) { state ^= 1u; } /* main.c */ #include "led.h" int main(void) { for (;;) led_toggle(); }
在上面的片段中标出公开接口、私有状态、调用者和可能永远不返回的位置。再写出如果删除 led.c,编译阶段和链接阶段分别会发生什么。
C1.5 未定义行为:编译成功也可能没有答案
编译器接受一段 C 代码,不等于程序对所有输入都有定义。越界访问、使用已结束生命周期的对象、带符号整数溢出、错误的移位宽度、解引用无效指针和违反有效类型规则,都可能进入未定义行为。优化器可以假设这些情况不会发生,于是“在调试版看起来正常”的代码在高优化或换工具链后改变结果。
嵌入式系统更需要把边界写出来,因为一次未定义行为可能破坏返回地址、任务栈、外设配置或下一次中断。初学阶段不要求记住标准的每个条款,但要养成三步:先说对象和范围,再给非法输入一个处理路径,最后用警告、主机测试或 sanitizer 捕获它。没有证据时不要把偶然输出解释成稳定语义。
在主机小程序里先写下你认为应该发生的结果,再启用编译器警告和 AddressSanitizer 运行。修复方式只能是加入容量合同或拒绝输入;不要通过“多申请一个字节”掩盖调用者仍然传错长度的问题。
C1.6 主机与目标机:同一 C 代码的不同证据
主机编译适合练习纯函数、协议解析、边界检查和状态机,因为输出容易自动比较;它不能证明 GPIO 复用、时钟树、DMA、IRQ 延迟、供电、电平或真实串口已经正确。目标机实验则需要工具链、启动文件、链接脚本、烧录器和观察设备。把两种路径并列记录,读者才能知道哪些结论来自模型,哪些来自硬件。
| 问题 | 主机测试 | 目标机测试 |
|---|---|---|
| 整数/数组边界 | 适合自动覆盖 | 可验证但成本更高 |
| 寄存器读写 | 只能用替身或模型 | 需看芯片与电气结果 |
| 时序/功耗 | 只能预测 | 需真实时钟与仪器 |
| 串口文本 | 可测解析器 | 需确认引脚、波特率与线缆 |
C2 · OBJECTS AND MEMORY
数组、指针、生命周期与边界
嵌入式 C 的大部分严重故障都与对象边界、生命周期或共享访问有关。先学会画对象,再谈寄存器和驱动。
C2.1 数组不是自动携带长度
函数参数中的数组通常退化为指针,接收方不知道元素数量。传递缓冲区时同时传长度,并在写入前检查容量。字符串还需要为终止字符保留空间。
C · BUFFER CONTRACTbool frame_copy(uint8_t *dst, size_t capacity, const uint8_t *src, size_t length) { if (!dst || !src || length > capacity) return false; for (size_t i = 0; i < length; ++i) dst[i] = src[i]; return true; }
C2.2 指针先回答三个问题
每次看到指针,先问它指向什么类型、有效对象有多长、有效期到何时。空指针检查只能排除地址为零,不能证明地址仍有效或长度足够。局部数组返回后生命周期结束,指向它的指针立即失效。
C2.3 栈、静态区与堆
局部自动对象通常位于栈,静态对象跨函数调用保留。许多小 MCU 项目避免在长期运行路径动态分配,因为碎片、失败处理和执行时间难以界定。这里不是宣布“堆永远不能用”,而是要求分配策略和失败证据。
C2.4 固定宽度、转换与常量的边界
嵌入式接口把整数送进寄存器和线上的字节,因此要同时关心数值范围和表达式类型。uint8_t 表示对象恰好能容纳 8 位无符号值,但参与运算时它可能提升为 int;把有符号值与无符号值混算,也可能让一个负数变成很大的正数。长度、索引、超时和错误码不应因为“看起来都是整数”就使用同一种类型。
常量要表达单位和意图:500u 只是一个无单位的数,LED_PERIOD_MS 才让调用者知道它是毫秒。对协议字段使用显式转换前,先判断转换是否可逆;如果不能覆盖全部输入,就在转换前拒绝或饱和,而不是让窄类型静默截断。
C · CONVERSION CHECK#include <stdint.h> #include <stdbool.h> bool to_u8(uint32_t value, uint8_t *out) { if (out == 0 || value > UINT8_MAX) return false; *out = (uint8_t)value; return true; }
分别为“LED 翻转次数”“UART 接收字节”“错误状态”选择类型,并写出最大值、单位和越界策略。不要只写 int;若选择固定宽度类型,也说明为什么它和协议/硬件边界相符。
C2.5 enum、bool 与 const:让意图显形
有限集合用 enum,真假条件用 bool,不会在运行时改变的表和配置用 const。它们不会自动阻止所有错误,但能让编译器、审查者和测试看到你的意图。把状态写成 0/1/2,读者无法知道 2 是故障、忙还是已完成;把配置写成可写全局变量,任何模块都可能在没有证据的情况下改变它。
对来自 UART、传感器或 Flash 的数值,不要直接强转为枚举后就使用。先检查是否落在已知集合,再进入状态机;未知值应进入安全分支并留下日志。对于布尔字段也要注意协议中的非零值是否都表示真,不能把“字节等于 1”与“逻辑为真”混为一谈。
C2.6 宏、sizeof 与静态断言
宏只是预处理文本替换,参数和整体表达式都要加括号;带副作用的参数不能被宏重复求值。sizeof 在数组仍处于定义作用域时才能得到整个数组大小,传入函数后通常只得到指针大小。对协议结构、寄存器字段和缓冲区,优先用命名常量、内联函数或 _Static_assert 让假设在编译期失败。
C · COMPILE-TIME CONTRACT#include <stdint.h> #define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0])) _Static_assert(sizeof(uint32_t) == 4, "uint32_t must be 32 bits"); static inline uint32_t set_mask(uint32_t value, uint32_t mask) { return value | mask; }
ARRAY_LEN 不能用于已经退化成指针的函数参数;这正是接口应同时接收长度的原因。
C3 · BITS AND REGISTERS
位运算、寄存器与 volatile
寄存器编程不是背十六进制。它是对一个有位字段合同的共享对象进行可审查修改,并区分软件缓存值与硬件当前状态。
C3.1 掩码、置位与清位
| 置位,& ~mask 清位,^ 翻转,移位用于把字段放到规定位置。宏参数要加括号,移位宽度要落在类型范围内。
C · BIT FIELD#define BIT(n) (UINT32_C(1) << (n)) #define FIELD_MASK (UINT32_C(3) << 4) reg = (reg & ~FIELD_MASK) | (UINT32_C(2) << 4);
C3.2 volatile 只解决可见访问
volatile 告诉编译器每次都执行该访问,适用于内存映射寄存器或会被中断异步改变的简单对象。它不提供原子性、互斥、顺序保证或线程安全。共享的读改写仍可能被中断打断。
C3.3 先查手册再写寄存器
写寄存器前核对基地址、偏移、位字段、复位值、可写性和修改顺序。有些状态位写 1 清除,按普通读改写会误清其他事件;有些外设要求先开总线时钟。代码中的常量应能回溯到当前版本参考手册。
C3.4 指针合同:地址、长度、生命周期
指针本身只是一段地址信息,不携带“对象有多大、由谁拥有、何时失效”。设计函数接口时,把这三件事写到参数命名或文档里:输入缓冲区是只读还是可写,长度由谁计算,输出是否允许与输入重叠,返回后调用者是否仍拥有内存。NULL 检查只处理一种错误,不能证明容量和生命周期。
C · EXPLICIT BUFFER APIbool parse_frame(const uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_cap, size_t *written) { if (!src || !dst || !written || src_len < 2u) return false; if (src[0] != 0x55u || src_len - 2u > dst_cap) return false; for (size_t i = 0; i < src_len - 2u; ++i) dst[i] = src[i + 1u]; *written = src_len - 2u; return true; }
不要运行前就写“解析成功”。请列出空指针、长度不足、容量不足和帧头错误四条输入,分别写出函数返回值、输出缓冲区是否允许改变以及应该记录的错误标签。
C3.5 对齐、别名与 memcpy:不要把字节硬转成结构体
协议帧是字节序列,不一定满足目标 CPU 对齐要求,也不一定与 C 结构体的填充布局相同。把 uint8_t* 直接强转成结构体指针,可能造成未对齐访问、严格别名违规、端序错误或读到编译器插入的填充字节。稳妥的边界做法是先逐字段解码,或复制到明确对齐的对象后再读取,并用静态断言和测试锁定布局。
C · BYTEWISE DECODEuint16_t read_le16(const uint8_t bytes[2]) { return (uint16_t)bytes[0] | ((uint16_t)bytes[1] << 8); } void encode_le16(uint8_t bytes[2], uint16_t value) { bytes[0] = (uint8_t)(value & 0xffu); bytes[1] = (uint8_t)(value >> 8); }
memcpy 可以安全复制对象表示,但不会自动解决端序、长度和协议合法性;复制后仍要按字段合同解释数据。
C3.6 栈、静态区、堆与所有权
局部数组的生命周期通常只到函数返回,静态对象跨调用保留,堆对象则需要明确的分配者和释放者。固件长期运行时,动态分配还会带来碎片、失败路径和不可预测的耗时;如果确实使用堆,要规定容量、释放时机、最大调用次数和失败后的安全状态。不要把“当前测试没有崩”当成所有权合同。
| 区域 | 适合 | 必须写清 |
|---|---|---|
| 栈 | 短生命周期、小对象 | 最大深度、不能返回地址 |
| 静态区 | 长期状态、只读配置 | 初始化、并发访问、复位行为 |
| 堆 | 确有动态需求的对象 | 拥有者、容量、释放和失败 |
C3.7 寄存器字段是一个共享对象
寄存器读改写前先查访问属性:只读、只写、读清除、写 1 清除、保留位和写入顺序都可能不同。对普通 RAM,reg = (reg & ~mask) | value 是常见模式;对硬件寄存器,它只有在手册允许读改写且没有并发修改时才成立。把复位值写进代码不是为了好看,而是为了让初始化前后的差异可以解释。
| 字段属性 | 常见风险 | 应采取的动作 |
|---|---|---|
| 保留位 | 写入非零改变未来行为 | 按复位值保留或使用专用寄存器 |
| 写 1 清除 | 读改写误清其他事件 | 按手册写入单独掩码 |
| 读清除 | 调试打印改变状态 | 记录访问副作用 |
| 中断共享 | 读改写被打断 | 临界区或原子寄存器操作 |
C3.8 端序与位域:协议先于编译器布局
C 的位域布局、结构体填充和端序不能直接当作线上的协议定义。协议应先写字节顺序、字段宽度、缩放和校验,再用移位与掩码实现。位编号从 0 开始还是从最高位开始,也要在资料卡里说清楚;否则“设置第 3 位”可能在两端表示不同字段。
C · MASKED FIELD#define MODE_MASK (UINT32_C(0x7) << 8) #define MODE_SHIFT 8u uint32_t with_mode(uint32_t reg, uint32_t mode) { if (mode > 7u) return reg; /* reject invalid field */ return (reg & ~MODE_MASK) | (mode << MODE_SHIFT); }
为字段定义掩码、移位和合法范围,列出输入 0、7、8 时的预期结果。再说明如果寄存器是“写 1 清除”类型,为什么不能直接复用这个读改写函数。
C3.9 volatile、原子性和临界区的分界
volatile 只要求编译器不要把一次硬件访问或异步标志的访问随意消掉,它不提供复合操作的原子性,也不提供互斥和跨核同步。中断与主循环共享一个计数器时,读、改、写可能被打断;RTOS 任务共享队列时,还需要由操作系统提供锁、通知或原子操作。把 volatile 当作“线程安全开关”会让竞态更难发现。
判断是否需要临界区要从对象和事件出发:谁会修改它、修改多长时间、读者是否能接受旧值、硬件是否有原子置位/清位寄存器。临界区也有成本,会影响中断延迟和低功耗;应记录最大持续时间,而不是到处包上一层关中断。
C4 · SMALL FIRMWARE DESIGN
用 enum、struct 和模块组织固件
真实固件不能把所有全局变量塞进 main.c。初学阶段就应学习把状态、配置和依赖变成显式对象。
C4.1 enum 表达有限状态
状态机的状态和事件应使用枚举,默认分支进入安全状态或记录未知输入。不要用散落的 0、1、2 表示业务状态。
C · STATE MACHINEtypedef enum { LED_IDLE, LED_ON, LED_FAULT } led_state_t; led_state_t step(led_state_t state, bool pressed) { if (state == LED_FAULT) return LED_FAULT; if (pressed) return state == LED_ON ? LED_IDLE : LED_ON; return state; }
C4.2 struct 把相关字段放在一起
配置、状态和统计应是不同结构体。配置通常可声明为 const;运行状态由拥有它的模块修改;统计字段只用于观察,不反向控制核心行为。这样测试能构造明确输入。
C4.3 .h 是合同,.c 是实现
头文件公开类型、常量和函数声明;源文件保留私有状态与实现。头文件要有 include guard,避免定义可写全局对象。每个模块只依赖它真正需要的接口,后续第 2 章才能解释编译单元和链接符号。
C4.4 用依赖注入隔离硬件接口
把硬件调用直接写进状态机,会让主机测试不得不模拟整个 MCU。更小的做法是定义一个窄接口,把“读取按键、写 LED、取得时间、发送文本”作为依赖传入;目标机提供 HAL 实现,主机测试提供记录调用的替身。这样同一份状态逻辑可以在没有开发板时验证,而硬件接线仍由目标机工程单独验收。
C · NARROW HAL INTERFACEtypedef struct { bool (*button_read)(void *ctx); void (*led_write)(void *ctx, bool on); uint32_t (*now_ms)(void *ctx); void *ctx; } io_port_t; void app_step(io_port_t *io) { if (!io || !io->button_read || !io->led_write) return; io->led_write(io->ctx, io->button_read(io->ctx)); }
写一个最小替身:每次读取按键时递增计数,每次写 LED 时保存最后状态。运行 app_step 后检查调用次数和状态,不需要伪造 GPIO 寄存器。再写一句说明这项测试仍不能证明 PA5 的电平真的改变。
C4.5 状态机的状态、事件与不变量
一个可维护的状态机至少区分状态、事件、动作和不变量。比如按键控制 LED 时,pressed 是输入事件,LED_ON/LED_OFF 是状态,写 GPIO 是动作,“故障状态不得自动回到正常”是不变量。把这些概念混在一个长 if 中,读者无法知道哪个分支负责恢复、哪个分支负责安全停机。
| 当前状态 | 事件 | 预期下一状态 | 可观察动作 |
|---|---|---|---|
| OFF | 按下 | ON | 写 LED=1 |
| ON | 按下 | OFF | 写 LED=0 |
| 任意 | 未知事件 | 保持或故障 | 记录错误 |
| FAULT | 普通事件 | FAULT | 不自动恢复 |
C4.6 错误传播与可观测性
返回 bool 只是开始。调用者还要知道失败发生在参数检查、硬件访问、超时还是协议校验;必要时使用枚举错误码并携带计数、时间戳或最近一次状态。错误信息不能在中断中随意打印,也不能为了“日志完整”阻塞实时路径;可以先写入有界环形缓冲区,再由低优先级任务输出。
错误路径也有合同:资源不足时是否保持旧配置,通信超时时是否重试,连续失败几次后是否进入 FAULT,复位后哪些统计被清零。把错误当作普通控制流来设计,后续第 9 章的设备协议和第 11 章的故障注入才有可测试的基础。
C5 · FROM HOST TO TARGET
桥梁练习:只改一个变量再上板
先在可观察的小函数上练习,再修改 NUCLEO‑G071RB 的 LED/UART 工程。每一步都规定输入、输出、失败和停止条件。
C5.1 练习一:预测再运行
把 500 ms 周期分别改为 100 ms 和 1000 ms,先写下 10 秒内预计翻转次数,再构建、烧录并观察。只改周期,不同时改时钟和串口。记录预测、实测与误差来源。
C5.2 练习二:增加一个有界计数器
每次 LED 点亮时增加 uint32_t 计数,并通过 UART 输出。说明复位后计数为何回到零;再指出若要求掉电保留,当前变量为什么不够。
C5.3 练习三:制造并修复一个边界错误
在主机小程序中创建 4 字节数组,尝试复制 5 字节。先让编译器警告和测试捕获错误,再加入容量检查。不要在目标板上用越界写入做“观察实验”。
C5.4 进入第 2 章的门槛
五项中任何一项说不清,就回到对应小节完成一个最小例子;全部通过后再进入预处理、编译、汇编和链接。
C5.5 主机编译:先让纯 C 可重复
在上板前,用主机编译器把不依赖寄存器的 C 逻辑单独编译,打开警告并把测试输出保存下来。主机编译器不是目标编译器的替代品,而是快速发现边界、未使用返回值和接口不一致的工具。遇到目标专有头文件时,把它隔离在 HAL 层,不要为了让主机通过而复制一套“看起来像寄存器”的业务代码。
BASH / POWERSHELL · HOST CHECKcc -std=c11 -Wall -Wextra -Wconversion -Wshadow -pedantic -fsanitize=address,undefined -g host_model.c app_logic.c -o host_model ./host_model # Windows MSYS2/LLVM 可用 clang 替换 cc
如果平台没有 sanitizer,保留警告和边界测试仍有价值;在报告中写明哪些检查没有启用。构建命令、编译器版本和预期输出一起保存,另一个读者才能复跑。
C5.6 把编译器警告变成修复线索
-Wall 并不等于“所有警告”,不同编译器和版本还会提供额外选项。初学阶段先处理未使用结果、隐式转换、格式化参数不匹配、可能未初始化和常量截断,再决定是否为某一条警告写出有依据的豁免。不要用全局关闭警告来换取干净的终端;警告是编译阶段的证据,不是噪音。
目标编译还要加入芯片型号、浮点/ABI、链接脚本、启动文件和优化级别。主机通过而目标链接失败,通常不是 C 逻辑突然错误,而是目标依赖没有被明确列出;反过来,目标能链接也不代表主机边界测试已经覆盖。
C5.7 编译、链接与内存地图的接力
预处理器决定“看见了什么”,编译器决定“每个翻译单元如何生成代码”,汇编器生成目标文件,链接器决定“这些代码和数据放到哪里”。启动文件提供入口和异常向量,链接脚本提供 Flash/RAM 的区域边界。读者进入第 2 章时,应能沿着一个全局符号从声明、定义、目标文件一直追到最终地址,而不是把 ELF 当成黑盒。
将“找不到头文件”“重复定义”“未定义引用”“RAM 超限”“烧录后 LED 不亮”分别放入预处理/编译、链接或目标机阶段,并为每项写出第一份应该查看的证据。
C5.8 桥梁结业:带着一份可交接记录进入第 2 章
完成本桥梁不要求背完 C 标准,而要求能把代码读成一组可检查的合同:对象是什么、长度和生命周期是什么、位字段如何解释、谁拥有共享状态、错误怎样返回、哪一份证据来自主机、哪一份必须上板。下一章会把这些对象放进编译单元、符号、段和地址;如果今天的接口仍靠“大家都知道”维持,链接器只会把隐含假设放大。
请保存一份自己的结业记录:主机编译命令与版本、至少一个边界测试输出、一个状态机矩阵、一个硬件接口替身、一次单变量修改,以及尚未在目标机验证的清单。记录不需要漂亮,但必须能让另一个读者从同一源码和同一命令继续。
六句话分别说明:输入对象与范围、核心机制、预期输出、实际输出、一个失败如何定位、结论没有覆盖什么。若没有开发板,把目标机字段写为“未执行”;若有开发板,附上板卡、工具、构建、烧录和观察条件。
C6 · TYPES AND INTERFACES
让类型成为硬件接口的护栏
固定宽度、转换规则和编译期检查把隐含假设变成可读的接口。本单元把“能编译”推进到“边界能解释”。
C6.1 协议字段与单位
为每一个来自硬件或通信线的字段写出宽度、端序、单位、缩放和合法范围。温度可能是摄氏度的十分之一,超时可能是毫秒,ADC 原始码可能需要参考电压才能转成物理量;如果代码只留下一个 int,这些语义会在调用者之间丢失。
C6.2 转换前检查,转换后断言
窄化转换前先比较范围,乘法和加法前先判断是否会溢出;对编译期已知的布局用 _Static_assert。断言失败时要有安全策略,例如拒绝帧、保持旧配置或进入故障状态,而不是继续把截断值写入寄存器。
C · RANGE-FIRSTbool scale_to_u16(uint32_t raw, uint16_t *out) { if (!out || raw > 4095u) return false; *out = (uint16_t)raw; return true; }
把一个“timeout”变量改成带单位的命名与类型,写出允许范围、超时后的状态和日志字段。不要接受“读者应该知道单位”作为合同。
C6.3 接口审查表
| 问题 | 合格答案的形状 |
|---|---|
| 输入能否为空? | 明确允许/拒绝和返回码 |
| 长度谁拥有? | 参数、命名和生命周期一致 |
| 输出何时有效? | 成功返回前已写入,失败不承诺 |
| 会阻塞多久? | 有上限或明确禁止的上下文 |
C6.4 整数提升与 API 的可读性
表达式中的整数提升会让 uint8_t 先变成 int,再参与加法、比较或移位;这并不表示所有结果都自动安全。函数接口应在边界处完成一次明确的转换和检查,内部计算使用足够宽的类型,最后再按合同写回寄存器或缓冲区。把转换散落在业务代码里,审查者很难判断哪个截断是有意的。
在自己的 C 代码中找一个赋值或函数调用,让宽类型进入窄类型;写出输入最大值、转换前检查和失败返回。若无法找到,构造一个 32 位计数转 8 位协议字段的例子。
C7 · VOLATILE AND CONCURRENCY
知道何时需要 volatile,何时远远不够
硬件寄存器、ISR 标志和 RTOS 共享数据看起来都在“异步变化”,但它们需要的同步语义不同。先区分对象,再选择工具。
C7.1 三个常见 volatile 场景
内存映射寄存器、由中断置位的简单标志、由外部硬件改变的状态寄存器,是最常见的 volatile 场景。它保证编译器不会把一次读取缓存成永远不变的值,却不保证一次读改写不可分割,也不保证多核或 DMA 与 CPU 的缓存一致性。使用前写出“谁在什么时候改变它”。
C7.2 中断共享对象的最小协议
若 ISR 只置位一个标志、主循环只清除它,可以用明确的单向协议;若双方都修改计数器或队列,就需要原子操作、短临界区或专门的数据结构。临界区要有最大时长和嵌套规则,不能在其中打印、等待串口或调用未知阻塞函数。
C · ISR FLAG CONTRACTstatic volatile bool rx_ready; void USART2_IRQHandler(void) { /* read hardware status, copy one bounded byte */ rx_ready = true; } bool app_take_rx_ready(void) { bool ready = rx_ready; rx_ready = false; return ready; }
这段示例只适用于“一个写者、一个读者、标志不携带计数”的简化合同;真实接收缓冲区还需要容量、溢出和并发访问策略。
C7.3 RTOS、DMA 与内存屏障的边界
进入 RTOS 后,任务同步优先使用队列、信号量、事件组或任务通知;进入 DMA 后,还要按芯片文档处理缓存、对齐和完成顺序。编译器屏障、CPU 内存屏障和互斥锁解决的是不同层次的问题。若说不清“哪个执行单元、哪块内存、哪个顺序”需要保证,就先不要增加关键字。
选择一个“中断通知主循环”的布尔变量,写出写者、读者、清除时机、丢失事件是否可接受和满载时怎么办。若事件不可丢失,说明为什么单个 volatile bool 不够。
C7.4 事件是否会丢:bool、计数还是队列
一个布尔标志只能表达“至少发生过一次”,无法表达发生了多少次,也不能保存事件携带的数据。如果 UART 接收、定时器 tick 或 DMA 完成事件不可丢失,就要用计数器、环形缓冲区或 RTOS 队列,并为满载、溢出和清空顺序写合同。选择数据结构前先回答“丢失一个事件是否安全”。
| 方式 | 保留的信息 | 典型限制 |
|---|---|---|
| bool | 发生过/未发生 | 重复事件会合并 |
| 计数器 | 发生次数 | 溢出与并发读改写 |
| 环形缓冲区 | 事件顺序和数据 | 容量、满载策略 |
| RTOS 队列 | 带同步的消息 | 调度、阻塞和优先级 |
C8 · COMPILE AND HANDOFF
把 C 桥梁交给编译器与链接器
最后一单元把类型、内存、位运算和模块合同汇总成可运行的主机检查,并明确它与第 2 章、目标机工程的交接边界。
C8.1 最小构建脚本与可重复输出
一个可交接的 C 小工程应包含源文件、头文件、构建命令、编译器版本、警告策略、测试输入和预期输出。命令要从工程根目录运行,不能依赖作者的全局变量或 IDE 最近打开的配置。第一次运行保存完整输出,第二次在删除产物后运行,用来检查构建是否真正声明了依赖。
MAKE-LIKE COMMANDScc -std=c11 -Wall -Wextra -Wconversion -Wshadow -pedantic -g app.c -o app ./app echo exit=$?
目标工程还会增加交叉编译器、启动文件和链接脚本;这不是把上面的 cc 换成一串更长的命令就结束,而是要在第 2 章追踪每个输入如何影响段与地址。
C8.2 主机测试清单
C8.3 进入第 2 章的交接包
把本页产物交给第 2 章时,最有价值的不是一份更长的语法笔记,而是三张可以继续追踪的表:对象与所有权表、字段与范围表、构建阶段与证据表。第 2 章会在此基础上解释预处理、编译、汇编、链接、符号和内存段;如果某个字段仍然只有“经验值”,就在交接包里明确标记。
C8.4 链接错误的最小诊断顺序
遇到 undefined reference,先检查声明与定义的拼写和链接语言,再检查实现文件是否加入目标列表,最后查看符号是否被 static 隐藏;不要先改链接脚本。遇到重复定义,找出哪个头文件误放了可写对象;遇到段溢出,查看 MAP 中最大的对象和链接区域,而不是盲目降低优化级别。
| 消息 | 第一份证据 | 不要先做 |
|---|---|---|
| header not found | 编译命令与 include 路径 | 复制作者绝对路径 |
| multiple definition | 目标文件和符号表 | 全局改成 static |
| undefined reference | 实现文件与链接列表 | 乱加库文件 |
| region overflowed | MAP 的段和区域边界 | 删除功能掩盖问题 |
任选一条消息,写出重现命令、首个证据、最小假设、下一步动作和仍未验证的结论。交接记录应让同伴知道你没有同时更换源码、编译器和链接脚本。