从串口到 I2C:一个反复出现的问题
回顾一下我们这一路学过的通信博客:
| 博客 | 外设 | 模式 | 收发用的数组 |
|---|---|---|---|
| 串口轮询 | USART2 | 轮询 | receiveData[2] |
| 串口中断 | USART2 | 中断 | receiveData[2](全局变量) |
| 串口 DMA 不定长 | USART2 + DMA | DMA + 空闲中断 | receiveData[50] |
| 蓝牙 BLE | USART3 + DMA | DMA + 空闲中断 | receiveData[50] |
| I2C 读 AHT20 | I2C1 + USART2 | I2C 轮询 | sendBuffer[3] / readBuffer[6] |
写代码时我脑子里一直有个疑惑:每次调用 HAL 收发函数,参数表格里都有一行”数据缓冲区指针“——我照着填 receiveData、sendBuffer,程序跑得挺好,但这些数据到底存在哪里? 是存在这个数组里,还是存在外设里?为什么每次都要传指针?为什么换个外设(从 UART 到 I2C),这个套路还是一模一样?
这篇博客就来解决这个疑惑。答案是:数组在 STM32 的内存(RAM)里,外设内部还有一个”一次只能装 1 字节”的数据寄存器(DR),数据从数组到外设、从外设到数组,都是 1 字节 1 字节地过手的。
两个存储地点:数组(RAM)和数据寄存器(DR)
要搞清楚数据存在哪,先分清两个完全不同的存储位置:
| 我的数组(RAM) | 外设数据寄存器 DR | |
|---|---|---|
| 位置 | STM32 内存,我代码里定义的变量 | 外设模块内部(如 USART2、I2C1 外设里) |
| 容量 | 想多大就多大(2、6、50 字节……) | 只有 1 字节 |
| 谁来读写 | CPU 通过代码读写 | 硬件自动操作 |
| 类比 | 候车大厅里的候车人群 | 检票口(一次只能过一个人) |
关键点:数据寄存器 DR 一次只能装 1 字节。这是硬件设计决定的——USART 外设有 USART_DR,I2C 外设有 I2C_DR,都是一字节宽的小寄存器。
打个比方:我的数组是候车大厅,DR 是检票口。一次通信要传 6 字节数据,就像 6 个人要出站——他们只能一个一个过检票口,不可能 6 个人同时挤过去。
发送:数据从哪来,到哪去
以 I2C 发送 3 字节测量命令为例(HAL_I2C_Master_Transmit(&hi2c1, addr, sendBuffer, 3, ...)):
1 | RAM(我的代码) I2C1 外设 总线 |
HAL_I2C_Master_Transmit 内部干的事,简化后就是:
1 | // HAL_I2C_Master_Transmit 内部(简化版) |
注意:0xAC、0x33、0x00 这三个字节全程待在 sendBuffer 数组里。CPU 只是”读”数组、”写”寄存器,数组里的值并不会消失。发完之后数组还在,下次发新命令直接覆盖。
串口发送(HAL_UART_Transmit)一模一样,只是把 I2C_FLAG_TXE 换成 UART_FLAG_TXE、把 I2C_DR 换成 USART_DR:
1 | for (i = 0; i < Size; i++) |
接收:数据从哪来,到哪去
以 I2C 接收 6 字节温湿度为例(HAL_I2C_Master_Receive(&hi2c1, addr, readBuffer, 6, ...)):
1 | 总线 I2C1 外设 RAM |
HAL 内部循环:
1 | for (i = 0; i < Size; i++) |
6 字节被 CPU 一个一个塞进 readBuffer 数组——CPU 每读一次 DR,硬件就会自动把下一个字节移进 DR。
串口中断模式下,这个搬运甚至就写在 HAL 源码里,我们之前还解析过这行代码(串口中断博客):
1 | *huart->pRxBuffPtr = (uint8_t)(huart->Instance->DR & 0x00FF); // DR → 数组,搬一个字节 |
pRxBuffPtr 就是 HAL 库内部的”数组指针”,每搬一个字节它自己 +1,指向数组下一个位置。
AHT20 在链路中的哪个位置?
前面的图都只画了 STM32 到总线的这一段,AHT20 一直没露面——它挂在总线的另一端。补上它,整条链路才完整:
1 | 发送 3 字节测量命令 0xAC 0x33 0x00 时: |
- ①
sendBuffer数组(STM32 的 RAM)→ CPU 逐字节塞进 I2C1 的 DR - ② 硬件把 DR 里的字节移位到 SDA 引脚,电平信号在总线上传播
- ③ 电平到达 AHT20 的 SDA 引脚 → 芯片内部按 SCL 时钟逐位接收 → 拼成一字节 → 存进 AHT20 芯片内部的寄存器
所以命令字节的最终”目的地”是 AHT20 芯片内部的寄存器——传感器芯片自己也有存储,数据不在 STM32 里,也不在总线上(总线只是”路”)。
接收时方向反过来:
1 | 读取 6 字节测量结果时: |
- ③ AHT20 把内部寄存器里的测量结果逐字节放上 SDA 线
- ④ STM32 的 I2C1 硬件逐位收下 → 装进 DR → CPU 把 DR 读走放进
readBuffer
AHT20 对 STM32 来说是个”黑盒”
我写的代码里从头到尾没有”直接操作 AHT20 内部寄存器”的写法——STM32 只做两件事:把命令字节发到总线上、从总线上收字节。AHT20 收到 0xAC 之后自己知道”该开始测量了”,测量完自己把结果放好等着被读。芯片内部怎么处理,STM32 完全不关心。
地址参数就是”点名”
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, ...) 里的地址(0x70)就是主机在总线上喊话点名:一条总线上可能挂着多个从机(OLED、传感器……),地址告诉它们”这 3 个字节是发给 0x70 的,其他的别收”。数据到了总线上,只有 AHT20 认领,别的设备”选择性失聪”。
这也顺带解释了串口和 I2C 的一个根本区别:串口是点对点(线那端就一个设备),不需要地址参数;I2C 是总线(线那端可能挂好几个从机),必须带地址点名,数据才能被正确的从机收下。
完整存储位置列表
| 环节 | 数据存在哪 |
|---|---|
| 发送起点 | STM32 RAM(sendBuffer 数组) |
| 过手 | STM32 的 I2C1 DR(1 字节) |
| 运输 | SDA 总线(电平信号,不存储) |
| 发送终点 | AHT20 芯片内部的寄存器(命令/配置) |
| 接收起点 | AHT20 芯片内部的寄存器(测量结果) |
| 过手 | STM32 的 I2C1 DR(1 字节) |
| 接收终点 | STM32 RAM(readBuffer 数组) |
为什么必须是数组?
因为 DR 一次只能装 1 字节,而通信往往是多字节的。假如不用数组、只用一个变量:
1 | uint8_t data; // 只有 1 字节 |
第二个字节一来就把第一个字节覆盖了。所以必须有”一块连续的内存”把多字节数据都装下——这就是数组。
数组大小怎么定?看我的通信协议:
- 串口轮询:指令固定 2 字节 →
receiveData[2] - 串口 DMA:数据不定长 →
receiveData[50](50 是安全上限) - I2C 读 AHT20:每次读 6 字节 →
readBuffer[6]
这也解释了之前博客里的一个细节:DMA 接收不定长数据那篇的数组为什么从 [2] 变成 [50]——不是语法变了,是数据量上限变了。
为什么 HAL 函数要传指针?
看函数签名:HAL_I2C_Master_Receive(..., uint8_t *pData, ...)——传的是数组首地址(数组名本身就是首地址,前面加 & 或者直接用数组名都行)。
因为 HAL 库函数要往stm32的内存里写数据(接收)或者从内存里读数据(发送)。传指针 = 把仓库地址告诉 HAL,它直接操作我的仓库,不需要把数据复制一份。这也意味着缓冲区大小、数据格式全由stm32自己掌控——HAL 只负责”从数组第 0 个位置搬到第 Size 个位置”。
想想看:如果不传指针而是传数组本身,C 语言会复制一份整个数组——又慢又浪费内存,而且接收时复制过去的副本根本收不到数据(数据要写回原数组才有用)。所以通信 API 一律传指针。
三种”搬运工”:轮询、中断、DMA
前面 5 篇博客学过的所有模式,数据存放的位置完全没变——数组还是那个数组(RAM),DR 还是那个 DR(外设里)。变的只有一件事:谁来搬。
| 模式 | 搬运工 | CPU 在干嘛 | 对应博客 |
|---|---|---|---|
| 轮询 | CPU 亲自搬,等一个搬一个 | 全程占着,啥也干不了 | 串口轮询、I2C 读 AHT20 |
| 中断 | 硬件标志位置位 → 中断服务函数来搬 | 平时干别的,被叫去才搬一下 | 串口中断 |
| DMA | 专门的 DMA 硬件搬,一次搬一批 | 彻底解放,搬完了才收到通知(回调) | 串口 DMA、蓝牙 |
轮询和中断的搬运其实”长得一模一样”——都是 CPU 执行”等标志位 → 搬一字节”的循环,区别只在等待方式:
- 轮询:CPU 站在 DR 门口死等标志位(
while (标志位 == 0);),期间什么事都干不了 - 中断:CPU 去干别的,标志位置位时硬件把 CPU 叫回来(触发中断),在中断服务函数里搬一字节,搬完继续干别的
DMA 则是完全不同的搬运工:它是个硬件模块,自己知道”源地址(DR)、目标地址(数组)、搬多少”,一条命令就能把整批数据搬完,连 CPU 都不需要。
关键:不管是哪种搬运工,数据最终都落在同一个数组里。所以不管是我在回调函数里用 receiveData,还是在 while(1) 里用 readBuffer,操作的都是同一块内存——程序逻辑完全不用区分数据是怎么进来的。
统一模型:所有通信外设一个套路
把 5 篇博客串起来,得到一个贯穿所有通信编程的模型:
1 | ┌───────────────────────────────────────────────────────────────┐ |
把 5 篇博客放进这个模型里看:
| 博客 | 搬运工 | 数组在哪 | DR → 数组由谁完成 |
|---|---|---|---|
| 串口轮询 | CPU 查询 RXNE | receiveData[2] 局部变量 |
CPU 在 HAL_UART_Receive 里逐字节读 DR |
| 串口中断 | 中断服务函数 | receiveData[2] 全局变量 |
CPU 在 UART_Receive_IT 里读 DR |
| 串口 DMA | DMA 硬件 | receiveData[50] 全局变量 |
DMA 控制器直接从 DR 搬到数组 |
| 蓝牙(USART3+DMA) | DMA 硬件 | receiveData[50] 全局变量 |
DMA 控制器直接从 DR 搬到数组 |
| I2C 读 AHT20 | CPU 查询 I2C 标志 | sendBuffer[3] / readBuffer[6] 局部变量 |
CPU 在 HAL_I2C 函数里搬运 |
所以可以看到,从 USART 到 I2C,从轮询到 DMA,底层永远是这个模型:
数组(RAM) ↔ 数据寄存器 DR(外设) ↔ 移位寄存器 ↔ 物理引脚
编程层面我只需要做两件事:
- 准备数组:发送前往数组里放数据;接收前准备一个空数组
- 调 HAL 函数:把数组首地址(指针)和长度告诉 HAL
剩下的”数组↔DR 一来一回”的搬运,交给 HAL 库(轮询/中断)或 DMA(硬件)。这也是为什么换一个通信外设(UART → I2C → 以后的 SPI/CAN)学习成本很低——学会一个通信外设,就学会了一半。
总结
| 问题 | 答案 |
|---|---|
| 数据到底存在哪? | 存在我代码定义的数组里(RAM 内存),发送和接收都是 |
| 外设里存数据吗? | 外设只有一个1 字节的数据寄存器 DR,只作”过手”用,不存数据 |
| 为什么用数组? | DR 一次只能装 1 字节,多字节数据必须有连续内存暂存 |
| 为什么传指针? | HAL 函数要直接读写我的内存,传首地址即可,避免复制 |
| 数据怎么进的数组? | 轮询/中断:CPU 逐字节搬;DMA:硬件整批搬——数组位置不变 |
| 换外设为什么套路一样? | USART、I2C(以及以后的 SPI/CAN)都是”数组 ↔ DR ↔ 引脚”模型 |
参考资源
- 上一篇:STM32——IIC通信,轮询模式读取温湿度传感器AHT20
- 串口轮询:STM32——串口通信,用电脑控制三色LED
- 串口中断:STM32——串口中断模式通信,让CPU不再”傻等”
- DMA 不定长:STM32——串口DMA模式收发,与不定长数据接收
- 蓝牙:STM32——蓝牙BLE通信,用蓝牙无线控制小灯
- 参考资料:STM32F103xx 参考手册(USART 数据寄存器 DR、I2C 数据寄存器 DR 章节)、AHT20 数据手册