简介:一份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字符串的指针,注意是双字节的,不是单字节ASCII;color是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的屏幕划分好区域,再码代码。我在项目里吃过亏,直接在代码里调试布局效率很低,烧录一次看一次效果太浪费时间。把坐标算好在纸上对齐,代码一版成型,这点时间花得很值。从那以后,我每次拿到新的屏幕例程,都会先用画点函数把边界格子点亮跑一遍,确认坐标系方向和尺寸范围,再去碰字模和驱动。希望帮到你。
本文还有配套的精品资源,点击获取