前言

本文记录嵌入式系统实验的两个综合任务: 任务一 —— 把 uC/OS-II 移植到 STM32F103C8 上,构建 3 个不同周期的任务; 任务二 —— 学习 I2C 总线协议,用 AHT20 传感器采集温湿度并通过串口上报,最后把采集任务"塞进"任务一的多任务框架里。

全文基于真实工程(Keil MDK5 + STM32CubeMX + STM32F103C8T6 最小系统板 + ST-Link V2),所有代码均可直接编译通过。

一、实验环境与硬件准备

1.1 软硬件清单

类别 型号 / 版本
主控 STM32F103C8T6 最小系统板(“蓝板”/Blue Pill,64 KB Flash / 20 KB RAM)
调试器 ST-Link V2(SWD 接口)
传感器 AHT20 / DHT20 温湿度模块(I2C 接口)
通信 USB 转 TTL 模块(CH340 / CP2102)
开发环境 Keil MDK 5.38 + ARMCC V5.06 update 7
配置工具 STM32CubeMX 6.18.1 + STM32Cube FW_F1 V1.8.7
RTOS uC/OS-II V2.93.01(Micrium / Silicon Labs,Apache-2.0)
上位机 Win11 + 串口助手(XCOM)

1.2 硬件接线总表

整个实验只用到 4 个外设引脚组,接线如下:

功能 STM32 引脚 说明
LED1 PA0 低电平点亮,串 330 Ω 到 3.3 V
LED2 PA1 低电平点亮,串 330 Ω 到 3.3 V
串口 TX PB6 USART1 重映射
串口 RX PB7 USART1 重映射
I2C SCL PB8 I2C1 重映射
I2C SDA PB9 I2C1 重映射
SWD PA13 / PA14 ST-Link 下载调试

下面是实物接线图
在这里插入图片描述

1.3 三条供电铁律

只用一个电源:板子由 ST-Link 的 3.3 V 供电,USB-TTL 的 VCC 不要接,否则两路电源互相反灌。
必须共地:ST-Link、USB-TTL、AHT20 模块的 GND 必须全部连到一起,否则串口一定收不到数据。
AHT20 用 3.3 V,不要接 5 V。

二、任务一:移植 uC/OS-II 并构建三个任务

2.1 先搞懂:什么是 RTOS

RTOS(Real-Time Operating System,实时操作系统) 的核心不是"快",而是"确定性"——系统对外部事件的响应时间有可预测的上界。

它和我们熟悉的裸机循环最大的区别在于任务调度模型:

对比项 裸机前后台(while(1) 轮询) RTOS 多任务
程序结构 一个超级循环,用状态机拆 多个独立任务,每个任务写得像"独占 CPU"
实时性 一个函数卡住,全系统卡住 高优先级任务可随时抢占 CPU
延时代码 HAL_Delay() 死等,CPU 空转 OSTimeDly() 主动让出 CPU,立刻切走
扩展性 加功能要改主循环 加功能只需新建任务

RTOS 的内核本质就是一个"调度器",它负责在合适的时候保存当前任务的寄存器现场、恢复下一个任务的现场。这个动作叫上下文切换(Context Switch)。

我们常听到的 RTOS 有:uC/OS-II / uC/OS-III、FreeRTOS、RT-Thread、ThreadX、Zephyr 等。其中 uC/OS-II 代码量小(约 6000 行)、注释详尽、教科书式的源码结构,是学习 RTOS 原理最合适的样本——这也是本次实验选它的原因。

2.2 uC/OS-II 是什么,为什么要移植

uC/OS-II 是 Micrium 公司(现属 Silicon Labs)推出的抢占式、可裁剪、基于优先级的实时内核,通过了 DO-178B 和 IEC 61508 SIL3 认证,工业界应用广泛。
所谓-移植(Porting),就是针对目标 CPU 重新实现 Ports/ 目录下的这 3 个文件,让内核能在该 CPU 上跑起来。

2.3 详细操作步骤

步骤 1:用 CubeMX 建一个干净的裸机工程
新建工程 → 选 STM32F103C8Tx → 按下表配置:

配置项 设置内容
RCC HSE = Disable;时钟树使用 HSI:PLL Source = HSI/2,PLLMul = ×16 → SYSCLK = 64 MHz;AHB /1,APB1 /2,APB2 /1
SYSDebug Serial Wire(只留 SWD,省出 PA15/PB3/PB4)
GPIO PA0 → GPIO_Output,Label = LED1;PA1 → GPIO_Output,Label = LED2
USART1 Mode = Asynchronous,115200,8-N-1
NVIC Priority Group = 4 bits(NVIC_PRIORITYGROUP_4)
Project Toolchain = MDK-ARM V5,Min Heap = 0x200,Min Stack = 0x400

任务一用不到 I2C,可以只配 GPIO + USART1。等做任务二时再加 I2C1。

步骤 2:放入 uC/OS-II 源码
在工程根目录(与 Core、Drivers 同级)新建 uC-OS2,拷入 Source/ 和 Ports/ 两部分。

Ports/ 下需要自己实现的 4 个文件

步骤 3:Keil 工程配置
Project → Options for Target:
(1) C/C++ → Define 里加:

USE_HAL_DRIVER,STM32F103xB,__TARGET_FPU_SOFTVFP

(2) C/C++ → Include Paths 加两条:

../uC-OS2/Source
../uC-OS2/Ports

(3) 新建分组 uC-OS2,只加这 4 个文件:

文件类型 路径
C Source …/uC-OS2/Source/ucos_ii.c
C Source …/uC-OS2/Ports/os_cpu_c.c
Assembler Source …/uC-OS2/Ports/os_cpu_a.asm
Assembler Source …/uC-OS2/Ports/os_vectors.asm

步骤 4:配置内核 os_cfg.h
只需要改这几处,其余保持 0u

/* App/os_cfg.h */
#define OS_MAX_TASKS            5u      /* 最多 5 个任务:本实验 3 个 + 空闲 + 余量   */
#define OS_LOWEST_PRIO          9u      /* 优先级 0~9,数字越小优先级越高             */
#define OS_TICKS_PER_SEC        1000u   /* 每秒 1000 个节拍 → 1 节拍 = 1 ms           */
#define OS_TASK_STAT_EN         0u      /* 关掉统计任务,省 512 字节 RAM              */
#define OS_TASK_IDLE_STK_SIZE   128u    /* 空闲任务栈                                 */

/* 本实验用不到 IPC,全部关掉省 Flash */
#define OS_SEM_EN     0u
#define OS_MUTEX_EN   0u
#define OS_Q_EN       0u
#define OS_MBOX_EN    0u
#define OS_FLAG_EN    0u
#define OS_MEM_EN     0u
#define OS_TMR_EN     0u

步骤 5:SysTick

(A) 让 HAL 和 uC/OS 共用一个 SysTick
STM32F1 只有一个 SysTick,HAL 用它做 HAL_Delay(),uC/OS 用它做心跳。做法是在原来的中断里追加一行:

/* Core/Src/stm32f1xx_it.c */
#include "ucos_ii.h"        /* 记得在 USER CODE BEGIN Includes 里加 */

void SysTick_Handler(void)
{
  /* USER CODE BEGIN SysTick_IRQn 0 */
  /* USER CODE END SysTick_IRQn 0 */
  HAL_IncTick();                 /* 保留:HAL 的毫秒计数,HAL_Delay 还靠它 */
  /* USER CODE BEGIN SysTick_IRQn 1 */
  OS_CPU_SysTickHandler();       /* 【新增】uC/OS-II 的节拍 */
  /* USER CODE END SysTick_IRQn 1 */
}

然后在第一个任务里(不能放 main())初始化 SysTick 频率:

OS_CPU_SysTickInitFreq(SystemCoreClock);   /* 64 MHz / 1000 = 64000 → 1 ms 一个节拍 */

步骤 6:写三个任务(App/app.c)
(1) App/os_cfg.h(关键片段)

/* 任务与优先级 */
#define OS_LOWEST_PRIO            9u
#define OS_MAX_TASKS              5u
#define OS_TICKS_PER_SEC          1000u

/* 任务栈尺寸(单位:OS_STK 个字) */
#define OS_TASK_IDLE_STK_SIZE   128u
#define OS_TASK_STAT_STK_SIZE   128u
#define OS_TASK_STAT_EN           0u

/* 钩子函数:放在 os_cpu_c.c 里 */
#define OS_CPU_HOOKS_EN           1u
#define OS_DEBUG_EN               0u
#define OS_ARG_CHK_EN             1u

(2) App/app_cfg.h

#ifndef  _APP_CFG_H_
#define  _APP_CFG_H_

/* Cortex-M 移植层需要的内核配置 */
#define  CPU_CFG_NVIC_PRIO_BITS             4u    /* STM32F1 的 NVIC 优先级位数 */
#define  CPU_CFG_KA_IPL_BOUNDARY            4u    /* 优先级 >= 4 的中断为"内核感知" */

/* 三个任务的优先级(数字越小越高) */
#define  APP_PRIO_LED1            4u
#define  APP_PRIO_LED2            5u
#define  APP_PRIO_MSG             6u

/* 任务栈(单位:字;64 字 = 256 字节) */
#define  APP_STK_LED             64u
#define  APP_STK_MSG            128u

/* 任务三的形态:1 = 采集 AHT20 温湿度(任务二);0 = 发送 hello(任务一) */
#define  APP_TASK3_AHT20          0u

#endif
  1. App/app.h
#ifndef __APP_H
#define __APP_H

#include "main.h"
#include "ucos_ii.h"

void App_Start(void);                  /* 初始化 uC/OS-II、创建 3 个任务、启动调度(不返回) */
void App_UartSend(const char *str);    /* 通过 USART1 发送字符串 */

#endif

(4) App/app.c —— 三个任务

/**
  * 三个任务:
  *   Task_Led1  优先级 4:每 1 秒翻转 LED1(PA0,低电平点亮)
  *   Task_Led2  优先级 5:每 3 秒翻转 LED2(PA1)
  *   Task_Msg   优先级 6:每 2 秒发送一句 hello(或采集 AHT20)
  */
#include "app.h"
#include "aht20.h"
#include <string.h>

/* ---------------------------- 任务栈 ---------------------------- */
static OS_STK Led1Stk[APP_STK_LED];
static OS_STK Led2Stk[APP_STK_LED];
static OS_STK MsgStk[APP_STK_MSG];

/**
  * @brief 串口发送(轮询阻塞;本任务优先级最低,不会挡住 LED 任务)
  */
void App_UartSend(const char *str)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)str, (uint16_t)strlen(str), 100U);
}

#if APP_TASK3_AHT20 > 0u
/**
  * @brief 把浮点数格式化为十进制字符串,不依赖 printf 的浮点支持
  * @note  先整体四舍五入到 dec 位小数,再拆分整数/小数部分,
  *        这样 25.96 会正确进位成 "26.0",而不会被截断成 "25.0"
  */
static char *App_FmtFloat(char *buf, float val, uint8_t dec)
{
    static const int32_t pow10[4] = {1, 10, 100, 1000};
    int32_t scale, scaled, ip, fp;
    int8_t  i, j;
    char   *p = buf;
    char    tmp[12];
    uint8_t neg;

    scale  = pow10[dec];
    neg    = (val < 0.0f) ? 1U : 0U;
    if (neg != 0U) { val = -val; }

    scaled = (int32_t)(val * (float)scale + 0.5f);   /* 放大后四舍五入 */
    if (scaled == 0) { neg = 0U; }                   /* 不输出 "-0.0" */

    if (neg != 0U) { *p++ = '-'; }
    fp = scaled % scale;
    ip = scaled / scale;

    i = 0;
    if (ip == 0) { tmp[i++] = '0'; }
    while (ip > 0) { tmp[i++] = (char)('0' + (ip % 10)); ip /= 10; }
    while (i > 0) { *p++ = tmp[--i]; }

    if (dec > 0U)
    {
        int32_t d = scale;
        *p++ = '.';
        for (j = (int8_t)dec - 1; j >= 0; j--)
        {
            d /= 10;
            *p++ = (char)('0' + (fp / d) % 10);
        }
    }
    *p = '\0';
    return buf;
}
#endif

/* ---------------------------- 任务 1:LED1 每 1 秒 ---------------------------- */
static void Task_Led1(void *p_arg)
{
    (void)p_arg;

    /* 移植层规定:OS 节拍定时器必须在 OSStart() 之后初始化,且只做一次 */
    OS_CPU_SysTickInitFreq(SystemCoreClock);

    for (;;)
    {
        HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
        OSTimeDly(OS_TICKS_PER_SEC);              /* 1000 节拍 = 1 秒 */
    }
}

/* ---------------------------- 任务 2:LED2 每 3 秒 ---------------------------- */
static void Task_Led2(void *p_arg)
{
    (void)p_arg;

    for (;;)
    {
        HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin);
        OSTimeDly(3 * OS_TICKS_PER_SEC);          /* 3000 节拍 = 3 秒 */
    }
}

/* ---------------------------- 任务 3 ---------------------------- */
#if APP_TASK3_AHT20 > 0u
/* 【任务二】每 2 秒采集一次 AHT20 温湿度并通过串口上报 */
static void Task_Msg(void *p_arg)
{
    uint8_t ok;
    float   t, h;
    char    msg[64];
    char    num[16];

    (void)p_arg;

    ok = (AHT20_Init() == 0U) ? 1U : 0U;          /* 探测传感器是否在线 */
    App_UartSend("hello uc/OS! 欢迎来到RTOS多任务环境!\r\n");

    for (;;)
    {
        if (ok != 0U)
        {
            if (AHT20_Read(&t, &h) == 0U)
            {
                strcpy(msg, "T=");
                strcat(msg, App_FmtFloat(num, t, 1));
                strcat(msg, "C  H=");
                strcat(msg, App_FmtFloat(num, h, 1));
                strcat(msg, "%\r\n");
                App_UartSend(msg);
            }
            else
            {
                /* 读取失败:复位 I2C 外设,防止 F1 的 BUSY 卡死,下周期重试 */
                (void)HAL_I2C_DeInit(&AHT20_I2C);
                (void)HAL_I2C_Init(&AHT20_I2C);
                ok = 0U;
                App_UartSend("AHT20 offline\r\n");
            }
        }
        else
        {
            App_UartSend("AHT20 not found\r\n");
            ok = (AHT20_Init() == 0U) ? 1U : 0U;
        }
        OSTimeDly(2 * OS_TICKS_PER_SEC);           /* 2000 节拍 = 2 秒 */
    }
}
#else
/* 【任务一】每 2 秒发送一句 hello */
static void Task_Msg(void *p_arg)
{
    (void)p_arg;

    for (;;)
    {
        App_UartSend("hello uc/OS! 欢迎来到RTOS多任务环境!\r\n");
        OSTimeDly(2 * OS_TICKS_PER_SEC);           /* 2000 节拍 = 2 秒 */
    }
}
#endif

/* ---------------------------- 启动 uC/OS-II ---------------------------- */
void App_Start(void)
{
    INT8U err;

    OSInit();                                                   /* 1. 初始化内核 */

    /* 2. 创建任务:栈顶 = &Stk[SIZE-1](Cortex-M 栈向下生长) */
    err = OSTaskCreate(Task_Led1, (void *)0, &Led1Stk[APP_STK_LED - 1U], APP_PRIO_LED1);
    if (err == OS_ERR_NONE)
    {
        err = OSTaskCreate(Task_Led2, (void *)0, &Led2Stk[APP_STK_LED - 1U], APP_PRIO_LED2);
    }
    if (err == OS_ERR_NONE)
    {
        err = OSTaskCreate(Task_Msg, (void *)0, &MsgStk[APP_STK_MSG - 1U], APP_PRIO_MSG);
    }
    if (err != OS_ERR_NONE)
    {
        Error_Handler();                                        /* 任务创建失败 */
    }

    OSStart();                                                  /* 3. 启动调度,永不返回 */
}

(5) Core/Src/main.c(修改处)

/* USER CODE BEGIN Includes */
#include "app.h"
/* USER CODE END Includes */

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_USART1_UART_Init();

  /* USER CODE BEGIN 2 */
  App_Start();      /* 初始化 OS、创建任务、启动调度(不返回) */
  /* USER CODE END 2 */

  while (1) { }     /* 永远走不到这里 */
}

2.4 实验现象

时刻 LED1 (PA0, 1 s) LED2 (PA1, 3 s) 串口 (2 s)
0 s — — hello uc/OS! 欢迎来到RTOS多任务环境!
1 s 翻转 — —
2 s 翻转 — hello uc/OS! 欢迎来到RTOS多任务环境!
3 s 翻转 翻转 —
4 s 翻转 — hello uc/OS! 欢迎来到RTOS多任务环境!
6 s 翻转 翻转 hello uc/OS! 欢迎来到RTOS多任务环境!

在这里插入图片描述

1s和3s周期对LED等进行点亮-熄灭的控制

三、任务二:I2C 协议与 AHT20 温湿度采集

3.1 I2C 总线协议入门

I2C(Inter-Integrated Circuit) 是 Philips(现 NXP)在 1982 年提出的一种两线制、半双工、同步、多主多从串行总线

信号线 全称 作用
SCL Serial Clock 时钟线,由主机产生
SDA Serial Data 数据线,双向

两根线都必须接上拉电阻(典型 4.7 kΩ)到 VCC,并且所有器件的引脚都是开漏输出
三个特殊电平状态:
起始条件 START:SCL 保持高电平时,SDA 由高变低
停止条件 STOP:SCL 保持高电平时,SDA 由低变高

  • 起始条件 S:SCL 为高时,SDA 由高 → 低
  • 停止条件 P:SCL 为高时,SDA 由低 → 高
  • 数据有效性:SCL 为高时 SDA 必须稳定(数据采样点);SCL 为低时 SDA 才允许变化。

3.2 软件 I2C 与硬件 I2C 的区别

一、硬件 I2C(Hardware I2C)
用 STM32 片上的 I2C 外设(I2C1 / I2C2),由硬件状态机自动产生时序。

/* 你只需要把数据丢进寄存器,剩下的起始/时钟/ACK 全由硬件完成 */
HAL_I2C_Master_Transmit(&hi2c1, 0x70, cmd, 3, 20);   /* 发 3 个字节 */
HAL_I2C_Master_Receive(&hi2c1, 0x71, buf, 7, 20);    /* 读 7 个字节 */

内部工作流程:

  • 置 CR1.START=1 → 硬件发起始条件
  • 等待 SR1.SB=1 → 起始位已发送
  • 写 DR → 硬件自动移位输出 8 位 + 收 ACK
  • 等待 SR1.TXE/BTF → 字节发送完成
  • 置 CR1.STOP=1 → 硬件发停止条件
    它的优点是cpu占用极低,速率高,抖动小且代码量小。

二、软件 I2C(Software / Bit-Banging I2C)
不用任何 I2C 外设,只用两个普通 GPIO:

/* 软件 I2C 的原子操作 */
#define SCL_H()   HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET)
#define SCL_L()   HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET)
#define SDA_H()   HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET)
#define SDA_L()   HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET)
#define SDA_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9)

/* 起始条件:SCL 高时 SDA 由高变低 */
static void I2C_Start(void)
{
    SDA_H(); delay_us(2);
    SCL_H(); delay_us(2);      /* 满足 t_SU;STA ≥ 4.7us */
    SDA_L(); delay_us(2);
    SCL_L(); delay_us(2);
}

/* 发送一个字节,返回从机 ACK(0 = 收到应答) */
static uint8_t I2C_SendByte(uint8_t byte)
{
    uint8_t i, ack;
    for (i = 0; i < 8; i++)
    {
        if (byte & 0x80) SDA_H(); else SDA_L();   /* 高位先行 */
        byte <<= 1;
        delay_us(2);
        SCL_H(); delay_us(2);                     /* 从机在这时采样 */
        SCL_L(); delay_us(2);
    }
    /* 第 9 个时钟:释放 SDA,读从机的应答 */
    SDA_H(); delay_us(2);
    SCL_H(); delay_us(2);
    ack = SDA_READ();                             /* 0 = ACK,1 = NACK */
    SCL_L(); delay_us(2);
    return ack;
}

它的优点是任意两个gpio都能用,不受外设引脚限制,比较稳定,时许可任意调整且便于示波器观察时序。
在本次实验中我们用到的是硬件I2C(HAL_I2C_* + I2C1 重映射到 PB8/PB9)。

3.3 硬件 I2C 实现

CubeMX 里勾选 I2C1 后,生成的代码默认在 PB6/PB7。要放到 PB8/PB9。
代码实现:
App/aht20.h

#ifndef __AHT20_H
#define __AHT20_H

#include "main.h"

#define AHT20_I2C_ADDR  0x70      /* 7 位地址 0x38 左移 1 位(写) */
#define AHT20_I2C       hi2c1     /* 使用 I2C1:PB8=SCL,PB9=SDA */

uint8_t AHT20_Init(void);
uint8_t AHT20_Read(float *temp, float *humi);

#endif

App/aht20.c:

#include "aht20.h"

/* ---------------------------- 内部工具函数 ---------------------------- */

/**
  * @brief  I2C 总线恢复
  *         STM32F1 的 I2C 外设存在 errata:从机在一次传输中途被中止时,
  *         可能一直拉低 SDA,导致硬件 BUSY 位永远清不掉。
  *         这里先用最多 9 个 SCL 时钟让它把数据移完并释放 SDA,
  *         再补一个 STOP 条件,最后重新初始化 I2C 外设。
  */
static void AHT20_I2cRecover(void)
{
    GPIO_InitTypeDef gpio = {0};
    uint8_t i;

    (void)HAL_I2C_DeInit(&AHT20_I2C);          /* 释放 I2C 外设和引脚 */
    __HAL_RCC_GPIOB_CLK_ENABLE();

    /* PB8 = SCL,配成开漏输出,默认拉高 */
    gpio.Pin   = GPIO_PIN_8;
    gpio.Mode  = GPIO_MODE_OUTPUT_OD;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOB, &gpio);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);

    /* PB9 = SDA,先当输入,监听从机是否还占着总线 */
    gpio.Pin  = GPIO_PIN_9;
    gpio.Mode = GPIO_MODE_INPUT;
    gpio.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(GPIOB, &gpio);

    /* 最多打 9 个时钟,直到 SDA 被从机释放为高电平 */
    for (i = 0U; i < 9U; i++)
    {
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET);
        HAL_Delay(1);
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);
        HAL_Delay(1);
        if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9) == GPIO_PIN_SET)
        {
            break;                              /* SDA 已释放 */
        }
    }

    /* 补一个 STOP 条件:SCL 高电平期间 SDA 由低变高 */
    gpio.Pin  = GPIO_PIN_9;
    gpio.Mode = GPIO_MODE_OUTPUT_OD;
    HAL_GPIO_Init(GPIOB, &gpio);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET);
    HAL_Delay(1);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);
    HAL_Delay(1);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET);
    HAL_Delay(1);

    /* 重新初始化 I2C 外设(MspInit 会把 PB8/PB9 配回复用开漏) */
    (void)HAL_I2C_Init(&AHT20_I2C);
}

static uint8_t AHT20_Write(const uint8_t *buf, uint16_t len)
{
    return (HAL_I2C_Master_Transmit(&AHT20_I2C, AHT20_I2C_ADDR,
                                    (uint8_t *)buf, len, 20) == HAL_OK) ? 0U : 1U;
}

static uint8_t AHT20_ReadBytes(uint8_t *buf, uint16_t len)
{
    /* 注意:HAL 内部会自己置/清最低位(读操作 RD_WRN=1),
       所以这里传 0x70 或 0x71 效果一样,工程里统一用 0x70 */
    return (HAL_I2C_Master_Receive(&AHT20_I2C, AHT20_I2C_ADDR,
                                   buf, len, 20) == HAL_OK) ? 0U : 1U;
}

/* ---------------------------- 对外接口 ---------------------------- */

/**
  * @brief  AHT20 初始化:检查校准状态,未校准则发送初始化命令
  * @retval 0 成功;1 失败(通常说明没有接模块)
  */
uint8_t AHT20_Init(void)
{
    static const uint8_t init_cmd[3] = {0xBE, 0x08, 0x00};   /* 初始化/校准命令 */
    uint8_t status = 0;
    uint8_t i;

    HAL_Delay(50);                              /* 上电后至少等 40 ms */
    if (AHT20_ReadBytes(&status, 1) != 0U)      /* 读状态字,无应答 = 没接模块 */
    {
        AHT20_I2cRecover();
        return 1;
    }
    if ((status & 0x08U) == 0U)                 /* bit3 = 0 表示尚未校准 */
    {
        if (AHT20_Write(init_cmd, 3) != 0U)
        {
            AHT20_I2cRecover();
            return 1;
        }
        for (i = 0U; i < 10U; i++)              /* 校准最多再等 100 ms */
        {
            HAL_Delay(10);
            if ((AHT20_ReadBytes(&status, 1) == 0U) && ((status & 0x08U) != 0U))
            {
                break;                          /* 校准完成 */
            }
        }
        if ((status & 0x08U) == 0U)
        {
            return 1;                           /* 校准超时 */
        }
    }
    return 0;
}

/**
  * @brief  启动一次测量并读取温湿度
  * @param  temp 温度(摄氏度,-40 ~ +85)
  * @param  humi 湿度(相对湿度 %,0 ~ 100)
  * @retval 0 成功;1 失败
  */
uint8_t AHT20_Read(float *temp, float *humi)
{
    static const uint8_t trig_cmd[3] = {0xAC, 0x33, 0x00};   /* 触发测量命令 */
    uint8_t  buf[7];
    uint8_t  i;
    uint8_t  done = 0;
    uint32_t h, t;

    if (AHT20_Write(trig_cmd, 3) != 0U)
    {
        AHT20_I2cRecover();
        return 1;
    }

    /* 轮询等待转换完成:一次转换约 75~80 ms,每 10 ms 读一次状态,
       bit7 = 0 表示数据就绪;最多等 20 次(200 ms)。
       注意:不能用死等固定 80 ms —— 刚好卡在边界上会时不时读到"忙"。 */
    for (i = 0U; i < 20U; i++)
    {
        HAL_Delay(10);
        if ((AHT20_ReadBytes(buf, 7) == 0U) && ((buf[0] & 0x80U) == 0U))
        {
            done = 1U;
            break;
        }
    }
    if (done == 0U)                             /* 超时仍忙或读数一直失败 */
    {
        AHT20_I2cRecover();
        return 1;
    }

    /* 湿度:20 位 */
    h = ((uint32_t)buf[1] << 12) | ((uint32_t)buf[2] << 4) | ((uint32_t)buf[3] >> 4);
    /* 温度:20 位 */
    t = (((uint32_t)(buf[3] & 0x0FU)) << 16) | ((uint32_t)buf[4] << 8) | (uint32_t)buf[5];

    *humi = (float)h * 100.0f / 1048576.0f;                 /* 2^20 = 1048576 */
    *temp = (float)t * 200.0f / 1048576.0f - 50.0f;
    return 0;
}

Core/Src/main.c:

/* 把浮点数格式化为字符串(不依赖 printf 的浮点支持,省 2 KB Flash) */
static char *FmtFloat(char *buf, float val, uint8_t dec)
{
    static const int32_t pow10[4] = {1, 10, 100, 1000};
    int32_t scale, scaled, ip, fp;
    int8_t  i, j;
    char   *p = buf;
    char    tmp[12];
    uint8_t neg;

    scale  = pow10[dec];
    neg    = (val < 0.0f) ? 1U : 0U;
    if (neg != 0U) { val = -val; }

    /* 先整体四舍五入再拆分,避免 25.96 被显示成 25.0 */
    scaled = (int32_t)(val * (float)scale + 0.5f);
    if (scaled == 0) { neg = 0U; }

    if (neg != 0U) { *p++ = '-'; }
    fp = scaled % scale;
    ip = scaled / scale;

    i = 0;
    if (ip == 0) { tmp[i++] = '0'; }
    while (ip > 0) { tmp[i++] = (char)('0' + (ip % 10)); ip /= 10; }
    while (i > 0) { *p++ = tmp[--i]; }

    if (dec > 0U)
    {
        int32_t d = scale;
        *p++ = '.';
        for (j = (int8_t)dec - 1; j >= 0; j--) { d /= 10; *p++ = (char)('0' + (fp / d) % 10); }
    }
    *p = '\0';
    return buf;
}

static void UartSend(const char *str)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)str, (uint16_t)strlen(str), 100U);
}

int main(void)
{
    char  msg[64], num[16];
    float t, h;
    uint8_t ok;
    uint32_t tick;

    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_I2C1_Init();
    MX_USART1_UART_Init();

    UartSend("AHT20 demo start\r\n");
    ok = (AHT20_Init() == 0U) ? 1U : 0U;
    if (ok == 0U) { UartSend("AHT20 NOT FOUND\r\n"); }

    tick = HAL_GetTick();

    while (1)
    {
        /* 每 2 秒采集一次 */
        if ((HAL_GetTick() - tick) >= 2000U)
        {
            tick = HAL_GetTick();

            if ((ok != 0U) && (AHT20_Read(&t, &h) == 0U))
            {
                strcpy(msg, "T=");
                strcat(msg, FmtFloat(num, t, 1));
                strcat(msg, "C  H=");
                strcat(msg, FmtFloat(num, h, 1));
                strcat(msg, "%\r\n");
                UartSend(msg);
            }
            else if (ok == 0U)
            {
                ok = (AHT20_Init() == 0U) ? 1U : 0U;   /* 定时重试探测 */
                UartSend("AHT20 not found, retrying\r\n");
            }
        }
    }
}

DHT20

四、总结

4.1 任务一:RTOS 移植的核心结论

1.uC/OS-II 的移植工作量集中在 Ports/ 目录的 3~4 个文件,内核 Source/ 一行都不用改——这就是"可移植性"的实际含义。
2.Cortex-M3 的双栈机制(MSP/PSP) 是移植的基础:任务跑在 PSP 上(每个任务一个栈),中断和内核跑在 MSP 上(OS_CPU_ExceptStk 独立 1 KB 栈),两者互不覆盖。
3.上下文切换挂接在 PendSV(优先级 0xFF 最低),保证切换只在所有中断处理完毕、寄存器状态确定时发生。
4.临界区使用 BASEPRI(优先级 4 为内核感知边界)而非关总中断,使优先级 0~3 的高实时中断不受内核临界区影响——这是 RTOS 实时性的关键设计。
5.三个任务以 1 s / 3 s / 2 s 独立周期运行且互不干扰,验证了抢占式优先级调度 + 时间延迟机制的正确性。

4.2 任务二:I2C 与 AHT20 的核心结论

1.I2C 是开漏 + 上拉的线与总线,只需要 SCL/SDA 两根线就能挂多个从机;START/STOP 是 SCL 高电平期间 SDA 的跳变,数据在 SCL 高电平时有效。
2.软件 I2C 与硬件 I2C 的本质区别:一个用 GPIO 手工"打拍子",一个用片上外设状态机。软件 I2C 灵活稳定但费 CPU,硬件 I2C 高效但 F1 上有硅 bug,需要额外做总线恢复。
3.AHT20 的 7 位地址是 0x38,HAL 库里传 8 位左移地址:写 0x70、读 0x71。
4.初始化命令 0xBE 0x08 0x00(校准),触发测量命令 0xAC 0x33 0x00;读回 7 字节 = 状态 + 5 字节数据 + CRC。
5.状态字节 bit7 = 忙标志、bit3 = 校准标志,必须轮询 bit7 而不能死等固定延时。
6.20 bit 原始数据 → 物理量的换算:湿度 raw/2^20100 %,温度 raw/2^20200 - 50 ℃。

参考资料

  • 《嵌入式实时操作系统 uC/OS-II(第2版)》—— Jean J. Labrosse
  • uC/OS-II V2.93.01 源码及 Ports/ARM-Cortex-M3/Generic 移植说明(Micrium / Silicon Labs,Apache-2.0)
  • 野火《STM32F103 开发实战指南》第 23 章「I2C–读写 EEPROM」
  • AHT20 数据手册(ASAIR,2019)
  • STM32F103xx 参考手册 RM0008 —— AFIO 重映射、I2C 章节
  • STM32F10xxx 勘误手册 ES096 —— I2C 外设 errata
  • ARM Cortex-M3 权威指南(Joseph Yiu)—— 异常模型、双栈、PendSV
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐