ZedBoard SPI轮询与中断实战:原理、代码与CPU占用对比

发布时间:2026/10/5 11:34:54
ZedBoard SPI轮询与中断实战:原理、代码与CPU占用对比 ZedBoard系列写到第五篇这次聊SPI。项目标题是“zedboard(5)spi轮询和中断”起因是前几篇把GPIO、UART、中断控制器都折腾完之后我发现不少朋友卡在同一个地方SPI调通不难难的是搞清楚轮询和中断这两种收发模式到底差在哪、什么时候用哪个、用了之后CPU分别要被吃掉多少。这篇文章我会先从SPI资源盘点和模式选型讲起再分别给出轮询和中断的完整实现思路、关键代码和实测对比最后把调试中遇到的坑整理成速查表。ZedBoard的Zynq-7000是个很好的实验平台因为你在裸机、Linux、PL逻辑三个层面都能接触SPI一套板子能把SPI从头到脚看透。无论你是刚接触Zynq的嵌入式新手还是已经在Linux下写过驱动但没细想过中断链路的开发者这篇都能给你一点参考。1. 为什么ZedBoard上要把SPI单独拎出来讲1.1 先盘一下手里的SPI资源Zynq-7000的PS端集成了两个SPI控制器分别叫SPI0和SPI1。这两个控制器挂在APB总线上由ARM Cortex-A9直接访问不需要在PL里去例化额外的IP核。也就是说你在Linux里看到的/dev/spidev0.0、/dev/spidev1.0以及裸机SDK里的XSpi驱动都是直接操作PS端的硬件外设跟FPGA逻辑本身没有关系。这点和PL里用AXI Quad SPI IP核是完全不同的两条路。PS端SPI控制器支持主机和从机两种角色支持Motorola SPI协议的四线接口SCLK、MOSI、MISO、片选CS。时钟极性和相位可以配置也就是常见的CPOL和CPHA组合对应SPI Mode 0到Mode 3。还有一点容易被忽略Zynq的SPI控制器带FIFO缓冲区深度虽然不像专用芯片那么夸张但足够应付大多数传感器和存储类芯片的读写。配合中断控制器和DMA控制器理论上可以做到数据在后台搬运CPU几乎不参与。我在实验里通常把SPI0接在扩展口或者Pmod上方便拿逻辑分析仪去抓波形。ZedBoard板卡自身没有把SPI固定接到某个板上外设上所以你要看原理图确认引脚分配。别小看这一步很多人的SPI调不出来不是代码问题是引脚压根接错了。1.2 轮询、中断、DMA三兄弟怎么选SPI收发数据软件层面通常有三种驱动方式轮询、中断和DMA。轮询是CPU不停读状态寄存器看发送FIFO空了没、接收FIFO有数据没有就处理没有就继续死等。中断是SPI控制器在发送完成、接收数据到达时拉高中断信号CPU暂停当前任务跳去处理。DMA则是SPI控制器直接把数据搬运到内存或者从内存搬出去完全绕过CPU。用生活化的方式理解轮询就像你去快递站等快递快递没到你就在那站着干等中断是你留了电话给快递员到了他打给你你再去拿DMA是快递到了直接有人帮你签收并放进仓库你啥时候需要啥时候去取。三种方式对CPU的占用率是依次下降的但代码复杂度和链路长度是依次上升的。选型的核心就两个问题数据量大不大CPU还有没有别的事要干。如果每次只发一两个字节比如配置一个传感器寄存器轮询最快最省事。如果数据量大比如持续刷一块SPI屏幕或者通过SPI读取FIFO数据流再用轮询就是在糟蹋CPU资源。这时候中断和DMA才是正路。但要注意不是所有情况下中断都比轮询快因为中断要保存现场、跳转、恢复现场这套开销在数据量极小的时候反而比轮询还慢。把这个问题想清楚了你才算真正理解SPI收发模式。2. 动手之前把硬件工程和设备树理顺2.1 Vivado里的Block Design最小配置不管是跑裸机还是跑Linux第一步都是先把PS端的SPI控制器使能出来。新建Vivado工程添加ZYNQ7 Processing System这个IP然后在配置界面里的Peripheral I/O Pins选项卡中把SPI0勾上选定MIO引脚。这里有个常见困惑SPI0和SPI1占用的MIO引脚是固定的MIO 10到MIO 15或者MIO 30到MIO 35这一组具体哪个控制器对应哪组MIO在Vivado图形界面里点开就能看到。如果你要用Linux还要确保UART1和SD0也勾选上否则系统起不来或者没法挂载文件系统。配置完成后Generate Output Products、生成比特流、Export Hardware带bitstream导出到SDK或者Vitis。到此硬件工程部分就结束了。整个过程花不了十分钟但有一点要提醒ZedBoard出厂默认跳线在某些版本上会影响MIO的电平标准如果你发现SPI波形幅度不对优先查跳线和原理图。我自己的习惯是在Block Design里把SPI0的时钟频率先不要拉太高默认值或者稍微降一点都可以。第一次调试SPI时钟越快越容易出问题。等波形确认没问题了再回头去提频。2.2 设备树里的SPI节点跑Linux的话硬件导出之后还要确保设备树里有SPI节点而且状态是okay。很多板卡默认设备树把用不到的外设都写成disabledSPI往往在列。打开设备树源码找到spi节点至少要有这样一段spi0 { status okay; compatible xlnx,zynq-spi-1.0; num-cs 1; spidev0 { compatible rohm,dh2228fv; reg 0; spi-max-frequency 1000000; }; };这个compatible rohm,dh2228fv看起来很奇怪但它是内核源码里spidev驱动留下的占位符。意思是让这个SPI子节点挂到spidev驱动下面然后用户空间才能看到/dev/spidev0.0。产品开发里不应该这么干因为正规做法是给从机设备写一个专属驱动这里只是实验方便。spi-max-frequency我建议第一次调试设成1MHz也就是1000000。原因很简单SPI时序问题排查的时候先排除时钟太快导致的建立时间不够。你设成20MHz示波器抓出来波形可能都是圆角反而让人误判成硬件问题。先1MHz跑通再逐步提频这是最稳的路子。2.3 裸机和Linux两条路先想清楚ZedBoard上搞SPI你至少有三条路可以走裸机SDK、Linux用户空间spidev、Linux内核模块。这篇博客先把前两种讲透因为它们最适合用来理解轮询和中断的区别。裸机SDK用Xilinx官方提供的XSpi驱动所有寄存器操作都被封装好了你能直接看到中断回调是怎么注册的、怎么被调用的非常适合理解原理。Linux用户空间spidev则适合快速验证连接和时序ioctl接口非常简洁三行代码就能完成一次收发。但spidev隐藏了底层细节你在用户空间看不到中断服务函数因为中断被内核驱动消化掉了。这个区别不搞清楚后面讲“SPI中断”的时候容易迷糊。我的建议是如果你只有一个下午的时间先在Linux下用spidev把轮询跑通再回裸机里把中断跑通。两条路交叉对比你对SPI的理解会非常立体。3. 轮询模式代码最短代价是CPU干等3.1 用户空间spidev轮询的标准写法Linux用户空间操作SPI本质上就是打开设备节点然后通过ioctl发送SPI_IOC_MESSAGE。这是一段非常经典的轮询代码支持全双工收发#include stdio.h #include fcntl.h #include stdint.h #include unistd.h #include sys/ioctl.h #include linux/spi/spidev.h int main(void) { int fd open(/dev/spidev0.0, O_RDWR); if (fd 0) { perror(open); return 1; } uint8_t mode SPI_MODE_0; uint8_t bits 8; uint32_t speed 1000000; ioctl(fd, SPI_IOC_WR_MODE, mode); ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, bits); ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, speed); uint8_t tx[] {0x9F, 0x00, 0x00, 0x00}; // 读JEDEC ID的命令 uint8_t rx[4] {0}; struct spi_ioc_transfer tr; tr.tx_buf (unsigned long)tx; tr.rx_buf (unsigned long)rx; tr.len sizeof(tx); tr.speed_hz speed; tr.delay_usecs 0; tr.bits_per_word 8; tr.cs_change 0; int ret ioctl(fd, SPI_IOC_MESSAGE(1), tr); if (ret 0) { perror(SPI_IOC_MESSAGE); return 1; } printf(ID: %02X %02X %02X %02X\n, rx[0], rx[1], rx[2], rx[3]); close(fd); return 0; }这段代码看着简单但有一个特别容易忽略的核心点SPI是全双工的。你在MOSI上发送一个字节的同时从机也会在MISO上回一个字节。所以哪怕你只是想给从机发一条命令硬件层面依然会有一进一出两个方向的数据流。如果你没有提供rx缓冲区或者提供了但不处理读回来的垃圾数据就会一直堆积在FIFO里影响下一次传输。还有一点SPI_IOC_MESSAGE这个ioctl在底层是同步执行的内核驱动会阻塞直到本次SPI传输完成。从用户程序的角度看这个过程就是死等——发完一个字节的数据必须等SPI控制器把最后一个位移出去才返回。这就是最纯粹的轮询。3.2 轮询到底烧掉多少CPU时间很多人觉得轮询无所谓反正SPI快。咱们来算一笔账。假设SPI时钟是1MHz传输一个字节需要8个SCLK周期也就是8微秒。如果传输1024字节算上传输间隙和片选切换大约需要8到10毫秒。在Linux系统里一个用户进程连续占用CPU 10毫秒对实时性要求严格的应用来说已经是灾难。我实测过一组数据在ZedBoard上以1MHz的SPI时钟连续读取1KB数据使用spidev轮询方式ioctl的耗时大约在9毫秒左右期间CPU该进程占用率接近百分之百。如果换成10MHz的SPI时钟耗时确实能降到1毫秒以内但代价是信号完整性问题开始出现波形质量变差对从机的要求也变高。所以轮询模式不是不能用而是你要清楚它的CPU代价。回到选型逻辑上如果这个SPI设备只在系统初始化时读写几次比如读EEPROM里的配置参数轮询完全够用不要为了用中断而用中断。如果这个SPI设备是高频采集的传感器或者是要不停刷新的大屏轮询就是在跟其他任务抢CPU这时候上中断或者DMA的好处就体现出来了。3.3 轮询最容易翻车的三个细节第一个细节是片选时序。CS拉低之后到第一个SCLK上升沿之间需要有一个建立时间。很多从机的数据手册会写这个时间一般是几十纳秒到几百纳秒。如果你用的是硬件自动片选这个时间由SPI控制器保证如果自己用GPIO模拟片选就很容易忽略导致从机采样时序错误读回来全是垃圾数据。第二个细节是时钟极性和相位。SPI_MODE_0是CPOL0、CPHA0意思是SCLK空闲时为低电平数据在第一个边沿采样。这是最常用的模式但你的从机不一定在这个模式下工作。如果从机要求SPI_MODE_3而你在用户空间设成了SPI_MODE_0表现往往很诡异读出来的ID不对、数据偶尔错位、有时候还全是0xFF。排查方法很简单把四种模式全试一遍看哪组参数能让数据稳定正确。第三个细节是收发对齐。SPI协议本身没有应答机制主机的每个SCLK时钟同时驱动MOSI发送和MISO接收。问题在于很多从机接收到命令后需要一定的准备时间才能把数据放到MISO上。最常见的做法就是发命令之后连续发几个哑字节用空时钟把数据顶出来。读JEDEC ID就是一个典型例子先发0x9F命令然后再发三个0x00字节期间从机才把厂商ID和设备ID依次送出来。这块如果没理解透你会发现读回来的数据总是比预期晚一拍甚至错位一整段。4. 中断模式把“死等”变成“等通知”4.1 先分清两种“中断”聊SPI中断之前必须先把概念理清楚否则你会在Linux和裸机之间被绕晕。第一种是裸机下的SPI控制器中断。SPI控制器在发送数据完成、接收FIFO有数据等条件下会触发一个中断信号连接在GIC中断控制器上CPU收到中断后跳转到中断服务函数在服务函数里处理数据。这是最底层的、真正意义上的SPI中断。第二种是Linux下的SPI中断。严格来说Linux内核里的SPI控制器驱动已经把中断注册并且处理掉了。你在用户空间用spidev时发一个ioctl下去看起来是同步阻塞的但底层内核驱动的传输路径里已经经过了中断处理。你只是看不到而已。所以如果你在用户空间里到处找“SPI中断API”方向就错了。还有一种很容易混淆的场景SPI从设备主动通知主机。不少SPI设备会单独引出一个INT引脚用来告诉主机“我有数据了赶紧来读”。这个中断严格来说是GPIO中断不是SPI控制器中断。它触发之后主机的处理流程是先响应GPIO中断然后在中断上下文或者下半部里发起SPI读取。这个组合才是Linux下“中断SPI”最常见的玩法。所以你一听到“SPI中断”先搞清楚说的是控制器中断还是从设备的GPIO触发中断。4.2 裸机SDK下注册SPI中断裸机环境是对理解中断最友好的地方因为整个链路都摊在你面前。用Xilinx的XSpi驱动和XScuGic中断控制器驱动核心步骤就这几步#include xspi.h #include xscugic.h #include xparameters.h static XSpi Spi; static XScuGic Intc; static u8 TxBuf[32]; static u8 RxBuf[32]; static volatile int TransferDone 0; void SpiIntrHandler(void *CallBackRef) { XSpi *SpiPtr (XSpi *)CallBackRef; u32 IntrStatus XSpi_IntrGetStatus(SpiPtr); XSpi_IntrClear(SpiPtr, IntrStatus); TransferDone 1; } int SpiSetup(void) { XSpi_Config *SpiCfg XSpi_LookupConfig(XPAR_XSPI0_DEVICE_ID); if (SpiCfg NULL) return -1; XSpi_CfgInitialize(Spi, SpiCfg, SpiCfg-BaseAddress); XSpi_SetOptions(Spi, XSP_MASTER_OPTION | XSP_MANUAL_SS_OPTION | XSP_MANUAL_START_OPTION); XSpi_Start(Spi); XSpi_IntrGlobalEnable(Spi); XSpi_IntrEnable(Spi, XSP_INTR_TX_EMPTY | XSP_INTR_RX_FULL); XSpi_SetStatusHandler(Spi, Spi, SpiIntrHandler); return 0; }然后是中断控制器的初始化把SPI0的中断ID连接到同一个处理函数上#include xscugic.h static XScuGic Intc; int IntcSetup(void) { XScuGic_Config *IntcCfg XScuGic_LookupConfig(XPAR_SCUGIC_0_DEVICE_ID); if (IntcCfg NULL) return -1; XScuGic_CfgInitialize(Intc, IntcCfg, IntcCfg-CpuBaseAddress); Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_IRQ_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, Intc); Xil_ExceptionEnable(); XScuGic_Connect(Intc, XPAR_XSPI0_INTR_ID, (Xil_ExceptionHandler)SpiIntrHandler, Spi); XScuGic_Enable(Intc, XPAR_XSPI0_INTR_ID); return 0; }这段代码里有几个地方必须注意。第一中断ID不能想当然不同版本的SDK里XPAR_XSPI0_INTR_ID对应的宏名可能略有差异编译报错就去头文件里搜一下。第二中断服务函数里只做置标志位这类轻量操作真正的数据解析放到主循环里做。如果你在中断服务函数里做复杂处理中断响应时间会被拉长严重时还会丢失后续中断。第三不要忘记调用XScuGic_Connect之前要先确保异常处理已经注册好否则中断来了内核根本不知道去哪里执行。初始化完成之后主程序里只需要发起一次传输然后该干嘛干嘛去。传输完成后SPI控制器会触发中断中断服务函数里把TransferDone置1主循环检测到这个标志就知道数据就绪了。这个模型才是中断模式的核心价值CPU不用盯着SPI而是被SPI喊一声再回来干活。4.3 Linux下真正合理的“中断SPI”组合Linux用户空间没有办法直接注册SPI控制器的中断如果你想把“SPI收到数据后立刻处理”这套逻辑跑起来通常有两种做法。第一种做法是用内核模块。在设备树里给SPI从机节点配置interrupts属性通常指向从机的INT引脚对应的GPIO中断。然后在内核模块里用request_irq注册中断处理函数中断到来时用工作队列或者tasklet发起SPI传输。这套链路是目前Linux下SPI设备驱动的标准写法但涉及内核态开发门槛比用户空间高不少。第二种做法是在用户空间用select、poll或者epoll等待spidev设备可读。伪代码大概是这样的struct pollfd fds[1]; fds[0].fd fd; fds[0].events POLLIN; int ret poll(fds, 1, 1000); if (ret 0) { /* 设备可读执行一次SPI传输 */ ioctl(fd, SPI_IOC_MESSAGE(1), tr); }这里要特别说明poll式的等待依赖内核驱动把SPI控制器当成一个可以产生可读事件的“文件”来管理它的底层其实依然是中断驱动的传输完成机制。但在用户空间你看到的表现是“我可以不等在ioctl上而是去等一个事件”。如果你的需求只是不想让线程一直被阻塞在SPI传输上这种写法完全够用。如果你需要的是真正的硬实时中断响应那就必须走内核模块甚至裸机。我的建议是ZedBoard上做实验先不要一头扎进内核模块。先用裸机把中断原理理解透然后再回到Linux里看设备树里那些interrupts属性是怎么回事你就豁然开朗了。4.4 轮询和中断实测对比我在ZedBoard上做过一组简单对比实验SPI时钟1MHz传输1KB数据分别用轮询和中断两种方式观察CPU占用和处理延迟。结果是轮询方式下整个传输过程CPU占用接近100%进程被完全占住中断方式下主循环大部分时间在空闲状态只有传输完成时进入中断做很短暂的标志位更新CPU占用显著下降。但中断并不是完全没有代价。每次中断都涉及压栈、跳转、弹栈这些开销在传输数据量很小的时候可能比轮询本身还大。比如只传4个字节中断进出的开销可能超过传输本身的时间这时候轮询反而是更好的选择。所以不要一听“中断更高级”就什么场景都上中断数据量小、频率低的应用轮询是最可靠简洁的。中断模式真正的优势场景是SPI设备会不定时地产生事件主机无法预测什么时候有数据只能用中断把CPU从循环等待里解放出来。把CPU让出去让它在其他任务上干活等事件来了再被喊回来这才是中断的初心。5. 调试心得与常见问题排查实录5.1 先别急着写代码先把时序抓出来我在调试SPI的时候最大的心得只有一句波形为王。不要急着分析寄存器先把逻辑分析仪接上看一眼SCLK、MOSI、MISO、CS四条线到底什么样。逻辑分析仪不需要买很贵的几十块、支持8通道、采样率25MHz以上的就够用。ZedBoard的SPI引脚是3.3V电平普通逻辑分析仪可以直连。我抓到最多的三种异常波形是CS时序不对、SCLK空闲电平反了、MOSI数据位序反了。这三种问题用眼睛看波形三秒钟就能发现用寄存器反推可能要折腾半小时。抓波形的具体方法是先让SPI发送一长串已知的0x55交替序列。0x55的二进制是01010101波形上会呈现出均匀的方波任何一位反了或者时序不对都一目了然。确认基础波形没问题之后再去接真实的从机设备否则一旦出错你压根不知道是SPI控制器的锅还是从机的锅。5.2 硬件片选与软件片选怎么选SPI片选有两种方式硬件自动片选和软件GPIO片选。Zynq的SPI控制器支持自动片选你在配置里可以让控制器在传输开始时自动拉低CS传输结束时自动拉高。优点是省GPIO代码简单。缺点是灵活性不够比如某些从机要求在连续两次传输之间CS必须有一个明确的拉高脉冲而硬件自动片选的行为不一定符合要求。软件片选是自己拿一个GPIO引脚当CS传输之前手动拉低传输结束后手动拉高。C代码里的顺序是先拉低CS再发SPI传输完成后拉高CS。这套流程看起来多写了代码但对时序的控制非常精确。在Linux spidev里spi_ioc_transfer结构体中的cs_change字段就控制着连续传输之间是否拉高再拉低CS。实际调Flash芯片的时候我经常要用cs_change来搭建特定的命令时序。比如读数据时我想让片选在整个读取过程中保持低电平就把连续几段transfer放在一次SPI_IOC_MESSAGE(N)调用里并把cs_change置0某些器件要求命令和地址发完后CS先拉高再拉低进入读状态那我就把一次大传输拆成两段中间的cs_change置1。还有一点提醒如果你改用GPIO软件片选必须确认这个GPIO和SPI的MIO不冲突而且GPIO初始化要在SPI传输之前完成。5.3 常见问题速查表把我在ZedBoard上调试SPI时遇到过的典型问题整理成了一张表方便你直接对照排查现象可能原因排查方向读回来的数据全是0xFF从机没响应或MISO没接对先查波形确认MISO有电平变化检查从机供电和复位读回来的数据全是0x00MOSI或者时钟极性可能不对换SPI Mode试确认SCLK空闲电平和采样边沿数据错位整体往后偏移命令后少发了哑字节查从机数据手册确认命令后需要多少个空时钟偶尔成功偶尔失败CS时序不稳定或者SPI时钟太快降低spi-max-frequency检查CS建立时间中断一直不触发中断ID配错或者GIC没使能确认XPAR宏定义检查XScuGic_Enable是否执行成功传输完成但回调没执行XSpi_SetStatusHandler没注册或中断标志没清检查中断使能寄存器回调里先清状态后置标志这几个问题里读回全0xFF是新手最容易遇到的因为它的表象特别像“硬件没连好”但很多时候其实是从机在等待主机片选拉低。尤其是手动片选的场景如果GPIO初始化和SPI初始化顺序不对CS引脚在被占用之前可能处于不确定状态从机根本没被选上。5.4 TF卡上拉和ESP8266这类相关问题的延伸调试过程中我还顺手验证了几个网友经常问的问题。比如TF卡在SPI模式下MISO、SCLK、CS这几根线要不要上拉。实测下来是需要的至少10kΩ上拉到3.3V不然读取会不稳定尤其卡刚开始上电的时候经常出现初始化失败。原理是SPI模式下TF卡部分引脚是开漏输出没有上拉就没法保证高电平可靠。还有人问ESP8266能不能直接连SPI接口芯片。答案是可以但要注意电平匹配ESP8266是3.3V系统SPI时钟最高能跑到几十MHz连接常见的SPI Flash或者传感器都行。不过ESP8266的硬件SPI在非官方SDK里用起来比较别扭很多人最后都选择用GPIO模拟SPI几个GPIO加上延时函数就能驱动很多低速SPI从机。模拟SPI虽然慢但可移植性强逻辑还简单调试的时候反而更容易定位问题。另外“SPI需要两个DMA吗”这个问题也经常被问到。SPI全双工的特性决定了一收一发各走一个方向所以在DMA路径上通常是TX一个DMA通道、RX一个DMA通道。但Zynq的SPI DMA路径比UART复杂不是所有使用场景都必须配齐两个关键看从机是否需要同时收发。这个等你真正用到DMA模式的时候去看TRM里DMA request映射表会更清楚。5.5 这次实验做完之后还能怎么扩展如果你成功在ZedBoard上把SPI轮询和中断都跑通了接下来我建议你做三件事。第一把SPI从机角色也试一遍让ZedBoard当从机另接一个主机来读它这样你对SPI通信的“谁提供时钟谁就是主”这句话会有切身体会。第二把PL端的AXI Quad SPI也调一遍对比一下PS端SPI和PL端SPI在使用方式上的差异很多用Zynq做采集系统的朋友最终会把SPI放到PL端去就是为了更高带宽和更低延迟。第三给SPI加上DMA重新测一遍CPU占用率你会发现数据量越大DMA的优势越明显。我个人在实际操作中的体会是轮询和中断不是“谁更高级”的关系而是“该用谁的时候用谁”。数据量小到几个字节轮询就是最简单可靠的方案不要为了炫技去搞一套复杂的中断链路。数据量上来或者对时延有要求中断和DMA带来的价值是肉眼可见的。ZedBoard的好处是你可以从裸机看到Linux、从Linux再看到PL一套板子把SPI吃透后面不管换什么芯片、什么平台心里都有底。