← 返回嵌入式学习资料预备单元 C · 第 2 章之前

CHAPTER 1 · ELECTRICAL FOUNDATIONS

电压、逻辑电平
信号与测量

从官方开发板、USB 供电和低压接口开始,学习如何在动手前确认安全边界,在读数后说明测量条件,并把主机模型与真实板卡的证据明确分开。

6 个一级单元51 个递进小节主机路径可复现

TEXTBOOK MAP

CHAPTER 1 · MAP

从安全边界到测量证据

六个一级单元对应六种认知任务:确认对象、理解电量、判断逻辑、记录信号、运行实验、完成交付。

先把现实说清楚
  • 01 安全边界与停止条件
  • 02 电压、电流、电阻、功率
再把信号变成证据
  • 03 逻辑阈值与浮空输入
  • 04 波形、时间与仪器
最后交付可复查路径
  • 05 主机实验与目标机边界
  • 06 资料卡、记录与交付

小节数量按认知任务变化:安全、信号和交付需要更多递进步骤;每个单元都保留无硬件替代路径。

本页位置:学完第 1 章的电气与测量后,先用本桥梁补齐真正会在固件中使用的 C,再进入第 2 章的编译与链接。不是语法速查表,而是从对象、边界和硬件访问出发的阅读与练习路径。

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(); }
CHECKPOINT C1.4指出每个名字的拥有者

在上面的片段中标出公开接口、私有状态、调用者和可能永远不返回的位置。再写出如果删除 led.c,编译阶段和链接阶段分别会发生什么。

C1.5 未定义行为:编译成功也可能没有答案

编译器接受一段 C 代码,不等于程序对所有输入都有定义。越界访问、使用已结束生命周期的对象、带符号整数溢出、错误的移位宽度、解引用无效指针和违反有效类型规则,都可能进入未定义行为。优化器可以假设这些情况不会发生,于是“在调试版看起来正常”的代码在高优化或换工具链后改变结果。

嵌入式系统更需要把边界写出来,因为一次未定义行为可能破坏返回地址、任务栈、外设配置或下一次中断。初学阶段不要求记住标准的每个条款,但要养成三步:先说对象和范围,再给非法输入一个处理路径,最后用警告、主机测试或 sanitizer 捕获它。没有证据时不要把偶然输出解释成稳定语义。

动手练习 · 先预测再捕获给 4 字节缓冲区传入长度 5

在主机小程序里先写下你认为应该发生的结果,再启用编译器警告和 AddressSanitizer 运行。修复方式只能是加入容量合同或拒绝输入;不要通过“多申请一个字节”掩盖调用者仍然传错长度的问题。

C1.6 主机与目标机:同一 C 代码的不同证据

主机编译适合练习纯函数、协议解析、边界检查和状态机,因为输出容易自动比较;它不能证明 GPIO 复用、时钟树、DMA、IRQ 延迟、供电、电平或真实串口已经正确。目标机实验则需要工具链、启动文件、链接脚本、烧录器和观察设备。把两种路径并列记录,读者才能知道哪些结论来自模型,哪些来自硬件。

表 C1.3 · 两种运行环境各自能证明什么
问题主机测试目标机测试
整数/数组边界适合自动覆盖可验证但成本更高
寄存器读写只能用替身或模型需看芯片与电气结果
时序/功耗只能预测需真实时钟与仪器
串口文本可测解析器需确认引脚、波特率与线缆

C2 · OBJECTS AND MEMORY

数组、指针、生命周期与边界

嵌入式 C 的大部分严重故障都与对象边界、生命周期或共享访问有关。先学会画对象,再谈寄存器和驱动。

先确认对象、版本和安全边界。
再运行命令、代码和可观察输出。
后解释失败位置与尚未证明的事实。

C2.1 数组不是自动携带长度

函数参数中的数组通常退化为指针,接收方不知道元素数量。传递缓冲区时同时传长度,并在写入前检查容量。字符串还需要为终止字符保留空间。

C · BUFFER CONTRACT
bool 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; }
CHECKPOINT C2.4为三个字段选择类型

分别为“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 API
bool 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; }
动手练习 · 边界表为 parse_frame 列出四个拒绝条件

不要运行前就写“解析成功”。请列出空指针、长度不足、容量不足和帧头错误四条输入,分别写出函数返回值、输出缓冲区是否允许改变以及应该记录的错误标签。

C3.5 对齐、别名与 memcpy:不要把字节硬转成结构体

协议帧是字节序列,不一定满足目标 CPU 对齐要求,也不一定与 C 结构体的填充布局相同。把 uint8_t* 直接强转成结构体指针,可能造成未对齐访问、严格别名违规、端序错误或读到编译器插入的填充字节。稳妥的边界做法是先逐字段解码,或复制到明确对齐的对象后再读取,并用静态断言和测试锁定布局。

C · BYTEWISE DECODE
uint16_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.1 · 存储区域选择问题
区域适合必须写清
栈短生命周期、小对象最大深度、不能返回地址
静态区长期状态、只读配置初始化、并发访问、复位行为
堆确有动态需求的对象拥有者、容量、释放和失败

C3.7 寄存器字段是一个共享对象

寄存器读改写前先查访问属性:只读、只写、读清除、写 1 清除、保留位和写入顺序都可能不同。对普通 RAM,reg = (reg & ~mask) | value 是常见模式;对硬件寄存器,它只有在手册允许读改写且没有并发修改时才成立。把复位值写进代码不是为了好看,而是为了让初始化前后的差异可以解释。

表 C4.1 · 位字段写入前的检查
字段属性常见风险应采取的动作
保留位写入非零改变未来行为按复位值保留或使用专用寄存器
写 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); }
CHECKPOINT C4.3把一个 3 位字段写成可测试函数

为字段定义掩码、移位和合法范围,列出输入 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 MACHINE
typedef 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 INTERFACE
typedef 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)); }
动手练习 · 主机替身记录一次 button_read 和 led_write

写一个最小替身:每次读取按键时递增计数,每次写 LED 时保存最后状态。运行 app_step 后检查调用次数和状态,不需要伪造 GPIO 寄存器。再写一句说明这项测试仍不能证明 PA5 的电平真的改变。

C4.5 状态机的状态、事件与不变量

一个可维护的状态机至少区分状态、事件、动作和不变量。比如按键控制 LED 时,pressed 是输入事件,LED_ON/LED_OFF 是状态,写 GPIO 是动作,“故障状态不得自动回到正常”是不变量。把这些概念混在一个长 if 中,读者无法知道哪个分支负责恢复、哪个分支负责安全停机。

表 C5.1 · 状态机测试矩阵
当前状态事件预期下一状态可观察动作
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 CHECK
cc -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 当成黑盒。

图 C5.3 · 一次固件构建的证据链阶段不同,失败语言也不同。
预处理头文件与宏
编译/汇编目标文件与警告
链接符号、段、地址
烧录目标机 Flash 与复位
CHECKPOINT C5.3把一个错误放回正确阶段

将“找不到头文件”“重复定义”“未定义引用”“RAM 超限”“烧录后 LED 不亮”分别放入预处理/编译、链接或目标机阶段,并为每项写出第一份应该查看的证据。

C5.8 桥梁结业:带着一份可交接记录进入第 2 章

完成本桥梁不要求背完 C 标准,而要求能把代码读成一组可检查的合同:对象是什么、长度和生命周期是什么、位字段如何解释、谁拥有共享状态、错误怎样返回、哪一份证据来自主机、哪一份必须上板。下一章会把这些对象放进编译单元、符号、段和地址;如果今天的接口仍靠“大家都知道”维持,链接器只会把隐含假设放大。

请保存一份自己的结业记录:主机编译命令与版本、至少一个边界测试输出、一个状态机矩阵、一个硬件接口替身、一次单变量修改,以及尚未在目标机验证的清单。记录不需要漂亮,但必须能让另一个读者从同一源码和同一命令继续。

FINAL CHECKPOINT C5.4用六句话交接你的 C 桥梁结果

六句话分别说明:输入对象与范围、核心机制、预期输出、实际输出、一个失败如何定位、结论没有覆盖什么。若没有开发板,把目标机字段写为“未执行”;若有开发板,附上板卡、工具、构建、烧录和观察条件。

C6 · TYPES AND INTERFACES

让类型成为硬件接口的护栏

固定宽度、转换规则和编译期检查把隐含假设变成可读的接口。本单元把“能编译”推进到“边界能解释”。

先确认对象、版本和安全边界。
再运行命令、代码和可观察输出。
后解释失败位置与尚未证明的事实。

C6.1 协议字段与单位

为每一个来自硬件或通信线的字段写出宽度、端序、单位、缩放和合法范围。温度可能是摄氏度的十分之一,超时可能是毫秒,ADC 原始码可能需要参考电压才能转成物理量;如果代码只留下一个 int,这些语义会在调用者之间丢失。

C6.2 转换前检查,转换后断言

窄化转换前先比较范围,乘法和加法前先判断是否会溢出;对编译期已知的布局用 _Static_assert。断言失败时要有安全策略,例如拒绝帧、保持旧配置或进入故障状态,而不是继续把截断值写入寄存器。

C · RANGE-FIRST
bool scale_to_u16(uint32_t raw, uint16_t *out) { if (!out || raw > 4095u) return false; *out = (uint16_t)raw; return true; }
CHECKPOINT C6.2给一个字段补齐单位

把一个“timeout”变量改成带单位的命名与类型,写出允许范围、超时后的状态和日志字段。不要接受“读者应该知道单位”作为合同。

C6.3 接口审查表

表 C6.3 · 函数接口交付前自问
问题合格答案的形状
输入能否为空?明确允许/拒绝和返回码
长度谁拥有?参数、命名和生命周期一致
输出何时有效?成功返回前已写入,失败不承诺
会阻塞多久?有上限或明确禁止的上下文

C6.4 整数提升与 API 的可读性

表达式中的整数提升会让 uint8_t 先变成 int,再参与加法、比较或移位;这并不表示所有结果都自动安全。函数接口应在边界处完成一次明确的转换和检查,内部计算使用足够宽的类型,最后再按合同写回寄存器或缓冲区。把转换散落在业务代码里,审查者很难判断哪个截断是有意的。

CHECKPOINT C6.4找出一处隐式窄化

在自己的 C 代码中找一个赋值或函数调用,让宽类型进入窄类型;写出输入最大值、转换前检查和失败返回。若无法找到,构造一个 32 位计数转 8 位协议字段的例子。

C7 · VOLATILE AND CONCURRENCY

知道何时需要 volatile,何时远远不够

硬件寄存器、ISR 标志和 RTOS 共享数据看起来都在“异步变化”,但它们需要的同步语义不同。先区分对象,再选择工具。

先确认对象、版本和安全边界。
再运行命令、代码和可观察输出。
后解释失败位置与尚未证明的事实。

C7.1 三个常见 volatile 场景

内存映射寄存器、由中断置位的简单标志、由外部硬件改变的状态寄存器,是最常见的 volatile 场景。它保证编译器不会把一次读取缓存成永远不变的值,却不保证一次读改写不可分割,也不保证多核或 DMA 与 CPU 的缓存一致性。使用前写出“谁在什么时候改变它”。

C7.2 中断共享对象的最小协议

若 ISR 只置位一个标志、主循环只清除它,可以用明确的单向协议;若双方都修改计数器或队列,就需要原子操作、短临界区或专门的数据结构。临界区要有最大时长和嵌套规则,不能在其中打印、等待串口或调用未知阻塞函数。

C · ISR FLAG CONTRACT
static 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 内存屏障和互斥锁解决的是不同层次的问题。若说不清“哪个执行单元、哪块内存、哪个顺序”需要保证,就先不要增加关键字。

CHECKPOINT C7.3把一个共享变量改写成事件协议

选择一个“中断通知主循环”的布尔变量,写出写者、读者、清除时机、丢失事件是否可接受和满载时怎么办。若事件不可丢失,说明为什么单个 volatile bool 不够。

C7.4 事件是否会丢:bool、计数还是队列

一个布尔标志只能表达“至少发生过一次”,无法表达发生了多少次,也不能保存事件携带的数据。如果 UART 接收、定时器 tick 或 DMA 完成事件不可丢失,就要用计数器、环形缓冲区或 RTOS 队列,并为满载、溢出和清空顺序写合同。选择数据结构前先回答“丢失一个事件是否安全”。

表 C7.4 · 事件承载方式
方式保留的信息典型限制
bool发生过/未发生重复事件会合并
计数器发生次数溢出与并发读改写
环形缓冲区事件顺序和数据容量、满载策略
RTOS 队列带同步的消息调度、阻塞和优先级

C8 · COMPILE AND HANDOFF

把 C 桥梁交给编译器与链接器

最后一单元把类型、内存、位运算和模块合同汇总成可运行的主机检查,并明确它与第 2 章、目标机工程的交接边界。

先确认对象、版本和安全边界。
再运行命令、代码和可观察输出。
后解释失败位置与尚未证明的事实。

C8.1 最小构建脚本与可重复输出

一个可交接的 C 小工程应包含源文件、头文件、构建命令、编译器版本、警告策略、测试输入和预期输出。命令要从工程根目录运行,不能依赖作者的全局变量或 IDE 最近打开的配置。第一次运行保存完整输出,第二次在删除产物后运行,用来检查构建是否真正声明了依赖。

MAKE-LIKE COMMANDS
cc -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 中最大的对象和链接区域,而不是盲目降低优化级别。

表 C8.4 · 错误消息与第一份证据
消息第一份证据不要先做
header not found编译命令与 include 路径复制作者绝对路径
multiple definition目标文件和符号表全局改成 static
undefined reference实现文件与链接列表乱加库文件
region overflowedMAP 的段和区域边界删除功能掩盖问题
FINAL CHECKPOINT C8.4把一个链接错误交接给同伴

任选一条消息,写出重现命令、首个证据、最小假设、下一步动作和仍未验证的结论。交接记录应让同伴知道你没有同时更换源码、编译器和链接脚本。