简介这是面向MCU开发与嵌入式学习者的点阵字体文件查看工具采用C#编写用于直观查看HZK16等16×16点阵字库数据尤其在低资源环境下的文本显示调试中很实用。工具以二维数组表示字符像素通过读取、解析和图形化展示帮助开发者快速确认字形并排查字库问题。压缩包内共15个文件结构精炼8个.cs源码承担文件读取、数据解析、界面逻辑与程序入口3个.resx保存窗体资源另含.sln/.csproj工程文件、App.config及Settings设置项整套工程可在Visual Studio中直接打开编译包体仅9KB轻量易用已有935人学习下载。通过完整项目代码可掌握System.IO读取字库文件、二进制数据解析、Windows Forms图形化展示以及缩放翻页等交互实现为理解嵌入式字符显示机制和自建类似工具提供实用参考也可作为C#桌面程序开发的演示范例。1. 点阵字体这个名词并不老C# 查看工具能救回大量旧字库点阵字体听起来是很古董的概念但在嵌入式LCD、老式工控屏和复古UI里它到今天还有大量存量而且格式比想象的更杂。我在一个工业屏项目里就栽过跟头屏幕只能显示8x8点阵现成查看工具要么只支持某个私有格式要么把位图放大得没法看。折腾两天后我干脆写了一个C#点阵字体文件查看工具把解析、渲染、缩放和批量导出全部跑通。这篇要拆的正是这套源码它解决的是未知点阵字库的肉眼查看和批量导出问题适合单片机工程师、做像素风界面的开发者也适合像你我一样需要临时读老字库的人。你不需要懂底层C跟着步骤就能跑通。2. 拆解二进制结构FNT0 格式定义与 C# 位操作很多点阵字体查看工具难用不是C#写不出来而是格式确认太折腾。市面上的字库文件常见的有HZK16、HZK32、FON、BDF还有各种LCD厂商自定义的私有二进制。HZK16是16x16汉字库每行两个字节BDF是文本格式而老式工业屏常用的是连文档都找不到的裸字模。这套源码里默认解析一个抽出来的简化格式“FNT0”格式设计比较干净方便你替换成自己的私有格式。2.1 格式定义8字节文件头加连续字模FNT0不是标准格式是我拆了几种常见字库后抽象出来的。整个文件由文件头加字模数据组成字模按字符编码顺序连续存放没有索引表。文件头固定8字节字节偏移长度字段说明0~34魔数固定为FNT041StartChar起始字符编码51EndChar结束字符编码61Width字体像素宽度71Height字体像素高度单个字形数据量不是Width * Height这么简单因为文件是字节流每行像素必须按字节对齐。例如宽度为8时每行占1字节宽度为12时每行仍要占2字节多余位补零。所以引入两个关键参数stride (Width 7) / 8表示每行占用的字节数glyphSize stride * Height表示每个字形占用的字节数。字符总数是EndChar - StartChar 1所以整个文件期望长度是8 glyphSize * (EndChar - StartChar 1)这个检查很重要后面坑1会用到。2.2 读取文件头并解析字形数据读取逻辑我放在BitmapFontLoader类里用静态方法加载byte[]。之所以不直接传入文件路径是为了让单元测试可以传入内存构造的任意字节数组不用真实文件。public static BitmapFont LoadFont(byte[] raw) { // 魔数固定为 FNT0占用前4字节 if (raw.Length 8 || raw[0] ! F || raw[1] ! N || raw[2] ! T || raw[3] ! 0) throw new FormatException(文件头魔数不匹配不是FNT0点阵字体); var font new BitmapFont(); font.StartChar raw[4]; font.EndChar raw[5]; font.Width raw[6]; font.Height raw[7]; int charCount font.EndChar - font.StartChar 1; int stride (font.Width 7) / 8; // 每行按字节对齐后的宽度 int glyphSize stride * font.Height; // 单个字形占用的字节数 int expectedLength 8 glyphSize * charCount; if (raw.Length expectedLength) throw new InvalidDataException($文件长度不足期望{expectedLength}字节实际{raw.Length}字节); font.Glyphs new byte[charCount][]; int cursor 8; for (int i 0; i charCount; i) { font.Glyphs[i] new byte[glyphSize]; Array.Copy(raw, cursor, font.Glyphs[i], 0, glyphSize); cursor glyphSize; } return font; }这段代码先校验魔数和长度再读取头部字段。关键参数是stride和glyphSize如果文件被截断会提前抛异常而不是解析到一半崩溃。这里字形数据用byte[][]而不是二维数组是因为每个字形需要独立引用后续按索引取某一字符更顺手。若你的文件比expectedLength大说明可能带尾部信息这里直接忽略多余部分。2.3 MSB-first 和 LSB-first位顺序决定是否镜像点阵字模最坑的是每个字节里哪一位对应最左边像素。多数老字库采用MSB-first即一个字节的最高位对应左边第一个像素但也有反过来的尤其是一些LCD厂商私有格式。下面这段代码把字节数组转成0/1像素矩阵用一个bool控制顺序。bool msbFirst true; // 通常为true若镜像翻转则改为false for (int row 0; row height; row) { for (int col 0; col width; col) { byte b glyph[row * stride col / 8]; int bit msbFirst ? (7 - col % 8) : (col % 8); pixels[row, col] (byte)((b bit) 0x01); } }col / 8得到当前列所在的字节索引col % 8是字节内偏移。msbFirst为true时取位7也就是最高位。宽度不是8的倍数时每行末尾多余的位通常补零但我们不读这些位对显示不影响。这个bool建议做成界面上的一个“翻转”按钮后面坑2会细说。3. 渲染与交互在 WinForms 里让 8x8 像素放大不模糊字体文件解析完只是第一步查看工具的核心体验在渲染。点阵字体的原始尺寸往往只有8到16像素直接拉大很考验渲染方式。这一章讲我在源码里用的 WinForms 渲染方案。3.1 自绘控件与 Paint 事件我自定义了一个PixelFontView控件继承自UserControl把所有字形画在一个网格里。这样滚动、缩放和刷新都集中在一个区域。public sealed class PixelFontView : UserControl { private BitmapFont _font; private float _scale 10f; public void SetFont(BitmapFont font) { _font font; Invalidate(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_font null) return; e.Graphics.Clear(Color.Black); int cols Math.Max(1, (Width - 20) / (int)(_font.Width * _scale)); int x0 10, y0 10; int count _font.Glyphs.Length; for (int i 0; i count; i) { int row i / cols; int col i % cols; int x x0 col * (int)(_font.Width * _scale); int y y0 row * (int)(_font.Height * _scale); DrawGlyph(e.Graphics, _font, _font.StartChar i, x, y); } } private void DrawGlyph(Graphics g, BitmapFont font, int charCode, int x, int y) { byte[] glyph font.Glyphs[charCode - font.StartChar]; int stride (font.Width 7) / 8; for (int py 0; py font.Height; py) { for (int px 0; px font.Width; px) { byte b glyph[py * stride px / 8]; int bit 7 - px % 8; if (((b bit) 1) 1) { e.Graphics.FillRectangle(Brushes.White, x px * _scale, y py * _scale, _scale, _scale); } } } } }注意OnPaint里我用FillRectangle直接按缩放比例画实心矩形没有经过DrawImage。这样最可控缩放倍数非整数也不会有插值问题。Brush用白色比较省事想换前景色可以用SolidBrush存成成员变量。这里_scale是关键参数初始10倍是为了看清8x8像素如果字体分辨率是16x16建议初始5倍。3.2 鼠标滚轮缩放与最近邻采样如果要在放大时保留原始锯齿风格绝不能使用默认的Graphics.DrawImage。WinForms 默认插值是双线性会导致边缘出现灰色过渡。滚轮缩放代码如下protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); if (e.Delta 0) _scale Math.Min(30f, _scale * 1.1f); else _scale Math.Max(1f, _scale / 1.1f); Invalidate(); }滚轮每次贡献的Delta通常为120这里直接按比例调整_scale。最小1倍最大30倍防止用户把字体放大到看不出像素界限。如果你更想用DrawImage画字形位图那必须显式设置最近邻和半像素偏移g.InterpolationMode InterpolationMode.NearestNeighbor; g.PixelOffsetMode PixelOffsetMode.Half;PixelOffsetMode.Half也很重要它让采样点对准像素中心避免放大后整体偏半格。我自己实测过只设NearestNeighbor不够加上Half后边缘才锐利。这是所有像素风格渲染的通用套路。3.3 一次性读入 vs 按需延迟加载FNT0 这种小字库一个文件最多几百KB完全可以一次读入内存。我在LoadFont里用File.ReadAllBytes读完再走内存解析这样后续操作不碰磁盘。但如果是全量 GBK 点阵字库几千个汉字一次性加载可能吃掉8MB以上此时建议只解析用户当前请求的字符。常见做法是建立索引表头文件里记录每个字形在文件中的偏移按需FileStream.Seek后读取单个字形。这一版的源码没有做“懒加载”是因为定位是查看工具而非字体生成库。文件超过5MB时我开始考虑延迟加载如果你要做成编辑器最好一开始就设计成按需读取。4. 踩坑实录点阵字体查看器最容易翻车的四个地方这里写的是我在这个项目里真实遇到过的坑按“现象 → 原因 → 解决”的顺序记录。每一条都值得提前避开。4.1 坑1文件长度与头部声明不一致导致越界现象解析到一半抛出IndexOutOfRangeException或者界面上显示的字形全是乱码每个字符的像素叠加在一起。原因文件头EndChar - StartChar 1计算出的字符数量比文件实际能容纳的字形数量多。常见于从老设备备份出来的字库文件被截断或者是把不同文件的头和数据硬拼在一起。没有在解析前做总长度校验直接按循环读就会越界。解决在LoadFont开头用raw.Length对比期望长度这是我的习惯int expectedLength 8 glyphSize * charCount; if (raw.Length expectedLength) throw new InvalidDataException($文件长度不足期望{expectedLength}字节实际{raw.Length}字节);注意这里用的是而不是!。很多复制传输会多出几个尾部填充字节只要不短于期望长度就能解析。如果你发现合理文件总是长度不足那第一优先怀疑头部字段被单字节读成了无符号数导致起始/结束字符区间错误。4.2 坑2位顺序搞反导致字符左右镜像现象字符看起来左右翻转比如显示‘A’时尖朝右或者整个字形呈“面条”状混乱排列。原因文件实际是LSB-first排列代码却按MSB-first解析导致每个字节内的位被颠倒读取。对8x8字形来说这条翻转会让所有垂直线条变成对称乱码因为左右边界完全换了位置。解决做一个切换开关而不是代码里写死。最简单的方式是把 2.3 节的msbFirst变量提升为控件属性然后绑到界面按钮public bool MsbFirst { get; set; } true; private void FlipButton_Click(object sender, EventArgs e) { MsbFirst !MsbFirst; pixelFontView.Invalidate(); }切换后用肉眼观察一个字母的镜像是否正常。也可以更进一步自动识别比较空格和‘M’这类字符左右对称的特征但多数情况下手动切换一次就够了。这里我给源码留下一个“翻转”按钮避免用户猜半天。4.3 坑3宽度不是8的倍数时 stride 算错现象每个字符都整体错位越到后面的字形越不连贯明明一行只有8个像素却从下一行中间开始。原因部分自定义格式里宽度为12、16或24时每行字节数不是简单的Width / 8。我一开始写代码图省事用了int stride width / 8当宽度12时算出1实际每行应该补到2字节。于是解析器把上一行的后半字节当成下一行开头越错越远。解决统一用(Width 7) / 8并且把stride作为字段保存在BitmapFont里而不是每次临时算。同时很多老格式有额外强制对齐规则例如 HZK16 是16宽度每行2字节但某些LCD私有格式会强制每行4字节即使只有8像素宽。遇到这种就给BitmapFont增加一个LineBytes属性在解析时覆盖stride。public int Stride (Width 7) / 8;把Stride做成只读属性所有取字模的地方统一走它就不会出现两处计算不一致。4.4 坑4缩放后边缘发虚看起来像用了劣质插值现象放大后边缘有灰阶像把像素字体做了“羽化”完全失去点阵字的锐利感。原因用Graphics.DrawImage时默认的InterpolationMode是双线性插值。双线性会在像素之间生成过渡色对照片是好事对点阵字体是灾难。另外PixelOffsetMode默认是InvalidOffset也可能让采样点落在像素边界上。解决如果你走DrawImage路线必须同时设置两个属性g.InterpolationMode InterpolationMode.NearestNeighbor; g.PixelOffsetMode PixelOffsetMode.Half;我最后还是干脆放弃了DrawImage直接在OnPaint里用FillRectangle画每一个像素点。当缩放倍数是整数时这种方式绝对锐利当缩放倍数是非整数比如1.37倍时FillRectangle同样会有轻微不匀但只要把_scale控制在整数附近肉眼几乎感知不到。for (int py 0; py font.Height; py) { for (int px 0; px font.Width; px) { if (IsPixelOn(glyph, stride, px, py)) { e.Graphics.FillRectangle(Brushes.White, x px * (int)_scale, y py * (int)_scale, (float)Math.Ceiling(_scale), (float)Math.Ceiling(_scale)); } } }这里把缩放值先取整再用Ceiling调整矩形宽度是为了避免像素之间出现细缝。这是我调了很久才定下来的细节。5. 进阶技巧把查看器变成批量导出工具并守护解析逻辑查看器只能看还不够实际项目里往往要把字库导出成图片或文本给美术或嵌入式同事确认。这个源码里的导出功能很直接遍历所有字形逐个输出成文本点阵。为了方便我定义了一个DumpGlyphToText方法把像素输出成#和.两种字符。public static string DumpGlyphToText(BitmapFont font, int charIndex) { int stride font.Stride; byte[] glyph font.Glyphs[charIndex]; StringBuilder sb new StringBuilder(); for (int row 0; row font.Height; row) { for (int col 0; col font.Width; col) { byte b glyph[row * stride col / 8]; int bit 7 - col % 8; sb.Append(((b bit) 1) 1 ? # : .); } sb.AppendLine(); } return sb.ToString(); }这个方法只要几十行但解决了很大的问题你不需要打开丑陋的工具就能直接在一份纯文本里核对字模。配合File.WriteAllText可以批量导出整个字库。源码里还附带了一个我最小的NUnit测试项目专门守住解析逻辑。核心测试用例是构造一个已知的字模文件断言像素数量和特定字符的形状。[Test] public void LoadFont_TwoGlyphs_ExpectCorrectLength() { byte[] raw BuildFnt0( startChar: A, endChar: B, width: 8, height: 8, glyphBits: new byte[] { 0x10, 0x12, 0xFE, 0x12, 0x10, 0x00, 0x00, 0x00 } ); BitmapFont font BitmapFontLoader.LoadFont(raw); Assert.AreEqual(2, font.Glyphs.Length); Assert.AreEqual(8 * 8, font.Glyphs[0].Sum(p p)); }这类测试的价值在于你换一个陌生格式时先写一个最小的字节数组喂给解析器确认无误再去解析真实文件排错成本会大幅降低。从那以后我每次拿到陌生字体文件都强制自己先写一个十六进制 dump 的小函数看一眼前64字节再决定解析方式。这个习惯帮我避开了至少三次格式翻车。希望帮到你。本文还有配套的精品资源点击获取