
搞嵌入式这些年我接到的技术提问里“两块板子之间怎么通信”绝对排得上前三。前阵子给一台小车换主控一台STM32F103要专门做电机控制另一台做姿态解算和上层逻辑两台之间距离不到二十厘米数据量也不大。我最后选了I2C通信两根线搞定板子焊好那天连起来一次跑通后续调试主要花在各种边角问题上。这篇就把I2C在两块STM32之间通信的完整实现写出来从方案怎么选、硬件怎么接、CubeMX怎么配到主机发送、从机接收、双向读写以及调试中那些能卡你一整天的坑都给你讲透。这篇内容适合两类人一类是刚接触STM32想把两块开发板之间通过I2C打通的人另一类是已经在用HAL库但被硬件I2C偶发卡死、回调不触发这类问题折磨过的人。我会尽量把每一处选择背后的原因说清楚你照着做能跑也知道为什么这么写。1. 为什么我选I2C做板间通信方案对比与整体设计1.1 UART、SPI、I2C三条路怎么选先说结论板间通信不是只能选I2C我最后选它是综合了线缆数量、扩展性、调试成本和速度要求的结果。把三种常见方案摆在一起对比会更直观方案最少线数是否支持多设备寻址常用速率适合场景UART2TX/RX不支持点对点115200bps ~ 2Mbps简单双机通信、日志输出I2C2SCL/SDA支持7/10位地址100k/400k/1MHz板内短距离多设备总线SPI4SCK/MOSI/MISO/CS靠CS片选最高几十Mbps高速传感器、Flash、屏我这次是两块STM32之间互相传控制指令和姿态数据单次数据量不超过几十字节更新频率大概几百赫兹I2C的400kHz完全够用。它最大的优势在于两根线就能挂多个设备以后要是再加个OLED屏、加个温度传感器直接挂在同一组SCL/SDA上就行不用改软件架构。SPI速度确实快但四根线占了IO而且每一帧都要控制CS片选软件上麻烦不少UART虽然是很多人的第一反应但两个主机之间没有地址概念想扩展第三个节点就得重新接线。当然I2C也有短板。它的时钟是由主机产生的主机一颗芯片挂了整条总线就废了速率上限比SPI低很多不适合搬大量数据。如果你要做两块板之间高速批量传数据比如每秒几百KB的图像块I2C就不合适老老实实上SPI或者并口。选方案永远是需求先行的不要为了“看起来高级”选一个用不上的高吞吐方案。1.2 主从角色、从机地址与通信速率怎么定I2C通信里必须先分清楚谁是主机、谁是从机。主机的职责是产生SCL时钟、发起START、发送地址、决定读写方向从机只能被动响应。我这次把小车主控设为主机姿态解算板设为从机因为整个系统的决策流是主控来驱动的——主控想读姿态数据就发起一次读想下发参数就发起一次写从机永远不主动抢总线。从机地址我用的是0x32这种7位地址。这里有个特别容易踩的坑I2C协议里地址字节实际上是8位低1位是读写标志位所以7位地址0x32在主机的发送函数里要写成0x32 1 0x64。如果你在代码里直接传0x32实际寻址到的就是地址0x19的设备从机怎么可能应答你。这个错我见过很多人犯包括我自己刚上手时也栽过。关于速率我选了400kHz的Fast模式。板间走线十几厘米PCB走线或者杜邦线都算很短400kHz完全没问题。如果两块板之间用的是二三十厘米以上的飞线或者经过连接器、线缆转接我建议保守一点降到100kHz。I2C速率受总线电容限制线越长、节点越多边沿就越缓超过上限之后从机就可能采不到正确的电平表现就是偶发丢字节、校验失败、地址NACK。速率不是越高越好稳定压倒一切。1.3 硬件I2C还是软件模拟I2C这个选择在STM32圈子里争议很大尤其F1系列的老用户基本都有心理阴影。STM32F1的硬件I2C外设早年被爆出过一些errata比如总线忙标志卡住、在特定时序下锁死网上骂声一片导致很多人一上来就劝退说“F1千万别用硬件I2C用软件模拟”。这个说法有一定历史背景但也不全对。现在的HAL库相比当年的标准外设库已经修了不少问题配合超时和错误恢复硬件I2C在两块F103之间做点对点通信是相当可靠的。我这次选了硬件I2C理由很直接硬件I2C用起来省CPU发送和接收过程不需要我逐位翻转引脚大量等待交给外设自己处理而且HAL库的IT模式直接回调代码结构清晰。软件模拟I2C虽然在引脚灵活性上有优势——你可以在任意GPIO上模拟时序而且推挽输出驱动能力更强但它的问题也很明显CPU忙等、时序要自己延时控制、一旦中断优先级没配好导致时序抖动通信就可能出错。所以我的建议是两块板之间的固定I2C通信优先用硬件I2C碰到疑难杂症比如F1总线锁死、上拉电阻不好换、或者你的引脚被别的功能占死没法复用再退到软件模拟。后面第5节我会专门讲F1硬件I2C的坑和恢复办法真出了问题也有退路。2. 硬件连接与工程初始化2.1 引脚选择与上拉电阻计算STM32F103的I2C1默认映射在PB6SCL和PB7SDA如果启用了重映射也可以跑到PB8/PB9上。我手里的板子这两个引脚没有被板载外设占用所以直接用默认映射。两块板用I2C1对连接线很简单信号主机STM32F103C8T6从机STM32F103C8T6说明SCLPB6PB6I2C1_SCLSDAPB7PB7I2C1_SDAGNDGNDGND必须共地3.3V3.3V3.3V可选视供电情况I2C总线是开漏结构也就是说驱动器件只能把线拉低不能主动拉高。高电平完全靠外部上拉电阻提供。这是很多人忽略的关键点不接上拉电阻总线就一直是低电平什么都跑不起来。STM32内部虽然也有上拉但阻值一般几十千欧驱动能力对于400kHz总线来说远远不够必须外接。上拉电阻选多大主要看总线电容和通信速率。I2C标准里对上升沿时间有要求100kHz模式下最大1µs400kHz模式下最大300ns。用RC充电模型估算R选大了边沿太缓从机可能识别不了R选小了功耗大在低电平状态下灌电流会超标。工程上常用这个经验值通信速率总线长度推荐上拉电阻100kHz20cm4.7kΩ100kHz20cm以上3.3kΩ400kHz20cm3.3kΩ400kHz20cm以上2.2kΩ我来回插拔过很多次最后定的是3.3kΩ。如果你用的是面包板加杜邦线尽量别超过400kHz杜邦线的寄生电容比PCB走线大不少我实测在面包板上400kHz偶尔会出现偶发NACK降到100kHz就什么问题都没有了。2.2 CubeMX配置一次点对少走弯路我习惯先用CubeMX生成工程框架再往里面填业务代码。两块板各自建一个工程配置逻辑大部分相同只差从机端要设置自身地址。下面这些配置项是必须确认的两块板都开启I2C1模式选I2C参数设置里速度模式选Fast Mode时钟频率填400000也就是400kHzNVIC设置里I2C1事件中断和I2C1错误中断都要勾选从机工程里在I2C1的Parameter Settings中找到Own Address 1填0x32时钟树里确认APB1时钟别太低I2C的时钟源来自APB1如果APB1只有8MHz你还要把I2C分频配到合适值CubeMX会自动算但你别手动把APB1砍得太狠。这里最容易漏的是第3步。很多人把CubeMX生成完直接在main里调HAL_I2C_Slave_Receive_IT结果从机一直收不到数据因为中断压根没使能。中断使能这类配置不勾选是编译不出错的但运行起来就是“死等”。所以每次生成代码后先检查NVIC里对应外设的中断有没有打勾。2.3 从机地址、时间基线与时钟拉伸从机地址在CubeMX里填0x32之后HAL库会自动帮你处理地址对齐。但你要清楚HAL的API要求主机传入的是“8位地址字节”也就是0x32左移一位后的0x64。在代码里我会统一写一个宏避免到处魔法数#define SLAVE_ADDR_7BIT 0x32 #define SLAVE_ADDR (SLAVE_ADDR_7BIT 1) /* 0x64 */另一个需要理解的概念是时钟拉伸Clock Stretching。I2C协议允许从机在来不及处理数据时把SCL线拉低逼主机等待。STM32的HAL库在从机模式默认会使用这个机制。这对主机来说意味着什么意味着如果从机的中断没被及时响应、或者从机程序死机SCL会一直保持低电平主机这边HAL_I2C_Master_Transmit就会一直等直到你设置的超时时间耗尽。理解了这个机制后面排查“主机超时”这类问题就很好定位先拿万用表量SCL是不是被拉低再看从机死没死。3. 主机发送、从机接收先跑通第一帧3.1 从机端的监听初始化与中断回调我建议先跑通一个最简单的场景主机往从机发一段固定长度的数据从机收到后在串口打印出来。这样能把I2C外设的基本链路打通后面再谈复杂协议才有底。从机端要同时用上“监听”和“接收”两个机制。监听是为了让从机在总线空闲时也保持对自己的地址敏感一旦主机发起传输、地址匹配就会触发地址回调。然后在地址回调里根据方向决定从机接下来是收数据还是发数据。/* 从机工程 */ #define FRAME_LEN 4 uint8_t rxBuf[FRAME_LEN]; void Slave_I2C_Init(void) { /* 开启监听等待主机寻址 */ HAL_I2C_EnableListen_IT(hi2c2); } /* 注意从机工程里我用的是I2C2外设因为从机端我特意换了一组引脚 */ void I2C2_EV_IRQHandler(void) { HAL_I2C_EV_IRQHandler(hi2c2); } void I2C2_ER_IRQHandler(void) { HAL_I2C_ER_IRQHandler(hi2c2); } void HAL_I2C_AddrCallback(I2C_HandleTypeDef *hi2c, uint8_t transferDirection, uint16_t AddrMatchCode) { if (hi2c-Instance ! I2C2) { return; } if (transferDirection I2C_DIRECTION_TRANSMIT) { /* 主机要写数据给从机从机准备好接收缓冲区 */ HAL_I2C_Slave_Receive_IT(hi2c, rxBuf, FRAME_LEN); } else { /* 主机要从机发数据第4节再讲 */ } } void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance ! I2C2) { return; } if (rxBuf[0] 0xA5) { printf([SLAVE] recv frame: %02X %02X %02X %02X\r\n, rxBuf[0], rxBuf[1], rxBuf[2], rxBuf[3]); } }注意HAL_I2C_AddrCallback里的方向参数I2C_DIRECTION_TRANSMIT是从主机的视角