这两年陆陆续续做了不少基于STM32F030的嵌入式小项目,用得最多的组合就是F030搭配AHT20温湿度传感器,再在代码里塞一个轻量级多任务调度器。说实话,这套方案不算什么新东西,但它把“低成本MCU + I2C传感器 + 不跑RTOS的多任务思路”这三件事装进了同一个篮子,非常适合入门进阶和快速交付实际产品。今天就把这套方案从头到尾拆开聊一聊——为什么选F030而不直接上F103,AHT20的数据手册里有哪几个坑,以及怎么用一个SysTick加一张任务表就能撑起传感器采集、显示刷新和按键响应这三种不同频率的任务。代码全部是我在开发板上实测过的,可以直接抄走改。
1. 项目整体构思:为什么用F030来跑传感器加调度器
1.1 硬件选型的逻辑与现实约束
STM32F030是一颗Cortex-M0内核的入门级芯片,主频最高48MHz,不同型号的Flash从16KB到256KB不等,RAM从4KB到32KB不等。拿我常用的F030C8T6来说,64KB Flash、8KB RAM,在2025年的采购单价已经压到了几块钱一颗。这个价格对比什么概念?F103C8T6要贵一些,F407更是贵出好几倍。对于一个“打个温湿度、显示个数据、处理两个按键”的项目来说,F030的性能完全够用,但对码农的代码质量要求更高——8KB RAM摆在那里,你没法像写F407那样肆无忌惮地开大数组、上完整文件系统,必须精打细算。
选F030还有一个隐性原因:它的外设比F103少了很多“自动化的糖”。比如F0系列的I2C虽然也是硬件I2C,但中断、错误处理、时序异常的容忍度都不如F1/F4系那么皮实。这意味着你写驱动的时候得更尊重数据手册,更认真地处理时序细节。很多人觉得F0难用,其实是因为习惯了F1上“随便配配就能乐”的错觉。我反而觉得,在这种资源紧张的芯片上把传感器驱动和任务调度理清楚,你换到任何一颗更高端的MCU都会游刃有余。
再回到项目需求上:一个典型的温湿度采集终端,至少要有三件事同时进行——周期性地从AHT20读温湿度、按固定频率刷新显示屏或串口输出、及时响应按键输入。如果换到F103上,你可能会偷懒写个裸机大循环,把三件事串在一起,反正主频高、跑得快。但在F030上,主频只有48MHz,串行执行一切功能的思路很快会遇到瓶颈:读一次AHT20要等80ms的测量时间,这个延迟放在大循环里,按键响应直接就迟钝了。所以F030这种配置,反倒逼着你把“多任务调度”这件事认真做起来。
1.2 前后台大循环的痛点与多任务调度的价值
我见过很多初学者写的温湿度程序,结构大概是这样的:
while (1) { AHT20_StartMeasure(); Delay_ms(80); AHT20_ReadData(); OLED_ShowTemp(); OLED_ShowHum(); Delay_ms(100); Key_Scan(); }这个写法有三个问题。第一,Delay_ms(80)是死等,这80ms里CPU什么事情都干不了,按键按下去最快也要80ms之后才能响应。第二,如果后面OLED刷新一帧画面需要50ms,再加上读传感器的80ms,整个循环周期就到了130ms以上,按键响应延迟进一步恶化。第三,一旦你想加第三个任务——比如每秒上传一次数据到串口,就得重新排循环参数,改一处崩一片。
多任务调度的本质,就是把你手头这几件事拆成相互独立的任务,每个任务有自己的执行周期和容忍延迟。用生活类比来说,大循环模式就像食堂只有一个窗口,所有人排队,最前面的人刷卡充值要磨蹭半分钟,后面饿肚子的人只能干等;多任务调度则是几个窗口同时开,每个窗口排队的人都是固定的,虽然一顿饭的总时间没变,但每个人等待的“最大时间”是可以预估和控制的。
对于我的项目,任务划分是这样的:
| 任务名称 | 执行周期 | 允许的最大延迟 | 说明 |
|---|---|---|---|
| 温湿度采集 | 500ms | 50ms | 涉及I2C通信,是核心任务 |
| 显示刷新 | 200ms | 20ms | 把最新温湿度刷到OLED或串口 |
| 按键扫描 | 20ms | 5ms | 消抖、边缘检测,响应要快 |
| 心跳/统计 | 1000ms | 100ms | 记录运行时间、异常计数 |
这里有个关键认知:多任务调度并不是让多个任务“同时”执行,而是让它们在宏观上看起来像同时执行——每个任务都短小精悍,执行完就立刻让出CPU,调度器定期轮询一圈,让大家都有机会跑。在F030这种没有MMU、没有硬件特权级的MCU上,我们不跑完整RTOS,用时间片轮询加一张任务表就够了。
2. AHT20传感器的通信与数据解析
2.1 AHT20的使用要点与I2C通信裁剪
AHT20是国产传感器厂商推出的一款I2C接口温湿度传感器,7位器件地址是0x38。这里第一个坑就来了:7位地址0x38在I2C总线上实际发送时,要左移一位变成8位地址。写操作地址是0x70,读操作地址是0x71。很多初学者直接拿0x38去发,结果I2C总线上一帧完整的地址都凑不出来,传感器根本不应答。
AHT20的上电流程不是“上电就能读”的。芯片上电后必须先发送一条初始化命令,格式是三个字节:0xBE, 0x08, 0x00。发送完这条命令之后,至少要等待10ms——手册上写的是“少于10ms可能初始化失败”,实测下来,我通常等20ms以上,保证芯片内部校准逻辑稳定。初始化不成功的最典型表现是:后面发测量命令,读出来的数据永远是一串0,或者状态位始终是busy。
测量命令也不复杂,发三个字节:0xAC, 0x33, 0x00。发送完成之后,传感器开始内部测量,大约需要80ms。这80ms就是前面说的大循环死等的元凶。在非阻塞方案里,我们要把这80ms变成“发起测量后先干别的,等时间到了再回来读数据”。注意,测量期间不能去读数据,读了也白读,状态位bit7会告诉你芯片还在忙。正确做法是先轮询状态寄存器的busy位,busy为0再读6个字节数据。
读数据这一步也有讲究。AHT20一次要连续读6个字节,这才是完整的测量结果。所有温湿度数据都在这一包6字节里,不需要分开读。数据布局如下:
| 字节 | bit7 | bit6 | bit5 | bit4 | bit3 | bit2 | bit1 | bit0 |
|---|---|---|---|---|---|---|---|---|
| data[0] | busy | - | - | - | 校准状态 | 保留 | 保留 | 保留 |
| data[1] | 湿度原始值[19:12] | |||||||
| data[2] | 湿度原始值[11:4] | |||||||
| data[3] | 湿度原始值[3:0] | 温度原始值[19:16] | ||||||
| data[4] | 温度原始值[15:8] | |||||||
| data[5] | 温度原始值[7:0] |
简单说,data[0]高4位是状态信息,湿度原始值是20位,分布在data[1][2]以及data[3]的高4位;温度原始值也是20位,分布在data[3]低4位加上data[4][5]。我在F030上实现的时候,写成这样的移位拼接:
uint32_t raw_hum = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | ((uint32_t)data[3] >> 4); uint32_t raw_temp = ((uint32_t)(data[3] & 0x0F) << 16) | ((uint32_t)data[4] << 8) | ((uint32_t)data[5]);注意,raw_temp这里必须把21位以上的无效位屏蔽掉,raw_temp &= 0xFFFFF,否则后续计算会带着脏数据。另外,data[0]的第3位是校准使能位,正常初始化后该位应为1,如果读出来是0,说明初始化数据没有正确写入,需要重新走初始化流程。
2.2 从原始数据到温湿度:计算与校验
AHT20手册给出的换算公式是这样的:
- 湿度百分比 = raw_hum / 2^20 * 100%
- 温度摄氏度 = raw_temp * 200 / 2^20 - 50
注意温度公式里的“200”和“-50”不是随便写的——AHT20的温度有效范围是-50℃到+150℃,正好是200℃的跨度,映射到20位ADC的0到2^20-1。所以温度原始值0对应-50℃,原始值2^20-1对应+150℃。
直接按手册公式用浮点算,在F030上没有任何问题:
float hum = (float)raw_hum * 100.0f / 1048576.0f; float temp = (float)raw_temp * 200.0f / 1048576.0f - 50.0f;但F030没有硬件FPU,浮点运算全靠编译器软浮点模拟,一次乘除要几十个指令周期。如果采样频率不高(比如每秒一次)也无所谓,但如果你打算做滤波算法、把数据存储成历史曲线,浮点开销会逐渐累积。所以我实际项目里一般不用浮点,改成定点运算。最常用的做法是放大100倍存储:湿度1234代表12.34%,温度2345代表23.45℃。这样显示和串口输出都变成整数运算,printf也不会因为%f格式串的重定向而拖慢速度。
// 湿度放大100倍,回传uint16_t uint16_t hum_x100 = (uint16_t)(((uint64_t)raw_hum * 10000) >> 20); // 温度放大100倍,回传int16_t int16_t temp_x100 = (int16_t)(((uint64_t)raw_temp * 20000) >> 20) - 5000;这里有个细节:乘法之前要先转成uint64_t,否则raw_hum * 10000在uint32_t范围内会溢出。20位原始值最大约104万,乘以10000就是104亿,已经超过uint32_t的42.9亿了。我在第一次写这个代码的时候没注意,结果数据在98%相对湿度附近突然跳变成负数,排查半天才发现是整数溢出。
需要做的校验有两处。第一,busy位确认:读数据之前要确保bit7为0,否则读到的可能是上一次的旧数据。第二,传感器数据合理性检查:读到的湿度如果小于0%或大于100%,温度如果超出-50℃到+150℃的物理范围,直接丢弃本次采样,不更新全局变量。这个合理性检查看着简单,实战中能帮你避免一大堆“数据飘了”的排查时间。
3. 轻量级多任务调度器的设计与实现
3.1 一个最小可用的任务表调度器
为什么不用FreeRTOS?当然能用,FreeRTOS在F030上也完全跑得起来,8KB RAM分配给一个带3~4个任务的RTOS也够用。但我个人偏好在这种极简场景里先试用自定义的协作式调度器,原因有三:第一,代码量极小,整个调度器核心不到100行,Flash占用几乎可以忽略;第二,行为完全可预期,不涉及优先级抢占、信号量、互斥锁这些容易出玄学问题的机制;第三,对初学者来说,自己写过一遍任务调度,才能真正理解RTOS内部的“就绪表”“时间片”是怎么一回事,后续上FreeRTOS会更快。
我的调度器核心数据结构和逻辑是这样的:
typedef struct { uint32_t period_tick; // 任务周期,单位:系统tick uint32_t next_tick; // 下一次到期时间点 uint32_t last_tick; // 上一次运行时间点(用于统计) void (*task_func)(void); // 任务函数指针 uint8_t enabled; // 任务使能标志 } TaskControlBlock;SysTick中断里只做一件事:递增一个全局tick计数器。注意,我特意不在中断里调用任何任务函数。借花献佛说一句老话:中断服务程序越短越好,最好短到只有一条赋值语句。
volatile uint32_t g_tick = 0; void SysTick_Handler(void) { g_tick++; }主循环里检查每个任务是否到期:
void Scheduler_Run(void) { uint32_t now = g_tick; for (uint8_t i = 0; i < TASK_MAX; i++) { if (!tasks[i].enabled) continue; if ((int32_t)(now - tasks[i].next_tick) >= 0) { tasks[i].next_tick += tasks[i].period_tick; tasks[i].last_tick = now; tasks[i].task_func(); } } }这行(int32_t)(now - tasks[i].next_tick)可能有人看不懂——这是嵌入式里经典的“无符号时间差比较法”。防止tick计数溢出回绕导致判断失败。比如now是5,next_tick是65535(假设16位计数器回绕),直接比较now >= next_tick会得到false,但用有符号差值判断就正确了。实际我们用的是32位计数器,回绕周期极长,但养成这个写法对你以后写其他MCU代码很有帮助。
还有一个细节:任务注册的时候,next_tick不要都设成0,否则启动瞬间所有任务一窝蜂执行一遍。我一般把周期短的任务next_tick设为0,让它先跑;周期长的任务next_tick设为它的周期,让它晚一点再跑,这样任务在时间上自然交错开。
3.2 任务间共享数据的保护策略
调度器跑起来之后,最典型的场景是:传感器采集任务每500ms更新一次g_temp_x100和g_hum_x100,显示任务每200ms读这两个全局变量并刷新屏幕。这里就有数据竞争问题了。
假设采集任务正在更新温度变量:g_temp_x100 = (int16_t)(...);这个赋值在Cortex-M0上是单条16位写指令,其实不会被打断。但假如我把采集任务里的计算写得复杂一点,分成了好几条指令,显示任务在执行过程中切进来读到一半更新的数据,显示出来的温度就会是“新旧混搭”的怪值。更危险的情况是64位变量(比如uint64_t的累计值),在M0上一条指令根本写不完,必定跨多条指令,读的一方大概率读到撕裂数据。
解决办法很简单:在访问共享变量的临界区短暂关中断。
__disable_irq(); uint16_t local_hum = g_hum_x100; int16_t local_temp = g_temp_x100; __enable_irq();F030是Cortex-M0内核,支持__disable_irq()和__enable_irq()这两个CMSIS内置函数。关中断的时间只有几条指令,微秒级,不足以影响任何任务的实时性。我这里多说一句:不要在关中断期间调用任何耗时的函数,尤其是I2C读写!I2C一次读6个字节加上启动、停止和地址帧,可能耗时几百微秒到几毫秒,期间中断全关,显示刷新、按键延时全都会乱掉。临界区里只做“复制变量”这种轻量操作。
3.3 为什么不让任务互相等待
协作式调度器最大的禁忌是:任务函数内部出现了“阻塞等待”。一旦某个任务在函数内部死等设备、死等时间,整个调度器就停了,所有任务跟着遭殃。
以AHT20为例,如果我在采集任务里写:
void Task_Humidity(void) { AHT20_StartMeasure(); Delay_ms(80); // 死等,调度器停摆! AHT20_ReadData(); UpdateGlobalValue(); }这个80ms的Delay,会让显示任务、按键任务全部被冻结。虽然单看采集任务好像“没问题”,但整个系统的实时性已经崩了。正确做法是把阻塞改成“状态机推进 + 调度器轮询”。驱动内部用一个状态变量记录当前处于哪个阶段:
typedef enum { AHT20_IDLE = 0, AHT20_WAIT_INIT, // 等待初始化完成 AHT20_MEASURE_START, // 刚发起测量 AHT20_WAIT_MEASURE, // 等待80ms测量完成 AHT20_READ_DONE // 读取完成 } AHT20_State;每次任务被调度器调用时,驱动只做“当前状态该做的事”,然后立刻返回。比如处于AHT20_WAIT_MEASURE状态时,就检查一下g_tick - last_start_tick >= 80是否成立,成立则去读数据,不成立就直接return,把CPU让给下一个任务。这就是“非阻塞驱动”的核心思想,也是让多任务调度器真正跑起来的必要前提。
4. 实操过程:从原理图到跑起来的完整记录
4.1 硬件连接与初始化顺序
我用的开发板是自己画的,F030C8T6,AHT20贴在一小块转接板上,通过四根线接到主板:VDD、GND、SDA、SCL。I2C总线上拉电阻用的4.7kΩ,上拉到3.3V。这里有个细节要说一下:AHT20的SDA和SCL引脚是开漏输出,所以必须要有外部上拉电阻。如果你的模块上没有集成上拉,你主板上必须焊,否则I2C通信会时好时坏——表现为第一次读正常、第二次全部是0xFF。
F030的I2C1引脚可以映射到好几组:PB6/PB7、PB8/PB9、PA9/PA10(部分封装)。我用的是PB6/PB7,CubeMX里把这两个引脚设为I2C1_SCL和I2C1_SDA的复用开漏模式。初始化顺序是这样的:
- 开启GPIOB和I2C1的时钟。
- 配置PB6/PB7为复用开漏输出,速度设为2MHz即可——I2C是开漏加上拉驱动,GPIO速度等级不需要拉满。
- 配置I2C1外设:标准模式(Standard Mode),时钟频率100kHz,7位地址模式。F030的I2C时钟源来自PCLK1,所以要注意PCLK1分频是否合理。48MHz主频下,把PCLK1设为48MHz,I2C1时序寄存器里需要配置合适的上升/下降时间和分频系数。
- 调用AHT20初始化命令,然后延时至少20ms。
- 再延时50ms以上,让传感器完成上电稳定,才开始发测量命令。
我把一个容易踩的坑提前在这里说了:F030的I2C外设偶发“总线忙”(Busy)状态。这通常是因为上电时序里SCL/SDA电平不稳定,导致I2C控制器误以为总线被占用。解决办法是在I2C初始化之后、第一次通信之前,主动做一次“复位总线”操作:把SCL上拉一个完整时钟周期(9个脉冲),再把SDA和SCL都释放为高电平,让总线回到空闲状态。这个操作在裸机上实现起来麻烦,但在调度器里可以写成一个单独函数,启动时调用一次,实测能解决大部分“莫名其妙读不到数据”的问题。
4.2 用状态机改造AHT20驱动并挂进调度器
完整的非阻塞AHT20驱动核心代码大概长这个样子:
volatile uint32_t g_tick; static AHT20_State aht20_state = AHT20_IDLE; static uint32_t aht20_last_cmd_tick; static uint8_t aht20_data[6]; void AHT20_Task(void) { switch (aht20_state) { case AHT20_IDLE: // 空闲时发起一次测量 I2C_WriteBytes(AHT20_ADDR_WRITE, (uint8_t[]){0xAC, 0x33, 0x00}, 3); aht20_last_cmd_tick = g_tick; aht20_state = AHT20_WAIT_MEASURE; break; case AHT20_WAIT_MEASURE: if ((uint32_t)(g_tick - aht20_last_cmd_tick) >= 80) { // 80ms到了,读取6字节数据 I2C_ReadBytes(AHT20_ADDR_READ, aht20_data, 6); if ((aht20_data[0] & 0x80) == 0) { // 忙位为0,数据有效,开始换算 uint32_t raw_hum = ((uint32_t)aht20_data[1] << 12) | ((uint32_t)aht20_data[2] << 4) | ((uint32_t)aht20_data[3] >> 4); uint32_t raw_temp = ((uint32_t)(aht20_data[3] & 0x0F) << 16) | ((uint32_t)aht20_data[4] << 8) | ((uint32_t)aht20_data[5]); raw_temp &= 0xFFFFF; // 合理性检查 if (raw_hum <= 1048576 && raw_temp <= 1048576) { __disable_irq(); g_hum_x100 = (uint16_t)(((uint64_t)raw_hum * 10000) >> 20); g_temp_x100 = (int16_t)(((uint64_t)raw_temp * 20000) >> 20) - 5000; __enable_irq(); } } aht20_state = AHT20_IDLE; } break; default: aht20_state = AHT20_IDLE; break; } }注意这里我把“读数据”和“换算数据”放在同一个调度片里完成,但只做简单的移位和乘除运算,总耗时大概几十微秒,不会阻塞其他任务。而那个80ms等待时间,调度器会继续跑显示、按键任务,真正实现了“用时间换并行”。
4.3 显示与串口输出策略
串口输出是我调试阶段的主力。F030的USART1直接重定向printf到串口,波特率115200。不过printf内部会调用fputc,一次输出一个字符,整个字符串输出完需要不少时间。在调度器任务里,我一般不直接printf大段字符串,而是拼一个缓冲区,一次性用HAL_UART_Transmit发出去。
显示任务里最实用的技巧是:只输出变化后的数值,不要每次都整屏刷新。我第一次写OLED显示任务时,每200ms就调一次OLED的整屏刷新函数,结果屏幕偶尔闪烁,后来改成“变更检测”——只有温度或湿度变化超过0.1才重刷当前区域。这个策略对任何微控制器都适用,具体到AHT20温湿度数据显示,我的做法是:
void Task_Display(void) { // 读取全局值(临界区保护) __disable_irq(); uint16_t hum = g_hum_x100; int16_t temp = g_temp_x100; __enable_irq(); // 只在变化超过阈值时更新 if ((hum >= g_last_hum + 10) || (hum + 10 <= g_last_hum) || (temp >= g_last_temp + 10) || (temp + 10 <= g_last_temp)) { OLED_ShowTemp(temp); OLED_ShowHum(hum); g_last_hum = hum; g_last_temp = temp; } }这里的阈值10代表0.1%,因为全局变量是放大100倍存的,判断变化超过10就等于真实变化超过0.1%。这个阈值既避免了传感器微小的固有噪声引起的屏幕颤动,又不会让人感觉到显示“卡顿”。
4.4 一阶滤波让数据更稳定
AHT20本身精度不错,但数据在相邻两次采样之间依然会有小幅度跳变——尤其是湿度,受空气流动、板温影响特别明显。如果直接把原始值丢到显示上,你会看到湿度末位数字在±0.5%范围内乱跳,观感很差。我一般会加一个简单的一阶低通滤波器,数学形式是:
当前滤波值 = 上次滤波值 + (当前采样值 - 上次滤波值) / N
N取4~16之间,N越大越平滑,但响应越慢。温湿度这类慢变量用N=8很合适。代码实现很简单:
int32_t filtered = g_filtered_hum; filtered += ((int32_t)hum_raw_x100 - filtered) / 8; g_filtered_hum = (uint16_t)filtered;关键点:滤波变量要用int32_t,避免除法过程中的负数符号问题。而且要在临界区里更新滤波值,防止读写到一半被打断。
5. 踩坑记录与排查经验实录
5.1 AHT20读不到数据的各类原因
这大概是最常见的问题,网上问的人最多的就是“为什么我的AHT20读出来全是0”。我在调试过程中遇过的情况可以总结成一张排查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| SCL波形正常,SDA只有高电平没有低电平 | SDA线没开漏、上拉电阻缺失或太大 | 检查GPIO复用配置和上拉电阻,4.7k~10k为宜 |
| 发送地址后ACK位一直为高 | 地址没左移、写地址用错 | 确认7位地址0x38要左移,写地址0x70、读地址0x71 |
| 读取数据全是0xFF | 传感器未初始化或I2C时序被中断打断 | 确认发过0xBE初始化命令,检查I2C中断优先级是否被频繁抢占 |
| 第一帧数据正常,后续全部超时 | 上电初期信号不稳 | 初始化前额外延时50ms,并做一次总线复位 |
| 数据能读但数值恒定不变 | 传感器自身处于异常状态 | 断电重新上电,必要时执行软复位(0xBA命令) |
我最痛苦的一次排查经历是:某一次板子重新打样后,I2C通信时好时坏,排查了整整一下午,最后发现是AHT20传感器封装底下有一小块助焊剂残留,导致VDD引脚接触不良。所以在这里提醒一句:AHT20是表面贴装元件,引脚间距很小,焊接后一定要用放大镜检查引脚间是否有锡珠短路,或者用万用电表通断档确认VDD和GND没有对地短路。
5.2 温湿度数据异常跳变
读到了数据,但数据“离谱”——比如湿度突然跳到200%,或者温度一秒内从23℃跳到40℃。这种情况通常有三个来源。
第一类是硬件层面的串扰。传感器如果紧挨着MCU或者电源稳压芯片,板子发热会导致温度读数偏高。我实测过:AHT20离F030芯片不到1cm时,温度读数比环境温度高出3~4℃;把传感器挪到板子边缘、离MCU超过2cm,读数恢复正常。湿度数据受影响则是因为PCB材料吸潮,尤其是廉价的FR4板材在湿度大的环境里会缓慢吸水,传感器周围的水汽分布被打乱。解决办法就是:传感器尽量靠近板边,远离发热源,底部可以开一个小的通风孔。
第二类是软件层面的数据污染。我在调度器里同时跑采集任务和显示任务,如果采集任务更新全局变量时显示任务正好读取,就可能拿到混合数据。加临界区保护能解决,但要注意临界区的变量读取也要保持一致——也就是读两个变量时不能被打断。
第三类是滤波不当导致的“看起来跳变”。如果你没加滤波,AHT20的湿度原始值在空气相对稳定时也会有±0.3%左右的噪声。这种噪声不是故障,是物理特性。加一阶滤波或者简单滑动平均就能压下来。但要注意滤波系数别太小,N=4以下几乎没有平滑效果,N=32以上会让响应慢得不像话——真实温度变化1℃要好几秒才在屏幕上有反应。
5.3 调度器长跑后卡死或时序漂移
调度器本身很简单,但跑时间长了会出现一些刁钻问题。我遇到过的典型情况有三种。
第一种,某任务内部出现了阻塞,导致后续任务全部停摆。特征是整个系统“半死不活”——显示不刷新了,但中断还在跑,按键灯也不亮了。排查思路是:在任务函数入口加一个调试计数器,用串口输出每个任务的执行次数,看哪个任务停更了。定位到任务后,检查函数内部是否有for循环等待设备、或者Delay_ms调用。把这类调用替换成状态机非阻塞模式就好了。
第二种,任务周期的累积漂移。如果我用了last_tick += period_tick这种写法,当任务执行时间本身比周期还长的时候,next_tick会越来越落后,导致任务执行频率比预期高。更准确的做法是我在前面代码里用的:next_tick += period_tick在到期时累加周期,而不是用当前时间加周期。这两种写法在任务偶尔抖动的场景下差别很明显,第一种会让任务追赶进度积累误差,第二种则不会。
第三种,编译优化导致的诡异现象。某次我把调试等级从-O0改成-O2,温湿度读数偶尔出现“上一次的旧值”,排查了半天,发现问题出在一个全局变量没有加volatile修饰。调度器主循环里读取的g_tick是全局变量,它在SysTick中断里被修改,编译器可能把它优化成寄存器缓存,导致主循环永远读到旧值。解决方式很简单:中断和主循环之间共享的所有变量都必须加volatile。这一点在F030这种Cortex-M0上尤其重要,因为M0没有硬件内存屏障指令,编译器优化更容易暴露这类问题。
5.4 功耗与性能优化空间
如果你的温湿度终端要拿电池供电,功耗是需要重点考虑的事情。AHT20自身功耗极低,F030在48MHz下全速运行电流大约十几毫安,但传感器加MCU整体跑下来,用纽扣电池供电撑不了几天。常见的优化思路有两个方向。
第一,降低主频。F030外接8MHz晶振时可以PLL倍频到48MHz,但实际做采集显示这种任务,16MHz甚至8MHz主频完全够用。我测过:48MHz跑一个“显示+采集+串口”任务,CPU占用率大约20%,降到16MHz后,任务周期拉长一些但都还在容忍范围内,功耗能降低一半以上。这里要注意:I2C外设的时钟源来自PCLK1,主频降得太低可能导致I2C的100kHz分频系数算不精确,需要实测波形。我的经验是主频降到16MHz时,I2C标准模式100kHz依然正常。
第二,空闲时进入睡眠模式。调度器在不执行任务时,可以让MCU进入WFI(等待中断)指令进入睡眠,直到SysTick中断唤醒,执行完该运行的任务后再次入睡。代码很简单:
while (1) { Scheduler_Run(); __WFI(); // 进入睡眠,等SysTick唤醒 }注意__WFI会让MCU在低功耗模式下等待任意中断——不只是SysTick,I2C中断、串口中断、外部按键中断都能唤醒。所以要在任务里处理好中断标志位,避免醒来后没活干又睡过去的死循环。这个方案配合F030的多种低功耗模式(Sleep、Stop、Standby之一),可以做到电池供电数月级别的续航,但这个方向适合作为后续扩展,这里先不展开。
6. 从这套方案能延伸出去的几个方向
这套“F030 + AHT20 + 任务调度器”的组合,本质上是一个通用性极强的嵌入式原型框架。换掉传感器,它能驱动其他I2C设备——比如气压传感器、光照传感器、OLED屏幕。把调度器的任务表从4个扩展到8个,配合一个简单的串口命令解析器,就能变成一个带远程配置的小型数据采集终端。我在实际项目里,就是在这个骨架上加了一个无线模块,把温湿度数据打包成定长帧,定时通过串口发给网关,再从网关汇聚到服务器。整个过程不需要引入任何重量级组件,所有代码还是那套F030的裸机编程。
如果后续要在更复杂的场景下使用这套思路,有两个技术升级方向是很自然的。一是给调度器引入优先级:把任务表支持的任务函数区分为高优先级和低优先级,高优先级任务可以抢占低优先级任务,这其实就是在往抢占式RTOS的方向演进。二是增加任务间的同步机制:比如用信号量通知“数据已到达”,替代现在这种纯周期轮询的方式。这两步走完,你对RTOS的理解已经和实际使用FreeRTOS没有本质区别了。
我个人在实际操作中的体会是:在F030这种小芯片上,把“非阻塞驱动”和“任务调度”这两件事做扎实了,比追求外设数量和代码规模有意义得多。很多人在F407上跑了大半年RTOS,回到F030裸机上还是写不好一个不阻塞的传感器驱动。而恰恰是这种简单的场景,最能打磨一个嵌入式工程师的基本功。这套代码原型我放在手边反复用,每次新项目需要接一颗新的I2C传感器,我都会先往这个调度器里加一个非阻塞状态机驱动,然后花半小时调通数据链路。这种“基座式”的开发节奏,长期下来效率要比每次从零写一个大循环高太多。
最后分享一个小技巧:当你的任务数量超过5个,或者你发现某个任务开始依赖另一个任务的数据时,就要考虑把任务代码拆得更细了。我见过很多人把“读传感器”和“显示数据”写进同一个任务,认为这样能减少全局变量访问的临界区问题。这个思路在任务少时可行,但一旦以后要加数据存储、加网络上报,你就会发现拆得越细越好扩展。调度器的价值在于它给了你一个低成本的任务拆分框架,别把这份自由浪费在把一切塞进同一个任务的懒惰里。