☰
GC9A01圆屏驱动实战:从ST7789移植到ESP32避坑指南
2026/10/2 3:35:37 网站建设 项目流程

简介:针对ESP32与GC9A01彩色TFT屏的MicroPython驱动资源,面向物联网及嵌入式开发者,解决从零编写屏驱代码的耗时难题。压缩包共25个文件,以py源码为主,辅以mpy预编译模块、raw图像与bmp位图素材,另有license与md说明,整体仅233KB,轻量易部署。已有190人学习下载,具备一定参考热度。资源内含完整的SPI通信初始化、基础图形绘制、文字显示、图像渲染及滚动、弧线等示例,代码均经实测可用,并附带注释;作者承诺提供技术支持,遇到问题可寻求协助,有助于开发者快速完成屏幕接入并专注于项目逻辑实现。

1. 圆屏上手前,先知道GC9A01和ST7789的差别在哪里

GC9A01这块240x240圆形LCD,很多人第一次拿到就当ST7789用,结果不是偏色就是整个圆环错位一圈,我看着也踩过同样的坑。最后把ST7789驱动改了三个点才稳定下来:init序列里的厂商解锁、行列地址偏移、MADCTL的RGB/BGR位。这套方案在ESP32系列上跑得很稳,SPI 40MHz时全屏填充一帧大约30ms,连续运行几小时不花屏。适合做手表表盘、旋钮面板、桌面小摆件这类需要一块圆屏显示状态信息的设备。如果你手里正好有一块GC9A01,想用MicroPython快速点亮并让它显示文字、图形和图片,这篇就是给你准备的。

2. 选型与接线:用ST7789驱动改GC9A01的必经路

GC9A01本身是一颗SPI接口的TFT驱动芯片,命令集和ST7789高度相似,所以现成的ST7789驱动代码大部分可以直接复用,但直接复制过去会出各种怪问题。先讲清楚硬件怎么接、驱动为什么这么改,后面跑代码才不会到处找原因。

2.1 引脚与接线:先排除黑屏的硬件问题

常见的GC9A01模组是7个引脚:VCC、GND、SCL、SDA、RES、DC、CS,部分模组还单独引出BLK背光控制。接线先按这个表来,我一般把背光单独接PWM引脚,而不是直接接3.3V,这样后面调亮度方便,也避免背光一直满电流导致发热。

模组引脚作用接ESP32-S3说明
VCC电源3V3不要接5V,部分带稳压的模组可以5V,但逻辑电平还是3.3V
GND地GND必须和单片机共地
SCLSPI时钟GPIO12对应SPI2 SCK
SDASPI数据GPIO11对应SPI2 MOSI,只写不读所以MISO不接
RES复位GPIO14低电平复位,不能用悬空
DC数据/命令切换GPIO9高电平写数据,低电平写命令
CS片选GPIO10拉低选中
BLK背光GPIO15接PWM,频率建议1kHz以上

注意几个细节:SCL和SDA之间不要飞太长的线,尤其是用杜邦线时,超过15cm后40MHz时钟就很容易出现串扰。RES引脚如果板上已经接了上拉电阻,可以不接MCU,但我习惯还是接一个GPIO控制,这样初始化时能手动复位,避免上电时序不稳定。CS必须接,不能直接接地,虽然只挂一块屏时接地也能用,但后续想加第二个SPI设备就必须靠CS区分。

接好线后,先不急着写驱动,直接用一个最简单的GPIO测试确认背光和复位正常:

from machine import Pin import time blk = Pin(15, Pin.OUT) rst = Pin(14, Pin.OUT) blk.value(1) rst.value(0) time.sleep_ms(50) rst.value(1)

这段代码先把背光点亮、复位脚拉低再释放。如果这时屏幕有轻微闪烁或者背光亮了,说明电源和基本引脚没问题。如果背光都不亮,先检查VCC和GND,再用万用表量模块背面LDO输出是否有3.3V,很多模组虽然标注5V输入,但板上其实有一颗RT9013之类的稳压芯片,供电问题在圆屏模组上比例不低。

2.2 驱动选型:为什么不自造轮子,而是改ST7789的驱动

MicroPython下给GC9A01写驱动,最常见也最可靠的做法是从现有的ST7789驱动改,而不是从寄存器手册一行一行写。原因是GC9A01的像素格式、窗口设置、SPI时序和ST7789基本一致,真正不同的地方就三处:初始化时多了厂商解锁命令、行列起始地址可能有偏移、颜色顺序位不同。

ST7789驱动的经典代码结构一般是:构造函数里传SPI、CS、DC、RST,初始化时发送一串寄存器命令,然后提供fill、pixel、hline、blit_buffer这些绘图方法。改造时重点看init函数:

def _init(self): # 第一处差异:GC9A01需要先解锁厂商命令 # ST7789的init里没有这段,直接跳转到其他设置 self._cmd(0xFE) self._cmd(0xEF) self._cmd(0xFE) self._cmd(0xEF) # 第二处差异:像素格式统一设为RGB565 self._cmd(0x3A, 0x05) # 第三处差异:行列地址范围 # 很多GC9A01模组的起始地址不是0,0,而是整体偏移了1~2像素 self._cmd(0x2A, bytes([0x00, 0x00, 0x01, 0x6F])) self._cmd(0x2B, bytes([0x00, 0x00, 0x01, 0x6F])) self._cmd(0x36, self._madctl(self._rotation)) self._cmd(0x11) time.sleep_ms(120) self._cmd(0x29)

这里三个差异点都需要解释。第一处0xFE/0xEF是GC9A01的厂商命令解锁,ST7789不需要,很多现成驱动改过来之后屏幕灰白一片,就是因为这段被当成无效命令跳过了。第二处0x3A设为0x05表示16位色RGB565,如果这里保持ST7789的某些默认值,颜色会发紫或者显示雪花。第三处行列地址范围0到239(0x6F是十六进制的111,也就是239),但如果你的模组整体显示偏移,需要把0x2B的起始地址改成1或2,这在第5章排查里细说。

颜色顺序差异在MADCTL里。_madctl这个方法会按旋转角度计算MX、MY、MV几个位,但GC9A01和ST7789默认的RGB/BGR位可能相反。

def _madctl(self, rotation): # rotation: 0, 1, 2, 3 对应 0°, 90°, 180°, 270° # MV表示行列交换,MX左右镜像,MY上下镜像 # bit3(0x08)是RGB/BGR顺序,0表示RGB,1表示BGR if rotation == 0: return 0x48 # 实测我的模组是BGR顺序 elif rotation == 1: return 0x28 elif rotation == 2: return 0x88 elif rotation == 3: return 0xA8

我手里的模组MADCTL设为0x48时颜色正常,如果你的屏幕红蓝互换,就把这个值加减0x08再试。旋转角度也可以在这里一次性调整,不需要改代码逻辑。选型结论很明确:去找一份结构干净的ST7789驱动,把init函数按上面替换,保留原有的绘图方法,就得到了一份可用的GC9A01驱动。不必从零写,也不必去网上找所谓的专用完美驱动,因为很多专用驱动反而绑定了特定硬件平台。

2.3 固件与开发环境:用Thonny连上板子再谈显示

代码改好之后,需要一个能交互调试的环境。常见做法是装Thonny,连接ESP32-S3的USB口,在解释器里选择MicroPython(ESP32)。注意连接前先按住板子上的BOOT按键再插USB,这样可以进入烧录模式,然后在Thonny的“解释器”菜单里选择对应的端口,点击“安装或更新固件”,把下载好的MicroPython固件烧进去。固件版本建议选带SPI驱动支持的新版,但具体哪个版本不是决定性因素,只要不是特别老的1.19之前版本都能跑。

连上之后,在Thonny的Shell窗口里先做一次基础验证:

import machine print(machine.freq())

能打印出CPU频率就说明REPL正常工作。然后把上面改好的驱动命名为gc9a01.py,通过Thonny保存到开发板根目录,后面所有示例代码都能import gc9a01直接使用。这里有个小坑:如果你把脚本保存为main.py,板子上电后会自动执行,调试阶段建议先不要写main.py,直接用Shell里的代码手动测试,否则显示代码崩溃一次就进不了REPL,还得重新擦除固件。

3. 在Thonny里跑通第一块圆屏:最小驱动代码与验证

驱动文件放好后,真正的乐趣从点亮开始。建议用三步递进验证:先全屏纯色,再显示文字,最后显示图片。每一步都能筛掉一类问题。

3.1 最小点亮代码:全屏纯色是最快的自检

新建一个文件,输入下面的代码,注意调整引脚编号与你的接线一致:

from machine import Pin, SPI import gc9a01 # SPI2: SCK=GPIO12, MOSI=GPIO11, MISO=GPIO13 # GC9A01只写不读,MISO可以不接,但SPI对象里仍要指定一个引脚 spi = SPI(2, baudrate=40_000_000, sck=Pin(12), mosi=Pin(11), miso=Pin(13)) display = gc9a01.GC9A01( spi, cs=Pin(10, Pin.OUT, value=1), dc=Pin(9, Pin.OUT, value=0), rst=Pin(14, Pin.OUT, value=1), backlight=Pin(15, Pin.OUT, value=1), rotation=0, width=240, height=240, ) # 全屏填充绿色,RGB565格式的颜色值 display.fill(0x07E0)

这个Demo的逻辑很简单:创建一个SPI对象,实例化GC9A01屏对象,然后fill填充整屏。看三个参数:baudrate设40M,这是我在杜邦线条件下反复试出的稳定值;cs和dc都指定了初始电平,避免上电瞬间误操作;rotation=0表示不旋转,因为圆屏本身无所谓横竖,坐标原点在左上角。

如果这一步屏幕全绿,恭喜,接线和init正确。如果屏幕花屏、偏色、或者只有背光亮,先检查SPI模式:很多ST7789驱动用mode 0(polarity=0, phase=0),但部分GC9A01模组在mode 3下更稳。临时改成下面这样对比:

spi = SPI(2, baudrate=40_000_000, polarity=1, phase=1, sck=Pin(12), mosi=Pin(11), miso=Pin(13))

mode切换是排查花屏最高效的手段,一次不行就换另一种。如果仍然异常,回到第2章的引脚测试确认背光和复位。

3.2 显示文字与简单图形:确认坐标系和旋转方向

纯色通过后,接着验证文字和坐标定位。驱动里常见的做法是外挂一个字体模块,比如把vga2_8x16这类字体文件存到板子上,然后配合Writer类输出字符串:

from writer import Writer import vga2_8x16 as font w = Writer(display, font, verbose=False) w.set_textpos(60, 80) w.printstring("GC9A01 OK") # 画一个圆,确认坐标和颜色 display.fill_circle(120, 120, 50, 0xF800)

Writer类的set_textpos接收(x, y),坐标原点在屏幕左上角,x向右,y向下。textpos参数顺序各版本可能相反,如果文字跑到了想不到的位置,把两个参数对调试试。fill_circle的中心坐标也是同样的坐标系,圆屏虽然外形是圆的,但驱动内部仍是240x240的矩形坐标,四个角区域在物理上被外壳遮挡,画图时不用刻意避开。

这一步常见问题是文字颜色不对或文字背景一大块黑色。原因通常是字体模块默认把背景色设为黑色,如果屏是深色背景看不出来,换成浅色背景就露馅。解决方法是Writer初始化后手动设text背景色和前景色:

w.set_color(0xFFFF, 0x0000) # 白字黑底

set_color两个参数分别是前景色和背景色,前景色就是文字颜色,背景色可以改成透明屏同色,这样不会有黑色方块。字体模块选择上,8x16的英文字体省内存,想显示中文需要单独的Unicode字库文件,后面进阶章节会提。

3.3 显示一张图:RGB565裸数据与blit_buffer

圆屏最实用的功能是显示小尺寸图片,比如logo。常见做法是先在一台电脑上用Python把图片转换成RGB565格式的裸数据,再传到开发板上。下面的代码假设你已经有一张120x120的图片裸数据文件logo.raw:

IMAGE_W = 120 IMAGE_H = 120 with open('logo.raw', 'rb') as f: raw = f.read() # 把图片画到屏幕中心 display.blit_buffer(raw, x=60, y=60, w=IMAGE_W, h=IMAGE_H)

blit_buffer是驱动里最核心的显示方法,四个参数含义:x和y是图片左上角坐标,w和h是图片宽高,raw数据每两个字节对应一个像素的RGB565值。如果你的图片是ARGB8888格式,需要先做通道转换,常见做法是在转换脚本里按小端序把RGB三个通道拼成16位颜色值。图片尺寸不要超过240x240,圆屏边角会被裁剪,所以设计素材时把主要内容放在中心半径120像素以内。

如果显示出来的图片颜色发暗或偏色,基本就是raw数据格式不对。RGB565里红色占高5位,绿色占中间6位,蓝色占低5位,字节顺序是小端,即低字节在前高字节在后。转换脚本里最容易出错的是颜色通道顺序,我在转换时会把每个像素值拆开打印几个样本对比屏幕效果,确认无误后再整体刷入。

4. 刷新与性能:把SPI时钟、缓冲区、背光PWM调到能长期用

一块圆屏不能只点亮一次就完事,实际项目里要做表盘、数值刷新、动态状态图标,这些都对刷新效率和稳定性有要求。这一章说清楚性能瓶颈在哪里、怎么调。

4.1 SPI时钟不是越高越好

GC9A01内置GRAM,写入数据后立即显示,不需要软件控制刷新率,所以帧率瓶颈完全在SPI传输速度。全屏填充240x240x2字节等于115200字节,SPI时钟40MHz时理论传输时间约28.8毫秒,加上命令和地址位,实际一帧约35毫秒,也就是大约28帧。如果升到60MHz,理论时间约19毫秒,但有木有杜邦线、模块背面走线、电源纹波这些变量,60MHz下很容易偶发花屏。

SPI时钟理论全屏传输时间实际感受稳定性
10MHz115ms明显卡顿非常稳定
20MHz58ms可接受稳定
40MHz29ms流畅杜邦线下稳定
60MHz19ms更快但挑线材容易花屏

我个人习惯:最终产品用PCB排线或FPC连接时,可以上60MHz;开发调试用杜邦线时,固定在40MHz。还有一种折中做法是多块区域刷新,屏幕大部分区域不动,只更新变化的数字或图标,这时SPI时钟反而不用太高,因为每次传输的数据量小,时钟低一点换来稳定性非常划算。

4.2 行缓冲和区域刷新:省内存也减少花屏

MicroPython下内存是稀缺资源,全屏缓冲需要115KB,大多数ESP32板子已经吃紧。所以驱动里fill、pixel这类方法内部通常是一次写一行或几行缓冲,而不是一次性申请整帧缓冲区。你自己做局部刷新时也要顺着这个思路:只提交变化区域。

一个典型场景是刷新温度数字,每次只更新一个小矩形区域,代码比全屏刷新高效得多:

def update_temp(temp_str): # 先清除旧数值区域,假设数值在(40, 100)开始,占60x32像素 display.fill_rect(40, 100, 60, 32, 0x0000) # 在清除后的位置重新写入字符串 w.set_textpos(40, 100) w.printstring(temp_str)

fill_rect的参数依次是x、y、宽、高、颜色。这里清除区域必须比文字的实际显示范围大一点,否则会留下笔画残影。如果你用Writer输出文字,还需要算好字体宽高,8x16字体下每个字符占8像素宽、16像素高,一个4位数字字符串就是32像素宽,加上左右各留4像素,就是40像素的清除宽度。

局部刷新还有一个隐藏好处:SPI传输的数据量小,总线负载低,长线场景下不容易出现最后几行花屏。我在做旋钮面板时,整个界面只有当前参数值在变,局部刷新后SPI时钟降到20MHz,连续跑一天没有一次性花屏。

4.3 背光PWM:别让闪烁成为“玄学”

圆屏的背光如果不做PWM,直接接高电平也能亮,但亮度不可调,而且某些模组的背光电路对电源纹波敏感,全亮时会让屏显出现横条纹。常见做法是用PWM控制BLK引脚:

from machine import Pin, PWM blk_pwm = PWM(Pin(15), freq=1000, duty_u16=32768)

freq设1000Hz,duty_u16设32768,也就是约50%占空比。注意freq不能太低,低于500Hz时人眼能感觉到闪烁,尤其是在屏幕边缘和圆形过渡区域,看起来像水波纹。Freq超过5000Hz时,部分模组的背光驱动电路会发出轻微啸叫声,所以1000Hz是最常用的中间值。

如果你的驱动文件里已经有backlight引脚参数,通常内部会自动创建PWM,这时只要传入Pin对象就行,不需要再单独建一个PWM实例。有些驱动里的backlight参数只做开关,不提供亮度控制,那你就在驱动外自己建PWM然后调到最小亮度验证一下,如果最低亮度还是刺眼,说明PWM频率和占空比参数需要调整,而不是驱动有问题。

5. 避坑指南:GC9A01最容易翻车的几个现场

这一段是亲测过程中最值钱的部分。下面五条按“现象→原因→解决”记录,遇到问题直接对照查。

5.1 画面整体偏移一圈

现象是圆屏显示的内容不是居中,而是整个向左或向上偏了1到3个像素,圆环边缘出现一条固定的杂色边。原因大多是GC9A01模组在出厂时面板安装位置和IC内部GRAM默认窗口没有对齐,导致行列起始地址需要偏移。

解决方法是修改set_window里的行列起始地址。通常驱动里会包装成_set_window或类似方法,你直接调用底层命令调整:

# 原起始地址是0,0,若画面向右偏移了2像素 # 把列地址起点改成2 display._cmd(0x2A, bytes([0x00, 0x02, 0x01, 0x6F])) # 把行地址起点也改成2 display._cmd(0x2B, bytes([0x00, 0x02, 0x01, 0x6F]))

0x2A控制列地址范围,0x2B控制行地址范围。后面四个字节分别是起始高8位、起始低8位、结束高8位、结束低8位。240像素的结束地址是239,二进制0x6F对应十进制的111,所以末尾固定为0x01 0x6F。如果偏移方向是左上,就把起点改成0;如果画面原本偏了但方向不确定,可以逐个试起点0到3,每次改动后全屏填充纯色,观察边缘是否干净。

5.2 屏幕灰白或只有噪点

现象是背光亮、屏幕色彩花乱或一片灰白。原因基本上可以锁定在两种:init时没有执行厂商解锁命令,或者SPI读写模式不匹配。GC9A01的厂商解锁命令0xFE/0xEF必须先发,否则后续寄存器命令全部无效,屏幕只能显示乱码。这个错误在从ST7789驱动改过来的过程中太常见了,因为ST7789没有这一段。

解决方法是确认init序列里0xFE/0xEF出现了两次,并且出现在所有其他命令之前。第二步把SPI的polarity和phase从(0,0)改成(1,1),再一次一次试。注意修改后需要重新初始化屏幕,不能简单重发几个命令,最好是复位整个display对象,或者调用display.reset()再执行init。

5.3 颜色发紫、红蓝互换

现象是屏幕上红色像素显示成蓝色,蓝色显示成红色,整个画面像滤镜翻转。原因很明确:MADCTL里的RGB/BGR位设置反了。这个位在0x36寄存器里是bit3,值为0x08。如果你的MADCTL值设成了0x40,那实际颜色顺序是RGB,而模组本身是BGR,就会互换。

解决方法是保持旋转参数不变,单独翻转0x08这一位。比如当前rotation=0时MADCTL是0x48,改成0x40就是RGB顺序,改成0x48就是BGR顺序。判断方法很简单:显示纯红0xF800,如果看起来发蓝,就把这一位翻转。颜色验证代码:

display.fill(0xF800) # 纯红 time.sleep_ms(1000) display.fill(0x07E0) # 纯绿 time.sleep_ms(1000) display.fill(0x001F) # 纯蓝

三种颜色分别显示正常,就说明RGB顺序和像素格式都对了。白色0xFFFF最容易掩盖顺序问题,所以别用白色验证。

5.4 长线花屏和偶发乱码

现象是刷新过程中屏幕某些行出现彩色噪点,或者整屏随机闪烁一下,但复位后又能正常。原因通常是SPI时钟过高、杜邦线过长或模块供电不稳。这个坑在40MHz以下很少出现,一旦超过40MHz就容易冒出来,所以它在接线阶段就要预防。结合经验,核心原因往往不是单一因素,而是“时钟高+线长+电源纹波”三件事叠加。

解决思路从三个方向同时下手:把baudrate降到20MHz或10MHz看是否复现,缩短SCL和SDA线缆长度,并尽量避免两根线平行走太长距离。电源方面,在模块的VCC和GND之间并联一个10uF到100uF的陶瓷电容,如果模组引脚附近空间不够,就焊在杜邦线连接处。我调试时遇到过一例,降频后仍有偶发花屏,最后发现是USB供电的5V转3.3V稳压模块带负载能力差,换了一个低压差LDO后问题消失。注意,背光电流必须计入电源余量。

5.5 Thonny里执行一半死机或黑屏

现象是REPL里运行刷新代码,刷到一半开发板突然重启,或者屏幕黑掉再亮但程序停住。原因两大类:一类是内存不足,MicroPython在动态申请大块buffer时失败会抛出MemoryError然后REPL复位;另一类是电源瞬时跌落,尤其当背光全亮、全屏填充同时进行时,电流峰值可能让板载稳压器进入保护。

解决方法是先看REPL输出有没有MemoryError。如果有,把全屏填充改成局部刷新,或者用gc.collect()手动回收内存。如果是电源问题,先把背光PWM占空比降低到50%再测试,或者初始化时先延时200ms再开启背光,避开上电瞬间的大电流。一个实用习惯:把display.init()里最后开的命令从0x29改为先延时再开,时序更稳:

self._cmd(0x11) time.sleep_ms(150) self._cmd(0x29) time.sleep_ms(50)

6. 把驱动封成一个小部件:圆屏局部刷新与帧率验证

GC9A01驱动跑通之后,建议你做两件收尾工作:把常用更新逻辑封装成一个状态显示类,以及测量一下真实帧率,知道自己在这块屏上还能做多复杂的动画。

封装数字显示类,是减少反复写fill_rect和text代码的好办法:

class UpdateText: def __init__(self, display, writer, x, y, w, h): self.d = display self.w = writer self.x = x self.y = y self.w = w self.h = h self.last = None def show(self, s): if s == self.last: return # 清除旧区域 self.d.fill_rect(self.x, self.y, self.w, self.h, 0x0000) # 写新字符串 self.w.set_textpos(self.x, self.y) self.w.printstring(s) self.last = s

这里把位置和大小写死,每次只清除固定区域再画新内容,字符串没变化时跳过刷新,能大幅减少没必要的SPI传输。使用它的典型场景是循环更新一个计数器:

counter = UpdateText(display, writer, 40, 100, 60, 32) for i in range(100): counter.show(str(i)) time.sleep_ms(200)

帧率验证可以用一个简单的计时循环来完成。下面代码测的是全屏填充的实际帧数,注意只测fill而不是复杂绘图,因为fill的速度基本等于SPI最大吞吐:

import time start = time.ticks_ms() for _ in range(50): display.fill(0x001F) # 蓝色 display.fill(0xF800) # 红色 dt = time.ticks_diff(time.ticks_ms(), start) fps = 100 / (dt / 1000) print(f"全屏填充帧率: {fps:.1f} fps")

以40MHz SPI为例,这个数字应该在25到30之间。如果明显偏低,看SPI时钟是否真生效;如果花屏,先降频。我自己的习惯是把这三个数值作为驱动改动的“体检指标”:PWM频率、SPI时钟、全屏填充帧率,每次换模组或改板子,先跑一遍这三项,全部符合才算通过。真正做界面时则尽量不全屏填充,而是用局部刷新把单次更新控制在几百微秒内,这样动画和数字滚动都能保持流畅。

这套方案已经被我在两块不同批次的GC9A01模组上验证过,init序列和偏移虽然略有差异,但排查路径完全一致:先确认解锁命令,再调行列地址,最后查颜色顺序。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询