STM32 从 0 到 1 做智能家居(五):工程结构与代码骨架

文章目录

    • [一、为什么不能把所有代码都塞进 main.c](#一、为什么不能把所有代码都塞进 main.c)
    • 二、最终的文件构成
    • [三、把 App 加进 Keil(两件事,别只做一件)](#三、把 App 加进 Keil(两件事,别只做一件))
      • [3.1 `.c` 文件加进工程组](#3.1 .c 文件加进工程组)
      • [3.2 `.h` 文件加进 Include Paths](#3.2 .h 文件加进 Include Paths)
      • [3.3 顺手勾上 Use MicroLIB](#3.3 顺手勾上 Use MicroLIB)
    • 四、写代码前必须懂的五个概念
      • [4.1 `volatile`:中断和主循环共享的变量必须加](#4.1 volatile:中断和主循环共享的变量必须加)
      • [4.2 `HAL_GetTick()`:所有周期任务的闸门](#4.2 HAL_GetTick():所有周期任务的闸门)
      • [4.3 USER CODE 段:只在这里写代码](#4.3 USER CODE 段:只在这里写代码)
      • [4.4 中断里只做最少的事](#4.4 中断里只做最少的事)
      • [4.5 状态机:整机只有三个模式](#4.5 状态机:整机只有三个模式)
    • [五、main.c 的四块骨架](#五、main.c 的四块骨架)
    • 六、主循环的纪律
    • 七、头文件与命名规范
      • [7.1 每个驱动一对 `.c/.h`](#7.1 每个驱动一对 .c/.h)
      • [7.2 命名约定](#7.2 命名约定)
    • 八、第一次编译与最小验证
    • 九、本章易错点总结
    • 十、小结与下一篇

系列第五篇。CubeMX 已经把底层配好了,但从这一篇开始,代码要由我们自己组织。先搭骨架、定规矩,再往里填驱动。

这一篇不写具体传感器,只解决一件事:让代码有一个不会越写越乱的结构。

一、为什么不能把所有代码都塞进 main.c

我做第一个版本时,main.c 写到 900 多行:DHT11 时序、I2C 读寄存器、PWM、串口解析、按键判断,全在一起。

当时觉得挺方便,直到有一次 DHT11 读失败。我想单独测一下传感器,结果发现:根本没法测------一上电就初始化 WiFi,初始化完还要等 3 秒,传感器代码和主循环搅在一起,注释掉哪一段都编译不过。

后来我把它拆成三块:

  • 底层初始化(CubeMX 生成,只读不改);
  • 自己的驱动(DHT11、ENS160、MQTT,独立文件);
  • 业务逻辑(模式切换、自动控制,留在 main.c)。

拆完之后,同一类问题只需要看一个文件。结构不是为了好看,是为了让排错范围变小。

二、最终的文件构成

text 复制代码
HAL_SmartHome\
├─ App\                          ← 你自己写的驱动,CubeMX 不管
│  ├─ DHT11.c / DHT11.h          单总线温湿度 + 微秒延时
│  ├─ ens160.c / ens160.h        I2C 空气质量
│  └─ mqtt.c / mqtt.h            ESP-AT + MQTT
├─ Core\
│  ├─ Inc\   main.h tim.h usart.h i2c.h gpio.h adc.h
│  └─ Src\   main.c tim.c usart.c i2c.c gpio.c adc.c stm32f4xx_it.c
├─ Drivers\                      ← HAL 库与 CMSIS,只看不改
├─ MDK-ARM\HAL_SmartHome.uvprojx ← Keil 工程
└─ HAL_SmartHome.ioc             ← CubeMX 工程

三层职责要分清楚:

层 谁生成 能不能改 放什么
Drivers\ CubeMX 不要改 HAL 库、CMSIS
Core\ CubeMX 只能改 USER CODE 段 初始化、中断入口、业务逻辑
App\ 你自己 随便改 传感器、通信驱动

这样分的好处很直接:CubeMX 重新生成代码时,只可能动 Core,永远动不到 App。你辛苦调通的 DHT11 时序不会因为改了一个引脚就消失。

三、把 App 加进 Keil(两件事,别只做一件)

3.1 .c 文件加进工程组

  1. Keil 左侧 Project 窗口,右键 Target 1 → Manage Project Items;
  2. 新建组 App(组名和文件夹同名,便于对应);
  3. 选中 App → Add Files → 加入 DHT11.c、ens160.c、mqtt.c;
  4. 点 OK。

.c 是函数实现,必须参与编译。只放文件夹不加工程,链接时会报:

text 复制代码
L6218E: Undefined symbol ReadData (referred from main.o)

3.2 .h 文件加进 Include Paths

头文件不参与编译,靠的是"搜索路径"。在 Options for Target → C/C++ → Include Paths 里加入:

text 复制代码
..\App
..\Core\Inc
..\Drivers\STM32F4xx_HAL_Driver\Inc
..\Drivers\CMSIS\Device\ST\STM32F4xx\Include
..\Drivers\CMSIS\Include

其中后四条 CubeMX 生成工程时通常已经有了,真正需要你手动补的是 ..\App。

如果漏了它,编译会报:

text 复制代码
cannot open source input file "DHT11.h"

补充一句:把 .h 也 Add 进 App 组只是"方便在工程树里双击查看",不能代替 Include Paths。

3.3 顺手勾上 Use MicroLIB

Options for Target → Target → 勾选 Use MicroLIB。

不勾的话,printf 重定向后可能一个字符都没有,或者程序卡在 fputc 里。这个坑在第二篇讲过,这里再提醒一次,因为它到这一章才真正开始影响你。

四、写代码前必须懂的五个概念

4.1 volatile:中断和主循环共享的变量必须加

volatile 的作用是告诉编译器:"这个变量可能被别的地方改,别把它优化到寄存器里。"

本工程里这些变量全部是"中断里改、主循环里读":

c 复制代码
volatile uint8_t g_mode = MODE_MANUAL;   // 按键中断改,主循环 switch
volatile uint8_t g_print_req = 0;        // 按键中断置 1,主循环清 0
volatile uint8_t g_net_req = 0;          // 中断请求切协议,主循环执行
volatile uint8_t esp_sub_ready = 0;      // 串口中断置 1,主循环处理

不加会怎样?开优化后,主循环可能只在进入时读一次 g_mode,之后一直用寄存器里的旧值。现象就是:按键明明触发了,串口也打印了,但模式就是不变。

一个很典型的翻车现场:代码逻辑完全正确,调试器里看变量也确实变了,但程序行为就是不跟着变。

4.2 HAL_GetTick():所有周期任务的闸门

它返回开机以来的毫秒数(uint32_t)。本工程所有"每隔一段时间做一次"都靠它:

c 复制代码
if(HAL_GetTick() - last_tick >= 2000)   // 2 秒到了
{
    last_tick = HAL_GetTick();
    Sensors_Update();
}

为什么要用"减法"而不是 if(HAL_GetTick() > last_tick + 2000)?

因为 HAL_GetTick() 是 32 位,大约 49.7 天会溢出归零。用无符号减法,溢出时结果依然正确;用加法比较则会在溢出点出现一次异常。这是个很少人提、但确实存在的坑。

4.3 USER CODE 段:只在这里写代码

CubeMX 生成的 main.c、tim.c、usart.c 里,只有 USER CODE BEGIN/END 之间是安全的。这条纪律从第二篇一直贯穿到最后,每次点 GENERATE CODE 前都要检查。

4.4 中断里只做最少的事

中断服务函数的原则:能一秒内做完,就别做两秒的事。

允许做的事:

  • 置一个标志(g_print_req = 1);
  • 读一个寄存器、存一个字节;
  • 翻转一个 LED。

绝对不允许:

  • HAL_Delay();
  • 等串口发送完成;
  • 调用 Mqtt_Server_Init() 这种要十几秒的函数;
  • 在中断里写 while(...) 等某个标志。

本工程的按键中断只做一件事:记录时间戳、判断消抖、置标志。真正的模式切换、命令执行,全部放到主循环。这样按键永远是"秒响应",不会被 WiFi 初始化拖住。

4.5 状态机:整机只有三个模式

c 复制代码
#define MODE_MANUAL 0   // 手动:按键直接控制设备
#define MODE_AUTO   1   // 自动:按温湿度和空气质量控制
#define MODE_REMOTE 2   // 远程:蓝牙 / MQTT 控制

三个模式决定了按键的含义、串口数据怎么处理、设备由谁控制。

所以看这份代码有一个诀窍:先找 g_mode 当前值,再看对应的 switch 分支。 同一个 KEY4,在自动模式是"进远程",在手动模式是"开关加热",行为完全不同------不是代码写错了,是模式不同。

另外,切换模式时必须先关输出(风扇、加热、舵机归位)。否则从自动切到手动时,风扇可能还在转,而手动标志却是"关",两边状态对不上,后面会很难查。

五、main.c 的四块骨架

不要一上来就写满,先按这四块搭好:

c 复制代码
/* ① 定义区:模式、阈值、全局状态 */
#define MODE_MANUAL 0
#define MODE_AUTO   1
#define MODE_REMOTE 2

volatile uint8_t g_mode = MODE_MANUAL;   // 上电默认手动

/* ② 工具函数声明:读传感器、打印、执行命令、切模式 */
static void Sensors_Update(void);
static void Print_Env(void);
static void Remote_Cmd_Exec(const char *cmd, uint8_t echo_back);
static void Switch_Mode(uint8_t new_mode);

/* ③ 中断回调:按键 + 串口接收 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin);
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);

/* ④ main():初始化 → while(1) 轮询 */
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();
    /* 其余外设初始化 ... */

    while(1)
    {
        /* 只做:判断标志 → 执行一次 → 立刻返回 */
    }
}

四个部分各自解决一个问题:

区块 作用 写错会怎样
① 定义区 统一模式、阈值、状态 阈值散落各处,改一个忘一个
② 工具函数 把一件事封装成一个函数 主循环越写越长,无法定位
③ 中断回调 只置标志,快速返回 中断里做耗时操作,整机卡死
④ main 初始化和轮询调度 顺序错、阻塞调用,功能互相拖累

关于 static:给函数加 static 表示"只在本文件可见",可以避免和别的文件重名。本工程的工具函数都只服务于 main.c,所以全部加 static。

六、主循环的纪律

while(1) 里有一条硬规则:

不允许任何形式的"死等"。

不允许的写法:

c 复制代码
while(USART2->SR & USART_SR_RXNE == 0);   // 等一个字节,卡死整机
HAL_Delay(5000);                          // 等 5 秒,按键全无响应
while(!mqtt_ready);                       // 等 WiFi,永远出不来

允许的写法是"查一下、没到就返回":

c 复制代码
while(1)
{
    if(g_print_req)          { g_print_req = 0; Print_Env(); }
    if(g_net_req)            { /* 切协议,内部也必须是有限步骤 */ }
    if(esp_sub_ready)        { esp_sub_ready = 0; Remote_Cmd_Exec(esp_sub_data, 0); }

    if(HAL_GetTick() - last_sensor >= 2000)
    {
        last_sensor = HAL_GetTick();
        Sensors_Update();
    }

    Mqtt_Poll();             // 只看缓冲区有没有完整行,没有就立刻返回
}

判断标准很简单:问自己"这个函数里有没有 while 等待或 HAL_Delay?" 如果有,它就不该出现在主循环的快速路径里。

WiFi 初始化要 10~16 秒,怎么办?不是把主循环卡住,而是:

  1. 上电先等 3 秒,让 ESP 启动完;
  2. 主循环里检测"该初始化了",进入初始化;
  3. 初始化期间用一个 g_net_busy 标志忽略按键,避免指令流被打断;
  4. 初始化完成立刻回到正常轮询。

这样最坏的情况是"初始化这十几秒按键没反应",而不是"整机永久卡死"。

七、头文件与命名规范

这一节内容不多,但能帮你避免 80% 的"重复定义"和"未定义"报错。

7.1 每个驱动一对 .c/.h

c 复制代码
/* DHT11.h */
#ifndef __DHT11_H
#define __DHT11_H

#include "main.h"

void DHT11_Init(void);
uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi);

#endif
  • 函数声明放 .h,实现放 .c ,main.c 只 #include;
  • #ifndef/#define/#endif 防重复包含,少了它,两个文件同时包含就会报重复定义;
  • 头文件里不要定义变量 。要共享变量,在 .c 里定义,在 .h 里写 extern:
c 复制代码
/* mqtt.c */
volatile uint8_t esp_sub_ready = 0;

/* mqtt.h */
extern volatile uint8_t esp_sub_ready;

如果直接把 volatile uint8_t esp_sub_ready = 0; 写进 .h,每个包含它的 .c 都会生成一份,链接时报 multiply defined。

7.2 命名约定

前缀 含义 例子
g_ 全局变量 g_mode、g_fan_on
MODE_ 模式常量 MODE_AUTO
大写宏 阈值、引脚 TEMP_HIGH、FAN_PWM
大驼峰函数 模块内操作 DHT11_ReadData()

统一命名不是为了好看,是为了你三个月后回来还能一眼看出"这是个全局状态"还是"局部临时变量"。

八、第一次编译与最小验证

骨架搭好后,不要直接写传感器。先做一次最小验证:

第一步:编译空骨架

三个 .c 里只放一个空函数(或者先只建文件、写头文件声明),main.c 里 #include 它们,编译。

要求:0 Error 0 Warning。这一步验证的是:文件加对了、Include Paths 加对了、头文件保护写对了。

第二步:串口打印计数

在 main.c 里实现 fputc 重定向到 USART1,主循环里每 1 秒打印一次计数:

c 复制代码
printf("tick = %lu\r\n", HAL_GetTick() / 1000);

能看到数字稳定递增,说明:时钟、串口、MicroLIB、重定向全部正常。

第三步:LED 翻转

用 HAL_GPIO_TogglePin(LED_T_GPIO_Port, LED_T_Pin) 每 500ms 翻一次。

LED 会闪,说明 GPIO 和 HAL_GetTick() 的节拍都对。

这三步全过,骨架就算立住了。后面每加一个驱动,都只在这个骨架上"挂"一块,出问题时范围很小。

九、本章易错点总结

现象 真正原因 处理
链接报 Undefined symbol ReadData App 里的 .c 没加进 Keil 工程组 Manage Project Items → Add Files
编译报 cannot open source input file "DHT11.h" Include Paths 里没有 ..\App C/C++ → Include Paths 添加
printf 完全没输出 没勾 Use MicroLIB,或没实现 fputc 重定向 勾 MicroLIB,fputc 指向 USART1
按键触发了,但模式不变 g_mode 没加 volatile,被优化成寄存器缓存 中断与主循环共享的变量全部加 volatile
链接报 multiply defined 变量定义写进了 .h,被多个 .c 包含 .c 里定义,.h 里用 extern
编译报重复定义/结构体重定义 头文件没写 #ifndef 保护 加头文件保护宏
系统卡死十几秒,按键无响应 在中断或主循环里调用了 Mqtt_Server_Init 这类长耗时函数 耗时操作拆成有限步骤,或放到主循环
主循环里等串口,其他功能全停 写了 while(!收到数据); 改成中断收 + 主循环查标志
所有代码堆在 main.c,无法单独测试 没拆驱动文件 按 App 目录拆分,一个模块一对 .c/.h
改了一次 CubeMX,代码少了一半 代码写在 USER CODE 标记外面 只写在 BEGIN/END 之间
上电后风扇自己转、状态混乱 切换模式时没有先关输出 Switch_Mode() 里先清 CCR、清标志
定时任务偶尔漏一次 用 HAL_GetTick() > last + 2000 比较,溢出点出错 用 HAL_GetTick() - last >= 2000

十、小结与下一篇

这一篇把代码的"骨架"立起来了:

  • Drivers / Core / App 三层分工,CubeMX 只动前两层;
  • .c 加进工程组,.h 加进 Include Paths;
  • 五个概念:volatile、HAL_GetTick()、USER CODE、中断最小化、状态机;
  • 主循环只做"判断 + 执行 + 返回",绝不阻塞;
  • 头文件声明、extern、命名规范,避免重复定义。

下一篇开始写第一个真正的驱动:DHT11 温湿度采集 。你会看到 delay_us 为什么要自己写、40µs 采样点是怎么来的、以及为什么读完 40 位数据必须关中断。

如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。

相关推荐
深圳老胡1 小时前
STM32F103 硬件 SPI 与软件 IO 模拟 SPI 最高速度差异详解
笔记·stm32·单片机·代码规范·1024程序员节
吴建旭 智宅焕2 小时前
智能家居全国交付中前端工具与后端基础设施的系统集成架构:报价入口与交付兜底的闭环设计
前端·架构·智能家居
蒲公英内测分发2 小时前
智能家居厂商有多个产品线,包装上的 App 下载入口该共用还是分开?
android·人工智能·数码相机·机器人·智能家居·智能硬件
吴建旭 智宅焕2 小时前
智能家居全国交付模式选型架构设计:连接型与兜底型的决策矩阵与匹配逻辑
运维·智能家居·devops
smartpi_ai2 小时前
机芯智能离线语音模块实战问答:SU-03T、CI-03T、SU-63T 与 JX-A7T 高频问题排查
人工智能·单片机·嵌入式硬件·ci/cd·语音识别
2501_922639582 小时前
率能半导体 SS6635E直流双向马达驱动芯片:30V/4.5A,覆盖电子锁、玩具、无线充电、机器人四大应用场景
单片机·嵌入式硬件·硬件工程
wuyk5552 小时前
TinyML 初探:在 MCU 上部署异常检测模型,实现本地振动故障诊断
stm32·单片机·嵌入式硬件·重构
新晨单片机设计2 小时前
S031A-基于STM32单片机指纹考勤系统【Proteus仿真+Keil程序+原理图】
stm32·单片机·嵌入式硬件·proteus·指纹考勤系统
新时代牛马3 小时前
`arch_timer`、`IPI5`、`HRTIMER`、`ksoftirqd` 的关系
网络·单片机·tcp/ip