手头一个采集小板用的主控是STM32G031K6,一直想用 HAL 库把 I2C(习惯叫 IIC)部分省心搞完,结果编译完才发现 32KB Flash 被 HAL 库吃掉一大块,后面还要塞算法和自检逻辑,顿时不淡定了。后来我把外设驱动全部换成LL 库重新写了一遍,I2C 主机模式从轮询到中断状态机都实测调通。这块芯片的 I2C 外设和 F1 系列差别不小,用 LL 库踩了几个很典型的坑,这篇就把整个过程和能直接抄的代码整理出来。
这篇文章适合正在用 G031/G0 系列做小项目的开发者,或者嫌 HAL 太占资源、想转 LL 库但不知道怎么下手的初学者。我会从为什么选 LL 库开始,讲到初始化、轮询收发、中断状态机,最后用 AT24C02 做个完整读写例子。照着走一遍,不说能精通,但至少能把一个稳定的 I2C 通讯跑起来。
1. 整体设计与方案选型
1.1 项目背景和真正的需求
当时项目要做的是一个低功耗传感器节点,主控选了 STM32G031K6,理由是便宜、功耗低、封装小,外设也够用。传感器通过 I2C 输出数据,板上还挂了一片 EEPROM 存校准参数,所以 I2C 是刚需。
麻烦在于功能叠加得比较快:低功耗定时唤醒、传感器读取、参数存储、串口调试、几个 GPIO 控制,再加上出厂自检。我用 CubeMX 生成了一个基础工程,没写几行业务代码,编译出来 Hex 就快到 Flash 上限了。这种时候要么砍功能,要么换更大 Flash 的型号,要么换更省资源的库。G031K8 虽然 Flash 大一倍,但当时已经画完板,换型号不现实。剩下最实际的路就是把手头 HAL 库的几个驱动重写。
LL 库正好是干这个的。它和 HAL 同源,属于 Cube 包的一部分,头文件就在同一个目录里,编译器不用额外装东西。但它的 API 设计方式完全不同:HAL 用句柄和回调,复杂但省心;LL 库基本是寄存器的“皮”,几乎没有任何状态机缓存,每个函数直接映射硬件位操作。性能和代码体积的优势很明显。
1.2 LL 库和 HAL 库的取舍逻辑
我见过不少人一上来就抵触 LL 库,理由是不如 HAL 好懂。实际上 LL 库的函数命名非常有规律,LL_I2C_TransmitData8看名字就知道是往发送数据寄存器里写一个字节,LL_I2C_IsActiveFlag_TXIS就是查发送缓冲空闲标志。只要对着参考手册的寄存器,基本能猜出八九成。
HAL 库的问题不在代码质量,在于它把所有场景都包了一层,哪怕你只用主机轮询发 10 个字节,初始化时也会初始化一堆句柄字段和回调函数,中断处理还要过一个事件翻译层。LL 库则是你写多少代码就编译进多少代码,没有多余的抽象。同一个工程切到 LL 库之后,光基础框架就省了不少空间,具体数字和优化有关,但肉眼可见地吃掉了 Flash 焦虑。
不过 LL 库也有代价,就是把 HAL 帮你兜着的事情全暴露出来了。比如发送完最后一个字节后要等 TC 还是 BTF,接收 N 个字节时什么时候要发 NACK,这些在 HAL 里可能一个接口就处理了,LL 得自己按状态机一步步来。但反过来说,正因为它强迫你理解协议细节,一旦调试通过,整个外设的工作过程你是真正心里有数的。
1.3 G031 的 I2C 外设特点
ST 从 G0 系列开始重新设计了 I2C 外设,和 F1/F3 时代那套 I2C 完全不同。G031 的 I2C1 没有像 F1 那样复杂的事件标志组合,而是用ISR寄存器里一组非常明确的标志位,比如TXIS、RXNE、TC、BTF、NACK、STOP。中断和轮询都在这些标志上做文章,反而比老的 I2C 事件标志好理解。
它也没有老的FREQ、TRISE这类用于时序配置的寄存器,取而代之的是一个TIMINGR寄存器,里面是调整 SCL 高低电平时间的计数值。这个寄存器是 G0 系列 I2C 配置的关键,用好了速度随便设,用不好频率能偏得离谱。
另外要提一点,G031 内部上拉电阻不是为 I2C 高速通信准备的,我实测 100kHz 工作正常,400kHz 拔掉外部上拉就偶尔出错。所以板上如果空间允许,最好还是焊外部上拉电阻。
2. 初始化:引脚、时钟、TIMINGR
2.1 引脚复用和开漏模式
G031 的 I2C1 引脚有多组映射,我最常用的就是 PA9/PA10,复用号是 AF1。下面这段是初始化前必须做好的准备工作:
LL_IOP_GRP1_EnableClock(LL_IOP_GRP1_PERIPH_GPIOA); LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_I2C1); // PA9: SCL, PA10: SDA LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_MODE_ALTERNATE); LL_GPIO_SetPinSpeed(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_SPEED_HIGH); LL_GPIO_SetPinOutputType(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_OUTPUT_OPENDRAIN); LL_GPIO_SetPinPull(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_PULL_UP); LL_GPIO_SetAFPin_8_15(GPIOA, LL_GPIO_PIN_9, LL_GPIO_AF_1); LL_GPIO_SetAFPin_8_15(GPIOA, LL_GPIO_PIN_10, LL_GPIO_AF_1);注意两个细节。第一,I2C 引脚必须配置为开漏,这是 I2C 协议的电气要求,SDA 和 SCL 都是线与关系。如果配成推挽输出,外部设备拉低总线的时候就会和引脚打架。第二,内部上拉在低速场景下能救急,但总线上挂多个从机或者线比较长的时候,必须靠外部上拉电阻保证信号质量。
时钟这部分,GPIOA 和 I2C1 要分别打开。G031 内部时钟树用起来很方便,直接用 HSI16 做起点,I2C 内核时钟单独配置。我项目里 I2C 内核时钟最后是 16MHz,这样后面算 TIMINGR 时按 62.5ns 一个计数来算就行。
2.2 I2C 外设初始化和 TIMINGR 的计算
G031 的 I2C 初始化顺序很固定:先关闭外设、配置模式、配置时序寄存器、设置自身地址,最后重新打开。代码骨架如下:
LL_I2C_Disable(I2C1); LL_I2C_ConfigTiming(I2C1, 0x00004F51); // 16MHz, 100kHz, CubeMX 微调值 LL_I2C_SetOwnAddress1(I2C1, 0x00, LL_I2C_OWNADDRESS1_7BIT); LL_I2C_SetMode(I2C1, LL_I2C_MODE_I2C); LL_I2C_SetDigitalFilter(I2C1, 0); LL_I2C_Enable(I2C1);这个0x00004F51是值得讲一讲的。TIMINGR寄存器从高到低依次是PRESC、SCLDEL、SDADEL、SCLH、SCLL。我们只看高低电平计算的部分:I2C 内核时钟如果是 16MHz,一个计数就是 1/16MHz = 62.5ns。100kHz 对应 SCL 周期 10us,换算成计数就是 10us / 62.5ns = 160 个计数。如果占空比按接近 50% 来取,SCLH 和 SCLL 各 80 个左右,十六进制就是 0x50。实际考虑到内部同步延迟和总线上升沿,CubeMX 会把它算得稍微偏一点,所以最终我用的是0x00004F51。
那SDADEL和SCLDEL是什么?SDADEL是 SDA 数据保持时间,SCLDEL是数据建立时间。I2C 协议对这两个时间有最小值要求,400kHz 快速模式下数据保持最小是 0.3us,建立最小是 0.1us。在 16MHz 时钟下,0.3us 最少要 5 个计数,0.1us 最少要 2 个计数。这也是为什么我不能直接给你一个万能时序值——你的 APB 时钟不同、目标速率不同、滤波设置不同,算出来的值都不一样。
如果你不想手算,最好的办法是让 CubeMX 在 I2C 配置界面里自动算,然后把生成代码里的TIMINGR值复制到自己工程里。这个方法我一直在用,省时又稳。不过我建议还是理解一下高低电平计数的换算关系,万一后面调频率偏了,至少知道往哪个方向调。
2.3 数字滤波器和总线挂载
LL_I2C_SetDigitalFilter(I2C1, 0)这行容易被忽略。G0 的 I2C 支持可编程数字滤波器,可以用来滤掉总线上的毛刺。设 0 表示关闭数字滤波,只用默认的模拟滤波。
在强干扰环境里,数字滤波很有用,比如设成 2 或 3,会明显提升抗干扰能力。但代价是增加了内部延迟,会影响最终 SCL 频率。所以如果你开了数字滤波器,TIMINGR里的值也需要相应调整,否则实际频率会比预期低。我的建议是初期调试先把滤波器关掉,把通讯调通之后再决定要不要开。
系统上电以后还有一个容易忽视的点:如果外部设备挂在总线上,而单片机还没初始化 I2C 引脚,这时候 SDA 和 SCL 都是高阻态,上拉电阻会把它们拉高,不会影响其他从机。但如果你初始化顺序反了,先配好 I2C 主模式再配 GPIO,外设可能在引脚还没有复用前就对内部状态做了错误判断。所以稳妥起见,GPIO 配置要放在 I2C 外设使能之前。
3. 轮询模式:主发送和主接收
3.1 主发送的完整流程
轮询模式最适合理解 I2C 主机的发送时序。下面这个是通用发送函数,dev_addr7是 7 位从机地址,buf是要发的数据:
static int i2c_master_tx(uint8_t dev_addr7, const uint8_t *buf, uint16_t len) { uint32_t tick; tick = get_tick(); while (LL_I2C_IsActiveFlag_BUSY(I2C1)) { if (get_tick() - tick > 100) return I2C_TIMEOUT; } LL_I2C_GenerateStartCondition(I2C1); LL_I2C_TransmitData8(I2C1, (dev_addr7 << 1) | 0); while (LL_I2C_IsActiveFlag_TXIS(I2C1) == 0) { if (get_tick() - tick > 100) return I2C_TIMEOUT; if (LL_I2C_IsActiveFlag_NACK(I2C1)) return I2C_NACK; } if (LL_I2C_IsActiveFlag_NACK(I2C1)) { LL_I2C_ClearFlag_NACK(I2C1); LL_I2C_GenerateStopCondition(I2C1); return I2C_NACK; } for (uint16_t i = 0; i < len; i++) { tick = get_tick(); while (LL_I2C_IsActiveFlag_TXIS(I2C1) == 0) { if (get_tick() - tick > 100) return I2C_TIMEOUT; } LL_I2C_TransmitData8(I2C1, buf[i]); } tick = get_tick(); while (!LL_I2C_IsActiveFlag_TC(I2C1) && !LL_I2C_IsActiveFlag_BTF(I2C1)) { if (get_tick() - tick > 100) return I2C_TIMEOUT; } LL_I2C_GenerateStopCondition(I2C1); tick = get_tick(); while (!LL_I2C_IsActiveFlag_STOP(I2C1)) { if (get_tick() - tick > 100) return I2C_TIMEOUT; } LL_I2C_ClearFlag_STOP(I2C1); return I2C_OK; }这里有个关键点:发送从机地址的时候,地址字节是(dev_addr7 << 1) | 0,不是直接把 0xA0 这种 8 位地址塞进去。很多第一次写的人会在这里翻车,因为 datasheet 里写的是 0xA0,那其实是 7 位地址 0x50 左移一位的结果。如果你已经有了 0xA0,直接传给TransmitData8就行,千万别再左移一位。
发送完最后一个数据后,要等TC或BTF置位,这表示最后一个字节已经在总线上传输完成。不能 TXIS 一置位就立刻发 STOP,否则最后一个字节可能还没发出去。这个小细节害我抓了好几个小时波形。
3.2 主接收的完整流程
接收比发送稍微绕一点,核心在于主设备在接收最后一个字节之前必须发出 NACK,告诉从机“后面不用再发了”。代码:
static int i2c_master_rx(uint8_t dev_addr7, uint8_t *buf, uint16_t len) { uint32_t tick; if (len == 0) return I2C_OK; tick = get_tick(); while (LL_I2C_IsActiveFlag_BUSY(I2C1)) { if (get_tick() - tick > 100) return I2C_TIMEOUT; } LL_I2C_GenerateStartCondition(I2C1); LL_I2C_TransmitData8(I2C1, (dev_addr7 << 1) | 1); if (len == 1) { LL_I2C_AcknowledgeNextData(I2C1, LL_I2C_NACK_NEXT); } for (uint16_t i = 0; i < len; i++) { tick = get_tick(); while (LL_I2C_IsActiveFlag_RXNE(I2C1) == 0) { if (get_tick() - tick > 100) return I2C_TIMEOUT; if (LL_I2C_IsActiveFlag_NACK(I2C1)) return I2C_NACK; } buf[i] = LL_I2C_ReceiveData8(I2C1); if (i == len - 2) { LL_I2C_AcknowledgeNextData(I2C1, LL_I2C_NACK_NEXT); } } LL_I2C_GenerateStopCondition(I2C1); tick = get_tick(); while (!LL_I2C_IsActiveFlag_STOP(I2C1)) { if (get_tick() - tick > 100) return I2C_TIMEOUT; } LL_I2C_ClearFlag_STOP(I2C1); return I2C_OK; }重点解释那个 NACK 的位置。I2C 总线上,主机读取数据时,每个字节接收完毕之后要拉低 SDA 表示 ACK,从机才会继续发下一个字节。如果主机不再需要数据,必须在最后一个字节接收之前配置为 NACK,这样从机在第 9 个时钟周期看到 SDA 是高电平,就知道该停止发送了。
len 等于 1 时,要在地址发送完成后立即设置 NACK,否则从机默认收到 ACK,会继续发第二个字节。len 大于 1 时,在接收倒数第二个字节之后配置 NACK,正好作用于最后一个字节。这个顺序错了,数据会多收或少收。
3.3 关于超时和标志清零
轮询代码里我到处加了get_tick()超时判断,这一点强烈建议你保留。I2C 总线上的问题大多数表现就是“卡死”,如果没有超时机制,CPU 会一直死等在某个标志位上,整颗芯片像被定住一样。用 SysTick 维护一个毫秒计数,定义一个get_tick()函数,一百行不到的代码,收益却是保证整个系统不会因为一次总线异常而瘫痪。
STOP 标志的处理也值得一提。G0 的 I2C 外设里,STOP 置位后不会自动清除,必须在检测到之后手动清。如果忘了清,下一次发起 STOP 时总线状态会很诡异。同理,NACK 标志、总线错误标志、仲裁丢失标志,都是需要主动清除的。LL 库提供了对应的LL_I2C_ClearFlag_xxx,用起来很直接。
4. 中断状态机:把轮询变成非阻塞
4.1 为什么轮询不够用
如果主循环里只有 I2C 读写,轮询完全够用。但我的传感器节点要处理低功耗唤醒、按键扫描、定时任务,甚至还要在 I2C 通讯的同时响应调试命令。如果整个 I2C 发送要等几百微秒甚至更久,其他任务全被卡住。这时候就需要把 I2C 换成中断驱动。
LL 库下做中断驱动,和 HAL 最大的区别是没有现成的回调函数帮你管理状态。你得自己定义一个小的状态机,然后在 IRQHandler 里推进它。听起来麻烦,其实逻辑非常清晰,尤其对小项目来说,状态机就十几个状态,比 HAL 那一大堆回调反而好排查。
4.2 状态机的设计思路
我习惯把一次 I2C 事务拆成这几个阶段:空闲、发送地址、发送数据、接收数据、停止、错误。全局变量里保存缓冲区指针、长度、当前读写位置,中断里根据标志位推进。
typedef enum { I2C_STATE_IDLE = 0, I2C_STATE_TX_ADDR, I2C_STATE_TX_DATA, I2C_STATE_RX_ADDR, I2C_STATE_RX_DATA, I2C_STATE_STOP, I2C_STATE_ERROR } i2c_state_t; static volatile i2c_state_t g_i2c_state; static const uint8_t *g_tx_buf; static volatile uint16_t g_tx_len; static volatile uint16_t g_tx_idx; static uint8_t *g_rx_buf; static volatile uint16_t g_rx_len; static volatile uint16_t g_rx_idx;发送的启动函数大致长这样:
void i2c_start_tx(uint8_t dev_addr7, const uint8_t *buf, uint16_t len) { g_tx_buf = buf; g_tx_len = len; g_tx_idx = 0; g_i2c_state = I2C_STATE_TX_ADDR; LL_I2C_GenerateStartCondition(I2C1); LL_I2C_TransmitData8(I2C1, (dev_addr7 << 1) | 0); LL_I2C_EnableIT_TXIS(I2C1); LL_I2C_EnableIT_TC(I2C1); LL_I2C_EnableIT_NACK(I2C1); LL_I2C_EnableIT_STOP(I2C1); }中断处理函数里,关键是分清 TXIS 和 TC 的职责。TXIS 表示发送数据寄存器空了,可以写下一个字节;TC 表示整次传输完成,可以发 STOP。这两个标志有时候同时出现,所以顺序很重要。
4.3 中断处理核心代码
void I2C1_IRQHandler(void) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) { LL_I2C_ClearFlag_NACK(I2C1); LL_I2C_GenerateStopCondition(I2C1); g_i2c_state = I2C_STATE_ERROR; return; } if (LL_I2C_IsActiveFlag_STOP(I2C1)) { LL_I2C_ClearFlag_STOP(I2C1); g_i2c_state = I2C_STATE_IDLE; } if (LL_I2C_IsActiveFlag_TC(I2C1) && g_i2c_state == I2C_STATE_TX_DATA) { LL_I2C_GenerateStopCondition(I2C1); g_i2c_state = I2C_STATE_STOP; } if (LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (g_i2c_state == I2C_STATE_TX_ADDR) { g_i2c_state = I2C_STATE_TX_DATA; } if (g_tx_idx < g_tx_len) { LL_I2C_TransmitData8(I2C1, g_tx_buf[g_tx_idx++]); } } if (LL_I2C_IsActiveFlag_RXNE(I2C1)) { g_rx_buf[g_rx_idx++] = LL_I2C_ReceiveData8(I2C1); if (g_rx_idx == g_rx_len - 1) { LL_I2C_AcknowledgeNextData(I2C1, LL_I2C_NACK_NEXT); } if (g_rx_idx == g_rx_len) { LL_I2C_GenerateStopCondition(I2C1); } } }这个中断处理函数把发送和接收都包在里面,实际项目里你可以根据当前状态只开对应的中断,进一步减少中断触发次数。
有一个易错点:如果发送的数据长度是 0,比如只发一个地址过去不跟数据,此时 TXIS 第一次置位后g_tx_idx已经等于g_tx_len,那么应该在中断里直接发 STOP。上面的代码里漏了这种情况,你可以在 TXIS 分支里加一个判断:
if (g_tx_idx == g_tx_len) { LL_I2C_GenerateStopCondition(I2C1); g_i2c_state = I2C_STATE_STOP; }这就是用 LL 库写中断的麻烦之处,也是它的价值所在:细节全在你面前,不会像 HAL 那样找个隐藏 bug 找到怀疑人生。
5. 实战:读写 AT24C02
5.1 接线和上拉电阻
AT24C02 是 EEPROM 里最典型的 I2C 设备,7 位地址通常由 A0/A1/A2 引脚决定,我板上三个引脚都接地,所以 7 位地址是 0x50,发送时用 0xA0 作为写地址、0xA1 作为读地址。如果你手头板子的 A0/A1/A2 接了别的高低电平,地址要相应改。
上拉电阻我选了 4.7kΩ,工作在 100kHz 下很稳,400kHz 也能用。如果你板子只跑 100kHz、总线电容也不大,10kΩ 也能工作,只是边沿会比较缓。总的原则是:速度越高、总线设备越多、线越长,上拉电阻就要选越小的值。但太小也不行,低电平灌电流会偏大,可能超出引脚的 IOCELL 规格。
5.2 页写、随机读和等待写周期
AT24C02 页大小是 8 字节,也就是一次写操作最多连续写 8 个字节。超过 8 字节,地址会回卷覆盖当前页前面的数据,这是芯片的行为,不是 bug。实际项目里做固件升级参数存储时,超过 8 字节的参数列表一定要手工分页。
写一个字节可以直接用上面那个i2c_master_tx,发送地址后跟一个数据字节。但真正的新手坑在写完数据之后:AT24C02 进入内部写周期,最长 5ms 内不响应任何 I2C 操作,如果你不管不顾继续发读写命令,得到的全是 NACK。
通用的处理办法是查询写周期完成:不停发一个写地址过去,芯片内部写完了会返回 ACK,没写完会返回 NACK。循环查询直到 ACK 即可。这也解释了为什么你的 I2C“死锁”有时候根本不是总线问题,而是对方故意不回你。
随机读就要用上重复起始条件。先发一个写地址,写入要读的寄存器地址,然后不发 STOP,再发一个重复起始条件和读地址,最后把寄存器里的数据读出来。中间这个重复起始由LL_I2C_GenerateStartCondition(I2C1)实现,不需要先 STOP 再 START。
5.3 页写和随机读的完整实现
省略地址部分和错误检查,核心流程可以写成这样:
i2c_master_tx(0x50, ®, 1); // 写寄存器地址,不发 STOP LL_I2C_GenerateStartCondition(I2C1); // 重复起始 LL_I2C_TransmitData8(I2C1, 0xA1); // 读地址 // 设置 NACK 后等待 RXNE,读取一个字节注意在上面的代码中,i2c_master_tx内部会把 STOP 发掉,所以不能直接用。更好的做法是单独写一个i2c_write_reg,从发送地址到写入数据之后不产生 STOP,等读操作开始后由读流程接管。我这里展示的是思路,实际工程里可以根据上面的轮询代码改一个i2c_start_without_stop版本。
关于 EEPROM 的批量读,其实和普通接收函数没有区别,只不过读之前要先发一个寄存器地址。把i2c_master_rx和写寄存器地址合并,就是一个高效的多字节读函数。需要留意的是读操作如果长度超过一页,AT24C02 的地址会自动跨页递增,不需要手动翻页。
6. 排查经验与问题速查
6.1 逻辑分析仪和调试手法
调 I2C 最怕的是“感觉它应该在跑”但就是不对。我的第一个建议是上逻辑分析仪,不用很贵的,能采样 8 通道、1MHz 以上就是够用的。抓 SCL 和 SDA 两根线,什么问题都能现原形。
看波形时重点关注三件事:起始条件是否正常、地址字节后的 ACK/NACK 电平、最后一个字节后有没有正确的 STOP。如果是 NACK 且发生在地址阶段,先核对从机地址和引脚接线;如果是数据阶段 NACK,再看是不是写到了只读区域或者发送长度超过了页大小。
没有逻辑分析仪的时候,我习惯在代码里放一个调试输出函数,把 I2C 状态机的当前状态通过一个 GPIO 翻转,再用示波器看电平变化。这种方法也能快速定位是卡在哪个环节。
6.2 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 地址之后一直 NACK | 地址错误 / 设备没上电 / 引脚错 | 核对 7 位地址、测量 SDA 波形 |
| SDA 一直低电平 | 从机拉死总线 / 之前通讯异常没结束 | 按 6.3 总线恢复流程处理 |
| 速度明显偏慢 | TIMINGR 配置偏大 / 数字滤波器开启 | 重新用 CubeMX 计算时序值 |
| 发送最后一个字节丢失 | TXIS 后立刻发 STOP 没等 TC | 确认代码等 TC/BTF 再发 STOP |
| 接收端多收一字节 | 最后字节前没有配置 NACK | 检查AcknowledgeNextData调用位置 |
| 写完 EEPROM 后命令不响应 | 内部写周期未结束 | 增加 ACK 轮询等待 |
| 100kHz 正常 400kHz 出错 | 上拉不够 / 总线电容过大 | 换更小上拉电阻,检查 PCB 布线 |
6.3 总线死锁恢复
I2C 有个非常经典的死锁场景:某次通讯中途异常,从机把 SDA 拉低后一直不放,单片机也不清楚当前总线状态,后续所有 START 条件都无法建立,总线等于废了。
这时候可以尝试用“九脉冲”恢复法。把 SCL 和 SDA 两个引脚临时改成普通开漏 GPIO,然后在 SCL 上手动输出 9 个时钟脉冲,同时确保 SDA 保持高电平。每发一个脉冲后检查 SDA 是否被释放,一旦 SDA 变高说明从机已经退出异常状态,恢复外设模式即可。
static void i2c_bus_recover(void) { LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_MODE_OUTPUT); LL_GPIO_SetPinOutputType(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_OUTPUT_OPENDRAIN); LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_10); // SDA 拉高 for (int i = 0; i < 9; i++) { LL_GPIO_ResetOutputPin(GPIOA, LL_GPIO_PIN_9); delay_us(5); LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_9); delay_us(5); } LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_9 | LL_GPIO_PIN_10, LL_GPIO_MODE_ALTERNATE); }这个恢复程序不是万能的,如果从机本身已经损坏或者处于异常低功耗状态,可能仍无法恢复。但在绝大多数由通讯干扰引起的死锁场景里,这一手能救回来一大半项目。
最后再分享一个小习惯:我在每次 I2C 操作完以后都会把外设的BUSY标志读一遍,如果有问题会在调试串口里打一个标记。这个习惯帮我提前发现了很多硬件连接上的松动问题,也让我对哪些场景容易触发总线错误有了直观判断。用 LL 库写 I2C 确实比用 HAL 多花一点心思,但换来的是对每个标志位、每个时序的完全掌控,G031 这种小芯片上尤其值得这么干。