工控项目做久了你会发现SPI 是那种平时不想它、用时甩不掉的接口。Flash 固件升级、ADC 采样、屏幕刷新、编码器读数……几乎每个项目里都有一两条 SPI 总线在默默干活。这个系列走到第六篇我把 GD32H759 上跑 RT-Thread 的 SPI 实战完整捋一遍从硬件接线、协议细节到设备框架、DMA 传输和现场踩坑一次讲透。这篇文章适合两类人一类是刚把 RT-Thread 跑起来、想搞懂 SPI 该怎么接和怎么用的新手另一类是已经在用 SPI 但被时序、片选、Cache 一致性问题折磨过的开发者。读完你至少能自己驱动一颗 SPI Flash或者把一路 SPI 接到 ADC、编码器、显示屏上遇到问题也知道从哪下手查。1. 工控场景下SPI 为什么是绕不开的一关1.1 SPI 的“物理课”四线、全双工、四种模式SPI 本质上是一根时钟线加两根数据线的同步串行协议。SCK 由主设备产生MOSI 走主机到从机的数据MISO 走从机回主机的数据CS 负责选通具体器件。它的核心优势是全双工时钟每个沿都能同时收发一位所以同样跑 50MHz 时钟实际有效吞吐远高于同频率的 UART 或 I2C。工控里需要连续搬运数据的场合比如 Flash 读固件、AD7606 采多通道波形SPI 都是首选。模式问题必须在项目第一天就定死。SPI 四种模式由 CPOL时钟极性和 CPHA采样相位组合出来模式 0 是空闲时钟为低、第一个沿采样模式 3 是空闲时钟为高、第二个沿采样。Flash、SD 卡、多数 ADC 都支持模式 0 和模式 3但有些传感器只认其中一种配错之后最常见的现象是读回来全是 0xFF 或者偶发错位一个字节。判断方式也很土但很有效拿逻辑分析仪抓一次正常通信的波形对照从机手册里的时序图看采样沿落在哪一抓一个准。1.2 GD32H759 的 SPI 外设到底强在哪GD32H759 是兆易创新 M7 内核系列里的高配型号主频高、SRAM 大SPI 外设也不止一路。对我这种工控玩法来说最大的价值是“外设多到可以各干各的”一路 SPI 专门挂 Flash 存固件和参数另一路 SPI 挂 ADC 采集第三路甚至可以留着跟 FPGA 通信互相不抢总线。这比在单路 SPI 上做分时复用轻松得多也少了很多排查“为什么采集时 Flash 写入失败”的烦恼。这系列 SPI 外设带了 FIFO支持 DMA。FIFO 的意义在于CPU 不需要每次收发一个字节都进中断硬件可以缓存几个字节再统一处理DMA 的意义更大大批量传输时 CPU 只要配置好源地址、目的地址和长度剩下的搬运全交给 DMA 控制器。GD32H759 的 M7 内核跑 RT-Thread 时这两个特性直接决定了系统实时性因为 SPI 传输不再频繁打断 RTOS 调度。1.3 从“能通”到“稳定”中间差了什么很多人第一次调 SPI调通一次读写就觉得完事了其实“能通”和“稳定”差得很远。工控现场有温度变化、电源波动、地线干扰还有别的外设同时抢总线、抢中断。我见过太多案例SPI 在实验室跑得飞快一到现场偶发丢数据最后查出来是片选时序太紧、从机没来得及锁存最后一个字节。所以这篇不只是在讲怎么调通一个 SPI而是讲怎么把它调到可以放心交付的级别涉及到的硬件设计、时序冗余、DMA 和 Cache 处理都是现场经验的沉淀。2. 硬件设计先行引脚、片选与时序的三个关键决策2.1 引脚复用与连线先把 IO 分配看清楚GD32H759 的引脚复用表比想象中复杂同一个 SPI 外设的信号可能出现在好几组引脚上但不同引脚走的 IO 速度等级不一样。我拿到板子第一件事不是写代码而是打开数据手册的 Alternate Function 映射表把要用的一组 SPISCK、MOSI、MISO、CS圈出来确认这些引脚没有跟调试串口、JTAG、以太网冲突。否则代码写好了才发现引脚复用冲突轻则改配置重则飞线非常痛苦。连线时我习惯按“主从对应”来检查主机的 MOSI 接从机的 MOSI主机的 MISO 接从机的 MISOSCK 对 SCKCS 对 CS。这个看似废话但 SPI 设备五花八门有些模块板把 MOSI/MISO 标成 DIN/DOUT有些标成 SDI/SDO飞线时特别容易反。反了之后的现象也很典型发命令有响应发数据全乱因为命令走对了、数据方向反了。所以硬件连完我必然先拿万用表量一遍通断再上电跑一个读 ID 的测试程序。2.2 硬件片选还是软件片选这不是选择题是场景题GD32H759 的 SPI 外设本身支持硬件片选NSS 引脚自动拉低拉高RT-Thread 的软件片选则是通过一个普通 GPIO 手动控制。很多新手默认用硬件片选觉得省事实际在工控场景里我反而推荐软件片选居多。硬件片选的优点是 CPU 不需要管 CS 动作适合单主单从、时序要求极快的场合。但它的缺点也很明显片选时机由硬件决定CS 释放的时序不好微调多个从机挂在同一条总线上时硬件 NSS 做多设备切换比较别扭。软件片选用 GPIO 控制 CS你可以在发送最后一个字节后再多等几十个微秒再释放 CS给从机留够处理时间也可以随时调整拉高拉低的顺序排查问题非常灵活。只要 GPIO 的翻转速度跟得上 SPI 时钟软件片选完全够用我的原则是能用软件片选就用软件片选除非外设手册明确要求极短的 CS 释放延时。2.3 时钟源与波特率预算SPI 时钟不是凭空产生的它挂在 APB 总线上GD32H759 的时钟树里SPI 模块时钟源取决于 APB1/APB2 的分频配置。调 SPI 速率之前先得搞清楚当前 APB 时钟是多少。我见过有人以为配置 max_hz50MHz 就有 50MHz结果 APB 只跑了 60MHz分频之后实际只有 15MHz数据手册上不支持这么低速率反而算错了时序。再提醒一点SPI 总线的实际速率 APB 时钟除以分频系数RT-Thread 的 rt_spi_configure 里 max_hz 只是“期望值”驱动会根据实际分频结果取一个不超过 max_hz 的值。所以别把 max_hz 当精确值用它只是上限预算。计算分频时尽量留 20% 以上余量特别是排线比较长、从机离得远的情况余量不足最常见的表现就是时序余量小偶尔一两个字节错位在实验室很难复现。3. RT-Thread SPI 设备框架从总线到设备的两层模型3.1 先理解“总线 - 设备”模型RT-Thread 把 SPI 抽象成两层SPI 总线SPI Bus和 SPI 设备SPI Device。总线对应物理上的 SPI 控制器比如 GD32H759 的 SPI0、SPI1设备对应挂在这条总线上具体从器件比如 W25Q128、AD7606。应用层操作的是一个 SPI 设备驱动层通过总线完成实际的寄存器访问。这样设计的好处是驱动可以复用换个芯片只要重写底层总线驱动上层的 Flash、传感器驱动不用动。用生活里的话讲总线就像小区的供水总管设备就是你家水表。你只需要跟水表打交道自来水公司负责总管维护。RT-Thread 里你要做的是先把“总管”注册好然后把“水表”具体设备挂上去之后上层代码只用管“打开水表、灌水、放水”不用关心总管怎么走。3.2 核心 API记住这几个就够用了RT-Thread 上层 SPI 接口并不难核心就几个函数。初始化的时候用 rt_spi_bus_attach_device 把设备挂到总线上这个函数会绑定设备名和 CS 引脚。日常传输用 rt_spi_transfer它完成一次双向传输send_buf 和 recv_buf 可以只填一个另一个用 NULL。如果既要发命令又要收数据最好用 rt_spi_send_then_send 或 rt_spi_send_then_recv它能把“先发命令、再收数据”分成两个阶段中间自动重发一次 CS。还有一组消息接口rt_spi_take_bus、rt_spi_take、rt_spi_release_bus、rt_spi_release这组接口把“占用总线、占用设备、释放总线、释放设备”拆开适合多任务并发访问同一个 SPI 设备时做互斥。我的习惯是如果项目里只有一个任务访问 SPI那 rt_spi_transfer 足够一旦有两个线程都要读写同一片 Flash就必须把 take/release 配对用否则两个线程的命令会串在一起后果是 Flash 状态机错乱。3.3 示例挂载一个 SPI Flash 设备假设 GD32H759 的 SPI0 上挂了 W25Q128CS 接 PE4。先要在驱动里把 SPI0 注册成总线然后挂载设备。RT-Thread Studio 生成的 BSP 里一般已经有 spi0 总线驱动你只需要在应用层初始化时做设备挂载和配置。代码大致是这样的#include rtthread.h #include rtdevice.h #include drv_spi.h #include spi_flash.h #define FLASH_CS_PIN GET_PIN(E, 4) /* 软件片选引脚 */ static struct rt_spi_device spi_dev_flash; static int spi_flash_init(void) { struct rt_spi_configuration cfg; /* 挂载设备最后一个参数是用户数据可以传给 CS 控制函数 */ if (rt_spi_bus_attach_device(spi_dev_flash, spi10, spi0, (void *)FLASH_CS_PIN) ! RT_EOK) { rt_kprintf(spi flash attach failed\n); return -RT_ERROR; } cfg.mode RT_SPI_MODE_0; /* W25Q128 支持模式 0 和模式 3 */ cfg.data_width 8; cfg.max_hz 50 * 1000 * 1000; /* 期望 50MHz实际由分频决定 */ cfg.cs_active_high RT_FALSE; /* 片选低有效 */ if (rt_spi_configure(spi_dev_flash, cfg) ! RT_EOK) { rt_kprintf(spi flash configure failed\n); return -RT_ERROR; } rt_kprintf(spi flash attached, cs pin%d\n, FLASH_CS_PIN); return RT_EOK; } INIT_APP_EXPORT(spi_flash_init);这里解释几个容易卡住的点。设备名我起的“spi10”不是乱取而是延续 RT-Thread 习惯总线名 序号避免和系统内其他设备重名。cfg.cs_active_high 配成 RT_FALSE是因为大多数 SPI 从机都是低有效片选这个配置项会直接影响软件片选 GPIO 的初始电平和翻转逻辑配反了会导致设备一直处于选中状态。4. 实战W25Q128 读写与 SPIDMA 性能优化4.1 用读 ID 验证通信链路挂载完成之后不要急着读文件系统先做一个最小验证读 JEDEC ID。W25Q128 的读 ID 命令是 0x9FCS 拉低发送命令字节然后连续读 3 个字节CS 拉高。如果读到 0xEF 开头的厂商 ID链路基本就通了。这一步能帮你把问题快速隔离在“硬件没通”还是“读写代码有问题”两个方向。static rt_err_t spi_flash_read_id(rt_uint8_t *id) { rt_uint8_t cmd 0x9F; rt_uint8_t rx_buf[4] {0}; struct rt_spi_message msg; /* 发送命令并接收 3 字节 ID */ msg.send_buf cmd; msg.recv_buf rx_buf; msg.length 4; msg.cs_take 1; /* 开始前拉低 CS */ msg.cs_release 1; /* 结束后释放 CS */ if (rt_spi_transfer_message(spi_dev_flash, msg) ! 4) { rt_kprintf(spi transfer failed\n); return -RT_ERROR; } id[0] rx_buf[1]; /* 厂商 ID */ id[1] rx_buf[2]; /* 类型 ID */ id[2] rx_buf[3]; /* 容量 ID */ return RT_EOK; }注意这里我用的是一次消息发送一个字节命令加三个字节接收。因为 SPI 是同进同出所以发送命令字节时MISO 上会同时返回一个无效字节后面三个接收字节时MOSI 也必须保持发送一般填 0xFF。收发长度一致是 SPI 的硬规矩少一个字节都不行。实测 W25Q128 正常返回 0xEF 0x40 0x18如果读到 0xFF 0xFF 0xFF先查接线和模式如果读到乱码重点查时钟频率太高或者 CS 时序问题。4.2 擦除、写入与校验一次完整的 Flash 操作Flash 的写入必须先擦后写这是它的物理特性决定的。我的这套流程先发写使能命令 0x06再发扇区擦除 0x20等待忙标志清除然后发页编程 0x02一页最多写 256 字节跨页必须拆分最后读回校验。整套流程在 RT-Thread 里可以封装成一个简单的驱动接口。等待忙标志是个容易忽视的点。Flash 擦除一个扇区可能要几百毫秒写一页也要几毫秒这期间你再发命令是无效的。W25Q128 的忙标志通过读状态寄存器 0x05 获得bit0 为 1 表示忙。我建议等待循环里加一个超时计数避免从机异常时程序死等。实际调试中我就遇到过 Flash 固件损坏导致状态寄存器一直忙如果没有超时保护整个系统会卡死在等待循环里。有了这些基础再配合 RT-Thread 的 SPI Flash 组件/* 使用 SPI Flash 组件自动注册成块设备 */ rt_hw_spi_flash_init(spi10, W25Q128);这条初始化会把 SPI 设备包装成块设备之后可以像操作普通磁盘一样在块设备上创建文件系统。我的项目一般把参数放在一个独立分区用 littlefs 或 elm 文件系统挂载掉电保存、在线升级都靠这一套。文件系统虽然方便但底层必须稳所以我在量产固件里宁可多写几行底层 Flash 驱动也不轻易省掉擦写校验。4.3 打开 DMA 之后M7 的 Cache 一致性必须处理GD32H759 是 Cortex-M7带 D-Cache。SPI 用 DMA 时CPU 和 DMA 操作同一片内存就可能出现一致性问题。简单说DMA 把数据写进内存的时候数据可能还在 Cache 里没写回主存反过来 DMA 从外设读数据到内存CPU 再读的时候可能读的是 Cache 里的旧数据。这问题在 M4 上不常见M7 上稍不注意就是“偶发性脏数据”的来源。我的做法很死板每次 DMA 收发前后显式做 Cache 维护。RT-Thread 的 Cortex-M7 移植里提供了 cache 操作函数也可以直接用 CMSIS 的 SCB_InvalidateDCache_by_Addr、SCB_CleanDCache_by_Addr。发送前 Clean把 Cache 里的数据写回主存接收后 Invalidate让 CPU 重新从主存读取。同时DMA 的缓冲区必须保证内存对齐我用 RT_ALIGN 或者直接定义一个 32 字节对齐的静态数组否则 DMA 配置可能直接报错。static rt_uint8_t tx_buf[256] __attribute__((aligned(32))); static rt_uint8_t rx_buf[256] __attribute__((aligned(32))); void dma_send(rt_uint8_t *data, rt_uint32_t len) { rt_memcpy(tx_buf, data, len); SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); /* 发送前回写 */ rt_spi_transfer(spi_dev_flash, tx_buf, rx_buf, len); SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); /* 接收后失效 */ }开了 DMA 之后数据传输不再占用 CPU这对 RTOS 的实时性帮助很大。比如用 ADC 连续采集时DMA 可以把结果一批批搬到内存CPU 只负责处理数据而不是每采一个点就中断一次。性能上DMA 方案和轮询方案在吞吐量上差距不大但 CPU 占用率下降明显工控系统里这比单纯跑高分更重要。5. 踩坑实录我在现场遇到过的问题5.1 串口打印“风暴”干扰 SPI 时序这个问题特别隐蔽触发原因也很反直觉。我在调 SPI 读 Flash 的时候习惯每读一个扇区就 rt_kprintf 打印一次进度为了看得清楚还在打印后面加延时。结果数据一多串口输出占用了大量 CPU 时间而且 UART 中断优先级配得比 SPI 高打印频繁时不断打断 SPI 的 DMA 完成中断和片选处理。现象是单独读一个小块没问题连续读大文件就偶发丢字节而且丢的位置随机。后来我把打印改成“每完成 1MB 打印一次”同时在采集任务里禁止任何打印问题立刻消失。这件事给我的教训是工控系统里调试打印要慎用尤其是中断频繁、DMA 大批量传输的时候打印风暴比日志本身更可怕。RT-Thread 里有个冷知识rt_kprintf 默认实现是轮询发送串口输出期间 CPU 一直在等,不是“后台慢慢发”所以打印越勤系统卡得越明显。5.2 CS 释放过早最后一个字节被从机吞掉有一次用 SPI 驱动一个 DAC 芯片输出波形偶发出现台阶看起来像丢数据。抓逻辑分析仪波形发现主机已经把最后一个字节发出去了但 CS 在时钟结束后不到 1 微秒内就拉高。DAC 芯片内部需要一点时间把最后一个字节锁存到输出寄存器CS 释放太早它还没锁存完就被打断了。解决方法是把软件片选的释放时间延后。RT-Thread 的消息接口里cs_release1 会在传输完成后马上拉高 CS这时我改为手动控制先发消息时 cs_take1、cs_release0传输完全结束后自己延时几十微秒再手动把 CS 置高。不要小看这几微秒它对从机的锁存时序是实打实的余量。现在我的习惯是所有 SPI 从设备只要不是 Flash 这种皮实器件CS 释放延时一律给 50 微秒以上。5.3 SPI 模式选错现象比你想的更难辨认模式选错不算罕见但它的表现经常被误判成“接线松了”或者“时钟太快”。我自己遇到过一种情况读某个传感器数据大部分是对的但每隔一段就错一个字节。排查了很久最后发现传感器只支持模式 1我配置的是模式 0。模式 0 和模式 1 的区别只在采样沿一个是在第一个沿采样一个是在第二个沿采样对于慢速设备可能最后一个字节恰好落在边沿附近就偶尔出错。碰到这种“偶发错位”的现场我建议直接用逻辑分析仪抓 SCK、MOSI、MISO 三根线把波形和传感器手册的时序图叠着看。如果发现数据线上电平变化和采样沿不对齐优先试另外三种模式多数情况下切换模式就能解决。还有个小技巧很多国产芯片支持模式 0 和模式 3可以先用模式 3 试一下如果两边都声称支持模式 3 的抗干扰性往往稍好因为空闲时时钟为高与大多数默认拉低的电路更兼容。5.4 DMA 缓冲区对齐问题不是每次都能跑起来的开了 DMA 之后最怕的不是速度上不去而是“时好时坏”。有一次我把 DMA 缓冲区定义成普通的局部数组第一次传输正常第二次直接 hard fault。查到最后是局部数组在栈上地址没有按 16 字节对齐GD32H759 的 DMA 配置要求内存地址对齐到传输粒度。RT-Thread 的驱动在配置 DMA 时一般会检查对齐但不同的 BSP 版本检查严格程度不一样赶上不严格的就是偶发故障。我的建议是缓冲区一律用全局静态变量并显式对齐到 32 字节不要相信编译器默认布局。另外M7 的双 Bank Flash 和 Cache 也会放大这种问题所以我在 SPI DMA 相关代码里统一用固定模式全局对齐数组 显式 Cache 维护 传输完成信号量等待。这套模板我复用得很好基本告别了“时好时坏”的问题。5.5 常见问题速查表现象优先排查方向处理建议读 ID 全 FF接线、模式、片选极性量通断试模式 0/3检查 CS 是否拉低偶发错位一字节采样沿不对、时钟过高看时序图改模式降分频大批量传输丢数据打印风暴、DMA 优先级减少打印调整中断优先级数据旧值残留D-Cache 未失效DMA 接收后显式 InvalidateDCache第二次传输 HardFaultDMA 缓冲区对齐改全局静态数组对齐到 32 字节多线程访问数据错乱总线未互斥用 rt_spi_take_bus/release_bus 包裹CS 释放后从机仍错片选释放太快手动延时后再拉高 CS写 Flash 失败忙标志未等完读状态寄存器等待加超时保护6. 几个我长期在用的扩展玩法SPI 不只是拿来读写 Flash。这个系列既然叫工控实战我顺便把几个真正在产线上跑过的 SPI 应用列出来。第一是 SPI 挂多路 ADCGD32H759 的 SPI 速度快配合 DMA 可以连续采集多通道模拟量精度和速率都能兼顾。第二是 SPI 挂编码器比如磁性编码器走 SPI 输出角度比 PWM 输出抗干扰强读回来的数据还是绝对位置省了找零流程。第三是 SPI 接 FPGA 或者 DSP两个处理器之间用 SPI 传控制命令和数据包协议简单可靠比并行总线省引脚。还有一个常被忽略的点上位机调试时可以用 USB 转 SPI 工具把主机逻辑放到 PC 上跑。Python 通过 pyserial 或者 libusb 直接操控这类工具可以模拟 SPI 主设备发命令、收数据非常适合做从机固件的自动化测试。我写过一个 Python 脚本自动给待测板子发送一组配置指令再读回状态寄存器比手工敲命令效率高一个数量级。这个思路在产线测试和现场排查时都很实用算是 SPI 调试的一个“降维打击”手段。根据我个人的经验SPI 这个外设“下限低、上限高”随便接根线就能通但要做到长时间、大批量、现场不出错其实考验的是对时序、DMA、Cache、中断优先级这些细节的把握。这篇里写的每一条坑都是我在真实项目里熬过夜才总结出来的希望你用的时候能少走一段弯路。如果调试过程中遇到了我上面没提到的问题建议优先抓波形、看寄存器、查从机手册把问题一步步拆小SPI 的故障没有解不开的。