Appearance
综合实践:从点灯到小型系统
概念
前面八章给的都是部件:GPIO 会点灯了、UART 会发字节了、SPI/I2C 能挂传感器了、RTOS 能让多件事轮流跑了。但会每一个部件和做出一个能用的东西之间还差一段路——这段路就是本章的内容。
一句话说清它是什么:本章是一条五步的项目路径,每一步只比上一步多一个部件、且每一步都有明确的验收标准;走完之后,你手里的是一个能自己采数据、能显示、能上报、且不会死机的小系统。
选定的题目是桌面环境监测终端:
| 部件 | 用到哪一章 |
|---|---|
| MCU + 最小系统 | GPIO 与最小系统 |
| OLED 显示屏(I2C) | SPI 与 I2C 总线 |
| 温湿度传感器(I2C) | SPI 与 I2C 总线 |
| 串口上报 | UART 串口通信 |
| 周期采样定时器 | 中断系统与定时器 |
| 三个任务 + 队列 | 实时操作系统(RTOS) |
| 按键切换显示 | GPIO 与最小系统 |
这一层回答了上一层什么问题:上一章回答"多件事怎么轮流跑";这一章回答"怎么把它们拼成一个能交付的东西"。拼装本身是一门手艺——它不产生新知识,只暴露前面每一章的边界。
原理
一、五个里程碑
里程碑的意义在于"每一步都能单独验收"。不要一次把五步全写完再上电,那样一旦不工作,你面对的是五个变量一起动。
| 步骤 | 做什么 | 用到 | 验收标准 |
|---|---|---|---|
| M1 点灯 | GPIO 推挽输出 + while (1) 翻转 | GPIO | LED 按固定频率闪,说明工具链、下载器、时钟、复位全通 |
| M2 按键 | 按键 + 外部中断,点亮第二颗灯 | GPIO、中断 | 按下立刻响应,LED 状态翻转,说明输入路径通 |
| M3 串口 shell | 收命令、回字符串(led on / led off) | UART | 能在终端里手动控制 LED,说明可观测性就位 |
| M4 采集 | 定时器周期触发,I2C 读温湿度并打印 | 定时器、I2C | 每秒打印一次读数,说明数据源通 |
| M5 成系统 | 三个 RTOS 任务 + 队列 + 看门狗 | RTOS | 采集、显示、上报互不阻塞,连续跑 24 小时不死 |
M3 是最容易被跳过、又最不该跳过的一步。 串口打印是嵌入式唯一的"printf 调试"手段;没有它,M4 之后的一切问题都只能靠"猜 + 改 + 烧"。
二、三层划分:BSP / 驱动 / 应用
新手最常见的坏习惯是把所有寄存器操作写进 main.c。正确做法是三层的:
text
应用层 app/ 状态机、任务、业务逻辑 —— 只调用驱动接口
--------------------------------------------------------------
驱动层 dev/ 传感器、OLED 的协议实现 —— 只调用 BSP 接口
--------------------------------------------------------------
板级支持 bsp/ 时钟、GPIO、UART、I2C 的寄存器配置与封装层与层之间单向调用,理由是硬件会变、芯片会换、引脚会重排:
- 换一块芯片(STM32F103 → STM32F407),只改
bsp/;驱动与应用一行不动。 - 换一块 OLED(SSD1306 → SH1106),只改
dev/oled.c的初始化序列。 - 换产品需求(显示温湿度改成显示气压),只改
app/。
这与本站 md/somerules.md 里"分层、层间单向调用"的写法是同一件事——分成三层不是为了矩阵好看,是因为它能限制改动的传播范围。
三、非阻塞:用 tick 判时替代 delay
HAL_Delay(1000) 这类函数是忙等(或至少是"阻塞当前上下文"):在它执行的整整 1 秒里,这个任务什么都干不了。如果它跑在 RTOS 里,它会把整个 CPU 让出去,其他任务也受影响。
正确写法是把"延时"改写成"记住下次该做的时刻,每次路过时问一句到点了没有":
text
阻塞写法: 非阻塞写法:
do_A(); delay(100); if (now - last >= 100) { do_A(); last = now; }
do_B(); delay(200); if (now - last2 >= 200) { do_B(); last2 = now; }
→ A 与 B 互相拖累 → A 与 B 各自独立,一次循环谁也不用等谁这就是状态机的思路:程序不再"卡在某个函数里",而是每次被调用时检查一下"当前状态 + 有没有到时间 / 有没有来数据",然后前进一小步就返回。
注意 now - last 要用无符号数相减:uint32_t 的 now 溢出回绕时,now - last 依然给出正确的差值(模运算),但如果写成 if (now >= last + 100) 就会在回绕点出错。这是嵌入式里最常见的溢出 bug 之一。
四、环形缓冲区:串口的标配
串口一个字节的到达时刻由对端决定,而"处理这一帧"的时刻由你的主循环决定。两者节奏不一致,就必须要有一个中间存放的地方——环形缓冲区(ring buffer / FIFO):
text
tail head
| |
v v
+-----+-----+-----+-----+-----+-----+-----+-----+
| 0 | 1 | 2 | 3 | | | | 7 |
+-----+-----+-----+-----+-----+-----+-----+-----+
<-- 已被读走 --> <-- 待读 --> <-- 空 -->两个指针 + 一个数组就是全部实现:
- 写入(在中断里):存到
head位置,head = (head + 1) % N; - 读出(在主循环里):取
tail位置,tail = (tail + 1) % N; - 空:
head == tail;满:(head + 1) % N == tail。
为了让取模变成位与(快得多),缓冲区大小要取 2 的幂,此时 (x + 1) % N 等价于 (x + 1) & (N − 1)。这就是为什么嵌入式里环形缓冲区的大小总是 64、128、256——不是为了好看,是为了能用掩码。
代价是牺牲一个槽位来区分"满"与"空":8 个槽的缓冲区只能存 7 个字节。
容量怎么定:容量要盖住"最坏情况下主循环多久没来读"。9600 bps 下每位 104.17 μs,而 8N1 一帧共 10 位,所以每个字节 1041.67 μs(约 1.04 ms)。如果主循环最坏 10 ms 才回来一趟,那要能装下 10 ms / 1041.67 μs = 9.6 字节——取 2 的幂 16 B(可用 15 B,裕量 56.25%)。这个数字小得让人意外,说明"缓冲够不够"真正的瓶颈不是波特率,而是你的主循环最坏会卡多久;工程上通常再乘一个安全系数,取 64 B 或 128 B。
五、资源预算:先算,再烧
嵌入式开发的资源是有上限的,所以动手前先做一张预算表。以一块 64 KB Flash / 20 KB RAM 的入门 MCU 为例:
| 项 | 估算 | 说明 |
|---|---|---|
| Flash:代码 | 30 KB | RTOS 内核约 6–8 KB,HAL 库 10–15 KB,应用 5 KB |
| Flash:常量表 | 8 KB | 字体点阵、字符串、校准参数 |
RAM:.data | 2 KB | 已初始化全局变量 |
RAM:.bss | 6 KB | 未初始化全局变量(含大数组) |
| RAM:堆 | 2 KB | 若全用静态分配可以不用 |
| RAM:栈 | 2816 B | main 1024 + 三任务各 512 + 空闲任务 256 |
| CPU | 6.2% | 三任务各自的"执行时间 / 周期"之和 |
(.data / .bss / 堆 / 栈 这几个名词在 进程内存映像 里已完整讲过,嵌入式只是把它们放进了 20 KB 里。)
栈越界是嵌入式最凶险的故障:栈向下生长越界会直接踩掉邻近的全局变量,现象是某几个无关的变量偶尔变成垃圾值,极难定位。所以:
- 每个任务的栈按"局部变量 + 调用深度 × 最坏单帧"估,然后留一倍裕量;
- 用
uxTaskGetStackHighWaterMark()读出历史最小剩余量,跑完所有分支后再收窄; - 栈里不要放大数组(
uint8_t buf[1024]放在局部变量里就是一颗定时炸弹)。
六、可靠性:看门狗与上电自检
产品与实验的区别只有一条:产品必须在出错之后自己爬起来。
| 机制 | 做法 |
|---|---|
| 独立看门狗 IWDG | 硬件倒计时,到点没"喂狗"就复位整颗芯片 |
| 喂狗位置 | 只在所有任务都正常报到的位置喂,不能放在定时中断里 |
| 上电自检 | 检测传感器是否存在、Flash 校验和、时钟是否锁定 |
| 错误码 | 出错不 while (1) 死等,而是记错误码 + 走降级路径 |
| 失败降级 | 传感器读失败 → 用上次的值 + 置标志,而不是整机停摆 |
喂狗位置的老坑:如果把喂狗写在定时器中断里,那么即使主循环已经死掉,中断照样在跑、照样喂狗——看门狗形同虚设。正确的做法是让每个任务周期性地置一个标志位,只有所有标志位都置齐了才喂狗。
七、调试三件套
| 手段 | 用途 | 什么时候用 |
|---|---|---|
| 串口打印 | 看变量、看流程、看时序 | 首选,成本最低 |
| LED 心跳 | 判断"程序还活着吗" | 程序卡死、串口也没输出时 |
| 逻辑分析仪 | 看真正的波形与时序 | 通信对不上、时序不满足时 |
| 调试器断点 | 看寄存器与内存 | 逻辑已经清楚、只差一个值 |
打印要克制:在 9600 bps 下打印一行 40 个字符要 41.7 ms,足以把一个实时性要求 10 ms 的任务拖垮。"加了打印就不工作了"往往不是程序错了,是打印本身改变了时序。
示例
例 1:Flash 与 RAM 预算
text
Flash: 代码 30 KB + 常量 8 KB = 38 KB / 64 KB = 59.38%
RAM : .data 2 KB + .bss 6 KB + heap 2 KB + 栈 2816 B
= 2048 + 6144 + 2048 + 2816 = 13056 B = 12.75 KB / 20 KB = 63.75%两项都在六成左右——能塞下,但余量不宽裕。这时候任何"再加一个显示缓冲(1 KB)"的决定都要重新算一遍。
例 2:CPU 利用率预算
| 任务 | 执行时间 | 周期 | 利用率 |
|---|---|---|---|
| 采集 | 1.2 ms | 100 ms | 1.20% |
| 显示 | 0.8 ms | 20 ms | 4.00% |
| 通信 | 5.0 ms | 500 ms | 1.00% |
| 合计 | 6.20% |
空闲 93.80%。这么大的余量说明这颗芯片的主频对当前需求是过剩的——这本身是个好结论:它给了你两条路,要么降主频省电,要么往上加功能。真正需要警惕的是相反的情形:利用率超过 70% 时,任何一次"加个打印"都可能让系统崩掉。
例 3:环形缓冲区容量
text
9600 bps -> 每位 1/9600 = 104.1667 us
8N1 共 10 位 -> 每字节 1041.6667 us(约 1.04 ms)
主循环最坏 1 ms 不读 -> 积压 0.96 B -> 取 2 的幂 2 B,可用 1 B,裕量 4.17%
主循环最坏 5 ms 不读 -> 积压 4.80 B -> 取 2 的幂 8 B,可用 7 B,裕量 45.83%
主循环最坏 10 ms 不读 -> 积压 9.60 B -> 取 2 的幂 16 B,可用 15 B,裕量 56.25%注意"每字节 1041.67 μs"与"每位 104.17 μs"差了 10 倍——这是串口算账最常见的错位:波特率数的是位,帧里的开销(起始位 + 停止位)要一起算进去。
"取 2 的幂"和"裕量"这两件事是同一个约束的两面:容量要够大(裕量),大小要能位与(2 的幂)。16 B 同时满足两者,而工程上取 64 B 或 128 B 则是再加一层安全系数——因为"主循环最坏 10 ms"这个前提本身就可能被估计得太乐观。
例 4:非阻塞闪烁的周期账
心跳灯要 1 Hz(一亮一灭一个周期 1 秒),用"亮 100 ms + 灭 900 ms"实现:
text
周期 = 100 + 900 = 1000 ms -> 1.000 Hz
在 100 ms 的 tick 下:亮占 1 个 tick、灭占 9 个 tick,共 10 个 tick = 1000 ms"亮 500 ms + 灭 500 ms"看起来更直观(各占一半),但 100 + 900 更实用:心跳灯是给调试用的,亮的时间短、灭的时间长,既省电又刺眼程度低,而且闪一下更容易被眼睛捕捉到。
例 5:C 实现——环形缓冲区与非阻塞状态机
/* project.c —— 串口环形缓冲区 + 非阻塞 LED 状态机 */
#include <stdio.h>
#include <stdint.h>
#define RING_SIZE 8 /* 取 2 的幂,便于用掩码取模 */
#define TICK_MS 100 /* 系统节拍:100 ms */
typedef struct {
uint8_t buf[RING_SIZE];
uint16_t head; /* 下一次写入的位置 */
uint16_t tail; /* 下一次读出的位置 */
uint32_t dropped; /* 因满而丢弃的字节数 */
} Ring;
static void ring_init(Ring *r)
{
r->head = 0;
r->tail = 0;
r->dropped = 0;
}
static int ring_push(Ring *r, uint8_t b)
{
uint16_t next = (uint16_t)((r->head + 1) & (RING_SIZE - 1)); /* 掩码取代取模 */
if (next == r->tail) { r->dropped++; return 0; } /* 满:牺牲一个槽位 */
r->buf[r->head] = b;
r->head = next;
return 1;
}
static int ring_pop(Ring *r, uint8_t *b)
{
if (r->tail == r->head) return 0; /* 空 */
*b = r->buf[r->tail];
r->tail = (uint16_t)((r->tail + 1) & (RING_SIZE - 1));
return 1;
}
int main(void)
{
Ring r;
uint8_t b;
int i, t;
printf("=== 1. ring buffer (RING_SIZE = %d, usable = %d) ===\n",
RING_SIZE, RING_SIZE - 1);
ring_init(&r);
for (i = 0; i < 12; i++) {
int ok = ring_push(&r, (uint8_t)(0x41 + i));
printf(" push 0x%02X -> %-4s (head=%u tail=%u dropped=%u)\n",
0x41 + i, ok ? "ok" : "FULL", r.head, r.tail, r.dropped);
}
printf(" drain:");
while (ring_pop(&r, &b)) printf(" 0x%02X", b);
printf("\n dropped total = %u\n", r.dropped);
printf("\n=== 2. non-blocking LED blinker (tick = %d ms) ===\n", TICK_MS);
printf(" on 100 ms + off 900 ms -> period 1000 ms -> 1.000 Hz\n");
for (t = 0; t < 12; t++) {
int ms = t * TICK_MS;
int on = (ms % 1000) < 100;
printf(" t=%2d (%5d ms) LED %s\n", t, ms, on ? "ON" : "OFF");
}
return 0;
}
c 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 1. ring buffer (RING_SIZE = 8, usable = 7) ===
push 0x41 -> ok (head=1 tail=0 dropped=0)
push 0x42 -> ok (head=2 tail=0 dropped=0)
push 0x43 -> ok (head=3 tail=0 dropped=0)
push 0x44 -> ok (head=4 tail=0 dropped=0)
push 0x45 -> ok (head=5 tail=0 dropped=0)
push 0x46 -> ok (head=6 tail=0 dropped=0)
push 0x47 -> ok (head=7 tail=0 dropped=0)
push 0x48 -> FULL (head=7 tail=0 dropped=1)
push 0x49 -> FULL (head=7 tail=0 dropped=2)
push 0x4A -> FULL (head=7 tail=0 dropped=3)
push 0x4B -> FULL (head=7 tail=0 dropped=4)
push 0x4C -> FULL (head=7 tail=0 dropped=5)
drain: 0x41 0x42 0x43 0x44 0x45 0x46 0x47
dropped total = 5
=== 2. non-blocking LED blinker (tick = 100 ms) ===
on 100 ms + off 900 ms -> period 1000 ms -> 1.000 Hz
t= 0 ( 0 ms) LED ON
t= 1 ( 100 ms) LED OFF
t= 2 ( 200 ms) LED OFF
t= 3 ( 300 ms) LED OFF
t= 4 ( 400 ms) LED OFF
t= 5 ( 500 ms) LED OFF
t= 6 ( 600 ms) LED OFF
t= 7 ( 700 ms) LED OFF
t= 8 ( 800 ms) LED OFF
t= 9 ( 900 ms) LED OFF
t=10 ( 1000 ms) LED ON
t=11 ( 1100 ms) LED OFF两处值得对着输出看:
- 推 12 个字节,
dropped只有 5,而最后能读出 7 个——因为牺牲一个槽位用于区分满/空。这就是"容量看起来是 8、实际可存 7"的来源。 - LED 在
t = 0与t = 10都亮,两点之间正好 10 × 100 ms = 1000 ms,周期 1.000 Hz。整个循环没有一处delay——这就是非阻塞的含义:每次路过只问一句"到点了没"。
例 6:Python——预算核算
# 嵌入式项目:Flash / RAM / CPU / 缓冲区预算
import unicodedata
def wpad(s, n):
"""按显示宽度右补空格:东亚宽字符算 2 列,否则中文表格必然错位"""
w = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
return s + " " * max(0, n - w)
print("=== 一、Flash 预算(64 KB)===")
flash = [("代码(RTOS + HAL + 应用)", 30 * 1024), ("常量表(字体/字符串/参数)", 8 * 1024)]
for k, v in flash:
print(f" {wpad(k, 28)}{v / 1024:5.1f} KB")
fsum = sum(v for _, v in flash)
print(f" 合计 {fsum / 1024:.0f} KB / 64 KB = {fsum / 65536 * 100:.2f}%")
print("\n=== 二、RAM 预算(20 KB)===")
ram = [(".data", 2 * 1024), (".bss", 6 * 1024), ("heap", 2 * 1024),
("main 栈", 1024), ("3 个任务栈 x 512", 3 * 512), ("空闲任务栈", 256)]
for k, v in ram:
print(f" {wpad(k, 20)}{v:6d} B")
rsum = sum(v for _, v in ram)
print(f" 合计 {rsum} B = {rsum / 1024:.2f} KB / 20 KB = {rsum / 20480 * 100:.2f}%")
print("\n=== 三、CPU 利用率 ===")
jobs = [("采集", 1.2, 100), ("显示", 0.8, 20), ("通信", 5.0, 500)]
tot = 0.0
for name, ex, per in jobs:
u = ex / per * 100
tot += u
print(f" {wpad(name, 6)}{ex} ms / {per} ms = {u:.2f}%")
print(f" 合计 {tot:.2f}%,空闲 {100 - tot:.2f}%")
print("\n=== 四、环形缓冲区容量 ===")
baud, bits_per_byte = 9600, 10 # 8N1:1 起始 + 8 数据 + 1 停止
t_byte_us = bits_per_byte / baud * 1e6
print(f" {baud} bps,每位 {1 / baud * 1e6:.4f} us;每字节 {bits_per_byte} 位(8N1)-> {t_byte_us:.4f} us")
for worst_ms in (1, 5, 10):
need = worst_ms * 1000 / t_byte_us
size = 2
while size - 1 < need: # 牺牲一个槽位,所以可用容量是 size - 1
size *= 2
print(f" 主循环最坏 {worst_ms:2d} ms 不读 -> 积压 {need:5.2f} B -> 取 2 的幂 {size:3d} B,"
f"可用 {size - 1:3d} B,裕量 {(size - 1 - need) / need * 100:.2f}%")
print("\n=== 五、看门狗余量 ===")
for timeout_ms, feed_ms in ((1000, 200), (2000, 200), (400, 500)):
tag = " (喂狗间隔比超时还长,必然复位!)" if feed_ms >= timeout_ms else ""
print(f" 超时 {timeout_ms} ms,每 {feed_ms} ms 喂一次 -> 余量 {timeout_ms / feed_ms:.2f} 倍{tag}")
python 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 一、Flash 预算(64 KB)===
代码(RTOS + HAL + 应用) 30.0 KB
常量表(字体/字符串/参数) 8.0 KB
合计 38 KB / 64 KB = 59.38%
=== 二、RAM 预算(20 KB)===
.data 2048 B
.bss 6144 B
heap 2048 B
main 栈 1024 B
3 个任务栈 x 512 1536 B
空闲任务栈 256 B
合计 13056 B = 12.75 KB / 20 KB = 63.75%
=== 三、CPU 利用率 ===
采集 1.2 ms / 100 ms = 1.20%
显示 0.8 ms / 20 ms = 4.00%
通信 5.0 ms / 500 ms = 1.00%
合计 6.20%,空闲 93.80%
=== 四、环形缓冲区容量 ===
9600 bps,每位 104.1667 us;每字节 10 位(8N1)-> 1041.6667 us
主循环最坏 1 ms 不读 -> 积压 0.96 B -> 取 2 的幂 2 B,可用 1 B,裕量 4.17%
主循环最坏 5 ms 不读 -> 积压 4.80 B -> 取 2 的幂 8 B,可用 7 B,裕量 45.83%
主循环最坏 10 ms 不读 -> 积压 9.60 B -> 取 2 的幂 16 B,可用 15 B,裕量 56.25%
=== 五、看门狗余量 ===
超时 1000 ms,每 200 ms 喂一次 -> 余量 5.00 倍
超时 2000 ms,每 200 ms 喂一次 -> 余量 10.00 倍
超时 400 ms,每 500 ms 喂一次 -> 余量 0.80 倍 (喂狗间隔比超时还长,必然复位!)第 5 段最后一行是故意留的反例:喂狗间隔一旦超过看门狗超时,系统会周期性复位——现象是"LED 每隔几百毫秒闪一次、串口每次重打启动信息"。这个现象在真机上极常见,而根因往往只是某个任务的周期比看门狗超时还长。
考点
- 五步里程碑各有独立验收标准,M1(点灯)验证的是工具链整条链,M3(串口 shell)验证的是可观测性——这两步最容易被跳过,也最不该跳。
- 三层划分 BSP / 驱动 / 应用,层间单向调用;换芯片只改 BSP,换器件只改驱动,换需求只改应用。
- 非阻塞 = 记录"下次该做的时刻"并在每次路过时比较,替代阻塞式
delay。比较必须写now - last >= N,不要写now >= last + N(uint32_t回绕会出错)。 - 环形缓冲区 = 数组 + head/tail 两个指针;空的条件是
head == tail,满的条件是(head + 1) % N == tail,所以牺牲一个槽位。 - 大小取 2 的幂是为了用
& (N - 1)代替% N,不是为了好看。 - 栈越界的现象是"无关变量偶尔变成垃圾值",极难定位;局部变量里不要放大数组,栈要留一倍裕量,用高水位线 API 复核。
- 喂狗不能放在定时中断里(主循环死了中断还活着,看门狗就失效了);正确做法是"所有任务都报到才喂"。
- 打印会改变时序:9600 bps 下 40 个字符要 41.7 ms,足以拖垮 10 ms 级的实时任务。
- 易错:把
% N写成% (N + 1);把"满"判据写成head == tail;只在main里while (1)而不喂狗;认为"跑通了就是好了"——产品还必须能自己从故障里爬起来。
小结
- 项目不是新知识,是部件的拼装,而拼装的手艺体现在:分步验收、分层、非阻塞、留裕量、能自恢复。
- 五个里程碑:点灯(工具链)→ 按键(输入)→ 串口 shell(可观测)→ 采集(数据)→ RTOS 成系统。
- 三层 BSP / 驱动 / 应用,把改动的传播范围限定在一层之内。
- 一切
delay都能改写成 tick 判时;改写之后各件事互不阻塞。 - 环形缓冲区是异步通信的标配,容量取 2 的幂,代价是牺牲一个槽位。
- 动手前先做 Flash / RAM / CPU 预算,栈要按最坏调用深度估并留一倍裕量。
- 看门狗的正确用法是"所有任务都报到才喂";最危险的是把喂狗放进定时中断。
回到主线:这一章没有引入新概念——它把 GPIO 的输出、中断与定时器 的时基、UART 的可观测、I2C 的数据源、ARM 的寄存器、RTOS 的调度全用了一遍,顺便用 进程内存映像 里那几个名词算了一次 20 KB 的账。到这里,一颗芯片能做的"软件"就做完了。
这一章答的是"软件怎么把芯片用起来"。再往下走,就要离开软件的地界:那些标准单元、时序约束、版图——也就是芯片本身是怎么被设计出来的。
下一篇:数字集成电路设计(进阶)
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。