第3章 I2C/SPI控制总线:I2C协议在Codec配置中的应用、SPI接口与寄存器读写、多设备总线仲裁

好,咱们进入控制总线这一章。说实话,这部分是音频驱动开发里最容易被忽视,但又最容易出坑的地方。你想想看,Codec芯片本身再牛,如果控制总线没调通,它就是个哑巴。我这些年调试过的Codec不下二十款,从低端到高端,几乎每次翻车都跟总线时序或地址冲突有关。

3.1 I2C协议在Codec配置中的应用

I2C是Codec配置的绝对主力。为什么?因为它只需要两根线——SCL和SDA,而且支持多设备挂载。对于Codec这种寄存器数量不多、配置频率不高的外设来说,I2C简直是天选之子。

I2C的通信流程其实很简单:

  • 起始条件:SCL高电平时,SDA从高变低
  • 设备地址+读写位:7位地址+1位读写标志
  • 从机应答:从机拉低SDA表示收到
  • 数据传输:每字节后跟一个应答位
  • 停止条件:SCL高电平时,SDA从低变高

我个人习惯把Codec的I2C地址记成两个:写地址和读地址。比如WM8960,7位地址是0x1A,那写地址就是0x34,读地址是0x35。很多新手搞混这个,我刚开始也犯过这错。

核心要点:Codec的I2C寄存器读写,本质就是「设备地址 + 寄存器地址 + 数据」的三段式操作。

写操作示例:

// 向WM8960的寄存器0x10写入0x03
// 步骤:起始 -> 0x34(写) -> ACK -> 0x10(寄存器) -> ACK -> 0x03(数据) -> ACK -> 停止

uint8_t buf[2] = {0x10, 0x03};
i2c_master_write(0x34, buf, 2);  // 封装好的I2C写函数

读操作稍微绕一点:

// 从WM8960的寄存器0x10读取当前值
// 步骤:起始 -> 0x34(写) -> ACK -> 0x10(寄存器) -> ACK -> 重启 -> 0x35(读) -> ACK -> 读取数据 -> NACK -> 停止

uint8_t reg = 0x10;
uint8_t val;
i2c_master_write(0x34, &reg, 1);   // 先写寄存器地址
i2c_master_read(0x35, &val, 1);    // 再读数据

嗯,这里要注意:有些Codec支持自动地址递增。你连续读写多个寄存器时,它会自动把地址加1。这在批量配置时特别有用。我在项目中遇到过,用单次读写方式配置整个Codec,结果初始化花了50ms,换成批量模式后直接降到5ms。

经验之谈:调试I2C时,逻辑分析仪是你的好朋友。别信代码里的打印,直接抓波形看。我曾经花了两小时查一个「写不进去」的问题,最后发现是上拉电阻焊错了。

3.2 SPI接口与寄存器读写

SPI在Codec里用得少一些,但一旦用上,基本都是高速或高精度场景。比如一些专业音频Codec,采样率192kHz、24bit,寄存器配置量巨大,I2C那100kHz或400kHz的速度就有点捉襟见肘了。

SPI的四根线:

  • SCLK:时钟,由主设备产生
  • MOSI:主出从入
  • MISO:主入从出
  • CS:片选,低电平有效

SPI读写Codec寄存器的套路和I2C不太一样。大多数Codec用的是「16位地址+8位数据」的格式,或者「8位地址+8位数据」。我见过最奇葩的是TI的TLV320AIC系列,它用7位地址+9位数据,因为寄存器是9位的。

// SPI写操作示例:向地址0x1234写入0x56
// 通常格式:CS拉低 -> 发送16位地址(高位在前) -> 发送8位数据 -> CS拉高

uint16_t addr = 0x1234;
uint8_t data = 0x56;

cs_low();
spi_transfer((addr >> 8) & 0xFF);  // 地址高8位
spi_transfer(addr & 0xFF);          // 地址低8位
spi_transfer(data);                 // 数据
cs_high();

读操作更讲究。有些Codec用「写地址+读数据」的两阶段模式,有些则用「地址包带读写位」的方式。我建议你仔细看数据手册的时序图,别想当然。

避坑指南:我曾经在SPI读操作上栽过跟头。某款Codec要求读操作时,地址的最高位要置1表示读。我没注意,结果读回来的全是0。查了两天才发现是地址格式搞错了。

SPI的速度优势很明显。I2C标准模式100kHz,快速模式400kHz。而SPI轻松跑到10MHz甚至更高。你想想看,如果Codec有100个寄存器要配置,I2C需要100 * (地址+数据) 约300个时钟周期,而SPI只需要100 * (地址+数据) 约24个时钟周期(假设16位地址+8位数据)。差距是10倍以上。

3.3 多设备总线仲裁

这是个大坑。一个I2C总线上挂多个设备,或者一个SPI总线上挂多个从机,怎么保证不打架?

I2C的多设备仲裁

I2C本身支持多主多从。仲裁发生在数据线SDA上。当两个主设备同时拉低SDA时,谁先拉低谁赢。但说实话,在Codec场景下,我们很少用多主模式。一般都是单主(SoC)多从(Codec、传感器、EEPROM等)。

关键点在于地址不能冲突。每个I2C设备都有唯一的7位地址。Codec通常提供1-2个地址引脚(如AD0、AD1),通过拉高拉低来改变地址。比如:

AD0电平 7位地址 写地址 读地址
GND 0x1A 0x34 0x35
VDD 0x1B 0x36 0x37

如果板子上有两个相同的Codec,你必须给它们分配不同的地址。我见过一个项目,硬件工程师把两个Codec的AD0都接地了,结果I2C总线上两个设备地址一样,读写全乱套。

SPI的多设备仲裁

SPI的仲裁简单粗暴——靠片选CS。每个从机独占一根CS线。主设备想跟谁说话,就把谁的CS拉低,其他从机的CS保持高电平。从机看到CS为高,就乖乖闭嘴,MISO输出高阻。

但这里有个隐藏问题:MISO线的电平冲突。如果两个从机同时被选中(比如软件bug导致两个CS同时拉低),它们会同时在MISO上输出数据,造成短路。轻则数据错误,重则烧毁IO口。

设计原则:SPI总线上,同一时刻只能有一个从机的CS为低。这是铁律,别挑战它。

实际项目中,我建议你这样做:

  1. 画清楚总线拓扑图:哪个设备挂在哪条总线上,地址是什么,CS用哪个GPIO
  2. 初始化时做设备探测:上电后先尝试读每个设备的ID寄存器,确认设备存在且地址正确
  3. 加锁保护:多线程环境下,I2C/SPI操作要加互斥锁,防止两个线程同时操作总线
  4. 留足时序余量:I2C的上拉电阻、SPI的时钟频率,别卡着极限值设计

我的习惯:每个Codec驱动里,我都会写一个check_id()函数。上电后先读芯片的ID寄存器,跟预期值比对。对不上就直接报错,不往下走。这招帮我抓到了至少三次硬件焊接问题。

最后说一句,总线仲裁这事儿,软件能做的有限。硬件设计上把地址和CS分配好,比你在代码里加一万个判断都管用。嗯,今天就到这儿,下一章咱们聊聊Codec的电源管理和时钟树,那才是真正让人头大的部分。