文章目录
-
- [一、为什么不能把所有代码都塞进 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)
- [3.1 `.c` 文件加进工程组](#3.1
- 四、写代码前必须懂的五个概念
-
- [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 状态机:整机只有三个模式)
- [4.1 `volatile`:中断和主循环共享的变量必须加](#4.1
- [五、main.c 的四块骨架](#五、main.c 的四块骨架)
- 六、主循环的纪律
- 七、头文件与命名规范
-
- [7.1 每个驱动一对 `.c/.h`](#7.1 每个驱动一对
.c/.h) - [7.2 命名约定](#7.2 命名约定)
- [7.1 每个驱动一对 `.c/.h`](#7.1 每个驱动一对
- 八、第一次编译与最小验证
- 九、本章易错点总结
- 十、小结与下一篇
系列第五篇。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 文件加进工程组
- Keil 左侧
Project窗口,右键Target 1→ Manage Project Items; - 新建组
App(组名和文件夹同名,便于对应); - 选中
App→Add Files→ 加入DHT11.c、ens160.c、mqtt.c; - 点 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 秒,怎么办?不是把主循环卡住,而是:
- 上电先等 3 秒,让 ESP 启动完;
- 主循环里检测"该初始化了",进入初始化;
- 初始化期间用一个
g_net_busy标志忽略按键,避免指令流被打断; - 初始化完成立刻回到正常轮询。
这样最坏的情况是"初始化这十几秒按键没反应",而不是"整机永久卡死"。
七、头文件与命名规范
这一节内容不多,但能帮你避免 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 位数据必须关中断。
如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。