自制工业控制系统:图形系统实战——2D渲染、中文点阵字体、控件与事件分发
Contents
起因
画布(Screen)之上没有任何 UI 基础设施——没有窗口系统、没有字体、没有控件库,全部自己建。要建的东西里最老的两样各有来历:画斜线的 Bresenham 算法是 1962 年 Jack Bresenham 在绘图仪时代发明的,整数增量、零浮点——那年头浮点运算贵得要用在刀刃上;中文显示走点阵则踩着 1980 年代国产微机汉卡的老路,当年在几百 KB 内存里显示汉字,点阵是唯一现实的选择。这篇把图形系统搭起来:2D 渲染原语、中文点阵字体、控件模型、事件分发,全部跑在协作式主循环里。目标界面在写代码前就该想清楚:一块坐标/进度区、一排规准与状态灯、一条报警栏、几个按钮和编辑框——就这些,没有窗口拖拽没有多层叠加。工业界面长什么样,决定这套系统长什么样:中文、高对比、状态一目了然。
一、渲染原语:够用就好的一小撮
在后备缓冲(width 步进)上实现,常用的就这几个:
|
|
脏矩形(dirty rect)是性能的命门:每帧只重绘变化的矩形区域并只拷贝那几行上屏。1080p 全刷是 8MB,一个 200×40 的状态数字刷新是 32KB——差 250 倍。控件标记自己脏,present 只拷脏矩形的行区间;同帧多个脏矩形先按 y 排序、合并相邻的,避免同一行来回拷三遍。静态工控界面下这个设计几乎免费。
坑:所有原语入口先裁剪再画。 控件传进来的矩形越界(负坐标、超宽高)时,set 不是 panic 就是把像素写到别人控件里——每个绘制原语第一行都是跟画布求交,越界部分静默丢弃。调试期留一个"越界即记日志"的断言版本,发布版关掉。
二、中文字体:从一个字怎么变成屏幕上的点讲起
先看一个字是怎么显示出来的。 屏幕上一个汉字,本质是一张小位图贴到画布上:16×16 的字模,每行 16 个点用 2 字节存(1 bit 一个点),16 行共 32 字节。位是 1 画前景色,是 0 留背景。所以"显示一个字"落到代码上就三步:查到这 32 字节 → 逐行逐位扫 → 是 1 就往后备缓冲写一个像素。8×16 的半角字符同理,每行 1 字节、共 16 字节。为什么用点阵不用矢量:TrueType 那类矢量字库存的是轮廓,显示前要做光栅化(贝塞尔求交、抗锯齿),是一整个渲染引擎的工程量;点阵直接存"已经光栅化好"的结果,工控界面 16px 的字号,点阵的显示效果完全够,换来的是渲染代码几十行。这一步想清楚了,“显示"的问题就只剩一个:几万个字的字模数据怎么组织、怎么查。
字库怎么构成。 核心是"码位 → 字模"这张查找表。最朴素的办法是按码位直接开偏移:CJK 基本区 0x4E00–0x9FFF 连续,offset = (cp - 0x4E00) * 32 就能查。这对成片的大块有效,但拉丁、西里尔、CJK 在码位空间里东一块西一块,按整个 Unicode 平面开稀疏数组是行不通的浪费。实际用两级稀疏索引:
|
|
查一个字:blk = BLOCKS[cp >> 8],码位落在 [start_cp, start_cp+count) 里就取 offset + (cp - start_cp) 的字模,查不到给固定的”□“兜底字模——覆盖范围在生成字库时就定了,运行期不会失败。
字模数据从哪来:从一款开源字体生成。用 Noto Sans CJK SC(SIL OFL 许可,明确允许商用、允许栅格化衍生),写个离线脚本按 16px 栅格化出点阵,按上面的格式输出二进制。CJK 基本区全收是 20902 字 ≈ 669KB;只收常用 7000 字 ≈ 224KB——图省心就收全,嫌内核体积就收常用的。生成脚本是一次性离线工具,产物二进制 include_bytes! 编进内核,跟零依赖策略一条线:字库跟着内核走,不依赖磁盘文件,启动即有 UI。
|
|
最后才是编码。 字库按 Unicode 码位组织了,字符串侧就顺势定死:系统内一切文本 UTF-8。&str 的 chars() 迭代直接产出码位,喂给两级索引,中间不需要任何码表转换。要守的纪律只有一条:Rust 的字符串切片按字节算,&s[i..j] 下标切在中文多字节中间会直接 panic——一切文本处理按 chars() 迭代走,宽度信息(全角 16 / 半角 8)跟着码位从 block 表查。这条纪律在国际化篇还要接着用。
顺带把一个老坑埋掉:商用点阵字库(方正、汉仪这些)的授权雷区在工控圈出名地多。从 OFL 字体自己生成点阵,版权问题就从"买授权"变成了"没有问题”。
三、控件模型:enum 分发,不搞继承
最小控件集合:窗口、按钮、标签、编辑框、列表、状态栏。Rust 没有继承,也不需要——每个控件是"一块矩形 + 一个绘制函数 + 一个事件处理函数":
|
|
控件集合扁平化:一个 Vec<Widget> 顺序遍历,不做嵌套控件树——工控界面没有层叠窗口,命中测试就是线性扫一遍矩形,几十个控件根本用不上树。每个控件自带视觉状态:按钮分常态/悬停/按压/禁用四套配色,编辑框有 500ms 闪烁的光标(它就是个自带定时脏标记的小控件)。布局用坐标常量 + 简单锚点(贴边、居中),不做通用布局引擎。配色高对比:车间强光环境,前景白/背景深灰起步,报警红、运行绿、参数黄各自固定,颜色即语义——操作工扫一眼颜色就知道机器状态,这比任何动画都有用。
四、事件分发与渲染节律
输入来自 HID 篇的 SPSC 队列,图形系统作为协作层的一个 step 函数跑:
|
|
两条节律纪律:
- 加工状态刷新 10–20Hz 封顶:状态回传队列(调度篇)里的坐标、电流、进度由状态栏消费,刷新够快人眼就认为实时——省下的 CPU 时间留给控制和 IO,不追 60fps。鼠标 HID 是 8ms 一次中断上报(键鼠篇),
ui_step以 10ms 节拍消化,队列天然削峰,两边节拍解耦 - 回调永远不在中断上下文执行:HID 中断只往队列塞事件,UI 逻辑全部在协作层消化——这是两级模型在图形系统的投影
总结
实战清单:hline/line/blit 原语 + 脏矩形机制、Unicode 两级稀疏索引的生成式点阵字库 + draw_text 按码位迭代、enum 分发的控件模型 + 高对比语义色、ui_step 里"清队列→分发→脏重绘"的固定节拍。跑通标志:屏幕上出现中文按钮,鼠标(HID 篇完成后)能点出按压态,状态栏以 15Hz 刷新不闪烁;混排一行中英文和西里尔文不乱码。下一篇去挖总线:PCIe 枚举,找到那块运动控制卡。
参考链接
- OSDev Wiki: Video Signals——扫描与撕裂的背景
- Bresenham 直线算法
- 本系列显卡篇:Intel集成显卡实战——从GOP帧缓冲接管显示输出;调度篇:中断驱动的硬实时层与协作式主循环
Author 软件开发大郭
LastMod 2026-09-11