☰
STM32 从 0 到 1 做智能家居(五):工程结构与代码骨架
2026/10/27 2:03:11 网站建设 项目流程

文章目录

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

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

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

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

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

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

后来我把它拆成三块:

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

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

二、最终的文件构成

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是函数实现,必须参与编译。只放文件夹不加工程,链接时会报:

L6218E: Undefined symbol ReadData (referred from main.o)

3.2.h文件加进 Include Paths

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

..\App ..\Core\Inc ..\Drivers\STM32F4xx_HAL_Driver\Inc ..\Drivers\CMSIS\Device\ST\STM32F4xx\Include ..\Drivers\CMSIS\Include

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

如果漏了它,编译会报:

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.1volatile:中断和主循环共享的变量必须加

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

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

volatileuint8_tg_mode=MODE_MANUAL;// 按键中断改,主循环 switchvolatileuint8_tg_print_req=0;// 按键中断置 1,主循环清 0volatileuint8_tg_net_req=0;// 中断请求切协议,主循环执行volatileuint8_tesp_sub_ready=0;// 串口中断置 1,主循环处理

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

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

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

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

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 状态机:整机只有三个模式

#defineMODE_MANUAL0// 手动:按键直接控制设备#defineMODE_AUTO1// 自动:按温湿度和空气质量控制#defineMODE_REMOTE2// 远程:蓝牙 / MQTT 控制

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

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

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

五、main.c 的四块骨架

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

/* ① 定义区:模式、阈值、全局状态 */#defineMODE_MANUAL0#defineMODE_AUTO1#defineMODE_REMOTE2volatileuint8_tg_mode=MODE_MANUAL;// 上电默认手动/* ② 工具函数声明:读传感器、打印、执行命令、切模式 */staticvoidSensors_Update(void);staticvoidPrint_Env(void);staticvoidRemote_Cmd_Exec(constchar*cmd,uint8_techo_back);staticvoidSwitch_Mode(uint8_tnew_mode);/* ③ 中断回调:按键 + 串口接收 */voidHAL_GPIO_EXTI_Callback(uint16_tGPIO_Pin);voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart);/* ④ main():初始化 → while(1) 轮询 */intmain(void){HAL_Init();SystemClock_Config();MX_GPIO_Init();MX_USART1_UART_Init();/* 其余外设初始化 ... */while(1){/* 只做:判断标志 → 执行一次 → 立刻返回 */}}

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

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

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

六、主循环的纪律

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

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

不允许的写法:

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

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

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

/* DHT11.h */#ifndef__DHT11_H#define__DHT11_H#include"main.h"voidDHT11_Init(void);uint8_tDHT11_ReadData(uint8_t*temp,uint8_t*humi);#endif
  • 函数声明放.h,实现放.c,main.c只#include;
  • #ifndef/#define/#endif防重复包含,少了它,两个文件同时包含就会报重复定义;
  • 头文件里不要定义变量。要共享变量,在.c里定义,在.h里写extern:
/* mqtt.c */volatileuint8_tesp_sub_ready=0;/* mqtt.h */externvolatileuint8_tesp_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 秒打印一次计数:

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 ReadDataApp里的.c没加进 Keil 工程组Manage Project Items → Add Files
编译报cannot open source input file "DHT11.h"Include Paths 里没有..\AppC/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 位数据必须关中断。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询