拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32 IIC主从机通讯实战:协议原理、代码实现与踩坑总结

简介这是一份基于STM32的IIC主从机通信完整工程示例面向嵌入式初学者与开发者旨在演示两片STM32之间通过IIC协议进行数据传输的流程。代码采用C语言编写重点展示SDA/SCL时序控制、START/STOP条件生成、7位从机地址匹配、ACK/NAK应答处理以及超时与异常处理等关键环节可直接迁移到传感器读取、板间通信等实际项目中。包内共338个文件除IIC核心源码的.c/.h文件外还包含标准外设库文件、Keil工程配置文件uvprojx/uvoptx以及编译生成的.o/.axf/.hex中间文件便于直接编译查看或对比学习压缩包整体7.31MB目录结构较为清晰。当前已有1839人学习浏览。通过阅读和运行示例可掌握主从机模式配置、数据收发与错误处理流程同时为后续在STM32上调试其他串行协议打下基础。 上个月一个老客户跑过来给我丢了个需求手头有两块STM32板子一块负责采集温湿度一块负责显示和控制两块板子要装在同一个设备里线越少越好。我第一反应是UART但他紧接着又补了一句后面可能还要加两个执行器子板最好一根总线能挂多个设备。那就别纠结了直接上IIC吧——两根线一主一从刚好匹配这个场景。STM32 IIC主从机通讯这套东西跑通不难但想稳定还是要抠细节。我用STM32标准外设库把主从机通讯跑通顺便做了一套简单的帧协议整个过程花了不到半天。这篇就把具体设计、代码和踩过的坑一起整理出来。如果你是在校学生做课设或者是刚接触STM32通讯的开发者这篇同样适用。IIC双机通讯最核心的点就三个协议时序能看懂、从机地址和中断流程能想清楚、硬件上拉电阻别乱选。把这三点弄明白主从机通讯基本不会有大问题。1. 项目概述与方案选型1.1 这个项目到底要解决什么问题两块STM32要“对话”本质上是两个独立的单片机系统之间做数据交换。采集板单独跑采集逻辑主控板单独跑界面和控制逻辑两者之间需要传递温湿度数据、风扇开关状态、系统心跳等几类信息。选型的时候其实把常见方案都过了一遍。UART最简单点对点通信很可靠但一个串口只能带一个设备想挂第二个从机就得再开一路串口SPI速度最快但SCK、MISO、MOSI、CS四根线从机多了片选线也堆起来。IIC的优势正好卡在中间标准模式100kbps、快速模式400kbps传输温湿度这种低频数据完全够用SCLSDA两根线就能挂上百个设备后期扩展从机不用动总线布线。1.2 为什么选IIC而不是UART和SPI我自己选型的判断标准很简单先看速度需求再看引脚预算最后看设备扩展性。以这个项目为例传送的数据量大概是每秒一组温湿度一组数据不超过8字节。UART就算115200bps也绰绰有余但它天然是一对一的后期扩展从机只能靠软件轮询或者换协议栈SPI要四个引脚起步主控板的引脚资源已经比较紧张。IIC唯一的劣势是协议状态机比UART复杂但STM32自带硬件I2C外设时序部分不用手写配置好寄存器就行。IIC还有个容易被忽略的好处它天生就有地址机制。每个从机都有自己的7位地址主机发数据的时候带上地址总线上的从机自己判断是不是呼叫自己。这个机制放到实际项目里就是“换地址”代替“换线”外设扩展省了太多事。1.3 主从架构与数据流的整体设计这个项目的架构很简单一主一从主机STM32F103主控板负责液晶显示、按键处理定时向从机请求温湿度数据也下发控制指令。从机STM32F103采集板负责读取SHT30温湿度传感器接收主机下发的控制指令并回传状态信息。数据流向是典型的主从轮询模式主机主动发起请求从机应答。从机绝不会主动发数据这是IIC主从通讯里一定要遵守的规矩否则总线冲突没法收场。后面代码也是按这个模型写的主机作为Master发起传输从机作为Slave在中断里应答。2. IIC通讯协议核心拆解2.1 从物理层到协议层IIC到底怎么“说话”IIC两根线SCL是时钟线SDA是数据线。总线空闲时两根线都被上拉电阻拉到高电平。想开始通讯主机会在SCL为高时把SDA从高拉低这就是起始条件结束时SCL为高时把SDA从低拉高这是停止条件。所有从机都在监听这两个条件就像办公室里喊“开会了”和“散会了”一样。数据位的传输要求SDA只能在SCL为低电平期间变化在SCL为高电平时必须保持稳定。这个规定保证了接收方采样时不会读到跳变过程中的数据。实际调程序时如果波形上看到SCL高电平期间SDA还在跳那基本就是代码写错了。ACK机制也很好理解。每发送完8个数据位发送方释放SDA接收方在第9个时钟周期把SDA拉低表示“我收到了”。如果接收方没拉低总线保持高电平主机就知道对方没接住可以决定重发或者停止。实际项目里最烦的就是等ACK等到一个NACK后面第四节会专门讲排查。2.2 地址、读写位和ACK/NACK机制IIC通讯中主机发送起始条件后第一帧永远是设备地址。标准模式是7位地址加1位读写标志比如从机地址是0x30那么主机发送的地址字节是0x30 1 | 0也就是0x60表示写操作如果是读操作就是0x30 1 | 1也就是0x61。很多人第一次调IIC失败就是这里左右移搞反了。每个从机在初始化时要设置自己的I2C_OwnAddress比如从机设置成0x30当主机发来的地址字节跟0x301匹配时从机的硬件I2C外设就会触发事件中断软件再根据读写标志决定接下来是收还是发。地址匹配失败的数据帧从机一概不理会。ACK和NACK的坑通常出在两个地方一种是主机发送读地址后从机的事件标志没置位导致主机一直等Event另一种是多字节接收时最后一个字节没有返回NACK从机不知道传输结束。这里有一个容易忽略的知识点主机读数据时倒数第二个字节要返回ACK最后一个字节要返回NACK表示“够了下一个时钟停止”。2.3 硬件设计里的上拉电阻与电平匹配IIC总线是开漏结构SCL和SDA必须接上拉电阻才能输出高电平。上拉电阻选多大直接影响通讯稳定性。低速近距离开4.7kΩ问题不大通信距离超过20厘米、或者快速模式400kbps时建议改成2.2kΩ甚至1kΩ否则上升沿太缓接收方采样容易出错。上拉电阻太小会让灌电流过大发热且增加功耗所以也别一味求小。如果主机是3.3V、从机是5V或者反过来IIC最好不要直连。常见做法是加电平转换芯片比如PCA9306或者用两个MOS管搭双向电平转换电路。两块STM32都是3.3V供电就不用担心这个问题但这个红线值得记住IIC不是耐压接口乱接容易烧IO口这种情况我见过好几次。3. 实操过程与代码实现3.1 硬件连接与工具准备两块STM32F103板子接线如下主机SCL(PB6) - 从机SCL(PB6)主机SDA(PB7) - 从机SDA(PB7)主机GND - 从机GND必须共地VCC按需连接如果两块板子各自独立供电就只有GND连通两个板子的I2C1都映射到PB6/PB7。接好之后用万用表量一下SCL和SDA对地电压正常应该在3.3V附近因为空闲时被上拉电阻拉高。如果量出来是0V先检查有没有共地再检查上拉电阻。调IIC强烈建议备一个逻辑分析仪几十块钱那种就够能直接看时序波形排查问题比盲猜快十倍。3.2 主机端初始化配置与数据发送主机端用STM32标准外设库初始化I2C1配置为主模式即可。关键代码void I2C1_Master_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_ClockSpeed 100000; I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_OwnAddress1 0x31; I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }GPIO一定要配置成复用开漏模式漏了OpenDrain这步后面SDA低电平都拉不下去。时钟速度先跑100k稳定后再尝试400k这是我一直推荐的做法。主机向从机发送数据的核心流程是起始-从机地址写位-数据-停止。标准库的Event检测方式直接、清晰uint8_t I2C_Master_SendData(uint8_t slaveAddr, uint8_t *data, uint16_t len) { uint16_t i 0; I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, slaveAddr, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); for (i 0; i len; i) { I2C_SendData(I2C1, data[i]); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE); return 0; }这里有个细节I2C_EVENT_MASTER_BYTE_TRANSMITTED表示数据已经发送出去并且收到从机的ACK如果从机没处理完数据这里就会一直卡住所以最好加个超时计数防止死循环。实际项目我改成每轮循环判断超时变量超过比如100ms就报错并重新初始化。3.3 从机端初始化配置与中断接收从机端初始化跟主机类似但OwnAddress要设为约定好的0x30。从机接收数据不能靠轮询要用I2C1事件中断void I2C1_Init_Slave(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_ClockSpeed 100000; I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_OwnAddress1 0x30; I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); I2C_ITConfig(I2C1, I2C_IT_EVT | I2C_IT_ERR, ENABLE); NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel I2C1_EV_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }事件中断里处理几个关键标志位ADDR表示地址接收完成RXNE表示收到一个新字节STOPF表示接收完整个数据帧。伪代码如下void I2C1_EV_IRQHandler(void) { if (I2C_GetITStatus(I2C1, I2C_IT_EVT) ! RESET) { if (I2C_CheckEvent(I2C1, I2C_EVENT_SLAVE_RECEIVER_ADDRESS_MATCHED)) { // 地址匹配准备接收 } if (I2C_CheckEvent(I2C1, I2C_EVENT_SLAVE_BYTE_RECEIVED)) { rxBuf[rxCnt] I2C_ReceiveData(I2C1); } if (I2C_CheckEvent(I2C1, I2C_EVENT_SLAVE_STOP_DETECTED)) { // 收到停止条件一帧结束 frameComplete 1; I2C_Cmd(I2C1, ENABLE); } } }从机接收一定要记得在STOPF事件后重新使能I2C否则下一个起始条件来了它不响应表现就是第一次通讯可能正常第二次开始主机等到超时。3.4 双向通讯的帧格式与协议约定裸数据一通乱传很快会出问题所以我在通讯层加了一个简单帧格式帧头命令字数据长度数据区CRC校验0xAACMDLENN字节crc8主机下发一条控制命令比如“打开风扇”AA 0x01 0x01 0x01 0xXX从机上报温湿度AA 0x10 0x04 T_MSB T_LSB H_MSB H_LSB 0xXX从机收到一帧后先判断帧头再解析命令字最后核对CRC。CRC不用搞太复杂CRC8多项式几十行代码就能搞定。帧格式的好处是通讯过程里偶尔丢字节或者混入干扰解析端能靠帧头对齐和CRC校验直接丢掉坏帧不会把脏数据传上去。3.5 主机读取从机数据的实现光主机发数据不行还得分主读取这里用I2C主接收模式主机发送地址加读位接着时钟信号由主机产生但数据由从机推送到SDA。从机侧对应的就是I2C_EVENT_SLAVE_TRANSMITTER_ADDRESS_MATCHED和I2C_EVENT_SLAVE_BYTE_TRANSMITTING事件。从机在地址匹配后把要返回的数据准备好在事件中断里用I2C_SendData发送。主机侧接收需要特别注意ACK策略uint8_t I2C_Master_ReadData(uint8_t slaveAddr, uint8_t *buf, uint16_t len) { uint16_t i 0; I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, slaveAddr, I2C_Direction_Receiver); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); for (i 0; i len; i) { if (i len - 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); } while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); buf[i] I2C_ReceiveData(I2C1); } I2C_GenerateSTOP(I2C1, ENABLE); I2C_AcknowledgeConfig(I2C1, ENABLE); return 0; }这段代码里最关键的环节就是最后一个字节前把ACK关掉接收完数据后再重新打开。很多直接抄代码的新手把这步漏了系统表现为多读一个字节、从机数据错位。4. 常见问题与排查技巧4.1 I2C总线卡在BUSY状态怎么办标准库开发最经典的报错之一I2C外设的BUSY标志被置位发送START之后CheckEvent一直超时。常见原因有三个一是通讯过程中SDA被意外拉低总线状态没恢复二是线接错或者接触不良三是程序跑飞之前正好停在半帧状态。我的处理顺序是先物理排查接线和波形确认总线本身没被外部锁住如果确认总线上没有其他设备占用就在初始化前对I2C外设做一次软件复位把BUSY清掉。标准库里用I2C_SoftwareResetCmd(I2C1, ENABLE)再DISABLE即可。实在不行就把GPIO配成普通推挽输出手动给SCL翻转9个时钟让可能卡在错误状态的从机恢复空闲态这招在总线卡死后特别管用。4.2 从机一直返回NACK或主机卡死主机发地址后Event不置位十有八九是从机地址对不上。检查三处从机的I2C_OwnAddress是否设置成了期望值主机发送的地址字节是不是正确左移了一位从机的事件中断有没有使能。注意从机地址左移这件事在标准库里I2C_Send7bitAddress传入的是7位地址库内部会自己左移但在自研协议或者驱动其他I2C器件时地址字节往往需要人为左移别照搬。另一个隐蔽问题是中断优先级。从机的事件中断优先级如果被设置得比主机通信过程中的某个中断低而且这个高优先级中断又长时间占用CPU从机可能来不及及时把数据送到移位寄存器主机那边就表现为数据超时。把I2C中断优先级调到最高或者次高一般能解决。4.3 数据错位和丢字节数据错位大多是主机侧的事件检测太慢。标准库的I2C_CheckEvent是个循环等待的过程如果代码里有其他中断打扰等回来的时候RXNE标志可能已经被覆盖了。这个问题用DMA能根治I2C接收方向配置成DMA模式数据由DMA自动搬运到内存CPU只在传输完成后收到完成中断。不过DMA模式下ACK策略的处理更隐蔽需要在传输接收最后一个字节之前关闭ACK否则最后一个字节会读成0xFF。如果不方便上DMA就尽量在中断里“快进快出”不要在事件中断里做长耗时操作比如浮点运算、打印调试、软件延时这些都是丢数据的温床。4.4 从机中断进不去或第二次通讯失败从机第一次通讯正常、第二次就断的情况大概率是STOPF事件后没有重新使能I2C。标准库在检测到STOP后需要再次调用I2C_Cmd(I2C1, ENABLE)因为检测到STOP时外设可能自动关闭接收状态。框架代码里加个fault计数每次通讯失败就自增连续失败3次就重新初始化I2C总线防止程序陷入死等。5. 项目扩展与应用心得5.1 从一主一从扩展到一主多从一主一从跑通后扩到一主多从其实就是改地址的事。把每个从机设置成不同的7位地址主机发送数据时选择对应地址即可。总线上拉电阻阻值要酌情减小因为多挂设备等于多个开漏输出并联总线电容也会上升。我在这个项目的第二阶段挂了一块执行器板处理方式跟采集板几乎一样只是命令字不同。唯一要留意的是软件轮询节奏别在IIC总线上用阻塞式长数据包持续发否则其他从机的响应延迟会变大。5.2 几点个人习惯与体会项目收尾后我总结了几点个人习惯写在这里供参考第一所有CheckEvent循环都加超时处理。单片机跑起来以后不可控因素太多没有超时的IIC代码就是一颗定时炸弹。第二每次调IIC都开逻辑分析仪抓波形不靠猜。第三通讯帧务必加CRC校验哪怕只是1字节CRC8也能避免非常多疑难杂症。这套代码后来被我抽出来做成了一个独立模块换不同型号的STM32时只要改引脚映射和时钟配置就能直接用。如果你正在做类似的双机通信建议也按“驱动层加协议层”的结构来组织代码驱动层管硬件时序、协议层管帧格式后期维护会轻松很多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门