☰
STM32驱动0.99寸圆屏:硬件SPI+DMA稳定刷新实战
2026/10/13 12:20:44 网站建设 项目流程

简介:STM32F103RCT6驱动0.99寸TFT圆屏的硬件SPI+DMA显示工程,面向具备一定STM32基础、希望解决屏幕刷新速度瓶颈的中级开发者。压缩包共218个文件,大小仅5.66MB,其中核心为41个C和40个H源码,并包含keil工程配置(uvprojx/uvoptx)、编译生成的hex/axf固件、map/lst/lnp等链接映射文件,以及bat清理脚本和备份文件,目录结构接近原始工程,便于按需查阅。已有547人学习。资料完整演示了硬件SPI配合DMA的初始化流程与显示驱动写法,实测动态图片刷新速度较快,可满足日常显示需求;作者同时指出主控内存有限、仅适合图片数量较少场景的局限。基于该源码,读者可快速迁移到同系列芯片,也可用于学习DMA传输、圆屏驱动或SPI时序优化。

1. 一块 0.99 寸圆屏,为什么值得折腾硬件 SPI + DMA

做嵌入式的小玩具做到一定阶段,总绕不开一个诉求:给设备加一块小屏,既能显示状态,又要把刷新做得流畅。STM32F103RCT6 配 0.99 寸 TFT 圆屏,是 stm32 驱动 spi 显示屏里性价比很高的一套组合——屏幕便宜、主控余量足,真正考验人的是显示链路能不能保持稳定。很多人点亮过屏幕就停下来了,一刷全屏就花屏、DMA 传输一次就罢工。这篇文章想解决的问题很直接:如何让这块圆屏在硬件 SPI + DMA 下稳定跑起来,并且把刷新率、局部更新、圆形裁剪这些中级玩法讲透。适合已经点亮过 OLED 或 TFT、想进一步吃透 SPI 传输的人,不适合第一次接触单片机显示的人。

2. 硬件连接与驱动 IC 选型:先看圆屏的引脚,再定接线

2.1 圆屏模组的驱动 IC 与引脚定义

市面上 0.99 寸圆屏分辨率通常是 240x240,驱动 IC 常见的是 ST7735S。这块 IC 的引脚不多,一个典型的 8 针模组长这样:

引脚方向功能说明
GND电源地线
VCC电源3.3V 供电,不能接 5V
SCL / SCK输入SPI 时钟
SDA / MOSI输入SPI 数据
RES输入复位,低电平有效
DC输入数据/命令选择,高电平为数据
CS输入片选,低电平有效
BLK输入背光控制,高电平点亮

这里有一个常被忽略的点:BLK 引脚不是简单的“接 VCC 就完事”。如果你把 BLK 直接拉高,背光是全亮的,做低功耗或调亮度时完全没法控制。我一般把 BLK 接到定时器 PWM 输出脚,用 1kHz 以上的频率调占空比,这样亮度可调,也避免低频 PWM 带来的明显闪烁。

2.2 为什么选 F103RCT6 而不是 C8T6

从资源角度看,F103RCT6 是 256KB Flash、48KB RAM,C8T6 是 64KB Flash、20KB RAM。一块 240x240 的 RGB565 全屏显存是 240x240x2 = 115200 字节,约 112.5KB。这个数字意味着:C8T6 的 RAM 放不下全屏双缓冲,RCT6 的 RAM 也放不下,但 RCT6 的 Flash 空间更大,可以塞下 GB2312 字体库或多套图片素材,不会在编译链接时被容量卡脖子。另外 RCT6 引脚多,LQFP64 封装引出 51 个通用 IO,接一个屏幕外还要接按键、传感器,余量充足。

硬件 SPI 的优势在于:SPI1 挂载在 APB2 总线上,时钟最高 72MHz,分频后能轻松跑到 18MHz 甚至 24MHz;而软件 SPI 用 GPIO 翻转,即使主频 72MHz,一次翻转加延时也远达不到这个速度。更关键的是,硬件 SPI 能直接触发 DMA,DMA 搬运数据时 CPU 可以干别的,刷新全屏时不再被 SPI 中断拖住。

2.3 我的引脚映射与接线参考

下面是我在这个项目里用的引脚组合,避开 USB 串口和调试器占用的引脚:

屏幕引脚接 STM32 引脚说明
SCLPA5SPI1_SCK
SDAPA7SPI1_MOSI
CSPA4软件控制片选
DCPA3数据/命令选择
RESPA2复位控制
BLKPA1TIM2_CH2 PWM 输出

这段接线最关键的部分是片选。STM32 的 SPI 外设有硬件 NSS 功能,但硬件 NSS 容易在空闲状态被拉低导致误触发,我习惯完全用 GPIO 软件拉 CS。这不算玄学,是 SPI 从设备时序的常见处理方式,硬件 NSS 在多主机或引脚复用场景下确实容易翻车。

3. 点亮屏幕前的关键配置:SPI 时钟、DMA 通道与复位时序

3.1 上电复位时序,决定屏幕是否白屏

ST7735S 这类驱动 IC 上电后必须给一个低电平复位脉冲。常见做法是:上电后先把 RES 拉低,延时 20ms 以上,再拉高,然后延时 120ms 等内部稳压器稳定。很多人写代码时把延时写成几个空循环了事,结果屏幕偶尔白屏偶尔正常,这就是时序裕量不够的表现。我用下面的流程:

void TFT_Reset(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_2); // RES = 0 Delay_ms(50); // 保持低电平 50ms GPIO_SetBits(GPIOA, GPIO_Pin_2); // RES = 1 Delay_ms(120); // 等待内部电路稳定 }

RES 引脚拉低的时间不要小于 10ms,这是驱动 IC 数据手册里给出的最小复位脉冲宽度;拉高后的等待时间决定屏幕内部 DC-DC 转换器是否稳定,宁可多等也不要赶时间。如果发现屏幕显示内容时好时坏,先把这里的延时加倍测试,通常能排除一大半问题。

3.2 SPI1 的寄存器级配置和参数选择

硬件 SPI 配置的核心参数有四个:主模式、8 位数据、时钟极性和分频系数。ST7735S 要求 SPI 模式下 CPOL=0、CPHA=0,也就是 SCK 空闲为低、第一个边沿采样数据。分频系数我建议先保守一点,从 4 分频开始,也就是 SPI 时钟 18MHz,屏幕正常后再尝试 2 分频提到 36MHz——虽然很多屏幕宣称支持 15MHz 或更高,但实际布线、杜邦线长度都会影响信号质量。

void SPI1_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; // 使能 GPIOA 和 SPI1 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // PA5 SCK、PA7 MOSI 配置为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA4 CS、PA3 DC、PA2 RES 配置为通用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); SPI_InitStructure.SPI_Direction = SPI_Direction_1Line_Tx; // 只发送 SPI_InitStructure.SPI_Mode = SPI_Mode_Master; // 主模式 SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; // 8 位数据 SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // SCK 空闲低电平 SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // 第一个边沿采样 SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件片选 SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4; // 36MHz / 4 = 9MHz SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; // 高位在前 SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }

这里有一个参数容易被忽略:SPI_Direction_1Line_Tx是只发送模式。因为屏幕只需要接收数据,不需要读回显存,用单线发送模式可以省掉 MISO 引脚的配置。如果你后续想读屏幕的寄存器来验证初始化是否成功,就得改成双向模式并把 PA6 也配置好。我在调试初始化序列时通常会临时打开读模式,确认驱动 IC 的 ID 寄存器返回的值符合预期,然后再改回单线发送。

另外注意,分频器的计算结果取决于 APB2 时钟。F103 默认 SYSCLK=72MHz,APB2=72MHz,所以SPI_BaudRatePrescaler_4实际得到 18MHz。如果系统时钟被改过,这里的实际频率也会跟着变。调试时如果屏幕花屏,先确认系统时钟是不是真的 72MHz,而不是靠猜。

3.3 DMA 通道怎么选,怎么配置

F103 的 DMA1 通道与 SPI1 发送对应的映射关系是:SPI1_TX 固定对应 DMA1_Channel3,SPI1_RX 对应 DMA1_Channel2。这个映射是硬件写死的,改不了。配置 DMA 时,把内存地址设为发送缓冲区的地址,外设地址设为 SPI1_DR 的地址,传输方向是从内存到外设。

void DMA1_SPI1_TX_Config(uint8_t *buffer, uint16_t size) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel3); // 复位通道,避免残留配置 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存 -> 外设 DMA_InitStructure.DMA_BufferSize = size; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不变 DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; // 单次传输,不循环 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 内存到外设,不是内存到内存 DMA_Init(DMA1_Channel3, &DMA_InitStructure); }

配置 DMA 时最容易出问题的是DMA_DIR和DMA_M2M两个参数。DMA_DIR_PeripheralDST表示外设是目标,数据从内存流向 SPI1_DR;DMA_M2M_Disable则告诉 DMA 这是外设参与的数据传输,不是内存拷贝。如果这两个参数设错,DMA 要么不传输,要么把 SPI 外设当成内存去读写,表现就是 SPI 发送不可预期,屏幕完全没反应。

DMA 传输完成后,通道 3 的传输完成标志位 TCIF3 会被置位。每次传输前要检查上一个传输是否真的结束了。这里有一个常见的坑:DMA 配置好后,必须先启动 DMA 再写 SPI1_DR 吗?不,正确顺序是先启动 SPI,再启动 DMA 传输。SPI 内部有发送缓冲,只要 SPI 使能且 TXE 为空,DMA 就会自动把数据塞进 SPI1_DR。启动 DMA 后,数据搬运完全由硬件完成,CPU 可以在while(1)里做其他事。

4. 显示窗口、画点和全屏刷新:DMA 最大价值在这里体现

4.1 显示窗口(CASET/RASET)与坐标体系

ST7735S 的显示操作逻辑是先设置窗口,再往窗口里灌像素数据。窗口由 CASET(列地址)、RASET(行地址)两条命令确定,然后 RAMWR 命令开始写入像素。这个机制最大的好处是:画一个局部矩形时不用重发全屏数据,可以配合 DMA 只更新需要变化的区域。

0.99 寸圆屏在 ST7735S 内部存在一个坐标偏移,原因很简单:圆形玻璃的边缘不是完整的 240x240 像素阵列,驱动 IC 实际驱动的有效显示区域在行列上都有偏差。以常见的 0.99 寸圆屏模组为例,我实测的偏移通常需要把显示列地址加上 26、行地址加上 1,否则画面会整体偏移甚至出现边缘像素错位。

void TFT_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { // 偏移量根据模组实测调整 x0 += 26; x1 += 26; y0 += 1; y1 += 1; TFT_Cmd(0x2A); // CASET 列地址 TFT_Data(x0 >> 8); TFT_Data(x0 & 0xFF); TFT_Data(x1 >> 8); TFT_Data(x1 & 0xFF); TFT_Cmd(0x2B); // RASET 行地址 TFT_Data(y0 >> 8); TFT_Data(y0 & 0xFF); TFT_Data(y1 >> 8); TFT_Data(y1 & 0xFF); TFT_Cmd(0x2C); // RAMWR 准备写入像素 }

这个偏移值每家模组厂可能不同,不同批次的屏幕也可能微调过。换一批屏幕或者换一家采购渠道后,如果发现画面偏移,第一时间修这里的偏移量。我见过有人在驱动代码里花几个小时查初始化命令,最后发现只是偏移参数没对上。

4.2 画点、矩形填充与 DMA 发送

有了 SetWindow,画一个矩形填充的流程变成了:设置窗口、准备颜色缓冲区、用 DMA 把缓冲区发给 SPI。一个 240x240 的矩形,缓冲区大小是 115200 字节,在 F103 上只能放到外部数组里,不能放到栈里。

#define TFT_WIDTH 240 #define TFT_HEIGHT 240 static uint16_t fillBuffer[TFT_WIDTH * 2]; // 一次最多填充一行 void TFT_FillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { uint16_t i, j; TFT_SetWindow(x0, y0, x0 + w - 1, y0 + h - 1); for (i = 0; i < w; i++) { fillBuffer[i] = color; // 每行填充同一种颜色 } for (j = 0; j < h; j++) { DMA_ClearFlag(DMA1_FLAG_TC3); // 清空上次传输完成标志 DMA1_Channel3->CNDTR = w; // 设置本次传输数量 DMA_Cmd(DMA1_Channel3, ENABLE); // 启动 DMA while (!DMA_GetFlagStatus(DMA1_FLAG_TC3)); // 等这一行传完 } }

这段代码的思路是:因为填充的是纯色矩形,每一行像素内容相同,我先构造一行的颜色数据,然后逐行 DMA 发送。这样缓冲区只需要一行大小,RAM 占用从 112KB 降到不到 1KB。如果填充的是渐变或者图片,就必须准备一块覆盖整个矩形区域的缓冲区。

CNDTR是 DMA 的剩余传输计数字段,注意这里写入的是传输像素字节数还是行宽。在这个例子里,w是像素个数,而 DMA 的传输单位是字节,且颜色是 16 位 RGB565,所以按理说CNDTR = w * 2才对。这是很多初学者最容易迷惑的地方。DMA1 通道 3 绑定 SPI1 的 8 位外设数据寄存器,传输单位若配置为DMA_PeripheralDataSize_Byte,那么一个像素的两个字节会被分成两次传输,缓冲区里每个像素其实占两个uint8_t。上面代码为了简化直接传了uint16_t,实际工程里应把w * 2作为传输数量,或者把数据长度设置成按字节计算。这个细节直接决定了屏幕显示出来的是正常颜色还是颜色错位的乱码。

4.3 全屏刷新频率怎么估算,局部刷新多少帧

刷一块全屏数据量是 240x240x2 = 115200 字节。SPI 时钟按 18MHz 算,理论每秒可传 2.25MB,刷一屏需要 0.051 秒,约 19.5 帧/秒。这个帧率做静态界面绰绰有余,做表盘秒针动画也够用,但如果做全屏平滑过渡,就会明显感觉卡。加上命令开销、循环等待,实际稳定帧率大约 15 帧/秒,这是硬件 SPI 在这个频率下的正常表现。

想提高帧率有两个方向:一是把 SPI 时钟提到 36MHz,全屏刷新数据量减半,帧率翻倍;二是只做局部刷新。表盘动画场景里,秒针的移动范围通常只有一个小矩形,比如 40x40 像素,刷新这个区域只需要 3200 字节,在 18MHz 下耗时不到 2ms,100 帧都跑得出来。所以实际项目里基本不会无脑全屏刷新,而是把变化区域算出来,只刷那一块。

局部刷新的代价是需要维护一份“屏幕当前内容”的映射,或者在绘制函数里精确记录脏矩形坐标。项目中我维护了一个dirtyRect结构体,每次画点、画线都会更新它,然后在下一次主循环里只刷新这个区域。不要小看这个设计,它把 CPU 占用从“刷新全屏期间 SPI 持续占用 DMA”降到了“刷新几十字节瞬间完成”,对同时跑传感器采样的场景意义很大。

5. 实战排查与避坑记录:白屏、花屏、DMA 卡死的五个高发问题

5.1 现象:屏幕全白,背光亮但没有任何内容

屏幕全白是最常见的故障现象。原因通常集中在三个地方:RES 复位脉冲宽度不够、初始化命令序列没有正确发送、SPI 时钟极性错误。我先查复位时序,因为它最容易用肉眼确认:看 RES 引脚是否在初始化时被拉低过 50ms 以上。

解决方法是按上面TFT_Reset()的时序重跑一遍,并把延时长一些。如果复位正常仍然是白屏,就在初始化命令的前后通过调试串口打印关键标志,或者在 DC 引脚上接逻辑分析仪验证发送出去的是命令还是数据。我遇到过一种情况:初始化命令里第一个字节是 0x01(SWRESFT 软复位),但这条命令被 DC 引脚的高电平当成数据处理了,屏幕自然白屏。DC 切换时序不对是白屏的高频原因,排查时务必优先看 DC 引脚。

5.2 现象:画面显示出来但整体偏移,右侧有缺失

显示内容偏移通常出现在刚换屏幕模组之后。之前提到 0.99 寸圆屏存在 CASET/RASET 偏移,不同模组厂商对偏移的定义不一样,有的是 X 方向偏移 26 像素、Y 方向偏移 1 像素,有的正好相反。

解决方法是先用纯色全屏刷一遍,观察露出的白边在哪一侧,然后反向调整TFT_SetWindow()里的偏移量。这个调试过程很折磨:刷 0xFFFF 看右边露、刷 0x0000 看左边露,反复调几次才能定位。我给自己的习惯是准备一个调偏移的调试函数,编译一次就可以通过串口指令修改偏移值,不用每次烧录。

5.3 现象:花屏、颜色错乱,像是每隔一个像素被吞掉

花屏的常见原因是 SPI 速率过高。杜邦线连接时,18MHz 信号在长线上会产生明显的过冲和反射,导致 SDA 上的数据在采样点不稳定。另一种情况是 CPOL/CPHA 配置错误:ST7735S 要求模式 0,如果配成了模式 3,数据在错误的边沿被采样,就会出现规则的花屏纹理。

解决办法是先把分频系数改到 8 或 16,确认问题消失,再逐步提升频率找到稳定上限。同时检查 SCK 和 SDA 是否靠得太近,数据线不要和电源线绞在一起。换成杜邦线时,高频 SPI 下最好剪短到 10cm 以内。

5.4 现象:DMA 只工作一次,第二次刷新时屏幕完全不动

DMA 第一次传输成功、第二次失效是典型的状态标志问题。DMA 控制器传输完成后 TCIF 标志是硬件置位的,如果不手动清除,下一次启动 DMA 会直接失败,或者传输计数没有重新装载。

解决方法是每次启动 DMA 前,先调用DMA_ClearFlag(DMA1_FLAG_TC3)清除标志,然后再设置CNDTR。还有一个容易被忽视的细节:SPI1 的 BSY 标志也必须确认清除。当 DMA 搬完最后一个字节,SPI 移位寄存器可能还没把数据完全发出去,如果立刻再次启动 DMA 发送,数据会互相覆盖。我一般会在启动前和发送后都检查SPI_GetFlagStatus(SPI1, SPI_FLAG_BSY)。这两步是从实际项目里磨出来的血泪经验,按顺序执行能避免绝大多数 DMA 卡死问题。

5.5 现象:刷新时出现横条纹,像是画面在“撕裂”

横条纹通常是背光 PWM 频率太低和屏幕刷新不同步造成的。BLK 引脚如果直接用低频 PWM 调光,比如 100Hz 以下,人眼能感知到亮度波动;而面板的刷新频率又与 SPI 传输节奏不一致,就会叠加出滚动的横条纹。另一种情况是,DMA 传输期间 SPI 时钟被其他中断频繁打断,导致屏幕上出现多条亮度不均匀的“扫描线”。

解决方法是把背光 PWM 频率提高到 1kHz 以上,TIM2 的 ARR 设得小一些;同时把 DMA 中断优先级调高,避免 SPI 传输中途被抢占。如果板上同时跑着其他外设中断,尽量让 DMA 优先级高于普通外设中断。这个坑比较容易忽略,我最初排查了半天 SPI 参数,最后才发现是 PWM 频率只有 200Hz 的问题。

6. 进阶玩法:圆形裁剪、局部动画与满负荷验证

圆屏和方屏最本质的区别在于:方屏显示内容可以直接铺满,圆屏则需要在渲染阶段把“超出圆形的像素”裁掉。0.99 寸圆屏虽然在驱动 IC 层面是 240x240 的方阵,但玻璃只露出中央一个圆形区域。如果你直接往四角画内容,角的像素实际上显示不出来,但会因为窗口正好在四角而浪费刷新字节数。圆屏的正确渲染思路是:在绘制函数里加一个圆形范围判断。

#define CENTER_X 120 #define CENTER_Y 120 #define RADIUS 118 int IsInsideCircle(uint16_t x, uint16_t y) { int dx = x - CENTER_X; int dy = y - CENTER_Y; return (dx * dx + dy * dy) <= (RADIUS * RADIUS); }

渲染表盘时,每次画像素前先判断这个点是否落在圆内,落在圆外直接跳过。这个判断会消耗一定的 CPU 时间,因此在实际项目里我一般只对“首次全量绘制”做圆内裁剪,动画阶段则是先计算脏矩形,再对脏矩形内的每个像素逐个判断。圆内裁剪的积分运算量不小,但脏矩形通常只有几十像素宽,开销完全可接受。如果你对计算速度不满意,还可以用查表法:把每个扫描行在圆内的起止 x 坐标提前算好,运行时直接用这个区间表来做裁剪,效率比逐像素平方判断高很多。

做完基础显示功能后,我建议做一个满负荷验证:在主循环里不停刷新全屏渐变图案,同时用逻辑分析仪抓取 SCK 和 SDA。观察几个参数:SCK 空闲电平是否稳定为低、数据采样时刻是否在稳定区域、CS 片选是否只在发送期间拉低。逻辑分析仪能看到硬件时序的真相。如果你手头只有示波器,就测 SCK 的最高频率是否和代码里配置的分频一致。满负荷验证通过后,才能说这个屏幕驱动方案真正“稳了”。

这个项目的工程包里带了完整的初始化序列、驱动代码和一个表盘 Demo。下载后第一步直接编译烧录看效果,第二步就是去改TFT_SetWindow()里的偏移量,改成适合你手上模组的数值,第三步再琢磨局部刷新和圆形裁剪。我从这个项目里学到最深的一课:屏幕类外设的“玄学”问题,十有八九是时序、电平、偏移这三类低级问题,而不是芯片坏了。从那以后,我每次换新屏幕都会先花 10 分钟确认 MADCTL 方向和窗口偏移,再谈后续优化。这 10 分钟,能省掉后面好几小时的排查时间。希望这篇文章,能帮你在点圆屏这件事上少走几步弯路。

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

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

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

立即咨询