STM32 IIC主从机通讯实战:协议原理、代码实现与踩坑总结
2026/9/9 6:48:35 网站建设 项目流程

简介:这是一份基于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,传输温湿度这种低频数据完全够用;SCL+SDA两根线就能挂上百个设备,后期扩展从机不用动总线布线。

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,当主机发来的地址字节跟0x30<<1匹配时,从机的硬件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_MATCHEDI2C_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时只要改引脚映射和时钟配置就能直接用。如果你正在做类似的双机通信,建议也按“驱动层加协议层”的结构来组织代码,驱动层管硬件时序、协议层管帧格式,后期维护会轻松很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询