十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32双机I2C通信实战:从硬件连线到代码实现

STM32双机I2C通信实战:从硬件连线到代码实现 两块 STM32 之间要用 I2C 通信第一次接触的人往往第一反应是“I2C 不是接传感器、接 EEPROM 用的吗两块单片机之间通信直接用 UART 不就行了”其实这个想法没问题UART 也确实是最常见的片间通信方式。但有些场景下I2C 反而是更合适的方案比如从机板卡上挂了一堆 I2C 外设主机只需要通过两根线就能同时控制外设和从机 MCU再比如你要做多主冗余、要挂多个从机且不想占用过多 GPIOI2C 这种带地址寻址的总线协议就能把线材和引脚压到最低。这篇文章我就用两块 STM32 做主从机把 I2C 通信从硬件连线、CubeMX 初始化、主从机代码实现到波形抓包和踩坑排查完整走一遍给准备在项目里用 I2C 做板间通信的工程师一份可以直接抄作业的参考。不管你是刚接触 STM32 HAL 库还是已经写过不少外设驱动这篇文章里的时序细节和总线异常排查思路应该都能帮你少折腾几个晚上。1. 为什么选 I2C双 MCU 通信方案的对比与适用前提1.1 两块 STM32 之间到底能不能用 I2C先说结论完全可以用而且 STM32 的硬件 I2C 外设本身就支持作为 Master 或 Slave 运行两块型号完全相同的芯片之间做 I2C 通信没有任何障碍。很多人下意识觉得 I2C 是“主机和外设”之间的协议其实协议本身只定义了 Master/Slave 角色并没有限定 Slave 必须是一个传感器或存储器芯片。STM32 作为 Slave 设备时它的 I2C 外设能够响应主机发出的地址匹配、接收数据、发送数据并且能产生对应中断所以从逻辑上就是一颗可编程的 I2C Slave 芯片。实际用在一块板卡内部的 MCU 间通信时I2C 相比 UART 的一个关键优势是你不需要双方约定完全一样的串口参数也不需要考虑波特率误差。I2C 是同步时钟协议SCL 时钟由 Master 主动产生从机只是被动跟随所以只要双方时序参数配置合理时钟频率差一点问题都不大。这点和 UART 的异步帧结构完全不同UART 一旦两边波特率偏差超过容忍范围数据就是乱码而 I2C 只要满足建立时间和保持时间数据就能稳定传输。1.2 I2C 与 UART、SPI 的取舍逻辑在做方案选择时我习惯从引脚数量、传输速度、总线扩展性、多从机能力和错误处理能力这几个维度去考虑。下面这个表可以比较直观地看出差异通信方式引脚数量最高速率常见多设备支持主从角色适用场景UART2TX/RX常见 921600bps可更高一般不支持总线挂接一对一需自行约定板间日志、简单命令交互SPI4SCK/MOSI/MISO/CS几十 Mbps支持多从机但每从机需 CS一主多从从机不能主动通信高速数据采集、显示、存储I2C2SCL/SDA100k/400k/1M支持多从机地址寻址一主多从、多主低速控制、寄存器读写、板内通信从表里能看出来I2C 在速度上并不占优特别是跨板通信时线上电容一大速率往往只敢跑 100k~400k。但如果你的应用是“主控板需要给从控板下发配置命令、读取状态”单次数据量可能就几个字节到几十个字节那 I2C 完全够用而且省引脚、省线缆。如果两块 MCU 之间要持续传输音频、图像这类大数据流那 SPI 或者 UART 高速模式更合适I2C 的仲裁和 ACK 机制反而会成为吞吐量的瓶颈。1.3 主从角色分配谁是 Master谁是 Slave双机 I2C 通信时角色分配要考虑几个问题哪个板子负责发起通信哪个板子需要主动上报状态从机有没有实时性要求通常做法是把负责逻辑控制、人机交互或者上位机通信的板子设为 Master把执行机构、采集板卡设为 Slave。比如一个四轴机械臂项目里主控板负责解析运动指令电机驱动板作为从机接收位置指令并返回编码器数据这种分工就很自然。另一个需要注意点是I2C 总线上所有通信都由 Master 发起Slave 不能主动向 Master 发送数据。因此如果从机有突发状态要上报就得在协议设计里预留“事件标志位”让 Master 周期轮询。如果你完全无法接受轮询非要从机主动上报那就得考虑改用 UART 或者把 I2C 设计成多主模式。我在这篇文章的例子里主从机都选择 STM32F103 系列主机用 I2C1从机用 I2C2这样便于在同一个调试工程里清晰区分代码逻辑。实际项目里完全可以都用 I2C1只要引脚不冲突就行。2. 硬件链路搭建引脚、上拉电阻、电平共地与复用配置2.1 引脚选择与功能复用表STM32 不同型号的 I2C 外设复用引脚差别很大务必以参考手册的 AF 映射表为准。以 STM32F103 为例I2C1_SCLPB6默认 或 PB8重映射I2C1_SDAPB7默认 或 PB9重映射I2C2_SCLPB10I2C2_SDAPB11如果你用的是 STM32F4 系列I2C1_SCL 还可能在 PB8I2C1_SDA 在 PB9I2C3 的引脚组合更多。这块搞错的话通信大概率起不来而且排查起来特别隐蔽因为代码初始化的是 I2C 外设实际引脚却没连到外设上。我建议拿到板子后先在数据手册里查 AFIO 映射表确认引脚编号再去看原理图连线。接线方面主机的 I2C1 和从机的 I2C2 之间需要把 SCL 对 SCL、SDA 对 SDA 直连并且两个板子必须共地。对就是 GND 一定要连在一起I2C 是电平信号不是差分信号参考地不一致会让总线上的高电平判断完全错乱。有些新手只接 SCL/SDA 两根线结果数据全是乱码其实就是缺了地线。2.2 上拉电阻取值和硬件连线要点I2C 总线是开漏结构SCL 和 SDA 引脚内部只是把线路拉到地的开关高电平完全靠外部上拉电阻提供。因此 I2C 总线必须接上拉电阻这点和 UART、SPI 的输出推挽结构有本质区别。上拉电阻的取值和总线速率、线缆长度、挂载设备数量都有关。常见取值如下总线模式推荐上拉电阻说明标准模式 100kbps4.7kΩ适用范围广推荐起步值快速模式 400kbps2.2kΩ~4.7kΩ若总线电容较大用 2.2kΩ 更稳快速模式 1MHz1kΩ~2.2kΩ需严格控制总线电容短线板内通信4.7kΩ~10kΩ板内 10kΩ 也能跑但余量小原则上电阻越大功耗越低但上升沿越缓电阻越小上升沿越陡但对驱动能力要求越高。我之前在一块 PCB 上做过测试两块 STM32 之间的走线只有 3cm 左右用 10kΩ 上拉跑 400kbps 也能通但换到排线连接时波形上升沿明显变差最终换成 2.2kΩ 才稳定。所以如果你用的是杜邦线或者长排线直接把上拉电阻选 2.2kΩ不要犹豫。STM32 内部虽然有可编程上拉但内部上拉阻值一般在 30kΩ~50kΩ 这个量级只能作为兜底不能替代外部上拉。如果只是调试阶段临时飞线可以先开内部上拉凑合跑 100kbps但正式电路设计一定要加外部上拉电阻。2.3 HAL 底层 GPIO 与 I2C 外设初始化在 CubeMX 里配置 I2C 时需要先选好 I2C 工作模式Master 还是 Slave。但实际上 STM32 的硬件 I2C 外设本身不区分“主机模式”还是“从机模式”它同时支持主从两种状态运行过程中由总线事件决定。CubeMX 里选的 Master/Slave 只是会帮你生成对应的默认初始化代码底层其实都是同一个外设。所以即使你把主从机都配置成 Master实际通信时只要代码里按角色调用不同 API系统也能正常工作。时钟速度配置项我一般这样设两块 MCU 通过 PCB 短线连接时直接选 400kHz通过杜邦线实验时先跑 100kHz 验证逻辑通了再提速。I2C 时钟速度对 STM32 来说只是一个挂载在 APB1 总线上的分频值只要不超过外设允许上限就没问题。下面是一个主设备的初始化参考HAL 库自动生成的代码手动补充 GPIO 复用部分void MX_I2C1_Init(void) { hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 100000; // 100kHz 标准模式 hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; // 仅快速模式用 hi2c1.Init.OwnAddress1 0x32; // 主设备自己的地址 hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 0x00; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(hi2c1) ! HAL_OK) { Error_Handler(); } }GPIO 复用初始化部分HAL 库会自动生成类似这样的代码void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_I2C1_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_OD; // 开漏复用模式 GPIO_InitStruct.Pull GPIO_PULLUP; // 调试时可以开启内部上拉 GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); }注意GPIO_MODE_AF_OD开漏复用这是 I2C 引脚配置的关键。如果误配成推挽复用GPIO_MODE_AF_PP总线高电平时引脚会主动输出强高电平一旦与另一个设备的开漏输出逻辑相冲突轻则通信异常重则可能损伤引脚。这个细节我见过太多次了每次排查到最后发现就是 GPIO 模式配错。3. 主设备完整实现轮询发送、接收与超时处理3.1 主发从收HAL_I2C_Master_Transmit 的用法与细节初始化完成之后主设备调用 HAL 库接口即可完成一帧数据传输。最基础的是阻塞式发送uint8_t txData[4] {0x01, 0x02, 0x03, 0x04}; HAL_StatusTypeDef status; status HAL_I2C_Master_Transmit(hi2c1, 0x30 1, txData, 4, 1000); if (status ! HAL_OK) { // 可以在这里读取 hi2c1.ErrorCode 判断失败原因 }这里有几个特别容易误导新手的点。第一函数第二个参数并不是从机地址本身而是“从机地址左移一位”后的字节。STM32 HAL 库要求传入的是 8 位地址字节其中最低位是 R/W 位。如果你的从机设备地址是 0x307 位地址那么发送时要传0x30 1也就是 0x60。有的开发者会在 CubeMX 里配置从机 OwnAddress 为 0x60然后主机传 0x60看起来能通但实际是两个地址体系在瞎撞只是某些情况下碰巧能匹配上非常危险。第二超时参数1000的单位是毫秒。如果总线卡死或从机不响应HAL 函数会一直等待直到超时返回。在实时性要求高的系统里这个阻塞时间可能无法接受那就需要改用中断或 DMA 方式。阻塞方式适合初始化阶段自检、低速控制报文这种场景。第三HAL_I2C_Master_Transmit 发送的完整时序是Start 条件 - 发送从机地址R/W写 - 等 ACK - 逐个发送数据字节每个字节等 ACK - Stop 条件。中途任何一个 ACK 失败函数会立即返回HAL_ERROR此时总线上可能已经产生了 Stop 条件也可能没有释放总线需要根据错误码进一步处理。3.2 主收从发HAL_I2C_Master_Receive 的用法与细节主机从从机读取数据的调用类似uint8_t rxData[6] {0}; HAL_StatusTypeDef status; status HAL_I2C_Master_Receive(hi2c1, 0x30 1, rxData, 6, 1000);这段代码执行时主机发送 Start - 地址R/W读 - 等待从机 ACK - 连续接收 6 个字节每收到一个字节后主机回送 ACK最后一个字节回送 NACK - Stop。你不需要手动控制 ACK/NACKHAL 库在最后一个字节会自动处理成 NACK。有个很容易踩的坑是主机调用 Master_Receive 之前从机必须已经调用了一个“准备接收”的函数否则从机不会应答地址。这一点在 I2C 从机模式下特别关键后面从机端实现里我会详细展开。如果要从同一个从机地址先写寄存器地址再读数据也就是很多 I2C 传感器的那种“写地址后重复起始”的读流程HAL 库里对应的是HAL_I2C_Mem_Write和HAL_I2C_Mem_Read。这两个函数专门用于“寄存器式设备”但两块 STM32 之间做自定义协议时也可以直接用这两个函数。比如你在从机端定义一个“寄存器映射表”主机向寄存器地址 0x00 写命令、从寄存器地址 0x10 读状态这样协议清晰而且 HAL 库已经帮你处理了重复起始条件。3.3 阻塞式能跑通之后再考虑中断和 DMA阻塞式函数代码简单但在通信过程中 CPU 会被占住如果通信频率高或者单帧数据长会影响实时任务。改中断方式其实不复杂HAL_StatusTypeDef status; status HAL_I2C_Master_Transmit_IT(hi2c1, 0x30 1, txData, 4); // 发送完成后会进入 HAL_I2C_MasterTxCpltCallback 回调中断方式下HAL_I2C_Master_Transmit_IT只负责启动传输函数立即返回。后续每个字节的发送和 ACK 检查都在中断里完成全部完成后 HAL 库会调用回调函数。你需要做的是在回调里设置一个标志位或者继续发起下一帧传输。void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance I2C1) { txCompleteFlag 1; } }DMA 方式则在数据量更大、CPU 不想频繁进中断时使用需要额外配置 DMA 通道并在HAL_I2C_MspInit里关联 DMA 句柄。对于绝大多数双 MCU 通信场景中断方式已经足够DMA 并不是必须的。我个人的建议是先让阻塞式跑通再切换中断最后再加 DMA。每一步都有明确的验证节点出了问题容易定位。4. 从设备端实现地址匹配、中断收发和状态机4.1 从设备地址怎么定7 位地址和右移一位的坑从机端的 OwnAddress 配置决定了它响应哪个地址。在 CubeMX 里配置从机 I2C需要填一个OwnAddress字段这个字段是 7 位地址比如 0x30。主机发送时要传的地址字节是0x30 1这样得到 0x60。I2C 总线上实际传输的地址字节是 0x60二进制 0110 0000最低位是 0表示“写”。当主机要读时实际传输 0x61最低位 1。STM32 硬件会自动匹配 7 位地址部分忽略最低的 R/W 位。所以你在调试时如果拿逻辑分析仪抓包会看到地址字节不是 0x30 而是 0x60 或 0x61。这是正常的千万不要以为代码写错了。这个“7 位地址和 8 位地址字节”的换算关系是我见过最多人搞混的地方。4.2 从机中断接收HAL_I2C_Slave_Receive_IT 的回调机制从机代码最关键的一点是它必须提前准备好接收缓冲区并调用接收函数否则主机发地址过去时从机不会 ACK。这一步经常被忽略因为在主机模式里是你主动调函数发起传输而在从机模式里你是被动等待如果没准备好总线上的数据传输就直接失败了。从机启动接收的方式如下uint8_t slaveRxBuf[8] {0}; // 在系统初始化阶段调用一次让从机进入“监听”状态 HAL_I2C_Slave_Receive_IT(hi2c2, slaveRxBuf, 8);调用后从机 I2C 外设会一直监听总线。当主机发送地址且匹配时从机硬件自动 ACK然后继续接收后续数据字节。全部接收完成后HAL 库调用回调void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance I2C2) { // 处理接收到的数据 processCommand(slaveRxBuf, 8); // 为下一次接收做准备 HAL_I2C_Slave_Receive_IT(hi2c2, slaveRxBuf, 8); } }这里需要特别注意一个特征从机接收完成后外设不会自动回到“等待下一次接收”的状态必须在回调里再次调用HAL_I2C_Slave_Receive_IT否则下一次主机再发数据时从机不会应答。这是 HAL 库从机模式最典型的隐藏陷阱。另外缓冲区长度要按最大可能帧长设置。如果主机只发了 3 个字节但从机设置了接收 8 个字节那么从机会一直等待后续 5 个字节直到主机发送 Stop 条件。HAL 库在 Stop 条件到来时会结束本次接收并且HAL_I2C_SlaveRxCpltCallback会被调用但接收到的有效字节数需要你自行判断HAL 库不会告诉你实际收到了多少字节。因此建议在协议里加上帧头、长度字段或者约定固定帧长这是双 MCU I2C 通信里很重要的设计点。4.3 从机响应主机读请求HAL_I2C_Slave_Transmit_IT 的启动顺序当主机发起读操作时从机需要准备发送数据。与接收一样从机也需要提前调用发送函数uint8_t slaveTxBuf[6] {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; HAL_I2C_Slave_Transmit_IT(hi2c2, slaveTxBuf, 6);调用时机有两种场景。第一种是系统初始化后立即准备发送比如从机状态数据是周期性刷新好的无论主机什么时候来读缓冲区里都是最新数据那就可以在初始化时调用一次发送完成后的回调里再调用下一次发送。第二种是收到主机命令后根据命令内容构造响应数据再调用发送函数。此时要注意如果主机已经发出了读地址但从机才调用HAL_I2C_Slave_Transmit_IT那从机会错过本次读请求因为地址匹配发生在函数调用之前。为了解决这个问题一种常见做法是从机一开始就调用发送函数占位然后维护一个缓冲区数据更新时直接修改缓冲区内容。发送回调中再次调用发送函数即可持续响应主机。发送完成的回调void HAL_I2C_SlaveTxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance I2C2) { HAL_I2C_Slave_Transmit_IT(hi2c2, slaveTxBuf, 6); } }从机端同时管理收发代码结构建议用状态机来维护typedef enum { I2C_SLAVE_IDLE, I2C_SLAVE_RX_READY, I2C_SLAVE_TX_READY } I2C_SlaveState; I2C_SlaveState i2cSlaveState I2C_SLAVE_IDLE; // 初始化时 void SlaveI2C_Init(void) { HAL_I2C_Slave_Receive_IT(hi2c2, slaveRxBuf, RX_BUF_SIZE); HAL_I2C_Slave_Transmit_IT(hi2c2, slaveTxBuf, TX_BUF_SIZE); i2cSlaveState I2C_SLAVE_RX_READY; } // 注意同时调用接收和发送的可能性需要仔细考虑后面会提到替代方案这里有个细节如果同时调用HAL_I2C_Slave_Receive_IT和HAL_I2C_Slave_Transmit_IT预期行为是主机读写都能响应实际测试中在部分 HAL 版本里确实可以。但更稳健的做法是在两个回调里动态切换状态确保同一时刻只有一个接收或发送请求处于激活状态以免出现状态冲突。我自己的经验是如果从机主要接收命令偶尔被读取状态那就在初始化时只调用HAL_I2C_Slave_Receive_IT收到命令后根据命令类型判断是否需要应答数据如果主机发的是“读请求命令”就转为调用HAL_I2C_Slave_Transmit_IT。这样逻辑清晰状态也不容易混乱。5. 把问题“看”出来逻辑分析仪抓波形与常见总线异常诊断5.1 抓什么起始、停止、ACK/NACK、地址字节调试 I2C 通信最重要的一步就是用逻辑分析仪抓总线波形。很多问题单纯看代码完全发现不了比如上拉太弱导致上升沿缓、从机 ACK 时序偏移、总线冲突等只有看波形才能定位。市面上几十块钱的逻辑分析仪加上 PulseView 或者厂家配套软件就能满足绝大多数 I2C 调试需求。抓波形时重点看这几段Start 条件SCL 高电平时 SDA 由高变低一个清晰的下降沿。地址字节起始后第一个字节8 位最高位是地址最高位最低位是 R/W。解析出来的值应该是 0x60写或 0x61读。ACK/NACK 位第 9 个时钟周期。SDA 为低表示 ACK为高表示 NACK。主机发送地址后如果得到 NACK说明从机没准备好或地址不匹配。数据阶段每个字节后跟一个 ACK/NACK。Stop 条件SCL 高电平时 SDA 由低变高。逻辑分析软件一般都会自动解析 I2C 协议直接在波形上标出 START、ADDRESS、DATA、ACK 等你只要对照解析结果判断是否符合预期。5.2 总线卡死、SDA 一直低电平、时钟拉伸误判最常见的线上故障是总线卡死SDA 一直被拉低SCL 正常或者也异常。这种情况通常发生在通信过程中某个设备出错把 SDA 拉低后没有释放。比如从机正在发送数据时主机发起了 Stop两边状态机不同步从机可能还在等时钟SDA 保持低电平整个总线就锁死了。遇到这种情况一个常见处理方式是在代码里检测到总线忙时对 SCL 引脚做 9 个脉冲的复位操作让从机状态机恢复void I2C_BusReset(GPIO_TypeDef* SCL_PORT, uint16_t SCL_PIN) { for (int i 0; i 9; i) { HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_RESET); delay_us(5); HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET); delay_us(5); } }注意这个操作要在 SDA 引脚配置为输入、SCL 配置为输出的情况下执行相当于手动模拟 9 个时钟周期把总线上的从机状态机顶出来。这样处理后再重新初始化 I2C 外设一般就能恢复了。另一个容易误判的是“时钟拉伸”Clock Stretching。有些从机在数据准备好之前会把 SCL 拉低要求主机等待。但标准 STM32 的 I2C 外设在从机模式下支持时钟拉伸主机模式下默认不会去响应从机的时钟拉伸如果你用的从机是支持时钟拉伸的第三方芯片就可能出现通信超时。好在两块 STM32 之间通信时双方都支持时钟拉伸实际项目中反而几乎用不上这个特性只要配置NoStretchMode保持默认的DISABLE就是允许时钟拉伸基本不会出问题。5.3 用状态寄存器和 HAL 返回值定位问题在抓不到波形的情况下只能靠 HAL 函数返回值和外设状态寄存器来定位。HAL 库的ErrorCode字段可以区分是HAL_I2C_ERROR_ACKF、HAL_I2C_ERROR_BERR、HAL_I2C_ERROR_TIMEOUT还是HAL_I2C_ERROR_DMA等错误类型。调试时可以在错误分支里把完整错误码打出来uint32_t err HAL_I2C_GetError(hi2c1); printf(I2C Error: %08lX\r\n, err);HAL_I2C_ERROR_BERR总线错误通常表示总线上出现了非法起始/停止条件多半是干扰或者电平异常HAL_I2C_ERROR_AF应答失败表示地址或数据传输时出现 NACK需要检查从机地址和从机就绪状态HAL_I2C_ERROR_TIMEOUT则是等待超时常见于从机时钟拉伸或总线锁死。还有一类问题很难从代码层面发现就是主机发送数据后从机已经收到了但主机端仍然报超时。这往往是因为从机在接收完成后未及时释放 SCL比如进入了某个死循环或者中断优先级被高优先级任务一直抢占导致 I2C 中断无法及时响应。这种情况下要重点排查从机的中断优先级设计把 I2C 中断优先级适当提高避免在中断服务程序里做耗时操作。6. 实测中踩过的坑从总线死锁到从机漏收6.1 从机必须提前调用接收函数这个问题我前面已经提到但实际项目中它造成的故障最为隐蔽。曾经有一次我写双机通信主机反复HAL_I2C_Master_Transmit返回成功但从机的回调就是进不去。最后检查发现从机初始化时只调了HAL_I2C_Slave_Receive_IT一次第一次接收完成后回调里忘了再次调用导致从机在第二次通信时不再应答。主机那边因为 I2C 地址 ACK 失败后 HAL 库可能返回超时也可能返回错误但问题根源却在从机的状态机维护上。解决这个问题其实没有捷径就是要养成一个习惯在从机回调函数中打印或置标志确认回调是否进入。如果你在主机的错误码里看到 ACK 失败第一时间去查从机是不是处于“接收完成但未重新准备”的状态而不是去怀疑主机配置。6.2 主设备停止条件与从机端 STOP 回调HAL 库的从机回调函数中除了数据接收完成回调外还有一个HAL_I2C_ListenCpltCallback。这个回调在从机完成一次完整的地址监听周期后被调用。意思是主机从 Start 开始到 Stop 结束整个过程完成后从机会触发这个回调。如果你的从机需要严格区分“一次完整事务的结束”可以在这个回调里重新启动监听。不过我实际使用中更多是在HAL_I2C_SlaveRxCpltCallback和HAL_I2C_SlaveTxCpltCallback里处理逻辑然后在HAL_I2C_ListenCpltCallback里打印一条日志用来观测总线上是否出现过意外 Stop。这一点在排查“从机为什么漏收后半段数据”时特别有用。6.3 7 位地址 vs 8 位地址为什么写地址多出读标志前面提到过主机的 HAL 函数参数要传左移后的地址。这里再补充一个实际测试时的小技巧如果你在逻辑分析仪上看到主机发送的地址字节是 0x30说明你在代码里直接传了 0x30 而不是0x30 1。这种情况下从机配置的 OwnAddress 如果是 0x30硬件并不会认为这是匹配地址因为 I2C 从机硬件匹配的是 7 位地址 0x30总线上的地址字节 0x30 去掉最低位的 R/W 位后是 0x180x30 右移一位两者根本不匹配。所以总线上的地址字节和从机配置的地址差了一位这个问题用逻辑分析仪一眼就能看出来。6.4 上电时序和复位的隐藏风险双 MCU 通信还有一个容易忽略的点上电时序。如果主机先上电立即开始发送而从机还没完成初始化那么第一次通信大概率会失败。我通常的处理方式是从机初始化完成后向主机发一个 GPIO 电平信号或者在从机初始化完成后通过 I2C 外的握手引脚通知主机“我准备好了”主机再开始通信。如果没有额外引脚也可以在主机端做重试机制通信失败后延时 100ms 重试连续重试几次直到成功。另一个隐藏风险是复位。如果从机在通信过程中意外复位而此时主机还在持续通信从机的引脚在复位瞬间可能是高阻态总线状态不确定可能拉低 SDA 导致锁死。因此在产品设计上从机复位后要尽快重新初始化 I2C 并进入监听状态主机侧则要处理好超时重试逻辑不要因为一次失败就永久停发。6.5 从机发送数据时碰到主机读长度不匹配主机读取字节数大于从机准备发送字节数时从机发完自己缓冲区数据后会一直发送 0xFF 或者总线上的数据实际上变成高阻由主机读到的可能是 0xFF也可能是随机值。这种情况不会产生错误但会给协议解析带来比较隐蔽的脏数据问题。我的做法是在协议的第一个字节放数据长度主机先读固定长度的帧头再按帧头里的长度继续读取剩余数据。这样可以灵活适配变长数据帧也能避免主机读取长度不匹配的问题。7. 一套可复制的从机通信框架与协议设计建议多块 STM32 之间通过 I2C 做板级通信除了把底层收发跑通更需要一个清晰的协议层。我在实际项目中整理了一套比较稳定的从机框架主要包含三个要素固定帧头、寄存器地址、可变长度载荷。具体数据格式由项目自行定义但思路如下第 1 字节帧头固定为 0xA5用于同步。第 2 字节命令/寄存器地址。第 3 字节本次传输的数据长度 N。第 4 ~ 4N-1 字节数据载荷。末尾 1 字节校验累加和或者 CRC8。主机写命令时一并发完整帧主机读状态时可以先用写操作向从机的命令寄存器写入“要读取的项目编号”然后发起读操作从机返回对应的状态帧。这样通信逻辑清晰后续扩展也简单。从机接收函数在主循环里不断重新启动监听但要注意不能频繁调用否则可能造成状态混乱。更好的做法是每个回调结束时重新启动一次保证同一时刻只有一个接收任务在跑。配合一个状态机变量从机整体结构可以写成void SlaveI2C_Run(void) { switch (i2cSlaveState) { case I2C_SLAVE_RX_READY: // 已经由回调或初始化启动了接收等待数据 break; case I2C_SLAVE_RX_DONE: processRxData(slaveRxBuf); memset(slaveRxBuf, 0, RX_BUF_SIZE); HAL_I2C_Slave_Receive_IT(hi2c2, slaveRxBuf, RX_BUF_SIZE); i2cSlaveState I2C_SLAVE_RX_READY; break; default: break; } }这套框架我在两个项目里直接复用一次是主控板通过 I2C 控制舵机驱动板另一次是采集板通过 I2C 上报 ADC 数据。只要把协议里的命令码和数据处理逻辑替换掉底层通信几乎不用改。8. 双机 I2C 通信的稳定性和扩展性考量最后聊一下稳定性和扩展性的经验。稳定性方面我在实际项目中体会最深的是“速度不是越高越好”。两块 STM32 在同块 PCB 上400kHz 确实能跑但在系统里同时有电机、继电器等干扰源时总线波形会劣化。此时把速率降到 100kHz牺牲一点吞吐量换来的却是极大的稳定性提升。I2C 的定位是控制面通信不是数据面通信不要指望拿它传大块数据。实时性要求高的控制环建议走 SPI 或者定时器同步I2C 只做参数下发和状态上报这是最稳妥的分工。扩展性方面I2C 的好处是地址寻址。如果从机板卡上还有其他 I2C 设备比如 EEPROM、传感器、温度监控芯片它们可以挂在同一条 I2C 总线上地址只要不冲突即可。因此双 MCU I2C 通信不仅仅解决“两个单片机怎么通信”还可以顺带形成一个统一的 I2C 管理总线。需要注意的是每个设备的 SDA/SCL 引脚都是开漏外部上拉电阻只需要一组不要每个设备各加一组否则并联阻值太小低电平驱动电流可能不够。如果你未来要扩展到三块、四块板卡之间的 I2C 通信设计上要特别注意总线上拉电阻和总线电容。板卡之间用排线连接时每一段线都会增加电容上拉电阻要相应减小。同时每块板卡最好加一个 I2C 地址拨码开关方便在总线上区分不同从机避免代码里写死地址导致生产时还要单独烧录不同固件。我实际项目中遇到过因为三块板卡并联导致 SCL 上升沿过缓通信时好时坏的情况。后来把上拉电阻从 4.7kΩ 换成了 2.2kΩ并且每块板卡上的从机地址通过拨码配置系统稳定运行至今。这里也提醒一下如果总线上设备数量变多不要只想着调上拉电阻尽量缩短总线走线长度、避免在一条总线上挂太多设备必要时用 I2C 总线扩展器或者分组隔离。这些都是配套方案需要在早期架构设计时就想好。最后分享一个我自己的调试习惯在两块 STM32 的 I2C 中断回调里分别置一个 GPIO 翻转信号用示波器双通道同时观察 SCL 和这个 GPIO可以直观地看到从机是不是及时响应了主机。如果 GPIO 翻转比 SCL 最后一个时钟晚很多说明从机中断响应被高优先级任务延后了如果翻转时间点不稳定说明从机调度有问题。这个土办法在定位“偶发通信超时”时比单纯看逻辑分析仪的波形更直接也更容易发现问题规律。
返回列表