3. HAL库I2C基础API:掌握核心收发函数

好,咱们进入实战环节。配置WM8960,说白了就是通过I2C往它的寄存器里写数据。你想想看,一个音频编解码器,少说几十个寄存器,每个寄存器控制着不同的功能模块——音量、采样率、输入输出路由等等。不会用I2C API,后面所有配置都无从谈起。

我个人习惯,在开始写代码之前,先把HAL库提供的I2C收发函数摸透。别急着复制粘贴,搞清楚每个参数的含义,后面调试能省一半时间。

3.1 核心API全景图

先给你一张总览图,看看HAL库为I2C主机模式提供了哪些“武器”:

HAL库I2C主机模式核心API总览 I2C_HandleTypeDef hi2c 阻塞式(轮询) 中断式(IT) DMA式 HAL_I2C_Master_Transmit HAL_I2C_Master_Receive HAL_I2C_Mem_Write / Mem_Read HAL_I2C_Master_Transmit_IT HAL_I2C_Master_Receive_IT HAL_I2C_Mem_Write_IT HAL_I2C_Master_Transmit_DMA HAL_I2C_Master_Receive_DMA HAL_I2C_Mem_Write_DMA 配置WM8960推荐:阻塞式(轮询) 原因:配置阶段数据量小、频率低,阻塞式简单可靠,无需中断/DMA资源 注:WM8960寄存器配置属于“写多读少”场景,核心用 Transmit / Mem_Write 即可

嗯,这张图你应该能看懂。对于配置WM8960这种场景——说白了就是上电后一次性写几十个寄存器——我建议你用阻塞式(轮询)。为什么?因为配置阶段数据量小,频率低,用中断或DMA反而增加了代码复杂度,没必要。

3.2 最常用的两个函数

咱们先看两个最基础的:发送和接收。虽然配置WM8960主要用发送,但接收也得会,万一你要回读寄存器确认配置呢?

3.2.1 HAL_I2C_Master_Transmit

这个函数负责往从机设备发送数据。原型长这样:

HAL_StatusTypeDef HAL_I2C_Master_Transmit(
    I2C_HandleTypeDef *hi2c,   // I2C句柄
    uint16_t DevAddress,       // 从机地址(7位左对齐)
    uint8_t *pData,            // 要发送的数据缓冲区
    uint16_t Size,             // 数据长度(字节数)
    uint32_t Timeout           // 超时时间(毫秒)
);

参数看着多,其实就五个。我一个个说:

  • hi2c:你在CubeMX里配置好的I2C句柄,比如 &hi2c1。
  • DevAddress:从机地址。注意!HAL库要求7位地址左对齐。WM8960的7位地址是0x1A,左对齐后就是0x1A << 1 = 0x34。我刚开始用的时候忘了左移,折腾了半天才发现地址不对。
  • pData:指向要发送数据的指针。对于WM8960,通常是一个包含寄存器地址和值的数组。
  • Size:发送的字节数。
  • Timeout:超时时间。配置阶段我一般设100ms,足够了。

返回值:HAL_OK 表示成功,HAL_ERROR 表示参数错误,HAL_BUSY 表示I2C总线忙,HAL_TIMEOUT 表示超时。每次调用后务必检查返回值!

3.2.2 HAL_I2C_Master_Receive

接收函数,从从机读取数据:

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

参数和发送函数完全一样,只是数据方向反了。pData指向的缓冲区用来存放接收到的数据。

我的经验:WM8960的寄存器回读需要先发送寄存器地址,再重新启动接收。这时候用HAL_I2C_Master_Transmit发送地址,再用HAL_I2C_Master_Receive读数据,中间要加一个短暂的延时(至少10μs),否则从机可能来不及准备数据。

3.3 专为寄存器操作设计的Mem系列函数

等等,上面那两个函数虽然能用,但每次都要手动处理寄存器地址,有点麻烦。HAL库贴心地提供了Mem系列函数,专门用来操作有内部地址空间的设备——WM8960就是这种设备。

函数名 功能 适用场景
HAL_I2C_Mem_Write 向指定内存地址写入数据 配置WM8960寄存器(最常用)
HAL_I2C_Mem_Read 从指定内存地址读取数据 回读WM8960寄存器状态
HAL_I2C_Mem_Write_IT 中断版写操作 非阻塞场景(本课程不涉及)
HAL_I2C_Mem_Read_DMA DMA版读操作 大数据量场景(本课程不涉及)

看看Mem_Write的原型:

HAL_StatusTypeDef HAL_I2C_Mem_Write(
    I2C_HandleTypeDef *hi2c,
    uint16_t DevAddress,
    uint16_t MemAddress,       // 寄存器地址(WM8960为16位)
    uint16_t MemAddSize,       // 寄存器地址长度:I2C_MEMADD_SIZE_8BIT 或 16BIT
    uint8_t *pData,
    uint16_t Size,
    uint32_t Timeout
);

看到没?多了一个MemAddress和MemAddSize参数。WM8960的寄存器地址是7位,但HAL库要求按16位传,所以MemAddSize用I2C_MEMADD_SIZE_8BIT。我见过有人用16BIT,结果地址发出去多了一个字节,WM8960根本不认。

注意:WM8960的寄存器地址虽然只有7位有效,但HAL库的Mem_Write函数在MemAddSize=8BIT时,会发送一个字节的地址(高1位填充0)。所以你的MemAddress直接传0~0x7F范围内的值就行,不需要左移。

3.4 实战:写一个WM8960寄存器

光说不练假把式。咱们直接写一个函数,用来配置WM8960的任意寄存器:

/**
  * @brief  写WM8960寄存器
  * @param  reg_addr: 寄存器地址 (0x00 ~ 0x7F)
  * @param  reg_val:  寄存器值 (9位,但HAL库按16位传,高7位忽略)
  * @retval HAL_OK: 成功,其他: 失败
  */
HAL_StatusTypeDef WM8960_WriteReg(uint8_t reg_addr, uint16_t reg_val)
{
    uint8_t data[2];

    // WM8960的寄存器值虽然是9位,但I2C传输时按16位组织
    // 高字节在前,低字节在后
    data[0] = (reg_val >> 8) & 0xFF;   // 高8位
    data[1] = reg_val & 0xFF;          // 低8位

    // 使用Mem_Write,自动处理寄存器地址
    return HAL_I2C_Mem_Write(
        &hi2c1,                // 假设使用I2C1
        WM8960_ADDR << 1,     // 7位地址左移,WM8960_ADDR = 0x1A
        reg_addr,              // 寄存器地址
        I2C_MEMADD_SIZE_8BIT,  // 8位地址长度
        data,                  // 要写入的数据
        2,                     // 写入2个字节
        100                    // 超时100ms
    );
}

这段代码我实际项目里一直在用,稳定得很。有几个细节你注意一下:

  • WM8960_ADDR << 1:地址左移,因为HAL库要求左对齐。WM8960的7位地址是0x1A,左移后变成0x34。
  • data数组:WM8960的寄存器值是9位,但I2C传输时按16位传,高字节在前。所以data[0]放高8位,data[1]放低8位。
  • Size=2:每次写2个字节。WM8960的寄存器就是这么规定的,不能多也不能少。

避坑指南:我曾经犯过一个错——把data数组定义成uint16_t类型,然后直接传指针。结果HAL库按字节发送,把16位数据的高8位和低8位顺序搞反了。记住,pData参数要求的是字节数组,不是字数组。

3.5 读寄存器:验证配置是否成功

有时候你需要确认一下刚才写的值对不对。WM8960支持寄存器回读,用Mem_Read实现:

HAL_StatusTypeDef WM8960_ReadReg(uint8_t reg_addr, uint16_t *reg_val)
{
    uint8_t buf[2];
    HAL_StatusTypeDef status;

    // 先发送寄存器地址(Mem_Read内部会处理)
    status = HAL_I2C_Mem_Read(
        &hi2c1,
        WM8960_ADDR << 1,
        reg_addr,
        I2C_MEMADD_SIZE_8BIT,
        buf,
        2,
        100
    );

    if (status == HAL_OK) {
        // 组合成16位值,注意字节序
        *reg_val = ((uint16_t)buf[0] << 8) | buf[1];
    }

    return status;
}

嗯,这里有个小坑。WM8960回读时,返回的也是2个字节,高字节在前。所以组合的时候要buf[0]左移8位再或上buf[1]。顺序搞反了,读出来的值就是错的。

3.6 超时时间的玄学

Timeout参数设多少合适?我一般遵循这个原则:

场景 推荐超时 说明
上电初始化阶段 100ms WM8960刚上电可能还没准备好,给足时间
正常运行时的配置 50ms 设备已稳定,超时太长会阻塞主循环
批量写多个寄存器 每个寄存器10ms 连续写时,每个操作之间留点余量

我个人习惯在调试阶段把超时设大一点(比如500ms),等程序稳定了再改小。不然I2C总线一堵,程序卡死在HAL_I2C_Mem_Write里,你连调试器都连不上。

3.7 错误处理:别让程序死得不明不白

每次调用I2C函数后,一定要检查返回值。我见过太多人写完寄存器就不管了,结果WM8960根本没配置成功,音频出不来还找不到原因。

推荐的做法:

if (WM8960_WriteReg(0x00, 0x0000) != HAL_OK) {
    // 错误处理:可以重试,或者记录错误标志
    Error_Handler();
}

如果连续失败,别盲目重试。先检查硬件:

  • I2C总线上拉电阻有没有焊?我遇到过没焊上拉电阻,总线一直低电平。
  • WM8960的供电正常吗?特别是AVDD和DVDD。
  • 地址对不对?0x1A左移后是0x34,别写成0x1A。

核心要点总结:

  • 配置WM8960用HAL_I2C_Mem_Write,阻塞模式最省心
  • 从机地址必须左移1位(7位转8位)
  • 寄存器值按高字节在前组织,用uint8_t数组传
  • 每次调用后检查返回值,别偷懒
  • 超时时间根据场景灵活调整,调试阶段设大点