1. 项目概述:当RT-Thread遇上DMA驱动的ST7735
折腾过STM32和SPI屏的朋友,大概都经历过一个阶段:用CPU吭哧吭哧地搬运数据去刷屏,屏幕是亮了,但主程序也差不多“卡死”了。尤其是像ST7735这种分辨率(比如常见的128x160)的TFT屏,刷一屏的数据量不小,用阻塞式SPI传输,刷屏瞬间的延迟感非常明显,UI动画更是想都别想。我之前分享过在RT-Thread下用普通SPI轮询和中断方式驱动ST7735,算是解决了“从无到有”的问题。但今天,我们要玩点更高级的,把DMA(直接存储器访问)这个“外挂”请出来,彻底解放CPU,让刷屏操作变成后台静默任务,系统流畅度直接提升一个档次。
简单说,这个项目就是在RT-Thread实时操作系统环境下,为STM32微控制器编写一个使用DMA进行SPI数据传输的ST7735 TFT液晶屏驱动。它解决的不仅仅是“点亮屏幕”,而是“高效、无阻塞地刷新屏幕”,为更复杂的图形界面(比如LVGL)打下坚实的基础。无论你是正在做智能家居中控、工业仪表盘,还是任何需要友好人机交互的设备,这套方案都能让你的界面响应如丝般顺滑。接下来,我会从设计思路、具体实现到避坑细节,完整拆解整个过程。
2. 整体设计与思路拆解:为什么是DMA+SPI?
在深入代码之前,我们得先想清楚几个为什么。为什么在已经有了SPI中断驱动后,还要大费周章地引入DMA?这套方案的核心优势到底是什么?
2.1 核心需求解析:从“能刷”到“畅刷”的质变
使用普通SPI(轮询或中断)驱动LCD,其数据传输过程严重依赖CPU。每次发送一个字节或一帧数据,CPU都需要参与其中:准备数据、写入数据寄存器、等待发送完成标志、处理中断。对于ST7735,初始化配置命令数据量小,影响不大,但到了刷屏阶段,需要连续发送1281602 = 40960字节(16位色深)。这个过程中,CPU被长时间占用,无法及时响应其他任务或中断,导致系统实时性下降,表现为界面卡顿、外部事件响应延迟。
而DMA的本质是一个智能的数据搬运工,它可以在不打扰CPU的情况下,在外设(如SPI的发送数据寄存器)和存储器(如RAM中的显存数组)之间直接搬运数据。CPU只需要在开始时配置好DMA的源地址、目标地址、数据量,然后启动它,就可以去处理其他任务了。DMA搬运完成后,通过一个中断通知CPU“活儿干完了”。这样一来,刷屏这个耗时操作对主程序的影响被降到最低,几乎可以忽略不计。
2.2 方案选型与架构设计
在RT-Thread的框架下,我们有几种方式来实现DMA SPI驱动:
- 直接操作HAL库/LL库+裸机DMA逻辑:最直接,但和RT-Thread的设备驱动框架结合度低,不利于设备管理、电源管理和驱动复用。
- 使用RT-Thread的SPI设备框架 + 自定义DMA控制:利用RT-Thread标准的
rt_device_write接口进行SPI通信,但在传输大量数据时,在其底层实现中手动启停DMA。这种方式耦合度依然较高。 - 实现一个完整的RT-Thread DMA SPI设备驱动:这是最规范、最符合RT-Thread设计哲学的方式。我们将创建一个新的设备驱动,它向上提供标准的设备操作接口(open/close/read/write/control),向下封装HAL库的SPI DMA操作,并处理好DMA传输完成回调、信号量同步等RT-Thread多任务机制。
显然,第3种方案是最优解。它让我们的驱动可以像使用串口一样使用SPI,并且享受RT-Thread设备模型的所有好处(如动态加载、统一调试接口)。我们的驱动架构将分为三层:
- 应用层:调用
rt_device_write发送命令和数据。 - 驱动层:实现
rt_spi_bus和rt_spi_device的相关操作函数集(ops),在transmit_message函数中判断数据长度,小数据用轮询,大数据(如刷屏)用DMA。 - 硬件层:基于STM32 HAL库的SPI和DMA初始化与中断处理。
2.3 关键挑战与应对策略
引入DMA也带来了新的复杂性:
- 内存对齐:DMA通常对源/目标地址有对齐要求(如字对齐、半字对齐)。我们的显存数组需要放在合适的内存区域(如不使用
__attribute__((aligned(4)))修饰,则需保证其自然对齐)。 - 数据一致性:在DMA传输进行中,CPU不能去修改正在被搬运的源数据缓冲区,否则会导致屏幕上出现乱码。这需要我们在软件设计上保证缓冲区在DMA传输期间的“只读”性。
- 传输完成判定:DMA传输完成中断(TC)和SPI传输完成中断(TXE)的时机需要理清。我们通常依赖DMA传输完成中断作为一次批量发送结束的标志。
- RT-Thread同步机制:在DMA传输期间,调用
rt_device_write的任务应该被挂起,直到DMA传输完成。这需要使用信号量(semaphore)或完成量(completion)来进行任务同步。
3. 硬件与软件环境准备
工欲善其事,必先利其器。在写代码前,确保你的战场已经布置妥当。
3.1 硬件连接清单
以STM32F103C8T6(蓝色药丸核心板)和1.44寸ST7735S SPI屏为例:
| STM32引脚 | 功能 | 连接至ST7735 |
|---|---|---|
| PA5 | SPI1_SCK | SCLK |
| PA7 | SPI1_MOSI | SDA |
| PA4 | SPI1_NSS (软件控制) | CS |
| PA2 | GPIO输出 | RESET |
| PA1 | GPIO输出 | DC (数据/命令选择) |
| 3.3V | 电源 | VCC |
| GND | 地 | GND |
注意:有些ST7735模块还需要背光控制(BLK),连接到一个GPIO口并通过PWM控制亮度。这里为了简化,我们直接将BLK接3.3V常亮。
3.2 软件环境与工程配置
- 开发环境:RT-Thread Studio 或 Keil MDK + RT-Thread Nano Pack。
- RT-Thread版本:建议使用4.1.0或以上版本,其对STM32系列BSP支持较为完善。
- 关键配置:通过RT-Thread的ENV工具或Studio的图形化配置器,开启以下组件:
RT-Thread Components -> Device Drivers -> Using SPI Bus/Device device drivers:这是核心。- 确保SPI对应的DMA通道在CubeMX或底层驱动中已正确启用。强烈建议使用STM32CubeMX初始化代码,它能图形化配置引脚、SPI参数和DMA,避免手动配置寄存器带来的低级错误。
使用CubeMX生成初始化代码时,重点配置:
- SPI1模式为
Full-Duplex Master,硬件NSS选择Disable(我们用软件控制CS)。 - 数据尺寸为
8 Bits(或16 Bits,取决于你希望按字节还是半字传输。ST7735通常按8位接收,但我们可以用16位模式一次发送两个像素的高低位字节,效率更高,但需要调整数据组织方式)。 - 在
DMA Settings标签页,为SPI1_TX添加一个DMA流(如DMA1_Channel3)。模式设为Normal(非循环),优先级High,内存地址自增,外设地址不变。 - 生成代码时,选择为每个外设生成独立的
.c/.h文件,方便管理。
3.3 驱动文件结构规划
在你的BSP工程或应用目录下,创建或规划以下文件:
drv_spi_dma.c // SPI DMA驱动实现,包含总线注册、设备操作函数集 drv_spi_dma.h // 驱动头文件,声明函数和重要宏 lcd_st7735.c // ST7735高层应用代码,包含初始化序列、画点、画线、刷屏函数 lcd_st7735.h // LCD相关宏定义、函数声明drv_spi_dma.c是我们本次的重中之重,它将实现RT-Thread SPI设备驱动框架与STM32 HAL库DMA功能的桥接。
4. DMA SPI驱动层核心实现
这是整个项目的引擎。我们将一步步实现一个支持DMA的SPI设备驱动。
4.1 SPI总线与设备注册
首先,我们需要在RT-Thread的设备框架中注册一个SPI总线。
// drv_spi_dma.h #ifndef __DRV_SPI_DMA_H__ #define __DRV_SPI_DMA_H__ #include <rtthread.h> #include <rtdevice.h> #include "spi.h" // STM32 HAL库的SPI头文件 #include "dma.h" // 定义我们自定义的SPI设备结构体,扩展rt_spi_device struct stm32_spi_dma_device { struct rt_spi_device rt_spi_device; // 内嵌标准SPI设备 SPI_HandleTypeDef *hspi; // 对应的HAL SPI句柄 DMA_HandleTypeDef *hdma_tx; // 对应的HAL DMA TX句柄 rt_sem_t dma_tx_sem; // 用于同步的DMA发送完成信号量 volatile rt_bool_t dma_busy; // DMA忙标志 }; // 函数声明 rt_err_t stm32_spi_dma_bus_register(SPI_HandleTypeDef *hspi, DMA_HandleTypeDef *hdma_tx, const char *bus_name); #endif// drv_spi_dma.c 部分代码 // SPI总线操作函数集 static const struct rt_spi_ops stm32_spi_dma_ops = { .configure = spi_configure, .xfer = spi_xfer, // 关键的数据传输函数,我们将在这里实现DMA逻辑 }; // 注册SPI总线 rt_err_t stm32_spi_dma_bus_register(SPI_HandleTypeDef *hspi, DMA_HandleTypeDef *hdma_tx, const char *bus_name) { struct stm32_spi_dma_device *spi_dma_dev; rt_err_t result; spi_dma_dev = rt_malloc(sizeof(struct stm32_spi_dma_device)); if (!spi_dma_dev) return -RT_ENOMEM; rt_memset(spi_dma_dev, 0, sizeof(struct stm32_spi_dma_device)); spi_dma_dev->hspi = hspi; spi_dma_dev->hdma_tx = hdma_tx; // 创建二进制信号量,初始值为1(可用) spi_dma_dev->dma_tx_sem = rt_sem_create("spi_dma_tx", 1, RT_IPC_FLAG_FIFO); if (!spi_dma_dev->dma_tx_sem) { rt_free(spi_dma_dev); return -RT_ERROR; } spi_dma_dev->dma_busy = RT_FALSE; // 注册SPI总线到内核 result = rt_spi_bus_register(&spi_dma_dev->rt_spi_device.parent, bus_name, &stm32_spi_dma_ops); if (result != RT_EOK) { rt_sem_delete(spi_dma_dev->dma_tx_sem); rt_free(spi_dma_dev); return result; } // 将自定义设备结构体挂载到总线对象的user_data上,方便后续取用 spi_dma_dev->rt_spi_device.parent.user_data = spi_dma_dev; return RT_EOK; }注册总线后,我们还需要在应用层调用rt_spi_bus_attach_device来挂载具体的SPI设备(比如我们的LCD),并指定CS引脚。
4.2 关键函数spi_xfer的实现:DMA与轮询的智能切换
spi_xfer是SPI设备操作的核心,它根据消息(struct rt_spi_message)的长度和配置,决定使用轮询还是DMA。
static rt_uint32_t spi_xfer(struct rt_spi_device *device, struct rt_spi_message *message) { struct stm32_spi_dma_device *spi_dma_dev = device->bus->user_data; SPI_HandleTypeDef *hspi = spi_dma_dev->hspi; rt_size_t send_len = message->length; rt_uint8_t *send_buf = (rt_uint8_t *)message->send_buf; rt_err_t ret; // 参数检查 if (!send_buf || send_len == 0) return 0; // 判断是否使用DMA传输:长度大于阈值,且是发送操作(message->cs_take 和 cs_release通常由框架控制) // 这里简化判断,实际可根据message->cs_take等做更精细控制 #define DMA_TRANSFER_THRESHOLD 32 // 阈值可调,比如32字节以上用DMA if (send_len >= DMA_TRANSFER_THRESHOLD && (message->send_buf != RT_NULL)) { // --- DMA 传输路径 --- // 1. 等待DMA通道空闲(获取信号量) rt_sem_take(spi_dma_dev->dma_tx_sem, RT_WAITING_FOREVER); spi_dma_dev->dma_busy = RT_TRUE; // 2. 配置DMA传输 // 注意:需要根据SPI数据宽度(8位或16位)设置DMA的数据宽度 // 假设SPI是8位数据格式 spi_dma_dev->hdma_tx->Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; spi_dma_dev->hdma_tx->Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; if (HAL_DMA_Start_IT(spi_dma_dev->hdma_tx, (rt_uint32_t)send_buf, (rt_uint32_t)&hspi->Instance->DR, send_len) != HAL_OK) { rt_sem_release(spi_dma_dev->dma_tx_sem); spi_dma_dev->dma_busy = RT_FALSE; return 0; // 启动DMA失败 } // 3. 使能SPI的DMA发送请求 __HAL_SPI_ENABLE(hspi); SET_BIT(hspi->Instance->CR2, SPI_CR2_TXDMAEN); // 4. 等待DMA传输完成(信号量由DMA完成中断回调函数释放) rt_sem_take(spi_dma_dev->dma_tx_sem, RT_WAITING_FOREVER); // 传输完成后,立即释放信号量,为下一次传输做准备 rt_sem_release(spi_dma_dev->dma_tx_sem); spi_dma_dev->dma_busy = RT_FALSE; // 5. 传输完成后的清理工作 // 等待SPI发送寄存器为空,确保最后一个字节已移位出去 while(__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_TXE) == RESET); // 等待SPI忙标志清除 while(__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY) == SET); CLEAR_BIT(hspi->Instance->CR2, SPI_CR2_TXDMAEN); __HAL_SPI_DISABLE(hspi); // 可选,根据实际情况决定是否关闭SPI return send_len; } else { // --- 轮询(阻塞)传输路径 --- // 用于发送短命令、初始化序列等 for (rt_size_t i = 0; i < send_len; i++) { // 等待发送缓冲区空 while(__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_TXE) == RESET); hspi->Instance->DR = send_buf[i]; } // 等待最后一字节发送完成 while(__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_TXE) == RESET); while(__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY) == SET); return send_len; } }关键点解析:
- 阈值选择:
DMA_TRANSFER_THRESHOLD是一个经验值。DMA本身有启动开销(配置寄存器、处理中断),对于几个字节的短数据,轮询可能更快。对于刷屏的几千上万个字节,DMA优势巨大。可以从64或128开始尝试。 - 同步机制:我们使用了一个二进制信号量
dma_tx_sem。在启动DMA前,任务调用rt_sem_take等待信号量(如果DMA正忙,则阻塞)。在DMA传输完成中断回调里,调用rt_sem_release释放信号量,唤醒等待的任务。这里有一个细节:在spi_xfer的DMA路径中,我们take了两次信号量。第一次是确保DMA空闲,第二次是等待本次DMA完成。在完成后的清理工作中,我们立即release,将信号量恢复为可用状态。这是一种常见的“获取-等待-释放”模式。 - 传输完成判定:DMA传输完成中断(
HAL_SPI_TxCpltCallback)只意味着数据从内存搬到了SPI的数据寄存器(DR)。我们还需要等待SPI本身将最后一个字节从移位寄存器发送出去(SPI_FLAG_BSY清除)。这个等待是必须的,否则在连续发送时可能出错。
4.3 DMA传输完成中断回调函数
这个函数在HAL库的DMA中断服务程序中调用,是释放信号量、通知任务传输完成的关键。
// 在stm32f1xx_it.c或专门的驱动文件中实现 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { // 我们需要找到这个hspi对应的自定义设备结构体 // 一种方法是在注册时,将设备指针保存在hspi->UserData中 struct stm32_spi_dma_device *spi_dma_dev = (struct stm32_spi_dma_device *)hspi->UserData; if (spi_dma_dev != RT_NULL) { // 释放信号量,唤醒等待的spi_xfer任务 rt_sem_release(spi_dma_dev->dma_tx_sem); } }注意:你需要确保在初始化时,将自定义的spi_dma_dev指针赋值给hspi->UserData。或者在驱动内部维护一个hspi到spi_dma_dev的映射表。
4.4 SPI配置函数spi_configure
这个函数用于配置SPI的工作模式、时钟等参数,通常由上层应用在挂载设备时调用。
static rt_err_t spi_configure(struct rt_spi_device *device, struct rt_spi_configuration *configuration) { struct stm32_spi_dma_device *spi_dma_dev = device->bus->user_data; SPI_HandleTypeDef *hspi = spi_dma_dev->hspi; rt_uint32_t spi_prescaler; // 根据configuration->max_hz计算分频系数 // 这里需要根据你的系统时钟和SPI时钟计算,是一个简化示例 uint32_t sysclk = HAL_RCC_GetPCLK2Freq(); // 假设SPI1在APB2上 uint32_t div = sysclk / configuration->max_hz; // ... 根据div找到最接近的预分频器值 (2,4,8,16,32,64,128,256) // 例如:if(div<=2) spi_prescaler=SPI_BAUDRATEPRESCALER_2; // 实际项目应使用HAL库的宏或计算函数 hspi->Init.BaudRatePrescaler = spi_prescaler; hspi->Init.Direction = SPI_DIRECTION_2LINES; hspi->Init.CLKPhase = configuration->mode & RT_SPI_CPHA ? SPI_PHASE_2EDGE : SPI_PHASE_1EDGE; hspi->Init.CLKPolarity = configuration->mode & RT_SPI_CPOL ? SPI_POLARITY_HIGH : SPI_POLARITY_LOW; hspi->Init.DataSize = SPI_DATASIZE_8BIT; // 根据需求可改为16BIT hspi->Init.FirstBit = configuration->mode & RT_SPI_MSB ? SPI_FIRSTBIT_MSB : SPI_FIRSTBIT_LSB; hspi->Init.NSS = SPI_NSS_SOFT; // 软件控制片选 hspi->Init.Mode = SPI_MODE_MASTER; if (HAL_SPI_Init(hspi) != HAL_OK) { return -RT_ERROR; } return RT_EOK; }5. ST7735应用层驱动实现
有了强大的DMA SPI底层驱动,上层的LCD驱动就轻松多了。我们主要关注刷屏函数的优化。
5.1 初始化与基础函数
初始化序列和之前类似,发送一系列命令和数据来配置ST7735。区别在于,现在我们使用RT-Thread的设备接口来发送。
// lcd_st7735.c static struct rt_spi_device *spi_dev_lcd; // SPI设备指针 int lcd_init(void) { rt_err_t ret; // 1. 初始化GPIO (RESET, DC) // ... // 2. 硬件复位 LCD_RST(0); rt_thread_mdelay(100); LCD_RST(1); rt_thread_mdelay(100); // 3. 查找并打开SPI设备 spi_dev_lcd = (struct rt_spi_device *)rt_device_find("spi10"); // 假设设备名为"spi10" if (!spi_dev_lcd) return -1; // 4. 配置SPI模式 struct rt_spi_configuration cfg; cfg.data_width = 8; cfg.mode = RT_SPI_MODE_0 | RT_SPI_MSB; // ST7735常用模式0,MSB先行 cfg.max_hz = 30 * 1000 * 1000; // 30MHz,根据你的MCU和屏幕支持调整 ret = rt_spi_configure(spi_dev_lcd, &cfg); if (ret != RT_EOK) return -2; // 5. 发送初始化命令序列 lcd_write_cmd(0x01); // Software reset rt_thread_mdelay(150); // ... 发送更多的初始化命令和数据 lcd_write_cmd(0x11); // Sleep out rt_thread_mdelay(120); lcd_write_cmd(0x29); // Display on return 0; } // 写命令(DC线拉低) void lcd_write_cmd(rt_uint8_t cmd) { LCD_DC(0); // 命令模式 rt_spi_send(spi_dev_lcd, &cmd, 1); } // 写数据(DC线拉高) void lcd_write_data(rt_uint8_t data) { LCD_DC(1); // 数据模式 rt_spi_send(spi_dev_lcd, &data, 1); }rt_spi_send是RT-Thread提供的便捷API,它内部会构造消息并调用我们实现的spi_xfer函数。
5.2 核心优化:使用DMA进行整屏刷新
这是体现DMA价值的关键函数。我们不再需要一个像素一个像素地发送,而是将整个帧缓冲区一次性提交给DMA。
// 假设我们有一个帧缓冲区,格式为RGB565 #define LCD_WIDTH 128 #define LCD_HEIGHT 160 static rt_uint16_t lcd_framebuffer[LCD_HEIGHT][LCD_WIDTH]; // 设置显示窗口(用于局部刷新或全屏刷新) static void lcd_set_window(rt_uint16_t x1, rt_uint16_t y1, rt_uint16_t x2, rt_uint16_t y2) { lcd_write_cmd(0x2A); // 列地址设置 lcd_write_data(x1 >> 8); lcd_write_data(x1 & 0xFF); lcd_write_data(x2 >> 8); lcd_write_data(x2 & 0xFF); lcd_write_cmd(0x2B); // 行地址设置 lcd_write_data(y1 >> 8); lcd_write_data(y1 & 0xFF); lcd_write_data(y2 >> 8); lcd_write_data(y2 & 0xFF); lcd_write_cmd(0x2C); // 写GRAM命令 } // 全屏刷新函数(使用DMA) void lcd_flush(rt_uint16_t x1, rt_uint16_t y1, rt_uint16_t x2, rt_uint16_t y2, rt_uint16_t *color_data) { rt_uint32_t size = (x2 - x1 + 1) * (y2 - y1 + 1); struct rt_spi_message msg; // 1. 设置显示窗口 lcd_set_window(x1, y1, x2, y2); // 2. 准备SPI消息 LCD_DC(1); // 进入数据模式 msg.send_buf = (rt_uint8_t *)color_data; // 注意:如果SPI是8位模式,这里需要将16位数据拆成两个8位发送。 // 更高效的做法是配置SPI为16位模式,并将color_data强制转换为rt_uint16_t*,一次发送两个字节。 // 这里假设我们已配置为16位SPI,且msg.send_buf类型匹配。 msg.recv_buf = RT_NULL; msg.length = size * 2; // RGB565每个像素2字节,总字节数 msg.cs_take = RT_TRUE; msg.cs_release = RT_TRUE; msg.next = RT_NULL; // 3. 传输数据!底层spi_xfer函数会根据长度自动选择DMA rt_spi_transfer_message(spi_dev_lcd, &msg); }关键点解析:
- 数据格式与SPI宽度:ST7735接收RGB565数据,每个像素16位。如果SPI配置为8位数据宽度,我们需要将每个16位像素拆成两个8位字节按顺序发送。如果SPI配置为16位数据宽度,我们可以直接将
rt_uint16_t数组的指针传给驱动,效率翻倍。但需要注意MCU的字节序(大端/小端),ST7735通常期望先发送高8位。在STM32(小端)上,直接发送uint16_t变量,低字节在前,可能不符合屏幕要求。一个常见的技巧是:在填充帧缓冲区时,就按照屏幕要求的顺序存储(例如,使用((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)得到的就是一个高字节在前、符合大多数SPI屏要求的RGB565值),然后以16位模式发送这个缓冲区。 rt_spi_transfer_message:这个RT-Thread API会处理片选(CS)的拉低和拉高,并调用底层的spi_xfer。我们的spi_xfer函数内部实现了DMA逻辑。- 局部刷新:
lcd_flush函数支持指定刷新区域,这对于LVGL这类GUI库非常有用,可以实现局部更新,进一步减少数据传输量。
6. 系统集成与测试
驱动写好了,需要把它集成到系统中并测试。
6.1 初始化调用与设备挂载
在RT-Thread的启动流程中(如main.c或专门的设备初始化文件),按顺序进行初始化:
// 1. 初始化HAL库、时钟、GPIO、SPI、DMA(由CubeMX生成的代码完成) // 2. 注册SPI DMA总线 extern SPI_HandleTypeDef hspi1; extern DMA_HandleTypeDef hdma_spi1_tx; stm32_spi_dma_bus_register(&hspi1, &hdma_spi1_tx, "spi1"); // 3. 挂载SPI设备到总线,并指定软件片选引脚 rt_spi_bus_attach_device(rt_spi_bus_device_find("spi1"), "spi10", GPIOA, GPIO_PIN_4); // CS引脚PA4 // 4. 初始化LCD lcd_init(); // 5. 初始化GUI(如LVGL)并绑定刷新函数 // ...6.2 性能测试与对比
编写一个简单的测试任务,循环用纯色填充屏幕,或者绘制一些图形,观察系统响应。
static void lcd_test_thread_entry(void *parameter) { rt_uint16_t color = 0xF800; // 红色 while(1) { // 方法1:使用旧的逐点绘制(非DMA) // for(int y=0; y<LCD_HEIGHT; y++) for(int x=0; x<LCD_WIDTH; x++) lcd_draw_point(x, y, color); // 方法2:使用新的DMA刷屏 for(int i=0; i<LCD_HEIGHT*LCD_WIDTH; i++) { lcd_framebuffer[0][i] = color; } lcd_flush(0, 0, LCD_WIDTH-1, LCD_HEIGHT-1, (rt_uint16_t*)lcd_framebuffer); color = (color == 0xF800) ? 0x07E0 : 0xF800; // 红绿切换 rt_thread_mdelay(500); // 观察切换是否流畅,同时可以点灯或打印,看系统是否卡顿 } }实测对比:使用DMA后,在刷屏的几百毫秒期间,点灯任务或串口打印任务几乎不受影响,系统响应迅速。而使用轮询刷屏时,其他任务会有明显的停顿。
7. 常见问题、排查技巧与深度优化
在实际操作中,你几乎一定会遇到下面这些问题。
7.1 DMA传输不启动或数据错乱
- 症状:屏幕全黑、花屏、只有部分区域更新。
- 排查步骤:
- 检查DMA和SPI时钟:确保在CubeMX和代码中,SPI和对应的DMA外设时钟已使能(
__HAL_RCC_SPI1_CLK_ENABLE(),__HAL_RCC_DMA1_CLK_ENABLE())。 - 检查内存地址对齐:确保你传递给DMA的源数据缓冲区(如
lcd_framebuffer)的地址是4字节对齐的。可以使用__attribute__((aligned(4)))定义数组,或者使用RT-Thread提供的内存分配函数(如rt_malloc_align)。 - 检查DMA数据宽度:确保
hdma_tx.Init.MemDataAlignment和PeriphDataAlignment与你的数据缓冲区类型和SPI数据寄存器宽度匹配。如果SPI是8位,内存是uint16_t数组,那么内存对齐应该是DMA_MDATAALIGN_HALFWORD,外设对齐是DMA_PDATAALIGN_BYTE,并且传输数量(send_len)应该是像素数*2。 - 检查中断优先级:DMA中断和SPI中断的优先级需要合理设置,避免被其他高优先级中断长时间阻塞。确保DMA传输完成中断能及时被响应。
- 使用逻辑分析仪或示波器:这是最直接的调试手段。抓取SCK、MOSI、CS、DC波形,看数据传输的时序、内容和长度是否符合预期。检查DMA传输期间,CS是否一直保持有效(低电平)。
- 检查DMA和SPI时钟:确保在CubeMX和代码中,SPI和对应的DMA外设时钟已使能(
7.2 屏幕显示错位或颜色错误
- 症状:图像偏移、颜色红蓝对调、颜色失真。
- 排查步骤:
- 检查字节序:这是最常见的问题。STM32是小端模式,而SPI是MSB先行。确保你组合RGB565值时,高字节对应R[4:0]G[5:3],低字节对应G[2:0]B[4:0]。或者,在发送前对每个
uint16_t进行字节交换(__REV16指令或手动交换)。 - 检查SPI模式:ST7735通常使用SPI模式0(CPOL=0, CPHA=0)或模式3。务必与屏幕数据手册一致。用逻辑分析仪看时钟极性和相位。
- 检查显存数据:在调用
lcd_flush前,将帧缓冲区的头几个像素数据通过串口打印出来,确认其值是否正确。 - 检查窗口设置:确认
lcd_set_window函数的参数计算正确,特别是结束坐标x2, y2应该是x1+width-1和y1+height-1。
- 检查字节序:这是最常见的问题。STM32是小端模式,而SPI是MSB先行。确保你组合RGB565值时,高字节对应R[4:0]G[5:3],低字节对应G[2:0]B[4:0]。或者,在发送前对每个
7.3 系统运行不稳定或死锁
- 症状:程序运行一段时间后卡死,或者DMA只工作一次。
- 排查步骤:
- 检查信号量使用:确保DMA完成中断回调中成功释放了信号量。可以在回调函数里加一个LED翻转或者串口打印来确认。
- 防止重入:确保
spi_xfer函数是线程安全的。我们的dma_busy标志和信号量提供了基本保护。但如果有多线程同时访问同一个SPI设备,需要考虑更严格的锁(如互斥锁)。 - 堆栈大小:DMA传输大量数据时,中断回调函数在中断上下文执行。确保中断不会导致栈溢出。适当增大相关任务的堆栈。
- DMA传输完成标志清理:在DMA传输完成后,确保清除了相应的中断标志位(
__HAL_DMA_CLEAR_FLAG)。HAL库通常在中断服务程序里处理了,但双重检查是好的。
7.4 深度优化技巧
- 双缓冲(Ping-Pong Buffer):这是高级技巧。准备两个帧缓冲区A和B。当DMA正在从缓冲区A读取数据刷屏时,CPU可以同时向缓冲区B绘制下一帧图像。当DMA完成A的传输后,立即切换至传输B,同时CPU开始绘制A。这几乎可以完全消除CPU等待DMA的时间,实现最高帧率。实现起来需要更精细的中断和缓冲区管理。
- SPI时钟最大化:在保证屏幕能稳定接收的前提下,尽量提高SPI时钟频率。ST7735S通常最高支持~30MHz。同时,优化PCB布局,缩短走线,有助于提高通信稳定性。
- 使用硬件流控(如果支持):有些高级MCU的SPI支持硬件流控(如STM32的NSS引脚在从机模式下的用法),可以更精确地控制数据传输节奏,但在主机驱动LCD的场景下较少使用。
- 将显存放在DTCM或SRAM中:对于高性能MCU(如STM32H7),将频繁访问的帧缓冲区放在最快的TCM或SRAM中,可以显著提升DMA读取速度和CPU填充速度。
8. 总结与展望
走到这里,一个基于RT-Thread和DMA的高性能ST7735驱动就算完成了。回顾整个过程,核心在于理解DMA“解放CPU”的思想,以及如何在RT-Thread的设备驱动框架下,将HAL库的DMA功能优雅地封装起来,并通过信号量实现任务同步。
这套驱动不仅仅适用于ST7735,其架构可以轻松移植到其他SPI接口的器件上,如OLED屏、Flash存储器、传感器等,只要它们有大量数据吞吐的需求。更重要的是,它为我们接入更复杂的图形库(如LVGL、uGFX)铺平了道路。这些GUI库的“刷屏”回调函数(flush_cb)可以直接调用我们的lcd_flush函数,从而享受到DMA带来的流畅体验。
在实际项目中,我建议将驱动进一步模块化,通过RT-Thread的ENV工具将其制作成一个软件包(package),方便在不同的BSP之间复用。同时,可以将帧缓冲区管理、颜色格式转换等功能也抽象出来,让驱动更加通用和强大。
最后,调试此类驱动,逻辑分析仪是你的最佳伙伴。它不仅能帮你验证时序,更能直观地看到DMA传输和CPU执行的并行关系,让你对系统运行状态有更深刻的理解。希望这篇长文能帮你少走弯路,顺利踏上RT-Thread和DMA的进阶之路。