0%

STM32——IIC通信,轮询模式读取温湿度传感器AHT20

从上一篇到这篇

上篇我们实现了蓝牙BLE通信:用 USART3 + DMA + 空闲中断接收手机发来的数据包,再解析 0xAA 包头 + 长度 + 校验和,控制了开发板上的 LED。

不管是串口、蓝牙还是 DMA,本质上都是把数据从 A 传到 B。但嵌入式世界里还有一种非常常见的场景:MCU 需要读取传感器的数据——温湿度、气压、加速度……这些传感器体积小、引脚少,不可能每个都占一个完整的串口。

于是就有了本期主角:IIC 通信,用它读出我房间的温度和湿度,再通过串口打印传输到电脑上。

顺带一提:AHT20 和 DHT20 是同一颗芯片(AHT20 是原厂名,DHT20 是兼容封装名),协议完全一样。


硬件原理

2.1 两根线的总线:SDA + SCL

串口用两根数据线做全双工通信,就像双向车道,两个方向可以同时走。而 IIC 只有一根数据线 SDA(Serial Data)+ 一根时钟线 SCL(Serial Clock):

串口 (USART) IIC (I2C)
数据线数量 2 根(TX/RX) 1 根(SDA)
时钟 双方各自时钟(异步) 主机提供(同步)
通信方向 全双工 半双工(同一时刻只能一个方向)
设备数量 点对点 总线(一主多从)

SDA 虽然允许双向通信,但同一时刻只能一个方向发送——就像单车道,来车和去车不能同时走,这就是半双工。

2.2 主从模式:一问一答

为了不让总线上的设备”打架”,IIC 采用主从模式:只有主机(STM32)能发起通信,从机(传感器)只能回答。

关键点:IIC 是总线协议,一条总线上可以挂很多从机,每个从机有唯一的设备地址。主机想跟谁说话,就在数据开头先发出它的地址,其他设备”选择性失聪”:

1
2
3
STM32 (主机) ──SDA──┬── AHT20 温湿度 (地址 0x70)
├── OLED 屏幕 (地址 0x7A)
└── ...

咦,AHT20 数据手册上写的地址明明是 0x38,这里为什么写 0x70?这个”地址之谜”我们到写代码时再解开(见 5.2 节)。

2.3 同步通信:为什么 IIC 要一根时钟线

串口是异步通信:双方各按自己的时钟速率(波特率)收发,这就要求双方晶振都足够精确。但很多小型传感器根本没有精确晶振,时钟一偏,双方就”不知所云”。

IIC 改用同步通信:主机通过 SCL 线发送固定频率的脉冲,作为总线上所有设备统一的时钟源。大家看同一个钟表走步,就永远不会乱。

2.4 一次完整的 IIC 通信(初学者简单了解即可)

IIC 数据线和时钟线都有上拉电阻,空闲时都是高电平。一次通信的过程:

1
2
3
4
5
SCL  ──┐┌──┐┌──┐┌──┐┌───┐┌──┐┌────
└┘ └┘ └┘ └┘ └┘ └┘
SDA ──┐ ┌─┐ ┌──┐ ┌─┐ ┌─
└──┘ └───┘ └───┘ └──────┘
↑开始信号 ↑数据位 ↑结束信号
  1. 起始信号:SCL 为高时,SDA 被拉低 → 所有从机竖起耳朵
  2. 传输数据:SCL 为低时主机设置 SDA 电平;SCL 为高时从机读取。每次发 8 位(1 字节)
  3. ACK 应答:每收完一字节,接收方在 SCL 低电平时把 SDA 拉低一下,表示”收到了”
  4. 结束信号:SCL 为高时,SDA 被拉高 → 通信结束

这些底层时序 HAL 库全部帮我们封装好了,我们只需调用 HAL_I2C_Master_Transmit / HAL_I2C_Master_Receive 两个函数即可,原理了解即可。


CubeMX 配置

3.1 打开 USART2 和 I2C1

新建工程后(本例工程名 IIc):

  1. 打开老朋友 USART2(模式 Asynchronous,波特率 115200)—— 用于串口打印温湿度
  2. 点击 I2C1,Mode 选择 I2C(标准模式),其他配置保持默认

image-20260801113633762

image-20260801113657526

I2C 的详细参数(时钟速度、寻址模式等)我们到初始化代码解析章节再讲,这里默认值即可。

3.2 勾选”为每个外设生成一对 .c/.h 文件”

这是本期新增的关键一步:Project Manager → Code Generator,勾选 Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral:

CubeMX-CodeGenerator设置

勾选后生成的代码结构会发生变化:以前全部写在 main.c 里的外设初始化,现在拆成了独立的文件:

1
2
3
4
5
6
7
8
9
Core/
├── Inc/
│ ├── gpio.h ← 新增
│ ├── i2c.h ← 新增(包含 hi2c1 句柄声明)
│ └── usart.h ← 新增
└── Src/
├── gpio.c ← 新增
├── i2c.c ← 新增(MX_I2C1_Init 在这里)
└── usart.c ← 新增

这样我们写 AHT20 驱动时,只要 #include "i2c.h" 就能拿到 hi2c1 这个外设操作句柄,不用把代码全堆在 main.c 里。

3.3 生成代码

按 Ctrl+S 保存并生成代码。

3.4 手动创建 AHT20 驱动文件(VS Code)

上一步 CubeMX 只会生成它自己管理的外设文件(gpio.c / i2c.c / usart.c),而 AHT20 是传感器,驱动代码需要我们自己写——所以接下来手动创建一对 .c/.h 文件:

  1. Core/Inc 文件夹上右键 → 新建文件 → 命名为 aht20.h(头文件,放函数声明)
  2. Core/Src 文件夹上右键 → 新建文件 → 命名为 aht20.c(源文件,放驱动实现)
1
2
3
4
5
6
7
8
9
10
11
Core/
├── Inc/
│ ├── gpio.h
│ ├── i2c.h
│ ├── usart.h
│ └── aht20.h ← 手动新建(我们自己的驱动)
└── Src/
├── gpio.c
├── i2c.c
├── usart.c
└── aht20.c ← 手动新建(我们自己的驱动)

视频教程中是在 CubeIDE 里右键新建 Header File / Source File,IDE 会贴心生成 #ifndef 防重复包含模板;VS Code 新建的是空文件,这个模板要自己写(4.1 节就会看到)。

⚠️ 注意(VS Code + CMake 工程特有):手动新建的 .c 文件不会自动参与编译,还要在 CMakeLists.txt 里登记一行,否则链接时找不到函数——这一步我踩了个大坑,详见「踩坑记录 坑 1」。


核心代码

4.1 aht20.h —— 头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef INC_AHT20_H
#define INC_AHT20_H

#include "i2c.h" // 引入 hi2c1 句柄声明和 HAL_I2C 系列函数

/**
* @brief 初始化 AHT20 传感器
* @note 主程序启动时调用一次:上电等待 40ms → 读状态字 → 检查校准位,
* 必要时发送初始化命令(详见 aht20.c 中的注释)
*/
void AHT20_Init(void);

/**
* @brief 读取温湿度数据
* @param Temrerature: 输出参数,函数内部填入温度值(单位 ℃)
* @param Humidity: 输出参数,函数内部填入湿度值(单位 %RH)
* @note 主循环里周期调用,每次调用完成一次完整的"发命令→等待→读数据"流程
*/
void AHT20_Read(float* Temrerature, float* Humidity);

#endif

#ifndef 防重复包含:防止头文件被多个文件 include 后出现重复定义(几乎所有 .h 文件都有)。

#include "i2c.h" 的层层递进:i2c.h → main.h → stm32f1xx_hal.h → stm32f1xx_hal_conf.h → 各种 HAL 头文件。所以 aht20.c 里 #include "aht20.h" 就能用 hi2c1 和 HAL_I2C 系列函数。

4.2 aht20.c —— 驱动实现(对照AHT20手册编写)

image-20260801120704276

image-20260801120727375

image-20260801120759240

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include "aht20.h"
#include "i2c.h"
#include "stm32f1xx_hal.h"
#include "stm32f1xx_hal_def.h"
#include "stm32f1xx_hal_i2c.h"
#include <stdint.h>

// ========== 设备地址 ==========
// AHT20 的 7 位从机地址是 0x38,但 I2C 协议规定主机发送的地址字节要左移 1 位,
// 空出的最低位作为读写标志位(R/W 位)。0x38 << 1 = 0x70,即"写地址"。
// 详见 5.2 节"0x70 还是 0x71?7 位地址与 8 位地址之谜"
#define AHT20_ADDRESS 0x70

/**
* @brief 初始化 AHT20 传感器
* @note 对照 AHT20 数据手册 5.4 节"传感器读取流程"编写:
* ① 上电后必须等待 40ms,传感器内部才能完成自检
* ② 读取 1 字节状态字
* ③ 检查校准位(bit3):为 1 表示校准完成,为 0 则需发送初始化命令
* @retval None
*/
void AHT20_Init(void)
{
uint8_t readBuffer; // 存放读取到的状态字

HAL_Delay(40); // ① 上电等待 40ms(手册硬性要求)

// ② 读取 1 字节状态字(轮询模式:阻塞直到接收完成)
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, &readBuffer, 1, HAL_MAX_DELAY);

// ③ 检查校准位:0x08 = 0b0000_1000,正好是 bit3 的掩码
if ((readBuffer & 0x08) == 0x00)
{
// ④ 校准位为 0,发送初始化命令 0xBE 0x08 0x00(手册规定)
uint8_t sendBuffer[3] = {0xBE, 0x08, 0x00};
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
}
}

/**
* @brief 读取温湿度数据
* @param Temrerature: 输出参数,温度值(单位 ℃)
* @param Humidity: 输出参数,湿度值(单位 %RH)
* @note 读取流程(对照手册 5.3/5.4 节):
* ① 发送测量命令 0xAC 0x33 0x00
* ② 等待 75ms(传感器测量需要时间,手册建议至少 75ms)
* ③ 读取 6 字节结果:Byte0=状态字,Byte1~5=原始数据
* ④ 拼接 20 位原始数据,用公式换算成实际温湿度
* @retval None
*/
void AHT20_Read(float *Temrerature, float *Humidity)
{
// ① 测量命令:0xAC(触发测量)+ 0x33 0x00(测量参数,手册规定)
uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00};
uint8_t readBuffer[6]; // ② 存放读回的 6 字节测量结果

// 发送测量命令(轮询模式:阻塞直到发送完成)
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);

// 等待传感器完成测量(手册建议至少 75ms)
HAL_Delay(75);

// 读取 6 字节测量结果(轮询模式:阻塞直到接收完成)
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, readBuffer, 6, HAL_MAX_DELAY);

// 检查状态字 bit7:0 = 测量完成,1 = 正在测量
if ((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;

// ===== 湿度原始数据拼接(20 位)=====
// 数据布局(手册 5.3 节):
// Byte1: Hum[19:12] | Byte2: Hum[11:4] | Byte3: Hum[3:0] + Tem[19:16]
// 所以湿度 = Byte1<<12 | Byte2<<4 | Byte3>>4
data = ((uint32_t)readBuffer[1] << 12)
| ((uint32_t)readBuffer[2] << 4)
| ((uint32_t)readBuffer[3] >> 4);
// 换算公式:湿度(%RH) = 原始值 / 2^20 × 100
*Humidity = data * 100.0f / (1 << 20);

// ===== 温度原始数据拼接(20 位)=====
// 温度 = (Byte3 & 0x0F)<<16 | Byte4<<8 | Byte5
data = (((uint32_t)readBuffer[3] & 0x0F) << 16)
| ((uint32_t)readBuffer[4] << 8)
| ((uint32_t)readBuffer[5]);
// 换算公式:温度(℃) = 原始值 / 2^20 × 200 - 50
*Temrerature = data * 200.0f / (1 << 20) - 50;
}
}

4.3 main.c —— 主程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* USER CODE BEGIN Includes */
#include "aht20.h" // AHT20 温湿度驱动(提供 AHT20_Init / AHT20_Read)
#include <stdio.h> // sprintf:把浮点数格式化进字符串
#include <string.h> // strlen:计算发送长度
/* USER CODE END Includes */

/* USER CODE BEGIN 2 */
AHT20_Init(); // 初始化 AHT20 传感器(上电后只需调用一次)
float Temrerature, Humidity; // 存放读取到的温度(℃)和湿度(%RH)
char message[50]; // 串口发送缓冲区,sprintf 格式化后的字符串放这里
/* USER CODE END 2 */

/* USER CODE BEGIN WHILE */
while (1)
{
// 读取一次温湿度,结果写入 Temrerature / Humidity 两个变量
AHT20_Read(&Temrerature, &Humidity);
// 格式化字符串:%.1f 保留 1 位小数,%% 转义成百分号,\r\n 回车换行
sprintf(message,"温度:%.1f℃,湿度:%.1f%%\r\n",Temrerature,Humidity);
// 通过串口2发送(轮询模式:阻塞直到发送完成)
HAL_UART_Transmit(&huart2, (uint8_t*)message, strlen(message), HAL_MAX_DELAY);
HAL_Delay(2000); // 每 2 秒刷新一次
/* USER CODE END WHILE */
}

代码讲解

5.1 AHT20_Init() —— 初始化流程

对照 AHT20 数据手册第 5.4 节「传感器读取流程」:

步骤 手册要求 代码实现
1 上电后等待 40ms 才能通信 HAL_Delay(40)
2 读取 1 字节状态信息 HAL_I2C_Master_Receive(..., 1, ...)
3 状态字第 3 位 = 1 表示正常 if((readBuffer & 0x08) == 0x00)
4 若第 3 位为 0,发送初始化命令 0xBE 0x08 0x00 HAL_I2C_Master_Transmit(...)

5.2 0x70 还是 0x71?7 位地址与 8 位地址之谜

写代码时我们会遇到一个困惑:原理部分说 AHT20 的地址是 0x70,但 AHT20 数据手册上写的却是 0x38(7 位:0111000),而我们发送读取命令时实际发出去的又是 0x71。三个数,到底谁是对的?

其实它们都对。IIC 通信每次发送一字节(8 位),但从机地址只有 7 位,所以协议规定:地址左移 1 位,空出的最低位作为 R/W 位——表示本次通信是写还是读:

1
2
3
4
7位地址: 0111000  (0x38)
↓ 左移1位
01110000 + 0 = 0x70 → 主机写(发送数据给从机)
01110000 + 1 = 0x71 → 主机读(从从机读数据)

所以 0x70 是”带写标志的地址”,0x71 是”带读标志的地址”,底层是同一个从机。

写代码时我们不用纠结:HAL_I2C_Master_Receive 内部会自动把 0x70 改成 0x71 使用,所以 aht20.c 里读状态、发命令统一写宏 AHT20_ADDRESS(0x70)就好——这也是 5.1 节初始化函数里传 0x70 的原因。

5.3 AHT20_Read() —— 触发测量并读取

步骤 手册要求 代码实现
1 发送测量命令 0xAC 0x33 0x00 HAL_I2C_Master_Transmit
2 等待测量完成 75ms HAL_Delay(75)
3 读取 6 字节数据(状态+温湿度) HAL_I2C_Master_Receive(..., 6, ...)
4 判断状态字第 7 位 = 0(数据有效) if((readBuffer[0] & 0x80) == 0x00)

5.4 HAL_I2C_Master_Receive / Transmit 参数详解

两个函数是本期的核心 API:

HAL_I2C_Master_Receive:

参数 含义 本项目中填的值
I2C 句柄指针 操作哪个 I2C 外设 &hi2c1
从机地址 要读取哪个从机(HAL 自动加 R/W 位) AHT20_ADDRESS (0x70)
数据缓冲区指针 数据存到哪里 &readBuffer / readBuffer
读取字节数 读多少个字节 1 / 6
超时时间 多久没成功就放弃 HAL_MAX_DELAY(永久等待)

HAL_I2C_Master_Transmit:

参数 含义 本项目中填的值
I2C 句柄指针 操作哪个 I2C 外设 &hi2c1
从机地址 发给哪个从机 AHT20_ADDRESS (0x70)
数据缓冲区指针 要发送的数据 sendBuffer
发送字节数 发多少个字节 3
超时时间 多久没成功就放弃 HAL_MAX_DELAY

与上篇 USART 对比:上篇蓝牙 BLE 中我们用的是 HAL_UART_Transmit(&huart3, ...) / HAL_UARTEx_ReceiveToIdle_DMA(...)——串口 API 不需要”设备地址”参数,因为串口是点对点的,一根线只连一个设备。而 I2C 每次通信都要带上从机地址,因为一条总线上可以挂多个传感器。另外 I2C 用 HAL_MAX_DELAY 做超时是阻塞式等待,上篇的 DMA+空闲中断则是非阻塞的——这个区别下期 I2C 中断/DMA 模式会专门讲。

5.5 20 位数据拼接(重点)

AHT20 返回的 6 字节中,温湿度各占 20 位(2.5 字节):

1
2
Byte0    Byte1      Byte2      Byte3      Byte4      Byte5
状态 湿度[19:12] 湿度[11:4] 湿度[3:0]|温度[19:16] 温度[15:8] 温度[7:0]

湿度拼接(20 位 = Byte1 高 12 位 + Byte2 高 8 位 + Byte3 高 4 位):

1
2
3
data = ((uint32_t)readBuffer[1] << 12)   // Byte1 移到 19~8 位
| ((uint32_t)readBuffer[2] << 4) // Byte2 移到 7~0 位
| ((uint32_t)readBuffer[3] >> 4); // Byte3 高4位移到 3~0 位

为什么强转 uint32_t? 因为 uint8_t 只有 8 位,readBuffer[1] << 12 会让 8 位数据直接”移出界”丢失!必须先转成 32 位再移位。

为什么用 | 不用 +? 位拼接是”按位组装”而不是数值相加——虽然位不重叠时两者结果相同,但 | 语义更准确,也避免未来位重叠时进位污染。**拼位用 |,算数用 +**。

与上篇 BLE 数据包对比:上篇我们解析蓝牙发来的数据包,用的是按字节拆分——拿到 receiveData 数组后,检查 [0]==0xAA(包头)、[1] 取长度、[2]~[n-1] 取数据段、最后一位校验和。那是因为协议设计者已经把数据边界对齐到了字节。而 AHT20 是传感器芯片,为了节省寄存器空间,温湿度各只占 20 位(2.5 字节),边界不跟字节对齐,没办法像上篇那样 data = receiveData[3] 直接取,必须用移位+拼接把分散在三个字节中的 20 位”挖”出来。这是底层驱动开发中非常常见的场景。

5.6 温湿度转换公式

对照手册给出的公式:

1
2
3
4
// 湿度:20位原始值 / 2^20 × 100%
*Humidity = data * 100.0f / (1 << 20);
// 温度:20位原始值 / 2^20 × 200 - 50
*Temrerature = data * 200.0f / (1 << 20) - 50;
  • 1 << 20 就是 2 的 20 次方(1048576),用移位比写死数字更清晰
  • 写 100.0f 而不是 100:强迫计算变成浮点运算,否则整数除法会丢掉小数部分

5.7 main.c 主程序讲解

1
sprintf(message,"温度:%.1f℃,湿度:%.1f%%\r\n",Temrerature,Humidity);
  • %.1f:保留一位小数的浮点占位符
  • %%:因为 % 本身是占位符标记,想输出真正的百分号要写两个
  • \r\n:回车换行
1
HAL_UART_Transmit(&huart2, (uint8_t*)message, strlen(message), HAL_MAX_DELAY);
  • (uint8_t*)message:把 char* 强转成 uint8_t*,因为 HAL 函数要求 uint8_t*,防止编译警告
  • strlen(message):字符串长度(需要 #include <string.h>)
  • HAL_Delay(2000):每 2 秒测量打印一次

与上篇 main.c 循环对比:上篇蓝牙用的是 DMA + 空闲中断——CPU 配置好 DMA 后就不管了,DMA 在后台自动收数据,收完一帧后触发空闲中断,在回调函数 HAL_UARTEx_RxEventCallback 里解析数据包。这是非阻塞+事件驱动的模式,CPU 全程空闲。而本篇的 while 循环里 AHT20_Read() 是阻塞式的——发完指令要死等 75ms 再读,HAL_Delay(2000) 也是阻塞延时。这是最简单的轮询模式。阻塞 vs 非阻塞各有适用场景:轮询简单直观、代码少,适合”定时读传感器”;DMA+中断释放 CPU,适合”同时干多件事”。后面我们也会看到 I2C 自己的中断/DMA 模式——届时就能把 AHT20 也升级为非阻塞驱动。


解读初始化代码

6.1 MX_I2C1_Init() —— I2C1 初始化

本期工程勾选了”为每个外设生成 .c/.h 文件”,所以 MX_I2C1_Init 不再出现在 main.c,而是移到了 i2c.c 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void MX_I2C1_Init(void)
{
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000;
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c1.Init.OwnAddress2 = 0;
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
if (HAL_I2C_Init(&hi2c1) != HAL_OK)
{
Error_Handler();
}
}

参数含义:

参数 含义 本项目填的值 对应 CubeMX 设置
ClockSpeed I2C 通信速率 100000(100kHz) Clock Speed: 100 kHz(标准模式)
DutyCycle 时钟占空比 I2C_DUTYCYCLE_2(2:1) 快速模式专用,标准模式下无效
OwnAddress1 本机地址(作为从机时用) 0 本机地址(主模式用不到)
AddressingMode 寻址模式 I2C_ADDRESSINGMODE_7BIT Addressing Mode: 7-bit
GeneralCallMode 广播呼叫支持 I2C_GENERALCALL_DISABLE General Call: Disable
NoStretchMode 时钟拉伸 I2C_NOSTRETCH_DISABLE No Stretch: Disable

注意:标准模式(100kHz)下最高只能传 100000,想用 400kHz 需要在 CubeMX 里把模式改成 Fast Mode。

6.2 HAL_I2C_MspInit() —— 引脚与时钟配置

MSP(MCU Specific Package)初始化负责引脚复用和时钟使能,是外设功能能否跑通的关键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void HAL_I2C_MspInit(I2C_HandleTypeDef* i2cHandle)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(i2cHandle->Instance==I2C1)
{
__HAL_RCC_GPIOB_CLK_ENABLE();
/**I2C1 GPIO Configuration
PB6 ------> I2C1_SCL
PB7 ------> I2C1_SDA
*/
GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7;
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

/* I2C1 clock enable */
__HAL_RCC_I2C1_CLK_ENABLE();
}
}

这里有两个全新的知识点,和 GPIO 输出、串口的配置都不一样:

① 引脚模式 GPIO_MODE_AF_OD(复用开漏):

模式 含义
GPIO_MODE_OUTPUT_PP 推挽输出(LED 用过)
GPIO_MODE_INPUT 输入(按键用过)
GPIO_MODE_AF_PP 复用推挽(串口 TX/RX 用过)
GPIO_MODE_AF_OD 复用开漏(I2C 专用,本期新增)

为什么 I2C 必须用开漏?回顾 2.4 节的时序图:IIC 总线靠上拉电阻工作,设备只能把 SDA/SCL 拉低,拉高靠上拉电阻完成。开漏输出正好就是这个特性——引脚只能输出低电平或”释放”(高阻态),由外部上拉电阻拉高。这样多个设备才能安全地共用一个引脚(任何一个设备都能把总线拉低)。

② 时钟使能顺序:先使能 GPIOB 时钟(引脚复用),再使能 I2C1 时钟(外设本身)。顺序反了会导致外设初始化失败。

6.3 配置对应关系表

CubeMX 设置 代码位置 底层寄存器
I2C1 Mode: I2C MX_I2C1_Init() + HAL_I2C_MspInit() CR1/CR2/CCR 寄存器
Clock Speed: 100kHz hi2c1.Init.ClockSpeed = 100000 CCR 寄存器(分频)
PB6/PB7 复用开漏 GPIO_MODE_AF_OD CRL 寄存器(复用功能)
I2C1 时钟使能 __HAL_RCC_I2C1_CLK_ENABLE() RCC_APB1ENR 寄存器

编译 & 烧录

老样子,这里不再展开。

如果使用 CubeIDE(视频中的方式):%f 浮点输出需要到 Project Properties → C/C++ Build → Settings → Tool Settings → MCU Settings 勾选 Use float with printf from newlib-nano;Eclipse 构建系统会自动扫描新建的 .c 文件,无需手动登记。


效果展示

连接好开发板,打开波特律动串口助手,选择对应 COM 口,就能看到每 2 秒刷新的温湿度:

https://www.bilibili.com/video/BV1tGGu6uEub/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa


踩坑记录

本期踩坑特别多,而且和视频中 CubeIDE 环境不同,在 VS Code + CMake 工程下遇到了一连串问题,全部记录如下:

坑 1:undefined reference to ‘AHT20_Init’(链接失败)

现象:编译报错 undefined reference to 'AHT20_Init' / 'AHT20_Read'。

原因:CubeMX 生成的 CMake 工程里,cmake/stm32cubemx/CMakeLists.txt 的源文件列表是显式列出的,只包含 CubeMX 自己生成的文件(main.c、i2c.c、usart.c…)。我们手动新建的 aht20.c 不在列表里,根本没被编译,链接时自然找不到函数。

解决:在顶层 CMakeLists.txt 的 target_sources 中手动添加(顶层文件 CubeMX 重新生成时不会覆盖):

1
2
3
4
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
# Add user sources here
Core/Src/aht20.c
)

如果用的是 CubeIDE,Eclipse 构建系统会自动扫描新建的 .c 文件,不会有这个问题——这是 CMake 系工程(VS Code/CLion)的通病。以后新建驱动文件,一定要记得在 CMakeLists.txt 里加一行!

坑 2:串口输出”温度:℃,湿度:%”(数值空白)

现象:烧录后串口正常输出文字,但 %f 的数值部分是空的。

原因:工程链接时用了 --specs=nano.specs(newlib-nano 精简 C 库,为了省 Flash),它默认不支持浮点格式化输出(%f),数值直接不打印。

先说清楚 newlib-nano 到底是个什么东西:

嵌入式 GCC 工具链(arm-none-eabi-gcc)自带的标准 C 库叫 newlib,它实现了 printf、malloc、memcpy 这些 C 标准库函数。但完整版 newlib 对 STM32 这种 Flash 只有几十 KB 的芯片来说太”胖”了,于是有了精简版 newlib-nano——用 --specs=nano.specs 这个链接参数切换过去。它裁剪掉了不少不常用的功能来省 Flash,printf 的浮点格式化输出(%f)就是被裁掉的功能之一。

打个比方:newlib 是”全家桶”,newlib-nano 是”轻量便携装”——你经常用的功能都在,但 %f 这种”用得少、又占地方”的功能被拿掉了。

那为什么不是报错,而是”数值空白”?这就涉及链接器的工作机制了:

  • newlib 的 printf 中,浮点格式化被单独编译成一个目标文件,导出的符号名就叫 _printf_float
  • 链接器默认只把被引用的目标文件链入最终固件(配合 --gc-sections 还会进一步裁剪掉没被引用的代码段)
  • 我在代码里写 sprintf(message, "温度:%.1f℃", T) 时,编译器认为 sprintf 存在(整数版 printf 能满足编译),链接器也没发现有人引用 _printf_float 这个符号,于是浮点版 printf 根本没被链进来
  • 结果就是:程序跑起来了,%f 却输出空——因为运行时根本没有处理浮点的代码

解决:链接参数加 -u _printf_float 强制拉入浮点支持。

-u 的含义是”强制把某个符号当作被引用处理”(undefined symbol)。-u _printf_float 就是骗过链接器:”有人引用了 _printf_float“,于是浮点版 printf 被链进固件,%f 恢复工作。

CubeIDE 里对应的勾选 **”Use float with printf from newlib-nano”**,本质就是加这个参数。

加到哪? 以本项目(VS Code + CMake)为例:

在顶层 CMakeLists.txt 末尾(和 target_sources 同级)加一行:

1
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE -u _printf_float)

位置如下(注意注释里 --specs=nano.specs 那行不用动,它来自工具链文件):

1
2
3
4
5
6
7
8
# 顶层 CMakeLists.txt

target_link_libraries(${CMAKE_PROJECT_NAME}
stm32cubemx
)

# 在上面这个 target_link_libraries 后面加这一行:
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE -u _printf_float)

两个参数的含义:

部分 含义
target_link_options CMake 命令:给指定的目标添加链接参数(目标级,只影响本目标)
${CMAKE_PROJECT_NAME} 目标名,本工程就是 IIc(set(CMAKE_PROJECT_NAME IIc) 那行定义的)
PRIVATE 只作用于 IIc 自己链接,不向外传递
-u _printf_float 强制链接器认为符号 _printf_float 被引用,从而拉入浮点版 printf

补充:--specs=nano.specs 不在 CMakeLists.txt 里,它定义在工具链文件 cmake/gcc-arm-none-eabi.cmake 中(set(CMAKE_EXE_LINKER_FLAGS ... --specs=nano.specs) 这一行),通过 CMakePresets.json 里的 toolchainFile 在配置工程时注入到全局链接参数。所以正常流程下你不需要动它,只要加 -u _printf_float 这一行就够了。

代价是 Flash 占用增加约 12KB(本项目 15.5KB → 27.5KB)。

备选方案(Flash 紧张时):不用 %f,手动拆整数和小数:

1
sprintf(message, "温度:%d.%d℃", (int)T, (int)(T*10)%10);

坑 3:sprintf 参数类型警告

现象:sprintf(message, ...) 报 warning——message 是 uint8_t 数组,而 sprintf/strlen 期望 char *。

解决:message 定义为 char message[50],发送时再强转 (uint8_t*)message 给 HAL 函数,两边都干净。

坑 4:位拼接用 + 还是 |

现象:编译出现 -Wparentheses 警告:suggest parentheses around '+' inside '<<'。

原因:data = ((uint32_t)readBuffer[3]>>4 + (uint32_t)readBuffer[2]<<4 + ...) 这种写法,虽然因为 << 优先级高于 + 结果碰巧正确,但可读性差、编译器警告。

解决:位拼接统一用 | 并加括号(**拼位用 |,算数用 +**):

1
2
3
data = ((uint32_t)readBuffer[1] << 12)
| ((uint32_t)readBuffer[2] << 4)
| ((uint32_t)readBuffer[3] >> 4);

总结

新知识点一览

知识点 说明
IIC 通信 半双工、主从模式、总线协议、同步通信
SDA/SCL 数据线 + 时钟线,开漏输出 + 上拉电阻
7位/8位地址 地址左移 1 位 + R/W 位,HAL 自动处理
HAL_I2C_Master_Transmit I2C 发送函数
HAL_I2C_Master_Receive I2C 接收函数
GPIO_MODE_AF_OD 复用开漏模式(I2C 专用)
AHT20 驱动编写 对照数据手册:初始化 → 触发测量 → 读 6 字节 → 拼接 → 公式转换
独立驱动文件 .c/.h 文件模式 + #ifndef 防重复包含
sprintf 浮点输出 %.1f 占位符 + -u _printf_float 链接参数

和上一篇的对比

上一篇(蓝牙 BLE) 本篇(IIC + AHT20)
通信外设 USART3 I2C1
数据方向 双向收发(全双工) 主从一问一答(半双工)
时钟 双方约定波特率(异步) 主机提供 SCL(同步)
驱动结构 代码写在 main.c 独立 aht20.c/h 驱动文件
传输内容 数据包协议(包头+长度+校验) 传感器寄存器读写
新 API DMA + 空闲中断 HAL_I2C_Master_Transmit/Receive

从串口到 IIC,我们从”点对点通信”进入”总线通信”。


参考资源