首页 / 资讯中心 / 文章详情

STM32G031用LL库写I2C通信的实战与排错记录

STM32G031用LL库写I2C通信的实战与排错记录 ★ FEATURED ARTICLE
前阵子调一块小板子主控是STM32G031K8T6外设不多偏偏要在IIC总线上挂一颗温湿度传感器和一颗EEPROM。按理说这种活用现成的库函数几分钟就能写完但我翻了半天例程最后还是决定用LL库重写一遍。原因很简单HAL库在I2C这种靠标志位和时序吃饭的外设上封得越厚问题越难查。如果你也准备在STM32G031K这类小芯片上用LL库跑IIC或者已经在IIC通信上被卡到怀疑人生这篇文章基本就是我这次调试全过程的复盘。我会把为什么要用LL库、IIC的硬件细节、收发代码怎么写、常见故障怎么排查都讲清楚尽量做到看完能直接抄到自己板子上跑。涉及代码的部分我用的都是STM32G0系列LL库的标准函数名如果你的开发包版本略有差异以自己手里的头文件为准。1. 为什么偏偏在G031上用LL库写IIC1.1 LL库和HAL库的本质差别一个薄封装一个厚封装先说个很多人忽略的事实LL库不是HAL库的精简版它的设计思路完全不同。HAL库追求“统一抽象”把一个外设封装成句柄、初始化结构体、回调函数和状态机你调用HAL_I2C_Master_Transmit()的时候它内部要经过超时判断、状态检查、标志位轮询、错误回调等好几层逻辑。而LL库的原则是“一层薄皮直接贴寄存器”绝大多数函数调用对应一次寄存器读写几乎没有多余的判断分支。这一点在I2C上特别要命。I2C通信的每一步都依赖硬件标志位TXIS表示发送寄存器空、RXNE表示接收寄存器非空、NACKF表示从机回应了非应答、STOPF表示停止条件已生成。用HAL库时这些标志位被封装在函数内部一旦通信卡住你根本不知道卡在了哪一步用LL库时收发流程的每一拍都暴露在你面前调试器一断寄存器值一读问题当场现形。我自己实测过同一个I2C读写函数HAL版本编译出来大约多占2KB左右的Flash这在小封装芯片上已经是不小的开销。G031K这种片子Flash本来就不宽裕省下来的空间放日志、放协议解析都更从容。1.2 G031K的资源底细小芯片更需要可控性STM32G031K8是Cortex-M0内核主频最高64MHzFlash有64KBRAM只有8KB。这类芯片大量出现在传感器采集、电池供电的小型设备、电机控制板、通信子卡上特点是“功能不复杂但对实时性和功耗有要求”。M0没有硬件除法器没有缓存中断响应速度全靠代码写得干不干净。如果I2C收发用HAL库那套轮询加重试机制一次通信的延时抖动会很明显。而LL库的代码路径短、分支少中断里做I2C状态机时一个状态的停留时间可以精确到微秒级。我并不是说HAL库不能用如果项目里外设很多、你又是快速原型验证HAL库的开发效率确实高。但这篇讲的是IIC的底层使用我想让你看到寄存器层面到底发生了什么。搞清楚这些哪怕你回头继续用HAL库遇到问题也知道它内部在干什么。1.3 这些人适合直接用LL库上手IIC先说结论如果你之前只写过Arduino或者只用过STM32CubeMX生成的HAL代码对寄存器、标志位这些概念还比较陌生我建议你先用LL库做一次I2C主机读写把START条件、地址、数据、ACK、STOP这一整套流程跑通再回去用HAL库会通透很多。如果你已经在产品上做过I2C通信但因为线上偶发卡死、NACK、总线锁死这些问题焦头烂额那这篇文章的排错部分应该能帮你省不少时间。G031K虽然是个小芯片但它的I2C外设和G0系列其他型号是一致的所以你手上的G030、G071、G0B1也一样适用。2. 动手配置代码前先摸清G031的I2C外设脾气2.1 开漏输出和上拉电阻为什么不能省I2C总线是开漏结构的这意味着引脚本身只能主动拉低不能主动输出高电平。SCL和SDA的高电平完全靠外部上拉电阻提供。很多人第一次调I2C没波形九成是忘了加上拉电阻或者引脚被配置成了推挽输出。上拉电阻的取值有讲究。标准模式下100kHz4.7kΩ到10kΩ都算安全范围快速模式400kHz建议用2.2kΩ到4.7kΩ。电阻太大总线上升沿太慢高速通信容易出错电阻太小灌电流偏大低功耗场景不划算。G031K的I2C引脚驱动能力并不强我一般习惯在100kHz下用4.7kΩ400kHz下用2.2kΩ实测波形都比较干净。另外要注意如果总线上挂了多个设备上拉电阻只需要一组放在靠近主控的位置或者总线中间都可以但不要每个设备都加上拉否则并联电阻太小低电平可能被拉不到0.3V以下。2.2 G031的I2C时钟来源与初始化顺序G0系列的时钟树比F1系列复杂不少I2C1的时钟源需要预先配置。LL库里提供了LL_I2C_SetClockSource()函数可以选择系统时钟分频或者PCLK作为I2C的时钟输入。我实际使用中一般保持默认时钟源然后用LL_I2C_SetTiming()设置时序参数只要通信速率正常不用过分纠结具体走的是哪条分频链。关键的是初始化顺序一定要先把GPIO时钟和I2C外设时钟打开再配置GPIO复用功能最后配置I2C参数并使能外设。反过来操作I2C外设和GPIO不同步轻则配置不生效重则总线刚使能就产生一次错误起始条件把从机状态机搞乱。2.3 I2C协议时序地址、读写位、ACK/NACK一看就懂I2C一帧数据的基本结构是START条件、从机地址7位读写位、从机ACK应答、若干数据字节、STOP条件。从机地址左移一位最低位0表示主机写、1表示主机读这个“地址左移读写位”的组合在调试时最容易看错。很多人在逻辑分析仪上抓到的波形地址段是0xAE、0xD0这类值其实那是包含了读写位的完整首字节。比如一个从机地址是0x57写成读操作就是0xAF写操作就是0xAE。如果你在代码里直接写0xAE作为设备地址硬件会把它当成一个7位地址实际寻址到地址0x57的设备时会失败。ACK和NACK也是初学者容易混的点。主机发送地址或数据后从机如果正常收到会在第9个时钟周期拉低SDA表示ACK如果从机忙、地址不匹配或者数据异常就保持高电平表示NACK。LL库里对应LL_I2C_IsActiveFlag_NACK()这个标志位通信失败时先查它能把问题范围缩小一大半。3. LL库I2C核心API拆解与收发时序对照3.1 初始化配置GPIO复用和I2C参数先看GPIO的初始化。G031K上I2C1可以映射到多组引脚我这次用的是PB6作为SCL、PB7作为SDA这两脚默认的复用功能里有一档就是I2C1具体AF编号要查数据手册。LL库代码大致如下LL_GPIO_InitTypeDef GPIO_InitStruct {0}; LL_I2C_InitTypeDef I2C_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_I2C1_CLK_ENABLE(); /* 注意LL库也可以用 LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_I2C1) */ GPIO_InitStruct.Pin LL_GPIO_PIN_6 | LL_GPIO_PIN_7; GPIO_InitStruct.Mode LL_GPIO_MODE_ALTERNATE; GPIO_InitStruct.Speed LL_GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.OutputType LL_GPIO_OUTPUT_OPENDRAIN; GPIO_InitStruct.Pull LL_GPIO_PULL_UP; GPIO_InitStruct.Alternate LL_GPIO_AF_1; /* 具体AF编号看芯片引脚定义 */ LL_GPIO_Init(GPIOB, GPIO_InitStruct);这里有几个细节值得说。I2C引脚一定要配成LL_GPIO_OUTPUT_OPENDRAIN只有开漏模式配合外部上拉才能正确实现I2C的电平逻辑。内部上拉不是不能用但G031的内部上拉电阻大约在30kΩ到50kΩ阻值偏大400kHz下可能不足以在时序要求内把总线拉高所以外部4.7kΩ上拉还是必要的。接着配置I2C外设参数并初始化I2C_InitStruct.PeripheralMode LL_I2C_MODE_I2C; I2C_InitStruct.Timing 0x10707DBC; I2C_InitStruct.AnalogFilter LL_I2C_ANALOGFILTER_ENABLE; I2C_InitStruct.DigitalFilter 0; I2C_InitStruct.OwnAddress1 0; I2C_InitStruct.TypeAcknowledge LL_I2C_ACK; I2C_InitStruct.OwnAddrSize LL_I2C_OWNADDRESS1_7BIT; LL_I2C_Init(I2C1, I2C_InitStruct); LL_I2C_Enable(I2C1);Timing这个参数是G0系列I2C初始化里最容易忽悠人的地方。它不是简单的分频值而是一堆位域的组合包含了PRESC、SCLDEL、SDADEL、SCLH、SCLL等字段。推荐的做法是用STM32CubeMX生成工程填好目标速率和I2C时钟频率让它自动算出这个值然后抄进自己的LL工程。手工算容易错而且不同主频下算出来的值完全不一样没必要在这里折磨自己。3.2 主机发送从START到STOP的完整流程主机发送一字节的核心逻辑是产生START条件、等待硬件把地址发出去、等TXIS标志位、填数据、全部发完立即产生STOP条件。LL库代码可以这样写uint8_t I2C_WriteByte(uint8_t devAddr, uint8_t regAddr, uint8_t data) { uint32_t timeout 10000; while (LL_I2C_IsActiveFlag_BUSY(I2C1)) { if (--timeout 0) return 1; } LL_I2C_GenerateStartCondition(I2C1); timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout 0) return 2; } LL_I2C_TransmitData8(I2C1, (devAddr 1) | 0); /* 地址发出后硬件等待从机ACK */ timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) return 3; if (--timeout 0) return 4; } LL_I2C_TransmitData8(I2C1, regAddr); timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) return 5; if (--timeout 0) return 6; } LL_I2C_TransmitData8(I2C1, data); timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout 0) return 7; } LL_I2C_GenerateStopCondition(I2C1); return 0; }这段代码的核心认知在于G0系列的新一代I2C外设在主机模式下生成START条件之后地址字节是由硬件自动发送的软件只需要等TXIS标志位然后通过LL_I2C_TransmitData8()往发送数据寄存器里填下一个字节。第一次等待TXIS实际上是等地址字节从寄存器移位出去、硬件发出地址后产生的中断标志。如果你在F1系列上写过I2C那里需要手动填地址字节但在G0上不用这个差别会坑不少人。每次LL_I2C_TransmitData8()写入后硬件如果收到从机的ACK会继续产生TXIS表示发送寄存器又空了可以填下一个字节。如果从机没应答NACK标志会置位此时忙等TXIS会一直超时所以我在循环里加了NACK判断能更快定位问题。3.3 主机接收NACK时机是读多字节的关键主机接收和发送的流程在地址阶段略有不同地址读位发出去之后主机变成接收方硬件会产生RXNE标志而不是TXIS。单字节读取的代码逻辑如下uint8_t I2C_ReadByte(uint8_t devAddr, uint8_t regAddr) { uint32_t timeout 10000; while (LL_I2C_IsActiveFlag_BUSY(I2C1)) { if (--timeout 0) return 0; } /* 先发送起始条件写地址把寄存器地址写进从机 */ LL_I2C_GenerateStartCondition(I2C1); timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout 0) return 0; } LL_I2C_TransmitData8(I2C1, (devAddr 1) | 0); /* 写地址 */ timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout 0) return 0; } LL_I2C_TransmitData8(I2C1, regAddr); timeout 10000; while (!LL_I2C_IsActiveFlag_TXIS(I2C1)) { if (--timeout 0) return 0; } /* 再次产生START条件切换到读地址 */ LL_I2C_GenerateStartCondition(I2C1); timeout 10000; while (!LL_I2C_IsActiveFlag_RXNE(I2C1)) { if (--timeout 0) return 0; } LL_I2C_TransmitData8(I2C1, (devAddr 1) | 1); /* 读地址 */ /* 如果是读多字节最后一字节前要发NACK */ LL_I2C_AcknowledgeNextData(I2C1, LL_I2C_ACK_NEXT_DATA_NACK); timeout 10000; while (!LL_I2C_IsActiveFlag_RXNE(I2C1)) { if (--timeout 0) return 0; } uint8_t data LL_I2C_ReceiveData8(I2C1); LL_I2C_GenerateStopCondition(I2C1); return data; }这段代码里有三个关键点。第一第二次START之前第一次通信必须完整结束否则从机的状态机还没回到空闲第二次寻址会失败。第二在读操作的最后一个数据字节之前要先把应答位配置成NACK。这是因为I2C协议规定主机在接收最后一个字节时必须回NACK告诉从机“不要再发了”然后产生STOP。如果你一直回ACK从机会继续输出数据总线状态就错乱了。第三LL_I2C_ReceiveData8()读到的才是真正的接收数据寄存器内容千万不要用发数据的函数去读数据。4. 从轮询收发到稳定通信我实际跑通的代码演进4.1 带超时的轮询收发函数前面的示例代码里我加了一个简单的计数器超时这种写法虽然糙但在裸机工程里非常实用。真正的项目里不能接受任何一个while循环把CPU卡死所以所有等待标志位的地方都必须有超时出口。这个超时出口可以是简单的while(--timeout)也可以是基于SysTick的毫秒级超时我更推荐后者因为不同情况下I2C等待标志位的耗时差异很大纯计数器的可移植性差。基本思路是写一个内部等待函数static int32_t I2C_WaitFlag(uint32_t flag, uint32_t timeoutMs) { uint32_t tickStart GetTick(); while (!LL_I2C_IsActiveFlag(I2C1, flag)) { if (LL_I2C_IsActiveFlag_NACK(I2C1)) { return -2; /* NACK错误 */ } if (GetTick() - tickStart timeoutMs) { return -1; /* 超时 */ } } return 0; }然后所有收发函数的等待部分都调用它这样超时时间可以按操作类型灵活调整。写操作一般5ms足够读EEPROM这类可能耗时更长的操作放到10ms到20ms。超时时间定得太短低主频或者总线负载高时容易误报定得太长系统卡顿明显。我一般先在逻辑分析仪上看一次正常通信的耗时然后留出3到5倍余量作为超时阈值。4.2 组合读写先写寄存器地址再读数据前面读字节的示例里其实已经包含了组合读写先产生START发写地址寄存器地址再产生第二次START发读地址然后读数据。这个流程在I2C术语里叫“写读组合”几乎所有传感器和存储器都支持这种操作方式。我建议把组合读写封装成一个通用函数对外只暴露设备地址、寄存器地址、数据缓冲区和长度int8_t I2C_ReadRegs(uint8_t devAddr, uint8_t regAddr, uint8_t *buf, uint16_t len) { /* 发送起始条件写地址 */ /* 发送寄存器地址 */ /* 再次发送起始条件读地址 */ /* 循环读取len个字节最后一字节前配置NACK */ /* 生成STOP */ }要注意的是如果len为0函数应当直接返回错误不要在总线上做无意义的操作。如果len为1那这个单一字节既是第一字节也是最后一字节要提前配置NACK。代码里可以用一个简单判断if (len 1) LL_I2C_AcknowledgeNextData(I2C1, LL_I2C_ACK_NEXT_DATA_NACK);这样逻辑清晰不容易漏。4.3 状态机化改造给中断和后续扩展留余地轮询写法的优点是简单直观缺点是阻塞CPU。如果在你的系统里I2C通信频率不高每次通信耗时几百微秒轮询完全够用。但如果你要在中断里维护一个实时控制环I2C的收发就不能长时间霸占CPU这时可以把I2C收发改造成状态机。一个典型的状态机大概是这样空闲态、起始态、发地址态、发寄存器地址态、重复起始态、发读地址态、接收数据态、发NACK态、停止态。每次进入状态机时根据当前状态执行一次LL库操作然后切换到下一状态函数立即返回。主循环里或者在定时器中断里反复调用这个状态机驱动函数直到状态回到空闲。这种改造的收益不只是不阻塞CPU更重要的是它能和DMA、中断结合起来。G0的I2C支持TXIS和RXNE中断你可以在中断里填下一个数据或者取走收到的数据配合状态机做一次几十字节的EEPROM页读写CPU几乎不参与功耗表现也会好很多。状态机的代码比轮询长但逻辑更清晰出问题容易加日志定位。5. 实测踩坑记录卡死、NACK、Busy和AF映射5.1 卡在等待TXIS/RXNE的两种常见根因我第一次在G031上跑I2C时程序卡死在地址发送后的TXIS等待循环里。用调试器暂停一看寄存器里NACKF已经置位了。这种情况只有两种原因地址不对或者总线上根本没有对应设备。排查链路我总结成了三板斧。第一板斧用逻辑分析仪或者示波器抓SCL和SDA波形。如果总线上根本没有波形问题在初始化或者使能环节重点查外设时钟有没有开、外设有没有LL_I2C_Enable()。第二板斧波形只有SCL没有SDA那种情况多半是从机地址匹配不上从机没有拉低SDA做ACK或者SDA一直为高。第三板斧波形正常、ACK也正常但程序还是卡等待那就查你等的是不是正确的标志位。发送数据要等TXIS接收数据要等RXNE这两个搞混了程序必然卡死。提示调试I2C千万别只靠肉眼看代码逻辑分析仪是性价比最高的工具。哪怕是最便宜的8通道逻辑分析仪也能把ACK/NACK、时序错误一眼看穿。5.2 地址发出去被NACK从地址、上拉到从机状态NACK意味着从机没有正确应答。常见原因里最蠢也最常见的是地址左移没做。比如设备手册写地址是0xAE那其实已经是包含读写位的8位地址了如果你代码里又左移一位硬件送出去的地址就完全错了。我的习惯是统一在代码里使用原始7位地址调用驱动函数时再左移拼读写位这样至少排查时不用反复心算。第二个常见原因是上拉电阻。如果你板子上忘了加上拉电阻或者上拉电阻值取得太大比如100kΩ总线高电平上升沿太慢从机在时钟高电平期间采样SDA时会读到错误电平表现为偶发NACK而不是完全没反应。这种故障示波器上看波形会明显看到SDA上升沿变缓。第三个原因是从机没上电或者处于复位状态。I2C从机如果还没初始化完成外部中断和I2C外设都没准备好自然不会应答。调试时可以先确认从机的电源LED亮没亮、复位引脚有没有被拉死。5.3 BUSY标志清不掉软复位和GPIO强制拉高I2C总线被占用时BUSY标志会置位。最常见的情况是通信中途跑飞比如从机没准备好、代码异常退出导致START或者STOP条件不完整总线状态机卡在半路。这时即使你重新初始化I2C外设BUSY标志可能还是1因为硬件认为总线正处于通信中。我的处理方法是先软复位I2C外设调用LL_I2C_DeInit()然后重新LL_I2C_Init()并LL_I2C_Enable()。如果还不行干脆把SCL和SDA两个引脚临时配置成普通推挽输出手动置高再切回复用模式强制让总线回到空闲电平。具体操作是先把两个引脚初始化为LL_GPIO_MODE_OUTPUT输出高电平保持几个微秒再改回LL_GPIO_MODE_ALTERNATE并重配I2C。这样几乎总能清掉BUSY。我遇到过一种特殊情况从机芯片内部锁死始终把SDA拉低这时候不管主机怎么复位I2C外设总线都是忙的。只能把从机断电复位。所以在产品设计时建议给I2C从机预留一个GPIO控制的电源开关既省电又能在总线锁死时远程复位从机。5.4 AF复用表查错波形反转和引脚无输出的排查G031K的引脚复用AF值不是连续排列的I2C功能可能映射在不同的AF编号下。查手册时如果看错AF编号引脚不会输出时钟总线完全没波形。还有一种更隐蔽的情况SCL和SDA两个引脚接反了或者其中一个AF配错导致波形完全乱掉。我自己的教训是上板之前先把芯片型号和引脚封装图打出来用荧光笔标出SCL和SDA引脚再对照数据手册的AF复用表核对每个引脚的AF值。这步十分钟的检查能省掉后面一整天的排查时间。如果板子已经焊好了引脚接反了只能飞线那就更痛苦。6. 工程化落地驱动封装、超时策略和实测数据6.1 写一个通用I2C驱动层项目里直接到处调用LL_I2C_GenerateStartCondition()这些底层函数迟早会乱。我习惯在LL库之上再套一层非常薄的驱动接口把所有设备共用的读寄存器、写寄存器、连续读写封装好上层业务代码只依赖这层驱动。驱动层提供这样几个接口就够了I2C_WriteByte(devAddr, regAddr, data)I2C_ReadByte(devAddr, regAddr)I2C_WriteRegs(devAddr, regAddr, buf, len)I2C_ReadRegs(devAddr, regAddr, buf, len)设备地址在调用时传入上层可以集中管理每个从机的地址常量。哪一天换主控芯片只要初始化函数和底层收发改一下驱动层和业务层基本不动。这个抽象层的价值在项目中期会越来越明显尤其是从机种类一多每个设备都维护自己一套寄存器定义底层接口统一能让代码量少三分之一。6.2 超时时间的选取与错误返回约定超时时间不能拍脑袋。先用逻辑分析仪抓一次正常通信的实际耗时把最长的那次记录下来然后乘以3到5倍作为超时阈值。比如100kHz下写一个字节加STOP大约需要90微秒到100微秒读一个寄存器大约需要200微秒左右那写超时给5ms、读超时给10ms就绰绰有余。要是用了400kHz耗时会缩短到一半以下超时阈值可以更保守。错误返回建议用负数区分错误类型0表示成功-1表示超时-2表示NACK错误-3表示参数错误。这样调用方看到返回值就能直接知道是重试、报错还是放弃。不要把错误码都混在一个return 1里线上问题根本没法定位。另外I2C通信失败后要不要重试、重试几次这是个产品策略问题。传感器读取这类操作可以重试2到3次EEPROM写入则不建议盲目重试因为可能已经写了一半重试反而造成数据错乱。我的习惯是上层区分“可重试”和“不可重试”两类操作把重试逻辑放在业务层而不是驱动层。6.3 实测数据100kHz与400kHz下的单次通信耗时最后给一组我实测的数据主控是STM32G031K8系统主频64MHz使用LL库轮询收发外接一个温湿度传感器和一个EEPROM逻辑分析仪抓的波形操作100kHz耗时400kHz耗时写1字节寄存器约110微秒约45微秒读1字节寄存器组合读写约320微秒约130微秒连续读16字节约720微秒约250微秒这个数据包含了一部分代码执行开销不只是纯I2C线协议耗时但对估算系统时间预算很有参考价值。比如你主循环里要读两个传感器、写一次EEPROM100kHz下I2C总占用大约1毫秒左右在大多数应用里完全可接受。如果追求更低延迟直接切到400kHzI2C开销几乎可以忽略。我调完这轮I2C之后最大的感受是LL库表面上比HAL库多写了很多“啰嗦”的代码但这些代码每一行都在明确告诉你硬件正在做什么。调I2C这种靠时序吃饭的外设这种确定性比什么都重要。最后再分享一个习惯遇到总线异常我第一反应永远是抓逻辑分析仪波形而不是反复看代码。波形能告诉你从机的真实反应代码只能告诉你你“以为”它在做什么。把这两者对得上I2C基本就不会有玄学问题。
阅读完成 · 觉得有帮助?
咨询建站