ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于STM32F103+FreeRTOS的多功能智能手表:从裸机卡顿到任务化重构的完整记录

基于STM32F103+FreeRTOS的多功能智能手表:从裸机卡顿到任务化重构的完整记录

一、系统整体结构

整个系统自底向上分四层,每层只向上层提供能力,互不越界:

┌─────────────────────────────────────────────────┐ │ 应用层 界面状态机(4 页)/ 校时逻辑 / 闹钟策略 │ ├─────────────────────────────────────────────────┤ │ 任务层 ClockTask │ SensorTask │ DisplayTask │ │ │ AlarmTask │ 软件定时器(闹钟比对) │ ├─────────────────────────────────────────────────┤ │ RTOS 层 FreeRTOS 内核:调度器 / 信号量 / 队列 / │ │ 互斥锁 / 软件定时器 / 堆(heap_4) │ ├─────────────────────────────────────────────────┤ │ 驱动层 I2C 读写(DS3231/OLED) │ DHT11 单总线 │ │ │ OLED 帧缓冲 │ 按键 EXTI │ 马达 GPIO │ ├─────────────────────────────────────────────────┤ │ 硬件层 STM32F103C8T6 + DS3231 + DHT11 │ │ + SSD1306 OLED + 3 按键 + 振动马达 │ └─────────────────────────────────────────────────┘

1.1 硬件方案

模块器件接口
主控STM32F103C8T6(72MHz,64KB Flash,20KB SRAM)—
时钟DS3231I2C(地址 0x68),VBAT 接纽扣电池,掉电时钟照走
温湿度DHT11单总线(一路 GPIO + 4.7K 上拉)
显示0.96" OLED(SSD1306)I2C(地址 0x3C,与 DS3231 共线)
按键3 枚轻触按键GPIO 外部中断(模式切换/加/减)
闹钟振动马达GPIO + 三极管扩流

1.2 任务划分

整个应用拆成4 个任务 + 1 个软件定时器:

任务优先级周期栈深度职责
ClockTask3500ms128经 I2C 读 DS3231,更新时间结构体
SensorTask22s128DHT11 单总线时序采集温湿度
DisplayTask4200ms256组帧刷 OLED(最高优先级,保证画面流畅)
AlarmTask1阻塞等待128等闹钟信号量,驱动马达振动
软件定时器—1s—比对当前时间/闹钟时间,到点 Give 信号量

优先级排布的思路:显示直接决定"跟手"的观感,给最高;时钟数据要在校时、闹钟比对之前保持新鲜,排第二档;传感器单次时序虽长,但读数晚几百毫秒人眼根本无感,放低优先级;闹钟任务平时根本不运行,只是"被信号量叫醒干活",优先级最低也无妨。这套排布背后有一条比"哪个功能重要给谁高优先级"更本质的逻辑:谁对响应延迟敏感谁高,谁平时不跑谁低。

1.3 数据流全景

任务之间不共享"正在写"的数据,只共享"写完即发布"的数据。一条数据的完整旅程:

按键按下 ──EXTI中断──► 键值队列 ──► DisplayTask 消费(切页/改字段) DS3231 ──I2C──► ClockTask ──► 时间结构体(互斥锁保护)──┐ DHT11 ──单总线──► SensorTask ──► 温湿度结构体 ────────────► DisplayTask │ 渲染帧缓冲 │ ──I2C──► OLED 软件定时器(1s)──比对闹钟──► 闹钟信号量 ──► AlarmTask ──► 马达振动

写入方(采集任务)拿锁写、放锁走,读取方(显示任务)同样短暂持锁拷贝一份快照,锁的持有时间以微秒计,不存在"一个任务抱着锁睡觉"的情况。

二、从上电到运行:启动流程

main()只做三件事:初始化硬件 → 创建 RTOS 对象 → 交出 CPU 给调度器。之后main再也不会返回,整个"程序"就变成了"系统"。

intmain(void){HAL_Init();/* 1. HAL 底层:NVIC 分组、SysTick 默认源 */SystemClock_Config();/* 2. HSE 8MHz ×9 锁相环 → 72MHz 系统时钟 */MX_GPIO_Init();/* 3. 按键(含 EXTI 配置)/马达/DHT11 总线脚 */MX_I2C1_Init();/* 4. I2C1 外设,400kHz 快速模式 */OLED_Init();/* 5. 推屏初始化序列(此时还是裸机时序) */vStartTasks();/* 6. 创建信号量/队列/定时器/四个任务 */vTaskStartScheduler();/* 7. 启动调度器,永不返回 */}

第 6 步创建所有内核对象并挂起四个任务;第 7 步调度器选出当前优先级最高的就绪任务(DisplayTask)作为第一个运行者,给它建一个假的初始栈帧(“看起来像是被中断过”),直接弹出寄存器开始执行。从这一刻起:

  • SysTick被 FreeRTOS 接管,每 1ms 产生一次节拍中断,整个系统的时间基准就是它;
  • IdleTask(优先级 0)自动创建,所有任务都阻塞时它才跑,顺便回收被删除任务的栈;
  • 之后每个任务的"生死调度",全部发生在中断里(见第四章)。

三、FreeRTOS 是怎么"跑起来"的:调度原理

3.1 一次任务切换的完整过程

FreeRTOS 在 Cortex-M 上的切换靠两个中断配合:

  1. SysTick(1ms 节拍):中断服务里调xTaskIncrementTick(),把所有处于"延时中"任务的剩余 tick 减一;有任务延时到期被置为就绪、且优先级高于当前任务时,只是挂起一个 PendSV 请求,并不当场切换;
  2. PendSV(优先级最低):等所有别的中断都处理完,CPU 空闲下来才真正执行切换——保存当前任务寄存器到它的私有栈、把栈指针切到目标任务、恢复目标任务的寄存器。

对每个任务来说,它以为自己在"连续运行",实际上每毫秒都可能被换下又换回,切换点永远在指令边界之外,任务代码零感知。

3.2 任务的三种状态

vTaskDelay() / Take 空信号量 / Receive 空队列 就绪 Ready ──────────────────────────────────────────────► 阻塞 Blocked ▲ │ │ 延时到期 / 信号量被 Give / 队列有数据 │ └──────────────────────────────────────────────────────┘ ▲ 当前最高优先级 → 占用 CPU = 运行 Running

关键认知:阻塞态的任务不占 CPU,连一个时钟周期都不占。AlarmTask 等信号量时可以永远睡,系统总 CPU 占用率里它贡献为 0——这就是"实时性强了反而不觉得卡"的根本原因:CPU 只在真正有活干的任务之间流转。

3.3 抢占:为什么说"并行"是假的但效果是真的

configUSE_PREEMPTION=1意味着:任何时刻,只要就绪表里有比当前运行任务优先级更高的任务,调度器就会(最迟在下一次节拍)把 CPU 抢过来给它。单核跑不出真并行,但四个任务的周期(200ms/500ms/2s/事件)互不阻塞、互不让路,宏观上就是"同时"在跑。裸机大循环做不到这一点:任何一个环节的delay()都会把其他所有环节卡住。

内核配置:configTICK_RATE_HZ = 1000(1ms 节拍),heap_4.c管堆(可合并内存块,适合反复创建/删除的软件定时器)。

四、通信协议详解

本项目用到两种物理协议:I2C(DS3231、OLED 共用)和单总线时序(DHT11)

4.1 I2C 总线协议:两根线怎么带两个设备

I2C 只用 SCL(时钟)、SDA(数据)两根线,都是开漏输出 + 上拉电阻,任何设备只能把线拉低、不能驱动高——线与特性天然支持多设备共线。一次完整传输的帧格式:

S(起始) → 从机地址7bit + R/W位 → ACK → 寄存器地址 → ACK → [重复起始 Sr] → 从机地址 + R/W=1 → ACK → 数据byte0 → ACK → … → 数据byteN → NACK → P(停止)
  • 起始 S:SCL 高电平期间 SDA 由高变低;停止 P:SCL 高期间 SDA 由低变高;
  • 第一个字节是"从机地址 + 读写位"(0 写 1 读),总线上每个设备比对地址,匹配者应答 ACK;
  • 读寄存器实际是"先写指针、再读数据"两段事务,中间用重复起始 Sr衔接(不释放总线所有权);
  • 主机每收到一个数据字节回 ACK,最后一个字节回NACK告知从机"读完了"。

DS3231 的 8 位写地址是 0xD0(7bit 0x68<<1),OLED SSD1306 是 0x78(7bit 0x3C)——地址不冲突,所以可以挂同一条总线。但协议允许共线,不允许多主机并发:两个任务同时发起传输,帧会互相踩。所以驱动层之上必须加一把互斥锁,把"一次完整传输"变成临界区(这就是踩坑 2 的由来)。

HAL 库把上述时序全部封进一个调用:

/* HAL_I2C_Mem_Read 内部 = 写寄存器指针 + 重复起始 + 连续读 N 字节 */HAL_I2C_Mem_Read(&hi2c1,DS3231_ADDR<<1,0x00,/* 从 0x00 秒寄存器起 */I2C_MEMADD_SIZE_8BIT,t,7,100);/* 连读 7 字节,超时 100ms */

DS3231 的地址指针每读一字节自动 +1,所以秒/分/时/星期/日/月/年(寄存器 0x00~0x06)一次突发读回,比逐寄存器读省 6 次总线事务。

4.2 DS3231:BCD 编码与寄存器细节

DS3231 的时间寄存器全是BCD 码——一个字节的高 4 位、低 4 位各表示一个十进制位,读回来"0x23"表示的是十进制 23,不是 35。所以驱动层第一件事就是转换:

#defineBCD2DEC(x)((x)/16*10+(x)%16)#defineDEC2BCD(x)(((x)/10<<4)+(x)%10)

几个必须掩码的位:秒寄存器 bit7 是振荡器停止标志 EOSF(纽扣电池耗尽会被置位,开机自检就看它);小时寄存器在 12 小时模式下高位有 AM/PM 和 12/24 标志,取数值要& 0x3F;月寄存器 bit7 是世纪位。校时则反过来DEC2BCD后HAL_I2C_Mem_Write回写,并确认控制寄存器 0x0E 的 EOSC 位为 0(振荡器使能)。

4.3 DHT11:单总线时序协议

DHT11 没有地址、没有 ACK、没有时钟线——它只有一根 DATA 线,用高电平的持续时间来编码 0 和 1,这就是"单总线时序协议":

主机起始: 拉低总线 ≥18ms → 释放拉高 20~40μs 从机应答: 拉低 80μs → 拉高 80μs(证明"我在线") 数据 40bit: 每 bit 以 50μs 低电平开始,后接高电平: 高 26~28μs = 0 高 70μs = 1 数据格式: 湿度整数8bit + 湿度小数8bit + 温度整数8bit + 温度小数8bit + 校验和8bit 校验和 = 前四字节之和的低 8 位

采样一个 bit 的技巧:等上升沿后固定延时 40μs 再读电平——0 的高电平此时已结束(读到 0),1 的高电平还没结束(读到 1),一次读取就完成判决,不用测脉宽。

这套协议的软肋是没有任何容错:一个中断插进采样窗口,bit 就错位,整帧报废。所以采样段必须关中断(临界区),而主机起始的 18ms 拉低是纯输出、不怕打断,可以放在临界区外用vTaskDelay让出 CPU——这个"哪段能睡、哪段必须霸占 CPU"的划分,是本项目对 RTOS 理解最深的一处(代码见 6.2)。另外协议规定两次采样间隔 ≥1s,SensorTask 的 2s 周期天然满足。

4.4 SSD1306:显存与页写入

OLED 驱动芯片 SSD1306 内部有一块 128×64bit 的显存(GDDRAM),MCU 不需要"画一个点推一个点",而是把整屏数据写进显存,面板自己扫描发光。写显存走 I2C,但每个 I2C 事务里还要带一个控制字节告诉 SSD1306 后续字节的性质:0x00 = 命令,0x40 = 显示数据。

显存按"页"组织:128 列 × 8 行 = 1 页,共 8 页。刷新第 n 页的命令序列:

0xB0|n(设页地址) → 0x00|列低4位 → 0x10|列高4位 → 0x40 + 连续128字节数据

DisplayTask 每 200ms 把 RAM 里的 1KB 帧缓冲按这个序列推给 OLED(OLED_Flush),这就是"双缓冲":渲染时画面停留在上一帧,推屏是一次性突发,不会出现"画了一半"的撕裂。

五、关键代码

5.1 任务与内核对象创建

/* 任务创建(main.c) */voidvStartTasks(void){xClockMutex=xSemaphoreCreateMutex();/* 保护时间结构体 */xAlarmSem=xSemaphoreCreateBinary();/* 闹钟触发信号 */xKeyQueue=xQueueCreate(5,sizeof(uint8_t));/* 键值通道 */xTimeCheckTmr=xTimerCreate("alarm",pdMS_TO_TICKS(1000),pdTRUE,NULL,vAlarmCheckCb);xTimerStart(xTimeCheckTmr,0);xTaskCreate(vClockTask,"clock",128,NULL,3,NULL);xTaskCreate(vSensorTask,"sensor",128,NULL,2,NULL);xTaskCreate(vDisplayTask,"display",256,NULL,4,NULL);xTaskCreate(vAlarmTask,"alarm",128,NULL,1,NULL);}

按键不单独开任务:EXTI 中断服务程序里消抖判稳后直接xQueueSendFromISR(xKeyQueue, &key, …),微秒级退出中断;DisplayTask 每轮先非阻塞掏空队列再刷帧,中断只当搬运工,决策全在任务里做。

5.2 四个任务本体

ClockTask——周期读时钟、加锁发布:

voidvClockTask(void*pv){uint8_tt[7];for(;;){HAL_I2C_Mem_Read(&hi2c1,DS3231_ADDR<<1,0x00,I2C_MEMADD_SIZE_8BIT,t,7,100);xSemaphoreTake(xClockMutex,portMAX_DELAY);s_time.hour=BCD2DEC(t[2]&0x3F);/* 12h 模式高位有标志位,要掩码 */s_time.minute=BCD2DEC(t[1]);s_time.second=BCD2DEC(t[0]&0x7F);/* bit7 是振荡器停止标志 */s_time.date=BCD2DEC(t[4]);s_time.month=BCD2DEC(t[5]&0x1F);xSemaphoreGive(xClockMutex);vTaskDelay(pdMS_TO_TICKS(500));/* 让出 CPU,不是 delay() 死等 */}}

SensorTask——DHT11 采集,“该睡的地方睡,该霸占的地方霸占”:

/* 1. 主机起始:拉低 20ms。拉低期间用 vTaskDelay 让出 CPU, 其他任务照跑,这段时间白白让给系统,不进临界区 */DHT11_OUT();DHT11_LOW();vTaskDelay(pdMS_TO_TICKS(20));DHT11_HIGH();nDelay_us(30);/* 2. 应答+采样段才进临界区,总时长 <1ms, 只屏蔽会打碎时序的那一小段中断 */UBaseType_t saved=taskENTER_CRITICAL();DHT11_IN();if(WaitPin(0,100)==TIMEOUT){taskEXIT_CRITICAL(saved);gotofail;}WaitPin(1,100);/* 3. 采 40bit:每个 bit 等上升沿后延时 40us 再采样, 采到 1 = 长高电平 = 数据 1 */for(i=0;i<40;i++){while(DHT11_READ()==0);nDelay_us(40);data[i/8]=(data[i/8]<<1)|DHT11_READ();while(DHT11_READ()==1);}taskEXIT_CRITICAL(saved);if(data[4]!=(uint8_t)(data[0]+data[1]+data[2]+data[3]))gotofail;/* 校验和 */

DisplayTask——收键 → 取数据快照 → 渲染 → 推屏:

voidvDisplayTask(void*pv){uint8_tkey;for(;;){while(xQueueReceive(xKeyQueue,&key,0)==pdTRUE)ui_on_key(key);/* 决定界面/调时字段 */xSemaphoreTake(xClockMutex,portMAX_DELAY);snprintf(buf,sizeof(buf),"%02d:%02d:%02d",s_time.hour,s_time.minute,s_time.second);xSemaphoreGive(xClockMutex);/* 只拷快照,持锁时间极短 */render_page(ui_page,buf,s_th);/* 画进帧缓冲,不动屏幕 */OLED_Flush();/* 页寻址批量推屏 */vTaskDelay(pdMS_TO_TICKS(200));}}

界面共 4 页:主时钟页 / 温湿度页 / 闹钟设置页 / 校时页,KEY1 循环切换,调时模式下 KEY2/KEY3 加减当前字段。

AlarmTask + 软件定时器——“平时零开销,到点才醒”:

staticvoidvAlarmCheckCb(TimerHandle_t xTimer)/* 在定时器服务任务里执行 */{if(!alarm.enabled)return;if(s_time.hour==alarm.hour&&s_time.minute==alarm.minute&&!alarm.fired){alarm.fired=true;/* 一次性触发,分钟变化后由 ClockTask 复位 */xSemaphoreGive(xAlarmSem);/* 到点唤醒 AlarmTask */}}voidvAlarmTask(void*pv){for(;;){xSemaphoreTake(xAlarmSem,portMAX_DELAY);/* 阻塞时零 CPU 占用 */for(inti=0;i<10;i++){/* 振动 1s:通断脉冲 */MOTOR_ON();vTaskDelay(pdMS_TO_TICKS(200));MOTOR_OFF();vTaskDelay(pdMS_TO_TICKS(200));}}}

闹钟判断不放在 AlarmTask 里轮询,而是软件定时器每秒比对一次;xSemaphoreGive的瞬间 AlarmTask 进就绪表,虽然它优先级最低,但 DisplayTask 比对完就vTaskDelay阻塞了,马达最迟 200ms 内必响——比裸机"等主循环转回来"稳定得多。

六、注意点

  1. 栈溢出 HardFault:configCHECK_FOR_STACK_OVERFLOW=2打开后才发现,带snprintf格式化输出的任务在 64 字栈上必炸。教训:有格式化输出的任务栈至少 128 字(512B)起,用uxTaskGetStackHighWaterMark实测余量再定。
  2. I2C 总线被两个任务同时用:DS3231 和 OLED 共用一条总线,一开始不加锁偶发读回 0xFF——两个任务的 I2C 事务在总线上互相插帧。FreeRTOS 下要么给 I2C 操作套互斥信号量,要么集中由一个任务代理访问。
  3. DHT11 数据全是 0:排查后发现是杜邦线太长且忘了 4.7K 上拉,总线一直是低,应答判断"假通过"。单总线一定要上拉,必要时缩短走线。
  4. 中断里调 FreeRTOS API 触发 assert:FreeRTOS 规定只有优先级数值低于(弱于)configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY的中断才能在 ISR 里调 API。把按键 EXTI 的优先级调到该阈值之下后,xQueueSendFromISR正常工作。
  5. 闹钟秒对齐漂移:软件定时器 1s 周期和 DS3231 的秒不严格同步,靠second==0判断偶发漏触发或重复触发。改成"触发后置标志位、分钟变化后复位"的一次性触发方案后彻底解决。

七、有关技术

技术层面:

  • FreeRTOS 多任务调度机制:优先级、抢占、阻塞/就绪态切换,以及"任务周期怎么定才不白烧 CPU";
  • I2C 通信协议:从帧格式、地址仲裁、重复起始到多设备共总线的加锁保护;
  • 单总线时序:理解了"时序敏感代码要关中断"以及临界区的代价;
  • 嵌入式工程能力:一个项目走通"外设驱动 → 中间件移植 → 任务划分 → 联调排错"的完整流程,对小型穿戴类设备的开发范式有了体感。

更重要的是方法论:先想清楚数据从哪来、到哪去,再决定任务怎么切——任务边界跟着数据流走,而不是跟着功能菜单走。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进