STM32从零到量产开发:四路继电器工业控制模块开发原理小灶 · 合集

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.cModbus_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 路继电器

好处:

  1. :位运算读 RAM 是单周期,读 GPIO 要访问外设寄存器、慢得多。
  2. :外部触点可能有抖动/干扰,Get 直接返回逻辑意图而非物理引脚,更可靠。
  3. 统一真相源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 有三个铁律(小白必须先懂):

  1. 写前必须整页擦除 (本芯片 2KB/页),擦除后全 0xFF
  2. 最小编程单位是半字(16 bit)。
  3. 擦写寿命有限(约 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 擦写次数------完美绕开"掉电丢失"和"寿命爆掉"两难。

一、需求与矛盾

工业设备要汇报"累计运行小时",要求:

  1. 断电不丢:重启后小时数不能归零。
  2. 连续准确:不能因为重启就少算这次上电的时间。
  3. 不能老写 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_SaveResetCountFlashParam_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 = 0Modbus_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)只做"把数据搬进缓冲区"这种极短动作。
  • 后台:主循环高速轮询各缓冲区,有数据就处理,处理完继续转。

每一圈主循环耗时极短(微秒级),所以:

  1. 响应及时:数据一到下一圈就被处理。
  2. 看门狗安全 :每圈都 FeedWatchdog(),IWDG 永远不被触发。
  3. 行为可预测:没有哪个任务会卡住别人,最坏执行时间可估算。

对比"多任务 RTOS":本项目功能足够简单,用超循环比上 RTOS 更轻、更稳、更易通过工业认证(少一层调度不确定性)。

四、原理:为什么故障安全检测要放 Poll 之后

注释写得很明白:

c 复制代码
/* #1 通信故障安全检测 ... 必须在 Modbus_Poll 之后调用,
 * 以便先刷新"最近有效帧"时间戳 */
Modbus_CheckFailSafe();

因为 Modbus_Poll 收到有效帧时会刷新 s_last_valid_tick(见小灶 01)。如果顺序反过来,先检查再收帧,同一圈里"刚收到的帧"要等下一圈才被看见,极端情况会多误判一个循环周期。顺序本身也是一种严谨。

五、巧妙点小结

巧妙之处 阻塞式写法的坑 本代码做法
函数非阻塞 死等/延时卡死系统 无活干立刻 return
看门狗安全 长延时触发 IWDG 复位 每圈喂狗,单圈远小于超时
架构选择 小项目硬上 RTOS,复杂化 超循环,轻量可预测
调用顺序 检测先于收帧,多误判一圈 Poll 后紧跟 CheckFailSafe

结语:给小白的"心法三句话"

  1. 凡时间差,用无符号减法(小灶 01)。
  2. 凡累计量,先想 RAM/Flash 怎么分(小灶 03、04)。
  3. 凡写计数器,先定指标语义 (小灶 08);凡位操作,用手动移位别用 struct bitfield(小灶 07)。

把这 9 篇吃透,你就能从"读懂别人代码"跨到"自己设计出稳当代码"。

相关推荐
小羊先生car1 小时前
F429-HAL 库驱动框架(2026/7/22)
单片机·嵌入式硬件
小羊先生car2 小时前
F429-SysTick(2026/7/22)
stm32·单片机·嵌入式硬件
hongmai6668883 小时前
ESP32-C61-WROOM-1-N8R2:Wi-Fi 6与RISC-V融合的中坚力量
人工智能·单片机·嵌入式硬件·物联网·智能家居·risc-v
炸膛坦客12 小时前
单片机/C/C++八股:(二十六)IIC 专题(I²C)---- 上集
c语言·c++·单片机
华清远见IT开放实验室17 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
AI的探索之旅19 小时前
AI辅助原理图评审:电源去耦、BOOT引脚、VCAP——19项逐一核查,遗漏?不存在的
人工智能·vscode·嵌入式硬件
茯苓gao19 小时前
嵌入式开发笔记:EtherCAT协议从硬件到软件完整配置指南——从零搭建一套EtherCAT通信系统
笔记·嵌入式硬件·学习
GeekArch19 小时前
第24讲:Vibe模式代码风格控制——适配Keil/STM32工程规范
人工智能·stm32·单片机·嵌入式硬件·mcu·决策树·ai编程
Freedom_my20 小时前
STM32项目3
stm32·单片机·嵌入式硬件