65.9K
CodeProject 正在变化。 阅读更多。
Home

PC机的真实模式、保护模式和长模式汇编教程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (173投票s)

2009年12月1日

CPOL

41分钟阅读

viewsIcon

365347

downloadIcon

2601

沉浸在系统编程中!

Github链接:https://github.com/WindowsNT/asm 现在包含VS解决方案以及自动编译/ISO生成/bochs/vmware/virtualbox运行。

臭名昭著的三部曲:第一部分

本文面向希望了解CPU工作原理的用户。我将解释一些汇编基础知识,真实模式、保护模式和长模式。提供了可工作的汇编代码,因此您可以亲自测试处理器在真实模式下的工作方式、如何进入保护模式、如何进入64位模式,以及最后如何退出所有模式并返回DOS。

你敢跟着学吗?我们开始吧!

要求

  • 汇编知识。虽然您不会编写代码,但像寄存器、内存访问、基本命令等基础知识会有帮助。
  • Flat Assembler,一个现代化的汇编器,可以为Win32、x64和DOS生成可执行文件。
  • FreeDOS.
  • Bochs,系统开发人员必备的工具。我推荐它,不仅因为它免费,而且因为它有一个调试器,可以捕获您的程序生成的任何异常并告诉您发生了什么。

在遥远的过去,Borland的Turbo Assembler (TASM)被使用,因为它 first 允许32位段。但自那时以来,情况发生了很大变化,TASM已经过时。此处的所有代码都已配置为使用现代的FASM进行编译,该汇编器可以为DOS和Windows生成16/32/64位可执行文件。

Visual Studio包含ML.EXEML64.EXE,这是较新的MASM版本。但是,这些汇编器只输出Windows可执行文件,并且只针对其各自的体系结构,因此ML.EXE只输出Win32的32位平代码,而ML64.EXE只输出Win64的64位平代码。

代码是一个VS解决方案,它将使用FASM进行编译,然后使用PowerShell创建一个ISO文件,其中包含入口程序entry.exe。然后您可以运行它,它将启动Bochs,并使用配置文件加载一个可引导的FreeDos磁盘,该磁盘上有一个CD-ROM驱动器,其中包含entry.exe,您可以运行它。

目前有一些bug,请继续!

汇编概述

引言

汇编语言基本上是一组低级指令(操作码),用于执行有用的任务和访问内存。为了简化操作,有寄存器——例如,用于获取和设置数据的地方。

分段

内存不像C语言中的数组那样被视为连续的字节数组。它被划分为段。段的含义取决于CPU模式(真实模式、保护模式或长模式)。每个内存地址都通过一个段寄存器(保存段值)和一个偏移量(指示从段开始处的距离)来引用。

堆栈

为了方便函数拥有局部变量(类似于C++)并在它们之间传递数据,每个应用程序都会设置一个特殊的段,称为“堆栈段”,其中保存了用于堆栈的内存地址。堆栈是“后进先出”(LIFO)的向量:最后推入它的就是第一个取出它的。

指针访问

这类似于C语言中的指针。如果您有一个变量在C语言中包含一个指针,您可以使用*来访问数据。在汇编中,您使用[]执行相同的操作。

; assume that DS has the data segment
mov si,000fh
mov dx,[si] ; Now dx has whatever was placed in DS:000fh

如果您尝试访问不存在的东西会怎样?在DOS中,您可能会损坏自己或操作系统,或两者都损坏;在保护模式系统中,您将不允许访问不存在的内存,因此,将调用异常处理程序(否则,您的程序将被终止)。

函数调用和中断

就像C++一样,汇编中也有函数调用(不,并非所有东西都用goto实现!)。根据编程模型(稍后将学习),函数调用可以是near(将当前IP推入堆栈,函数使用RET退出),far(将IP和CS推入堆栈,函数使用RETF退出),或interrupt

中断基本上是一种处理器在被调用时执行的处理程序。很多时候,这些是软件中断,这意味着程序使用INT指令调用它。事实上,所有DOS/BIOS服务都通过中断提供,所以如果程序员执行

; assume that DS has the data segment
mov ah,9
mov dx,msg;
int 21h;

那么程序员就知道他调用了INT 21h的函数9,这是一个DOS函数,用于显示由DS:DX指向的字符串。每个中断(共有255个)的地址存储在中断表中,可以通过SIDT命令访问,并且根据处理器模式(稍后将学习)而有所不同。IPCS和标志被推入堆栈,中断使用IRET退出。

当发生其他事件(通常是异常)时,也可能发生中断。例如,当您的代码将某个数除以零时,将自动调用INT 00hINT 00h的代码会看到您试图用零除,因此无法继续。所以Windows会显示一个漂亮的提示消息并关闭您的应用程序。

如果您可以自己安装一个INT 00h处理程序(通过DOS函数25h),那么异常将在它到达Windows之前到达您的代码(结构化异常处理基本上就是这样做的),但您仍然必须修复错误。如果您无法修复错误,那么您必须中止——这与Windows所做的几乎一样。

在DOS中,默认的异常处理程序除了阻止CPU进一步处理之外几乎不做任何事情,所以您只能通过Ctrl+Alt+Del恢复。

寄存器

16位寄存器

  • AX
  • BX
  • CX
  • DX
  • SI
  • DI
  • BP
  • SP
  • IP
  • 16位标志寄存器

IP保存当前执行点。当执行命令时,IP会自动更改其值。

AXBXCXDX可以整体访问

mov ax,1    ; ax is now 1
mov cx,ax  ; cx is now also 1

或者使用它们的低8位(alblcldl)或高8位(ahbhchdh)。

xor ax,ax  ; ax is now 0

mov ah,1    ; ah is now 1, thus ax is now 0100h
mov al,2   ; al is now 2, thus ax is now 0102h

SIDI始终作为16位寄存器访问(没有sl、sh),它们通常用作数据指针。BP也可以用作通用寄存器(尽管它通常用于访问堆栈),而SP保存当前堆栈项的指针。所以让我们看看当我们向堆栈中推送内容时会发生什么。

; assume that SP has the value of 0100h
mov ax,3   ; ax is now 0
push ax    ; ax is put to the stack, SP has now the value of 00FEh; (100h - 2)
mov dx,5   ; dx is now 5
push dx    ; dx is put to the stack, SP is now 00FCh;
pop bx     ; bx gets the value from the stack top (5). SP gets back to 00FEh
pop cd     ; cx gets the value from the stack top (3). SP gets back to 0100h

当我们推送的内容超过当前堆栈容量会怎样?嘭,堆栈溢出。您可能在C++递归函数中遇到过。

标志寄存器是一组16位(并非所有位都实际使用),它们的值会根据每个操作码的操作而变化。JMP命令的变量(JZJAEJB等)可以根据这些标志进行条件跳转。例如,在操作结果为零后,ZF(零标志)设置为1。

mov ax,bx    ; Get value of BX to AX
or  ax,ax    ; Is AX 0? If yes, or ax,ax will also say 0, so ZF will be 1
je  AxIsZero 

jmp AxIsNotZero

您可以使用pushfpopf将标志设置到寄存器或从寄存器读取,例如

pushf  ; push flags to stack
pop ax ; ax has now the flags

or al,1 ; set the bit 0 to 1

push ax
popf

16位段寄存器

  • CS
  • DS
  • SS
  • ES
  • FS
  • GS

这些寄存器保存一个标识当前段的值。该值如何被解释取决于当前的CPU模式(真实/保护/长模式)。

CS始终保存当前执行代码的段。您不能使用诸如mov cs,ax之类的指令来设置CS。当您调用位于另一个段的函数(FAR call)或跳转到另一个段(FAR jump)时,CS会发生变化。

DS保存默认数据段。这意味着,如果您执行此操作

mov si,0

mov ax,[si]
mov bx,[1000h]

那么AX的值将来自DS指向的段,偏移量由SI指定,而BX的值将来自DS指向的段,偏移量为1000h。如果您想使用另一个段,则必须明确指定。

mov di,0
mov ax,[fs:di]
mov bx,[es:1000h]

DI用作索引时,ES保存默认段。当BP用作索引时,SS是默认段。在所有其他情况下,DS是默认段。请注意,并非所有寄存器都可以用作真实模式下的索引,例如,mov ax,[dx]在真实模式下无效。

ESFSGS是通用辅助段寄存器。SS保存堆栈段的值。

32位寄存器

32位寄存器在所有模式(真实、保护和长模式)下都可用。

  • EAX
  • EBX
  • ECX
  • EDX
  • ESI
  • EDI
  • EBP
  • ESP
  • EIP
  • 32位标志寄存器

它们中的每一个都是其相对16位寄存器的扩展。例如

mov eax,0   ; eax is now 0, so ax is also 0.

mov ax,1    ; ax is now 1, eax is also 1
or eax,0FFFF0000; ax is now 1, eax is now 0FFFF0001h

32位寄存器可在真实模式下使用,但索引(EDIESI)不能使用,除非其上16位为零(即,最大索引仅为65535)。但请参见下面的“非真实模式”,它提供了一种解决方法。

64位寄存器

64位寄存器仅在处理器处于64位模式下可用。它们在真实模式、保护模式或兼容模式下不可用。

  • RAX
  • RBX
  • RCX
  • RDX
  • RSI
  • RDI
  • RBP
  • RSP
  • RIP
  • 64位标志寄存器

此外,x64还定义了8个额外的64位寄存器(r8、r9、r10、r11、r12、r13、r14、r15)用作辅助寄存器,以及一些128位寄存器用于多媒体编程。

在本手册中,当操作应用于32位和64位寄存器时,我使用X(reg)的符号,例如XIP、XSP等。

控制寄存器

  • CR0
  • CR1
  • CR2
  • CR3

这些寄存器包含有关CPU当前状态的信息。

  • CR0主要用于将CPU设置为保护模式(位0),并启用分页(位31)。
  • CR1保留。
  • CR2在触发页面错误异常时保存页面错误线性地址。
  • CR3保存分页表的地址。
  • CR4定义了一些其他标志,例如物理地址扩展(PAE)和VM86模式。

有关更多信息,请参阅Wikipedia上的控制寄存器

调试寄存器

  • DR0
  • DR1
  • DR2
  • DR3
  • DR6
  • DR7

这些寄存器包含硬件调试信息。DR0-DR3保存4个断点的线性地址,DR6-DR7保存用于使用它们的标志。更多信息请参见调试寄存器

测试寄存器

  • TR4
  • TR5
  • TR6
  • TR7

这些寄存器曾用于保存CPU测试信息(现已从集合中移除)。更多信息请参见测试寄存器

真实模式

寻址和分段

在真实模式下,所有内容都是16位的。整个内存不是通过从0开始的绝对索引来访问的,而是被划分为段。每个段代表实际的偏移量乘以16。在此基础上,可以添加一个偏移值来引用从该段开始处的距离。这两部分(段:偏移量)告诉CPU我们需要访问的内存的绝对值。例如:

  • 0000h : 0000h:表示段0,偏移量0。0*16 + 0 = 0 实际内存地址。
  • 0100h : 000Fh:表示段100h,偏移量0Fh。100h*16 + 0Fh = 100Fh = 实际内存地址。
  • 0002h : 0000h:2h*16 + 0 = 20h 实际内存地址。
  • 0001h : 0010h:1h*16 + 10h = 20h 实际内存地址。如您所见,内存地址可能会重叠。

由于段和偏移量都只是16位值,因此通过此方法可访问的最大内存为0ffffh : 0010h = 1MB。指定0ffffh段和大于0010h的偏移量会导致回绕(参见保护模式,A20线)。由于0a000h:0000之后的区域保留给系统(屏幕等),因此DOS应用程序只能使用640KB。

此外,所有段都具有来自任何地方的读/写/执行访问权限(即,任何程序都可以读取/写入或执行任何段内的代码)。因为在16位真实模式OS中,CPU看到的内存如上所述,任何应用程序都可以读取或写入内存的任何部分,包括OS驻留的部分。这就是为什么真实模式OS是单任务OS。

在真实模式下,CS:IP保存当前执行点,DS保存默认数据段,SS保存堆栈段。任何代码段或数据段超过64K的应用程序都必须将其分解为多个段。

中断

中断只是特殊函数,当发生某些事件时(硬件中断),例如除以零,或当通过软件调用时(通过使用INT指令——软件中断)。在真实模式下,有256个中断。包含每个中断的段:偏移量表的初始位置是绝对地址0,但在使用LIDT指令时(使用SITD获取表地址),它可能位于其他位置(在286+上)。

在真实模式下,OS通过软件中断向应用程序提供功能,例如,DOS在INT 21h中提供了一系列函数。

程序执行

程序由DOS加载到一个内存段,执行从EXE文件头中指定的偏移量开始(如果是没有头信息的COM文件,则从0100h开始)。之后,应用程序可以随心所欲地操作,完全破坏内存。这是真实模式的一个“特性”:应用程序拥有整个机器。此外,应用程序可以(通过in/out操作码)直接与任何硬件通信,从而绕过OS可能存在的任何限制或安全措施。如果应用程序崩溃,整个系统都会崩溃,您必须重新启动。

代码

这是一个简单的“Hello World”示例,用于使用多个段的16位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.

汇编器如何知道data16code16code16_2stack16段的实际值?它不知道。它所做的是插入空值,然后在EXE文件中创建条目(称为“重定位”),以便加载器在将代码复制到内存后,在指定地址写入段的真实值。由于此重定位映射有一个头,因此COM文件不能有多个段,即使它们的总和小于64KB。

该程序通过远调用调用另一个段中的ShowMsg函数,该函数使用DOS函数(09hINT 21h)来显示文本。但是,它也可以通过直接写入视频缓冲区(文本模式下位于段0b000h)来实现,从而绕过任何OS或09h函数可能实现的任何安全措施。因此,不可能实现多任务处理,因为每个应用程序都可以轻松地写入任何地方,从而破坏另一个应用程序或OS的数据。

这是一个简单的16位COM“Hello World”示例。

org    100h         ; code starts at offset 100h
use16               ; use 16-bit code
                    ; The SS is same as CS (since only one 
                    ; segment is here) and SP is set to 0xFFFE

mov ax,0900h
mov dx,Msg
int 21h;
mov ax,4c00h
int 21h

Msg db "Hello World!$"

这里有什么区别?所有内容(代码、数据、堆栈)必须位于一个段中。代码必须从偏移量100h开始(以允许DOS将信息放入低100h字节),并且不得定义堆栈段或数据段——COM文件是“内存映像”,并且限制为64KB。因此,COM文件很少使用。

通常,DOS程序由一些代码段、一些数据段和一个堆栈段组成,如上所示。DOS程序通过中断(Interrupts)调用DOS和BIOS函数,并完成其任务。

编程模型

由于段限制为64KB,因此存在许多编程模型,具体取决于应用程序的要求。

  • Tiny,当所有内容都必须放在一个段中(COM文件)。
  • Small,当有一个代码段和一个数据段时。调用和跳转是near。
  • Medium,当有一个数据段但有多个代码段时。调用和跳转是far。
  • Compact,当有一个代码段但有多个数据段时。调用和跳转是near。
  • Large,当有更多的代码段和数据段时。调用和跳转是far。
  • Huge,当数据结构超过64KB时,因此必须通过编程将其分解为多个段。

最常见的模型是Small和Large。

保护模式

在32位保护模式代码中(我们这里不讨论16位保护模式,因为它非常罕见),一个段可以有任何大小,从1字节到4GB。OS定义每个段的大小,现在每个段都可以有访问限制(读、写、执行开或关)。这允许OS“保护”内存。此外,还有4个权限级别(0到3,0为最高),因此,例如,当用户应用程序在级别3下运行时,它不能触及在级别0下运行的OS。

此外,如果一个32位保护模式任务崩溃,OS会捕获异常并安全地终止程序,而不会使任何其他应用程序或OS本身崩溃。这样,就可以实现真正的多任务处理。

多任务处理

许多人认为多任务处理是同时运行应用程序的艺术。这是不正确的,因为一个CPU核心一次只能执行一个命令。实际上发生的是OS允许任务#1运行X时间,切换到任务#2,允许它运行X时间,切换到任务#3,这个过程非常快,以至于看起来是同时进行的。

A-20 线

启用A-20线是使用640KB以上内存的第一步。这个技巧(在286+上可用)是获得0xFFF0字节RAM(在0ffffh:0010到0ffffh:0ffffh范围内)并在真实模式下访问的方法。启用该线(通过键盘控制器!——是的,我甚至不明白为什么)会强制CPU避免回绕。这块内存(称为高内存区域,HMA)由HIMEM.SYS用于加载DOS的部分,从而为应用程序提供更多低内存。

以下代码启用/禁用A20。请注意,如果安装了HIMEM.SYS,A20默认是启用的。应该查询HIMEM.SYS来更改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

以下代码检查A20,如果启用则返回1到CF,否则返回0。

CheckA20:
    PUSH ax 
    PUSH ds
    PUSH es 

    XOR ax,ax 
    MOV ds,ax 
    NOT ax 
    MOV es,ax 
    MOV ah,[ds:0] 
    CMP ah,[es:10h] 
    JNZ A20_ON 

    CLI 
    INC ah 
    MOV [ds:0],ah 
    CMP [es:10h],ah 
    PUSHF 
    DEC ah 
    MOV [ds:0],ah 
    STI 
    POPF 
    JNZ A20_ON 

    CLC 
    POP es
    POP ds
    POP ax 
    RET 

A20_ON: 
    STC 
    POP es
    POP ds
    POP ax 
RET

全局描述符表类型1:应用程序条目

全局描述符表(GDT)是一个包含所有全局可见段的表。每个段都有属性,例如

  • 大小
  • 基地址(内存中的物理地址)
  • 访问限制

对于保护模式,系统维护GDTR寄存器(可通过SGDT / LGDT访问),其中包含6字节数据。

  • 2字节——整个数组的大小。由于每个GDT条目是8字节,因此最多可以指定8192个条目。
  • 4字节——GDT数组在内存中的物理地址。

GDT条目有两种类型。应用程序条目(S标志==1,见下文)和OS条目(S标志==0)。

C++结构体中应用程序条目的GDT定义如下:

struct GDT_STR
{
    unsigned short seg_length_0_15;
    unsigned short base0_15;
    unsigned char  base16_23;
    unsigned char  flags;
    unsigned char  seg_length_16_19:4;
    unsigned char  access:4;
    usigned  char  base24_31;
};

虽然这看起来很简单,但它比您想象的要复杂。让我们检查一下字段。请注意,下面的分析是针对S位设置为1(用户GDT)的。如果此位为0,那么我们讨论的是系统相关的GDT条目(稍后讨论)。

  • seg_length
    • 一个20位的值,描述段的长度。如果G标志(见下文)未设置,此值表示实际段长度。如果设置了G标志,此值乘以4096来表示段长度。所以,如果您将其设置为FFFFh(20位)并且设置了G,则它是10000h * 4096 = 4GB。
  • base
    • 一个32位值,指示段在物理内存中的起始位置。
  • flags
    • 段的标志
      • 位0:类型
        • 0 - 数据
        • 1 - 代码
      • 位1:子类型
        • 对于代码段(B0 == 1)
          • 0 - 非符合执行。非符合段只能从相同特权级别的段调用。
          • 1 - 符合执行。符合执行的段可以从相同或更高特权级别的任何段调用。如果一个段是符合执行的,特权级别为3,您可以从特权级别为0、1或2的段调用它。如果段不是符合执行的,则只能从相同特权级别的段调用。
        • 对于数据段(B0 == 0)
          • 0 - 向上扩展。段从基地址开始,到其限制为止。
          • 1 - 向下扩展。段从其限制开始,到其基地址为止,地址方向相反。此标志的创建是为了方便堆栈段的扩展,但当今的操作系统不使用它。
      • 位2:可访问性
        • 对于代码段(B0 == 1)

          请注意,代码段是不可写的。但是,由于段基址可以重叠,您可以创建一个具有与代码段相同基地址和限制的可写数据段。

          • 0 - 不可读。任何试图从该段读取内存的代码都将产生异常。
          • 1 - 可读。
        • 对于数据段(B0 == 0)
          • 0 - 不可写。任何试图写入此数据段的代码都将产生异常。数据段始终是可读的。
          • 1 - 可写。
      • 位3:已访问
        • 0 - 段未被访问。
        • 1 - 段已被访问。CPU每次访问该段时都会设置此位,以便OS了解访问该段的频率,从而知道是否可以将其缓存到磁盘。
      • 位4:S
        • 0 - 此描述符用于OS。如果未设置此位,则所有GDT条目都具有不同的含义,如下所述。
        • 1 - 此描述符用于应用程序。
      • 位5-6:DPL
        • 此段的特权级别,从00(最高)到11b(3)(最低)。
      • 位7:P
        • 设置为1表示段存在于内存中。如果OS将此段缓存到磁盘,则将P设置为0。任何尝试访问已移除段的操作都将导致异常。OS捕获此异常,并将段重新加载到内存中,再次将P设置为1。
  • access
    • 位0:AVL
      • 未使用,设置为0。
    • 位1:L
      • 设置为0表示32位段。如果设置为1,则表示长模式下使用的64位段。
    • 位2:D

      真实模式段始终为16位默认。

      • 当D未设置时,操作码的默认值为16位。通过添加0x66或0x67前缀,该段仍然可以执行32位命令。
      • 当D设置时,操作码的默认值为32位。通过添加0x66或0x67前缀,该段仍然可以执行16位命令。
    • 位3:G
      • 设置为1表示将seg_length乘以4096来找到实际段长度,如上所述。

正如您所见,段可能根本不存在于内存中,这允许OS将段缓存到磁盘,并仅在需要时重新加载它。

GDT表中的第一个条目始终为0。CPU不从此条目#0读取信息,因此它被视为“虚拟”条目。这使得程序员可以将0值放入段寄存器(DS、ES、FS、GS)而不会导致异常。

以下代码创建了一些GDT条目,然后加载它们。

struc GDT_STR s0_15,b0_15,b16_23,flags,access,b24_31
; 'access' taken as a byte, it is actually 4+4 bits
{
    .s0_15   dw s0_15
    .b0_15   dw b0_15
    .b16_23  db b16_23
    .flags   db flags
    .access  db access
    .b24_31  db b24_31
}

gdt_start    dw gdt_size
gdt_ptr      dd 0
dummy_descriptor   GDT_STR 0,0,0,0,0,0
code32_descriptor  GDT_STR 0ffffh,0,0,9ah,0cfh,0 ; 4GB 32-bit code, 
       9ah = 10011010b = Present, DPL 00,No System, 
       Code Exec/Read. 0cfh access = 11001111b = Big,32bit,
       <resvd 0>,1111 more size
data32_descriptor  GDT_STR 0ffffh,0,0,92h,0cfh,0 ; 4GB 32-bit data, 
       92h = 10010010b = Present, DPL 00, No System, Data Read/Write
stack32_descriptor GDT_STR 0ffffh,0,0,92h,0cfh,0 ; 4GB 32-bit stack
code16_descriptor  GDT_STR 0ffffh,0,0,9ah,0,0    ; 64k 16-bit code
data16_descriptor  GDT_STR 0ffffh,0,0,92h,0,0    ; 64k 16-bit data
stack16_descriptor GDT_STR 0ffffh,0,0,92h,0,0    ; 64k 16-bit data
gdt_size = $-(dummy_descriptor)

; For each of the descriptors, I create this code. 
' I 've only created it now for code32_descriptor.
 xor eax,eax
 mov     ax,CODE32 ; the definition of a CODE32 segment USE32 in our code
 shl     eax,4           ; make a physical address
 mov     [ds:code32_descriptor.b0_15],ax ; store the low 16 bytes
 shr     eax,16
 mov     [ds:code32_descriptor.b16_23],al ; 
 mov     [ds:code32_descriptor.b24_31],ah ;


; assuming that DS points to the segment which all the above resides
; Set gdt ptr
xor     eax,eax
mov     ax,ds
shl     eax,4 ; By multiplying the segment with 16, we make it a physical address
add     ax,dummy_descriptor ; add the offset of the first entry
mov     [gdt_ptr],eax ; save pointer to the physical location
mov     bx,gdt_start
lgdt    [bx] ; load the GDT

请注意,如果您想访问真实模式段中的数据,则必须创建这些段的条目。

选择符

在真实模式下,段寄存器(CSDSESSSFSGS)指定一个真实模式段。您可以将任何值放入其中,无论它指向哪里。您可以从该段读取、写入和执行。在保护模式下,这些寄存器被加载为选择符

选择符

  • 位0-1:RPL
    • 请求保护级别。它必须等于或低于段的DPL。
  • 位2:TI
    • 如果此位设置为1,则选择符选择LDT中的条目,而不是GDT(有关LDT的更多信息,请参见下文)。
  • 位3-15
    • 表中(GDT或LDT)的零基索引。

因此,要将ES加载为code32段,我们将执行

mov ax,0008h  ; 0-1 : 00 privilege, 2 : 0 (GDT), 3-15 = 1 (Second Entry)
mov es,ax

在保护模式下,您不能像在真实模式下那样随意选择段寄存器的值。您必须放入有效值,否则会收到异常。

中断

OS使用LIDT指令加载中断表。IDTR包含6字节数据,2字节用于表长度,4字节用于内存中的物理地址。

其中的每个条目现在是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
}

让我们看一些代码来定义一个中断。

SEGMENT CODE32 USE32
    intr00:
      ; do nothing but return
     IRETD

...
SEGMENT DATA16 USE16

    idt_PM_start      dw             idt_size
    idt_PM_length dd 0
    interrupt0 db 6 dup(0)
    idt_size=$-(interruptsall)
...
SEGMENT CODE16 USE16

      xor eax,eax
      mov eax,CODE32
      shl eax,4 ; Make it physical address
      add eax,intr00 ; Add the offset
      mov [interrupt0 + 2],eax
      mov ax,0008h;  The selector of our COD32
      mov [interrupt0],ax
    
...
mov bx,idt_PM_start
mov ax,DATA16
mov ds,ax
; = NO DEBUG HERE =
cli
lidt [bx]

请注意=NO DEBUG HERE=。一旦重置了IDT表,真实模式调试器就无法工作。所以,如果您尝试单步执行LIDT,您将崩溃。而且,您不能从保护模式调用DOS或BIOS中断。但是,Bochs有自己的硬件调试器,可以单步执行任何内容,您可以在那里进行操作!

准备崩溃

您的第一个保护模式应用程序几乎肯定会崩溃。当发生这种情况时,CPU会执行三重错误并重置。为了避免重置,您可以放入一段真实的代码来执行。

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

如果CPU崩溃,将执行您的例程。该例程必须重置所有寄存器和堆栈,然后退出到DOS。

进入保护模式

cli
mov eax,cr0
or eax,1
mov cr0,eax

之后,您必须执行一次远跳转到一个保护模式代码段,以清除可能的无效命令缓存。使用JMP FAR会导致错误,因为此时汇编器不知道我们处于保护模式。 如果这个代码段是16位代码段,您必须这样做:

db 0eah    ; Opcode for far jump
dw StartPM ; Offset to start, 16-bit
dw 018h    ; This is the selector for CODE16_DESCRIPTOR,
           ; assuming that StartPM resides in code16

如果这个代码段是32位代码段,您必须这样做:

db 66h     ; Prefix for 32-bit
db 0eah    ; Opcode for far jump
dd StartPM ; Offset to start, 32-bit
dw 08h     ; This is the selector for CODE32_DESCRIPTOR,
           ; assuming that StartPM resides in code32

在启用中断之前,必须设置堆栈和其他寄存器。

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
; (You can debug here) ...

非真实模式

由于保护模式无法调用DOS或BIOS中断,因此对于DOS应用程序通常没有用。但是,386+处理器中的一个“bug”变成了一个称为非真实模式的功能。非真实模式是从真实模式访问整个4GB内存的方法。这个技巧是未公开的,但是大量的应用程序(包括HIMEM.SYS)正在使用它。

  • 启用A20。
  • 进入保护模式。
  • 加载一个段寄存器(ESFSGS)到一个4GB的数据段。
  • 返回真实模式。

只要寄存器不改变其值,它仍然指向一个4GB的数据段,因此可以与EDI一起使用来访问整个地址空间。从保护模式返回后,您可以轻松执行

; assuming FS has loaded a 4GB data segment from Protected Mode
mov edi,1048576 ; point above 1MB
mov byte [fs:edi],0 ; Set a byte above 1MB.

286缺乏此功能,因为要退出保护模式,CPU必须重置,因此所有寄存器都会被销毁。

以下函数是一个将CPU置于非真实模式并将FS设置为32位数据段的例程。

struc GDT_STR
        
                s0_15   dw ?
                b0_15   dw ?
                b16_23  db ?
                flags   db ?
                access  db ?
                b24_31  db ?
ENDS        

SEGMENT CODE16 USE16 PUBLIC
ASSUME CS:CODE16

; GDT definitions
gdt_start dw gdt_size
gdt_ptr dd 0
dummy_descriptor GDT_STR <0,0,0,0,0,0>
code16_descriptor  GDT_STR <0ffffh,0,0,9ah,0,0>    ; 64k 16-bit code
data32_descriptor  GDT_STR <0ffffh,0,0,92h,0cfh,0> ; 4GB 32-bit data,   92h = 10010010b = Presetn , DPL 00, No System, Data Read/Write
gdt_size = $-(dummy_descriptor)

dummy_idx       = 0h    ; dummy selector
code16_idx      =       08h             ; offset of 16-bit code segment in GDT
data32_idx      =       10h             ; offset of 32-bit data  segment in GDT

PUBLIC _EnterUnreal
PROC _EnterUnreal FAR

    PUSHAD
    PUSH DS
    
    PUSH CS
    POP DS
    
    mov     ax,CODE16 ; get 16-bit code segment into AX
    shl     eax,4           ; make a physical address
    mov     [ds:code16_descriptor.b0_15],ax ; store it in the dscr
    shr     eax,8
    mov     [ds:code16_descriptor.b16_23],ah

    XOR eax,eax
    mov     [ds:data32_descriptor.b0_15],ax ; store it in the dscr
    mov     [ds:data32_descriptor.b16_23],ah

    
    ; Set gdt ptr
    xor eax,eax
    mov     ax,CODE16
    shl     eax,4
    add     ax,offset dummy_descriptor
    mov     [gdt_ptr],eax

    
    cli
    mov bx,offset gdt_start
    lgdt [bx]
    mov eax,cr0
    or al,1
    mov cr0,eax 
    
    mov ax,data32_idx
    mov fs,ax
    
    mov     eax,cr0         
    and     al,not 1        
    mov     cr0,eax         

    MOV AX,0
    MOV FS,AX
    POP DS
    POPAD    
    
    RETF

ENDP


全局描述符表

S标志设置为0时,GDT条目的含义完全不同。

flags - 段的标志

更多关于门的信息将在本文稍后讨论。

  • 位3 2 1 0:条目类型
    • 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位陷阱门

本地描述符表

本地描述符表(LDT)是一种允许每个应用程序拥有私有段集的方法,使用LLDT汇编指令加载。选择符中的LDT位指定从GDT还是LDT加载段。虽然最初这很有用,但由于分页,现代操作系统不使用它。

调用门

调用门是从低特权代码切换到高特权代码的机制,用于用户级代码调用系统级代码。在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将从门调用返回。

如今,由于更快的SYSENTER/SYSEXIT命令,调用门不再使用。其使用范围仅限于:

  • 当您需要切换到非Ring 3 <-> Ring 0 的转换时(Sys命令只能从3到0,反之亦然)。
  • 用于恶意软件利用,通过修补GDT来创建调用门并执行特权命令。请注意,在Windows的x64版本中,“内核补丁保护”阻止修改GDT。

 

SYSENTER / SYSEXIT

这些指令是从ring 3切换到ring 0的当前方法。您将使用WRMSR来设置CS(0x174)、XSP(0x175)和XIP(0x176)的新值。XCX必须保存SYSEXIT的ring 3堆栈指针,XDX包含SYSEXIT的ring 3 IP。为CS存储的值必须是4个选择符的索引,第一个是ring 0代码,第二个是ring 0数据,第三个是ring 3代码,第四个是ring 3数据。这些值是固定的,因此要使用SYSENTER,您的GDT表必须包含这些条目,格式如下。

这些操作码仅支持在ring 3和ring 0之间切换,但它们速度更快。

 

分页

在使用上述设置时,多任务OS中会出现一些问题:

  • 任务必须完全加载到内存中。
  • DOS应用程序认为它们总是访问最低1MB的RAM,因此它们不能放在其外部。
  • 应用程序必须处理自己的段,这些段必须与其他应用程序的段不同,因此创建动态链接库的应用程序成本很高。

分页是将地址重定向到另一个地址的方法。应用程序使用的地址称为“线性地址”,实际地址称为“物理地址”。

 

32位保护模式分页

最简单的分页形式由2个表组成:页目录页表。页目录是一个包含1024个32位条目的数组,格式如下:

PRUWDANSGA-Addr

  • P - 页面存在于内存中。这个标志允许OS将页面缓存回磁盘,清除P,并在软件尝试访问页面时生成页面错误时重新加载它们。
  • R - 如果设置为1,页面是读写;否则是只读。
  • U - 如果设置,只有ring 0可以访问此页面。
  • W - 如果设置,启用写回。
  • D - 如果设置,页面将不会被缓存。
  • A - 当页面被访问时设置(不是自动设置,与GDT位不同)。
  • N - 设置为0。
  • S - 设置为0。如果启用了PSE,见下文。
  • G - 设置为0。
  • Addr - 页目录条目指向的页表的最高20位(低12位被忽略,因为必须是4096对齐的)。

页表是一个包含1024个32位条目的数组,格式相同。地址指向此页面映射到的实际物理地址。

由于有1024个页表和1024个目录条目,总共可以进行1024x1024个映射。由于页面大小为4096字节,我们可以映射整个32位地址空间。

在启用分页(CR0 PE位)之前,将第一个页目录条目的地址放入CR3。

PSE

如果启用了PSE(CR4位4),那么S可以为1,在这种情况下,页面大小变为4MB,并且页面必须是4MB对齐的。此模式是为了避免大量小页面,但以内存浪费为代价,如果所需内存略大于4MB。幸运的是,模式可以混合使用。

 

物理地址扩展(PAE)

PAE是x86使用36个地址位而不是32个地址位的能力。这使得可用内存从4GB增加到64GB。32位应用程序仍然只能看到4GB的地址空间,但OS可以通过分页将高区域的内存映射到较低的4GB地址空间。扩展此功能是为了在64位软件出现之前应对(如今已不足的)4GB限制。

启用PAE(CR4位5)意味着现在您有3个分页级别:除了PT和PDT之外,您现在有了PDTD(页目录指针表),它有4个64位条目。PDTD中的每个条目都指向一个4KB的页目录(与普通分页相同)。新页目录中的每个条目现在是64位长(因此有512个条目)。新页表中的每个条目都指向一个4KB的页表(与普通分页相同),并且新页表中的每个条目现在是64位长,因此有512个条目。因为这只允许原始映射的四分之一,所以支持4个目录/表条目。第一个条目映射第一个1GB,第二个映射第二个1GB,第三个映射第三个1GB,最后,第四个条目映射第四个1GB。

但是现在PDPT/PDT中的“S”位具有不同的含义:如果未设置,则表示页面条目为4KB;如果设置,则表示该条目不指向PT条目,而是描述一个2MB页面。所以您可以根据S位具有不同级别的分页遍历。

页目录条目中还有一个新的标志,即NX位(位63),如果设置,则阻止在该页面上执行代码。

此系统允许OS处理超过4GB的内存,但由于地址空间仍为4GB,每个进程仍限制为4GB。内存最多可达64GB,但一个进程看不到全部内存。

 

平模式

最初,本地描述符表(LDT)被使用,以便每个应用程序都可以拥有本地段数组。但由于分页,现代32位OS现在使用“平模式”。这样,应用程序可以获得整个4GB地址空间来容纳代码、数据和堆栈,但该地址空间的一部分被映射到不同的物理内存。因此,应用程序可以使用相同的内存地址,这些地址被映射到不同的物理地址。

例如,请看以下在32位Windows下运行的两个C++程序:

int main()
{
    ; CS:EIP at this point is, (say) 010Ch : 00004000h. 
    int flags = MB_OK;
    char* msg = "Hello there";
    char* title = "Title";
    MessageBox(0,msg,title,flags);     ; Address of message box is (say) 00547D45h
}
int main()
{
    ; CS:EIP at this point are the same as in previous program. However paging actually 
    ; maps them to a different physical address so these
    ; two programs do not interfere with same memory.
    ; This is transparent to the application
    int flags = MB_OK;
    char* msg = "Hello there";
    char* title = "Title";
    MessageBox(0,msg,title,flags);
    ; Address of message box is (say) 00547D45h,
    ; and this value is mapped to the same 
    ; memory as in the previous application, so the shared function
    ; "MessageBox" is only once found in physical memory.
}

这使得应用程序程序员无需考虑分段。所有指针都是near的,没有段(所有段都具有相同的值),因此创建应用程序更加容易。没有“small/large”模型之说,因为所有内容都在同一段内。

OS通过分页将所有需要的内存(例如,一个DLL)映射到32位地址空间中的某个虚拟地址,应用程序将永远不会考虑远指针或分段。CS、DS和SS的值永久地查看整个4GB地址空间,但所有地址都是虚拟的,并通过分页映射到应用程序,因此没有分段。

所以,在平模式下:

  • 所有段都扩展到完整的32位4GB。
  • 通过分页,不同的线性地址被映射到相同的物理地址,相似的线性地址被映射到不同的物理地址。
  • 没有分段、LDT、调用门。没有ring 1或ring 2。通过SYSENTER/SYSEXIT进入(它们现在共享旧的LOADALL操作码)。

由于其简单性,“平模式”现在是所有32位OS使用的模式,也是64位模式下唯一存在的模式。

VM86模式

到目前为止,保护模式一切都很好,但当时已经有很多现有的应用程序是真实模式的。即使在今天,Windows下仍然运行着许多(主要是游戏)应用程序。为了让这些认为自己拥有机器的应用程序合作,应该创建一个特殊的模式。

VM86模式是EFlags寄存器的一个特殊标志,允许正常的16位DOS内存映射(640KB),它当然通过分页转发到实际内存——这使得可以同时运行多个DOS应用程序,而不会让一个应用程序有机会覆盖另一个。EMM386.EXE,这个老牌的内存管理器,将处理器置于该状态。OS会对进程进行逐步监视,确保进程不会执行非法操作(所以不要指望在加载EMM386.EXE时进入保护模式,因为一旦您尝试使用LGDT设置GDT,您将被终止)。

一旦设置了VM标志,您就可以将一个普通的“段”加载到段寄存器中。DOS应用程序的中断调用会被OS捕获并通过它来模拟——如果可能的话。此外,一些指令会被忽略,例如,如果您执行CLI,中断实际上并不会被禁用。OS会看到您不希望被打断,并相应地做出反应,但中断仍然存在。

所有VM86代码都在PL 3(最低特权级别)下执行。端口的Ins/Outs也会被捕获并尽可能模拟。VM86有趣之处在于有两个中断表,一个用于真实模式,一个用于保护模式。但只有保护模式的中断会被执行。

VM86已从64位模式中移除,因此64位OS无法再执行16位DOS代码。为了执行此类代码,您需要一个模拟器,如DosBox

HIMEM.SYS

HIMEM是DOS的通用扩展内存管理器。当时,扩展内存主要(如果不是完全)用于从磁盘缓存数据,特别是来自大型应用程序的数据。HIMEM将CPU置于非真实模式(或在286上使用LOADALL),并为需要更多内存而又不干扰保护模式细节的应用程序提供了一个简单的接口。通过启用A20线,HIMEM允许DOS的COMMAND.COM的一部分驻留在高内存区域。由于非真实模式仍然是真实模式,因此即使加载了HIMEM.SYS,您的保护模式应用程序也可以执行我们讨论过的操作。

EMM386.EXE

当时存在一种现已消除的内存形式,称为“扩展”内存。许多应用程序都是为了利用它而编写的,但现代标准是保护模式。EMM386将CPU置于VM86模式,并通过分页将1MB以上的内存映射到真实模式段(0xA0000以上),因此想要使用扩展内存的应用程序可以通过EMM386.EXE来使用它。此外,EMM386允许在CONFIG.SYS中使用“devicehigh”和“loadhigh”命令,允许应用程序尽可能加载到这些高段。

由于VM86模式是保护模式,如果加载了EMM386,您的保护模式应用程序就无法执行我们讨论过的操作,因为一旦您尝试LGDT,您的程序将被终止,因为环3应用程序(记住VM86模式是一种保护模式,DOS应用程序在此模式下运行在环3)无法设置GDT。

DPMI

DOS保护模式接口(DPMI)是一个允许DOS应用程序运行32位代码的系统。非真实模式不足够,因为它只允许移动数据,但不能执行代码。DPMI服务器所做的是处理我们上面讨论过的那些繁琐的表,允许可执行文件直接指定32位代码。当可执行文件调用DOS时,DPMI服务器会捕获调用,切换到真实模式,调用DOS,然后返回到保护模式。

LOADALL

当时存在一个现在已不存在且大部分未公开的指令,LOADALL(286上是0xF0x5,386上是0xF0x7)。LOADALL正如其名,用于从内存中的一个表加载所有寄存器(包括GDTR和IDTR)。在286的LOADALL(386无法访问)中,此表固定在内存地址0x800;而在386的LOADALL中,它读取真实模式ES:EDI指向的缓冲区。由于CPU根本不检查LOADALL加载的任何值是否有效,因此LOADALL被当时的许多工具(包括HIMEM.SYS)用于各种臭名昭著的操作。

  • 从真实模式访问整个内存,而无需进入保护模式和非真实模式。
  • 使用分页运行真实模式代码。
  • 从保护模式返回到真实模式,而无需重置286。
  • 在保护模式下,无需VM86(286上不存在)即可运行正常的16位代码。这是通过捕获每次内存访问(这将导致GPF,因为所有段都被标记为非present)并使用另一个LOADALL来模拟所需结果来实现的。当然,这太慢了,但它促成了386中VM86模式的创建,其中LOADALL最终被淘汰了。

286的LOADALL本身在手册中被提及并且是*部分*公开的;相比之下,386的LOADALL则非常晦涩,可能是为了诱使程序员利用新的VM86模式。

 

长模式

x64 CPU有三种工作模式:

  • 真实模式,与DOS相同。
  • Legacy模式,与32位保护模式相同。
  • 长模式

长模式有两种子模式:

  • 兼容模式:与32位保护模式相同。这允许64位OS运行32位应用程序。
  • 64位模式:用于64位应用程序。

要工作在长模式下,程序员必须考虑以下事实:

  • 与保护模式(可以有或无分页运行)不同,长模式绝对需要PAE和分页。也就是说,即使您的映射是“透明的”,您也不能省略分页。您必须创建PAE风格的页表,并且平模式是长模式下唯一有效的模式。没有分段。
  • AMD文档称,为了进入长模式,您必须先进入保护模式——但是,事实证明这并不总是正确的,因为您现在可以直接从真实模式进入长模式,通过在一个指令中同时启用保护模式和长模式(这可以工作,因为控制寄存器可以从真实模式访问)。我在长模式线程从真实模式创建时使用了这种方法。
  • 虽然理论上任何64位值都可以用作地址,但实际上我们还不需要2^64的内存。因此,当前实现仅实现了48位寻址,这强制所有指针的位47-63要么全为0,要么全为1。这意味着您有2个有效“规范”地址范围,一个从0到0x00007FFF'FFFFFFFF,另一个从0xFFFF8000'00000000到0xFFFFFFFF'FFFFFFFF,总共256TB。大多数OS将上层保留给内核,下层保留给用户空间。而且,您不能使用无用的位来存储关于指针的额外智能信息,因为CPU不会忽略这些位,它强制它们全部为1或全部为0。忘掉您坏习惯吧:)

长模式分页

在长模式下,分页系统增加了一个新的顶层结构,PML4T,它有512个64位长的条目,指向一个PDPT。现在PDPT也有512个条目(而不是x86模式下的4个)。所以现在您可以有512个PDPT,这意味着一个PT条目管理4KB,一个PDT条目管理2MB(4KB * 512个PT条目),一个PDPT条目管理1GB(2MB*512个PDT条目),一个PML4T条目管理512 GB(1GB * 512个PDPT条目)。由于有512个PML4T条目,总共可以寻址256TB(512GB * 512个PML4T条目)。

这也是不使用整个64位进行寻址的另一个原因。使用整个64位会迫使我们拥有6个分页级别。

PDPT/PDT中的每个“S”位可以是0,表示下方还有一个较低级别的结构,或者1,表示遍历在此结束。如果PDPT的S位为1,则页面大小为1GB。

 

全局描述符表

  • 创建64位段
    • 标记为64位的段与4GB限制的32位段非常相似,但L位设置为1且D位设置为0。D位在16位段中设置为0,但当L位设置时,它表示一个64位段。
  • 64位段始终从0开始,始终在0xFFFFFFFFFFFFFFFF结束。

如果您的GDT位于内存的低4GB区域,则在进入长模式后无需更改它。但是,如果您打算在长模式下调用SGDTLGDT,则现在必须处理10字节的GDTR,其中包含2字节用于GDT的长度,8字节用于其物理地址。

您加载到访问64位段的任何选择符都会被忽略,并且DSESSS根本不使用。所有段都是平坦的,一切都通过分页完成。分段时代结束。

中断

您必须重置IDT以使用64位描述符。

其中的每个条目现在是16字节,描述了64位模式下中断处理程序的位置。

struc IDT_STR 
{
 .ofs0_15 dw ofs0_15
 .sel dw sel
 .flags db flags        
 .ofs16_31 dw ofs16_31
 .ofs32_63 dd ofs32_63
 .zero dd zero
}

进入长模式

; 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
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.
  • 关闭分页(如果已启用)。为此,您必须确保您正在“透明”区域中运行。
  • 通过设置CR4的第五位来设置PAE。
  • 创建新的页表并将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

linear rsp,stack64_end

linear是一个查找目标线性地址的宏。SSDSES在64位模式下不使用。也就是说,如果您想访问另一个段中的数据,您不能将DS加载到该段的选择符并访问数据。您必须指定数据的线性地址。数据和堆栈始终使用线性地址访问。“平坦”模式不仅是默认模式,也是64位模式下唯一的模式。但是GS和FS仍然可以用作辅助寄存器,并且它们的值仍然受到GDT的验证。在Windows中,FS指向线程信息块

一旦进入64位模式,操作码的默认值(除了jmp/call)仍然是32位。所以需要一个REX前缀(0x40到0x4F)来标记一个64位操作码。如果您的汇编器支持“code64”段,它会自动处理。

此外,现在必须使用新的LIDT指令设置一个64位中断表,这次接受一个10字节的操作数(2字节用于长度,8字节用于位置),并且IDT表中的每个条目占用10字节,2字节用于选择符,8字节用于偏移量。

返回兼容模式

由于在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位OS会不断地从64位模式跳到兼容模式,以便能够运行64位和32位应用程序。

为什么64位OS的Windows驱动程序必须是64位的?因为没有针对驱动程序(ring 0)代码的WOW64。如果他们愿意,他们可以创建一个——我猜他们想迫使制造商最终迁移到64位。一个不错的决定,我必须承认。

退出长模式

您必须使用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 the dirty, old, protected mode :(

64位下的非真实模式

抱歉,让您暂时感觉良好。没有什么能让您从真实模式访问超过4GB的RAM(除非AMD的CPU中有复活节彩蛋)。此外,虽然32位寄存器EAXEBX等在真实模式下可用,但64位寄存器RAXRBX在兼容模式下甚至不可用——仅在64位模式下可用。或者,谁知道呢?

64位下的虚拟86模式

一旦CPU进入长模式,就不再支持VM86了。这就是为什么64位OS无法运行16位应用程序的原因。但是,像DosBox这样的模拟器可以很好地运行您16位的老游戏。

64位DPMI

不存在,但我做了一些类似的东西,它允许DOS应用程序在真实、保护和长模式下运行多个线程,同时仍然可以访问DOS中断。

https://codeproject.org.cn/Articles/894522/Teh-Low-Level-M-ss-DOS-Multicore-Mode-Interface

是的,我做到了:)

 

代码

此处提供的代码会干扰我们迄今为止讨论过的所有内容。它仍然有一些不完善的函数,但它确实有效。尽情享受吧!

历史

  • 2018年12月30日 - 更多关于平模式实现、拼写错误、线程代码的细节。
  • 2018年12月25日 - 清理,Github代码,VS解决方案。
  • 2015年5月21日 - 分页分析。
  • 2015年3月24日 - 添加了LOADALL,并更新了非真实模式代码。
  • 2015年2月5日 - 添加了调用门和SYSENTER/SYSEXIT信息。
  • 2014年9月30日 - 哇,5年后,至少修复了一些拼写错误。
  • 2009年12月2日 - 首次发布。
PC机的真实模式、保护模式和长模式汇编教程 - CodeProject - 代码之家
© . All rights reserved.