1. 项目背景与痛点为什么我要自己动手写字模提取工具搞嵌入式开发的朋友尤其是玩单片机驱动点阵LCD、OLED屏的肯定都遇到过“显示汉字”这个不大不小的坎。市面上的LCD驱动芯片为了控制成本和封装尺寸绝大多数都不内置中文字库。这就意味着你想在屏幕上显示一个“你好”不能简单地发送“你好”这两个字符的编码而是必须先把这两个汉字对应的“图像”——也就是字模数据——准备好再按照屏幕特定的扫描顺序取模方式发送过去。早些年网络资源还没现在这么丰富找工具是个麻烦事。现在好了一搜一大把从免费到收费从命令行工具到带漂亮界面的软件应有尽有。我最初也是图省事直接在网上找了个评价还不错的免费工具用。一开始相安无事直到某天我需要为一个新项目生成一批字模生成的代码烧录进去后屏幕上所有的汉字中间都诡异地出现了一条细细的“黑线”把字从中间劈开了。调试了半天排除了硬件和驱动代码的问题最后才怀疑到字模数据本身。回头仔细检查工具生成的数组发现数据规律不对再一看软件界面角落里多了一行小字“试用期已过部分功能受限”合着这是收费软件的“催款通知”啊——不付费就给你生成有问题的数据这操作真是让人哭笑不得。这种被“卡脖子”的感觉非常糟糕。项目进度卡在这里临时找其他工具又要重新适应和验证。我意识到依赖一个可能随时“罢工”或者“使坏”的第三方工具在关键项目里是巨大的风险。作为一个有十多年经验的嵌入式老鸟我决定不再把这种基础能力寄托在别人身上。不就是从字库里把字“抠”出来嘛原理能有多复杂自己动手丰衣足食。于是我花了一些时间研究了字模提取的核心原理并用VB当时手头最方便的原型工具写了一个自用的小工具。虽然它功能单一只实现了一种取模方式代码也不够优雅但胜在完全可控、没有后门。今天我就把这个过程中的核心原理、算法步骤和关键代码实现掰开了揉碎了分享给大家。即使你以后不用VB理解了这套逻辑用C、Python、甚至Excel都能自己实现。2. 字模提取的核心原理汉字如何在计算机中“画”出来在深入代码之前我们必须搞清楚几个基本概念什么是字模标准字库如HZK16是如何组织的单片机需要的字模数据又是什么格式2.1 字模的本质二值化位图所谓字模你可以把它理解为一个汉字的“微型黑白照片”。对于常见的16x16点阵汉字这个“照片”就是16像素宽、16像素高的网格。每个格子像素只有两种状态亮1表示这个点要显示或者灭0表示这个点不显示。那么一个16x16的汉字就需要 16 * 16 256 个比特bit的信息来存储。在计算机中8个比特为一个字节Byte所以一个这样的汉字字模恰好需要 256 / 8 32 个字节来完整描述。这32个字节的排列顺序就是“取模方式”。它决定了这256个点是如何被“扫描”并打包成字节流的。最常见的两种方式是列行式和行列式而列行式又分为顺向和逆向。HZK16字库采用的是“列行式、自上而下、自左而右、高位在前”的方式。我们来拆解一下列行式优先处理列。先处理第一列的所有行再处理第二列...自上而下在每一列内从最上面的像素第0行开始向下扫描。自左而右列的顺序是从最左边第0列到最右边第15列。高位在前在一个字节内最高位bit7对应的是该列更靠上的像素。假设一个汉字的左上角第一个点是亮的1按照这个规则它会被放在第一个字节的最高位bit7。这对于理解后续的偏移量计算和数据转换至关重要。2.2 GB2312编码与HZK16字库结构我们的汉字成千上万计算机怎么知道你要“我”这个字的字模呢这就需要一个“地址簿”也就是编码标准。在DOS和早期嵌入式领域GB2312是最常用的简体中文编码。它把常用的汉字和符号排列在一个94行 x 94列的庞大表格里行号称为“区”列号称为“位”。每个汉字对应一个唯一的“区码”和“位码”。HZK16文件就是这个“地址簿”对应的“仓库”。它严格按照GB2312的区、位顺序把每个汉字的32字节字模数据一个接一个地、紧密地排列在一起。因此只要我们知道了某个汉字的GB2312编码就能像查字典一样通过一个简单的公式计算出这个汉字的字模数据在HZK16这个“大仓库”中的精确位置偏移量然后直接去那个位置读取32个字节出来。注意HZK16只包含了GB2312的汉字和符号对于更全的GBK编码包含更多生僻字和繁体字是无能为力的。如果你的项目需要显示“喆”、“堃”这类字HZK16里是没有的需要考虑使用更大的字库文件如HZK24、HZK32或矢量字库。2.3 单片机系统的特殊需求取模转换与源码格式直接从HZK16读出的32字节数据是给DOS显示系统用的。但我们的单片机屏幕驱动方式可能千差万别。比如有些OLED屏是“行列式、自左而右、自上而下”扫描这和HZK16的“列行式”完全不同。这就需要进行“取模方式转换”本质上是将这256个比特按照新的扫描规则重新排列组合成32个字节。这是字模提取工具的核心算法之一也是很多现成工具提供多种“取模方式”选项的原因。其次单片机开发中我们通常需要把这些字模数据作为常量数组直接编译进程序的ROMFlash中。因此工具输出的最终形式往往是一段C语言或汇编语言的数组定义代码例如// 汉字“我”的字模数据 (HZK16默认方式) unsigned char code hanzi_wo[] { 0x08,0x08,0x08,0x11,0x11,0x32,0x34,0x50,0x91,0x11,0x12,0x12,0x14,0x14,0x08,0x00, 0x20,0x20,0x20,0x44,0x44,0x48,0x50,0x7F,0x40,0x40,0x40,0x40,0x40,0x40,0x00,0x00 };这样开发者直接复制这段代码到工程里就可以通过hanzi_wo这个数组来访问“我”字的显示数据了。3. 从原理到实现手把手拆解字模提取全流程理解了原理我们来看具体的实现步骤。我将以最经典的HZK16字库为例详细说明从汉字到单片机可用C数组的每一个环节。3.1 第一步获取目标汉字的GB2312内码这是整个过程的起点。在计算机内部汉字是以其编码内码形式存储和传输的。对于GB2312一个汉字由两个字节组成。在Windows环境下我们可以利用其底层使用GBK兼容GB2312编码的特性来获取。操作方法将需要提取字模的汉字字符串保存到一个文本文件中并确保该文本文件以ANSI编码在简体中文Windows下即为GBK编码保存。以二进制模式打开这个文本文件。直接读取相应位置的字节。对于纯汉字字符串第1、2个字节就是第一个汉字的内码第3、4个字节是第二个汉字的内码以此类推。为什么这么做因为如果直接在程序里用字符串处理函数获取字符编码可能会受到运行时环境或编译器设置的影响。而将其写入文件再以二进制读取是最直接、最底层、兼容性最好的方法能确保拿到最原始的GBK/GB2312双字节码。我在VB工具里采用的就是这种方法虽然多了一次文件IO但保证了结果的绝对可靠。3.2 第二步计算字模数据在HZK16中的偏移量拿到高字节Hbyte和低字节Lbyte后我们需要将它们转换为GB2312的“区码”和“位码”。GB2312编码从0xA1A1开始。因此区码Qu Hbyte - 0xA1位码Wei Lbyte - 0xA1由于HZK16文件是按先区后位、顺序紧密排列的且每个字模占32字节所以偏移量计算公式为偏移量 (LocationOffset) [(区码) * 94 (位码)] * 32这里乘以94是因为每个区有94个位。这里有一个关键细节这个偏移量是从文件头第0字节开始计算的字节偏移量。在编程中文件操作函数如fseek,Get通常使用从0开始的偏移量所以这个公式计算出的值可以直接使用。举例计算“我”字 “我”的GB2312内码为0xCE 0xD2。区码 0xCE - 0xA1 0x2D (十进制45)位码 0xD2 - 0xA1 0x31 (十进制49)偏移量 (45 * 94 49) * 32 (4230 49) * 32 4279 * 32 136928 (字节) 换算成十六进制是0x216E0。注意这是从文件开始到“我”字字模第一个字节的偏移量。3.3 第三步读取并处理原始字模数据使用文件操作函数从计算出的LocationOffset位置开始连续读取32个字节这就是HZK16格式的“我”的字模数据。此时的数据是“原始数据”其排列方式符合我们前面说的“列行式”。如果你屏幕的驱动IC恰好也支持这种扫描方式有些液晶驱动芯片如ST7567可以配置那么这32个字节可以直接使用。但很多时候我们需要进行转换。3.4 第四步取模方式转换算法详解核心这是技术含量最高的一步。我们以将HZK16的“列行式”转换为最常见的“行列式、逐行扫描、高位在左”为例进行说明。原始数据HZK16列行式结构 32个字节对应16列每列2个字节因为一列有16行2字节16比特。字节0: 第0列第0-7行 (bit7是第0行)字节1: 第0列第8-15行 (bit7是第8行)字节2: 第1列第0-7行字节3: 第1列第8-15行...字节30: 第15列第0-7行字节31: 第15列第8-15行目标数据行列式逐行扫描结构 同样是32个字节但对应16行每行2个字节因为一行有16列2字节16比特。字节0: 第0行第0-7列 (bit7是第0列)字节1: 第0行第8-15列 (bit7是第8列)字节2: 第1行第0-7列字节3: 第1行第8-15列...字节30: 第15行第0-7列字节31: 第15行第8-15列转换算法思路位操作 我们不能简单地交换字节位置因为比特的对应关系已经变了。我们需要进行“矩阵转置”操作但这里不是字节矩阵而是比特矩阵。最清晰的方法是在内存中构建一个16x16的二维比特数组bitmap[16][16]。遍历原始32字节根据HZK16的规则将每个字节的8个比特填充到bitmap数组的相应位置列、行。遍历目标bitmap数组按照行列式的规则从每一行中取出16个比特组合成2个字节依次存入目标数组。简化算法一次到位 我们可以不通过中间矩阵直接通过位运算计算新数组中每个字节的每一个比特。对于目标数组中的第i个字节i从0到31它属于第row i / 2行。它是该行的左半字节还是右半字节由i % 2决定0为左半部分0-7列1为右半部分8-15列。对于该字节的第bit位0是最低位7是最高位它对应原始数据中的哪一列、哪一行呢目标比特位于第row行第col (i%2)*8 (7-bit)列因为高位在左所以用7-bit。在原始数据中这个点位于第col列第row行。原始数据中这个点存储在hzbyte数组的第index col * 2 (row/8)个字节的第(row % 8)比特位上注意HZK16字节内高位对应上方像素。这个逻辑有点绕但用代码实现就是一个嵌套循环。我在VB工具里写的fanzhuan函数就是实现了类似的转换逻辑通过getxnbit函数来提取原始字节中指定位的值。3.5 第五步生成C语言源代码格式转换后的32字节数据需要格式化为C语言数组。这一步相对简单但要注意细节数组定义使用unsigned char code对于Keil C51或const unsigned char对于标准C来定义确保数据存放在程序存储区。数据格式每个字节以0x开头两位十六进制数表示例如0x1F。排版美观每16个字节即一个完整汉字的左半部分或右半部分换一行方便阅读和检查。通常在逗号后换行。添加注释在数组前加上注释说明是哪个汉字这在大规模字库提取时非常有助于维护。4. 实战用现代语言Python重构字模提取工具VB工具虽然能用但毕竟环境古老。这里我用Python重新实现一个命令行版本的工具它更简洁、更通用也更容易集成到自动化脚本中。我们将实现核心的提取和转换功能。4.1 工具设计与依赖我们将编写一个Python脚本font_extractor.py。它不需要任何第三方库仅使用Python标准库。功能包括从命令行接收汉字字符串。支持指定输入HZK16文件路径。支持选择是否进行取模转换提供HZK16原始方式和行列式两种。输出格式化后的C语言数组到控制台或文件。4.2 核心代码实现#!/usr/bin/env python3 # -*- coding: gbk -*- # 注意文件编码确保能处理中文 import sys import os import argparse class HZK16Extractor: def __init__(self, hzk16_pathHZK16): 初始化加载HZK16字库文件 self.hzk16_path hzk16_path if not os.path.exists(hzk16_path): raise FileNotFoundError(f字库文件 {hzk16_path} 不存在) # 我们将以二进制模式按需读取不一次性加载整个文件节省内存 def get_gb2312_code(self, hanzi_str): 获取汉字字符串的GB2312编码字节序列 # 在简体中文Windows/Linux的GBK环境下汉字编码与GB2312兼容 # 注意这里返回的是字节数组 try: return hanzi_str.encode(gb2312) except UnicodeEncodeError: # 如果遇到GB2312不支持的字符如GBK扩展字符尝试用GBK编码但HZK16可能没有 print(f警告: 字符 {hanzi_str} 可能不属于GB2312基本集使用GBK编码尝试。) return hanzi_str.encode(gbk) def calculate_offset(self, byte1, byte2): 根据GB2312编码字节计算在HZK16中的偏移量 # GB2312 编码范围从 0xA1A1 开始 qu byte1 - 0xA1 # 区码 wei byte2 - 0xA1 # 位码 # 每个字模32字节每区94个字 offset (qu * 94 wei) * 32 return offset def extract_raw_data(self, offset): 从HZK16文件的指定偏移量读取32字节原始字模数据 with open(self.hzk16_path, rb) as f: f.seek(offset) data f.read(32) if len(data) ! 32: raise ValueError(f从偏移量 {offset} 读取字模数据失败或数据不完整。) return data def convert_to_row_scan(self, raw_data): 将列行式(HZK16)数据转换为行列式(逐行扫描)数据 # raw_data: 32 bytes, column-major, top-down, left-to-right # target: 32 bytes, row-major, left-to-right, top-down target bytearray(32) # 构建一个虚拟的16x16位矩阵便于理解转换 # 但这里我们直接通过计算进行转换效率更高 for row in range(16): # 目标行 byte_idx (row // 8) * 2 # 在原始数据中每8行占用2个字节两列 bit_in_row row % 8 # 处理目标行的左半部分第0-7列 left_byte 0 for col in range(8): # 找到原始数据中对应的字节和位 src_col col src_byte_index src_col * 2 (0 if row 8 else 1) # 确定是列的上半部分还是下半部分 src_bit_pos 7 - bit_in_row # HZK16字节内高位在上方 src_byte raw_data[src_byte_index] # 取出该位的值 (0或1) bit_value (src_byte src_bit_pos) 0x01 # 放到目标字节的相应位置高位在左 left_byte | (bit_value (7 - col)) # 处理目标行的右半部分第8-15列 right_byte 0 for col in range(8, 16): src_col col src_byte_index src_col * 2 (0 if row 8 else 1) src_bit_pos 7 - bit_in_row src_byte raw_data[src_byte_index] bit_value (src_byte src_bit_pos) 0x01 right_byte | (bit_value (7 - (col - 8))) # 存入目标数组 target[row * 2] left_byte target[row * 2 1] right_byte return target def generate_c_code(self, hanzi, data, moderaw): 生成C语言数组定义代码 lines [] # 数组名使用拼音或索引这里用索引简单处理 # 在实际应用中可以做得更友好比如用拼音 array_name ffont_{ord(hanzi):x} # 用Unicode码点作为名称的一部分避免重复 lines.append(f// 汉字: {hanzi} (模式: {mode})) lines.append(fconst unsigned char {array_name}[] {{) hex_strs [f0x{b:02X} for b in data] # 每16个字节换一行方便查看对应一个16x16点阵的上下半部分 for i in range(0, 32, 16): line , .join(hex_strs[i:i16]) lines.append(f {line},) lines.append(};) return \n.join(lines) def process_string(self, text, modeconverted, output_fileNone): 处理整个字符串生成所有汉字的字模代码 gb_bytes self.get_gb2312_code(text) # 检查字节数是否为偶数纯汉字 if len(gb_bytes) % 2 ! 0: print(警告: 输入字符串可能包含非中文字符如半角符号结果可能不准确。) all_code [] char_count len(gb_bytes) // 2 all_code.append(f// 字模提取结果 - 共 {char_count} 个汉字) all_code.append(f// 源字符串: {text}) all_code.append(f// 取模模式: {mode}) all_code.append() for i in range(char_count): byte1 gb_bytes[i*2] byte2 gb_bytes[i*2 1] hanzi text[i] # 获取对应的汉字字符 # 检查编码是否在GB2312基本集范围内 (0xA1A1 - 0xF7FE) if not (0xA1 byte1 0xF7 and 0xA1 byte2 0xFE): print(f跳过非GB2312基本集字符: {hanzi} (编码: {byte1:02X}{byte2:02X})) continue offset self.calculate_offset(byte1, byte2) raw_data self.extract_raw_data(offset) if mode converted: final_data self.convert_to_row_scan(raw_data) else: # raw 模式 final_data raw_data c_code self.generate_c_code(hanzi, final_data, mode) all_code.append(c_code) all_code.append() # 空行分隔 result \n.join(all_code) if output_file: with open(output_file, w, encodingutf-8) as f: f.write(result) print(f字模代码已写入文件: {output_file}) else: print(result) def main(): parser argparse.ArgumentParser(descriptionHZK16字库字模提取工具) parser.add_argument(text, help需要提取字模的汉字字符串用引号括起来如 你好世界) parser.add_argument(-f, --font, defaultHZK16, helpHZK16字库文件路径 (默认: ./HZK16)) parser.add_argument(-m, --mode, choices[raw, converted], defaultconverted, help取模模式: raw (HZK16原始列行式), converted (行列式逐行扫描默认)) parser.add_argument(-o, --output, help输出文件名如果不指定则打印到控制台) args parser.parse_args() try: extractor HZK16Extractor(args.font) extractor.process_string(args.text, modeargs.mode, output_fileargs.output) except Exception as e: print(f错误: {e}, filesys.stderr) sys.exit(1) if __name__ __main__: main()4.3 工具使用示例准备将HZK16字库文件可从网络下载与font_extractor.py脚本放在同一目录。基本使用在命令行中运行。python font_extractor.py 单片机这将输出“单”、“片”、“机”三个汉字的字模数组默认转换为行列式。指定原始模式python font_extractor.py 测试 -m raw输出到文件python font_extractor.py 嵌入式开发 -o font_data.c指定字库路径python font_extractor.py 显示 -f /path/to/your/HZK164.4 代码关键点解析与避坑指南编码问题脚本第二行的# -*- coding: gbk -*-以及代码中使用.encode(gb2312)是关键。这确保了汉字字符串能正确转换为GB2312字节序列。如果你的系统环境默认编码不是中文这一点尤其重要。偏移量验证calculate_offset函数计算出的偏移量必须作为整数传递给file.seek()。务必确认计算正确否则会读到错误的数据。一个简单的验证方法是用计算出的偏移量附近的数据与已知的正确字模进行对比。转换算法验证convert_to_row_scan函数是核心也是最易出错的部分。强烈建议编写单元测试找几个简单的汉字如“一”、“十”手动计算出它们在两种模式下的理论值或用可靠的第三方工具生成对照数据来验证你的转换算法是否正确。非GB2312字符处理代码中加入了简单的判断和警告。在实际项目中如果确定只用GB2312汉字可以严格过滤。如果需要更多字符就要准备更大的字库文件如HZK24FGBK字库并修改偏移量计算公式因为GBK不是简单的94x94矩阵。性能考虑当前实现是每提取一个字就打开一次文件在extract_raw_data中。对于提取大量汉字这会带来不必要的IO开销。一个优化方法是在__init__中一次性将整个HZK16文件读入内存约260KB后续操作直接在内存中进行速度会快很多。当然对于260KB的内存占用在当今设备上完全可以接受。5. 常见问题、排查技巧与进阶思考在实际使用自研字模工具或集成字模到嵌入式项目时你会遇到各种各样的问题。下面是我总结的一些典型场景和解决方法。5.1 字模显示问题排查清单当你的屏幕显示汉字出现乱码、错位、残缺时可以按照以下步骤排查问题现象可能原因排查方法完全乱码显示为毫无规律的斑点1. 取模方式与LCD驱动设置不匹配。2. 字模数据源错误编码错误或字库文件损坏。1.核对取模方式这是最常见的原因。用工具生成一个简单汉字如“一”的字模分别用“原始HZK16”和“行列式”两种模式测试。同时检查LCD初始化代码中关于扫描方向的配置位通常叫SEG/COM Scan Direction。2.验证字库和编码用本文的Python脚本提取“啊”字GB2312第一个汉字编码A1A1的字模与网上已知正确的数据进行比对。确保输入给工具的汉字编码正确。汉字上下或左右颠倒扫描方向设置反了。检查LCD驱动IC数据手册中关于“列地址递增方向”和“行地址递增方向”的配置。通常可以通过命令0xA0/0xA1和0xC0/0xC8来调整。与取模工具中的“垂直镜像”、“水平镜像”选项对应。汉字中间出现一条固定的空白线1. 字模数据本身有问题就像我遇到的“收费软件陷阱”。2. 数据传输或存储过程中某个固定位被错误清零。1.检查原始数据将字模数组以二进制形式打印出来观察32个字节的数值。对于16x16点阵第15、16字节索引14、15和第31、32字节索引30、31分别对应左下和右下角的8行数据。如果这些字节全为0就可能出现中间断线。2.检查驱动代码确认写入LCD显存GRAM的循环逻辑没有错误没有漏写或跳写某个字节。只有部分汉字能显示生僻字显示为空白或乱码使用的字库如HZK16不包含该生僻字。确认你要显示的汉字是否在GB2312字符集内共6763个汉字。可以使用在线工具查询。如果必须显示需要换用包含GBK或更大字符集的字库文件并相应修改提取工具。显示花屏伴随其他图形错乱1. 字模数据在内存中的存储地址越界破坏了其他数据。2. 指针操作错误导致数据被覆盖。1.检查数组定义确保code或const数组正确定义在正确的存储区域且编译器没有优化掉。2.使用调试器在内存窗口中查看字模数组所在地址的数据与工具生成的数据进行逐字节比对看是否在运行时被意外修改。5.2 嵌入式端的优化技巧空间换时间建立索引表如果你的项目只显示几十个固定汉字如菜单界面最省事的方法就是用工具一次性生成所有这些字的数组编译进去。但如果汉字较多且动态变化每次都从完整的HZK16中查找效率低。可以在单片机内建立一个“汉字-索引”映射表。例如将常用汉字的GB2312编码排序后放在一个数组中字模数据紧挨着存放。查找时用二分法找到编码后就能立即计算出字模数据的地址。使用稀疏存储对于16x16点阵很多汉字的字模数据中存在大量连续的0x00或0xFF。可以考虑使用简单的压缩算法如Run-Length Encoding (RLE)在存储时压缩显示时解压。这对于Flash空间极其紧张的低端MCU很有意义。利用硬件特性一些高端MCU或外部Flash支持内存映射XIP或DMA。可以将整个字库文件如HZK16烧录到外部Flash的固定区域并将其地址映射到MCU的寻址空间。显示时直接通过指针计算地址并读取无需加载到RAM节省了大量内存。5.3 从HZK16到更高级的字库HZK16是入门首选但它的局限也很明显只有16x16一种字体且是等宽点阵美观度一般。当项目需要更漂亮的字体或多字号时就需要处理其他字库HZK12/HZK24/HZK32这些是不同大小的点阵字库原理与HZK16完全相同只是每个字模的字节数不同12x12为24字节24x24为72字节32x32为128字节。偏移量计算公式中的“32”需要替换为对应的字节数。矢量字库如TTF/OTF这是完全不同的领域。你需要集成一个轻量级的矢量字体渲染引擎如FreeType的简化版或者在PC端预先将需要的汉字和字号渲染成位图再转换成字模。后者是嵌入式系统更常用的方法用桌面软件生成所需汉字、字号、甚至加粗斜体效果的点阵图再用工具转换成数组。这给了UI设计更大的灵活性。自制小字库对于物联网设备状态显示等场景可能只需要几十个汉字。完全可以用图形化软件甚至Windows画图手绘12x12或16x16的点阵图然后自己按照取模规则“翻译”成十六进制数。虽然笨但对于极少量字符来说最快最直接。自己动手写一个字模提取工具看似是重复造轮子但其价值远超工具本身。它迫使你深入理解字符编码、文件格式、位操作、显示原理这些嵌入式开发的基础知识。当你再遇到显示问题时你不再是一个只会点击“生成”按钮的使用者而是一个能洞察数据流向、能精准定位问题的解决者。这份对底层细节的掌控力正是资深工程师与普通开发者的区别所在。我的这个Python版本工具虽然只有百来行代码但它干净、透明、可靠在任何需要的时候都能为我提供确定性的输出这或许就是“工匠精神”在软件领域的一种体现吧。