开年第一件事,就是折腾手里那块吃灰很久的野火MINI开发板。这次想做个小界面,要在LCD的文本框和按钮上显示中文,结果卡在了字库生成这一步。网上资料虽然多,但大多是只言片语,或者直接给你一个工具让你自己玩,参数设置错了也没人告诉你为什么。这篇文章就是把我这次踩坑、试错、最后跑通的全过程记录下来,从FontCvtST.exe怎么用,到生成的.c文件怎么和野火MINI无缝对接,一次讲透。
1. 项目整体设计与需求拆解
1.1 嵌入式LCD显示汉字的痛点
STM32这类MCU上的LCD显示,本质上做的是点阵填充——屏幕上的每个像素点,要么亮要么灭,或者用不同颜色亮度表示。英文和数字用8x16、6x12这类ASCII字库就能搞定,一个字符占几十个字节。但汉字就麻烦了,常用GB2312编码里面有6763个汉字,每个字至少16x16点阵,全量字库下来少说也要200多KB。野火MINI板上的Flash通常也就512KB到1MB级别,RAM更是紧张,根本塞不下。
所以实际项目里,我们通常只提取自己用到的几个汉字,做成一个精简字模数组,再烧进代码里。这种做法的核心工具就是字模提取软件——比如标题里提到的FontCvtST.exe。
我最初的想法是直接在屏幕上用串口或者按键输入汉字,然后显示出来。后来发现这条路走不通:汉字不能像ASCII那样直接从显示库里边查边画,必须预先做字模,然后让GUI库去调用。更麻烦的是,不同的GUI库(比如emWin、uCGUI,或者自己裸写的LCD驱动)对字模数据格式的要求还不一样。
多少次我在屏幕上看到一条条横线竖线,或者在文本框里打出来的字完全变成了乱码马赛克,最后发现不是硬件有问题,而是字模取模方向和LCD驱动扫描方向不一致。方向对了,整个世界就对了。
1.2 为什么选择FontCvtST.exe这个方案
FontCvtST.exe是野火官方配套的一个字体转换工具,它的全称是Font Convert Symbian Tool(严格说起来这是从Symbian时代流传下来的工具,但野火在ST系列板子上把它做成了标配)。它能把Windows系统字体(.ttf/.fon)里的指定字符,按照你设定的像素大小和取模方式,转换成C语言数组,生成一个可以直接放进Keil工程的.c文件。
选择它的理由有三个:
第一,操作门槛低。不需要装额外的仿真环境或者庞大字体工具链,双击运行,点几下就能出结果。
第二,生成格式灵活。它能直接输出标准C数组,你不需要自己写脚本去解析font文件,省掉了很多工作量。
第三,生态匹配。野火MINI的例程里本身就封装好了LCD驱动以及调用字模的接口,用这个工具生成的字库,配合野火的显示函数,几乎不用改代码就能跑起来。
当然,网上也有Image2Lcd、PCtoLCD2002这样的字模工具,它们也能用。但如果你手里的板子是野火,并且不想在驱动层做过多改动,FontCvtST.exe是最直接的一张牌。
2. FontCvtST.exe工具解析与字模生成实操
2.1 工具界面与核心参数详解
打开FontCvtST.exe,界面并不复杂:上方是字体选择、大小、笔画粗细等常规选项,中间有一个预览框,可以敲几个字符进去看看效果,左侧是生成字库时需要设置的一些格式参数。
字体这一栏没什么说的,选你喜欢的字体就行(楷体、宋体、黑体都行,但嵌入式屏幕上推荐黑体/无衬线字体,笔画清晰,小了也容易辨认)。真正关键的是下面这几个参数,这里单独拿出来说:
- 字体大小:通常用16、20、24、32。字体大小决定一个汉字在屏幕上占多少像素。16x16是最省空间的基本规格,适合列表文本;20或24适合按钮标题;32适合大标题或者需要突出显示的数字。
- 取模方式:逐行式(横向取模)和逐列式(纵向取模)。这个选项最坑,必须和你的LCD驱动扫描方式对齐。后面我会详细讲。
- 字节位序:高位在前还是低位在前,通常配合取模方式一起选择。
- 生成格式:这里选生成C源码(.c文件)而不是BIN文件,因为我要把它直接编进固件里。
- 字符集范围:如果只需要显示指定的几十个汉字,就自己敲进去;如果需要全量字库,就加载Unicode或者GB2312字库表,但那样生成的.c文件会很大,编译和烧录的时候要有思想准备。
2.2 生成.c文件的具体操作步骤
我这里以要做一个“温湿度传感器界面”为例,界面上有“当前温度:25.5°C”、“湿度:60%RH”这样的文本,还有两个按钮“刷新”、“返回”。至少需要以下字符:
当前温度:25.5°C 湿度:60%RH 刷新 返回注意半角冒号和全角冒号,半角数字、全角数字,这些都要提前规划好,不然生成字模的时候漏掉几个,后面调用时就会出现空格或者显示不出来的“空心字”。
具体操作如下:
第一步,打开FontCvtST.exe,从字体列表里选择黑体,大小选16。
第二步,在预览输入框里输入上面这一串字符,建议每行一个词,方便后续检查。
第三步,设置取模格式。这里我需要先说清楚一个原理:LCD屏在驱动时,像素是从左到右、从上到下逐点刷新,数据位通过并口或者SPI总线一位一位送过去。如果你的字模是按横向取模(即一行一行取点),而驱动代码按纵向扫描方式去逐位画点,那么画出来的字就是歪的、断的,甚至完全不可辨认。
野火MINI板上的LCD驱动(一般是ILI9341这类TFT屏)默认是按横向扫描的,所以我在这边取模方式选择“逐行式”。字节位序选择“高位在前”。然后点“生成”或者“转换”,软件会弹出一个对话框,选择保存成.c文件。
第四步,把刚才生成的文件用一个字模查看器工具打开(或者直接记事本打开),确认第一行前面有const unsigned char font_16[] = { 0x00, 0x18, ...};这样的结构,并且数组的大小符合预期。如果每个字的字模大小是32字节(16x16/8),那么5个字的数组就是160字节,大致可以估算有没有漏字。
2.3 字模C文件内部结构分析
用记事本打开生成的.c文件,你会看到类似这样的结构:
const unsigned char code font16x16[] = { /* 当 */ 0x00,0x00,0x00,0x00,0x40,0x00,0x40,0x00,0x20,0x00,0x20,0x00,... /* 前 */ 0x00,0x00,0x3F,0xFC,0x20,0x04,0x20,0x04,0x3F,0xFC,0x20,0x04,... };数组名可以自己改,注释里会标明每个字模对应的汉字。每一行有16个字节(对应16行,每行1字节),这样一个16x16汉字占32字节。这种结构,你可以直接把它包含进你的显示驱动模块。
如果你用的是野火自带的LCD驱动,你会发现它的底层画点函数一般是这样:
void LCD_Fast_DrawPoint(uint16_t x, uint16_t y, uint16_t color);有了这个函数,字模的本质就是告诉驱动:从(x,y)开始,在每个像素点上判断“这一位是1还是0”,是1就画前景色,是0就跳过(或者画背景色)。所以字模文件本身不依赖任何GUI库,甚至可以自己手写一个简单的显示函数去调用它。
这里顺便提一句,如果你是使用emWin这类GUI库,那么它要求字模数据符合它的字体结构格式(比如GUI_FONT_INFO),而不是简单的位图数组。碰到这种情况,要么用emWin自带的Font Converter工具,要么在生成的.c文件基础上再做一层封装。不过本文以野火的裸驱方案为主,emWin只作提及。
3. STM32野火MINI上的移植与显示实现
3.1 野火MINI开发板的LCD驱动基础
野火MINI通过FSMC总线或者SPI接口连接TFT-LCD。FSMC(灵活的静态存储控制器)方式性能高,可以把LCD当成一块外部存储器来读写,所以刷屏速度很快。初始化流程一般是这样:
- 开启FSMC时钟,配置GPIO复用功能;
- 设置FSMC控制器时序(地址建立时间、数据建立时间等,需要参考具体LCD控制器的数据手册);
- 配置LCD控制器(如ILI9341)的显示方向、色彩格式;
- 调用初始化序列,点亮背光。
这部分例程野火官方例程里写得很全,一般不需要大改。你只需要确认LCD屏上横、竖屏的扫描方向参数。在例子工程里,lcd_init.c或是ltdc.c文件里面有关于扫描方向的宏定义,比如:
#define USE_HORIZONTAL 1这个宏会决定画点函数中x、y坐标的映射关系,进而影响字模取模时横向还是纵向扫描。如果你发现字是倒着的、旋转的,先查这个宏和字模取模方向是否匹配,基本就能解决。
3.2 将字模.c文件接入Keil工程
把上一步生成的font16x16.c文件拷贝到工程目录下,然后在Keil里:
- 点“Add Existing Files to Group”,把该.c文件加入工程的“APP”或者“FONT”分组里;
- 在你自己的显示模块头文件里,使用extern声明这个数组:
extern const unsigned char font16x16[];; - 编写一个显示汉字的函数。
这里给一个实际能用的裸驱代码,核心思路是定位字模在数组中的偏移位置,然后逐字节逐位画点。
#define FONT_WIDTH 16 #define FONT_HEIGHT 16 #define FONT_BYTES (FONT_WIDTH * FONT_HEIGHT / 8) // 在(x, y)处以color颜色显示一个汉字ch void LCD_ShowChinese(uint16_t x, uint16_t y, uint16_t ch_index, uint16_t color, uint16_t bkcolor) { uint16_t i, j; uint8_t byte_val; const unsigned char *p = &font16x16[ch_index * FONT_BYTES]; for (i = 0; i < FONT_HEIGHT; i++) { byte_val = p[i]; // 这一行对应一个字节 for (j = 0; j < 8; j++) { if (byte_val & (0x80 >> j)) { LCD_Fast_DrawPoint(x + j, y + i, color); } else if (bkcolor != 0xFFFF) // 如果不需要透明背景,画背景色 { LCD_Fast_DrawPoint(x + j, y + i, bkcolor); } } } }注意这里有个细节:ch_index怎么算?如果你按照固定字符顺序生成字库,可以用一个映射表把汉字和下标对应起来。比如:
typedef struct { char *text; uint16_t index; } FontMap; const FontMap font_map[] = { {"当", 0}, {"前", 1}, {"温", 2}, {"度", 3}, {":", 4}, ... };调用时,遍历这个映射表,找到对应的索引,再传入LCD_ShowChinese。实际项目里我一般会把字符串解析和字模索引查询封装在一起,这样上层传进来的就是普通字符串,不用自己手动计算下标。
3.3 文本框场景的汉字渲染
文本框这个概念,在界面上一般是指一个方框区域,里面显示一段文字,经常需要和背景色分开。比如我做的“当前温度:25.5°C”,文本框底色是深蓝色,字是白色。
文本框的渲染分成三块:边框、背景、文本。
边框就是画四条线,这个用LCD驱动里面现成的画线函数LCD_DrawLine即可。背景填充就是用一个颜色填满区域,通常用LCD_Fill(x1, y1, x2, y2, bkcolor)。
重点是文本部分。文本框内的字符串,可能是英文数字汉字混合的。所以显示函数需要区分ASCII字符和汉字字符:
- 如果是ASCII字符(比如数字、小数点、字母C),用自带的8x16或者6x12点阵字模;
- 如果是汉字字符(GB2312的两个字节),查
font_map映射表,再用上面写的LCD_ShowChinese函数显示。
一个简化版的实现思路:
void LCD_ShowString_TextBox(uint16_t x, uint16_t y, char *str, uint16_t fontColor, uint16_t bkColor) { while (*str) { uint8_t ch = (uint8_t)*str; if (ch < 0x80) { LCD_ShowChar(x, y, ch, fontColor, bkColor); x += 8; // ASCII一个字符占8像素宽 str++; } else { // 汉字编码两个字节,先从映射表找索引 uint16_t index = FindFontIndex((uint8_t*)str); LCD_ShowChinese(x, y, index, fontColor, bkColor); x += 16; // 16x16字体 str += 2; } } }这样处理后,文本框里的内容可以是混合文本,比如“T:25.5°C”这种。有一点要留意:如果文本框宽度不够,字串会显示到框外面去。建议在渲染前先用strlen估算字符串宽度,再决定字体要不要缩小,或者直接把字符串裁成两行。
3.4 按钮场景的汉字渲染
按钮在界面上的表现为一个矩形区域,有背景色、边框、文字。按下和松开时,背景色会反转,或者文字颜色改变。这个场景下的汉字渲染,和文本框本质是一样,只是加了交互反馈逻辑。
具体来说,按钮的汉字渲染要注意两点:
第一,字的水平垂直居中。不能简单地按左上角坐标开始画。需要先计算按钮区域的中心点,再减去汉字串的总宽度和总高度的一半,得到起始坐标。对于一组汉字,总宽度 = 汉字个数 x 16。举例:按钮宽80像素,高40像素,上面要显示“刷新”两个字,两个16x16汉字总宽32像素,那么起始x坐标 = 按钮x + (80 - 32)/2 = 按钮x + 24;起始y坐标 = 按钮y + (40 - 16)/2 = 按钮y + 12。
第二,按下效果。主要是交换前景色和背景色,或者让字的颜色变暗。这个切换在触摸响应函数里做。触摸按下时,用一个变量记录按钮状态,并调用LCD_ShowChinese重新绘制汉字。松开时再恢复常态颜色。这里要用到LCD快速画点函数,动作要快,不然会有“残影”或者闪烁感。
按钮按下态的伪代码:
void Button_DrawPressed(Btn_TypeDef *btn) { // 1. 填充按下后的背景色 LCD_Fill(btn->x, btn->y, btn->x + btn->width, btn->y + btn->height, BTN_PRESS_BG); // 2. 居中绘制汉字 uint16_t start_x = btn->x + (btn->width - btn->text_width) / 2; uint16_t start_y = btn->y + (btn->height - FONT_HEIGHT) / 2; LCD_ShowString_TextBox(start_x, start_y, btn->text, BTN_PRESS_FG, BTN_PRESS_BG); }按钮状态切换的关键在于text_width要提前算好。如果按钮字串是固定的,“刷新”、“返回”,那直接写死就行;如果是动态生成的,比如“确认”变“取消”,要在切换前重新计算。
4. 常见问题与排查技巧实录
4.1 取模方式与LCD驱动不匹配导致乱码
这是我这次遇到的最头大的问题。第一次生成字库时,我图省事选了“逐列式”,结果在屏幕上显示出来的汉字像是被揉碎了一样,完全认不出来。后来对照LCD的扫描顺序才发现,ILI9341在横向显示模式下是一行一行刷的,而逐列取模先取的是第一列8个点、第二列8个点……两者方向不匹配,画出来的点全乱了。
解决办法很简单:把取模方式改成“逐行式,高位在前”。但如果你用的是纵向屏(竖屏显示),也可能需要反过来。关键是记住这条规律:
LCD一行一行刷,就用逐行取模;LCD一列一列刷,就用逐列取模。改了LCD扫描方向,字库也要重新生成,别偷懒。
4.2 Keil工程中.c文件编码格式问题
有一个极其隐蔽的坑,就是FontCvtST.exe生成的.c文件默认可能是GB2312编码,而Keil工程里其他文件用的是UTF-8。当你用const char *str = "当前温度";这种字符串,然后直接去和字模索引映射表做匹配时,如果编码不对,汉字字符串在内存里的字节序就不一样,查映射表就会卡死。
我的排查方法是:在Keil里编译,在调试模式下查看str指向的那几个字节是不是正常汉字编码,再对照映射表。如果发现字模表里用的索引对不上,两个办法:
一是在FontCvtST.exe生成字符列表时,尽量用同样的编码方式(UTF-8 vs GBK)和你的源码保持一致;
二是干脆不依赖字符串匹配,直接用Unicode编码值做索引。比如用L"刷新"这种宽字符串,提前在工程里指定好字符编码为UTF-8,这样字模表也按UTF-8生成,两边都是同一种字节流,就不会有匹配不上的问题。
实际上我现在更推荐第二种做法:把界面字符串集中放在一个头文件里,用宏定义或者枚举常量来管理索引,而不是写死“刷新”两个中文字符在代码里到处引用。这样编码和安全都可控。
4.3 内存占用过大或生成不完全
我试过把整个GB2312常用汉字表(大概3000多字)全部生成一个.c文件,16x16编码下每个字32字节,算下来也要100KB左右。野火MINI板能放下,但编译时间明显变长,下载到MCU里也要等更久。而且如果Flash空间不足,Keil会直接报Area 'EROM' can't fit之类的错误。
更理性的做法是只生成界面用到的汉字。我第一次做温湿度界面时,就是把所有可能出现的字段先列出来,比如“温度”、“湿度”、“正常”、“异常”、“刷新”、“返回”、“历史”、“记录”等,再去生成字模。这样生成的.c文件可能就几十个字节到几百个字节,完全无压力。
但如果你的产品需要支持任意输入,就必须用字库芯片(如GT21L16S2W)或者外部Flash存储全字库,启动时加载到外部RAM的映射区域,这个就不属于本次讨论范围了。至少要形成这种认知:MCU里不适合装全字库,按需取字才合理。
4.4 字体太小看不清的优化方案
16x16的字在128x160这种小屏上还可以,但在320x240的屏上看就觉得小。这个时候有几种优化思路:
第一种,换更大字号的字模。重新在FontCvtST.exe里选24或者32号字体,生成的字体数组相应变大。好处是省心、效果直观,代价是RAM和Flash占用增加。我的做法是界面里面大标题用24x24,内容文本用16x16,按钮用20x20,这样既突出重点,又不至于每个角落都是大黑字。
第二种,微软雅黑这类字体虽然有更好的抗锯齿效果,但MCU上直接生成灰度字模比较麻烦,因为每个像素不仅要有“亮灭”信息,还要有灰度级别(比如4bit、8bit),数组体积直接以倍数增长。除非你的LCD屏本身支持RGB565颜色且RAM足够,不然还是老老实实用单色字模,靠前景色和背景色搭配来做层次。
第三种,用颜色来区分优先级,而不是单纯靠字号。这一点在文本框场景里特别好用:正常温度用白色,超过阈值用红色闪烁,提示信息用黄色。用户扫一眼就能看到重点,根本不用凑近看字大小。
4.5 触摸按钮响应与字体刷新时序问题
野火MINI板上的触摸方案常见有两种:电阻屏(XPT2046)和电容屏(GT9147之类)。不管哪种,触摸扫描是一个中断或者轮询任务。如果在触摸响应回调里直接刷新汉字,频繁点击时可能出现“闪屏”或者“刷新未完成又收到新触摸”的竞态情况。
我自己的习惯是:触摸扫描只维护一个事件标志,在主循环里统一去绘制按钮按下态和汉字。这样即使触摸中断频率再高,显示刷新也不会被打断,画面稳定很多。代码结构大致是:
while (1) { if (touch_event_flag) { touch_event_flag = 0; uint16_t x = get_touch_x(); uint16_t y = get_touch_y(); if (is_in_button(&btn_refresh, x, y)) { Button_DrawPressed(&btn_refresh); delay_ms(100); // 按键消抖,防止误触发 Button_DrawReleased(&btn_refresh); // 执行刷新逻辑 } } }这里的delay_ms只是演示,实际项目里建议用定时器或者状态机来避免阻塞。核心思想是:界面刷新和触摸事件要在同一个线程模型下协调,而不是在中断里做重活。
5. 一点实操心得
FontCvtST.exe其实只是一个“取字模”的工具,真正决定汉字显示效果的,是你对LCD扫描方式的理解和字模数据格式的掌握。这次在野火MINI上跑通之后,我最大的收获不是“会生成font文件了”,而是明白了一个道理:字模方向、编码格式、渲染时机这些事情,在前期如果规划好,后面基本不会出幺蛾子。
我个人的建议是,第一次做这个功能时,不要一上来就追求完美界面。先在屏幕上显示一个16x16的单个汉字,确认取模方向正确、编码匹配。然后扩展到一行字符串,最后再做文本框和按钮交互。每一步都验证通过,再继续叠功能。这样排查起来,你永远知道自己错在哪一步。
另外一个提升效率的小技巧:把字模.c文件和普通业务代码分开目录管理,命名规则统一(比如font_h16.c,表示16x16高度的字体)。这样你的工程结构清晰,后续要换字体大小或者加字,直接重新生成文件替换即可,不会影响别的代码逻辑。
如果你手里也有野火MINI板,而且卡在汉字显示这一关,希望这篇记录能帮你少走点弯路。写完这段字,我去改一版24号字模,把按钮标题做大一点,给家里老人用起来更方便。