最近在一个Zynq平台的项目里把一颗RTC和一板EEPROM接到了I2C总线上,顺手把PetaLinux工程里的I2C配置完整捋了一遍。这套流程说复杂不复杂,但坑不少,尤其对于刚接触PetaLinux的人,经常会卡在“明明改好了设备树,为什么系统里就是看不到总线”这类问题上。这篇文章我把从硬件确认、设备树修改、内核配置、rootfs集成到最终部署验证的完整路径写出来,都是我在实际项目中踩过、验证过的做法。
写这篇文章的目标读者是用PetaLinux做Zynq/MPSoC开发的工程师,尤其是刚把I2C外设加到硬件里、正准备让Linux跑通的这批人。我默认你已经会用Vivado建工程、能生成XSA,也熟悉基本的Linux命令行。如果这些还不熟,先补一补再来看也不迟。读完这篇文章,你能独立把一个挂在PS侧或者EMIO上的I2C设备在Linux里完整跑起来,并且遇到问题时知道从哪里开始排查。
1. 配置前的整体思路与必备认知
1.1 先分清是PS侧I2C还是PL侧I2C
PetaLinux里配置I2C,第一步不是打开配置文件,而是先搞清楚你的I2C控制器到底接在哪边。Zynq-7000的PS内部自带两个I2C控制器,I2C0和I2C1,对应的设备树节点通常是i2c@e0004000和i2c@e0005000。驱动用的是Cadence的I2C IP核,设备树里的compatible一般是cdns,i2c-r1p10或cdns,i2c-r1p14。这两个控制器可以直接用PS的MIO引脚,比如I2C0的SCL/SDA可以分配到MIO[10:11]这类引脚上,具体能分到哪些MIO组合要以Zynq-7000 TRM里的MIO表为准。
如果你的I2C接口是通过PL逻辑扩展出来的,比如接了Xilinx的AXI IIC IP核,那情况就不一样了。这种情况设备树里会出现一个挂在AXI总线上的I2C控制器节点,而且你需要确保PL侧的bitstream已经加载,不然CPU访问不到控制器的寄存器地址。两种场景的排查思路完全不同,所以在动手之前先确认这一点,能帮你省掉后面好几个小时的盲目调试。
怎么确认?最简单的方法是打开Vivado工程的Block Design,看I2C IP是直接从PS端连到MIO的IIC_0,还是需要经过PL的AXI互联。也可以在PetaLinux启动后执行i2cdetect -l,如果看到了类似i2c-0和i2c-1两个总线,通常就是PS的两个控制器;如果看到i2c-2、i2c-3,那多半还挂了PL侧的I2C。
1.2 配置I2C之前必须先拿到的四样信息
- 原理图里I2C挂在哪条总线上:是PS的I2C0还是I2C1,还是PL侧扩展出来的某条总线。总线不同,设备树里操作的对象就不同。
- 外设的7位地址:几乎所有I2C外设数据手册里都会给地址,比如AT24C02的地址是0x50,但很多资料会写成0xA0,那是8位地址含读写位的写法。设备树、i2c-tools里用的都是7位地址。这个换算关系我后面还会再提。
- 外设的工作模式:是标准的100kHz、400kHz快速模式还是1MHz高速模式,这决定设备树里的
clock-frequency怎么填。 - 内核里有没有对应的设备驱动:比如EEPROM用
at24驱动,RTC用rtc-ds1307、rtc-pcf8563这类驱动,传感器则五花八门。如果内核里没有对应驱动,你写了设备树节点也没用,只会多报一条Unknown device之类的错误。
这些信息里面,最容易忽略的是外设地址。I2C协议里地址分7位和10位两种,绝大多数消费级芯片都是7位地址。数据手册里如果写0xA0,那就是7位地址0x50左移一位加上读写位得到的。设备树里写reg属性时填7位地址0x50,不是0xA0,这一点我在第3章会用一个实际例子讲明白。
2. 在PetaLinux工程里让I2C控制器跑起来
2.1 从Vivado到XSA,先把外设使能
很多人一上来就改设备树,发现怎么改都不生效,最后回过头来查,居然是硬件工程里根本没把I2C的PS控制器使能。在Vivado里,如果你要让PS的I2C0工作,需要打开Zynq PS配置界面,在Peripheral I/O里勾选I2C 0,然后在MIO Configuration里给SCL和SDA分配具体的MIO引脚。这一步没做,后面的一切都是空中楼阁。
完成硬件配置后,重新生成bitstream并导出XSA。然后在PetaLinux工程里执行:
petalinux-config --get-hw-description=/path/to/xsa目录这个命令会把工程里硬件相关的配置更新一遍。之后你可以在project-spec/meta-user/recipes-bsp/device-tree/files/目录下看到生成的设备树相关文件。这里有一个习惯我一直保持:拿到新XSA之后先去查看一下生成的设备树里是否包含了I2C节点,如果硬件使能正确,通常会有类似这样的内容:
i2c@e0004000 { compatible = "cdns,i2c-r1p10"; reg = <0xe0004000 0x1000>; interrupts = <0 25 4>; clocks = <&clkc 38>; ... };注意这个节点里可能没有status = "okay",而是继承自父节点或者默认打开。但如果你用的是其他的BSP包,有时会写成status = "disabled",这种情况需要在设备树中显式打开。
2.2 设备树源文件里添加I2C控制器配置
PetaLinux工程中,用户自定义设备树的推荐位置是project-spec/meta-user/recipes-bsp/device-tree/files/system-user.dtsi。这个文件专门留给用户做修改使用,避免直接改BSP自动生成的设备树,因为后者在重新获取硬件描述时会被覆盖。
要让I2C0跑起来,在system-user.dtsi里加上:
/include/ "system-conf.dtsi" &i2c0 { status = "okay"; clock-frequency = <100000>; pinctrl-names = "default"; };这里status = "okay"是打开控制器,clock-frequency设置总线频率。Zynq PS的I2C控制器时钟来自PS的I2C参考时钟,一般默认能跑到400kHz,但为了稳妥,我在项目里如果外设没有特殊要求,都会设成100kHz。外设跑得快不快有时候不重要,稳定才是第一位的。
还有一种情况是控制器默认引用了一个已经定义的引脚配置,而你的硬件改到了别的MIO上,这时候可能需要添加pinctrl-0属性来匹配。不过在大多数PetaLinux工程里,PS侧I2C的引脚mux已经在启动时的固件配置里完成,设备树里不需要重复描述。
2.3 内核配置确保I2C驱动编进来
设备树节点有了,还得确保内核把对应的I2C控制器驱动编进去。执行:
petalinux-config -c kernel进入内核配置菜单,然后查找以下几项:
CONFIG_I2C:I2C核心支持,必须为y或m。CONFIG_I2C_CADENCE:Cadence I2C控制器驱动,也就是PS侧I2C的驱动,必须选上。CONFIG_I2C_CHARDEV:I2C设备文件接口,编译成内核模块后会在/dev/i2c-N生成设备节点,i2c-tools依赖这个。CONFIG_EEPROM_AT24:常见EEPROM驱动,如果你要挂AT24C系列,选上。CONFIG_RTC_DRV_DS1307、CONFIG_RTC_DRV_PCF8563等:具体看你的RTC型号。
PetaLinux的kernel配置有个特点,你直接改内核源码里的.config,下次petalinux-build的时候可能会被还原。正确的做法是把要开启的配置项写进PetaLinux用户配置文件中,比如project-spec/meta-user/recipes-kernel/linux/linux-xlnx/user_*.cfg,常见的文件名是user_2025.1.cfg或者直接就一个user.cfg,根据你使用的PetaLinux版本不同而有差异。每次构建时它会自动合并进去。例如:
CONFIG_I2C=y CONFIG_I2C_CADENCE=y CONFIG_I2C_CHARDEV=y CONFIG_EEPROM_AT24=y这样做的好处是配置可追溯、可重复,换一台电脑重新构建时也能复现。我就是因为这个习惯,在升级PetaLinux版本之后少踩了很多坑。
3. 挂载具体I2C设备:设备树、驱动与用户空间
3.1 先扫描总线,找到外设地址
控制器跑起来之后,先别急着写设备树子节点。我个人的习惯是先构建一个“最小系统”,用i2cdetect把总线上的设备地址扫出来,确认硬件连接没问题,再继续往下做。
构建好后启动板子,执行:
i2cdetect -l能看到类似这样的输出:
i2c-0 i2c Cadence I2C Controller I2C adapter i2c-1 i2c Cadence I2C Controller I2C adapter这表示内核已经成功注册了两条I2C总线。接下来扫描总线0:
i2cdetect -y 0如果你的EEPROM地址是0x50,屏幕上就会在0x50位置出现一个十六进制编号,比如50,表示在这个地址检测到了设备。如果没有出现,先不要怀疑软件,优先检查硬件,常见的坑我在第5章里详细说。
3.2 在设备树中描述I2C子设备
扫描确认地址之后,就可以在对应的I2C控制器节点下添加子设备了。假设I2C0上挂了一个AT24C02 EEPROM,7位地址是0x50,设备树里这么写:
&i2c0 { status = "okay"; clock-frequency = <100000>; eeprom@50 { compatible = "atmel,24c02"; reg = <0x50>; pagesize = <8>; }; };这里reg属性的0x50就是7位设备地址,前面我一直强调的0x50与0xA0的区别就在这里。很多数据手册会给一个地址字节,比如AT24C02的Device Address是1010 A2 A1 A0 R/W,其中前四位固定为1010,后三位由硬件引脚A0-A2决定,最低位是读写位。如果把整个字节算下来是0xA0,去掉最低位后就是0x50。设备树里要填0x50,i2c-tools里扫出来的也是0x50。
对于挂在同一总线上的多个设备,直接在节点下并列添加即可:
&i2c0 { eeprom@50 { compatible = "atmel,24c02"; reg = <0x50>; }; rtc@51 { compatible = "pcf8563"; reg = <0x51>; }; };要注意的是,每个子节点的reg必须与硬件上的地址跳线一致。如果A0、A1、A2引脚悬空或者没接,地址就是芯片手册里的默认值,不少板子的默认值需要重新核对原理图。
3.3 把厂商提供的设备树内容搬进PetaLinux工程
实际项目中经常遇到这种情况:你是从方案商或芯片原厂拿到了一套旧设备树,里面已经写好了ADC、传感器、AD9361之类设备的I2C挂载节点,现在要把它挪到新建的PetaLinux工程里。直接全文复制system-user.dtsi大概率会编译报错,因为旧工程里引用的有些头文件路径、时钟节点名和新的BSP不一致。
我的做法分三步:
- 第一步,先对比新旧两个设备树里I2C控制器的节点名。比如旧工程里可能叫
i2c@e0004000,新工程里可能有一个&i2c0的别名,写法不同但指向同一个控制器。优先使用新工程里的别名写法。 - 第二步,把旧设备树中目标外设的子节点单独摘出来,只保留
compatible、reg、interrupts这类核心属性,以及外设本身必需的私有属性。像时钟、复位、引脚这类属性,如果新工程里已有全局定义,就删掉不要。比如AD9361的设备树节点会带clocks属性,搬到新工程时先注释掉,让驱动自动探测,跑起来再按报错逐项补。 - 第三步,把整理好的节点粘贴到
system-user.dtsi对应控制器下,执行:
petalinux-build如果编译报语法错误,检查括号是否配对、分号是否遗漏。很多时候设备树编译错误都是这类低级问题,别慌,编译器会告诉你具体在哪一行。
3.4 内核I2C设备驱动注册机制速览
设备树写对了,能不能真正驱动起来,还得看内核里有没有对应的I2C驱动,以及驱动的匹配方式是否和设备树里的compatible一致。I2C设备驱动最常用的注册写法是:
static const struct of_device_id my_i2c_dt_ids[] = { { .compatible = "myvendor,mydevice" }, { } }; MODULE_DEVICE_TABLE(of, my_i2c_dt_ids); static struct i2c_driver my_i2c_driver = { .driver = { .name = "my_i2c_driver", .of_match_table = my_i2c_dt_ids, }, .probe_new = my_i2c_probe, .id_table = my_i2c_id_table, }; module_i2c_driver(my_i2c_driver);当驱动加载时,内核会把这个驱动的of_match_table里列出的compatible和设备树节点里声明的compatible做比较,匹配上了就调用probe函数,同时注册一个I2C客户端设备。如果你写的是自定义驱动,就一定要确保设备树里compatible字符串和of_match_table里的完全一致,多一点空格都不行。
注册完成后,你会在/sys/bus/i2c/devices/下看到类似0-0050这样的目录,0-0050表示总线0上地址为0x50的设备。看到这个目录,基本说明设备树解析和设备注册已经成功,接下来就是用户空间的事情了。
4. Rootfs集成与完整构建部署流程
4.1 在PetaLinux rootfs中加入i2c-tools
内核和设备树搞定了,还需要用户空间的调试工具。PetaLinux默认不会把i2c-tools打进根文件系统,需要手动配置。执行:
petalinux-config -c rootfs然后按菜单路径进入:
Filesystem Packages → console → tools → i2c-tools勾选上i2c-tools,保存退出。这里建议把i2c-tools的dev和dbg包也一起选上,有些调试工具如i2c-stub-from-dts在dbg包里。
如果你更喜欢直接改配置文件,可以在project-spec/meta-user/conf/user-rootfsconfig里添加:
CONFIG_i2c-tools然后在petalinux-config -c rootfs中确认生效。这个方法的优点是可以直接进版本管理,换人换机器都能复现。总之,只要rootfs配置已生效,构建出的镜像里就会包含i2cdetect、i2cget、i2cset、i2cdump这一组命令行工具。
4.2 重新构建、打包与制作SD卡启动镜像
改完设备树、内核配置和rootfs之后,回到PetaLinux工程根目录:
petalinux-build构建过程会重新编译设备树、内核以及根文件系统。构建完的产物分布在images/linux/目录下,核心是三个文件:BOOT.BIN、boot.scr和image.ub。
如果BOOT.BIN不存在,需要执行打包命令:
petalinux-package --boot --format BIN --fsbl images/linux/zynq_fsbl.elf --fpga images/linux/design.bit --u-boot images/linux/u-boot.elf这条命令会根据你实际使用的硬件工程,生成包含FSBL、bitstream和U-Boot的BOOT.BIN。image.ub则是内核+设备树+根文件系统的镜像。
制作SD卡时,我的做法是:
sudo fdisk /dev/sdX # 创建一个FAT32分区,大约500MB,用于存放BOOT.BIN、boot.scr、image.ub # 剩余空间创建一个ext4分区,用于根文件系统 sudo mkfs.vfat -n BOOT /dev/sdX1 sudo mkfs.ext4 -L rootfs /dev/sdX2 sudo mount /dev/sdX1 /mnt/boot sudo mount /dev/sdX2 /mnt/rootfs sudo cp images/linux/BOOT.BIN images/linux/boot.scr images/linux/image.ub /mnt/boot/ sudo tar xf images/linux/rootfs.tar.gz -C /mnt/rootfs sync从PetaLinux 2020.1之后的版本开始,image.ub可以同时包含内核和设备树,根文件系统则单独打包。只要FAT分区里有这三个文件,开发板就能正常启动。
4.3 启动后的完整验证清单
板子启动后,我一般按这个顺序验证I2C是否正常工作:
dmesg | grep i2c,检查内核启动时是否打印了I2C控制器的注册信息。正常能看到类似“i2c /dev entries driver”或者“cdns-i2c e0004000.i2c: 400 kHz mmio ...”。i2cdetect -l,确认有几个I2C adapter。i2cdetect -y 0,扫描总线0上的设备,确认目标设备地址出现。i2cget -y 0 0x50 0x00,如果外设是EEPROM,读取0x00地址的字节,看返回值是否为0xFF或预期值。i2cset -y 0 0x50 0x00 0x55,写入一个字节,再i2cget读回来,确认读写都正常。
如果挂的是0.96寸OLED这类显示设备,还可以配合i2cdump查看驱动初始化时往控制寄存器里写的数据。总之,验证这一步的核心是确认总线通、地址对、读写工作正常,做到这三条,你的I2C软件链路就已经完全跑通了。
5. 常见问题与排查技巧实录
5.1 设备树改了但系统启动后没生效
这是最高频的坑。很多人改了system-user.dtsi,执行了petalinux-build,烧进去却发现行为没变化。排查思路很简单,先确认你启动时用的设备树是不是新编译出来的那份。如果你用的是image.ub,设备树已经打进这个文件里了,只重新生成system.dtb没有用,必须重新打包image.ub。正确做法是重新执行petalinux-build,让它把设备树、内核、根文件系统都重新打包。
另一个坑是PetaLinux在构建时不一定每次都重新编译设备树,如果只修改了dtsi但构建系统判断缓存未失效,就会出现改了等于没改的情况。这时可以手动清理:
petalinux-build -c device-tree -x distclean petalinux-build设备树是否生效,最直接的确认方法是启动后查看:
ls /proc/device-tree/如果节点存在,再去soc目录或者对应地址目录下找i2c@e0004000,再看里面的status属性是不是okay。这个目录的内容就是内核解析设备树后的结果,绕过了所有中间环节,非常直观。
5.2 i2cdetect扫描不到设备
扫描不到设备时,先用万用表量一下SCL和SDA引脚上的电平,静态时都应该是高电平,因为I2C总线靠上拉电阻默认拉高。如果某个引脚是低电平,可能是上拉电阻没焊、I2C设备挂死、引脚mux配置错误或者地址冲突。
我遇到过最隐蔽的一种情况是:板子上某个I2C设备地址和i2cdetect扫描地址发生冲突,导致扫描流程卡死,表现为i2cdetect执行后停在某个地址半天不动。这时可以用i2cdetect -y -r 0强制使用读模式扫描,或者用i2cdetect -y -q 0使用快速模式,通常能绕过这类问题。
还有一类常见问题是SDA和SCL接反了。原理图上看着没问题,但实际PCB布线时交叉了,这种情况用示波器抓波形最容易发现。如果示波器上能看到SCL有脉冲而SDA没有响应,基本就是设备没有ACK,这时先查地址、查供电,最后再查接线。
5.3 I2C挂在EMIO上怎么处理
Zynq的PS侧I2C不一定只能走MIO,也可以从EMIO引出到PL引脚。很多项目为了布线方便,会让I2C信号从PL侧引脚出来,这就涉及两个问题:一是PL的bitstream必须加载,EMIO引脚才有输出;二是设备树里需要确保引脚复用配置正确。
在硬件侧,Vivado里依然是在PS的I2C外设配置中使能I2C,但在MIO配置里选择EMIO而不是某个具体的MIO引脚。这样I2C控制器的信号就通过EMIO连到了PL逻辑,再到板级引脚上。这种情况下,设备树里I2C控制器的地址、中断和时钟都与MIO模式相同,只是引脚属于PL侧。
启动时如果bitstream没有加载,你可能会看到I2C控制器已经注册成功,但i2cdetect扫描时所有地址都无响应。这时优先确认FPGA加载是否成功:
cat /sys/class/fpga_manager/fpga0/state状态如果是active,再检查PL侧引脚有没有被其他IP占用。EMIO的I2C还容易受PL侧IO标准配置影响,比如电压域设置不对,导致引脚电平根本拉不上去。这类问题软件上往往无从下手,必须回到Vivado里核对引脚的IO Standard。
5.4 内核日志与常见报错速查
为了方便排查,我把实际项目里常见的I2C报错整理成了一个速查表:
| 报错信息 | 通常原因 | 排查方向 |
|---|---|---|
timeout waiting for bus | 总线上有设备拉低SCL,或其他主设备占用 | 用示波器看SCL电平,检查是否有设备挂死 |
arbitration lost | 多主设备同时发起传输,或总线干扰严重 | 检查上拉电阻阻值、总线长度、是否存在两个主设备 |
Remote I/O error | 从设备无ACK | 核对7位地址、供电、接线,用i2cdetect扫描 |
controller timed out | 控制器时钟配置异常 | 检查内核时钟树,确认I2C控制器时钟频率正常 |
i2c_xfer failed | 驱动与设备通信失败 | 查看具体驱动代码,确认寄存器访问时序 |
还有一条经验:i2cset写数据后立刻i2cget读回来的值不对,有时不是I2C总线的问题,是外设本身需要延时。尤其EEPROM写周期是5毫秒左右,写完马上读可能读到旧数据。这时候加一个usleep或者sleep再读,往往就正常了。这类问题在脚本里很常见,别一上来就怀疑硬件。
5.5 关于设备树覆盖与复用的一点建议
我在多个PetaLinux版本之间切换后,最大的体会是:每个版本的BSP生成的设备树结构都可能有差异,比如有的版本里I2C节点已经有status = "okay",有的版本里没有。因此在system-user.dtsi里写控制器节点时,不要只写子设备,还要显式写上status = "okay"和clock-frequency。这样即使自动生成的设备树随版本变化,你的用户配置依然能稳定覆盖上去。
另外,如果老BSP里用了i2c@e0004000这种路径式写法,而新BSP已经定义了&i2c0别名,我建议优先使用&i2c0。别名方式更直观,而且在设备树编译时能自动匹配到具体控制器节点,不至于因为路径差异导致节点没有被覆盖。我见过有人把整段i2c@e0004000 { ... }复制进新工程的system-user.dtsi,结果新工程里这个控制器节点名变成了i2c@e0005000,等于改了另一个控制器,折腾了很久才发现问题。
根据我个人的实践经验,配置PetaLinux I2C这件事,真正的分水岭不在于你会不会敲那几条命令,而在于你把硬件、设备树、内核驱动这三层的关系理清没有。硬件给的是物理通路,设备树告诉内核“这里有什么、地址是多少”,驱动则负责真正把数据读写出来。三层只要有一层脱节,表现出来就是“总线不通”或者“设备不识别”。先跑通i2cdetect,再谈设备树、再调驱动,这个顺序能帮你省掉至少一半的排查时间。
最后分享一个我自己用的小技巧:把下面这几条命令写成一个i2c_check.sh脚本,放在板子里随时执行,能快速定位八成以上的I2C问题:
#!/bin/sh echo "===== I2C adapters =====" i2cdetect -l echo "===== Scan bus 0 =====" i2cdetect -y 0 echo "===== Scan bus 1 =====" i2cdetect -y 1 echo "===== Kernel i2c log =====" dmesg | grep -i i2c | tail -n 20脚本不值钱,值钱的是你拿到输出之后能一眼看出问题在哪条链路。I2C这个协议本身不复杂,生命周期也长,把一套排查方法固化下来,以后在哪个平台、哪个项目里都通用。