ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32F767+ILI9341 SPI DMA刷屏优化实战

STM32F767+ILI9341 SPI DMA刷屏优化实战

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

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

第一次把 ILI9341 接到 STM32F767 上,我用的是最朴素的轮询写法:填一个像素,等标志位,再填下一个。320×240 的屏,刷一屏要几百毫秒,滑动界面像放幻灯片。后来把 SPI 时钟拉到 50MHz 以上,发现速度并没有线性提升——瓶颈根本不在 SPI 时钟,而在 CPU 被逐像素的等待拖死了。这个项目的核心就一句话:用 SPI + DMA 把 CPU 从刷屏里解放出来,让 F767 的硬件搬运能力替你去喂屏。适合手里有 F767 开发板、9341 屏,正在被刷屏速度折磨的嵌入式开发者;也适合想搞明白 DMA 双缓冲、SPI 时序配置到底怎么落地的人。下面从接线、配置、代码到踩坑,一路拆开讲。

2. 硬件链路与工程骨架:SPI 到底挂在哪、DMA 怎么配

2.1 先确认引脚和 SPI 实例,别急着写代码

F767 的 SPI 外设多,但不是每个都能随便挂 DMA。常见做法是把 9341 挂在 SPI1 或 SPI4 上,因为这两个实例的 TX DMA 请求映射清晰、带宽够用。以 SPI1 为例,典型引脚是 PA5(SCK)、PA7(MOSI)、PA4(CS,软件控制)、PA3(DC,数据/命令选择)、PA2(RST)。注意 9341 只写不读的场景下 MISO 可以不接,省一根线也省配置。

这里有个容易被忽略的点:DC 引脚决定了当前字节是命令还是数据。很多新手把 DC 也交给硬件 SPI 的 NSS 去管,结果发现命令和数据混在一起。正确做法是 DC 用普通 GPIO 软件翻转,在发命令前置低、发数据前置高,DMA 传输期间 DC 必须保持稳定——所以一次 DMA 传输只能搬「纯命令」或「纯数据」,不能混。

工程骨架我一般这样分:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── lcd_9341.h # 屏驱动对外接口 │ │ └── spi_dma.h # SPI+DMA 底层封装 │ └── Src/ │ ├── main.c │ ├── lcd_9341.c # 命令/数据/初始化序列 │ └── spi_dma.c # DMA 启动、完成回调 ├── Drivers/ │ └── STM32F7xx_HAL_Driver/ └── stm32f7xx_it.c # DMA 中断入口

把「屏协议层」和「总线传输层」分开,后面换屏(比如换成 9488)只改 lcd_9341.c,DMA 那层不动。这是二次开发时最省事的分层。

2.2 SPI 参数:时钟极性、相位和分频怎么定

9341 的 SPI 模式是 Mode 0 或 Mode 3,实际用 Mode 0 最稳:CPOL=0、CPHA=1Edge。F767 的 SPI1 挂在 APB2 上,时钟源常见 108MHz,分频到 27MHz 或 54MHz 都能跑,但 9341 手册标称写周期最快约 100ns 量级,实际 54MHz 下多数模块能稳定工作,个别廉价屏在 54MHz 会花屏,降到 27MHz 就好。我一般先上 27MHz 跑通,再往上试。

hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_1LINE; // 只发不收 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL=0 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA=0 hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 108/4=27MHz hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;

参数说明:SPI_DIRECTION_1LINE是单线发送模式,省掉接收逻辑;NSS用软件是因为 CS 由我们自己控制时序;BaudRatePrescaler是分频系数,改这个值就是改刷屏速度,但别一上来就拉到 2 分频,先确认屏能扛住。

2.3 DMA 通道绑定:TX 流和中断要成对出现

SPI1_TX 在 F767 上映射到 DMA2 Stream3 或 Stream5(Channel 3)。用 CubeMX 生成的话它会自动选,手写的话要查参考手册的 DMA 请求映射表。关键配置是:方向内存到外设、字节对齐、普通模式(不是循环)、传输完成开中断。

hdma_spi1_tx.Instance = DMA2_Stream3; hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3; hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址自增 hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_spi1_tx.Init.Mode = DMA_NORMAL; hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;

MemInc必须开,否则 DMA 永远只搬第一个字节;Mode用 NORMAL,因为每次刷屏是独立的一次传输,循环模式适合持续采样的场景,这里用不上。中断里只做一件事:清标志、置完成位,别在中断里做耗时操作。

3. 核心代码拆解:一次刷屏从命令到像素的完整链路

3.1 命令与数据分离:DC 引脚的控制时机

9341 的协议是「先发命令字节,再发参数或像素数据」。用 DMA 之后,DC 的翻转必须发生在 DMA 启动之前,而不是传输中途。所以封装两个函数:一个发命令(DC=0),一个发数据(DC=1),各自内部启动 DMA 并等待完成。

static void lcd_write_cmd(uint8_t cmd) { LCD_DC_LOW(); // DC=0 表示命令 LCD_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, &cmd, 1); while (dma_busy) { } // 等这一字节发完 LCD_CS_HIGH(); } static void lcd_write_data(const uint8_t *buf, uint32_t len) { LCD_DC_HIGH(); // DC=1 表示数据 LCD_CS_LOW(); dma_busy = 1; HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)buf, len); while (dma_busy) { } // 大数据块靠 DMA 搬 LCD_CS_HIGH(); }

逻辑说明:单字节命令其实用轮询更快,因为 DMA 启动本身有开销;只有像素这种大块数据才值得上 DMA。dma_busy在 DMA 完成回调里清零,主循环靠它做同步。参数len是字节数,注意 9341 的像素是 16 位色,一个像素两个字节,传之前要保证缓冲区按字节组织。

3.2 设置窗口:一次命令 + 四个参数

刷一块区域前要先设行列地址窗口,这是 9341 的标准流程:发 0x2A(列地址)带 4 个参数,发 0x2B(行地址)带 4 个参数,再发 0x2C 进入写显存模式,之后连续发像素。

void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { uint8_t param[4]; lcd_write_cmd(0x2A); // 列地址设置 param[0] = x0 >> 8; param[1] = x0 & 0xFF; param[2] = x1 >> 8; param[3] = x1 & 0xFF; lcd_write_data(param, 4); lcd_write_cmd(0x2B); // 行地址设置 param[0] = y0 >> 8; param[1] = y0 & 0xFF; param[2] = y1 >> 8; param[3] = y1 & 0xFF; lcd_write_data(param, 4); lcd_write_cmd(0x2C); // 进入写显存 }

参数说明:x0/y0 是左上角,x1/y1 是右下角,9341 的坐标是包含端点的。这里每个参数都拆成高低字节,因为 SPI 是 8 位传输。窗口设好后,后续所有像素数据会自动按窗口顺序填充,不用再发地址。

3.3 整屏填充:DMA 一次搬完一屏像素

有了窗口和 DMA,整屏填充就变成「设窗口 + 发一大块数据」。320×240×2 字节约 150KB,F767 的 SRAM 够放,但更省内存的做法是分块:每次搬若干行,用同一块缓冲区。

#define BUF_LINES 16 static uint8_t line_buf[320 * BUF_LINES * 2]; void lcd_fill_color(uint16_t color) { uint32_t total = 320 * 240; uint32_t done = 0; uint16_t c = (color >> 8) | (color << 8); // 9341 是大端 lcd_set_window(0, 0, 319, 239); while (done < total) { uint32_t n = (total - done > 320 * BUF_LINES) ? 320 * BUF_LINES : (total - done); for (uint32_t i = 0; i < n; i++) { line_buf[i * 2] = c >> 8; line_buf[i * 2 + 1] = c & 0xFF; } lcd_write_data(line_buf, n * 2); done += n; } }

逻辑说明:c做了字节序交换,因为 9341 期望高字节先到;分块大小BUF_LINES决定单次 DMA 传输量,16 行约 10KB,兼顾速度和内存占用。如果追求极限,可以把整屏缓冲放外部 SDRAM,一次 DMA 搬完,但多数场景分块已经够快。

3.4 完成回调与忙标志:别在中断里做重活

DMA 传输完成会进HAL_SPI_TxCpltCallback,这里只清忙标志。如果在这里直接启动下一次传输,容易和 HAL 内部状态打架,稳妥做法是置标志、回主循环再启动。

volatile uint8_t dma_busy = 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { dma_busy = 0; // 只清标志,不做其他事 } }

参数说明:dma_busy用volatile修饰,因为它在中断和主循环之间共享,编译器不能优化掉。主循环里的while (dma_busy)就是靠它做同步,简单但有效。

4. 避坑与排查:刷屏花屏、卡死、速度上不去的真实原因

4.1 花屏或颜色错乱:字节序和窗口没对齐

现象:填充纯色正常,但显示图片时颜色像被拆开,红蓝互换或像素错位。原因通常是两个:一是像素字节序没交换,9341 默认大端;二是窗口设置的 x1/y1 超出了实际范围,导致数据写到窗口外回绕。解决:统一在写像素前做高低字节交换,窗口坐标严格限制在 0~319 和 0~239 内,并在lcd_set_window里加断言。

4.2 DMA 传输只发了一个字节就停

现象:调用HAL_SPI_Transmit_DMA后,只看到第一个字节出去,后面没了。原因多半是MemInc没开,或者 DMA 通道和 SPI 实例没对上(比如 SPI1 配到了 DMA1 的流上)。解决:查参考手册的 DMA 请求映射表,确认 Stream 和 Channel 组合;检查Init.MemInc = DMA_MINC_ENABLE。

4.3 刷屏过程中程序卡死

现象:刷几屏后死机,调试器停在while (dma_busy)。原因通常是 DMA 完成中断没使能,或者中断优先级被其他外设挡住,导致dma_busy永远不清。解决:确认HAL_NVIC_EnableIRQ(DMA2_Stream3_IRQn)已调用,中断优先级别设得比 SysTick 还低;也可以在等待循环里加超时,超时后手动复位 SPI 和 DMA。

4.4 提高 SPI 时钟后反而更慢或不稳

现象:分频从 4 改到 2,刷屏速度没提升,偶尔花屏。原因:9341 模块的排线、电平转换芯片有上限,54MHz 下信号完整性变差;另外 F767 的 SPI 在高速下对 GPIO 速度等级有要求。解决:把相关 GPIO 的Speed设为GPIO_SPEED_FREQ_VERY_HIGH,先降到 27MHz 验证功能,再逐步往上试,别一步到位。

4.5 用 HAL 延时导致帧率上不去

现象:功能都对,但整屏刷新还是慢。原因:初始化序列里用了HAL_Delay,刷屏循环里也夹了延时。解决:初始化延时保留(屏上电需要),刷屏路径里一个延时都不要有,同步全靠 DMA 完成标志。

5. 二次开发:把刷屏封装成可复用的图层接口

5.1 从「填色」到「贴图」:加一层显存抽象

直接调lcd_write_data写像素,代码会散得到处都是。我一般加一层小显存:维护一块 320×240 的 16 位缓冲,所有绘图操作(画点、画线、贴图)都写这块内存,最后统一lcd_flush()用 DMA 推给屏。这样上层逻辑和总线彻底解耦,换屏只改 flush 实现。

static uint16_t framebuf[320 * 240]; void fb_set_pixel(int x, int y, uint16_t color) { if (x < 0 || x >= 320 || y < 0 || y >= 240) return; framebuf[y * 320 + x] = color; } void lcd_flush(void) { lcd_set_window(0, 0, 319, 239); lcd_write_data((uint8_t *)framebuf, 320 * 240 * 2); }

参数说明:framebuf占 150KB,F767 的 DTCM 有 128KB、SRAM1 有 368KB,放 SRAM1 没问题;如果内存紧张,可以只缓冲脏矩形区域,用lcd_flush_area(x0,y0,x1,y1)局部刷新。

5.2 局部刷新:只推变化的区域

全屏 flush 一次 150KB,27MHz 下约 45ms,也就是 20 帧出头。如果界面只有小区域在变(比如进度条、数字),只刷那块区域能轻松上 60 帧。做法是记录脏矩形,flush 时只设对应窗口、只搬对应行。

void lcd_flush_area(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_set_window(x0, y0, x1, y1); for (uint16_t y = y0; y <= y1; y++) { lcd_write_data((uint8_t *)&framebuf[y * 320 + x0], (x1 - x0 + 1) * 2); } }

逻辑说明:逐行搬是为了避开 framebuf 里行间不连续的问题;每行一次 DMA,行数不多时开销可接受。如果追求更少 DMA 启动次数,可以把脏区域先拷到连续缓冲再一次发。

5.3 验证方法:用示波器和帧计时双确认

改完之后怎么确认真的快了?两个手段:一是用 GPIO 翻转做帧计时,flush 前后各翻一次,示波器看高电平宽度就是单帧耗时;二是在主循环里用 DWT 周期计数器统计每秒 flush 次数。别只凭肉眼感觉「好像快了」,数据说话。

配置单帧耗时(实测参考)备注
轮询 27MHz约 300msCPU 全程占用
DMA 27MHz 全屏约 45msCPU 空闲
DMA 27MHz 局部约 5ms小区域刷新
DMA 54MHz 全屏约 25ms需信号质量过关

表格里的数字是量级参考,实际随屏模块和布线浮动,但趋势是明确的:DMA 解决的是 CPU 占用,局部刷新解决的是数据量,两者叠加才是高速刷屏的正解。

5.4 一个具体技巧:双缓冲切换避免撕裂

如果界面有动画,单缓冲会出现「上半屏新画面、下半屏旧画面」的撕裂。做法是开两块 framebuf,绘制在后台缓冲,画完一次性 flush 再交换指针。代价是内存翻倍,F767 扛得住;如果内存不够,退而求其次用局部刷新缩小撕裂窗口。

static uint16_t *front = buf_a; static uint16_t *back = buf_b; void fb_swap(void) { uint16_t *t = front; front = back; back = t; lcd_flush_from(front); // 把刚画好的后台缓冲推上去 }

参数说明:front是当前显示内容,back是绘制目标;fb_swap里先交换再 flush,保证推上去的是完整一帧。注意 flush 期间不要再改front,否则又会出现不一致。

这套东西我最早是在一个手持设备项目里踩出来的,当时为了省事直接在中断里启动下一次 DMA,结果偶发死机查了两天。从那以后我每次改 DMA 相关代码,都强制先用示波器量一遍完成信号,再上整机跑。希望这些能帮到你,少走几晚调试的弯路。

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

返回列表