stm20260720-从新手 C 到量产 STM32 工程:程序设计推导指南
案例项目 :STM32 LED 智能控制系统(4 路 PWM + UART 指令 + 三路按键 + IWDG 看门狗)
平台 :STM32F103ZET6 + CubeMX + HAL 库 + Keil MDK
本文目标 :不是教你"这段代码什么意思",而是教你"怎么一步步推导出需要写这段代码"------让新手有迹可循
目录
- [第零章:新手思维 vs 量产思维的根本差异](#第零章:新手思维 vs 量产思维的根本差异)
- 第一章:拿到规格书后第一步该干什么
- 第二章:从需求清单到模块划分的推导
- [第三章:LED 模块的完整推导链](#第三章:LED 模块的完整推导链)
- 第四章:按键模块的完整推导链
- 第五章:串口模块的完整推导链
- 第六章:主循环的推导链
- 第七章:量产加固的推导链
- 第八章:设计迭代的真实过程(不是一遍写完)
- 附录:量产设计检查清单
第零章:新手思维 vs 量产思维的根本差异
0.1 新手最常见的困惑
你学了 C 语言,会写 if/else、for 循环、函数调用、结构体、指针。然后你打开一个量产工程,看到几百行代码分散在十几个文件里,第一反应是:
"这些代码是怎么想出来的?我怎么可能一次设计出这么多东西?"
答案是:没有人是一次想出来的。 量产工程是一轮一轮推导 出来的,每一轮解决一个问题,解决完又冒出新的问题,再解决,如此迭代。本文要教就是这条推导链。
0.2 两种思维模式的对比
| 维度 | 新手 C 语言作业 | 量产 STM32 工程 |
|---|---|---|
| 目标 | 功能跑通就满分 | 长期稳定运行 + 可维护 + 可扩展 |
| 代码量 | 一个 main.c 几十行 | 多文件分层,几百到几千行 |
| 设计起点 | "我要实现 XX 功能" | "规格书要求什么?约束是什么?" |
| 思考方式 | 从代码开始想 | 从问题开始想,代码是问题的答案 |
| 错误处理 | 不管,崩了重启就行 | 每一层都要防御,不能崩 |
| 时序 | delay() 等就行 |
不能阻塞,多任务并发 |
| 演进 | 写完就交了 | 第一版只是起点,后面还要加固 |
0.3 量产工程的核心设计原则(先记住,后面会逐条推导)
- 分层隔离:应用层不碰寄存器,驱动层不碰业务逻辑
- 非阻塞 :永远不用
HAL_Delay(),用时间戳轮询 - 输入校验:所有外部输入都不可信,必须检查
- 异常兜底:出错了不能继续跑错误状态,要安全停机
- 状态可观测:任何时候都能知道系统在干什么
这 5 条不是拍脑袋定的,每一条都是被真实量产事故"教训"出来的。后面每一章会告诉你这些原则是从什么问题推导出来的。
第一章:拿到规格书后第一步该干什么
1.1 新手的错误起点
新手拿到规格书,第一反应是打开 Keil,新建一个 main.c,开始写 int main(void)。
这是错的。 你还没想清楚要写什么,就开始写代码了。
1.2 正确的第一步:提取"设计需求清单"
打开规格书,拿一支笔,做三件事:
第一件:列出所有"实体"(系统里有哪些东西)
读规格书,把所有名词圈出来:
实体清单:
├── LED0 ------ 系统心跳呼吸灯,PC6,TIM3_CH1
├── LED1 ------ 外设指示灯,PC7,TIM3_CH2
├── LED2 ------ 外设指示灯,PC8,TIM3_CH3
├── LED3 ------ 外设指示灯,PC9,TIM3_CH4
├── KEY1 ------ 按键,PB3
├── KEY2 ------ 按键,PB4
├── KEY3 ------ 按键,PB5
├── UART4 ------ 串口通信,PC10(TX)/PC11(RX)
└── IWDG ------ 独立看门狗
为什么要先列实体? 因为每个实体后面都会变成一个"模块"。你现在不列清楚,写到后面发现"哦还有个看门狗要处理",就得回头改架构。量产工程最怕的就是返工架构。
第二件:列出每个实体的"操作需求"(要能对它做什么)
对每个实体,问自己:规格书要求对它能做什么操作?
LED0:
- 上电自动呼吸(不需要任何指令触发)
- 全局总控时可以被强制关/开/恢复正常
LED1~LED3:
- 单独开/关(按键控制)
- 批量开/关(串口 ON/OFF 指令)
- 批量调亮度(串口 BRIGHT 指令)
- 全局总控(串口没要求?规格书说预留)
KEY1~KEY3:
- 检测按下事件
- KEY1 → LED1 常亮
- KEY2 → LED1 熄灭
- KEY3 → LED2 状态翻转
UART4:
- 接收串口指令(ON/OFF/BRIGHT/STATUS/HELP)
- 发送响应信息
- 非阻塞接收(不能卡住主循环)
IWDG:
- 防止程序死机
- 需要定期喂狗
为什么要列操作需求? 因为每个操作后面会变成一个"函数"。你现在列出来,设计接口的时候就不会遗漏。比如你如果漏了"LED0 全局总控时也要能被关掉",后面写
GlobalControl的时候就会忘记处理 LED0。
第三件:列出所有"约束"(不能做什么)
约束清单:
├── LED 是共阳极,低电平点亮,PWM 占空比要取反
├── 亮度范围 0~1000(PWM ARR = 1000)
├── 呼吸灯只有 LED0,其他 LED 不呼吸
├── LED0 不受按键控制,不受 ON/OFF 批量控制
├── 串口指令以 \r\n 结尾
├── BRIGHT 参数必须是纯数字,范围 0~1000
├── 看门狗超时时间有限,主循环不能长时间阻塞
├── STM32F103 无 FPU,浮点运算慢
└── 按键有机械抖动,需要消抖
为什么要列约束? 因为约束决定了代码"不能怎么写"。比如"共阳极取反"这个约束,直接决定了 PWM 写入函数要
ARR - brightness。比如"无 FPU"这个约束,直接决定了呼吸算法不能用sin()浮点运算。
1.3 这一步的产出
三张清单:实体清单、操作需求清单、约束清单。
这三张清单就是后面所有设计的输入。你不需要写任何代码,但有了这三张清单,你心里已经知道:
- 要建几个模块(每个实体一个)
- 每个模块要暴露哪些函数(每个操作一个)
- 代码里要注意哪些坑(每个约束一条)
第二章:从需求清单到模块划分的推导
2.1 新手的问题:代码应该放在哪里?
新手会把所有代码塞进 main.c:
c
// 新手写法:全部塞在 main.c
int main(void) {
HAL_Init();
// 初始化 LED
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
// ... 4路
// 初始化按键
// 初始化串口
while(1) {
// 读按键
HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3);
// 消抖
// 读串口
// 解析指令
// 控制 LED
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 500);
// 呼吸灯
// 喂狗
}
}
这在新手能跑通。但在量产中,这会导致:
- main.c 越来越长,改一个功能要在一堆代码里找
- 无法复用,下一个项目要用 LED 控制,得从 main.c 里抠代码
- 无法测试,LED 逻辑和按键逻辑混在一起,没法单独验证
2.2 推导:为什么需要分层
你面对的问题:代码越来越多,怎么组织才不会乱?
你有两个选择:
| 选择 | 优点 | 缺点 |
|---|---|---|
| A. 全放 main.c | 简单直接 | 代码一长就失控 |
| B. 按功能拆分模块 | 各模块独立,好维护 | 需要想清楚接口 |
量产工程永远选 B。原因是:量产项目会迭代,你改 LED 逻辑的时候不能影响按键逻辑。物理隔离(不同文件)是最可靠的隔离方式。
2.3 推导:怎么拆模块
回到实体清单,每个实体对应一个模块:
实体 → 模块文件
─────────────────────────────
LED0~LED3 → app_led.c / app_led.h
KEY1~KEY3 → app_key.c / app_key.h
UART4 → app_uart.c / app_uart.h
IWDG → 直接在 main.c 用(太简单,不值得拆)
主循环逻辑 → main.c(调度各模块)
2.4 推导:模块之间的关系
拆完模块后,你要问自己:这些模块之间谁调用谁?
main.c(调度层)
├── 调用 App_LED_Init() / App_LED_Process()
├── 调用 App_Key_Init() / App_Key_Scan() / App_Key_GetEvent()
├── 调用 App_UART_Init() / UART_GetChar() / UART_SendString()
└── 直接操作 hiwdg(看门狗太简单,不封装)
模块间禁止互相调用:
✗ app_key.c 不能调用 app_led.c
✗ app_uart.c 不能调用 app_led.c
✓ 只有 main.c 能调用所有模块
为什么模块间不能互相调用? 因为如果 app_key.c 直接调 App_LED_SetSingleMode(),那按键的逻辑就和 LED 的逻辑耦合了。将来你要改"KEY1 控制 LED2 而不是 LED1",就得改 app_key.c。但如果按键模块只负责"报告按键事件",由 main.c 决定"按键事件触发什么",那你只需要改 main.c 的一行代码。
这叫控制反转------模块只负责"发生了什么",由上层决定"怎么做"。
2.5 这一步的产出
你现在有了项目骨架:
Core/Inc/
├── app_led.h ← LED 模块接口
├── app_key.h ← 按键模块接口
├── app_uart.h ← 串口模块接口
└── main.h
Core/Src/
├── app_led.c ← LED 模块实现
├── app_key.c ← 按键模块实现
├── app_uart.c ← 串口模块实现
└── main.c ← 主调度
但每个文件里面应该写什么?接口怎么设计? 这就是后面四章要推导的。
第三章:LED 模块的完整推导链
这一章是全文核心。我会带你走过 LED 模块从零到完整的每一个设计决策 ,每个决策都回答三个问题:遇到什么问题?有哪些选择?为什么选这个?
决策点 1:怎么表示 4 个 LED 的状态?
你面对的问题:4 个 LED,每个有亮度、模式、绑定的 PWM 通道。这些信息怎么存?
选择 A(新手直觉):用分散的变量
c
uint16_t brightness_0, brightness_1, brightness_2, brightness_3;
uint8_t mode_0, mode_1, mode_2, mode_3;
uint32_t channel_0, channel_1, channel_2, channel_3;
问题:12 个变量,操作时要写 4 遍几乎相同的代码。加一个 LED 就要加 3 个变量。
选择 B(进阶):用平行数组
c
uint16_t brightness[4];
uint8_t mode[4];
uint32_t channel[4];
问题:比 A 好了,但 3 个数组是独立的,传参时要传 3 个。语义上不清晰------brightness[2] 和 mode[2] 是同一个 LED 的属性,但代码看不出来。
选择 C(量产做法):用结构体数组
c
typedef struct {
uint16_t brightness; /* 当前亮度缓存 0~LED_PWM_PERIOD */
LED_Mode_t mode; /* 当前工作模式 */
uint32_t tim_channel; /* 绑定TIM3 PWM通道 */
} LED_Info_t;
static LED_Info_t s_led[LED_MAX] = {
{ 800U, LED_MODE_BREATH, TIM_CHANNEL_1 }, /* LED0 */
{ 500U, LED_MODE_OFF, TIM_CHANNEL_2 }, /* LED1 */
{ 500U, LED_MODE_OFF, TIM_CHANNEL_3 }, /* LED2 */
{ 500U, LED_MODE_OFF, TIM_CHANNEL_4 }, /* LED3 */
};
为什么选 C:
- 一个 LED 的所有属性绑在一起,语义清晰
- 初始化时一目了然------每行就是一个 LED 的全部信息
- 传参只需传一个
LED_ID_t,函数内部用s_led[id].brightness访问 - 将来加属性(比如加个
blink_period),只改结构体定义,不改函数签名
推导出来的新问题 :结构体里有个
LED_Mode_t类型,这个类型从哪来?→ 引出决策点 2。
决策点 2:LED 的"模式"怎么表示?
你面对的问题:LED 有"熄灭""常亮""呼吸""闪烁"几种状态。怎么在代码里表示?
选择 A:用数字
c
#define MODE_OFF 0
#define MODE_ON 1
#define MODE_BREATH 2
问题:调试时打印 mode=2 你不知道是什么。而且 #define 没有类型检查,传 99 也不报错。
选择 B(量产做法):用枚举
c
typedef enum {
LED_MODE_OFF = 0U, /* 熄灭 */
LED_MODE_ON, /* 常亮,支持自定义亮度 */
LED_MODE_BREATH, /* 呼吸渐变模式,仅LED0默认启用 */
LED_MODE_BLINK /* 预留闪烁模式,扩展使用 */
} LED_Mode_t;
为什么选 B:
- 调试时看变量值直接显示
LED_MODE_BREATH,不用查表 - 枚举有类型,编译器能做检查
LED_MODE_BLINK是预留的------为什么预留?因为规格书可能将来要加闪烁功能,枚举里留一个位置,将来只加case不改枚举定义
推导出来的新问题:有了模式和亮度,当模式改变时,怎么把新状态同步到硬件?→ 引出决策点 3。
决策点 3:模式改变后,怎么刷新硬件?
你面对的问题 :当调用 App_LED_SetSingleMode(LED1, LED_MODE_ON) 时,需要把 LED1 的 PWM 输出更新。但不同模式的刷新方式不一样------ON 模式要写亮度值,OFF 模式要写 0,BREATH 模式不在这里刷新(由 Process 函数管)。
选择 A(新手做法) :在每个 Set 函数里直接写硬件
c
void App_LED_SetSingleMode(LED_ID_t id, LED_Mode_t mode) {
s_led[id].mode = mode;
if (mode == LED_MODE_ON)
__HAL_TIM_SET_COMPARE(&htim3, s_led[id].tim_channel, ...);
else if (mode == LED_MODE_OFF)
__HAL_TIM_SET_COMPARE(&htim3, s_led[id].tim_channel, 1000);
// ...
}
问题:以后写 SetSingleBrightness、ControlGroup、GlobalControl 时,每个函数里都要写一遍这个 if/else。重复代码。
选择 B(量产做法) :抽一个内部函数 LED_ApplyMode,统一处理
c
static void LED_ApplyMode(uint8_t id) {
if (id >= LED_MAX) return; // 防御性检查
switch (s_led[id].mode) {
case LED_MODE_ON:
LED_WritePWM(s_led[id].tim_channel, s_led[id].brightness);
break;
case LED_MODE_OFF:
LED_WritePWM(s_led[id].tim_channel, 0U);
break;
case LED_MODE_BREATH:
/* 呼吸动画由 App_LED_Process 周期刷新,此处不操作 */
break;
case LED_MODE_BLINK:
/* 预留 */
LED_WritePWM(s_led[id].tim_channel, 0U);
break;
default:
LED_WritePWM(s_led[id].tim_channel, 0U); // 未知模式→安全熄灭
break;
}
}
为什么选 B:
- 所有"改模式后要刷新硬件"的逻辑集中在一处,改一处全员生效
SetSingleMode只需两行:改状态 + 调 ApplyModedefault分支保证未知模式不会导致不可控输出
注意 BREATH 分支的注释 :"呼吸动画由 App_LED_Process 周期刷新,此处不操作"。这不是偷懒,是职责分离------ApplyMode 负责"静态模式"的即时刷新,Process 负责"动态模式"的周期刷新。如果 ApplyMode 也写呼吸的 PWM,就会和 Process 打架。
推导出来的新问题 :LED_ApplyMode里调用了LED_WritePWM,这个函数又要做什么?→ 引出决策点 4。
决策点 4:PWM 写入函数为什么要单独封装?
你面对的问题:LED 是共阳极,低电平点亮。亮度 0 = 熄灭 = CCR 设为 1000(ARR 值),亮度 1000 = 最亮 = CCR 设为 0。这里有个取反关系。
如果直接在 ApplyMode 里写:
c
__HAL_TIM_SET_COMPARE(&htim3, s_led[id].tim_channel, LED_PWM_PERIOD - s_led[id].brightness);
问题:
- 每个写 PWM 的地方都要记得取反------忘记一次就是 bug
- 亮度值可能超过 1000,没有保护
- 将来硬件改成共阴极,所有地方都要改
量产做法 :封装一个 LED_WritePWM,把取反和边界保护藏在里面
c
static void LED_WritePWM(uint32_t channel, uint16_t brightness) {
/* 边界容错,防止参数溢出损坏 PWM */
if (brightness > LED_PWM_PERIOD) {
brightness = LED_PWM_PERIOD;
}
/* 共阳极取反:亮度0→CCR=1000(灭),亮度1000→CCR=0(最亮) */
__HAL_TIM_SET_COMPARE(&htim3, channel, LED_PWM_PERIOD - brightness);
}
为什么这么做:
- 取反逻辑只写一次,永远不会忘
- 边界保护只写一次,永远不会漏
- 将来硬件改了,只改这一个函数
static限制只有本文件能调用------外部模块想写 PWM 必须通过公开接口
设计模式总结 :这就是"信息隐藏"。硬件细节(共阳极取反、ARR 值)藏在驱动层内部,上层只管"我要设亮度 500",不需要知道 500 怎么变成 CCR 值。
推导出来的新问题:呼吸灯怎么实现?→ 引出决策点 5。
决策点 5:呼吸灯算法为什么用三角波?
你面对的问题:LED0 要呼吸渐变------亮度从 0 慢慢升到 1000,再慢慢降到 0,循环往复。
选择 A(新手做法):用正弦波
c
brightness = (sin(time * freq) + 1.0) * 500.0;
问题:
- STM32F103 没有 FPU ,
sin()是软件浮点模拟,一条sin调用要几百个时钟周期 - 呼吸灯 20ms 刷一次,如果
sin占用太多时间,会影响主循环其他任务
选择 B(量产做法):用三角波,纯整数加减
c
static uint16_t s_breath_val = 0U;
static int16_t s_breath_dir = LED_BREATH_STEP;
void App_LED_Process(void) {
static uint32_t last_tick = 0U;
uint32_t now_tick = HAL_GetTick();
/* 20ms 刷一次 */
if ((now_tick - last_tick) < LED_BREATH_MS) return;
last_tick = now_tick;
if (s_led[LED0].mode != LED_MODE_BREATH) return;
/* 三角波:0→1000→0 循环 */
s_breath_val += s_breath_dir;
if (s_breath_val >= LED_PWM_PERIOD) {
s_breath_val = LED_PWM_PERIOD;
s_breath_dir = -((int16_t)LED_BREATH_STEP); // 到顶了,开始下降
} else if (s_breath_val <= 0U) {
s_breath_val = 0U;
s_breath_dir = (int16_t)LED_BREATH_STEP; // 到底了,开始上升
}
LED_WritePWM(TIM_CHANNEL_1, s_breath_val);
}
为什么选 B:
- 纯整数加减,几个时钟周期就完成,不占 CPU
- 视觉上三角波和正弦波差别不大(人眼对亮度变化不敏感)
- 步长
LED_BREATH_STEP可调------值大呼吸快,值小呼吸慢 - 刷新间隔用
HAL_GetTick()时间戳控制,非阻塞
关键推导 :为什么
last_tick是static?因为这个函数会被主循环反复调用,last_tick需要在调用之间保持值。如果不用static,每次调用都从 0 开始,时间判断就失效了。static局部变量 = 函数的"记忆"。
推导出来的新问题:Process 函数怎么被调用?多久调一次?→ 这在第六章主循环推导中解答。
决策点 6:为什么需要"分组控制"和"全局总控"两层接口?
你面对的问题:规格书要求多种控制方式------
- 串口
ON指令:点亮 LED1~3(不含 LED0) - 串口
BRIGHT 500:设 LED1~3 亮度 - 按键 KEY1:只控制 LED1
- 全局总控:所有 LED(含 LED0)一起操作
这些操作的"范围"不一样,如果只有一个 SetSingleMode 接口,main.c 里就要写循环:
c
// 串口 ON 指令
App_LED_SetSingleMode(LED1, LED_MODE_ON);
App_LED_SetSingleMode(LED2, LED_MODE_ON);
App_LED_SetSingleMode(LED3, LED_MODE_ON);
这样写的问题:如果将来 LED 数量从 4 变成 8,main.c 里所有循环都要改。
量产做法:在 LED 模块内部分层封装
c
/* 第一层:单独控制(按键用) */
void App_LED_SetSingleMode(LED_ID_t id, LED_Mode_t mode);
/* 第二层:分组控制 LED1~3(串口 ON/OFF/BRIGHT 用) */
void App_LED_ControlGroup(uint8_t on_off);
void App_LED_SetGroupBrightness(uint16_t brightness);
/* 第三层:全局总控含 LED0(系统级命令用) */
void App_LED_GlobalControl(LED_GLOBAL_CMD_t cmd);
为什么分三层:
- 每层的调用者不同------按键只控制单个灯,串口控制一批灯,全局控制所有灯
- LED0 的心跳呼吸灯是"特权灯"------分组控制跳过它,全局控制才动它
- 将来加新的控制方式(比如"只控制偶数号灯"),加第四层就行,不影响现有代码
看 ControlGroup 的实现------注意循环从 LED1 开始:
c
void App_LED_ControlGroup(uint8_t on_off) {
LED_Mode_t target_mode = LED_MODE_OFF;
if (on_off != 0U) target_mode = LED_MODE_ON;
/* 跳过 LED0,仅操作 LED1/2/3 */
for (uint8_t i = LED1; i < LED_MAX; i++) {
App_LED_SetSingleMode((LED_ID_t)i, target_mode);
}
}
为什么循环从 LED1 开始? 因为 LED0 是心跳灯,分组控制不能动它。这个"跳过 LED0"的逻辑在
ControlGroup内部处理,调用者(main.c)不需要关心。这就是封装的好处------复杂度藏在模块内部。
决策点 7:为什么每个公开函数开头都要检查 id >= LED_MAX?
看这些函数的入口:
c
void App_LED_SetSingleMode(LED_ID_t id, LED_Mode_t mode) {
if ((uint8_t)id >= LED_MAX) return; // ← 这行
...
}
void App_LED_SetSingleBrightness(LED_ID_t id, uint16_t brightness) {
if ((uint8_t)id >= LED_MAX) return; // ← 这行
...
}
你面对的问题 :如果别处传了 LED_ID_t = 5(只有 0~3 是合法的),数组越界访问 s_led[5] 会导致未定义行为------可能 HardFault,可能写坏内存。
新手做法:不管,反正我不会传错。
量产做法:每个入口都检查,出错就安全返回(什么都不做),不往下执行。
这叫"防御式编程" 。你不信任调用者,永远假设输入可能非法。在量产中,一个越界访问可能导致现场设备失控,代价远大于多一行
if。
LED 模块完整接口设计回顾
经过 7 个决策点的推导,最终的 app_led.h 接口:
c
/* 配置参数区------量产统一修改 */
#define LED_MAX 4U
#define LED_PWM_PERIOD 1000U
#define LED_BREATH_STEP 10U
#define LED_BREATH_MS 20U
/* 类型定义 */
typedef enum { LED0=0U, LED1, LED2, LED3 } LED_ID_t;
typedef enum { LED_MODE_OFF=0U, LED_MODE_ON, LED_MODE_BREATH, LED_MODE_BLINK } LED_Mode_t;
typedef enum { LED_GLOBAL_OFF=0U, LED_GLOBAL_ON, LED_GLOBAL_NORMAL } LED_GLOBAL_CMD_t;
/* 三层控制接口 */
void App_LED_Init(void); // 初始化
void App_LED_SetSingleMode(LED_ID_t id, LED_Mode_t mode); // 单独控制
void App_LED_SetSingleBrightness(LED_ID_t id, uint16_t b); // 单独亮度
void App_LED_ControlGroup(uint8_t on_off); // 分组开关
void App_LED_SetGroupBrightness(uint16_t brightness); // 分组亮度
void App_LED_GlobalControl(LED_GLOBAL_CMD_t cmd); // 全局总控
void App_LED_Process(void); // 呼吸刷新
回头看 :这 7 个函数不是一开始就想好的,是从"规格书要求什么操作"一个个推导出来的。你如果跳过需求分析直接写代码,很可能会漏掉
GlobalControl或者把ControlGroup和SetGroupBrightness混成一个函数。
第四章:按键模块的完整推导链
决策点 1:为什么按键需要消抖?
你面对的问题:机械按键按下/松开时,金属触片会弹跳,产生一串快速跳变的高低电平:
理想信号: _______|‾‾‾‾‾‾‾‾‾|_______
实际信号: _______|‾|_|‾|‾‾‾‾|_|‾|_______
↑ 这些跳变会被误判为多次按下
新手做法 :不管,HAL_GPIO_ReadPin 读到低电平就认为按下了。
问题:可能一次按下被识别成 3~5 次按下,LED 状态翻转 5 次又回到原位。
量产做法:检测到电平变化后,等一段时间(通常 10~20ms),如果电平稳定了才确认有效。
决策点 2:消抖算法怎么实现?
你面对的问题:怎么用代码实现"等电平稳定"?
选择 A :用 HAL_Delay(20) 阻塞等待
c
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) == 0) {
HAL_Delay(20); // 等 20ms
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) == 0) {
// 确认按下
}
}
问题:HAL_Delay 会阻塞主循环 20ms,期间呼吸灯不刷新、串口不处理、看门狗可能超时。量产中绝对不能用。
选择 B(量产做法):用时间戳记录电平变化时刻,下次扫描时检查是否已稳定足够长时间
项目实际代码:
c
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
uint8_t last_state; /* 上一次原始电平 */
uint8_t stable_state; /* 消抖后稳定电平 */
uint32_t last_change_tick;/* 电平变化时间戳 */
Key_Event_t event; /* 缓存按键事件 */
} Key_Info_t;
static Key_Info_t key_table[KEY_MAX] = {
{GPIOB, GPIO_PIN_3, GPIO_PIN_SET, GPIO_PIN_SET, 0U, KEY_EVENT_NONE},
{GPIOB, GPIO_PIN_4, GPIO_PIN_SET, GPIO_PIN_SET, 0U, KEY_EVENT_NONE},
{GPIOB, GPIO_PIN_5, GPIO_PIN_SET, GPIO_PIN_SET, 0U, KEY_EVENT_NONE},
};
扫描逻辑:
c
void App_Key_Scan(void) {
uint8_t i = 0U;
uint8_t current_level = 0U;
uint32_t now_tick = HAL_GetTick();
for (i = 0U; i < KEY_MAX; i++) {
current_level = HAL_GPIO_ReadPin(key_table[i].port, key_table[i].pin);
/* 第一步:电平发生跳变,重置消抖计时 */
if (current_level != key_table[i].last_state) {
key_table[i].last_state = current_level;
key_table[i].last_change_tick = now_tick;
continue; // 这次不判断,等下一次扫描
}
/* 第二步:电平没变,检查是否稳定超过消抖时间 */
if (current_level != key_table[i].stable_state) {
if ((now_tick - key_table[i].last_change_tick) >= KEY_DEBOUNCE_MS) {
/* 稳定了!确认有效状态 */
key_table[i].stable_state = current_level;
if (current_level == GPIO_PIN_RESET) {
key_table[i].event = KEY_EVENT_PRESS; // 低电平=按下
} else {
key_table[i].event = KEY_EVENT_RELEASE; // 高电平=释放
}
}
}
}
}
逐行拆解这个算法的推导过程:
-
三个状态变量的作用:
last_state:上次读到的原始电平------用来检测"电平是否跳变了"stable_state:消抖后的确认电平------用来检测"新电平是否和已确认的不同"last_change_tick:上次跳变的时刻------用来计算"已经稳定了多久"
-
为什么用
continue?因为电平刚跳变,这一轮不可能确认稳定,必须等下一轮扫描(10ms 后)再判断。continue跳过本轮后续逻辑。 -
为什么用
>=而不是==?如果主循环因为某次任务耗时超过 10ms,now_tick - last_change_tick可能是 25ms 而不是恰好 20ms。用>=保证不会漏判。 -
为什么低电平是按下?因为硬件上按键一端接 GPIO,一端接 GND,上拉默认高电平。按下时接通 GND,电平拉低。这是硬件约束决定的。
决策点 3:为什么用 GetEvent / ClearEvent 模式而不是回调函数?
你面对的问题:消抖确认后,怎么通知 main.c "按键被按下了"?
选择 A:回调函数
c
// 按键模块直接调用 main.c 的函数
void OnKeyPressed(uint8_t key_id) {
if (key_id == KEY1) App_LED_SetSingleMode(LED1, LED_MODE_ON);
// ...
}
问题:按键模块要知道"KEY1 控制 LED1"------这把业务逻辑耦合进了驱动层。换个项目,KEY1 可能控制电机而不是 LED,回调函数就要改。
选择 B(量产做法):事件缓存 + 主动查询
c
// 按键模块只负责"记录事件"
Key_Event_t App_Key_GetEvent(uint8_t key_id) {
if (key_id >= KEY_MAX) return KEY_EVENT_NONE;
return key_table[key_id].event;
}
// main.c 主动查询并消费
key_evt = App_Key_GetEvent(KEY1);
if (key_evt == KEY_EVENT_PRESS) {
App_LED_SetSingleMode(LED1, LED_MODE_ON);
UART_SendString("KEY1: LED1 ON\r\n");
App_Key_ClearEvent(KEY1); // 处理完了,清除事件
}
为什么选 B:
- 按键模块完全不知道"按键按下后要做什么"------它只报告"按下了"
- main.c 决定"按下后做什么"------业务逻辑集中在调度层
ClearEvent保证一个事件只被处理一次------不会因为主循环跑得快,一个按下被响应多次
这叫"生产者-消费者"模式 。按键模块是生产者(产生事件),main.c 是消费者(处理事件)。中间用
event字段做缓存,解耦了产生和消费的时机。
注意ClearEvent的调用位置:在处理完事件之后调用,不是在处理之前。如果先 Clear 再处理,处理过程中如果出错,事件就丢了。
第五章:串口模块的完整推导链
决策点 1:为什么用中断接收而不是轮询?
你面对的问题:串口要接收不定长的指令(如 "BRIGHT 500\r\n"),怎么收?
选择 A(新手做法):轮询阻塞接收
c
HAL_UART_Receive(&huart4, buf, 64, 1000); // 阻塞等 1 秒
问题:
- 阻塞期间主循环卡死,呼吸灯停、按键不扫、看门狗可能超时
- 不知道指令多长,按 64 字节收会等到超时才返回,响应慢
- 如果一次没收完,剩下的数据丢了
选择 B(量产做法):中断 + 单字节接收 + 环形缓冲区
c
// 每收到 1 个字节,硬件触发中断
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart->Instance == UART4) {
UART_RxHandler(uart4_rx_byte); // 把字节存进环形缓冲区
HAL_UART_Receive_IT(&huart4, &uart4_rx_byte, 1U); // 重新开启接收
}
}
为什么选 B:
- 中断不阻塞主循环------收到一个字节只需几个微秒,主循环几乎无感
- 每次只收 1 字节,不用知道指令多长
- 环形缓冲区暂存数据,主循环有空时再来取
推导出来的新问题:中断里收到的字节存哪里?主循环怎么取?→ 引出决策点 2。
决策点 2:为什么用环形缓冲区?
你面对的问题:中断里收到字节,主循环要读字节。中断可能在任何时刻发生,主循环也在跑,两者怎么协调?
选择 A:单字节变量
c
volatile uint8_t rx_byte;
// 中断里写 rx_byte
// 主循环里读 rx_byte
问题:主循环处理指令的时候(可能几十毫秒),中断又收到好几个字节,前面的被覆盖了。
选择 B:数组队列
c
uint8_t buf[128];
int idx = 0;
// 中断里 buf[idx++] = data;
问题:idx 只增不减,数组很快就满了。
选择 C(量产做法):环形缓冲区------头尾两个指针,写到头绕回头
c
#define UART_RX_BUF_SIZE 128U
static uint8_t rx_buffer[UART_RX_BUF_SIZE];
static volatile uint16_t rx_write_idx = 0U; // 中断写
static volatile uint16_t rx_read_idx = 0U; // 主循环读
写入(中断里调用):
c
void UART_RxHandler(uint8_t data) {
uint16_t next_write = (rx_write_idx + 1U) % UART_RX_BUF_SIZE;
/* 缓冲区未满才写入,满了就丢弃新数据 */
if (next_write != rx_read_idx) {
rx_buffer[rx_write_idx] = data;
rx_write_idx = next_write;
}
}
读取(主循环里调用):
c
uint8_t UART_GetChar(uint8_t *data) {
if (rx_read_idx == rx_write_idx) return 0U; // 空
*data = rx_buffer[rx_read_idx];
rx_read_idx = (rx_read_idx + 1U) % UART_RX_BUF_SIZE;
return 1U;
}
逐行拆解:
-
为什么用
% UART_RX_BUF_SIZE取模?因为环形------写到 127 后下一个位置是 0,不是 128。取模自动绕回。 -
为什么判断
next_write != rx_read_idx而不是rx_write_idx != rx_read_idx?因为先算出下一个位置再判断,可以区分"满"和"空"两种状态。如果write追上了read,说明满了,丢弃新数据。 -
为什么
rx_write_idx和rx_read_idx要加volatile?因为它们被中断和主循环同时访问 ------中断写write_idx,主循环读write_idx。如果不加volatile,编译器可能把主循环里的读取优化掉(缓存到寄存器不重新读),导致主循环永远以为缓冲区是空的。
volatile的本质:告诉编译器"这个变量随时可能被别处修改,每次用的时候都重新从内存读"。
- 为什么
rx_buffer不加volatile?因为rx_buffer是数组,数组内容通过索引访问。索引加了volatile保证读到正确的索引值,而数组内容在中断写完索引更新之前不会被主循环读到(因为write_idx还没更新,主循环认为没有新数据)。这是单生产者单消费者模型的安全保证。
决策点 3:发送为什么用阻塞式?
你注意到发送函数用的是阻塞式 HAL_UART_Transmit:
c
void UART_SendString(const char *str) {
if (str == NULL) return; // 空指针检查
uint16_t len = (uint16_t)strlen(str);
HAL_UART_Transmit(&huart4, (uint8_t *)str, len, 500U); // 500ms 超时
}
为什么不也用中断发送?
- 发送是主动行为------主循环决定什么时候发,不存在"数据突然来"的问题
- 发送的内容都是短字符串("LED1~3 ON\r\n"),几十字节,115200 波特率下不到 1ms
- 中断发送需要维护发送缓冲区、状态机,复杂度高,收益低
- 500ms 超时保护------如果硬件故障导致发送卡住,不会永远阻塞
设计原则 :不是所有东西都要用中断。中断用于"不可预测时机的事件"(接收),阻塞用于"可控短时操作"(发送)。简单优先,有性能问题再优化。
决策点 4:为什么 NULL 检查很重要?
c
void UART_SendString(const char *str) {
if (str == NULL) return; // ← 这行
...
}
你面对的问题 :如果别处传了 NULL 进来,strlen(NULL) 会直接 HardFault。
新手做法:不管,反正我不会传 NULL。
量产做法:每个接收指针参数的函数,第一件事就是检查 NULL。这不是多此一举------在量产中,某个同事重构代码时可能不小心传了 NULL,检查能避免整机死机。
第六章:主循环的推导链
决策点 1:为什么不能在主循环里用 HAL_Delay?
你面对的问题:主循环要同时做三件事------
- 每 20ms 刷新呼吸灯
- 每 10ms 扫描按键
- 随时处理串口指令
如果用 HAL_Delay:
c
while(1) {
App_LED_Process();
HAL_Delay(20); // ← 阻塞 20ms
App_Key_Scan();
HAL_Delay(10); // ← 又阻塞 10ms
ProcessUART();
}
问题:串口指令最坏要等 30ms 才被处理,呼吸灯刷新间隔变成 30ms 而不是 20ms。整个时序乱了。
量产做法 :时间片轮询------用 HAL_GetTick() 判断"到时间了没有"
c
uint32_t tick_key = 0U;
while (1) {
HAL_IWDG_Refresh(&hiwdg); // 喂狗
g_loop_cnt++;
App_LED_Process(); // 内部自带 20ms 时间判断
if (HAL_GetTick() - tick_key >= 10U) { // 到 10ms 了?
tick_key = HAL_GetTick();
App_Key_Scan();
// 处理按键事件...
}
// 串口非阻塞读取
uint8_t rx_data;
while (UART_GetChar(&rx_data) == 1U) {
// 拼接指令...
}
}
为什么这么做:
- 主循环每一轮都很快跑完(微秒级),不会阻塞
- 每个任务用自己的时间戳判断"该不该执行",互不影响
- 串口处理放在最后------因为
UART_GetChar是非阻塞的,没数据就立即返回
核心思想 :主循环像一个不断巡视的值班员------每次路过都看一眼"呼吸灯该刷了吗?""按键该扫了吗?""串口有数据吗?"------该干就干,不该干就过。永远不等。
决策点 2:串口指令怎么拼接和解析?
你面对的问题:串口中断收到的是零散字节('B'、'R'、'I'、'G'、'H'、'T'、' '、'5'、'0'、'0'、'\r'、'\n'),怎么拼成完整指令 "BRIGHT 500"?
推导过程:
第一步:需要一个缓冲区存正在拼接的指令
c
#define CMD_BUF_LEN 64U
char g_cmd_buf[CMD_BUF_LEN] = {0};
uint8_t g_cmd_idx = 0U;
第二步:从环形缓冲区逐字节读取
c
uint8_t rx_data;
while (UART_GetChar(&rx_data) == 1U) {
// 处理 rx_data
}
第三步:遇到 \r 或 \n 表示指令结束
c
if (rx_data == '\r' || rx_data == '\n') {
if (g_cmd_idx > 0U) { // 缓冲区有内容才解析
g_cmd_buf[g_cmd_idx] = '\0'; // 加字符串结束符
UART_ParseCommand(g_cmd_buf);
memset(g_cmd_buf, 0, CMD_BUF_LEN); // 清空缓冲区
g_cmd_idx = 0U;
}
}
第四步:普通字符存入缓冲区
c
else {
if (g_cmd_idx < CMD_BUF_LEN - 1U) { // 防止溢出
g_cmd_buf[g_cmd_idx++] = rx_data;
}
}
为什么检查
g_cmd_idx < CMD_BUF_LEN - 1?留一个位置给'\0'。如果不留,64 字节填满后g_cmd_buf[64] = '\0'就是越界写。
决策点 3:指令解析为什么用 sscanf + strcmp?
你面对的问题:拼接出 "BRIGHT 500" 后,怎么提取指令名 "BRIGHT" 和参数 "500"?
c
void UART_ParseCommand(char *cmd_str) {
char cmd[16] = {0};
char param_buf[16] = {0};
uint16_t bright_val = 0U;
// sscanf 分割指令和参数
sscanf(cmd_str, "%s %s", cmd, param_buf);
if (strcmp(cmd, "ON") == 0) {
App_LED_ControlGroup(1U);
UART_SendString("LED1~3 ON\r\n");
}
else if (strcmp(cmd, "BRIGHT") == 0) {
if (!IsStringDigit(param_buf)) {
UART_SendString("ERR: invalid num\r\n");
return;
}
bright_val = atoi(param_buf);
if (bright_val > 1000U) {
UART_SendString("ERR: range 0~1000\r\n");
return;
}
App_LED_SetGroupBrightness(bright_val);
// ...
}
// ...
}
为什么用 sscanf + strcmp 而不是手写解析:
- 指令格式简单("CMD PARAM" 两段式),
sscanf一行搞定分割 strcmp做字符串比较清晰直观- 如果指令很复杂(带多个参数、引号、转义),才需要手写解析器
设计原则 :用标准库函数,不要重新发明轮子。但标准库函数不做边界检查,所以参数校验要自己做------
IsStringDigit就是手动校验。
决策点 4:为什么 BRIGHT 参数要做两层校验?
c
// 第一层:检查是否全为数字
if (!IsStringDigit(param_buf)) {
UART_SendString("ERR: invalid num\r\n");
return;
}
// 第二层:检查范围
bright_val = atoi(param_buf);
if (bright_val > 1000U) {
UART_SendString("ERR: range 0~1000\r\n");
return;
}
为什么不直接 atoi 然后检查范围?
- 如果传进来 "ABC",
atoi("ABC")返回 0------不报错,但语义错误(用户输入了非法字符串,系统当成 0 处理了) - 如果传进来 "99999",
atoi返回 99999------超过uint16_t范围会溢出 - 先检查是不是纯数字,再检查范围------两层防御
IsStringDigit 的实现:
c
uint8_t IsStringDigit(const char *str) {
if (str == NULL || strlen(str) == 0) return 0U;
while (*str != '\0') {
if (!isdigit((uint8_t)*str)) return 0U;
str++;
}
return 1U;
}
注意第一个检查
str == NULL || strlen(str) == 0------如果用户只输入 "BRIGHT\r\n" 没有参数,param_buf是空字符串,IsStringDigit返回 0,报错 "invalid num"。这就是防御式编程------考虑用户可能输入不完整指令的情况。
第七章:量产加固的推导链
决策点 1:为什么需要看门狗?喂狗点怎么设计?
你面对的问题:量产设备可能因为电磁干扰、电源抖动、软件 bug 导致程序死循环或卡死。没有人盯着,设备必须能自己恢复。
解决方案:IWDG 独立看门狗------一个独立计时的硬件,超时后自动复位整个芯片。软件必须在超时前"喂狗"(刷新计数器)。如果程序卡死了,就不会喂狗,看门狗超时后自动复位。
喂狗点的推导:
看主循环代码,有 6 个喂狗点:
c
while (1) {
HAL_IWDG_Refresh(&hiwdg); // ① 循环入口
App_LED_Process();
HAL_IWDG_Refresh(&hiwdg); // ② LED刷新后
if (HAL_GetTick() - tick_key >= 10U) {
App_Key_Scan();
// 处理 KEY1...
HAL_IWDG_Refresh(&hiwdg); // ③ KEY1处理后
// 处理 KEY2...
HAL_IWDG_Refresh(&hiwdg); // ④ KEY2处理后
// 处理 KEY3...
}
while (UART_GetChar(&rx_data) == 1U) {
HAL_IWDG_Refresh(&hiwdg); // ⑤ 每收一个字节
// ...
}
}
// 中断里也喂
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
HAL_IWDG_Refresh(&hiwdg); // ⑥ 中断里
}
为什么要在这么多地方喂?因为每个地方都可能"卡住":
| 喂狗点 | 防护的场景 |
|---|---|
| ① 循环入口 | 万一上一轮某个操作耗时接近看门狗超时,入口处先喂一次保底 |
| ② LED刷新后 | App_LED_Process 内部有可能因为某些原因耗时(虽然是简单的加减法,但防御性) |
| ③④ 按键处理后 | 按键处理会调用 UART_SendString,这是阻塞发送,可能耗时 |
| ⑤ 串口字节循环 | 如果环形缓冲区里积了很多数据,while 循环可能跑很多轮 |
| ⑥ 中断里 | 如果主循环卡死,中断还能喂狗------等等,这不就喂不了了吗? |
注意⑥的矛盾 :中断里喂狗其实有风险------如果主循环死了但中断还在触发(串口一直来数据),看门狗就不会超时复位。但这个项目的做法是在中断里喂狗是为了防止"主循环正常跑,但某次串口数据特别多导致主循环一轮耗时太长"的情况。这是一个权衡------不是完美方案,但在实际场景中够用。
决策点 2:Error_Handler 为什么关中断等死?
c
void Error_Handler(void) {
__disable_irq(); // 关闭所有中断
while (1) {
HAL_IWDG_Refresh(&hiwdg); // 只喂狗
}
}
你面对的问题 :HAL 库函数出错时会调用 Error_Handler。这里该做什么?
选择 A:打印错误信息,继续跑
c
void Error_Handler(void) {
UART_SendString("Error!\r\n");
// 继续跑?
}
问题:系统已经处于错误状态,继续跑可能导致更严重的后果------写错寄存器、输出错误 PWM 烧灯、发错串口指令。量产中,错误状态下继续运行是最危险的。
选择 B(量产做法):关中断 + 喂狗 + 死等
c
void Error_Handler(void) {
__disable_irq(); // 关中断:不再响应任何中断,系统"冻结"
while (1) {
HAL_IWDG_Refresh(&hiwdg); // 喂狗:不重启,保持冻结状态
}
}
为什么这么设计:
__disable_irq()关闭所有中断------系统停止响应外部事件,进入"安全停机"状态- 持续喂狗------不会自动复位重启,保持冻结
- 为什么不直接不喂狗让它复位?因为有些错误(如时钟配置失败)复位后还会再次失败,陷入死循环。冻结状态让调试人员能看到"系统停在这里了",方便定位问题。
设计哲学 :出错时安全停机 比带病运行好。你宁可设备"冻结"让维护人员来修,也不能让它"发疯"输出错误控制信号。
决策点 3:assert_failed 为什么也要喂狗?
c
void assert_failed(uint8_t *file, uint32_t line) {
while (1) {
HAL_IWDG_Refresh(&hiwdg);
HAL_Delay(10);
}
}
assert_failed 是 HAL 库的参数断言失败回调。和 Error_Handler 类似------冻结系统等维护。这里多了一个 HAL_Delay(10) 做一个轻微延时,降低 CPU 功耗。
注意 :
assert_failed只在USE_FULL_ASSERT宏定义打开时才编译。量产发布时通常会关闭 assert 以节省 Flash 空间,开发阶段打开以捕获参数错误。
第八章:设计迭代的真实过程(不是一遍写完)
这一章最重要------新手以为工程师是一遍写出最终代码的,但实际不是。 量产工程是多轮迭代出来的。下面模拟真实的迭代过程。
第一轮:让灯亮起来(最小可行系统)
目标:4 个 LED 能亮,不关呼吸、不关按键、不关串口。
c
// 第一轮的 main.c
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_TIM3_Init();
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 200); // LED0 半亮
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 500); // LED1 最亮
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 500); // LED2 最亮
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_4, 500); // LED3 最亮
while (1) {
// 空循环
}
}
这一轮的代码很丑------直接写寄存器,没有封装,没有结构体。但这轮的目的是验证硬件没问题:4 个 LED 都能亮,PWM 占空比能控制亮度,共阳极取反逻辑正确。
新手误区:想一步到位写出完美代码。正确做法是先写"丑代码"验证硬件,再逐步重构。
第二轮:加呼吸灯
目标:LED0 呼吸渐变。
c
// 第二轮:在 while(1) 里加呼吸
uint16_t breath = 0;
int16_t dir = 10;
while (1) {
breath += dir;
if (breath >= 1000) { breath = 1000; dir = -10; }
if (breath <= 0) { breath = 0; dir = 10; }
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 1000 - breath);
HAL_Delay(20); // ← 先用 delay,能跑就行
}
这一轮验证呼吸算法逻辑正确。但还是用了 HAL_Delay------这一轮不关心时序,只验证算法。
第三轮:加按键控制
目标:KEY1 点亮 LED1,KEY2 熄灭 LED1。
c
// 第三轮:加按键
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) == 0) {
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 500);
}
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4) == 0) {
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 1000);
}
这一轮你会发现问题 :按键抖动导致一次按下被识别多次。→ 这时你才去研究消抖算法,设计 app_key.c。
关键 :你不是一开始就知道需要消抖的。你是写到了按键控制,发现了抖动问题,才推导出需要消抖。这就是"问题驱动设计"。
第四轮:把 HAL_Delay 换成时间戳
目标:呼吸灯和按键同时工作,不能互相阻塞。
这时你发现 HAL_Delay(20) 会让按键响应延迟 20ms。→ 推导出需要非阻塞时间戳。
c
uint32_t tick_breath = 0;
uint32_t tick_key = 0;
while (1) {
if (HAL_GetTick() - tick_breath >= 20) {
tick_breath = HAL_GetTick();
// 呼吸刷新
}
if (HAL_GetTick() - tick_key >= 10) {
tick_key = HAL_GetTick();
// 按键扫描
}
}
第五轮:加串口控制
目标:串口收 "ON\r\n" 点亮所有 LED。
这时你发现:
- 串口接收不能阻塞 → 推导出中断接收
- 收到的字节要暂存 → 推导出环形缓冲区
- 要拼接完整指令 → 推导出
\r\n结束符判断 - 要解析指令 → 推导出
sscanf + strcmp
第六轮:拆分模块
代码越来越多,main.c 越来越长。→ 这时你才把 LED 代码移到 app_led.c,按键移到 app_key.c,串口移到 app_uart.c。
第七轮:量产加固
功能都跑通了,但:
- 可能死机 → 加看门狗
- 可能有非法输入 → 加参数校验
- 可能硬件故障 → 加 Error_Handler 安全停机
- 可能需要调试 → 加 STATUS 指令打印循环计数
迭代过程总结
第一轮:验证硬件 → 丑代码直接写寄存器
第二轮:验证算法 → 呼吸逻辑跑通(先用 delay)
第三轮:加新功能 → 发现按键抖动问题 → 设计消抖
第四轮:解决冲突 → 发现 delay 阻塞 → 改时间戳
第五轮:加通信 → 发现串口阻塞 → 设计中断+环形缓冲
第六轮:重构架构 → 代码太长 → 拆分模块
第七轮:量产加固 → 加看门狗/校验/错误处理
每一步都不是预先规划的,而是前一步暴露了问题,这一步才去解决。 新手看到最终代码觉得"好多东西要想",但如果你按这个迭代过程走,每一步只想一个问题,就不会觉得难了。
附录:量产设计检查清单
每次完成一个模块设计后,对照这张表检查:
接口设计检查
- 每个公开函数是否有参数合法性检查(id 越界、NULL 指针、数值超限)?
- 公开函数是否只通过
.h暴露,内部函数是否用static隐藏? - 模块间是否禁止互相调用(只有 main.c 调用各模块)?
- 配置参数是否用
#define集中在头文件,方便量产修改?
时序设计检查
- 主循环里是否有
HAL_Delay?(应为没有) - 周期性任务是否用
HAL_GetTick()时间戳控制? - 中断回调函数是否只做最少的工作(存数据,不做业务逻辑)?
- 中断和主循环共享的变量是否加了
volatile?
健壮性检查
- 数组访问是否有越界保护?
- 外部输入(串口数据)是否经过校验?
- 看门狗是否在所有可能耗时的位置喂狗?
- Error_Handler 是否安全停机而不是继续运行?
可维护性检查
- 头文件是否有 Doxygen 注释说明函数用途、参数、返回值?
- 复杂逻辑是否有注释说明"为什么这么写"?
- 硬件相关参数(共阳极取反、ARR 值)是否封装在单个函数内?
- 新增功能时是否只需要加代码而不需要改现有代码(开闭原则)?
最后给新手的建议:不要试图一次写出完美的量产代码。先写出能跑的丑代码,然后在每一轮迭代中问自己------"现在最大的问题是什么?"------然后解决这个问题。7 轮迭代之后,代码自然就是量产级的了。