简介:面向STM32F103C8T6平台与1.8寸ST7735彩屏的联调学习需求,这份程序包在原商家示例基础上重写了大部分驱动与显示逻辑,重点解决中英文混排显示与字库调用的问题,适合已掌握GPIO、SPI基础、希望打通彩屏驱动到汉字取模显示链路的中级单片机开发者。包内共101个文件,以49个.h头文件和41个.c源文件为主体,涵盖外设驱动、字库索引与显示接口,另附2个hex可执行文件便于直接烧录验证、2个汇编启动文件及Keil工程与批处理、链接脚本等配置项,整体压缩约412KB,体积轻便。目前已有4887人浏览学习。代码按文件内提示做了移植分层,更换引脚或屏幕尺寸时改动量较小;中英文显示与取模流程有迹可循,可作为彩屏菜单、参数界面等小型项目的参考起点,也便于排查花屏、偏移与乱码问题。
1. 手上这块 1.8 寸屏,卡住的往往不是屏
手上有一块 STM32F103C8T6 最小系统板,再配一块 1.8 寸 ST7735 彩屏,这是很多人做小仪表、手持设备、教学实验的起手式。点亮背光、刷个纯色、打几个英文数字,基本半天能搞定。真正让人卡住的环节出现在要显示「温度:26.5℃」这种中文句子的时候——问题不在屏,在这颗芯片的资源账:C8T6 只有 64KB Flash 和 20KB SRAM,一套 GB2312 的 16×16 全点阵字库接近 220KB,塞不进去;128×160 分辨率、16 位色的整屏显存要 40KB,同样放不下。这两个「放不下」决定了中文显示这套程序必须换一种组织方式:字库外挂、显存分块。这套 STM32F103C8 驱动 1.8 寸 ST7735 显示中文的工程,做的就是把这两笔账算平。适合正在跟小尺寸彩屏较劲、被中文乱码和花屏折腾过一轮的开发者。
2. 硬件连线与工程骨架:把 ST7735 挂到 SPI1 上
先解决物理层,不然后面所有调试都是空谈。1.8 寸 ST7735 模组常见的引出脚是 8 根:VCC、GND、CS、RESET、DC(A0)、MOSI(SDA)、SCK(SCL)、LED(BLK)。它只支持写、不支持读,没有 MISO,这一点很关键——意味着 SPI 可以配成单线发送模式,省一根线,也省掉读时序的等待逻辑。
2.1 引脚分配与供电上的两个硬约束
F103C8T6 的 SPI1 固定在 PA5(SCK) 和 PA7(MOSI),这两个脚必须配成复用推挽,速度开到 50MHz。剩下的控制脚随便挑普通 IO 就行,我一般按下面这张表接,顺序不乱,杜邦线也好插:
| 屏引脚 | STM32 引脚 | 配置 | 说明 |
|---|---|---|---|
| VCC | 3.3V | 电源 | 不要接 5V,模组背面有 LDO 的除外 |
| GND | GND | 电源 | 和板子共地,别图省事只接一根 |
| CS | PA4 | 推挽输出 | 片选,低有效 |
| DC/A0 | PA3 | 推挽输出 | 低=命令,高=数据 |
| RESET | PA2 | 推挽输出 | 低电平复位,至少保持 10ms |
| MOSI/SDA | PA7 | 复用推挽 | SPI1_MOSI |
| SCK/SCL | PA5 | 复用推挽 | SPI1_SCK |
| LED/BLK | PA1 | 推挽输出 | 背光,高亮低灭;也可直接接 3.3V |
供电这里有个绕不过去的事:1.8 寸屏背光那几颗 LED 并联起来电流不小,直接从最小系统板的 3.3V LDO 上取电,屏幕全白的时候板子容易掉压复位。血泪经验是背光单独走一路限流电阻,或者干脆从 USB 5V 经 100Ω 电阻供,信号线仍然 3.3V 电平,ST7735 认 3.3V 逻辑没问题。另外模组 VCC 和 GND 之间并一颗 100nF 加一颗 10μF,刷屏时的瞬态电流靠它们兜。
SPI 时钟这块要提前说清楚:ST7735 数据手册给的写周期是 66ns,换算下来串行时钟上限大约 15MHz。F103 的 SPI1 挂在 72MHz 的 APB2 上,2 分频是 36MHz,4 分频是 18MHz,8 分频是 9MHz。新手最容易照着例程写个 2 分频,然后收获一块雪花屏。先按 8 分频(9MHz)跑通,确认画面稳定后再往 4 分频试,跑不稳就退回来,别在这一步上耗时间。
2.2 工程目录与初始化骨架
这类工程的结构通常很规整,按层次拆开看就三块:底层 SPI 与 GPIO、ST7735 驱动、上层字符与图形。下面是我照着这个资源整理出来的目录形态,不同版本命名会有点出入,但分层逻辑一致:
Project/ ├── CORE/ # 启动文件、system_stm32f10x.c ├── STM32F10x_FWLib/ # 标准外设库 ├── HARDWARE/ │ ├── SPI/ # spi.c:SPI1 初始化、单字节收发 │ ├── LCD/ # lcd.c:ST7735 命令、初始化、设窗、填充 │ └── FONT/ # font.c:字模读取抽象层 ├── USER/ │ ├── main.c │ └── stm32f10x_it.c └── TEXT/ # 字库二进制,烧到外部 Flash 用SPI 初始化的写法直接决定后面能不能提速,我一般这么写:
static void LCD_SPI_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); /* SCK(PA5) / MOSI(PA7):复用推挽,50MHz */ gpio.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); /* CS / DC / RES / BLK:普通推挽 */ gpio.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; gpio.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOA, &gpio); GPIO_SetBits(GPIOA, GPIO_Pin_1); // 背光默认打开 GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS 拉高,空闲态 spi.SPI_Direction = SPI_Direction_1Line_Tx; // 屏只写不读,单线发送 spi.SPI_Mode = SPI_Mode_Master; spi.SPI_DataSize = SPI_DataSize_8b; spi.SPI_CPOL = SPI_CPOL_High; // 模式 3 spi.SPI_CPHA = SPI_CPHA_2Edge; spi.SPI_NSS = SPI_NSS_Soft; // 片选自己控制 spi.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; // 72/8 = 9MHz,先求稳 spi.SPI_FirstBit = SPI_FirstBit_MSB; spi.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &spi); SPI_Cmd(SPI1, ENABLE); }这段代码里能调的参数就三个:SPI_BaudRatePrescaler决定刷屏速度上限,SPI_CPOL/SPI_CPHA决定采样边沿。ST7735 在 SCL 上升沿锁存数据,所以用模式 3(空闲高、第二边沿采样)或者模式 0 都行,市面上例程两种都有,只要和硬件时序对得上画面就正常。SPI_Direction_1Line_Tx是专门为这种只写屏准备的,省掉接收寄存器的处理。
3. ST7735 驱动层:从复位序列到窗口坐标的完整链路
驱动层是这套程序里最像「黑匣子」的部分——一长串初始化命令,看着像乱码,改一行就花屏。其实拆开看就四类:电源与时钟配置、显示方向与像素格式、Gamma 校正、窗口与写入。理解了这个分类,后面调试就有方向了。
3.1 命令与数据的切换时序
ST7735 靠 DC 引脚区分命令和数据,DC 低电平表示接下来一个字节是命令,高电平表示数据。CS 是片选,低有效。这里有两种写法:一种是 CS 全程拉低、只切 DC;另一种是每个字节都拉一次 CS。前者快,后者稳。我倾向后者做初版,跑通之后再改成批量传输。
#define LCD_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define LCD_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define LCD_DC_CMD() GPIO_ResetBits(GPIOA, GPIO_Pin_3) // 低:命令 #define LCD_DC_DATA() GPIO_SetBits(GPIOA, GPIO_Pin_3) // 高:数据 static inline void LCD_WriteByte(uint8_t dat) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, dat); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); (void)SPI_I2S_ReceiveData(SPI1); // 读走清标志,漏了这步后面会偶发丢字节 } void LCD_WriteCmd(uint8_t cmd) { LCD_DC_CMD(); LCD_CS_LOW(); LCD_WriteByte(cmd); LCD_CS_HIGH(); } void LCD_WriteData8(uint8_t dat) { LCD_DC_DATA(); LCD_CS_LOW(); LCD_WriteByte(dat); LCD_CS_HIGH(); } void LCD_WriteData16(uint16_t dat) { LCD_DC_DATA(); LCD_CS_LOW(); LCD_WriteByte((uint8_t)(dat >> 8)); // 高字节在前,RGB565 是大端序 LCD_WriteByte((uint8_t)(dat & 0xFF)); LCD_CS_HIGH(); }LCD_WriteData16里高低字节的顺序不能反,反了颜色就是蓝红互换。ST7735 接收 RGB565 像素时按大端序,先高后低,且不需要像 SPI 那样考虑字节序设置——因为 SPI 已经配成 MSB 先行,逐字节发就行。
3.2 初始化序列里真正不能删的几条
初始化那几十行里,有些是可以按模组微调的,有些删掉直接黑屏。下面这份是我在 1.8 寸模组上验证过的骨架,注释标出了每条命令的作用,方便按需裁剪:
void LCD_Init(void) { LCD_SPI_Init(); /* 硬复位:RES 拉低 20ms 再拉高,等 120ms 让内部电荷泵稳定 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2); Delay_ms(20); GPIO_SetBits(GPIOA, GPIO_Pin_2); Delay_ms(120); LCD_WriteCmd(0x01); Delay_ms(120); // SWRESET 软复位 LCD_WriteCmd(0x11); Delay_ms(120); // SLPOUT 退出睡眠 LCD_WriteCmd(0xB1); // FRMCTR1 帧率:普通模式 1 行反转 LCD_WriteData8(0x01); LCD_WriteData8(0x2C); LCD_WriteData8(0x2D); LCD_WriteCmd(0xB4); // INVCTR 列反转 LCD_WriteData8(0x07); LCD_WriteCmd(0xC0); // PWCTR1 电荷泵电压 LCD_WriteData8(0xA2); LCD_WriteData8(0x02); LCD_WriteData8(0x84); LCD_WriteCmd(0xC1); LCD_WriteData8(0xC5); // PWCTR2 LCD_WriteCmd(0xC2); LCD_WriteData8(0x0A); LCD_WriteData8(0x00); // PWCTR3 LCD_WriteCmd(0xC3); LCD_WriteData8(0x8A); LCD_WriteData8(0x2A); // PWCTR4 LCD_WriteCmd(0xC4); LCD_WriteData8(0x8A); LCD_WriteData8(0xEE); // PWCTR5 LCD_WriteCmd(0xC5); LCD_WriteData8(0x0E); // VMCTR1 VCOM LCD_WriteCmd(0x36); LCD_WriteData8(0x08); // MADCTL:竖屏正向、RGB 顺序 LCD_WriteCmd(0x3A); LCD_WriteData8(0x05); // COLMOD:16bit/pixel LCD_WriteCmd(0xE0); // 正极性 Gamma LCD_WriteData8(0x02); LCD_WriteData8(0x1C); LCD_WriteData8(0x07); LCD_WriteData8(0x12); LCD_WriteData8(0x37); LCD_WriteData8(0x32); LCD_WriteData8(0x29); LCD_WriteData8(0x2D); LCD_WriteData8(0x29); LCD_WriteData8(0x25); LCD_WriteData8(0x2B); LCD_WriteData8(0x39); LCD_WriteData8(0x00); LCD_WriteData8(0x01); LCD_WriteData8(0x03); LCD_WriteData8(0x10); LCD_WriteCmd(0xE1); // 负极性 Gamma LCD_WriteData8(0x03); LCD_WriteData8(0x1D); LCD_WriteData8(0x07); LCD_WriteData8(0x06); LCD_WriteData8(0x2E); LCD_WriteData8(0x2C); LCD_WriteData8(0x29); LCD_WriteData8(0x2D); LCD_WriteData8(0x2E); LCD_WriteData8(0x2E); LCD_WriteData8(0x37); LCD_WriteData8(0x3F); LCD_WriteData8(0x00); LCD_WriteData8(0x00); LCD_WriteData8(0x02); LCD_WriteData8(0x10); LCD_WriteCmd(0x29); Delay_ms(20); // DISPON 开显示 }必须解释的几条:0x11 SLPOUT之后的 120ms 不能省,电荷泵没建立起来直接发像素,屏会闪一下然后黑;0x3A设成0x05才是 16 位色,设成0x06是 18 位色,一颗像素要 3 个字节,代码全得改;0x29 DISPON之前所有配置都不生效,忘了这条屏永远黑。Gamma 那两组参数看着头疼,其实是用示波器或者厂家的参考值调出来的,除非画面明显偏色,不建议改。
0x36 MADCTL这个参数值得单独说。它是显示方向的开关,Bit7 是 MY(行方向翻转)、Bit6 是 MX(列方向翻转)、Bit5 是 MV(行列交换)、Bit3 是 RGB/BGR 顺序。0x08表示竖屏正向加 RGB 顺序。如果刷上去红色显示成蓝色,就是模组的 RGB/BGR 跟参数对不上,改成0xC8(加 MY、MX 翻转)或者0x00试一试,一分钟能对上。
3.3 设窗与填充:128×160 模组的偏移陷阱
ST7735 内部 GRAM 是 132×162,而这块屏的玻璃只有 128×160。多出来的边怎么对齐,不同批次模组不一样。所以设窗口时必须带 offset:
/* 不同批次的 1.8 寸模组 offset 不同,常见组合:(0,0) (2,1) (0,1) */ #define X_OFFSET 2 #define Y_OFFSET 1 void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // CASET 列地址设置 LCD_WriteData16(x0 + X_OFFSET); LCD_WriteData16(x1 + X_OFFSET); LCD_WriteCmd(0x2B); // RASET 行地址设置 LCD_WriteData16(y0 + Y_OFFSET); LCD_WriteData16(y1 + Y_OFFSET); LCD_WriteCmd(0x2C); // RAMWR,后面紧跟像素数据流 }参数含义很直白:x0,y0是左上角,x1,y1是右下角,都是屏幕坐标,加 offset 之后才是 GRAM 坐标。0x2C发出后,后续每发 2 个字节,光标自动右移一格,到行末自动换到下一行起始。所以刷一块矩形只需要设一次窗。
有了设窗,纯色填充就是套个循环:
void LCD_Fill(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_DC_DATA(); LCD_CS_LOW(); for (uint32_t i = 0; i < (uint32_t)w * h; i++) { LCD_WriteByte(color >> 8); LCD_WriteByte(color & 0xFF); } LCD_CS_HIGH(); }这里循环体直接调LCD_WriteByte,没有再走LCD_WriteData16,是为了避免每像素两次 CS 翻转。这个细节在全屏填充时差别很明显:128×160 是 20480 像素,每次翻转 CS 是两条 IO 操作,省下来就是四万多次操作。
4. 中文显示核心:GB2312 编码、16×16 字模与字库三条路
英文和数字之所以简单,是因为 ASCII 码就是字模的索引——字符'A'是 0x41,减掉 0x20 乘上 16 字节,直接就能定位到 8×16 的点阵。中文没这个便宜可占:GB2312 里汉字是双字节编码,字模在字库里的位置要靠区位码换算出来,而且字库本身就有 200KB 以上,得单独安排存放位置。
4.1 从内码到字模偏移的换算
GB2312 把字符分成 94 个区、每区 94 位。一个汉字的内码两个字节各减去 0xA0,得到的两个数就是区码和位码,都从 1 开始。知道区位码之后,字模在标准 GB2312 16×16 字库里的偏移就是((区码-1) * 94 + (位码-1)) * 32字节,其中 32 是 16×16 点阵的字节数(256 位 / 8)。
/* 输入 GB2312 内码的两个字节,返回字模在字库中的字节偏移 */ static uint32_t GB2312_Offset(const uint8_t *gb) { uint8_t area = gb[0] - 0xA0; // 区码 1~94 uint8_t pos = gb[1] - 0xA0; // 位码 1~94 /* 非法内码直接返回错误值,避免越界读到别的字模 */ if (area < 1 || area > 94 || pos < 1 || pos > 94) { return 0xFFFFFFFF; } return ((uint32_t)(area - 1) * 94 + (pos - 1)) * 32; }注意区码范围:1~9 区是符号和全角字符,16~55 区是一级汉字(按拼音排),56~87 区是二级汉字(按部首排),88 区往后是空白或未定义。所以字库文件的实际大小不是均匀的 94×94×32,从 1 区到 87 区算下来是 87×94×32 ≈ 256KB,其中汉字部分 6763 个占去约 211KB。
字模读取做成一个抽象层,后面换存储介质就不用动显示代码:
/* 三种后端共用这一个接口,换存储介质只改这一处 */ /* 后端 A:片内 Flash 常量数组,只放几十个常用字,适合资源极紧的场景 */ extern const uint8_t g_font16_small[]; uint8_t Font16_Read(uint32_t off) { return g_font16_small[off]; } /* 后端 B:外挂 SPI Flash(W25Q64 之类),烧录整份 GB2312 字库 */ uint8_t Font16_Read(uint32_t off) { uint8_t b; W25QXX_Read(&b, FONT_BASE_ADDR + off, 1); // FONT_BASE_ADDR 是字库烧录起始地址 return b; } /* 后端 C:中文点阵字库芯片,内部已经固化 GB2312,发 24bit 地址读字节 */ uint8_t Font16_Read(uint32_t off) { uint8_t b; GT20L_Read(off, &b, 1); return b; }后端 A 最省事也最受限,一个 16×16 汉字 32 字节,64KB Flash 里挤出一半给字库,也就装一千个出头,还得和程序争空间;后端 B 用 SPI Flash,8MB 的片子装 GB2312 绰绰有余,还能顺带放图片和 24 点阵字库;后端 C 是专门的硬件字库芯片,读接口简单,代价是多一颗料、成本略高。选哪个取决于你的板子还有没有空间和预算,软件上就改Font16_Read一个函数。
4.2 取模软件的三个设置项决定成败
字模数据的字节排布方式不是唯一的,取模软件里那几行选项决定了最终数组长什么样。PCtoLCD2002 这个工具用得多,我在这个项目上固定这样设置:阴码、逐列式、顺向、16×16、C51 格式。这四个词对应的意思是:点亮像素用 1 表示(阴码)、按列扫描(逐列式)、高位在前(顺向)、每个字节后跟一个逗号方便贴进代码。
设置错了的后果很直接:字显示成横竖颠倒、上下半字错位、或者整片糊成一块。常见的翻车是「逐行式」配「纵向取模」代码,两者齿不对齿。要么把取模软件改成逐列式,要么改代码里的行遍历逻辑,我一般统一成逐列式,代码也顺手:
void LCD_ShowChinese16(uint16_t x, uint16_t y, const uint8_t *str, // GB2312 双字节字符串 uint16_t color, uint16_t bg) { while (*str) { if (*str < 0x80) { str++; continue; } // 碰到 ASCII 跳过,交给英文函数处理 uint32_t off = GB2312_Offset(str); if (off == 0xFFFFFFFF) { str += 2; continue; } LCD_SetWindow(x, y, x + 15, y + 15); LCD_DC_DATA(); LCD_CS_LOW(); for (uint8_t row = 0; row < 16; row++) { /* 逐列式:每行 16 像素 = 2 字节,先高字节后低字节 */ uint8_t b0 = Font16_Read(off + row * 2); uint8_t b1 = Font16_Read(off + row * 2 + 1); for (uint8_t bit = 0; bit < 8; bit++) LCD_WriteByte((b0 & (0x80 >> bit)) ? (color >> 8) : (bg >> 8)), LCD_WriteByte((b0 & (0x80 >> bit)) ? (color & 0xFF) : (bg & 0xFF)); for (uint8_t bit = 0; bit < 8; bit++) LCD_WriteByte((b1 & (0x80 >> bit)) ? (color >> 8) : (bg >> 8)), LCD_WriteByte((b1 & (0x80 >> bit)) ? (color & 0xFF) : (bg & 0xFF)); } LCD_CS_HIGH(); str += 2; // 汉字占两个字节 x += 16; if (x + 16 > 128) { x = 0; y += 16; } // 到行尾换行 } }函数参数里color和bg都是 RGB565,前景色和背景色各传一个;str是以 GB2312 编码的双字节序列,遇到小于 0x80 的字节说明是 ASCII,跳过留给英文函数,这样中英混排时不会把英文当成半个汉字算错位置。这里用了逗号表达式把两次LCD_WriteByte写在一行,实际工程里拆成两个语句更清楚,排版也好看。
4.3 三条字库落地方案的实际取舍
把三种字库方案摆在一起对比一下,选型时就不纠结了:
| 方案 | 占用 | 可显示字数 | 成本 | 适用场景 |
|---|---|---|---|---|
| 片内 Flash 常量数组 | 最多约 30KB | 几百字,按需裁剪 | 0 | 字数固定的小仪表、菜单界面 |
| 外挂 SPI Flash | 约 220KB 起 | GB2312 全量 6763 汉字 | 一颗 Flash 芯片 | 需要显示任意中文字符串 |
| 硬件字库芯片 | 0(外部读取) | 全量,还支持 12/16/24 点阵 | 一颗字库芯片 | 产品化、需要多字号 |
判断标准很朴素:如果屏幕上永远只出现「上」「下」「确」「定」「返回」这几十个固定词,直接塞片内 Flash,连外部器件都省了;如果要做参数设置界面、要显示用户输入的设备名,那全量字库跑不掉,SPI Flash 是最省心的路;如果是量产项目、要过成本核算,字库芯片和 Flash 的报价拉一张表对比一下,往往芯片方案的总 BOM 更低,因为省了烧录工序。
5. 现场排查:花屏、乱码、刷新慢这几类坑
调试这类屏幕,真正花时间的不是写代码,是看到异常现象之后不知道往哪个方向查。下面几条是我在不同模组、不同主控上反复遇到的,每条按「现象 → 原因 → 解决」记下来,遇到时照着对一遍,能省掉大半天。
5.1 硬件与时序类异常
现象一:屏幕上一片雪花点或者随机彩条,偶尔复位后又能正常。原因多半在 SPI 时钟上——F103 的 4 分频是 18MHz,超过 ST7735 额定写周期能承受的上限,加上杜邦线长了以后信号完整性更差,边沿出现振铃,从机锁存时读到错误位。解决:把SPI_BaudRatePrescaler从_4改成_8(9MHz),同时把 SCL、MOSI 两根线缩短到 10cm 以内,杜邦线能换排线就换排线,板上并 100nF 去耦电容。
现象二:画面整体偏移两个像素,或者最右边四列是花的。这是 GRAM 尺寸(132×162)和玻璃尺寸(128×160)对不齐导致的。不同批次的模组,玻璃贴在 GRAM 上的起始位置不一样,有的在 (2,1),有的在 (0,0),还有的纵向在 (0,1)。解决:把X_OFFSET、Y_OFFSET依次试(0,0)、(2,1)、(0,1)三组,哪一组能把LCD_Fill(0,0,128,160,color)正好铺满不留边,就用哪一组。这是纯玄学,只能试。
现象三:全屏刷白色时板子复位或者程序跑飞。背光 LED 和屏驱动同时抽电流,最小系统板上的 3.3V LDO 扛不住瞬态,电压掉到 BOR 阈值以下触发复位。解决:背光串限流电阻单独走一路,或者在 VCC 与 GND 之间并 10μF 以上的钽电容,位置越靠近屏排针越好。
5.2 字库与软件类异常
现象四:中文显示成一堆方块、问号,或者显示的是完全不相干的别的字。最常见的根因是源文件编码。Keil 工程里如果源文件存成 UTF-8,"温度"这两个字节是E6 B8 A9 E5 BA A6,按 GB2312 减去 0xA0 算出来的区位码完全错位,自然读出一堆乱字。解决有两个:把源文件另存为 ANSI(在中文系统上就是 GB2312),或者直接在代码里用十六进制转义写死,"\xCE\xC2\xB6\xC8"就是「温度」,这样不受编辑器编码影响,缺点是改起来费劲。
现象五:单个字能显示,连续显示一句话时后面的字错位或者跑到屏幕外。说明宽度累加逻辑有问题。汉字占 16 像素宽,str += 2和x += 16必须成对出现,落掉一个就会越走越偏;另外换行判断要写成x + 16 > 128,写成x > 128会最后一个字超出屏幕右边界,ST7735 会把超出的部分自动回卷到下一行开头,看上去就是文字从左边冒出来。
现象六:改了字号之后字模糊或者字形被拉伸。点阵字库只能整倍放大,16 点阵按 1.5 倍画就是糊的,按 2 倍画边缘全是锯齿。正确做法是为每个字号准备一套独立点阵——12×12、16×16、24×24 各存一份,显示时按字号参数选不同的读取基址。24 点阵全量字库约 480KB,SPI Flash 里放得下,片内 Flash 想都别想。
提示:调试阶段在
LCD_ShowChinese16开头加一句LCD_Fill(x, y, 16, 16, 0xF800);,先把目标区域刷成红色。红色块位置对了才说明坐标系和 offset 是对的,再去查字模。这个顺序能省掉一半的排查时间。
6. 二次开发:从能显示到好用的几步改造
拿到这套代码,多数人的下一步不是「能显示中文」,而是「显示得好看、改起来方便」。这里给几个我在实际项目里做过的改造方向,从易到难排。
第一层是最低成本的封装——统一文字入口。现在中文、英文分两个函数,调用时得自己判断字符串里有没有汉字,很别扭。包一层LCD_DrawText,内部按字节扫描,小于 0x80 走 ASCII 分支、大于等于走 GB2312 分支,同时维护统一的x游标:
/* 统一文字入口:中英混排,返回下一行的 y 坐标 */ uint16_t LCD_DrawText(uint16_t x, uint16_t y, const char *str, uint16_t color, uint16_t bg) { while (*str) { if ((uint8_t)*str < 0x80) { // ASCII:8x16 点阵,宽 8 LCD_ShowChar8x16(x, y, *str, color, bg); x += 8; str += 1; } else { // GB2312:16x16 点阵,宽 16 LCD_ShowChinese16(x, y, (const uint8_t *)str, color, bg); x += 16; str += 2; } if (x + 16 > 128) { x = 0; y += 16; } // 行末折行,行高统一 16 } return y + 16; }行高统一按 16 算是关键,这样英文和汉字基线一致,不会出现英文浮在汉字上半截的怪样子。返回y + 16是为了支持多行连续输出,上层调用只要y = LCD_DrawText(0, y, ...)就能往下堆。
第二层是做字号参数化。把LCD_ShowChinese16里的常量 16 换成变量,字库基址按字号查表:
| 字号 | 每字字节数 | 全量约占用 | 字库基址偏移 |
|---|---|---|---|
| 12×12 | 24 | 162KB | 0x000000 |
| 16×16 | 32 | 216KB | 0x030000 |
| 24×24 | 72 | 487KB | 0x070000 |
基址偏移按上一档的大小向上取整,烧录时用工具把三份字库顺序写进 SPI Flash,代码里只管加偏移。这样换字号就变成传一个参数,不用改函数体。
第三层是提速,这也是最后悔药的一步。逐字节写 SPI 刷一屏要几十毫秒,做动画就卡。改造方向是把像素数据搬进内存缓冲,SPI1_TX 开 DMA,CPU 只管算下一帧。F103 的 SPI1_TX 对应 DMA1 通道 3,注意 DMA 传输宽度要和 SPI 数据帧对齐——如果 SPI 是 8 位,缓冲区就得按字节排,高字节在前:
/* 16 行缓冲:128 像素 x 2 字节 x 16 行 = 4KB,20KB SRAM 里留得下 */ static uint8_t s_blk_buf[128 * 2 * 16]; void LCD_FlushBlock(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_DC_DATA(); LCD_CS_LOW(); DMA1_Channel3->CMAR = (uint32_t)s_blk_buf; DMA1_Channel3->CNDTR = (uint16_t)(w * h * 2); // 按字节计 DMA1_Channel3->CCR |= DMA_CCR_EN; while (!(DMA1->ISR & DMA_ISR_TCIF3)); // 等传输完成 DMA1->IFCR = DMA_IFCR_CTCIF3; DMA1_Channel3->CCR &= ~DMA_CCR_EN; LCD_CS_HIGH(); }CNDTR填的是字节数不是像素数,这个单位很容易搞错,错了就是刷一半。缓冲区大小要算清楚:128×2×16 = 4096 字节,加上字库缓存、栈、其他全局变量,20KB SRAM 还有富余,但如果你贪心开 32 行缓冲就接近 8KB 了,得留神栈溢出。
验证改造效果的办法很土但很准:用 SysTick 在刷屏前后打时间戳,串口打印出毫秒数。改造前 128×160 全屏纯色大约 35~40ms,改成 DMA 块刷之后能压到 8ms 以内,肉眼就看不出闪烁了。这个数字每块板子不一样,跑一次心里就有底。
最后说一个我踩过的坑。有次为了省事,把 16 点阵字库直接const数组塞进片内 Flash,编译通过了,跑起来显示到第 800 多个字的时候突然重启。查了两天才发现是 Flash 写满之后链接器把字库截断了,越界读到非法地址触发 HardFault。从那以后我每次加字库,第一件事是看编译输出的.map文件里 Flash 用了多少、还剩多少,宁可外挂 Flash 也不跟程序抢那几十 KB。希望你在这块屏上少走两步弯路。
本文还有配套的精品资源,点击获取