做过几年嵌入式开发的人基本都遇到过这样的事手头的传感器、存储器芯片规格书上清清楚楚写着“寄存器地址 16 位”可主控芯片的硬件 I2C 外设要么已经被别的设备占用要么引脚被复用掉了一组。这时候最省事的办法就是拿普通 GPIO 去模拟 I2C 时序也就是常说软件 IIC。我在实际项目里用这一招解决过不少硬生生被“资源不足”卡住的通信需求尤其是 16 位地址器件只要摸清时序和地址顺序做出来的效果甚至比某些硬件外设还稳。这篇文章就围绕“软件 IIC 发送 16 位地址”这件事把方案思路、底层时序、代码实现和排查方法一次性讲透。适合正在调试 I2C 从机、想绕开硬件外设限制或者被 16 位寄存器地址搞到头大的开发者看完可以直接照着抄。1. 需求与方案拆解为什么偏偏要软件 IIC1.1 软件模拟 I2C 在什么场景下最划算软件 I2C 的本质就是用两个 GPIO 引脚按协议要求的时序去翻电平。SCL 引脚产生时钟SDA 引脚负责数据收发。大家通常是在下面几种情况里把它翻出来用主控芯片硬件 I2C 外设数量不够比如一个外设已经接了多个设备再想挂新传感器只能软件模拟。硬件 I2C 的引脚不是想用就能用的。做 PCB 时别人把某根引脚先占了或者引脚分布太散走线困难。某些国产芯片或者旧型号 MCU硬件 I2C 实现得不够顺手中断、错误标志位各种小毛病调试起来比软件模拟还费劲。需要实现非常规的时序比如多主设备、特殊单次传输、带时钟延展的复杂从机软件实现反而更灵活。软件 I2C 最大的优势是“任意引脚都能用”。只要引脚是 IO 口把 SCL 和 SDA 定义出来就能随时移植到另一颗芯片上。遇到硬件 I2C 被引脚复用冲突时软件方案几乎不需要改板子。代价当然也有。最明显的是 CPU 占用每传输一个字节要花几百微秒在电平翻转和位判断上。对大多数传感器交互场景来说这个消耗完全可接受。如果系统里跑着大量实时任务就需要评估一下通信频率和 CPU 负载之间的平衡。1.2 16 位地址背后的真实含义先说清楚一个容易混淆的点标题里的“16 位地址”通常指的不是从机地址而是寄存器地址也叫字地址、内部地址。I2C 从机地址通常是 7 位加上读写位组成一个字节而芯片内部寄存器如果很多地址就会超过 8 位需要两个字节来表示。比如有些存储芯片的容量达到几十 KB 甚至上百 KB8 位地址只能寻址 256 个单元根本不够用于是规格书里就要求发送两个字节的寄存器地址。还有一个更少见的情况某些特殊芯片本身不区分“从机地址寄存器地址”而是直接使用一个 16 位地址字段来定位目标。这类芯片往往把器件选择和内部单元统一编码通信时先把高 8 位发出去再发低 8 位中间每个字节都要等待从机应答。不管你遇到的是哪种理解“先高位后低位”的顺序是通用的。从协议角度拆解16 位地址传输流程比 8 位地址多了一个字节但复杂度主要增加在读写切换上。写操作通常容易搞定起始条件、从机地址写位、寄存器地址高字节、寄存器地址低字节、数据字节最后停止。读操作则要处理“先写后读”的切换多半要用到重复起始条件这一块很多初学者最容易写乱。2. 时序与地址格式动手前必须搞清的三件事2.1 起始、停止、字节与应答时序拆解I2C 协议虽然看起来就两根线但每个动作都有严格的电平配合。软件模拟想要可靠第一步就是把这些基本时序刻在脑子里。起始条件START在 SCL 为高电平期间SDA 从高电平跳变到低电平。这个“先 SCL 高、然后 SDA 拉低”的顺序不能反。实现代码时通常先让 SCL 和 SDA 都输出高接着把 SDA 拉低最后把 SCL 拉低为后续字节传输做准备。停止条件STOP在 SCL 为高电平期间SDA 从低电平跳变到高电平。操作顺序是先确保 SDA 为低然后把 SCL 拉高最后把 SDA 拉高。字节传输I2C 数据字节高位在前。每一个位先设置 SDA 电平再拉高 SCL 形成一个高脉冲在 SCL 高电平期间数据保持稳定然后拉低 SCL再去改变 SDA 准备下一位。这个顺序意味着 SCL 高电平期间 SDA 是不允许跳变的。应答位ACK数据传输完 8 位之后在第 9 个 SCL 脉冲处理应答。主设备在发送完第 8 位后把 SDA 释放设置为输入由从设备将 SDA 拉低表示 ACK。如果 SDA 在第 9 个时钟高电平期间读回为高就是 NACK说明从机没收到数据或者地址不对。对于软件 I2C这个“释放 SDA”的动作要格外小心我会在后面的代码实现里单独讲。如果从机支持时钟延展它会在某个时钟周期把 SCL 拉到低电平表示“我还没准备好别急着继续”。软件模拟时每次把 SCL 拉高后都应该检查一下 SCL 是否真的为高如果被从机拉低就原地等待直到超时为止。很多开发者忽略这一点结果碰到某些稍微复杂点的从机就一直“假死”。2.2 16 位寄存器地址的发送顺序与读写切换16 位寄存器地址传输时标准顺序是高位字节在前低位字节在后。写操作流程发送 START。发送从机地址 写标志低电平等待 ACK。发送寄存器地址高字节等待 ACK。发送寄存器地址低字节等待 ACK。依次发送要写入的数据每个字节等 ACK。发送 STOP。读操作流程发送 START。发送从机地址 写标志等待 ACK。发送寄存器地址高字节等待 ACK。发送寄存器地址低字节等待 ACK。发送重复起始条件Repeated START。发送从机地址 读标志高电平等待 ACK。读取数据每读完一个字节主机发送 ACK读完最后一个字节主机发送 NACK。发送 STOP。这里最容易出错的是第 5 步。有些人会在写完寄存器地址后发送 STOP再重新启动来切换读模式。这种方式在很多器件上也能跑但不符合标准推荐做法而且会让总线多一次不必要的“释放-占用”过程某些自动地址递增的器件可能会在 STOP 后重置内部指针导致读出来的是错误地址。为了可靠尽量使用重复起始条件也就是不先发 STOP而是直接在 SCL 高电平期间再拉低一次 SDA形成新的 START。还有一个细节16 位地址设备里常见的地址递增方式有两种。一种是在写完地址后自动递增连续读时不需要再写地址另一种是读固定寄存器地址不会自动变。使用连续读功能前务必查规格书确认。2.3 速率、上拉电阻与电平匹配的取值思路软件 I2C 的速率不像硬件外设那样由分频器精确控制主要取决于延时函数和指令执行速度。标准模式下SCL 高电平和低电平最小时间为 4.7 微秒快速模式是 1.3 微秒。软件模拟通常跑到 100kHz 左右比较踏实也就是一个 SCL 周期约 10 微秒高低电平各 5 微秒。如果 MCU 主频足够快写到 400kHz 理论可行但延时函数里随便穿插几个中断就很容易超标准所以我个人宁可比标称慢一点也不去赌极限速度。上拉电阻的选择也很关键。I2C 总线是开漏结构SDA 和 SCL 都需要外部上拉电阻软件模拟也不例外。电阻选太大会导致上升沿太慢波形变缓距离拉长后尤其明显选太小则灌电流过大端口输出低电平时可能超过芯片允许的电流。常见做法是在 2.2k 到 4.7k 之间选总线长、负载电容偏大就选小一点板子短、走线少就选大一点。如果是 5V 系统建议用 4.7k3.3V 系统建议用 2.2k 或者 3.3k。电平匹配也要提前算好。有的传感器是 5V 器件主控是 3.3V直接接会有烧毁风险。如果只是单向通信用电平转换电路双向通信则需要带方向自动判断的电平转换方案。软件 I2C 中SDA 方向的切换同样要放在整个通信流程里处理不能只在硬件上解决问题。3. 软件实现从引脚配置到读写函数3.1 引脚与方向控制设计我用的方案是把 SCL 固定配置为推挽输出SDA 配置为推挽输出但允许在读取时快速切换方向。为什么不用开漏输出开漏模式下要让 SDA 输出高电平只能靠外部上拉电阻上升沿速度受电阻和电容影响低速场景问题不大但在高速或长走线时波形容易变差。推挽输出模式翻转速度快但要控制好“释放总线”的动作也就是把 GPIO 改为输入模式让外部上拉电阻把 SDA 拉高。具体引脚定义时建议在文件顶部用宏或者枚举统一管理。这样换板子、换 MCU 时只要改一处配置就能整个模块复用。示意代码如下// 引脚编号按实际硬件调整 #define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN 6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN 7 #define SCL_H() i2c_scl_set_level(1) #define SCL_L() i2c_scl_set_level(0) #define SDA_H() i2c_sda_set_level(1) #define SDA_L() i2c_sda_set_level(0) #define SDA_IN() i2c_sda_set_dir(0) // 输入模式 #define SDA_OUT() i2c_sda_set_dir(1) // 输出模式 #define SDA_READ() i2c_sda_get_level()方向切换的时机要特别注意在读 SDA 之前必须先设置输入模式再延时一会儿等引脚电平稳定下来再取值。很多新手的代码看起来逻辑正确就是读出来全是 1问题往往就是方向切换之后没有给信号稳定时间或者上拉电阻太大导致电平爬升慢。我通常会在切换成输入后额外加一次延时函数。3.2 微秒级延时函数怎么写延时函数是软件 I2C 的心脏。主频不同、编译器优化级别不同所需循环次数也不同。最稳妥的方式是先用示波器抓波形然后微调循环次数。没有示波器时可以用一个直观的方法先写一个粗略的延时然后接上从机通信如果通信不稳定就逐步加大延时。示意代码如下void i2c_delay(void) { volatile uint32_t i; for (i 0; i 200; i) { __asm(nop); // 空操作防编译器过度优化 } }以主频 72MHz 为例每次循环加 nop 大概十几个周期200 次循环差不多几微秒符合标准模式的高低电平时间。如果主频更高比如 100MHz 以上循环次数要适当增加如果主频只有 8MHz循环次数可能 20 到 30 就够了。这个函数是整个时序的基础后面每个电平操作之间都插入一次速度就能均匀下来。另外一个重大干扰源是中断。如果系统里开了多个中断在 I2C 传输过程中频繁进入中断电平翻转就可能被拉长导致从机超时。严谨的做法是在传输期间关闭可屏蔽中断或者至少把 I2C 的传输过程放在临界区保护里。有些实时任务必须响应中断那就要把 I2C 延时尽量缩短并且保证中断处理时间远小于半个 SCL 周期。3.3 写 16 位寄存器完整实现底层单字节发送函数一般长这样// 返回 0 表示收到 ACK返回 1 表示收到 NACK uint8_t i2c_write_byte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { SDA_H(); } else { SDA_L(); } data 1; i2c_delay(); SCL_H(); i2c_delay(); SCL_L(); i2c_delay(); } // 第 9 个时钟释放 SDA 并读取 ACK SDA_IN(); i2c_delay(); SCL_H(); i2c_delay(); uint8_t ack SDA_READ(); SCL_L(); i2c_delay(); SDA_OUT(); return ack; }数据从左往右逐位发送也就是高位在前。每次先把 SDA 摆好再拉高 SCL然后拉低 SCL这样从机在 SCL 高电平期间看到的 SDA 一定是稳定的。第 9 个脉冲时SDA 改成输入让从机控制总线读取回来判断 ACK 状态。读取完成后要把 SDA 重新切回输出因为后面可能马上要发送下一个数据位。有了这个基础函数写 16 位寄存器就只需要按顺序组织封装int i2c_write_reg16(uint8_t slave_addr, uint16_t reg_addr, const uint8_t *data, uint8_t len) { uint8_t i; uint8_t addr_w (slave_addr 1) | 0x00; i2c_start(); if (i2c_write_byte(addr_w) ! 0) return -1; if (i2c_write_byte((reg_addr 8) 0xFF) ! 0) return -2; if (i2c_write_byte(reg_addr 0xFF) ! 0) return -3; for (i 0; i len; i) { if (i2c_write_byte(data[i]) ! 0) return -4; } i2c_stop(); return 0; }注意从机地址的处理芯片数据手册里通常给的是 7 位设备号比如 0x50但它实际占用总线时是左移一位变成 0xA0最后一位是读写标志。很多人直接把数据手册里的地址当成总线地址用结果从机一直不应答。这个问题我在实际调试中见到太多次了。启动和停止函数也很关键void i2c_start(void) { SDA_H(); SCL_H(); i2c_delay(); SDA_L(); i2c_delay(); SCL_L(); i2c_delay(); } void i2c_stop(void) { SCL_L(); SDA_L(); i2c_delay(); SCL_H(); i2c_delay(); SDA_H(); i2c_delay(); }start 和 stop 必须保证操作顺序完全符合协议尤其不能先动 SCL 再动 SDA。如果顺序反了总线上的其他设备会把动作误判成普通数据位导致通信直接乱掉。3.4 读 16 位寄存器完整实现读操作的代码比写操作复杂一点因为要先写地址再用重复起始切换到读状态。以“写入 16 位寄存器地址然后连续读取 len 字节”为例uint8_t i2c_read_byte(uint8_t ack) { uint8_t i; uint8_t data 0; SDA_IN(); i2c_delay(); for (i 0; i 8; i) { data 1; SCL_H(); i2c_delay(); if (SDA_READ()) { data | 0x01; } SCL_L(); i2c_delay(); } // 第 9 个时钟主机输出 ACK 或 NACK SDA_OUT(); if (ack) { SDA_H(); // NACK } else { SDA_L(); // ACK } i2c_delay(); SCL_H(); i2c_delay(); SCL_L(); i2c_delay(); SDA_IN(); return data; } int i2c_read_reg16(uint8_t slave_addr, uint16_t reg_addr, uint8_t *data, uint8_t len) { uint8_t addr_w (slave_addr 1) | 0x00; uint8_t addr_r (slave_addr 1) | 0x01; i2c_start(); if (i2c_write_byte(addr_w) ! 0) return -1; if (i2c_write_byte((reg_addr 8) 0xFF) ! 0) return -2; if (i2c_write_byte(reg_addr 0xFF) ! 0) return -3; // 重复起始条件 i2c_start(); // 注意这里不是 STOP而是 START if (i2c_write_byte(addr_r) ! 0) return -4; for (uint8_t i 0; i len; i) { // 最后一位发 NACK其余发 ACK uint8_t ack_flag (i len - 1) ? 1 : 0; data[i] i2c_read_byte(ack_flag); } i2c_stop(); return 0; }这里重复起始条件直接调用 i2c_start() 是没问题的因为从停止条件来看start 之前并不强制要求立刻从低电平出发它只是要求 SCL 为高电平时 SDA 产生下降沿。执行完最后一个字节的读写后再调用 stop 释放总线。读字节函数里主机发送 ACK 或 NACK 的顺序也很重要。读当前字节时参数 ack 表示“我读完这个字节后要不要继续读下一个”。如果还要继续读发 ACK如果这是最后一个字节发 NACK否则从机会以为你还要数据会继续占用总线。4. 实战经验调试中遇到的高频故障4.1 从机不 ACK怎么一步步排查从机不应答是最常见的故障现象五花八门有时是地址发过去就没反应有时是写地址字节之后回 ACK但一写寄存器地址就 NACK。看到这种情况我一般的排查顺序是先确认从机供电和复位引脚。很多芯片复位引脚悬空或者电平不对芯片根本没跑起来自然不会应答。用万用表量一下 VCC 和复位脚排除硬件问题后再查协议。核对从机地址。检查代码里用的是不是已经左移过的地址。用示波器或者逻辑分析仪抓 START 后的第一个字节看看实际总线上发的 8 位二进制和芯片期望的匹配不匹配。确认寄存器地址在芯片地址范围内。如果芯片只有 16 位地址空间的一部分有效你写了个超范围的地址芯片收到之后可能直接 NACK。对照数据手册查地址映射很关键。检查时序是否过慢或过快。软件模拟时SCL 高电平时间过长有些从机会超时复位高电平时间过短从机采样不到数据。把延时加大试试如果加大后能通说明原来的时序太急促。用万用表检查 SCL 和 SDA 线上是否一直有设备把总线拉低。如果上电后某根线始终为低可能是从机处于异常状态或者上拉电阻没接导致总线释放不了。4.2 总线卡死与释放策略软件 I2C 有个特别烦人的问题通信中断后从机可能还停在某个中间状态把 SDA 拉低不松手。此时你再发 START因为 SDA 本来就是低根本无法产生下降沿整个通信就卡死了。处理办法是做一个总线恢复函数。最简单粗暴但好用的方式是把 SCL 手动翻转若干次比如翻转 9 到 16 次每次在 SCL 高电平期间把 SDA 拉高逼从机退出内部状态。核心原因是连续的 SCL 脉冲会让从机把未完成的字节接收完或者丢弃掉最终回到空闲状态。我一般会写一个带超时的总线复位函数放在通信失败后的错误处理流程里。每次开始通信前也可以先读一次 SDA 电平。如果 SDA 为低就调用恢复流程确保总线干净再进入正常收发。这比直接重启芯片或者重新上电省事得多。4.3 寄存器地址高低位搞反16 位地址最经典的坑就是高低位顺序。有些芯片支持“先发低字节再发高字节”也有些芯片固执地要求“先发高字节再发低字节”还有不少芯片可以通过配置位切换顺序。我遇到过一个从机它在写地址时要求高位在前但在读状态切换时又要求先写低位再写高位这种临时修改地址顺序的芯片虽然少见但确实存在。写代码前一定要把数据手册里的时序图放大看清楚看地址字节是 MSB first 还是 LSB first以及它标注的是 Bit 15 还是 Bit 0。如果在调试时发现地址写对了但数据总是不对可以故意把高 8 位和低 8 位交换测试一下顿时就能分辨是不是因为顺序反了。还有一种隐藏很深的问题读写函数里的寄存器地址是 16 位整型但有人在调用时把地址写成了 int 类型导致高 8 位变成了符号扩展后的 FF。比如 0x80A0 这种地址如果用了有符号类型右移 8 位后可能变成 0xFF80而不是 0x80。这个问题在编译期不报错调试起来特别折磨人。建议全部使用 uint16_t 类型来传递寄存器地址强制无符号。4.4 中断干扰和速度异常软件 I2C 跑在一个中断繁忙的系统里时波形会变得歪歪扭扭。比如 SCL 高电平期间来了个高优先级中断处理了几百微秒从机在这段时间里可能已经把数据采样走了也可能还没准备好下一次通信就对不上了。解决思路有三条传输过程中屏蔽中断。最简单直接适合小数据量通信。把 I2C 延时设计得足够长让中断处理时间小于时序最小间隔这样即使临时打断也不至于违规。把软件 I2C 传输放到 DMA 之外的低优先级任务里允许它在通信期间被打断但每次被打断恢复后继续执行剩余流程。如果需要更高的稳定性可以换一种思路SCL 的上升沿和下降沿不放在同一段代码里而是在 SCL 高电平期间开启一个定时器定时时间到后产生中断继续下一步。不过这种实现复杂度高我通常只在特殊项目里用。4.5 调试工具与实际观测建议软件 I2C 的调试最好还是备一个逻辑分析仪。它的采样频率不用太高20MHz 左右就能看清 100kHz 的 I2C 时序。把 SCL 和 SDA 夹上去在软件里加一个触发点然后观察报文是不是符合预期。如果没有逻辑分析仪示波器也能看但示波器抓数据帧比较费劲因为单次触发难以完整捕获完整报文。观测时重点看四个位置START 条件是否清晰SDA 下降沿必须出现在 SCL 高电平期间。每个字节的第 9 个 SCL 脉冲上SDA 是否为低ACK。从写寄存器地址切换到读数据的重复起始条件是否正常。最后一个数据字节后 STOP 条件是否标准。如果逻辑分析仪解码出来的数据乱七八糟先去抓字节顺序再去看 ACK。很多时候是软件时序的微小偏差导致的偶发问题把延时统一加大一点问题就消失了。调完一轮软件 I2C我自己最大的感受是代码本身不复杂复杂的是对协议细节的尊重。16 位地址无非是比 8 位地址多写一个字节但它在时序上的要求、读写切换的配合、ACK 处理的细节全都和底层协议环环相扣。软件模拟虽然灵活但吃亏在没有硬件外设帮你擦屁股任何一个漏掉的时序都要自己兜回来。最后再分享一个我反复用的小技巧写完这套软件 I2C 后先在空总线上用连续写的方式操作一个已知地址的寄存器把读回值和写进去的值对比。如果连续写读都不对多半是 GPIO 方向切换或者上拉电阻的问题如果能写不能读重点检查重复起始条件和最后一个 NACK 是不是漏了。这套流程走通了16 位地址的软件 I2C 基本上就能稳稳扛住项目里的各种通信任务。