ST7789V2 字符与图片显示驱动实现:从原理到实战
在如今的嵌入式开发中,一块小小的彩色屏幕往往能极大提升设备的交互体验。无论是智能手表、便携仪表,还是 DIY 小项目,1.3~1.69 英寸的 SPI 接口 TFT 屏几乎成了标配。而在这类屏幕背后, ST7789V2 这颗控制器芯片正默默承担着图像渲染的核心任务。
我最近在一个基于 ESP32 的低功耗显示模块项目中,深入打磨了一套适用于多平台(STM32、ESP32、Arduino)的 ST7789V2 驱动代码。它不仅能稳定显示字符和图片,还绕开了不少官方资料里没写清楚的“坑”。今天就来聊聊这套方案背后的细节——不讲空话,只说实战经验。
为什么是 ST7789V2?
市面上常见的 TFT 控制器不少,比如 ILI9341、ST7735、SSD1351 等,但如果你用的是 240×320 分辨率的小屏,尤其是圆形或矩形的紧凑型模块,大概率会遇到 ST7789V2。
这颗芯片有几个很讨喜的特点:
- 高集成度 :内置 GRAM(显存),不需要外挂帧缓冲,对 RAM 有限的 MCU 友好。
- 低功耗设计 :支持 Sleep In/Out 命令,待机时电流可降至 μA 级别。
- 单电源供电 :典型工作电压 2.2V~3.3V,自带升压电路,省去额外的 VGH/VGL 电源设计。
- 接口灵活 :支持四线 SPI(最常见)、三线 SPI 和并行接口,适配性强。
相比 ILI9341,它的封装更小、刷新更快;相比 OLED 驱动芯片如 SSD1331,又没有烧屏风险,寿命更长,成本也更低。对于资源受限但需要彩色 GUI 的场景,是个非常务实的选择。
SPI 通信:别让时序拖了后腿
虽然 ST7789V2 支持多种接口,但在大多数开发板上,我们还是用 四线 SPI 模式:SCK、MOSI、CS、DC。其中 DC 引脚特别关键——它是命令和数据的“开关”。
通信流程其实很简单:
- 拉低 CS 片选,启动传输;
- 设置 DC:低电平发命令,高电平发数据;
- 通过 MOSI 发送字节,SCK 提供同步时钟;
- 一轮结束后拉高 CS。
典型的写数据操作如下:
void lcd_write_command(uint8_t cmd) {
CS_LOW();
DC_CMD(); // DC = 0
SPI_WRITE(cmd);
CS_HIGH();
}
void lcd_write_data_16bit(uint16_t color) {
CS_LOW();
DC_DATA(); // DC = 1
SPI_WRITE(color >> 8); // 高8位
SPI_WRITE(color & 0xFF); // 低8位
CS_HIGH();
}
这里有个容易忽略的点: SPI 模式必须是 Mode 0(CPOL=0, CPHA=0) 。也就是说,时钟空闲为低,数据在上升沿采样。如果配置成 Mode 3,轻则花屏,重则完全无反应。
另外, 最大时钟频率 也很关键。官方标称 15MHz,但在 STM32F4 或 ESP32 上实测可以跑到 27MHz 甚至更高(需注意信号完整性)。我曾在 ESP32 上启用硬件 SPI + DMA,刷一整屏(240×320×2B ≈ 150KB)仅需约 6ms,接近 15fps 的动画流畅度。
⚠️ 如果你还在用软件模拟 SPI,那刷屏速度可能只有几帧每秒。建议尽早切换到硬件 SPI,性能差距不是一点半点。
RGB565:如何把颜色“压缩”进 16 位
TFT 屏的颜色格式基本都是 RGB565。为什么不是 RGB888?因为每个像素节省 1 字节!240×320 的屏幕总共能省下 76,800 字节——这对 RAM 宝贵的 MCU 来说太重要了。
RGB565 把 16 位拆成:
- R(红)占 5 位 → 0~31
- G(绿)占 6 位 → 0~63(人眼对绿色更敏感)
- B(蓝)占 5 位 → 0~31
一个白色像素 (255,255,255) 转换后就是:
#define RGB565(r, g, b) (((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))
计算过程:
- r & 0xF8 → 保留高 5 位(255 → 248)
- g & 0xFC → 保留高 6 位(255 → 252)
- b >> 3 → 右移三位(255 → 31)
最终组合为: (248 << 8) | (252 << 3) | 31 = 0xFFFF
但要注意: 有些屏幕的字节顺序是反的 !即先传低字节再传高字节。如果你发现图像偏红或颜色错乱,八成是高低字节颠倒了。解决办法要么改写函数顺序,要么在初始化时设置 MADCTL 寄存器调整输出极性。
如何画一个像素?别小看这个基础操作
所有图形绘制的起点,都是 lcd_draw_pixel(x, y, color) 。看似简单,实则涉及地址窗口设置,稍有不慎就会导致偏移或越界。
正确的做法是:
void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) {
lcd_write_command(ST7789_CASET); // Column Address Set
lcd_write_data_16bit(x0);
lcd_write_data_16bit(x1);
lcd_write_command(ST7789_PASET); // Page Address Set
lcd_write_data_16bit(y0);
lcd_write_data_16bit(y1);
}
void lcd_draw_pixel(uint16_t x, uint16_t y, uint16_t color) {
if (x >= LCD_WIDTH || y >= LCD_HEIGHT) return;
lcd_set_window(x, y, x, y);
lcd_write_command(ST7789_RAMWR); // Write to GRAM
lcd_write_data_16bit(color);
}
重点在于 CASET 和 PASET 必须精确设定起始和结束坐标。很多初学者直接写死 0~239 和 0~319 ,结果在部分屏幕上映射错误,尤其是带裁剪区域的圆屏。
此外,频繁调用 lcd_draw_pixel 来绘图效率极低。比如画一条线要调用上百次,每次都要设置窗口和命令,通信开销巨大。 优化策略是批量写入 :先准备好一段连续的颜色数组,然后一次性写入。
显示字符:ASCII 和中文怎么搞?
字符本质上是点阵图像。英文常用 8×16 或 12×24 字模,存储在 Flash 中即可。
以 8×16 ASCII 字体为例:
const uint8_t ascii_8x16[95][16] = { /* 数据由字模工具生成 */ };
void lcd_show_char(uint16_t x, uint16_t y, char ch,
uint16_t fg, uint16_t bg) {
if (ch < ' ' || ch > '~') return;
const uint8_t *p = ascii_8x16[ch - ' '];
for (int row = 0; row < 16; row++) {
uint8_t line = p[row];
for (int col = 0; col < 8; col++) {
uint16_t color = (line >> (7 - col)) & 0x01 ? fg : bg;
lcd_draw_pixel(x + col, y + row, color);
}
}
}
这段代码逐位判断是否点亮,并调用 lcd_draw_pixel 。虽然效率不高,但对于菜单文字、状态提示这类静态内容足够用了。
至于中文显示,难点不在渲染,而在 编码匹配和存储 。推荐做法是:
- 使用 UTF-8 编码解析输入字符串;
- 对每个汉字查表获取其在 GB2312 或 Unicode 字库中的索引;
- 从外部 Flash 或程序内存加载对应 16×16 或 24×24 点阵数据;
- 按位绘制。
实际项目中,我会将常用汉字预生成为 .h 文件,用 Image2LCD 或类似的工具导出 C 数组,直接编译进固件。虽然体积大些,但访问速度快,无需动态解码。
图片显示:别把 BMP 当圣经
很多人以为要显示图片就得处理 BMP 文件头,其实大可不必。在嵌入式系统中,最高效的方式是 提前把图片转为 RGB565 数组 ,固化在 Flash 中。
例如一张 240×320 的开机 LOGO:
extern const uint16_t logo_image[240 * 320] PROGMEM;
显示函数如下:
void lcd_show_image(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint16_t *img) {
lcd_set_window(x, y, x + w - 1, y + h - 1);
lcd_write_command(ST7789_RAMWR);
for (int i = 0; i < w * h; i++) {
uint16_t color = pgm_read_word(&img[i]); // AVR/ESP32
// STM32: __packed uint16_t *ptr = (uint16_t*)img; color = ptr[i];
lcd_write_data_16bit(color);
}
}
关键点:
- 使用 PROGMEM (AVR/ESP32)或 __attribute__((section(".rodata"))) (GCC)将图像放入 Flash,避免占用 RAM;
- 工具推荐 Image2LCD ,支持多种格式、旋转、色深转换;
- 若图像小于屏幕,可指定区域局部刷新,减少传输量。
💡 经验之谈:不要试图在运行时解码 JPEG/PNG。即使有 LZ 解压库,也远不如直接读取 RAW RGB565 数组来得快。
初始化序列:成败在此一举
ST7789V2 的初始化命令序列是黑盒操作,不同厂商可能略有差异。网上流传的版本五花八门,很多根本跑不起来。
经过多次调试,我总结出一套稳定可用的流程(适用于主流 1.69” 屏):
void lcd_init() {
lcd_reset(); // 硬复位或软延时
delay_ms(150);
lcd_write_command(0x11); // Sleep Out
delay_ms(120);
lcd_write_command(0x36); // MADCTL: Memory Access Control
lcd_write_data(0x00); // 0x00: 正常方向;0xC0: 180°旋转
lcd_write_command(0x3A); // COLMOD: Interface Pixel Format
lcd_write_data(0x05); // 16-bit / RGB565
lcd_write_command(0xB2); // Porch Setting
lcd_write_data(0x0C); lcd_write_data(0x0C);
lcd_write_data(0x00); lcd_write_data(0x33);
lcd_write_data(0x33);
lcd_write_command(0xB7); // Gate Control
lcd_write_data(0x35);
lcd_write_command(0xBB); // VCOM Setting
lcd_write_data(0x19);
lcd_write_command(0xC0); // Power Control 1
lcd_write_data(0x2C);
lcd_write_command(0xC2); // UG Control
lcd_write_data(0x01);
lcd_write_data(0xFF);
lcd_write_command(0xC3); // VGHL Control
lcd_write_data(0x11);
lcd_write_command(0xC4); // VDV and VRH Command Enable
lcd_write_data(0x20);
lcd_write_command(0xC6); // Frame Rate Control
lcd_write_data(0x0F); // 60Hz
lcd_write_command(0xD0); // Power Control 2
lcd_write_data(0xA4); lcd_write_data(0xA1);
lcd_write_command(0xE0); // Positive Gamma Control
uint8_t gammaP[] = {0xD0,0x04,0x0D,0x11,0x13,0x2B,0x3F,0x54,0x4C,0x18,0x0D,0x0B,0x1F,0x23};
for (int i = 0; i < 14; i++) lcd_write_data(gammaP[i]);
lcd_write_command(0xE1); // Negative Gamma Control
uint8_t gammaN[] = {0xD0,0x04,0x0C,0x11,0x13,0x2C,0x3F,0x44,0x51,0x2F,0x1F,0x1F,0x20,0x23};
for (int i = 0; i < 14; i++) lcd_write_data(gammaN[i]);
lcd_write_command(0x21); // Display Inversion ON (可选)
// lcd_write_command(0x20); // Off
lcd_write_command(0x29); // Display ON
}
其中几个关键寄存器:
- 0x36 ( MADCTL ):控制显示方向、BGR 顺序、行/列交换;
- 0x3A ( COLMOD ):必须设为 0x05 启用 RGB565;
- 0x29 :最后一步打开显示,缺了它屏幕永远黑着。
如果你改了方向,记得同步调整 lcd_set_window 中的宽高逻辑。
实战中的那些“坑”
做过几个项目后才发现,很多问题不出现在代码里,而出在细节处理上。
❌ 屏幕全白或全黑?
- 检查是否发送了
0x11(Sleep Out)和0x29(Display On); - 复位后要有足够延时(≥120ms),否则初始化失败;
- 电源不稳定也会导致白屏,加个 10μF 陶瓷电容靠近 VCC。
❌ 图像颜色发红?
- 很可能是 RGB565 字节顺序错了。尝试交换高低字节发送顺序,或者在
MADCTL中设置 BGR 标志位。
❌ 刷屏慢如幻灯片?
- 软件 SPI 是原罪。换成硬件 SPI,时钟提到 20MHz+,速度立竿见影;
- 启用 DMA 后,CPU 几乎不参与传输,还能干别的事。
❌ 中文显示成方块?
- 检查字库是否正确加载;
- 确保 UTF-8 解码逻辑正确,特别是多字节字符切分;
- 查看字模尺寸是否与绘制函数匹配(16×16 却按 8×8 读)。
性能与资源优化建议
为了在低端 MCU 上也能流畅运行,我总结了几条实用经验:
✅ 使用 Flash 存储资源
字体、图标、LOGO 全部放进 Flash,使用 pgm_read_word (ESP32/AVR)或 __packed 属性访问,RAM 压力骤降。
✅ 局部刷新代替全屏重绘
比如只更新时间区域、电池图标,其他保持不变。可节省 70% 以上的 SPI 通信量。
✅ 合理布局 PCB
- SPI 信号线尽量短且等长;
- 远离电机、继电器等干扰源;
- 地平面完整铺铜,降低 EMI。
✅ 电源设计不容忽视
尽管 ST7789V2 支持宽压,但仍建议使用 LDO 输出稳定的 3.3V。若共用系统电源,务必添加滤波电容。
写在最后
这套 ST7789V2 驱动代码已在多个项目中验证:从 STM32F103 的工业面板,到 ESP32 的 Wi-Fi 相框,再到 Arduino Nano 的迷你终端,都能稳定运行。
它的核心价值不只是“能显示”,而是 在资源极度受限的条件下,实现可靠、高效的图形输出 。理解底层机制后,你可以轻松扩展出线条、矩形、圆形等基本图形函数,甚至构建简单的 GUI 框架。
未来如果有需求,我也计划将其封装为跨平台库(Arduino Library + STM32 HAL Driver),支持更多功能如触摸输入、双缓冲动画等。
毕竟,在嵌入式世界里,能让一块小屏幕准确表达信息,本身就是一种优雅的技术实现。

1547


被折叠的 条评论
为什么被折叠?



