ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#点阵字体查看工具:HZK16字库解析与WinForms调试实战

C#点阵字体查看工具:HZK16字库解析与WinForms调试实战

简介:这是一份基于C#开发的点阵字体文件查看工具源码,面向MCU开发、嵌入式系统及对UCDOS点阵字库感兴趣的开发者,解决HZK16等16x16点阵数据的快速查看与解析问题。工具利用System.IO读取字库文件,通过数据解析模块将二进制点阵转换为可视化图像,并使用Windows Forms或WPF构建界面,支持搜索、缩放、翻页等交互操作,是学习C#桌面应用与嵌入式字符显示的实用范例。压缩包共15个文件,包含8个cs源代码、3个resx资源文件及工程配置、解决方案文件等,整体仅9KB,结构紧凑且便于研读。已有935人学习或下载。通过完整源码可以深入了解点阵字体从文件解析到图形渲染的全过程,也能为MCU软件开发中的字符显示模块提供参考和复用基础。

1. 点阵字体查看工具:C# 写个字库调试器的原始动机

做嵌入式显示的朋友丢来一个 12864 液晶屏项目,屏上汉字整行整行出雪花点。排查三天后锁定真凶:点阵字体文件用错了版本,标称 HZK16,实际装的是 12 号点阵数据,索引偏移全部错位。从那以后我一直把点阵字体查看工具这个 C# 小程序当作调试标配——它直接读 HZK16、ASC16 这类字库文件,把每个字符的字节解析成 16x16 的点阵网格,同时给出区位码、十六进制原始数据和逐位二进制串。适合谁用?嵌入式开发、LCD/OLED 显示调试、老式工控屏维护,或者任何被「乱码到底出在代码还是字库」折磨过的人。下面按文件格式、核心解析、运行步骤和二次改造完整拆一遍,拿到源码就能跑。

2. 技术选型与整体架构:WinForms 轻量工程的三个关键取舍

2.1 为什么选 WinForms:工具类项目不需要框架的重量

拿到需求第一件事是定 UI 框架。这个工具的界面本质上只有三块:左侧一个文本框输入字符,中间一块自绘的点阵预览区,右侧一个十六进制和二进制数据面板。控件就是 Label、TextBox、Panel 这几样,预览区用 OnPaint 自绘,完全用不到 WPF 的数据模板、绑定和样式体系。工具类项目最重要的是启动快、依赖少、改起来直接。WinForms 编译出单个 exe 就能拷到工控机上跑,不挑运行时配置;WPF 在这个场景下除了让 XAML 好看一点,没有实质收益。

另一个考虑是维护成本。这类字库调试工具通常三五天写完,然后长期吃灰,等下一次调屏再翻出来改。WinForms 可视化设计器里拖控件比 XAML 快,老工程师接手零门槛。有人会问为什么不用纯控制台程序——命令行能打印十六进制,但没法把点阵画成可对齐的网格,预览和十六进制必须同屏对照才有效率,GUI 是刚需。

2.2 工程目录结构:四个文件夹的职责边界

源码的目录结构:

DotMatrixFontViewer/ ├── DotMatrixFontViewer.sln ├── DotMatrixFontViewer/ │ ├── Program.cs │ ├── MainForm.cs │ ├── MainForm.Designer.cs │ ├── FontReaders/ │ │ ├── FontReaderBase.cs │ │ ├── Hzk16Reader.cs │ │ ├── Hzk12Reader.cs │ │ ├── Hzk24Reader.cs │ │ └── Asc16Reader.cs │ ├── Rendering/ │ │ └── GlyphRenderer.cs │ └── Utils/ │ ├── Gb2312Helper.cs │ └── HexDumpBuilder.cs

模块边界刻意按单一职责划分:FontReaders 负责装载文件、计算偏移、取出某个字符的原始字节,不碰界面;Rendering 只做字节到 Bitmap 的转换;Utils 承载编码转换和文本格式化;MainForm 只做拼装和事件分发。每个文件的职责:

文件职责
FontReaderBase抽象基类,定义装载文件和取字模接口,持有通用文件读取逻辑
Hzk16Reader / Hzk12Reader / Hzk24Reader中文字库读取,各自处理点阵尺寸与偏移公式
Asc16Reader西文字库读取,8x16 点阵,索引即 ASCII 码
GlyphRenderer字节数组渲染为 Bitmap,按缩放倍数逐位绘制
Gb2312Helper字符到 GB2312 字节的转换,并统一处理代码页注册
HexDumpBuilder十六进制和二进制文本生成,控制每行宽度

这个划分对二次开发很友好:以后接 TTF 转点阵,只需新增一个 TtfGlyphSource 实现同一套抽象接口;接网络字库接口也一样,替换实现类不影响渲染和面板。

2.3 数据流链路:从字库文件到屏幕像素的完整路径

整个程序的运行时数据流是一条直线:文件装载 → 字符编码 → 偏移计算 → 字模读取 → 并行渲染。用户在左侧输入一个汉字,MainForm 先调 Gb2312Helper 把字符转成两个字节(区码和位码),再调 Hzk16Reader 按公式算出文件偏移,读出 32 字节字模。这 32 字节同时走两条路:一路进 GlyphRenderer 变成 Bitmap 画到预览区,另一路进 HexDumpBuilder 变成十六进制和二进制文本,刷新右侧面板。

数据流里最关键的一点是索引计算必须和字库文件的排列约定一致。HZK16 按 GB2312 的区位顺序排列字符,区号和位号都从 0xA1 开始计数,所以偏移要用区号减 0xA1、位号减 0xA1,再乘每区 94 位、每位 32 字节。这个公式错一处,读出来的就是隔壁字符的字模,表现成「整体错位」而不是「完全乱码」,最难排查。界面刷新用的是双缓冲,避免预览区和数据面板频繁重绘闪烁,这点在自绘控件里属于常规操作,但初次写自绘的人容易忽略。

3. 核心模块拆解:区位码换算、点阵渲染与数据面板的实现

3.1 字模读取器:区位码换算与 32 字节定位

先看抽象基类 FontReaderBase,它把「装载文件」和「取字模」定义成接口,具体字库只负责返回自己的点阵尺寸:

public abstract class FontReaderBase { protected byte[] _fontData; public string FilePath { get; private set; } public abstract int GlyphWidth { get; } public abstract int GlyphHeight { get; } public abstract int GlyphSize { get; } public bool Load(string path) { if (!File.Exists(path)) return false; _fontData = File.ReadAllBytes(path); FilePath = path; return _fontData.Length > 0; } public abstract byte[] GetGlyph(char ch); public bool IsGlyphAvailable(char ch) { try { return GetGlyph(ch) != null; } catch { return false; } } }

Load 用 File.ReadAllBytes 一次读入整个字库。点阵字库最大也就几百 KB,比如 HZK16 标准文件约 276 KB,没必要做流式读取或内存映射。GlyphWidth、GlyphHeight、GlyphSize 由子类返回,UI 层根据这三个值决定缩放倍数、网格线数量和每行十六进制宽度,界面不用写死任何数字。IsGlyphAvailable 包一层异常捕获,界面上遇到生僻字不会崩,只会标成无字模。

Hzk16Reader 实现中文字库的定位:

public class Hzk16Reader : FontReaderBase { public override int GlyphWidth => 16; public override int GlyphHeight => 16; public override int GlyphSize => 32; public override byte[] GetGlyph(char ch) { byte[] gbBytes = Gb2312Helper.Encode(ch); if (gbBytes == null || gbBytes.Length < 2) return null; int quCode = gbBytes[0] & 0xFF; // 区码 int weiCode = gbBytes[1] & 0xFF; // 位码 int offset = ((quCode - 0xA1) * 94 + (weiCode - 0xA1)) * GlyphSize; if (offset < 0 || offset + GlyphSize > _fontData.Length) return null; byte[] glyph = new byte[GlyphSize]; Array.Copy(_fontData, offset, glyph, 0, GlyphSize); return glyph; } }

几个容易翻车的细节。第一,区码位码取出后必须 & 0xFF,C# 的 byte 转 int 会做符号扩展,0xD6 直接转成 int 变负数,偏移公式全错。第二,偏移公式 ((quCode - 0xA1) * 94 + (weiCode - 0xA1)) * 32 里,94 是 GB2312 每个区的总位置数,32 是 16x16 点阵的单字字节数;换 HZK24 时最后乘数改成 72。第三,读出的 32 字节按行存放,第 0、1 字节是第一行 16 个像素,第 2、3 字节是第二行,依此类推,渲染和格式化都依赖这个排布。

Asc16Reader 更直接,ASCII 码本身就是文件索引:

public class Asc16Reader : FontReaderBase { public override int GlyphWidth => 8; public override int GlyphHeight => 16; public override int GlyphSize => 16; public override byte[] GetGlyph(char ch) { int ascii = (int)ch; if (ascii < 0 || ascii > 255) return null; int offset = ascii * GlyphSize; if (offset + GlyphSize > _fontData.Length) return null; byte[] glyph = new byte[GlyphSize]; Array.Copy(_fontData, offset, glyph, 0, GlyphSize); return glyph; } }

Asc16 每字符 16 字节,对应 8 列宽、16 行高,一行一字节。注意字符码超过 255 时提前返回 null,否则越界读取会读出相邻区域的数据。这里也暴露了 HZK 系列和 ASC 系列的本质差异:中文要经过区位码换算,西文直接乘 16 就是偏移,接入新字库时先搞清楚它的索引方式。

3.2 点阵渲染:字节数组到 Bitmap 的逐位绘制

字模读出来只是 32 个字节,要变成人眼能看的格子,得逐位判断像素是否置位。GlyphRenderer 核心代码:

public class GlyphRenderer { public Bitmap RenderToBitmap(byte[] glyph, int width, int height, int scale) { var bmp = new Bitmap(width * scale, height * scale, PixelFormat.Format32bppArgb); using (var g = Graphics.FromImage(bmp)) { g.Clear(Color.White); g.InterpolationMode = InterpolationMode.NearestNeighbor; g.PixelOffsetMode = PixelOffsetMode.Half; int bytesPerRow = width / 8; for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { int byteIndex = row * bytesPerRow + col / 8; int bitIndex = 7 - (col % 8); bool isSet = (glyph[byteIndex] & (1 << bitIndex)) != 0; if (isSet) { g.FillRectangle(Brushes.Black, col * scale, row * scale, scale, scale); } } } } return bmp; } }

渲染逻辑的核心是 byteIndex 和 bitIndex 两个索引。byteIndex = row * bytesPerRow + col / 8,根据行列算出像素落在哪个字节;bitIndex = 7 - (col % 8),因为点阵字库约定字节最高位对应最左侧像素,即高位在前。如果遇到把最低位当最左像素的字库,把 bitIndex 改成 col % 8 即可,但必须同步修改 HexDumpBuilder,两处位序约定不一致,预览和二进制面板就对不上。

scale 是缩放倍数,预览区用 8,16 点阵放大成 128x128 像素,方便对照网格线逐格检查。用 FillRectangle 而不是 SetPixel 是因为效率:SetPixel 在循环里逐点调用,几百次调用还能接受,但每次重绘都这样会有明显卡顿;FillRectangle 直接按目标大小填充方块,点阵放大场景天然就是矩形块,视觉上更锐利。InterpolationMode 设为 NearestNeighbor 则是为了禁掉默认的双线性插值,否则点阵硬边会被糊成灰边。

3.3 数据面板:十六进制与二进制字符串的生成

右侧面板要显示原始数据和位级视图,HexDumpBuilder 负责格式化:

public static class HexDumpBuilder { public static string BuildHex(byte[] glyph, int bytesPerRow) { var sb = new StringBuilder(); for (int i = 0; i < glyph.Length; i += bytesPerRow) { for (int j = 0; j < bytesPerRow && i + j < glyph.Length; j++) { sb.Append(glyph[i + j].ToString("X2")).Append(' '); } sb.AppendLine(); } return sb.ToString(); } public static string BuildBinary(byte[] glyph, int bitsPerRow) { var sb = new StringBuilder(); int totalBits = glyph.Length * 8; for (int i = 0; i < totalBits; i += bitsPerRow) { for (int j = 0; j < bitsPerRow && i + j < totalBits; j++) { int byteIndex = (i + j) / 8; int bitIndex = 7 - ((i + j) % 8); sb.Append((glyph[byteIndex] & (1 << bitIndex)) != 0 ? '1' : '0'); } sb.AppendLine(); } return sb.ToString(); } }

BuildHex 按 bytesPerRow 分组,16x16 字库传入 2,一行十六进制正好对应点阵的一行像素。BuildBinary 先算总位数再按 bitsPerRow 切行,bitIndex 同样是高位在前,与渲染模块保持一致。这里有个设计细节值得抄:格式化工具全部用 StringBuilder 而不是字符串拼接,32 字节的十六进制文本很短感受不到差异,但对批量导出场景,比如第 6 章要做的批量字模生成,StringBuilder 是必须的。

提示:改字库时最怕二进制面板和预览对不上。两个模块共用同一套位序约定,建议把位序规则放进基类常量或配置,而不是散落在渲染和格式化各自的方法里,改一次不会漏。

4. 编译运行与扩展:从源码到 HZK12 接入的完整步骤

4.1 环境准备与编译:命令行和 IDE 两种跑法

项目是标准 WinForms 工程,用 .NET SDK 就能编译,命令行方式最省事:

dotnet build DotMatrixFontViewer.sln -c Release dotnet run --project DotMatrixFontViewer

dotnet build 编译 Release 版本,产物在 bin/Release 目录;dotnet run 直接启动界面。想用 IDE 就打开解决方案文件,把 DotMatrixFontViewer 设为启动项目,运行即可。项目依赖很少,除了系统自带的 WinForms,只有一个 System.Text.Encoding.CodePages 包,用于在 .NET Core 上提供 GB2312 代码页。

编译报错先看两处:SDK 版本是否支持 WinForms,以及 NuGet 还原是否成功。如果提示找不到 CodePages 包,先执行 dotnet restore。另外注意目标框架,工程如果标的是带平台后缀的目标框架,要在 Windows 上编译,其他系统下 dotnet build 会因 WinForms 平台限制失败。

4.2 操作流程与验证:用「中」字校验字库文件

跑起来后建议固定一套验证流程。先用「中」字做基准测试,它的点阵结构有代表性,网上能查到标准字模数据。操作路径:打开字库文件(HZK16 或 ASC16),在左侧输入「中」,右侧出现 32 字节十六进制数据,中间预览显示 16x16 点阵。看一眼「中」字的横竖结构是否端正,就知道位序和偏移是否正确。

拿到陌生字库文件,我习惯做三个检查。第一看文件大小:标准 GB2312 的 HZK16 应该约 94 区 × 94 位 × 32 字节 ≈ 276 KB,差太多说明版本不对。第二看「啊」字:区位码 16-01,偏移在文件靠前位置,正常情况读出的字模左上结构明显。第三看半角字符:HZK16 只收汉字和全角符号,半角字母必须用 ASC16 查看,混用必然错位。

4.3 扩展接入:HZK12 与 HZK24 读取器怎么写

工程自带 Hzk12Reader 和 Hzk24Reader,写法跟 Hzk16Reader 几乎一致,只差三个数字。贴一段 Hzk12Reader:

public class Hzk12Reader : FontReaderBase { public override int GlyphWidth => 12; public override int GlyphHeight => 12; public override int GlyphSize => 24; // 12 行 × 每行 2 字节 public override byte[] GetGlyph(char ch) { byte[] gbBytes = Gb2312Helper.Encode(ch); if (gbBytes == null || gbBytes.Length < 2) return null; int quCode = gbBytes[0] & 0xFF; int weiCode = gbBytes[1] & 0xFF; int offset = ((quCode - 0xA1) * 94 + (weiCode - 0xA1)) * GlyphSize; if (offset < 0 || offset + GlyphSize > _fontData.Length) return null; byte[] glyph = new byte[GlyphSize]; Array.Copy(_fontData, offset, glyph, 0, GlyphSize); return glyph; } }

唯一要留意的是 12x12 字模每行只有 12 个有效位,却占 2 个字节,第二字节高 4 位是无效填充。渲染循环只读前 12 位,自然跳过填充,但 GlyphSize 必须按 24 算;误写成 18 会导致全部索引错位。HZK24 是 24 行 × 每行 3 字节,GlyphSize 是 72,没有填充问题。接入新字库的套路就三步:确认点阵宽高,确认单字字节数,确认位序,翻译成这几个抽象成员的返回值即可。

5. 避坑与排查:点阵字库调试里反复出现的五个经典问题

5.1 预览区全空或全满:偏移公式与编码不匹配

现象是输入任意汉字,预览区要么一片空白,要么整块黑。原因是偏移算出负值或越界时返回 null,位全 0 是空白;算到别的字符区域且恰好撞上全 1 的字模,就整块黑。最常见成因是编码搞错,例如用 UTF-8 转字节,一个汉字变 3 个字节,区码位码取错。解决方法是确认两点:字符转字节必须走 GB2312;从字节数组取值后要 & 0xFF 再参与计算。我漏写过 & 0xFF,区码变负数,查了半小时才想起来 C# 的 byte 转 int 会符号扩展。

5.2 字模整体错位一行或一列:位序约定不一致

现象是字模读得出来,但整体右移一列或上下串行。原因是不同厂商对「字节最高位对应最左像素」的约定有分歧,多数 HZK 高位在前,部分嵌入式厂商改成低位在前。解决方法是先做位序测试:输入「明」这种左右结构明显的字,看渲染出来的左半部是否完整。如果左右互换笔画断裂,把渲染模块的 bitIndex 从 7 - (col % 8) 改成 col % 8,同时改 HexDumpBuilder 的对应行,两处必须同步。

5.3 生僻字读出乱码或空白:GB2312 覆盖范围边界

现象是常用字全部正常,个别生僻字读出来是乱码或空白。原因是 HZK16 基于 GB2312,字符集只收录 6763 个汉字,超出范围的字符在文件里没有对应数据,查询要么越界返回,要么落在别的字符区域。这不算代码 bug,是字符集边界。解决方法是先调 IsGlyphAvailable 判断,把无字模的字符在界面上标灰;必须显示这些字就换 GBK 字库或改用 TTF 转点阵,工具的抽象接口已经预留了换实现的余地。

5.4 GB2312 编码在 .NET 6 上抛异常:代码页注册问题

现象是程序在 .NET Framework 上正常,迁到 .NET 6 后调用 Encoding.GetEncoding("GB2312") 直接抛「不支持代码页」异常。原因是 .NET Core 默认不携带传统代码页编码,GB2312、GBK 都需要额外注册提供程序。解决方法是在引用 System.Text.Encoding.CodePages 包之后,在静态构造函数里注册:

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

这行代码放在 Gb2312Helper 的静态构造里,整个进程注册一次即可。我在 .NET 8 上验证过,注册后 GB2312、GBK 都能正常转换,不需要其他适配。

5.5 放大预览边缘发灰:插值模式导致的渲染失真

现象是放大到 8 倍后点阵边缘出现灰色过渡,网格线和黑格对不齐。原因是 DrawImage 缩放默认用双线性插值,对点阵这种硬边图形会产生模糊。解决方法是把 Graphics 的 InterpolationMode 设为 NearestNeighbor,PixelOffsetMode 设为 Half,前者保证像素点对点放大,后者修正缩放时的取整偏移。RenderToBitmap 里已配好,如果自己加网格线,务必用同一个 Graphics 对象绘制,避免二次缩放。

注意:五个问题覆盖「读不出来、读错位置、读错字符、跑不起来、显示发虚」五类症状。排查顺序先看编码,再看偏移,最后看渲染,能省一半时间。

6. 二次开发:把查看器改造成 C 数组字模生成器

查看器跑通后,最有价值的改造方向是变成字模生成器。嵌入式项目调屏通常要把字模以 C 数组写进代码,手工从十六进制面板复制 32 个字节再套格式,容易漏位。改造思路是在现有读取链路上加一个导出按钮,把当前字模序列化成嵌入式风格的数组:

public static string ToCArray(byte[] glyph, string variableName) { var sb = new StringBuilder(); sb.AppendLine($"const unsigned char {variableName}[{glyph.Length}] = {{"); for (int i = 0; i < glyph.Length; i++) { sb.Append($" 0x{glyph[i]:X2}"); if (i < glyph.Length - 1) sb.Append(','); if ((i + 1) % 8 == 0) sb.AppendLine(); } sb.AppendLine(); sb.AppendLine("};"); return sb.ToString(); }

调用时用字符的 GB2312 字节拼变量名,避免重名:

string varName = $"glyph_0x{g1:X2}_{g2:X2}";

批量导出是下一个顺手就能做的扩展:输入一段字符串,遍历每个字符取出字模,合并成一个字模表数组输出,对做菜单字库、图标字库特别实用。我在某次 HMI 项目里用这个改出来的生成器,一次性把 200 多个汉字转成点阵数组,烧进屏幕逐字比对全部无误。从那以后我每拿到一套新字库,第一件事就是先塞进这个工具里过一遍,确认偏移和位序没问题再写驱动代码。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进