从零开始点亮一块彩屏:STM32 + ST7789V 驱动实战全记录
你有没有过这样的经历?手头有一块小巧精致的彩色TFT屏幕,引脚密密麻麻,数据手册厚得像本字典。接上STM32后,要么黑屏、要么花屏,调试几天都没搞明白哪里出错。
别担心,这几乎是每个嵌入式开发者在做图形显示时都会踩的坑。
今天我们就来彻底拆解一个实际项目中最常见的组合——STM32 使用 HAL 库驱动 ST7789V 彩屏。不讲空话,只说实战中真正关键的问题和解决方案。目标是让你看完就能动手,接上就能亮。
为什么选 ST7789V?它到底强在哪?
市面上能用的 TFT 驱动芯片不少,比如老将 ILI9341、SSD1351,但近几年越来越多模块转向ST7789V。这不是偶然。
先看几个硬指标:
| 特性 | 参数 |
|---|---|
| 分辨率支持 | 最高 240×320 |
| 色深 | 支持 RGB565(16位)或 RGB666 |
| 接口类型 | SPI / RGB / MCU 并行 |
| SPI 最高速率 | 可达 15MHz |
| 工作电压 | 2.2V ~ 3.3V |
| 内置升压电路 | ✅ 无需外部高压 |
相比 ILI9341 这类传统方案,ST7789V 的优势在于:
-更紧凑的封装,适合小尺寸面板(常见于1.3”~2.0”圆角屏)
-自带DC-DC,省去额外电源设计
-初始化流程更简洁
-SPI模式下刷新更快
更重要的是,它的命令协议对“批量写GRAM”做了优化,配合DMA传输效率非常高,非常适合用在资源有限的MCU上。
硬件怎么连?别让接线毁了你的努力
再好的软件也架不住硬件接错。我们先明确最常用的四线SPI + 控制信号接法:
| 屏幕功能 | 推荐连接(以 STM32F103C8T6 为例) |
|---|---|
| SCL (SPI CLK) | PA5(SPI1_SCK) |
| SDA (MOSI) | PA7(SPI1_MOSI) |
| CS(片选) | PB0(任意GPIO) |
| DC(数据/命令) | PB1(任意GPIO) |
| RST(复位) | PB2(可选,也可硬件复位) |
| BLK(背光) | PB3(可接PWM调光) |
⚠️ 注意事项:
- 所有IO必须为3.3V电平兼容!如果你的MCU是5V系统(如某些Arduino),需要加电平转换。
-CS 虽然可以用硬件NSS,但我们强烈建议使用软件控制(即普通GPIO),避免SPI总线冲突。
- VCC 和 GND 尽量短而粗,旁边一定要放一颗100nF陶瓷电容去耦。
PCB布局上记住一句话:SPI走线越短越好,远离电源线和按键干扰源。
SPI配置要点:Mode 3 是命门!
很多人第一次点不亮屏,问题就出在SPI时序上。
ST7789V 的SPI接口要求非常明确:
👉CPOL = 1, CPHA = 1 → 即 SPI Mode 3
什么意思?
- 空闲时钟为高电平(CPOL=1)
- 数据在第二个时钟边沿采样(上升沿读取)
这个设置必须和你在CubeMX里配的一致。否则即使代码没错,通信也会失败。
下面是基于 HAL 库的标准初始化配置(CubeMX生成后微调):
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_HIGH; // CPOL=1 hspi1.Init.CLKPhase = SPI_PHASE_2EDGE; // CPHA=1 → Mode 3 hspi1.Init.NSS = SPI_NSS_SOFT; // 软件控制CS hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 72MHz → 18MHz SCK hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;📌重点提醒:虽然理论上支持18MHz,但在长线或劣质排线上容易出错。建议初次调试设为SPI_BAUDRATEPRESCALER_8(约9MHz),稳定后再提速。
最核心的底层函数:命令与数据如何区分?
ST7789V 通过一个叫DC 引脚的信号来判断当前传的是“命令”还是“数据”。
- DC = 低电平 → 下一条是命令(如0x2A 设置列地址)
- DC = 高电平 → 下一条是数据(如具体的X坐标值)
所以我们需要封装两个基础操作:
#define CS_LOW() HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET) #define CS_HIGH() HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET) #define DC_CMD() HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_RESET) #define DC_DATA() HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET) void LCD_Write_Cmd(uint8_t cmd) { CS_LOW(); DC_CMD(); HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); CS_HIGH(); } void LCD_Write_Data(uint8_t data) { CS_LOW(); DC_DATA(); HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); CS_HIGH(); }这两个函数看似简单,却是整个驱动的基石。后面所有高级功能都建立在这之上。
初始化不是复制粘贴!顺序和延时都很关键
网上很多示例直接把初始化序列扔出来,结果你一运行就花屏。原因往往是忽略了厂商特定的时序要求。
以下是经过验证的 ST7789V 初始化流程(适用于大多数240x240/240x320模组):
void ST7789_Init(void) { // 硬件复位 HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_SET); HAL_Delay(120); // 必须等待足够时间让内部电路稳定 LCD_Write_Cmd(0x11); // Sleep Out HAL_Delay(120); LCD_Write_Cmd(0x36); // MADCTL: 存储方向控制 LCD_Write_Data(0x00); // 默认方向(可根据需要改为0x70实现横屏) LCD_Write_Cmd(0x3A); // COLMOD: 设置颜色格式 LCD_Write_Data(0x55); // 16-bit/pixel, RGB565 // Porch 控制(影响帧同步) LCD_Write_Cmd(0xB2); uint8_t porch[] = {0x0C, 0x0C, 0x00, 0x33, 0x33}; LCD_Write_Buffer(porch, 5); LCD_Write_Cmd(0xB7); // Gate Control LCD_Write_Data(0x35); LCD_Write_Cmd(0xBB); // VCOM Setting LCD_Write_Data(0x19); LCD_Write_Cmd(0xC0); // LCM Control LCD_Write_Data(0x2C); LCD_Write_Cmd(0xC2); LCD_Write_Data(0x01); LCD_Write_Cmd(0xC3); LCD_Write_Data(0x12); LCD_Write_Cmd(0xC4); // VRH/VDC Enable LCD_Write_Data(0x20); LCD_Write_Cmd(0xC6); // Frame Rate = 60Hz LCD_Write_Data(0x0F); LCD_Write_Cmd(0xD0); // Power Control LCD_Write_Data(0xA4); LCD_Write_Data(0xA1); // Gamma校正(不同厂家可能参数不同) LCD_Write_Cmd(0xE0); uint8_t gammaP[] = {0xD0,0x04,0x0D,0x11,0x13,0x2B,0x3F,0x54,0x4C,0x18,0x0D,0x0B,0x1F,0x23}; LCD_Write_Buffer(gammaP, 14); LCD_Write_Cmd(0xE1); uint8_t gammaN[] = {0xD0,0x04,0x0C,0x11,0x13,0x2C,0x3F,0x44,0x51,0x2F,0x1F,0x1F,0x20,0x23}; LCD_Write_Buffer(gammaN, 14); LCD_Write_Cmd(0x21); // 开启显示反相(可选) LCD_Write_Cmd(0x13); // Normal Display On HAL_Delay(10); LCD_Write_Cmd(0x29); // Main Screen Turn On HAL_Delay(10); }💡经验提示:
-HAL_Delay()不要轻易删!尤其在0x11(退出睡眠)之后必须延时至少120ms。
- Gamma参数因模组而异,如果发现偏色严重,尝试注释掉 E0/E1 设置看看是否改善。
- 若使用圆形屏(如240x240),还需额外设置裁剪区域。
如何高效刷新画面?Set_Address_Window 是灵魂
你以为初始化完就可以随便画图了?错。
如果不设置“窗口”,每次写数据都会从GRAM起点开始覆盖,导致错位甚至死机。
正确做法是:先划定要写的矩形区域,再发送像素流。
这就是Set_Address_Window函数的作用:
void ST7789_Set_Address_Window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_Write_Cmd(0x2A); // Column Address Set LCD_Write_Data(x0 >> 8); LCD_Write_Data(x0 & 0xFF); LCD_Write_Data(x1 >> 8); LCD_Write_Data(x1 & 0xFF); LCD_Write_Cmd(0x2B); // Row Address Set LCD_Write_Data(y0 >> 8); LCD_Write_Data(y0 & 0xFF); LCD_Write_Data(y1 >> 8); LCD_Write_Data(y1 & 0xFF); LCD_Write_Cmd(0x2C); // Write Memory Start }举个例子:你想在屏幕上画一个红色方块(宽100px,高50px),位于左上角:
ST7789_Set_Address_Window(0, 0, 99, 49); uint16_t red = 0xF800; // RGB565 红色 for (int i = 0; i < 100 * 50; i++) { LCD_Write_Data(red >> 8); LCD_Write_Data(red & 0xFF); }这样就能精准地只更新那一块区域,不会影响其他内容。
刷新太慢怎么办?这些优化你必须知道
直接用HAL_SPI_Transmit()发送大量像素,CPU占用极高。实测全屏刷新一次(240×320×2Byte)在10MHz下需约150ms,也就是每秒最多刷6帧——动画卡成幻灯片。
怎么办?三条路:
✅ 方法一:启用局部刷新
不要每次都刷全屏!只更新变化的部分。
例如按钮按下时,仅重绘按钮区域;仪表盘更新时,只改数字部分。
// 只刷新中间一个小区域 ST7789_Set_Address_Window(100, 100, 150, 150); send_updated_pixels();这是性价比最高的优化。
✅ 方法二:使用 DMA 加速传输
把SPI数据搬运交给DMA,释放CPU去做别的事。
启用方式很简单,在CubeMX中打开SPI TX DMA通道,然后修改发送函数:
void LCD_Write_Buffer_DMA(uint8_t *buffer, size_t len) { CS_LOW(); DC_DATA(); HAL_SPI_Transmit_DMA(&hspi1, buffer, len); // 注意:需等待传输完成或使用回调机制 }⚠️ 提醒:DMA传输期间不能操作SPI,建议加信号量或使用完成回调。
✅ 方法三:提高SPI速率至15MHz
前提是你线路够短、信号质量好。可在初始化中改为:
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; // 72MHz → 36MHz? NO!等等!STM32F1 APB2最大才72MHz,分频最小是2,得到SCK=36MHz ——超了!
查手册可知,ST7789V 最高支持15MHz。所以稳妥起见,选择 prescaler=4(18MHz)或 prescaler=8(9MHz)更可靠。
常见问题排查指南:别再问“为什么我的屏不亮”
❌ 问题1:屏幕全白/全红/花屏
可能原因:
- DC引脚接反或悬空
- SPI Mode 错误(用了Mode 0)
- 初始化序列缺失关键步骤
- 供电不足或电压不稳
解决方法:
1. 用万用表确认VDD是否达到3.3V
2. 用逻辑分析仪抓波形,检查前几条指令是否正确发出
3. 在0x11后加足120ms延时
4. 检查 MADCTL 和 COLMOD 是否设对
❌ 问题2:能亮但刷新卡顿严重
瓶颈定位:
- 是否每次都在刷全屏?
- 是否用了阻塞式SPI发送大数组?
- 是否没开DMA?
优化建议:
- 实现脏矩形检测,合并多个小更新为一次大传输
- 对静态背景缓存到外部SRAM
- 使用双缓冲+垂直同步思想减少撕裂感
❌ 问题3:背光一闪一闪
这通常是BLK引脚PWM频率太低导致的。
解决方案:
// 设置TIM PWM频率 > 1kHz(最好2kHz以上) __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, brightness_level);频率低于500Hz人眼就能察觉闪烁。
更进一步:让它不只是“能亮”,而是“好用”
当你已经能让屏幕正常工作,下一步就是提升工程化水平:
🧩 模块化封装
把驱动独立成lcd_drv.c和lcd_drv.h,对外暴露统一接口:
void lcd_init(void); void lcd_draw_pixel(int x, int y, uint16_t color); void lcd_fill_rect(int x, int y, int w, int h, uint16_t color); void lcd_display_on/off(void);方便后续集成GUI库(如LVGL)。
💡 支持旋转显示
通过修改MADCTL寄存器实现横竖屏切换:
#define MADCTL_VERTICAL 0x00 #define MADCTL_HORIZONTAL 0x70 // MY=0, MX=1, MV=1 LCD_Write_Cmd(0x36); LCD_Write_Data(MADCTL_HORIZONTAL);记得同时调整Set_Address_Window中的坐标映射。
🔋 动态背光调节
利用定时器PWM输出,根据环境光或用户设置动态调光:
void lcd_set_backlight(uint8_t level) { // level: 0~100 uint32_t pulse = (level * __HAL_TIM_GET_AUTORELOAD(&htim)) / 100; __HAL_TIM_SET_COMPARE(&htim, TIM_CHANNEL_x, pulse); }节能又护眼。
结语:从点亮到驾驭,只差这几步
驱动一块TFT屏幕,表面看是技术活,实则是对细节的极致把控。
我们回顾一下成功的关键节点:
- ✅ 正确连接硬件,尤其是DC、CS、SPI Mode
- ✅ 完整且有序的初始化流程,带必要延时
- ✅ 使用
Set_Address_Window精准控制写入区域 - ✅ 通过局部刷新 + DMA 提升性能
- ✅ 封装良好接口,为后续扩展留空间
这套方案已在工业仪表、手持设备、创客玩具等多个项目中稳定运行。只要你按步骤来,避开文中提到的那些“坑”,几乎没有理由点不亮。
如果你正在做一个带屏的产品,或者想给自己的项目加个酷炫界面,不妨试试这个组合。它成本低、资料多、生态成熟,是现阶段嵌入式图形显示的黄金搭档。
如果你在实现过程中遇到了其他挑战,欢迎留言交流。一起把这块小小的彩屏,玩出更大的可能。