
一、系统整体结构整个系统自底向上分四层,每层只向上层提供能力,互不越界:┌─────────────────────────────────────────────────┐ │ 应用层 界面状态机(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 上的切换靠两个中断配合:SysTick(1ms 节拍):中断服务里调xTaskIncrementTick(),把所有处于延时中任务的剩余 tick 减一;有任务延时到期被置为就绪、且优先级高于当前任务时,只是挂起一个 PendSV 请求,并不当场切换;PendSV(优先级最低):等所有别的中断都处理完,CPU 空闲下来才真正执行切换——保存当前任务寄存器到它的私有栈、把栈指针切到目标任务、恢复目标任务的寄存器。对每个任务来说,它以为自己在连续运行,实际上每毫秒都可能被换下又换回,切换点永远在指令边界之外,任务代码零感知。3.2 任务的三种状态vTaskDelay() / Take 空信号量 / Receive 空队列 就绪 Ready ──────────────────────────────────────────────► 阻塞 Blocked ▲ │ │ 延时到期 / 信号量被 Give / 队列有数据 │ └──────────────────────────────────────────────────────┘ ▲ 当前最高优先级 → 占用 CPU 运行 Running关键认知:阻塞态的任务不占 CPU,连一个时钟周期都不占。AlarmTask 等信号量时可以永远睡,系统总 CPU 占用率里它贡献为 0——这就是实时性强了反而不觉得卡的根本原因:CPU 只在真正有活干的任务之间流转。3.3 抢占:为什么说并行是假的但效果是真的configUSE_PREEMPTION1意味着:任何时刻,只要就绪表里有比当前运行任务优先级更高的任务,调度器就会(最迟在下一次节拍)把 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/W1 → ACK → 数据byte0 → ACK → … → 数据byteN → NACK → P(停止)起始 S:SCL 高电平期间 SDA 由高变低;停止 P:SCL 高期间 SDA 由低变高;第一个字节是从机地址 读写位(0 写 1 读),总线上每个设备比对地址,匹配者应答 ACK;读寄存器实际是先写指针、再读数据两段事务,中间用重复起始 Sr衔接(不释放总线所有权);主机每收到一个数据字节回 ACK,最后一个字节回NACK告知从机读完了。DS3231 的 8 位写地址是 0xD0(7bit 0x681),OLED SSD1306 是 0x78(7bit 0x3C)——地址不冲突,所以可以挂同一条总线。但协议允许共线,不允许多主机并发:两个任务同时发起传输,帧会互相踩。所以驱动层之上必须加一把互斥锁,把一次完整传输变成临界区(这就是踩坑 2 的由来)。HAL 库把上述时序全部封进一个调用:/* HAL_I2C_Mem_Read 内部 写寄存器指针 重复起始 连续读 N 字节 */HAL_I2C_Mem_Read(hi2c1,DS3231_ADDR1,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)/104)(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){xClockMutexxSemaphoreCreateMutex();/* 保护时间结构体 */xAlarmSemxSemaphoreCreateBinary();/* 闹钟触发信号 */xKeyQueuexQueueCreate(5,sizeof(uint8_t));/* 键值通道 */xTimeCheckTmrxTimerCreate(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_ADDR1,0x00,I2C_MEMADD_SIZE_8BIT,t,7,100);xSemaphoreTake(xClockMutex,portMAX_DELAY);s_time.hourBCD2DEC(t[2]0x3F);/* 12h 模式高位有标志位,要掩码 */s_time.minuteBCD2DEC(t[1]);s_time.secondBCD2DEC(t[0]0x7F);/* bit7 是振荡器停止标志 */s_time.dateBCD2DEC(t[4]);s_time.monthBCD2DEC(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 savedtaskENTER_CRITICAL();DHT11_IN();if(WaitPin(0,100)TIMEOUT){taskEXIT_CRITICAL(saved);gotofail;}WaitPin(1,100);/* 3. 采 40bit:每个 bit 等上升沿后延时 40us 再采样, 采到 1 长高电平 数据 1 */for(i0;i40;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.houralarm.hours_time.minutealarm.minute!alarm.fired){alarm.firedtrue;/* 一次性触发,分钟变化后由 ClockTask 复位 */xSemaphoreGive(xAlarmSem);/* 到点唤醒 AlarmTask */}}voidvAlarmTask(void*pv){for(;;){xSemaphoreTake(xAlarmSem,portMAX_DELAY);/* 阻塞时零 CPU 占用 */for(inti0;i10;i){/* 振动 1s:通断脉冲 */MOTOR_ON();vTaskDelay(pdMS_TO_TICKS(200));MOTOR_OFF();vTaskDelay(pdMS_TO_TICKS(200));}}}闹钟判断不放在 AlarmTask 里轮询,而是软件定时器每秒比对一次;xSemaphoreGive的瞬间 AlarmTask 进就绪表,虽然它优先级最低,但 DisplayTask 比对完就vTaskDelay阻塞了,马达最迟 200ms 内必响——比裸机等主循环转回来稳定得多。六、注意点栈溢出 HardFault:configCHECK_FOR_STACK_OVERFLOW2打开后才发现,带snprintf格式化输出的任务在 64 字栈上必炸。教训:有格式化输出的任务栈至少 128 字(512B)起,用uxTaskGetStackHighWaterMark实测余量再定。I2C 总线被两个任务同时用:DS3231 和 OLED 共用一条总线,一开始不加锁偶发读回 0xFF——两个任务的 I2C 事务在总线上互相插帧。FreeRTOS 下要么给 I2C 操作套互斥信号量,要么集中由一个任务代理访问。DHT11 数据全是 0:排查后发现是杜邦线太长且忘了 4.7K 上拉,总线一直是低,应答判断假通过。单总线一定要上拉,必要时缩短走线。中断里调 FreeRTOS API 触发 assert:FreeRTOS 规定只有优先级数值低于(弱于)configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY的中断才能在 ISR 里调 API。把按键 EXTI 的优先级调到该阈值之下后,xQueueSendFromISR正常工作。闹钟秒对齐漂移:软件定时器 1s 周期和 DS3231 的秒不严格同步,靠second0判断偶发漏触发或重复触发。改成触发后置标志位、分钟变化后复位的一次性触发方案后彻底解决。七、有关技术技术层面:FreeRTOS 多任务调度机制:优先级、抢占、阻塞/就绪态切换,以及任务周期怎么定才不白烧 CPU;I2C 通信协议:从帧格式、地址仲裁、重复起始到多设备共总线的加锁保护;单总线时序:理解了时序敏感代码要关中断以及临界区的代价;嵌入式工程能力:一个项目走通外设驱动 → 中间件移植 → 任务划分 → 联调排错的完整流程,对小型穿戴类设备的开发范式有了体感。更重要的是方法论:先想清楚数据从哪来、到哪去,再决定任务怎么切——任务边界跟着数据流走,而不是跟着功能菜单走。