
简介面向STM32单片机开发者的TFTLCD汉字显示工程资料包聚焦如何用C语言驱动彩色液晶屏并正确输出汉字点阵适合正在学习嵌入式显示技术或需要快速完成LCD界面开发的工程师。压缩包共136个文件涵盖h头文件、c源程序、Keil工程配置、hex烧录文件及调试辅助文件等整体大小仅2.39MB文件分类清晰便于按需查阅。工程实现了LCD初始化、显示区域设置、背光控制、汉字编码GB2312/GBK到点阵数据的转换与逐行写入等关键环节同时包含寄存器配置与HAL库相关文件可直接移植到常见STM32型号。已有3464人学习/下载对理解STM32外设接口GPIO、SPI或I2C与液晶屏控制时序以及嵌入式C语言工程组织方式均有实际参考价值。1. TFTLCD显示汉字卡住你的往往不是屏幕而是点阵做 32 单片机项目的人大多有过这种经历LCD 驱动代码烧进去画点、画线、显示英文数字都正常一换汉字就出现一整排乱码或者干脆空白。很多人第一反应是液晶屏坏了或者初始化序列不对折腾半天发现问题根本不在屏幕上而是汉字数据本身没准备好。TFTLCD 要显示中文核心流程并不复杂先算出汉字在字库中的点阵地址读出 16×16 或 24×24 的字节数组再按位映射成 LCD 上的像素颜色。只要明白编码如何换算、点阵如何排列、写屏顺序如何控制剩下的事情就是用 C 语言在 STM32 标准外设库工程里把数据搬进显存。这篇就以常见的 8080 并口 TFT 屏为例从点阵提取开始一路写到屏幕显示中间给出可以直接抄进 Keil 工程的代码和参数说明适合正在调 LCD 初始化、或者手头有现成工程但不知道怎么显示中文的开发者。2. 汉字点阵原理与 GB2312 编码换算2.1 16×16 点阵是怎么排布的汉字显示的本质是点阵填充。常见的 16×16 点阵每个汉字占用 32 个字节。这 32 个字节不是随便排列的通常按“逐行式”存储第一行 16 个点对应 2 个字节高位在前即第一个字节的最高位对应最左边的点第二行又是 2 个字节依次往下直到第 16 行。也就是说字节 0、1 是第一行字节 2、3 是第二行依此类推。如果你的字库取模软件输出的是“逐列式”或者“低位在前”那显示方向就会错要么汉字左右镜像要么上下颠倒。在 C 语言中判断某个点是否被点亮常用位运算if ((dot_data[line * 2 byte_index] (7 - bit_col)) 0x01) { // 画点 }这段代码里line是行索引 0~15byte_index是当前行内的第几个字节 0 或 1bit_col是字节内的列偏移 0~7。因为第一个字节的最高位是行内最左边的点所以用7 - bit_col把位右移到最低位再与 1。这样写比直接移位判断更直观后续改用 24×24 字库时也不容易出现位序错误。2.2 汉字内码到字库地址的计算计算机中一个 GB2312 汉字占用两个字节高字节和低字节都在 0xA1~0xFE 之间。要找到它在 HZK16 字库文件中的位置先要换成区位码uint32_t HZK16_Offset(uint8_t high_byte, uint8_t low_byte) { uint32_t area high_byte - 0xA1; // 区号 uint32_t pos low_byte - 0xA1; // 位号 return (area * 94 pos) * 32; // 每个汉字 32 字节 }area是区码pos是位码。之所以乘 94是因为 GB2312 每个区最多容纳 94 个汉字位。偏移量最后乘 32对应 16×16 点阵的字节数。这个公式是从技术上换算不涉及任何文本编码规范问题。实际操作中如果你手里是 GBK 字库比如“HZK16”的姊妹版本计算方式相同因为 GBK 保持了 GB2312 在 0xA1~0xFE 区间的兼容性。从文件读取点阵数据时我一般这样做FILE *fp fopen(HZK16, rb); uint8_t dot[32]; fseek(fp, HZK16_Offset(0xD6, 0xD0), SEEK_SET); fread(dot, 1, 32, fp); fclose(fp);这里取的是“中”字的区位码高字节 0xD6低字节 0xD0。fseek定位到偏移位置fread一次性读出 32 字节点阵。如果在嵌入式环境没有文件系统可以把整个字库转成 bin 数组存入外部 Flash或者直接在编译期用包含字库的 C 数组。注意fread返回的是实际读取字节数最好判断一下防止文件损坏。2.3 编码适配与 OLED 汉字的差别很多人在控制 OLED 显示汉字时也用同样的点阵方法但 OLED 屏幕尺寸小多使用 12×12、16×16 点阵字库文件结构不同。比如 16×16 OLED 中文常直接用 32 字节而 12×12 点阵通常是 24 字节排列方式也按行分多字节。TFTLCD 分辨率大常见做法是直接用 HZK16 或更大的 24×24 字库。如果你的 C 源码文件本身保存成 UTF-8 编码那字符串中的汉字高字节和低字节会变成 0xE? 开头直接用上面的偏移公式会取错位置。解决方案有两个编译前把源文件转成 GB2312/GBK 编码或者在代码里强制类型转换并手动调整char chinese[3] 汉; uint8_t high (uint8_t)chinese[0]; uint8_t low (uint8_t)chinese[1]; offset ((high - 0xA1) * 94 (low - 0xA1)) * 32;即便 Keil 能识别 UTF-8char数组中存的仍然是多字节序列必须自己拆分。网上很多教程直接写中然后取str[0]在 UTF-8 环境下往往得到的是 0xE4 一类的首字节算出来的字模是错的。项目里的LCD.axf是编译产物无法反推出当时源文件的编码所以阅读源码时留意一下 Keil 的 Edit-Configuration-Encoding 设置建议统一用 GB2312 保证中文注释和字符串一致。3. STM32 通过 GPIO 模拟 8080 时序驱动 TFTLCD3.1 为什么选 GPIO 并口而不是 SPI 或 I2CSTM32F103 等型号自带 SPI 和 I2C 外设但这类 TFTLCD 屏最常见的控制器是 ILI9341、ILI9325 等它们原生支持 8080 并口和 SPI 模式。SPI 模式接线少刷屏速度却受限于 SPI 时钟而且初始化序列要和控制器手册严格对应。GPIO 模拟 8080 并口虽然占用引脚多但时序是对寄存器直接置位和清零不依赖硬件 SPI 的空闲电平极性调试直观尤其适合学习 LCD 控制器底层协议。项目中出现stm32f10x_tim.c、stm32f10x_rcc.c这类标准外设库源文件说明工程基于标准外设库SPL。使用 SPL 的好处是直接操作GPIO_InitTypeDef、SPI_InitTypeDef结构体和 HAL 库的逻辑基本一致换到 HAL 工程时只是函数名不同。这里我按最小改动原则采用 GPIO 模拟方式只需初始化一组推挽输出引脚。3.2 引脚分配与 GPIO 初始化以典型的 16 位并口屏为例数据线至少 8 根控制线有 CS、RS或 DC、WR、RD、RESET。如果控制器支持 16 位并行还需要高 8 位数据线很多屏可以配置为 8 位模式减少引脚占用。下面是我惯用的引脚映射引脚功能STM32 引脚配置DB0~DB7PB0~PB7推挽输出 50MHzRSPA0推挽输出WRPA1推挽输出RDPA2推挽输出CSPA3推挽输出RESETPA4推挽输出初始化代码在GPIO_InitTypeDef中设置GPIO_Mode_Out_PP意思是通用推挽输出。8080 并口不需要开漏读操作时通过 RD 引脚拉低读取数据但多数 8 位并口屏在读寄存器时也需要数据线方向切换。只关注写命令和写数据的话全部配成输出就够用。下面是标准外设库的初始化void LCD_GPIO_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Speed GPIO_Speed_50MHz; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_Init(GPIOB, gpio); }GPIO_Speed_50MHz不是让引脚真的以 50MHz 翻转而是设置 IO 口驱动能力上限。并口线多同时翻转时对 EMI 敏感过低的翻转速率可能导致写入数据时未稳定一般取 50MHz 最稳妥。代码库里的stm32f10x_rcc.c就是这个时钟控制函数所在的源文件复位后默认所有外设时钟关闭必须先开启 GPIOA 和 GPIOB 的时钟。3.3 写命令与写数据函数的时序拆解8080 并口单次传输分为四步拉低 CS 选中设备RS 置为 0 表示写命令或 1 表示写数据WR 拉低再拉高产生上升沿锁存数据最后释放 CS。写命令和写数据的区别仅在于 RS 电平所以可以抽一个公共写函数void LCD_WriteBit(unsigned char rs, unsigned char data) { GPIO_Write(GPIOB, data); // 数据放到 DB0~DB7 GPIO_ResetBits(GPIOA, GPIO_Pin_3); // CS0选中 if (rs) GPIO_SetBits(GPIOA, GPIO_Pin_0); // RS1 数据 else GPIO_ResetBits(GPIOA, GPIO_Pin_0); // RS0 命令 GPIO_ResetBits(GPIOA, GPIO_Pin_1); // WR0 // 保持至少几个时钟周期等待线上数据稳定 for (volatile int i 0; i 5; i); GPIO_SetBits(GPIOA, GPIO_Pin_1); // WR1上升沿锁存 GPIO_SetBits(GPIOA, GPIO_Pin_3); // CS1释放 }GPIO_Write是标准外设库直接写整个端口一次输出的函数比逐位操作快得多。volatile的空循环是为了让 WR 低电平持续一小段时间如果 STM32 主频太高去掉延时在某些控制器上有概率写错。很多屏的控制手册会给出 WR 最小脉冲周期通常为几十纳秒5次空循环在 72MHz 下已经足够但如果你用的是 168MHz 的 STM32F407需要加大到20左右。接着封装两个更高的接口void LCD_WriteReg(uint16_t reg) { LCD_WriteBit(0, (uint8_t)(reg 8)); LCD_WriteBit(0, (uint8_t)(reg 0xFF)); } void LCD_WriteData(uint16_t data) { LCD_WriteBit(1, (uint8_t)(data 8)); LCD_WriteBit(1, (uint8_t)(data 0xFF)); }到这里读写 8 位数据的基础函数就齐了。若屏幕支持 16 位并口可以一次写入 16 位但上面的代码是 8 位模式传输两次数据才完成一个像素周期速度会慢一半。实际项目中如果不需要滚屏动画8 位模式显示静态中文完全没问题。前面提到的lcd.c文件里通常就是这些底层函数加上初始化序列stm32f10x_tim.c在工程里可能是为背光 PWM 或者时间基准准备的不影响显示主流程。4. 显示区域设置与汉字绘制函数4.1 设置窗口坐标避免全屏刷新TFTLCD 控制器的显存工作原理决定了画图前必须划定显示区域。如果每次都从原点刷新整屏 320×240 像素逐个写会明显闪烁尤其是显示几十个汉字时。正确做法是先通过指令设置列地址和行地址范围再连续写入像素数据让控制器自动回绕地址。不同控制器的指令集有差异但方向和寄存器写法类似。以常见的 ILI9341 为例窗口设置指令是 0x2A列和 0x2B行void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteReg(0x2A); LCD_WriteData(x0 8); LCD_WriteData(x0 0xFF); LCD_WriteData(x1 8); LCD_WriteData(x1 0xFF); LCD_WriteReg(0x2B); LCD_WriteData(y0 8); LCD_WriteData(y0 0xFF); LCD_WriteData(y1 8); LCD_WriteData(y1 0xFF); LCD_WriteReg(0x2C); // 开始写入显存 }LCD_WriteReg(0x2A)之后跟四个参数分别表示起始列高 8 位、起始列低 8 位、结束列高 8 位、结束列低 8 位。对于 320×240 的屏坐标范围是 0~319 和 0~239所以高 8 位在大多数情况下是 0但必须补上否则控制器接收参数长度不对。0x2C 指令后每次写数据指针自动加一逐点填充。如果你的屏是 ST7789 等控制器指令可能不同但0x2A/0x2B/0x2C是很多老式并口屏通用的先对照初始化序列里有没有出现这几个寄存器。4.2 汉字绘制函数与颜色参数窗口设置好后把点阵数据画到屏幕上是比较机械的工作。每个字节有 8 个位为 1 则画前景色为 0 则画背景色。汉字不同于图形背景色通常保留为屏幕底色所以只在前景点位置写颜色背景处跳过不写这样不会覆盖已经显示的其他内容。如果要显示带底色的标题栏则把背景色也写入。下面是一个带背景色开关的绘制函数void LCD_DrawHz(uint16_t x, uint16_t y, const uint8_t *dot, uint16_t fcolor, uint16_t bcolor, uint8_t show_bg) { for (int row 0; row 16; row) { LCD_SetWindow(x, y row, x 15, y row); // 单行窗口 for (int col 0; col 16; col) { uint8_t byte dot[row * 2 col / 8]; if (byte (0x80 (col % 8))) { LCD_WriteData(fcolor); } else { if (show_bg) LCD_WriteData(bcolor); else LCD_WriteData(0xFFFF); // 用白色占位实际应跳过 } } } }这段代码有个小问题窗口是固定宽度 16即使某一位不显示也必须写满窗口内的每个像素否则后面的像素会错位。所以背景跳过在并口纯软件方式下不现实必须写入一个占位颜色。要真正实现“只画前景色”需要每画一位就重新设置窗口效率很低所以工程上通常直接连背景一起写。0xFFFF是 RGB565 格式的白色如果你的屏底色不是白色就把这个值改成外部传入的底色变量。参数含义如下参数含义建议值x, y汉字左上角坐标范围 0~宽度-16dot32 字节点阵数组指针来自字库读取或常量数组fcolor前景色 RGB5650x0000 黑0xFFFF 白bcolor背景色 RGB565常设为 LCD 初始底色show_bg是否填充背景1 填充0 不填但会占位RGB565 颜色格式在高位字节存红色 5 位、绿色 6 位、低位字节存蓝色 5 位。如果你习惯用0xFC18这种数值表示某种红色注意不要和 24 位 RGB 混淆。液晶屏控制器在 16 位模式下直接发送两字节即完成一个像素。4.3 显示中英文混合字符串的逻辑实际显示界面很少只显示单个汉字一般是一串中英文字符混排。这时需要判断当前字节是 ASCII 字符还是中文字符第一字节因为 ASCII 在内存里小于 0x80而 GB2312 中文两个字节都大于等于 0xA1。判断出来了再分别走LCD_DrawHz和画英文字模函数。常见写法void LCD_ShowString(uint16_t x, uint16_t y, const char *str, uint16_t fcolor, uint16_t bcolor) { while (*str) { if (*str 0x80) { // ASCII 字符使用自带的 8x16 字模 LCD_DrawAscii(x, y, *str, fcolor, bcolor); x 8; str; } else { uint8_t h *str; uint8_t l *(str 1); uint16_t offset ((h - 0xA1) * 94 (l - 0xA1)) * 32; const uint8_t *dot get_dot_from_flash(offset); LCD_DrawHz(x, y, dot, fcolor, bcolor, 0); x 16; str 2; } } }很多例程直接用*(str1)而不判断是否字符串末尾如果最后一个字符是半个中文字符会越界读内存。为了简单这里的get_dot_from_flash是从提前烧录到外部 Flash 的字库区取数据返回指针指向 32 字节缓冲区。你要是在 SPL 工程里看到stm32f10x_flash.c大概率就是它来负责读取字库——这种做法的好处是不把几十 KB 字库塞进芯片内部 Flash给用户代码留空间。显示完一行后更新x坐标超过屏幕宽度就换行并把y加上行高。5. 进阶技巧字库裁剪与缓存优化标准做法是整字库烧进外部 Flash段地址固定用偏移读取。但如果你用的 STM32F103C8 只有 64KB Flash放不下整个 HZK16 的 256KB 字库就得做字库裁剪。常见方案是先用离线工具从 HZK16 里提取你会用到的几十个汉字生成一个数组放入 C 文件再定义宏或者查找函数定位。我通常用脚本生成如下结构const struct hz_item { uint16_t code; // 汉字内码如 0xC4E3 代表“你” uint8_t dot[32]; } hz_table[] { {0xC4E3, {0x04,0x04,0x04,...}} , {0xBAEC, {0x00,0x00,0x7F,...}} };编译时哈希表太麻烦直接用线性查找汉字数量不超过 100 个时速度完全可接受。查找函数也很直接const uint8_t *sear_hz(uint16_t code) { for (int i 0; i sizeof(hz_table) / sizeof(hz_table[0]); i) { if (hz_table[i].code code) return hz_table[i].dot; } return NULL; }上面线性查找的复杂度是 O(n)n 是字表长度。要提速可以把 code 字段排序后二分但嵌入式项目里界面文案最多一两百字线性扫描耗时远小于 LCD 刷新一次的时间刻意优化没有意义。另一个更快的办法是让取模工具直接生成“按显示顺序排列的点阵数组”运行时只要按序号取数据连内码查找都省了缺点是后续改文案要重新取模。我在实际项目中两种方案交替用原型阶段用裁剪字库界面稳定后改用顺序数组。关于缓存如果你要在屏幕上频繁更新某个数值比如实时温度建议先绘制到内存缓冲区再一次性整体搬到液晶屏。但 STM32F103 内部 SRAM 有限320×240×2 字节要 153.6KB肯定放不下。折中办法是仅开一行缓存改哪个区域就重新设置哪个窗口配合LCD_SetWindow只刷新变化区域。OLED 显示汉字的做法也类似只不过 OLED 显存小往往开 128×8 字节缓存TFT 屏因为数据量大换成按需刷新更有价值。最后提供一个验证方法显示完一个汉字后在它周围画一个矩形边框如果汉字填满边框且没有出现镜像说明点阵排列方向和窗口设置正确如果上下颠倒把点阵数据的行循环改成从 15 到 0如果左右镜像将字节内的移位方向反转。这种快速定位手段比对着代码猜更有效也能帮你判断问题究竟出在lcd.c的函数里还是字库数据来源上。本文还有配套的精品资源点击获取