☰
基于EDK2的UEFI中文拼音输入法实现与QEMU验证
2026/10/1 14:55:03 网站建设 项目流程

我折腾 UEFI 固件也有几年了,平时给人远程解决问题的次数不算少,每次碰到要在 BIOS 里输入中文的需求就觉得特别拧巴。搜索栏里敲拼音是不行的,手写区只能输英文和数字,设备标签、资产编号、资产责任人这类字段,只要涉及到汉字,基本上就是两眼一抹黑。后来我索性自己动手,基于 EDK2 写了一个可以在 UEFI 环境里跑起来的中文拼音输入法模块,共享出来给有同样需求的朋友研究。这篇博文不聊空理论,完整记录这个项目从需求拆解、代码实现到 QEMU 验证的全过程,以及我踩过的那些坑。

UEFI 中文输入法这个方向,网上资料少得可怜,能找到的多半是“改字体、显示中文”的教程,真正能做到“输入中文”的几乎没有。倒不是因为技术上做了多难的事,而是这个需求确实太小众,绝大多数人不会想着在 BIOS 设置界面里打汉字。但对我这种经常要给设备做资产登记、要在 UEFI 下写中文备注的人来说,这个功能就是刚需。既然官方不给做,那我们就自己来。

1. 项目整体设计与思路拆解

1.1 为什么 BIOS/UEFI 一直缺一套中文输入法

先得搞清楚一个基础问题:UEFI 能不能显示中文?答案是能。UEFI 规范里,字符串统一使用 UCS-2 编码,每个字符固定两个字节,Unicode 码点直接支持常用汉字。大部分主板固件也都内嵌了中文字库,否则你开机进 BIOS 切到简体中文界面,看到的就应该是乱码而不是正常汉字了。

那问题出在哪?出在输入环节上。UEFI 的键盘协议只能拿到硬件的按键事件,常见实现是 USB 键盘的扫描码和 KeyCode。固件内部做的字符串处理,也基本是 ASCII 级别的“按哪个键插哪个字符”。你按一个字母键,就拿一个 ASCII 码,这套逻辑从小到大没人想着去扩展。要支持中文,意味着得做一套完整的输入法框架:接收拼音字母、维护拼音到汉字的映射表、候选词选择、以及把最终选中的汉字以 UCS-2 形式写回文本框。这一套做下来,不是不能做,是没人愿意在产品规划里给这个功能排期。毕竟 BIOS 设置界面一年才进几次,合理。

1.2 这个项目最终交付了什么

我做的这个模块,本质上是一个独立的 UEFI 应用程序,也可以作为 DXE 驱动集成到固件里。它实现的功能是:在任意一个 UEFI 文本输入场景里,通过快捷键唤起拼音输入模式,然后按照拼音全拼输入汉字,候选词直接以中文显示在屏幕上,回车确认后把选中的汉字通过 UEFI 的文本输入协议回传给调用方。

项目代码采用标准 EDK2 工程结构,模块名定为ChinesePinyinInput。核心组成部分包括:

  • 拼音到汉字的一二级字库映射表,覆盖 GB2312 常用汉字,大约 3700 个常用拼音组合,13000 多个汉字条目
  • 基于顺序表的拼音检索与候选词排序逻辑
  • 基于 UEFI GOP(Graphics Output Protocol)的中文渲染输出模块,不依赖固件内部字体,直接渲染内置点阵字库,保证在任何机器上都能正常显示
  • 一个模拟输入循环的测试外壳,方便在 QEMU 虚拟机里单独启动验证

选 QEMU 来验证是刻意的。直接在真机上刷固件测试,风险太大,变砖成本高。QEMU 支持 OVMF(开放虚拟固件),可以直接加载 UEFI 应用,整个开发迭代回到编译、运行、看日志的快节奏里,不用频繁断电重启。

2. 核心细节解析与实操要点

2.1 UEFI 键盘输入与文本协议的本质

要说这个项目的关键点,绕不开 UEFI 的键盘协议。UEFI 定义了EFI_SIMPLE_TEXT_INPUT_EX_PROTOCOL,它比老的EFI_SIMPLE_TEXT_INPUT_PROTOCOL更强大。后者一次只能拿到一个EFI_INPUT_KEY,包含扫描码和 Unicode 字符;前者能拿到完整的EFI_KEY_DATA,里面带有按键状态、切换状态等信息。

我的输入法要想被普通固件应用调用,方法有两种:

  1. 直接作为独立的 UEFI 应用运行,自己接管 GOP 输出和Timer事件做键盘轮询
  2. 实现一个代理输入协议,替换ConIn的默认实现,劫持按键流

我选了第一种,原因是开发周期短,验证直接,不涉及到底层协议替换的兼容性问题。后续如果要做成驱动集成进固件,核心检索逻辑和界面渲染逻辑可以原封不动搬过去,只需要改一改外层的协议挂接部分。

键盘输入的轮询则采用了EFI_TIMER_ARCH_PROTOCOL配合事件回调,每 10ms 检测一次键盘缓冲区。这里有个特别要注意的细节:UEFI 的WaitForKey事件在控制台应用里很容易丢键,因为固件控制台驱动和你的应用同时在争抢同一个ConIn。所以我强烈建议用周期性事件加手动读键盘的方式,而不是干等事件触发。实测下来,10ms 轮询完全够用,手感上没有明显延迟。

2.2 汉字怎么在屏幕上画出来,才是真正的分水岭

如果你只是想在 UEFI Shell 里输出中文,很多固件可以直接用Print打 UCS-2 字符串。但那是基于固件自带字体服务EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL的OutputString,实际效果高度依赖固件实现——有些机器字库不全,缺字就显示成方框,毫无办法。

我的方案绕过了固件字体服务,直接用 GOP 在 Framebuffer 上画像素。自己内置一份 16x16 点阵汉字库,UCS-2 码点到像素点阵的映射自己维护。这样带来的好处显而易见:

  • 显示效果在所有机器上完全一致,跟固件字库无关
  • 候选词、提示文字、输入框全部统一风格
  • 后续想加生僻字、加粗字体,都只需要扩展点阵数据就行

代价也很明显,点阵库比较占空间。16x16 点阵,每个汉字 32 字节,GB2312 常用 6763 字,总计也就 216KB 左右。用 UEFI 的 NVRAM 或者文件系统加载都能接受,编进模块里也行,整个应用体积最大也就 300KB 上下,完全在 UEFI 可执行文件的合理范围内。

2.3 拼音检索的数据结构选择

中文输入法最难的不是界面,是拼音到汉字的映射检索效率。UEFI 环境没有操作系统,没有动态内存的便利机制,不能随意 malloc 一个巨大的哈希表,也不适合做复杂的字符串索引。我最终用了最简单但足够可靠的方案:有序拼音表 + 二分查找。

整个字库表按拼音字母序静态排列,每个拼音条目对应一个{ pinyin_string, 候选汉字数组 }结构。查找时,用用户输入的拼音串对拼音表做二分查找,命中后把候选汉字数组读取出来,按常用度排序展示。3700 个拼音条目,二分查找最多比较约 12 次,一次定位完成,性能完全不是瓶颈。

候选汉字的数组采用定长结构#define MAX_CANDIDATES 32,每个条目存 UCS-2 码点。这基于一个经验判断:一般拼音的候选中,超过 32 个字的情况极少,即便超过,让用户翻页也比一次堆满屏幕更友好。数据结构简单可靠,在裸金属 UEFI 环境下这点尤其重要。

3. 实操过程与核心环节实现

3.1 搭建 EDK2 编译环境

第一步是准备 EDK2 开发环境。我用的版本是 edk2-stable202311,系统为 Ubuntu(版本无所谓,主要依赖编译工具链)。

git clone https://github.com/tianocore/edk2.git -b edk2-stable202311 cd edk2 git submodule update --init --recursive make -C BaseTools

编译 EDK2 项目前必须记得完成子模块初始化,缺失子模块会让编译在找BaseCryptLib等依赖时报出各种奇怪错误。实测很多新手卡在这一步,但错误信息看起来跟子模块毫无关系。

需要确认编译工具链存在,没有就装:

sudo apt install build-essential uuid-dev nasm python3

然后设置环境变量并编译 OVMF(用于后续 QEMU 验证):

source edksetup.sh build -a X64 -p OvmfPkg/OvmfPkgX64.dsc -b DEBUG -t GCC5

编译产物OVMF_CODE.fd和OVMF_VARS.fd就是 QEMU 需要加载的固件镜像。

3.2 模块工程结构

EDK2 的应用模块,最精简的工程结构包含三个文件:.inf模块描述文件、.c主逻辑源码、.dsc或.fdf里的模块注册(如果是要集成进固件)。我这个实验工程结构如下:

ChinesePinyinInput/ ├── ChinesePinyinInput.inf ├── ChinesePinyinInput.c ├── PinyinTable.h ├── PinyinTable.c ├── Font16.c └── Font16.h

.inf文件内容(精简版):

[Defines] INF_VERSION = 0x00010005 BASE_NAME = ChinesePinyinInput FILE_GUID = 8E8F6D51-2E74-4b0e-A4D6-97D1414E59A2 MODULE_TYPE = UEFI_APPLICATION ENTRY_POINT = PinyinInputMain [Sources] ChinesePinyinInput.c PinyinTable.c Font16.c [Packages] MdePkg/MdePkg.dec MdeModulePkg/MdeModulePkg.dec [LibraryClasses] UefiApplicationEntryPoint UefiLib UefiBootServicesTableLib UefiRuntimeServicesTableLib PrintLib [Protocols] gEfiGraphicsOutputProtocolGuid gEfiSimpleTextInputExProtocolGuid

FILE_GUID是模块的唯一标识,不能跟其他模块重复。如果你要编译多次改 GUID,直接改这里就行。

3.3 拼音检索核心实现

核心检索函数按“全拼匹配”实现。输入"zhong",二分查找命中"zhong"条目,取出候选字数组。代码如下:

#include <Uefi.h> #include <Library/UefiLib.h> #include <Library/BaseMemoryLib.h> #include <Library/MemoryAllocationLib.h> extern PINYIN_ENTRY mPinyinTable[]; extern UINTN mPinyinTableSize; UINTN PinyinSearch (IN CHAR16 *Pinyin) { UINTN Left = 0; UINTN Right = mPinyinTableSize - 1; UINTN Middle; INTN Result; while (Left <= Right) { Middle = (Left + Right) / 2; Result = StrnCaseCmp (Pinyin, mPinyinTable[Middle].Pinyin, MAX_PINYIN_LEN); if (Result == 0) { return Middle; } if (Result < 0) { Right = Middle - 1; } else { Left = Middle + 1; } } return (UINTN)-1; }

StrnCaseCmp是 UEFI 库函数,直接支持大小写不敏感比较,减少一层输入串预处理。为什么限制最大拼音长度?因为汉语拼音全拼最长也就 6 个字母(比如 zhuang),做定长截断既安全又省内存。

3.4 点阵字模渲染逻辑

渲染部分用 GOP 协议获取当前图形模式下的 Framebuffer 地址、分辨率、像素格式。16x16 点阵字的每个像素对应一个字模字节的一个比特位,置 1 则画点,置 0 则跳过。

EFI_STATUS DrawGlyph ( IN EFI_GRAPHICS_OUTPUT_PROTOCOL *Gop, IN UINTN X, IN UINTN Y, IN UINT16 UnicodeChar ) { UINT8 *Glyph; UINT32 PixelColor; UINT32 BgColor; UINTN Row, Col; Glyph = GetGlyphFromFont (UnicodeChar); PixelColor = 0xFFFFFFFF; // 白色前景 BgColor = 0xFF000000; // 黑色背景 for (Row = 0; Row < 16; Row++) { for (Col = 0; Col < 16; Col++) { if (Glyph[Row * 2 + Col / 8] & (0x80 >> (Col % 8))) { DrawPixel (Gop, X + Col, Y + Row, PixelColor); } else { DrawPixel (Gop, X + Col, Y + Row, BgColor); } } } return EFI_SUCCESS; }

注意点阵字模的位排列:我的数据按“逐行、高位在前”的方式存储,每行两个字节,对应 16 个像素。生成字模时如果不统一好这个约定,画出来的字会左右镜像或者上下颠倒,这是所有点阵渲染最容易出问题的地方。

3.5 主程序输入循环

输入循环是整个程序的骨架,完整逻辑如下:

  1. 等待用户按键
  2. 如果按F2或Ctrl+Space,切换输入法开关
  3. 输入法打开时,字母键进入拼音缓冲区,数字键选择候选,空格上屏,退格删除拼音
  4. 输入法关闭时,所有按键原样回写,表现为普通英文输入
  5. 每次状态变更后,重绘整个输入区域

核心伪代码:

while (TRUE) { Status = WaitForKeyEvent (); // 等待事件触发 Status = ConInEx->ReadKeyStrokeEx (ConInEx, &KeyData); Key = KeyData.Key; if (Key.ScanCode == SCAN_F2) { InputMode = !InputMode; DrawStatusBar (); continue; } if (InputMode && IsLetter (Key.UnicodeChar)) { AppendToPinyinBuffer (Key.UnicodeChar); RefreshCandidates (); continue; } if (InputMode && Key.UnicodeChar == L' ') { CommitCurrentCandidate (); continue; } if (InputMode && Key.UnicodeChar >= L'1' && Key.UnicodeChar <= L'9') { SelectCandidate (Key.UnicodeChar - L'1'); continue; } }

实测里最值得留意的是WaitForKeyEvent和ReadKeyStrokeEx的组合行为。WaitForKeyEvent返回后,键盘缓冲区里可能积累多个按键,必须一次性把缓冲区读空,否则会有“键飘”的错觉——你按了三个字母,界面上只显示一个。

4. 在 QEMU 上完整验证流程

4.1 制作可启动的 UEFI 应用镜像

单独验证一个 UEFI 应用,最简单的办法是做一个 FAT 格式的启动盘镜像,把编译产物.efi文件放进去,用 QEMU 从该镜像启动,然后在 UEFI Shell 里手动运行。步骤:

# 生成一个 64MB 的 FAT 镜像 dd if=/dev/zero of=uefi_app.img bs=1M count=64 # 使用 mtools 或直接在宿主机挂载循环设备,把 efi 文件拷进去 mformat -i uefi_app.img -F :: mcopy -i uefi_app.img ChinesePinyinInput.efi ::

启动 QEMU:

qemu-system-x86_64 \ -machine q35 \ -drive if=pflash,format=raw,readonly=on,file=OVMF_CODE.fd \ -drive if=pflash,format=raw,file=OVMF_VARS.fd \ -drive file=uefi_app.img,format=raw \ -net none \ -m 1024

启动后 QEMU 会进入 OVMF 的菜单,默认会尝试从 FAT 镜像引导。如果 OVMF 没有直接进 Shell,就按ESC进入 Boot Manager,选择 UEFI Shell,然后在 Shell 里输入fs0:进入文件系统目录,再运行ChinesePinyinInput.efi。

4.2 真实验证效果与观察记录

运行应用后,屏幕会显示底部输入状态栏和候选词条。按F2打开输入法,输入"wo",界面立即出现五个候选字:我、握、窝、卧、沃。按数字键1选中“我”,字符以 16x16 点阵渲染到输入区域,一切符合预期。

这里特别提一个 QEMU 环境的天然优势:OVMF 的高分辨率 GOP 模式默认能跑到 800x600 或 1024x768,我的点阵渲染逻辑在 16x16 的缩放比例下清晰可见。如果你要跑高分屏的机器测试,建议把分辨率锁定在 800x600,因为字模是固定像素的,分辨率越高字看起来就越小。

4.3 编译集成进固件镜像的扩展思路

如果你的目标不是做实验,而是把输入法集成到真正的 BIOS 固件里,方向上需要改两处:

  1. 将MODULE_TYPE从UEFI_APPLICATION改为DXE_DRIVER
  2. 在平台固件的.dsc和.fdf文件中追加模块引用与 DXE 阶段的驱动加载条目

集成后有两点不得不提醒:

  • 安全启动兼容性:一旦固件开启 Secure Boot,任何未签名的 DXE 驱动都会被拒绝加载。自己做实验时,需要关闭 Secure Boot 或使用 Platform Key 自行签名。这部分不在本文展开,但方向是明确的。
  • 内存占用:点阵字库存的是静态数组,编进固件后相当于在 BIOS 芯片里多了几百 KB 占用。对于老平台,固件镜像体积限制紧,需要考虑外置存储加载的方式。

5. 常见问题与排查技巧实录

5.1 中文全部显示成方框

现象:候选词条区域出现整排方块,而不是汉字。

排查方向:

  • 先确认你的渲染函数是否走的是 GOP 自绘路径。如果某个地方回退到了Print或OutputString,那显示效果就由固件字库决定,不保证有中文字形
  • 再确认字模表编码是否和实际 UCS-2 码点匹配。很多从网上找的点阵字库是按区位码组织的,并不是 Unicode 码点顺序,必须做一层转换
  • 检查点阵数据下标计算是否越界。汉字"我"的码点是0x6211,直接拿它当数组下标根本不可能命中正确字模,需要先用映射函数转换到字库内部索引

这个坑我踩了整整一个晚上,最后发现是字模数据本身按 GB2312 区位排列,而我的渲染函数直接用 Unicode 码点索引,两张皮对不上。

5.2 按键强烈的“粘连”感

现象:字母输入时感觉按键被延迟,连续按三个字母只能显示两个,偶尔出现重复插入。

原因:ReadKeyStrokeEx只读了一个事件对应的按键,缓冲区里还残留数据。尤其当输入法在每次按键后都要重绘整个候选栏时,重绘开销让读取频率下降,缓冲区堆积严重。

解决:改成读空模式,在每次按键事件触发后,循环调用ReadKeyStrokeEx直到返回EFI_NOT_READY,把这轮所有进入缓冲的键位全部消费完,再做 UI 重绘。

5.3 在真机上退出应用后无法输入数字

现象:应用运行期间一切正常,退出应用回到 UEFI Shell,发现数字键全部失效,只有字母能用。

这个问题非常隐蔽。原因在于我的输入循环里对键盘状态做了SetKeyToggleState的恢复逻辑,但应用退出时没有正确清理,固件的键盘驱动残留了某些状态值。

解决:在模块Unload函数里显式恢复所有 Toggle 状态,并调用gST->ConIn->Reset (gST->ConIn, FALSE)复位控制台输入设备。这个 “退出前必须复位” 的教训如果不记录下来,真机测试能让人怀疑人生。

5.4 候选词排序不符合使用习惯

调试过程中发现常用字没有排在候选第一位。比如输入"shi",排第一的居然是“蚀”,而不是“是”或“十”。

原因:我的初始字库表按拼音顺序遍历填充候选数组,候选汉字本身的排序则沿用了 GB2312 码位顺序——这个顺序跟汉字使用频率没有强相关。

解决:引入了一个简单的常用字优先级表,覆盖前 500 个高频汉字,检索后先按优先级表排序,其余按码位顺序兜底。效果显著,至少“的、一、是、在、我、有”这些高频字的操作体感正常了。

5.5 输入法切换快捷键跟固件快捷键冲突

实验时用Ctrl+Space作为切换快捷键,结果发现跟固件自己的某些热键功能冲突。比如部分厂商的固件把Ctrl+Space绑定为“恢复默认设置”,导致输入法开关没触发,BIOS 设置反而被重置了。

解决:最终改用F2作为唯一的切换键,并在状态栏上实时显示当前模式。实际体验下来,F2 在 BIOS 环境里很少被占用,冲突概率低。

6. 项目改造与扩展的可能方向

到这里,基础版本已经能用了。但我自己在收尾时也在想,这个项目其实还能往三个方向延伸。

一个是把码表换成五笔输入。拼音输入法适合普通用户,但对熟悉五笔的人来说,五笔的重码率低,输入效率高不少。数据结构上,把拼音键串替换成五笔编码串即可,其余的候选检索和渲染逻辑完全可以复用。

另一个是集成进真正的固件工程,给自己手头的主板做定制。想做这件事,建议先从 OVMF 源码开始练手,在 OvmfPkg 的 DXE 阶段先跑通集成流程,再尝试真机的固件改造。真机固件的定制涉及厂商私有模块、固件体积限制、签名校验等多层问题,务必做好变砖的心理准备和烧录器备份。

再一个方向是优化字体渲染。目前用的 16x16 点阵在 4K 分辨率下显得很粗糙,如果你有更高的视觉要求,可以换成 24x24 字模,或者引入等宽矢量字体的简易渲染。UEFI 环境下做矢量字体渲染,核心是移植一套轻量级的 TrueType 光栅化库——这不是开玩笑的事,工程量不小,但至少我验证过方向完全可行。

最后说点实际的个人经验。如果回到最初,我最庆幸的是选了“独立 UEFI 应用 + QEMU 验证”这条路。UEFI 开发一个非常大的特点是环境碎片化严重,同一个代码在 OVMF 上跑通了,到了真机可能因为内存布局、协议版本、固件实现差异冒出各种问题。把验证环境做得越可控,后面排查真机问题时能排除掉的变量就越多。

代码本身现在已经能在 OVMF 环境里正常编译运行,整个项目的源码组织也足够清晰,想要拿去研究的朋友可以直接从PinyinSearch的调用流程看起。如果你也遇到过 BIOS 里没法录入汉字的困扰,或者正在琢磨 UEFI 应用开发,建议你直接把仓库拉下来跑一跑,最简单的验证只要十分钟就能完成:编译、做镜像、QEMU 一跑,你在 UEFI 环境里打出第一个汉字的时候,会觉得这一切折腾都值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询