1. 为什么在GD32H759上跑RT-Thread还要死磕SPI
拿到GD32H759这块片子的时候,我第一反应是外设资源真够猛的——Cortex-M7内核跑到480MHz,带一堆定时器、CAN-FD、以太网MAC,还有好几个SPI接口。但真正把它塞进工控板子跑起来,才发现SPI这块儿要是不吃透,后面接ADC、接编码器、接Flash存储,处处都是坑。
这篇东西是我在GD32H759 + RT-Thread环境下折腾SPI的完整记录。核心想解决几个问题:RT-Thread的SPI设备框架怎么跟GD32H7的硬件SPI对接、DMA怎么配才不丢数据、片选到底用硬件还是软件、工控场景下怎么保证长时间通信不翻车。适合已经能把RT-Thread跑起来、但一接SPI外设就各种玄学问题的兄弟参考。如果你还在纠结SPI时序图怎么看,我也会用生活化的方式把它讲清楚,保证看完能直接上手改代码。
先说结论:GD32H759的SPI外设本身不复杂,难的是RT-Thread那套设备驱动模型跟它之间的适配层,以及工控现场对稳定性的变态要求。下面我按实际调试顺序,从框架设计一路讲到问题排查。
2. 整体方案设计与RT-Thread SPI框架拆解
2.1 为什么选RT-Thread的SPI设备框架而不是裸机
裸机写SPI很简单,初始化寄存器、拉片选、发数据、等标志位,几十行搞定。但工控项目里SPI总线上往往挂好几个设备——一个W25Q64存参数、一个MT6816磁编码器读位置、可能还有个外部ADC采模拟量。裸机这么写下去,每个设备的时序、片选、速率都得手动管,代码很快就成一团乱麻。
RT-Thread的SPI设备框架本质上是给你抽了一层“总线-设备”模型。SPI总线注册一次,挂在上面的设备各自注册,框架帮你管片选、管传输队列、管DMA。我选它的核心理由有三个:一是多设备共存时片选不会打架,二是DMA传输可以跟线程调度配合,不阻塞其他任务,三是换芯片平台时上层应用代码基本不用动。
注意:RT-Thread的SPI框架分两层,底层是
spi_ops里的configure和transfer,上层是rt_spi_send、rt_spi_recv这些API。很多人只调上层API,结果底层transfer没实现好,DMA配了也不工作。
2.2 GD32H759的SPI外设特点与选型考量
GD32H759的SPI跟STM32H7系列很像,支持全双工、半双工、 simplex模式,FIFO深度16级,支持TI模式和Motorola模式。工控场景我一般用Motorola模式,CPOL和CPHA根据从机手册来定。速率方面,SPI时钟从APB总线分频出来,GD32H759的APB2能跑到120MHz,SPI最高能到60MHz左右,但实际工控板子上走线长了,我一般压到10MHz到20MHz之间,稳定优先。
这里有个选型细节:GD32H759有几个SPI接口,SPI0和SPI1挂APB2,SPI2挂APB1。如果同时要用多个SPI,尽量把高速设备挂SPI0/SPI1,低速的挂SPI2,避免总线争抢。另外DMA请求映射要查手册,每个SPI的TX和RX对应哪个DMA通道是固定的,配错了DMA根本不触发。
2.3 硬件片选与软件片选的取舍
这个问题热词里也有人问,我直接说我的做法:工控场景优先用软件片选。原因很简单,硬件片选虽然省CPU,但片选时序跟数据之间的间隔是硬件固定的,有些从机对片选建立时间和保持时间要求比较怪,硬件片选调不了。软件片选就是普通GPIO,拉低拉高之间可以插延时,灵活得多。
但软件片选有个坑:RT-Thread的SPI框架默认会帮你管片选,如果你在rt_spi_configure里指定了CS引脚,框架会在传输前后自动拉。我一般把CS当普通GPIO自己控制,在transfer函数外面手动拉,这样时序完全可控。代价是每次传输多几条GPIO操作指令,对480MHz的M7来说可以忽略。
3. 核心细节解析与实操要点
3.1 SPI模式与时序参数的确定方法
SPI时序图看着晕,其实就四个参数:CPOL决定空闲时时钟是高还是低,CPHA决定数据在第一个边沿还是第二个边沿采样。组合起来四种模式,工控器件里Mode 0和Mode 3最常见。W25Q64用Mode 0,MT6816用Mode 3,这个必须查手册,配错了读出来全是0xFF或者0x00。
我实际调试时的做法是:先拿逻辑分析仪抓一下从机手册里标称的时序,确认CPOL和CPHA,然后在RT-Thread的rt_spi_configure里设RT_SPI_MODE_0或RT_SPI_MODE_3。速率先设低,比如1MHz,通了再往上加。有一次我直接上20MHz,结果MT6816读出来角度跳变,降到5MHz就稳了,后来查是板子上拉电阻太大导致边沿变缓。
3.2 DMA配置的关键参数与计算过程
DMA是SPI高速传输的命根子。GD32H759的DMA跟STM32类似,有多个通道,每个通道有优先级、数据宽度、地址增量这些参数。配SPI DMA时,外设地址固定,内存地址递增,数据宽度一般设8位或16位,跟SPI数据帧大小一致。
传输长度怎么算?比如我要读W25Q64的4KB数据,SPI数据帧8位,那DMA传输数量就是4096。但注意RT-Thread的SPI框架里,transfer函数的length参数是消息个数,每个消息可能包含多个字节。我一般把DMA配置成循环模式关掉,传输完成中断里给信号量,线程等信号量再继续。
提示:GD32H759的SPI DMA有个细节,TX和RX要同时配,即使你只发不收,RX的DMA也得开着,否则FIFO溢出会出错。我踩过这个坑,只配TX DMA,发了几百字节后SPI直接卡死。
3.3 RT-Thread SPI设备注册与驱动对接
RT-Thread里注册SPI总线用rt_spi_bus_register,需要传一个struct rt_spi_bus和struct rt_spi_ops。ops里至少实现configure和transfer。configure里根据rt_spi_configuration结构体设置CPOL、CPHA、数据位宽、速率。transfer里根据rt_spi_message链表逐个处理,每个message有send_buf、recv_buf、length、cs_take、cs_release这些字段。
我的做法是在transfer里判断有没有DMA,有DMA走DMA路径,没DMA走查询路径。查询路径简单但占CPU,适合小数据量。DMA路径复杂但效率高,适合大数据块。两条路径都实现,上层根据数据量自动选。
4. 实操过程与核心环节实现
4.1 环境搭建与基础工程配置
先确保RT-Thread的BSP能跑起来,串口能打印。然后打开SPI驱动框架,在rtconfig.h里确认RT_USING_SPI和RT_USING_SPI_MSD(如果用文件系统)是开的。GD32H759的BSP里一般有现成的SPI驱动,但可能只实现了查询模式,DMA要自己加。
我习惯先写个最小测试:注册SPI总线,配一个W25Q64设备,读ID。ID读对了说明时序和片选没问题,再往下做DMA。读ID的代码很简单,发0x9F,收3个字节,对比0xEF4018之类的值。
4.2 SPI总线注册与设备挂载代码实现
总线注册的代码大概长这样:
static struct rt_spi_bus spi_bus0; static struct gd32_spi spi0; rt_err_t gd32_spi_configure(struct rt_spi_device *device, struct rt_spi_configuration *cfg) { struct gd32_spi *spi = device->bus->parent.user_data; // 根据cfg->mode设置CPOL/CPHA // 根据cfg->max_hz计算分频系数 // 根据cfg->data_width设置数据帧大小 return RT_EOK; } rt_uint32_t gd32_spi_transfer(struct rt_spi_device *device, struct rt_spi_message *message) { // 遍历message链表 // 对每个message,拉片选、发数据、收数据、释放片选 return length; } static struct rt_spi_ops gd32_spi_ops = { .configure = gd32_spi_configure, .transfer = gd32_spi_transfer, }; int rt_hw_spi_init(void) { // 使能SPI时钟、GPIO时钟、DMA时钟 // 配置GPIO复用 // 注册总线 rt_spi_bus_register(&spi_bus0, "spi0", &gd32_spi_ops); spi_bus0.parent.user_data = &spi0; return 0; }设备挂载用rt_spi_bus_attach_device,传设备名、总线名、片选引脚。片选引脚如果是软件控制,传RT_NULL,自己在传输时拉GPIO。
4.3 DMA传输通道配置与中断处理
DMA配置我单独写了个函数,在transfer里判断数据长度超过阈值(比如32字节)就走DMA。配置步骤:使能DMA时钟、设置外设地址为SPI数据寄存器、设置内存地址为发送缓冲区、设置传输方向、设置数据宽度、设置优先级、使能传输完成中断。
中断处理里给信号量,线程等信号量。这里有个细节:DMA传输完成中断里要清标志位,还要关DMA通道,否则下次传输会出错。我一般传输完成后把DMA通道disable,下次传输前再enable。
void DMA0_Channel0_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH0, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH0, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH0); rt_sem_release(&spi_dma_sem); } }4.4 实际读写W25Q64与MT6816的测试记录
W25Q64测试:读ID成功,擦除扇区,写数据,读回来对比,全对。速率从1MHz加到20MHz,读4KB数据耗时从约32ms降到约1.6ms,DMA效果明显。
MT6816测试:这是个磁编码器,SPI Mode 3,读角度寄存器。一开始读出来数据跳变,逻辑分析仪一看,CS拉低到第一个时钟沿之间只有几十纳秒,MT6816要求至少100ns。在CS拉低后加了rt_hw_us_delay(1),问题解决。速率最后定在5MHz,再高数据就不稳。
5. 常见问题与排查技巧实录
5.1 SPI通信无响应的排查思路
先查片选,用万用表量CS引脚在传输时有没有拉低。再查时钟,逻辑分析仪看SCK有没有波形。如果CS和SCK都正常但没数据,查MISO有没有被从机驱动。有一次我忘了配MISO的GPIO复用,一直读0xFF,查了半天。
还有个隐蔽问题:RT-Thread的SPI框架里,如果configure函数返回错误,框架不会报错,直接继续传输,但参数没设进去。我习惯在configure里加打印,确认参数真的设进去了。
5.2 DMA传输数据错位的几种原因
数据错位最常见的原因是DMA内存地址没递增,或者数据宽度设错了。比如SPI数据帧8位,DMA设成16位,数据就错位了。还有DMA优先级太低,被其他DMA打断,也会错位。我一般把SPI DMA优先级设成高。
另一个原因是FIFO阈值。GD32H759的SPI FIFO有阈值设置,DMA请求在FIFO达到阈值时触发。如果阈值设得不对,DMA传输的数据量会跟预期不符。我一般把TX FIFO阈值设成空,RX FIFO阈值设成满,这样DMA请求最及时。
5.3 工控现场长距离SPI通信的稳定性处理
工控现场SPI走线可能几十厘米,甚至通过排线到另一个板子。这种场景下速率必须降,我一般不超过5MHz。另外要加串联电阻,一般22欧到33欧,抑制反射。CS和SCK线上可以加小电容,但别太大,否则边沿变缓。
还有个经验:长距离SPI通信时,软件片选比硬件片选稳。因为软件片选可以在CS拉低后延时,等信号稳定了再发时钟。硬件片选没这个灵活性。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 读数据全0xFF | MISO未配置或从机未驱动 | 量MISO引脚电平 | 配置GPIO复用,检查从机供电 |
| 读数据全0x00 | 时钟未输出或从机未响应 | 逻辑分析仪看SCK | 检查SPI使能位,检查片选 |
| 数据错位 | DMA宽度或地址增量错误 | 查DMA配置寄存器 | 设对数据宽度,内存地址递增 |
| 高速时数据跳变 | 信号完整性差 | 示波器看边沿 | 降速率,加串联电阻 |
| DMA不触发 | DMA通道映射错误 | 查手册DMA请求映射表 | 改用正确的DMA通道 |
| 传输卡死 | FIFO溢出或DMA未关 | 查SPI状态寄存器 | 同时配TX和RX DMA,传输后关DMA |
提示:逻辑分析仪是调SPI的必备工具,没有它基本靠猜。我用的是一款几百块的国产逻辑分析仪,抓SPI时序足够。
6. 工控场景下的SPI性能优化与扩展思考
6.1 多设备共存时的总线仲裁与片选管理
一条SPI总线上挂多个设备时,片选管理是核心。我的做法是每个设备独立CS,传输时只拉对应设备的CS,其他保持高。RT-Thread框架里,如果每个设备注册时都指定自己的CS引脚,框架会自动管。但我还是喜欢自己管,因为有些设备需要特殊的片选时序。
多设备共存还有个速率问题。不同设备支持的SPI速率不同,比如W25Q64能到50MHz,MT6816只能到10MHz。RT-Thread的configure是按设备调的,所以每个设备可以设不同速率,框架在切换设备时会重新配置SPI。这个机制很好用,但要注意切换设备时的片选间隔,别让上一个设备的CS还没拉高就配了下一个设备的参数。
6.2 SPI与RT-Thread线程调度的配合
工控项目里SPI传输往往在独立线程里做,比如一个线程专门读编码器,一个线程专门写Flash。RT-Thread的SPI框架在传输时会拿总线锁,所以多线程同时访问SPI不会打架,但会串行化。如果两个线程都频繁访问SPI,要考虑优先级和传输量,避免低优先级线程饿死。
我一般把SPI传输放在中等优先级线程里,传输时如果走DMA,线程会挂起等信号量,CPU让给其他线程。这样即使SPI传输量大,也不影响高优先级任务。
6.3 从查询模式到DMA模式的平滑迁移
很多兄弟一开始用查询模式,后来数据量大了想换DMA,但改起来怕出问题。我的做法是保留两条路径,在transfer里根据长度自动选。查询模式代码不动,DMA模式新增。这样小数据量走查询,大数据量走DMA,迁移平滑,风险低。
迁移时注意DMA的缓冲区要对齐,GD32H759的DMA对内存地址有对齐要求,一般4字节对齐。我习惯用rt_malloc分配缓冲区,然后手动对齐,或者直接用静态数组加__attribute__((aligned(4)))。
6.4 后续可以扩展的方向
这套SPI框架跑通后,可以往上叠更多东西。比如接SPI接口的ADC,做多通道模拟量采集;接SPI Flash做文件系统,用RT-Thread的DFS;接SPI屏幕做本地显示。每个扩展都是在这套框架上加设备,底层不用动。
还有个方向是SPI从机模式,GD32H759支持SPI从机,可以用在跟其他主控通信的场景。不过从机模式对时序要求更严,调试起来更麻烦,我目前还没在工控项目里用过。
我在实际项目里踩过的最大坑是DMA和查询模式混用时,忘了在DMA传输前关查询模式的中断,结果两个路径同时操作SPI寄存器,数据全乱。后来在transfer入口加了状态判断,确保同一时间只有一个路径在跑。这个细节手册上不会写,但实际调试中很容易遇到。