写这篇的时候我手头的 GD32H759 板子已经跑了两个月RT-Thread 相关的时钟、串口、GPIO 也分别写过笔记。这次轮到 SPI正好把我从踩坑到稳定运行的完整链路整理出来。SPI 在工控板上的地位不用多讲外部 Flash 存参数、ADC 采模拟量、磁编码器读角度、FPGA/DSP 之间做轻量互联十块板子里有八块绕不开它。这篇文章既是我自己的项目记录也希望能帮到正在用 GD32H759 或者 GD32H7 系列配 RT-Thread 做工业控制的朋友无论你是刚接触 RT-Thread 的 SPI 框架还是已经调过几个 SPI 外设想再隔离一下问题都可以顺着下面的思路过一遍。SPI 本身并不难难在面对具体外设时到底该选哪种模式、哪个片选策略、哪条传输路径。GD32H759 这颗 M7 主频已经拉到 600MHzCPU 不是瓶颈反而周边总线和缓存一致性问题更容易成为拦路虎。我们先把这些容易翻车的点逐个说清楚。1. 方案选型SPI 在工控应用中的三个关键选择1.1 为什么工控板上的高实时外设都爱走 SPISPI 是一种同步串行总线四根线大体分工SCK 负责时钟MOSI 是主出从入MISO 是主入从出CS 负责片选。它支持全双工速率可以拉得很高几乎成了 Flash、ADC、编码器、显示控制器的默认选择。I2C 虽然线少但速度一般地址仲裁也麻烦UART 通用但异步通信两边要约定的参数更多。在工控场景里如果两个主控之间要周期交换一帧数据比如 DSP 算完的控制量回传SPI 也是极好的通道直接由片选选中设备语义清晰实时性可控。这也是为什么 FPGA 卡、SPI ADC、磁编码器这些硬实时外设基本首选 SPI。一句话速率高、时序简单、全双工很多必须在一帧之内完成交换的场景SPI 比 I2C 舒服得多。1.2 硬件 SPI 还是软件模拟别只凭好感决定很多人一开始图省事想拿 GPIO 模拟 SPI。模拟的好处是引脚任意、不受复用表限制比如芯片某个 SPI 口恰好被别的高速外设占了派两个 GPIO 出来模拟一条低速链路完全可行。坏处是极性和相位都要自己掐实时性还受任务调度影响。RT-Thread 下如果 SPI 传输放在普通线程里软件模拟会被更高优先级线程打断导致时序抖动。对 W25Q64 这种 Flash读 ID 慢一点没事但如果挂在电机控制回路上读编码器时序抖动就意味着控制周期晃动。我的经验是默认全部走硬件 SPI只有三类情况才考虑软件模拟速率要求很低、引脚确实不够用、或者只是调试期临时验证。量产板子上尽量老老实实把硬件 SPI 的复用引脚规划好。1.3 片选策略早点定硬件片选与软件片选怎么配片选分硬件片选和软件片选。硬件片选由 SPI 外设自己在传输前拉低 CS、结束后拉高CPU 不用管优点是省心、时序稳定适合高频读写缺点也典型有些外设要求 CS 在整个多字节传输期间保持低电平如果中途被其它设备抢占或者 DMA 拆分传输CS 一抖数据就作废。软件片选是拿一个普通 GPIO 手动控制传输前拉低、传输后拉高灵活得多多设备共享总线时也方便做互斥。但软件片选要自己保证时序尤其 SCK 起来之前 CS 必须稳定。我建议量产优先用硬件片选但必须对着外设数据手册检查 CS 时序要求调试期反倒推荐软件片选改引脚、试不同外设都方便等代码稳定了再把 CS 移交给外设。片选策略最好在原理图阶段就定下来板子回来再改引脚往往要飞线。2. 基础储备GD32H759 的 SPI 外设与 RT-Thread 驱动框架2.1 GD32H759 的 SPI 外设资源与引脚复用细节GD32H759 作为 GD32H7 的旗舰型号外设铺得非常足SPI 也不是一个两个关键是要把引脚复用表搞清楚。GD32 的 SPI 复用通常集中在某组 GPIO 的 AF 配置上比如 MOSI、MISO、SCK、CS 各自对应特定的 AF 编号。这块不查手册直接写代码基本必翻车我最开始就遇到过代码配置完成、示波器量 SCK 一点反应都没有最后发现是 AF 编号填错时钟没进 SPI 模块。GD32 的库函数思路和 STM32 HAL 不完全一样外设时钟、GPIO 复用、SPI 初始化结构体是分开配的缺一个都起不来。另外要注意 SPI 引脚的 I/O 速度等级高速 SPI 如果引脚配置成低速波形上升沿会变得非常肉超过额定时钟就出错。我一般会把 SCK、MOSI 的 GPIO 速度配上最高档既保证信号质量又避免误码。2.2 RT-Thread SPI 设备框架到底帮你做了什么RT-Thread 把 SPI 抽象成两层底层是 SPI 总线设备比如 spi0、spi1负责收发上层是挂在总线上的具体设备比如 spi00、spi01每个绑定一条片选线和一组参数。上层代码只跟设备打交道不关心片选是谁在拉也不关心底层是 GD32 还是 STM32。所以当你把一块 Flash 驱动从 STM32 移植到 GD32H759 上时驱动源码几乎不用改只要把设备名和配置换成对应的 SPI 外设就行。这一层抽象在项目中非常值钱。API 上配置参数通过 rt_spi_configure 设置传输数据用 rt_spi_transfer 或 rt_spi_send_then_recv 这类函数完成。如果一次交互需要连续发多段消息比如先命令、再地址、再数据用 rt_spi_transfer_message 把多个 rt_spi_message 串成链一次调用完成中间片选不倒换。2.3 模式、速率、数据宽度三个必填参数的坑SPI 配置三件套模式、数据宽度、最大波特率RT-Thread 里用一个 rt_spi_configuration 结构体装。模式由 CPOL 和 CPHA 组合出 0 到 3 四种CPOL 决定空闲时 SCK 是高还是低CPHA 决定数据在哪个沿采样。这里最大的坑是数据手册模式和 RT-Thread 模式宏对不上。有些外设手册直接写 Mode 0/1/2/3RT-Thread 也有 RT_SPI_MODE_0 到 3但偶尔代码里写错一个字母组合一错波形满盘皆错。数据宽度一般 8 位够用少数传感器要 16 位注意一旦配成 16 位发送长度就是字节数的一半。波特率由 max_hz 指定RT-Thread 驱动会按分频关系找最接近但不超额的频率。这里不要一味贪高结合线路长度和外设上限留余量我通常取外设额定时钟的一半往下起调稳定后再往上提。3. 从底层到业务SPI 设备挂载与 W25Q64 读写全套流程3.1 第一步在 menuconfig 里把 SPI 驱动打开在 RT-Thread BSP 目录执行 scons --menuconfig打开设备驱动下的 SPI 配置。路径一般是 RT-Thread Components - Device Drivers - SPI把使用 SPI 总线设备选中。如果你的 BSP 已经带了 drv_spi.c还应该在板级配置里把对应 SPI 外设使能比如 SPI0 外设的宏。构建时留意有没有真正编进 drv_spi.c如果没有整个 BSP 都不带 SPI 总线注册上层代码全部白搭。检查方法很简单启动后 msh 里运行 list_device能看到 spi0 这类设备节点说明驱动已经注册到内核。这一步是最基础的但很多人板子拿到手第一件事就是写应用根本没确认驱动是否开启最后浪费一整天查一个其实很简单的点。3.2 把 W25Q64 挂上总线attach 才是关键动作设备挂在总线上不是自动的需要调用 rt_spi_bus_attach_device。第三个参数是总线名第二个是你希望给从设备起的名字比如 spi00第四个是片选引脚。这里有个容易误会的点很多示例里设备名直接叫 w25q64是因为做了进一步封装。如果只是想操作 SPI Flash我的做法是先把底层 SPI 设备挂成 spi00然后拿 SFUD 软件包自动识别 Flash 型号SFUD 内部会再注册一个块设备读 ID、擦写都从 SFUD 走。attach 之后还要用 rt_spi_configure 把三件套填上。W25Q64 容量是 64Mbit也就是 8MB常见版本时钟支持很高我保守起见在 50MHz 上下运行读 ID 验证通过后再超频一点。注意设备 attach 之后片选引脚就交给 SPI 总线框架管理了。业务代码里千万不要再去手动拉 CS否则框架的锁和你的手工时序会互相打架表现就是偶发性数据错乱。3.3 Flash 读写全流程读 ID、擦除、写回读 ID 的指令是 0x9F发完命令后连续读 3 字节对应厂家、型号、容量。直接贴一个最小函数#include rtthread.h #include rtdevice.h static struct rt_spi_device *flash_dev; static int flash_read_id(void) { uint8_t cmd 0x9F; uint8_t id[3] {0}; if (!flash_dev) flash_dev (struct rt_spi_device *)rt_device_find(spi00); if (!flash_dev) return -RT_ERROR; rt_spi_send_then_recv(flash_dev, cmd, 1, id, 3); rt_kprintf(flash id: %02X %02X %02X\n, id[0], id[1], id[2]); return id[0] 0xEF ? 0 : -RT_ERROR; } MSH_CMD_EXPORT(flash_read_id, read w25q64 id);读 ID 通了就说明模式、时钟、片选链路都对。接下来是标准操作序列发 0x06 写使能读状态寄存器 0x05 等待 BUSY 位变回 0发 0x20 扇区擦除按 4KB 对齐的起始地址再等忙最后发 0x02 页编程。页编程一页最多 256 字节跨页要自己切地址。几行代码就能把读回全 FF 验证出来。如果使用 SFUD这些细节会被隐藏但底层原理还是这套建议至少手工走一遍再依赖软件包不然出了问题根本不知道是 Flash 状态机的问题还是 SPI 模式的问题。3.4 上 DMA 提速之前先想清楚 Cache 怎么办在 M7 这颗芯上SPI 跑 DMA 是家常便饭但 DMA 和 D-Cache 的一致性会直接绊倒很多人。SPI 外设通过 DMA 读到内存时数据是 DMA 直接写进 RAM 的CPU 的 D-Cache 里可能还存着旧值反过来 CPU 写好发送缓冲DMA 可能读到 Cache 里没写回内存的内容。典型现象同一个 buffer 第一次传输正常第二次以后收到上一帧旧数据。解法是传输前对发送缓冲做 cache clean传输后对接收缓冲做 cache invalidate。RT-Thread GD32 BSP 的 drv_spi.c 如果开了 DMA通常会在框架里处理这个动作但前提是你在构建时把对应的 DMA 和 Cache 接口开了。自己写 buffer 时尽量用 Cache Line 对齐的静态数组比如__attribute__((aligned(32)))。这里我再强调一遍M7 和 M4 不一样M4 没有 D-Cache 还好H759 插上 DMA 就绕不开这个问题别再对着波形一遍遍怀疑线接错。4. 进阶玩法编码器读取、ADC 接入和 Python 上位机调试4.1 读 MT6816 磁编码器的实操记录MT6816 是磁编码器SPI 接口读角度很方便。这类传感器特别适合用来暴露 SPI 框架的一个问题频繁小包读取时如果每次传输都走 DMA中断和缓存开销反而大。我的做法是单独给它建一个低速率、非 DMA 的传输通道或者复用框架但配置较低的 max_hz。读角度一般流程CS 拉低主机发送寄存器地址从机在 MISO 上吐数据主机在同一个 SCK 窗口收到 16bit 数据其中低 14 位就是角度值。RT-Thread 里用 rt_spi_send_then_recv 一步完成命令加接收。注意这类芯片对 CS 建立时间有要求如果你的 SPI 速率很高但 CS 前沿离 SCK 第一个沿太近读出来的值会偶发抖动。这种时候要么降速要么用一个单独的 GPIO 片选提前拉低手动控制建立时间。我最后是软件片选加数据手册推荐的模式实测稳定。4.2 Python 通过 USB 虚拟串口远程操作 SPI调试期不想反复接仿真器的时候我习惯用 RT-Thread 的 USB 设备组件做一个 CDC 类虚拟串口上位机用 Python 发命令控制板端 SPI。RT-Thread 官方 USB 框架里有 CDC ACM 示例启用后在板子上多一个 ttyACM 或虚拟 COM 口。板端注册一个 msh 命令比如 spi_xfer参数是设备名、发送内容Python 端用 pyserial 把命令发下去再收打印结果。这个方式特别适合在线验证波形没问题但要反复改参数的场景比如调 W25Q64 时序时不同时钟参数在 Python 里批量下发读数对比省得每次重新编译烧写。贴一个最小实现static void spi_xfer(int argc, char **argv) { struct rt_spi_device *dev; uint8_t buf[32], rx[32]; int i, len; if (argc 3) { rt_kprintf(usage: spi_xfer spi00 hexstr\n); return; } dev (struct rt_spi_device *)rt_device_find(argv[1]); len strlen(argv[2]) / 2; for (i 0; i len; i) sscanf(argv[2] i * 2, %2hhx, buf[i]); rt_spi_transfer(dev, buf, rx, len); for (i 0; i len; i) rt_kprintf(%02X , rx[i]); rt_kprintf(\n); } MSH_CMD_EXPORT(spi_xfer, spi master transfer by cmd);import serial ser serial.Serial(COM3, 115200, timeout1) ser.write(bspi_xfer spi00 9F0000\n) print(ser.read(64).decode(errorsignore))这里注意 %2hhx 依赖 libc嵌入式环境如果没开完整 newlib解析可能会出问题建议改用手写的 hex2byte 函数替代别盲目抄。4.3 一条总线上挂多个从设备时的参数切换因为 RT-Thread 把每个从设备都抽象成一个 spiXX 设备同一 SPI 总线上挂 Flash、ADC、编码器甚至 FPGA 都没问题只要片选不同、配置不同。我会在初始化代码里为每个从设备单独做一个 configure 结构体统一封装成设备上下文里面存设备句柄、参数、私有数据。这样业务层读温度传感器就调用 temp_sensor_read内部自动找到对应 SPI 设备并切换参数不会写乱。需要提醒的是切换不同模式的从设备时rt_spi_configure 会重新改外设寄存器多次切换会有一点开销但对工控周期来说通常可忽略。更需要注意的是总线互斥RT-Thread 的 SPI 总线在传输时自带互斥锁但如果你在中断里调用传输函数就得用支持中断上下文的 API 或自己保证临界区不然优先级反转和死锁早晚找上门。5. SPI 排查实录从波形错乱到 Cache 一致性5.1 波形看着正常数据却错位先查 CPOL/CPHA先说一个我踩过的效率最高的坑示波器看SCK、MOSI 都对MISO 波形也有可读回的数据永远错位。后来逐条对比发现把 Mode 3 当成了 Mode 0 写。CPOL 决定空闲电平CPHA 决定采样沿两者组合错一个数据就会差半个时钟周期。一句话经验先确定从设备手册上的时序图找到 SCK 空闲电平和采样沿再映射到 RT-Thread 的模式宏。也可以用逻辑分析仪抓完整一次传输看数据位在哪一个沿跳变哪个沿应该被捕获。下面的对应关系可以收藏模式CPOLCPHA空闲 SCK 电平采样沿000低上升沿101低下降沿210高下降沿311高上升沿另外有些从设备手册不写模式编号但会写明 SCK 空闲为低、数据在第二个沿采样那你该选 Mode 1。以手册为准不要盲猜默认。5.2 M7 的 D-Cache 与 DMA 之间暗藏的坑这个坑前面提过单独展开讲一下排查方法。现象是开 DMA 后接收缓冲区第一次能读到正确数据第二次以后一直读到旧数据或者发送端改了 buf波形上 SCK 正常但 MOSI 还是老数据。先别怀疑 SPI 外设先怀疑 Cache。处理手段发送前 clean 数据接收后 invalidate 数据如果 buffer 是 malloc 出来的优先改成 Cache Line 对齐的静态数组频繁做小传输时宁可关闭 DMA 走 CPU 轮询省心。为什么 M7 特别严重因为 H759 的 CPU 主频 600MHzCache 命中率很高SPI DMA 写的 RAM 区域如果刚被 CPU 读过Cache 里的旧副本会盖掉新数据。RT-Thread 的 drv_spi 在 DMA 路径里是否做了维护取决于 BSP 版本建议直接读一遍源码确认不要默认框架一定处理了。5.3 同一条 SPI 总线上挂多个设备的三大纪律第一条同一时刻只能有一个片选有效。软件片选下特别容易出事线程 A 拉低了设备 1 的 CS调度让线程 B 去操作设备 2两条总线命令就串了。RT-Thread 的 SPI 设备框架内部有锁但你要是绕过框架直接操作 CS就失去了保护。第二条CS 释放要有最小宽度。很多外设要求 CS 拉高后保持一小段时间才能再拉低连续高频轮询时容易踩线。第三条MISO 线不要多设备硬怼。从设备输出如果只有 CMOS 电平驱动多个设备同时拉低会造成电流回溯建议 MISO 路径上加隔离或者选择输出高阻的从设备。工控环境干扰大SPI 走线超过 10cm 就把速率降一档别只看芯片标称的最高时钟。5.4 我私藏的回环测试命令与排查顺序排障顺序我总结成先看外设寄存器状态再看 RT-Thread 设备是否注册最后才动波形。启动后 msh 敲 list_device 确认 spi0 在不在再敲 list_thread 看有没有外设线程异常接着用示波器或逻辑分析仪看 CS、SCK、MOSI 三根线的静态电平和传输沿最后才怀疑软件逻辑。为了快速定位我养成了一个习惯写一个 spi_loopback 测试命令把 MOSI 和 MISO 短接发一串 0x5A、0xA5看回环数据对不对。这个命令在板子贴片前或者在无外设环境下特别有用能直接区分板级硬件问题和驱动问题。很多读外设老是不对的故障用回环一测就原形毕露。就我个人项目里的体会SPI 这套链路 90% 的调试时间都花在模式、片选和 Cache 这老三样上真正难的不是 SPI 本身而是把 GD32H759 外设手册、RT-Thread 框架源码和从设备数据手册三份文档对照着读。实际调板时我总喜欢先做一个最朴素的回环实验再挂真实设备这样每加一个外设心里都有底。这篇是系列里的第 6 篇后面大概还会继续聊 DMA、CAN 或者工业以太网相关的内容如果大家在这块有更多坑欢迎在评论区补充我们互相填坑。写完之后我再啰嗦一句SPI 看起来简单但它对时序细节的敏感程度一点不比 CAN 这种复杂总线低前期多花十分钟看数据手册后期能省一大笔调试时间。