这两年做小项目,我越来越喜欢直接在STM32G031K8上用LL库写I2C,而不是动不动就把HAL库那一大坨拉进来。G031K8是Cortex-M0+内核、最高64MHz主频的小芯片,Flash也就64KB,RAM更紧张,很多场景就是采集个传感器、读写个EEPROM、驱动个小屏幕。这种活儿如果用HAL库的I2C阻塞接口,代码看着省事,但真要调试总线和时序问题,中间那层封装反而让人抓狂。LL库的I2C用起来更像“照着参考手册写寄存器”,所有状态位、中断标志都暴露在眼前,出了问题能顺着代码一步步追到硬件行为。
这篇文章我用自己的实操经历,从选型、CubeMX配置、轮询和中断两种收发实现,到总线卡死、NACK这些老问题的排查思路,把STM32G031K8的I2C完整过一遍。刚接触LL库的朋友可以直接照抄代码先跑通,再用我给的调试方法去理解每一行在干什么。
1. 为什么折腾LL库:先说清楚选型思路
1.1 这个型号到底适合什么场景
STM32G031K8属于G0系列里的经济型型号,后缀K代表32脚封装,K8是64KB Flash、8KB RAM的配置。这芯片的优势不是算力,而是外设和封装够用、功耗低、价格也压得下来。实际产品里常见的是智能家居里的温湿度采集节点、小型仪器里的传感模块、电池供电的便携设备这类需求,主控不需要跑复杂算法,但要求外设灵活、待机功耗低、代码能精简就精简。
在这类项目里,I2C承担的角色往往是“周边低速连接”——接SHT30这类温湿度传感器、AT24Cxx系列EEPROM存配置参数、SSD1306驱动的OLED显示屏等等。这些设备速率最高也就400kHz,数据量不大,但通信可靠性要求比较高,毕竟传感器数据读不到或EEPROM写错,直接就是产品功能异常。
1.2 从HAL换到LL,中间差了什么
很多人第一次接触Cube库都是从HAL开始的,因为它的API是以“完成一次完整事务”为单位的。比如你要读一个传感器,一个HAL_I2C_Master_Receive就帮你把START、地址、数据、STOP全包了,里面还有超时和状态机管理。这在快速验证的时候确实方便,但问题也在这里:一旦总线挂死、NACK发生、或者你想在某个字节中间改变ACK策略,你得去翻HAL内部那几百行状态机,甚至怀疑它某个分支在特定时钟下跑得不到位。
LL库则是另一条路,它基本是寄存器的轻封装。每一个函数几乎对应参考手册里某一位或某个流程的一步,比如LL_I2C_GenerateStartCondition就是写CR2的START位,LL_I2C_IsActiveFlag_TXIS就是读ISR的TXIS位。没有隐藏的状态机,没有默认超时,也没有“帮你做决定”。代价就是所有步骤自己编排,但好处是你对总线上的每个时钟周期在干什么都心里有数。
所以做I2C这种物理层行为复杂、又容易受外部设备影响的协议,我个人的判断是:LL库比HAL库更适合调试和生产维护,尤其当你的代码需要在多种从机设备之间来回切换时,状态机的透明性能省掉大量猜测。
1.3 硬件准备与GPIO映射
G031K8这个封装上,I2C1可以映射到好几组引脚,我习惯用的是PB6和PB7,复用功能为AF1。这组引脚好处是位置顺手、和SWD调试引脚不冲突,布线时也比较容易避开晶振区域。如果PB6/PB7被占用了,还可以看PA9/PA10,同样配AF1。
硬件上要注意的是I2C是开漏结构,外部必须有上拉电阻。一般400kHz时2.2k到4.7k比较合适。如果板子省了上拉电阻,总线空闲时SCL和SDA就是低电平,初始化后LL_I2C_IsActiveFlag_BUSY会一直报忙,后面怎么操作都白搭。我吃过一次亏,PCB样板为了省两个电阻没放上拉,结果代码调了一晚上,最后发现是硬件问题。
GPIO初始化时还得把速度档位拉高,否则边沿太缓会影响400kHz通信。虽然I2C本身是开漏,内部上下沿其实靠上拉电阻,但输出驱动器速度不足会在翻转时产生畸变,所以尽量配置成Very High速度。
2. I2C外设的关键细节,不改念不准
2.1 时序参数是怎么算出来的
G0系列I2C外设里最劝退人的就是I2C_TIMINGR这个寄存器。它由PRESC、SCLDEL、SDADEL、SCLH、SCLL五段组成,分别控制分频、数据建立时间、数据保持时间、高电平周期、低电平周期。HAL库封装了这套计算,LL库则直接把原始值扔给你。
我的做法是:第一版参数绝对不要手算,直接在CubeMX里把I2C1配成Fast Mode 400kHz,然后让它自动填I2C_TIMINGR值。CubeMX会根据你在时钟树里给I2C内核选择的时钟源,算出当前频率下最合适的32位时序值。比如I2C内核时钟用64MHz,目标400kHz时生成的值通常长成0x10A16E4A这样,里头已经包含了滤波器和同步沿的补偿。
但CubeMX给的值只是理论值,实际PCB走线长度、上拉电阻大小、从机芯片的输入阈值都会影响波形。调试时如果发现波形边沿过缓或者从机偶发不响应,需要微调的就是SDADEL和SCLDEL,这两个负责数据建立和保持时间。把示波器挂上,观察SDA在SCL下降沿之前稳定了多久,如果太短就加大SDADEL。SCLDEL影响的是SCL低电平到SDA数据有效的间隔,调大了能缓解一些慢速从机跟不上节奏的问题。
2.2 传输流程的状态机视角
I2C主模式收发看起来简单:发START、写地址、传数据、发STOP。但在G0的I2C外设内部,每一个阶段都有对应的状态位。不理解这些位,LL库代码写出来就只能靠抄。
关键状态位有这些:BUSY表示总线忙,TXIS表示发送数据寄存器空、可以写入下一字节,RXNE表示接收数据寄存器非空、可以读走数据,TC表示主模式传输完成、但还没发STOP,NACK表示从机回了非应答,ARLO表示仲裁丢失。
主发送场景下的状态流动是:先清BUSY确认总线空闲,然后置START位,硬件会在总线上产生起始条件。随后你要等TXIS,只有这个标志置位了才能把从机地址写入发送寄存器。地址发完,TXIS会再次置位,这时候才能开始送数据字节。最后一个字节发完后,可以选择硬件自动发STOP,也可以等TC出现后手动发STOP。两种方式分别对应自动结束模式和非自动结束模式,在初始化结构体里用PeripheralMode选择。
理解这套流程最大的价值在于排查问题。比如总线卡死在发送地址后,你就知道要看从机是否真的在ACK阶段回了应答;卡死在数据阶段,就要怀疑从机是不是因为时序不满足而中断了接收。所有状态都是可见的,比HAL库抛一个超时错误回来明确得多。
2.3 从机地址和读写位
I2C从机地址在总线上是7位,发送时要左移一位,最低位写0表示主机发、写1表示主机收。这个细节在LL库和HAL库里都一样,只是HAL的接口参数帮你做了这部分处理,而LL库需要你自己移位,所以特别容易忘。
比如从机设备地址是0x50,实际写到发送寄存器里的字节就是0xA0或0xA1。调试时如果拿逻辑分析仪抓波形发现地址之后跟着的ACK不对,先看看是不是这里左右移搞反了。
地址后跟的读写位决定了后续数据方向,而方向切换时还需要处理重复起始条件。比如先写寄存器地址再读数据的场景,HAL库的Mem_Read接口内部帮你做了,LL库下需要自己先发START、写地址+0、发寄存器地址,然后再发一次START、写地址+1、进入接收流程。这个重复起始条件不能省略,否则从机不会正确切换到输出状态。
3. 一步步把I2C跑起来:初始化与主模式收发
3.1 CubeMX里的最小配置
我新建项目时一般先把时钟源确定好。G031K8可以跑内部HSI 16MHz然后倍频到64MHz,也可以外部晶振。I2C1的内核时钟源在CubeMX的RCC配置里选,我用的是PCLK1,因为G0上APB1不分频时就是系统时钟64MHz,频点简单。
I2C1配置页面里把这些选好:
- I2C模式选Standard Mode或Fast Mode。默认Standard Mode是100kHz,传感器和EEPROM基本都支持,但如果传输数据量大,建议直接选Fast Mode 400kHz,省时间。
- 如果只做主模式,Own Address随便填一个不冲突的值就行,比如0x32。
- Analog Filter建议打开,能滤掉总线上的短毛刺,Digital Filter先设0,等有干扰再往上加。
- Timing值让CubeMX自动生成,不要手动改。
生成的代码里GPIO的复用配置也已经帮你写好了,LL库下就是LL_GPIO_SetPinMode和LL_GPIO_SetPinAF这两组调用。你只需要确认一下引脚是不是PB6/PB7,别在硬件上接的是别的端口。
3.2 LL库初始化的完整代码
配置完CubeMX生成工程后,典型的I2C初始化代码长这样。注意LL库的初始化函数不会自动开启外设,需要单独调用Enable,这个和HAL库不一样,很容易漏:
void I2C1_Init(void) { LL_I2C_InitTypeDef I2C_InitStruct = {0}; LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_I2C1); LL_AHB2_GRP1_EnableClock(LL_AHB2_GRP1_PERIPH_GPIOB); LL_GPIO_SetPinMode(GPIOB, LL_GPIO_PIN_6 | LL_GPIO_PIN_7, LL_GPIO_MODE_ALTERNATE); LL_GPIO_SetPinSpeed(GPIOB, LL_GPIO_PIN_6 | LL_GPIO_PIN_7, LL_GPIO_SPEED_FREQ_VERY_HIGH); LL_GPIO_SetPinOutputType(GPIOB, LL_GPIO_PIN_6 | LL_GPIO_PIN_7, LL_GPIO_OUTPUT_OPENDRAIN); LL_GPIO_SetPinAF(GPIOB, LL_GPIO_PIN_6, LL_GPIO_AF_1); LL_GPIO_SetPinAF(GPIOB, LL_GPIO_PIN_7, LL_GPIO_AF_1); I2C_InitStruct.PeripheralMode = LL_I2C_MODE_I2C; I2C_InitStruct.Timing = 0x10A16E4A; I2C_InitStruct.AnalogFilter = LL_I2C_ANALOGFILTER_ENABLE; I2C_InitStruct.DigitalFilter = 0; I2C_InitStruct.OwnAddress1 = 0x32; I2C_InitStruct.TypeAcknowledgment = LL_I2C_ACK; I2C_InitStruct.OwnAddrSize = LL_I2C_OWNADDRESS1_7BIT; LL_I2C_Init(I2C1, &I2C_InitStruct); LL_I2C_Enable(I2C1); }这里有几个容易踩的坑。一个是GPIO必须配成开漏输出,CubeMX默认会帮你在初始化代码里写好,但如果手写很容易漏,漏了之后总线会被强行拉低,上拉电阻都拉不回来。另一个是I2C的时钟使能操作在G0上是APB1,GPIO时钟在AHB2,两个总线别搞混。第三个是Timing值不要照抄别人的工程,因为内核时钟源不同、速率目标不同,生成的值差别很大。
3.3 主发送和主接收的轮询实现
初始化完后,我用一个简单的轮询函数发送几个字节。这个函数不带你多字节缓存管理,核心是把LL库的各个状态位串起来,逻辑很直白:
uint8_t I2C_Master_WriteReg(uint8_t DevAddr, uint8_t RegAddr, uint8_t *pData, uint8_t Len) { uint8_t i; uint32_t timeout = 0xFFFF; while (LL_I2C_IsActiveFlag_BUSY(I2C1)) { if (--timeout == 0) return 1; } LL_I2C_GenerateStartCondition(I2C1); timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) return 2; if (--timeout == 0) return 3; } LL_I2C_TransmitData8(I2C1, (DevAddr << 1) | 0); while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) return 2; if (--timeout == 0) return 4; } LL_I2C_TransmitData8(I2C1, RegAddr); for (i = 0; i < Len; i++) { timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) return 2; if (--timeout == 0) return 5; } LL_I2C_TransmitData8(I2C1, pData[i]); } timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_TC(I2C1)) { if (--timeout == 0) return 6; } LL_I2C_GenerateStopCondition(I2C1); return 0; }这段代码里用轮询等待TXIS,每次等待都带超时和NACK检查。之所以没有用HAL那种“整体调用、内部处理一切”的风格,是因为我想明确知道每一步失败在哪个阶段,返回值2代表从机没应答,返回值1说明总线一直忙,返回值4说明地址阶段就没通。这种定位粒度对现场调试非常有用。
接收方向稍有不同,因为主机要控制ACK还是NACK。举一个接收N个字节的简化流程:发完读地址后,最后一个字节要在读走倒数第二字节后、最后一个字节数据到达前关掉ACK,这样从机知道主机不想继续收了,然后发STOP。G0的I2C外设支持自动结束模式,可以在初始化时配置成每个事务自动发STOP,省掉手动操作,但灵活性就降低了。
下面这个函数是“先发寄存器地址、再读回Len个字节”的典型场景,用的是最基础的轮询方式:
uint8_t I2C_Master_ReadReg(uint8_t DevAddr, uint8_t RegAddr, uint8_t *pData, uint8_t Len) { uint32_t timeout = 0xFFFF; uint8_t i; while (LL_I2C_IsActiveFlag_BUSY(I2C1)) { if (--timeout == 0) return 1; } LL_I2C_GenerateStartCondition(I2C1); timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout == 0) return 2; } LL_I2C_TransmitData8(I2C1, (DevAddr << 1) | 0); timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout == 0) return 3; } LL_I2C_TransmitData8(I2C1, RegAddr); LL_I2C_GenerateStartCondition(I2C1); timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout == 0) return 4; } LL_I2C_TransmitData8(I2C1, (DevAddr << 1) | 1); for (i = 0; i < Len; i++) { timeout = 0xFFFF; while (!LL_I2C_IsActiveFlag_RXNE(I2C1)) { if (--timeout == 0) return 5; } pData[i] = LL_I2C_ReceiveData8(I2C1); if (i == Len - 2) { LL_I2C_AcknowledgeData(I2C1, LL_I2C_NACK); } } LL_I2C_GenerateStopCondition(I2C1); LL_I2C_AcknowledgeData(I2C1, LL_I2C_ACK); return 0; }这里有个细节值得单独说:在Len等于1、2这些边界情况下,倒数第二个字节的位置容易算错。Len等于1时,硬件实际上在从机发出第一个字节后就需要主机回NACK,所以i==Len-2这个判断会失灵。我在代码里用的是纯演示逻辑,正式项目里一般会把Len==1单独写分支,或者改用自动结束模式。这也是LL库项目容易出问题的地方,边界条件都暴露在你面前,不像HAL库帮你兜了底。
3.4 打成中断版本
轮询方式代码简单,但主循环会被I2C传输占住,尤其在400kHz下传几十字节,耗时可以达到毫秒级,对需要同时处理按键、显示刷新的场景不友好。所以实际项目我通常会把收发切成中断驱动。
中断版的核心思路是把上面轮询里的等待状态,搬到I2C1的全局中断里用状态机处理。一次传输需要定义一个小的软件状态对象,至少包含:当前阶段(START/地址/数据/结束)、目标缓冲区指针、剩余字节数、方向标志。
开启中断前先做初始化,然后使能I2C1的TXIS、RXNE、TC、NACK等中断标志。置START位之后,硬件会在需要写地址、写数据、接收数据、传输完成时进入中断回调。中断里根据当前状态和标志位决定下一步操作。这种写法没有阻塞等待,主循环可以继续跑其他任务。
中断版本代码比轮询长一倍,但性能提升明显。我通常在项目初期先用轮询把通信逻辑调通,确认从机行为和时序都没问题,再改成中断版。不要一上来就写中断状态机,那样出了问题很难分清是外设行为没弄懂还是状态机逻辑写错了。
4. 调试过程中的坑与排错速查
4.1 总线Busy卡死
LL库代码最常见的问题就是启动传输前等待BUSY标志清零,但BUSY一直不释放。原因往往不是软件,而是上一个传输没有正确结束。硬件上如果STOP条件没发出,或者从机还在应答周期里,总线会被认为忙。
解决方法是排查三点:确认上一次传输确实执行到了GenerateStopCondition;确认初始化时I2C外设没有被重复复位导致状态丢失;必要时强制让GPIO输出几个时钟周期,把挂在总线上的从机状态机复位。后一种办法需要手动把SCL引脚配成普通推挽输出,做几次高低电平翻转,然后再恢复成复用开漏模式。
如果你发现上电后第一次通信就卡BUSY,优先怀疑上拉电阻缺失、引脚复用AF配错、或者I2C外设时钟没有打开。
4.2 NACK的处理
NACK在I2C通信里是“信息”而不是“错误”。地址阶段收到NACK,说明总线上没有这个地址的设备,或者设备地址移位错了。数据阶段收到NACK,多半是设备不支持你正在写的寄存器,或者正处于忙碌状态。
处理NACK时不要盲目重试。比如EEPROM在写内部页时是不会响应主机的,这种场景软件上要做“轮询等待写完成”的逻辑,而不是简单重发。我在驱动AT24C32时,写完一页后加了一个小循环,反复发一个哑读命令,直到设备不再回NACK,才算是真正写完了。
另外要记住,从机回NACK后,主机必须发STOP才能让总线回到空闲,否则总线会卡在通信中间状态。
4.3 波形不对
LL库调试I2C的最佳工具是逻辑分析仪,不需要太高采样率,400kHz总线上用24MHz采样的设备就很清晰。我习惯抓三段波形:起始条件、地址字节后的ACK位、最后一个字节后的STOP条件。这三段都正常,传输基本就通了。
如果发现SCL频率明显偏低,先看时序参数里PRESC是不是太大。如果发现SDA上数据的建立时间不够,从机读到的数据不稳定,就回头调SDADEL。如果波形边沿很缓,就是上拉电阻太大或者GPIO速度档太低。
有一种情况比较隐蔽:I2C引脚被内部上拉还是外部上拉搞混了。G0的I2C引脚内部也有弱上拉,但强度远不如外部上拉,如果省了外部上拉,低速时波形看起来正常,升到400kHz就出问题。这类问题靠示波器观察幅度和边沿能很快定位。
4.4 常见问题速查表
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 启动传输卡BUSY | 上次通信没正常结束,总线挂死 | 检查STOP是否发出,检查上拉电阻 |
| 地址阶段NACK | 设备地址写错、移位错误、设备不在线 | 检查DevAddr左移后最低位,量总线地址波形 |
| 数据阶段NACK | 寄存器不支持、EEPROM写忙 | 查阅从机手册,增加写忙等待 |
| 数据传输一半超时 | 从机时钟拉伸或从机无响应 | 检查SCL是否被从机拉低,是否需要等待 |
| 偶发通信失败 | 时序边沿过缓或干扰 | 示波器抓波形,微调SDADEL/SCLDEL,开启模拟滤波 |
| 初始化后SCL和SDA为低 | 缺上拉电阻 | 量总线电平,加2.2k-4.7k上拉 |
排查的总原则是:先用示波器或逻辑分析仪确认物理层对没对,再怀疑软件逻辑。I2C的问题十有八九是物理层或时序参数引起的,寄存器配置和状态位反而是相对可靠的。
5. 收尾的经验
调了十几个I2C从机设备之后,我的体会是:LL库并没有比HAL库“更难用”,只是把思考的责任还给了开发者。HAL库让你填参数就能跑,但当你想让I2C在复杂的传感器阵列里稳定工作,就必须理解时序参数、状态位、ACK策略这些细节,而LL库逼着你直面它们。这个学习曲线是值得的,尤其当你需要在资源紧张的G031K8上同时跑多个外设时,代码的精简和可控会带来实实在在的好处。
最后再分享一个小技巧:我会在工程里保留一个只用LL库GPIO翻转写的简易I2C软件模拟实现,专门用来和硬件I2C外设做对照。当硬件I2C偶发异常时,切换到软件模拟跑一遍同样的协议,能很快区分是外设配置问题还是外部设备问题。这个“双通道”调试法帮我解决过好几次棘手的总线兼容性问题,值得一试。