1. 项目背景与整体设计思路
这一篇我来聊聊GD32H759和RT-Thread组合下的SPI实战。先说项目背景,我手头这套工控板是一个现场温控模块,核心工作由三路SPI外设承担:一路240x320的TFT屏幕显示实时温度曲线,一路MAX31865读PT100铂电阻温度,还有一路W25Q64 NOR Flash存配置参数和运行日志。工控场景下这种“屏幕+传感器+存储”的三件套很典型,但也是最容易出现调度冲突的组合。
以前这个板子用STM32F407裸机开发,三路SPI全靠中断和标志位轮询来协调。屏刷到一半Flash写入突然插入,画面撕裂是小事,最怕的是总线上出现半截数据,导致Flash状态字读取错误、整块数据报废。换到GD32H759之后,我干脆把系统切成RT-Thread,所有SPI外设统一挂到RT-Thread设备模型下面,用总线锁和线程调度来解决这种访问冲突。运行了大半年,稳定性比我预想的好,这也正是我觉得值得把过程整理出来分享的原因。
选GD32H759不是拍脑袋。这颗芯片是Cortex-M7内核,主频能跑到550MHz,RAM和Flash容量比F407高一个量级,而且SPI外设资源特别充足。我用的三路SPI分别挂在不同的SPI控制器上,从硬件层面避免了共享总线的尴尬。再加上GD32的外设库是传统寄存器风格,熟悉标准外设库的人上手很快,配置SPI时直接操作CTL、STAT、DATA这几个寄存器就能完成大部分工作。
1.1 裸机方案与RTOS方案的取舍逻辑
把SPI从裸机搬到RT-Thread,本质上解决的是“谁先用总线”的问题。裸机环境下所有SPI操作都跑在中断里,遇到高优先级中断抢占,低优先级的传输就会被延迟。你无法预测一个Flash写入会被屏幕刷新打断多少次。每次打断都意味着总线状态要保存、恢复,还要保证片选时序不出错,这种复杂度到后期根本压不住。
RT-Thread里SPI设备模型自带一个互斥锁。任何线程想要操作SPI,必须先拿到总线锁,操作结束后释放。如果两个线程同时发请求,后者的线程会挂起进入等待状态,而不是像裸机那样直接冲进临界区。从应用层看,SPI的访问被串行化了,底层驱动不需要做任何加锁保护,只要保证一次传输过程中不主动让出CPU就行。
这种做法带来的直接收益是:我可以在应用层随便开线程,一个线程刷屏,一个线程读温度,一个线程写Flash,三者之间不需要自己设计复杂的“信号量+标志位+超时”仲裁机制。RT-Thread把这层工作全部收纳进框架,我只专注于业务逻辑。
1.2 三路SPI外设的需求拆解
三路SPI外设的性质完全不同,必须区别对待:
| 外设 | 传输方向 | 数据量特征 | 实时性要求 | 频率选择 |
|---|---|---|---|---|
| W25Q64 Flash | 双向 | 读多写少,单次最大256字节页编程 | 低,可容忍等待 | 40MHz |
| MAX31865 温度芯片 | 双向 | 固定2~3字节,周期轮询 | 中,100ms内完成即可 | 3MHz |
| TFT SPI屏幕 | 单向为主 | 大流量,全屏刷新2~3ms | 高,尽量不被阻塞 | 30MHz |
Flash写入偶尔大块,屏幕刷新持续高频,温度读取是固定节奏的小包。如果三个任务调度不好,屏幕最容易出问题,因为刷新延迟哪怕几十毫秒,人眼都能捕捉到闪烁。我最终的方案是屏幕线程优先级最高,挂在SPI2上并配独立DMA通道;温度读取线程次之,固定每100ms唤醒一次;Flash写入优先级最低,只在日志模块触发时才跑。三者之间靠RT-Thread的优先级抢占和SPI总线锁共同保障时序。
2. GD32H759 SPI硬件资源与速率计算
跑通RT-Thread驱动之前,得先把GD32H759的SPI硬件底子摸透。这颗芯片有多个SPI控制器,名字从SPI0一路排到后面,每一路都支持主模式和从模式,也都有独立的DMA请求通道。不同控制器挂在不同的APB总线上,分频系数来源不一样,配置速率之前必须搞清楚时钟树。
2.1 SPI外设的时钟来源与分频计算
GD32H759的SPI时钟来自APB总线时钟。我用的主频是550MHz,APB1和APB2分别可以配置不同的分频。假设APB2输出时钟是137.5MHz(550MHz/4),挂在这个总线上的SPI控制器,它的工作频率就是从137.5MHz往下分出来的。
SPI的最终波特率公式是:SPI时钟频率 = APB总线频率 / 分频系数。GD32标准外设库里通常用spi_baudirq_prescaler这个枚举来选分频系数,常见值有2、4、8、16、32、64、128、256。如果我想要40MHz,而APB2是137.5MHz,那么137.5 / 4 = 34.375MHz,最接近又不超过40MHz的就是这个值。如果你对速率有严格需求,就得回头调整APB分频。
我自己在项目里是这样定的:屏幕用30MHz,Flash用40MHz,MAX31865用3MHz。这几个速率分别对应不同的分频系数。由于三路SPI挂在不同总线上,互不牵扯,配置起来自由度很大。
2.2 硬件片选与软件片选怎么选
SPI片选(CS)是总线仲裁的关键。GD32H759的SPI控制器支持硬件片选和软件片选两种方式。硬件片选模式下,只要向SPI数据寄存器写数据,硬件会自动拉低CS,传输结束后拉高,全程不需要CPU介入。软件片选模式下,CS由普通GPIO控制,驱动里手动拉低再拉高。
我的建议是:工控场景一律用软件片选。原因有两个。第一,硬件片选的拉高时机不完全可控,有些从设备要求在最后一个字节移位完成后再等一小段时间释放CS,硬件的自动时序不总是满足。第二,多路外设分时复用时,如果有两个设备挂在同一个SPI控制器上,软件片选可以精准控制“先抬起A设备的CS,再拉低B设备的CS”,避免两个设备同时使能造成总线冲突。
我实际操作时,SPI0上的W25Q64和SPI2上的屏幕片选都是独立GPIO,只有MAX31865的片选直接复用硬件支持,因为它的读时序固定,CS释放快慢影响不大。片选GPIO的推挽输出速度建议设置到高速,尤其是屏幕刷新场景,CS翻转频率很高,如果GPIO速度不够,波形上升沿会变缓,影响时序裕量。
2.3 数据位宽与传输模式配置
GD32H759的SPI外设支持8位和16位数据位宽。初始化时用spi_data_frame_format配置。工控设备里大部分SPI从设备默认都是8位模式,但有一种情况要注意:16位模式下有些芯片是按16位frame来收发数据的,比如某些串行DAC和温度传感器。如果驱动框架里用的数据宽度和底层配置对不上,读出来的数据会整体错位。
传输模式方面,SPI有模式0、1、2、3四种组合,对应CPOL(时钟极性)和CPHA(时钟相位)两个参数。W25Q64和MAX31865都支持模式0和模式3,屏幕驱动芯片通常也兼容模式0。我统一选用模式0:空闲时SCK为低电平,第一个时钟沿采样数据。这样三个设备不用切换配置,降低复杂度。
3. RT-Thread SPI驱动框架:从总线注册到设备挂载
RT-Thread对SPI的抽象分两层:总线层和设备层。这个设计跟Linux的SPI子系统很像,只不过RT-Thread的API更简洁。你得先注册一个SPI总线驱动,然后在这个总线上挂载具体设备,应用层操作的是设备句柄。
3.1 底层总线驱动需要实现什么
SPI总线驱动在RT-Thread里对应struct rt_spi_ops,需要实现两个回调:configure和xfer。configure负责把SPI控制器配置成目标模式,xfer负责一次实际的数据收发。整个框架只依赖这两个函数,剩下的总线加锁、片选控制都由RT-Thread设备模型帮你处理。
我在GD32H759上实现configure时,做了这几件事:根据传入的max_hz计算分频系数,调用GD32库函数设置SPI模式、数据宽度、时钟极性和相位。xfer函数则是核心,需要遍历一个rt_spi_message消息链,逐条完成发送和接收。
RT-Thread的xfer设计很有特点。消息链上可以挂多个rt_spi_message,每条消息还能标记cs_take和cs_release,分别表示“这次传输前拉低片选”和“这次传输后拉高片选”。这意味着一次总线操作可以串行执行一长串命令,中间不释放总线,非常适合Flash芯片的“读状态字-读数据”这种复合操作。
3.2 总线注册与设备挂载的完整流程
代码层面,整个过程分三步走。
第一步,注册总线:
static struct rt_spi_bus gd32_spi0_bus; static const struct rt_spi_ops spi0_ops = { .configure = spi0_configure, .xfer = spi0_xfer, }; rt_spi_bus_register(&gd32_spi0_bus, "spi0", &spi0_ops);注册之后,系统里就有了一个名叫spi0的总线。应用层可以用rt_device_find("spi0")拿到总线设备,但常规做法是在总线上挂载子设备。
第二步,挂载设备:
static struct rt_spi_device spi0_dev_w25q64; rt_spi_bus_attach_device(&spi0_dev_w25q64, "w25q64", "spi0", RT_NULL);这里"w25q64"是设备名,"spi0"是总线名。挂载成功后,应用层直接通过rt_device_find("w25q64")拿到设备。第三个参数传入片选GPIO等私有数据,我一般放一个自定义结构体指针。
第三步,配置参数:
struct rt_spi_configuration cfg; cfg.mode = RT_SPI_MODE_0 | RT_SPI_MASTER | RT_SPI_MSB; cfg.data_width = 8; cfg.max_hz = 40 * 1000 * 1000; rt_spi_configure(&spi0_dev_w25q64, &cfg);配置好之后,底层configure回调就会被调用,硬件寄存器完成最终设置。之后应用层只需要调用rt_spi_transfer或者rt_spi_take_bus+rt_spi_transfer组合就能收发数据。
3.3 总线锁的正确使用方式
RT-Thread的SPI API有一个容易混淆的地方:rt_spi_transfer它内部并不自动拿总线锁。如果你直接调用,总线上同时只能有一个线程操作,但问题是RTOS调度下多个线程可能同时执行rt_spi_transfer,这时候就需要手动加锁。
标准用法是:
rt_spi_take_bus(dev); rt_spi_take_cs(dev); result = rt_spi_transfer(dev, send_buf, recv_buf, len); rt_spi_release_cs(dev); rt_spi_release_bus(dev);take_bus拿锁,take_cs拉低片选。如果整个消息链不需要中间释放CS,还有一种简便写法:
struct rt_spi_message msg; msg.send_buf = send_buf; msg.recv_buf = recv_buf; msg.length = len; msg.cs_take = 1; msg.cs_release = 1; rt_spi_transfer_message(dev, &msg);transfer_message内部会统一拿锁、操作片选再释放,使用起来相对安全。我在底层驱动封装里给每个设备都做了一层spi_read_reg和spi_write_reg接口,这些接口内部统一处理锁和片选,应用层看不到这些细节。
4. 实操代码:三路外设从底层到应用一次跑通
理论框架聊完了,这里直接给可以抄作业的驱动代码。我会按W25Q64、MAX31865、屏幕三路分别说,重点讲每路驱动里跟其他设备不一样的地方。
4.1 W25Q64 Flash驱动:页写入和状态轮询
W25Q64是8Mbit的SPI NOR Flash,容量1MB。驱动它核心是几个命令:0x06写使能,0x90读设备ID,0x02页编程,0x03读数据,0x05读状态寄存器。页编程一次最多写256字节,写入期间Flash的BUSY位会置1,你必须轮询状态寄存器等它完成。
初始化时先发0x90读ID验证硬件通路:
uint8_t cmd[4] = {0x90, 0x00, 0x00, 0x00}; uint8_t buf[4]; rt_spi_take_bus(&w25q64_dev); rt_spi_take_cs(&w25q64_dev); rt_spi_transfer(&w25q64_dev, cmd, buf, 4); rt_spi_release_cs(&w25q64_dev); rt_spi_release_bus(&w25q64_dev); // buf[2] 是高字节ID,W25Q64应该返回0x40ID正确后初始化完成。写入一个页的流程是这样的:发写使能0x06,拉高CS,再发页编程命令0x02、目标地址的高中低三字节和最多256字节数据,最后等BUSY位清零。这个流程里“发写使能”和“发数据”之间CS必须拉高再拉低,Flash要求这么严格的时序,CS一直接低无效。
误以为可以用rt_spi_transfer_message把整条链一次完成,实际上我在调试时发现,有些Flash对“写使能命令和页编程命令必须是一次独立的CS周期”有硬性要求。所以底层驱动要拆成两次独立操作,中间插入CS拉高动作,其他环节保持不变。
4.2 MAX31865温度驱动:周期轮询与字节拼接
MAX31865是一款RTD数字转换芯片,配置寄存器和温度数据寄存器都是8位宽的,通信协议很简单。它内部有故障检测,读数据还要顺带检查故障标志位。
驱动逻辑按照100ms周期执行:先写配置寄存器选择转换模式,再读两个字节的温度数据寄存器,把高低字节拼成15位数值,然后乘上对应的比例系数得到电阻值,再由查表计算出PT100温度。
MAX31865对片选时序特别敏感,CS必须在整次传输期间保持低电平,中途不能拉高。如果中间不小心释放了CS,芯片的转换流程会被打断,读出来可能是全1或者上次的缓存数据。所以我把读温度封装成一个函数,内部用rt_spi_take_bus+rt_spi_take_cs包住,保证CS在函数内不变化。
代码片段:
static uint32_t max31865_read_reg(uint8_t reg) { uint8_t buf[2] = {reg, 0x00}; uint8_t recv[2] = {0, 0}; struct rt_spi_message msg = {0}; msg.send_buf = buf; msg.recv_buf = recv; msg.length = 2; msg.cs_take = 1; msg.cs_release = 1; rt_spi_transfer_message(&max31865_dev, &msg); return recv[1]; }这里cs_take和cs_release都置1,表示这次传输是独占CS的。回调函数里需要注意,RT-Thread的xfer操作在每次取CS之后,底层驱动要真实地把GPIO拉低,不能只在软件层面模拟。
4.3 SPI屏幕驱动:DMA刷新和帧缓冲配合
屏幕刷新是性能要求最高的场景。我用的TFT屏是240x320,RGB565格式,一个全屏帧缓冲是240 * 320 * 2 = 153600字节。每次刷新全屏意味着要往SPI总线上灌15万字节,如果纯靠CPU从SPI数据寄存器逐字节搬运,按30MHz速率算也要几十毫秒,期间CPU什么都干不了。
方案是给SPI2配一个DMA通道。初始化时把DMA的源地址设为屏幕帧缓冲,目的地址设为SPI2的数据寄存器地址,传输方向是内存到外设,传输完成后触发DMA中断,在中断里释放一次刷新的信号量。
RT-Thread的设备模型本身对DMA友好,底层xfer可以判断消息的send_buf是否非空、recv_buf是否为空,如果属于纯发送方向就往DMA通道上挂任务:
dma_config(&spi2_dma_tx, (uint32_t)msg->send_buf, (uint32_t)&SPI2->DATA, msg->length); dma_start(&spi2_dma_tx);注意DMA搬运期间,SPI总线锁不能释放,否则另一路SPI操作会篡改GPIO状态。我的处理方式是xfer函数里等待DMA完成,等待期间调用rt_sem_take挂起当前线程,DMA中断里释放信号量。这样锁的持有时间被压缩到最小,不会影响其他SPI设备的调度。
屏幕刷新还有一个细节是开窗命令。全屏刷新可以不开窗,但显示波形曲线或者局部变化时必须先发0x2A和0x2B命令设置窗口范围,之后只刷新窗口内的数据。这个逻辑写在应用层,底层驱动不关心窗口的事。
5. 调试实录:我踩过的那些SPI坑
这一节是我的实务记录。SPI看起来协议简单,实际联调的问题五花八门,波形、时序、片选、数据错位、时钟极性问题都可能让系统措手不及。
5.1 波形异常:先查时钟极性和相位再说
屏幕刷新出现花屏,第一反应不要怀疑屏幕,先查SCK波形。用示波器看SCK空闲电平,如果是低电平,说明当前是模式0或模式2,如果SCK空闲时是高电平,说明配置成了模式1或模式3。大多数SPI设备默认模式0,也就是CPOL=0、CPHA=0。如果你的驱动配置成了模式1对不上,数据会全部移位半个时钟周期,导致屏幕识别命令全部错乱。
一个特别容易踩的坑是GD32库函数的模式枚举和RT-Thread的RT_SPI_MODE_0之间的转换。RT-Thread用位标来标记模式,底层驱动在configure回调里必须正确解析这些位标,映射到GD32的SPI时钟极性寄存器位。我一开始直接用赋值方式把RT_SPI_MODE_0映射成GD32库函数的模式值,结果模式3的配置正好反了,屏幕出来是倒像,查了很久才发现是映射关系写错。
5.2 总线锁死:线程挂起消耗排查
RT-Thread下最常见的死锁场景是:一个线程拿了总线锁,在传输过程中调用了会阻塞的API。比如在xfer函数内部执行等待DMA完成的rt_sem_take时,如果信号量迟迟不释放,而DMA中断本身因为优先级配置问题进不来,整个SPI就永久卡住。
我的排查方法是给底层xfer函数加超时机制。rt_sem_take有一个带超时的版本rt_sem_take(sem, rt_tick_from_millisecond(100)),超时后返回错误码并主动释放总线锁。这样即使DMA没触发,系统也能恢复,不会因为单次SPI异常导致整个板子死机。这个防护在工控现场至关重要。
另一个隐藏问题是总线锁释放遗漏。如果你在应用层用rt_spi_take_bus获取锁,中途业务判断出错直接return了,忘了调用rt_spi_release_bus,那么其他线程会永久阻塞在这个设备上。我的习惯是使用rt_spi_transfer_message接口,因为它内部会自动管理锁,应用层不需要也不应该手动拿锁。
5.3 数据错位:字节序与位宽陷阱
MAX31865和屏幕上出现“数值整体偏移一个字节”的现象,多半是数据位宽配置成了16位。GD32的SPI硬件配置成16位模式后,哪怕你只发一个字节,硬件也会把寄存器的高8位当成第一个字节发送,这样接收端看到的就是0x00+有效字节,数据整体错位。
我的排查流程固定是:先读设备ID,然后用示波器数一个字节的SCK时钟个数。如果是16个脉冲,说明数据位宽设置错了;如果是8个脉冲但数据不对,再查发送数据的字节序是MSB还是LSB。GD32的SPI默认高位在前,大部分SPI设备也都是高位在前,但有一小撮传感器芯片是LSB first,如果不注意就会得到“反过来”的数据。
下面是实际项目里最容易碰到的问题速查表:
| 现象 | 优先排查方向 | 解决方案 |
|---|---|---|
| 屏幕花屏/乱码 | SCK空闲电平、模式映射 | 确认CPOL/CPHA与设备匹配,检查RT-Thread模式解析 |
| 温度读数跳变 | 片选时序、CS拉高时机 | 用软件片选,确保传输期间CS不变 |
| Flash写失败 | 写使能命令独立CS周期 | 拆两次传输,中间拉高CS |
| SPI设备找不到 | 设备挂载名拼写 | 检查rt_spi_bus_attach_device的名字参数 |
| DMA数据传输错位 | 字节宽度配置、DMA突发长度 | 确认DMA数据宽度为8位,突发长度设为1 |
| 多线程死锁 | 锁未释放、信号量超时 | xfer里加超时机制,异常路径自动释放 |
5.4 从波形到执行的联调顺序
最后分享一个联调顺序,能帮你少走弯路。每次新板子回来,我不会直接跑RT-Thread应用层,而是先写一个裸机循环,把SPI初始化好,然后对着示波器量SCK和MOSI的波形,确认电平、频率、相位都正确。波形对了再做第二步:用逻辑分析仪看片选时序,确保每次传输CS的拉低、拉高时间符合设备要求。前两步都过了,才把RT-Thread设备树挂起来跑应用。
这套流程的底层逻辑是分层隔离。如果把RTOS调度问题、锁冲突、驱动bug全部混在一起调,输出就会是同一个症状可能有几十个原因,根本无从下手。一步步来,每个环节都把问题缩小到一个可确认的范围内,调试效率高很多。
关于SPI这块,我实际体验下来最值钱的建议是:不要过度信任示例代码,一定要用示波器或逻辑分析仪给自己留一份“真实波形参照”。GD32H759性能强、外设多,跑RT-Thread完全没有适配压力,但在最高频的SPI速率先一家一步去摸透底层时序,后面所有应用代码都稳了。下一步我准备在SPI链路里把加密和校验也加上,给工业现场的数据再上一道保险。