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,它只干三件事:
- 提供统一的I2C读写入口
- 封装WM8960的寄存器读写协议
- 集中处理超时和错误码
核心原则: 上层代码(比如初始化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。这样耦合度很低,维护起来很舒服。
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的各种功能模块时,你会回来感谢自己的。