3. I2C控制接口实战:使用STM32 HAL库驱动WM8960寄存器读写

好,咱们进入第三章。这一章要解决一个很实际的问题——怎么通过I2C总线,让STM32和WM8960对上话。

WM8960这颗Codec,它的控制接口就是I2C。你想让它播放声音、调节音量、切换输入源,本质上都是在操作它内部的寄存器。说白了,你往哪个地址写什么值,它就干什么活。

我刚开始调这颗芯片的时候,犯过一个低级错误——I2C时序没问题,地址也没写错,但就是读不回数据。折腾了半天,发现是忘了给芯片供电……嗯,这种坑踩过一次就记住了。

3.1 WM8960的I2C协议细节

WM8960的I2C从机地址是7位模式,固定为0x1A(左对齐)。如果你用8位地址,写操作是0x34,读操作是0x35。这个地址在数据手册第XX页有写,但我建议你直接记0x1A,因为HAL库的HAL_I2C_Mem_Write函数用的就是7位地址。

关键参数速查表

参数值说明
I2C从机地址(7位)0x1AWM8960固定地址
寄存器地址宽度16位高9位为页地址,低7位为寄存器偏移
数据宽度8位每个寄存器存储1字节数据
最大I2C时钟400kHz快速模式,STM32配置时注意
写操作格式START + 地址 + 寄存器高字节 + 寄存器低字节 + 数据 + STOP连续传输

这里有个容易忽略的点:WM8960的寄存器地址是16位的,不是常见的8位。你写I2C的时候,必须先发高字节,再发低字节。HAL库的HAL_I2C_Mem_Write函数第三个参数就是寄存器地址,类型是uint16_t,正好匹配。

3.2 STM32 I2C外设初始化

我个人习惯用STM32CubeMX生成初始化代码,然后手动微调。I2C的配置其实不复杂,但有几个参数会影响通信稳定性。

下面是我在项目中常用的配置模板:

// I2C句柄定义
I2C_HandleTypeDef hi2c1;

// I2C初始化函数
static void MX_I2C1_Init(void)
{
    hi2c1.Instance = I2C1;
    hi2c1.Init.ClockSpeed = 100000;          // 标准模式100kHz,调试阶段先用低速
    hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
    hi2c1.Init.OwnAddress1 = 0;
    hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
    hi2c1.Init.OwnAddress2 = 0;
    hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
    hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
    
    if (HAL_I2C_Init(&hi2c1) != HAL_OK)
    {
        Error_Handler();  // 初始化失败,进入错误处理
    }
}

调试建议:刚开始调I2C时,把时钟设到100kHz。等通信稳定了,再改到400kHz。我曾经一上来就开400kHz,结果波形畸变,排查了半天才发现是上拉电阻阻值不对。

GPIO的配置也要注意。I2C的SCL和SDA引脚必须配置为开漏输出,并外接上拉电阻。STM32内部虽然有上拉,但驱动能力不够,尤其是高速模式时。我一般用4.7kΩ的上拉电阻,总线电容不大的话没问题。

3.3 寄存器写操作实现

写操作是基础。WM8960的每个寄存器都是16位地址 + 8位数据。HAL库封装得很好,一行代码就能搞定:

/**
  * @brief  写WM8960寄存器
  * @param  reg_addr: 16位寄存器地址
  * @param  data: 8位数据
  * @retval HAL_OK表示成功,其他值表示失败
  */
HAL_StatusTypeDef WM8960_WriteReg(uint16_t reg_addr, uint8_t data)
{
    return HAL_I2C_Mem_Write(&hi2c1, 
                             WM8960_ADDR,   // 0x1A
                             reg_addr, 
                             I2C_MEMADD_SIZE_16BIT,  // 16位寄存器地址
                             &data, 
                             1,              // 写入1字节
                             HAL_MAX_DELAY); // 超时时间,单位ms
}

这个函数看着简单,但有个细节:I2C_MEMADD_SIZE_16BIT这个参数告诉HAL库,寄存器地址是16位的。如果你误写成I2C_MEMADD_SIZE_8BIT,HAL库只会发一个字节的地址,WM8960根本收不到正确的寄存器编号。

实际项目中,我习惯再加一层封装,把常用的寄存器配置写成函数:

// 设置WM8960的采样率
HAL_StatusTypeDef WM8960_SetSampleRate(uint8_t rate)
{
    // 假设rate定义在WM8960_SR_48K等宏中
    return WM8960_WriteReg(WM8960_CLOCKING1, rate);
}

// 设置耳机音量(左声道)
HAL_StatusTypeDef WM8960_SetHeadphoneVolumeLeft(uint8_t volume)
{
    // volume范围0~127,对应-73dB~+6dB
    return WM8960_WriteReg(WM8960_LOUT1_VOLUME, volume & 0x7F);
}

3.4 寄存器读操作实现

读操作比写操作稍微麻烦一点。WM8960的读流程是:先发写命令把寄存器地址告诉芯片,然后重新发START信号,再发读命令,最后读取数据。

HAL库的HAL_I2C_Mem_Read函数已经封装好了这个流程:

/**
  * @brief  读WM8960寄存器
  * @param  reg_addr: 16位寄存器地址
  * @param  data: 存放读取数据的指针
  * @retval HAL_OK表示成功
  */
HAL_StatusTypeDef WM8960_ReadReg(uint16_t reg_addr, uint8_t *data)
{
    return HAL_I2C_Mem_Read(&hi2c1, 
                            WM8960_ADDR, 
                            reg_addr, 
                            I2C_MEMADD_SIZE_16BIT, 
                            data, 
                            1, 
                            HAL_MAX_DELAY);
}

注意:WM8960的寄存器是只写或读写混合的。不是所有寄存器都能读回来。比如电源管理寄存器(地址0x19)就只能写,读回来的数据是无效的。我建议你对照数据手册的"Register Map"表格,确认每个寄存器的读写属性。

3.5 实战:验证I2C通信

写好了驱动函数,怎么验证通信是否正常?我一般用两种方法:

  1. 读芯片ID寄存器:WM8960的寄存器0x00(Software Reset)和0x01(Device ID)可以用来验证。读0x01应该返回固定值0x4F。
  2. 写后读回:找一个可读可写的寄存器(比如0x02的Left Input PGA Volume),先写一个已知值,再读回来对比。

下面是一个简单的测试函数:

void WM8960_TestCommunication(void)
{
    uint8_t id = 0;
    uint8_t test_val = 0x55;
    uint8_t read_back = 0;
    
    // 测试1:读取设备ID
    if (WM8960_ReadReg(0x01, &id) == HAL_OK)
    {
        printf("Device ID: 0x%02X\n", id);
        if (id == 0x4F)
            printf("WM8960 detected!\n");
        else
            printf("Unexpected ID, check wiring\n");
    }
    else
    {
        printf("I2C read failed!\n");
    }
    
    // 测试2:写后读回(使用寄存器0x02)
    WM8960_WriteReg(0x02, test_val);
    HAL_Delay(1);  // 等待写操作完成
    WM8960_ReadReg(0x02, &read_back);
    
    if (read_back == test_val)
        printf("Write/Read test PASS\n");
    else
        printf("Write/Read test FAIL: wrote 0x%02X, read 0x%02X\n", test_val, read_back);
}

3.6 常见问题与避坑指南

我在多个项目里调过WM8960的I2C,下面这几个坑几乎每次都会遇到:

  • I2C总线卡死:如果SCL被拉低超过一定时间,STM32的I2C外设会进入总线忙状态。解决办法是复位I2C外设,或者重新初始化GPIO。我写了个I2C_ResetBus()函数,在每次通信前检查总线状态。
  • 地址错误:WM8960的7位地址是0x1A,但有些数据手册写的是0x34(8位写地址)。如果你用8位地址模式,记得左移一位。HAL库默认用7位地址,所以直接传0x1A就行。
  • 寄存器地址顺序:16位地址必须先发高字节。HAL库的I2C_MEMADD_SIZE_16BIT已经处理好了,但如果你自己用HAL_I2C_Master_Transmit手动拼数据包,一定要先发高8位。
  • 上电时序:WM8960上电后需要等待至少10ms才能进行I2C通信。我习惯在初始化函数开头加一个HAL_Delay(20),确保芯片完全准备好。

调试利器:如果你手头有逻辑分析仪,抓一下I2C波形。看START条件、地址、ACK/NACK、数据、STOP条件是否完整。我曾经靠这个发现了一个上拉电阻虚焊的问题——波形上升沿明显变缓,导致从机无法正确采样。

3.7 I2C通信流程图

下面这张图展示了WM8960 I2C通信的完整流程,从初始化到读写操作:

WM8960 I2C通信流程图 WM8960 I2C通信流程图 初始化I2C外设 配置SCL/SDA为开漏输出 延时20ms等待芯片稳定 选择写操作或读操作 写操作 读操作 HAL_I2C_Mem_Write (地址0x1A, 16位寄存器地址) 发送1字节数据 HAL_I2C_Mem_Read (地址0x1A, 16位寄存器地址) 读取1字节数据 验证通信:读ID或写后读回 ! 读操作内部包含写地址过程

这张图把整个流程串起来了。你从初始化开始,一步步走到验证结束。如果中间任何一步出错,逻辑分析仪抓波形是最快的排查手段。

3.8 小结

这一章我们搞定了WM8960的I2C控制接口。说白了,就是三件事:初始化I2C外设、写寄存器、读寄存器。代码量不大,但细节不少——地址格式、寄存器宽度、上电时序,哪个环节出问题都会导致通信失败。

我个人建议,拿到新板子后,先跑一遍WM8960_TestCommunication函数。如果设备ID读回来是0x4F,说明I2C通信已经通了,后面调音频通路就心里有底了。如果读不到,别急着怀疑代码,先检查硬件——供电、上拉电阻、引脚连接,这些基础问题往往是最容易忽略的。