SoC FPGA中SPI控制器设备树配置与Linux驱动加载实战
1. 项目概述与核心挑战
折腾Cyclone V SoC的HPS(Hard Processor System)外设,尤其是自己手动添加一个IP核并让Linux系统正确识别和驱动,这事儿我前前后后搞了得有一个多月。核心目标很明确:在一个现成的、运行着Linux的SoC FPGA工程里(比如友晶DE1-SoC的参考设计),通过Qsys给HPS的轻量级AXI总线(h2f_lw_axi_master)挂上一个SPI控制器,然后让Linux系统在启动时自动加载驱动,最终在/dev目录下生成可用的SPI设备节点,方便上层应用进行读写操作。
听起来流程很标准,对吧?理论上,Qsys连线、Quartus编译、生成设备树、配置内核、更新SD卡,一气呵成。但实际操作中,我卡在了最后一步:系统启动日志明明显示识别到了altera_spi这个控制器,但/dev目录下就是找不到对应的spidev设备。这个问题困扰了我很久,根本原因在于Qsys自动生成的设备树源文件(.dts)内容不完整,缺少了关键属性,导致内核的SPI驱动框架无法成功创建设备节点。本文将详细复盘整个流程,重点拆解这个“坑”以及如何填平它,希望能帮到同样在SoC FPGA软硬件协同设计中摸索的朋友。
2. 硬件系统搭建与Qsys配置
2.1 工程基础与IP添加
我的起点是一个已经能正常启动Linux的DE1-SoC工程,例如其DE1_SOC_Linux_FB工程。这一步很关键,它保证了HPS、DDR、时钟、复位等基础子系统是正常工作的,我们只需要做“加法”。
首先,打开工程的Qsys系统。在IP Catalog中,搜索并添加SPI (3 Wire Serial)核(Altera的Avalon-MM SPI Controller)。这里有个细节需要注意:Qsys里可能同时存在SPI (3 Wire Serial)和SPI (Avalon-MM),它们协议略有不同。对于挂载到HPS AXI总线上,通常选择SPI (Avalon-MM),因为它是一个内存映射的从设备,符合AXI总线访问规范。添加后,将其重命名为一个有意义的名称,比如spi_0。
接下来是配置IP核参数。最重要的参数是时钟和寄存器映射宽度。
- 时钟(Clock):在
Clock设置中,需要设置SPI控制器模块的工作时钟(clk)和外部SPI接口的时钟(sclk)。sclk的频率就是我们最终SPI通信的时钟频率。这里我设定了目标频率为2MHz。但需要注意,这个频率会由Qsys系统的主时钟分频得到,实际生成的可能不是精确的2M,比如我最终实测是1.92MHz,这是正常的,取决于输入时钟和分频系数。 - 寄存器(Registers):确保数据宽度(Data Width)设置为8(标准SPI通常按字节传输),其他如时钟极性(CPOL)、时钟相位(CPHA)可以先保持默认,后续在驱动中可以通过ioctl动态配置。
配置完成后,需要将这个SPI控制器的Avalon-MM Slave端口连接到HPS的h2f_lw_axi_master接口上。h2f_lw_axi_master是HPS通往FPGA逻辑的轻量级AXI总线,专门用于连接这类低速外设。连线时,注意地址映射,Qsys会自动分配一个基地址,记下这个地址(例如0x00010000),后续在设备树中会用到。
最后,将SPI核的export端口导出。主要是四个信号:sclk(时钟)、mosi(主机输出)、miso(主机输入)、ss_n(片选,低有效)。在Qsys中右键点击这些信号,选择Export并命名。
2.2 Quartus工程集成与“无引脚”测试方案
在Qsys中生成系统后,回到Quartus II。首先,将新生成的.qsys文件(或对应的.qip文件)添加到工程中。然后,在顶层Verilog或VHDL文件中,实例化这个新系统模块,并将刚才导出的SPI信号连接到顶层端口。
这里我采用了一个“取巧”的测试方案:由于我这个阶段的目标是验证软硬件链路和驱动加载是否成功,而非驱动实际的外设(如SPI Flash或传感器),所以我并没有在.qsf约束文件中为这四个SPI信号分配具体的物理引脚。也就是说,这些信号在FPGA内部是“悬空”的,没有接到开发板的任何插针上。
那么如何验证SPI控制器是否真的在工作呢?我使用了Quartus内置的Signaltap II Logic Analyzer。在Signaltap中,添加这四根SPI信号线作为采样信号。编译工程并生成.sof文件后,通过JTAG下载到FPGA。当Linux启动后,运行测试程序试图访问SPI设备时,Signaltap就能实时抓取到这些内部信号线上的波形。如果能抓到符合SPI时序的波形,就证明HPS已经成功通过驱动对FPGA里的SPI控制器发出了指令,硬件链路是通的。这是一个非常有效的“黑盒”验证方法,尤其适合在驱动开发早期、硬件连接尚未最终确定时使用。
注意:使用Signaltap需要占用额外的FPGA逻辑和存储器资源,可能会影响布局布线结果。在最终产品中,需要移除Signaltap逻辑。同时,确保Signaltap的采样时钟与被测信号(如SPI的
clk)同步或满足奈奎斯特采样定理,否则可能抓不到正确波形。
3. 设备树(Device Tree)的生成与关键修改
这是整个过程中最核心、也最容易出问题的环节。设备树是Linux内核用来描述硬件拓扑结构的数据结构,对于SoC FPGA这种动态可配置的硬件尤为重要。
3.1 自动生成与初次编译
在Quartus工程全编译并成功生成.sof文件后,我们需要为这个新的硬件配置生成设备树文件。Altera/Intel提供了SOC EDS工具链。打开SOCEDS Command Shell,切换到你的Quartus工程目录。
通常,参考工程会自带一个Makefile,里面定义了生成dts(设备树源文件)和dtb(设备树二进制 blob)的规则。执行命令:
make dts这个命令会调用bsp-editor或sopc2dts工具,根据你的.sopcinfo文件(由Qsys生成)自动创建soc_system.dts文件。这一步通常比较顺利。
接着,尝试编译dts为dtb:
make dtb这里我遇到了第一个错误:
Error: soc_system.dts:xxx.xxx: Label or path pll_stream not found这个错误提示pll_stream这个节点不存在。检查我的Qsys系统,确实有一个名为pll_stream的PLL IP,但它只用于为FPGA内部的某个视频流处理模块提供时钟,并没有连接到HPS的AXI总线上,理论上Linux不需要管理它。这个错误是工具链自动解析时产生的误报。
解决方法:我们可以手动使用设备树编译器(dtc)并添加-f(force)参数来忽略这个错误,强制生成dtb。
dtc -I dts -O dtb -o soc_system.dtb -f soc_system.dts这样,soc_system.dtb文件就生成了。将其重命名为SD卡启动分区所要求的名字(通常是socfpga.dtb),并替换SD卡中的原有文件。
3.2 对比分析与手动修补
按照上述流程更新SD卡并启动开发板后,我在内核启动日志中看到了:
altera_spi ff200100.spi: Altera SPI Controller probed这令人振奋,说明内核已经发现了这个位于地址0xff200100(这是我的基地址)的SPI控制器,并且匹配到了altera_spi这个驱动。然而,/dev目录下空空如也,没有spidev设备节点。
问题出在哪里?关键在于,内核驱动“探测(probe)”成功,只意味着控制器本身被识别。但要为这个控制器下的每个SPI设备(比如一个SPI Flash芯片)创建用户空间可访问的节点,还需要设备树提供更详细的信息,特别是spidev子节点的描述。
我对比了网友提供的一个成功案例的dts片段和我自动生成的dts片段,发现了天壤之别。
自动生成的dts片段(不完整):
spi_0: spi@0xff200100 { compatible = "altr,spi-1.0"; reg = <0xff200100 0x20>; interrupt-parent = <&intc>; interrupts = <0 43 4>; clocks = <&clk_0>; };手动修改后的完整dts片段:
spi_0: spi@0xff200100 { compatible = "altr,spi-16.1", "altr,spi-1.0"; reg = <0xff200100 0x20>; interrupt-parent = <&intc>; interrupts = <0 43 4>; clocks = <&clk_0>; #address-cells = <1>; #size-cells = <0>; bus-num = <0>; num-chipselect = <1>; status = "okay"; spidev0: spidev@0 { compatible = "rohm,dh2228fv"; reg = <0>; spi-max-frequency = <1000000>; }; };关键差异解析:
compatible属性:增加了"altr,spi-16.1"。这有助于匹配更特定版本的驱动。不同版本的Quartus/IP核可能需要不同的字符串,"altr,spi-1.0"是通用匹配项。#address-cells和#size-cells:这是必须添加的!它们定义了SPI总线子节点的寻址方式。#address-cells = <1>表示子节点的reg属性用一个32位数字表示地址(即片选索引)。#size-cells = <0>表示没有大小字段。没有这两行,内核的SPI子系统无法正确解析子节点。bus-num:指定这是系统中的第几个SPI总线。如果只有一个SPI控制器,设为0。num-chipselect:控制器支持的片选信号数量。我的IP核配置了一个片选,所以是1。status = “okay”:明确启用该设备。spidev子节点:这是创建/dev/spidevX.Y设备的关键。compatible = “rohm,dh2228fv”:这是一个Linux内核中通用的SPI用户空间设备兼容字符串。使用它,内核会自动加载spidev驱动,并创建设备节点。注意:在生产环境中,如果连接的是具体器件(如Flash),应使用该器件的具体兼容字符串。reg = <0>:指定该设备连接在哪个片选上(CS0)。spi-max-frequency = <1000000>:指定该设备支持的最大SPI时钟频率(1MHz)。这个值必须设置,且不能超过控制器和物理设备的能力。它也是驱动进行时钟分频的依据。
3.3 解决节点冲突与最终编译
我将上述完整片段手动添加到我的soc_system.dts文件中spi_0节点的大括号内。然后再次执行dtc编译命令,却遇到了第二个错误:
ERROR (duplicate_label): Duplicate label 'spidev0' on /sopc@0/spi@0xff200100/spidev@0 and /sopc@0/bridge@0xc0000000/spi@0x1000100e0/spidev@0错误提示标签spidev0重复了。搜索dts文件发现,除了我添加的SPI_0,HPS内部还有一个硬核SPI控制器(spi@0x1000100e0),它下面已经定义了一个spidev0节点。设备树中每个节点的标签(label)必须是全局唯一的。
解决方法:将我添加的节点标签和节点名修改为唯一的即可。例如,将spidev0: spidev@0修改为spidev1: spidev@0。这样,标签spidev1就唯一了,而@0表示的是在该SPI控制器上的片选索引0,不影响。修改后保存,重新编译dtb,成功。
将新的dtb文件更新到SD卡,同时别忘了将Quartus编译生成的.sof文件通过quartus_cpf工具转换为.rbf文件(Raw Binary File),也命名为soc_system.rbf并放入SD卡对应位置。.rbf是HPS在启动阶段配置FPGA逻辑的比特流文件。
4. Linux内核驱动配置与编译
要让Linux内核支持我们的SPI控制器,需要确保相关驱动被编译进内核或作为模块。
- 进入内核配置:在Linux内核源码目录下,执行
make menuconfig。 - 定位SPI驱动:
- 使用
/键搜索SPI。 - 找到并进入
Device Drivers -> SPI support。 - 确保
<*> Altera SPI Controller被选中(打上*号,表示编译进内核)。这个驱动对应compatible属性中的“altr,spi-1.0”。 - 同时,在
SPI support菜单下,找到User mode SPI device driver support,这个就是spidev驱动,也必须选中。它对应spidev子节点中的“rohm,dh2228fv”。
- 使用
- 保存并编译:保存配置(通常为
.config),然后执行内核编译命令(如make zImage -j4)。编译完成后,在arch/arm/boot/目录下得到新的zImage文件。 - 更新启动文件:将新编译的
zImage和之前准备好的socfpga.dtb、soc_system.rbf一同拷贝到SD卡的FAT32启动分区。
至此,硬件配置(.rbf)、硬件描述(.dtb)、驱动支持(zImage)都已更新完毕。
5. 系统启动验证与用户空间测试
将SD卡插入DE1-SoC开发板,上电启动。通过串口终端观察启动日志,你应该能看到类似以下信息,表明SPI控制器和spidev设备都被成功识别和创建:
altera_spi ff200100.spi: Altera SPI Controller probed ... spidev spi32766.0: spidev spi32766.0 attached to SPI controller spi32766注意这里的spi32766.0,其中32766是系统动态分配的总线号,0是片选号。登录系统后,检查/dev目录:
ls -l /dev/spi*应该能看到一个名为spidev32766.0的设备节点。
5.1 编写简单的C测试程序
创建一个简单的C程序(如spi_test.c)来测试基本的读写功能。这个程序打开设备,设置SPI模式、速度和位宽,然后发送一字节数据并读取(由于MISO未接实际设备,读回的数据可能是随机的或0xFF)。
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <linux/spi/spidev.h> #include <stdint.h> int main() { int fd; const char *device = "/dev/spidev32766.0"; uint8_t mode = SPI_MODE_0; // CPOL=0, CPHA=0 uint8_t bits = 8; uint32_t speed = 500000; // 500 kHz uint8_t tx_buffer[1] = {0xAA}; // 要发送的数据 uint8_t rx_buffer[1] = {0}; // 1. 打开设备 fd = open(device, O_RDWR); if (fd < 0) { perror("Can't open device"); return -1; } // 2. 设置SPI模式 if (ioctl(fd, SPI_IOC_WR_MODE, &mode) == -1) { perror("Can't set SPI mode"); close(fd); return -1; } // 3. 设置每字节位数 if (ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, &bits) == -1) { perror("Can't set bits per word"); close(fd); return -1; } // 4. 设置最大时钟速度 if (ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed) == -1) { perror("Can't set max speed"); close(fd); return -1; } // 5. 准备传输结构体 struct spi_ioc_transfer tr = { .tx_buf = (unsigned long)tx_buffer, .rx_buf = (unsigned long)rx_buffer, .len = 1, .delay_usecs = 0, .speed_hz = speed, .bits_per_word = bits, }; // 6. 执行SPI传输 if (ioctl(fd, SPI_IOC_MESSAGE(1), &tr) == -1) { perror("SPI transfer failed"); close(fd); return -1; } printf("Sent: 0x%02X, Received: 0x%02X\n", tx_buffer[0], rx_buffer[0]); // 7. 关闭设备 close(fd); return 0; }在开发板Linux系统上,使用交叉编译工具链编译该程序:
arm-linux-gnueabihf-gcc -o spi_test spi_test.c然后运行./spi_test。
5.2 使用Signaltap验证硬件波形
在运行测试程序的同时,Quartus II中的Signaltap II已经设置好并运行。你可以在Signaltap的波形窗口中看到mosi、sclk、ss_n信号上出现清晰的时序波形。ss_n拉低表示传输开始,sclk出现脉冲,mosi上出现对应0xAA(二进制10101010)的数据。由于miso没有连接,其信号可能为高阻态或固定电平。看到这个波形,就铁证如山:从Linux用户空间发出的SPI访问指令,已经穿过内核驱动、AXI总线,成功抵达了FPGA中的SPI控制器,并转换成了正确的物理时序。整个软硬件协同链路完全打通。
6. 常见问题排查与深度解析
6.1 内核启动日志分析要点
遇到问题,第一现场永远是内核启动日志(dmesg)。关注以下几点:
- 未发现设备:如果完全没有
altera_spi相关的打印,首先检查dtb文件是否正确更新、SPI控制器的compatible属性是否与驱动匹配、寄存器地址reg是否正确。 - Probe失败:如果看到
probe failed,可能原因是中断号interrupts设置错误、时钟clocks引用错误或未使能、或者寄存器映射长度reg不对。 - Probe成功但无spidev:这就是我遇到的情况。重点检查设备树中SPI控制器节点下是否缺少
#address-cells、#size-cells、spidev子节点及其spi-max-frequency属性。
6.2 设备树语法与调试工具
- dtc工具:除了编译(
dts->dtb),还可以反编译(dtb->dts)来检查最终生成的设备树内容:dtc -I dtb -O dts -o extracted.dts socfpga.dtb。 - 内核中的设备树:系统启动后,可以在
/proc/device-tree/目录下以文件系统形式查看当前使用的设备树信息。例如,cat /proc/device-tree/sopc@0/spi@ff200100/compatible可以查看SPI控制器的兼容字符串。 - of_API*:在驱动开发中,内核通过
of_*系列函数(如of_property_read_u32)从设备树节点读取属性。确保dts中的属性名和驱动中查找的名字完全一致。
6.3 地址映射与中断号确认
- 寄存器地址(reg):在Qsys中连接
h2f_lw_axi_master时分配的基地址,是相对于该总线地址空间的偏移。在设备树中,需要的是该外设在HPS视角下的物理地址。对于h2f_lw_axi_master上的设备,其物理地址通常是0xff200000 + 分配的偏移地址。务必在Qsys或生成的map.h文件中确认。 - 中断号(interrupts):在Qsys中查看SPI控制器的
irq输出连接到了哪个中断线,并确认该中断线在HPS GIC(通用中断控制器)中的编号。格式<0 43 4>通常表示:<中断类型(SPI=0) 中断号 触发类型(4=高电平触发)>。中断号需要与Qsys系统和HPS硬件手册对应。
6.4 性能与稳定性考量
- 时钟频率:设备树中
spi-max-frequency和用户程序ioctl设置的speed,最终都会受到IP核中SCLK分频系数的限制。实际通信频率是两者中较小的一个。建议在IP核中设置一个足够高的基准时钟,以便在驱动中灵活分频。 - DMA使用:对于大数据量传输,可以考虑在Qsys中为SPI控制器启用DMA,并在Linux驱动中配置DMA通道,以减轻CPU负担。这需要在设备树中补充DMA相关属性。
- 多设备与片选:如果SPI总线上有多个从设备,需要在设备树中为每个从设备定义一个
spidev@x子节点(x为不同的片选索引),并确保num-chipselect设置正确。用户空间程序通过打开不同的/dev/spidevB.C(B为总线号,C为片选号)来访问不同设备。
整个流程走通后,回头看,最关键的突破点就在于对设备树的理解从“自动生成即可”深入到“需要手动完善关键属性”。SoC FPGA的软硬件协同设计,要求开发者必须同时具备FPGA硬件描述和Linux内核驱动的知识,而设备树正是连接这两端的桥梁。这次踩坑填坑的经历,虽然耗时,但对理解整个嵌入式Linux系统的硬件抽象层运作机制,价值巨大。
