简介:ST7789V2是智能手表、微型显示屏中常见的LCD控制器,该资源提供了一套亲测可用的字符与图片显示代码,适合需要快速上手驱动的嵌入式开发者。压缩包内共2个文件,包括lcd.c与lcd.h,体积仅6KB,前者封装了初始化、SPI/I2C通信、像素操作和显示刷新等核心函数,后者声明了接口与常量,整体结构清晰,便于直接引用到工程中。代码重点展示了如何通过RGB565格式向控制器写点,内置ASCII字符库可将字符映射为像素阵列,同时也给出图片数据转换与帧缓冲刷新的基本思路,覆盖从底层时序配置到上层显示调用的完整链路。目前已有3885人浏览学习,对正在调试ST7789V2屏幕、希望快速实现基础显示功能的开发者来说,是一份轻量且可直接验证的参考实现。
1. 点亮 ST7789V2 的第一步:把画点函数跑通
把 ST7789V2 驱动的小尺寸显示屏接到手头这块开发板上,第一件事不是抢着显示一句 Hello World,而是先把画点函数跑通——屏幕上所有内容,字符、图片、进度条、波形图,最终都建立在画点之上。这份驱动代码我已经在实际工程里完整跑通,从 SPI 初始化、寄存器序列到 ASCII 字模渲染和 RGB565 图片整屏显示,拿过去替换引脚映射就能点亮。适合正在做低成本显示面板、数据仪表盘,或者想在同一块屏上叠加文本和图片的嵌入式开发。下面按我实际调试的顺序,从硬件接线、初始化链路、渲染实现,一路讲到内存优化和典型踩坑记录。
2. 硬件连接与初始化序列:把 ST7789V2 点亮的完整链路
2.1 引脚映射与 SPI 接线方式
ST7789V2 这颗驱动在常见小屏模块上基本统一成了 SPI 接口,总共要接 7 根信号线:电源正负、串行时钟 SCLK、数据线 MOSI、片选 CS、数据/命令选择 DC、复位 RST,再加一根背光控制 BLK。多数模块会把 CS 默认拉低,但我的习惯是把它接到 GPIO 上,这样初始化失败时还能单独复位总线状态。
| 屏端引脚 | 类型 | 接到 MCU 的用途 |
|---|---|---|
| VCC | 电源 | 3.3V,不能直接接 5V |
| GND | 地 | 与 MCU 共地 |
| SCLK | 输入 | SPI 时钟,速率建议 10MHz 起步 |
| MOSI | 输入 | SPI 数据,只写不读 |
| CS | 输入 | 片选,可接 GPIO 或固定低 |
| DC | 输入 | 命令/数据选择,最关键的时序引脚 |
| RST | 输入 | 复位,低电平至少保持 10ms |
| BLK | 输入 | 背光使能,不接时默认常亮 |
关于引脚映射,常见做法是把 SCLK 和 MOSI 接到 MCU 的硬件 SPI 引脚,剩下 CS、DC、RST、BLK 全部交给 GPIO。注意屏幕的 VCC 额定电压是 3.3V,如果模块上没做电平转换,直接用 5V 供电轻则花屏,重则烧驱动。如果模块板载了稳压电路,那 VCC 才能按模块丝印标注来。
如果 MCU 没有空闲的硬件 SPI,软件模拟也能跑。软件 SPI 的好处是引脚随便选,坏处是每次读写都要 CPU 参与,刷新一张 240×240 的图片会很吃力。我的建议是能用硬件 SPI 就不要用软件模拟,把 CPU 省下来处理业务逻辑。
/* 引脚下发用宏来做,方便换板子时只改这里 */ #define PIN_SCLK GPIO_PIN_5 /* 接屏幕 SCLK */ #define PIN_MOSI GPIO_PIN_7 /* 接屏幕 MOSI */ #define PIN_CS GPIO_PIN_2 /* 接屏幕 CS */ #define PIN_DC GPIO_PIN_3 /* 接屏幕 DC */ #define PIN_RST GPIO_PIN_4 /* 接屏幕 RST */ #define PIN_BLK GPIO_PIN_1 /* 接屏幕 BLK */ #define LCD_CS_HIGH() GPIO_WritePin(CS_PORT, PIN_CS, 1) #define LCD_CS_LOW() GPIO_WritePin(CS_PORT, PIN_CS, 0) #define LCD_DC_HIGH() GPIO_WritePin(DC_PORT, PIN_DC, 1) #define LCD_DC_LOW() GPIO_WritePin(DC_PORT, PIN_DC, 0) #define LCD_RST_HIGH() GPIO_WritePin(RST_PORT, PIN_RST, 1) #define LCD_RST_LOW() GPIO_WritePin(RST_PORT, PIN_RST, 0)这组宏没有复杂逻辑,只是把 GPIO 置高置低封装成语义清楚的接口,后面所有驱动代码只认这些宏,不再直接操作寄存器。拿到代码要做的第一件事就是把端口名、引脚号替换成自己板子上的定义,换板时也只需要动这一小段,渲染逻辑完全不用碰。CS 和 DC 这两个引脚不能被 SPI 外设自动控制,必须用 GPIO 手动拉,时序上要先于数据变化。
2.2 初始化序列解析:每个寄存器命令的作用
初始化是驱动里最容易被忽略的环节。很多人拿到代码直接跳着发命令,结果屏幕亮了但颜色不对,或者画面整体偏移。先按下面这个顺序跑,跑通了再逐条删命令验证各条作用。
void LCD_Init(void) { /* 1. 复位:RST 拉低至少 10ms,再拉高等 120ms */ LCD_RST_LOW(); DelayMs(50); LCD_RST_HIGH(); DelayMs(120); /* 2. MADCTL:控制扫描方向和 RGB/BGR 排列 */ LCD_WriteCmd(0x36); LCD_WriteData(0x00); /* 默认方向,RGB 排列 */ /* 3. COLMOD:像素格式 */ LCD_WriteCmd(0x3A); LCD_WriteData(0x05); /* 16bit 色深,RGB565 */ /* 4. 关闭反色显示 */ LCD_WriteCmd(0x20); /* 5. 退出睡眠模式,等电源稳定 */ LCD_WriteCmd(0x11); DelayMs(120); /* 6. 打开显示 */ LCD_WriteCmd(0x29); }这段序列里 0x36 最常被调整。它的低 3 位控制扫描方向,第 3 位控制 RGB/BGR 排列,第 5 位控制显示镜像。参数 0x00 表示从左上角开始、红绿蓝正常排列;屏幕方向反了或者颜色互换,改的就是这个参数,而不是改画点函数。0x3A 设置成 0x05 表示 16bit,0x06 是 18bit,两种色深的字节对齐不一样,后续写数据必须和它保持一致。0x20 和 0x21 互为反义,0x21 会开启反色,看到负片效果时要先怀疑是不是多发了这一条。睡眠退出命令 0x11 后面的延时至少给到 120ms,这个时间别压缩,电源纹波和内部晶振起振都靠这段时间稳定。
提示:屏幕旋转后内容只在局部显示,多数不是初始化问题,而是 0x2A/0x2B 窗口寄存器坐标没有跟着方向改,细节放在第 5 章。
写命令和写数据这两个底层函数值得单独看清楚,因为 DC 引脚的极性是这类 SPI 屏最容易错的地方。
void LCD_WriteCmd(uint8_t cmd) { LCD_DC_LOW(); /* DC=0:当前字节是命令 */ LCD_CS_LOW(); SPI_WriteByte(cmd); /* 写入命令字节 */ LCD_CS_HIGH(); LCD_DC_HIGH(); /* 回到数据态 */ } void LCD_WriteData(uint8_t dat) { LCD_DC_HIGH(); /* DC=1:当前字节是数据 */ LCD_CS_LOW(); SPI_WriteByte(dat); LCD_CS_HIGH(); }时序要点是 DC 信号要和 SPI 字节对齐:CS 拉低之后第一个时钟上升沿采样的必须是有效数据,所以 DC 要在 CS 之前设置好。如果 DC 和 CS 顺序写反,屏幕就会随机把命令和数据混在一起,现象是初始化序列无效、画面花。SPI_WriteByte 在自己工程里就是硬件 SPI 发送一字节的封装,先用轮询发送跑稳定,再换 DMA 提速。
3. 核心渲染代码拆解:从画点函数到 RGB565 图片整屏显示
3.1 画点函数与窗口设置:一切渲染的基石
ST7789V2 没有“直接画一个点”的指令,它只有“设置窗口 + 批量写像素”这个操作。所以画一个点也要走完整流程:先用 0x2A 设置列地址范围,再用 0x2B 设置行地址范围,最后用 0x2C 往显存里写一个像素。
void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); /* 列地址 */ LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); /* 行地址 */ LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); /* 开始写显存 */ } void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { if (x >= LCD_WIDTH || y >= LCD_HEIGHT) return; /* 越界直接丢弃 */ LCD_SetWindow(x, y, x, y); LCD_WriteData(color >> 8); /* 高字节在前 */ LCD_WriteData(color & 0xFF); }边界检查值得保留。ST7789V2 的窗口寄存器是 16bit 的,但实际有效区域只有 240×240,往越界位置写像素不会报错,而是卷到显存的另一个位置,表现出来就是斜向杂色条纹。颜色这里按高字节在前发送,这是 SPI 屏最常见的字节序;如果颜色总是红蓝互换,把这两行的发送顺序反过来试。
窗口参数按闭区间传,比如画一个宽 5 像素的竖条,调用 LCD_SetWindow(10, 0, 14, 239),列地址是 10 到 14 共 5 列。第一次用的人容易传成 10 到 15,多出一列,滚动屏幕时右侧会出现一条多余的线。窗口设置的越界没有异常机制,它只会安静地写错显存。
3.2 字符显示:ASCII 字模与坐标计算
字符显示的本质是把每个字符的字模点阵铺到画点上。ST7789V2 不内置字库,全部靠驱动自己准备。常见做法是用 5×7 或 8×16 的 ASCII 点阵表,这份驱动里用的是 5×7,每个字符占 7 个字节,每个字节的高 5 位依次对应屏幕上的 5 列。
/* 以字符 'A' 为例,每字节的 bit7~bit3 对应一列 */ const uint8_t font5x7[][7] = { /* ' ' */ {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, /* '!' */ {0x10, 0x10, 0x10, 0x10, 0x00, 0x10, 0x00}, /* 'A' */ {0x7C, 0x82, 0x82, 0x7C, 0x82, 0x82, 0x00}, /* 'B' */ {0xFC, 0x82, 0xFC, 0x82, 0x82, 0xFC, 0x00}, }; void LCD_DrawChar(uint16_t x, uint16_t y, char ch, uint16_t color, uint16_t bg) { if (ch < ' ' || ch > '~') ch = '?'; const uint8_t *glyph = font5x7[ch - ' ']; for (uint8_t row = 0; row < 7; row++) { uint8_t line = glyph[row]; for (uint8_t col = 0; col < 5; col++) { if (line & (0x10 >> col)) { LCD_DrawPixel(x + col, y + row, color); } else if (bg != COLOR_NONE) { LCD_DrawPixel(x + col, y + row, bg); } } } }font5x7 的索引就是 ASCII 码减去空格字符的偏移,ch 是 'A' 时取到 font5x7['A' - ' ']。查表前加一个 if 判断,把控制符和不可见字符统一替换成 '?',避免非法下标越界。把 '\0' 或换行符传进来时,数组越界可能把相邻字符目录也读进来,这是很隐蔽的内存踩踏。
绘制逻辑上,每行字节 line 从高位到低位逐位判断,bit4 对应第 1 列,bit0 对应第 5 列。字符是 5 像素宽、7 像素高,字符间距由调用方控制,两个字符之间通常留 1 像素空隙。bg 参数传 COLOR_NONE 可以跳过背景绘制,这种透明字符在叠加内容时很省时间。
中文显示也是一样的套路,只是把字模换成 16×16 或 24×24,索引方式从字符编号变成 GB2312 或 UTF-8 解码后的区位码。代码结构不用改,只是从直接查表变成先解码再查表。
3.3 图片显示:RGB565 格式转换与整屏刷新
显示图片和显示字符本质是同一件事:把一块区域的像素逐一喂给 0x2C。最容易忽略的是发送窗口和像素循环的配合。
void LCD_ShowImage(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint16_t *img) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); for (uint32_t i = 0; i < (uint32_t)w * h; i++) { LCD_WriteData(img[i] >> 8); LCD_WriteData(img[i] & 0xFF); } }这段代码先算好整张图片的窗口,然后连续写数据。循环变量用了 uint32_t,因为 240×240 的像素总量是 57600,uint16_t 最大只能到 65535,单张图刚好放得下,但同一时间刷新两块区域或图片更大时,16bit 计数器就会溢出变成死循环。这是个容易被忽略的坑,代码审查时最容易漏掉。
RGB565 的意思是每个像素 16bit:高 5 位红色、中 6 位绿色、低 5 位蓝色。图片在电脑上转换时,常见做法是用取模工具把 BMP/JPG 输出成 C 语言数组,转换时选择 RGB565 大端模式,也就是数组里先存高字节再存低字节。手头只有小端数组时,发送时把 img[i] 的高低字节交换一下就行,不用重新取模。
整屏刷新时,窗口设成一整屏,然后连续写 57600 个像素。到这里有一个性能选择:如果 MCU 有硬件 SPI 和 DMA,可以连续搬运,CPU 完全不用管;没有 DMA 时就得考虑一次只刷新局部区域,比如先刷新变化区域再刷新状态栏,用户感知会快很多。图片数组放在 const 区能省 RAM,但串行 Flash 读取比 RAM 慢,刷全屏时会卡顿。能放 RAM 的图片尽量放 RAM,不能放的就分批搬运,一次搬半行到一行,边读边写,避免整张图占满内存。
4. 工程目录与运行:从固件编译到屏幕正常显示
4.1 工程目录结构与关键文件职责
驱动在工程里按模块分层,字符、图片、底层外设互不纠缠,目录结构大致如下:
project_root/ ├── app/ │ ├── main.c # 入口,初始化外设后调用 LCD_Init │ └── lcd_demo.c # 示例:显示字符、图片、跑马灯 ├── driver/ │ ├── lcd_st7789.c # ST7789V2 底层初始化与读写 │ ├── lcd_st7789.h # 对外接口声明 │ └── spi_mcu.c # SPI 外设初始化与单字节收发 ├── fonts/ │ ├── font5x7.c # ASCII 点阵表 │ └── font_16x16.c # 中文字库(按需启用) ├── image/ │ └── pic_logo.c # 取模工具生成的 RGB565 图片数组 └── build/ └── Makefile # 编译脚本| 文件 | 职责 | 二次开发时改哪里 |
|---|---|---|
| spi_mcu.c | 初始化 SPI 时钟/引脚,提供 SPI_WriteByte | 换 MCU 时主要改这里 |
| lcd_st7789.c | 初始化序列、画点、字符、图片接口 | 功能扩展主要改这里 |
| font5x7.c | 字形点阵表 | 想换字体就替换表数据 |
| lcd_demo.c | 演示调用顺序 | 新功能的起点 |
main.c 里的调用顺序有讲究:先配置系统时钟,再初始化 SPI GPIO,然后调用 LCD_Init,最后进入刷屏逻辑。如果 SPI 初始化跑在 LCD_Init 之后,第一次写命令时总线时钟还是错的,寄存器序列全部白写。我习惯在 LCD_Init 入口加一次软复位,相当于初始化前再清一次状态,这样调试中复位 MCU 后屏也能重新收一遍命令。
4.2 编译配置与内存占用:全屏缓冲的性价比不高
编译这份驱动不需要额外库,重点是确认 SPI 时钟分频和缓冲区策略。SPI 速率我一般给到 10MHz 起步,排线短可以上 20MHz;超过 20MHz 丢位率明显上升,这时候不是软件能救的。时钟模式必须选 Mode 0,CPOL=0、CPHA=0,和 ST7789V2 的输入采样沿匹配。
内存占用最容易翻车。240×240×2 字节是 115200 字节,约合 112.5KB,这对很多小 MCU 来说几乎是全部 RAM。如果代码里还准备了一个 uint16_t 全屏数组做双缓冲,那 225KB 直接爆掉。常见做法是去掉全屏缓冲,只在 LCD_ShowImage 里逐像素流式写入,也就是这份驱动目前的实现——不申请任何大块 RAM,只用了几个局部变量。代价是刷新期间 CPU 不能干别的,如果一边刷屏一边处理按键,就考虑局部缓冲方案:一次只缓冲 8 行,DMA 搬运 8 行的时间去处理输入。
/* 一个 5x7 字符点阵表约 95 字符 * 7 字节 = 665 字节 */ /* 一张 240x240 RGB565 图片的数组是 115200 字节 */ /* Flash 不足时优先压缩图片尺寸或换更小的字库 */不需要背这些数字,编译时看 map 文件就知道 Flash 还剩多少,再决定图片要不要降分辨率。字符点阵表对 Flash 占用很小,问题永远出在图片数组上,这是优先下手的地方。
4.3 验证手段与失败时的检查顺序
驱动写完先别急着看效果,按顺序做三个验证。
第一,用逻辑分析仪或示波器抓 SCLK 和 MOSI,确认 SPI 时钟频率符合配置,抓到的第一个字节确实是 0x36。分析仪能直接看到命令字节后面跟的数据对不对,DC 拉低时发的是不是命令。第二,把 SPI 关掉,所有引脚设为普通 GPIO,手动电平翻转确认 CS、DC、RST 都连对了,这一步能排除接线错误和 GPIO 复用冲突。第三,把延时时间翻倍再初始化,如果翻倍后能点亮但 120ms 不行,基本就是复位时序给太短。
如果这三步都过了还是白屏,问题很可能落在硬件上。常见情况是模块没焊好,SPI 数据线或 SCLK 虚焊,用示波器看 MOSI 上有没有波形,没波形就查焊接和排线,不要重复刷寄存器命令。
| 现象 | 检查顺序 |
|---|---|
| 白屏无任何输出 | SPI 波形 → 复位延时 → 电源电压 |
| 花屏但有背光 | 命令/数据顺序 → 像素格式 0x3A → 反色 0x20/0x21 |
| 有内容但颜色不对 | MADCTL 0x36 的 RGB 位 → 字节序高低位 |
| 滚动撕裂 | 是否整窗写入 → DMA 与 TE 同步 |
5. 避坑笔记:ST7789V2 调试中反复踩过的四个常见问题
5.1 画面整体发花,颜色像调色盘打翻
现象:初始化后屏幕能亮,但显示的颜色通道像是被旋转了一遍,原图的红变成蓝,人脸变绿。
原因:多数是 MADCTL 的 RGB/BGR 位反了,或者 0x3A 设置了 18bit 像素格式而代码按 16bit 发送。这两种情况都在初始化序列里,影响的是全局颜色映射而不是局部花屏。
解决:先确认 0x3A 的参数是 0x05,然后把 0x36 的参数从 0x00 改成 0x70、0xA0 逐个试,看哪种最接近正常。如果颜色还是不对,把 LCD_WriteData 的高低字节发送顺序互换再测。屏厂给的初始化参数表里通常会标注好默认方向,买模块回来第一件事就是拿它对照。
5.2 图片和字符整体偏移,屏幕只剩一部分内容
现象:整屏图片或字符像被推到了一边,左边缺一块,右边多出一条黑边,滚动时边缘还会出现残影。
原因:显示区域坐标起点和窗口寄存器没有配对。ST7789V2 的显存是 240×240,但不同模组物理封装后,显存起始地址可能和屏幕第一行不对齐,这个偏移量由模组厂家决定,代码里看不到。
解决:在初始化后增加坐标偏移,常见做法是给画点函数传入 offset_x 和 offset_y,或者修改窗口寄存器的起始坐标。偏移是固定值时,也可以直接写死在 LCD_DrawPixel 入口。注意偏移量会因 MADCTL 旋转而变化,旋转 90 度后要重新验证一遍,不能沿用竖屏的偏移参数。
5.3 白屏但背光亮,程序看起来跑得正常
现象:初始化流程执行完,屏幕保持全白或全灰,像完全没收到数据。
原因:SPI 模式不对是最常见的。ST7789V2 要求 CPOL=0、CPHA=0,如果初始化 SPI 时误设成 Mode 3,时钟沿错了,发过去的命令字节驱动识别不了。其次是复位时序太短,RST 拉低不足 10ms 会让寄存器崩在未知状态。
解决:先检查 SPI 配置里 CPOL 和 CPHA 两个位,再在 LCD_Init 开头把 RST 低电平时间从 50ms 加到 100ms,高电平后等 150ms。最后把背光引脚先接固定高电平,排除 GPIO 配置冲突。这一步排线一乱就是血泪经验,换屏排线后白屏的概率比寄存器写错高得多。
5.4 滚动或刷新时出现明显撕裂横线
现象:屏幕内容不是整块切换,画面中间有一条横线,上下半屏内容错开,快速滚动时尤其明显。
原因:窗口刷新分成了两个连续区间,两个区间之间跨过一个显示扫描周期;或者 DMA 搬运和屏幕自刷新不同步,屏幕正在扫描到中部时写入数据,撕裂线就出现了。
解决:方向一是把每次刷新包在一个 LCD_SetWindow 里,不要在一个窗口里分两次写。方向二是用 ST7789V2 的 TE(Tearing Effect)输出信号,等屏幕进入安全扫描窗口再开始写,初始化里开 TE 的代码很简短:
/* 初始化时开启 TE 输出,拉高 TE 引脚电平 */ LCD_WriteCmd(0x35); /* 0x35: TEON */ LCD_WriteData(0x00); /* 参数 0: 从 VSYNC 同步 */方向三是拿不到 TE 信号时,先初始化一次全窗口,之后所有局部刷新都沿用这个窗口,避免反复切换窗口触发边界错位。撕裂不是驱动 bug,是写入时机和扫描时机没对齐,确认这个前提后排查路径会短很多。
6. 二次开发思路:从画点驱动升级成可复用的显示框架
拿到这份 ST7789V2 驱动,最容易做的是在 LCD_DrawPixel 上不断叠封装。我的建议是先别急着加几百个功能函数,而是定义一个绘图表面的抽象接口,让上层代码不再关心屏幕分辨率、方向和底层 SPI 发送:
typedef struct { uint16_t width; uint16_t height; void (*draw_pixel)(uint16_t x, uint16_t y, uint16_t color); void (*fill_rect)(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color); } lcd_surface_t;把分辨率、画点、填充矩形抽出来之后,上面跑列表菜单、跑图表都只依赖这个结构体。以后换模组,只要另外实现一套 draw_pixel 和 fill_rect,业务代码一行不用改。这是这份驱动给我最大的启发。
再往上一层,可以尝试做离屏画布。内存够时就准备一块 240×240×2 的 RAM 作为画布,把写屏函数改成搬一块区域到屏幕;内存不够就改成 40×40 的小图标画布,用透明通道拼出复合图案,再一次性刷上去。轴向图表、滑块、进度条都是这么拼出来的,不必每次都在屏幕上擦来擦去。
验证方法上,我的习惯是给 fill_rect 写一个纯坐标计算的单元测试——不开屏、只验证越界。比如画 10 个随机矩形全屏叠加,统计最终覆盖的像素数和数学期望比对,误差为零才连硬件下发。这个习惯救了我很多次,从那以后我每次改布局都强制先跑一遍屏幕坐标越界检查,再连硬件测试。
希望这些思路和踩坑记录能帮你在 ST7789V2 上少走两趟弯路。
本文还有配套的精品资源,点击获取