RTOS多任务与温度采集实验——基于STM32F103的uC/OS移植与AHT20温湿度采集
RTOS多任务与温度采集实验——基于STM32F103的uC/OS移植与AHT20温湿度采集
前言:本实验分为两个任务。任务一学习嵌入式实时操作系统(RTOS),以 uC/OS 为例,将其移植到 STM32F103 上,构建至少 3 个任务并观察多任务并发执行的效果;任务二学习 I2C 总线通信协议,使用 STM32F103 完成 AHT20 温湿度传感器的数据采集,并通过串口输出到上位机。文中所有实验成果截图在对应位置贴出。
目录
一、实验目的
- 学习嵌入式实时操作系统(RTOS)的基本概念,掌握 uC/OS 在 STM32F103 上的移植方法,理解多任务的调度机制;
- 学习 I2C 点对点通信协议的原理与时序,掌握"软件I2C"与"硬件I2C"的区别;
- 使用 STM32F103 完成基于 I2C 协议的 AHT20 温湿度传感器数据采集,并通过串口输出到上位机;
- 学会使用示波器/逻辑分析仪观察 IIC 的时序数据,理解 I2C 通信的底层细节。
二、实验环境
| 项目 | 说明 |
|---|---|
| 开发板 | STM32F103C8T6 最小系统板 |
| 传感器 | AHT20 温湿度传感器模块 |
| IDE | Keil MDK 5 |
| RTOS | uC/OS-III(也可用 uC/OS-II) |
| 上位机 | 串口调试助手(Windows 10) |
| 仪器 | 示波器 / 逻辑分析仪 |
三、任务一:uC/OS-III 移植到 STM32F103 与多任务创建
3.1 什么是 RTOS?为什么需要它?
在裸机系统中,所有任务都在一个无限循环(main 里的 while(1))中顺序执行,任务之间是前后台关系:中断为"前台",主循环为"后台"。这种结构存在几个问题:
- 任务只能顺序执行,一个任务耗时长会阻塞其他任务;
- 实时性差,响应时间不可预测;
- 任务多了以后代码耦合严重、难以维护。
实时操作系统(RTOS) 引入了任务(Task)、调度器(Scheduler)、信号量、消息队列等概念,由调度器根据优先级在多个任务间切换 CPU 使用权,每个任务看起来像是在"并行"运行。
uC/OS 是 Micrium 公司推出的经典商用 RTOS,本实验使用 uC/OS-III。它的特点是:
- 基于优先级的抢占式调度,同优先级任务采用时间片轮转;
- 任务数量不受限制(仅受 RAM 限制);
- 提供 Semaphore、Mutex、Message Queue、Event Flag 等丰富的同步与通信机制。
3.2 uC/OS-III 移植步骤
移植的整体思路:先跑通裸机工程 → 加入 uC/OS-III 源码 → 修改移植相关文件 → 配置 os_cfg.h → 创建任务。
(1)准备工程
使用 STM32F103C8T6 的标准库(或 HAL 库)工程模板,确保裸机工程能正常编译下载(例如 LED 闪烁例程)。
(2)添加 uC/OS-III 源码
将 uC/OS-III 源码包复制到工程目录,一般包括以下几部分:
uCOS-III/SOURCE:uC/OS-III 内核源码(os_core.c、os_task.c、os_sem.c 等);uC/OS-III/PORT:移植层,分为通用移植层(CPU 相关,CPU文件夹中的 cpu_core.c 等)和芯片级移植层(os_cpu.h / os_cpu_c.c / os_cpu_a.asm,负责上下文切换、SysTick 处理);CPU:CPU 通用层(位带操作、临界区宏、数学运算等);LIB:Micrium 提供的库文件(lib_mem.c、lib_str.c 等)。
参考:STM32F103C8T6移植uC/OS-III基于HAL库超完整详细过程(见文末参考链接1)
(3)Keil 工程配置
- 在 Keil 中新建 Group:
uCOS-III_SOURCE、uCOS-III_PORT、CPU、LIB,把对应 .c / .asm 文件加进去; - 添加头文件包含路径;
- 因为代码里大量使用汇编,启动文件和
os_cpu_a.asm需要在 Target 选项中确认使用 ARM 编译器版本一致(AC5/AC6),AC6 下可能需要修改个别语法。
(4)配置 os_cfg.h
通过条件编译裁剪内核功能,关键配置:
#define OS_CFG_APP_TASK_Q_EN 1u /* 任务队列使能 */
#define OS_CFG_SEM_EN 1u /* 信号量 */
#define OS_CFG_MUTEX_EN 1u /* 互斥量 */
#define OS_CFG_MSG_EN 1u /* 消息 */
#define OS_CFG_SCHED_ROUND_ROBIN_EN 1u /* 同优先级时间片轮转 */
(5)SysTick 与中断配置
uC/OS 需要一个周期性的时钟节拍。这里需要注意一个经典坑:STM32 标准库的 delay 函数和 uC/OS 都想占用 SysTick。解决办法是让 uC/OS 接管 SysTick,自己延时用 OSTimeDlyHMSM()。
在 os_cpu_c.c 的 OS_CPU_SysTickHandler() 中处理节拍中断;或使用 os_cpu_c.c 中提供的 CPU_TS 时间戳功能,将 SysTick 中断指向 OS 的 handler。
(6)主函数结构
uP/OS 的主函数采用经典的"创建 App 任务 → 初始化 OS → 启动调度器"结构,见下面代码。
3.3 三个任务的创建与代码
题目要求:
- 任务1:以 1s 为周期对 LED 进行点亮、熄灭的控制;
- 任务2:以 3s 为周期对 LED(另一个灯)进行点亮、熄灭的控制;
- 任务3:以 2s 为周期通过串口发送
"hello uc/OS! 欢迎来到RTOS多任务环境!"。
#include "os.h"
#include "stm32f10x.h"
/* 任务优先级(数值越大优先级越高,uC/OS-III中0为最高) */
#define TASK1_PRIO 5u
#define TASK2_PRIO 6u
#define TASK3_PRIO 7u
/* 任务栈大小 */
#define TASK1_STK_SIZE 128u
#define TASK2_STK_SIZE 128u
#define TASK3_STK_SIZE 256u
/* 任务栈 */
static CPU_STK Task1Stk[TASK1_STK_SIZE];
static CPU_STK Task2Stk[TASK2_STK_SIZE];
static CPU_STK Task3Stk[TASK3_STK_SIZE];
/* 任务控制块 */
static OS_TCB Task1TCB;
static OS_TCB Task2TCB;
static OS_TCB Task3TCB;
/* 主任务:负责创建其他任务 */
static void AppTaskCreate(void *p_arg)
{
OS_ERR err;
(void)p_arg;
/* 创建任务1:LED1 1s周期闪烁 */
OSTaskCreate((OS_TCB *)&Task1TCB,
(CPU_CHAR *)"Task1 LED",
(OS_TASK_PTR )Task1_LED,
(void *)0,
(OS_PRIO )TASK1_PRIO,
(CPU_STK *)&Task1Stk[0],
(CPU_STK_SIZE)TASK1_STK_SIZE / 10u,
(CPU_STK_SIZE)TASK1_STK_SIZE,
(OS_MSG_QTY )0u,
(OS_TICK )0u,
(void *)0,
(OS_OPT )(OS_OPT_TASK_STK_CHK | OS_OPT_TASK_STK_CLR),
(OS_ERR *)&err);
/* 创建任务2:LED2 3s周期闪烁 */
OSTaskCreate((OS_TCB *)&Task2TCB,
(CPU_CHAR *)"Task2 LED",
(OS_TASK_PTR )Task2_LED,
(void *)0,
(OS_PRIO )TASK2_PRIO,
(CPU_STK *)&Task2Stk[0],
(CPU_STK_SIZE)TASK2_STK_SIZE / 10u,
(CPU_STK_SIZE)TASK2_STK_SIZE,
(OS_MSG_QTY )0u,
(OS_TICK )0u,
(void *)0,
(OS_OPT )(OS_OPT_TASK_STK_CHK | OS_OPT_TASK_STK_CLR),
(OS_ERR *)&err);
/* 创建任务3:串口2s周期发送 */
OSTaskCreate((OS_TCB *)&Task3TCB,
(CPU_CHAR *)"Task3 UART",
(OS_TASK_PTR )Task3_SendMsg,
(void *)0,
(OS_PRIO )TASK3_PRIO,
(CPU_STK *)&Task3Stk[0],
(CPU_STK_SIZE)TASK3_STK_SIZE / 10u,
(CPU_STK_SIZE)TASK3_STK_SIZE,
(OS_MSG_QTY )0u,
(OS_TICK )0u,
(void *)0,
(OS_OPT )(OS_OPT_TASK_STK_CHK | OS_OPT_TASK_STK_CLR),
(OS_ERR *)&err);
OSTaskDel((OS_TCB *)0, &err); /* 删除自己 */
}
/* 任务1:LED1以1s周期点亮熄灭 */
static void Task1_LED(void *p_arg)
{
OS_ERR err;
(void)p_arg;
for (;;)
{
LED1_TOGGLE(); /* 翻转LED1 */
OSTimeDlyHMSM(0u, 0u, 0u, 500u,
OS_OPT_TIME_HMSM_STRICT, &err); /* 延时500ms,1s周期翻转 */
}
}
/* 任务2:LED2以3s周期点亮熄灭 */
static void Task2_LED(void *p_arg)
{
OS_ERR err;
(void)p_arg;
for (;;)
{
LED2_TOGGLE(); /* 翻转LED2 */
OSTimeDlyHMSM(0u, 0u, 1u, 500u,
OS_OPT_TIME_HMSM_STRICT, &err); /* 延时1.5s,3s周期翻转 */
}
}
/* 任务3:串口以2s周期发送消息 */
static void Task3_SendMsg(void *p_arg)
{
OS_ERR err;
(void)p_arg;
for (;;)
{
printf("hello uc/OS! 欢迎来到RTOS多任务环境!\r\n");
OSTimeDlyHMSM(0u, 0u, 2u, 0u,
OS_OPT_TIME_HMSM_STRICT, &err); /* 延时2s */
}
}
int main(void)
{
OS_ERR err;
/* 硬件初始化(裸机部分) */
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
LED_Init();
USART_Config();
/* uC/OS-III 初始化 */
OSInit(&err);
if (err != OS_ERR_NONE) { while (1); }
/* 创建启动任务 */
OSTaskCreate((OS_TCB *)&AppTaskStartTCB,
(CPU_CHAR *)"App Task Start",
(OS_TASK_PTR )AppTaskCreate,
(void *)0,
(OS_PRIO )APP_TASK_START_PRIO,
(CPU_STK *)&AppTaskStartStk[0],
(CPU_STK_SIZE)APP_TASK_START_STK_SIZE / 10u,
(CPU_STK_SIZE)APP_TASK_START_STK_SIZE,
(OS_MSG_QTY )0u,
(OS_TICK )0u,
(void *)0,
(OS_OPT )(OS_OPT_TASK_STK_CHK | OS_OPT_TASK_STK_CLR),
(OS_ERR *)&err);
OSStart(&err); /* 启动调度器,之后CPU控制权交给uC/OS */
while (1);
}
要点说明:
- 三个任务都写成了
for(;;)死循环 +OSTimeDlyHMSM()的形式。任务延时后让出 CPU,调度器去运行其他就绪任务,这就是多任务"并发"的本质; OSTimeDlyHMSM(0,0,1,500, ...)表示延时 1 秒 500 毫秒,配合LED_TOGGLE翻转一次,正好构成 1s / 3s 的完整闪烁周期;- 串口发送重定向了
printf(重写fputc指向USART_SendData),注意这里是对共享资源(USART1)的访问,三个任务都用串口时严格来说应加互斥量保护,本实验只有任务3用串口所以暂时不加。
3.4 实验结果
三个任务同时运行:两个 LED 分别以 1s、3s 周期闪烁,串口每 2s 打印一条欢迎信息,全部体现在一张实验成果图中:

四、任务二:I2C 协议与 AHT20 温湿度采集
4.1 什么是"软件I2C"和"硬件I2C"?
I2C(Inter-Integrated Circuit)是一种两线式串行总线,只需要两根线:
- SCL:时钟线,由主机产生;
- SDA:数据线,双向传输。
总线上可以有多个设备,每个设备有独立的 7 位地址。通信时序包括:起始信号(S)→ 发送设备地址+读写位 → 等待从机应答(ACK)→ 传输数据 → 停止信号(P)。
实现 I2C 有两种方式:
| 对比项 | 软件I2C(模拟I2C) | 硬件I2C |
|---|---|---|
| 实现方式 | 用普通GPIO,软件控制引脚电平按I2C时序翻转 | 使用芯片内部专用I2C外设控制器 |
| 引脚 | 任意GPIO均可 | 只能使用固定的I2C复用引脚(如PB6/PB7) |
| 速度 | 相对慢,受CPU执行速度和软件延时影响 | 快,效率高,不占CPU(可用DMA/中断) |
| 时序控制 | 完全由程序员掌控,时序清晰可调 | 由硬件电路自动完成,时序固定 |
| 稳定性 | 稳定可靠,可移植性强 | STM32F103的硬件I2C有设计缺陷(BUSY卡死等知名bug),常见死锁问题 |
| 使用场景 | 调试方便、多路复用、低速场合 | 高速通信、对CPU占用敏感的场合 |
正因为 STM32F103 硬件 I2C 的历史遗留问题,实际工程中很多开发者宁愿用软件模拟 I2C,本实验也推荐使用软件I2C(野火例程中 AHT20 用的也是模拟时序)。
4.2 AHT20 数据手册要点
阅读 AHT20 数据手册,几个关键点:
- I2C 地址:AHT20 的 7 位 I2C 从机地址固定为
0x38(SA0 接低电平),写地址为0x70,读地址为0x71; - 上电校准:上电后需要发送初始化命令
0xBE(0xAC, 0xBE, 0x00, 0x00)使能校准,并通过读取状态字的 Busy 位(bit7)判断是否就绪; - 触发测量:发送
0xAC 0x33 0x00命令启动一次温湿度测量,测量约需 80ms,期间状态字 Busy 位为 1; - 数据格式:测量完成后返回 7 个字节(状态 + 20bit湿度 + 20bit温度),按下式换算:
湿度 R H = S R H 2 20 × 100 % 湿度RH = \frac{S_{RH}}{2^{20}} \times 100\% 湿度RH=220SRH×100%
温度 T ( ℃ ) = S T 2 20 × 200 − 50 温度T(℃) = \frac{S_T}{2^{20}} \times 200 - 50 温度T(℃)=220ST×200−50
- 常用命令汇总:
| 命令 | 字节序列 | 含义 |
|---|---|---|
| 软复位 | 0xBA | 复位传感器 |
| 初始化 | 0xBE 0x08 0x00 | 使能校准 |
| 触发测量 | 0xAC 0x33 0x00 | 启动一次测量 |
4.3 任务1实现:每2秒采集一次温湿度
思路:裸机 + 软件模拟 I2C,主循环中每 2 秒触发一次 AHT20 测量,读取数据换算后经串口发送到上位机(win10 串口助手)。
AHT20 驱动的核心代码(模拟 I2C 时序部分野火例程已提供,此处给出应用层逻辑):
#include "stm32f10x.h"
#include "usart.h"
#include "AHT20-CHIP_GET.h" /* 野火提供的AHT20驱动 */
int main(void)
{
uint32_t CT_data[2] = {0};
float hum = 0, tem = 0;
USART_Config(); /* 串口初始化:115200-8-N-1 */
I2C_GPIO_Init(); /* 软件I2C引脚初始化 */
Delay_ms(200); /* AHT20上电稳定 */
AHT20_Init(); /* 发送0xBE初始化命令,使能校准 */
while (1)
{
/* 触发测量并读取温湿度原始数据 */
AHT20_Read_CTdata(CT_data);
hum = CT_data[0] * 100.0 / 1024 / 1024; /* 湿度:20bit / 2^20 * 100% */
tem = CT_data[1] * 200.0 / 1024 / 1024 - 50; /* 温度:20bit / 2^20 * 200 - 50 */
printf("当前湿度: %.2f%%\r\n", hum);
printf("当前温度: %.2f℃\r\n", tem);
printf("--------------------------------------------------\r\n");
Delay_ms(2000); /* 每2秒采集一次 */
}
}
程序与模块初始阶段的问答(对应题目要求4.3-(4)):
- 模块的IIC地址是多少? AHT20 模块的 7 位 IIC 地址是 0x38,即程序中发送的写地址字节为
0x70(0x38<<1 | W)、读地址字节为0x71(0x38<<1 | R); - 程序发送了什么命令? 初始阶段先发送状态读取命令
0x71检查校准使能位,然后发送初始化命令0xBE(完整序列 0xAC 0xBE 0x00 0x00) 使能校准;每次采集时发送触发测量命令0xAC 0x33 0x00,等待约 80ms 后再发送0x71读取 7 字节测量数据。
4.4 任务2完成:基于 uC/OS 多任务的时间片调度
题目要求:在任务一中已经完成每 2 秒采集一次温湿度;现在要在每一个任务中都完成每 2 秒采集一次温湿度,串口发送到上位机,观察任务的时间片调度效果。
实现思路:把 uC/OS 任务一工程里的 LED 任务替换/改造为 AHT20 采集任务,创建多个(例如 2~3 个)相同优先级或不同优先级的采集任务,每个任务内部都是"采集 → printf 输出(带任务编号)→ 延时2s"。
/* AHT20采集任务:每2秒采集一次并发送 */
static void Task_AHT20(void *p_arg)
{
OS_ERR err;
uint32_t CT_data[2] = {0};
CPU_INT32U id = (CPU_INT32U)p_arg; /* 用参数区分任务编号 */
float hum, tem;
for (;;)
{
AHT20_Read_CTdata(CT_data);
hum = CT_data[0] * 100.0f / 1024.0f / 1024.0f;
tem = CT_data[1] * 200.0f / 1024.0f / 1024.0f - 50.0f;
printf("[Task %d] 湿度: %.2f%% 温度: %.2f℃\r\n",
(int)id, hum, tem);
OSTimeDlyHMSM(0u, 0u, 2u, 0u,
OS_OPT_TIME_HMSM_STRICT, &err);
}
}
/* 创建两个采集任务示例 */
OSTaskCreate(&TaskA20_1TCB, "AHT20 Task1", (OS_TASK_PTR)Task_AHT20,
(void *)1u, TASK_A20_PRIO,
&TaskA20_1Stk[0], TASK_STK/10, TASK_STK,
0u, 0u, 0, OS_OPT_TASK_STK_CHK|OS_OPT_TASK_STK_CLR, &err);
OSTaskCreate(&TaskA20_2TCB, "AHT20 Task2", (OS_TASK_PTR)Task_AHT20,
(void *)2u, TASK_A20_PRIO, /* 相同优先级 → 时间片轮转 */
&TaskA20_2Stk[0], TASK_STK/10, TASK_STK,
0u, 0u, 0, OS_OPT_TASK_STK_CHK|OS_OPT_TASK_STK_CLR, &err);
注意点:
- 软件I2C是共享资源!两个任务同时操作同一路 I2C 总线会发生时序错乱,需要用互斥量(Mutex)保护 AHT20 的读写过程:
OS_MUTEX I2C_Mutex;
/* 启动任务中创建互斥量 */
OSMutexCreate((OS_MUTEX *)&I2C_Mutex,
(CPU_CHAR *)"I2C Mutex",
(OS_ERR *)&err);
/* 采集任务中加锁使用 */
OSMutexPend((OS_MUTEX *)&I2C_Mutex,
(OS_TICK )0,
(OS_OPT )OS_OPT_PEND_BLOCKING,
(CPU_TS *)0,
(OS_ERR *)&err);
AHT20_Read_CTdata(CT_data); /* 临界区:独占总线 */
OSMutexPost((OS_MUTEX *)&I2C_Mutex, (OS_OPT)OS_OPT_POST_NONE, (OS_ERR *)&err);
- 两个相同优先级的任务在 uC/OS-III 中通过
OSSchedRoundRobinCfg()开启时间片轮转后,会交替获得 CPU,串口输出会呈现两个任务交替打印的效果,这正是"时间片调度"的直观体现; - printf 同样建议加互斥量,避免两个任务的输出交错出现乱码。
4.5 逻辑分析仪抓取 IIC 时序分析
使用示波器(或 Keil 的逻辑分析仪,观察 GPIO 模拟的 SCL/SDA 引脚电平变化)抓取一次完整的 I2C 通信波形,对照 I2C 协议逐段分析。
下图是分析得到的 AHT20 一次 I2C 写事务的完整时序(起始信号 → 7位从机地址 0x38 + 写位 → 从机ACK → 触发测量命令 0xAC → 从机ACK → 停止信号):

从波形上可以读出几个典型特征:
- 起始信号:SCL 为高电平期间,SDA 由高变低;
- 地址与命令传输:7 位从机地址
0x38左移一位补写位 0 组成地址字节0x70,随后发送命令字节0xAC,每字节传输完毕后由 AHT20 在第 9 个时钟拉低 SDA 给出应答 ACK; - I2C 是半双工通信,SCL 高电平期间 SDA 必须保持稳定,只有起始/停止信号例外(SCL 高时允许 SDA 跳变);
- 数据位在 SCL 低电平期间翻转,在 SCL 高电平期间被采样;
- 停止信号:SCL 高电平期间 SDA 由低变高。
一次完整的温湿度读取过程在上述写事务之后还包括:等待测量完成(约80ms)→ 重发起始信号 + 读地址 0x71 → 连续读取 7 字节数据(状态字节最高位为 Busy 标志)→ 主机回非应答 NACK → 停止信号。
4.6 实验结果
任务二完整实验过程演示视频(AHT20 温湿度采集,串口实时输出到 win10 上位机):
温度
五、踩坑记录与总结
踩坑记录
- SysTick 冲突:裸机 delay 函数占用了 SysTick,移植 uC/OS 后心跳丢失,程序卡死。解决:统一由 uC/OS 管理 SysTick,应用延时改用
OSTimeDlyHMSM(); - AHT20 读到 0:忘记先发送
0xBE初始化使能校准,或测量后没等 80ms 就读数据(Busy 位还是 1),导致读出全 0。解决:严格按照手册时序"初始化→触发测量→等待→读取"; - 多任务下 I2C 总线错乱:两个采集任务同时操作软件 I2C,SDA 时序被打断。解决:用 OS_MUTEX 保护总线操作;
- AC6 编译 os_cpu_a.asm 报错:部分汇编语法在 AC6 下不兼容,可在 Options → Target 中切换为 AC5,或使用适配 AC6 的移植包。
总结
- 通过本实验掌握了 uC/OS-III 在 STM32F103 上的完整移植流程,理解了任务、优先级、时间片轮转和内核裁剪等 RTOS 核心概念;
- 理解了软件 I2C 与硬件 I2C 的区别与取舍,掌握了 I2C 协议的起始/停止/应答等底层时序;
- 学会了阅读 AHT20 数据手册,按照手册命令流程完成温湿度采集与数据换算;
- 通过逻辑分析仪把抽象的协议时序变成了看得见的波形,对嵌入式通信协议的调试能力有了实实在在的提升。
参考链接
- STM32F103C8T6移植uC/OS-III基于HAL库超完整详细过程
- uC/OS 移植相关(参考)
- uC/OS 移植踩坑总结(踩坑较多,推荐阅读)
- AHT20 数据手册(Aosong 奥松电子官网)
- 野火 STM32 配套教材《STM32库开发实战指南》第 23 章 “I2C–读写EEPROM”
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)