简介一份51单片机TFT彩屏汉字显示例程面向嵌入式入门学习者、电子竞赛备赛者以及需要在智能仪表、手持设备等场景中快速实现中文界面显示的单片机开发者51单片机也是高校经典教学芯片该例程能帮助学习者将硬件驱动与字形显示结合起来。工程覆盖硬件初始化、液晶屏驱动配置、图形界面文本绘制、主循环更新等完整软件流程主程序负责启动配置与循环调度驱动层处理屏显时序和像素输出图形界面层封装文字绘制接口字模文件则保存汉字点阵数据。压缩包共23个文件整体仅71KB以C语言源码、头文件和集成开发环境工程配置为主同时包含列表文件、目标文件等中间文件、十六进制固件输出及多类备份文件结构清晰便于对照源码理解编译过程也为二次移植保留了参考。通过阅读和调试可掌握单片机与TFT彩屏之间的指令交互、汉字内码到点阵像素的映射方法并理解颜色格式、坐标定位、清屏刷新等驱动细节还能学习如何在资源受限的单片机平台上实现较复杂的显示效果。已有933人浏览学习尤其适合希望用精简工程快速上手嵌入式显示开发并在此基础上扩展更多字体库或图形界面的读者。1. 51单片机TFT彩屏汉字例程先把编码、取模、SPI驱动三关打通很多玩51单片机的人第一次接TFT彩屏都是从画点、画线、显示英文开始的。等界面需要上中文时会发现情况完全变了同样是显示一个字符汉字不能像ASCII那样直接查一张8×16的表它要先知道GB2312的区位码再用区位码去算字模在字库里的偏移最后还要把字模的每个bit按正确的字节序搬到屏幕上。任何一个环节的顺序反了、方向反了出来的字就是镜像或者乱码。这套51单片机TFT彩屏汉字例程解决的就是这个问题它把汉字的编码换算、字模存储和屏幕驱动做成了一个可以直接跑的完整工程。拿到手烧录就能显示中文适合做仪表盘、工控小屏界面和课程设计。2. 工程结构和技术栈一套能跑的代码是怎么组织的2.1 拿到手的目录结构长什么样一套完整的例程打开后一般会包含源码目录、取模工具配置说明和烧录说明。以常见的STC89C52系列配合ST7735驱动的1.8寸彩屏为例工程的源码目录结构大致是这样的结构不同版本文件名可能略有出入但模块划分基本一致HZK16_DEMO/ ├── src/ │ ├── main.c // 主流程定时器初始化、屏幕初始化、循环显示 │ ├── lcd_st7735.c // ST7735驱动SPI时序、初始化序列、画点函数 │ ├── lcd_st7735.h // LCD相关宏定义、引脚映射、API声明 │ ├── hz_utils.c // GB2312编码换算、汉字显示API │ ├── hz_utils.h │ ├── font_hz16.c // 16×16汉字字模数组存放在code区 │ └── font_ascii8x16.c // ASCII字符8×16字模英文显示用 ├── tools/ │ ├── 取模软件配置说明.txt // 重点逐行式、高位在前等参数 │ └── 字库裁剪工具说明.txt └── README.md // 接线图、烧录步骤、单片机型号说明这个结构的核心思路是把硬件的屏幕驱动和软件的汉字逻辑分开。lcd_st7735.c只负责跟屏幕打交道提供画点、清屏、设置窗口这些原子操作hz_utils.c负责把汉字编码换算成字模地址再调用画点函数把字模画出来。main.c里不需要出现任何跟SPI时序相关的代码它只调用lcd_draw_text()这类高层接口。2.2 为什么选这套技术栈51单片机驱动TFT彩屏听起来有点“小马拉大车”实际上选型得当完全够用。屏幕选ST7735这类SPI接口的型号数据线只需要MOSI、SCLK、CS、DC、RST五根STC89C52没有硬件SPI用IO口模拟SPI即可速度在12MHz晶振下足够刷新一个128×160的屏显示静态菜单完全不会卡。如果换成带硬件SPI的STC12系列可以进一步提高刷新率代码结构不变。字模存储是个关键决策。51单片机的片内RAM通常只有256字节一个16×16的汉字字模就要32字节放不了几个字。所以这套例程把字模数组用code关键字存放在程序存储区等效于直接在flash里内嵌字库。这样只要flash空间够就可以放几百上千个汉字。我在实际使用中通常会裁剪字库只保留用得到的汉字而不是放整个16区全字库这样能省一半空间。2.3 API设计的层次先画点再显字整套例程的API设计是典型的“原子操作 → 组合功能”结构。底层只提供四个函数分别是初始化、清屏、画点、画像素块上层基于画点实现字符和字符串真正的显示逻辑用不到底层的SPI命令。这种分层的好处是如果你要换屏幕型号只需要重写lcd_st7735.c这一个文件上层的hz_utils.c可以原封不动地留下来。// lcd_st7735.h 核心接口 void lcd_init(void); // 初始化GPIO和屏幕寄存器 void lcd_clear(unsigned int color); // 整屏填充指定颜色 void lcd_set_window(unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1); // 设置像素窗口 void lcd_draw_pixel(unsigned int x, unsigned int y, unsigned int color); // 在指定坐标画点上层的汉字显示接口只需要关心坐标、字符串指针和颜色三个参数不用关心SPI时序和屏幕是横屏还是竖屏。这里有一个细节需要留意lcd_set_window这个函数在批量填充和连续画字时特别关键如果逐点去写每次画点都要重新发坐标命令速度会慢一半以上。3. 核心代码拆解一个汉字字符串是怎么变成屏幕像素的3.1 GB2312编码与字模偏移的计算逻辑这套例程的精华所在就是汉字编码换算函数。GB2312编码中一个汉字由两个字节组成第一个字节是区码第二个字节是位码范围都在0xA1到0xFE之间。字模在字库数组中的偏移由区码和位码共同决定。计算方法是用区码和位码分别减去0xA1转换成从0开始的索引然后通过区索引×94 位索引得到这个字在字库中的序号再乘以每个汉字占用的字节数就得到偏移。// hz_utils.c根据GB2312区码和位码计算字模偏移 // 每个16x16汉字字模占32字节16行 × 每行2字节 unsigned int hz_get_offset(unsigned char area, unsigned char pos) { unsigned int index; unsigned int offset; // 区码和位码范围在0xA1~0xFE之间 // 先转换成0~93的索引94是GB2312每区的字符数 index (unsigned int)(area - 0xA1) * 94 (unsigned int)(pos - 0xA1); offset index * 32; // 16x16点阵16行 × 2字节 32字节 return offset; }这里特别要注意减法运算的顺序。area和pos是unsigned char类型如果直接写成(area - 0xA1) * 94由于默认的整型提升机制可能会导致负数参与乘法所以要先转成unsigned int。实际调试时我曾在这里翻过车某个汉字总是显示成另外一个字查了半天发现就是类型转换的问题。这个函数本身不读字库只做数学运算如果你要把字库放到外部flash或者SD卡只需要改这一处偏移计算让它去读外部存储器就行。3.2 字模数据的字节序和扫描方式字模数据看起来是一堆十六进制数实际上它的bit分布直接决定了文字的显示效果。一套16×16字模用32字节存储前16字节对应第一行到第十六行的左半部分后16字节对应右半部分每一行一个字节的8个bit从左到右对应屏幕上的8个像素。用的取模方式是“逐行式、高位在前、行内从左到右”即字节的最高位对应最左边的像素。// font_hz16.c 字模数组片段 // 这个数组在编译后位于程序存储区(code段)不占用片内RAM const unsigned char code font_hz16[] { /* 显示示例“中”字仅展示前两行 */ 0x00, 0x00, // 第1行全空 0x7F, 0xFC, // 第2行左右各有一截横线 /* 后面还有30字节构成完整的16行 */ };如果字模显示出来左右颠倒或者上下倒扣通常是取模工具里的选项没选对。例程配套的说明里会明确写“逐行式、顺向、高位在前”这三个选项必须跟代码里的扫描逻辑一致。代码扫描时先判断该行的高字节和低字节是否在画点上然后逐位判断。这里字节内位判断的顺序跟字模工具的选择是绑定的不要随意去改扫描顺序要么改取模参数要么改扫描代码二选一不要两个都动。3.3 汉字显示函数把32字节翻译成屏幕上的点显示一个汉字的完整过程分三步先从GB2312字符串中取出两个字节的区码和位码然后调用偏移计算函数找到字模在font_hz16数组中的起始位置最后逐行扫描字模数据按bit位置调用画点函数。下面这个函数是整套例程中我建议你仔细读一遍的实现。// hz_utils.c在指定坐标显示一个16x16汉字 void lcd_draw_hz16(unsigned int x, unsigned int y, unsigned char *hz_str, unsigned int color) { unsigned char area, pos; unsigned int offset; unsigned char i, j; unsigned char byte_h, byte_l; area hz_str[0]; // 区码第一个字节 pos hz_str[1]; // 位码第二个字节 // 边界检查非GB2312范围内的数据直接跳过 if (area 0xA1 || area 0xFE || pos 0xA1 || pos 0xFE) { return; } offset hz_get_offset(area, pos); // 计算字模在字库数组中的偏移 for (i 0; i 16; i) { // 每行两个字节第一个是高字节(左半部分)第二个是低字节(右半部分) byte_h font_hz16[offset i * 2]; byte_l font_hz16[offset i * 2 1]; // 高字节的bit7~bit0对应左半部分8个像素 for (j 0; j 8; j) { if (byte_h (0x80 j)) { lcd_draw_pixel(x j, y i, color); } // 低字节对应右半部分8个像素 if (byte_l (0x80 j)) { lcd_draw_pixel(x j 8, y i, color); } } } }这个函数的三个参数需要说清楚x和y是汉字左上角的坐标对应屏幕坐标系横坐标向右增大纵坐标向下增大hz_str是指向GB2312字符串的指针注意是双字节的不是单字节ASCIIcolor是16位颜色值例如0xF800是红色0x07E0是绿色。函数没有返回值如果编码无效内部会直接return不在屏幕上产生任何输出。字模数据是用code修饰的编译器会把它放到flash区这样函数运行时不占用宝贵的片内RAM。如果想要显示中文和英文混排的字符串就需要一个遍历逻辑来区分当前遇到的是单字节ASCII还是双字节汉字。ASCII编码的范围是0x00~0x7F汉字编码的两个字节都在0xA1以上所以判断规则很简单第一个字节小于0xA1就按ASCII处理否则按GB2312处理指针跳过两个字节。// hz_utils.c中英文混排字符串显示 void lcd_draw_text(unsigned int x, unsigned int y, char *str, unsigned int color) { unsigned int cursor_x x; while (*str ! \0) { if ((unsigned char)*str 0xA1) { // 单字节ASCII字符宽度8像素 lcd_draw_ascii16(cursor_x, y, (unsigned char)*str, color); cursor_x 8; str; } else { // 双字节汉字宽度16像素 lcd_draw_hz16(cursor_x, y, (unsigned char *)str, color); cursor_x 16; str 2; } } }这里有个坐标步进的细节容易踩坑。ASCII字符的宽度是8像素汉字是16像素如果混排时都按16像素步进英文字母之间就会空一大截如果都按8像素步进汉字就会互相覆盖。正确做法是在循环里根据当前字符类型动态决定步进量。这种混排逻辑在菜单界面里几乎是必须的因为绝大多数菜单都包含中文和数字、字母的组合。4. SPI驱动和屏幕初始化数据是怎么送到液晶面板上的4.1 模拟SPI的时序实现51单片机没有硬件SPI所以例程里用IO口模拟。模拟SPI的原理很简单对于每个字节的8个bit先设置MOSI的电平再翻转SCLK产生时钟边沿让屏幕在上升沿采样数据。一个完整的bsp_spi_write函数包括时钟极性和相位控制这部分参数必须跟屏幕的数据手册一致。ST7735和ILI9341都是SPI模式0也就是时钟空闲时为低电平上升沿采样数据。// lcd_st7735.c软件模拟SPI发送单字节 // 时钟极性空闲低电平采样边沿上升沿 void spi_write_byte(unsigned char dat) { unsigned char i; for (i 0; i 8; i) { if (dat 0x80) { LCD_MOSI 1; } else { LCD_MOSI 0; } dat 1; LCD_SCLK 0; // 拉低时钟准备上升沿 delay_us(1); // 等待电平稳定确保满足屏幕的建立时间 LCD_SCLK 1; // 产生上升沿屏幕在此刻采样数据 } }最常见的坑是延迟时间给得太短。STC89C52在12MHz晶振下IO翻转速度比较快但屏幕的SPI时序要求数据在时钟边沿之前建立稳定。delay_us(1)如果被优化掉或者注释掉屏上就会随机出现花点。这个延迟通常不需要很长一个空指令的延时就够了但如果换用更快的屏幕需要重新对照数据手册调整。4.2 屏幕初始化序列白屏到能显示的必经之路TFT彩屏上电后需要写入一串初始化命令屏幕才会从白屏状态变成可显示状态。这个初始化序列包含软件复位、退出睡眠模式、设置显示方向、设置像素格式、打开背光等步骤。不同型号的屏幕初始化序列差别很大例程里这一部分要严格按照屏幕厂家的数据手册来写。下面是ST7735初始化序列中的核心片段缩减版完整版一般有一二十条命令。// lcd_st7735.c初始化序列示例 void lcd_init(void) { // 第一步硬件复位 LCD_RST 1; delay_ms(50); LCD_RST 0; delay_ms(50); LCD_RST 1; delay_ms(120); // 第二步退出睡眠模式 lcd_write_cmd(0x11); delay_ms(120); // 第三步设置显示方向为竖屏 lcd_write_cmd(0x36); lcd_write_data(0xA8); // 第四步设置像素格式为16位色 lcd_write_cmd(0x3A); lcd_write_data(0x05); // 第五步打开显示 lcd_write_cmd(0x29); // 最后清屏并点亮背光 LCD_BL 1; lcd_clear(0x0000); }初始化序列中涉及的命令格式需要解释一下。屏幕控制芯片内部有一套寄存器主机通过发送命令字节和参数数据来控制它。lcd_write_cmd和lcd_write_data的区别在于DC引脚电平命令时DC拉低数据时DC拉高。写命令时后面可以跟多个数据字节比如0x36命令后面要跟一个方向参数0xA8这个参数决定屏幕的扫描方向和RGB的排列顺序。很多人改屏幕接线后显示颜色异常或者图像镜像问题经常出在这里——0x36的取值是依据屏幕的安装方向而定的。4.3 像素窗口和批量数据填充每次画点都发送坐标命令效率太低尤其是一次要显示几十个汉字时大量的时间浪费在重复发送命令上。例程提供了lcd_set_window函数它告诉屏幕芯片一个矩形范围之后连续写入的所有数据都会按行列顺序填充到这个范围内不用再指定坐标。这个特性在显示文字时作用明显因为一个16×16汉字的32字节数据可以一行接一行地连续发出去。// lcd_st7735.c设置像素窗口并批量填充颜色 void lcd_fill_rect(unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1, unsigned int color) { unsigned long int total (unsigned long int)(x1 - x0 1) * (y1 - y0 1); unsigned long int i; lcd_write_cmd(0x2A); // 设置列地址范围 lcd_write_data(x0 8); // 高字节在前 lcd_write_data(x0 0xFF); lcd_write_data(x1 8); lcd_write_data(x1 0xFF); lcd_write_cmd(0x2B); // 设置行地址范围 lcd_write_data(y0 8); lcd_write_data(y0 0xFF); lcd_write_data(y1 8); lcd_write_data(y1 0xFF); lcd_write_cmd(0x2C); // 开始写入像素数据 for (i 0; i total; i) { lcd_write_data(color 8); // 16位色高字节在前 lcd_write_data(color 0xFF); } }这里有一个容易忽视的细节16位色值的字节序。RGB565格式中一个像素占两个字节屏幕芯片要求高字节先到。例程里lcd_write_data(color 8)在先color 0xFF在后顺序不能写反。如果写反你会看到整个屏的颜色全部错乱红蓝互换、绿紫错位。这个错误非常隐蔽因为代码逻辑上完全没错只是字节序不对。5. 避坑指南白屏、乱码、镜像这些问题的真实排查思路5.1 现象汉字显示为乱码或“天书”一样的无规律字符原因最常见的是GB2312编码的区码和位码没有被正确提取。比如编译器使用了UTF-8编码保存源代码源文件里的中文字符串变成了三个字节一组的UTF-8序列而字模库是按GB2312的两个字节偏移来组织的字节数对不上自然找不到正确的字模位置。解决把源文件编码强制改为GB2312或GBK。我用的是Keli软件可以在编辑器设置里把“Encoding”改成“Chinese GB2312”。另外也可以用纯十六进制数组的方式定义字符串比如unsigned char code str[] {0xC4, 0xE3, 0xBA, 0xC3, 0x00};这样绕开源文件编码问题完全依赖手写编码虽然麻烦但最可靠。5.2 现象汉字显示出来是左右镜像的像照镜子一样原因取模软件生成字模时选择了“逆向”或“从右到左”选项导致字节内的bit顺序跟代码的扫描顺序相反。代码是按0x80 j从高位到低位扫描的如果取模时字节内的bit是从低位排列的显示结果就是镜像。解决重新打开取模软件确认设置为“逐行式、顺向、高位在前”。注意不是改代码来适配字模而是保证取模参数跟代码一致。因为我发现同时改两处容易造成混乱保留一边作为标准另一边去适配会让后续加字时少很多麻烦。5.3 现象屏幕白屏没任何显示原因细查三步。第一步背光没点亮屏幕白亮但无字第二步初始化序列失败屏幕保持白屏状态第三步SPI接线错误数据根本没送进去。最常见的白屏原因是复位时序太短屏幕的内部控制电路还没有准备好初始化命令就被发送了。解决硬件复位后要延时120ms以上再发第一条命令。我用逻辑分析仪抓过时序发现很多白屏问题出现在上电后立即初始化的场景。把延时加大到200ms问题通常就能解决。如果仍然白屏先用万用表量一下LCD_RST引脚的电压确认单片机的复位引脚接线正确。5.4 现象显示正常但是刷新慢菜单切换有“拖影”感原因每次画点都调用一次spi_write_byte而且画点时没有使用窗口功能每个像素都重新发送坐标命令。尤其是一次性显示多行汉字时冗余命令的开销被放大。解决在lcd_draw_hz16中先把所有点扫描出来再用lcd_fill_rect或者在设置窗口后用连续写数据的方式一次性发送全部像素。我在实践中把显示单个汉字的时间从几十毫秒降到了几毫秒这个优化对刷新速度的提升非常明显。6. 二次开发给例程加上多字号、滚动显示和状态栏菜单6.1 支持12×12小字号汉字16×16汉字在128×160的屏幕上能显示8列4行对于信息密集的菜单界面不太够用。可以增加一套12×12的字模数据每个汉字占24字节行宽12像素。实现方式是在font_hz12.c中增加对应的数组然后在hz_utils.c里增加一个lcd_draw_hz12函数。这个函数的扫描逻辑跟16×16版本相似区别在于字节数和行宽。调用时根据字号需要选择不同的显示函数或者增加一个字号参数。// hz_utils.c字号分派逻辑示例 void lcd_draw_hz_choose(unsigned int x, unsigned int y, unsigned char *hz_str, unsigned char font_size, unsigned int color) { if (font_size 12) { lcd_draw_hz12(x, y, hz_str, color); } else if (font_size 16) { lcd_draw_hz16(x, y, hz_str, color); } }增加字号时取模软件需要重新生成对应尺寸的字模且取模参数必须保持与原有字模一致。如果12×12字模用了不同的扫描方向那么显示逻辑也要跟着调整这属于两个独立的配置组合不要混用。我在项目里往往把12×12和16×16并存小字号用于状态栏显示的数字和短文本大字号用于菜单标题。6.2 滚动显示多行文本101单片机资源有限没法像计算机那样滚动页面但可以借助窗口移位来实现文本滚动。思路是把整个文本区域当作一个可移动的窗口通过改变起始y坐标让内容逐行移动。最简单的方式是设置一个滚动偏移量scroll_y每刷新一帧让偏移量加一行超过显示区域后清零。// 滚动显示示例代码 void menu_scroll_display(void) { static int scroll_y 0; unsigned int i; lcd_clear(0x0000); // 清屏再绘制避免残留 for (i 0; i 4; i) { // 每一行文本按滚动偏移量向上移动 lcd_draw_text(10, (i * 20) - scroll_y, menu_items[i], 0xFFFF); } scroll_y 2; // 每次移动2像素视觉上更平滑 if (scroll_y 20) { scroll_y 0; } }滚动显示的细节在于刷新方式。如果每次都全屏清空再重新绘制会有闪烁甚至撕裂感更好的做法是只更新变化区域也就是上一帧和下一帧不同的部分。不过对于8MHz主频的51来说全屏清空重画的耗时并不是完全不能接受关键看应用场景。如果是频繁刷新建议把文本行数控制在屏幕能显示的范围以内。6.3 做一个带状态栏的仪表盘界面基于这套例程的架构可以快速组合出一个仪表盘界面状态栏固定在最顶显示时间或信号状态内容区显示文字数据底部显示操作提示。状态栏用lcd_fill_rect填充底色再在底色上用白色画文本中间区域显示汉字的测量数值。这种组合完全不需要改底层驱动只需要在main.c里组织好布局。// main.c 中的界面布局示意 void update_dashboard_ui(unsigned int voltage) { lcd_clear(0x0000); // 顶部状态栏深蓝色背景白色文字 lcd_fill_rect(0, 0, 127, 16, 0x001F); lcd_draw_text(10, 2, 状态:运行, 0xFFFF); // 中部数据区显示汉字和数字 lcd_draw_text(20, 40, 电压:, 0x07E0); lcd_draw_number(60, 40, voltage, 0xFFFF); // 底部提示栏黄色背景黑色字 lcd_fill_rect(0, 140, 127, 159, 0xFFE0); lcd_draw_text(20, 144, 按K1确认, 0x0000); }lcd_draw_number需要自行实现本质上是把整数转换成十进制字符串再调用lcd_draw_text逐个显示。这里要注意数字字符串是ASCII字符走的显示宽度的分支不同用混排逻辑可以自然处理这个场景。界面布局确定后整个菜单逻辑的维护就变得很简单每次更新只需要改update_dashboard_ui里的内容和位置参数。做二次开发时建议先画一张坐标草图把128×160的屏幕划分好区域再码代码。我在项目里吃过亏直接在代码里调试布局效率很低烧录一次看一次效果太浪费时间。把坐标算好在纸上对齐代码一版成型这点时间花得很值。从那以后我每次拿到新的屏幕例程都会先用画点函数把边界格子点亮跑一遍确认坐标系方向和尺寸范围再去碰字模和驱动。希望帮到你。本文还有配套的精品资源点击获取