Intel 汇编手册。






5.00/5 (87投票s)
一站式:x86, x64, 虚拟化, 多核,以及新增内容
Github 链接: https://github.com/WindowsNT/asm. 整个项目。
引言
这是我关于 Intel 汇编的完整且最终的文章,它包含了所有之前的硬件文章(内部原理,虚拟化,多核,DMMI)以及一些新信息(HIMEM.SYS,Flat 模式,EMM386.EXE,扩展内存,DPMI 信息)。
阅读本文将使您能够理解操作系统的工作原理、内存的分配和寻址方式,以及如何开发自己的操作系统级驱动程序和应用程序。
为了帮助您理解正在发生的事情,github 项目包含了文章的许多方面(我还在不断添加内容)。它是一个可运行的工具,包含了一个Bochs 二进制文件、VMWare 和 VirtualBox 的配置以及一个 Visual Studio 解决方案。整个项目使用Flat Assembler 以汇编语言构建。
像 TASM 或 MASM 这样的汇编器将无法工作,因为它们只支持特定的架构。
Bochs 是进行实验的最佳环境,因为它包含一个硬件 GUI 调试器(我为自己开发它感到自豪),可以帮助您理解内部原理。没有 Bochs 进行调试是不可能的,因为调试器要么只支持实模式(如 MSDOS Debug),并且假设您始终拥有某种控制(这在大多数调试区域都不是这种情况),要么只能在现有环境(如 Visual Studio)中运行。
如果您具备良好的 C 知识,这将有助于理解内部原理。汇编知识是推荐的,但即使您对汇编一无所知,您也可以跟上文章的进度。
通用信息
架构和 CPU
汇编语言是一种一切都需要手动完成的语言。一个单独的printf()
调用可能需要数千条汇编指令来执行。虽然本文不试图教您汇编,但需要牢记的是,即使要实现最小的结果,也需要很多东西(这实际上是创建高级语言的原因)。汇编语言也特定于架构(这里我们讨论 Intel x86 和 x64),而像 C 这样的语言是可移植的。
汇编语言有一套(相对而言)小的命令
- 在不同位置之间移动数据的命令
- 执行数学算法的命令(从简单到复杂)
- 检查条件的命令(例如
if
) - 其他命令(稍后讨论)
CPU 是执行汇编指令的单元。执行方式取决于处理器的运行模式,有 4 种模式:
- 实模式
- 保护模式(有两种版本,分段和扁平)
- 长模式
- 虚拟化(不完全是一种模式,但我们稍后会讨论)
本章的下一段将讨论汇编语言的各种一般性元素。
内存
物理上,内存是一个大数组。如果您有 4GB,您可以将其描述为unsigned char mem[4294967295]
。但是,其使用方式很大程度上取决于处理器模式和操作系统配置。因此,您不会将其视为一个大数组来访问。
堆栈和函数
堆栈是用于临时存储的特殊内存。传递给函数的参数被“压入”堆栈,当函数结束时,它们被“弹出”,堆栈清空,C 函数的局部变量也在此处,这就是为什么它们在函数终止时消失。堆栈内存,从技术上讲,就是用于特殊用途的普通内存。
这(目前过于简化了)是汇编中一个函数大致发生的情况。
int x(int a,int b)
{
return a + b;
}
int c = x(5,10); // result c = 15
x:
mov ax,[first stack element]
mov bx,[second stack element]
add ax,bx
ret 4
main:
push 5
push 10 ; the order is different, but let's forget about that now
call x
; ax contains the resuln
变量“a”和“b”被“压入”临时内存(如果 int = 16 位,则现在内存减少 4 字节)。调用该函数,然后它返回,堆栈清空,ax
包含返回值。请注意,上面的内容是对实际汇编代码的巨大简化,但我们暂时就这样。
寄存器
除了内存之外,每个 CPU 都有一些用于存储数据的辅助位置,称为**寄存器**。可用的寄存器取决于当前运行的模式。有些寄存器有特殊含义,有些则用于通用目的。
中断
中断是中断其他运行代码的代码。目前,只需将其视为一个可以在您执行另一个函数时运行的函数。有一些中断是由 CPU 自动生成的(硬件中断或异常发生时),也有一些是由软件“调用”的中断。它们的工作方式取决于运行模式,最多可以有 255 个中断。
异常
异常是 CPU(例如,在您的 C++ 代码中发生除零错误时,将执行 int 00 函数)或通过 API(例如,使用throw
关键字)触发的中断,它会生成一个软件中断。在我们讨论的较低级别,异常和中断之间没有区别。
现在我们对基础知识有了一些了解,让我们继续讨论 CPU 模式。
实模式
架构
**实模式**是最古老的模式。DOS 在其中运行。Windows 3.0 在使用 /r 开关启动时也在此模式下运行。一切都是 16 位的。它是操作中最弱的模式,但不是最简单的模式。内存由一个 20 位的控制器寻址,最多可访问 1MB 内存。在此限制之上的可用内存对实模式来说是无用的。
分段
内存不作为数组访问,而是以段的形式访问。每个指针由一个 16 位的段(即内存地址除以 16)和一个偏移量(描述从偏移量开始有多远)组成。因此,我们将看到一些简单的(十六进制)示例。
0000:0000 -> memory address 0
0000:0010 -> memory address 16 (hex 10)
0001:0002 -> memory address 18. Segment 1*16 + offset 2
0010:0034 -> 0x10*16 + 0x34
0011:0024 -> 0x11*16 + 0x24, same pointer as above
FFFF:0010 -> Maximum available address, specifying more than 0010h results in wrapping around zero.
我们可以看到段可以重叠。指定 0ffffh 段和一个大于 0010h 的偏移量会导致回绕。段的最大容量为 64KB。虽然我们可以一直到 FFFF 段,但只有低 640KB 可供 DOS 应用程序使用,因为上面的段(0xA000 以上)是为 BIOS 保留的。
所有段都可以从任何地方进行读/写/执行访问(即,任何程序都可以读取/写入或执行任何段内的代码)。任何应用程序都可以读取或写入内存的任何部分,包括操作系统所在的区域。这就是为什么实模式操作系统是单任务操作系统,如果一个应用程序崩溃,您必须重新启动。
寄存器
实模式寄存器是 16 位的,包括:
- 四个通用寄存器:AX、BX、CX、DX。它们的 8 位高位部分可以作为 AH、BH、CH、DH 访问,低位部分可以作为 AL、BL、CL、DL 访问。
- 一个寄存器用于保存当前正在执行的代码的偏移量:IP。
- 四个用作指针的寄存器:SI、DI、BP、SP。SP 指向可用堆栈内存的末尾(不能像其他寄存器一样用作索引)。每次我们将内容压入堆栈时,SP 都会减少。弹出时,SP 增加。这些寄存器没有 8 位分割。
- 四个用于包含段的寄存器:CS(始终保存当前正在执行的代码的段)、DS、ES 和 SS。SS 保存堆栈内存的段,DS 保存数据的段,ES 是一个辅助寄存器。
因此,代码始终在 CS:IP 执行,堆栈由 SS:SP 指向。
386 CPU 添加了更多寄存器,在实模式下也可以访问:
- 非段寄存器的 32 位扩展:EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP、EIP。
- 另外两个辅助段寄存器:GS 和 FS。
- 5 个控制寄存器:CR0、CR1、CR2、CR3、CR4。
- 6 个调试寄存器:DR0、DR1、DR2、DR3、DR6、DR7,用于硬件断点。
DS 是默认的数据段,除非另有指定或使用 SP 或 BP。
mov ax,[100] ; gets value from DS:100
mov ax,[si] ; gets value from DS:SI
mov ax,[es:si] ; from ES:SI
; When BP or SP is used, SS is the default.
ESI、EDI、EBP 和 ESP 可用作指针。如果它们的高位不为零,则会发生异常(除非您处于 Unreal 模式,稍后讨论)。
当 REP 操作存储数据时(movsb、stosw 等),使用 DI 作为索引时,ES 是默认段。
COM 和 EXE 文件
COM 文件是内存映射,适合单个段。前 128 字节包含PSP,这是一个包含信息的数据结构,段的其余部分包含程序的所有代码、数据和堆栈内存。CS = DS = ES = SS。SP 设置为 0xFFFE,指向段的末尾。执行从 CS:IP = 0x100(PSP 之后)开始。
EXE 文件可能包含多个段,因此 EXE 文件的大小可以超过 64KB。DS 和 ES 最初指向 PSP。加载 EXE 文件时,会解析“重定位”。重定位是可执行文件中汇编器留空的位置,将在运行时填充段值。由于此重定位图有一个头部,因此 COM 文件即使总大小小于 64KB 也无法拥有多个段。
中断
DOS 和 BIOS 提供的所有函数都可以通过实模式软件中断进行访问。在实模式下,RAM 的前 1024 字节(从 0000:0000 开始)包含一组 256 个段:偏移量指针到每个中断。在 286+ 中,可以使用LIDT
指令更改此位置,该指令指向一个 6 字节的数组。
- 字节 0-1 包含 IDT 的完整长度,最大为 1KB => 256 个条目。
- 字节 2-5 包含内存中 IDT 第一个条目的物理地址。
当某些事件发生时,处理器会自动发出一些中断。在实模式下,最重要的中断是:
- 中断 0,在除以零时调用。
- 中断 1,在调试器进行单步执行时调用。
- 中断 3,在断点处调用。
- 中断 6,在非法操作码时调用。
- 中断 9,在按键时调用。
软件中断为实模式应用程序提供各种服务。最重要的中断是:
- 0x10,BIOS 显示功能
- 0x13,BIOS 磁盘功能
- 0x14,BIOS 串行端口功能
- 0x16,BIOS 键盘功能
- 0x17,BIOS 并行端口功能
- 0x21,DOS 功能(文件、输入、输出、应用程序、配置等)
- 0x2F,TSR 功能
- 0x31,DPMI 功能
- 0x33,鼠标功能
使用出色的Ralf Brown 中断列表,您可以了解世界上所有的中断。
模型
由于内存分段,创建了不同的编程模型集,这主要导致了编译器和库之间的不兼容。C 指针被描述为 near 或 far,具体取决于它们是否包含段。
- Tiny 模型。所有内容都必须包含在单个段(COM 文件)中。指针是 near。
- Small 模型。一个代码段,一个数据段。所有指针都是 near。
- Medium 模型。一个数据段,多个代码段。代码指针为 far,数据指针为 near。
- Compact 模型。一个代码段,多个数据段。代码指针为 near,数据指针为 far。
- Large 模型。多个代码和数据段,代码和数据指针为 far。单个数据结构仍限于 64KB。
- Huge 模型。多个代码和数据段,所有指针都为 far。
优点
实模式的唯一优点是可以通过软件中断访问 DOS 和 BIOS 功能。因此,DOS 扩展程序(允许应用程序在保护模式下运行)使用的所有技术都涉及暂时切换到实模式以调用 DOS。
下面是 Tiny 模型中的一个快速“Hello World”。
org 0x100 ; code starts at offset 100h
use16 ; use 16-bit code
mov ax,0900h
mov dx,Msg
int 21h
mov ax,4c00h
int 21h
Msg db "Hello World!$"
这个非常简单的程序调用了两个 DOS 函数。第一个是函数 9(ah 寄存器),它在 DS:DX 中接受一个指向要写入屏幕的字符串的指针(DS 已经包含段,这是一个 com 文件)。第二个是函数 4C,它终止程序。
下面是相同应用程序的 EXE 格式。
FORMAT MZ ; DOS 16-bit EXE format
ENTRY CODE16:Main ; Specify Entry point (i.e. the start address)
STACK STACK16:stackdata ; Specify The Stack Segment and Size
SEGMENT CODE16_2 USE16 ; Declare a 16-bit segment
ShowMsg:
mov ax,DATA16
mov ds,ax ; Load DS with our "default data segment"
mov ax,0900h
mov dx,Msg
int 21h; ; Call a DOS function: AX = 0900h (Show Message),
; DS:DX = address of a buffer, int 21h = show message
retf ; FAR return; we were called from
; another segment so we must pop IP and CS.
SEGMENT CODE16 USE16 ; Declare a 16-bit segment
ORG 0 ; Says that the offset of the first opcode
; of this segment must be 0.
Main:
mov ax,CODE16_2
mov es,ax
call far [es:ShowMsg] ; Call a procedure in another segment.
; CS/IP are pushed to the stack.
mov ax,4c00h ; Call a DOS function: AX = 4c00h (Exit), int 21h = exit
int 21h
SEGMENT DATA16 USE16
Msg db "Hello World!$"
SEGMENT STACK USE16
stackdata dw 0 dup(1024) ; use 2048 bytes as stack. When program is initialized,
; SS and SP are automatically set.
汇编器如何知道data16
、code16
、code16_2
和stack16
段的实际值?它不知道。它所做的是插入空值,然后在 EXE 文件中创建条目(称为“重定位”),以便加载器在将代码复制到内存后,将段的真实值写入指定地址。由于此重定位映射有一个头部,因此 COM 文件即使总计小于 64KB 也无法拥有多个段。
此程序通过 far 调用调用另一个段中的ShowMsg
函数,该函数使用 DOS 函数(09h
,INT 21h
)来显示文本。
问题
- 如果运行多个应用程序,一个应用程序可以覆盖任何其他应用程序而无需任何通知。
- 最多只能访问 1MB 内存,而上面的 384K 被 BIOS 使用,所以只有 640K 可用。
- 在应用程序和库之间混合 far 和 near 指针会导致不兼容,通常会导致崩溃。
- 如果发生问题,PC 需要重启。
扩展内存
为了解决 640KB 的限制,创建了一种额外的兼容内存,称为扩展内存或 EMS 内存。这不是处理器功能,而是一组硬件(ISA 卡)扩展,其中包括一个驱动程序来执行页面切换,即用该卡上的内存替换安装的内存部分。它最多可提供 32MB 的额外内存,但它被映射到高段之一(A000、B000、C000、D000、E000 或 F000),这意味着这些额外内存不能同时可用。扩展卡附带一个驱动程序,必须在 config.sys 中安装,并使用 LIM EMS 协议,通过中断 67h 提供服务。
-
检测 EMS,通过测试是否存在名为 EMMXXXX0 的设备。
EMSName db 'EMMXXXX0',0
mov dx,EMSName ; device driver name
mov ax,3D00h ; open device-access/file sharing mode
int 21h
jc NotThere
mov bx,ax ; put handle in proper place
mov ax,4407h ; IOCTL - get output status
int 21h
jc NotThere
cmp al,0FFh
jne NotThere
mov ah,3Eh ; close device
int 21h
jmp ItIsThere
- 分配 EMS
Interrupt 0x67, AH = 0x43, BX = # of pages (1 page = 16KB)
- 检测要使用的段
Interrupt 0x67, AH = 0x41
- 保存之前的 EMS 映射
Interrupt 0x67, AH = 0x47
- 保存之前的 EMS 映射
Interrupt 0x67, AH = 0x47
- 映射我们的已分配内存
Interrupt 0x67, AH = 0x44
- 恢复之前的 EMS 映射
Interrupt 0x67, AH = 0x48
- 释放 EMS
Interrupt 0x67, AH = 0x45
int 0x67 提供了各种其他功能。
A20 线
我们看到最大地址是 FFFF:0010,因为增加偏移量会导致回绕。这是正确的,因为 8088 CPU 只有 20 位寻址。然而,286+ 添加了第 21 条线(称为 A20 线),当它启用时,FFFF:0010 到 FFFF:FFFF 可以使用而不会回绕(增加了近 64KB)。这部分内存(称为高内存区域,HMA)现在可以从实模式访问,并且可以使用HIMEM.SYS 将部分 DOS 加载到其中,从而为应用程序提供更多低内存。
手动启用或禁用 A20 需要我们与键盘控制器通信。
WaitKBC:
mov cx,0ffffh
A20L:
in al,64h
test al,2
loopnz A20L
ret
ChangeA20:
call WaitKBC
mov al,0d1h
out 64h,al
call WaitKBC
mov al,0dfh ; use 0dfh to enable and 0ddh to disable.
out 60h,al
ret
分段保护模式
架构
保护模式解决了实模式的问题。特别是:
- 最多可直接访问 16MB(286)和 4GB(386+)。
- 内存访问受到检查,提供保护和权限级别。
- 如果发生问题,问题可以被隔离,而其他应用程序不受影响。
- 有 16 位保护模式(286+)或 32 位保护模式(386+)。
DOS 从未在保护模式下运行。Windows 3.0 在使用 /s 开关启动时,在 16 位分段保护模式下运行。Windows 95+、Linux 和其他 32 位操作系统在扁平保护模式下运行,但在检查扁平模式之前,我们将深入研究保护模式的复杂机制。扁平模式大大简化了普通分段保护模式的许多复杂问题。
保护模式引入了“环”,即授权级别。有四个环(环 0、1、2 和 3),其中环 0 是最高授权的,环 3 是最低授权的。运行在较低特权环中的代码无法(在没有操作系统监督的情况下)访问较高环中的代码。
内存
内存中的每个段不再固定,也不再具有固定的 64KB 大小。保护模式下的段大小可以是任意的,从 1 字节到 4GB。每个段都有自己的限制(读、写、执行访问)和自己的保护环。
寄存器
实模式中存在的相同寄存器集仍然可用。此外,任何寄存器都可以用作索引,例如mov ax,[ebx]
将起作用。
全局描述符表 (GDT)
全局描述符表 (GDT) 是一组描述 CPU 所有段的条目。每个条目长度为 8 字节,格式如下:
位 | 含义 |
---|---|
0-15 | Limit low 16 位 |
16-31 | Base low 16 位 |
32-39 | Base medium 8 位 |
40 | Ac |
41 | RW |
42 | DC |
43 | Ex |
44 | S |
45-46 | Priv |
47 | Pr |
48-51 | Limit upper 4 位 |
52-53 | 保留(0) |
54 | Sz |
55 | Gr |
56-63 | Base upper 8 位 |
- **基址**是一个 32 位值,指示该段开始的物理内存。
- **限制**是一个 20 位值,指示段的长度,具体取决于**Gr**位。如果**Gr**位为 1,则实际限制是限制值 * 4096。
- **Ex**标志为 1,表示代码段,或 0,表示数据段。
- **DC**标志的含义不同,取决于**Ex**标志。
- 对于代码段(Ex = 1),如果 DC 为 0,则该段不是一致的。非一致段只能从具有相同特权级别的段调用。如果 RW 为 1,则该段是一致的,并且也可以从较高特权级别的段调用。例如,环 3 的一致段可以从环 2 的段调用。
- 对于数据段(Ex = 0),如果 DC 为 0,则数据段向上扩展,否则向下扩展。对于向下扩展的段,它从其限制开始,到其基址结束,地址方向相反。此标志的创建是为了方便堆栈段的扩展,但今天已不再使用。
- **RW**标志的含义不同,取决于**Ex**标志。
- 对于代码段(Ex = 1),如果为 0,则该段不可读。如果为 1,则代码段可读。
- 对于数据段(Ex = 0),如果为 0,则段为只读,否则为读写。
请注意,代码段不可写。但是,由于段基址可以重叠,您可以创建一个具有与代码段相同基址和限制的可写数据段。
- **Pr**表示当前环(00 到 11)。
- **Ac**位表示访问。CPU 每次访问该段时都会设置此位,以便操作系统了解该段的访问频率,从而知道是否可以将其缓存到磁盘。
- **S**位必须为 1 以表示代码和数据段,为 0 以表示系统段(见下文)。
- **Pr**位可以设置为 1,表示该段存在于内存中。如果操作系统将该段缓存到磁盘,则将 Pr 设置为 0。任何尝试访问已删除段的操作都会导致异常。操作系统会捕获此异常,并将段重新加载到内存中,再次将 Pr 设置为 1。
- **Sz**位可以有两个值:
- 0,在这种情况下,操作数的默认值为 16 位。通过在 386+ 命令前添加 0x66 或 0x67 前缀,该段仍然可以执行 32 位命令。
- 1(386+),在这种情况下,操作数的默认值为 32 位。通过在 16 位命令前添加 0x66 或 0x67 前缀,该段仍然可以执行 16 位命令。
在实模式下,段寄存器(CS
、DS
、ES
、SS
、FS
、GS
)指定一个实模式段。您可以将任何值放入其中,无论它指向哪里。您可以从该段进行读取、写入和执行。在保护模式下,这些寄存器加载的是**选择器**。选择器是 GDT 的索引,格式如下:
位 | 含义 |
---|---|
0-2 | RPL。请求的保护级别,必须等于或低于段的 PL。 |
2 | 0 从 GDT 获取条目,1 从 LDT 获取(见下文)。 |
3-15 | 指向表的基于 0 的索引。 |
在保护模式下,您不能像在实模式下那样随意选择段寄存器的值。您必须输入有效值,否则会收到异常。异常是 GDT 表中的第一个条目,始终设置为 0。CPU 不会从第 0 条目读取信息,因此它被视为一个“虚拟”条目。这允许程序员将 0 值放入段寄存器(DS、ES、FS、GS)而不会导致异常。
GDT 通过执行LDGT
命令加载到 CPU,该命令指向一个 6 字节的数组:
- 字节 0-1 包含 GDT 的完整长度,最大为 4KB => 4096 个条目。
- 字节 2-5 包含 GDT 第一个条目的物理地址。
中断
中断表现在每个定义的で中断有 8 字节长,结构如下:
struc IDT_STR
{
.ofs0_15 dw ofs0_15
.sel dw sel
.zero db zero
.flags db flags ; 0 P,1-2 DPL, 3-7 index to the GDT
.ofs16_31 dw ofs16_31
}
每个中断也有一个保护级别。LIDT
命令的功能与实模式下的相同,指向一个 6 字节的数组(包含大小和第一个条目的物理位置)。
执行 LIDT 命令后,实模式中断将不再工作,因此实模式调试器将无用。
局部描述符表 (LDT)
局部描述符表 (LDT) 是一种方法,允许每个应用程序在多任务场景下拥有自己的私有段集,使用LLDT
汇编指令加载。选择器中的 LDT 位指定加载的段是来自 GDT 还是 LDT。
GDT 中的系统段
当 GDT 中的**S**位为 0 时,表示与系统相关的段。在这种情况下,GDT 条目描述了三种系统段:
- 任务段
- 调用门
- 中断门
- 陷阱门(与中断门相同,区别在于发生陷阱时,中断仍然启用)
GDT 条目中的位 40-43 具有以下含义:
- 0000 - 保留
- 0001 - 可用的 16 位 TSS
- 0010 - 局部描述符表 (LDT)
- 0011 - 忙碌的 16 位 TSS
- 0100 - 16 位调用门
- 0101 - 任务门
- 0110 - 16 位中断门
- 0111 - 16 位陷阱门
- 1000 - 保留
- 1001 - 可用的 32 位 TSS
- 1010 - 保留
- 1011 - 忙碌的 32 位 TSS
- 1100 - 32 位调用门
- 1101 - 保留
- 1110 - 32 位中断门
- 1111 - 32 位陷阱门
调用门
调用门是从低特权代码切换到高特权代码的机制,用于用户级别代码调用系统级别代码。您在 GDT 中指定一个 1100 类型条目,格式如下:
隐藏 复制代码
struct CALLGATE
{
unsigned short offs0_15;
unsigned short selector;
unsinged short argnum:5; // number of arguments to copy to the stack from the current stack
unsigned char r:3; // Reserved
unsigned char type:5; // 1100
unsigned char dpl:2; // DPL of this gate
unsigned char P:1; // Present bit
unsigned short offs16_31;
};
使用 CALL FAR 和此调用门的选择器(忽略偏移量)将切换到门并执行更高级别的特权命令。如果 argnum 指定要复制的参数,系统将在推送 SS、ESP、CS、EIP 后将它们复制到新堆栈。使用 RETF 将从门调用返回。
调用门是 CPU 环之间转换的慢速机制。
TSS 描述符、任务门和硬件多任务
拥有在 GDT 和局部描述符表中保存任务段的能力,CPU 提供了**任务切换**的能力。任务状态段是 CPU 保存局部任务(当前寄存器)信息的地方。执行 far JMP 或 CALL(偏移量与调用门一样被忽略)并使用指向 GDT TSS 的选择器将“切换”到该任务,恢复已保存的寄存器。TSS 描述符用于指定用于从 GDT 加载新 CPU 状态的 TSS 的基址和限制。CPU 有一个名为任务寄存器(Task Register)的寄存器,它指示哪个 TSS 将接收旧 CPU 状态。当 TR 寄存器加载有 LTR 指令时,CPU 会查看 GDT 条目(由 LTR 指定),并将 TR 的可见部分与 GDT 条目的基址和限制加载,隐藏部分则用 GDT 条目的基址和限制加载。保存 CPU 状态时,将使用 TR 的隐藏部分。
除了 far call 和 jmp 之外,还可以通过使用任务门描述符来触发上下文切换。与 TSS 描述符不同,任务门描述符可以位于 GDT、LDT 或 IDT 中(因此您可以在中断发生时强制进行任务切换)。
进入保护模式
要遵循的步骤是:
- 启用 A20。
- 设置 GDT。
- 设置 IDT(如果您在保护模式下需要中断)。
- 使用 MSW 或 CR0 寄存器进入保护模式。
您可以使用 MSW 寄存器(在 286 中)或 386+ 中的 CR0。
; 386+
mov eax,cr0
or eax,1
mov cr0,eax
; 286
smsw ax
or al,1
lmsw ax
之后,您必须执行一次 far jump 到受保护模式的代码段,以清除可能的无效命令缓存。如果此代码段是 16 位代码段,则必须执行:
db 0eah ; Opcode for far jump
dw StartPM ; Offset to start, 16-bit
dw xx ; A selector value in the GDT, with the Sz bit off.
如果此代码段是 32 位代码段,则必须执行:
db 66h ; Prefix for 32-bit
db 0eah ; Opcode for far jump
dd StartPM ; Offset to start, 32-bit
dw xx ; A selector in the GDT, with the Sz bit on.
此外,您还必须设置堆栈和其他寄存器。
mov ax, data_selector
mov ds,ax
mov ax, stack_selector
mov ss,ax
mov esp,1000h ; assuming that the limit of the stack segment
; selected by stack_selector is 1000h bytes.
sti
...
退出保护模式
cli
mov eax,cr0
and eax,0ffffffeh
mov cr0,eax
mov ax,data16
mov ds,ax
mov ax,stack16
mov ss,ax
mov sp,1000h ; assuming that stack16 is 1000h bytes in length
mov bx,RealMemoryInterruptTableSavedWithSidt
litd [bx]
sti
; (Real mode debugger works here) ...
在 286 中,您无法返回实模式,因为LMSW ax
移除保护模式标志会导致处理器重置,内存保持不变。286 强制进行此重置,并提供一段代码在重置后执行:
MOV ax,40h
MOV es,ax
MOV di,67h
MOV al,8fh
OUT 70h,al
MOV ax,ShutdownProc
STOSW
MOV ax,cs
STOSW
MOV al,0ah
OUT 71h,al
MOV al,8dh
OUT 70h,al
在 386+ 中,可以正常退出并返回实模式。
问题
虽然您可以直接访问所有内存,但仍然存在大量的分段和缓慢的任务切换或环之间的慢速移动。
扁平保护模式
分页
分页是将内存地址重定向到另一个地址的方法。请求的地址称为**线性地址**,目标地址称为**物理地址**。当线性地址与物理地址相同时,我们称之为“透明”区域。
为了实现分页,使用了两个表:**页目录**和**页表**。
**页目录**是一个包含 1024 个 32 位条目的数组,格式如下:
P,R,U,W,D,A,N,S,G,AA,Addr
- P - 页存在于内存中。此标志允许操作系统将页缓存回磁盘,清除 P,并在软件尝试访问该页时生成页错误时重新加载它们。
- R - 如果设置,则页为读写,否则为只读。此限制仅适用于环 3,除非设置了 CR0 中的 WP 位。
- U - 如果未设置,则只有环 0 可以访问此页。
- W - 如果设置,则启用写回。
- D - 如果设置,则该页不会被缓存。CPU 将页表缓存到其**转换查找缓冲区**(**TLB**)。
- A - 当访问页时设置(不像 GDT 位那样自动)。
- N - 设置为 0。
- S - 设置为 0。如果启用了页大小扩展(PSE),则 S 可以为 1,在这种情况下,页大小为 4MB,并且页必须对齐 4MB。此模式是为了避免大量的小页,但以内存浪费为代价,如果所需的内存略大于 4MB。幸运的是,模式可以混合使用。
- G - 设置为 0。
- Addr - 指向**页表**条目的高 20 位(低 12 位被忽略,因为它必须对齐 4096 字节)。
**页表**是一个包含 1024 个 32 位条目的数组,格式相似:
P,R,U,W,C,A,D,N,G,AA,Addr
- C 位与之前的 D 位相同。
- D 位用于标记操作系统写入的脏页(已写入的页)。
- 扁平的 G 位,如果设置,会阻止在 TLB 中缓存。
- Addr 是指向该条目的 4096 字节对齐的物理地址。虚拟地址从页目录中的偏移量和页表中的偏移量计算得出。
启用分页:
- 将 CR3 加载为页目录第一个条目的地址(必须对齐 4096 字节)。
- 设置 CR0 的位 31。这需要保护模式,除了 LOADALL(见下文)。
加载表后,它们会被缓存到 TLB。重新加载 CR3 将重置缓存。486+ 还提供 INVLPG 指令来仅重置单个页缓存,而不是整个 TLB。
架构
分段保护模式非常复杂。通过使用分页,保护模式可以“扁平化”,实现以下目标:
- 所有进程都获得 4GB 的虚拟地址空间。保护在分页级别进行。所有段都是 4GB,所有段选择器始终指向同一个段。
- 编程更简单,因为只需要“near”指针。
- 操作系统可以将共享库(在物理内存中仅存在一次)映射到每个应用程序的多个虚拟目标。
- 应用程序只看到映射到其自身虚拟地址空间中的内存,因此硬件会保护进程。
此外,所有现代操作系统现在只使用 4 个保护环中的 2 个,环 0 用于内核,环 3 用于所有用户应用程序。不再使用调用门。
SYSENTER/SYSEXIT
为了加快用户模式(环 3)和内核模式(环 0)之间的切换速度,必须实现一种不同于调用门的方法。SYSENTER/SYSEXIT 指令是当前从环 3 切换到环 0 的方式。您将使用 WRMSR 来设置 CS(0x174)、ESP(0x175)和 EIP(0x176)的新值。ECX 必须包含 SYSEXIT 的环 3 堆栈指针,EDX 包含 SYSEXIT 的环 3 EIP。为 CS 条目存储的值必须是 4 个选择器的索引,第一个是环 0 代码,第二个是环 0 数据,第三个是环 3 代码,第四个是环 4 数据。这些值是固定的,因此为了使用 SYSENTER,您的 GDT 表必须包含这些格式的条目。
这些操作码仅支持环 3 和环 0 之间的切换,但它们速度快得多。它们现在用于代替速度慢得多的调用门。
软件多任务
今天的操作系统不再使用任务门。相反,它们应用软件多任务来在进程之间切换。
- 运行“调度程序”(中断计时器)。
- 根据线程和进程的优先级切换堆栈和 EIP。
由于软件调度程序仅保存任务切换所需的必要内容,因此它比分段模式硬件切换更快。
保护模式事实
Unreal 模式
由于保护模式无法调用 DOS 或 BIOS 中断,因此它对 DOS 应用程序通常用处不大。然而,386+ 处理器中的一个“bug”被发现是一个称为**unreal 模式**的功能。Unreal 模式是从实模式访问整个 4GB 内存的方法。这个技巧是未公开的,但大量的应用程序正在使用它。这个技巧基于这样一个事实:段选择器最初可以指向一个 4GB 的数据段(在 GDT 中设置),当它返回到实模式时,其“不可见部分”保持不变,并且仍然具有 4GB 的限制。
要使用 Unreal 模式,您必须:
- 启用 A20。
- 进入保护模式。
- 将一个段寄存器(
ES
或FS
或GS
)加载为 4GB 数据段。 - 返回实模式。
从保护模式返回后,您可以轻松地执行:
; assuming FS has loaded a 4GB data segment from Protected Mode
mov ax,0
mov fs,ax
mov edi,1048576 ; point above 1MB
mov byte [fs:edi],0 ; Set a byte above 1MB.
286 缺少此功能,因为退出保护模式需要重置 CPU,因此所有寄存器都会被销毁(但请参阅下面的 LOADALL)。
Huge 实模式
上述 Unreal 模式的理论也可以应用于 CS,使得在 EIP > 0xFFFF 时可以在 1MB 以上的位置执行代码。但是,在调用中断时,EIP 的高 16 位不会压入堆栈,因此返回时您不会回到原来的位置。因此,Huge 实模式并没有得到广泛使用。
LOADALL
当时,存在一个现在已不存在且大部分未公开的指令,称为 LOADALL(286 中为 0xF 0x5,386 中为 0xF 0x7)。LOADALL 正如其名称所示,用于从内存中的一个表加载所有寄存器(包括 GDTR 和 IDTR)。在 286 中,LOADALL(386 无法访问)的此表固定在内存地址 0x800,而在 386 LOADALL 中,它读取实模式 ES:EDI 指向的缓冲区。由于 CPU 根本不检查 LOADALL 加载的任何值是否有效,因此 LOADALL 被当时许多工具(包括 HIMEM.SYS)用于各种臭名昭著的操作。
- 从实模式访问所有内存,而无需进入保护模式和 Unreal 模式。
- 使用分页在实模式下运行代码。
- 在实模式下运行 32 位代码。
- 在保护模式下运行正常的 16 位代码,而无需 VM86(286 中没有)。这是通过捕获每个内存访问(这将导致 GPF,因为所有段都被标记为不存在)并使用另一个 LOADALL 来模拟所需结果来实现的。当然,这太慢了,但它导致了 386 中 VM86 模式的创建,其中 LOADALL 最终被淘汰。
LOADALL 无法将 286 切换回实模式,但使用 LOADALL 消除了进入保护模式的需要。
LOADALL 286 本身在手册中被提及并且是*部分*文档化的;相比之下,LOADALL 386 则被大量隐藏,可能是为了让程序员利用新的 VM86 模式。
HIMEM.SYS
保护模式很复杂,如果没有可用的调试器,很容易导致大量无法解决的崩溃。为了帮助程序员,Microsoft 创建了一个驱动程序,能够从普通的 16 位 DOS 应用程序管理保护模式,使其能够访问高内存。当时,扩展内存主要(如果不是完全)用于缓存磁盘数据,特别是来自大型应用程序的数据。HIMEM 将 CPU 置于 Unreal 模式(或在 286 中使用 LOADALL),并为想要更多内存而无需处理保护模式细节的应用程序提供了一个简单的接口。通过启用 A20 线,当 config.sys 具有 DOS=HIGH 指令时,HIMEM 允许部分 DOS COMMAND.COM 驻留在高内存区域。
- 检测 HIMEM.SYS
Interrupt 0x2F, AX = 0x4300
- 返回 HIMEM.SYS 函数指针
Interrupt 0x2F, AX = 0x4310
以下所有功能都由上述中断返回的 ES:BX 指针提供。
- 检测/启用/禁用 A20
AH = 0x7 (detect), 0x3 (enable), 0x4 (disable)
- 分配 HMA
AH = 0x1
- 释放 HMA
AH = 0x2
- 分配扩展内存
AH = 0x9
- 释放扩展内存
AH = 0xA
- 在实模式/保护模式之间复制内存
AH = 0xB
- 锁定/解锁保护模式内存
AH = 0xC (Lock), 0xD (Unlock)
HIMEM.SYS 用于移动内存以对其进行碎片整理。当您将在保护模式下直接访问内存时,锁定内存很有用。实际上,由于 HIMEM 将 CPU 置于 Unreal 模式,您可以使用返回的相同指针直接访问。
VM86 模式
当时许多现有应用程序是实模式的,而在保护模式引入时。即使在今天,Windows 下仍然玩着许多(主要是游戏)应用程序。为了让这些(认为自己拥有机器的)应用程序协同工作,应该创建一个特殊的模式。
VM86 模式是EFlags
寄存器的一个特殊标志,它允许一个普通的 16 位 DOS 内存映射(640KB),该内存通过分页转发到实际内存 - 这使得可以同时运行多个 DOS 应用程序,而不会让一个应用程序有机会覆盖另一个应用程序。EMM386.EXE 将处理器置于该状态。操作系统会逐个步骤地监视进程,确保进程不会执行任何非法操作。通常,您还希望将所有其他关键结构(GDT、IDT 等)映射到 1MB 以上,以便它们对任何 VM86 进程不可见。
触发 VM86 模式,您可以使用 PUSHFD 和 IRET。
mov ebp,esp push dword [ebp+4] push dword [ebp+8] pushfd or dword [esp], (1 << 17) ; set VM flags push dword [ebp+12] ; cs push dword [ebp+16] ; eip iret
设置 VM 标志后,您可以将正常的“段”加载到段寄存器中。DOS 应用程序的中断调用由操作系统捕获并通过它进行模拟 - 如果可能的话。此外,一些指令被忽略,例如,如果您执行 CLI,中断实际上并不会被禁用。操作系统知道您不希望被中断,并会相应地采取行动,但中断仍然存在。
所有 VM86 代码都在 PL 3(最低特权级别)下执行。对端口的 Ins/Outs 也会被捕获并尽可能模拟。VM86 的有趣之处在于有两个中断表,一个用于实模式,一个用于保护模式。但只有保护模式中断被执行。
VM86 已从 64 位模式中移除,因此 64 位操作系统无法再执行 16 位 DOS 代码。为了执行此类代码,您需要一个模拟器,例如DosBox。
许多应用程序也是为利用扩展内存而编写的,但现代标准是保护模式。EMM386 将 CPU 置于 VM86 模式,并通过分页将 1MB 以上的内存映射到实模式段(0xA0000 以上),因此希望使用扩展内存的应用程序可以通过EMM386.EXE 使用它,它提供了 LIM EMS int 0x67 接口。此外,EMM386 允许在CONFIG.SYS 中使用“devicehigh”和“loadhigh”命令,允许应用程序在可能的情况下加载到这些高段。
物理地址扩展 (PAE)
PAE 是 x86 使用 36 位地址而不是 32 位地址的能力。这使得可用内存从 4GB 增加到 64GB。32 位应用程序仍然只看到 4GB 的地址空间,但操作系统可以(通过分页)将高区域的内存映射到低 4GB 地址空间。添加此扩展是为了应对(如今已不足够的)4GB 限制,然后再进入 64 位 CPU 的时代。
启用 PAE(CR4 的位 5)意味着现在您有 3 个分页级别:除了页目录和页表之外,您还有**PDTD**,页目录指针表,它有四个 64 位条目。PDTD 中的每个条目都指向一个 4KB 的页目录(与正常分页相同)。新页目录中的每个条目现在是 64 位长(因此有 512 个条目)。新页表中的每个条目都指向一个 4KB 的页表(与正常分页相同),并且新页表中的每个条目现在是 64 位长,因此有 512 个条目。因为这将只允许原始映射的四分之一,所以支持 4 个目录/表条目。第一个条目映射第一个 1GB,第二个映射第二个 GB,第三个映射第三个 GB,最后,第四个条目映射第四个 GB。
但是现在 PDT 中的“S”位具有不同的含义:如果未设置,则表示页条目是 4KB,如果设置,则表示该条目不指向 PT 条目,而是它本身描述一个 2MB 页。因此,您可以根据 S 位有不同级别的分页遍历。
页目录条目中还有一个新的标志,NX 位(位 63),如果设置,则阻止在该页上执行代码。
此系统允许操作系统处理超过 4GB 的内存,但由于地址空间仍为 4GB,每个进程仍限于 4GB。内存最多可达 64GB,但进程无法看到整个内存。
直接内存访问驱动程序有问题,因为它们不使用分页内存。如果使用 32 位工作,驱动程序必须自己管理分页表才能操作超过 4GB 的内存,这可能导致与操作系统的兼容性问题,除非向驱动程序公开了安全的 DMA API。因此,PAE 很快被 64 位操作系统取代,而 PAE 仍然是 64 位操作系统的必要分页级别。
DPMI
对于 DOS 应用程序来说,Unreal 模式还不够,最终需要创建一个完全 32 位的应用程序。DPMI(Dos Protected Mode Interface)是一个驱动程序,为希望在 32 位保护模式下运行的应用程序提供了一个(相对复杂)接口。基于 DPMI 的 DOS 扩展程序,如 DOS4GW 和 DOS32A,被创建用于支持希望在 32 位下运行同时仍能访问 DOS 中断的应用程序(主要是游戏)。DPMI 捕获中断调用,切换到实模式,执行中断,然后返回到保护模式。DPMI 甚至支持多任务和多个“虚拟”32 位机器。
DOS 扩展程序使用“线性可执行文件”(LE 或 LX 格式),其中包含本机 32 位代码。DOS32A 可以加载和运行此类可执行文件。这是一个 FASM 示例,演示了使用 DPMI 创建 LE 可执行文件。
通过中断 2F 检测 DPMI
Interrupt 0x2F, AX = 0x1687
来自DJCPP的示例
modesw dd 0 ; far pointer to DPMI host's ; mode switch entry point mov ax,1687h ; get address of DPMI host's <a href="http://www.delorie.com/djgpp/doc/dpmi/api/2f1687.html">int 2fh</a> ; mode switch entry point or ax,ax ; exit if no DPMI host jnz error mov word ptr modesw,di ; save far pointer to host's mov word ptr modesw+2,es ; mode switch entry point or si,si ; check private data area size jz @@1 ; jump if no private data area mov bx,si ; allocate DPMI private area mov ah,48h ; allocate memory int 21h ; transfer to DOS jc error ; jump, allocation failed mov es,ax ; let ES=segment of data area @@1: mov ax,0 ; bit 0=0 indicates 16-bit app call modesw ; switch to protected mode jc error ; jump if mode switch failed ; else we're in prot. mode now
应用程序通过 0x4C int 0x21 终止(与实模式相同)。其余 DPMI 功能通过 int 0x31 提供,包括:
- 实模式中断捕获(类似于函数 0x25 int 0x21)
- 实模式异常捕获
- 直接或通过 int 0x31 函数 3 调用 DOS 中断
- 实模式回调
- DPMI 客户端之间的内存共享
- 分页
- 设置硬件断点
- TSR 功能
许多出色的游戏,如《失落的星球》,都在 DPMI 下运行。
长模式
架构
为了克服 x86 的 4GB 限制而创建的任何方法,最终都会导致全 64 位处理器。在讨论了所有保护模式的复杂性之后,我们很幸运地发现 x64 CPU 架构要简单得多。x64 CPU 有 3 种操作模式:
- 实模式
- 保护模式(称为旧模式)
- 长模式,包含两个子模式:
- 兼容模式,32 位。这允许 64 位操作系统原生运行 32 位应用程序。
- 64 位模式
为了在长模式下工作,程序员必须考虑以下事实:
- 与保护模式不同,保护模式可以带分页或不带分页运行,长模式仅在 PAE 和分页下以扁平模式运行。所有段都是扁平的,范围从 0 到 0xFFFFFFFFFFFFFFFF,所有内存寻址都是线性的。DS、ES、SS 被忽略。**“扁平”模式是长模式下唯一有效的模式。没有分段。**
- 您可以直接从实模式进入长模式,只需一条指令即可启用保护模式和长模式(这可以工作,因为控制寄存器可以从实模式访问)。
- 虽然理论上任何 64 位值都可以用作地址,但实际上我们还不需要 2^64 内存。因此,当前实现仅支持 48 位寻址,这强制所有指针的位 47-63 要么全部为 0,要么全部为 1。这意味着您有 2 个有效“规范”地址范围,一个从 0 到 0x00007FFF'FFFFFFFF,另一个从 0xFFFF8000'00000000 到 0xFFFFFFFF'FFFFFFFF,总共 256TB 的空间。大多数操作系统将高地址区域保留给内核,低地址区域保留给用户空间。
为了验证是否支持长模式,我们必须检查扩展的 CPUID 功能。
mov eax, 0x80000000
cpuid
cmp eax, 0x80000001
jb .NoLongMode
寄存器
在 64 位模式下运行时,可以使用以下 64 位扩展:
- RAX、RBX、RCX、RDX、RSI、RDI、RSP、RBP、RIP
- 新增 8 个 64 位寄存器:R8 到 R15。低 32 位以 R8D - R15D 格式表示,高 8 位以 R8W - R14W 格式表示,低 8 位以 R8B - R14B 格式表示。
这些寄存器仅在 64 位模式下可用。在所有其他模式下,包括兼容模式,它们都不可用。
GDT/IDT
GDT 的位 53,以前保留,现在是“L”位。当 L 为 1 时,Sz 位必须为 0,这表示 64 位代码(L=1 和 Sz=1 的组合是保留的,使用时将引发异常)。限制始终是 0 到 0xFFFFFFFFFFFFFFFF,基址始终为 0。
如果您的 GDT 位于内存的低 4GB 区域,则进入长模式后无需更改它。但是,如果您计划在长模式下调用SGDT
或LGDT
,则现在必须处理 10 字节的 GDTR,它包含 2 字节的 GDT 长度和 8 字节的 GDT 地址。
加载到访问 64 位段的任何选择器都将被忽略,并且DS
、ES
、SS
根本不使用。所有段都是扁平的,所有操作都通过分页完成。但是 GS 和 FS 仍然可以用作辅助寄存器,并且它们的值仍然需要从 GDT 进行验证。在 Windows 中,FS 指向**线程信息块**。
IDT 与保护模式的 IDT 类似,区别在于每个条目都扩展为包含中断的 64 位物理地址。
struc IDT_STR
{
.ofs0_15 dw ofs0_15
.sel dw sel
.zero db zero
.flags db flags ; 0 P,1-2 DPL, 3-7 index to the GDT
.ofs16_31 dw ofs16_31
.ofs32_63 dd ofs32_63
.zero dd 0
}
长模式下没有 LDT、VM86、DPMI、Unreal 模式或调用门。缺少 VM86 是 64 位操作系统无法在没有模拟器的情况下运行 16 位软件的原因。
长模式分页
在长模式下,分页系统增加了一个新的顶层结构,**PML4T**,它有 512 个 64 位长条目,指向一个 PDPT,现在 PDPT 也有 512 个条目(而不是 x86 模式下的 4 个)。因此,现在您可以拥有 512 个 PDPT,这意味着一个 PT 条目管理 4KB,一个 PDT 条目管理 2MB(4KB * 512 个 PT 条目),一个 PDPT 条目管理 1GB(2MB*512 个 PDT 条目),一个 PML4T 条目管理 512GB(1GB * 512 个 PDPT 条目)。由于有 512 个 PML4T 条目,总共可以寻址 256TB(512GB * 512 个 PML4T 条目)。
这也是不使用整个 64 位进行寻址的原因。使用整个 64 位会迫使我们有 6 个分页级别,而现在需要 4 个。
PDPT/PDT 中的每个“S”位都可以为 0,表示下面有较低级别的结构,或为 1,表示遍历在此结束。如果 PDPT S 位为 1 且 CPU 支持,则页大小为 1GB。
Intel 有一个关于 PML5 的草案,这是一个新的顶层结构,当 CPU 支持 56 位寻址时,它将允许 5 级分页。
要验证是否支持 1GB 页,我们尝试 EDX 的位 26。
mov eax,80000001h
cpuid
bt edx,26
jnc .no1gbpg
进入长模式
; Disable paging, assuming that we are in a see-through.
mov eax, cr0 ; Read CR0.
and eax,7FFFFFFFh; Set PE=0
mov cr0, eax ; Write CR0.
mov eax, cr4
bts eax, 5
mov cr4, eax ; Set PAE
mov ecx, 0c0000080h ; EFER MSR number.
rdmsr ; Read EFER.
bts eax, 8 ; Set LME=1.
wrmsr ; Write EFER.
; Enable Paging to activate Long Mode. Assuming that CR3
' is loaded with the physical address of the page table.
mov eax, cr0 ; Read CR0.
or eax,80000000h ; Set PE=1.
mov cr0, eax ; Write CR0.
- 关闭分页(如果已启用)。为此,您必须确保您运行在“透明”区域。
- 设置 PAE,方法是设置 CR4 的第五位。
- 创建新的页表并将
CR3
加载为它们。因为在进入长模式之前CR3
仍然是 32 位的,所以页表必须位于低 4GB 区域。 - 启用长模式(注意,这不会*进入*长模式,它只会*启用*它)。
- 启用分页。启用分页会激活并进入长模式。
因为rdmsr
/wrmsr
操作码在实模式下也可用,您可以通过同时设置 CR0 的 PE 和 PM 位直接从实模式激活长模式。
进入 64 位
现在您处于兼容模式。通过跳转到 64 位代码段进入 64 位模式。
; also db 066h if entering from a 16-bit code segment
db 0eah
dd LinearAddressOfStart64
初始 64 位段必须位于低 4GB,因为兼容模式看不到 64 位地址。请注意,您必须使用线性地址,因为 64 位段始终从 0 开始。另请注意,如果当前的兼容段默认是 16 位,您必须使用 066h 前缀。
您在 64 位模式下要做的唯一事情就是重置RSP
。
mov rsp,STACK64
shl rsp,4
add rsp,stack64_end
在 64 位模式下不使用SS
、DS
、ES
。也就是说,如果您想访问另一个段中的数据,您不能将 DS 加载到该段的选择器并访问数据。您必须指定数据的线性地址。数据和堆栈始终通过线性地址访问。“扁平”模式不仅是默认模式,也是 64 位模式的唯一模式。
一旦进入 64 位模式,操作数(除了jmp
/call
)的默认值仍然是 32 位。因此,需要 REX 前缀(0x40 到 0x4F)来标记 64 位操作数。如果您的汇编器支持“code64”段,它会自动处理。
此外,现在必须使用新的LIDT
指令设置 64 位中断表,该指令需要一个 10 字节的操作数(2 字节用于长度,8 字节用于位置)。
返回兼容模式
要退出 64 位模式,首先必须返回到兼容模式。因为在 64 位模式下 0eah 不是有效的跳转,所以您必须使用RETF
技巧回到兼容模式的段。
push code32_idx ; The selector of the compatibility code segment
xor rcx,rcx
mov ecx,Back32 ; The address must be an 64-bit address,
; so upper 32-bits of RCX are zero.
push rcx
retf
这会让您回到兼容模式。64 位操作系统不断在 64 位和兼容模式之间跳转,以便能够同时运行 64 位和 32 位应用程序。
退出长模式
您必须使用 32 位选择器重新设置所有寄存器 - 回到分段。此外,您必须处于透明区域,因为要退出长模式,您必须停用分页。当然,您可以通过重置 PM 位立即切换到实模式。
; We are now in Compatibility mode again
mov ax,stack32_idx
mov ss,ax
mov esp,stack32_end
mov ax,data32_idx
mov ds,ax
mov es,ax
mov ax,data16_idx
mov gs,ax
mov fs,ax
; Disable Paging to get out of Long Mode
mov eax, cr0 ; Read CR0.
and eax,7fffffffh ; Set PE=0.
mov cr0, eax ; Write CR0.
; Deactivate Long Mode
mov ecx, 0c0000080h ; EFER MSR number.
rdmsr ; Read EFER.
btc eax, 8 ; Set LME=0.
wrmsr ; Write EFER.
; Back to protected mode
中断优先级
Windows 中的驱动程序开发人员会知道 IRQL 的含义。IRQL 是用于确定中断优先级的 CPU 功能。x86 和 x64 有 CLI 指令*可以*完全禁用中断,但在现代多线程系统中,应该存在某种可以确定中断优先级的东西。Windows 驱动程序函数 KeRaiseIrlq 和 KeLowerIrlq 修改 CR8 寄存器,设置 CPU 中断优先级(0-15,其中 0 是 PASSIVE_LEVEL,2 是 DISPATCH_LEVEL)。当有中断待处理时,会将其优先级与 CR8 进行比较。如果向量更高,则服务它,否则它将被挂起,直到 CR8 设置为较低值。CR8 在 CPU 重置时从 0 开始。
根据 Intel 的 Vol 3A,第 10.8.3 节,中断优先级是中断向量的更高 4:7 位。
多核
单个 CPU 一次只能执行一个指令。单处理器中的多任务处理通常是不同运行进程的寄存器/分页之间的快速切换(在软件层面),它非常快,以至于看起来进程同时运行。
多核 CPU 类似于拥有许多共享相同内存的单 CPU。其他所有内容(寄存器、模式等)都特定于每个 CPU。这意味着如果我们有一个 8 核处理器,我们就必须执行相同的过程 8 次才能将其置于长模式。我们可以让一个处理器处于实模式,另一个处理器处于保护模式,另一个处理器处于长模式等等。
在多核配置中,我们关心三件事:
- 如何发现多个处理器及其属性。
- 如何从一个 CPU 与另一个 CPU 进行通信。
- 如何同步对敏感数据的访问。
发现
高级可编程中断控制器 (APIC) 是一组位于内存中的表,它将为我们提供所需的信息。首先,我们发现 APIC 的存在。
mov eax,1
cpuid
bt edx,9
jc ApicFound
其次,我们在内存中搜索高级配置和电源接口 (ACPI)。ACPI 是第一个 APIC 表,它位于 BIOS 内存的某个位置,物理地址在 E0000 和 0xFFFFF 之间,并且具有以下头部:
struct RSDPDescriptor { char Signature[8]; uint8_t Checksum; char OEMID[6]; uint8_t Revision; uint32_t RsdtAddress; ; The following is present if ACPI 2.0 uint32_t Length; uint64_t XsdtAddress; uint8_t ExtendedChecksum; uint8_t reserved[3]; }
上面的 RSDP 描述符包含签名值,对于第一个 ACPI 表,该值为0x2052545020445352。如果内存中找不到此签名,则我们没有 ACPI,因此没有多个 CPU 核心。
每个描述符还有一个校验和,通过以下算法进行验证:
IsChecksumValid: PUSH ECX PUSH EDI XOR EAX,EAX .St: ADD EAX,[FS:EDI] INC EDI DEC ECX JECXZ .End JMP .St .End: TEST EAX,0xFF JNZ .F MOV EAX,1 .F: POP EDI POP ECX RETF
如果我们成功找到 ACPI 2.0 表并验证了其 ExtendedChecksum,那么我们必须使用 XsdtAddress(始终指向低 4GB)来查找其他表。如果是 ACPI 1.0,则使用 RsdtAddress。
找到地址后,我们使用它来定位第一个 APIC 表。起始表包含指向所有其他表的指针(如果 APIC 2.x+,则为 32 位或 64 位),位于头部之后。此物理地址在 1MB 以上,因此只能从受保护(或 Unreal)模式访问。有许多 ACPI 表,但我们只对其中的几个感兴趣。
它们都有以下头部:
struct ACPISDTHeader
{
char Signature[4];
unsigned long Length;
unsigned char Revision;
unsigned char Checksum;
char OEMID[6];
char OEMTableID[8];
unsigned long OEMRevision;
unsigned long CreatorID;
unsigned long CreatorRevision;
};
我们将找到的第一个表包含头部之后所有其他 APIC 表的指针。Length 成员包含整个表(包括头部)的长度。
要查找我们有多少个处理器,我们找到“MADT”表,该表在头部具有“APIC”签名。标准头部之后,我们有:
- 在偏移量 0x24 处,本地 APIC 地址,我们稍后会用到它。
- 在偏移量 0x2C 处,MADT 表的其余部分包含一系列可变长度的记录,用于枚举中断设备。每条记录都以 2 个头字节开始,1 个用于类型,1 个用于长度。如果类型字节为 0,则长度字节后面的字节包含 6 个字节,描述一个物理 CPU。第一个字节是 ACPI 处理器 ID,第二个字节是此处理器的 APIC ID。
循环遍历上述表将向我们展示所有已安装的处理器及其 ACPI 和 APIC ID。
初始启动
CPU 可以通过发出“处理器间中断”(IPI) 来与其他 CPU 通信。为了准备 APIC 管理中断,我们必须启用“杂项中断向量寄存器”,索引为 0xF0。
; Assuming FS is loaded with a linear 4GB segment unreal mode
MOV EDI,[LocalApic]
ADD EDI,0x0F0
MOV EDX,[FS:EDI]
OR EDX,0x1FF
MOV [FS:EDI],EDX
之后,我们就可以发送 IPI 了。IPI (处理器间中断) 是通过使用本地 APIC 的中断命令寄存器发送的。这由两个 32 位寄存器组成,一个位于偏移量 0x300,一个位于偏移量 0x310(所有本地 APIC 寄存器都对齐到 16 字节)。
- 偏移量 0x310 处的寄存器是我们首先写入的,它在位 24-27 中包含我们要发送中断的处理器对应的本地 APIC。
- 偏移量 0x300 处的寄存器具有以下结构。
struct R300
{
unsigned char VectorNumber; // Starting page for SIPI
unsigned char DestinationMode:3; // 0 normal, 1 low, 2 SMI, 4 NMI, 5 Init, 6 SIPI
unsigned char DestinationModeType:1; // 0 for physical 1 for logical
unsigned char DeliveryStatus:1; // 0 - message delivered
unsigned char R1:1;
unsigned char InitDeAssertClear:1;
unsigned char InitDeAssertSet:1;
unsigned char R2:2;
unsigned char DestinationType:2; // 0 normal, 1 send to me, 2 send to all, 3 send to all except me
unsigned char R3:12;
};
写入寄存器 0x300 将实际发送 IPI(这就是为什么你必须先写入 0x310)。请注意,如果 DestinationType
不是 0
,则寄存器 0x310 中的目标地址将被忽略。在 Windows 下,IPI 以 IRQL 级别 29(x86)或 14(x64)发送。
众所周知,CPU 从 0xFFFF:0xFFF0 位置开始在实模式下运行,但这仅对第一个 CPU 成立。所有其他 CPU 都“休眠”,直到被唤醒,处于一种称为 Wait-for-SIPI 的特殊状态。主 CPU 通过发送包含该 CPU 启动地址的 SIPI (Startup Inter-Processor Interrupt) 来唤醒其他 CPU。之后,会有其他处理器间中断在 CPU 之间进行通信。
要唤醒处理器,我们会发送两个特殊 IPI。第一个是“Init
”IPI, DestinationMode
为 5,它存储 CPU 的起始地址。请记住,CPU 在实模式下启动。由于处理器在实模式下启动,我们必须为其提供一个实内存地址,存储在 VectorNumber
中。第二个 IPI 是 SIPI, DestinationMode
为 6,它启动 CPU。起始地址必须是 4096 对齐的。
后续通信
除了上面看到的 INIT
和 SIPI
之外,本地 APIC 还可以用于发送正常中断,即在目标 CPU 的上下文中执行 INT XX
。我们必须考虑以下几点:
- 如果 CPU 处于
HLT
状态,中断会唤醒它,并且当中断返回时,CPU 会从HLT
操作码之后的指令继续执行。如果还有CLI
,那么我们必须发送一个 NMI 中断(APIC 中断寄存器中的一个标志)来唤醒 CPU。 - 如果 CPU 处于
HLT
状态,并且我们再次发送INIT
和SIPI
,CPU 将从实模式重新开始。 - 中断必须存在于目标处理器中。例如,在保护模式下,中断必须已在
IDT
中定义。 - 本地 APIC 对所有 CPU(内存方面)都是通用的,因此,在我们发出中断之前,必须对其进行写访问锁定(互斥锁)。
- 由于寄存器无法在 CPU 之间传递,我们必须将所有(如果需要)用于中断的寄存器写入一个单独的内存区域。
- 中断可能会失败,因此您必须依赖某种 CPU 间通信(通过共享内存和互斥锁)来验证传递。
- 最后,中断的处理程序必须告诉其本地 APIC 有一个“中断结束”。这类似于过去的 int 0x21 的 out 020h,al 。现在我们向 EOI 寄存器(
LocalApic + 0xB0
)写入值 0(中断结束)。
同步
由于 CPU 共享相同的内存,因此同步对关键部分的写读访问至关重要。在 Windows 中,我们当然有现成的互斥锁,但在这里需要做一些额外的工作。我们可以如下创建自己的互斥锁变量:
- 初始化,将一个字节设置为 0xFF。
- 锁定互斥锁,减少其值。
- 解锁互斥锁,增加其值,除非已为 0xFF。
- 等待互斥锁,但不锁定它:一个简单的循环。
; assuming edi has the address .Loop1: CMP byte [edi],0xff JZ .OutLoop1 pause JMP .Loop1 .OutLoop1:
请注意 pause 指令(等于 rep nop)。这是给 CPU 的一个提示,表明我们在自旋循环中,这极大地提高了性能,因为它避免了代码预取。
我们的问题是等待互斥锁,然后获取它(类似于 WaitForSingleObject()
)。这段代码将不起作用:
.Loop1:
CMP byte [edi],0xff
JZ .OutLoop1
pause
JMP .Loop1
.OutLoop1:
.MutexIsFree:
DEC [edi]
原因是,在 JZ 命令(它已经验证了互斥锁是空闲的)和 DEC [edi] 执行之前,另一个 CPU 可能会获取互斥锁(竞态条件)。
幸运的是,CPU 提供了 LOCK CMPXCHG 指令,该指令会自动为我们获取锁。
.Loop1:
CMP byte [edi],0xff
JZ .OutLoop1
pause
JMP .Loop1
.OutLoop1:
; Lock is free, can we grab it?
mov bl,0xfe
MOV AL,0xFF
LOCK CMPXCHG [EDI],bl
JNZ .Loop1 ; Write failed, someone got us
.OutLoop2: ; Lock Acquired
我们使用 CMPXCHG
指令,结合 LOCK
前缀,原子地测试 [edi] 是否仍为 0xFF
(AL 中的值),如果是,则将其写入 BL
并设置 ZF
。如果此时另一个 CPU 已经执行了相同的操作,则 ZF
将被清除,并且 BL
不会被移动到 [edi]。
虚拟化
技术上讲,虚拟化是系统中的“系统”。它是处理器在同一处理器内部的克隆。它的设置并不复杂,并且由于您能够在现有操作系统内部运行另一个操作系统,因此它极大地增强了计算能力。
每个 CPU(称为主机)一次可以运行一个虚拟机(称为客户机)。您可以为每个 CPU 配置多个客户机,并像多任务处理一样暂停/恢复每个客户机。如果您有 8 个 CPU 核心,当然可以同时运行 8 个客户机。
VM 操作的生命周期如下:
- 测试 CPU 是否支持虚拟化。
mov eax,1 cpuid bt ecx,5 jc VMX_Supported jmp VMX_NotSupported
- 从 IA32_VMX_BASIC 寄存器检查 CPU 特定修订版。
mov ecx, 0480h rdmsr
- 这个 64 位寄存器包含我们项目的重要信息。
- 位 0 - 31:32 位 VMX 修订号。
- 位 32 - 44:字节数(最多 4096),我们稍后需要分配。
- 启用 VMX 操作。
mov rax,cr4 bts rax,13 mov cr4,rax
- 配置 VMXON 结构。这是一个 4096 对齐的 CPU 特定数组,其大小必须是我们从 IA32_VMX_BASIC 寄存器获得的数字。VMXON 结构包含:
- 4 个字节,包含修订号。
- 4 个字节用于 VMX 中止数据(我们稍后会检查)。
- 执行 VMXON 命令。
- 对于每个客户机,配置一个 VMCS。VMCS 是一个 4096 对齐的 CPU 特定数组,我们需要为每个客户机分配,并且其大小必须是我们从 IA32_VMX_BASIC 寄存器获得的数字。要加载 VMCS 进行配置,我们使用 VMPTRLD 操作码。要读取或写入 VMCS,我们使用
VMREAD
、VMWRITE
和VMCLEAR
。VMCS 包含:- 4 个字节用于 VMX 中止数据(我们稍后会检查)。
- 其余字节由 VMCS 组使用(我们稍后会检查)。
- 配置客户机可用的内存。
- 使用 VMLAUNCH 启动客户机。
- 客户机在特定条件下返回(退出)到主机。
- 主机使用 VMPAUSE、VMRESUME 来暂停或恢复其客户机。
- 当客户机终止时,主机使用 VMXOFF 来关闭 VMX 操作。
VMCS 组
VMCS 的其余部分(即,在前 8 个字节(修订版 + VMX 中止)之后)被分为 6 个子组:
- 客户机状态
- 主机状态
- 非根控件
VMExit
控件VMEntry
控件VMExit
信息
上述每个字段都包含重要信息。我们将逐一查看。要标记一个 VMCS
以便后续使用 VMREAD
或 VMWRITE
进行读取/写入,您首先需要将其前 4 个字节初始化为修订号(如上面的 VMXON
结构),然后执行一个指向其地址的 VMPTRLD
。
Intel 手册 3B 的附录 H 包含所有索引的列表。例如,客户机 RIP 的索引是 0x681e
。要将值 0
写入该字段,我们将使用:
mov rax,0681eh
mov rbx,0
vmwrite rax,rbx
并非所有功能都始终存在于所有处理器上。我们必须在测试它们之前检查 VMX MSR 以获取可用功能。Intel 的 3B 附录 G 包含所有这些 MSR。要加载 MSR,请将其编号放入 RCX 并执行 rdmsr
操作码。结果在 RAX 中。
IA32_VMX_BASIC
(0x480):基本 VMX 信息,包括修订版、VMCS 大小、内存类型等。IA32_VMX_PINBASED_CTLS
(0x481):用于引脚基础 VM 执行控件的允许设置。IA32_VMX_PROCBASED_CTLS
(0x482):用于处理器基础 VM 执行控件的允许设置。IA32_VMX_PROCBASED_CTLS2
(0x48B):用于二级处理器基础 VM 执行控件的允许设置。IA32_VMX_EXIT_CTLS
(0x483):用于 VM Exit 控件的允许设置。IA32_VMX_ENTRY_CTLS
(0x484):用于 VM Entry 控件的允许设置。IA32_VMX_MISC MSR
(0x485):用于杂项数据的允许设置,例如RDTSC
选项、无限制客户机可用性、活动状态等。IA32_VMX_CR0_FIXED0
(0x486) 和IA32_VMX_CR0_FIXED1
(0x487):指示在 VMX 操作中 CR0 中允许为 0 或 1 的位。IA32_VMX_CR4_FIXED0
(0x488) 和IA32_VMX_CR4_FIXED1
(0x489):CR4 的情况相同。IA32_VMX_VMCS_ENUM
(0x48A):VMCS 的枚举器助手。IA32_VMX_EPT_VPID_CAP
(0x48C):提供有关 VPID 和 EPT 功能的信息。
主机状态
它包含以下信息(括号中为位号):
- CR0,CR3,CR4,RSP,RIP(各 64 位)
- CS,SS,DS,ES,FS,GS,TR 选择器(各 16 位)
- FS,GS,TR,GDTR,IDTR 基址(各 64 位)
IA32_SYSENTER_CS
(32)IA32_SYSENTER_ESP
(64)IA32_SYSENTER_EIP
(64)*IA32_PERF_GLOBAL_CTRL
(64)*IA32_PAT
(64)*IA32_EFER
(64)
主机状态告诉 CPU 在客户机退出后如何返回主机。在执行成功的 VMLAUNCH 或 VMRESUME 命令后(如果此命令失败,执行将在其后继续),主机将暂停,直到客户机退出。当客户机退出时,主机将从此 VMCS 组加载值。
客户机状态
它包含以下信息(括号中为位号):
- CR0,CR3,CR4,DR7,RSP,RIP,RFLAGS(各 64 位)
- 对于 CS,SS,DS,ES,FS,GS,LDTR,TR 中的每一个:
- 选择器(16 位)
- 基址(64 位)
- 段限制(32 位)
- 访问权限(32 位)
- 对于 GDTR 和 IDTR:
- 基址(64 位)
- 限制(32 位)
IA32_DEBUGCRTL
(64)IA32_SYSENTER_CS
(32)IA32_SYSENTER_ESP
(64)IA32_SYSENTER_EIP
(64)IA_PERF_GLOBAL_CTRL
(64)IA32_PAT
(64)IA32_EFER
(64)SMBASE
(32)- 活动状态(32 位)- 0 活跃,1 非活跃(已执行 HLT),2 发生三次故障,3 等待启动 IPI (SIPI)。
- 中断状态(32 位)- 定义应在 VM 中阻止的某些功能的 istate。
- 挂起的调试异常(64 位)- 用于通过 DR7 进行硬件断点。
- VMCS 链接指针(64 位)- 保留,设置为
0xFFFFFFFFFFFFFFFF
。 - VMX 抢占定时器值(32 位)
- 页目录指针表条目(4x64 位),指向页面。
此组定义客户机如何启动。客户机可以以两种模式启动:
- 分页 32 位保护模式。
- 实模式(无限制客户机),如果 CPU 支持。
在分页保护模式下启动客户机不允许客户机稍后进入长模式,也不允许修改 GDT。如果客户机期望实模式启动但不支持无限制客户机,则可以启动到 VM86 模式。
在无限制客户机中,客户机以实模式启动,并可以修改 VMCS 控制字段允许的任何寄存器。请注意,您仍然为 CS 加载保护模式风格的段,并且实模式以保护模式选择器开始,但您可以立即使用 JMP 加载一个新的实模式段。
执行控制字段
这些字段配置了在客户机中允许执行什么,不允许执行什么。任何不允许的操作都会导致VMEXIT。这些部分是:
- 引脚基础(32 位):中断。
- 处理器基础(2x32 位)。
- 主要:单步、TSC HLT INVLPG MWAIT CR3 CR8 DR0 I/O 位图。
- 次要:EPT、描述符表更改、无限制客户机等。
- 异常位图(32 位):每个异常有一个位。如果位为 1,则异常会导致
VMExit
。 - I/O 位图地址(2x64 位):控制 IN/OUT 何时导致
VMExit
。 - 时间戳计数器偏移量。
- CR0/CR4 客户机/主机掩码。
- CR3 目标。
- APIC 访问。
- MSR 位图。
例如,您可以配置它,以便异常会进入主机,而不是被捕获在客户机中。同样,您可能不允许 GDT 更改、控制寄存器更改等。
退出控制字段
这些字段告诉 CPU 在 VMExit
时要加载什么以及要丢弃什么。
VMExit
控件(32 位)。VMExit
控件用于 MSR。
退出控制字段
这些字段告诉 CPU 在退出时要注入客户机的内容。
VMEntry
控件(32 位)。VMEntry
控件用于 MSR。VMEntry
控件用于事件注入。
退出信息字段(只读)。
- 基本信息。
- 退出原因(32 位)。
- 退出限定符(64 位)。
- 客户机线性地址(64 位)。
- 客户机物理地址(64 位)。
- 矢量化退出信息。
- 事件传递退出。
- 指令执行退出。
- 错误字段。
EPT
EPT 是一种将主机物理地址转换为客户机物理地址的机制。它与长模式分页机制完全相同。
如果您在分页保护模式下启动客户机,则不需要 EPT。使用无限制客户机要求我们使用 EPT。您可以检查 0x48B (IA32_VMX_PROCBASED_CTLS2) MSR 位 7,以查看是否支持无限制客户机。
手动退出
知道自己是客户机的客户机可能希望故意与主机交换信息。为此,提供了 VMCALL 指令来手动触发退出。
DMMI
DPMI 可以工作,但还需要一个长模式驱动程序。因此,我决定创建一个 TSR 服务,包含在 github 项目中。我称之为 DOS Multicore Mode Interface。它是一个驱动程序,可以帮助您使用 int 0xF0
为 DOS 开发 32 位和 64 位应用程序。此中断可从实模式、保护模式和长模式访问。将函数编号放入 AH
。
要检查其是否存在,请检查 INT 0xF0
的向量。它不应指向 0
或 IRET
,ES:BX+2 应指向 DWORD 'dmmi'。
Int 0xF0
向所有模式(real
、protected
、long
)提供以下功能:
AH = 0
,验证存在。返回值:如果驱动程序存在,则AX = 0xFACE
,DL
= 总 CPU 数,DH
= 虚拟化支持(0 无,1 仅 PM,2 无限制客户机)。此功能可从real
、protected
和long
模式访问。AH = 1
,开始线程。BL 是 CPU 索引(1
到max-1
)。该函数创建一个线程,具体取决于AL
。0
,开始(无)实模式线程。ES:DX
= 新线程的段:偏移。线程以 FS 能够进行非实模式寻址的方式运行,必须使用RETF
返回。1
,开始 32 位保护模式线程。EDX
是线程的线性地址。线程必须使用RETF
返回。2
,开始 64 位长模式线程。EDX
保存启动 64 位长模式代码的线性地址。线程必须使用RET
终止。3
,开始虚拟化线程。BH 包含虚拟化模式(1 表示无限制客户机实模式线程,2 表示保护模式),EDX 包含虚拟化堆栈线性地址(或无限制客户机模式下的段:偏移格式)。线程必须使用RETF
或VMCALL
返回。
AH = 5
,互斥锁函数。此函数可从所有模式访问。AL = 0
=> 初始化互斥锁到ES:DI
(实模式),EDI
线性(保护模式),RDI
线性(长模式)。AL = 1
=> 锁定互斥锁。AL = 2
=> 解锁互斥锁。AL = 3
=> 等待互斥锁。
AH = 4
,执行实模式中断。此函数可从所有模式访问。AL
是中断号,BP
保存AX
值,BX,CX,DX,SI,DI
会被传递给中断。DS
和ES
从ESI
和EDI
的高 16 位加载。AH = 9
,切换到模式。AL = 0 -> 非实模式,立即返回(也从保护模式和长模式的 int 0F0 可用)。AL = 1 -> 保护模式,ECX = 要启动的线性地址。AL = 2 -> 长模式,ECX = 要启动的线性地址。
现在,如果您有多个 CPU,您的 DOS 应用程序/游戏就可以直接访问所有 2^64 内存和所有 CPU,同时仍然能够直接调用 DOS。为了避免直接从汇编调用 int 0xF0
并使驱动程序与更高级的语言兼容,已安装了一个 INT 0x21
重定向处理程序。如果您从主线程调用 INT 0x21
,则直接执行 INT 0x21
。如果您从 protected
或 long
模式线程调用 INT 0x21
,则会自动执行 INT 0xF0
函数 AX = 0x0421
。
虚拟化调试器
在 DOS 下调试保护模式或长模式几乎是不可能的。我现在正在尝试创建一个简单的 DEBUG 增强功能,称为 VDEBUG,它应该能够通过虚拟化调试任何 DOS 应用程序。
这个应用程序应该执行以下操作:
- 加载被调试程序(int 0x21,函数 0x4B01)。
- 进入长模式(int 0xf0,函数 0x0902)。
- 准备虚拟化结构(int 0xf0,函数 0x0801)。
- 启动一个无限制客户机 VM。
- 在 VM 中,设置陷阱标志,使每个操作码都导致 VMEXIT。
- 跳转到被调试程序的入口点。
- 当目标进程调用 int 0x21 函数 0x4C 终止时,控制权会返回到 int 0x21 函数 0x4B01 调用之后的命令。检查那里是否在虚拟机下。如果是,则执行 VMCALL 退出。
- 返回实模式并退出。
目前,已实现的功能有:
- r - (寄存器)- 显示控制、通用、段寄存器、反汇编和使用 UDIS86 的字节。
- g - (go)- 运行程序。
- t - (trace)- 跟踪命令。
- h - (help)- 显示帮助。
- q - (quit)- 退出。
在 config.asm 中编译 VDEBUG=1 以启用 VDebug。
多核调试器
在 DOS 下调试保护模式或长模式几乎是不可能的(再说一遍)。我现在正在尝试创建一个简单的 DEBUG 增强功能,称为 MDEBUG,它应该能够从另一个 CPU 核心调试任何 DOS 应用程序。
这个应用程序应该执行以下操作:
- 跳转到另一个核心。
- 加载被调试程序(int 0x21,函数 0x4B01)。
- 设置陷阱标志。
- 发生异常时,停止第一个处理器,然后转到 MDEBUG 处理器。
- 恢复时,向第一个处理器发送恢复 IPI。
此项目尚未创建,但我希望它很快就会在这里出现!
切换器
使用此 DMMI 客户端实现真正的 DOS 多任务处理。此应用程序应执行以下操作:
- 提示输入核心、可执行文件和参数。
- 在特定处理器内的虚拟化模式下运行可执行文件。
- 在某个按键组合(例如 Ctrl+Alt+Ins)时,执行 VMCALL 并暂停 VM。
- 根据需要切换应用程序。
即将创建!
项目
完整的 github 项目包括本文讨论的许多功能。它按 4 个过滤器组织:16 位代码、32 位代码、数据、DMMI 客户端和配置文件。
您能阅读到这里,说明您确实很有决心。玩得开心,祝您好运!
参考文献
- http://www.fysnet.net/emsinfo.htm,EMS 信息
- http://www.ctyme.com/rbrown.htm,Ralf Brown 中断列表
- http://bochs.sourceforge.net,Bochs
- https://github.com/Himmele/My-Blog-Repository/blob/master/Operating%20Systems/Build%20Your%20Own%20OS/Protected%20Mode%20Tutorial.txt,Till Gerken PM 教程
- https://wiki.osdev.org/Context_Switching,任务切换
- http://www.sudleyplace.com/dpmione/dpmispec1.0.pdf, DPMI 规范
- http://www.delorie.com/djgpp/doc/dpmi/, DJCPP DPMI 示例
- http://www.sudleyplace.com/swat/, 386SWAP 保护模式调试器
- http://dos32a.narechk.net/index_en.html, DOS32A DPMI 扩展器
- http://www.dumais.io/index.php?article=ac3267239dd3e34c061de6413203fb98,VMX 示例和图表