ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IIC通信协议详解:软件IIC时序、C函数与避坑排查

IIC通信协议详解:软件IIC时序、C函数与避坑排查

简介:《IIC通信协议详解》是一份面向嵌入式入门者与单片机开发学习者的技术文档,围绕两线式串行总线的原理理解与代码实现展开,可帮助读者厘清SDA、SCL时序关系,并理清软件IIC与硬件IIC的差异、适用场景及通信流程。压缩包内含1个docx文件,大小约81KB,内容相对集中,便于按主题查阅与随用随看。文档覆盖空闲状态、开始信号、停止信号、应答信号和字节发送等关键环节,配有起始、停止、ACK/NACK及发送单字节的程序片段,并强调SCL高电平期间SDA不得变化、低电平才可改变等约束,同时提及半双工通信特点与软硬件实现取舍。已有1074人学习,适合用作IIC协议的基础梳理材料,也可为传感器、EEPROM等外设调试提供时序判断与代码参考。

1. 从一次读不到数据的调试说起:IIC 到底该怎么学

焊好一块小板子,上电,读传感器 ID 寄存器,串口回来永远是0xFF。示波器一挂,SCL 那条线像被谁按住了一样,怎么都拉不到高电平——上拉电阻漏贴了。这种亏,写过 IIC 驱动的人基本都吃过一次。IIC 只有两根线,SDA 传数据、SCL 打节拍,看上去比 SPI、UART 都好懂,真要自己从零写出稳定能跑的时序,坑一点都不少:起始信号差一个微秒、应答位方向没切回来、延时常量算错,现象全是"读不到"。

这份《IIC 通信协议详解》走的是另一条路:它不满足于讲协议,把软件 IIC 从空闲状态、起始、停止、应答一直到字节收发,逐段落到可以直接抄进工程的 C 函数里。适合两类人——刚学单片机、想搞明白"两根线凭什么能通信"的新手,以及手上有现成硬件 IIC 外设、但碰上时序异常需要退回软件模拟来定位问题的老手。

2. 协议分层与软硬件选型:先用软件 IIC 把时序握在手里

拿到一份讲协议的资料,最怕它一口气把电气特性、仲裁、时钟同步全倒出来,看完还是不知道怎么下手。这份文档的组织顺序比较务实:先给两条线的电平规则,再区分软件 IIC 与硬件 IIC,最后才一段一段写代码。这个顺序其实就是实际开发的顺序——先把"什么时候能改电平"这条铁律刻进脑子,后面所有函数都是它的推论。

2.1 两条线上的电平规则:所有时序的总根子

文档里反复强调的两句话,是整个 IIC 的地基:

  • 当 SCL 为高电平时,SDA 不允许变化;
  • 当 SCL 为低电平时,SDA 可以任意变化(0 或 1)。

理解了这两句,起始信号、停止信号、数据位、应答位就全都成了一句话的推论。起始信号是"SCL 高电平期间 SDA 由高变低",这不是违规吗?是违规,但协议规定这个违规组合专门用来表示"开始";停止信号同理,是"SCL 高电平期间 SDA 由低变高"。数据位则老老实实遵守规则:SCL 低电平时把数据摆上 SDA,SCL 拉高后接收方采样,高电平期间数据必须稳住不动。

文档还补了一条容易被忽略的:只有在 SCL 为高电平时,IIC 电路才记录 SDA 上的电平,SCL 为低时无论 SDA 是什么都不采样。这句解释了为什么"拉低 SCL 再改 SDA"是安全的,也解释了为什么很多新手代码在 SCL 高电平期间改数据,从机却能偶尔读到正确值——不是对了,是碰巧采样时刻躲开了跳变。

总线状态SCLSDA说明
空闲11两线均被上拉电阻拉高,器件输出级截止
起始1→1→0SCL 保持高,SDA 下降沿
数据位高电平采样稳定高电平期间 SDA 不可跳变
应答 ACK第 9 个时钟高0接收方拉低 SDA
非应答 NACK第 9 个时钟高1接收方释放 SDA
停止1→0→1SCL 保持高,SDA 上升沿

这张表建议直接贴在工位边上。后面写任何一行驱动代码,都能在表里找到它对应的位置。

2.2 软件 IIC 与硬件 IIC 的取舍:管脚灵活性和吞吐量的对赌

文档把两者讲得很清楚:软件 IIC 是用单片机两个普通 I/O 口模拟出来的,靠软件翻转管脚状态去凑出 IIC 波形;硬件 IIC 对应芯片内部的 I2C 外设,有专门的驱动电路和专用管脚,配置的是内部寄存器。

实际选型时我一般这么判断:

维度软件 IIC硬件 IIC
管脚占用任意 GPIO,可随时改固定复用管脚
速率受延时精度和中断影响,一般 100~200kbps 稳可到 400kbps 甚至更高
CPU 开销全程占用,每个位都要进函数配置完可由中断/DMA 搬运
多设备冲突处理自己写重试和总线恢复部分芯片硬件层处理,部分要靠 errata 绕
可移植性换芯片只改几个宏换芯片基本要重写初始化
调试可见性每一步都能打断点、点灯黑匣子,出错只能看寄存器状态

一句话总结我自己的用法:板子还在调、传感器还没认全的时候用软件 IIC,等协议跑通、数据量上来了再切硬件 IIC。文档里也点明了硬件效率远高于软件,而软件不受管脚限制、接口灵活——这句话反过来读就是:软件 IIC 的价值在"灵活"和"可控",不在"快"。

还有一个词值得单独拎出来:IIC 是半双工通信。同一时刻数据只能朝一个方向走,主机发完一个字节必须等从机应答,从机才能继续。这和 UART 的全双工不一样,写代码时不能想当然地"边发边收",很多读寄存器的时序错误,根子就在没接受"半双工"这个前提。

2.3 从文档到代码的映射:五个状态与七个函数

文档把一次完整通信拆成开始、结束、发送、响应、接收五个部分。落到 C 代码上,我习惯分成三层:

  • 管脚层:SDA_OUT()/SDA_IN()切方向,IIC_SDA/IIC_SCL读写电平;
  • 时序层:IIC_Start/IIC_Stop/IIC_Ack/IIC_NAck;
  • 字节层:IIC_Send_Byte/IIC_Wait_Ack/IIC_Read_Byte。

再往上就是设备层,比如"写寄存器地址 + 读 N 个字节"这种组合时序,文档没有展开,但那属于具体器件手册的事,协议层不该管。

这七个函数加起来不到两百行,却覆盖了 90% 的常见外设:EEPROM、温湿度传感器、OLED 屏、加速度计、RTC 芯片,用的都是这一套。把它们的时序抠准了,剩下的就是查器件手册填寄存器地址。

3. 空闲、起始与停止:把时序图翻译成 GPIO 动作

真正开始写代码,第一件事不是写起始信号,是确认总线现在处于什么状态。文档专门用一段讲空闲状态:SDA 和 SCL 同时为高电平,各器件的输出级场效应管截止,总线被释放,由两条线上各自的上拉电阻把电平拉高。这句话里藏着两个信息——一是空闲不是"没人管",而是"被电阻管着";二是如果上拉电阻缺失或阻值太大,总线根本回不到空闲态,后面所有时序都会错位。

3.1 上拉电阻:为什么总线上不能没有它

IIC 的器件输出级是开漏结构,只能主动把线拉低,拉高全靠外部上拉电阻。所以你会看到很多模块上已经焊了 4.7kΩ 或 10kΩ 的电阻,也见过有人把它拆了接更小的阻值。阻值怎么选,本质是上升沿时间和功耗的折中:阻值大,上升沿变缓,高速率下采样点可能还没到高电平;阻值小,边沿陡峭,但器件灌电流变大。总线电容越大(挂的设备越多、走线越长),越要往小里选。

这段文档没有给具体阻值,但它的表述很准确:空闲状态"由两条信号线各自的上拉电阻把电平拉高"。我调试时看总线是否真的空闲,用的就是最土的办法——上电不初始化,先用万用表量 SDA、SCL 对 VCC 的电压,接近电源电压才算合格。低一半、低更多,先查上拉和短路,别急着怀疑代码。

3.2 起始信号:SCL 高电平期间 SDA 的下降沿

起始信号的定义是"当 SCL 为高期间,SDA 由高到低的跳变",文档特意加了一句:它是一种电平跳变时序信号,而不是一个电平。这句话是防坑的关键,因为很多初学者会写成"把 SDA 拉低 = 起始",漏了 SCL 必须为高的前提。

文档给的实现,我按注释的顺序读一遍:

// 产生 IIC 起始信号 // 1. 设置 SDA 为输出方向 // 2. 先拉高 SDA,再拉高 SCL,回到空闲状态 // 3. 在 SCL 为高期间拉低 SDA,形成下降沿 // 4. 拉低 SCL,钳住总线,准备发送或接收数据 void IIC_Start(void) { SDA_OUT(); // SDA 线切为输出方向 IIC_SDA = 1; // 先给数据线高电平 IIC_SCL = 1; // 时钟线拉高,总线进入空闲态 delay_us(4); // 保持空闲电平,等待从机识别 IIC_SDA = 0; // START: SCL 为高时,SDA 由高变低 delay_us(4); // 保持该跳变,满足从机的建立时间 IIC_SCL = 0; // 钳住 I2C 总线,准备发送或接收数据 }

逻辑说明:这四步的顺序不能乱。先SDA_OUT()是因为上电后 GPIO 默认可能是输入,方向没切对,写IIC_SDA = 1只是写了个寄存器,管脚未必有输出;先拉高 SDA 再拉高 SCL,是为了避免在 SCL 已经为高的时候把 SDA 从低拉高——那会被从机误判成停止信号。最后把 SCL 拉低,是为了离开"SCL 高电平期间 SDA 不许变"的约束区,接下来的数据位才有地方改。

参数说明:delay_us(4)是这套代码里最敏感的常量。4μs 对应约 125kbps 的理论上限,实际因为函数调用开销会慢一些。如果你的主频很低或者用了不带优化的编译选项,4μs 可能远远不够,表现为起始信号发出去了但从机没反应。

3.3 停止信号:为什么必须先拉低 SCL

停止信号是"当 SCL 为高期间,SDA 由低到高的跳变",同样是一个跳变而不是一个电平。文档的实现是这样的:

// 产生 IIC 停止信号 // 1. 设置 SDA 为输出方向 // 2. 先拉低 SCL,再拉低 SDA // 3. 拉高 SCL,此时 SDA 仍为低 // 4. 在 SCL 为高期间拉高 SDA,形成上升沿 void IIC_Stop(void) { SDA_OUT(); // SDA 线切为输出方向 IIC_SCL = 0; // 先拉低时钟,允许 SDA 变化 IIC_SDA = 0; // STOP: 先把数据线拉到低 delay_us(4); IIC_SCL = 1; // 时钟拉高,此时 SDA 保持低电平 IIC_SDA = 1; // 在 SCL 高期间 SDA 由低变高,发出结束信号 delay_us(4); }

逻辑说明:第一步必须先IIC_SCL = 0。假设上一步是接收完字节,SCL 可能还停在高电平,这时候直接把 SDA 从高拉到低,会被从机当成起始信号,总线状态立刻错乱。先拉低时钟,等于给自己争取了一个"可以随便改 SDA"的窗口,把 SDA 先摆到低电平,再把 SCL 拉高,最后才抬 SDA,形成合法的上升沿。

参数说明:这里的delay_us(4)同样是给从机的识别时间。文档在发送字节的注释里提过"对某些模块这三个延时都是必须的",说的就是这类延时不是保险丝,是时序本身的一部分。

一个常见的翻车点是:初始化 GPIO 后没发停止信号就直接开始读数据。如果上一次通信因为异常中断,总线可能卡在某个从机正拉着 SDA 的状态,此时主机贸然发起始,波形就对不上了。我一般在上电初始化末尾补一句IIC_Stop(),当作后悔药,把总线拉回确定的空闲态。

3.4 延时常量与 400kbps:一个位周期怎么算

文档提到高速 IIC 总线一般可达 400kbps 以上。这个数字不是拿来感受的,是要换算成延时参数的。

400kbps 意味着每秒 40 万个位,每个位的周期是 2.5μs,半边高电平半边低电平,各 1.25μs。标准模式 100kbps 则是位周期 10μs,高低各 5μs。所以你会看到不同代码里的delay_us常量大不一样:写delay_us(4)的,实际上跑在 100kbps 附近甚至更低;写delay_us(1)或干脆用空指令的,冲着快速模式去的。

换算完还得留余量。软件 IIC 的延时函数本身有调用开销,GPIO 翻转也要时间,实际位周期永远比算出来的长。我的经验是:想要 100kbps,延时按 80kbps 算;想要 400kbps,先确认主频够高、延时函数是精准的忙等而不是走系统节拍,否则老老实实降到 200kbps 用。稳定比标称速率重要得多。

4. 字节收发与应答:一个字节的 8 个时钟和等待应答的超时

起始信号把总线钳住之后,接下来就是一位一位地搬数据。IIC 的数据位是边沿触发的:SCL 低电平期间主机把数据摆上 SDA,SCL 上升沿之后接收方采样,SCL 高电平期间数据保持稳定,SCL 下降沿之后才能改下一位。八个节拍送完一个字节,第九个节拍交给接收方回应答。

4.1 发送一个字节:MSB 优先与高位判断

文档的IIC_Send_Byte用的是最常见的写法——每次取最高位,送完之后把整个字节左移一位:

// IIC 发送一个字节 // 返回值未使用,实际应答由 IIC_Wait_Ack 单独判断 void IIC_Send_Byte(u8 txd) { u8 t; SDA_OUT(); // 发送前确保 SDA 是输出方向 IIC_SCL = 0; // 拉低时钟,开始数据传输 for (t = 0; t < 8; t++) { // 取当前字节的最高位,为 1 则 SDA 拉高,为 0 则拉低 if ((txd & 0x80) >> 7) IIC_SDA = 1; else IIC_SDA = 0; txd <<= 1; // 左移一位,下一轮取次高位 delay_us(2); IIC_SCL = 1; // 时钟拉高,从机在此刻采样 delay_us(2); IIC_SCL = 0; // 时钟拉低,允许改变下一位数据 delay_us(2); } }

逻辑说明:txd & 0x80判断的是 bit7,>> 7把它变成 0 或 1。也可以直接写IIC_SDA = (txd & 0x80) >> 7,文档注释里两种都给了。关键是txd <<= 1这一句,在送完当前位之后立刻左移,下一轮循环用同一个判断逻辑就能取到次高位。整个循环跑完 8 次,正好把 txd 的 8 位从高到低送出去。

参数说明:txd是要发的一个字节,注意函数内部对它做了左移,是按值传递,外面传进来的变量不会被改,但如果传的是某个寄存器变量的引用或者后面还要用它,得留个心眼。三次delay_us(2)分别对应"数据建立时间""高电平保持时间""低电平准备时间",少了任何一个,波形都会变窄,从机在高速下就可能采错。

还有一点文档专门写了:只有当 SCL 被拉低后,SDA 才能被改变。代码里每次改IIC_SDA之前都确认IIC_SCL = 0,这个顺序是硬约束,不是风格问题。

4.2 等待应答:方向切换与超时保护

发完 8 位,第 9 个时钟周期交给从机。主机要做两件事:把 SDA 从输出切成输入(不然主机会一直驱动这根线,从机拉不低),然后看从机有没有把它拉低。文档的写法是:

// 等待应答信号到来 // 返回值:1 表示接收应答失败,0 表示接收应答成功 u8 IIC_Wait_Ack(void) { u8 ucErrTime = 0; SDA_IN(); // SDA 切换为输入,交给从机驱动 IIC_SDA = 1; // 释放数据线,拉高以便检测下降 delay_us(1); IIC_SCL = 1; // 时钟拉高,从机在此周期内应答 delay_us(1); while (READ_SDA) // 读取 SDA,若一直为高表示没有应答 { ucErrTime++; if (ucErrTime > 250) { IIC_Stop(); // 超时后发停止信号,释放总线 return 1; } } IIC_SCL = 0; // 时钟拉低,应答周期结束 return 0; }

逻辑说明:SDA_IN()是整个函数的核心,方向不切,后面READ_SDA读到的永远是主机自己写进去的 1,从机应答到此为止。IIC_SDA = 1这一句在切为输入后写,实际作用因芯片而异——有的 MCU 输入模式下写 ODR 仍会影响上拉/下拉,有的完全无效,但保留它没有坏处,能兼容一部分"先写后切"的老代码。

参数说明:ucErrTime > 250是超时门限,文档没说这个 250 是怎么来的,本质上是个经验值——循环 250 次的总时间大致在几百微秒到一两毫秒之间,足够从机响应,又不至于让主程序卡太久。如果从机没接、地址错了、或者器件正在复位,这个循环不设上限就是死循环,整个系统卡在这里。这个超时是必须的,不是可选项。

超时后调用IIC_Stop()也有讲究:总线已经乱了,发一个停止信号能让还在等待的从机复位状态机,下次通信才有可能成功。很多只写"返回错误"不写"释放总线"的实现,第二次调用还是失败,原因就在这。

4.3 接收一个字节与 ACK/NACK 的收尾

接收是发送的镜像:主机先把 SDA 切成输入,每来一个 SCL 高电平读一位,左移拼装,八位读完由主机决定给应答还是非应答。

// 读 1 个字节,ack=1 发送 ACK,ack=0 发送 NACK u8 IIC_Read_Byte(unsigned char ack) { unsigned char i, receive = 0; SDA_IN(); // SDA 切换为输入,准备读取 for (i = 0; i < 8; i++) { IIC_SCL = 0; // 拉低时钟,允许从机改变 SDA delay_us(2); IIC_SCL = 1; // 拉高时钟,此时 SDA 上是从机送出的数据位 receive <<= 1; // 左移一位,给新数据腾位置 if (READ_SDA) receive++; // 读到 1 则最低位置 1 delay_us(1); } if (!ack) IIC_NAck(); // 主机不想再读了,回 NACK else IIC_Ack(); // 主机还要继续读,回 ACK return receive; }

逻辑说明:receive <<= 1必须在读 SDA之前执行,否则第一次左移会把刚读到的最高位挤掉。这是新手最常写反的一行,现象是读回来的字节整体右移一位、最低位恒为 0。READ_SDA在 SCL 高电平期间读取,正好对应协议规定的采样窗口。

参数说明:ack控制第 9 个时钟周期主机给什么。读最后一个字节时必须传 0,发 NACK,通知从机"我不要了,你可以释放 SDA 了",这样主机才能顺利发出停止信号;如果最后一个字节还回 ACK,从机会继续往 SDA 上送数据,主机这时发停止信号,两者就在总线上打架,波形会很难看。

4.4 组装一次完整的寄存器读:五个原子函数的组合

把上面几个函数串起来,就是实际外设驱动里最常见的"读寄存器"操作。以读某传感器的一个 8 位寄存器为例,常见做法是:

// 读指定器件某个 8 位寄存器的值 // dev_addr: 7 位从机地址,reg: 寄存器地址 // 返回读到的字节,通信失败返回 0xFF u8 IIC_Read_Reg(u8 dev_addr, u8 reg) { u8 data; IIC_Start(); // 起始信号 IIC_Send_Byte((dev_addr << 1) | 0); // 地址 + 写方向 if (IIC_Wait_Ack()) { IIC_Stop(); return 0xFF; } IIC_Send_Byte(reg); // 要读的寄存器地址 if (IIC_Wait_Ack()) { IIC_Stop(); return 0xFF; } IIC_Start(); // 重复起始信号,切换为读 IIC_Send_Byte((dev_addr << 1) | 1); // 地址 + 读方向 if (IIC_Wait_Ack()) { IIC_Stop(); return 0xFF; } data = IIC_Read_Byte(0); // 只读一个字节,回 NACK IIC_Stop(); // 停止信号,释放总线 return data; }

逻辑说明:注意中间那个IIC_Start()是重复起始,不是先 Stop 再 Start。区别在于重复起始期间总线控制权没有释放,其他主机不会插进来;如果先 Stop 再 Start,理论上别的设备可以抢占总线,读到的数据就可能来自错误的对象。大部分单主系统里两者现象一样,但协议上只有重复起始是标准写法。

参数说明:dev_addr << 1是因为 IIC 的地址帧是 7 位地址加 1 位读写方向位,左移之后最低位空出来,写操作填 0,读操作填 1。很多人第一次看到(addr << 1) | 0会愣一下,其实就是把"读写位"拼进去。每步都判断应答,失败立刻 Stop 并返回错误值,这套写法能保证任何一步出错都不会把总线留在占用状态。

5. 避坑与排查:示波器上看不出来的五个坑

协议本身不复杂,出事的地方往往在协议之外。下面五条是我和周围人踩过的,每条都按"现象是什么 → 真正的原因 → 怎么解决"整理,能省掉不少盯代码的时间。

5.1 五条高频出错的记录

第一坑:读回来的数据恒为 0xFF

现象:起始信号、地址、寄存器地址全都发了,IIC_Wait_Ack也返回 0(有应答),但读出来的字节永远是0xFF。

原因:IIC_Read_Byte里没有正确切换到输入方向,或者SDA_IN()和SDA_OUT()的宏定义写反了。主机还在驱动 SDA,读到的是自己写进去的高电平。

解决:在IIC_Read_Byte入口处打断点,读之前先量一下 SDA 的电平;如果主机还在拉高,说明方向切换失败。检查宏定义里引脚配置寄存器的位有没有写对,输入模式是否配成了上拉输入而不是浮空。

第二坑:从机地址对了,但偶尔应答失败

现象:单步调试永远成功,全速跑十次失败一两次。

原因:延时常量在单步时被拉长了几十倍,全速下不够用,或者主循环里有中断打断,把某个高电平周期撑得太长,从机判为超时。

解决:先把delay_us整体放大一倍验证是否是时序余量问题;确认问题时,在关键时序函数里临时关中断,测完再开。如果是从机超时判定导致,需要把整个字节收发做成原子的,不能中途被高优先级中断切走。

第三坑:上电第一次通信必失败,复位一次就好

现象:冷启动后第一次读总是错误,按一下复位键之后就正常了。

原因:从机上电复位比主机慢,主机初始化完立刻发时序,从机还没准备好;或者总线上残留了上一次异常掉电时的电平状态。

解决:初始化末尾加一段上电延时,并在第一次通信前先发一个IIC_Stop()清理总线。如果总线真的被某个从机拉死,常见做法是手动给 SCL 打 9 个时钟脉冲,让从机的移位寄存器把剩余的位数吐完,再发停止信号。

第四坑:程序卡死在等待应答里,看门狗都救不回来

现象:拔掉传感器或者换一块没焊芯片的板子,程序直接停住,串口不再输出。

原因:IIC_Wait_Ack没有超时判断,while (READ_SDA)死循环。

解决:必须加计数超时,超时后发停止信号并返回错误。这个错误值要往上传,让上层决定是重试还是报故障,不能悄悄吃掉。带看门狗的系统里,等待应答的循环里最好也喂一口狗,否则复位之后总线状态还是乱的。

第五坑:多设备挂在同一总线上,读 A 设备却影响了 B

现象:总线挂了两三个 IIC 器件,单独调都正常,一起用就随机出错。

原因:地址冲突,或者其中一个器件的上拉电阻被重复焊接导致等效阻值过小、边沿过冲;还有一种情况是某个器件在掉电时把 SDA 拉低,整条总线被拖死。

解决:先断开其他器件逐个验证地址,用扫描程序把总线上所有应答的地址打印出来对比手册;上拉电阻只保留一组,其余拆掉;掉电拉低的问题只能从硬件上解决,加隔离或者改上电顺序。

5.2 一张上板自检清单

代码调不通的时候,我按这个顺序走一遍,基本能定位到问题在哪一层:

  1. 万用表量 SDA、SCL 对 VCC 电压,两条线空闲时都应接近电源电压;
  2. 上电不发任何信号,用逻辑分析仪或示波器看两条线是否被意外拉低;
  3. 只发IIC_Start(),看波形上 SCL 高电平期间 SDA 是否有干净的下降沿;
  4. 发地址字节,确认第 9 个时钟周期 SDA 是否被从机拉低;
  5. 全部失败时换一个已知能用的器件交叉验证,先排除是代码还是硬件。

这套流程的价值在于把"读不到数据"这个笼统现象切成五段,每一段都有明确的观察点。比一遍遍改代码试运气靠谱得多。

6. 二次开发:把它改成能跑在任意引脚、任意速率的总线层

这份文档给的函数是直接操作IIC_SDA、IIC_SCL两个宏的,换个芯片或者换组引脚就得改函数体。真正拿到工程里用,第一件事是把管脚抽出去,第二件事是给总线加上故障恢复能力。

6.1 用宏和函数指针把管脚、延时抽象出来

不改函数体、只改配置的做法,是把引脚操作全部收进宏里,函数只依赖这几个宏名:

// 移植时只改这一段,下面所有时序函数不用动 #define IIC_PORT GPIOB #define IIC_SCL_PIN GPIO_PIN_6 #define IIC_SDA_PIN GPIO_PIN_7 #define IIC_SCL_H() HAL_GPIO_WritePin(IIC_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define IIC_SCL_L() HAL_GPIO_WritePin(IIC_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) #define IIC_SDA_H() HAL_GPIO_WritePin(IIC_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define IIC_SDA_L() HAL_GPIO_WritePin(IIC_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) #define READ_SDA HAL_GPIO_ReadPin(IIC_PORT, IIC_SDA_PIN) // 速率相关:单位微秒,改这一处就能整体调快调慢 #define IIC_DELAY_US(x) delay_us(x)

这样换芯片只需要改端口和引脚号,时序函数一行不动。更进一步可以把IIC_SCL_H()这类宏换成函数指针,运行时选择走软件模拟还是走硬件外设,同一套上层设备代码两种底层都能用。我一般不会一上来就做到函数指针这层,除非确实有产品线要在两种方案间切换,否则先把宏抽象做干净就够用了。

6.2 加超时、重试和总线恢复

原始代码里的超时只出现在IIC_Wait_Ack,起始、停止、收发字节本身都没有保护。工程化改造我通常补三样:

  • 给每个while等待加计数上限,超时统一返回错误码;
  • 在设备层加重试,比如读寄存器失败重试 3 次,每次之间补一个停止信号;
  • 加一个IIC_Bus_Recover()函数,检测到 SDA 长时间为低时,手动打时钟脉冲把从机状态机推完。

恢复函数的关键在于:先把 SCL 配成输出、SDA 配成输入,然后最多打 9 个 SCL 脉冲,每打一个读一次 SDA,直到 SDA 变高,最后补一个停止信号。这一段代码文档里没有,但凡是产品化的 IIC 驱动几乎都有,掉了电、热插拔、从机死机,全靠它把总线救回来。

6.3 改完怎么验证还是对的

改完之后别急着上业务代码,先跑三个验证:一是用逻辑分析仪抓一次完整读寄存器,逐位核对起始、地址、应答、数据、停止是否符合时序图;二是把延时常量减半再翻倍,确认在 ±50% 范围内通信仍然稳定,说明时序有余量;三是人为把从机断电再上电,看恢复函数能不能把总线拉回可用状态。

这三个验证跑通,这套驱动才敢往产品里放。

改这类底层代码,我吃过最大的亏是"看着能跑就不管了"——软件 IIC 在实验台上永远稳,一进现场碰上长线、多设备、电源波动就开始玄学。从那以后我每次写完或改完 IIC 驱动,都强制走一遍逻辑分析仪逐位核对加断电恢复测试,不管工期多紧。希望帮到你,手上这份文档里的时序和函数,就是走这套流程最好的起点。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进