【操作系统】模式切换篇

CPU的模式

什么是CPU的模式?这和CPU的发展过程有关,最开始CPU是8位的,后来发展到16位,然后是32位,现在是64位,多少多少位指的是寄存器的位宽。CPU能使用的寄存器宽度以及CPU使用的指令等就构成了CPU的模式,比如16位模式和32位模式,注意除了寄存器,不同模式下CPU对指令的解释也是不同的,因此16位模式的程序是不能在32位模式下运行的。为了向后兼容,后来的CPU要能运行在之前CPU的模式下,比如32位CPU也能跑16位模式,这样在之前CPU上编写的程序也能在新CPU上运行。

说到CPU的模式,我们常常会看到16位实模式,32位保护模式这样的概念,单从字面上看,让人像个丈二的和尚。这两个概念其实包含了两对概念:

  • 16位和32位
  • 实模式和保护模式

16位和32位模式很好理解,它描述的是寄存器的宽度和CPU的指令。实模式和保护模式是个啥呢?要理解这两个概念,我们需要看下这两个概念的英文原文。

  • 实模式原文是real address mode(简称为real mode),全称真实地址模式。
  • 保护模式原文是protected virtual address mode,全称受保护的虚拟地址模式。

首先我们可以看到它们限定的实体都是寻址模式(address mode),也就是CPU访问内存的方式。CPU访问内存不都是二维数组的形式吗?为何还有虚实之分呢?

这里的虚和实指的是段寄存器中的值是(真实的)二维数组下标,还是(虚假的)段描述符偏移。由于虚拟寻址模式下可以控制内存是否允许被访问,所以它又多了一个限定:受保护的。这也意味着在真实寻址模式下,对任意内存的访问都是始终被允许的,是不具备段保护功能的。

保护模式下的段寻址

在切换到保护模式之前,我们还得再唠唠段寻址,上次唠了5毛钱的,这次再唠5毛钱。

首先来回顾一下段寻址的本质,所谓段寻址就是把一维数组抽象成二维数组,用二维数组下标来定位内存字节,CPU会帮我们把二维下标换算成一维下标,这一点依然没有改变。

实地址模式下段寄存器指哪打哪,CPU没有拒绝的理由,然而在保护模式下,CPU是可以拒绝非法的段寻址的,因此,保护模式保护的是段,而不是某个或某几个任意的内存字节。在保护模式下,CPU除了需要知道段的起始地址,还需要知道关于这个段的一些属性,比如段的类型,安全级别等等。这么多的信息,段寄存器肯定是放不下了,要知道即使是在32位(包括64位)模式下,段寄存器依然只有16位。

为了解决这个问题,我们需要把段的相关信息放到内存中,相比于寄存器,内存就如同大海一样。这些放在内存中的段信息称为全局描述符表,英文名Global Descriptor Table,简称GDT。全局描述符表中的每一项代表了一个段的信息,称为段描述符,英文叫Segment Descriptor,简称SD。与此同时,我们还要告诉CPU全局描述符表的起始地址和长度,这样CPU才知道GDT放在那里,这里GDT的起始地址和长度叫做GDT描述符。之后我们使用段寻址时,给到段寄存器的其实是SD相对于GDT起始地址的偏移量。32位保护模式下SD的大小为8字节,如果我们将段寄存器设置为0x8,那么CPU就会使用GDT中的第二个段描述符,如下图所示。注意GDT的第一个段描述符是null,也就是全零,设置这样一个非法的段描述符是为了防止从16位实地址模式切换到32位保护模式后忘记设置段寄存器引发的问题。

段描述符中主要包括段的起始地址,段的大小和段的属性3大部分,SD的结构如下图所示。

段描述符

扫描二维码关注公众号,回复: 14780460 查看本文章

段大小和段起始地址在这8个字节中并不是连续分布的,这样的设计我也想不通,像极了一开始没有预留足够的空间,后来又加上的。

注意Type字段有4个字节,X用来区分代码段还是数据段,中间两个比特在代码段和数段中的解释是不同的,在代码段中,C=0表示该段的代码不能被特权等级(DPL)比它低的段中的代码调用,也就是说只有特权等级更高或相同的段才能调用这个段中的代码,这就是保护模式的关键所在。

切换到32位保护模式:启动超级变换形态

我们不能总是让CPU在16位实地址模式下工作,在真正执行操作系统之前,我们必须将CPU切换到32位保护模式,一是因为效率更高,我们可以利用32位寄存器,二是因为32位保护模式下段寻址方式发生了变化,CPU可以拒绝非法的地址访问。

进入32位保护模式同时也意味着我们要离开BIOS了,因为BIOS是为16位指令编译的,没法在32位模式下使用。因此一旦切换到32位保护模式,包括驱动屏幕,键盘,硬盘等都需要我们自己来编写程序实现了,这绝对是大显身手的好机会,不管怎样,我们先切换过去再说。

切换到保护模式需要在内存中准备两个东西,一个是GDT,一个是GDT描述符。CPU提供了lgdt指令来设置全局段描述符,这只是准备工作,真正让CPU进入32位模式还需要将cr0寄存器的最后一位设置位1。注意,最然我们一直在说32位保护模式,但是它其实包含了两个概念,32位模式和保护模式,要分别进行设置。

注意我们还没介绍中断向量表(IDT),所以目前我们要关闭中断,因为32位模式下BIOS提供的那些中断也不能用了,因为BIOS的程序都是16位指令,无法在32位模式下运行。所以,我们还是先关闭中断吧。

;; 准备GDT
gdt_start:
gdt_null:
  dd 0, 0       ; 第一个段描述符必须是null
gdt_code:
  dw 0xffff     ; Limit(0-15位)
  dw 0x0        ; 基址(0-15位)
  db 0x0        ; 基址(16-23位)
  db 10011010b  ; 第一个标志+类型标志
  db 11001111b  ; 第二个标志+Limit(16-19位)
  db 0x0        ; 基址(24-31位)
gdt_data:
  dw 0xffff     ; Limit(0-15位)
  dw 0x0        ; 基址(0-15位)
  db 0x0        ; 基址(16-23位)
  db 10010010b  ; 第一个标志+类型标志
  db 11001111b  ; 第二个标志+Limit(16-19位) 
  db 0x0        ; 基址(24-31位)
gdt_end:

;; GDT描述符
gdt_descriptor:
  dw gdt_end - gdt_start - 1      ; GDT大小,总是真实大小减一
  dd gdt_start                    ; GDT起始地址

CODE_SEG equ gdt_code - gdt_start ; 代码段描述符偏移量
DATA_SEG equ gdt_data - gdt_start ; 数据段描述符偏移量

;; 切换保护模式
cli                    ; 关闭中断,因为我们还没有设置好保护模式下的中断向量表
lgdt [gdt_descriptor]  ; 设置GDT
mov eax, cr0
or eax, 0x1            ; 将cr0寄存器的最后一位设置为1进入32位模式
mov cr0, eax
jmp CODE_SEG:init_pm   ; 进行一个远跳转(机器码EA),清空指令流水线,
                       ; 因为CPU已经处于32位模式了,指令流水线中的16位指令需要清空掉

bits 32                ; 编译为32位指令,也可写作[bits 32]
;; 初始化保护模式
init_pm:
  mov ax, DATA_SEG     ; 在保护模式,旧的段已经没有用了,
  mov ds, ax           ; 所以,我们将段寄存器指向GDT中定义得数据段
  mov es, ax 
  mov fs, ax 
  mov gs, ax
  mov ebp , 0x90000    ; 更新栈得位置
  mov esp , ebp
  jmp $                ; for {}

上面就是切换到32位保护模式的过程了,关于GDT的设置,目前我们只做了最简单可行的配置,数据段和代码段是重叠的,作为示例,先这样吧。

进入操作系统

我们已经介绍了足够多的基础知识了,也知道了操作系统是如何启动的,我们已经在引导扇区里和BIOS玩的够久了,是时候真正进入操作系统了。

;; 启动扇区
org 0x7c00                        ; 也可以写作 [org 0x7c00]

OS_ADDR       equ   0x7e00        ; 操作系统内存地址
VIDEO_ADDR    equ   0xb8000       ; 帧缓存地址
CODE_SEG      equ   gdt_code - gdt_start   ; 代码段描述符偏移量
DATA_SEG      equ   gdt_data - gdt_start   ; 数据段描述符偏移量

;; ===================
;; 1.读盘
;; 2.准备GDT
;; 3.切换32位保护模式
;; 4.跳转到操作系统
;; ===================

mov  [boot_device], dl            ; 保存引导盘的驱动器号
;; 读盘
mov  ah, 0x2                      ; 读磁盘
mov  al, 0x10                     ; 扇区数,16×512B=8KB
mov  ch, 0                        ; 柱面,从0开始
mov  cl, 2                        ; 扇区,从1开始
mov  dh, 0                        ; 磁头,从0开始
mov  dl, [boot_device]            ; 驱动器号,0:软驱A,1:软驱B,0x80:磁盘C
mov  bx, OS_ADDR                  ; 数据地址
int  0x13                         ; 磁盘中断
jc   read_disk_err                ; 读盘失败则跳转到read_disk_err
;; 清屏
mov ah, 0x6                       ; 屏幕初始化或上卷
mov al, 0x0                       ; 上卷行数,0表示整个窗口空白
mov bh, 0x0                       ; 卷入后空出的行的写入属性
mov ch, 0x0                       ; 左上角行号
mov cl, 0x0                       ; 左上角列号
mov dh, 0x18                      ; 右下角行号
mov dl, 0x4f                      ; 右下角列号
int 0x10                          ; 触发中断
;; 切换保护模式
cli                               ; 关闭中断,因为我们还没有设置好保护模式下的中断向量表
lgdt [gdt_descriptor]             ; 设置GDT
mov  eax, cr0                     ; 将寄存器cr0的最
or   eax, 0x1                     ; 后一位设置为1后,
mov  cr0, eax                     ; CPU进入32位模式
jmp  CODE_SEG:init_pm             ; 执行一个远跳转(机器码EA),清空CPU指令流水线

read_disk_err:
    mov si, err_code_read_disk    ; 错误码写入地址
    call hex2str                  ; 将错误码转换成十六进制字符串
    mov ah, 0x13                  ; 显示字符串
    mov al, 0x1                   ; 写入模式
    mov cx, 0x14                  ; 字符串长度
    mov dh, 0x8                   ; 显示位置的行号
    mov dl, 0x0                   ; 显示位置的列号
    mov bp, err_read_disk         ; 字符串地址
    mov bh, 0                     ; 页号
    mov bl, 0x4                   ; 字符显示属性
    int 0x10                      ; 打印字符串中断
    jmp fin

;; 函数
hex2str:
    pusha
    add si, 1
    shr ax, 4
    shr al, 4
    call hex2ascii
    sub si, 1
    shr ax, 8
    call hex2ascii
    popa
    ret

hex2ascii:
    pusha
    add al, 0x30
    cmp al, 0x39
    jle .num
    add al, 0x7
    .num:
    mov [si], al
    popa
    ret

fin:
    hlt
    jmp fin

bits 32                           ; 编译为32位指令
init_pm:
    mov ax, DATA_SEG              ; 在保护模式,旧的段已经没有用了,
    mov ds, ax                    ; 所以,我们将段寄存器指向GDT中定义得数据段
    mov es, ax 
    mov fs, ax 
    mov gs, ax
    mov ebp, 0x9fb00              ; 更新栈得位置,指向空闲空间的顶部
    mov esp, ebp
 
    call OS_ADDR                  ; 跳转系统内核,也就是os_main
    .fin:
        hlt
        jmp .fin                  ; for {}


;; 数据
boot_device:         db 0
err_read_disk:       db 'read disk failed:'
err_code_read_disk:  db '??H', 0
msg_load_os:         db 'loading os...', 0

;; 准备GDT
gdt_start:
gdt_null:
    dd 0, 0                       ; 第一个段描述符必须为null
gdt_code:                         ; 代码段
    dw 0xffff                     ; Limit(0-15位)
    dw 0x0                        ; 基址(0-15位)
    db 0x0                        ; 基址(16-23位)
    db 10011010b                  ; 第一个标志+类型标志
    db 11001111b                  ; 第二个标志+Limit(16-19位)
    db 0x0                        ; 基址(24-31位)
gdt_data:                         ; 数据段
    dw 0xffff                     ; Limit(0-15位)
    dw 0x0                        ; 基址(0-15位)
    db 0x0                        ; 基址(16-23位)
    db 10010010b                  ; 第一个标志+类型标志
    db 11001111b                  ; 第二个标志+Limit(16-19位) 
    db 0x0                        ; 基址(24-31位)
gdt_end:

;; GDT描述符
gdt_descriptor:
    dw gdt_end - gdt_start - 1    ; GDT大小,总是真实大小减一
    dd gdt_start                  ; GDT起始地址

;; 填充引导扇区
db 510+$$-$ dup 0                 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55                         ; 启动扇区标识

;; 系统内核
; bits 32
os_main:
    mov word [VIDEO_ADDR], 0xf<<8|'A'
    jmp $

db 512+os_main-$ dup 0

;; 填充剩余扇区,因为前面我们读了16个扇区
db 15*512 dup 0

如果你在屏幕左上角看到了’A’,那就说明我们成功进入到了操作系统了。啥,这也能成功?在这里我不得不再次提醒各位,内存是一个数组,数组只有下标和值。CPU只会傻傻的一条接着一条执行指令,程序能否正确执行,主要看能不能跳转到正确的指令,能不能找到正确的数据,而这其中的关键在于指令或数据的下标是不是正确,下标,下标,它真的很重要。

大家一定要想明白call OS_ADDR是如何跳转到os_main的,这里我们取了巧,代码在磁盘镜像中的布局和在内存中的布局是一样的,所以我们不需要使用org额外修正os_main的地址,是不是很神奇呢。

其实我们想做的事情非常简单,将操作系统的机器指令读到内存中,放在一个指定的位置,然后在完成一些基本的初始化和设定后跳转到操作系统的第一条指令所在的地址,至此操作系统登基称帝,接管一切事物。虽然说起来不难,但是这条登基之路却是充满坎坷的。如果把写操作系统比作登山,那现在我们还只是在山脚下转了转,找到了一条上山的小路。

猜你喜欢

转载自blog.csdn.net/puss0/article/details/128321945