文章目录
- 一、为什么不能把所有代码都塞进 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文件加进工程组
- Keil 左侧
Project窗口,右键Target 1→Manage Project Items; - 新建组
App(组名和文件夹同名,便于对应); - 选中
App→Add Files→ 加入DHT11.c、ens160.c、mqtt.c; - 点 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 秒,怎么办?不是把主循环卡住,而是:
- 上电先等 3 秒,让 ESP 启动完;
- 主循环里检测"该初始化了",进入初始化;
- 初始化期间用一个
g_net_busy标志忽略按键,避免指令流被打断; - 初始化完成立刻回到正常轮询。
这样最坏的情况是"初始化这十几秒按键没反应",而不是"整机永久卡死"。
七、头文件与命名规范
这一节内容不多,但能帮你避免 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 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 位数据必须关中断。
如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。