玩嵌入式的朋友应该都遇到过这个尴尬:LCD上英文数字显示得好好的,一到中文就集体“摆烂”,屏幕上该显示“温度”的地方变成一堆方块和乱码。网上搜了一圈,有人让你用取模软件,有人让你移植字库,但对于只想在STM32野火MINI开发板上跑个简单界面、让文本框和按钮显示几个汉字的人来说,这些方案要么太重、要么讲得云里雾里。我最早处理这个需求的时候也被卡了一整天,后来总算把整套流程跑通了:用野火资料包自带的FontCvtST.exe把汉字转成.c文件,再在工程里写一个专门的中文显示函数,最后让汉字稳稳地显示在文本框和按钮上。这条路不算最优解,但胜在简单直接、容易复现,非常适合想快速出效果又不想把整个GUI框架搬进来的场景。
这篇文章我就把完整的操作链路拆开讲一遍,包括字体取模工具怎么设置参数、生成的.c文件怎么接入Keil工程、中文显示函数怎么写,还有最容易踩的几个坑。视频和网上帖子基本只告诉你“点一下生成就行了”,真正关键的参数含义和匹配逻辑往往一笔带过,而这恰恰是大家反复翻车的根源。
1. 项目整体设计与思路拆解
1.1 汉字显示的本质:点阵数据是怎么变成屏幕上的字的
先搞清楚这个项目到底在解决什么问题。STM32芯片本身不认识汉字,LCD屏幕也不认识,屏幕只知道“某个坐标的像素点是什么颜色”。那汉字怎么显示?本质上就是先把汉字翻译成一张点阵图,把这张点阵图转换成一串二进制的数字,存到C语言的数组里。显示的时候,程序逐个读取这串数字的每一位,是1就在对应坐标画一个点,是0就跳过,一串点画完,一个汉字就出现了。
这就像十字绣。图纸上标的每个格子是黑的还是白的,对应到屏幕上就是“这个像素亮还是不亮”。每个汉字对应一张方格图,分辨率就是像素大小。常见的有12x12、16x16、24x24等,16x16就是横向16个点、纵向16个点,识别度比较高,对存储空间和单片机处理能力也比较友好,是野火MINI这种入门级开发板上最常见的规格。16x16的一个汉字,每个点用1个bit表示,总共256个点,就是32个字节。如果你要在界面上显示几十个汉字,数据量也不大,完全可以全部烧进Flash里。
明白这个原理之后,整个项目就分成三大块:取模生成数据、把数据写进工程、写代码把数据画出来。下面我按这个顺序一步步来。
1.2 为什么用FontCvtST.exe而不是其他取模工具
字体取模的工具其实一大堆,什么PCtoLCD2002、Img2Lcd、FontGen等等,功能都差不多。但为什么我推荐用FontCvtST.exe?因为它是野火开发板资料包里自带的工具,和野火MINI的LCD驱动库配套性最好。我实测过几款第三方取模软件生成的取模格式,有的默认是逐列式,有的默认是字节倒序,拿到野火的LCD驱动里一显示就是花屏、镜像,排查起来非常折腾。FontCvtST生成的格式和野火常用的LCD底层显示逻辑一致,基本上生成完直接就能用。
另外这个工具支持把多个汉字一次性生成到一个.c文件里,每个汉字对应一个常量数组,还会自动生成对应的索引信息,不用自己手工整理每一个字的偏移量。如果你只是显示固定的一两屏文字,这种方案比移植一套完整的中文字库省事得多。一个中文字库里几千个字模的数据,动辄几百KB起步,对FLASH有限的MINI板来说完全没必要,除非你要做一个文本阅读器之类的应用。
1.3 功能边界和程序结构规划
在写代码之前,我先把需求在纸上拆了一下。界面大概包含两部分:一个文本框区域,用来显示状态信息;一个按钮区域,用来响应按键操作。技术上需要处理的事情有四件:第一,用FontCvtST生成包含所有需要汉字的.c文件;第二,把这个.c文件加入Keil工程,并且把字模数据声明到外部可访问;第三,在LCD驱动之上封装一个“在指定坐标显示指定汉字”的函数;第四,在文本框和按钮的绘制代码里调用这个函数完成显示。
千万别跳过规划直接上手写代码,也别等把所有代码写完了才去生成字模。我的建议是先列出界面里所有要显示的汉字,比如状态信息可能需要“温度”“湿度”“正常”“异常”,按钮上需要“开始”“停止”“确认”“返回”,全部汇总成一个字表,一次性生成,不然后续加字就要重新再走一遍完整流程,很烦。
2. FontCvtST.exe 取模实操:参数怎么选才不出乱码
2.1 工具界面和生成流程走一遍
FontCvtST.exe在野火MINI的资料包路径一般是“野火资料/软件资料/字模软件/”或者“野火产品资料/配套软件/”下面,具体路径不同版本的资料包略有差异,直接搜索文件名就能找到。这个工具是Windows下的绿色EXE,不需要安装,双击就能打开,可能是繁体或者简体的老式界面,但基本功能一目了然。
具体操作步骤我习惯这样做:
- 在“字体”下拉框选择一个中文字体,比如宋体、黑体、微软雅黑。这一步决定了字模的笔画风格。
- 在“大小”或者“点阵大小”栏输入16,也就是要生成16x16的点阵字模。
- 在中间的文本输入框里输入你需要显示的汉字,注意一次把所有需要的字都输入进去,后面再补会很麻烦。
- 检查取模方式和数据格式的选项。不同版本界面文字可能不一样,但只要看到“横向取模”“纵向取模”“字节正序”“字节反序”之类选项,就按我下面讲的规则去选。
- 点击“生成”或者“导出”,保存成一个.c文件,比如font_chinese.c。
有一点需要注意,工具生成的.c文件编码通常是ANSI/GB2312,而你在Keil里新建源文件时默认可能是UTF-8。如果编码不一致,编译的时候经常报“input file contains invalid character”之类的警告,或者生成的数组注释里的汉字直接变成乱码。我后来的做法是生成后先用记事本打开确认一下内容,如果Keil报编码问题,就另存为ANSI编码再放进工程。
2.2 取模方式:横向取模还是纵向取模
关于取模方式,很多教程只说“随便选一种,代码里对应就行”,但这句话坑了不少人。实际原因是:取模方式必须和显示函数里读取数据的方式完全一致。如果工具按“横向取模”生成数据,你的显示函数就得一行一行地画;如果按“纵向取模”生成,函数就得一列一列地画。两者不匹配,屏幕上就会出现“雪花”一样的乱点。
以FontCvtST为例,我推荐选“横向取模,逐行提取,高位在前”,也就是先取点阵第0行的前8个点,低bit在前或者高bit在前,在工具里会有对应选项。为什么推荐这个?因为野火LCD驱动库里已经有现成的画点函数ILI9341_DrawPoint,底层逻辑是按屏幕坐标逐点绘制,我只需要把字模数据按行拆解成字节,再逐位判断即可,代码写起来最直观,调试也方便。
“高位在前”和“低位在前”的区别,简单说就是:一行8个点对应一个字节,如果你把这8个点从左边起命名为b7到b0(高位到低位),那么“高位在前”就是把b7塞进字节的第7位,“低位在前”就是把b0塞进最高位。选择“高位在前”更符合人类从左往右的阅读习惯,后面写画点函数的时候思路也更顺。当然你用“低位在前”也行,但画点时就要注意位移的方向,稍微绕一点。
2.3 生成出来的.c文件到底长什么样
生成好的.c文件打开之后大致是这样一个结构:
// Font: 宋体, Size: 16, 横向取模, 高位在前 // 汉字: 温 度 开 始 const unsigned char code_Font_温度[] = { 0x04, 0x44, 0x04, 0x44, ... // 32个字节 }; const unsigned char code_Font_度[] = { 0x00, 0x00, 0x7F, 0xFC, ... // 32个字节 };有的版本会把这些数组放到一个统一的数组里,再用偏移量索引,这个没有统一标准,只要你能看懂哪个数组对应哪个汉字就行。拿到这个文件之后,我建议先做一个动作:打开它,数一下每个数组是不是确实是32个字节(16x16)。如果工具生成的不是32个字节而是别的数量,说明点阵设置和预想的有出入,需要在工具里重新设置点阵大小。
还有一个细节容易被忽略:生成的.c文件顶部往往没有包含头文件的语句,数组定义都是裸的const数组。放到Keil工程后,编译可能会因为“未使用”产生告警,但不影响功能。如果你实在看着不舒服,可以在文件里补一个空头文件包含。
3. 把字模接进Keil工程:工程配置和显示函数封装
3.1 文件怎么加进工程才不出问题
字模.c文件生成之后,下一步就是把它集成到Keil工程里。野火MINI的官方例程通常已经给你搭好了LCD的底层驱动,你只需要做加法,不需要动底层。
第一步,把生成的font_chinese.c文件复制到工程的“Hardware”或者“User”目录下。第二步,在Keil左边Project栏里右键对应的组,选择“Add Existing Files to Group”,把这个.c文件加进来。这里有个小坑:如果你直接把文件复制进文件夹但没有在Keil工程里添加引用,编译器根本不会编译这个文件,链接的时候就会出现找不到符号的错误。别问我怎么知道的,这个错误我犯过不止一次。
建议同时在工程里新建一个font_chinese.h文件,里面写清楚每个汉字的数组声明:
#ifndef __FONT_CHINESE_H #define __FONT_CHINESE_H extern const unsigned char code_Font_温度[]; extern const unsigned char code_Font_度[]; extern const unsigned char code_Font_开[]; extern const unsigned char code_Font_始[]; #define FONT_WIDTH 16 #define FONT_HEIGHT 16 #endif这样在显示文件里只需要包含这个头文件,就可以直接用这些字模数组了。不建.h直接写extern声明也能跑,但工程一复杂、汉字一多,头文件管理起来会省心很多。
3.2 按行扫描的中文显示函数怎么写
现在核心来了:用这些数组把汉字画到屏幕上。我在野火LCD驱动的基础上写了一个最基础的中文显示函数:
void LCD_ShowChinese(uint16_t x, uint16_t y, const uint8_t *fontData, uint16_t color, uint16_t bkColor) { uint16_t i, j; uint8_t byteData; uint16_t pixelX, pixelY; for (i = 0; i < FONT_HEIGHT; i++) { for (j = 0; j < FONT_WIDTH / 8; j++) { byteData = fontData[i * (FONT_WIDTH / 8) + j]; for (uint8_t k = 0; k < 8; k++) { pixelX = x + j * 8 + k; pixelY = y + i; if (byteData & (0x80 >> k)) { LCD_DrawPoint(pixelX, pixelY, color); } else { LCD_DrawPoint(pixelX, pixelY, bkColor); } } } } }这个函数的逻辑就是先按行遍历,每行有FONT_WIDTH/8个字节(16x16的字体就是2个字节),然后逐位判断。注意最后那个else分支:字模里为0的位置用背景色画点,这有两个作用,一是把文字区域里不该显示的部分覆盖成背景色,二是如果这个区域之前画过其他内容,能顺便清掉,做到干净的矩形显示效果。
如果你用16x16的字模但实际字体里有些汉字只有12x12的宽度,那显示出来会不太整齐。稳妥的做法是统一用16x16,FontCvtST在生成时会把汉字自动居中放在16x16的格子内,这样所有汉字宽度一致,界面排版不会忽宽忽窄。
显示英文和数字还是走原来的LCD_ShowString这类函数,数字和字母本身就是ASCII字符,对应的英文字模一般已经在驱动库里内置了。中英文混排时只要在关键坐标上做换算就行,一个英文数字字符占用8x16的网格,一个汉字占用16x16的网格,两个英文字符正好等于一个汉字的宽度,排起来很顺。
3.3 多汉字连续显示的坐标推进
单独显示一个汉字很简单,但界面上要显示一整句话,比如“温度正常”,就得连续显示四个汉字。这时候最重要的事情是维护好坐标,每显示完一个汉字,x坐标就要向右移动FONT_WIDTH个像素。
我封装了一个直接传字符串就能显示的函数,内部做一个简单的映射表:
typedef struct { char *name; // 汉字对应的字符,比如 "温" const uint8_t *font; // 对应的字模数组 } FontMap; static const FontMap fontMapTable[] = { {"温", code_Font_温度}, {"度", code_Font_度}, {"开", code_Font_开}, {"始", code_Font_始}, }; void LCD_ShowChineseString(uint16_t x, uint16_t y, char *str, uint16_t color, uint16_t bkColor) { while (*str != '\0') { for (uint8_t i = 0; i < sizeof(fontMapTable) / sizeof(fontMapTable[0]); i++) { if (strncmp(str, fontMapTable[i].name, 2) == 0) { LCD_ShowChinese(x, y, fontMapTable[i].font, color, bkColor); break; } } x += FONT_WIDTH; str += 2; // 一个汉字在GB2312里占2个字节 } }这里strncmp比较2个字节,是因为汉字在C语言字符串里以GB2312或者说双字节编码存在。如果你的工程编译环境把字符串当成UTF-8处理,一个汉字占3个字节,那这个函数就需要调整。Keil默认对不含特殊字符的常量字符串通常按ANSI处理,所以2字节的判断在大多数情况下是可行的,但如果发现匹配不上,优先检查一下工程的编码设置。
对比查找表的方式确实土,但适合字库量少的场景。如果你要显示几十上百个汉字,循环逐一比较效率偏低,那可以改成用每个汉字GB2312编码的区位码做索引,直接通过偏移量查数组。我在实际项目里一般控制在十几个字以内,查找表的方案完全够用,而且扩展方便,新增汉字只需要往表里加一行。
4. 文本框和按钮上显示汉字的制作细节
4.1 文本框的绘制和汉字排版
我们要做一个像样的文本框界面,不只是一个裸的汉字。我习惯先画一个矩形框,然后在矩形内部显示汉字。野火的LCD驱动库里提供了画矩形的底层函数,画空心矩形一般用LCD_DrawRectangle,画实心矩形用LCD_Fill。文本框通常用空心矩形加背景填充的组合,看起来像一个凹陷的输入区域。
假设文本框的左上角坐标是(30, 50),宽度是140,高度是30。要在里面显示“温度”两个字,先计算文字的起始坐标。16x16的两个汉字总宽度是32px,文本框宽度是140,那么水平方向居中时左边距是(140 - 32) / 2 = 54px,所以文字起始x坐标就是30 + 54 = 84。垂直方向同理,文本框高度30,文字高度16,上边距是(30 - 16) / 2 = 7px,直线起始y坐标就是50 + 7 = 57。
LCD_Fill(30, 50, 30 + 140, 50 + 30, BACKGROUND_COLOR); LCD_DrawRectangle(30, 50, 30 + 140, 50 + 30, FRAME_COLOR); LCD_ShowChineseString(84, 57, "温度", FOREGROUND_COLOR, BACKGROUND_COLOR);看着简单,但坐标计算这里有一个很容易出错的地方:你画文字的时候,LCD_ShowChineseString是连背景一起画的,如果先调用LCD_Fill再调用LCD_ShowChineseString,文字的背景色会覆盖掉之前填充的颜色,这是没问题的。反过来如果先写字再填充矩形,字就会被矩形覆盖掉。顺序不能搞反。
4.2 按钮的两种状态:按下和松开
按钮比文本框稍微复杂一点,因为按钮需要响应按键事件,表现出“按下了”和“松开了”两种不同状态。我一般用实心矩形的颜色变化来表示状态:按下时背景色加深,松开时恢复原色。这个方案不占用额外资源,也不影响中文字模,只是把绘制函数里的颜色参数换成不同的值而已。
比如一个“开始”按钮,正常状态是蓝色背景、白色文字,按下状态变成深蓝色背景、白色文字。绘制顺序是:先填充矩形背景,再画矩形边框,然后居中显示汉字“开始”。“开始”两个字的宽度是32px,按钮宽度是100px,起始x坐标就是按钮x + (100 - 32) / 2 加上按钮左边距,垂直居中类似。
按键扫描周期通常20ms一个循环,每次检测到按键状态变化后标记一个“需要重绘”的标志位,然后调用按钮绘制函数。关键点是在重绘时先做一次全矩形LCD_Fill清背景,再画边框、写字,否则快速抖动时文字会留下一堆“残影”。你可以在实际测试中发现,不清背景直接写字的后果就是文字边缘就像複印机卡纸一样叠在一起,难看得很。
4.3 把两块内容组织到主循环里
野火MINI的LCD例程通常已经在主循环里做了按键扫描和屏幕刷新。我们只需要在原来的框架上增加两个绘制函数,一个叫DrawTextBox,一个叫DrawButton,在主循环的合适位置调用即可。
void DrawTextBox(void) { LCD_Fill(30, 50, 170, 80, WHITE); LCD_DrawRectangle(30, 50, 170, 80, BLUE); LCD_ShowChineseString(84, 57, "温度", BLUE, WHITE); } void DrawButton(uint8_t state) { uint16_t bgColor = (state == KEY_DOWN) ? DARK_BLUE : BLUE; LCD_Fill(120, 110, 220, 140, bgColor); LCD_DrawRectangle(120, 110, 220, 140, WHITE); LCD_ShowChineseString(148, 117, "开始", WHITE, bgColor); }主循环里检测到按键事件后调用DrawButton(KEY_DOWN),延时100ms左右松手检测,再调用DrawButton(KEY_UP)。这种写法不涉及任何操作系统和复杂GUI框架,就是最朴素的裸机绘制,但效果已经很接近一个正式的交互界面了。
我实际做的时候遇到过一个有趣的问题:按键消抖和重绘逻辑冲突,按键按下的瞬间明明调用了DrawButton,但屏幕上按钮没任何变化。排查半天发现是因为按键扫描里有一个while等待松手的死循环,重绘确实执行了,但紧接着又被下一次按键扫描打断,导致颜色被覆盖回去。后来改成状态机方式,每次循环只检测事件、标记状态、统一重绘,问题就解了。这里也顺带提醒一下,做单片机界面交互,最忌讳在绘制过程中等待,凡是需要延时的逻辑都放在状态机里处理才稳定。
5. 常见问题与排查技巧实录
5.1 显示花屏、乱码,字像雪花一样
这种情况九成是取模方式和显示函数不匹配。工具生成的是纵向取模,代码里却用横向方式去读;或者工具是低位在前,代码却是高位判断。排查方法很简单:打开生成的.c文件,把第一个字节自己换算成二进制,然后对照这个汉字左上角的点阵,看第一个字节的最高位如果按照“从左到右”的规则应该对应格子里的哪个位置。如果完全对不上,就改工具的取模方式或者显示函数的解析方向,二选一做匹配。
5.2 显示出来的是镜像字
镜像字说明字节的位序反了。高位在前和低位在前选反了就会这样。解决办法是把显示函数里的判断从0x80 >> k改成0x01 << k,或者在工具里切换位序选项。镜像问题不影响坐标位置,只是文字整体左右颠倒,识别度比较高。
5.3 Keil编译报错或编译后字模数组变成空白
这个坑我印象特别深。工具生成的文件保存编码是GB2312,你用Source Insight或Visual Studio Code打开再保存一下,很可能被转成UTF-8。UTF-8编码的汉字在Keil老版本里会被拆成3个字节,数组注释乱码不说,部分编译器还会把多字节字符报警告。我处理的办法是生成完字模文件后用文本编辑器查看右下角编码格式,如果显示UTF-8,就“另存为”选择ANSI编码保存,再进Keil编译。新版本的Keil和ARMCC虽然对UTF-8支持好了很多,但为了稳我还是习惯统一ANSI。
5.4 汉字显示位置偏了或者叠字
这个问题主要是坐标推进没算对。仔细检查一下:如果是通过LCD_ShowChineseString循环显示,每显示完一个字x坐标要加16;如果直接手动写多个LCD_ShowChinese调用,就要保证每个x偏移量都是16的整数倍。另外确认你的字模是不是每个都按16x16生成的,如果工具在生成时对某些生僻字自动用了更宽的规格,那宽度就不统一了,排出来的文字自然对不齐。
5.5 按钮文字显示后被框线覆盖
绘制顺序问题。有些LCD驱动画矩形框的函数是先填充内部再画四条边,有些则是直接完整重绘整个矩形,如果你把LCD_DrawRectangle放在LCD_ShowChineseString后面执行,框线就会从文字上压过去。解决办法是严格按照“填充背景 -> 画边框 -> 显示文字”的顺序执行,不要偷懒改顺序。
5.6 单片机Flash不够用,字模放不下
一个16x16汉字占32字节,其实很小。但如果手误把工具里的“字体大小”选成了32甚至48,一个汉字就要占128字节或288字节,界面上几十个字就是好几KB,对MINI板这种小Flash芯片来说压力就出来了。确认一下你的每个字模数组大小是否符合预期,另外记得把数组定义加重const修饰符,让它存到Flash而不是RAM。如果不加const,数组会默认拷贝到RAM里,野火MINI的RAM本来就不大,很容易出现硬件错误HardFault。
我在项目里就犯过这个错,显示三个汉字,RAM直接爆了,程序跑飞。加const之后Flash存储完全没压力,运行也稳定了。
5.7 工具生成的文件路径中带中文导致编译失败
这个比较玄学,但实测就是会发生。如果你把字模.c文件放在一个包含中文名或者空格的文件夹里,个别版本的Keil会报“cannot open source file”或者莫名的头文件错误。解决办法是把文件路径全部改成英文,我是喜欢建一个Hardware目录专门放LCD驱动和字模文件,统一用英文命名,可靠性最高。
6. 从显示汉字到做一个像样的交互界面
字模显示这件事本身不难,但它是一层底层能力。有了这个基础,你就可以在野火MINI上轻松做出各种带中文标签的界面,比如状态监控面板、菜单选项、参数设置页面。我做完这一套后,又把界面分成了两屏,一屏显示传感器状态,一屏显示控制按钮,用一个按键切换页面,稳定性很好,整套代码没有一个动态内存分配,跑起来非常可控。
给刚上手的朋友一个建议:不要一上来就去学复杂GUI框架,先把裸机LCD绘制、字模显示、按键状态机这三件事练熟,之后再去接触图形库,理解成本会低非常多。我在实际带项目新人的时候也发现,能独立写出中式按钮重绘逻辑的人,后面写复杂界面基本都不会太吃力。
最后再分享一个省事小技巧:FontCvtST生成的.c文件不要每次重新生成,尽量保持一份稳定的“字库文件”,所有界面共用的汉字都在里面维护。这样你做第二屏、第三屏时,只需要在头文件里补几个extern声明,不需要重新跑一遍取模工具,开发效率能提升不少。这个事儿看着琐碎,实际项目里越到后面越值钱。