ZYNQ-7000 SPI通信全解析:从PS配置到PL自定义IP与DMA高速传输
1. 项目缘起为什么要在ZYNQ-7000上折腾SPI如果你手头有一块ZYNQ-7000的开发板无论是ZedBoard、Zybo还是其他型号你大概率会面临一个选择如何让它的可编程逻辑PL部分和处理器系统PS部分高效地“对话”这个问题看似简单却是嵌入式系统设计的核心。SPISerial Peripheral Interface作为一种高速、全双工、同步的串行通信总线因其协议简单、速率高、支持多从机等优点成为了芯片间通信的“万金油”。在ZYNQ平台上SPI的应用场景非常广泛连接高速ADC/DAC、配置外部Flash、驱动TFT屏幕、与传感器如IMU、环境传感器通信甚至是作为两个ZYNQ芯片之间的数据链路。然而ZYNQ-7000的SPI实现有其特殊性。它不像单纯的MCUSPI控制器可能只有一个。在ZYNQ中你至少有两种选择使用PS端自带的SPI控制器通过MIO或EMIO引出或者在PL端用逻辑Verilog/VHDL自己实现一个SPI控制器。前者简单直接但灵活性受限于硬核IP后者完全自由可以定制时序、位宽、时钟极性和相位但需要你懂硬件描述语言和时序设计。更复杂的情况是你可能需要PS和PL协同工作比如PS通过AXI总线配置和控制一个位于PL端的自定义SPI IP核实现超高带宽或特殊协议的数据传输。这恰恰是ZYNQ的魅力所在也是新手最容易感到困惑的地方。我最近在一个数据采集项目中就需要通过SPI以20MHz的时钟频率连续从一块高速ADC读取数据。最初尝试使用PS端的SPI控制器发现其DMA和中断配置在应对持续流数据时有些力不从心且时钟抖动影响了ADC的采样精度。最终我选择在PL端用逻辑实现了一个专用的SPI Master控制器并通过AXI-Lite接口让PS端的应用层程序进行控制和状态查询通过AXI-Stream接口将数据直接送入PL端的FIFO和数据处理流水线。这个过程中踩了不少坑也积累了一些心得。本文将围绕ZYNQ-7000的SPI通信从PS端配置、PL端实现到软硬件协同设计进行一次深度的拆解和实操分享。2. PS端SPI控制器快速上手的捷径与性能天花板对于大多数标准外设连接使用ZYNQ PS端集成的SPI控制器是最快、最稳定的方案。ZYNQ-7000的PS通常包含两个SPI控制器每个控制器支持主/从模式和多从机选择通过单独的片选信号。其配置主要通过Xilinx提供的驱动和库函数完成。2.1 硬件设计与Vivado配置首先你需要在Vivado的Block Design中对ZYNQ Processing System IP进行配置。启用SPI控制器在“Peripheral I/O Pins”或“MIO Configuration”选项卡下找到SPI 0和SPI 1。勾选你需要使用的控制器例如SPI 0。选择引脚ZYNQ的SPI信号可以通过MIO直接连接到PS引脚或EMIO路由到PL引脚引出。如果外设直接连接到PS的固定功能引脚就选MIO。如果需要更大的灵活性或者PS的MIO引脚不够用就选择EMIO。选择EMIO后Vivado会自动在ZYNQ IP核上生成一组SPI0_*_io的接口信号你需要将它们引出到顶层端口。配置时钟SPI控制器的输入时钟来自PS的内部时钟。你需要关注SPI参考时钟ref_clk的频率因为它决定了SPI时钟SCLK的分频范围。通常这个时钟在100-200MHz量级。连接中断可选如果你计划使用中断模式而非轮询模式需要在“Interrupts”选项卡下启用SPI控制器的中断并将其连接到PS的GIC通用中断控制器。配置完成后生成HDL Wrapper和Bitstream。在导出硬件时务必包含Bitstream文件以便后续在Vitis或SDK中开发软件。2.2 软件驱动与基础API使用在Vitis或SDK中Xilinx提供了xspi.h和xspi_*等一系列驱动函数。一个最基本的SPI主设备发送/接收流程如下#include xspi.h #include xparameters.h // 包含硬件定义 #define SPI_DEVICE_ID XPAR_XSPI_0_DEVICE_ID int main() { XSpi SpiInstance; XSpi_Config *SpiConfig; u8 SendBuffer[4] {0x01, 0x02, 0x03, 0x04}; u8 RecvBuffer[4] {0}; // 1. 查找并初始化驱动 SpiConfig XSpi_LookupConfig(SPI_DEVICE_ID); if (SpiConfig NULL) { /* 错误处理 */ } int Status XSpi_CfgInitialize(SpiInstance, SpiConfig, SpiConfig-BaseAddress); if (Status ! XST_SUCCESS) { /* 错误处理 */ } // 2. 配置SPI模式 // 设置为主模式 Status XSpi_SetOptions(SpiInstance, XSP_MASTER_OPTION); // 设置时钟极性和相位模式0 (CPOL0, CPHA0) Status | XSpi_SetOptions(SpiInstance, XSP_CLK_ACTIVE_LOW_OPTION | XSP_CLK_PHASE_1_OPTION); // 启动SPI设备 XSpi_Start(SpiInstance); // 3. 选择从设备操作片选线 XSpi_SetSlaveSelect(SpiInstance, 0x01); // 选择从设备0对应SS0线拉低 // 4. 执行阻塞式传输 Status XSpi_Transfer(SpiInstance, SendBuffer, RecvBuffer, 4); if (Status ! XST_SUCCESS) { /* 错误处理 */ } // 5. 传输完成后可以取消片选可选有些驱动会在传输后自动管理 // XSpi_SetSlaveSelect(SpiInstance, 0x00); return 0; }注意XSpi_SetOptions中关于时钟极性和相位的选项命名可能有些反直觉。XSP_CLK_ACTIVE_LOW_OPTION对应CPOL1时钟空闲时为高电平XSP_CLK_PHASE_1_OPTION对应CPHA1数据在第二个时钟边沿采样。要配置最常用的模式0CPOL0 CPHA0应该不设置这两个选项。务必查阅xspi.h头文件中的注释或者通过示波器实测SCLK和MOSI的波形来验证。2.3 性能瓶颈与实战踩坑PS端SPI控制器用起来方便但其性能存在天花板在高速或实时性要求高的场景下容易成为瓶颈CPU占用率高无论是轮询还是中断模式每次传输都需要CPU深度参与。对于持续的数据流CPU会被频繁打断无法处理其他任务。延迟不确定中断响应、任务调度都会带来不可预测的延迟。对于需要精确时序控制的外设如某些ADC要求SCLK边沿与信号严格对齐这可能是个问题。吞吐量限制虽然SPI控制器本身时钟可以很高例如100MHz以上但通过APB总线与CPU交互、软件驱动层的开销实际有效数据吞吐率会大打折扣。在我的测试中使用中断模式连续传输实测吞吐率很难稳定超过15-20 Mbps。灵活性不足PS端SPI控制器的时序是固定的。如果你需要非标准的SPI变种比如支持双向数据线3线SPI、或需要动态改变时钟频率和相位PS控制器就无能为力了。实操心得在最初的原型阶段强烈建议先使用PS端SPI控制器验证外设的基本功能。这能帮你快速排除硬件连接问题。一旦功能调通如果遇到性能瓶颈再考虑迁移到PL端方案。同时务必用逻辑分析仪或示波器抓取SPI总线波形确认时钟极性、相位、片选时序完全符合从设备的数据手册要求。很多通信失败的问题根源都在于模式配置错误。3. PL端自定义SPI IP核释放可编程逻辑的潜力当PS端SPI无法满足需求时在PL端实现自定义SPI IP核就成了必然选择。这给了你完全的掌控权你可以设计一个状态机精确控制SCLK的每一个边沿MOSI/MISO的每一位数据以及片选信号的建立和保持时间。3.1 状态机设计与Verilog实现一个最基础的SPI Master状态机可以包含以下几个状态IDLE空闲、START启动传输拉低片选、SHIFT移位数据、STOP结束传输拉高片选。以下是一个简化版的模式0CPOL0 CPHA0SPI Master核心代码module spi_master #( parameter DATA_WIDTH 8, parameter CLK_DIV 10 // 系统时钟分频系数用于生成SCLK )( input wire sys_clk, input wire sys_rst_n, // 控制接口可连接AXI-Lite input wire [DATA_WIDTH-1:0] tx_data, input wire start, output reg [DATA_WIDTH-1:0] rx_data, output reg busy, output reg done, // SPI物理接口 output reg sclk, output reg mosi, input wire miso, output reg cs_n ); reg [31:0] clk_cnt; reg [3:0] bit_cnt; // 假设8位数据需要4位计数器0-7 reg [DATA_WIDTH-1:0] tx_reg; reg [DATA_WIDTH-1:0] rx_reg; localparam S_IDLE 2b00; localparam S_START 2b01; localparam S_SHIFT 2b10; localparam S_STOP 2b11; reg [1:0] state; // 时钟分频与SCLK生成 always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin clk_cnt 0; sclk 1b0; // 模式0空闲时SCLK为低 end else begin if (state S_SHIFT) begin if (clk_cnt CLK_DIV - 1) begin clk_cnt 0; sclk ~sclk; // 在SHIFT状态翻转SCLK end else begin clk_cnt clk_cnt 1; end end else begin clk_cnt 0; sclk 1b0; // 非SHIFT状态SCLK保持空闲电平 end end end // 主状态机 always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin state S_IDLE; cs_n 1b1; mosi 1bz; busy 1b0; done 1b0; bit_cnt 0; tx_reg 0; rx_reg 0; rx_data 0; end else begin done 1b0; // done信号默认拉低只在一个周期内有效 case (state) S_IDLE: begin cs_n 1b1; busy 1b0; if (start) begin state S_START; tx_reg tx_data; busy 1b1; end end S_START: begin cs_n 1b0; // 拉低片选 // 模式0下在第一个SCLK边沿前就准备好数据 mosi tx_reg[DATA_WIDTH-1]; // 先输出最高位(MSB) tx_reg {tx_reg[DATA_WIDTH-2:0], 1b0}; // 左移 state S_SHIFT; bit_cnt DATA_WIDTH - 1; // 还剩DATA_WIDTH-1位要发送 end S_SHIFT: begin // 在SCLK的下降沿模式0的数据采样边沿采样MISO // 注意这里的状态机由系统时钟驱动我们通过检测sclk的边沿来同步操作 if (clk_cnt CLK_DIV/2 - 1 sclk 1b0) begin // 检测SCLK上升沿前准备数据 mosi tx_reg[DATA_WIDTH-1]; tx_reg {tx_reg[DATA_WIDTH-2:0], 1b0}; end if (clk_cnt CLK_DIV - 1 sclk 1b1) begin // 检测SCLK下降沿采样数据 rx_reg {rx_reg[DATA_WIDTH-2:0], miso}; if (bit_cnt 0) begin state S_STOP; end else begin bit_cnt bit_cnt - 1; end end end S_STOP: begin cs_n 1b1; // 拉高片选 rx_data rx_reg; // 锁存接收到的数据 done 1b1; // 产生一个周期的完成脉冲 state S_IDLE; end default: state S_IDLE; endcase end end endmodule这个代码是一个高度简化的示例用于说明核心状态转移和数据移位逻辑。在实际工程中你需要考虑更多细节比如可配置的CPOL/CPHA、更精确的片选建立/保持时间控制、以及支持16位或32位数据宽度。3.2 通过AXI接口与PS协同工作一个孤立的PL端SPI IP核用处不大关键是要让PS端的ARM处理器能够控制它并交换数据。这就需要为我们的SPI Master模块添加AXI接口。通常有两种选择AXI-Lite用于低速、小数据量的控制寄存器访问。例如PS通过AXI-Lite写入要发送的数据、启动传输、读取状态寄存器和接收到的数据。这是最常用的方式。AXI-Stream用于高速、流式数据传输。PS或PL的其他模块可以通过AXI-Stream接口源源不断地向SPI IP核发送待发送数据流或者从IP核读取接收到的数据流非常适合DMA场景。在Vivado中你可以使用“Create and Package New IP”向导将你的Verilog模块封装成带AXI4-Lite接口的IP。向导会自动生成地址映射、读写逻辑等样板代码你只需要在用户逻辑部分实例化你的spi_master模块并将控制信号start,tx_data等和状态信号busy,rx_data等映射到AXI寄存器上即可。一个关键的集成步骤在Block Design中将你新创建的AXI SPI IP核添加进来并使用AXI Interconnect连接到ZYNQ PS的M_AXI_GP0接口这是PS端ARM访问PL的通用AXI主端口。同时将IP核的sys_clk和sys_rst_n连接到PS提供的FCLK_CLK0和FCLK_RESET0_N。最后将IP核的SPI物理引脚sclk,mosi,miso,cs_n引出到顶层端口分配到FPGA的具体引脚上。3.3 自定义IP的软件驱动与内存映射访问硬件生成Bitstream并导出到Vitis后软件访问就变得非常直观。Vitis会自动为你的自定义IP生成一个包含基地址XPAR_MY_SPI_IP_0_BASEADDR的头文件。你可以像操作内存一样通过指针访问IP的寄存器。假设我们在IP中定义了三个32位寄存器偏移0x00:CTRL_REG。Bit0为start位写1启动传输。偏移0x04:TX_DATA_REG。写入要发送的数据。偏移0x08:RX_DATA_REG。读取接收到的数据。偏移0x0C:STATUS_REG。Bit0为busy位Bit1为done位。对应的软件代码可能如下#include xparameters.h #include xil_io.h #define SPI_IP_BASE XPAR_MY_SPI_MASTER_0_BASEADDR #define REG_CTRL (SPI_IP_BASE 0x00) #define REG_TX_DATA (SPI_IP_BASE 0x04) #define REG_RX_DATA (SPI_IP_BASE 0x08) #define REG_STATUS (SPI_IP_BASE 0x0C) void spi_transfer(u32 tx_val) { // 1. 写入发送数据 Xil_Out32(REG_TX_DATA, tx_val); // 2. 启动传输向CTRL寄存器的start位写1 Xil_Out32(REG_CTRL, 0x01); // 3. 轮询等待传输完成 while ((Xil_In32(REG_STATUS) 0x01) ! 0) { // 等待busy位变低或者检查done位变高 // 具体取决于你的状态寄存器设计 } // 4. 读取接收数据 u32 rx_val Xil_In32(REG_RX_DATA); return rx_val; }这种方式将SPI通信的时序控制完全交给了硬件状态机软件只需要发起请求和等待结果CPU占用率极低。通过合理设计状态机你可以确保SCLK的时序非常精确和稳定。4. 高速流数据传输AXI DMA与SPI IP的联合作战对于前面提到的20MHz SPI持续读取ADC数据的场景仅仅有自定义SPI IP还不够。如果每个数据样本都需要PS端CPU来发起一次读操作瓶颈立刻又回到了CPU。此时需要引入AXI Direct Memory Access (DMA)IP核构建一个从SPI外设到PS DDR内存的自动数据传输通道。4.1 系统架构设计整个数据流架构如下PL端自定义SPI Master IP负责产生精确的SCLK从ADC的MISO线读取数据。每当接收完一个完整的数据字如16位就产生一个“数据有效”脉冲。AXI-Stream接口在SPI IP内部将接收到的数据打包成AXI-Stream格式。tdata是数据本身tvalid由“数据有效”脉冲驱动。AXI DMA IP配置为Scatter-Gather模式S2MM Only。其S_AXIS_S2MM端口连接SPI IP的AXI-Stream输出。DMA负责将源源不断的流数据搬运到PS端DDR内存中指定的缓冲区。PS端软件程序负责初始化DMA设置目的地址DDR中的缓冲区和传输长度例如设置一个循环缓冲区。然后启动DMA。DMA会在后台自动工作无需CPU干预。中断处理可以配置DMA在传输完成或达到一半时产生中断通知CPU处理已经采集到的数据块。在Vivado Block Design中你需要连接ZYNQ7 PS-AXI Interconnect-AXI DMA-你的SPI IP (AXI-Stream Slave)。同时将DMA的M_AXI_S2MM端口通过另一个AXI Interconnect连接到ZYNQ PS的S_AXI_HP0接口这是PS端的高性能从端口专为PL高速访问DDR设计。4.2 软件配置与数据缓冲区管理软件侧的工作主要集中在配置DMA引擎和管理内存缓冲区。#include xaxidma.h #include xparameters.h #include xscugic.h // 中断控制器 #define DMA_DEV_ID XPAR_AXIDMA_0_DEVICE_ID #define RX_BUFFER_SIZE 16384 // 16KB的接收缓冲区 u8 *RxBuffer; // 指向DDR中缓冲区的指针 int setup_dma() { XAxiDma_Config *DmaConfig; XAxiDma DmaInstance; int Status; // 1. 初始化DMA驱动 DmaConfig XAxiDma_LookupConfig(DMA_DEV_ID); Status XAxiDma_CfgInitialize(DmaInstance, DmaConfig); if (Status ! XST_SUCCESS) { /* 处理错误 */ } // 2. 禁用中断初始化时先关闭 XAxiDma_IntrDisable(DmaInstance, XAXIDMA_IRQ_ALL_MASK, XAXIDMA_DEVICE_TO_DMA); // 3. 分配对齐的内存缓冲区DMA要求地址对齐 RxBuffer (u8*)memalign(64, RX_BUFFER_SIZE); // 64字节对齐是常见要求 if (RxBuffer NULL) { /* 处理错误 */ } // 4. 配置DMA S2MM通道的传输 Status XAxiDma_SimpleTransfer(DmaInstance, (UINTPTR)RxBuffer, RX_BUFFER_SIZE, XAXIDMA_DEVICE_TO_DMA); if (Status ! XST_SUCCESS) { /* 处理错误 */ } // 5. 设置并启用中断此处省略中断控制器GIC的详细配置 // ... 配置GIC将DMA的S2MM完成中断连接到处理函数 ... return XST_SUCCESS; }启动后DMA会自动将SPI接收到的数据填入RxBuffer。当缓冲区满或达到预设长度时DMA会产生中断。在中断服务程序中你可以处理这批数据例如进行滤波、存储到文件、或通过网络发送然后重新提交同一个缓冲区或另一个缓冲区给DMA形成“乒乓缓冲”或循环缓冲实现不间断采集。4.3 时序收敛与调试技巧在PL端实现高速SPI如50MHz以上时时序收敛是关键挑战。SCLK作为时钟信号其从FPGA引脚输出到外部设备再返回MISO数据整个路径的延迟必须严格控制。约束是关键必须在XDC约束文件中为SPI相关信号添加正确的时序约束。至少包括create_clock为生成SCLK的模块内部时钟如sys_clk定义周期。set_output_delay约束SCLK、MOSI、CS_N输出到引脚相对于板级时钟的延迟。set_input_delay约束从引脚输入的MISO数据相对于SCLK的建立和保持时间。这个值需要根据你的ADC数据手册和PCB走线延迟来估算。使用IOB寄存器在Vivado综合设置中可以为输出信号如sclk,mosi和输入信号miso勾选“IOB”属性或者在你的代码中使用(* IOB TRUE *)约束。这会让工具将相关的触发器放入IO Block中减少从CLB到IO的路径延迟提高时序性能。逻辑分析仪调试ChipScopeVivado内置逻辑分析仪是你的好朋友。将SPI IP内部的关键信号状态机状态、移位寄存器、数据有效标志以及AXI-Stream接口的tvalid/tready/tdata信号抓出来可以清晰地看到数据流是否畅通状态转移是否正确。跨时钟域处理如果你的SPI IP工作在spi_clk域由内部分频产生而AXI-Stream接口工作在axi_clk域如100MHz那么从SPI接收数据到推入AXI-Stream流是一个跨时钟域操作。必须使用异步FIFO来安全地传递数据否则会出现数据丢失或亚稳态问题。一个真实的坑我曾遇到在20MHz SPI时钟下偶尔会丢失一两个数据位的问题。用ChipScope抓取发现miso信号在SCLK采样边沿附近有轻微的毛刺。原因是PCB上MISO走线过长且靠近一个开关电源受到了噪声干扰。解决方案是在ADC的MISO输出端串联一个22欧姆的小电阻并在FPGA输入端使用施密特触发器Schmitt Trigger输入在约束文件中设置IOSTANDARD为LVCMOS33_HSTL等具有迟滞特性的标准并稍微增加SPI状态机中采样窗口的宽度在SCLK边沿后等待一小段时间再采样。硬件设计和逻辑设计需要协同考虑。5. 进阶话题SPI协议变种与系统优化掌握了基础的单路SPI主从通信后在实际项目中你可能会遇到更复杂的需求。5.1 多从机管理与片选策略ZYNQ PS端的SPI控制器通常支持多个硬件片选SS输出。在PL端自定义IP时你可以轻松扩展出多路片选。关键在于管理好片选信号的时序在切换从设备时必须确保当前传输完全结束CS_N拉高并满足从设备数据手册要求的片选无效时间tCSH才能拉低下一个从设备的片选。最好在状态机中设计一个IDLE_BETWEEN状态来处理这个间隔。对于更复杂的场景比如需要动态寻址大量从设备可以使用“软件片选”只使用一个硬件CS_N线配合MOSI线发送地址/命令字节来选择目标从机。这需要从设备支持这种寻址模式很多数字电位器、IO扩展芯片支持。5.2 支持Quad-SPI (QSPI)和Dual-SPI许多SPI Flash芯片支持QSPI模式使用4根数据线IO0-IO3同时传输数据速率翻四倍。在PL端实现QSPI控制器状态机会更复杂需要处理单线指令阶段和多线数据阶段之间的切换。Xilinx也提供了官方的AXI Quad SPI IP核它同时支持标准SPI、双线和四线模式并且可以直接连接PS是一个非常好的选择避免了重复造轮子。如果你的主要应用是启动配置或存储扩展直接使用这个IP核是最高效的。5.3 低功耗与动态重配置在电池供电设备中SPI总线功耗也需要考虑。当总线空闲时确保将MOSI、SCLK等输出引脚设置为高阻态1‘bz或固定电平避免产生不必要的电流。对于PL端IP可以在IDLE状态将输出使能关闭。ZYNQ的PL部分支持部分重配置Partial Reconfiguration。你可以设计一个“通用通信引擎”通过重配置动态加载不同的比特流使其在不同的时刻作为SPI Master、I2C Controller或UART使用。这对于功能多样但资源受限的设备来说是一种高级的节省资源的方法。但这涉及到复杂的设计流程和工具链使用属于更进阶的主题。从简单的PS端API调用到PL端自定义状态机再到引入DMA构建高速数据流管道ZYNQ-7000为SPI通信提供了从易到难、从通用到定制的完整解决方案。选择哪种方案取决于你的具体需求开发速度、性能、灵活性、功耗和成本。理解每一种方案背后的原理和实现细节能让你在面临设计抉择时游刃有余。最关键的是无论选择哪条路扎实的硬件调试技能示波器、逻辑分析仪和严谨的时序约束都是项目成功的保障。