☰
GD32H759与RT-Thread SPI实战:从模式配置到DMA缓存一致性
2026/11/6 8:23:44 网站建设 项目流程

写这篇的时候,我手头的 GD32H759 板子已经跑了两个月,RT-Thread 相关的时钟、串口、GPIO 也分别写过笔记。这次轮到 SPI,正好把我从踩坑到稳定运行的完整链路整理出来。SPI 在工控板上的地位不用多讲,外部 Flash 存参数、ADC 采模拟量、磁编码器读角度、FPGA/DSP 之间做轻量互联,十块板子里有八块绕不开它。这篇文章既是我自己的项目记录,也希望能帮到正在用 GD32H759 或者 GD32H7 系列配 RT-Thread 做工业控制的朋友,无论你是刚接触 RT-Thread 的 SPI 框架,还是已经调过几个 SPI 外设想再隔离一下问题,都可以顺着下面的思路过一遍。

SPI 本身并不难,难在面对具体外设时,到底该选哪种模式、哪个片选策略、哪条传输路径。GD32H759 这颗 M7 主频已经拉到 600MHz,CPU 不是瓶颈,反而周边总线和缓存一致性问题更容易成为拦路虎。我们先把这些容易翻车的点逐个说清楚。

1. 方案选型:SPI 在工控应用中的三个关键选择

1.1 为什么工控板上的高实时外设都爱走 SPI

SPI 是一种同步串行总线,四根线大体分工: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/3,RT-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, timeout=1) ser.write(b"spi_xfer spi00 9F0000\n") print(ser.read(64).decode(errors="ignore"))

这里注意 %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 主频 600MHz,Cache 命中率很高,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 这种复杂总线低,前期多花十分钟看数据手册,后期能省一大笔调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询