STM32从零到量产开发:四路继电器工业控制模块开发实战笔记(小白视角)

STM32从零到量产开发:四路继电器工业控制模块开发实战笔记(小白视角)

这份笔记不是"教程",而是一个刚入门的人 陪你一起,把这颗 STM32F103RCT6 量产固件从"看不懂"到"能改、能加功能"的完整心路。

重点不是记住每一行代码,而是学会怎么读、怎么想、怎么把需求变成代码

文中所有代码片段都来自本项目真实源码,可直接对照 Core/SrcCore/Inc 下文件阅读。


0. 小白自白:我接到了什么活

项目叫"P02 四路继电器工业控制模块",芯片是 STM32F103RCT6(256KB Flash、48KB RAM,性价比之王)。它要干的事很明确:

  • 通过 RS485(Modbus RTU 从站) 接收上位机指令,控制 4 路继电器;
  • 通过 RS232 提供一个本地调试命令行(看状态、改参数);
  • 4 路 数字输入(DI) 可采集外部信号;
  • 参数(地址、波特率、通信使能......)要掉电不丢
  • 最后要能稳定量产,所以还要有故障安全、诊断、设备履历。

我刚拿到这份代码时,文件夹里有 Core/Drivers/Middlewares/MDK-ARM/......一堆东西,30 多个 .c 文件。第一反应是:头大。但后来发现,量产代码的核心逻辑其实就集中在七八个文件里,其他都是 CubeMX 自动生成的"地基",不用细看。

记住第一句话:不要试图一次看懂所有代码,先看懂"骨架"。


1. 第一关:看懂一个陌生的大工程

1.1 先读"需求文档",而不是先读代码

新手最容易犯的错:打开 main.c 从上往下啃。错。先看两样东西:

  • README_量产代码说明.md ------ 整体架构、模块职责、寄存器映射;
  • 产品说明书.md ------ 对外功能规格(Modbus 功能码、寄存器地址、命令表)。

读完后你会建立一个"外部视角":这个设备对外承诺了什么。之后看代码,就是在验证"它是怎么兑现这些承诺的"。需求优先,代码只是需求的实现。

1.2 再读 main.c ------ 主循环是程序的"心电图"

打开 Core/Src/main.c,直奔 while(1) 主循环。本项目的主循环极其干净:

c 复制代码
while (1) {
    Modbus_Poll();           // RS485 Modbus 处理(有帧才执行)
    Modbus_CheckFailSafe();  // #1 故障安全检测(高频轮询)
    RS232Shell_Process();    // RS232 命令行(收到完整行才解析)
    BSP_DI_Scan();           // DI 消抖扫描
    BSP_LED_Refresh();       // LED 刷新
    SystemApp_FeedWatchdog();// 喂独立看门狗
}

这是整个程序的全部生命节奏 。你看,没有任何"阻塞等待"------没有 delay(1000) 卡死主循环。所有模块都是"轮询 + 非阻塞"的:有数据就处理,没数据立刻返回。这就是裸机前后台系统的典型写法,也是量产固件必须掌握的节奏。

小白心法:看懂主循环,就等于看懂了程序"每秒都在干什么"。其他函数都是被这几行调用的"零件"。

1.3 顺藤摸瓜,画一张分层图

main.c 里那几个函数出发,往上追溯,你会自然画出三层架构:

复制代码
        ┌─────────────────────────────────────────┐
层3 应用 │  main.c 主循环 / system_app / rs232_shell │  ← "业务编排"
        ├─────────────────────────────────────────┤
层2 协议 │  modbus_rtu(Modbus RTU 从站协议栈)      │  ← "通信语言"
        ├─────────────────────────────────────────┤
层1 硬件 │  bsp_rs485 / bsp_rs232 / bsp_relay /     │  ← "手脚"
        │  bsp_di / bsp_led / flash_param           │
        └─────────────────────────────────────────┘

这张图是你自己的理解工具,不一定要画得多标准。关键是建立"调用方向自上而下、依赖方向自下而上"的直觉:

  • 应用层决定"什么时候该做什么";
  • 协议层负责"把字节流翻译成语义"(读线圈/写寄存器);
  • BSP 层只懂"怎么操作某一个 GPIO、串口、Flash",完全不关心业务逻辑。

为什么这么分?因为分层后,换一颗芯片只改 BSP,加一个功能只改应用层。这是量产代码能复用、能维护的根本。

1.4 CubeMX 生成代码 vs 自己写的代码(千万别乱改的区域)

你会看到 main.h 里有很多 #define DI1_Pin ...,这些不是手写的 ,是 CubeMX 配置引脚后自动生成的,位于 /* Private defines */ 区。代码里还有大量 /* USER CODE BEGIN */ / /* USER CODE END */ 标记。

铁律:CubeMX 生成的区域不要手动改 (改了下次生成会被覆盖);只在 USER CODE 区间写自己的逻辑。这也是为什么引脚宏放在 main.h 而不是各模块 .h------它由工具统一管理,挪走反而破坏工具链。

1.5 建立"三层 + 主循环"心智模型

到此,第一关结束。你不需要看懂任何函数的内部实现,只要能回答三个问题:

  1. 程序主循环每秒在轮询什么?(答:Modbus、故障安全、Shell、DI、LED、喂狗)
  2. 代码分哪几层?(答:应用 / 协议 / BSP)
  3. 哪些代码不能碰?(答:CubeMX 生成区)

够了,可以进下一关。


2. 第二关:把每个模块拆开读(理解既有代码)

现在选几个核心模块,逐个拆开。目的不是背下来,而是看懂"作者当年为什么这么写"------这正是为后面自己设计打基础。

2.1 RS485 模块:为什么是"线性帧缓冲 + IDLE 中断"?

Modbus RTU 是帧结构不明确 的协议:它靠"帧间静默时间"(3.5 个字符时间)来分隔一帧。所以 RS485 接收不能用"收到一个字节处理一个字节",必须攒够一整帧再处理

本项目做法:串口开 IDLE 中断 (线路空闲即一帧结束)→ 触发后把整帧复制到线性缓冲 s_rx_frame,置 frame_ready 标志。Modbus_Poll 检测到标志才解析。

小白疑问:"为什么不用环形 FIFO?" → 因为 Modbus 是帧协议,必须整帧校验(CRC 覆盖整帧)。环形 FIFO 适合"字节流、随时可取"的场景(比如下面 RS232 Shell 逐字符收命令),而 Modbus 必须按帧处理。

这就是"协议模型决定缓冲模型"的第一个例子,记住它,后面会反复用到。

2.2 RS232 模块:为什么是"环形 FIFO"?

RS232 调试命令行是逐字符到达 的:你敲 S T A T S \r,每按一下来一个字节。它不需要"整帧"概念,而是要"不丢字节地缓存,直到收到回车再一次性解析"。

于是这里用了环形缓冲区(ring buffer) :中断里每收到一个字节就 push 进环,主循环 RS232Shell_Process 从环里 pop 出字符拼成行。环形结构的妙处是写指针和读指针各自前进、自动回绕,不用担心覆盖还没读的数据(前提是缓冲够大)。

对比 2.1 vs 2.2:同样是串口,一个用"线性帧缓冲+IDLE",一个用"环形 FIFO"。差别完全来自上层协议要不要"整帧"。这就是读懂代码的关键------永远问"它服务的协议是什么"。

2.3 继电器 BSP:一个位图缓存的妙处

bsp_relay.cs_relay_state(一个字节的 bit0~3)缓存 4 路继电器状态。好处:

  • 读状态不用再去读 GPIO(快、且逻辑状态与物理状态解耦);
  • BSP_Relay_SetAll(mask) 一次设置 4 路,靠位运算。
c 复制代码
static uint8_t s_relay_state = 0u;  // bit0~3 对应 RELAY1~4

小白心法:用"位图"表示一个只有 0/1 的多路状态,是嵌入式极常见的套路。4 路 = 4 个 bit,一个字节搞定。

2.4 Flash 参数:掉电保存的"三板斧"

flash_param.c 解决一个问题:参数要掉电不丢,但 Flash 不能随便写(擦写寿命约 1 万次)。它用了三招:

  1. magic 字 :写入时打标记 PARAM_MAGIC,上电读回时先校验"这页到底有没有有效数据";
  2. CRC16 校验Param_CalcCRC 对整个结构体算校验,防止读到一半损坏的数据被当成真参数;
  3. 只在必要时写:地址/波特率这种参数,只有 Modbus 写命令来了才存一次,绝不高频擦写。
c 复制代码
int8_t FlashParam_Save(uint8_t slave_addr, uint8_t baud_index, uint8_t link_enable,
                       uint8_t fail_safe_en, uint8_t fail_safe_sec)

注意这个函数是5 个参数 。其中 0 / 0xFF 表示"该项不修改"(见后面设计篇会讲为什么这样设计)。

小白心法:Flash 当成"硬盘"------写入慢、有寿命、要先擦后写。所以运行时只改 RAM 副本,只有要持久化时才写 Flash

2.5 Modbus 协议栈:dispatch 是怎么分流 8 个功能码的

modbus_rtu.c 的核心是 Modbus_Poll:取帧 → 校验 CRC → 按功能码 fc 分流到 MB_HandleReadCoils / MB_HandleWriteSingleReg 等处理函数。这是一个典型的 switch-dispatch(分发) 结构。

每个处理函数内部套路都一样:

  1. 解析起始地址、数量;
  2. 范围合法性检查 (超界就回异常 MB_BuildException);
  3. 读/写实际硬件或变量;
  4. 组帧 + 算 CRC 发出去。
c 复制代码
switch (reg_addr) {
case IR_ADDR_UPTIME:     value = SystemApp_GetUptime();     break;
case IR_ADDR_RESETCOUNT: value = SystemApp_GetResetCount(); break;
case IR_ADDR_FRAMECOUNT: value = s_frame_count;             break;
...
}

看到这个 switch 你就明白:加一个新寄存器,就是在这个 switch 里加一个 case。协议层的可扩展性,就藏在这种"查表/分发"结构里。


3. 第三关:用真需求验证理解(第一次联机)

光读不练假把式。把板子用 485 转 USB 接到电脑,开一个 Modbus 调试助手(9600/8N1/HEX),发一帧读线圈命令。

我第一次成功收到 01 01 01 00 51 88 时,那种"代码真的活了"的兴奋,是任何教程给不了的。这帧的含义是:地址 01、功能码 01(读线圈)、返回 1 字节数据 00(全断)、CRC 51 88

这一步的意义 :把"我在读代码时建立的想象"和"真实硬件行为"对上了。从此你看 MB_HandleReadCoils 时,脑子里有了一个具体的字节流在流动。


4. 第四关:从"读懂"跨到"设计"(最核心的一跃)

读到这,你已经能"看懂"代码了。但看懂 ≠ 能写。真正的分水岭,是把"需求"翻译成"代码结构"的能力。

我总结了一套自己的四步法,后面三个功能都是它跑出来的:

复制代码
需求 → 数据 → 接口 → 实现
  │       │       │       │
 问"要   问"需要  问"放   问"具体
 什么"   哪些数  在哪层   怎么写
         据"     谁提供"

举个生活化的例子:老板说"灯坏了要报警"。

  • 需求:通信断了要自动保护设备。
  • 数据:我得知道"多久没收到通信了" → 需要一个"最近一次有效帧的时间戳"。
  • 接口 :这个时间戳放在哪?谁更新?谁检查?→ 协议层 Modbus_Poll 收到帧就更新,主循环里一个 Modbus_CheckFailSafe() 检查。
  • 实现:比较"当前时间 - 时间戳"是否超过阈值,超过就动作。

设计的核心永远是"数据从哪来、到哪去"。 先把数据流想清楚,代码只是把数据流串起来。


5. 第五关:亲手实现三个增强功能(完整复盘)

下面是本项目真实做过的三件事,按"小白怎么想 → 怎么落地"完整复盘。

5.1 #1 通信故障安全(Fail-safe on comm loss)

需求原文:连续 N 秒无有效 Modbus 帧 → 自动切到安全态。

小白的第一反应(naive)

"加个定时器中断,超时就把继电器全关了。"------听起来对,但马上遇到问题:定时器中断里关继电器、闪 LED,中断里做的事太多;而且"N 秒"还是可配的,改配置还得重设定时器。

怎么重新想的

回到第四关的四步法:

  • 数据 :需要"上一次有效帧的时刻"。→ 在协议层维护一个 s_last_valid_tick
  • 接口 :谁更新它?Modbus_Poll 每收到一个有效帧 (CRC 通过、地址匹配)就刷新它。谁检查?主循环里高频调用的 Modbus_CheckFailSafe()
  • 为什么用轮询而非中断?因为"超时检测"对实时性要求不高(秒级),放在主循环轮询最简单、最不易出错,还不污染中断。

还有两个关键设计决策:

(a) 上电基准防误触发 。刚上电时本来就没有通信,如果时间戳初始化为 0,马上就会误判"超时"。所以 Modbus_Init 里把它初始化为当前 tick:

c 复制代码
/* 以当前时刻作为"最近有效帧"基准, 防止上电后立即误触发故障安全 */
s_last_valid_tick = HAL_GetTick();

(b) 安全态定义成"全部断开"(失电安全)。工业惯例:通信丢失时,设备应进入"最不危险"的状态。继电器是低电平吸合,断开 = 写高电平 = 失电,是最安全的。所以:

c 复制代码
void Modbus_CheckFailSafe(void)
{
    const param_t *p = FlashParam_Get();
    if (p->fail_safe_en == 0u || p->fail_safe_sec == 0u) {
        return;                       // 未启用则不处理
    }
    if (s_fail_safe_active) {
        return;                       // 已触发: 持续保持断开, 不重复动作
    }
    uint32_t now     = HAL_GetTick();
    uint32_t elapsed = now - s_last_valid_tick;          // 减法对 32 位回绕安全
    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);          // LED 快闪告警
    }
}

© 自动恢复 。收到任意有效帧就解除------在 Modbus_Poll 里,只要帧有效,立刻刷新时间戳并清标志:

c 复制代码
s_last_valid_tick = HAL_GetTick();
if (s_fail_safe_active) {
    s_fail_safe_active = 0u;
    BSP_LED_SetMode(LED_MODE_BLINK_SLOW);   // 恢复正常指示, 等主站重新下发控制
}

(d) 参数可配、存 Flash 。超时秒数 fail_safe_sec 和使能 fail_safe_en 加进 FlashParam_Save 的第 4、5 个参数,写保持寄存器 0x0003/0x0004 即可改,立即生效(下次轮询读到的就是新值)。

设计复盘 :同一功能,"定时器中断"和"主循环轮询时间戳"都能做。选后者是因为------更简单、不碰中断、参数还能动态配。量产代码的第一原则是稳,而不是炫

5.2 #3 诊断计数 + 继电器寿命

需求:统计通信质量(接收帧/CRC 错/异常响应),以及每路继电器累计动作次数(用于寿命管理)。

关键设计:动作次数"只在状态翻转时 +1"

这是本项目最容易被忽略、却最能体现"设计思维"的一处。BSP_Relay_Set 里:

c 复制代码
uint8_t old_state = (s_relay_state >> idx) & 0x01u;
/* 仅当目标状态与当前状态不同才执行动作 */
if (((state != 0u) ? 1u : 0u) == old_state) {
    return;                         // 目标状态 == 当前状态, 直接返回, 不算一次动作
}
... // 真正翻转 GPIO
s_relay_act_count[idx]++;           // 仅实际翻转时累计

为什么这么设计? 因为继电器的物理寿命 = 实际吸合/释放次数。如果上位机连发 10 次"打开继电器",而它本来就开着,这 10 次里没有一次真实机械动作,不该计入寿命。只统计"状态真正翻转"的那一次,寿命计数器才准。顺带还避免了无谓的 GPIO 重写。

寿命告警 :超过阈值 RELAY_LIFE_THRESHOLD = 100000 次,置位一个位图 BSP_Relay_GetLifeAlarmMask(),供 Modbus 读取。

异常响应计数(一个细节) :异常响应在 MB_BuildException 里 +1。但广播帧(地址 0)本来就不回复,不该算"异常"。于是加了个判断:

c 复制代码
static void MB_BuildException(uint8_t fc, uint8_t slave_addr, uint8_t ex_code)
{
    if (slave_addr != MB_BROADCAST_ADDR) {   // 仅非广播帧计异常
        s_ex_resp_count++;
    }
    ...
}

对外暴露 :这些计数器通过输入寄存器(FC04 可读)开放出去------帧计数、CRC 错、异常计数、4 路动作次数、寿命告警位图,全部在 MB_HandleReadInputRegsswitch 里加 case 即可,完美印证了 2.5 说的"加寄存器就是加 case"。

5.3 #4 Modbus 增强(FC17 / 广播 / 设备信息)

需求:加 FC17 Report Slave ID、广播地址 0、设备信息寄存器(SN/固件/运行小时)。

(a) 广播地址 0 :原本就支持------is_broadcast 时执行写操作但不回包。理解它很重要,因为故障安全要"收到广播帧也算通信正常",所以 s_frame_count++s_last_valid_tick 刷新放在广播判断之前。

(b) FC17 Report Slave ID:这是 Modbus 标准的"设备自报家门"命令,主机用它扫描"总线上谁在线"。实现就是回一段固定结构(从机 ID + 运行状态 0xFF + 设备类型/固件版本):

c 复制代码
static void MB_HandleReportSlaveID(const uint8_t *frame, uint16_t len, uint8_t slave_addr)
{
    extra[0] = (uint8_t)(DEVICE_TYPE & 0xFFu);
    extra[1] = (uint8_t)((FW_VERSION >> 8) & 0xFFu);
    extra[2] = (uint8_t)(FW_VERSION & 0xFFu);
    ... // 组帧: slave_id + run_status(0xFF) + extra
}

© 运行小时怎么"跨重启连续"? 这是个经典坑。如果每次都把"本次运行秒数"直接写 Flash,Flash 早被擦烂了。本项目用 "基准 + 增量"

  • run_sec_base:上次掉电前累计的秒数,存在 Flash(掉电不丢);
  • 运行时实时值 = run_sec_base + 本次上电秒数(HAL_GetTick()/1000)
  • 只有要存 Flash 时(改参数、复位计数),才把已流逝的上电时间折算进基准:
c 复制代码
static void Param_UpdateRunSec(void)
{
    s_param.run_sec_base = s_param.run_sec_base + (HAL_GetTick() / 1000u);
}

这样运行小时在多次重启间连续累计,又几乎不增加 Flash 擦写次数。这就是"用 RAM 扛高频、用 Flash 扛持久"思想的落地。

(d) 序列号 SN 从哪来? 每颗 STM32 芯片在出厂时烧录了 96-bit 唯一 ID,地址 0x1FFFF7E8。直接读它、三段异或成一个 32-bit SN,天然全球唯一,不用额外烧录:

c 复制代码
static uint32_t MB_GetDeviceSN(void)
{
    const uint32_t *uid = (const uint32_t *)DEV_UID_BASE;  // 0x1FFFF7E8
    return (uid[0] ^ uid[1] ^ uid[2]);
}

设计复盘:SN 不用"出厂时人工写",而是"读芯片自带的唯一 ID"------这是把"唯一性"交给硬件而非流程,量产时少一道工序、少一个出错点。


6. 第六关:调试命令 + 注释 + 文档(交付即维护)

功能写完了,但别人(包括未来的你)怎么验证?本项目在 RS232 Shell 里加了 STATSFS 命令:

  • STATS:打印帧计数、CRC 错、异常、各路动作次数、寿命告警、运行时间;
  • FS / FS EN 0|1 / FS SEC <1-255>:查看/配置故障安全。

关于注释 :用户特别强调"加必要注释"。我养成一个习惯------注释不写"这段代码做了什么"(代码本身看得懂),而写"为什么这样做":

c 复制代码
/* 仅当目标状态与当前状态不同才执行动作, 既避免无谓的 GPIO 翻转,
 * 也保证动作计数只统计真实的状态变化 (寿命统计准确 #3) */

这种注释在未来改代码时价值连城------它解释了"约束",防止后人好心把 if (...== old_state) return; 删掉,导致寿命计数失真。

关于文档 :每加一个功能,同步更新 README_量产代码说明.md 的寄存器映射表、命令表。文档落后代码,是量产项目的头号隐患。


7. 第七关:编译烧录验证(闭环,含一个真坑)

代码写完,用 Keil MDK(ARMCC V5) 编译。第一次构建结果:

复制代码
0 Error(s), 2 Warning(s)

两个警告都是 #870-D: invalid multibyte character sequence,定位到 rs232_shell.c 里两处中文 Shell_SendStr 字符串。原因:源文件是 UTF-8,而 Keil ARMCC V5 把中文字符串字面量当成非法多字节序列。

修法 :把这两处中文提示改成英文 ASCII(与 Shell 全英文输出约定一致)。而中文注释不触发该警告,保留不动。

这是一个真实踩过的坑,也是一条经验:Keil ARMCC V5 + UTF-8 源文件下,中文注释安全、中文字符串字面量会报警。运行时输出的文本一律用英文,省心。

重新构建后应为 0 Error / 0 Warning,再烧录、用 RS485 联机验证三个功能,整个开发闭环才算完成。


8. 心法总结:读懂代码 vs 设计代码的两条路径

回看全程,我把能力拆成两条不同的腿:

读懂代码(输入) 设计代码(输出)
出发点是 已有的代码 一个需求/问题
思考方向 自上而下顺藤摸瓜 自下而上找数据流
关键问题 "它为什么这么写?" "我需要哪些数据、放哪层?"
常用工具 主循环、调用图、对比相似模块 需求→数据→接口→实现 四步法
标志 能在脑中模拟字节流 能写出一个 case、一个函数

小白最容易卡在"读懂了但不会写"。破局点就是第四关那套四步法:别急着写,先回答"需要什么数据、谁提供、放哪层"。数据流想通了,代码几乎是自动长出来的。

三条永远正确的原则:

  1. 稳胜于炫:能用轮询就不用中断,能用现成结构(switch 分发、位图、magic+CRC)就不造轮子。
  2. 分层不越界:协议层不懂硬件细节,BSP 层不懂业务逻辑。谁该提供数据,就让谁提供。
  3. 注释写"为什么"、文档跟代码走:你今天省下的注释,是明天 debug 时流的泪。

9. 给后来小白的 Checklist(量产固件自查)

  • 主循环无阻塞、无长延时
  • Flash 写入低频(只在参数变更/关键节点),有 magic + CRC 保护
  • 通信丢失有安全态(失电安全),且可配置、可自恢复
  • 对外可读的诊断/履历数据齐全(帧计数、CRC 错、寿命、运行小时、SN)
  • 所有新加功能都有注释解释"为什么",且同步进 README
  • 编译 0 错误 0 警告(注意中文字符串字面量在 Keil 下的坑)
  • 用真实硬件联机验证过,不只是"逻辑上应该对"

10. 深入篇:功能代码详细分析 ------ 那些"巧妙"的设计

这一关专门扒开代码,看每一处看似普通、其实用心 的写法。小白读到这里,最好把对应的 .c 文件打开,一边对照一边读。

10.1 故障安全:HAL_GetTick() 的"减法魔法"

先看 Modbus_CheckFailSafe 里这句:

c 复制代码
uint32_t now       = HAL_GetTick();
uint32_t elapsed   = now - s_last_valid_tick;

HAL_GetTick() 返回 uint32_t,毫秒计数,约 49.7 天 后会从最大值回绕到 0。很多人担心:万一回绕,now - s_last_valid_tick 不就变负数、算错了吗?

巧妙之处 :C 语言里 uint32_t 是无符号数,无符号减法自带"模 2³² 回绕"语义 。比如 now=5s_last_valid_tick=0xFFFFFFF0(很大),5 - 0xFFFFFFF0 在数学上等于 -4294967285,但在 32 位无符号下结果正好是 0x00000005 + 0x00000010 = 0x15 = 21(补码回绕)。只要 now 真的在 s_last_valid_tick 之后(哪怕跨越了回绕点),减法结果永远是正确的"经过的毫秒数"。所以这里不用做任何"是否回绕"的判断,这就是老手省掉的那一行防御代码。

再看两个 if (...) return; 的先后顺序,也是巧思:

c 复制代码
if (p->fail_safe_en == 0u || p->fail_safe_sec == 0u) return;  // ① 未启用
if (s_fail_safe_active) return;                               // ② 已触发
  • ① 把"开关"和"超时时间"都判了------fail_safe_sec==0 也视为关闭,防止"配了启用但秒数为 0 导致一启动就触发"。
  • ② 是关键:一旦进入安全态,就不再反复调 BSP_Relay_SetAll(0u)。继电器是机械元件,反复重写同一状态虽不会损坏,但 ② 这个守卫让"已触发"之后主循环每圈什么都不做,干净利落。

而"自动恢复"藏在 Modbus_Poll 里,且先于广播判断执行:

c 复制代码
s_frame_count++;
s_last_valid_tick = HAL_GetTick();          // 收到任意有效帧就刷新
if (s_fail_safe_active) {
    s_fail_safe_active = 0u;                 // 解除安全态
    BSP_LED_SetMode(LED_MODE_BLINK_SLOW);    // LED 恢复正常慢闪
}

为什么"刷新时间戳"要放在广播判断之前? 因为广播帧(地址 0)也代表"上位机还活着"。如果只在非广播帧才刷新,那么纯广播控制的现场会因为收不到单播帧而误判超时。把刷新放在最前,广播/单播/读命令统统算"通信正常"。这就是把"故障安全的判定口径"和"是否回包"解耦。

10.2 继电器动作计数:一个 return 省下的不只是性能

BSP_Relay_Set 开头的这段:

c 复制代码
uint8_t old_state = (s_relay_state >> idx) & 0x01u;
if (((state != 0u) ? 1u : 0u) == old_state) {
    return;                         // 目标状态 == 当前状态, 直接返回
}
... // 真正翻转 GPIO
s_relay_act_count[idx]++;           // 仅实际翻转时累计

巧妙之处有三层:

  1. old_state 比对,而非"收到命令就 +1" 。继电器的物理寿命只和"真实吸合/释放次数"有关。上位机连发 10 次"打开",若本来就开着,这 10 次没有一次机械动作,寿命计数器不该涨。只有"状态真的变了"才 ++。这一行让寿命统计反映真实磨损,而不是通信噪声。
  2. 顺带避免了无谓的 GPIO 重写 。不改状态就直接 return,不会去 HAL_GPIO_WritePin 把引脚再驱动一遍(虽然驱动同电平无害,但省掉更干净,也避免极端情况下的毛刺)。
  3. 故障安全复用同一函数,不重复计数 。触发安全态时调的是 BSP_Relay_SetAll(0u),它内部逐路调 BSP_Relay_Set。若继电器本来就是断的,BSP_Relay_Set 直接 return,不会把"进入安全态"误计成一次寿命消耗。设计的一致性在这里自动生效。

小白体会:好的封装是"一个函数只做一件事、且行为自洽",上层随便怎么组合调用都不会出错。BSP_Relay_SetAll 不需要知道寿命计数,却因为 BSP_Relay_Set 的自洽而天然正确。

10.3 Flash 参数:两个"哨兵值"和一个"排除自身"的 CRC

FlashParam_Save 的签名有 5 个参数,但调用方常常只想改其中 1 个。它是怎么做到的?

c 复制代码
int8_t FlashParam_Save(uint8_t slave_addr, uint8_t baud_index, uint8_t link_enable,
                       uint8_t fail_safe_en, uint8_t fail_safe_sec)
{
    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; }
    ...
}

巧妙之处:用"特殊值当哨兵"表达"我不想改这项"。

  • 地址用 0 表示"不改"(因为合法地址范围是 1~247,0 本就不合法);
  • 索引/使能/超时用 0xFF 表示"不改"(因为这些字段合法范围不含 0xFF)。

这样一个函数搞定所有"改任意子集"的需求,不用为"只改地址""只改波特率"写一堆独立函数。这是参数接口设计里极常用的套路,记住它,以后写"可部分更新的配置"直接套。

再看 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 字段本身就是校验值,当然不能把自己算进校验里 ,否则永远对不上。所以长度特意减掉一个 uint32_t(4 字节)。这是个容易踩的坑:很多人把整个结构体丢进 CRC,结果存的时候写 crc,读的时候 crc 变了,校验永远失败。

还有"设备履历不清零"的设计------FlashParam_ResetDefault(恢复出厂)特意保留 reset_countrun_sec_base

c 复制代码
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;       // 运行时间: 设备履历, 不清零

巧妙之处:区分"用户配置"和"设备履历"。出厂设置可以重置,但"这台设备累计运行了多久、复位了多少次"是设备的身份证,恢复出厂不该抹掉。这一行体现了对"什么数据属于谁"的思考。

最后那句擦写寿命的注释也值得记住:STM32F103 Flash 擦写寿命 1 万次,工业设备每天重启 ≤ 10 次可使用约 2.7 年这是用数字说服自己"低频写 Flash 是安全的",而不是凭感觉。

10.4 运行小时:基准 + 增量的"折纸"模型

Param_UpdateRunSec 只有一行:

c 复制代码
s_param.run_sec_base = s_param.run_sec_base + (HAL_GetTick() / 1000u);

巧妙之处:把"时间"像折纸一样折进持久化的基准里。

  • 实时运行秒数 = run_sec_base(上次存盘时折进去的)+ HAL_GetTick()/1000(本次上电的);
  • run_sec_base 只在"要存盘时"才被这一行更新------把已经流逝的这次上电时间折进基准,然后存盘。

效果:设备上电 100 天、重启 50 次,运行小时依然连续;而 Flash 只在改参数/复位时才写一次。高频的"时间流逝"全在 RAM 里算,低频的"落盘"才碰 Flash。这正是嵌入式"持久化"的标准范式:RAM 是快表,Flash 是慢库,二者靠"基准 + 增量"同步。

10.5 序列号:借芯片的"出厂身份证"

c 复制代码
static uint32_t MB_GetDeviceSN(void)
{
    const uint32_t *uid = (const uint32_t *)DEV_UID_BASE;  // 0x1FFFF7E8
    return (uid[0] ^ uid[1] ^ uid[2]);                     // 96位 UID 三段异或
}

巧妙之处有三层:

  1. 不用人工烧录 SN 。每颗 STM32 在出厂时由晶圆厂烧了 96-bit 唯一 ID(地址 0x1FFFF7E8)。直接读它,等于白嫖一个全球唯一的身份证,量产少一道工序。
  2. 指针强转读取(const uint32_t *)0x1FFFF7E8 把固定地址当成数组首地址,一次取 3 个 uint32_t------这是嵌入式"把硬件寄存器/ROM 当内存读"的常规操作。
  3. 三段异或而非直接取一段:96 位里不同批次的位可能呈现规律,异或能把三段信息搅在一起,得到一个更"随机/均匀"的 32 位 SN,降低撞号概率。

小白注意:0x1FFFF7E8STM32F1 系列的 UID 地址。换到 F4/G0 等系列地址不同,移植时要查手册------这正是"借硬件特性"的代价:可移植性要自己负责。

10.6 Modbus 组帧:大小端的两个"反向"细节

Modbus 协议有两个容易写反的地方,本项目都处理对了:

c 复制代码
static void MB_AppendWord(uint16_t w) {
    MB_AppendByte((uint8_t)(w >> 8));   // 高字节先发 (大端)
    MB_AppendByte((uint8_t)(w & 0xFFu));
}
static void MB_FinalizeCRC(void) {
    uint16_t crc = MB_CRC16(s_tx_buf, s_tx_len);
    MB_AppendByte((uint8_t)(crc & 0xFFu));        // CRC 低字节先发 (小端)
    MB_AppendByte((uint8_t)((crc >> 8) & 0xFFu));
}

巧妙之处(也是坑):普通数据"高字节在前",CRC "低字节在前"。

  • 寄存器值 0x1234 在线上是 12 34(大端);
  • 但 CRC 结果 0x1234 在线上是 34 12(小端,Modbus 规定)。

初学者 10 次有 8 次在这里翻车:把 CRC 也按大端发,对方校验永远不过。本项目把"数据"和"CRC"分开两个函数,各自管好自己的字节序,调用处 MB_AppendWord(...) + MB_FinalizeCRC() 就不会混。

10.7 一个 16 位寄存器塞下两个状态:跨界位运算

故障安全状态对外暴露时,用一个输入寄存器同时表达"是否启用"和"是否正触发":

c 复制代码
case IR_ADDR_FAILSAFE:
    value = (uint16_t)((FlashParam_Get()->fail_safe_en ? 1u : 0u) |
                       (s_fail_safe_active ? 0x100u : 0u));
    break;

巧妙之处:16 位寄存器有 16 个 bit,何必浪费?

  • bit0(0x0001)= 故障安全是否启用;
  • bit8(0x0100,即 0x100u)= 当前是否处于安全态。

一个寄存器、两个标志、各占一位。主站读回来 & 0x01 看启用、& 0x100 看触发,清清楚楚。这是"位域/标志位打包"思想的迷你版 ------和设备寄存器、状态字打交道时天天用。注意 0x100u 已经超过 8 位,所以 value 必须是 uint16_t,用 uint8_t 会截断丢标志。

10.8 异常计数排除广播:一行 if 守住语义

c 复制代码
static void MB_BuildException(uint8_t fc, uint8_t slave_addr, uint8_t ex_code)
{
    if (slave_addr != MB_BROADCAST_ADDR) {  // 仅非广播帧计异常
        s_ex_resp_count++;
    }
    ...
}

巧妙之处 :广播帧(地址 0)按 Modbus 规矩根本不回包 ,自然也谈不上"回了异常响应"。如果不加这个 if,广播一个非法命令就会被误记一次异常,诊断数据失真。这一行守住的是"异常响应计数"这个词的字面语义------没回包就不算回包异常。

小白复盘:这类 bug 最隐蔽------编译没错、运行也不崩,只是统计悄悄偏大。所以凡是"计数类"代码,都要问自己:"什么情况下不该计入?"

10.9 非阻塞主循环:为什么没有 delay()

回到 main.c 主循环,整圈没有任何 HAL_Delay 式阻塞。每个模块都是"有活就干、没活就退":

c 复制代码
while (1) {
    Modbus_Poll();          // 没帧 -> 立刻 return
    Modbus_CheckFailSafe();  // 没超时 -> 立刻 return
    RS232Shell_Process();    // 没完整行 -> 立刻 return
    BSP_DI_Scan();
    BSP_LED_Refresh();
    SystemApp_FeedWatchdog();
}

巧妙之处:轮询(polling)让所有任务"公平地瓜分"每一圈 CPU 。如果某个模块里写了 for (i=0;i<1000000;i++)HAL_Delay(500),主循环会被卡住,别的任务(比如看门狗没及时喂)就会出事。裸机前后台系统的铁律:主循环里只做"快进快出"的事,长耗时要么拆成状态机,要么丢给定时器/中断。 这也是为什么前面故障安全宁愿用"每圈轮询时间戳"也不开定时器中断------保持主循环 simple、可预测。

10.10 小结:所谓"巧妙",其实都是"想清楚代价"

把上面 9 处串起来看,"巧妙"根本不是炫技,而是一句话:每一处写法都在权衡"正确性 / 资源 / 可维护性"之后,选了最稳的那个。

写法 表面看 真正在权衡的
无符号减法算超时 没判回绕 利用语言特性,省掉防御代码且更正确
状态翻转才计数 多一个 if 寿命统计反映真实磨损
哨兵值 0/0xFF 参数多而怪 一个函数支持任意子集更新
CRC 排除自身 减 4 字节 避免"自己校验自己"的死循环
履历不清零 多两行保存 区分用户配置与设备身份
基准+增量计时 一行折叠 RAM 扛高频、Flash 扛持久
读芯片 UID 指针强转 白嫖全球唯一 SN,省工序
大小端分两个函数 略啰嗦 防止 CRC 字节序写反
一个寄存器两位 位运算 寄存器是稀缺资源,能省则省
排除广播的计数 多一行 if 守住"异常响应"的语义
主循环零阻塞 不直观 保住系统可预测、看门狗不饿死

记住这张表,下次你写代码时,也会自然地先问:"我这是在为哪三个代价做权衡?"------问出这个问题,你就已经走在"设计者"的路上了。


写于本项目完成 #1/#3/#4 三项增强之后。这份笔记的价值不在"答案",而在"怎么走到答案"。当你也能对着需求画出数据流、写出第一个 case 时,你就已经不是小白了。

相关推荐
疯狂打码的少年1 小时前
【软件工程】软件项目管理(人员/产品/过程/项目)
笔记·软件工程
zyplayer-doc1 小时前
研发接口文档怎么长期维护:zyplayer-doc把API、Markdown和变更记录放进同一个知识库
大数据·数据库·人工智能·笔记·pdf·ocr
2CM_Embed1 小时前
F28379D 下载后 VOFA+ 无数据、Reset 后恢复:一次 TZ 中断误触发排查记录
笔记·单片机·sci·c2000·f28379d·vofa·tz
天空'之城2 小时前
C 语言工业级通用组件手写 10:字节序转换
c语言·字节序·大小端·嵌入式通信·工业级组件
星恒随风2 小时前
C++ STL 详解:list 的使用、迭代器失效、模拟实现与 vector 对比
开发语言·数据结构·c++·笔记·学习·list
一起努力啊~3 小时前
DataWhale组队学习笔记--llm-algo-leetcode(五)
笔记·学习·llm
是上好佳佳佳呀4 小时前
【机器学习|DAY03】K近邻算法(KNN)笔记
笔记·机器学习·近邻算法
我叫洋洋13 小时前
C ++ [ hello world ]
c语言·c++·算法
天空'之城14 小时前
C 语言工业级通用组件手写 09:CRC32 数据校验
c语言·嵌入式开发·数据校验·crc32·工业级组件