在主机模式下使用STM32Cube HAL I2C驱动

背景

对于每个MCU系列,STMicroelectronics都提供了一个嵌入式固件包,其中包括硬件抽象层(HAL)驱动。该驱动提供了一组高级API,旨在以高度可移植的方式抽象出MCU及其外设的复杂性。I2C接口就是其中之一。有关HAL I2C驱动入门的初学者指南,请参阅Shawn Hymel的教程。如需快速了解I2C主设备可用的通信功能,请继续阅读。

HAL驱动支持三种编程模型用于其数据处理功能:轮询、中断和DMA。轮询函数以阻塞模式运行,这意味着这些函数在操作完成之前不会返回。为了防止应用程序挂起,用户必须提供合适的超时值。中断和DMA函数以非阻塞模式运行,这意味着这些函数在操作启动后返回,允许应用程序继续执行,而操作在后台继续进行。然而,必须配置并启用回调函数以处理操作完成后引发的信号。

在阻塞模式下作为I2C主设备时,有四个API函数可用于与从设备通信:

  • HAL_I2C_Master_Transmit()
  • HAL_I2C_Master_Receive()
  • HAL_I2C_Mem_Write()
  • HAL_I2C_Mem_Read()

对于非阻塞功能,中断和DMA模式有等效的函数。然而,由于轮询函数是三者中最直接的,因此本参考指南将使用它们。

发送数据

从主设备向从设备发送数据在大多数情况下并不复杂。可以使用HAL_I2C_Master_Transmit()HAL_I2C_Mem_Write() 函数。选择哪一个取决于消息结构或仅仅是个人偏好。

HAL_I2C_Master_Transmit()

此API函数的函数原型如下所示。第一个参数是一个配置结构,其创建在入门教程中有详细说明。第二个参数是从设备的地址(必须左移一位)。第三个和第四个参数分别是指向数据缓冲区的指针和应从缓冲区发送到从设备的数据量。最后一个参数是以毫秒为单位的超时时间。请注意,用户可以提供 HAL_MAX_DELAY作为参数以禁用超时并无限阻塞,直到函数返回。

HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, 
										  uint16_t DevAddress, 
										  uint8_t *pData, 
										  uint16_t Size, 
										  uint32_t Timeout);

调用此函数生成的 I2C序列如下所示。阴影区域表示信号由从设备驱动的位置。在这种情况下,从设备仅确认其自身地址(加上写位)以及随后的任何数据字节。请注意,发送的数据字节数由提供给函数调用的Size参数决定。

01_00

作为此函数使用的示例,请考虑以下代码,其中缓冲区的内容被发送到地址为0x40的从设备。I2C传输由逻辑分析仪捕获,生成的波形也如下所示。请注意,起始条件存在,但在解码协议图形中没有标签的空间。

uint8_t dataBuffer[10] = {0x03, 0x01};
HAL_I2C_Master_Transmit(&hi2c1, (0x40 << 1), dataBuffer, 2, HAL_MAX_DELAY);

02_00

HAL_I2C_Mem_Write()

此函数适用于主设备希望写入从设备上特定内存位置的常见场景。例如,大多数I2C传感器包含用于更改设置和启动测量的配置和命令寄存器。此函数的原型如下。

HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, 
									uint16_t DevAddress, 
									uint16_t MemAddress, 
									uint16_t MemAddSize, 
									uint8_t *pData, 
									uint16_t Size, 
									uint32_t Timeout);

显然,它包含与HAL_I2C_Master_Transmit() 函数相同的所有参数,以及两个额外的参数。第一个参数MemAddress是从设备中缓冲区内容将写入的起始内存地址。第二个参数MemAddSize是内部内存地址的大小(I2C_MEMADD_SIZE_8BITI2C_MEMADD_SIZE_16BIT)。I2C序列现在将如下所示。

03_00

它与HAL_I2C_Master_Transmit()生成的序列相同,只是MemAddress 参数在从设备地址之后和来自数据缓冲区的第一个字节之前发送。以下示例使用HAL_I2C_Mem_Write()函数将值0x01写入从设备上位于内存地址0x03的寄存器。请注意,逻辑分析仪捕获的I 2C操作与上面HAL_I2C_Master_Transmit() 函数的示例完全相同。

uint8_t dataBuffer[10] = {0x01};
HAL_I2C_Mem_Write(&hi2c1, (0x40 << 1), 0x03, I2C_MEMADD_SIZE_8BIT, dataBuffer, 1, HAL_MAX_DELAY);

04_00

接收数据

与从主设备发送数据到从设备不同,用于从从设备接收数据的两个函数不可互换。一个函数仅接收数据,而另一个函数首先指定从何处接收数据。

HAL_I2C_Master_Receive()

此API函数用于简单地从从设备请求数据。请注意,以下原型中的参数与HAL_I2C_Master_Transmit()的参数相同。然而,在这种情况下,数据缓冲区用于存储传入数据,Size 参数指定在发送Nack之前接收多少字节的数据。

HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, 
										 uint16_t DevAddress, 
										 uint8_t *pData, 
										 uint16_t Size, 
										 uint32_t Timeout);

描述此函数操作的序列图如下所示。请注意,它与上面讨论的任何一个数据传输函数都有根本的不同。首先,地址字节中的方向位被设置为读取而不是写入。其次,在从设备确认其自身地址后,它开始向主设备发送数据字节(请注意,阴影字段表示从设备正在驱动信号)。现在,主设备负责确认它接收到的每个字节,直到它不再希望接收它们。它通过发送Nack后跟停止条件来告诉从设备停止发送数据。

05_00

下面的代码示例显示了主设备从地址为0x40的从设备请求三个字节的数据。通过观察逻辑分析仪的捕获,我们可以看到,操作完成后,数据缓冲区将包含值{0x00, 0x68, 0xF0}。

HAL_I2C_Master_Receive(&hi2c1, (0x40 << 1), dataBuffer, 3, HAL_MAX_DELAY);

06_00

HAL_I2C_Mem_Read()

此API函数用于从特定内存地址的从设备请求数据。再次考虑一个 I2C传感器,其中测量值存储在从设备的特定寄存器中,主设备必须从该寄存器读取数据。如下所示,函数原型包含与HAL_I2C_Mem_Write() 函数相同的参数。然而,与上述情况一样,数据缓冲区将用于存储传入的数据,而不是作为数据源。

HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, 
								   uint16_t DevAddress, 
								   uint16_t MemAddress, 
								   uint16_t MemAddSize, 
								   uint8_t *pData, 
								   uint16_t Size, 
								   uint32_t Timeout);

此函数的独特之处在于,它首先执行I2C传输操作,以告诉从设备从哪个内存地址获取数据。随后是重复的起始条件以开始接收操作。完整的 I2C序列如下所示。请注意,HAL_I2C_Mem_Read() 是唯一能够在阻塞模式下生成重复起始条件的函数。 如果需要重复起始条件,仅调用HAL_I2C_Master_Transmit()后立即调用 HAL_I2C_Master_Receive()是不够的。

07_00

以下代码示例从地址为0x40的从设备的内存地址0x01开始接收两个字节的数据。从逻辑分析仪的捕获中注意到,操作完成后,缓冲区将包含{0x68, 0x90}。

HAL_I2C_Mem_Read(&hi2c1, (0x40 << 1), 0x01, I2C_MEMADD_SIZE_8BIT, dataBuffer, 2, HAL_MAX_DELAY);

/* This is NOT guaranteed to work as a substitute for the above! */
// dataBuffer[0] = 0x01;
// HAL_I2C_Master_Transmit(&hi2c1, (0x40 << 1), dataBuffer, 1, HAL_MAX_DELAY);
// HAL_I2C_Master_Receive(&hi2c1, (0x40 << 1), dataBuffer, 2, HAL_MAX_DELAY);

08_00

总结

STMicroelectronics提供的HAL I2C驱动程序允许主设备以阻塞模式或非阻塞模式与从设备通信(阻塞模式是两者中较简单的)。要在阻塞模式下向从设备发送数据,可以使用HAL_I2C_Master_Transmit() 函数或HAL_I2C_Mem_Write()函数。两者可以互换使用。唯一的区别是,HAL_I2C_Mem_Write()明确指定了从设备上的内存地址作为参数。用户应根据其应用程序决定哪种方式最合适。要从从设备接收数据,可以使用HAL_I2C_Master_Receive()函数或HAL_I2C_Mem_Read()函数。然而,这两个函数不能互换使用。HAL_I2C_Master_Receive() 只是从从设备读取数据,而 HAL_I2C_Mem_Read() 首先向从设备发送一个内存地址,然后发出一个重复的起始条件,接着进行读取操作以获取位于该内存地址的数据。选择使用哪个函数取决于从设备期望的 I2C序列。

HAL_I2C_Master_Transmit() vs HAL_I2C_Mem_Write()

// 简单发送 - 设备地址+数据
HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
uint8_t *pData, uint16_t Size, uint32_t Timeout);

// 带内存地址的发送 - 设备地址+内存地址+数据
HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
uint16_t MemAddress, uint16_t MemAddSize,
uint8_t *pData, uint16_t Size, uint32_t Timeout);

  • Mem_Write会自动发送设备地址→内存地址→数据

  • 某些I2C从设备需要先指定寄存器地址才能写入数据

HAL_I2C_Master_Receive() vs HAL_I2C_Mem_Read()

// 直接读取 - 设备地址后立即读取数据
HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
uint8_t *pData, uint16_t Size, uint32_t Timeout);

// 带地址的读取 - 设备地址→发送内存地址→重复起始→读取数据
HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
uint16_t MemAddress, uint16_t MemAddSize,
uint8_t *pData, uint16_t Size, uint32_t Timeout);

使用 Master_Transmit/Receive 的场景:简单命令/状态设备、流式数据设备

使用 Mem_Write/Read 的场景:寄存器型设备、带地址空间的存储设备

这是一个很好的问题,这几个函数是STM32 HAL库中使用I2C总线时最核心的接口。它们的区别主要在于 操作层级适用场景

简单来说:

  • Transmit/Receive基础通信,你完全控制时序和协议。

  • Mem_Write/Mem_Read高级抽象,针对“带内部寄存器地址的设备”,HAL帮你处理了部分协议。

下面我们来详细解释和举例。


核心区别:协议层级

1. HAL_I2C_Master_Transmit()HAL_I2C_Master_Receive()

这是最底层的I2C主机通信函数。它们只负责完成一次标准的I2C通信序列

  • Transmit:主机发送 → START + 设备地址(写) + 数据包 + STOP

  • Receive:主机接收 → START + 设备地址(读) + 接收数据 + STOP

关键点:它们不关心你发送或接收的数据是什么含义。数据包的内容(比如设备内部的寄存器地址)需要你手动构造并作为数据的一部分发送出去。

2. HAL_I2C_Mem_Write()HAL_I2C_Mem_Read()

这是更高级的封装函数,专门用于操作具有内部寄存器(或存储地址) 的设备,如传感器、EEPROM等。

  • Mem_Write:向设备的指定寄存器写入数据。其完成的序列是:
    START + 设备地址(写) + 寄存器地址 + 数据包 + STOP

  • Mem_Read:从设备的指定寄存器读取数据。其完成的序列是:
    START + 设备地址(写) + 寄存器地址 + RESTART + 设备地址(读) + 接收数据 + STOP

关键点Mem 函数自动帮你处理了 “先写寄存器地址,再读/写数据” 这个常见流程。你只需要提供目标寄存器地址和数据即可,简化了操作。


示例说明:什么情况用什么接口

场景一:控制一个OLED屏幕(SSD1306)

OLED屏通常使用“命令+数据”的模式,没有明确的寄存器地址概念。

  • 发送命令:你需要先发送一个“控制字节”(0x00表示命令),再发送命令码。

  • 发送数据:你需要先发送一个“控制字节”(0x40表示数据),再发送数据流。

这种情况,你需要手动构造数据包,适合使用基础函数。

c

复制

下载

// 向OLED发送一个命令序列(例如初始化)
uint8_t cmd_buffer[2];
cmd_buffer[0] = 0x00; // 控制字节:命令
cmd_buffer[1] = 0xAE; // 实际的命令:关闭显示

// 使用基础发送函数,将cmd_buffer的内容发送出去
HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDRESS, cmd_buffer, 2, HAL_MAX_DELAY);

为什么不用 Mem_Write
因为OLED的协议不是“寄存器地址+数据”的标准模式。Mem_Write 会自动在数据前加一个“寄存器地址字节”,这会破坏OLED期望的协议格式。

场景二:读取温湿度传感器(SHT30)

SHT30这类传感器有明确的命令寄存器。例如,读取测量数据的命令是 0x2C060x2C10

情况A:使用基础函数(手动构造)

c

复制

下载

// 1. 发送测量命令 (0x2C06)
uint8_t tx_cmd[2] = {0x2C, 0x06};
HAL_I2C_Master_Transmit(&hi2c1, SHT30_ADDRESS, tx_cmd, 2, HAL_MAX_DELAY);

// 等待测量完成
HAL_Delay(20);

// 2. 读取6个字节的数据(温度、湿度、CRC)
uint8_t rx_data[6];
HAL_I2C_Master_Receive(&hi2c1, SHT30_ADDRESS, rx_data, 6, HAL_MAX_DELAY);
// 然后从 rx_data 中解析出温湿度值

情况B:使用 Mem 函数(更简洁,但需要注意!)
有些传感器的“命令”可以视为一个16位的“寄存器地址”。对于SHT30,这取决于其I2C模式(是“写命令”模式还是“寄存器访问”模式)。

c

复制

下载

// 假设传感器支持类似寄存器的访问方式(注意:SHT30通常不这么用,这里用MPU6050举例更合适)
// 例如:从MPU6050的加速度计寄存器(0x3B)读取6个字节
uint8_t accel_data[6];
uint16_t mem_addr = 0x3B; // 目标寄存器地址
uint16_t mem_add_size = I2C_MEMADD_SIZE_8BIT; // 寄存器地址是8位

HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDRESS, mem_addr, mem_add_size, accel_data, 6, HAL_MAX_DELAY);
// 一行代码完成了“设置读地址”和“读取数据”两个步骤

调试与验证方面的建议(非常实用)

:one: 强烈建议使用逻辑分析仪

  • 对比:

    • 数据手册中的 I2C 波形

    • HAL 实际发出的波形

  • 特别关注:

    • 是否有 Repeated START

    • 寄存器地址字节数是否正确(8-bit / 16-bit)


:two: 善用 HAL_I2C_GetError()

在通信失败时:

uint32_t err = HAL_I2C_GetError(&hi2c1);

区分:

  • HAL_I2C_ERROR_AF(NACK)

  • HAL_I2C_ERROR_TIMEOUT

  • HAL_I2C_ERROR_BERR

这往往直接指向“函数用错了”。


总结成几条“工程级结论”

  1. 读操作是否需要先写寄存器地址,是选择 I2C API 的决定性因素

  2. HAL_I2C_Mem_Read()HAL_I2C_Master_Receive(),不要混用

  3. 写操作虽然“可互换”,但不建议互换

  4. HAL API 的选择本质上是 协议选择

  5. 代码中应显式体现“为什么选这个函数”,而不是“刚好能用”