RTOS多任务与温度采集实验——基于STM32F103的uC/OS移植与AHT20温湿度采集

前言:本实验分为两个任务。任务一学习嵌入式实时操作系统(RTOS),以 uC/OS 为例,将其移植到 STM32F103 上,构建至少 3 个任务并观察多任务并发执行的效果;任务二学习 I2C 总线通信协议,使用 STM32F103 完成 AHT20 温湿度传感器的数据采集,并通过串口输出到上位机。文中所有实验成果截图在对应位置贴出。


目录


一、实验目的

  1. 学习嵌入式实时操作系统(RTOS)的基本概念,掌握 uC/OS 在 STM32F103 上的移植方法,理解多任务的调度机制;
  2. 学习 I2C 点对点通信协议的原理与时序,掌握"软件I2C"与"硬件I2C"的区别;
  3. 使用 STM32F103 完成基于 I2C 协议的 AHT20 温湿度传感器数据采集,并通过串口输出到上位机;
  4. 学会使用示波器/逻辑分析仪观察 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);
}

要点说明:

  1. 三个任务都写成了 for(;;) 死循环 + OSTimeDlyHMSM() 的形式。任务延时后让出 CPU,调度器去运行其他就绪任务,这就是多任务"并发"的本质;
  2. OSTimeDlyHMSM(0,0,1,500, ...) 表示延时 1 秒 500 毫秒,配合 LED_TOGGLE 翻转一次,正好构成 1s / 3s 的完整闪烁周期;
  3. 串口发送重定向了 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 数据手册,几个关键点:

  1. I2C 地址:AHT20 的 7 位 I2C 从机地址固定为 0x38(SA0 接低电平),写地址为 0x70,读地址为 0x71;
  2. 上电校准:上电后需要发送初始化命令 0xBE(0xAC, 0xBE, 0x00, 0x00)使能校准,并通过读取状态字的 Busy 位(bit7)判断是否就绪;
  3. 触发测量:发送 0xAC 0x33 0x00 命令启动一次温湿度测量,测量约需 80ms,期间状态字 Busy 位为 1;
  4. 数据格式:测量完成后返回 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

  1. 常用命令汇总:
命令 字节序列 含义
软复位 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);

注意点:

  1. 软件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);
  1. 两个相同优先级的任务在 uC/OS-III 中通过 OSSchedRoundRobinCfg() 开启时间片轮转后,会交替获得 CPU,串口输出会呈现两个任务交替打印的效果,这正是"时间片调度"的直观体现;
  2. 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 上位机):

温度


五、踩坑记录与总结

踩坑记录

  1. SysTick 冲突:裸机 delay 函数占用了 SysTick,移植 uC/OS 后心跳丢失,程序卡死。解决:统一由 uC/OS 管理 SysTick,应用延时改用 OSTimeDlyHMSM();
  2. AHT20 读到 0:忘记先发送 0xBE 初始化使能校准,或测量后没等 80ms 就读数据(Busy 位还是 1),导致读出全 0。解决:严格按照手册时序"初始化→触发测量→等待→读取";
  3. 多任务下 I2C 总线错乱:两个采集任务同时操作软件 I2C,SDA 时序被打断。解决:用 OS_MUTEX 保护总线操作;
  4. AC6 编译 os_cpu_a.asm 报错:部分汇编语法在 AC6 下不兼容,可在 Options → Target 中切换为 AC5,或使用适配 AC6 的移植包。

总结

  • 通过本实验掌握了 uC/OS-III 在 STM32F103 上的完整移植流程,理解了任务、优先级、时间片轮转和内核裁剪等 RTOS 核心概念;
  • 理解了软件 I2C 与硬件 I2C 的区别与取舍,掌握了 I2C 协议的起始/停止/应答等底层时序;
  • 学会了阅读 AHT20 数据手册,按照手册命令流程完成温湿度采集与数据换算;
  • 通过逻辑分析仪把抽象的协议时序变成了看得见的波形,对嵌入式通信协议的调试能力有了实实在在的提升。

参考链接

  1. STM32F103C8T6移植uC/OS-III基于HAL库超完整详细过程
  2. uC/OS 移植相关(参考)
  3. uC/OS 移植踩坑总结(踩坑较多,推荐阅读)
  4. AHT20 数据手册(Aosong 奥松电子官网)
  5. 野火 STM32 配套教材《STM32库开发实战指南》第 23 章 “I2C–读写EEPROM”
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐