STM32从零到量产开发:四路继电器工业控制模块开发实战笔记(小白视角)
这份笔记不是"教程",而是一个刚入门的人 陪你一起,把这颗 STM32F103RCT6 量产固件从"看不懂"到"能改、能加功能"的完整心路。
重点不是记住每一行代码,而是学会怎么读、怎么想、怎么把需求变成代码 。
文中所有代码片段都来自本项目真实源码,可直接对照
Core/Src、Core/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 建立"三层 + 主循环"心智模型
到此,第一关结束。你不需要看懂任何函数的内部实现,只要能回答三个问题:
- 程序主循环每秒在轮询什么?(答:Modbus、故障安全、Shell、DI、LED、喂狗)
- 代码分哪几层?(答:应用 / 协议 / BSP)
- 哪些代码不能碰?(答: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.c 用 s_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 万次)。它用了三招:
- magic 字 :写入时打标记
PARAM_MAGIC,上电读回时先校验"这页到底有没有有效数据"; - CRC16 校验 :
Param_CalcCRC对整个结构体算校验,防止读到一半损坏的数据被当成真参数; - 只在必要时写:地址/波特率这种参数,只有 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(分发) 结构。
每个处理函数内部套路都一样:
- 解析起始地址、数量;
- 做范围合法性检查 (超界就回异常
MB_BuildException); - 读/写实际硬件或变量;
- 组帧 + 算 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_HandleReadInputRegs 的 switch 里加 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 里加了 STATS 和 FS 命令:
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、一个函数 |
小白最容易卡在"读懂了但不会写"。破局点就是第四关那套四步法:别急着写,先回答"需要什么数据、谁提供、放哪层"。数据流想通了,代码几乎是自动长出来的。
三条永远正确的原则:
- 稳胜于炫:能用轮询就不用中断,能用现成结构(switch 分发、位图、magic+CRC)就不造轮子。
- 分层不越界:协议层不懂硬件细节,BSP 层不懂业务逻辑。谁该提供数据,就让谁提供。
- 注释写"为什么"、文档跟代码走:你今天省下的注释,是明天 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=5,s_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]++; // 仅实际翻转时累计
巧妙之处有三层:
- 用
old_state比对,而非"收到命令就 +1" 。继电器的物理寿命只和"真实吸合/释放次数"有关。上位机连发 10 次"打开",若本来就开着,这 10 次没有一次机械动作,寿命计数器不该涨。只有"状态真的变了"才++。这一行让寿命统计反映真实磨损,而不是通信噪声。 - 顺带避免了无谓的 GPIO 重写 。不改状态就直接
return,不会去HAL_GPIO_WritePin把引脚再驱动一遍(虽然驱动同电平无害,但省掉更干净,也避免极端情况下的毛刺)。 - 故障安全复用同一函数,不重复计数 。触发安全态时调的是
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_count 和 run_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 三段异或
}
巧妙之处有三层:
- 不用人工烧录 SN 。每颗 STM32 在出厂时由晶圆厂烧了 96-bit 唯一 ID(地址
0x1FFFF7E8)。直接读它,等于白嫖一个全球唯一的身份证,量产少一道工序。 - 指针强转读取 :
(const uint32_t *)0x1FFFF7E8把固定地址当成数组首地址,一次取 3 个uint32_t------这是嵌入式"把硬件寄存器/ROM 当内存读"的常规操作。 - 三段异或而非直接取一段:96 位里不同批次的位可能呈现规律,异或能把三段信息搅在一起,得到一个更"随机/均匀"的 32 位 SN,降低撞号概率。
小白注意:
0x1FFFF7E8是 STM32F1 系列的 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 时,你就已经不是小白了。