STM32从零到量产开发:四路继电器工业控制模块开发原理小灶 · 合集
本文是《新手量产开发实战笔记》配套的 9 篇独立"原理小灶"合集 。每篇只讲一个"看似普通、其实用心"的代码点,从小白视角把底层原理、真实代码、设计权衡拆开讲透。
阅读建议:不用按顺序。哪段代码你看不懂"为什么这么写",直接跳到对应小灶。
所有代码片段均取自本项目真实源码(
modbus_rtu.c/flash_param.c/bsp_relay.c/system_app.c/main.c)并逐字核对。
目录
| # | 小灶 | 对应功能 | 一句心法 |
|---|---|---|---|
| 01 | [无符号减法与时间戳回绕](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | #1 故障安全心跳 | 算时间差永远用 uint32_t 减法 |
| 02 | [状态翻转检测与动作计数](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | #3 寿命统计 | 数边沿,不数电平 |
| 03 | [Flash 参数三板斧](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | 参数持久化 | magic + CRC + 哨兵值 |
| 04 | [运行小时基准增量模型](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | #4 运行小时 | RAM 扛高频,Flash 扛持久 |
| 05 | [芯片 UID 与序列号](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | #4 设备 SN | 白嫖硬件身份证 |
| 06 | [Modbus 组帧字节序](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | 协议组帧 | 数据大端,CRC 小端,分函数管 |
| 07 | [位标志寄存器压缩](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | #1/#3 状态位图 | 一个寄存器当两个用 |
| 08 | [异常计数语义边界](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | #3 诊断计数 | 计数前先想"指标要反映什么" |
| 09 | [零阻塞主循环](# 小灶 对应功能 一句心法 01 无符号减法与时间戳回绕 #1 故障安全心跳 算时间差永远用 uint32_t 减法 02 状态翻转检测与动作计数 #3 寿命统计 数边沿,不数电平 03 Flash 参数三板斧 参数持久化 magic + CRC + 哨兵值 04 运行小时基准增量模型 #4 运行小时 RAM 扛高频,Flash 扛持久 05 芯片 UID 与序列号 #4 设备 SN 白嫖硬件身份证 06 Modbus 组帧字节序 协议组帧 数据大端,CRC 小端,分函数管 07 位标志寄存器压缩 #1/#3 状态位图 一个寄存器当两个用 08 异常计数语义边界 #3 诊断计数 计数前先想"指标要反映什么" 09 零阻塞主循环 系统架构 每函数瞬间返回,看门狗饿不死) | 系统架构 | 每函数瞬间返回,看门狗饿不死 |
它们之间的逻辑链路
这 9 处不是孤立的,串起来就是"一个稳健量产固件"的设计骨架:
┌──────────── 系统骨架 ────────────┐
│ 09 零阻塞主循环 (所有任务的容器) │
└───────────────────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
01 故障安全 03 Flash 参数 06 协议组帧
(心跳时间戳) (magic/CRC/哨兵) (字节序)
│ │ │
└──── 04 运行小时(基准+增量, 复用 03 的保存时机) ────┘
│
├─ 02 寿命计数 (边沿检测) ── 07 位标志压缩(状态位图)
│
├─ 05 芯片UID → SN (塞进 07 的寄存器)
│
└─ 08 异常计数 (语义边界, 与 01 的有效帧计数对照)
一句话:09 是容器,03 是存储底座,01/02/04/05/07/08 是各项功能在"正确性 / 资源 / 可维护性"之间选最稳那个的具体落地,06 是它们对外说话(协议)的方式。
小灶 01 · 无符号减法与时间戳回绕(通信故障安全的心跳检测)
表面看 :
now - last不就是算个时间差吗?有什么难的。真正巧妙 :它用 无符号整数的回绕运算特性 ,让"系统连续运行 49 天"这种极端情况也不会算错;并且把"刷新心跳"放在广播判断之前,避免纯广播现场误触发。
一、背景:为什么要用时间戳判断"通信失联"
通信故障安全(Fail-safe)的逻辑是:
如果从机连续 N 秒没收到任何有效 Modbus 帧,就自动把继电器全部断开,防止上位机死机/断线后设备失控。
这就要记录"上一次正常通信的时间",然后每次循环拿"现在"减去它,看超没超时。
核心代码(modbus_rtu.c 的 Modbus_CheckFailSafe):
c
uint32_t now = HAL_GetTick();
uint32_t elapsed = now - s_last_valid_tick; // 关键:无符号减法
uint32_t timeout_ms = (uint32_t)p->fail_safe_sec * 1000u;
if (elapsed >= timeout_ms) {
BSP_Relay_SetAll(0u); // 切安全态:全部断开
s_fail_safe_active = 1u;
BSP_LED_SetMode(LED_MODE_BLINK_FAST);
}
二、原理:无符号减法自带"回绕安全"
HAL_GetTick() 返回的是 uint32_t(无符号 32 位毫秒计数器),约 49.7 天 后会从 0xFFFFFFFF 翻回 0x00000000(这叫"回绕 / wrap-around")。
如果用有符号 int32_t,比较 now - last 会出大 Bug:
last = 0xFFFFFFF0(接近上限)now = 0x00000005(回绕之后)- 真实流逝 =
0x15 = 21 ms - 有符号减法结果变成负数 →
if (elapsed >= timeout)永远为假 → 故障安全永久失灵
而 C 语言规定:无符号整数溢出按模 2³² 运算 。所以 0x00000005 - 0xFFFFFFF0 在模 2³² 下结果恰好是 0x15 = 21,永远是正确正数差。
这就是"巧妙"的第一层:不用写任何 if (now < last) { ... } 回绕补偿代码,靠类型本身的数学性质就对了。
💡 小白心法:凡是要算"时间差 / 经过量",一律用
uint32_t相减,永远不用有符号数去比。 这是嵌入式行业几十年的约定俗成。
三、原理:上电基准防"启动即误触发"
还有一个坑:刚上电时还没任何通信,如果 s_last_valid_tick 是 0,那上电一瞬间 elapsed 就已经很大,会立刻误触发故障安全。
解决在 Modbus_Init:
c
/* 以当前时刻作为"最近有效帧"基准, 防止上电后立即误触发故障安全
* (上电后 fail_safe_sec 秒内无通信属正常启动阶段) */
s_last_valid_tick = HAL_GetTick();
一句话:把"上一次正常通信"初始化成"开机这一刻",等于默认给了启动阶段一个宽限期。
四、巧妙点小结
| 巧妙之处 | 小白容易踩的坑 | 本代码做法 |
|---|---|---|
用 uint32_t 减法算时间差 |
用 int32_t,49 天回绕后变负数、比较失效 |
无符号模运算,天然正确 |
| 上电就刷新基准时间戳 | 基准为 0,开机即误报失联 | Init 里先打时间戳 |
| 刷新放在广播判断前 | 纯广播现场(从不回包)永远不刷新,最终误触发 | 收到任意有效帧(含广播)都刷新,见小灶 08 关联的"有效帧计数"逻辑 |
小灶 02 · 状态翻转检测与动作计数(继电器寿命统计)
表面看 :控制继电器不就是
GPIO=1 / GPIO=0吗?累计次数不就是每次调用+1?真正巧妙 :它只在状态真正翻转时 才动 GPIO、才
+1,把"电平"和"边沿"区分开------因为继电器的物理寿命,只认真实的吸合次数。
一、背景:寿命为什么要"数动作次数"
继电器是机械触点 ,每吸合/断开一次,触点就磨损一点。规格书里的"电气寿命 10 万次"指的是真实动作次数。
所以"动作计数"必须反映真实磨损,而不是"主站发了多少条命令"。
二、核心代码(bsp_relay.c)
c
void BSP_Relay_Set(uint8_t ch, uint8_t state)
{
uint8_t idx;
uint8_t old_state;
if (ch < 1u || ch > RELAY_CH_NUM) {
return;
}
idx = ch - 1u;
old_state = (s_relay_state >> idx) & 0x01u; // 读出当前缓存状态
/* 仅当目标状态与当前状态不同才执行动作 */
if (((state != 0u) ? 1u : 0u) == old_state) {
return; // 同状态:不翻转、不计数
}
if (state) {
HAL_GPIO_WritePin(... GPIO_PIN_RESET); // ON:低有效
s_relay_state |= (uint8_t)(1u << idx);
} else {
HAL_GPIO_WritePin(... GPIO_PIN_SET); // OFF:高有效
s_relay_state &= (uint8_t)~(1u << idx);
}
s_relay_act_count[idx]++; // 仅实际翻转时累计
}
三、原理:电平 vs 边沿(教科书级概念)
这是数字电路里最基础也最重要的一对概念:
- 电平(level):此刻是 1 还是 0。
- 边沿(edge):从 0 变 1(上升沿)、从 1 变 0(下降沿)。
计数器要数的是边沿,不是电平。
经典反例:主站因为软件 bug 或轮询逻辑,连发 10 条"打开继电器 1"。如果已开着,物理上根本没再动作。若写成"每次调用 +1",计数器会暴涨到 10,寿命统计彻底失真。
本代码先 old_state 比对,相同就 return,于是:
- 已开 → 再发"开" → 不翻转、不计数 ✅
- 关 → 发"开" → 翻转、+1 ✅
这就是"边沿计数 / 去重"。
四、原理:缓存状态位图,避免读 GPIO
old_state 不是去读引脚,而是读 s_relay_state 这个 RAM 缓存位图:
c
static uint8_t s_relay_state = 0u; // bit0~3 对应 4 路继电器
好处:
- 快:位运算读 RAM 是单周期,读 GPIO 要访问外设寄存器、慢得多。
- 稳 :外部触点可能有抖动/干扰,
Get直接返回逻辑意图而非物理引脚,更可靠。 - 统一真相源 :
BSP_Relay_Get/GetAll/Toggle全基于这个缓存,避免"缓存与硬件不一致"。
💡 小白心法:驱动层维护一份"逻辑状态缓存",对外只认缓存,不认引脚。 这是几乎所有 MCU 外设驱动的标准写法。
五、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| 边沿计数 | 每次调用 +1,连发同命令虚增寿命 | old_state 比对,相同即返回 |
| 不翻转就不碰 GPIO | 反复写同一电平,触点无谓电弧、EMI | 翻转才写引脚 |
| 缓存状态位图 | 每次读 GPIO,慢且受干扰 | RAM 位图作唯一真相源 |
| 上电初始化不计数 | 上电默认态被误记一次操作 | Init 里直接置缓存,不调 Set |
小灶 03 · Flash 参数三板斧(magic + CRC16 + 哨兵值)
表面看 :不就是把几个配置写到 Flash,上电再读回来?
真正巧妙 :它用 magic 识别是否已编程 、CRC16 检测数据损坏 、哨兵值让一个函数能"只改任意几个字段",还顺手把"设备履历"和"用户配置"分开对待。
一、背景:Flash 不是普通内存
STM32F103 的内部 Flash 有三个铁律(小白必须先懂):
- 写前必须整页擦除 (本芯片 2KB/页),擦除后全
0xFF。 - 最小编程单位是半字(16 bit)。
- 擦写寿命有限(约 1 万次)。频繁写会写废。
所以 Flash 参数设计的核心矛盾是:既要"断电不丢",又不能"频繁擦写"。
二、第一板斧:magic ------ 区分"已编程"还是"空白"
Flash 出厂或刚擦除后是全 0xFF。如果只靠"读出来的值"判断,无法区分:
- 是用户存的
slave_addr = 0xFF? - 还是根本没存过(空白)?
于是加一个"魔数"字段:
c
#define PARAM_MAGIC ... // 比如 0x5A5A
p->magic = PARAM_MAGIC;
上电校验:
c
if ((s_param.magic != PARAM_MAGIC) ||
((uint16_t)s_param.crc != Param_CalcCRC(&s_param))) {
Param_LoadDefault(&s_param); // 未编程/损坏 → 用出厂默认
Param_WriteFlash(&s_param);
}
三、第二板斧:CRC16 ------ 检出"数据损坏"
Flash 会老化(位翻转),也可能在"正在擦写时掉电"导致半截数据。CRC 就是校验和,能发现这种损坏。
最容易被忽略的巧妙点 ------ CRC 计算时要排除 CRC 字段自己:
c
static uint16_t Param_CalcCRC(const param_t *p)
{
uint16_t crc_len = (uint16_t)(sizeof(param_t) - sizeof(uint32_t)); // 减掉末尾 crc
return Param_CRC16((const uint8_t *)p, crc_len);
}
如果把自己也算进去,那 CRC 永远对不上(因为 crc 字段是后来填的)。减掉最后 4 字节(crc 是 uint32_t),只覆盖 magic ~ run_sec_base,才正确。
四、第三板斧:哨兵值 ------ 一个函数改"任意子集"
需求:FlashParam_Save 要能单独改"从机地址",也能单独改"波特率",也能一次改多个。朴素做法要写 N 个函数。
本代码用两个哨兵值解决:
c
// 0 表示"不修改"(用于 slave_addr,因为 0 是广播地址、对用户非法)
// 0xFF 表示"不修改"(用于其余字段)
if (slave_addr != 0u) s_param.slave_addr = slave_addr;
if (baud_index != 0xFFu) s_param.baud_index = baud_index;
if (link_enable != 0xFFu) s_param.link_enable = link_enable;
if (fail_safe_en != 0xFFu) s_param.fail_safe_en = fail_safe_en;
if (fail_safe_sec != 0xFFu) s_param.fail_safe_sec = fail_safe_sec;
为什么 slave_addr 用 0、其他用 0xFF 当哨兵? 因为 slave_addr 合法范围是 1~247,0 恰好是非法值,可以安全复用为"不改";而其他字段(如 link_enable 只有 0/1)0 是合法值,必须用 0xFF 当哨兵。这就是按字段语义选哨兵,不是随便定的。
五、额外巧妙:履历与配置分开
reset_count(累计复位次数)、run_sec_base(累计运行秒基准)属于设备履历 ,恢复出厂不该清零:
c
void FlashParam_ResetDefault(void)
{
uint32_t saved_reset_count = s_param.reset_count;
uint32_t saved_run_sec = s_param.run_sec_base;
Param_LoadDefault(&s_param);
s_param.reset_count = saved_reset_count; // 履历保留
s_param.run_sec_base = saved_run_sec;
...
}
memset(p, 0, sizeof(param_t)) 也值得注意:先整体清零,消除编译器对齐填充(padding)字节的随机性,保证 CRC 可复现。
六、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| magic 识别空白 | 0xFF 与"未存过"混淆 | 固定魔数区分 |
| CRC 排除自身 | 把自己算进 CRC,永远校验失败 | 减掉 crc 字段长度 |
| 哨兵值 | 写 N 个 Save 函数 | 0 / 0xFF 两个哨兵,按字段语义选 |
| 履历保护 | 恢复出厂把计数也清了 | 先存后恢复 |
| 整体清零 | padding 字节不确定导致 CRC 漂移 | memset 先清 |
小灶 04 · 运行小时基准 + 增量模型
表面看 :运行小时不就是
秒数 / 3600吗?真正巧妙 :它用一个 Flash 里的"基准" + 一个 RAM 里"实时算的增量" 的组合,既做到跨重启连续累计 ,又几乎不增加 Flash 擦写次数------完美绕开"掉电丢失"和"寿命爆掉"两难。
一、需求与矛盾
工业设备要汇报"累计运行小时",要求:
- 断电不丢:重启后小时数不能归零。
- 连续准确:不能因为重启就少算这次上电的时间。
- 不能老写 Flash:Flash 只有 1 万次擦写寿命(见小灶 03)。
朴素方案都有硬伤:
| 方案 | 坑 |
|---|---|
| RAM 变量每秒 +1 | 一断电全没 |
| 每过 1 小时写一次 Flash | 频繁擦写,寿命几年就废 |
| 每次上电把上次值 +1 存 Flash | 只数"开机次数",不算真实运行时间 |
二、核心代码(两段配合)
读取侧 (system_app.c)------ 运行时实时拼出总秒数:
c
uint32_t SystemApp_GetRunSeconds(void)
{
/* 累计运行秒数 = 掉电保存基准 + 本次上电已流逝秒数 */
return FlashParam_Get()->run_sec_base + (HAL_GetTick() / 1000u);
}
uint32_t SystemApp_GetRunHours(void)
{
return SystemApp_GetRunSeconds() / 3600u; // 秒 → 小时
}
保存侧 (flash_param.c)------ 低频保存时才把"本次已跑的时间"折算进基准:
c
static void Param_UpdateRunSec(void)
{
s_param.run_sec_base = s_param.run_sec_base + (HAL_GetTick() / 1000u);
}
Param_UpdateRunSec() 在 FlashParam_Save(改参数时)、FlashParam_SaveResetCount、FlashParam_ResetDefault 里被调用------都是原本就要写 Flash 的时机,没有额外增加擦写。
三、原理:基准 + 增量(类似"银行存款"模型)
把运行时间想象成存钱:
run_sec_base= 已存进银行(Flash)的本金,掉电还在。HAL_GetTick()/1000= 今天刚赚、还在手里(RAM)的零钱。- 总财产 = 本金 + 零钱,随时可查。
- 只在"去银行办业务"(恰好要存别的东西)时,顺手把零钱存进本金。
于是:
- 本次上电跑了 3 小时,没保存过 → 读出来是
base + 3h,不丢。 - 下次保存参数时,base 变成
base + 3h,零钱归零重计。 - Flash 擦写次数 = 参数变更次数(低频),完全不额外消耗寿命。
四、为什么"实时读"和"保存折算"要对齐
注意两个地方都用同一个 HAL_GetTick()/1000:
- 读的时候:base(旧本金)+ 当前零钱。
- 保存的时候:base = base + 当前零钱(把零钱固化成新本金)。
保存后下一次读:新 base + 新零钱(从 0 开始计的小零钱)。两段数学完全衔接,不会出现重复加或漏加。
💡 小白心法:RAM 扛高频变化,Flash 扛持久存储;两者用"保存时折算"做桥接。 这是 MCU 固件里处理"累计量 + 掉电保存"的通用范式(里程表、能耗统计、运行计时都用它)。
五、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| 基准 + 增量分离 | 要么丢、要么老写 Flash | base 存 Flash,增量实时算 |
| 折算融入既有保存 | 为计时专门加写 Flash 频率 | 复用参数保存时机,零额外擦写 |
| 读/存同公式 | 两段不一致导致重复/漏算 | 都用 base + tick/1000 |
小灶 05 · 芯片 UID 与序列号(白嫖硬件身份证)
表面看 :序列号不就是烧录时写进 Flash 的一个数?
真正巧妙 :它直接读取 STM32 芯片出厂就固化在固定地址的 96-bit 唯一 ID,连烧录工序都省了;再用"三段异或"把 96 位压缩成 32 位 SN,每颗芯片都不同。
一、背景:序列号为什么难
每台设备要有一个全球唯一 SN,方便追溯、批量管理。朴素做法:
- 人工给每台分配一个号,烧进 Flash → 容易重复、容易漏、量产麻烦。
- 买专用芯片(如 1-Wire EEPROM)存 SN → 多一颗料、多一笔成本。
如果 MCU 自己就带"身份证",这些问题全没了。
二、原理:STM32 的出厂唯一 ID
STM32F1 在系统存储器 的固定地址 0x1FFFF7E8 固化了 96-bit Unique Device ID ,出厂时由 ST 烧录,每颗芯片全球唯一,且只读。
参考手册 RM0008 第 30.2 节明确记载了这个地址。我们可以直接把它当指针读出来:
c
#define DEV_UID_BASE 0x1FFFF7E8u // STM32F1 96-bit UID 基地址
static uint32_t MB_GetDeviceSN(void)
{
const uint32_t *uid = (const uint32_t *)DEV_UID_BASE;
/* 三段 32-bit 异或, 得到一颗芯片唯一的 SN */
return (uid[0] ^ uid[1] ^ uid[2]);
}
uid[0]、uid[1]、uid[2] 就是那 96 位拆成的 3 个 32 位字。
三、原理:为什么用"异或"压缩
96 位太长(要 6 个 16 位寄存器才放得下)。产品只想要一个 32 位 SN,于是把三段**异或(XOR)**合并:
c
return (uid[0] ^ uid[1] ^ uid[2]);
为什么是异或、不是相加或只取一段?
- 只取一段:万一某段有规律(如批次相关),SN 分布不均匀,相邻设备 SN 可能撞车。
- 异或三段:把三段的信息搅在一起,得到分布均匀的 32 位值;且异或不丢信息对称性,计算极快(单指令)。
- 32 位 SN 空间约 42 亿,对单型号产品足够区分。
然后映射成两个 16 位输入寄存器,供主站读取:
c
case IR_ADDR_SN_LOW: value = (uint16_t)(MB_GetDeviceSN() & 0xFFFFu); break;
case IR_ADDR_SN_HIGH: value = (uint16_t)((MB_GetDeviceSN() >> 16) & 0xFFFFu); break;
四、为什么"读固定地址"是安全的
0x1FFFF7E8 属于芯片系统存储区,不在用户 Flash 里,不会被擦写破坏;它是只读的,普通代码读它不会改它。这种"硬件白给的唯一标识"是量产固件的常见技巧。
💡 小白心法:能用硬件白给的,就别自己造。 UID、唯一序列号、复位原因寄存器(RCC->CSR)都是 ST 预先为你准备好的"免费功能"。
五、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| 序列号来源 | 人工烧录易重复/遗漏 | 读芯片 96-bit UID,出厂唯一 |
| 压缩方式 | 取一段易撞车 | 三段异或,分布均匀 |
| 部署工序 | 多一颗 SN 芯片 / 多一道烧录 | 零额外硬件、零额外工序 |
| 寄存器映射 | 96 位无处放 | 拆成两个 16 位输入寄存器 |
小灶 06 · Modbus 组帧的字节序(大端数据 / 小端 CRC)
表面看 :把数据塞进发送缓冲区不就完事?
真正巧妙 :它把"数据"和"CRC"的字节序用两个独立函数分别处理 ------数据按大端 (高字节先),CRC 按小端(低字节先)。这一拆,消灭了"字节序写反"这个嵌入式最常见、最难查的经典 Bug。
一、背景:字节序是什么
一个 16 位数 0x1234,在串口线上要分两个字节发。先发哪个?
- 大端(Big-Endian) :先发高字节
0x12,再发0x34。也叫"网络字节序"。 - 小端(Little-Endian) :先发低字节
0x34,再发0x12。x86、ARM 内存里常用。
Modbus 协议规定:
- 数据字段(寄存器值、地址等)用大端。
- CRC16 校验值用小端(低字节先发)。
这是两个相反的约定,新手极容易搞混。
二、核心代码(两个函数,各管各的)
c
static void MB_AppendWord(uint16_t w)
{
/* Modbus 大端序: 高字节在前 */
MB_AppendByte((uint8_t)(w >> 8)); // 先高字节 0x12
MB_AppendByte((uint8_t)(w & 0xFFu)); // 后低字节 0x34
}
static void MB_FinalizeCRC(void)
{
uint16_t crc = MB_CRC16(s_tx_buf, s_tx_len);
/* CRC 低字节在前 (Modbus 小端序) */
MB_AppendByte((uint8_t)(crc & 0xFFu)); // 先低字节
MB_AppendByte((uint8_t)((crc >> 8) & 0xFFu)); // 后高字节
}
MB_AppendWord 用于所有寄存器值、起始地址、数量等数据 ;MB_FinalizeCRC 在组帧末尾调用,单独处理 CRC 的两个字节。
三、原理:为什么要"分开成两个函数"
如果只写一个"追加 16 位"的函数,组数据时用大端,到 CRC 时又得反过来写一遍,代码里就会出现:
c
append(w >> 8); append(w & 0xFF); // 数据
...
append(crc & 0xFF); append(crc >> 8); // CRC,写法相反
两处写法相反,肉眼极易抄错,而且编译器不会报错------发出来的帧主站收了 CRC 不对,排查要半天。
本代码把"大端追加"固化进 MB_AppendWord,所有数据统一走它;CRC 单独一个 MB_FinalizeCRC,职责单一、不可能混淆。这就是用函数边界强制协议正确性。
四、原理:CRC 是"计算值"不是"数据"
注意 CRC 不是从寄存器里读出来的数据,而是对整帧已组好的字节实时算出来的:
c
uint16_t crc = MB_CRC16(s_tx_buf, s_tx_len);
它必须在所有数据追加之后 才算,算完再按小端追加。顺序不能乱:组所有数据 → 算 CRC → 追加 CRC。
💡 小白心法:协议组帧时,把"字节序约定"封装成独立的 append 函数,别在业务代码里到处手写移位。 谁大端、谁小端,让函数名和注释说清楚,业务代码只管"塞值"。
五、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| 数据/CRC 字节序相反 | 手写两处相反移位,极易抄反 | 拆成 AppendWord(大端) / FinalizeCRC(小端) |
| CRC 时机 | 边组边算,漏算前缀 | 数据全组完再算再追加 |
| 职责单一 | 一个函数又管数据又管 CRC | 两函数各管一段,编译器也帮着查 |
小灶 07 · 位标志寄存器压缩(一个寄存器当两个用)
表面看 :故障安全的"是否启用"和"是否正触发"是两个状态,用两个寄存器存不行吗?
真正巧妙 :它把两个布尔塞进同一个 16 位输入寄存器的不同位 (bit0 + bit8),主站读一次寄存器就能同时拿到两个信息------省寄存器地址、省总线带宽。
一、背景:Modbus 寄存器是"稀缺资源"
每个保持/输入寄存器是一个 16 位地址单元。虽然地址空间大,但:
- 主站轮询要逐个读,寄存器越多,轮询越慢、总线越忙。
- 产品文档里每多一个寄存器,对接的上位机就要多配一项。
- 布尔状态(是/否)本身只占 1 个 bit,独占 16 位太浪费。
所以行业惯例:用"位域(bit field)"把多个布尔打包进一个寄存器。
二、核心代码
故障安全状态寄存器 IR_ADDR_FAILSAFE = 0x000F:
c
case IR_ADDR_FAILSAFE:
/* 状态位图: bit0=使能, bit8=当前触发 */
value = (uint16_t)((FlashParam_Get()->fail_safe_en ? 1u : 0u) |
(s_fail_safe_active ? 0x100u : 0u));
break;
fail_safe_en(配置:是否启用)→ 放在 bit0 (值0x0001)。s_fail_safe_active(运行时:当前是否处于安全态)→ 放在 bit8 (值0x0100=0x100u)。
主站读这一个寄存器,就能同时知道"你配了启用吗" + "现在触发了吗"。
寿命告警寄存器 IR_ADDR_LIFE_ALARM = 0x000A 也同理:
c
case IR_ADDR_LIFE_ALARM:
value = (uint16_t)BSP_Relay_GetLifeAlarmMask(); // bit0~3 对应 4 路
break;
4 路继电器的"是否到寿命"4 个布尔,塞进 bit0~bit3。
三、原理:位运算是"免费的组合"
合并两个标志用按位或 |:
c
value = (enable ? 1u : 0u) | (active ? 0x100u : 0u);
0x100u就是 bit8(1 << 8)。|把两个独立 bit 叠到同一个 16 位数上,互不干扰。- 主站解析时按位与
& 0x01取使能、& 0x100取触发,各取所需。
为什么选 bit0 和 bit8 而不是 bit0 和 bit1?因为 bit0 是"配置态"、bit8 是"运行态",语义上分属不同维度,隔开放更清晰,也方便以后 bit1~bit7 留给其他配置、bit9~bit15 留给其他运行标志。这是为未来扩展预留位图空间的巧思。
四、原理:位域 vs 真正的 struct bitfield
注意代码里没有用 C 语言的 struct { unsigned en:1; unsigned active:1; } 这种位域语法,而是手动移位 0x100u。原因:
- 手动移位字节序/布局 100% 可控、可读、可文档化,主站按位解析不会因编译器对齐差异翻车。
- 编译器对 struct bitfield 的位排列是实现定义的,跨编译器可能不一致------协议代码里要避开。
💡 小白心法:协议/寄存器映射里,别用 struct bitfield,用显式
(1u << N)手动移位。 可读、可移植、可查。
五、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| 多布尔占多寄存器 | 浪费地址、拖慢轮询 | 一个寄存器多位打包 |
| bit 选择 | 随便堆 bit0/bit1 | 配置/运行分维度,预留扩展 |
| 位域实现 | 用 struct bitfield,跨编译器不稳 | 显式 << / 0x100u 手动移位 |
小灶 08 · 异常计数语义边界(广播帧为何不计入)
表面看 :收到非法请求就回异常帧,顺便把"异常次数"加一,有啥好想的?
真正巧妙 :它用一行
if (slave_addr != 广播地址)守住了"异常响应计数"的语义边界------广播帧本就不回包,自然不该算进"我回复了多少异常"。
一、背景:Modbus 广播地址
Modbus 规定地址 0 是广播地址 :一条命令发到总线上,所有从机都执行,但谁都不回复(否则大家同时回,总线就撞车了)。
本项目也支持广播(MB_BROADCAST_ADDR = 0x00)。广播典型用途:同时让一串设备继电器全部断开。
二、核心代码
构造异常响应时:
c
static void MB_BuildException(uint8_t fc, uint8_t slave_addr, uint8_t ex_code)
{
/* 仅对"非广播"地址计异常响应 (#3): 广播帧本就不回复, 不应计入 */
if (slave_addr != MB_BROADCAST_ADDR) {
s_ex_resp_count++;
}
s_tx_buf[0] = slave_addr;
s_tx_buf[1] = (uint8_t)(fc | 0x80u);
s_tx_buf[2] = ex_code;
s_tx_len = 3u;
MB_FinalizeCRC();
}
注意:s_ex_resp_count++ 被 if (slave_addr != 0) 包着。
三、原理:计数的"目的"决定计数的"边界"
这里有两套计数,目的不同,所以对广播的处理截然相反:
| 计数器 | 目的 | 广播帧算不算 |
|---|---|---|
s_frame_count(有效帧计数) |
"通信正不正常" | 算(广播也是有效通信,能解除故障安全) |
s_ex_resp_count(异常响应计数) |
"我回复了多少条异常" | 不算(广播不回包,谈不上"回复") |
看 Modbus_Poll 里对有效帧的处理:
c
s_frame_count++; // 任意有效帧(含广播)都 +1
s_last_valid_tick = HAL_GetTick(); // 含广播 → 刷新心跳
而异常计数只在"真的回了异常包"时才 +1。这就是按语义精确计数------不是"发生了什么就记什么",而是"这个指标要衡量什么就记什么"。
四、原理:为什么这行 if 很容易被漏掉
新手写异常计数,往往直接 s_ex_resp_count++ 放在 MB_BuildException 里,因为"构造异常就 +1 嘛"。但广播场景下:
- 主站发了广播非法命令(比如写只读寄存器)。
- 从机构造异常响应(内部流程走了一遍)。
- 但因为是广播,最终不发送 (
need_reply = 0,Modbus_Poll末尾if (need_reply && s_tx_len>0)拦住)。 - 如果没那行
if,计数器会凭空 +1,误导运维以为"设备在频繁报异常"。
一行 if 守住了指标的真实含义。
💡 小白心法:写计数器前先问自己"这个指标到底要反映什么"。 同一件事(收到非法帧)在不同指标里该不该计数,取决于指标的定义,不是取决于代码走了哪条分支。
五、巧妙点小结
| 巧妙之处 | 朴素做法的坑 | 本代码做法 |
|---|---|---|
| 异常计数边界 | 构造即 +1,广播被误计 | if (addr != 广播) 守住语义 |
| 与有效帧计数对照 | 两套计数逻辑混为一谈 | 按"指标目的"分别处理 |
| 指标真实性 | 计数反映"代码走了分支" | 计数反映"实际语义发生" |
小灶 09 · 零阻塞主循环(超循环架构的可预测性)
表面看 :
while(1)里调几个函数,转圈执行而已?真正巧妙 :主循环里的每一个函数都是"非阻塞、瞬间返回"的------有数据才处理,没数据立刻走人。这保证了系统永远可预测 、看门狗永远饿不死,是整个固件稳定性的地基。
一、背景:阻塞是 MCU 的隐形杀手
新手常写出这种代码:
c
// 危险写法!
while (UART_Receive() == 0) { } // 死等一个字节
HAL_Delay(1000); // 阻塞 1 秒
后果:
- 死等串口时,其他所有事(喂狗、控继电器、刷 LED)全停摆。
- 一旦
HAL_Delay太长,独立看门狗(IWDG 6.4 秒超时)就会触发复位,设备反复重启。 - 系统行为不可预测,调试时"莫名其妙卡住"。
二、核心代码(本项目的真实主循环)
c
while (1)
{
Modbus_Poll(); // 无帧 → 直接 return
Modbus_CheckFailSafe(); // 无超时 → 直接 return
RS232Shell_Process(); // 没收完整行 → 直接 return
BSP_DI_Scan(); // 基于 tick 消抖,瞬时
BSP_LED_Refresh(); // 基于 tick 闪烁,瞬时
SystemApp_FeedWatchdog(); // 喂 IWDG
}
每个函数内部都是"先判断有没有活干,没有立刻返回":
c
void Modbus_Poll(void)
{
if (!BSP_RS485_IsFrameReady()) {
return; // 没帧,1 微秒就出去
}
...
}
c
void RS232Shell_Process(void)
{
if (!Shell_HasCompleteLine()) {
return; // 行没收完,直接走
}
...
}
三、原理:轮询式(前后台)架构
这是 MCU 量产固件最主流的"超循环(super-loop)/ 前后台"架构:
- 前台:中断(串口收字节、定时器 tick)只做"把数据搬进缓冲区"这种极短动作。
- 后台:主循环高速轮询各缓冲区,有数据就处理,处理完继续转。
每一圈主循环耗时极短(微秒级),所以:
- 响应及时:数据一到下一圈就被处理。
- 看门狗安全 :每圈都
FeedWatchdog(),IWDG 永远不被触发。 - 行为可预测:没有哪个任务会卡住别人,最坏执行时间可估算。
对比"多任务 RTOS":本项目功能足够简单,用超循环比上 RTOS 更轻、更稳、更易通过工业认证(少一层调度不确定性)。
四、原理:为什么故障安全检测要放 Poll 之后
注释写得很明白:
c
/* #1 通信故障安全检测 ... 必须在 Modbus_Poll 之后调用,
* 以便先刷新"最近有效帧"时间戳 */
Modbus_CheckFailSafe();
因为 Modbus_Poll 收到有效帧时会刷新 s_last_valid_tick(见小灶 01)。如果顺序反过来,先检查再收帧,同一圈里"刚收到的帧"要等下一圈才被看见,极端情况会多误判一个循环周期。顺序本身也是一种严谨。
五、巧妙点小结
| 巧妙之处 | 阻塞式写法的坑 | 本代码做法 |
|---|---|---|
| 函数非阻塞 | 死等/延时卡死系统 | 无活干立刻 return |
| 看门狗安全 | 长延时触发 IWDG 复位 | 每圈喂狗,单圈远小于超时 |
| 架构选择 | 小项目硬上 RTOS,复杂化 | 超循环,轻量可预测 |
| 调用顺序 | 检测先于收帧,多误判一圈 | Poll 后紧跟 CheckFailSafe |
结语:给小白的"心法三句话"
- 凡时间差,用无符号减法(小灶 01)。
- 凡累计量,先想 RAM/Flash 怎么分(小灶 03、04)。
- 凡写计数器,先定指标语义 (小灶 08);凡位操作,用手动移位别用 struct bitfield(小灶 07)。
把这 9 篇吃透,你就能从"读懂别人代码"跨到"自己设计出稳当代码"。