I2C通信协议基础:从物理层到HAL库实战

各位同学,今天我们来啃一块硬骨头——I2C通信协议。说实话,很多工程师做了好几年嵌入式,对I2C的理解还停留在「调库调通就行」的阶段。但咱们做WM8960音频驱动,I2C是控制通道,一旦出问题,音频芯片根本不搭理你。所以这一章,咱们把I2C从物理层到HAL库撸一遍。

本章核心目标:搞懂I2C总线怎么干活,然后用STM32 HAL库把读写函数写出来,最后用逻辑分析仪验证数据对不对。

1. I2C总线物理层:两根线怎么传数据?

I2C总线就两根线:SCL(时钟线)和SDA(数据线)。所有设备都挂在这两根线上,主设备负责产生时钟,从设备响应地址。我刚开始学的时候总觉得神奇——两根线怎么传地址、数据、应答这么多信息?

关键点在于:SDA线上的数据必须在SCL高电平时被采样。SCL低电平时,SDA可以变化;SCL高电平时,SDA必须稳定。这就是I2C的「时序规矩」。

咱们看几个核心时序概念:

  • 起始条件(START):SCL高电平时,SDA从高变低。我习惯叫它「握手信号」。
  • 停止条件(STOP):SCL高电平时,SDA从低变高。表示传输结束。
  • 应答位(ACK):每传完8位数据,接收方在第9个SCL周期拉低SDA,告诉发送方「收到了」。
  • 非应答(NACK):接收方不拉低SDA,表示「没收到」或「不想收了」。

个人经验:我在调试WM8960时遇到过一个问题——芯片偶尔不响应。后来发现是起始条件保持时间不够。STM32的I2C外设默认时序参数有时偏紧,尤其是400kHz高速模式。建议先用100kHz标准模式调通,再提速。

2. I2C总线时序:一个完整的读写周期

一个完整的I2C写操作长这样:

  1. 主设备发送起始条件
  2. 主设备发送7位从设备地址 + 1位写标志(0)
  3. 从设备应答ACK
  4. 主设备发送寄存器地址(1字节)
  5. 从设备应答ACK
  6. 主设备发送数据(1字节或多字节)
  7. 从设备每字节应答ACK
  8. 主设备发送停止条件

读操作稍微复杂一点:先写寄存器地址,再重新发送起始条件,然后读数据。这叫「复合格式」。

注意:WM8960的I2C地址是0x1A(7位地址),左对齐后是0x34(写地址)和0x35(读地址)。很多新手把7位地址和8位地址搞混,导致芯片不响应。我建议直接用HAL库的7位地址模式,别自己移位。

3. STM32 HAL库I2C驱动框架

STM32 HAL库把I2C操作封装成了几个核心函数。咱们用CubeMX配置好外设后,主要跟这些函数打交道:

函数名 功能 阻塞/非阻塞
HAL_I2C_Master_Transmit() 主设备发送数据 阻塞
HAL_I2C_Master_Receive() 主设备接收数据 阻塞
HAL_I2C_Mem_Write() 写指定寄存器地址 阻塞
HAL_I2C_Mem_Read() 读指定寄存器地址 阻塞
HAL_I2C_Master_Transmit_IT() 中断方式发送 非阻塞
HAL_I2C_Master_Receive_IT() 中断方式接收 非阻塞

我个人建议:驱动初始化阶段用阻塞模式,因为简单可靠。等系统跑起来后,如果I2C操作频繁,再切到中断或DMA模式。WM8960的寄存器配置只在初始化时做一次,阻塞模式完全够用。

4. 使用CubeMX配置I2C外设

打开CubeMX,选好你的STM32型号。我以STM32F407为例:

  1. 在Pinout视图里找到I2C1(或I2C2/I2C3,看你板子原理图)
  2. 将PB6设为I2C1_SCL,PB7设为I2C1_SDA
  3. 在Configuration → I2C1里设置参数:
参数 推荐值 说明
I2C Speed Mode Standard Mode (100kHz) 先低速调通
Own Address 1 0x00 主设备不需要地址
Addressing Mode 7-bit WM8960是7位地址
Dual Address Disabled 不需要双地址
General Call Disabled 不需要通用呼叫
No Stretch Mode Disabled 允许从设备拉伸时钟

避坑指南:我曾经在配置I2C时忘了开GPIO的复用功能开漏输出。结果SDA和SCL一直拉不高,示波器一看波形都是平的。记住:I2C的GPIO要配成开漏输出 + 上拉电阻,外部上拉电阻一般选4.7kΩ。

5. 编写I2C读写函数并验证

好,配置完CubeMX,生成代码。现在咱们写两个核心函数:

/* WM8960 I2C写寄存器函数 */
HAL_StatusTypeDef WM8960_WriteReg(uint8_t reg_addr, uint16_t data)
{
    uint8_t buf[2];
    
    /* WM8960寄存器数据是9位,但HAL库按字节传 */
    /* 高字节:寄存器地址 + 数据高位 */
    buf[0] = (reg_addr << 1) | ((data >> 8) & 0x01);
    /* 低字节:数据低8位 */
    buf[1] = data & 0xFF;
    
    return HAL_I2C_Master_Transmit(&hi2c1, WM8960_ADDR << 1, buf, 2, 100);
}

/* WM8960 I2C读寄存器函数 */
HAL_StatusTypeDef WM8960_ReadReg(uint8_t reg_addr, uint16_t *data)
{
    uint8_t buf[2];
    HAL_StatusTypeDef ret;
    
    /* 先写寄存器地址 */
    ret = HAL_I2C_Master_Transmit(&hi2c1, WM8960_ADDR << 1, &reg_addr, 1, 100);
    if (ret != HAL_OK) return ret;
    
    /* 再读2字节数据 */
    ret = HAL_I2C_Master_Receive(&hi2c1, WM8960_ADDR << 1, buf, 2, 100);
    if (ret != HAL_OK) return ret;
    
    /* 组合成16位数据 */
    *data = ((uint16_t)buf[0] << 8) | buf[1];
    return HAL_OK;
}

这里有个细节:WM8960的寄存器地址是7位,数据是9位。但I2C传输是按字节对齐的,所以咱们把寄存器地址左移1位,把数据的最高位塞进去。嗯,这个做法在WM8960数据手册里有说明,我第一次看的时候也愣了一下。

验证方法:写一个测试函数,读取WM8960的芯片ID寄存器(地址0x00)。正常应该返回0x6300。如果返回0xFFFF或0x0000,说明I2C通信有问题。我习惯用逻辑分析仪抓波形,看起始条件、地址、数据、应答位是否都对。

6. I2C通信核心流程(SVG图)

下面这张图把I2C写WM8960寄存器的完整流程画出来了。你看一眼就能明白数据怎么在总线上流动:

I2C写WM8960寄存器完整流程 主设备(STM32) 从设备(WM8960) I2C总线 (SCL + SDA) ① 发送START ② 地址0x34+写 ③ ACK应答 ④ 发送寄存器地址 ⑤ ACK应答 ⑥ 发送2字节数据 ⑦ ACK应答 ⑧ 发送STOP

这张图里,蓝色是主设备,绿色是从设备。橙色是起始/停止条件,紫色是地址阶段,红色是数据阶段。你仔细看,每个步骤都有来有回——主设备发完,从设备应答。这就是I2C的「一问一答」机制。

常见问题:如果WM8960不应答,先检查硬件连接——上拉电阻焊了吗?电压对了吗?WM8960的VDD是3.3V,但I2C引脚可能容忍5V。再用示波器看SCL有没有时钟信号。我曾经遇到过PCB走线太长导致信号反射,加了个33pF电容对地就好了。