ST7789V2驱动实现:字符与图片显示

AI助手已提取文章相关产品:

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 引脚特别关键——它是命令和数据的“开关”。

通信流程其实很简单:

  1. 拉低 CS 片选,启动传输;
  2. 设置 DC:低电平发命令,高电平发数据;
  3. 通过 MOSI 发送字节,SCK 提供同步时钟;
  4. 一轮结束后拉高 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 。虽然效率不高,但对于菜单文字、状态提示这类静态内容足够用了。

至于中文显示,难点不在渲染,而在 编码匹配和存储 。推荐做法是:

  1. 使用 UTF-8 编码解析输入字符串;
  2. 对每个汉字查表获取其在 GB2312 或 Unicode 字库中的索引;
  3. 从外部 Flash 或程序内存加载对应 16×16 或 24×24 点阵数据;
  4. 按位绘制。

实际项目中,我会将常用汉字预生成为 .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),支持更多功能如触摸输入、双缓冲动画等。

毕竟,在嵌入式世界里,能让一块小屏幕准确表达信息,本身就是一种优雅的技术实现。

您可能感兴趣的与本文相关内容

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值