
玩嵌入式的朋友应该都遇到过这个尴尬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的宽度那显示出来会不太整齐。稳妥的做法是统一用16x16FontCvtST在生成时会把汉字自动居中放在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声明不需要重新跑一遍取模工具开发效率能提升不少。这个事儿看着琐碎实际项目里越到后面越值钱。