STM32F767+SPI+DMA驱动ILI9341高速刷屏实战

发布时间:2026/10/30 5:26:25
STM32F767+SPI+DMA驱动ILI9341高速刷屏实战

简介:这份资源面向嵌入式开发学习者与显示驱动调试人员,聚焦STM32F767通过SPI结合DMA高速驱动9341 TFT液晶屏的完整工程实现,解决刷屏效率低、CPU占用高的问题。工程围绕54MHz SPI时钟、DMA内存到外设传输、9341初始化序列与20Hz整屏刷新展开,涵盖外设初始化、DMA传输配置、中断处理与主循环数据更新等关键环节,适合具备一定HAL库基础、希望掌握高速刷屏思路的中高级开发者参考。压缩包共1099个文件,以591个c源文件与275个h头文件为主体,辅以51个汇编文件、42个链接脚本及Keil、IAR工程配置与库文件,整体约54.23MB,目录结构完整便于按模块查阅。目前已有2123人学习下载,可据此对照梳理SPI DMA驱动显示器的配置流程与优化方向。

1. 一块 9341 屏刷出 60 帧,靠的不是玄学

第一次把 STM32F767 和 ILI9341 接起来的时候,我用的是最朴素的HAL_SPI_Transmit轮询刷全屏,320×240 分辨率、16 位色,一帧下来接近 150KB 数据,刷一次肉眼可见地卡顿,动画像幻灯片。后来把 SPI 时钟拉到 50MHz 以上、改用 DMA 搬运、再把刷屏拆成按行/按块提交,同一块屏、同一颗 MCU,帧率直接翻了几倍,滑动列表和进度条终于顺了。这套「STM32F767 + SPI + DMA + ILI9341」的高速刷新方案,解决的就是嵌入式 GUI 里最容易被低估的一环:显示带宽。它适合正在用 F7 系列做仪表盘、手持终端、工控面板,被刷屏速度卡住的开发者;也适合想搞明白 DMA 双缓冲、SPI 时序、9341 命令流到底怎么配合的人。下面按「能干什么 → 怎么跑 → 怎么改」拆开讲。

2. 架构拆解:从帧缓冲到屏幕像素的完整链路

2.1 硬件连接与 SPI 选型

这套方案的核心链路是:MCU 内存里的像素数据 → SPI 外设 → DMA 通道 → ILI9341 的显存。F767 有多个 SPI 外设,刷屏一般挑 SPI1 或 SPI4/SPI5,因为它们能挂到较高的 APB 时钟上。接线层面,除了 SCK、MOSI、CS 之外,DC(数据/命令选择)和 RST 是必须单独用 GPIO 控制的,BLK 背光可以接 PWM 做调光。MISO 在纯刷屏场景下可以不接,因为 9341 只写不读也能跑,省一根线。

选型上有几个关键点值得说清楚。第一,SPI 时钟频率不是越高越好,9341 的 datasheet 标称写周期上限大约在 10MHz 量级,但实际工程里很多人跑到 40~60MHz 也能稳定工作,这属于「超规格但常见」的做法,稳定性取决于走线长度、上拉电阻和电源质量。第二,DMA 通道要和 SPI 的收发请求对应上,F7 的 DMA 请求映射表里每个 SPI 的 TX/RX 都有固定通道,配错了现象是「DMA 启动了但一个字节都发不出去」,这个坑后面会细讲。第三,CS 建议用软件控制而不是硬件 NSS,因为 9341 的命令流需要在一帧数据中间保持 CS 有效,硬件自动 NSS 容易在字节间隙拉高。

常见做法是把 DC 引脚的操作封装成宏,在发命令前置低、发数据前置高,配合 DMA 时要注意:DMA 传输期间不能随意翻转 DC,否则数据会被当成命令解析,屏幕直接花屏。

2.2 目录结构与模块划分

一个能跑起来、也方便二次开发的工程,目录大致是这样组织的:

project/ ├── Core/ │ ├── Inc/ │ │ ├── lcd_9341.h // 屏幕驱动对外接口 │ │ ├── lcd_bus.h // SPI/DMA 底层总线封装 │ │ └── gfx.h // 绘图原语(画点、线、矩形、字符) │ └── Src/ │ ├── main.c │ ├── lcd_9341.c // 9341 初始化、命令流、窗口设置 │ ├── lcd_bus.c // DMA 发送、忙等待、DC 控制 │ └── gfx.c // 上层绘图,调用 lcd_bus ├── Drivers/ │ └── STM32F7xx_HAL_Driver/ └── Middlewares/ └── (可选) 字库 / GUI 库

分层意图很明确:lcd_bus只管「把一段内存通过 SPI+DMA 发出去」,不关心发的是命令还是像素;lcd_9341负责 9341 的寄存器序列和坐标窗口;gfx是纯软件绘图,最终都落到「设置窗口 + 连续写像素」这一个动作上。这样分层的好处是,换屏(比如换成 ST7789)只需要改lcd_9341.c,总线层和绘图层基本不动。

2.3 DMA 双缓冲与传输完成回调

高速刷屏的瓶颈往往不在 SPI 时钟,而在「CPU 等 DMA 发完才能准备下一块数据」。解决办法是双缓冲:准备两块缓冲区,DMA 发 A 块的同时 CPU 往 B 块填数据,发完切换。F7 的 DMA 支持传输完成中断,在回调里切换缓冲区并重新启动传输。

// lcd_bus.c 片段:DMA 发送 + 完成回调切换 static uint8_t * volatile dma_buf; // 当前 DMA 正在发送的缓冲 static volatile uint8_t dma_busy = 0; void lcd_bus_send_dma(const uint8_t *data, uint32_t len) { while (dma_busy) { /* 等待上一笔完成,实际工程可换成信号量 */ } dma_busy = 1; dma_buf = (uint8_t *)data; HAL_SPI_Transmit_DMA(&hspi1, dma_buf, len); // 启动 DMA 传输 } // HAL 的 SPI 发送完成回调,DMA 发完自动进来 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { dma_busy = 0; // 释放忙标志,允许下一笔 } }

这段代码的逻辑是:lcd_bus_send_dma先检查忙标志,避免上一笔还没发完就覆盖 DMA 源地址;HAL_SPI_Transmit_DMA把数据指针交给 DMA 控制器后立即返回,CPU 可以继续干活;传输完成时 HAL 回调把dma_busy清零。参数上,len是字节数,注意 9341 是 16 位色时每个像素 2 字节,传像素数据时len要按字节算而不是按像素算,这个换算错了会出现「只刷了半屏」的现象。

提示:忙等待的while (dma_busy)在裸机里能用,但如果工程里跑了 RTOS,建议换成二值信号量,否则高优先级任务可能被这个循环拖住。

2.4 9341 的窗口机制与按块刷新

9341 刷屏不是「随便从哪个坐标开始写」,而是先设置一个矩形窗口(Column Address Set + Page Address Set),然后连续写入的像素会按窗口自动换行填充。这个机制是高速刷新的关键:一次设置窗口,然后 DMA 连续发一整块像素,中间不需要再发命令。

// lcd_9341.c 片段:设置窗口并启动整块传输 void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_write_cmd(0x2A); // Column Address Set lcd_write_data16(x0); lcd_write_data16(x1); lcd_write_cmd(0x2B); // Page Address Set lcd_write_data16(y0); lcd_write_data16(y1); lcd_write_cmd(0x2C); // Memory Write,之后连续写像素 } void lcd_blit(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint16_t *pix) { lcd_set_window(x, y, x + w - 1, y + h - 1); lcd_bus_send_dma((const uint8_t *)pix, w * h * 2); // 2 字节/像素 }

lcd_set_window里 0x2A/0x2B 是列/页地址,0x2C 是写显存命令,发完 0x2C 之后所有数据都被当作像素。lcd_blit把一块矩形区域一次性推过去,w * h * 2是字节数。实际工程里,整屏刷新可以一次 blit 全屏,局部刷新(比如进度条、数字变化)只 blit 变化的小矩形,带宽省下来帧率自然上去。这也是为什么很多仪表盘项目看起来「很流畅」——它们根本没在刷全屏,只刷了变化区域。

3. 跑起来:从零搭建可运行的刷屏工程

3.1 时钟与外设初始化顺序

F767 的 SPI 时钟挂在 APB 总线上,初始化顺序错了会出现「SPI 配置成功但时钟不对」的隐性故障。推荐顺序是:先配系统时钟(HSE/PLL,保证 SPI 所在 APB 达到目标频率),再开 GPIO 和 DMA 时钟,最后初始化 SPI。SPI 的波特率分频要对着 APB 时钟算,比如 APB2 是 108MHz,想要 54MHz 的 SPI 时钟就选 2 分频。

// main.c 片段:初始化顺序 SystemClock_Config(); // 先定系统时钟,SPI 分频才有依据 MX_GPIO_Init(); // DC/RST/CS 引脚 MX_DMA_Init(); // 先开 DMA 时钟和中断 MX_SPI1_Init(); // 最后配 SPI 参数 lcd_init(); // 9341 上电序列

顺序背后的原因是:SPI 初始化时会根据当前 APB 时钟计算分频寄存器,如果系统时钟还没配好就初始化 SPI,分频值会按默认时钟算,最终 SPI 实际速率和你以为的差一大截。这个坑很隐蔽,因为代码不报错,只是「怎么调都快不起来」。

3.2 9341 上电初始化序列

9341 上电后需要一串固定命令才能进入正常显示模式,顺序和延时都有讲究。典型序列包括:软复位(0x01)、睡眠退出(0x11,之后要等 120ms)、像素格式设置(0x3A,设成 0x55 表示 16 位色)、显示开(0x29)。中间还有伽马校正、电源控制等命令,不同批次的屏模组可能略有差异。

void lcd_init(void) { lcd_reset(); // 硬件复位,拉低 RST 再拉高 HAL_Delay(120); lcd_write_cmd(0x01); // 软复位 HAL_Delay(120); lcd_write_cmd(0x11); // 退出睡眠 HAL_Delay(120); // 这个延时不能省 lcd_write_cmd(0x3A); lcd_write_data8(0x55); // 16 位色,RGB565 lcd_write_cmd(0x29); // 开显示 }

0x11之后的 120ms 延时是血泪经验:省掉它,屏幕可能显示正常但偶发花屏,或者第一次刷屏丢前几行。0x3A的参数 0x55 对应 16 位色,如果你传的是 18 位色数据却设了 0x55,颜色会整体偏。初始化完成后建议先刷一屏纯色验证,纯色能正常显示再上图片数据,否则分不清是初始化问题还是数据传输问题。

3.3 编译、下载与首屏验证

工程用 STM32CubeIDE 或 Keil 都能编,关键检查项是:链接脚本里 DMA 缓冲区所在的内存区域是否可被 DMA 访问(F7 的 DTCM 内存 DMA 访问不了,缓冲区要放到 AXI SRAM 或 SRAM1)。下载后首屏验证按这个顺序走:先刷红、绿、蓝三色确认颜色通道没错;再刷一个渐变条确认像素顺序(RGB565 高低字节序反了会变成蓝绿颠倒);最后刷一张小图确认窗口坐标没偏移。

如果首屏是白屏,先查背光和 RST 时序;如果是花屏,查 DC 引脚在 DMA 期间是否被误翻转;如果是颜色不对,查 0x3A 参数和字节序。这三类现象覆盖了 90% 的首次调试问题。

4. 核心代码精读:DMA 传输、字节序与局部刷新

4.1 单字节命令与批量数据的分离

9341 的命令和数据共用 SPI 总线,靠 DC 引脚区分。命令通常只有 1 字节,数据可能是一整块像素。如果命令也走 DMA,每次都要启动一次传输,开销反而大。常见做法是命令走轮询、数据走 DMA。

void lcd_write_cmd(uint8_t cmd) { LCD_DC_LOW(); // DC 低 = 命令 HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); // 单字节轮询,快 } void lcd_write_data8(uint8_t data) { LCD_DC_HIGH(); // DC 高 = 数据 HAL_SPI_Transmit(&hspi1, &data, 1, 10); }

命令用轮询是因为它短,启动 DMA 的固定开销(配置寄存器、等通道空闲)比直接发一个字节还大。数据用 DMA 是因为它长,CPU 不该陪着等。这个「短轮询、长 DMA」的分界是工程里的常见取舍,分界点大概在几十字节量级,低于这个量级 DMA 不一定划算。

4.2 RGB565 字节序与高低位交换

9341 接收 16 位像素时默认高字节在前,而 STM32 是小端,内存里uint16_t的低字节在前。直接把uint16_t数组指针转成uint8_t*发出去,颜色会变成「红蓝互换」或整体偏色。解决办法有两种:发送前做字节交换,或者在 9341 初始化时设置 MADCTL(0x36)的 BGR 位配合数据格式调整。

// 方案一:发送前交换字节(简单但多一次遍历) void lcd_swap_bytes(uint16_t *buf, uint32_t count) { for (uint32_t i = 0; i < count; i++) { buf[i] = (buf[i] >> 8) | (buf[i] << 8); // 高低字节互换 } }

方案一逻辑直观,代价是每次刷屏多一遍内存遍历,全屏 76800 像素就是 76800 次操作,在 216MHz 的 F7 上不算致命但也不免费。方案二是在生成像素数据时就按目标字节序存,绘图函数直接产出「已交换」的格式,省掉运行时遍历。我一般选方案二,把交换成本摊到绘图阶段,刷屏路径上零额外开销。

4.3 局部刷新与脏矩形

全屏刷新是带宽杀手,真正让界面「跟手」的是脏矩形:只重绘发生变化的区域。实现上维护一个「脏区域」矩形,绘图操作时不断合并扩大这个矩形,帧末统一 blit 一次。

static uint16_t dirty_x0, dirty_y0, dirty_x1, dirty_y1; void gfx_mark_dirty(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { if (x < dirty_x0) dirty_x0 = x; if (y < dirty_y0) dirty_y0 = y; if (x + w > dirty_x1) dirty_x1 = x + w; if (y + h > dirty_y1) dirty_y1 = y + h; } void gfx_flush(void) { if (dirty_x1 <= dirty_x0) return; // 无变化直接返回 lcd_blit(dirty_x0, dirty_y0, dirty_x1 - dirty_x0, dirty_y1 - dirty_y0, framebuf + dirty_y0 * LCD_W + dirty_x0); dirty_x0 = LCD_W; dirty_y0 = LCD_H; // 重置脏区域 dirty_x1 = 0; dirty_y1 = 0; }

gfx_mark_dirty把每次绘图的影响范围合并进一个包围盒,gfx_flush只把这一个矩形推给屏幕。参数上要注意framebuf的跨行问题:如果脏矩形不是从 x=0 开始,framebuf + y*LCD_W + x取到的是一行内的起始地址,但lcd_blit按w*h连续读,会读到下一行的数据——所以要么保证 framebuf 里这块区域是连续的(整屏缓冲天然连续),要么按行分多次 blit。这个细节不注意,局部刷新会出现「斜切」错位。

5. 避坑与排查:刷屏翻车的五条血泪记录

5.1 现象:DMA 启动了但屏幕没反应

原因通常是 DMA 通道和 SPI 请求没对上,或者 DMA 缓冲区放在了 DTCM 内存区。F7 的 DTCM 是 CPU 专用高速内存,DMA 控制器根本访问不到,HAL_SPI_Transmit_DMA返回 HAL_OK 但数据永远发不出去。解决:查参考手册的 DMA 请求映射表确认通道,把缓冲区用__attribute__((section(".RAM_D1")))或链接脚本放到 AXI SRAM。

5.2 现象:刷屏偶发花屏、前几行错位

多半是 9341 退出睡眠(0x11)后的延时不够,或者 DMA 传输期间 DC 引脚被其他代码翻转。前者补足 120ms 延时;后者检查是否有中断服务程序在操作同一个 GPIO,必要时在 DMA 传输期间关掉相关中断。

5.3 现象:颜色整体偏蓝或红蓝互换

RGB565 字节序问题,或者 0x3A 设成了 18 位色却发 16 位数据。先确认 0x3A 参数是 0x55,再检查发送前有没有做字节交换。如果只有图片偏色而纯色正常,基本可以锁定是图片数据的字节序问题。

5.4 现象:帧率上不去,CPU 占用却很高

检查是不是每次刷屏都在做全屏 blit,以及while (dma_busy)忙等待是否拖住了主循环。改成脏矩形 + 信号量通知,CPU 占用会明显下降。另一个隐藏点是 SPI 分频算错,实际速率只有预期的一半,用逻辑分析仪抓 SCK 频率一测便知。

5.5 现象:局部刷新出现斜切错位

lcd_blit按连续内存读,但脏矩形不是整行时源地址跨行不连续。解决是按行拆分多次 blit,或者维护整屏 framebuf 并保证 blit 的源区域在内存里连续。这个坑在只刷小数字、小图标时最容易暴露。

6. 二次开发:把刷屏方案改成你自己的显示层

拿到这套代码后,最值得先动的是总线层和绘图层的解耦。我一般会把lcd_bus抽象成一组函数指针(send_cmd、send_data、blit),这样换屏、换总线(SPI 换 FSMC/并口)时上层gfx一行不改。F767 本身有 LTDC 外设,如果项目对帧率要求更高,可以把 9341 换成带并口的屏走 LTDC + SDRAM 帧缓冲,这套gfx的脏矩形逻辑可以直接复用,只是blit的实现从 SPI DMA 换成内存拷贝。

第二个改造点是字库和图片资源。现在很多工程把字库放外部 Flash,刷字符时先读 Flash 再 blit,读 Flash 的 SPI 和刷屏的 SPI 如果共用总线会互相阻塞。常见做法是字库缓存到内部 SRAM 的热区,或者用双 SPI 分开。第三个点是和 GUI 库对接,如果上 LVGL,把lcd_blit注册成flush_cb,脏矩形逻辑交给 LVGL 管理,你只需要保证flush_cb里启动 DMA 后尽快返回,并在传输完成回调里调lv_disp_flush_ready。

验证改造是否成功,我习惯用三个指标:全屏刷新耗时(用 DWT 周期计数器测)、局部刷新 CPU 占用、连续刷 1000 帧有无花屏。前两个看性能,第三个看稳定性。有一次我为了省事把 DMA 缓冲区和栈放同一个内存段,单帧测试全过,连续刷几分钟后偶发花屏,查了两天才定位到是 DMA 和栈抢同一块 SRAM。从那以后我每次改内存布局,都强制用逻辑分析仪抓一段连续刷屏的 SCK 和 DC 波形再收工。希望这套拆解能帮到你,少走几个我踩过的坑。

本文还有配套的精品资源,点击获取