4、HAL库I2C驱动层实现:HAL_I2C_Master_Transmit/Receive封装、WM8960寄存器读写函数、超时与错误处理

好,咱们进入第四章节。前面几章我们把硬件原理图、WM8960的寄存器地图都捋了一遍。现在要动真格的了——写代码。

这一章的核心就一件事:让STM32通过I2C总线,能跟WM8960说上话。说白了,就是封装几个读写函数。你别小看这一步,我见过太多项目,音频初始化死活过不去,最后查出来是I2C通信本身就有问题。

4.1 为什么需要封装一层?

HAL库自带的 HAL_I2C_Master_Transmit() 和 HAL_I2C_Master_Receive() 能用吗?能用。但直接裸用,你会遇到几个麻烦:

  • 每次都要填设备地址、超时时间,代码重复率高
  • WM8960的寄存器读写有固定格式(7位地址 + 9位寄存器号 + 9位数据),HAL原生接口不直接支持
  • 错误处理散落在各处,出了问题很难定位

所以,我们要做一层薄薄的封装。我个人习惯,把这层叫做 BSP_I2C_Driver,它只干三件事:

  1. 提供统一的I2C读写入口
  2. 封装WM8960的寄存器读写协议
  3. 集中处理超时和错误码

核心原则: 上层代码(比如初始化WM8960的PLL、配置DAC)永远不要直接调用HAL_I2C_Master_Transmit。所有I2C操作都通过这层封装走。这样,哪天你要换I2C引脚、甚至换一颗主控芯片,只需要改这一层。

4.2 HAL_I2C_Master_Transmit/Receive 的封装

先看最基本的。我通常会写两个静态函数,把HAL的接口包一层:

/* bsp_i2c_driver.c */

#include "bsp_i2c_driver.h"
#include "i2c.h"   // HAL生成的I2C句柄

#define I2C_TIMEOUT_MS   100   // 默认超时100ms

static I2C_HandleTypeDef *hi2c_audio = &hi2c1;  // 假设音频I2C挂在I2C1上

/**
 * @brief  向I2C设备写入多字节数据
 * @param  dev_addr: 7位设备地址(左对齐)
 * @param  pdata: 数据缓冲区
 * @param  len: 数据长度
 * @retval HAL_StatusTypeDef
 */
static HAL_StatusTypeDef I2C_Write(uint16_t dev_addr, uint8_t *pdata, uint16_t len)
{
    return HAL_I2C_Master_Transmit(hi2c_audio, dev_addr, pdata, len, I2C_TIMEOUT_MS);
}

/**
 * @brief  从I2C设备读取多字节数据
 * @param  dev_addr: 7位设备地址(左对齐)
 * @param  pdata: 数据缓冲区
 * @param  len: 数据长度
 * @retval HAL_StatusTypeDef
 */
static HAL_StatusTypeDef I2C_Read(uint16_t dev_addr, uint8_t *pdata, uint16_t len)
{
    return HAL_I2C_Master_Receive(hi2c_audio, dev_addr, pdata, len, I2C_TIMEOUT_MS);
}

嗯,这里要注意:WM8960的I2C设备地址是7位 0x1A,但HAL库要求传入的是左对齐的8位地址。所以调用时要写成 0x1A << 1,也就是 0x34。我在项目中遇到过有人直接传 0x1A,结果设备一直无应答,查了半天才发现是地址没移位。

小技巧: 我习惯在头文件里定义一个宏:#define WM8960_ADDR (0x1A << 1),这样调用时直接用宏,既清晰又不容易出错。

4.3 WM8960寄存器读写函数

WM8960的寄存器读写协议是这样的:

  • 写操作:先发2字节寄存器地址(高9位有效,低7位填充0),再发2字节数据(高9位有效,低7位填充0)
  • 读操作:先发2字节寄存器地址,然后重新启动(Restart),再读取2字节数据

你想想看,如果每次都要手动拼这两个2字节的包,代码会变得很啰嗦。所以封装两个专用函数:

/**
 * @brief  写WM8960寄存器
 * @param  reg_addr: 9位寄存器地址 (0x00 ~ 0x1FF)
 * @param  reg_val:  9位寄存器值 (0x000 ~ 0x1FF)
 * @retval 0: 成功, -1: 超时, -2: 其他错误
 */
int WM8960_WriteReg(uint16_t reg_addr, uint16_t reg_val)
{
    uint8_t tx_buf[4];
    HAL_StatusTypeDef status;

    // 寄存器地址:高9位有效,放在前2字节
    tx_buf[0] = (reg_addr >> 1) & 0xFF;   // 高8位
    tx_buf[1] = (reg_addr & 0x01) << 7;   // 低1位放到bit7,其余为0

    // 寄存器值:高9位有效,放在后2字节
    tx_buf[2] = (reg_val >> 1) & 0xFF;    // 高8位
    tx_buf[3] = (reg_val & 0x01) << 7;    // 低1位放到bit7

    status = I2C_Write(WM8960_ADDR, tx_buf, 4);

    if (status == HAL_TIMEOUT) {
        return -1;   // 超时
    } else if (status != HAL_OK) {
        return -2;   // 其他错误
    }
    return 0;
}

/**
 * @brief  读WM8960寄存器
 * @param  reg_addr: 9位寄存器地址
 * @param  reg_val:  输出读取到的9位值
 * @retval 0: 成功, -1: 超时, -2: 其他错误
 */
int WM8960_ReadReg(uint16_t reg_addr, uint16_t *reg_val)
{
    uint8_t tx_buf[2];
    uint8_t rx_buf[2];
    HAL_StatusTypeDef status;

    // 先发送寄存器地址
    tx_buf[0] = (reg_addr >> 1) & 0xFF;
    tx_buf[1] = (reg_addr & 0x01) << 7;

    status = I2C_Write(WM8960_ADDR, tx_buf, 2);
    if (status != HAL_OK) {
        return (status == HAL_TIMEOUT) ? -1 : -2;
    }

    // 再读取2字节数据
    status = I2C_Read(WM8960_ADDR, rx_buf, 2);
    if (status != HAL_OK) {
        return (status == HAL_TIMEOUT) ? -1 : -2;
    }

    // 解析9位数据
    *reg_val = ((uint16_t)rx_buf[0] << 1) | (rx_buf[1] >> 7);
    return 0;
}

这里有个细节:WM8960的寄存器地址和数据都是9位的,但I2C每次传输是按字节走的。所以我们需要把9位数据拆成两个字节来发。拆法其实不复杂,但容易搞反高低位。我曾经在这个地方栽过跟头——写进去的值读出来总是对不上,后来用逻辑分析仪抓波形才发现是字节顺序搞反了。

特别注意: WM8960的数据手册里,寄存器地址是9位,但有些寄存器(比如0x00~0x3F)只用到了低7位。即便如此,你仍然必须按9位格式发送,高位补0。否则设备会忽略你的操作。

4.4 超时与错误处理策略

I2C通信不是100%可靠的。总线冲突、从设备忙、线路干扰都可能导致传输失败。所以错误处理不能马虎。

我的做法是:分层处理,向上返回统一错误码。

错误码 含义 常见原因
0 成功 -
-1 超时 I2C总线卡死、从设备无应答、SCL/SDA被拉低
-2 NACK错误 设备地址错误、寄存器不存在、从设备忙
-3 参数错误 传入的寄存器地址超出0x1FF、指针为NULL

在调用层,我建议这样处理:

/* 调用示例 */
int ret = WM8960_WriteReg(0x10, 0x01FF);
if (ret == -1) {
    /* 超时:可以尝试重新初始化I2C总线 */
    MX_I2C1_Init();
    ret = WM8960_WriteReg(0x10, 0x01FF);
} else if (ret == -2) {
    /* NACK:检查设备地址和硬件连接 */
    Error_Handler("WM8960 NACK");
} else if (ret != 0) {
    /* 未知错误 */
    Error_Handler("WM8960 unknown error");
}

嗯,这里有个经验:超时错误有时候是瞬态的,重试一次就能恢复。所以我一般在超时后做一次总线复位(重新调用HAL的I2C初始化函数),然后重试。如果连续3次都超时,才判定为硬件故障。

4.5 整体架构图

下面这张图展示了这一层的完整调用关系。你可以看到,上层应用(比如WM8960初始化模块)只跟 WM8960_WriteReg/ReadReg 打交道,不直接接触HAL。这样耦合度很低,维护起来很舒服。

I2C驱动层架构图 I2C驱动层架构图 上层应用(WM8960初始化/配置) WM8960_WriteReg / WM8960_ReadReg I2C_Write / I2C_Read(超时封装) HAL_I2C_Master_Transmit / Receive STM32 I2C外设 + WM8960 错误处理 • 超时重试 • NACK检测 • 参数校验 • 总线复位 • 错误码返回

4.6 完整头文件参考

最后,给出一份头文件的骨架。你直接复制到工程里,稍作修改就能用:

/* bsp_i2c_driver.h */
#ifndef __BSP_I2C_DRIVER_H__
#define __BSP_I2C_DRIVER_H__

#include <stdint.h>

/* WM8960 I2C设备地址(7位左对齐) */
#define WM8960_ADDR         (0x1A << 1)

/* 错误码定义 */
#define I2C_OK              0
#define I2C_ERR_TIMEOUT     -1
#define I2C_ERR_NACK        -2
#define I2C_ERR_PARAM       -3

/* 寄存器读写接口 */
int WM8960_WriteReg(uint16_t reg_addr, uint16_t reg_val);
int WM8960_ReadReg(uint16_t reg_addr, uint16_t *reg_val);

/* 批量写(用于初始化时一次配置多个寄存器) */
int WM8960_WriteMultiReg(const uint16_t *reg_addr_array, 
                         const uint16_t *reg_val_array, 
                         uint16_t count);

#endif /* __BSP_I2C_DRIVER_H__ */

看到那个 WM8960_WriteMultiReg 了吗?这是我额外加的一个函数。初始化WM8960时,往往要一口气写几十个寄存器。如果每个都单独调用 WM8960_WriteReg,效率低不说,代码也难看。批量写函数内部可以做个优化:连续地址的寄存器合并成一次I2C传输。不过这是进阶话题了,咱们后面章节再展开。

好,这一章就到这里。代码不多,但每一行都是经过实践检验的。你先把这层封装写好、调通,后面配置WM8960的各种功能模块时,你会回来感谢自己的。