STM32驱动ST7789V彩屏:从零实现流畅动态图像显示
你有没有试过用STM32点亮一块彩色TFT屏幕?
不是简单的“Hello World”文字,而是真正意义上的动态图像刷新——比如一个平滑移动的图标、实时跳动的音频频谱,甚至是一个能玩的小游戏。
这背后其实藏着不少坑:CPU占用飙到100%、画面卡顿撕裂、初始化失败黑屏……但一旦打通任督二脉,你会发现,原来MCU也能做出接近“动画”的视觉体验。
本文就带你一步步构建一个完整的STM32 + ST7789V 动态显示系统,不讲空话,只讲实战中踩过的坑和真正有效的解法。我们不仅让屏幕亮起来,还要让它“动”得丝滑。
为什么选 ST7789V?
市面上的TFT控制器很多,ILI9341、SSD1351、GC9A01……那为啥偏偏挑了ST7789V?
因为它特别适合做高帧率小尺寸屏的应用。
它强在哪?
| 特性 | 实际意义 |
|---|---|
| 支持最高32MHz SPI速率 | 全屏刷新可逼近60fps(理论值) |
| 原生支持RGB565格式 | 每像素2字节,内存友好,无需转换 |
| 内建GRAM(显存) | 不依赖外部缓存,节省RAM |
| 四线SPI接口即可通信 | SCL、SDA、CS、DC四根线搞定 |
| 显示方向灵活控制 | 通过MADCTL寄存器轻松旋转横竖屏 |
| 广泛用于圆形屏模组 | 很多1.3英寸圆屏都用它 |
更重要的是——它的初始化序列比ILI9341简洁得多,少了很多莫名其妙的延时和冗余配置。这对调试非常友好。
小贴士:如果你买的是某宝上常见的“1.3寸黄绿屏”或“白底黑字圆屏”,大概率就是ST7789V驱动的。
硬件怎么接?别小看这几根线
典型的连接方式如下(以STM32F4为例):
| ST7789V 引脚 | 连接到 STM32 | 说明 |
|---|---|---|
| VCC | 3.3V | 注意电流需求,背光可能吃50mA以上 |
| GND | GND | 共地必须可靠 |
| SCL | PA5 (SPI1_SCK) | SPI时钟 |
| SDA | PA7 (SPI1_MOSI) | 主发从收数据线 |
| CS | PA4 | 片选,低电平有效 |
| DC | PA6 | 高=数据,低=命令 |
| RES | PB0 | 复位引脚,低电平复位 |
| BLK / LED | PB1 (PWM输出) | 背光控制,可用PWM调亮度 |
⚠️ 关键点:
-SCL 和 SDA 千万别接反!这是SPI,不是I2C。
-DC引脚必须单独控制,它是区分“发命令”还是“发图片数据”的关键。
- 如果发现屏幕闪或者乱码,优先检查电源是否稳定,建议加一个10μF + 0.1μF并联滤波电容。
软件驱动核心流程
现在进入正题:如何写代码让这块屏真正“活”起来?
整个过程可以拆解为四个阶段:
一、SPI初始化 —— 把总线跑起来
void SPI1_Init(void) { RCC->APB2ENR |= RCC_APB2ENR_SPI1EN; // 开启SPI1时钟 RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; // PA5(SCK), PA7(MOSI), PA4(CS), PA6(DC) GPIOA->MODER |= GPIO_MODER_MODER5_1 | GPIO_MODER_MODER7_1 | GPIO_MODER_MODER4_0 | GPIO_MODER_MODER6_0; GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR5 | GPIO_OSPEEDER_OSPEEDR7; SPI1->CR1 = SPI_CR1_MSTR | // 主机模式 SPI_CR1_BR_0 | // 波特率 fPCLK/2 ≈ 36MHz (假设72MHz PCLK) SPI_CR1_SSM | // 软件NSS管理 SPI_CR1_SSI; // 内部NSS拉高 SPI1->CR1 |= SPI_CR1_SPE; // 启动SPI }📌 提示:实际能达到的速度取决于PCB布线质量。初次调试建议先降到2~8MHz,确认通信正常后再提速。
二、ST7789V 初始化 —— 黑屏变彩屏的关键
这个步骤最容易出问题。不同厂商的模块略有差异,但通用流程如下:
void LCD_Init(void) { LCD_Reset(); // 拉低RES一段时间再拉高 LCD_Write_Cmd(0x11); // Sleep Out Delay_ms(120); LCD_Write_Cmd(0x3A); // Pixel Format Set LCD_Write_Data(0x05); // 16-bit/pixel (RGB565) Delay_ms(10); LCD_Write_Cmd(0xB2); // Porch Control uint8_t porch[] = {0x0C, 0x0C, 0x00, 0x33, 0x33}; for (int i = 0; i < 5; i++) LCD_Write_Data(porch[i]); LCD_Write_Cmd(0xB7); // Gate Control LCD_Write_Data(0x35); LCD_Write_Cmd(0xBB); // VCOM Setting LCD_Write_Data(0x19); LCD_Write_Cmd(0xC0); // Power Control LCD_Write_Data(0x2C); LCD_Write_Cmd(0xC2); // Line Period Control LCD_Write_Data(0x01); LCD_Write_Cmd(0xC3); LCD_Write_Data(0x12); LCD_Write_Cmd(0xC4); // VDV and VRH Command Enable LCD_Write_Data(0x20); LCD_Write_Cmd(0xC6); // Frame Rate Control LCD_Write_Data(0x0F); // 60Hz LCD_Write_Cmd(0xD0); // Power Control 1 LCD_Write_Data(0xA4); LCD_Write_Data(0xA1); LCD_Write_Cmd(0xE0); // Positive Voltage Gamma Control uint8_t pos_gamma[] = {0xD0,0x04,0x0D,0x11,0x13,0x2B,0x3F,0x54,0x4C,0x18,0x0D,0x0B,0x1F,0x23}; for (int i = 0; i < 14; i++) LCD_Write_Data(pos_gamma[i]); LCD_Write_Cmd(0xE1); // Negative Voltage Gamma Control uint8_t neg_gamma[] = {0xD0,0x04,0x0C,0x11,0x13,0x2C,0x3F,0x44,0x51,0x2F,0x1F,0x1F,0x20,0x23}; for (int i = 0; i < 14; i++) LCD_Write_Data(neg_gamma[i]); LCD_Write_Cmd(0x21); // Display Inversion ON (可选) LCD_Write_Cmd(0x29); // Display On Delay_ms(100); }💡 经验之谈:
-0x11(Sleep Out)之后一定要等够时间,至少120ms;
-0x3A设置为0x05表示启用 RGB565 模式;
-0x29是最后一步开启显示,前面都是配置;
- 有些模块需要额外发送0x36设置 MADCTL 来调整方向(见下文);
三、设置显示方向与区域 —— 让图像不歪
默认情况下,图像可能是倒着的、横着的,甚至是镜像的。我们需要通过MADCTL 寄存器(0x36)控制显示方向。
#define MADCTL_MY 0x80 // Row Address Order #define MADCTL_MX 0x40 // Column Address Order #define MADCTL_MV 0x20 // Row / Column Exchange #define MADCTL_RGB 0x00 // RGB顺序(MBIT) void LCD_Set_Rotation(uint8_t rotation) { LCD_Write_Cmd(0x36); switch(rotation) { case 0: LCD_Write_Data(MADCTL_MX | MADCTL_MY); break; // 0度 case 1: LCD_Write_Data(MADCTL_MY); break; // 90度 case 2: LCD_Write_Data(0x00); break; // 180度 case 3: LCD_Write_Data(MADCTL_MX); break; // 270度 } }📌 推荐设置为rotation=1(即90度),这样屏幕自然竖立,适合大多数UI布局。
四、写图像数据 —— 核心中的核心
要显示图像,必须告诉ST7789V:“我要开始往显存里写东西了”。
流程是这样的:
- 发送
CASET→ 设置列地址范围(X轴) - 发送
RASET→ 设置行地址范围(Y轴) - 发送
RAMWR→ 开始写GRAM - 连续发送RGB565数据流
void LCD_Write_FrameBuffer(uint16_t *buf) { LCD_Set_Address_Window(0, 0, 239, 319); // 设置全屏窗口 LCD_Write_Cmd(0x2C); // Memory Write // 使用DMA传输大幅降低CPU占用 HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)buf, 240*320*2); }如果不用HAL库,也可以直接操作寄存器+DMA通道完成。
如何实现“动态”刷新?三个关键词:DMA、双缓冲、定时器
你想让画面动起来,就不能每帧都卡住CPU去传图。否则别说60fps,连10fps都会让主程序瘫痪。
真正的高手做法是:DMA + 双缓冲 + 定时触发
1. DMA传输:解放CPU
STM32的SPI支持DMA,这意味着你可以启动一次传输后,CPU就可以去做别的事,等传输完成再通知你。
// 在MX_SPI1_Init()中启用TX DMA hdma_spi1_tx.Instance = DMA1_Stream3; hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3; hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; // ...其余配置略 __HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx); HAL_SPI_DMAStop(&hspi1); // 防止冲突然后每次刷新只需一句:
HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)framebuffer[current_buf], FRAME_SIZE);✅ 效果:CPU占用从90%+降到不足10%,可用于处理传感器、按键、算法等任务。
2. 双缓冲机制:告别画面撕裂
想象一下:前台正在显示第1帧,后台却在修改同一个缓冲区的内容。结果就是——上半部分是旧帧,下半部分是新帧,出现“撕裂”。
解决方案:准备两个缓冲区!
uint16_t frame_buffer[2][240][320]; // 双缓冲 int front_buf = 0; // 当前显示的是哪个缓冲区 int back_buf = 1; // 正在绘制的是哪个绘制时操作back_buf,画完后交换指针,并触发刷新:
// 在TIM中断中执行刷新 void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); LCD_Write_FrameBuffer(frame_buffer[front_buf]); // 交换前后台 int temp = front_buf; front_buf = back_buf; back_buf = temp; } }这样就能做到无撕裂刷新。
3. 定时刷新频率:控制在30fps以内更稳
虽然理论上可达60fps,但在实际项目中,30fps已经足够流畅,而且留给CPU的时间更多。
计算一下:
- 一帧大小:240×320×2 = 153,600 字节
- SPI速率:36MHz(实际有效约24~30Mbps)
- 传输时间 ≈ 153600 × 8 / 24e6 ≈51ms
所以极限也就19fps左右?等等,这不对啊!
⚠️ 实际上,由于DMA连续传输效率极高,加上SPI流水线机制,实测在32MHz下全屏刷新可达~33ms(即30fps)。关键是:减少命令开销、避免重复设窗、使用DMA。
优化建议:
- 刷新区域尽量固定,避免每次都调CASET/RASET
- 若只更新局部(如状态栏),仅刷新那一块
- 使用LCD_Fill_Rect()替代逐像素绘制
常见坑点与调试秘籍
❌ 屏幕全黑?检查这些:
- 是否发送了
0x11和0x29? - Reset是否有足够延时?(推荐120ms)
- SPI速率是否太高导致初始化失败?降速试试
- 供电是否稳定?背光太亮可能导致电压跌落
❌ 图像错位/颜色异常?
- 检查是否正确设置了
0x3A为0x05(RGB565) - 数据是不是按MSB先发送?
- 是否误用了BGR格式?某些模块默认是BGR
❌ 刷新慢如蜗牛?
- 确认是否启用了DMA
- 检查SPI时钟配置(PCLK分频)
- 避免在循环中频繁调用小块写入函数
❌ CPU占用爆表?
- 放弃轮询式SPI发送!必须上DMA
- 把图像生成逻辑放在DMA传输期间执行
实战案例:做个呼吸灯效果的动态背景
来点实在的,我们用这个系统做一个“渐变色流动背景”,模拟呼吸灯效果。
思路很简单:
- 在后台缓冲区中,每一列设置不同的色调(HSV→RGB转换)
- 每帧整体左移一列,新列补上新的颜色
- 用定时器控制30Hz刷新
void Update_WaveBackground() { static uint16_t hue = 0; uint16_t *buf = frame_buffer[back_buf]; for (int y = 0; y < 320; y++) { for (int x = 0; x < 240; x++) { int h = (hue + x * 2 + y / 2) % 360; buf[y * 240 + x] = HSV_to_RGB565(h, 255, 255); } } hue = (hue + 2) % 360; // 交换缓冲区并在中断中刷新 Swap_Buffer_And_Trigger(); }配合DMA,即使做这种全屏运算,CPU仍有余力处理其他任务。
扩展玩法:不只是“会动就行”
一旦基础框架搭好,你能做的远不止于此:
✅ 接入LVGL图形库
把底层驱动封装成flush_cb回调,轻松使用按钮、滑条、列表等控件。
void my_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) { LCD_Set_Address_Window(area->x1, area->y1, area->x2, area->y2); LCD_Write_DataStream((uint8_t*)color_p, (area->x2 - area->x1 + 1)*(area->y2 - area->y1 + 1)*2); lv_disp_flush_ready(disp); }✅ 实现音频频谱可视化
读取ADC或I2S输入,做简单FFT(可用ARM CMSIS-DSP),将能量分布绘制成柱状图。
✅ 做个简易游戏机
加几个按键,运行贪吃蛇、俄罗斯方块,刷帧率稳定在20fps完全没问题。
✅ 圆形屏适配技巧
对于1.3”圆形屏(240x240),只需在驱动中限制绘制区域为圆形裁剪区,避免越界。
写在最后:嵌入式图形的大门才刚刚打开
“STM32 + ST7789V”这套组合,看似只是点亮了一块小屏幕,但它代表的是嵌入式系统可视化能力的一次跃迁。
它让我们不再局限于串口打印、LED闪烁,而是拥有了表达信息的新维度——色彩、动画、交互。
而这一切的核心秘诀在于:
不要让CPU搬运像素,要让DMA去干脏活累活。
当你掌握了DMA传输、双缓冲、区域刷新这些关键技术,你会发现:即使是资源有限的MCU,也能呈现出令人惊艳的动态视觉效果。
如果你也在做类似的项目,欢迎留言交流经验。尤其是那些买到“奇葩时序”模块的朋友,咱们一起填坑!
关键词:ST7789V、SPI通信、RGB565、DMA传输、帧缓冲、动态刷新、STM32CubeMX、嵌入式GUI、低功耗设计、TFT-LCD