基于 STM32F103 + FreeRTOS 的智能闹钟系统设计与实现
1. 项目背景
这个项目最初并不是为了简单点亮 LED 或让蜂鸣器响一下,而是希望把 FreeRTOS、多外设驱动、任务拆分、状态机、队列通信和嵌入式 UI 组织到一个完整的小型系统中。
从项目里面学到以下知识:
- 用 FreeRTOS 将输入、显示、传感器、时钟、闹钟和音乐播放拆成独立任务。
- 用消息队列解耦任务之间的直接调用。
- 用状态机管理 UI 模式和闹钟生命周期。
- 用 BSP、Task、App 分层降低代码耦合。
- 在有限硬件资源下实现时间设置、闹钟设置、贪睡、重复响铃、温湿度显示和低功耗策略。
2. 项目功能
- 当前时间显示
- 当前时间手动设置
- 闹钟时间设置
- 闹钟开启和关闭
- 闹钟到点自动响铃
- PB1 停止响铃
- PB11 贪睡 5 分钟
- 重复响铃开关
- 铃声编号选择和音乐命令队列扩展
- 旋转编码器调节音量、时间、重复开关和铃声编号
- OLED 多页面显示
- DHT11 温湿度采集
- 响铃超时自动停止
- 输入事件队列
- 音乐命令队列
- OLED 熄屏和 DHT11 降采样
- 调试页面显示队列丢包计数、任务计数和剩余堆空间
3. 系统总体设计
3.1 硬件架构
项目使用 STM32F103C8T6 作为主控,结合 OLED、DHT11、无源蜂鸣器、旋转编码器和按键完成智能闹钟的完整交互。
| 模块类别 | 硬件/接口 | 功能描述 |
|---|---|---|
| 主控芯片 | STM32F103C8T6 | 系统核心控制器,负责 FreeRTOS 任务调度、外设管理及业务逻辑运行 |
| 显示模块 | OLED 显示屏 | 实时显示当前时间、闹钟状态、温湿度信息以及系统调试信息 |
| 环境检测模块 | DHT11(PA11) | 周期性采集环境温度和湿度数据,并显示在 OLED 界面 |
| 声音输出模块 | 无源蜂鸣器(TIM1_CH1 / PA8) | 利用 PWM 输出不同频率信号,实现闹钟铃声播放 |
| 人机交互模块 | 旋转编码器(TIM3 Encoder Mode) | 用于菜单导航、时间调整、音量调节、铃声切换等参数设置 |
| 功能按键1 | PB1 | 控制闹钟启停、关闭响铃等功能 |
| 功能按键2 | PB11 | 切换设置页面;长按进入时间设置界面;闹钟响铃时触发贪睡模式 |
| 调试模块 | PA0 LED | 指示系统运行状态,同 |
在stm32cubemx上的对应引脚关系:


3.2 软件架构
软件采用三层结构:
- BSP 驱动层:只关心硬件读写,如 OLED、DHT11、Buzzer、Encoder、Key。
- Task 任务层:完成周期扫描、数据采集、显示刷新、时钟推进和音乐播放。
- App 应用层:维护业务状态,如当前时间、闹钟状态、UI 模式、音量和事件分发。
App 层
- App_Task 输入事件解释和业务分发
- Alarm_App 闹钟状态机
- Clock_App 当前时间维护
- UI_App 页面模式状态机
- App_State 音量和活跃状态
- Input_Event 输入事件队列封装

Task 层
- Key_Task 按键扫描,生成输入事件
- Encoder_Task 编码器扫描,生成旋转事件
- Clock_Task 每秒推进时间,检测闹钟
- OLED_Task OLED 页面刷新
- DHT11_Task 温湿度采集
- Music_Task 蜂鸣器音乐播放

BSP 层

任务关系图如下:
| 数据流 | 说明 |
|---|---|
| Key_Task → InputEventQueue | 按键事件发送到输入队列 |
| Encoder_Task → InputEventQueue | 编码器事件发送到输入队列 |
| InputEventQueue → App_Task | App任务统一处理用户输入 |
| App_Task → Alarm_App | 修改闹钟设置 |
| App_Task → Clock_App | 修改时间设置 |
| App_Task → UI_App | 页面切换 |
| App_Task → App_State | 更新系统状态 |
| App_Task → MusicCmdQueue | 发送播放音乐命令 |
| Clock_Task → Clock_App | 更新时间 |
| Clock_Task → Alarm_App | 检查闹钟触发条件 |
| Clock_Task → MusicCmdQueue | 闹钟到点发送响铃命令 |
| MusicCmdQueue → BuzzerMusic_Task | 蜂鸣器播放控制 |
| DHT11_Task → App_State | 更新温湿度数据 |
| OLED_Task ← Clock_App | 获取时间数据显示 |
| OLED_Task ← Alarm_App | 获取闹钟数据显示 |
| OLED_Task ← UI_App | 获取界面状态 |
| OLED_Task ← App_State | 获取系统状态与温湿度 |
这套架构的核心思想是:输入任务只产生事件,应用任务解释事件,业务模块维护状态,显示任务只读取快照并显示。
3.3 FreeRTOS任务配置
系统采用 FreeRTOS 进行任务调度,各任务职责划分如下:
| 任务名称 | 功能描述 | 运行周期 | 类型 |
|---|---|---|---|
| Key_Task | 按键扫描与事件生成 | 10ms | 周期任务 |
| Encoder_Task | 编码器扫描与事件生成 | 2ms | 周期任务 |
| Clock_Task | 时钟推进与闹钟检测 | 1s | 周期任务 |
| OLED_Task | OLED页面刷新 | 100ms | 周期任务 |
| DHT11_Task | 温湿度采集 | 1s | 周期任务 |
| App_Task | 输入事件处理与业务分发 | 事件驱动 | 业务任务 |
| BuzzerMusic_Task | 铃声播放控制 | 事件驱动 | 业务任务 |
其中 App_Task 作为系统核心控制器,负责解释输入事件并驱动各业务模块运行。
3.4 队列通信设计
系统主要使用消息队列实现任务之间的解耦。
| 队列名称 | 数据类型 | 作用 |
|---|---|---|
| g_InputEventQueue | InputEvent_t | 传递按键与编码器输入事件 |
| g_MusicCmdQueue | MusicCmd_t | 传递音乐播放控制命令 |
系统的数据流如下:
输入任务 → 输入事件队列 → App_Task → 业务模块
业务模块不直接操作其他任务,而是通过消息队列进行通信。这样能够降低模块耦合度,提高系统可维护性和扩展能力。
3.5 工程目录结构
工程按照 BSP、Task、App 三层进行组织:
Project
│
├── BSP
│ ├── bsp_oled.c
│ ├── bsp_dht11.c
│ ├── bsp_buzzer.c
│ ├── bsp_encoder.c
│ └── bsp_key.c
│
├── App
│ ├── alarm_app.c
│ ├── clock_app.c
│ ├── ui_app.c
│ ├── app_state.c
│ └── input_event.c
│
├── Tasks
│ ├── key_task.c
│ ├── encoder_task.c
│ ├── clock_task.c
│ ├── oled_task.c
│ ├── dht11_task.c
│ └── music_task.c
│
├── Core
│
└── FreeRTOS
这种组织方式使驱动层、任务层和业务层职责清晰,便于后续扩展和维护。
4. 关键模块设计
4.1 输入事件模块
功能
输入模块负责把 PB1、PB11 和旋转编码器转换成统一的输入事件。
事件类型包括:
- `INPUT_EVENT_PB1_SHORT`
- `INPUT_EVENT_PB11_SHORT`
- `INPUT_EVENT_PB11_LONG`
- `INPUT_EVENT_ENCODER_STEP`
实现思路
按键任务周期扫描按键,PB11 采用独立逻辑区分短按和长按。编码器任务以 2ms 周期读取 TIM3 编码器计数,将旋转量投递到输入事件队列。
这里没有让按键任务直接修改闹钟时间,也没有让编码器任务直接修改音量。它们只负责报告“发生了什么输入”。
数据流
|
发送方 |
接收方 |
事件/作用 |
|
Key_Task |
g_InputEventQueue |
发送 PB1 / PB11 按键事件 |
|
Encoder_Task |
g_InputEventQueue |
发送编码器步进(step)事件 |
|
g_InputEventQueue |
App_Task |
输入事件队列传递 |
|
App_Task |
Alarm_App / Clock_App / UI_App |
根据当前 UI 模式进行事件 |
核心算法
PB11 的长按识别不是继续复用阻塞式 `Key_GetNum()`,而是在任务中直接读取 GPIO 并累计按下时间:
- 按下超过 800ms,触发长按事件。
- 松开且按下时间超过 30ms,触发短按事件。
- 长按已经触发后,松手不再重复触发短按。
这个设计解决了阻塞式按键扫描无法统计按下持续时间的问题。
4.2 UI 状态机模块
功能
UI 模式模块负责描述用户当前正在操作哪个页面。
主要模式包括:
| 枚举值 | 模式名称 | 功能说明 |
|---|---|---|
UI_MODE_NORMAL |
主界面模式 | 显示当前时间、闹钟状态、温湿度等信息 |
UI_MODE_SET_ALARM_HOUR |
闹钟小时设置 | 调整闹钟小时数(0~23) |
UI_MODE_SET_ALARM_MINUTE |
闹钟分钟设置 | 调整闹钟分钟数(0~59) |
UI_MODE_SET_ALARM_REPEAT |
闹钟重复设置 | 设置单次、工作日、每天等重复模式 |
UI_MODE_SET_ALARM_RINGTONE |
闹钟铃声设置 | 选择蜂鸣器播放的铃声编号 |
UI_MODE_SET_CLOCK_HOUR |
时钟小时设置 | 手动修改当前时间小时数 |
UI_MODE_SET_CLOCK_MINUTE |
时钟分钟设置 | 手动修改当前时间分钟数 |
UI_MODE_DEBUG |
调试模式 | 显示系统状态、任务信息、传感器数据等调试内容 |
实现思路
PB11 短按用于闹钟设置流程,PB11 长按用于当前时间设置和调试页面切换。编码器在不同 UI 模式下具有不同含义:
| UI模式 | 页面功能 | 编码器作用 |
|---|---|---|
NORMAL |
主界面 | 调节音量 |
SET_ALARM_HOUR |
闹钟设置 | 调节闹钟小时 |
SET_ALARM_MINUTE |
闹钟设置 | 调节闹钟分钟 |
SET_ALARM_REPEAT |
闹钟设置 | 切换重复开关 |
SET_ALARM_RINGTONE |
闹钟设置 | 选择铃声编号 |
SET_CLOCK_HOUR |
当前时间设置 | 调节当前小时 |
SET_CLOCK_MINUTE |
当前时间设置 | 调节当前分钟 |
数据流
| 流程阶段 | 模块 | 功能说明 |
|---|---|---|
| 1 | Input(输入事件) | 接收按键、编码器等输入事件 |
| 2 | App_Task | 统一处理输入事件 |
| 3 | 当前 UI 模式判断 | 根据 g_CurrentUIMode 选择处理逻辑 |
| 4 | 对应业务模块 | 执行具体参数修改或界面操作 |
核心算法
UI 本质上是一个状态机,而不是简单的页面变量。PB11 短按和长按分别对应两条状态转换链:

4.3 闹钟状态机模块
功能
`Alarm_App` 是整个项目的核心业务模块,负责维护闹钟状态、闹钟时间、下一次实际响铃时间、重复开关、贪睡状态和铃声编号。
实现思路
闹钟状态被抽象为 4 个状态:
- `ALARM_STATE_OFF`:闹钟关闭
- `ALARM_STATE_ON`:闹钟开启,等待触发
- `ALARM_STATE_RINGING`:正在响铃
- `ALARM_STATE_SNOOZING`:贪睡中,等待下一次响铃
状态转换如下:

数据流
Clock_Task
│
▼
Alarm_App_Check(now)
│
▼
到达 nextRingTime ?
│
YES
│
▼
AlarmState = RINGING
│
▼
Music_CMD_PostStart()
│
▼
Music Queue
│
▼
BuzzerMusic_Task
│
▼
PWM输出铃声
核心算法
项目中一个很关键的设计是将 `alarmTime` 和 `nextRingTime` 分开:
- `alarmTime`:用户设置的原始闹钟时间。
- `nextRingTime`:系统下一次真正应该响铃的时间。
如果用户设置 07:30,闹钟响后选择贪睡 5 分钟,那么:
alarmTime = 07:30 nextRingTime = 07:35
这样不会破坏用户设置的原始闹钟时间。贪睡结束后再次响铃,停止响铃时再根据重复开关决定回到 `ON` 还是 `OFF`。
4.4 时钟任务模块
功能
`Clock_Task` 每秒运行一次,负责推进当前时间、更新响铃计时、检测闹钟触发并发送音乐命令。
实现思路
当前时间由软件时钟维护,`Clock_Task` 使用 `vTaskDelayUntil()` 保持 1s 周期运行。
数据流
Clock_Task
│
▼
更新时间
│
▼
Alarm_App_Check(now)
│
├── 到达闹钟时间 ──► START
│
└── 响铃超时 ─────► STOP
│
▼
g_MusicCmdQueue
│
▼
BuzzerMusic_Task
核心算法
响铃超时属于时间事件,而不是输入事件,因此放在 `Clock_Task` 中处理。当前配置中响铃超时时间为 60 秒,到达后自动停止音乐,并根据重复开关决定闹钟后续状态。
4.5 OLED 显示模块
功能
OLED 负责展示当前时间、闹钟时间、闹钟状态、音量、温湿度、设置页面和调试信息。
实现思路
OLED 任务不直接修改业务状态,而是读取 `Clock_App`、`Alarm_App`、`UI_App`、`App_State` 的快照进行显示。这样显示层不会反向污染业务逻辑。
数据流
业务模块
(Clock / Alarm / UI / DHT11)
│
▼
App_State
│
▼
OLED_Task
│
▼
根据UI模式
刷新页面
│
▼
OLED显示
核心算法
OLED 页面按 UI 模式分流:
| 页面类型 | 页面名称 | 显示内容 |
|---|---|---|
| 主页面 | 正常页(Normal Page) | 当前时间、闹钟时间/贪睡时间、音量等级、温度、湿度 |
| 设置页面 | 闹钟设置页(Alarm Setting) | 闹钟小时、闹钟分钟、重复开关状态、铃声编号 |
| 设置页面 | 当前时间设置页(Clock Setting) | 当前时间调整状态、小时设置、分钟设置 |
| 运行页面 | 响铃页(Ringing Page) | 响铃提示、停止操作提示、贪睡操作提示 |
| 调试页面 | 调试页(Debug Page) | 输入事件丢包数、编码器事件丢包数、音乐命令丢包数、剩余堆空间、任务运行计数 |
同时,OLED 任务根据最近用户活动时间实现 30 秒无操作熄屏,响铃时强制点亮。
4.6 DHT11 温湿度模块
功能
DHT11 模块负责采集环境温湿度,并在 OLED 正常页面中显示。
实现思路
BSP 层通过 GPIO 开漏输出和输入模式切换完成 DHT11 单总线协议。使用 TIM2 提供微秒级延时,通过高电平持续时间判断数据位 0 或 1。
数据流
DHT11_Task
│
▼
DHT11_Read_Data()
│
▼
温度 / 湿度
│
▼
App_State
│
▼
OLED_Task
│
▼
OLED显示
核心算法
DHT11 的难点在于时序严格。项目中在读取 40bit 数据前会先发送起始信号,然后切换为输入模式等待 DHT11 响应。读取数据位时,通过统计高电平时间判断当前 bit:
- 高电平时间较短,判断为 0。
- 高电平时间较长,判断为 1。
读取完成后通过校验和判断数据是否有效。
4.7 蜂鸣器音乐模块
功能
蜂鸣器模块负责 PWM 输出、音量控制和音乐播放。
实现思路
BSP 层通过 TIM1 PWM 输出不同频率的方波,音乐任务接收 `g_MusicCmdQueue` 中的 START / STOP 命令后播放或停止旋律。
数据流
Clock_Task
│
├────►
│
App_Task
│
▼
g_MusicCmdQueue
│
▼
BuzzerMusic_Task
│
▼
Buzzer PWM
核心算法
音符本质上是频率和持续时间的组合。播放时根据频率动态修改 TIM1 自动重装载值和比较值,让无源蜂鸣器输出对应音高。
当前工程已经在应用层维护了铃声编号,并在音乐命令结构中加入了 `ringtone` 字段。这说明系统已经具备多铃声扩展接口。当前播放任务侧主要仍是一张旋律表,后续只需要将旋律表扩展为数组,即可按 `ringtone` 选择不同铃声。
5. 核心技术分析
5.1 为什么使用事件队列,而不是任务互相调用
如果按键任务直接修改闹钟时间,编码器任务直接修改音量,OLED 任务再读取这些变量,系统很快会变成“到处都是 if 和全局变量”的结构。
本项目采用输入事件队列:
输入采集任务 -> 输入事件队列 -> App_Task -> 业务模块
好处是:
- 输入采集和业务解释解耦。
- 同一个输入在不同 UI 模式下可以表达不同含义。
- 业务状态集中在 App 层,便于维护。
- 事件投递失败可以计数,便于调试。
5.2 为什么闹钟必须使用状态机
闹钟不是简单的“时间到了就响”。真实闹钟至少包含开启、关闭、响铃、贪睡、重复、超时停止等多个状态。如果只用多个布尔变量,会很容易出现矛盾状态。
例如
isAlarmOn = 1
isRinging = 1
isSnoozing = 1
这些变量组合起来含义并不清晰。状态机能明确约束系统在同一时刻只能处于一个主状态,并且所有状态转换都有触发条件。
5.3 为什么要区分 alarmTime 和 nextRingTime
支持贪睡后,用户设置值和系统运行时临时值必须分离。
如果贪睡时直接修改 `alarmTime`,用户原本设置的 07:30 会被改成 07:35。这样下一天重复响铃时,闹钟时间就不再是用户想要的时间。
因此:
- 用户配置保存在 `alarmTime`。
- 贪睡后的实际触发时间保存在 `nextRingTime`。
这个设计体现了嵌入式工程中很重要的思想:配置值和运行时状态不要混在一起。
5.4 为什么显示层只读快照
OLED 任务只负责显示,不负责修改状态。比如它可以显示当前处于 `RINGING`,但不能自己停止闹钟。
这样做的好处是:
- 显示刷新频率可以独立调整。
- 业务状态不会被显示逻辑意外修改。
- 后续更换显示屏或 UI 框架时,不需要重写闹钟业务逻辑。
5.5 低功耗策略为什么先做 Sleep 级优化
当前项目的时间由 FreeRTOS 软件时钟推进。如果直接进入 STOP 模式,在没有 RTC 或低功耗唤醒源配合的情况下,SysTick 会受影响,闹钟时间可能不准。
因此当前更合适的策略是先做温和低功耗:
- 30 秒无操作后 OLED 熄屏。
- OLED 熄屏后 DHT11 从 1 秒采样一次降低到 5 秒检查一次。
- 保留 FreeRTOS 调度和软件时钟,避免破坏闹钟核心功能。
源码中已经开启了 `configUSE_IDLE_HOOK`,并定义了 `APP_LOW_POWER_IDLE_SLEEP`,后续可以补充自定义 Idle Hook,在空闲时执行 `__WFI()`。这一步需要结合功耗测试验证,不能只为了低功耗而牺牲时间准确性。
6. 项目难点
难点 1:DHT11 读取不稳定
问题
DHT11 驱动调试时经常读取失败,表面上看像是代码细节错误。
原因分析
DHT11 是单总线时序器件,对微秒级延时、GPIO 输入输出模式切换和中断干扰都比较敏感。如果任务栈不足、延时不准确或读取过程中被打断,都可能导致校验失败。
解决方案
项目中使用 TIM2 实现微秒级延时,读取时根据高电平持续时间判断 bit,并通过校验和验证数据。在读取关键时序段临时关闭中断,减少时序被打断的概率。
最终效果
DHT11 数据可以被周期性采集,并在 OLED 页面显示温湿度。熄屏后任务降低采样频率,减少无意义的传感器访问。
难点 2:PB11 既要短按又要长按
问题
PB11 需要同时承担闹钟设置、当前时间设置、调试页面切换和响铃时贪睡等功能。直接复用阻塞式按键读取函数会导致无法准确识别长按。
原因分析
原始按键函数内部会等待按键释放,这种阻塞方式适合简单短按,但不适合统计“按住了多久”。一旦任务阻塞,就很难做长按计时和事件分发。
解决方案
在 `Key_Task` 中直接扫描 PB11 GPIO,使用计时变量累计按下时间:
- 超过 800ms 触发长按。
- 松开且超过消抖时间触发短按。
- 长按触发后不再重复触发短按。
最终效果
PB11 可以稳定区分短按和长按,并能在不同状态下表达不同含义:正常页面下切换设置流程,响铃状态下触发贪睡。
难点 3:编码器残留输入影响模式切换
问题
频繁切换 UI 模式时,编码器计数可能残留,导致刚进入新页面就误调音量、闹钟时间或当前时间。
原因分析
编码器任务持续读取 TIM3 计数,UI 模式切换是另一个事件。当模式刚变化时,旧模式下产生但尚未消费的 step 可能在新模式下被解释,造成错误修改。
解决方案
在 `App_Task` 中引入模式切换保护窗口。每次 UI 模式变化后,短时间内忽略编码器事件,等待残留输入自然清空。
最终效果
模式切换和编码器调参之间的边界更清晰,避免了“没有转编码器,数值却变化”的交互 bug。
难点 4:任务之间职责容易混乱
问题
功能增加后,按键、OLED、闹钟和蜂鸣器逻辑容易互相调用,代码会快速失控。
原因分析
简单 demo 阶段可以把所有逻辑写在一个任务中,但闹钟加入贪睡、重复、铃声、当前时间设置后,业务关系复杂度明显上升。
解决方案
将系统拆为 BSP、Task、App 三层:
- BSP 层只读写硬件。
- Task 层负责周期行为和事件产生。
- App 层维护业务状态和状态机。
任务之间通过队列传递事件,不直接修改其他任务内部变量。
最终效果
代码结构更接近真实嵌入式项目,新增功能时主要扩展状态机和事件分发逻辑,而不是在各个任务里到处补条件判断。
7. 项目成果展示
7.1 OLED 页面效果
系统包含多个 OLED 页面:
正常页面:当前时间、闹钟时间、闹钟状态、音量、温湿度、编码器状态。

闹钟设置页面:小时、分钟、重复开关、铃声编号。

当前时间设置页面:当前小时和分钟。

响铃页面:显示响铃提示、PB1 停止、PB11 贪睡。


调试页面:显示输入事件丢包、编码器事件丢包、音乐命令丢包和剩余堆空间。

7.2 运行行为
系统启动后,时钟从默认时间开始运行,闹钟模块初始化为开启状态。用户可以通过 PB11 进入设置流程,通过编码器调整参数,通过 PB1 开启或关闭闹钟。
当当前时间到达 `nextRingTime` 时,闹钟进入 `RINGING` 状态并发送音乐播放命令。响铃过程中:
- PB1:停止响铃。
- PB11:进入 5 分钟贪睡。
- 60 秒无操作:自动停止响铃。
7.3 调试与可靠性指标
项目没有在代码中固化具体性能测试报告,因此不虚构响应时间或 CPU 占用率。当前可展示的工程化观测指标包括:
- `g_InputEventDropCount`:按键事件队列丢包计数。
- `g_EncoderEventDropCount`:编码器事件队列丢包计数。
- `g_MusicCmdDropCount`:音乐命令队列丢包计数。
- `xPortGetFreeHeapSize()`:剩余堆空间。
- 任务计数器:观察 Key、PB1、PB11、OLED 等任务运行状态。
这些指标可以直接显示在 OLED 调试页上,用于判断系统是否存在队列拥塞、任务卡死或堆空间不足。
8. 项目总结
8.1 技术收获
这个项目覆盖了 STM32 HAL、GPIO、定时器 PWM、TIM 编码器模式、DHT11 单总线时序、OLED 显示、FreeRTOS 任务、队列、互斥量和软件时钟等知识点。
其中最重要的不是某一个外设怎么驱动,而是理解了:外设只是能力,真正决定项目质量的是任务如何拆、状态如何管、事件如何流动。
8.2 架构收获
项目从最初的蜂鸣器播放 demo,逐步演进为 BSP、Task、App 分层结构。这个过程说明,当功能变多后,不能靠到处加 if 维护系统,而要先抽象状态,再设计状态转换,最后决定哪个任务负责触发。
8.3 工程收获
项目中遇到过栈空间不足、OLED 共享资源竞争、按键长按识别、编码器残留输入、DHT11 时序敏感、队列消息结构升级等问题。这些问题都不是单纯语法错误,而是典型的嵌入式工程问题。
解决它们的过程训练了几个重要习惯:
- 出现运行异常时,要优先考虑栈、堆、任务阻塞和外设时序。
- 共享资源要用互斥量或明确所有权保护。
- 任务通信优先使用队列或事件,不随意改其他任务变量。
- 配置值和运行时状态要分开。
- 显示层只读状态,不直接修改业务。
8.4 个人心得
这个项目的动机很简单:就是高中时期用过的一款闹钟,功能和现在这个项目非常接近。后来学习 RTOS 时,我意识到不能等到把整个 RTOS 的所有底层原理都完全学透之后,才开始做一个完整项目。真正的工程思维不是先把所有知识点孤立学完,而是在实操中不断遇到问题、定位问题、修正设计。
项目最开始是从 STM32CubeMX 配置和点亮 LED 起步的,随后逐步编写 OLED、按键、LED、电位器等 BSP 驱动。后来为了实现音乐播放,又引入了蜂鸣器;为了增加环境感知能力,又加入了 DHT11 温湿度传感器。DHT11 的调试花了很长时间,最初一直以为是代码细节问题,后来才发现更关键的是堆栈资源和任务配置问题。这次经历让我意识到,嵌入式调试不能只盯着某一行代码,堆、栈、任务调度、外设时序同样是排查重点。
开发工具上,项目也从单纯依赖 Keil,逐渐切换到 VS Code 编写代码、Keil 调试和烧写的方式。这个变化本身也是一次工程习惯的调整:写代码、管理文件、阅读工程结构和调试下载可以由不同工具承担,不必把所有工作都限制在同一个 IDE 里。
在整个开发过程中,堆栈空间问题出现过多次:写早期任务时遇到过,调 DHT11 时遇到过,后面加入贪睡等功能时也再次遇到过。这让我形成了一个很重要的排错习惯:当新增功能后编译没有报错,但单片机运行异常时,不能只怀疑代码细节,还要优先检查任务栈、系统堆、队列大小、任务阻塞和外设时序。
最终学到的核心知识可以概括为四点:
- 状态机是防止复杂逻辑混乱的关键。功能越多,越需要先画状态和状态转换,而不是直接写判断条件。
- 堆栈空间是 FreeRTOS 项目中非常重要的排查方向。运行异常不一定是语法问题,也可能是资源配置问题。
- 任务之间要有明确的保护边界。共享状态要通过临界区、互斥量或封装接口访问,不能随意跨任务改变量。
- 任务之间要通过队列、事件或命令通信。输入任务、应用任务、音乐任务各自负责自己的事情,系统才不会因为功能增加而失控。
回头看,这个项目真正训练的不是“会不会驱动某个外设”,而是如何把多个外设、多个任务、多个状态组织成一个还能继续扩展和维护的嵌入式系统。
8.5 后续优化方向
后续可以继续优化
- 增加 RTC,用硬件时间替代软件时钟。
- 增加 STOP 或 Standby 低功耗模式,并通过 RTC Alarm 或 EXTI 唤醒。
- 完善多铃声播放任务,让 `ringtone` 真正选择不同旋律表。
- 增加 Flash 参数保存,掉电后保留闹钟时间、音量、重复开关和铃声编号。
- 增加蓝牙或串口配置功能。
- 增加更完整的任务运行统计和栈水位检测。
- 优化 OLED 页面交互,让设置流程更接近真实产品。
9.代码实现
具体代码在github上
fish-eat-no/RTOS_Clock
更多推荐


所有评论(0)