0%

深入stm32的底层通信模型--数据的存储与传输

从串口到 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
2
3
4
5
6
7
8
          RAM(我的代码)                 I2C1 外设                   总线
┌──────────────────────┐ ┌─────────────────────────┐
│ sendBuffer[0] = 0xAC │──①──→│ DR │──②──→ SDA 引脚
│ sendBuffer[1] = 0x33 │──①──→│ (1字节小信箱) │──②──→ (逐位移位发出)
│ sendBuffer[2] = 0x00 │──①──→│ ↑ │
└──────────────────────┘ └───────────┼─────────────┘
① CPU 把 sendBuffer[i] 写入 DR(HAL 库内部循环)
② 硬件把 DR 里的字节移出到总线上

HAL_I2C_Master_Transmit 内部干的事,简化后就是:

1
2
3
4
5
6
// HAL_I2C_Master_Transmit 内部(简化版)
for (i = 0; i < Size; i++)
{
while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_TXE) == RESET); // 等 DR 空了
hi2c->Instance->DR = pData[i]; // 把数组第 i 个字节塞进 DR
}

注意:0xAC、0x33、0x00 这三个字节全程待在 sendBuffer 数组里。CPU 只是”读”数组、”写”寄存器,数组里的值并不会消失。发完之后数组还在,下次发新命令直接覆盖。

串口发送(HAL_UART_Transmit)一模一样,只是把 I2C_FLAG_TXE 换成 UART_FLAG_TXE、把 I2C_DR 换成 USART_DR:

1
2
3
4
5
for (i = 0; i < Size; i++)
{
while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE) == RESET); // 等 DR 空了
huart->Instance->DR = pData[i]; // 塞一个字节
}

接收:数据从哪来,到哪去

以 I2C 接收 6 字节温湿度为例(HAL_I2C_Master_Receive(&hi2c1, addr, readBuffer, 6, ...)):

1
2
3
4
5
6
7
总线               I2C1 外设                    RAM
SDA 引脚 ──→ 移位寄存器 ──→ DR ──②──→ readBuffer[0]
(硬件逐位移入) readBuffer[1]
readBuffer[2]
readBuffer[3]
readBuffer[4]
readBuffer[5]

HAL 内部循环:

1
2
3
4
5
for (i = 0; i < Size; i++)
{
while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_RXNE) == RESET); // 等 DR 里有数据
pData[i] = hi2c->Instance->DR; // 把 DR 里的字节存进数组
}

6 字节被 CPU 一个一个塞进 readBuffer 数组——CPU 每读一次 DR,硬件就会自动把下一个字节移进 DR。

串口中断模式下,这个搬运甚至就写在 HAL 源码里,我们之前还解析过这行代码(串口中断博客):

1
*huart->pRxBuffPtr = (uint8_t)(huart->Instance->DR & 0x00FF);  // DR → 数组,搬一个字节

pRxBuffPtr 就是 HAL 库内部的”数组指针”,每搬一个字节它自己 +1,指向数组下一个位置。


AHT20 在链路中的哪个位置?

前面的图都只画了 STM32 到总线的这一段,AHT20 一直没露面——它挂在总线的另一端。补上它,整条链路才完整:

1
2
3
4
5
6
7
8
9
10
11
                    发送 3 字节测量命令 0xAC 0x33 0x00 时:
┌────────────────────────────────────────────┐
│ │
STM32 (主机) │ SDA 总线 │ AHT20 (从机)
┌──────────────┐ │ ┌──────────────┐ ┌──────────────┐ │ ┌──────────────┐
│ sendBuffer │ │ │ I2C1 外设 │ │ │ │ │ 芯片内部 │
│ [0]=0xAC ────┼─①─┼───→│ DR(1字节) │─②─┼─→ SDA 引脚 ──┼─────┼─→│ 引脚 → 移位 │
│ [1]=0x33 │ │ │ │ │ │ │ │ 寄存器 → 内部 │
│ [2]=0x00 │ │ └──────────────┘ └──────────────┘ │ │ 命令/配置寄存器 │
└──────────────┘ │ │ └──────────────┘
└────────────────────────────────────────────┘
  • ① sendBuffer 数组(STM32 的 RAM)→ CPU 逐字节塞进 I2C1 的 DR
  • ② 硬件把 DR 里的字节移位到 SDA 引脚,电平信号在总线上传播
  • ③ 电平到达 AHT20 的 SDA 引脚 → 芯片内部按 SCL 时钟逐位接收 → 拼成一字节 → 存进 AHT20 芯片内部的寄存器

所以命令字节的最终”目的地”是 AHT20 芯片内部的寄存器——传感器芯片自己也有存储,数据不在 STM32 里,也不在总线上(总线只是”路”)。

接收时方向反过来:

1
2
3
4
5
6
7
8
9
10
11
                    读取 6 字节测量结果时:
┌────────────────────────────────────────────┐
│ │
STM32 (主机) │ SDA 总线 │ AHT20 (从机)
┌──────────────┐ │ ┌──────────────┐ ┌──────────────┐ │ ┌──────────────┐
│ readBuffer │ │ │ I2C1 外设 │ │ │ │ │ 芯片内部 │
│ [0]←───④────┼───┼───←│ DR(1字节) │←③─┼── SDA 引脚 ←──┼─────┼──←│ 测量结果寄存器 │
│ [1] │ │ │ │ │ │ │ │ (温湿度原始值) │
│ ... │ │ └──────────────┘ └──────────────┘ │ └──────────────┘
└──────────────┘ │ │
└────────────────────────────────────────────┘
  • ③ 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
2
uint8_t data;   // 只有 1 字节
HAL_UART_Receive(&huart2, &data, 2, HAL_MAX_DELAY); // 想收 2 字节?没地方放!

第二个字节一来就把第一个字节覆盖了。所以必须有”一块连续的内存”把多字节数据都装下——这就是数组。

数组大小怎么定?看我的通信协议:

  • 串口轮询:指令固定 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌───────────────────────────────────────────────────────────────┐
│ 【通信统一模型】 │
│ │
│ ┌──────────────┐ 搬运机制 ┌───────────────────┐ │
│ │ 数组 / 变量 │ ◄──────────────► │ 外设数据寄存器 DR │ │
│ │ (RAM 内存) │ │ (外设硬件内部) │ │
│ │ │ 轮询: CPU 手动 │ USART_DR │ │
│ │ receiveData │ 中断: 硬件通知 │ I2C_DR │ │
│ │ sendBuffer │ DMA: 硬件自动 │ │ │
│ │ readBuffer │ │ │ │
│ └──────────────┘ └─────────┬─────────┘ │
│ ▲ │ │
│ │ TXE / RXNE 标志驱动 │ │
│ └─────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘

把 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(外设) ↔ 移位寄存器 ↔ 物理引脚

编程层面我只需要做两件事:

  1. 准备数组:发送前往数组里放数据;接收前准备一个空数组
  2. 调 HAL 函数:把数组首地址(指针)和长度告诉 HAL

剩下的”数组↔DR 一来一回”的搬运,交给 HAL 库(轮询/中断)或 DMA(硬件)。这也是为什么换一个通信外设(UART → I2C → 以后的 SPI/CAN)学习成本很低——学会一个通信外设,就学会了一半。


总结

问题 答案
数据到底存在哪? 存在我代码定义的数组里(RAM 内存),发送和接收都是
外设里存数据吗? 外设只有一个1 字节的数据寄存器 DR,只作”过手”用,不存数据
为什么用数组? DR 一次只能装 1 字节,多字节数据必须有连续内存暂存
为什么传指针? HAL 函数要直接读写我的内存,传首地址即可,避免复制
数据怎么进的数组? 轮询/中断:CPU 逐字节搬;DMA:硬件整批搬——数组位置不变
换外设为什么套路一样? USART、I2C(以及以后的 SPI/CAN)都是”数组 ↔ DR ↔ 引脚”模型

参考资源