SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
支线一 / 01 · 先把屏幕驱动起来

先驱动手表彩屏:GC9A01 与 RGB565

已经能通过 SPI 点亮 OLED,接下来读懂手表真正使用的彩屏驱动:从一条命令、一个像素开始,走到自己组织并显示一张矩形图像。

前置:GPIO、SPI 与 OLED 实验像素、窗口与真实驱动颜色与矩形命令可交互
学完要能做到说明每根屏幕信号线的作用,解释初始化在准备什么,把 RGB565 颜色拆成正确的发送字节,算出区域端点与数据长度,并沿项目代码找到屏幕真正收到数据的位置。本篇先完成直接绘图,下一篇把这条通路接给 LVGL。

第 1 节 · 先把芯片、屏幕与驱动文件对上号

手表使用 GC9A01 控制的 240×240 圆形彩色 LCD。上一节的单色 OLED 实验帮助理解 SPI 和显示数据;这里每个像素需要表示颜色,初始化命令和像素格式都要按 GC9A01 重新对上。

本篇配套:GC9A01A 数据手册

使用 GalaxyCore · GC9A01A Rev.1.0 Preliminary(2019-07-02),由 Waveshare 托管厂商原文,共 192 页。下面页码对应此 PDF;若阅读器不定位,可搜索命令名或十六进制编号。

本节要解决什么打开手册哪里回到代码找什么
核对串行接口和 D/CX§4.1.8~4.1.9,p.28~30write_cmd 与 write_data;D/CX 对应模块的 D/C
从休眠走到显示开启§6.2.4,p.103;§6.2.10,p.110Init 中的 11、等待、29;结合模块要求核对顺序
确定方向与颜色格式§6.2.18,p.127;§6.2.22,p.13536 48 与 3A 05;分别检查方向/BGR 和像素位数
组织 RGB565 发送字节§4.5.2,p.48 起write_data16、像素数组的两个字节
指定窗口并写像素§6.2.11~6.2.13,p.111~115SetWindow 的 2A、2B、2C;起止坐标及像素顺序

手册中的信号名称、命令和位定义用于解释控制器;本仓库引脚以 gpio.c、spi.c 和硬件接线为准。这里引用 Waveshare 托管资料不表示手表采用其成品模组。当前模组没有可确认的厂商接线文档,不额外指定其供电范围或排针顺序。

1.1 先确定位置:什么叫“寻址”?

假设要让屏幕中心的一小点变成红色。只发送“红色”还不够:屏幕有许多可以分别显示颜色的小点,也就是像素,控制器怎么知道要改哪一个?还必须告诉它位置。

可以把这些位置想成方格纸,横着排成行,竖着排成列。给每一列、每一行编号,就能用两个数找到一个位置:x 表示列编号,y 表示行编号,写成 (x, y)。按这样的编号找到要操作的位置,就叫寻址。这里的坐标指定屏幕上的像素位置,不是直接传一个 STM32 内存地址。

五列四行的放大像素网格:列编号从零开始,坐标二逗号一定位到第三列第二行
把小屏幕放大成方格看:每格表示一个像素位置。示例仅有 5 列、4 行,用来练习坐标,不是手表的实际分辨率。

例如上图的 (2, 1):先找到编号 2 的列,再找到编号 1 的行,它们交会的格子就是目标。编号从 0 开始,所以它实际上是第 3 列、第 2 行,不能把“编号 2”与“第 2 个”混为一谈。

手表这块屏幕的逻辑范围有 240 列、240 行,也就是常说的 240×240 分辨率。列编号 x 与行编号 y 都从 0 到 239。若按图中向右、向下增加的坐标方向理解,(0,0) 是左上角,(1,0) 是右边相邻位置,(0,1) 是下边相邻位置;(120,120) 则靠近中心。控制器还可以设置显示方向,实际画面是否旋转或镜像,后面结合初始化命令再看。

1.2 坐标怎样交给屏幕?先选位置,再写颜色

画一个像素时,程序要先通过 SPI 发送命令和参数,告诉屏幕控制器“接下来修改这个位置”,再发送这个像素的颜色。控制器把颜色保存到对应的显示存储位置,面板才显示出它。位置与颜色是两份不同的信息,颜色字节本身不包含坐标。

画一块图时,可以先指定一个矩形范围,叫作写入窗口。例如选中列编号 2~3、行编号 1~2,就选中了 2×2、共四个像素位置。随后按约定顺序发送四份颜色;控制器每接收完一份,就把写入位置移到下一格,不需要每次都重新发送坐标。

本篇后面会用 GC9A01_SetWindow 展开“怎样指定这个范围”,再用实际字节说明“先填哪一格、后填哪一格”。现在先记住绘图的两步:选好要改的位置或范围,再提供这些位置的颜色。

1.3 圆形外观,为什么仍然使用矩形坐标?

240×240 描述的是控制器用于定位像素的逻辑行列范围,面板的可见外形则是圆形。因此,坐标在 0~239 内,并不保证那个位置在圆形可见范围内:矩形四角的某些位置看不到。初次画小方块先放在 (120,120) 附近,便于观察;不要因为角落没有出现图像,就立即判断 SPI 发送失败。

信号当前项目连接由谁控制
SCKPB13 / SPI2_SCKSPI2 硬件输出时钟
MOSIPB15 / SPI2_MOSISPI2 硬件输出命令与像素字节
CSPC7,低有效普通 GPIO,决定屏幕是否参与
D/CPC6,低=命令,高=参数或像素普通 GPIO,决定收到的字节怎样解释
RSTPC8,低有效普通 GPIO,复位显示控制器
MISO当前显示连接未布置返回线这条驱动路径只写屏幕
背光本模块按现有接线由电源供给没有独立背光控制脚;软件接口为空操作

先读 spi.c 的 MX_SPI2_Init:主机、8 位、MSB First、空闲低、第一边沿采样,软件 NSS;当前 SPI 内核选 SYSCLK,按 160 MHz 与分频 8 得到 20 MHz。这里只说明现有配置,不把 20 MHz 当作所有模块都能保证的速度。控制线则在 gpio.c 配置为普通推挽输出。

文件先看哪里解决的问题
gc9a01.h尺寸、颜色类型、公开函数声明应用能调用什么
gc9a01.c底层发送、初始化、绘图把一次画图变成屏幕命令和 SPI 字节
lv_port_disp.c缓冲区与 disp_flush把 LVGL 画出的矩形交给硬件驱动
app.capp_init、app_tick应用如何初始化并持续推进界面

阅读时先按正在执行的函数追踪。源文件里有旧板注释,判断当前引脚和行为应看正在编译的宏与赋值。例如当前实际使用 SPI2 和 PC6/PC7/PC8;gpio.c 还把三条控制线的初始输出设为低,不能仅凭文件头“默认高”的描述推测上电波形。

第 2 节 · 一条命令,怎样真正从引脚发出去?

2.1 先认识驱动内部的小函数

下面的函数是 gc9a01.c 自己定义的封装,不是另一套 HAL。static 把它们限制在这个 C 文件内部;应用应使用头文件公开的接口。先看职责,再读调用:

函数参数与返回值做什么
CS_LOW / CS_HIGH无参数、无返回值分别将 PC7 拉低、拉高
DC_LOW / DC_HIGH无参数、无返回值分别将 PC6 拉低、拉高
spi_write(data, len)字节指针、字节数量;无返回值调用 HAL_SPI_Transmit,使用 &hspi2 与 50 ms 超时参数
write_cmd(cmd)一个 uint8_t 命令;无返回值选中屏幕,D/C 低,发送命令,释放片选
write_data(data)一个 uint8_t 参数;无返回值选中屏幕,D/C 高,发送参数,释放片选
write_data16(data)一个 uint16_t 数值;无返回值拆成高字节、低字节,以数据状态发送

以 write_cmd(0x2A) 为例,0x2A 的业务意义稍后解释;现在先追踪它以“命令身份”进入屏幕的过程。HAL 的管理对象、指针、数量与超时已在 SPI 章介绍,这里重点看 D/C 和 CS:

当前源码节选 · 读懂现有实现,不在应用中重复定义

static void write_cmd(uint8_t cmd)
{
    CS_LOW();
    DC_LOW();
    spi_write(&cmd, 1);
    CS_HIGH();
}

这里先选中屏幕,再设置 D/C,然后才开始时钟传输;关键是数据被屏幕采样时 D/C 已处于命令状态,并满足器件时序。命令之后的坐标参数要通过 write_data 或 write_data16 发送,不能继续当成新命令。

一个命令字节经过 GPIO 控制与 SPI 硬件到达屏幕
控制器不会认识 C 函数名,它看到的只有控制线电平与线上字节。

2.2 write_data16 不是把 uint16_t 内存直接发出去

它先取 data >> 8 得到高字节,再取 data & 0xFF 得到低字节,放进两个元素的 uint8_t 数组。比如 239=0x00EF,发出的顺序是 00、EF。这个拆分动作在坐标和像素两处都很重要。

当前驱动在一个命令、一个或两个参数字节之间会释放并再次选中 CS。阅读时按实际分段理解,不把整个设置过程误画成 CS 必须全程低的单个波形;换其他控制器时,要重新确认它允许的事务边界。

第 3 节 · 为什么要初始化这么多字节?

GC9A01_Init(void) 是公开的屏幕初始化函数,没有参数和返回值。调用前,SPI2 和控制线 GPIO 必须已经初始化;它不能替代供电与正确接线。

3.1 先复位,再准备显示控制器

现有代码将 RST 拉低,等待 50 ms,再拉高等待 120 ms。这里的 HAL_Delay 是在普通初始化流程中使用;数值描述当前项目选择,不是所有屏幕都必须照抄的最小时间。

现有 GC9A01 初始化从复位到显示开启的阶段
省略大量具体参数,用来理解执行顺序;完整序列保留在驱动源码中。

查初始化时,先在手册 §6.1 命令列表中找到编号,再进入对应命令详解。表格中的 Command 是命令字节,Parameter 是随后传入的参数;不要把参数继续当成新命令。

3.2 先读几条直接影响画图的设置

源码中的命令与参数先理解什么学习时怎样观察
0x36,参数 0x48MADCTL:当前配置包含扫描映射与 BGR 设置图像镜像、方向和红蓝对应要结合面板检查
0x3A,参数 0x05选择本驱动使用的 16 位像素格式后续每像素组织两个字节
0x2A、0x2B,范围 0~239列和行的可写区域坐标从零开始,末端是 239
0x21显示反相相关设置属于面板初始化的一部分,不等于 C 中对颜色逐位取反
0x11、0x29退出休眠、打开显示不能省略等待后就认定初始化完成

大量其他参数服务于这块面板的电源、扫描和显示效果,不是通用点灯常量。第一次完整保留匹配模组的初始化,再抓住上表几项建立联系;移植其他屏幕时,按其模组资料核对,不能把一串十六进制数字当作任何屏幕通用的启动密码。

现有代码还发送 TE 相关设置,但当前模块连接和刷新路径没有使用独立 TE 同步信号。初始化里出现一个命令,并不意味着应用已经实现对应的完整同步功能。

第 4 节 · 颜色从三个分量,变成两个发送字节

单色 OLED 每像素一个位;彩屏要表达颜色。本项目把每个像素编码成一个 16 位数,类型 gc9a01_color_t 是 uint16_t 的别名。RGB565 表示红色占 5 位、绿色 6 位、蓝色 5 位。

带着 p.135 的 3Ah 参数表看 0x05:写成二进制是 0000 0101,低三位 DBI[2:0] 为 101,对应 MCU 接口的 16 位像素。再去 p.48 起的四线串行数据格式图找 16 位格式,核对每个像素怎样分成两个发送字节。这里是在读参数位,不是把“05”当成无需解释的常量。

4.1 从 0~255 的颜色分量压到 5、6、5 位

如果输入红、绿、蓝各是一个 0~255 的八位数,可以先用右移减少位数,再把它们放进一个 16 位数中。下面公式是截去低位的简单转换;它不是所有颜色精确无损的表示。

r、g、b 为已准备好的 0~255 分量 · 先看位域如何拼接

uint16_t color = (uint16_t)(((r >> 3) << 11)
                          | ((g >> 2) << 5)
                          |  (b >> 3));
逻辑颜色RGB565 数值本驱动期望的像素字节顺序
红0xF800F8 00
绿0x07E007 E0
蓝0x001F00 1F
白0xFFFFFF FF
黑0x000000 00

这是数据编码上的颜色名称。最终红蓝是否与肉眼一致,还要结合控制器 BGR 位和面板匹配检查。它与下面的高低字节顺序是两个不同设置,不应同时乱改。

颜色实验:数值、内存字节、线上字节

预览显示量化后的逻辑颜色,不模拟面板的 BGR、反相或光学特性。先试红、绿、蓝,再试中间值。

4.2 MSB First 为什么不能自动修好高低字节顺序?

本项目 SPI 数据长度是八位。MSB First 决定每个字节内部先发 bit 7;它不会将一个内存中的 uint16_t 自动拆成“高字节先发”。在小端 STM32 上,数值 0xF800 的内存字节通常是 00 F8。如果把这段内存强制转成 uint8_t 指针直接发送,就会先发 00。

因此,手动准备像素数组时应明确写 F8、00;使用库生成的颜色缓冲区时,则要查库的字节交换配置与输出路径。不要把“位顺序”“高低字节顺序”“RGB/BGR”合成同一个概念。

第 5 节 · 告诉屏幕:这批像素要写在哪里?

屏幕不会从 C 数组名称知道位置。先发送一组命令设置目标矩形,再按约定次序发送这个矩形包含的全部像素。

5.1 先认识 SetWindow 的四个参数

GC9A01_SetWindow(x0, y0, x1, y1) 接收四个 uint16_t 坐标,无返回值;前两个是起点,后两个是包含在区域内的终点。本驱动要求应用自己保证范围有效。

当前源码主体 · 三条命令分别设置列、行,并开始写像素

void GC9A01_SetWindow(uint16_t x0, uint16_t y0,
                      uint16_t x1, uint16_t y1)
{
    write_cmd(0x2A);
    write_data16(x0);
    write_data16(x1);

    write_cmd(0x2B);
    write_data16(y0);
    write_data16(y1);

    write_cmd(0x2C);
}

0x2A 后的四个参数字节表示列起点和终点,0x2B 后表示行起点和终点;0x2C 告诉控制器接下来开始写显示存储区。坐标参数经 D/C 高发送,命令本身经 D/C 低发送。

跟着一张命令表,把四个参数读成实际字节

打开 §6.2.11,p.111:2Ah 命令表。SC 是起始列,EC 是结束列;[15:8] 表示高八位,[7:0] 表示低八位。因此四个参数依次是“起点高、起点低、终点高、终点低”。这正对应代码中两次 write_data16,各拆出两个字节。

例如列编号从 110 到 111:110=006E,111=006F,所以先以 D/C 低发 2A,再以 D/C 高发 00 6E 00 6F。按同样方法读 p.113 的 2Bh 行范围,最后读 p.115 的 2Ch 写入说明,确认随后发送的是像素。下表就是由这三处说明与本例坐标一起推导出来的。

遇到命令表还列着并口数据脚时,先别把这些脚接到 STM32:控制器支持多种接口。当前选用四线串行方式,需要结合 p.28~30 的串行传输图理解这些命令如何变成 SPI 字节。

5.2 一个 2×2 区域,末端为什么要减一?

从 (110,110) 画宽 2、高 2,包含的列是 110、111,行也是 110、111。因此终点是 (111,111),不是 (112,112)。一般公式为 x1=x+w-1、y1=y+h-1;反过来宽=x1-x0+1。

阶段D/C本例字节
设置列范围0 发命令,1 发参数2A → 00 6E 00 6F
设置行范围0 发命令,1 发参数2B → 00 6E 00 6F
开始写像素02C
写四个像素1F8 00 07 E0 00 1F FF FF

上面的像素数组按本驱动窗口顺序逐行排列:第一行红、绿,第二行蓝、白。每次写入后控制器推进到下一个地址。物理可见方向由 MADCTL 的坐标映射决定,因此先把它理解成逻辑窗口中的先后次序。

区域实验:改坐标,看命令和字节数

(0,0)(239,239)灰角是不可见区域的教学示意

先改成 2×2 对照上表,再把区域移向角落,区分矩形地址有效与像素是否可见。圆边只是教学近似,不代替实际面板边界。

第 6 节 · 现在自己完成纯色、一个点和一张小图

6.1 先认识应用层可以调用的函数

公开接口参数与返回值准备条件
GC9A01_FillScreen(color)一个 gc9a01_color_t 颜色,无返回值屏幕已初始化;填满逻辑全屏
GC9A01_WritePixels(data, len)字节地址、字节数量,无返回值已经设置目标窗口,数据长度与窗口对应
GC9A01_DrawBitmap(x, y, w, h, data)左上角、宽高、RGB565 字节地址,无返回值内部设置窗口再发数据;数组至少有 w×h×2 字节
GC9A01_SetBacklight(on)一个开关值,无返回值当前项目实现为空函数,不能据此调节亮度

GC9A01_DrawBitmap 没有额外的数组长度参数,不能替调用者检查实际数组有多大。宽高不得为零,x+w、y+h 不得超过 240;第一次只用明确、很小的区域。

6.2 独立显示实验先调用一次初始化,再刷纯色

在独立、与手表接线相同的显示实验工程中,包含 gc9a01.h,先完成基础 HAL、系统时钟、GPIO 和 SPI2 初始化。COLOR_RED、COLOR_GREEN、COLOR_BLUE 是 gc9a01.h 已定义的 RGB565 常量;HAL_Delay 的毫秒等待已在前面介绍。

第一步 · 先检查纯色,避免字体和布局问题混进来

/* main 的基础初始化之后;独立显示实验,不同时运行 LVGL。 */
GC9A01_Init();
GC9A01_FillScreen(COLOR_RED);
HAL_Delay(500);
GC9A01_FillScreen(COLOR_GREEN);
HAL_Delay(500);
GC9A01_FillScreen(COLOR_BLUE);

先观察是否依次出现预期颜色。若红蓝互换,查面板映射与 BGR;若颜色杂乱,另查两个字节的顺序。当前手表的 app_init 会经 lv_port_disp_init 初始化屏幕,因此不在现有运行中又插入一次 GC9A01_Init,也不让两个执行路径同时画图。

6.3 一个像素,就是宽 1、高 1 的小图

第二步 · 初始化后执行;数组两个字节表示中心附近的一个红色像素

static const uint8_t red_pixel[] = {0xF8, 0x00};
GC9A01_DrawBitmap(120, 120, 1, 1, red_pixel);

一个像素肉眼不明显,可以改成 2×2 先核对字节,再用已知颜色填充更大的数组。下面不用新绘图函数,只把四个像素按顺序列出来:

第三步 · 八个字节,窗口刚好四个像素

static const uint8_t four_pixels[] = {
    0xF8, 0x00,  0x07, 0xE0,  /* 第一行:红、绿 */
    0x00, 0x1F,  0xFF, 0xFF   /* 第二行:蓝、白 */
};
GC9A01_DrawBitmap(110, 110, 2, 2, four_pixels);

6.4 画一个容易看见的 20×20 红色方块

每像素两字节,20×20 需要 800 字节。static 让数组的存储不占普通函数调用的局部栈空间,也使它长期有效;本例仍是轮询发送。先把 400 个像素的高低字节填好,再一次画到屏幕中心附近:

第四步 · 应用代码;不会自动裁剪,不与 LVGL 刷新并行执行

static uint8_t red_block[20 * 20 * 2];
for (uint16_t pixel = 0; pixel < 20 * 20; pixel++) {
    red_block[pixel * 2] = 0xF8;
    red_block[pixel * 2 + 1] = 0x00;
}
GC9A01_DrawBitmap(110, 110, 20, 20, red_block);

换成一张图片,本质仍然是准备这样的数组。彩色图片转换时应选择 RGB565、逐行、高字节先发的像素数据,不要把上一节的单色字模直接传入。若工具附加了文件头或尺寸描述,先取出真正的像素部分。文字也可以先变成前景色、背景色组成的小图;字体布局等到 LVGL 支线继续。

6.5 FillScreen 为什么只准备一行数组?

现有 GC9A01_FillScreen 先设置全屏窗口,把同一种颜色重复写进 240×2=480 字节的一行数组,再保持 CS 低、D/C 高,循环发送 240 行,最后释放 CS。它发送了 115200 字节,但不用在 MCU 中保存一整屏颜色数组。重复同色时,一行像素可以反复使用。

按 20 MHz 理想时钟计算,纯像素传输约 46.08 ms。每次 HAL 调用只处理一行,50 ms 是单次调用的超时参数,不是全屏刷新总耗时的上限。

第 7 节 · 绘图已经能用,还要守住接口边界

7.1 WritePixels 的长度不是无限大

公开函数 GC9A01_WritePixels 的 len 是 uint32_t,但它直接传给本版 HAL_SPI_Transmit 的 uint16_t Size,超出范围会发生截断。全屏 115200 字节不能通过当前接口一次原样发送;FillScreen 采用 480 字节一行,LVGL 当前一批最多约 4800 字节,避免了这两条正常路径的超长单次传输。

自己调用 DrawBitmap 时也要守住每次 w×h×2 不超过 65535 的约束,较大图片分成行或条带分别设置窗口、发送;不要以为上层参数写成 32 位,底层就自动支持整屏。

7.2 无返回值与现有错误处理的实际含义

目前公开绘图接口返回 void。spi_write 虽然比较了 HAL 返回值,却没有把错误返回给上层;WritePixels 和 FillScreen 的部分调用直接忽略 HAL 状态。发生传输错误时,应用不能仅凭这些函数“返回了”就确认屏幕更新成功,flush_ready 也不证明面板已接受画面。

更完整的工程化驱动应把失败状态传回调用者,规定停止、清理、重试与刷新通知的策略。这里把当前限制写清楚,避免在教程示例中凭空给现有函数加上并不存在的错误返回。

7.3 自动改成 DMA,不等于完成异步显示驱动

write_cmd、write_data16 内部用了局部数据;FillScreen 的行缓冲则由整个函数重复使用。改异步发送必须同时处理数据生命周期、CS 与 D/C、下一条命令的启动时机、LVGL 缓冲释放和错误收尾。当前项目主路径仍为轮询,本节不运行代码生成或改写固件。

学完之后,怎样证明自己会驱动这块屏?

步骤要能独立说明或操作看不到预期时先查
认线指出 SPI2、CS、D/C、RST 的真实引脚读正在编译的宏和初始化,不只读旧注释
初始化从 RST 走到显示开启,说明像素格式与方向设置供电、复位、命令与参数的 D/C
纯色轮流填充红绿蓝,写出对应字节RGB/BGR 与高低字节分开检查
画区域手算 20×20 的终点和 800 字节长度包含式端点、非零尺寸、边界与数组容量
显示图片组织逐行的 RGB565 字节,不附文件头位深、行顺序、字节序,不使用单色字模直接代替
接 LVGL解释 area、color_p、4800 字节缓冲与完成通知输出字节顺序、处理函数是否持续执行
移植指出需要随硬件改变的引脚、配置、初始化和像素格式不默认任意控制器都接受同一组命令

没有硬件时,先完成本页颜色、窗口互动和源码追踪。具备设备后,在独立实验中按上述顺序观察,再回到现有项目;不要一开始就叠加整套 UI、异步传输与多任务,导致无法判断哪一层出了问题。

检查理解

在 (110,110) 画 20×20 RGB565 色块,哪个组合正确?
接下来:让 LVGL 帮我们画界面现在已经会设置区域并写入像素。下一篇从缓冲、刷新回调和时间推进开始接入图形库;不需要一次掌握所有控件。

参考与源码入口

  • 本仓库的 gc9a01.c/.h、spi.c、gpio.c、lv_port_disp.c、app.c 与 lv_conf.h:当前实际调用和已存在的限制。
  • GalaxyCore · GC9A01A 手册:用于核对控制器命令,不代替具体屏幕模组资料。
  • LVGL 8.3 · 显示端口:绘图缓冲、刷新回调和完成通知;本节按仓库的 8.x 接口讲解。

教学图和互动模型独立编写,不代表实际屏幕测量。完整来源见 资料索引。