
1. 从一次光标闪烁说起NTLDR 启动阶段到底在做什么如果你用 WinDbg 内核调试过 Windows 的启动过程或者用 Bochs 单步跟过 NTLDR大概率会在osloader!DoGlobalInitialization里看到一行很不起眼的调用HW_CURSOR(0,127)。注释写着Turn the cursor off参数一个是 0一个是 127。很多人第一次看到会愣一下——关光标为什么传 127这个 127 到底进了哪个寄存器又是怎么一路走到 BIOS 的 INT 10H 的这篇就把这条链路完整拆开。核心检索词先摆出来osloader是 NTLDR 里负责加载内核的模块DoGlobalInitialization是它的全局初始化入口HW_CURSOR是外部服务表里的硬件光标函数指针最终通过INT 10H的 02H 功能号把光标位置写进 VGA 寄存器。适合谁看适合正在做启动流程分析、逆向 NTLDR、或者想搞懂实模式/保护模式切换下 BIOS 调用怎么落地的人。下面从源码片段、寄存器配置表到 Bochs/QEMU 单步验证一步步跟做。2. 前置准备TaoToken 与调试环境怎么搭分析这类启动代码光看静态反汇编不够得能单步、能看寄存器、能读内存。我自己的做法是静态源码用文本工具比对动态验证用 Bochs 或 QEMU 挂调试器。如果你需要一边查资料一边让模型帮你解释某段汇编的语义可以用 TaoToken 的模型对话能力做辅助理解它支持多模型切换适合这种“贴一段反汇编问它什么意思”的场景。先把入口地址记下来后面 CTA 会用到模型对话贴汇编问语义https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite接入文档看 API 怎么调https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentapi_docutm_campaignrewriteAPI Keys 管理https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite环境侧你需要准备三样东西一份 NTLDR 的符号或反汇编能定位osloader!DoGlobalInitialization、一个能跑实模式中断的模拟器Bochs 最直观QEMU 也可以、以及一个能看_EXTERNAL_SERVICES_TABLE结构的内存查看器。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 需要看产品全貌可以从这里进。3. 可复制配置从 DoGlobalInitialization 到 INT 10H 的完整链路3.1 调用点HW_CURSOR(0,127) 的宏展开先看调用现场。DoGlobalInitialization里第一件事就是关光标VOID DoGlobalInitialization( IN PBOOT_CONTEXT BootContextRecord ) { // // Turn the cursor off // HW_CURSOR(0,127); ... }HW_CURSOR不是函数是宏。它在bldrx86.h里定义#define HW_CURSOR (*ExternalServicesTable-HardwareCursor)也就是说HW_CURSOR(0,127)等价于调用ExternalServicesTable-HardwareCursor(0, 127)。这个ExternalServicesTable是_EXTERNAL_SERVICES_TABLE类型的指针挂在BootContextRecord上。从调试输出能看到它的偏移[0x004] ExternalServicesTable : 0x244ec [Type: _EXTERNAL_SERVICES_TABLE *] [0x018] HardwareCursor : 0x22ea8 [Type: void (__cdecl*)(unsigned long,unsigned long)]所以HardwareCursor的真实地址是0x22ea8参数是两个unsigned longY 和 X。调用时传的是(0, 127)即 Y0、X127。3.2 HardwareCursor 汇编参数怎么进寄存器HardwareCursor是一段汇编导出例程注释写得很清楚; Name: ; HardwareCursor ; Description: ; Positions the hardware cursor and performs other display stuff. ; Arguments: ; ULONG Y coord (0 based) ; ULONG X coord (0 based) ; TOS - ULONG Flat return address (must be used with KeCodeSelector) ; ; If X 0x80000000, then Y contains values that get placed into ; ax (low word of Y) and bx (hi word of y). ; Otherwise X,Y coors for cursor关键分支在这里如果 X 等于0x80000000走特殊路径把 Y 的高低字分别塞进ax和bx否则就是普通光标定位。我们这次传的 X127不等于0x80000000所以走普通路径EXPORT_ENTRY_MACRO HardwareCursor MAKE_STACK_FRAME_MACRO HardwareCursorFrame, ebx ENTER_REALMODE_MACRO push bp mov bp,sp add bp,2 mov eax,[bp].YCoord mov edx,[bp].XCoord cmp edx,80000000h jne gotxy mov ebx,eax shr ebx,16 jmp doint10 gotxy: mov dh,al ; DH 行(Y坐标) mov ah,2 ; 02H — 设置光标位置 mov bh,0 ; 显示页码 0 doint10: int 10h pop bp REMOVE_STACK_FRAME_MACRO HardwareCursorFrame RE_ENABLE_PAGING_MACRO EXPORT_EXIT_MACRO逐行拆ENTER_REALMODE_MACRO先把 CPU 从保护模式切回实模式因为 BIOS 中断只能在实模式下调。然后从栈帧里取 Y 和 Xmov dh,al把 Y 的低字节放进DHmov ah,2指定 INT 10H 的 02H 功能号mov bh,0指定显示页 0。最后int 10h触发 BIOS 调用。3.3 INT 10H 02H 功能寄存器配置表INT 10H 的 02H 功能就是“用文本坐标设置光标位置”。入口参数固定四个寄存器寄存器含义本次取值说明AH功能号02H设置光标位置BH显示页码0第 0 页DH行Y 坐标0第 0 行DL列X 坐标127第 127 列出口参数无。这里有个容易踩的点mov dh,al只搬了 Y 的低字节所以 Y 的有效范围是 0–255。传 0 没问题。而 X127 进的是DL127 列在 80 列文本模式下已经超出屏幕宽度BIOS 会把光标“推”到不可见区域效果就是关掉光标。这就是HW_CURSOR(0,127)关光标的原理——不是真的关是把光标挪到屏幕外。3.4 调试器里看到的调用现场在 WinDbg 里单步到调用点能看到压栈过程kd p osloader!DoGlobalInitialization0x3b: 004012a8 6a7f push 7Fh kd p osloader!DoGlobalInitialization0x3d: 004012aa 6a00 push 0push 7Fh压入 127Xpush 0压入 0Y。注意压栈顺序是反的先压 X 再压 Y因为__cdecl从右往左压参。继续单步到间接调用kd p osloader!DoGlobalInitialization0x45: 004012b2 ff5018 call dword ptr [eax18h] kd r eax000244ec ebx004013a2 ecx00000000 edx00064544 esi00024538 edi0048164f eip004012b2 esp00060ec4 ebp00060ed0 cs0008 ss0010 ds0010 fs0030 gs0000 efl00000046 ds:0010:0002450400022ea8eax0x244ec正是ExternalServicesTable的地址[eax18h]就是HardwareCursor字段取出来是0x22ea8。再看栈上的参数kd dd 00060ec4 00060ec4 00000000 0000007f 00024538 00061ff0esp0x60ec4处第一个是 0Y第二个是0x7fX127。完全对得上。4. 验证请求用 Bochs/QEMU 单步看光标位置变化4.1 Bochs 配置与断点Bochs 的调试能力比 QEMU 更适合这种场景因为它能直接看 VGA 寄存器。先准备一个能启动 NTLDR 的镜像然后在 bochsrc 里打开调试magic_break: enabled1 debug: actionreport启动后进调试器在HardwareCursor入口下断b 0x22ea8 c命中断点后先看寄存器状态确认已经进实模式r重点看CS和EFLAGS。实模式下CS是段基址EFLAGS的VM位为 0。然后单步执行到int 10h之前s s s每步之后用r看AH/DH/DL/BH是否变成02/00/7F/00。到int 10h执行完光标位置就被写进 VGA 的 CRTC 寄存器了。4.2 读 VGA 光标寄存器确认VGA 文本模式的光标位置存在 CRTC 的 0x0E 和 0x0F 两个寄存器里分别存高字节和低字节。光标位置的计算公式是cursor_offset row * 80 column本次 row0、column127算出来 offset127。但 80 列模式下有效列是 0–79127 已经越界BIOS 通常会把它钳到某个值或者直接写入。用 Bochs 的info vga或者直接读端口 0x3D4/0x3D5 可以验证out 0x3D4, 0x0E in 0x3D5 out 0x3D4, 0x0F in 0x3D5读出来的高低字节拼起来就是 BIOS 实际写入的光标偏移。如果和 127 不一致说明 BIOS 做了钳位但效果一样——光标不在可见区域。4.3 QEMU 侧的替代验证QEMU 没有 Bochs 那么细的 VGA 寄存器查看但可以用-d int打印中断调用qemu-system-i386 -fda ntldr.img -d int -no-reboot -monitor stdio-d int会把每次中断的寄存器状态打到日志里搜v02就能找到这次 INT 10H 调用日志里会带AH02 DH00 DL7F。这算是一种旁证。5. 本篇常见错排查5.1 断点下在 0x22ea8 却命不中最常见的原因是地址没重定位。0x22ea8是相对OsLoaderBase的偏移实际加载基址可能是0x400000。从调试输出看[0x020] OsLoaderBase : 0x400000所以真实地址是0x400000 0x22ea8 0x422ea8。断点要下在0x422ea8或者直接用符号osloader!HardwareCursor。5.2 单步进 int 10h 后跑飞int 10h会跳到 BIOS 的中断向量如果你在 Bochs 里没配好 BIOS 镜像或者断点设在了中断处理程序里就会一路跑进 ROM。正确做法是在int 10h指令上下断执行完立刻停不要s进去。用p单步过中断或者s之前先b在返回地址。5.3 读 VGA 寄存器读到 0xFF端口读写顺序错了。CRTC 的索引端口是0x3D4数据端口是0x3D5。必须先往0x3D4写索引再从0x3D5读数据。如果反过来读到的就是无效值。另外注意out/in指令在保护模式下需要 IOPL 权限Bochs 调试器里直接执行没问题但在 guest 代码里要确保 CPL0。5.4 X127 到底算不算越界80 列文本模式下列的有效范围是 0–79。127 确实越界。但 BIOS 的 02H 功能对越界值的处理是实现相关的有的 BIOS 直接写入有的钳到 79有的回绕。不管哪种结果都是光标不可见。如果你在真实硬件上看到光标还在闪可能是 BIOS 把 127 钳到了 79而 79 列在屏幕最右边视觉上不明显但确实存在。要彻底关光标更稳的做法是用 01H 功能设置光标形状为不可见而不是靠 02H 挪位置。5.5 保护模式切实模式失败ENTER_REALMODE_MACRO涉及 GDT、CR0、段寄存器一连串操作。如果单步时发现CS还是保护模式的选择子说明切换没完成。检查CR0的 PE 位是否清零以及CS是否被重新加载为实模式段值。这一步出错后面的int 10h会直接触发异常。6. 继续深入的方向与工具入口把这条链路走通之后你可以顺着_EXTERNAL_SERVICES_TABLE往下挖里面还有InitializeDisplayForNt、DetectHardware、DiskIOSystem等一堆函数指针每一个都是一条独立的 BIOS 调用链。HardwareCursor只是最简单的一个因为它只用了 INT 10H 的 02H 一个功能号。如果你在跟DoGlobalInitialization的其他分支时遇到看不懂的汇编可以把片段贴到模型对话里让它帮你逐行解释模型对话https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite接入文档https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentapi_docutm_campaignrewriteAPI Keyshttps://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite如果你是要长期做启动流程逆向或者写自动化分析脚本可以考虑 Coding Plan把这种重复的“贴汇编问语义”流程固化下来Coding Planhttps://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite最后留一个实操建议在 Bochs 里把HardwareCursor的int 10h前后各下一个断点执行前记下AH/DH/DL/BH执行后读 CRTC 的 0x0E/0x0F把两组值对一遍。这个动作做上三五次整条链路就刻在脑子里了比看十遍源码都管用。