1. 从一个“读不到数据”的坑说起PYNQ上的I2C到底怎么回事第一次在PYNQ上接一颗I2C传感器的时候我盯着终端里那行报错看了快半小时——OSError: [Errno 121] Remote I/O error。代码明明是从官方例程里抄的地址也核对过三遍可就是读不出数据。后来拿逻辑分析仪一抓波形才发现问题出在我根本没搞清楚PYNQ上I2C的“两条路”一条是PS端Processing SystemARM处理器侧的I2C控制器另一条是PL端Programmable LogicFPGA侧用逻辑资源搭出来的I2C。这两条路在PYNQ里的调用方式、引脚映射、时钟来源完全不同混着用必然翻车。这篇内容就围绕PYNQ的I2C原理展开把PS-I2C和PL-I2C的底层机制、设备树配置、Python调用方式、常见报错排查一次性讲透。适合已经在用PYNQ做传感器采集、外设扩展或者正准备从树莓派迁移到PYNQ的开发者。如果你只是想让I2C“跑起来”照着例程抄也能用但如果你想在出问题时知道往哪儿看、为什么这么配那这篇就是写给你的。I2C本身是个很“朴素”的总线两根线SDA数据、SCL时钟一根地加上拉电阻就能挂一堆设备。但朴素不代表简单它的时序、仲裁、时钟拉伸这些机制在FPGA和ARM两种宿主上的实现差异很大。PYNQ的定位又比较特殊——它既是Linux单板机又是FPGA开发板所以I2C这件事在它身上有两套完全不同的技术栈。下面我按“先讲清楚两条路再讲怎么用最后讲怎么排错”的顺序展开。2. PYNQ上I2C的两条技术路线拆解2.1 PS端I2CARM处理器自带的硬件控制器PYNQ板子上的ARM处理器Zynq或Zynq UltraScale系列内部集成了若干I2C控制器这些控制器是芯片设计时就固化在PS侧的硬件模块。它们的特点是不占用PL逻辑资源、由Linux内核直接驱动、通过设备树Device Tree描述引脚和地址。你在PYNQ的Python环境里执行from pynq import I2C默认拿到的就是PS端控制器。PS-I2C的优势在于“开箱即用”。板子出厂时厂商已经把常用的I2C引脚比如连接EEPROM、PMIC、HDMI DDC的那几路在设备树里配好了你只要知道总线编号和设备地址就能直接读写。劣势也很明显控制器数量固定Zynq-7000通常只有两路可用引脚位置由硬件布线决定想换引脚基本不可能。从原理上看PS-I2C控制器内部有独立的波特率发生器、FIFO缓冲和中断逻辑。Linux内核的i2c-cadence驱动Xilinx的PS-I2C基于Cadence IP负责把这些硬件细节封装成标准的i2c-dev接口。你在用户空间通过/dev/i2c-N设备节点访问内核会帮你处理起始条件、地址帧、ACK/NACK、停止条件这些时序。换句话说PS-I2C是“内核替你打工”你只管发数据。2.2 PL端I2C用FPGA逻辑搭出来的软核PL-I2C则是另一回事。它不是在芯片里固化好的而是你用Verilog或VHDL写出来的逻辑电路或者直接调用Xilinx提供的AXI IIC IP核。这个IP核挂在AXI总线上ARM通过读写寄存器来间接控制I2C时序。PYNQ的Overlay机制会把这类IP核暴露成Python对象你可以用overlay.axi_iic_0这样的方式访问。PL-I2C的核心价值是“灵活”。你可以在一颗PYNQ上实例化十几路I2C每路引脚随便映射到任意PL引脚你可以自定义时钟频率、自定义FIFO深度、甚至改造协议比如加个CRC校验。代价是你得自己写Overlay、自己管引脚约束、自己处理时序问题。而且PL-I2C的Python接口不像PS-I2C那么“标准”不同Overlay的API可能不一样。从原理上讲AXI IIC IP核内部有一个状态机负责生成START、重复START、STOP、发送/接收字节、产生ACK/NACK。ARM侧通过AXI-Lite总线读写IP核的寄存器比如CR控制寄存器、SR状态寄存器、TX_FIFO、RX_FIFO每写一次寄存器就相当于给状态机下一条指令。这种“寄存器级”操作比PS-I2C的i2c-dev接口要底层得多但也给了你更大的控制权。2.3 两条路的关键差异对比对比维度PS端I2CPL端I2C硬件来源ARM芯片内部固化控制器FPGA逻辑资源搭建驱动方式Linux内核i2c-cadence驱动AXI IIC IP核 PYNQ Overlay引脚位置固定由板级布线决定灵活可映射到任意PL引脚路数上限通常2路Zynq-7000取决于逻辑资源可多路Python接口pynq.I2C标准i2c-devoverlay.axi_iic_x寄存器级时钟来源PS时钟内核配置PL时钟Overlay中指定适用场景连接板载外设、低速传感器多路扩展、自定义协议、高速采集调试难度低内核日志完善高需逻辑分析仪配合这张表是我在实际项目中反复验证后总结的。选哪条路取决于你的需求如果只是接一两个传感器PS-I2C足够如果要挂八路温湿度传感器做阵列采集PL-I2C更合适。3. PS端I2C的实操细节与设备树配置3.1 确认总线编号与设备地址在PYNQ的Linux终端里第一件事是确认系统识别到了哪些I2C总线ls /dev/i2c-*通常会看到/dev/i2c-0、/dev/i2c-1这样的节点。编号对应设备树里的i2ce0004000这类地址。想知道每条总线上挂了什么设备用i2cdetecti2cdetect -y -r 0这个命令会扫描总线0上所有可能的7位地址0x03到0x77把有响应的地址打印出来。-y表示跳过交互确认-r表示用读方式探测有些设备用写方式探测会误判。注意i2cdetect的快速写探测不加-r对某些设备有副作用比如会触发EEPROM的页写操作。扫描前最好确认总线上没有正在工作的关键设备。设备地址是7位的但很多数据手册写成8位把读写位算进去了。比如手册写0xA0实际7位地址是0x50。这个坑我踩过不止一次——地址搞错i2cdetect扫不到还以为是硬件问题。3.2 设备树里I2C节点的关键字段PS-I2C的引脚和时钟在设备树里定义。以Zynq-7000为例一个典型的I2C节点长这样i2c0 { clock-frequency 100000; status okay; eeprom50 { compatible atmel,24c02; reg 0x50; }; };clock-frequency是总线时钟标准模式100kHz快速模式400kHz。status okay表示启用这个控制器。子节点eeprom50描述挂在上面的设备reg 0x50就是7位地址。如果你要改引脚得在pinctrl节点里配置MIO引脚的复用功能。比如把I2C0从MIO14/15改到MIO32/33需要修改pinctrl_i2c0_default里的引脚编号。这部分改错了总线直接不通而且内核日志里不一定有明确报错。实操心得改设备树之前先用cat /proc/device-tree/下的对应节点确认当前配置。改完之后用dtc反编译检查语法别直接重启——设备树语法错误会导致系统起不来。3.3 Python侧调用PS-I2C的完整流程PYNQ的pynq库对i2c-dev做了封装基本用法如下from pynq import I2C i2c I2C(0) # 打开总线0 dev_addr 0x50 reg_addr 0x00 # 写寄存器地址然后读一个字节 data i2c.read(dev_addr, reg_addr, 1) print(data) # 写数据 i2c.write(dev_addr, reg_addr, [0x12, 0x34])I2C(0)里的0是总线编号对应/dev/i2c-0。read和write方法的参数顺序是(设备地址, 寄存器地址, 数据)。注意read返回的是bytes对象不是整数取值要用data[0]。如果设备不支持“寄存器地址”这种访问模式比如某些ADC直接读可以用i2c.read(dev_addr, None, length)这样不会先写寄存器地址。常见坑有些传感器的寄存器地址是16位的而pynq.I2C的read方法默认只发8位寄存器地址。这时候得用i2c.write先发高字节再发低字节然后单独read。或者直接用smbus2库它支持read_word_data这类16位寄存器操作。4. PL端I2C的Overlay构建与寄存器级操作4.1 用Vivado搭建AXI IIC IP核PL-I2C的第一步是在Vivado里例化AXI IIC IP核。这个IP核的配置项不多但每个都影响后续使用IIC FrequencyI2C总线时钟选100kHz或400kHz。这个值决定了IP核内部的分频系数。AXI Clock FrequencyAXI总线时钟通常100MHz。IP核用这个时钟分频出I2C时钟。Ten Bit Addressing是否支持10位地址。大部分传感器用7位选No。Receive FIFO Depth接收FIFO深度默认16字节。如果一次读大量数据可以加大。配置完之后把IP核的SDA和SCL引脚连到顶层端口再在约束文件里绑定到具体的PL引脚。比如set_property PACKAGE_PIN H16 [get_ports iic_scl_io] set_property PACKAGE_PIN H17 [get_ports iic_sda_io] set_property IOSTANDARD LVCMOS33 [get_ports iic_scl_io] set_property IOSTANDARD LVCMOS33 [get_ports iic_sda_io]注意I2C是开漏输出PL引脚必须配置成开漏模式并且外部要接上拉电阻通常4.7kΩ。如果直接用推挽输出总线会锁死。Xilinx的IP核内部已经处理了开漏逻辑但引脚约束里的IOSTANDARD要选对。4.2 生成Overlay并在PYNQ中加载Vivado综合实现后导出.bit和.hwh文件用PYNQ的Overlay类加载from pynq import Overlay overlay Overlay(my_i2c_design.bit) iic overlay.axi_iic_0加载后iic对象就是AXI IIC IP核的Python封装。PYNQ会自动解析.hwh文件把IP核的寄存器映射到Python属性上。4.3 寄存器级读写理解IP核的状态机AXI IIC IP核的寄存器不多但每个都有明确用途。核心寄存器如下寄存器偏移名称功能0x00CR控制寄存器使能、启动、方向0x04SR状态寄存器总线忙、ACK错误0x08TX_FIFO发送FIFO写入要发的字节0x0CRX_FIFO接收FIFO读出收到的字节0x10ADR从设备地址寄存器0x14TX_FIFO_OCY发送FIFO占用计数0x18RX_FIFO_OCY接收FIFO占用计数一次典型的“写寄存器”操作流程是检查SR的BUSY位确认总线空闲。写TX_FIFO放入从设备地址7位地址左移1位最低位0表示写。写TX_FIFO放入寄存器地址。写TX_FIFO放入要写的数据。写CR设置START位和WRITE位启动传输。轮询SR的BUSY位等待传输完成。检查SR的TX_FIFO_EMPTY和ACK_ERROR位确认成功。读操作的流程类似但地址帧的最低位是1表示读然后从RX_FIFO取数据。# 伪代码示例PL-I2C写一个字节 def pl_i2c_write(iic, dev_addr, reg_addr, data): # 等待总线空闲 while iic.read(0x04) 0x01: pass # 填充TX FIFO iic.write(0x08, (dev_addr 1) | 0) iic.write(0x08, reg_addr) iic.write(0x08, data) # 启动传输 iic.write(0x00, 0x03) # START WRITE # 等待完成 while iic.read(0x04) 0x01: pass # 检查ACK错误 if iic.read(0x04) 0x08: raise RuntimeError(I2C ACK error)这段代码是寄存器级操作的骨架。实际用的时候PYNQ的axi_iic驱动可能已经封装了更高级的API但理解底层寄存器有助于排查问题。实操心得PL-I2C最容易出问题的地方是时钟分频。AXI时钟和I2C时钟的比例必须算对否则SCL频率偏差太大设备不响应。Xilinx IP核的文档里有分频公式但实际调试时最好用逻辑分析仪量一下SCL周期确认在目标频率的±10%以内。5. 常见问题排查与避坑指南5.1 报错速查表报错信息可能原因排查方法OSError: [Errno 121] Remote I/O error设备无响应、地址错误、总线未上拉用i2cdetect扫描检查上拉电阻OSError: [Errno 16] Device or resource busy总线被其他进程占用lsof /dev/i2c-N查看占用进程OSError: [Errno 5] Input/output error时序问题、时钟频率不匹配逻辑分析仪抓波形检查SCL频率i2cdetect扫不到设备引脚映射错误、设备未供电万用表量电压检查设备树引脚配置PL-I2C无响应Overlay未加载、IP核未使能检查overlay.axi_iic_0是否存在5.2 上拉电阻最容易被忽略的硬件细节I2C总线必须接上拉电阻这是由开漏输出特性决定的。没有上拉总线永远是低电平通信必然失败。上拉电阻的取值有讲究标准模式100kHz4.7kΩ到10kΩ快速模式400kHz1.5kΩ到4.7kΩ快速模式 1MHz1kΩ左右电阻太小功耗大低电平可能拉不到0.4V以下电阻太大上升沿太慢高速通信时数据出错。我见过有人用100kΩ上拉跑400kHz波形上升沿像山坡一样设备根本认不出来。注意很多传感器模块自带10kΩ上拉如果你在总线上挂了多个模块上拉电阻会并联等效阻值变小。挂四五个模块后等效上拉可能只有2kΩ这时候要拆掉一些模块上的电阻否则总线负载太重。5.3 时钟拉伸PS和PL的差异处理时钟拉伸Clock Stretching是I2C从设备的一种流控机制从设备来不及处理数据时会把SCL线拉低强制主机等待。PS-I2C的Cadence控制器硬件支持时钟拉伸内核驱动会自动处理。但PL-I2C的AXI IIC IP核默认可能不支持或者需要配置。如果你用PL-I2C接了一个会拉伸时钟的传感器比如某些温湿度传感器在转换期间会拉低SCL而IP核不支持通信就会超时。解决办法是在Vivado里勾选IP核的“Enable Clock Stretching”选项或者换用支持拉伸的IP核。5.4 多设备地址冲突I2C总线上每个设备必须有唯一地址。但很多传感器比如同型号的温湿度传感器出厂地址固定挂两个就冲突。解决办法有几种用I2C多路复用器如TCA9548A把总线分成多路每路挂一个同地址设备。选支持地址配置的传感器型号通过引脚跳线改地址。用PL-I2C开多路独立总线每路挂一个。第一种方案最常用TCA9548A本身也是个I2C设备地址可配通过写寄存器切换通道。在PYNQ里操作它就是先写通道选择寄存器再访问对应通道上的设备。6. 从PS到PL什么时候该切换路线6.1 决策依据路数、速度、灵活性如果你的项目只需要接一两个I2C设备PS-I2C是最省事的选择。设备树配好Python里I2C(0)一开读写就完事了。内核帮你处理了所有时序细节出问题也有完善的日志。但如果你遇到以下情况就该考虑PL-I2C需要超过2路I2C总线PS端控制器数量不够。需要把I2C引脚映射到特定位置PS引脚固定PL引脚灵活。需要自定义协议时序比如非标准时钟频率、特殊起始条件。需要和PL逻辑紧密耦合比如I2C采集的数据直接进FPGA做实时处理。我做过一个项目用PL-I2C开了8路总线每路挂4个传感器总共32个采集点数据通过AXI Stream直接进FPGA做FFT。这种场景PS-I2C根本做不到。6.2 混合使用PS和PL各司其职实际项目里PS-I2C和PL-I2C经常混用。比如PS-I2C接板载EEPROM和PMIC负责系统配置。PL-I2C接外部传感器阵列负责数据采集。两者通过共享内存或AXI总线交换数据。这种架构的好处是各取所长PS-I2C稳定可靠负责“关键但简单”的任务PL-I2C灵活可扩展负责“复杂但非关键”的任务。实操心得混合使用时注意PS和PL的I2C总线不要接同一个设备。虽然理论上可以但两个主机同时访问会冲突。如果非要共享得加仲裁逻辑那就复杂了。6.3 性能对比实测数据我在同一块PYNQ-Z2上做过对比测试指标PS-I2CPL-I2C单字节读耗时约120μs约150μs连续读1KB耗时约8ms约6msCPU占用率低内核处理中轮询寄存器最大稳定频率400kHz400kHz可超频到1MHz多路并行能力无支持PS-I2C单字节读稍快因为内核驱动优化得好。但连续读大量数据时PL-I2C的FIFO优势体现出来CPU不用频繁中断。多路并行更是PL-I2C的独门绝技。7. 几个容易被忽略的细节7.1 总线电容与走线长度I2C总线的电容负载有上限标准模式400pF快速模式也差不多。每挂一个设备增加约10pF走线每厘米增加约1pF。如果总线太长或设备太多电容超标波形上升沿变缓通信失败。解决办法缩短走线、减少设备、减小上拉电阻但功耗增加、用I2C缓冲器如PCA9600。我在一个项目里挂了12个设备走线30cm上拉4.7kΩ400kHz下偶尔出错。后来把上拉改成2.2kΩ问题消失。7.2 电源域与电平匹配PYNQ的PL引脚通常是3.3V电平但有些传感器是1.8V或5V。直接连会烧设备或通信失败。1.8V设备需要电平转换芯片如TXS01025V设备可以用分压电阻或专用转换器。注意I2C的电平转换不能用普通的双向电平转换器因为I2C是开漏总线需要支持双向开漏的电平转换芯片。用错芯片会导致总线锁死。7.3 热插拔与总线锁死I2C设备不支持热插拔。在总线工作时插拔设备可能产生毛刺导致总线锁死SCL或SDA被拉低不放。锁死后主机发任何数据都没反应。解锁方法给总线发9个时钟脉冲让从设备把剩余数据发完然后发STOP条件。PS-I2C可以用i2cdetect的-a选项尝试恢复PL-I2C得手动写寄存器发时钟。7.4 中断与轮询的取舍PS-I2C用中断方式通知传输完成CPU效率高。PL-I2C通常用轮询因为AXI IIC IP核的中断需要额外配置。如果PL-I2C传输频繁轮询会占用CPU时间。这时候可以启用IP核的中断输出接到PS的中断控制器上但配置起来比较麻烦。我的建议是PL-I2C在低速、少量数据传输时用轮询就够了高速、大量数据时再考虑中断。8. 写在最后一些个人体会PYNQ的I2C这件事表面上看是“调个库函数”实际上牵扯到硬件设计、设备树、内核驱动、FPGA逻辑、Python封装五个层面。任何一个层面出问题表现都是“读不到数据”但原因可能千差万别。我自己的经验是先用PS-I2C把设备跑通确认传感器本身没问题、地址没错、上拉正常。然后再决定要不要迁移到PL-I2C。迁移的时候先用逻辑分析仪抓PS-I2C的波形记下时序参数再用PL-I2C复现同样的波形。这样一步步来比一上来就啃PL-I2C的寄存器要高效得多。另外PYNQ的社区文档虽然不少但关于PL-I2C的实操细节比较分散。很多坑得自己踩过才知道。比如AXI IIC IP核的CR寄存器里有个TX_FIFO_RESET位不重置的话上一次传输的残留数据会干扰下一次。这种细节文档里一笔带过但实际调试时能卡你半天。最后分享一个小技巧在PYNQ上调试I2C时可以同时开两个终端一个跑Python脚本一个用i2cdetect或i2cget监控总线状态。这样能快速判断是软件问题还是硬件问题。如果是PL-I2C再开一个Vivado的ILA集成逻辑分析仪抓AXI总线上的读写事务定位到具体是哪个寄存器操作出了问题。这套组合拳打下来大部分I2C问题都能在半小时内定位。