STM32F103 驱动 AT24C02 这活儿,看着就是个入门级 I2C 实验,但真上手调过的人都知道,里面的坑一点都不少。尤其 F103 那颗硬件 I2C 外设,官方勘误表列了一堆事件异常,不少哥们第一次调 I2C 就被它卡了两三天,最后老老实实换成软件模拟 I2C,才把 EEPROM 的读写跑通。这篇我会把 AT24C02 读写全流程掰开揉碎讲清楚,从 I2C 协议的时序细节、器件地址怎么算,到软件 I2C 和硬件 I2C 两套方案的完整实现都过一遍,再把我踩过的坑和排查思路整理成表格。准备做掉电保存、设备参数存储的开发者,或者刚学完 I2C 协议想上手实战的朋友,这篇的建议应该能帮你少走不少弯路。
1. 项目概述:这个组合到底解决什么问题
1.1 不到一块钱容量还够用的“小纸条”
AT24C02 是一颗 2Kbit(256 字节)的串行 EEPROM,跑在标准 I2C 协议下。它的定位可以理解成一张“掉电不消失的小纸条”:主控芯片正常工作时随时可以往上面写数据,断电之后数据依然保留。这个特性天然适合用来存那些“平时要改、断电不能丢”的信息,比如设备校准参数、用户配置项、开机次数、日志索引等等。
我在一个模拟项目里做过一台温控仪表,温度阈值、PID 系数、设备地址这些参数都放在 AT24C02 里。生产组装的时候用上位机通过串口写一次,客户现场如果需要调整阈值,也只需要通过按键或者指令写进去。相比把参数烧死在程序里,这种方案改参数不用重新烧录,灵活度高很多。
1.2 核心参数与选型理由
先看一组 AT24C02 的关键参数:
| 参数 | 数值 | 说明 |
|---|---|---|
| 容量 | 256 字节 | 分 32 页,每页 8 字节 |
| 写周期时间 | 约 5ms | 写完一页或单字节后都需要等待 |
| 擦写寿命 | 100 万次 | 系统设计时要考虑磨损均衡 |
| 数据保持 | 100 年 | 断电后数据不丢失 |
| 接口 | I2C | 标准模式 100kHz / 快模式 400kHz |
在 1Kbit 到 16Kbit 的小容量存储区间,I2C 接口 EEPROM 是绝对的主流。为什么不干脆用 SPI 接口的 EEPROM?SPI 速度更快,但要多占两根 IO。为什么不用主控内部 Flash?因为 Flash 是按扇区擦除的,改写一个字节也要先整块擦掉,而且擦写次数只有 1 万次左右,用来频繁记录小型参数很容易把 Flash 写废。EEPROM 的字节级擦写能力和 100 万次寿命,正好匹配“小数据、频繁改、要掉电保存”的场景。
1.3 整体设计思路
整个项目的核心链路是:STM32F103 通过 I2C 总线向 AT24C02 发送地址字节和数据字节,AT24C02 执行写入后在内部完成擦写;读操作则是主控发出读地址,AT24C02 把指定存储单元的数据依次推出来。我们真正要搞定的是三件事:
- 底层时序:起始、停止、ACK 响应、字节的位传输
- 中间层驱动:针对 AT24C02 的页写、随机读、连续读
- 上层应用策略:写保护、磨损均衡、参数结构体分布
很多人一上来就抄驱动代码,结果拿到板子上要么先死在“无 ACK”,要么死在“写入后读出来全是 0xFF”。根本原因不是代码抄得不对,而是没搞懂协议层每个字节、每个电平背后的含义。下面把 I2C 协议先吃透,再上手代码时就会顺很多。
2. 动手前必须吃透的 I2C 协议细节
2.1 物理层:开漏结构和上拉电阻决定了总线形态
I2C 总线物理层面很简单,SCL 和 SDA 两根线都是开漏结构,平时靠外部上拉电阻把电平拉到高。任何设备想发信号,只需要把自己的输出管脚拉低;释放管脚,电平自动被上拉回高。
这个“谁都能拉低”的设计带来两个重要特性。一是多设备可以挂在同一总线上,靠地址区分谁应答;二是线与逻辑天然实现了仲裁,如果两个主控同时拉低,总线就是低,谁先释放谁可能就输了。对初学者来说,先记住最直接的结论:总线上所有设备空闲时,SCL 和 SDA 必须都是高电平,这是判断总线是否“活着”的第一指标。
还有一个容易忽略的点:开漏输出模式下,把引脚写成 1 其实是释放引脚而不是真正输出高电平。所以软件模拟 I2C 时,想读回 SDA 电平,直接用读 IDR 寄存器的方式就行,不需要把 GPIO 模式切来切去,这也是后面代码能写得很简洁的基础。
2.2 起始、停止和数据位的时序拆解
I2C 协议里最关键的两个条件:
- 起始条件(START):SCL 保持高电平时,SDA 由高跳变到低
- 停止条件(STOP):SCL 保持高电平时,SDA 由低跳变到高
为什么要规定“SCL 高电平期间 SDA 不能变化”?因为数据位的规则正是:SCL 高电平期间 SDA 的电平代表当前位的数据,SCL 低电平期间 SDA 才可以切换。如果你把 SCL 拉高后 SDA 还乱跳,设备就会把它误解为起始或停止信号。每次通信都以 START 开始、以 STOP 结束,一次完整事务中间的所有数据都必须遵守“SDA 切换在低电平,SCL 采样在高电平”的铁律。
数据字节的传输顺序也是固定的:先是最高位(MSB),一个时钟脉冲传输一位,8 个脉冲传完一个字节。第 9 个脉冲用于应答位,由数据接收方控制 SDA:接收方拉低表示 ACK,释放表示 NACK。主机每发完一个字节就要释放 SDA,观察从设备是否拉低应答,这是判断从设备是否“活着”的最快手段。
2.3 寻址字节与读写方向
I2C 设备地址是 7 位,总线主发设备地址时实际发 8 位:高 7 位是地址,最低位是读写标志(0=写,1=读)。以 AT24C02 为例,它的前 4 位固定为 1010,接下来 3 位由硬件引脚 A2 A1 A0 的电平决定,最后 1 位是 R/W。
把 A0、A1、A2 都接地,7 位地址就是 0b1010000,十六进制是 0x50。换算成总线实际发送的字节:写操作 0xA0,读操作 0xA1。如果板子上把 A0 拉高,写地址就从 0xA0 变成 0xA2,这个细节经常让人排查半天。
有个容易混淆的点:AT24C01(128 字节)因为字节地址只有 7 位,A0 引脚被并入字地址,所以设备地址固定为 0xA0/0xA1,A0 引脚不能再用来扩展地址。AT24C02 则要单独送完整的 8 位字节地址,三个引脚都可以灵活配置。刚开始不熟的时候建议老老实实把 A0~A2 全接地,统一用 0xA0/0xA1,减小变量。
3. 硬件设计要点:引脚、上拉电阻与方案取舍
3.1 GPIO 配置与上拉电阻计算
STM32F103 的 I2C1 对应 PB6(SCL)、PB7(SDA),I2C2 对应 PB10、PB11。选择引脚后,如果走硬件 I2C,GPIO 必须配置为开漏复用输出(AF_OD),让外设接管引脚控制权。用软件模拟 I2C 时,GPIO 模式直接配成开漏输出(Out_OD)即可,SDA 需要读回数据时直接读 IDR 寄存器,开漏模式下读 IDR 拿到的就是引脚实际电平,不需要切换输入模式。
上拉电阻怎么选?I2C 规范给了约束:标准模式(100kHz)下信号上升沿不能超过 1 微秒,快模式(400kHz)下不能超过 300 纳秒。上升沿时间由上拉电阻和总线电容共同决定,理论上阻值越小沿越陡,但阻值太小电流又太大,低电平会被拽得很不干净。常见做法:100kHz 用 4.7kΩ 到 10kΩ,400kHz 用 2.2kΩ 到 4.7kΩ。单板只有一颗从设备、线长在 10cm 以内时,4.7kΩ 基本通吃。
注意:部分开发板上的 I2C 引脚已经焊了上拉电阻,但有的板子什么也没焊。如果发现自己写的代码在别人板子上正常、自己板子上死活无应答,先量一下 SCL/SDA 空闲电压是不是接近 3.3V,如果被钳在某个低电平,八成就是缺上拉或者上拉阻值太小。
走线方面,SCL 和 SDA 尽量并行走线,远离电机驱动、继电器这类强干扰源。EEPROM 的 VCC 引脚旁边放一颗 0.1uF 去耦电容,紧贴器件引脚放置,能挡掉不少高频噪声。虽然这些属于基础操作,但在排查疑难问题的时候,很多“偶发数据错乱”最后都归到了布局和电源上,提前做好能省一大半调试时间。
3.2 软件 I2C 与硬件 I2C 的取舍
这是项目一开始就必须做的选择题。
硬件 I2C 的优势是 CPU 负担低、时序由外设精确控制,但 F103 这颗硬件 I2C 外设历史上存在不少设计缺陷和勘误项——事件标志位在特定时序下会卡住、总线忙标志 BUSY 释放不及时等等。官方虽然给了规避方法,但对新手来说每一步都像在踩雷。
软件 I2C 用普通 GPIO 模拟时序,代码所见即所得,出问题也很好定位,代价是 CPU 要参与到每个比特的翻转里,传输速率上不去。但仔细想想,一个字节大约几十微秒的 CPU 占用,对大多数控制类应用毫无压力。对 256 字节的 EEPROM 来说,即使软件模拟,写完整个芯片也用不了几毫秒。
我的建议是:项目里如果没有极端性能要求,优先用软件 I2C 跑通功能,再根据余力去研究硬件 I2C。调通功能比炫外设用法重要得多。后面第四部分先给出完整可用的软件 I2C 代码,第五部分再讲硬件 I2C 怎么配、哪些坑必须绕开。
4. 软件 I2C 完整代码拆解
4.1 底层时序函数
直接贴一套我验证过、结构也比较干净的软件 I2C 实现。先把引脚和相关操作宏定义好:
#define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_PORT GPIOB #define SCL_H() (I2C_PORT->BSRR = I2C_SCL_PIN) #define SCL_L() (I2C_PORT->BRR = I2C_SCL_PIN) #define SDA_H() (I2C_PORT->BSRR = I2C_SDA_PIN) #define SDA_L() (I2C_PORT->BRR = I2C_SDA_PIN) #define SDA_READ() ((I2C_PORT->IDR & I2C_SDA_PIN) != 0)然后是四个核心时序原语,分别是延时、起始、停止、写字节和读字节:
static void I2C_Delay(void) { uint8_t i = 6; while (i--); } void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); // SCL 高时 SDA 拉低,形成起始条件 I2C_Delay(); SCL_L(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); // SCL 高时 SDA 拉高,形成停止条件 I2C_Delay(); } uint8_t I2C_WriteByte(uint8_t data) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (data & 0x80) SDA_H(); else SDA_L(); data <<= 1; SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); } SDA_H(); // 释放 SDA,等待从设备应答 SCL_H(); I2C_Delay(); ack = SDA_READ() ? 1 : 0; SCL_L(); I2C_Delay(); return ack; // 返回 1 表示收到 NACK,0 表示 ACK } uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i, data = 0; SDA_H(); // 释放 SDA,让从设备驱动数据 for (i = 0; i < 8; i++) { data <<= 1; SCL_H(); I2C_Delay(); if (SDA_READ()) data |= 0x01; SCL_L(); I2C_Delay(); } if (ack) SDA_L(); // 主机应答:还需要继续读 else SDA_H(); // 主机非应答:结束读取 SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); SDA_H(); return data; }几个值得说道的细节:
- 起始和停止操作的本质区别,就是 SCL 高电平期间 SDA 的跳变方向,所以代码里都强调“先给 SCL 拉高,再动 SDA”。顺序一旦颠倒,从设备就认不出这个条件。
- 从设备应答是“用低电平表示 ACK”的,所以 SDA_READ 读到 1 意味着没有应答,返回 1 给上层做错误处理。这个方向别搞反了,否则错误处理逻辑全乱。
- 模拟 I2C 的延时直接影响总线速率。上面 I2C_Delay 配合 72MHz 主频,实测 100kHz 标准模式时序很稳。想跑 400kHz 就把循环次数调小,但前提是波形还能满足 AT24C02 数据手册里的建立时间和保持时间要求。用逻辑分析仪抓一次波形来校准延时,比拍脑袋猜靠谱得多。
4.2 AT24C02 单字节读写
有了时序原语,写 AT24C02 就变得很直白。先定义设备地址:
#define AT24C02_WR 0xA0 #define AT24C02_RD 0xA1单字节写:
uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); if (I2C_WriteByte(AT24C02_WR)) goto err; // 设备地址无应答 if (I2C_WriteByte(addr)) goto err; // 字节地址无应答 if (I2C_WriteByte(data)) goto err; // 数据无应答 I2C_Stop(); delay_ms(5); // 等待内部写周期完成 return 0; err: I2C_Stop(); return 1; }随机读:
uint8_t AT24C02_ReadByte(uint8_t addr, uint8_t *data) { I2C_Start(); if (I2C_WriteByte(AT24C02_WR)) goto err; if (I2C_WriteByte(addr)) goto err; // 重复起始:不发停止,直接再次发起起始 I2C_Start(); if (I2C_WriteByte(AT24C02_RD)) goto err; *data = I2C_ReadByte(0); // 最后一个字节回 NACK I2C_Stop(); return 0; err: I2C_Stop(); return 1; }随机读的流程非常典型:先“伪写”设置字节地址,再用重复起始把方向切到读。这里的“重复起始(Restart)”不是简单的 START+STOP,而是从写流程无缝切换到读流程,期间总线不能释放。如果中间插了一个 STOP 再 START,有些从设备状态机会被搞乱,读出来的地址不对。
写周期这件事再强调一遍:AT24C02 在收到数据后会进入内部擦写状态,这个状态下器件不响应任何总线操作,时间大约 5ms。所以每次写操作之后必须延迟至少 5ms,或者用后面讲的 ACK 轮询方式确认写周期结束。忘了这个延时,紧跟着写下一组数据,结果就是数据丢失或者写错位置。
4.3 页写入与连续读:留意 8 字节的墙
AT24C02 支持页写,一次最多写 8 个字节,正好一页。页写函数在单字节写基础上循环发送即可,但有个必须记住的边界问题:
当写入起始地址不在页边界时,连续写入不能跨越页边界。例如你从地址 0x06 开始写 4 个字节,地址 0x06 到 0x09 恰好跨过了第 0 页的边界(0x00~0x07),器件会把 0x09 的内容“卷”到 0x00 去,数据直接串位。这是 AT24C02 以及几乎所有同系列 EEPROM 的固定行为,驱动里必须做分页保护。
uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *data, uint8_t len) { uint8_t page_remain, chunk; if (len == 0) return 0; // 计算从当前地址到本页末尾还能写几个字节 page_remain = 8 - (addr % 8); chunk = (len > page_remain) ? page_remain : len; I2C_Start(); if (I2C_WriteByte(AT24C02_WR)) goto err; if (I2C_WriteByte(addr)) goto err; for (uint8_t i = 0; i < chunk; i++) { if (I2C_WriteByte(data[i])) goto err; } I2C_Stop(); delay_ms(5); return 0; err: I2C_Stop(); return 1; }这里的 chunk 就是真正能在当前页内写入的字节数。如果 len 超过 chunk,这个函数只写了第一部分,剩余数据需要放到下一轮循环,起始地址变成 addr + chunk,直到全部写完。写超过 8 字节的数组时,这种“按页切割”的写法特别重要,我见过不少人写了 16 字节数据,读回来却是乱序的,根因就是跨页回卷。
连续读则是另一回事。起始地址确定后,只要主机一直回 ACK,从设备就会自动把地址递增,一页接一页读下去,直到主机对最后一字节回 NACK 结束。所以连续读完全可以一口气读完 256 字节,不需要像写那样做分页处理。要注意的只有一点:最后一字节必须回 NACK,否则器件以为你还要继续读,STOP 条件之后总线状态会变得很别扭。
5. 硬件 I2C 的寄存器级实现与经典坑
5.1 基本配置流程
软件 I2C 跑通后,不少人会想折腾一把硬件 I2C。用标准外设库配置 I2C1 大概是这个流程:
void I2C1_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; I2C_InitTypeDef I2C_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStruct); I2C_InitStruct.I2C_Mode = I2C_Mode_I2C; I2C_InitStruct.I2C_ClockSpeed = 100000; I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2; I2C_InitStruct.I2C_Ack = I2C_Ack_Enable; I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStruct.I2C_OwnAddress = 0x00; I2C_Init(I2C1, &I2C_InitStruct); I2C_Cmd(I2C1, ENABLE); }发送一字节数据的标准事件轮询写法:
void AT24C02_HW_WriteByte(uint8_t addr, uint8_t data) { // 起始条件 I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 发送设备写地址 I2C_Send7bitAddress(I2C1, 0x50, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 发送字节地址 I2C_SendData(I2C1, addr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); // 发送数据 I2C_SendData(I2C1, data); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 停止条件 I2C_GenerateSTOP(I2C1, ENABLE); delay_ms(5); }代码看起来顺理成章,但在某些场合就是会在某个 while 里死等。我调试时习惯在每个事件等待循环里加一个计数器,超时后打印出当前卡在哪个阶段,很快就能定位是起始没发出去、还是地址没被应答、还是数据没发完。
5.2 F103 硬件 I2C 的经典问题与规避
F103 硬件 I2C 的“名声不好”不是空穴来风。官方勘误表较长,跟日常开发强相关的几类问题:
- EV7 事件(接收数据寄存器非空)在快模式下可能不触发,导致读流程卡死在等待数据的中途。
- BUSY 标志偶发不清除,一次通信异常后,后续所有操作都进不了 START。
- 在关闭外设时若正处于发送状态,个别情况下 SDA 会被一直拉低,总线被锁死。
有三个我在实际项目中验证有效的规避手段:
- 所有事件等待都加超时,超时后执行“软复位”:关闭 I2C 外设、重新使能,必要时把 GPIO 重新初始化一遍再重试。
- 通信异常恢复时,用软件把 SCL 和 SDA 分别拉高、拉低几个脉冲,手动把挂在总线上的从设备状态机复位掉。
- 如果条件允许,直接切回软件 I2C 驱动。控制类应用中性能代价通常可以忽略,换来的是稳定迭代的速度。
顺便提一句 HAL 库:如果是从标准外设库移植到 HAL,HAL_I2C_Master_Transmit 这类阻塞接口内部也要轮询事件,仍然要给它合理的超时参数,否则总线异常时照样卡死。很多人吐槽 HAL 的 I2C 卡死,本质上还是 F103 底层那点事被原封不动搬到了 HAL 层而已。
6. 常见问题、调试工具与避坑记录
6.1 问题排查速查表
我把自己在 AT24C02 调试里遇到过的、以及帮别人排查过的典型问题整理成了一张表,可以直接当排查手册用:
| 现象 | 可能原因 | 排查/解决建议 |
|---|---|---|
| 写地址后无 ACK | 上拉电阻缺失或阻值过大 | 测量 SDA 空闲电平是否接近 VCC;确认 0xA0 与 A0~A2 实际引脚连接是否一致 |
| 读回来全是 0xFF | 写入未成功或地址不对 | 检查写周期延时是否够 5ms;用单字节读定位实际写入位置 |
| 写数据正确但断电丢失 | 器件写保护引脚被拉高 | 检查 WP 引脚是否接地,AT24C02 的 WP 拉高时整片禁止写入 |
| 跨页写导致数据错位 | 未做页边界保护 | 写入前计算本页剩余字节,拆分成多页写入 |
| 数据偶发错乱 | 总线竞争或上拉过弱 | 降速到 100kHz,检查 SCL/SDA 波形;增加延时 |
| 硬件 I2C 卡死在等待事件 | F103 硬件 I2C 事件异常 | 增加超时;尝试软复位外设;必要时换软件 I2C |
这里特别想强调 WP 引脚。AT24C02 的写保护脚(第 7 脚)在高电平时整片禁止写入,特别坑。我遇到过有人把 WP 接到了 MCU 的一个输出引脚,程序里没初始化就直接写,结果因为 GPIO 复位后的默认状态把 WP 拉高,写操作全部静默失败——器件不报告任何错误,读出来的还是旧数据,排查了大半天才找到元凶。
6.2 调试工具与方法
排查 I2C 问题,最有效率的工具是逻辑分析仪。不用买贵的那种,几十块、采样率 24MHz 的八通道足够抓 100kHz/400kHz 的 I2C 波形了。把 SCL 接 CH0、SDA 接 CH1,触发方式选 SDA 下降沿(起始条件),一抓一个准。
抓完波形重点看几件事:
- 起始条件是不是在 SCL 高电平期间发生了 SDA 下降沿
- 数据位是不是在 SCL 低电平期间切换、高电平期间稳定
- 每发完一个字节,有没有出现主机释放 SDA、从机拉低应答的“低电平小口子”
- 停止条件是不是完整的 SCL 高电平、SDA 上升沿
这几项全部正常,协议层面基本没有大问题。如果波形一切正常但数据还是不对,那就把注意力放到应用层逻辑,比如地址计算、页边界、WP 引脚这些“软件之外的软件”。
没有逻辑分析仪,用 GPIO 翻转法也能凑合:在关键函数入口出口翻转一个空闲引脚,配合示波器看程序到底卡在哪一段。虽然粗糙,但配合串口打印状态码,已经能解决八成的问题。再不行就在软件 I2C 的 I2C_Start、I2C_Stop、发地址、发数据这些函数里分别打印日志,逐段排除。
6.3 驱动封装的两个建议
最后说点我自己的体会。AT24C02 这套驱动写完以后,我一般会顺手做两件小事,对后续量产和维护帮助很大。
第一件事是给读写加超时。软件 I2C 虽然“所见即所得”,但如果总线被异常拉低——比如某次误操作把 SDA 配置成了推挽输出——软件 I2C 会卡在读 ACK 的死循环里。所有可能循环等待的位置都加上超时计数,超时后返回错误并复位总线状态。宁可多写 20 行代码,也不要在现场被一个死循环折腾到拔电。
第二件事是封装一层“校验读回”。写完一页数据后,立即用随机读把同一地址的数据读回来比对,不一致就重试几次,仍失败就上报错误。这个动作对调试期帮助极大,能把“写入成功但实际没写上”这类隐蔽问题直接暴露出来。量产版本里也可以保留这个机制,作为存储子系统的健康自检,成本不过几毫秒,收益却是让偶发故障提早暴露在明处,而不是等客户报告数据丢了才手忙脚乱。
这两点做完之后,我再看回最初被硬件 I2C 折磨的经历,反而觉得那段弯路挺值。正是因为没有一把梭直接用现成驱动,才把协议细节和器件行为都摸透了。后面再换任何 I2C 接口的传感器、RTC、DAC,调起来都快了很多,毕竟 I2C 这套底层逻辑是通用的,唯一要变的只是设备地址和寄存器映射而已。