从上一篇到这篇
上篇我们实现了蓝牙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 | STM32 (主机) ──SDA──┬── AHT20 温湿度 (地址 0x70) |
咦,AHT20 数据手册上写的地址明明是 0x38,这里为什么写 0x70?这个”地址之谜”我们到写代码时再解开(见 5.2 节)。
2.3 同步通信:为什么 IIC 要一根时钟线
串口是异步通信:双方各按自己的时钟速率(波特率)收发,这就要求双方晶振都足够精确。但很多小型传感器根本没有精确晶振,时钟一偏,双方就”不知所云”。
IIC 改用同步通信:主机通过 SCL 线发送固定频率的脉冲,作为总线上所有设备统一的时钟源。大家看同一个钟表走步,就永远不会乱。
2.4 一次完整的 IIC 通信(初学者简单了解即可)
IIC 数据线和时钟线都有上拉电阻,空闲时都是高电平。一次通信的过程:
1 | SCL ──┐┌──┐┌──┐┌──┐┌───┐┌──┐┌──── |
- 起始信号:SCL 为高时,SDA 被拉低 → 所有从机竖起耳朵
- 传输数据:SCL 为低时主机设置 SDA 电平;SCL 为高时从机读取。每次发 8 位(1 字节)
- ACK 应答:每收完一字节,接收方在 SCL 低电平时把 SDA 拉低一下,表示”收到了”
- 结束信号:SCL 为高时,SDA 被拉高 → 通信结束
这些底层时序 HAL 库全部帮我们封装好了,我们只需调用
HAL_I2C_Master_Transmit/HAL_I2C_Master_Receive两个函数即可,原理了解即可。
CubeMX 配置
3.1 打开 USART2 和 I2C1
新建工程后(本例工程名 IIc):
- 打开老朋友 USART2(模式 Asynchronous,波特率 115200)—— 用于串口打印温湿度
- 点击 I2C1,Mode 选择 I2C(标准模式),其他配置保持默认


I2C 的详细参数(时钟速度、寻址模式等)我们到初始化代码解析章节再讲,这里默认值即可。
3.2 勾选”为每个外设生成一对 .c/.h 文件”
这是本期新增的关键一步:Project Manager → Code Generator,勾选 Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral:

勾选后生成的代码结构会发生变化:以前全部写在 main.c 里的外设初始化,现在拆成了独立的文件:
1 | Core/ |
这样我们写 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 文件:
Core/Inc文件夹上右键 → 新建文件 → 命名为aht20.h(头文件,放函数声明)Core/Src文件夹上右键 → 新建文件 → 命名为aht20.c(源文件,放驱动实现)
1 | Core/ |
视频教程中是在 CubeIDE 里右键新建 Header File / Source File,IDE 会贴心生成
#ifndef防重复包含模板;VS Code 新建的是空文件,这个模板要自己写(4.1 节就会看到)。
⚠️ 注意(VS Code + CMake 工程特有):手动新建的 .c 文件不会自动参与编译,还要在 CMakeLists.txt 里登记一行,否则链接时找不到函数——这一步我踩了个大坑,详见「踩坑记录 坑 1」。
核心代码
4.1 aht20.h —— 头文件
1 |
|
#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手册编写)



1 |
|
4.3 main.c —— 主程序
1 | /* USER CODE BEGIN Includes */ |
代码讲解
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 | 7位地址: 0111000 (0x38) |
所以 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 | Byte0 Byte1 Byte2 Byte3 Byte4 Byte5 |
湿度拼接(20 位 = Byte1 高 12 位 + Byte2 高 8 位 + Byte3 高 4 位):
1 | data = ((uint32_t)readBuffer[1] << 12) // Byte1 移到 19~8 位 |
为什么强转 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 | // 湿度:20位原始值 / 2^20 × 100% |
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 | void MX_I2C1_Init(void) |
参数含义:
| 参数 | 含义 | 本项目填的值 | 对应 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 | void HAL_I2C_MspInit(I2C_HandleTypeDef* i2cHandle) |
这里有两个全新的知识点,和 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 | target_sources(${CMAKE_PROJECT_NAME} PRIVATE |
如果用的是 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 | # 顶层 CMakeLists.txt |
两个参数的含义:
| 部分 | 含义 |
|---|---|
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 | data = ((uint32_t)readBuffer[1] << 12) |
总结
新知识点一览
| 知识点 | 说明 |
|---|---|
| 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,我们从”点对点通信”进入”总线通信”。
参考资源
- 视频:keysking STM32入门教程 第12集 IIC通信读取温湿度传感器AHT20(DHT20)
- AHT20 数据手册(波特律动资料包中提供)
- 波特律动文档站:STM32 学习指南
- 上一篇:蓝牙BLE通信,用蓝牙无线控制小灯