STM32硬件SPI+DMA+外部Flash驱动0.99寸圆形TFT屏
2026/9/2 1:24:17 网站建设 项目流程

简介:面向 STM32 进阶开发者的显示方案资源包,以 STM32F103RCT6 驱动 0.99 寸 TFT 圆屏为项目载体,重点演示硬件 SPI 与 DMA 配合、外部 FLASH W25Q64 存储图片并高速刷屏的实现方法。项目先利用 SPI 接口将图片写入外部 Flash,显示时再通过 DMA 搬运至控制器 RAM 并由圆屏呈现,可有效降低 CPU 占用,适合学习嵌入式外设联动、存储扩展与显示优化的读者。资源共 195 个文件,压缩包约 3.65 MB;以 h 头文件、c 源码为主,另含 uvprojx/uvoptx 工程配置、axf/hex 烧录文件、map/lst 编译输出及 bat 脚本,可在 Keil 中直接打开工程对照学习。已有 592 人浏览学习。通过该包可掌握硬件 SPI+DMA 降低 CPU 负载的数据搬移思路、W25Q64 外部存储扩展图片容量的流程,以及圆屏在紧凑型设备中的接入方式;工程结构较完整,便于后期改造移植,适合边阅读代码边调试,作为中高级 STM32 屏显项目及实际产品原型开发的参考资料。 昨天把一个吃灰半年的0.99寸圆形TFT屏从抽屉里翻了出来,顺手接到了STM32F103RCT6上。接的时候我想,要是还用软件模拟SPI刷图,这块屏基本就是一块漂亮的幻灯片——刷一帧能卡掉半条命,全程CPU占用拉满。于是这次我没走老路,直接把硬件SPI、DMA、外部SPI NOR FLASH三样东西组合在一起,做了一套完整的“先存片、后刷片”显示方案。这篇就记录一下整个项目的硬件接线、驱动配置、图片存储和排坑过程,给想玩小圆屏的人一个能直接抄作业的参考。

1. 项目概述与整体设计思路

1.1 硬件型号与资源盘点

先说主控。STM32F103RCT6是LQFP64封装的Cortex-M3,主频72MHz,片上Flash 256KB、SRAM 48KB,外设资源对这个小项目来说完全够用。最关键的是它有两个硬件SPI(SPI1和SPI2),SPI1挂在APB2总线上,最高可以跑到18Mbit/s(在不超频的情况下72MHz/4分频),这个速度虽然比不了现在的H7系列,但驱动一块0.99寸小圆屏绰绰有余。

屏是0.99寸圆形TFT,分辨率常见的是240×240,驱动IC以GC9107居多,也有部分批次是ST7735S。这类屏的接口就是标准的4线SPI加几个控制脚:SCK、SDA(MOSI)、RES、DC、CS、BLK。注意,这种小尺寸屏一般只写不读,所以MISO引脚通常不接,但要是FLASH也要挂在同一条SPI总线上,MISO就得留出来。

存储用的是W25Q64,一颗8Mbit(也就是1MB)的SPI NOR FLASH。为什么选它:便宜、好买、驱动代码网上遍地都是,而且SPI NOR支持按字节随机读,当作图片资源库非常合适。

1.2 为什么非要用硬件SPI+DMA+外部FLASH

这个组合不是炫技,是被实际需求逼出来的。

  • 软件模拟SPI刷240×240的RGB565图片,一帧要传输115200字节,GPIO翻转速度撑死几Mbit/s,刷一帧画面在200ms以上,肉眼可见的撕裂和闪烁,CPU还没法干别的。
  • STM32F103RCT6片内Flash只有256KB,存代码还好,再塞几张全屏图片就捉襟见肘,改一张图还得重新刷固件,太痛苦。
  • 刷长图、做界面切换、放动画帧,需要大量连续数据,这部分数据放外部FLASH,主控只负责“读出来、送出去”,不占用宝贵的内存。

所以整体思路拆成三层:硬件SPI负责和屏幕对话,DMA负责搬运数据,外部FLASH负责囤图。CPU只是在启动时把图片从FLASH读到内存缓冲,再触发一次DMA传输,剩下时间可以去处理按键、传感器或者干脆睡大觉。

1.3 适合谁参考

这个项目适合已经会GPIO点灯、串口打印、基础定时器的人,想往“高性能显示”迈一步的玩家。通过这个项目你能顺带搞明白SPI时序怎么配、DMA传输怎么触发、NOR Flash怎么分区块管理,这几个知识点以后做任何屏幕项目都用得上。

2. 硬件连接与电路设计

2.1 引脚分配与连接说明

我把TFT和W25Q64挂在同一条SPI1总线上,各用各的片选线。这个做法的好处是少占用一组SPI外设,坏处是驱动上要小心处理好CS切换时序,稍后会说。

引脚分配如下:

功能引脚说明
SPI1_SCKPA5时钟线,接屏SCK和W25Q64的CLK
SPI1_MOSIPA7主出从入,接屏SDA和W25Q64的DI
SPI1_MISOPA6主入从出,接W25Q64的DO,读FLASH用
TFT_CSPA4屏幕片选,低有效
TFT_DCPB0数据/命令选择:高电平数据,低电平命令
TFT_RSTPB1复位脚,低电平复位
TFT_BLKPB10背光控制,PWM或直接高电平点亮
FLASH_CSPB12W25Q64片选,低有效

连接时要特别注意SCK和MOSI不要接反,很多小屏模块上丝印会标成SCL和SDA,其实和SPI的SCK、MOSI是一回事。DC和CS不要共用一个引脚,虽然某些极限压缩引脚的玩法会这么干,但代价是每次读写都要手动翻转DC,逻辑复杂且容易出错。

2.2 电源与去耦注意事项

0.99寸圆屏背光全亮时电流大概在20~40mA,W25Q64读写时几mA,主控全速跑时几十mA,整体功耗不大,但从USB取电或者用AMS1117降压供电都要注意稳压后的纹波。

我的做法是:VCC引脚就近放一个0.1uF陶瓷电容,电源入口再放一个10uF钽电容或者电解电容。不要小看这两个电容,背光PWM调光时如果电源纹波大,屏幕上会出现规律性横条纹,排查起来非常折腾。另外,所有信号线尽量短,杜邦线长度超过15cm后SPI速度就只能往下降,这一点在刷屏花屏时是最常见的元凶。

2.3 W25Q64的WP和HOLD引脚

W25Q64的引脚不算多,但有两个人容易忽略:WP(写保护)和HOLD(挂起)。这两个脚内部虽然都有上拉,但稳妥起见我还是建议外部各加一个10K上拉到3.3V。尤其是HOLD脚,如果悬空时受到干扰被拉低,SPI通信会直接挂起,表现就是这个设备莫名其妙不响应,重新上电又好了,极具迷惑性。

3. SPI+DMA驱动层原理与配置

3.1 SPI时序参数怎么定

GC9107这颗驱动IC的写入时序要求不复杂,SPI模式0和模式3都支持,CPOL=0、CPHA=0是最常用的。字节顺序MSB先行,8位数据宽度,这些在ST官方和屏厂给的例程里都是一样的,直接照抄即可。

时钟分频我选的是4分频,也就是72MHz/4=18MHz。理论上F103的SPI1极限就是18MHz,实际跑下来刷纯色块非常稳,但接长杜邦线后建议降到8分频(9MHz)换取稳定性。时钟频率不是越高越好,波形边沿变差、信号反射变大,都可能造成数据错误。判断办法很简单:刷一幅带斜线和文字的图片,看边缘有没有毛刺或随机杂色点。

3.2 DMA配置:传输模式和句柄关联

F103的SPI1发送对应DMA1的Channel3,接收对应DMA1的Channel2。我这个项目主要用发送方向,DMA配置为:外设地址固定为SPI1->DR,内存地址递增,数据宽度8位,传输模式用Normal(一次性传输),方向为内存到外设。

这里特别提醒一个HAL库的坑:如果用CubeMX生成代码,DMA句柄必须和SPI句柄关联起来,否则调用HAL_SPI_Transmit_DMA会直接报错。关联代码长这样:

__HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);

这句话的意思是告诉SPI外设:“你的发送DMA通道是hdma_spi1_tx”。很多人忘了这一行,或者手动写代码时顺序不对,导致DMA始终不触发,现象就是HAL_SPI_Transmit_DMA返回HAL_BUSY,屏幕死活不刷新。

3.3 完整DMA发送流程代码

void LCD_WriteData_DMA(uint8_t *buf, uint16_t len) { while (HAL_SPI_GetState(&hspi1) != HAL_SPI_STATE_READY); // 等待上一次传输结束 HAL_SPI_Transmit_DMA(&hspi1, buf, len); }

这段代码看起来简单,但有两个关键点:

  • 每次调用前必须确认SPI状态是READY,否则数据会丢失或错位。
  • 刷整屏时115200字节一次传完,SRAM只有48KB,根本放不下整帧,所以实际使用时分批传输,比如一次传8KB,循环15次才算刷完一屏。

DMA传输完成可以用HAL_SPI_TxCpltCallback回调通知,在回调里置一个标志位就行,千万不要在中断回调里做耗时操作,比如继续读FLASH、翻转DC脚,这些动作放到主循环里做。

3.4 实测性能对比

软件模拟SPI刷一帧240×240的数据大约在200~300ms,CPU占用接近100%。硬件SPI不用DMA,用阻塞式HAL_SPI_Transmit,刷一帧大约60ms,但CPU被占满。上了DMA之后,传输一帧约50~60ms,CPU基本放空,这个性能差距在界面上是能明显感知的:同样做一个按钮按下反色效果,软模拟会卡顿,DMA方案能做到流畅。

4. 外部FLASH的图片存储与管理

4.1 为什么选SPI NOR FLASH而不是NAND

标题里出现了外部FLASH,这里展开说一下选型逻辑。NAND FLASH容量大、单位成本低,但有坏块、需要ECC校验、读写以页为单位,单片机裸机跑起来麻烦。SPI NOR FLASH虽然容量小,但胜在随机读取方便——你随时可以从任意地址读任意长度的字节,非常适合存储“图片素材库”这种小颗粒数据。W25Q64的扇区擦除单位是4KB,页编程单位是256字节,设计分区时按4KB对齐就行。

4.2 图片取模与bin文件制作

屏幕是RGB565格式,每个像素2字节。一副240×240的图片裸数据大小是115200字节,也就是约112.5KB。1MB的W25Q64理论上能塞8张整幅图片,实际扣除分区和管理信息后放6张比较合理。

取模我用的是Image2Lcd这个工具,参数设置如下:

  • 输出格式:C数组或Bin文件
  • 像素格式:RGB565
  • 扫描方向:水平扫描(从左到右,从上到下)
  • 最大宽度和高度:240×240
  • 包含文件头:不勾选

这里有个坑:Image2Lcd的“灰度/颜色”选项如果选错,出来的数据会整幅偏色或反相。RGB565是小端存储,也就是16位数据先存低字节再存高字节,如果屏幕刷出来颜色错乱,优先检查取模时是否勾选了“字节交换”。

4.3 图片烧录方案与分区管理

图片数据怎么进W25Q64?最简单省事的方法:把图片bin文件转成C数组,编译进固件,程序启动时检测FLASH里的“魔法数字”(比如前4字节固定为0xAA55A55A),如果不是就执行一次全片擦除并写入图片数据,写完后再写魔法数字,这样以后每次上电只用读FLASH,不用重复写入。

分区表我建议这样安排:

起始地址大小用途
0x0000004KB头部信息:图片数量、启动标志
0x001000128KB图片1
0x021000128KB图片2
0x041000128KB图片3
剩余空间768KB后续可扩展字库、动画帧等

4.4 显示一张外部FLASH图片的完整流程

void LCD_ShowFlashImage(uint32_t addr, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { uint8_t buf[4096]; uint32_t remain = w * h * 2; uint32_t offset = 0; while (remain > 0) { uint32_t chunk = (remain > sizeof(buf)) ? sizeof(buf) : remain; W25Q64_Read(addr + offset, buf, chunk); LCD_WriteData_DMA(buf, chunk); offset += chunk; remain -= chunk; } }

注意一个关键点:W25Q64的读操作和LCD的写操作共用同一个SPI1,二者不能并发。所以每个分块要严格串行:先读FLASH到内存,再把内存通过DMA发给LCD,不能一边读一边发。如果追求极致速度,可以考虑双缓冲乒乓结构:一块缓冲在读FLASH,另一块缓冲在DMA发送,交替使用,能把FLASH读取和LCD刷屏的时间重叠起来,实测大概能再提升15%~20%的帧率。

5. 圆屏显示的特殊处理

5.1 方形像素与圆形屏幕的冲突

很多刚拿到圆屏的人会想:屏是圆的,那我直接在矩形区域刷图不就行了吗?其实不然。TFT的显示驱动芯片并不知道玻璃是圆的,它把整个240×240的矩形像素区域都拉出来了,圆形只是外面的盖板玻璃遮住了四个角。所以如果你在矩形区域刷一张普通图片,四个角的内容会被黑色边框遮挡,效果非常廉价。

要体现出圆屏的价值,就得把图片内容限制在圆形区域内,或者干脆绘制圆形、圆弧、环形仪表界面。

5.2 画圆与填充圆的窗口裁剪法

在TFT上画圆不能直接写一个像素就发送一次,那样效率太低。正确姿势是“逐行约束窗口”:对每一行,根据圆方程算出这一行有效像素的左右边界,然后只对这一行范围做填充。

void LCD_FillCircle(uint16_t cx, uint16_t cy, uint16_t r, uint16_t color) { int16_t y; for (y = cy - r; y <= cy + r; y++) { if (y < 0 || y >= LCD_HEIGHT) continue; int32_t dy = y - cy; int32_t dx = (int32_t)sqrtf(r * r - dy * dy); uint16_t x0 = cx - dx; uint16_t x1 = cx + dx; if (x0 < 0) x0 = 0; if (x1 >= LCD_WIDTH) x1 = LCD_WIDTH - 1; LCD_SetWindow(x0, y, x1, y); LCD_WriteData_DMA(color, x1 - x0 + 1); } }

这段代码的核心思想是:用圆方程约束每行的绘制宽度。每次只开一个高度为1、宽度可变的窗口,写入一段连续颜色数据,比逐像素点要快一个数量级。画圆环的话,只需在外圆范围内再排除内圆的范围,同样是在每行算两个区间做差。

5.3 圆弧与仪表盘绘制的扩展思路

想做仪表盘界面的话,推荐在此基础上扩展成角度扫描。根据角度θ算出弧上点的坐标,然后填充一个从圆心到弧边的“扇形”,或者用圆弧边界做环形高亮。这类绘制在F103上逐行填充完全跑得动,240×240的分辨率下一帧满屏填充大约50ms,做一个简单的动态仪表指针动画完全没有压力。

另外提醒一句:不同厂家的圆屏初始化时序和坐标偏移可能不同,有的屏幕默认原点在左下角,有的屏横竖方向是镜像的。如果画圆画出来是椭圆或者位置偏了,多半不是算法问题,而是初始化代码里的显示偏移没调好,先刷一个边框矩形,观察实际显示范围再调整。

6. 常见问题与排查技巧实录

6.1 屏幕花屏、变色、杂色点

这是最高频的问题。排查顺序我总结成一个固定套路:

现象可能原因处理方法
全屏花屏,颜色随机SPI时钟太快分频从4改8,降到9MHz试试
图片颜色反相/偏绿RGB565字节序不对检查Image2Lcd是否勾选字节交换
刷到一半错位电源纹波大加电容,检查供电线
特定区域乱码接线过长缩短杜邦线,或改用排线焊接
画面整体偏移初始化坐标偏移不对调SetWindow的偏移参数

6.2 DMA传输卡死与SPI状态锁死

调用HAL_SPI_Transmit_DMA后如果返回HAL_BUSY,多半是上一次传输没结束就再次调用。我在项目里加了一个信号量:DMA完成回调里置标志位,主循环里发送前检查标志位。这样既不会丢数据,也不会卡死。

还有一个容易被忽略的坑:DMA传输完成后,SPI的BSY标志可能还没清零,如果立刻切换DC引脚发命令,偶尔会吞掉命令。稳妥做法是在DMA完成回调里加一个小延时,或者等待__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY)为RESET再操作。

6.3 W25Q64读不出数据,ID全0xFF

先确认MISO是不是真的连上了,很多屏模块不引出MISO,如果FLASH单独接线,MISO悬空就会读全1。其次检查WP和HOLD是否上拉,第三个常见问题是FLASH_CS没有初始化成高电平,导致一上电FLASH就被选中,处于未知状态。软件上可以打印JEDEC ID(0xEF4017对应W25Q64),这是最快判断通信是否正常的办法。

6.4 ST-Link下载失败:target DLL has been cancelled

这个报错很多新手遇到直接懵,其实是ST-Link和目标板之间的连接不稳定。常见原因有三类:目标板供电不足,BOOT0跳线帽没有接地,SWDIO/SWCLK线太长或者接触不良。我遇到最邪门的一次是复位引脚上的一颗大电容导致ST-Link握手失败,换小电容或直接去掉就正常了。

解决办法:先确认BOOT0低电平,再把SWD速度从4MHz降到1MHz,最后检查GND线是否牢靠,基本上能解决90%的情况。如果还是不行,把ST-Link拔了重插,或者给板子单独上电。

7. 一点个人的实操体会

这个项目做完之后,我自己最大的感受是:DMA并不是什么高深莫测的东西,它就是一个“配置好之后不用管、搬运完成喊你一声”的工具人。难点不在于DMA本身,而在于你什么时候触发它、触发前数据准备好了没有、触发后下一个动作要等多久。把这些时序理清楚,整个刷屏链路就顺畅了。

另外建议入手一块逻辑分析仪,几十块钱的8通道就够用,抓一下SPI波形,SCK频率、CS时序、数据字节对不对一目了然。比拿来示波器盯波形或者瞎猜要高效得多。

最后再分享一个可以继续扩展的方向:这套“SPI+DMA+外部FLASH”的组合完全可以升级成简易GUI。在W25Q64剩余空间里放一套字库,再把图片替换成图标、按键素材,配合一颗陀螺仪或者编码器,就能做出一个带旋转菜单的圆形智能手表原型。代码框架不用动,只需要加状态机和输入处理,剩下的交给DMA去跑就行。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询