4. I2C驱动实现:STM32硬件I2C驱动编写、WM8960读写寄存器与通信验证

好,到了真正动手写代码的时候了。前面我们搞定了硬件连接,现在要让STM32和WM8960能“说上话”。WM8960的配置全靠I2C,所以这一章的核心就是——把I2C驱动写扎实,把读写寄存器函数封装好,最后验证通信是否正常。

我个人习惯把I2C驱动分成三层:底层硬件初始化、中间层读写函数、上层寄存器操作。这样分层清晰,出了问题也好定位。

4.1 STM32硬件I2C初始化

STM32的I2C外设,说白了就是一个硬件状态机。你配好时钟、引脚、速率,它就能自动处理起始信号、地址、数据、应答这些繁琐的时序。我们只需要往数据寄存器里填数就行。

以STM32F4系列为例,我用的是I2C1,PB6-SCL、PB7-SDA。初始化代码大致如下:

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;
    HAL_I2C_Init(&hi2c1);
}
小提示:时钟速度我习惯设100kHz。WM8960支持400kHz快速模式,但初期调试用100kHz更稳定。等通信验证通过后再提速,这是我在项目中踩过坑后养成的习惯。

引脚配置别忘了开漏输出和上拉。STM32的I2C引脚必须配成开漏模式,外部上拉电阻已经焊在板子上了。如果你用的是内部上拉,记得使能GPIO的上拉电阻。

4.2 WM8960写寄存器函数

WM8960的I2C设备地址是0x1A(7位地址)。写寄存器时,先发设备地址+写位,再发寄存器地址(8位),最后发寄存器数据(16位)。注意数据是16位的,但I2C每次只能传8位,所以要分两次发——先高8位,再低8位。

嗯,这里要注意:WM8960的寄存器地址只有7位有效,但协议里用了一个字节来传。高位的bit7必须为0,否则芯片不认。

HAL_StatusTypeDef WM8960_WriteReg(uint8_t reg_addr, uint16_t reg_val)
{
    uint8_t buf[3];
    buf[0] = reg_addr & 0x7F;          // 寄存器地址,只取低7位
    buf[1] = (reg_val >> 8) & 0xFF;   // 数据高8位
    buf[2] = reg_val & 0xFF;           // 数据低8位

    return HAL_I2C_Master_Transmit(&hi2c1, WM8960_ADDR << 1, buf, 3, 100);
}

为什么地址要左移一位?因为HAL库的地址参数需要包含读写位。WM8960_ADDR是7位地址0x1A,左移一位变成0x34,最低位留给HAL库内部填充读写标志。

避坑指南:我曾经在这里卡了整整半天——写寄存器时忘了把地址左移,结果示波器上看到地址一直不对。后来才发现HAL库的地址参数格式和裸机编程不一样。记住:HAL_I2C_Master_Transmit的地址参数是左移后的8位地址。

4.3 WM8960读寄存器函数

读寄存器稍微麻烦一点。需要先发一个写操作,把寄存器地址告诉WM8960,然后发一个重复起始信号,再发读操作,从芯片里读回两个字节的数据。

HAL_StatusTypeDef WM8960_ReadReg(uint8_t reg_addr, uint16_t *reg_val)
{
    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;

    // 第二步:读取两个字节的数据
    ret = HAL_I2C_Master_Receive(&hi2c1, WM8960_ADDR << 1, buf, 2, 100);
    if (ret != HAL_OK) return ret;

    *reg_val = ((uint16_t)buf[0] << 8) | buf[1];
    return HAL_OK;
}

读回来的数据是高字节在前,低字节在后,和写的时候顺序一致。这个顺序在WM8960的数据手册里有明确说明,但很多初学者会搞反。我建议你拿到数据后先打印出来看看,确认高低字节对不对。

4.4 通信验证方法

代码写完了,怎么知道通信是不是通的?我一般用三个步骤来验证:

  1. 读芯片ID寄存器——WM8960的寄存器0x00存放芯片ID和版本号,读出来应该是0x0043(具体值看芯片版本)。如果读到0xFFFF或0x0000,说明I2C没通。
  2. 写一个已知值再读回来——选一个可读可写的寄存器(比如0x02的电源管理寄存器),先写一个值,再读回来对比。这叫“回读验证”。
  3. 用示波器看波形——这是最直接的方法。看SCL和SDA上有没有完整的起始信号、地址、数据、应答。如果只有起始信号没有应答,说明地址不对或者WM8960没上电。

下面这张图展示了I2C通信验证的完整流程:

I2C通信验证流程图 初始化I2C外设 读寄存器0x00(芯片ID) ID == 0x0043 ? 是 写/读寄存器回读验证 否 检查硬件连接/上电 通信验证通过 ✓ 验证要点 • 确认SCL/SDA波形完整 • 确认设备地址正确 • 确认有ACK应答信号 • 回读数据与写入一致

4.5 验证代码示例

下面是一段完整的验证代码,你可以直接放到main函数里跑:

void I2C_Test(void)
{
    uint16_t chip_id = 0;
    uint16_t test_val = 0;
    uint16_t read_back = 0;

    // 第一步:读芯片ID
    if (WM8960_ReadReg(0x00, &chip_id) == HAL_OK)
    {
        printf("Chip ID: 0x%04X\r\n", chip_id);
        if (chip_id == 0x0043)
        {
            printf("芯片ID验证通过!\r\n");
        }
        else
        {
            printf("芯片ID错误,请检查硬件!\r\n");
            return;
        }
    }
    else
    {
        printf("I2C通信失败!\r\n");
        return;
    }

    // 第二步:回读验证
    test_val = 0x1234;
    WM8960_WriteReg(0x02, test_val);
    HAL_Delay(10);  // 等待写入完成
    WM8960_ReadReg(0x02, &read_back);

    if (read_back == test_val)
    {
        printf("回读验证通过!写入: 0x%04X, 读出: 0x%04X\r\n", test_val, read_back);
    }
    else
    {
        printf("回读验证失败!写入: 0x%04X, 读出: 0x%04X\r\n", test_val, read_back);
    }
}
关键点总结:
  • I2C初始化:时钟100kHz,引脚开漏输出,外部上拉
  • 写寄存器:先发地址,再发数据(高字节在前)
  • 读寄存器:先写地址,再重复起始,读回两字节
  • 验证方法:读芯片ID + 回读验证 + 示波器看波形

说实话,I2C驱动这部分看起来简单,但坑不少。我见过太多人卡在地址左移、高低字节顺序、应答超时这些小问题上。我的建议是——先把读芯片ID这一步跑通,只要ID对了,后面的配置就顺理成章了。