stm20260720-从新手 C 到量产 STM32 工程:程序设计推导指南

stm20260720-从新手 C 到量产 STM32 工程:程序设计推导指南

案例项目 :STM32 LED 智能控制系统(4 路 PWM + UART 指令 + 三路按键 + IWDG 看门狗)

平台 :STM32F103ZET6 + CubeMX + HAL 库 + Keil MDK

本文目标 :不是教你"这段代码什么意思",而是教你"怎么一步步推导出需要写这段代码"------让新手有迹可循


目录


第零章:新手思维 vs 量产思维的根本差异

0.1 新手最常见的困惑

你学了 C 语言,会写 if/elsefor 循环、函数调用、结构体、指针。然后你打开一个量产工程,看到几百行代码分散在十几个文件里,第一反应是:

"这些代码是怎么想出来的?我怎么可能一次设计出这么多东西?"

答案是:没有人是一次想出来的。 量产工程是一轮一轮推导 出来的,每一轮解决一个问题,解决完又冒出新的问题,再解决,如此迭代。本文要教就是这条推导链

0.2 两种思维模式的对比

维度 新手 C 语言作业 量产 STM32 工程
目标 功能跑通就满分 长期稳定运行 + 可维护 + 可扩展
代码量 一个 main.c 几十行 多文件分层,几百到几千行
设计起点 "我要实现 XX 功能" "规格书要求什么?约束是什么?"
思考方式 从代码开始想 问题开始想,代码是问题的答案
错误处理 不管,崩了重启就行 每一层都要防御,不能崩
时序 delay() 等就行 不能阻塞,多任务并发
演进 写完就交了 第一版只是起点,后面还要加固

0.3 量产工程的核心设计原则(先记住,后面会逐条推导)

  1. 分层隔离:应用层不碰寄存器,驱动层不碰业务逻辑
  2. 非阻塞 :永远不用 HAL_Delay(),用时间戳轮询
  3. 输入校验:所有外部输入都不可信,必须检查
  4. 异常兜底:出错了不能继续跑错误状态,要安全停机
  5. 状态可观测:任何时候都能知道系统在干什么

这 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

  1. 一个 LED 的所有属性绑在一起,语义清晰
  2. 初始化时一目了然------每行就是一个 LED 的全部信息
  3. 传参只需传一个 LED_ID_t,函数内部用 s_led[id].brightness 访问
  4. 将来加属性(比如加个 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

  1. 调试时看变量值直接显示 LED_MODE_BREATH,不用查表
  2. 枚举有类型,编译器能做检查
  3. 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);
    // ...
}

问题:以后写 SetSingleBrightnessControlGroupGlobalControl 时,每个函数里都要写一遍这个 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

  1. 所有"改模式后要刷新硬件"的逻辑集中在一处,改一处全员生效
  2. SetSingleMode 只需两行:改状态 + 调 ApplyMode
  3. default 分支保证未知模式不会导致不可控输出

注意 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);

问题:

  1. 每个写 PWM 的地方都要记得取反------忘记一次就是 bug
  2. 亮度值可能超过 1000,没有保护
  3. 将来硬件改成共阴极,所有地方都要改

量产做法 :封装一个 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);
}

为什么这么做

  1. 取反逻辑只写一次,永远不会忘
  2. 边界保护只写一次,永远不会漏
  3. 将来硬件改了,只改这一个函数
  4. static 限制只有本文件能调用------外部模块想写 PWM 必须通过公开接口

设计模式总结 :这就是"信息隐藏"。硬件细节(共阳极取反、ARR 值)藏在驱动层内部,上层只管"我要设亮度 500",不需要知道 500 怎么变成 CCR 值。
推导出来的新问题:呼吸灯怎么实现?→ 引出决策点 5。

决策点 5:呼吸灯算法为什么用三角波?

你面对的问题:LED0 要呼吸渐变------亮度从 0 慢慢升到 1000,再慢慢降到 0,循环往复。

选择 A(新手做法):用正弦波

c 复制代码
brightness = (sin(time * freq) + 1.0) * 500.0;

问题:

  • STM32F103 没有 FPUsin() 是软件浮点模拟,一条 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

  1. 纯整数加减,几个时钟周期就完成,不占 CPU
  2. 视觉上三角波和正弦波差别不大(人眼对亮度变化不敏感)
  3. 步长 LED_BREATH_STEP 可调------值大呼吸快,值小呼吸慢
  4. 刷新间隔用 HAL_GetTick() 时间戳控制,非阻塞

关键推导 :为什么 last_tickstatic?因为这个函数会被主循环反复调用,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);

为什么分三层

  1. 每层的调用者不同------按键只控制单个灯,串口控制一批灯,全局控制所有灯
  2. LED0 的心跳呼吸灯是"特权灯"------分组控制跳过它,全局控制才动它
  3. 将来加新的控制方式(比如"只控制偶数号灯"),加第四层就行,不影响现有代码

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 或者把 ControlGroupSetGroupBrightness 混成一个函数。


第四章:按键模块的完整推导链

决策点 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;  // 高电平=释放
                }
            }
        }
    }
}

逐行拆解这个算法的推导过程

  1. 三个状态变量的作用

    • last_state:上次读到的原始电平------用来检测"电平是否跳变了"
    • stable_state:消抖后的确认电平------用来检测"新电平是否和已确认的不同"
    • last_change_tick:上次跳变的时刻------用来计算"已经稳定了多久"
  2. 为什么用 continue ?因为电平刚跳变,这一轮不可能确认稳定,必须等下一轮扫描(10ms 后)再判断。continue 跳过本轮后续逻辑。

  3. 为什么用 >= 而不是 == ?如果主循环因为某次任务耗时超过 10ms,now_tick - last_change_tick 可能是 25ms 而不是恰好 20ms。用 >= 保证不会漏判。

  4. 为什么低电平是按下?因为硬件上按键一端接 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

  1. 按键模块完全不知道"按键按下后要做什么"------它只报告"按下了"
  2. main.c 决定"按下后做什么"------业务逻辑集中在调度层
  3. ClearEvent 保证一个事件只被处理一次------不会因为主循环跑得快,一个按下被响应多次

这叫"生产者-消费者"模式 。按键模块是生产者(产生事件),main.c 是消费者(处理事件)。中间用 event 字段做缓存,解耦了产生和消费的时机。
注意 ClearEvent 的调用位置:在处理完事件之后调用,不是在处理之前。如果先 Clear 再处理,处理过程中如果出错,事件就丢了。


第五章:串口模块的完整推导链

决策点 1:为什么用中断接收而不是轮询?

你面对的问题:串口要接收不定长的指令(如 "BRIGHT 500\r\n"),怎么收?

选择 A(新手做法):轮询阻塞接收

c 复制代码
HAL_UART_Receive(&huart4, buf, 64, 1000);  // 阻塞等 1 秒

问题:

  1. 阻塞期间主循环卡死,呼吸灯停、按键不扫、看门狗可能超时
  2. 不知道指令多长,按 64 字节收会等到超时才返回,响应慢
  3. 如果一次没收完,剩下的数据丢了

选择 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. 每次只收 1 字节,不用知道指令多长
  3. 环形缓冲区暂存数据,主循环有空时再来取

推导出来的新问题:中断里收到的字节存哪里?主循环怎么取?→ 引出决策点 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;
}

逐行拆解

  1. 为什么用 % UART_RX_BUF_SIZE 取模?因为环形------写到 127 后下一个位置是 0,不是 128。取模自动绕回。

  2. 为什么判断 next_write != rx_read_idx 而不是 rx_write_idx != rx_read_idx ?因为先算出下一个位置再判断,可以区分"满"和"空"两种状态。如果 write 追上了 read,说明满了,丢弃新数据。

  3. 为什么 rx_write_idxrx_read_idx 要加 volatile ?因为它们被中断和主循环同时访问 ------中断写 write_idx,主循环读 write_idx。如果不加 volatile,编译器可能把主循环里的读取优化掉(缓存到寄存器不重新读),导致主循环永远以为缓冲区是空的。

volatile 的本质:告诉编译器"这个变量随时可能被别处修改,每次用的时候都重新从内存读"。

  1. 为什么 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 超时
}

为什么不也用中断发送?

  1. 发送是主动行为------主循环决定什么时候发,不存在"数据突然来"的问题
  2. 发送的内容都是短字符串("LED1~3 ON\r\n"),几十字节,115200 波特率下不到 1ms
  3. 中断发送需要维护发送缓冲区、状态机,复杂度高,收益低
  4. 500ms 超时保护------如果硬件故障导致发送卡住,不会永远阻塞

设计原则 :不是所有东西都要用中断。中断用于"不可预测时机的事件"(接收),阻塞用于"可控短时操作"(发送)。简单优先,有性能问题再优化。

决策点 4:为什么 NULL 检查很重要?

c 复制代码
void UART_SendString(const char *str) {
    if (str == NULL) return;  // ← 这行
    ...
}

你面对的问题 :如果别处传了 NULL 进来,strlen(NULL) 会直接 HardFault。

新手做法:不管,反正我不会传 NULL。

量产做法:每个接收指针参数的函数,第一件事就是检查 NULL。这不是多此一举------在量产中,某个同事重构代码时可能不小心传了 NULL,检查能避免整机死机。


第六章:主循环的推导链

决策点 1:为什么不能在主循环里用 HAL_Delay?

你面对的问题:主循环要同时做三件事------

  1. 每 20ms 刷新呼吸灯
  2. 每 10ms 扫描按键
  3. 随时处理串口指令

如果用 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) {
        // 拼接指令...
    }
}

为什么这么做

  1. 主循环每一轮都很快跑完(微秒级),不会阻塞
  2. 每个任务用自己的时间戳判断"该不该执行",互不影响
  3. 串口处理放在最后------因为 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 而不是手写解析

  1. 指令格式简单("CMD PARAM" 两段式),sscanf 一行搞定分割
  2. strcmp 做字符串比较清晰直观
  3. 如果指令很复杂(带多个参数、引号、转义),才需要手写解析器

设计原则 :用标准库函数,不要重新发明轮子。但标准库函数不做边界检查,所以参数校验要自己做------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 然后检查范围

  1. 如果传进来 "ABC",atoi("ABC") 返回 0------不报错,但语义错误(用户输入了非法字符串,系统当成 0 处理了)
  2. 如果传进来 "99999",atoi 返回 99999------超过 uint16_t 范围会溢出
  3. 先检查是不是纯数字,再检查范围------两层防御

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);  // 喂狗:不重启,保持冻结状态
    }
}

为什么这么设计

  1. __disable_irq() 关闭所有中断------系统停止响应外部事件,进入"安全停机"状态
  2. 持续喂狗------不会自动复位重启,保持冻结
  3. 为什么不直接不喂狗让它复位?因为有些错误(如时钟配置失败)复位后还会再次失败,陷入死循环。冻结状态让调试人员能看到"系统停在这里了",方便定位问题。

设计哲学 :出错时安全停机带病运行好。你宁可设备"冻结"让维护人员来修,也不能让它"发疯"输出错误控制信号。

决策点 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。

这时你发现:

  1. 串口接收不能阻塞 → 推导出中断接收
  2. 收到的字节要暂存 → 推导出环形缓冲区
  3. 要拼接完整指令 → 推导出 \r\n 结束符判断
  4. 要解析指令 → 推导出 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 轮迭代之后,代码自然就是量产级的了。

相关推荐
开发笔记-阿牛6 小时前
解析一款有意思的产品||CK6159A语音芯片设计的儿童专注力训练机-舒尔特玩具
stm32·单片机·嵌入式硬件·音频
Echo缘6 小时前
嵌入式系统C语言资源分类与内存分布分析
c语言·开发语言
JaneConan7 小时前
鸿蒙 ArkUI 深水区:@Watch 和 @Observed,状态变了「自动跑」+ 嵌套对象「深层重绘」
开发语言·后端·ui·harmonyos
赤水无泪7 小时前
qt中图标、名称的设置方式
开发语言·qt
王莎莎-MinerU7 小时前
MCP 解决的是工具接入,科研 Agent 还缺的是科学证据接口标准化
开发语言·网络·人工智能·深度学习·pdf·c#·php
橘子海全栈攻城狮7 小时前
【最新源码】基于SpringBoot + Vue的超市管理系统的设计与实现D002
java·开发语言·vue.js·spring boot·后端·spring
在水一缸7 小时前
深入浅出 Catch2:现代 C++ 测试框架的优雅实践
开发语言·c++·单元测试·log4j·测试框架·catch2
-银雾鸢尾-8 小时前
C#中Object类内的方法
开发语言·c#
浮江雾8 小时前
Flutter第十节-----Flutter布局与组件全解析
android·开发语言·前端·学习·flutter·入门