干我们这行的,遇到“I2C总线上有设备ACK但就是读不到数据”这种问题,通常比芯片烧糊了还让人上火。前阵子调一块多传感器板子,I2C上同时挂了EEPROM、温度传感器和触摸控制器,结果设备地址撞车,排查半天才定位到问题。后来我专门做了一个“USB TO I2C Scan”的小工具:通过USB转I2C适配器,用上位机逐地址扫描挂载设备,把结果直接落到Excel表格里,顺便把100KHz总线速率档位下的时序参数也测了一遍。这篇文章就把整个过程拆开讲清楚,从协议原理、扫描实现到Excel导出,再到波形实测,希望能帮你少踩几个坑。
1. 项目由来:为什么需要一张总线地址地图
1.1 我遇到的实际问题
那块板子上有三个I2C从机:一颗AT24C02存储芯片、一颗LM75温度传感器、还有一颗GT911触摸控制器。按照手册,AT24C02默认地址是0x50,LM75是0x48,GT911根据复位引脚电平是0x5D或0x14。听起来互不干扰,但上电之后上位机怎么都读不到温度数据,用示波器看波形又发现SDA上有很奇怪的应答冲突。
问题就出在地址上。GT911的地址脚被拉高后走的是0x5D,但它的驱动里还会临时占用一段地址去做触摸坐标轮询;而AT24C02如果A2A1A0三个引脚全部悬空,某些批次的默认地址也会落在0x57附近,和别的器件挨得很近。这种“看起来不冲突、实际互相踩”的地址编排,靠肉眼看原理图根本发现不了,必须把总线上所有实际应答的地址全部扫出来,做一张地址地图。
1.2 能用的工具不少,为什么最后选了USB转I2C
排查手段其实很多:逻辑分析仪、示波器、MCU里跑一段扫描代码、树莓派上i2cdetect都行。但我当时的诉求很明确:第一,要能在Windows主机上快速操作,别每次都得插开发板;第二,扫描结果要方便留档,最好能直接生成表格,因为后面还要给产线测试做参考文档;第三,我想在同一套环境里验证100KHz总线速率下时序是否达标。
对比了一圈,逻辑分析仪适合看波形,但不利于批量扫地址;MCU扫描代码灵活,但每次改地址范围都要重新烧录,而且串口输出还得自己处理。USB转I2C适配器刚好卡在中间:它把I2C时序交给固件处理,上位机只管发命令和收状态,天然适合做Scan和Excel导出这类二次开发。如果你手头只有USB转TTL模块也不是不行,用软件模拟I2C时序也能扫,但实时性和波形质量都差很多,用在100KHz速率测试上很容易误判,我不推荐。
1.3 项目目标与最终交付物
我在动手前给自己定了三个交付目标,后面所有步骤都是围绕它们展开的:
- 能扫描I2C总线上所有7位地址,区分“有ACK响应”和“无设备”,并识别扫描过程中总线异常;
- 把扫描结果按地址、器件类型、ACK状态整理成Excel表格,自动高亮发现的设备;
- 在100KHz标准速率下抓取SCL/SDA波形,对照I2C规格书的时序参数做一份实测记录,验证这块板子的总线余量是否足够。
这三件事做完,这套工具就不只是临时排查用的脚本,而是一份可以复用到后续项目里的测试资产了。
2. 链路与协议:USB、UART、I2C是怎么串起来的
2.1 USB转I2C适配器的物理链路
要理解Scan的整个过程,先得把“USB转I2C”这件事拆开。市面上大多数USB转I2C适配器,内部其实是两层桥接:USB物理层先转成UART或SPI,再由一颗MCU(常见的是STM32、CH341或CP2112方案)把收到的数据包翻译成I2C时序。也就是说,你在上位机里写的命令,并不是直接变成SCL/SDA电平,而是先进入适配器固件,固件再按照I2C协议状态机把数据发到总线上。
这带来一个实际影响:上位机和桥接固件之间是异步串口通信,而你真正关心的I2C时序质量,完全取决于桥接固件的实现。所以选购或自制适配器时,至少要确认它支持100KHz标准模式,并且没有把时钟延展(Clock Stretching)功能阉割掉。我调试用的那台适配器走的就是USB转UART再到I2C的结构,在Windows下枚举成一个虚拟串口,应用层只用操作COM口,不用碰USB驱动细节,这一点对快速开发非常友好。
USB层还有一个容易忽略的点:如果上位机怎么发命令都收不到应答,先别怀疑I2C总线,应该用USB抓包工具或者设备管理器确认虚拟串口是否真的枚举成功。很多时候是USB线接触不良或者驱动版本问题,导致应用层彻底断连,白白浪费半天排查时间。
2.2 I2C地址与应答机制(Scan的对象)
I2C总线上每个从机都有一个7位地址,加上读写位组成第一个字节。主机发送START信号后,发出这8位数据,然后释放SDA等待从机应答。从机只有在自己地址匹配时才会在第9个时钟周期拉低SDA作为ACK;不匹配的设备保持高电平,即NACK。
Scan的原理就是基于这个应答机制:对0x00到0x7F的每一个7位地址,发送一个“仅地址字节”的事务,然后检测是否存在ACK。只要有ACK,就说明该地址上有设备在应答。因为I2C是开漏结构,多个设备同时应答同一个地址会在SDA上产生冲突,这种OWM(多主)场景下Scan结果还会出现“该地址有响应但通信不稳定”的现象,这也是刚才GT911和EEPROM冲突能被打出来的原因。
实际扫描时要注意地址范围并不全是可用的。0x00是General Call地址,0x04到0x07、0x78到0x7B等是保留地址段,普通设备不会应答这些地址。所以我的扫描范围默认是0x08到0x77,但也会保留全量扫描的选项,因为偶尔有非标设备会在保留地址上产生响应,出现这种情况本身就是值得记录的总线异常。
2.3 100KHz标准模式的时序规格
I2C标准模式(Standard-mode)的总线速率是100KHz。很多新人不理解为什么速率测试不直接跑400KHz或者1MHz,非要先测100KHz,其实是因为100KHz档位是整个I2C协议兼容性的基座:几乎所有桥接芯片、传感器、EEPROM都支持它,而且时序余量最大。如果在100KHz下时序都不达标,那跑高速档位基本不用指望。
100KHz模式下的关键时序参数,我做了一张简化表,调试时对照这张表查波形就够了:
| 参数 | 含义 | 100KHz规格要求 |
|---|---|---|
| f_SCL | SCL时钟频率 | 不超过100KHz |
| t_HIGH | SCL高电平时间 | ≥4.0us |
| t_LOW | SCL低电平时间 | ≥4.7us |
| t_r | SCL/SDA上升时间 | ≤1000ns |
| t_f | SCL/SDA下降时间 | ≤300ns |
| t_HD;STA | 起始条件保持时间 | ≥4.0us |
| t_SU;STA | 起始条件建立时间 | ≥4.7us |
| t_SU;DAT | 数据建立时间 | ≥250ns |
| t_HD;DAT | 数据保持时间 | ≥0ns,且不超过3.45us |
| t_SU;STO | 停止条件建立时间 | ≥4.0us |
| t_BUF | 总线释放时间 | ≥4.7us |
| Cb | 每根总线最大容性负载 | ≤400pF |
看这张表可以发现,I2C的低速档位反而对上升时间提出了限定,因为总线是开漏结构,上升沿完全靠上拉电阻被动充电,如果上拉电阻太大或者总线电容太大,上升沿就会变得很缓,导致从机误判逻辑状态。这也是为什么后面做速率测试时,上拉电阻要专门拿出来算一算。
2.4 为什么测试速度档选在100KHz
回到项目标题里那个“100KHz总线速率测试”。除了兼容性考量,还有一个现实原因:Flash类存储器在写操作时,内部擦写时间长达几毫秒,如果总线速率太高,主机侧容易在数据保持时间上卡得太紧;而100KHz的低速档位能让时序余量更充足,也更适合用普通逻辑分析仪抓波形。
另一方面,很多USB转I2C适配器的固件默认支持速率只有100KHz和400KHz两档,100KHz往往是固件默认值。当你想验证一个适配器是否“诚实”时,测100KHz是最公平的:不会像400KHz那样暴露出桥接固件的时序抖动,也不会像1MHz那样对线材和电容极其敏感。先把100KHz这一档的波形测干净,再谈高速优化,这是我从多次调试里总结出的顺序。
3. 扫描器实现:从轮询到代码
3.1 扫描算法与命令帧设计
在写扫描脚本之前,首先得确定适配器的命令帧格式。我用的这台适配器是转发式设计:上位机发一个字节命令加一个字节地址,适配器固件就在总线上执行对应操作,返回一个状态字节表示ACK或NACK。典型命令如下:
- 0x53('S'):扫描命令,后面跟7位地址加写位;
- 0x52('R'):读命令,用于后续探测器件类型;
- 0x57('W'):写命令,带长度和数据。
扫描算法的核心就是一层循环加一次应答检测:
import serial ser = serial.Serial('COM7', 115200, timeout=1) found = [] for addr in range(0x08, 0x78): addr_with_write = (addr << 1) & 0xFE cmd = bytearray([0x53, addr_with_write]) ser.write(cmd) resp = ser.read(1) if resp and resp[0] == 0x00: found.append(addr) print(f'Address 0x{addr:02X}: ACK') else: print(f'Address 0x{addr:02X}: NACK') print('Found devices:', ', '.join(f'0x{addr:02X}' for addr in found))这段代码的逻辑很简单,但有几个细节容易踩坑。第一,串口超时不能设得太短,因为桥接固件在检测总线冲突或时钟延展时,耗时会比正常扫描长;我把timeout设成1秒,扫描完整个地址范围大约十来秒,保证每个地址都被充分判断。第二,地址字节里的读写位必须置0,否则会变成读操作,某些传感器在收到读请求后会真的开始输出寄存器数据,导致状态判断混乱。第三,扫描时要把适配器的内部应答超时设置调小,否则遇到SDA被拉死的情况,整个扫描会卡在某个地址上,后面全都不走了。
3.2 让从机“无副作用”应答的技巧
很多人第一次写扫描程序时,会直接用“读一个字节”的方式去探测地址。这个做法在多数从机上没问题,但对某些写敏感的芯片非常危险:比如存储在收到读命令后会中断当前操作,或者触屏控制器会改变中断输出状态。更稳妥的做法是,只发送“起始条件 + 地址字节 + 停止条件”,中间不传任何数据,也就是I2C协议里的零长度事务。有些适配器固件专门为Scan优化过,发出的就是这个帧。
这种零长度事务在逻辑上等价于“敲门问一下”,从机只要地址匹配就ACK,然后主机立刻STOP,不会动从机内部任何寄存器。我实际扫下来的经验是,绝大多数I2C设备都能正确响应这种探测,只有极少数老芯片要求必须在地址字节后带至少一个数据字节才肯应答,遇到这种设备再单独加一轮带数据的写探测也不迟。
3.3 上位机扫描脚本到这里就能跑了
完整脚本除了扫描,最好还要加两件事:记录扫描耗时和保存原始日志。我在脚本里加了一个统计模块,每一行输出都带时间戳,方便后面和Excel导出数据对应。
import time start = time.time() raw_log = [] for addr in range(0x08, 0x78): ... raw_log.append(f'{addr:02X} {status}') print(f'Scan finished in {time.time() - start:.2f}s')日志格式我建议用纯文本,每行“地址 状态”,这样无论接入Excel还是扔进数据库都方便。实测扫描一次全地址范围大约12秒,其中大部分时间花在串口交互延迟上。如果你用的适配器支持I2C连续扫描模式(一次遍历所有地址再统一返回结果),耗时能压到2秒以内,但那种模式对从机的副作用更难控制,我自己还是宁可慢一点。
3.4 结果样例与实际扫描日志
下面是一次真实扫描的结果片段,板子上挂的是LM75、AT24C02和一颗RTC,地址分别是0x48、0x50、0x68:
SCAN 0x48 ... ACK SCAN 0x49 ... NACK SCAN 0x4A ... NACK SCAN 0x50 ... ACK SCAN 0x51 ... NACK SCAN 0x68 ... ACK从日志看,三个器件全部落在常见地址段内。这种地址地图就能直接拿去核对原理图了:如果原理图上某个芯片的地址应该出现在0x51,扫描结果里却是0x50,那就是地址引脚焊接或者连错线的问题。我当时排查GT911时,就是先扫出0x5D出现ACK,然后才发现它的复位引脚被板子上另一个信号拉高了,导致地址从默认的0x14变成了0x5D,完全偏离了原理图标注。这种问题不看实际总线,光读代码是永远发现不了的。
4. 扫描结果落到Excel:既是记录,也是BOM
4.1 为什么选Excel作为交付格式
我见过不少人把扫描结果直接贴在聊天记录里,或者整理成Markdown表格发到群里。这种做法应急可以,但到了做产线测试文档或者项目评审时,就发现格式完全不够用。Excel的好处是天生适合做“地址地图”:每一行是一个地址,每一列是一个属性,还能用颜色把“有设备”“无设备”“异常设备”区分开,别人拿到文件后一眼就能看懂。
另外,你测完总线时序之后,也要把实测数据和规格要求放进同一张表里做对比。这些数据用Excel管理比用Markdown或代码注释方便得多,还能直接做条件格式,超标的参数自动变红。所以我的处理流程是:先用脚本生成结构化数据,再用Python脚本直接写Excel文件,省去手动复制粘贴的麻烦。很多人习惯把日志先整理成Markdown表格再手工转换到Excel,列宽、合并单元格、重复表头这些坑够折腾半天,不如一开始就用代码生成。
4.2 字段设计与器件地址推断
Excel表我按这几种字段来设计:
| 字段 | 说明 | 示例 |
|---|---|---|
| 7位地址(HEX) | 扫描到的设备地址 | 0x48 |
| 7位地址(DEC) | 十进制,方便排序 | 72 |
| 写地址 | 地址左移一位,写方向 | 0x90 |
| 读地址 | 地址左移一位后置1,读方向 | 0x91 |
| ACK状态 | ACK / NACK | ACK |
| 推测器件 | 根据地址段和经验值推断 | LM75 / 24C02 |
器件类型只能写“推测”,不能写“确定”。I2C的地址段可以重叠,0x48可能是LM75,也可能是PCF8574或者其他传感器。要确认具体型号,还得再执行一次读ID寄存器或者读配置寄存器的操作,我在扫描表里单独留了一列“备注”,专门放二次探测的信息。这种谨慎不是多余的,产线测试时如果根据推测结果错判物料,返工成本就大了。
4.3 openpyxl自动生成带高亮的地址表
我用的导出工具是Python的openpyxl库,脚本只需要几十行。关键代码是这样的:
from openpyxl import Workbook from openpyxl.styles import PatternFill, Font wb = Workbook() ws = wb.active ws.append(['7位地址(HEX)', '7位地址(DEC)', '写地址', '读地址', 'ACK状态', '推测器件']) green_fill = PatternFill(start_color='C6EFCE', end_color='C6EFCE', fill_type='solid') gray_fill = PatternFill(start_color='D9D9D9', end_color='D9D9D9', fill_type='solid') for addr in range(0x08, 0x78): if addr in found: ws.append([f'0x{addr:02X}', addr, f'0x{(addr<<1)&0xFE:02X}', f'0x{(addr<<1)|0x01:02X}', 'ACK', infer_device(addr)]) ws.cell(row=ws.max_row, column=5).fill = green_fill else: ws.append([f'0x{addr:02X}', addr, f'0x{(addr<<1)&0xFE:02X}', f'0x{(addr<<1)|0x01:02X}', 'NACK', '']) ws.cell(row=ws.max_row, column=5).fill = gray_fill wb.save('i2c_scan_result.xlsx')infer_device函数只做地址段的初步判断,比如0x20到0x27往PCF8574猜,0x48到0x4F往温度传感器猜,0x50到0x57往EEPROM猜,0x68往RTC猜。这样生成的Excel不需要人工修图,直接就能发出去。如果后面要加时序实测数据,就再加一张Sheet,放进测量的波形参数。
4.4 扫描表还能怎么用
这张表不只是排错用的。我在后续项目里发现,它还能当“地址规划检查表”:新画一块板子时,把所有候选器件地址都填进去,提前检查有没有冲突,比等板子回来再扫要省太多时间。另外,用I2C扩展器(比如TCA9548A)做多通道总线时,每个通道都要出一张地址地图,扫描表直接从Excel模板里套,效率高很多。
如果地址不够用,扫描表也能帮你快速评估是否要加扩展器。比如一个项目里同时有8颗EEPROM、4颗传感器、2颗RTC,7位地址空间看起来够用,但实际器件可能分布在重叠的地址段,这时扫出来的地址地图会告诉你究竟需不需要上TCA9548A这类扩展芯片,用数据说话总比拍脑袋靠谱。
5. 100KHz总线速率实测:波形、参数与上拉电阻
5.1 测量工具准备与抓取要点
扫描完地址,下面进入速率测试环节。这里要抓的是100KHz档位下的SCL和SDA波形。我建议至少准备一台20MHz以上带宽的示波器,或者采样率不低于50MSa/s的逻辑分析仪。示波器探头要靠近总线终端,接地线尽量短,不然测出来的上升沿会带进大量振铃噪声,误判成时序超标。
抓波形时,我习惯把触发条件设在SCL的下降沿上,先看一整帧I2C交易的起始条件、地址字节和停止条件,然后放大到单个SCL周期,用示波器的光标功能分别测出t_HIGH和t_LOW。SDA上的数据建立时间最好单独抓一次,因为有些适配器在从USB收到命令转成I2C时序时,会在地址字节附近插入一个很窄的毛刺,肉眼不放大根本看不见。
5.2 实测数据对规格表
我拿当前板子实测得到的数据如下,和100KHz规格表对比如下:
| 参数 | 规格要求 | 实测结果 | 判定 |
|---|---|---|---|
| f_SCL | ≤100KHz | 99.3KHz | PASS |
| t_HIGH | ≥4.0us | 4.52us | PASS |
| t_LOW | ≥4.7us | 5.06us | PASS |
| t_r | ≤1000ns | 623ns | PASS |
| t_f | ≤300ns | 182ns | PASS |
| t_SU;DAT | ≥250ns | 428ns | PASS |
| t_BUF | ≥4.7us | 6.1us | PASS |
从数据上看余量还比较充足,尤其是t_SU;DAT做到了428ns,比最低要求多了将近一倍。这说明板子的布线质量还行,适配器的固件时序也稳定。不过这里有个容易忽略的细节:我测的是“单次读操作”的时序,连续写操作或者总线冲突场景下,时序会变差。所以真要做出厂验收级别的结论,还得多抓几组不同类型的交易再取最小值来判断。
5.3 上拉电阻怎么算、怎么选
很多人测量时序不达标时,第一反应是怀疑适配器固件不行,其实很大概率是上拉电阻选错了。I2C总线的上升时间由上拉电阻和总线电容共同决定。选型时有两个方向:
- 下限由灌电流决定。标准模式下IO_L通常按3mA算,VOL_max是0.4V,所以上拉电阻最小不能低于(VDD-0.4V)/3mA。3.3V系统算出来大约是967Ω,工程上一般取1KΩ以上,再小就把从机的灌电流余量吃没了。
- 上限由上升时间要求决定。100KHz模式下tr最大是1000ns,估算公式是R_p_max ≈ tr_max / (0.8473 × Cb)。如果总线电容按200pF算,那么上限大约是5.9KΩ,所以4.7KΩ是个很稳的取值;如果总线很短,2.2KΩ也可以,上升边会更陡。
我这次板子上VDD是3.3V,总线电容估在150pF左右,最后选的是4.7KΩ上拉。实测上升时间623ns,对比1000ns的上限还有将近40%余量。如果总线引线很长,或者挂载了超过8个设备,电容一上去,同样的4.7KΩ就可能不够,这时就得降阻值或者用强驱动的适配器。测出波形超标后,先不要急着改软件,优先检查上拉电阻是不是和总线长度匹配。
5.4 SDA被拉死、时钟延展等现场问题处理
实测过程中最头疼的不是时序参数不达标,而是整个总线被某个从机卡死。最常见的现场是:某个器件的复位引脚没有按手册要求拉高,导致它上电后直接拉低SDA,这时无论怎么发扫描命令,适配器都收不到应答,总线一直处于忙状态。我遇到GT911通信失败那次就是这个问题,触摸控制器在异常复位时序下会把SDA钳位在低电平,整个扫描过程卡在第一个地址上。
处理办法分几步:第一步,断开出问题从机的电源,确认总线是否能恢复;第二步,如果从机不能单独断电,就给适配器发连续9个SCL时钟脉冲,让挂死的从机状态机复位;第三步,查这个器件的复位和供电时序,比如GT911要求上电后延迟至少10ms再拉高复位脚,否则就会进入异常模式。这类问题本质上不是I2C协议问题,而是电源管理问题,但表现起来和总线故障一模一样,容易被误导。
另外还要关注时钟延展。SMBus上的不少芯片会在处理数据时主动拉低SCL,让主机等待。如果适配器固件不支持时钟延展,即使SCL频率是100KHz,也可能通信失败。判断方法很简单:用示波器看SCL的低电平时间,正常100KHz模式下t_LOW在5us左右,如果某些数据位突然出现在6us以上的时长,很可能就是从机在做时钟延展。
6. 排错记录:USB、驱动与系统层面的坑
6.1 设备管理器里“I2C HID设备代码12”
调试这套USB转I2C工具时,我在一台Windows机器上遇到过设备管理器报“I2C HID设备找不到足够资源可以使用(代码12)”。这个报错看起来很吓人,但本质上不是I2C总线问题,而是系统给I2C HID设备分配中断或内存资源失败。常见原因有两种:一是BIOS里把I2C控制器资源占用了,二是USB Hub带宽分配冲突。
我的处理顺序是:先换一个物理USB口,排除主板USB控制器资源争用;然后在设备管理器里禁用再启用该设备,让系统重新分配资源;最后升级芯片组驱动。如果还不行,就要进BIOS检查是否有设备占用了IO资源。这个坑和总线测试本身关系不大,但一旦踩上,适配器的USB链路会整个失效,非常耽误时间。
6.2 STM32方案的USB虚拟串口无法识别
很多USB转I2C适配器内部用的就是STM32,如果你自己做适配器或者买到这种方案,还会遇到一个问题:插上电脑后虚拟串口不识别。大概率不是驱动问题,而是STM32的USB枚举条件没满足。最常见的三个原因:外部晶振频率不准、D+上拉电阻没有正确使能、VBUS检测脚没有接好。特别是用内部RC时钟跑USB时,频率偏差超过千分之一就可能枚举失败,表现为Windows提示“无法识别的USB设备”。
遇到这种情况先量一下D+引脚上电后的电平,再做USB抓包看设备描述符有没有上传。我之前在自制适配器上踩过这个坑,最后发现是晶振负载电容焊错了,把20pF电容当成12pF用,导致USB帧头抖动严重。如果你只是想调试I2C而不是研究USB,建议优先选择成熟的USB转UART桥接芯片方案,省去枚举这一层麻烦。
6.3 我的几个底层检查习惯
折腾完这些坑,我养成了几个习惯。第一,每次插上新适配器,先看设备管理器里枚举出的COM口号,再在终端里发一个空命令确认固件有回包,避免把USB链路问题误判成I2C问题。第二,扫描总线之前,先用万用表量一下SCL和SDA的静态电平,正常空闲状态应该是高电平;如果某根线是低电平,那就别急着跑扫描,先查上游问题。第三,驱动方面,使用FT231X这类USB UART芯片时,尽量去官网下载对应版本驱动,Windows自带的驱动虽然能用,但遇到高速或者特殊命令行模式时容易抽风。
这套工具做完之后,我现在调试I2C设备基本就三步走:先扫地址生成本底地图,再抓100KHz波形验证时序,最后把两张表合并进同一份Excel存档。产线上如果哪批板子出货不良,拿这份基线和实际测量对比,很快就能定位是器件地址异常还是时序裕量不足。后面我打算把扫描模块再扩展一下,加入自动读取器件ID寄存器的功能,省得每次还得手工去核对推测器件,那样这张地址地图就更完整了。