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_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- 接入文档(看 API 怎么调):https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_content=api_doc&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
环境侧你需要准备三样东西:一份 NTLDR 的符号或反汇编(能定位osloader!DoGlobalInitialization)、一个能跑实模式中断的模拟器(Bochs 最直观,QEMU 也可以)、以及一个能看_EXTERNAL_SERVICES_TABLE结构的内存查看器。官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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 long:Y 和 X。调用时传的是(0, 127),即 Y=0、X=127。
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;否则就是普通光标定位。我们这次传的 X=127,不等于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 和 X,mov dh,al把 Y 的低字节放进DH,mov 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 没问题。而 X=127 进的是DL,127 列在 80 列文本模式下已经超出屏幕宽度,BIOS 会把光标“推”到不可见区域,效果就是关掉光标。这就是HW_CURSOR(0,127)关光标的原理——不是真的关,是把光标挪到屏幕外。
3.4 调试器里看到的调用现场
在 WinDbg 里单步到调用点,能看到压栈过程:
kd> p osloader!DoGlobalInitialization+0x3b: 004012a8 6a7f push 7Fh kd> p osloader!DoGlobalInitialization+0x3d: 004012aa 6a00 push 0push 7Fh压入 127(X),push 0压入 0(Y)。注意压栈顺序是反的,先压 X 再压 Y,因为__cdecl从右往左压参。继续单步到间接调用:
kd> p osloader!DoGlobalInitialization+0x45: 004012b2 ff5018 call dword ptr [eax+18h] kd> r eax=000244ec ebx=004013a2 ecx=00000000 edx=00064544 esi=00024538 edi=0048164f eip=004012b2 esp=00060ec4 ebp=00060ed0 cs=0008 ss=0010 ds=0010 fs=0030 gs=0000 efl=00000046 ds:0010:00024504=00022ea8eax=0x244ec正是ExternalServicesTable的地址,[eax+18h]就是HardwareCursor字段,取出来是0x22ea8。再看栈上的参数:
kd> dd 00060ec4 00060ec4 00000000 0000007f 00024538 00061ff0esp=0x60ec4处第一个是 0(Y),第二个是0x7f(X=127)。完全对得上。
4. 验证请求:用 Bochs/QEMU 单步看光标位置变化
4.1 Bochs 配置与断点
Bochs 的调试能力比 QEMU 更适合这种场景,因为它能直接看 VGA 寄存器。先准备一个能启动 NTLDR 的镜像,然后在 bochsrc 里打开调试:
magic_break: enabled=1 debug: action=report启动后进调试器,在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本次 row=0、column=127,算出来 offset=127。但 80 列模式下有效列是 0–79,127 已经越界,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会把每次中断的寄存器状态打到日志里,搜v=02就能找到这次 INT 10H 调用,日志里会带AH=02 DH=00 DL=7F。这算是一种旁证。
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 代码里要确保 CPL=0。
5.4 X=127 到底算不算越界
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_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- 接入文档:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_content=api_doc&utm_campaign=rewrite
- API Keys:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
如果你是要长期做启动流程逆向或者写自动化分析脚本,可以考虑 Coding Plan,把这种重复的“贴汇编问语义”流程固化下来:
- Coding Plan:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
最后留一个实操建议:在 Bochs 里把HardwareCursor的int 10h前后各下一个断点,执行前记下AH/DH/DL/BH,执行后读 CRTC 的 0x0E/0x0F,把两组值对一遍。这个动作做上三五次,整条链路就刻在脑子里了,比看十遍源码都管用。