操作系统内核Hack:(二)底层编程基础

操作系统内核Hack:(二)底层编程基础

《操作系统内核Hack:(一)实验环境搭建》中,我们看到了一个迷你操作系统引导程序。尽管只有不到二十行,然而要完全看懂还是需要不少底层软硬件知识的。本文的目的就是跟大家一起学习这一部分知识,本着够用就行的原则,不会完全铺开来,只要能让我们顺利走完未来的操作系统内核Hack之旅就可以了。


1.开篇:“古怪”的80386

如果大家跳过这一部分直接看本文后面的部分,或者您之前接触过操作系统内核的学习,一定会觉得80386的行为很古怪。为什么开机之后要设置一大堆东西然后跳到一个叫做什么保护模式的东西?为什么内存管理要分段还要分页这么麻烦?这种种令人费解的现象背后其实是有一段不同寻常的历史的,了解了其来龙去脉,一切就变得理所当然了!

关于Intel的x86处理器整个家族史就不详谈了,否则可能要说上个几天几夜。我们主要关心具有里程碑意义的两款CPU:8086和80386。为什么只关注这两款CPU呢?首先通过一篇不错的资料简单了解一下这两款CPU的历史,再细说缘由。

80x86段式寻址的原因

Intel 8086是一个由Intel于1978年所设计的16位微处理器芯片,是x86架构的鼻祖。8086 CPU是Intel系列的16位微处理器,也就是说“算术逻辑运算单元(ALU)”的宽度,即数据总线是16位,可直接运算长度是16位的数据。但它却有20条地址线,可直接寻址1MB的存储空间(注:当时认为20根线寻址1MB已经绰绰有余了,详见流传的盖茨大叔语录)。于是Intel设计了一种在当时看来不失为巧妙的方法,即分段的方法。同时配合新引入的四个段寄存器:CS、DS、SS和ES,通过移位相加的方法实现了16位ALU产生20位地址的目的(注:别着急,后面我们会详说)。除此以外,还带来了程序地址不用硬编码(逻辑地址)等好处。

从80286开始,Intel引入了更为先进的保护模式。这种模式下内存段的访问受到了限制,访问内存时不能直接访问段式寻址计算出的地址了,而需要经过额外转换和检查。于是老式的8086方式被成为实模式。终于说到了我们的主角80386 CPU,它是一个32位的CPU,也就是它的ALU数据总线是32位的,同时它的地址总线与数据总线宽度一致也是32位,因此其寻址能力达到4GB。理论上说,当数据总线与地址总线宽度一致时,其CPU结构应该简洁明了。但是,80386无法做到这一点。作为X86产品系列的一员,80386必须维持那些段寄存器的存在,还必须支持实模式,同时又要能支持保护模式。这给Intel的设计人员带来很大的挑战,Intel选择了在段寄存器的基础上构筑保护模式,并且保留段寄存器16位

从8086的16位到80386的32位处理器,这看起来是处理器位数的变化,但实质上是处理器体系结构的变化。从80386以后,Intel的CPU经历了80486、Pentium、PentiumII、PentiumIII等型号,虽然它们在速度上提高了好几个数量级,功能上也有不少改进,但基本上属于同一种系统结构的改进与加强,而无本质的变化,所以我们把80386以后的处理器统称为IA32(32 Bit Intel Architecture)。

说到这里各位应该明白了,之所以不能跳过8086直接介绍80386或之后更新的CPU,是因为兼容的原因。也就是说,80386中一些看似奇怪的行为,例如之上面提到的16位寄存器、段式寻址和保护模式,其实都是“历史遗留问题”造成的。Intel为了向前兼容,必须继续兼容过去的设计。


2.寄存器简介

了解了整个的大背景后,我们首先学习一下最基础知识-寄存器。寄存器可以分为很多种,这里主要学习我们最常用到的两种:通用寄存器和段寄存器。

2.1 通用寄存器

通用寄存器是最常用的一类寄存器,16位CPU的通用寄存器共有8个:AX,BX,CX,DX,BP,SP,SI,DI。它们可以用来参与算术运算和逻辑运算,可以保存运算结果,也可以用来传输数据。在特定用途中,某些通用寄存器是有特殊用处的,例如拷贝数据时,CX为计数器,SI和DI为源和目的地址;BP为栈基地址,而SP为栈顶指针。

2.2 段寄存器

8086有四个段寄存器:代码段寄存器CS、数据段寄存器DS、堆栈段寄存器SS、附加段ES。每当需要产生物理地址时,BIU(总线接口单元)会自动引用一个段寄存器并左移4位再与一个16位的偏移相加。若一个程序的代码长度、堆栈长度和数据长度均不超过64K字节,则可在程序开始时给DS、SS等赋值,这样在程序的其他地方就不用考虑这些段寄存器,程序就能正常运行了。

这样看来,我们之前再熟悉不过的五种寻址方式,其实只是确定那个16位偏移的方式,即有效地址。而决定基地址的值其实在段寄存器里。后面在“分段管理机制”中我们会详细介绍。


3.NASM汇编

因为Orange’s使用NASM(Netwide Assembler),一种非常流行的支持从16位到64位及Linux和Windows平台的汇编器,所以为了能够顺利进行下去,我们有必要简要了解一下汇编语言和NASM的基础知识。此部分内容不用死记硬背,可以留作后续编写汇编代码时的参考手册,可以先关注加粗的重点内容。学会一种汇编语言也是一门手艺,你不知道什么时候就有可能要读或写汇编。

汇编基础知识温习

《六星经典CSAPP-笔记(7)加载与链接》中学习过编译过程。当我们编译高级语言的源代码时,通常包括预处理、编译、汇编、链接四个阶段。实际上driver(GCC)在背后帮我们调度着预处理器cpp、编译器cc1、汇编器as、链接器ld来完成这些工作。现在我们直接手写汇编程序而不是高级语言程序,所以预处理和编译阶段自然就省了。开发测试也就变得简单了,只要写好汇编程序后,如果所有代码都放在一个文件中,则直接用汇编器产生可执行文件。如果放在多个文件,则产生可重定位的.obj目标文件后再链接就可以了。

3.1 AT&T语法 vs. Intel语法

在Linux下,默认的汇编语言编译器(汇编器)是GNU Assembler(GAS)。GAS采用的是一种叫做AT&T的古老的汇编语言语法,只有GAS和一些老式汇编器在使用。而NASM和TASM、MASM(Microsoft Assembler),以及DOS汇编器都采用的是Intel语法。大多数汇编器都支持它,就连新版的GAS也允许在GAS中使用Intel语法了。

之前在学习《深入理解计算机系统》(简称CSAPP)时曾接触过这两种语法,“倔强的”CSAPP坚持全书采用AT&T语法。当时学习时重点不是这两种语法,所以只在读书笔记《六星经典CSAPP-笔记(3)程序的机器级表示》中说了一下四个区别。在此重新整理一下两者的主要差别:

  • 操作数前缀:Intel语法的寄存器和立即数都没有前缀%和$,例如esp而非AT&T语法的%esp,push 4而非AT&T语法的pushl $4
  • 操作数顺序:Intel语法指令的操作数顺序与ATT语法的完全相反,例如mov eax, 4而非AT&T语法的movl $4, %eax
  • 操作数位宽:Intel语法通过在内存操作数(而不是操作码本身)前面加 byte ptr、word ptr和dword ptr来指定大小,例如mov al, byte ptr foo而非AT&T语法的movb foo, %al
  • 间接寻址格式:Intel语法用不同的方式描述内存位置,例如DWORD PTR [ebp+8]而非AT&T语法的8(%ebp)
  • 长跳转指令:Intel语法是call/jmp far section:offset,而AT&T语法是 lcall/ljmp section,offset

还有种更高级的汇编HLA(High Level Assembly),在《The Art of Assembly Language》中通篇都是,“高级”得简直不像汇编。因为跟我们的主题关系不大,所以这里提一句就不细说了。

3.2 二进制文件

这里简要介绍一下常见的二进制文件格式:

  • HEX:Intel标准的十六进制文件,它每行以冒号开头,用十六进制的ASCII码保存机器指令。它常用来保存单片机或其他处理器的目标程序代码。
  • ELF:诸多*nix使用的既可执行(Executable)又可重定位链接(Linkable)的文件格式。类似的还有古老的a.out、COFF以及Windows下的PE,这些都是需要跟操作系统的加载器配合才能正常运行的文件。关于ELF文件格式详见《程序员的自我修养:(1)目标文件 》
  • BIN最纯粹的二进制文件,输出文件中除了你编写的汇编对应的机器码外不会附加任何东西,没有固定的文件扩展名。常见的应用场景有:
    • MS-DOS中的.COM文件
    • 设备驱动中的.SYS文件
    • 操作系统开发
    • 引导程序开发(Boot Loader)
  • AXF:ARM的调试文件,除了包含bin的内容之外,还附加了其他的调试信息。

我们要开发的是操作系统内核,是由硬件而非操作系统直接加载的,所以当然要用Bin文件格式。NASM默认产生BIN格式的可执行文件,可以用-f参数指定其他格式。这里单独指出一个NASM为BIN文件提供的指令ORG,ORG指令指明了当前程序要被加载到内存中的哪里,影响就是:所有内部的地址引用都会加上ORG指明的偏移量

3.3 NASM编程入门

相比MASM、DOS汇编器等Intel语法的汇编器,NASM对汇编语法做了很多简化,写出来的汇编代码比较简洁、优雅,所以受到了诸多开发者的追捧。可惜不知道什么原因,使用NASM的图书却非常少,权威的参考资料只能是官方网站上的教程了。这一部分可以作为参考手册,只有真正动手写NASM汇编代码时才能真正掌握这些语法。

3.3.1 排版

与其他汇编器类似,NASM的每行源代码都包含四部分:标签、指令、操作数、注释

  • 标签和注释是可选的,是否有操作数根据指令语法来定
  • 可以在行尾用”\”将下一行也作为当前行的一部分
  • 空格多少没有限制
  • 有效字符是字母、数字、”_”、”$”、”#”、”@”、”~”、”.”、”?”
    • 只能以字母、”.”、”_”、”?”开头(”.”开头是有特殊含义的)
    • 以”"eax与寄存器eax区分
  • 支持各种机器指令,及显式地指明段寄存器,例如mov [es:bx],ax
  • 支持伪指令(下一小节马上详细介绍)
label:    instruction operands        ; comment

3.3.2 伪指令

伪指令是x86机器指令表里没有的指令,它们可以出现在指令部分,NASM提供的伪指令有:

  • 声明初始化数据:DB, DW, DD, DQ, DT, DO, DY, DZ
  • 声明未初始化数据:RESB, RESW, RESD, RESQ, REST, RESO, RESY, RESZ。这些伪指令的操作数就是要保留的字节或字或双字的长度。
  • 引入外部二进制文件:INCBIN。这对于引入图片或声音文件到游戏可执行文件里非常方面。
  • 定义给定常量值的符号:EQU。必须包含标签,因为EQU实际上是将操作数定义到标签上。
  • 重复指令或数据:TIMES。类似MASM的dup。TIMES的操作数不能是宏。

; 1) DB and Friends: Declaring Initialized Data
          db        0x55                    ; just the byte 0x55 
          db        0x55,0x56,0x57          ; three bytes in succession 
          db        'a',0x55                ; character constants are OK 
          db        'hello',13,10,'$'       ; so are string constants 
          dw        0x1234                  ; 0x34 0x12 
          dw        'a'                     ; 0x61 0x00 (it's just a number) 
          dw        'ab'                    ; 0x61 0x62 (character constant) 
          dw        'abc'                   ; 0x61 0x62 0x63 0x00 (string) 
          dd        0x12345678              ; 0x78 0x56 0x34 0x12

; 2) RESB and Friends: Declaring Uninitialized Data
buffer:   resb      64                      ; reserve 64 bytes 
wordvar:  resw      1                       ; reserve a word 
realarray resq      10                      ; array of ten reals

; 3) INCBIN: Including External Binary Files
          incbin    "file.dat"              ; include the whole file 
          incbin    "file.dat",1024         ; skip the first 1024 bytes

; 4) EQU: Defining Constants
message   db        'hello, world' 
msglen    equ       $-message

; 5) TIMES: Repeating Instructions or Data
zerobuf:  times     64 db 0
buffer:   db        'hello, world' 
          times     64-$+buffer db ' '      ; Make the total length of buffer
                                            ; up to 64
times 100 movsb

3.3.3 有效地址

有效地址指的是指令中引用了内存位置的操作数。有效地址的寻址模式多种多样,最复杂、最强大的就是“偏移量(基地址, 缩放因子, 内存单元长度)”,其他形式都是它的变种。之前在《六星经典CSAPP-笔记(3)程序的机器级表示 》中“2.寄存器与寻址”里曾学习过,这种形式能在一条指令里就访问到类似struct中的数组字段的某一项这种复杂的内存地址,例如:访问rec->a[1]就是8(rec, 1, sizeof(int)) = rec + 1*4 + 8。

struct rec {
    int i;
    int j;
    int a[3];
    int *p;
}

现在就说说NASM中有效地址的格式。与上面AT&T语法不同,NASM的有效地址采用方括号表示,不用方括号括号括起来的符号和变量名都认为是地址,括起来的表示其内容。并且NASM能够自动转换一些看似不正确的有效地址。

        mov     ax,[wordvar] 
        mov     ax,[wordvar+1] 
        mov     ax,[es:wordvar+bx]

; Involving more than one register
        mov     eax,[ebx*2+ecx+offset] 
        mov     ax,[bp+di+8]

; NASM is capable of doing algebra on these effective addresses
        mov     eax,[ebx*5]             ; assembles as [ebx*4+ebx] 
        mov     eax,[label1*2-label2]   ; ie [label1+(label1-label2)]

3.3.4 常量

NASM支持各种进制的数值、字符、字符转义、字符串等等常量。

; 1) Numeric Constants
        mov     ax,200          ; decimal
        mov     ax,0c8h         ; hex 
        mov     ax,$0c8         ; hex again: the 0 is required 
        mov     ax,0xc8         ; hex yet again 
        mov     ax,0hc8         ; still hex
        mov     ax,11001000b    ; binary 
        mov     ax,1100_1000b   ; same binary constant

; 2) Character Constants
        mov eax,'abcd'

; 3) String Constants
        db    'hello'               ; string constant 
        db    'h','e','l','l','o'   ; equivalent character constants

        dd    'ninechars'           ; doubleword string constant 
        dd    'nine','char','s'     ; becomes three doublewords 
        db    'ninechars',0,0,0     ; and really looks like this

; 4) Floating-Point Constants
        db    -0.2                    ; "Quarter precision" 
        dw    -0.5                    ; IEEE 754r/SSE5 half precision 
        dd    1.2                     ; an easy one 
        dd    1.222_222_222           ; underscores are permitted 

3.3.5 表达式

NASM支持各种常见的数学运算,例如位运算|、^、&、<<、>>,算数运算+、-、*、/等。此外,NASM支持两种特殊的符号(token),用于计算当前汇编代码的位置:$$$$表示当前行被汇编后的起始位置,所以jmp $会无限循环;$$表示当前节(section)被汇编后的起始位置,所以我们可以用($-$$)计算距离。

3.3.6 局部标签

NASM从其他汇编器借鉴了局部标签的概念,例如下面例子中label1和label2下的.loop标签,在每个全局标签下都可以定义同名的局部标签,彼此不会发生冲突。而且NASM还更进一步,允许访问其他全局标签下的局部标签,例如从label3中跳转到label1的.loop标签。

label1  ; some code 

.loop 
        ; some more code 

        jne     .loop 
        ret 

label2  ; some code 

.loop 
        ; some more code 

        jne     .loop 
        ret

label3  ; some more code 
        ; and some more 

        jmp label1.loop

4.深入系统引导

计算机的启动引导过程(Booting)涉及到很多硬件和BIOS知识,先简要了解一下,其实我们最关心的两个东西是:引导扇区(Boot Sector)引导程序(Boot Program, Boot Loader)。下面我们就通过磁盘的格式化了解引导扇区是怎么来的、有几种类型,再由计算机的启动过程了解引导程序是如何被加载的以及它的运行环境是什么样的。尽管每一部分的知识看起来有些庞杂,但每一小节我们都会得出一个结论,这个结论对我们最终的总结至关重要。这些知识对我们亲自动手实现一个引导程序以及后续的操作系统内核都是至关重要的,根基牢固才能爬的更高!

4.1 启动

通过这一部分我们了解一下引导程序在整个计算机启动过程中的位置,以及进入引导程序时的系统环境是什么样的。从更高的地方向下俯瞰我们要写的引导程序,有助于我们在脑海中先形成一个清晰的图景。

关于Boot词源的小故事

《30天自制操作系统》中提到Boot的来历:“boot这个词本是长靴的单数形式,它与计算机的启动有什么关系呢?一般应该将启动称为start的。实际上,boot这个词是bootstrap的缩写,原指靴子上附带的便于拿取的靴带。但自从有了《吹牛大王历险记》(德国)这个故事以后,bootstrap这个词就有了“自力更生完成任务”这种意思。而且,磁盘上明明装有操作系统,还要说读入操作系统的程序(即IPL)也放在磁盘里,这就像打开宝物箱的钥匙就在宝物箱里一样,是一种矛盾的说法。这种矛盾的操作系统自动启动机制,被称为bootstrap方式。boot这个说法就来源于此。”

4.1.1 启动过程

计算机可以通过按下机箱上的电源键或软件命令两种方式启动,加电后固化在ROM中的BIOS程序开始工作,进行如下以下过程,参照Wiki上BIOS条目的”Boot process”和”Extensions (option ROMs)”两节:

  1. Power-On Self-Tests(POST):加电自检,检查并初始化各种设备
  2. Option ROMs:硬盘、网卡、显卡、RAID卡等周边设备可能有它们自己的ROM对BIOS进行扩展。这种扩展程序运行在BIOS加载引导程序之前,可以增强或替换已存在的BIOS服务。扩展程序甚至可以包含一整个操作系统,或改变引导过程到网络引导
  3. Load Boot Program:BIOS调用INT 19H中断,根据引导顺序设置,从启动设备(Boot Device)的引导扇区加载引导程序,并移交控制权

4.1.2 初始环境

引导程序的初始环境很简单,参照Wiki上BIOS条目的”Boot environment”一节:

  • CPU:CPU处于16位的实模式
  • 寄存器:通用寄存器和段寄存器的值都未定义,除了CS、SS、SP、DL。其中CS初始为0,IP为0x7c00。因为总是被加载到固定内存位置,所以引导程序无需是可重定位的。DL包含启动设备的驱动号。SS:SP指向一个足够大的有效栈来支持硬件中断
  • BIOS服务:此时所有BIOS服务都是可用的,POST初始化好了系统时钟、中断控制器、DMA控制器及其他主板芯片等
  • 内存:0x00400包含中断向量表,POST已经将其中的每个条目指向BIOS提供的中断处理程序(Interrupt Service Routine, ISR)。0x00400–0x004FF是BIOS的保留区域。0x00500以上的内存都可以被引导程序来使用,引导程序甚至可以覆盖自己

4.2 引导扇区

引导扇区(Boot Sector)是一种特殊的扇区,因此要深入了解就先要学习一下磁盘的物理结构和格式化,这样才能知道引导扇区的来龙去脉。

4.2.1 扇区

每张磁片的正反两面各有一个磁头,磁盘的每一面会对应一个磁头(Head)。每一面都被分为很多条磁道(Cylinder/Track),即表面上的一些同心圆,不同磁片上同一半径的同心圆构成了柱面,柱面数=磁道数。而每一个磁道又按512个字节为单位划分为等分,叫做扇区(Sector)。

扇区是磁盘的最小物理单位,磁盘驱动器在向磁盘读取和写入数据时以扇区为单位,通过磁道-盘面-扇区(Cylinder-Head-Sector, CHS)定位。因此磁盘的容量=磁头数(盘面数)×柱面数(磁道数)×扇区数×512字节。例如3.5寸的软盘有一个磁片,但正反两面都能存储,每面80个磁道,每个磁道18个扇区,所以其总容量=2×80×18×512≈1.44M。

4.2.2 格式化

参照Wiki上Disk Formatting条目,磁盘格式化通常分为三个过程:

  1. 低层次格式化(Low-Level Formatting):磁盘控制器在磁盘中写入扇区标记(Sector Marker)、CRC等永久信息,这些信息隐藏在扇区之间或其他地方,不计入总容量。LLF一般在出厂时完成,出厂后不可能自己重新LLF,我们在操作系统中执行的LLF其实只是恢复了磁盘的一些出厂设置,其实应叫做重新初始化(Reinitialization)
  2. 分区(Partitioning):将磁盘分成一个或多个区域,将数据结构信息写入到每个区域的开始或结束部分。分区由操作系统的分区编辑器(Partition Editor)完成,如fdisk,分区时通常会检查坏道和坏扇区。软盘不存在分区的问题
  3. 高层次格式化(High-Level Formatting):在分区内创建一个空的文件系统格式,并安装引导程序,即快速格式化。也可以全面扫描磁盘上的缺陷。我们重做系统时的格式化就是HLF(如果不重新分区的话)。HLF软件如Windows上的format,Unix上的mkfs。对于软盘来说,LLF和HLF都是由格式化软件一次完成的,90年代后的软盘一般预格式化为DOS的FAT 12文件系统格式

因此,扇区512字节的大小是出厂时就格式化好的,不可修改。下面要介绍到两种类型的引导扇区MBR和VBR,MBR是在分区软件时创建的,而VBR是在HLF时由格式化软件创建的

4.2.3 两种类型

根据前一小节的介绍,引导扇区是安装操作系统时在HLF时建立出来的,那它里面一开始时都包含什么呢?引导扇区包含了可被BIOS加载到内存中执行的机器码。它的具体位置和大小由不同平台的设计规范来决定。在IBM的PC兼容机上,BIOS会将设备的第一个扇区作为引导扇区,加载到0x7c00内存位置执行。其他系统会有所不同。

IBM的PC兼容机的引导扇区有几种格式:

  • Master Boot Record (MBR):已分区的存储系统上的第一个扇区。MBR扇区一般包含:
    • 446字节的引导程序(Bootstrap Code):可以调用活动分区的VBR代码或Boot Manager的代码
    • 64字节的分区表(Disk Partition Table, DPT)。分为16字节的4项,所以只能有4个主分区,且4个主分区中只能有一个是激活的(活动分区,Active Partition)。后来发展为可以指定一个扩展分区(Extended Partition)来容纳很多个逻辑分区(Logical Partition)
    • 2字节的签名(Boot Signature):0x55AA
  • Volume Boot Record (VBR):未分区的存储系统的第一个扇区,或已分区的存储系统上一个独立分区的第一个扇区。不像MBR控制整个磁盘,每个分区都有自己的VBR(网上一些资料中的DBR(DOS Boot Record)和OBR(OS Boot Record)跟VBR说的都是一个东西吧?)。VBR包含:
    • BIOS Parameter Block(BPB):BPB用来描述文件系统的基本结构,像FAT12/16/32、NTFS都使用BPB,不同文件系统的BPB长度会有些差别
    • 引导程序:加载当前存储设备上或独立分区上的操作系统内核。程序可能直接被BIOS调用,也可以被MBR或Boot Manager调用以实现多操作系统引导
    • 2字节的签名:0x55AA

多操作系统安装引导

在我之前一篇《Linux Mint 17一周使用体验》中曾介绍了如何在Windows 7下安装Linux Mint实现双系统。现在回头看,理解得能更深一下。

当时首先删除了E和F两个分区,并把它们合并到一起。然后在Windows 7下用EasyBCD配置Grub,使其从硬盘加载Linux引导文件和iso镜像安装文件。此时EasyBCD应该是修改了硬盘上MBR的引导程序,使其调用Grub而不是直接进入Windows 7。所以重启系统后我们就看到了Grub的多操作系统菜单,选择Linux Mint后Grub就将控制权转移给Linux的安装引导程序。

如果不用EasyBCD,我们如何Hack一下呢?首先在E和F合并后的分区上制作VBR引导扇区,将Linux安装引导程序准确地放到它应在的位置。然后手动修改硬盘的MBR,使其引导代码调用VBR。更完美的话就是自己做一个类似Grub的引导菜单。当然,这要求很高超的技术!

在IBM的PC兼容机上,BIOS忽略VBR和MBR的区别。BIOS固件程序只是简单的加载并运行存储设备上的第一个扇区,只要它以0x55AA结尾。如果是软盘或USB的话,那就是VBR。如果是硬盘的话,那就是MBR。MBR的代码自己去理解DPT中的磁盘分区信息,并运行活动分区上的VBR。同理,VBR自己去理解BPB中当前分区信息,加载并运行第二阶段的引导程序。这种灵活性使得更复杂的Boot Manager成为可能,但增大了滋生引导扇区病毒的可能。因为MBR代码远在操作系统内核和杀毒软件之前运行,所以非常顽固。

BPB跟BIOS有什么关系?

一直困惑BPB到底跟BIOS有什么关系,明明是VBR或者操作系统去读的。最终还是在一篇外文All about BIOS parameter blocks中找到了答案:“The “BIOS” in “BIOS parameter block” is not a reference to what is nowadays commonly referred to as “the BIOS”: the machine’s firmware. PC firmware is wholly ignorant of BIOS parameter blocks. The name has its origins in the design of MS/PC-DOS version 2.0 whereby the operating system was divided into an upper half, dealing with system calls and the abstractions of files and directories, and a lower half, dealing with device drivers and the nitty gritty of physical DASD access. The upper half was the BDOS, the “Basic Disc Operating System”, and the lower half the BIOS, the “Basic Input/Output System”. ”所以说BIOS不是固件BIOS,固件BIOS程序会完全忽略掉BPB,这名字起的可真是坑爹啊!BPB中的BIOS其实指的是DOS下半部系统——Basic Input/Output System。之前DOS使用FAT的第一个字节作为媒体描述符,但渐渐不够用了,于是引入了BPB。后续的DOS版本不断向BPB添加新内容,BPB的长度也越来越长。

4.3 引导程序

512字节扇区大小和BIOS只加载第一个扇区作为引导扇区,这两个无法改变的事实限制住了引导程序的长度也只能是512字节(BIOS只加载一个扇区不知道是不是处于性能考虑?)。一个扇区内的代码能做的事有限,这个问题难不倒聪明的工程师们,于是现行通用的做法就是分阶段引导(Multi-Stage Booting)。

4.3.1 第一阶段

一阶段引导程序(Stage-1 Boot Loader)就是指从BIOS引导至MBR或VBR扇区中的可执行代码。由于分成了多个阶段,所以一阶段引导程序的任务很简单,就是突破512字节的限制!它只需在存储设备上想办法定位到二阶段引导程序,将其拷贝到内存的某个位置,然后跳转到其entry point执行就可以了。

4.3.2 第二阶段

二阶段引导程序(Stage-2 Boot Loader)就是从一阶段引导过来的,可以是LILO、GRUB这种Boot Manager,也可能是活动分区上的VBR代码。二阶段引导程序的任务就是加载操作系统内核,进入保护模式。像GRUB还会有第三阶段,这里就不赘述了。

4.4 本章小结

4.4.1 全引导过程

首先梳理一下第一次安装操作系统时,整个系统的启动过程:

  1. POST加电自检
  2. 从软盘加载并执行VBR
  3. 格式化硬盘:包括LLF、分区(创建MBR)、HLF(建立文件系统和引导程序)
  4. 正式安装:语言、输入设备等设置;拷贝系统文件;网络、账号等设置
  5. 弹出软盘,重启机器
  6. 从硬盘加载并执行MBR
  7. MBR => VBR:MBR加载并执行活动分区的VBR
  8. VBR => Kernel:加载并执行操作系统内核

4.4.2 明确要做的工作

接下来就明确一下我们到底要做什么?首先对以上过程做两个简化:去掉硬盘,我们的操作系统的所有文件都在软盘上;去掉安装过程,因为都在软盘上,所以我们的操作系统直接就是可使用的。所以按照《Orange’S》上的讲解,我们要做的事儿就是:

  • 一阶段引导程序:从BIOS引导过来后,在FAT12软盘上找到二阶段Loader,加载并运行。注:这里我觉得作者偷了点懒,他详细介绍了BPB却没有去用,像RootDirSectors根目录占用扇区数和SectorNoOfRootDirectory根目录起始扇区号都是可以根据BPB信息算出来的,但作者直接用常量写死了,这样BPB就完全是给FreeDOS用的了
  • 二阶段引导程序:从软盘上找到Kernel;进入保护模式;加载运行Kernel
  • 操作系统内核:开始提供各种系统服务

4.4.3 DOS & FAT12

这里我们会有两个疑问:一是不用FAT12文件系统格式可不可以?二是不依赖FreeDOS可不可以?

  1. 用FAT12文件系统的好处是:用二进制拷贝的方式制作软盘,修改起来很麻烦;在系统启动完成后,作为用户的根文件系统(根文件系统(Root File System, RFS)就是系统的根目录,在操作系统启动时其他目录挂载到根目录下)
  2. 依赖FreeDOS加载我们引导程序的好处是:方便用Debug工具调试(比直接用Bochs调试方便吗?)

如果你足够强悍,或者对MS-DOS和FAT存有偏见,或者觉得从FAT12扫描定位文件用了大量汇编代码太麻烦,那你完全可以不用它们,那应该怎么做呢?查阅《linux内核完全注释》后发现,Linux 0.11的两阶段Loader不依赖任何文件系统,一阶段Loader和二阶段Loader都是通过扇区号在磁盘上绝对定位的,真正进入Kernel后才有了文件系统的概念。所以你完全可以模仿Linux 0.11的做法:将文件顺序保存,一阶段Loader加载二阶段Loader,以及二阶段Loader加载Kernel都直接通过扇区号绝对定位,即使用裸扇区,进入Kernel后再考虑根文件系统的问题。你甚至可以完全模拟出一个真正的操作系统的安装过程,如为硬盘分区创建MBR、拷贝操作系统文件到硬盘等等。

可能大家能够看出,这里隐藏了个“先有鸡先有蛋”的问题。因为一般文件系统都是操作系统格式化时创建的,引导扇区和程序也是这时写入的,但我们现在要引导的就是操作系统,这可怎么办?处于现代的我们手段就很多了,就像《Orange’S》中的方式:在Linux上将我们的引导程序和内核编译成二进制文件,再利用FreeDOS将软盘格式化好,解决了代码编译和文件系统的问题也就没有任何问题了。当年Linus是怎样做的呢?Linus是在Minix上编译出的第一版Linux程序,根文件系统用的也是Minix格式的。


5.保护模式基础

保护模式中一个很重要的内容就是虚拟地址物理地址的转换,实际上这是由分段管理和分页管理机制共同合作完成的。但现阶段我们还接触不到分页管理,本着“够用就行”的精神,不让大家失去兴致,所以本部分只介绍保护模式最基础的知识——分段管理机制,看一下二维虚拟地址是如何转换成一维线性地址的。其他高级内容,例如分页管理、特权级检查等,都留到后面陆续学习。

; 15    0   31    0                31     0                 31     0
; ┏━━━━━┓   ┏━━━━━┓    ┏━━━━━━┓    ┏━━━━━━┓    ┏━━━━━━┓     ┏━━━━━━┓
; ┃选择子┃ : ┃偏移量┃ =>┃分段管理┃ => ┗━━━━━━┛ =>┃分页管理┃ => ┗━━━━━━┛ =>
; ┗━━━━━┛   ┗━━━━━┛    ┗━━━━━━┛                ┗━━━━━━┛
;    二维虚拟地址                    一维线性地址                物理地址

5.1 段描述符

在实模式下,逻辑地址是由段值和段内偏移两部分组成。而在保护模式下,段描述符(Segment descriptor)替代了段值。为什么不能继续像以前那样,直接用28位段值和32位偏移不就能完成32位的寻址?因为段描述符不只包含段基地址这个最基本的信息,此外它还包含段的界限、属性、权限等等信息。下面先看一下段描述符的结构。

; 描述符图示:
; 高地址……………………………………………………………………………………低地址
; |  7   | 6 | 5 | 4 | 3 | 2  |  1  |  0   |
; ┏━━━━━━┳━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━┓
; ┃31..24┃ 属性等 ┃段基址(23..0)┃段界限(15..0)┃
; ┃段基址2┃(见下图)┃    段基址1  ┃    段界限1  ┃
; ┗━━━━━━┻━━━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━━┛
;        │        \_____________
;        │                      \___________
;        │                                  \_______________
;        │                                                  \
;        ┏━━┳━━┳━━┳━━━┳━━┳━━┳━━┳━━━━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┓
;        ┃ 7┃ 6┃ 5┃ 4 ┃ 3┃ 2┃ 1┃  0  ┃ 7┃ 6┃ 5┃ 4┃ 3┃ 2┃ 1┃ 0┃
;        ┣━━╋━━╋━━╋━━━╋━━┻━━┻━━┻━━━━━╋━━╋━━┻━━╋━━╋━━┻━━┻━━┻━━┫
;        ┃ G┃ D┃ 0┃AVL┃段界限2(19..16)┃ P┃ DPL ┃DT┃    TYPE   ┃
;        ┗━━┻━━┻━━┻━━━┻━━━━━━━━━━━━━━┻━━┻━━━━━┻━━┻━━━━━━━━━━━┛

可以看到,段描述符要表达的信息非常丰富,远不是32位段寄存器就能表示的,所以也就有了段描述符。这里就简要介绍几个比较关键的字段:

  • 段基地址:用32位表示,可以从32位线性地址空间中的任何一个字节开始,不用像8086的13位表示那样必须被16整除。
  • 段界限:用20位表示,规定了段的大小。
  • 段属性
    • G:段界限的粒度。G=0表示段界限以1字节为单位,20位的段界限可表示1B到1M;G=1表示段界限以4K为单位,20位的段界限可表示4K到4G。
    • DPL:描述符的特权级,用于特权检查以决定该段能否进行访问。
    • DT:描述符的类型。DT=1表示当前是存储段的描述符,即程序可直接访问的代码和数据段;DT=0表示当前是系统段或门的描述符。
    • TYPE:由四位组成,在数据段和代码段中的位2和位1的含义有所不同。分别表示描述符表示的是数据段还是代码段(E)、是否是一致代码段(C)或者数据段向低还是高扩展(ED)、数据段是否可写(W)或代码段是否可读(R)、是否已被访问(A)。

现阶段我们主要关注段基地址就够了。

为什么段基地址是分开的?

在80286保护模式下,段基地址只有24位,段界限只有16位长。尽管80286的描述符也是8个字节长,但实际只使用低6字节,而高2字节必须置为0。所以为了兼容80286,80386才这样安排描述符的结构,使得80286的描述符格式在80386下继续有效。

5.2 段描述符表

本节只介绍全局段描述符表GDT,而LDT和IDT留到需要时再做学习。由段描述符组成的线性表称为段描述符表,每个段描述符表本身形成一个特殊的数据段,最多可以含有8096个段描述符(具体原因请参见下一节段选择子)。在整个系统中,全局段描述符表GDT只有一张。由于GDT不能由GDT本身之内的段描述符进行描述,所以处理器使用一个专门的48位的寄存器GDTR,来为GDT这一特殊的系统段提供一个“伪描述符”

; GDTR图示:
;         ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
;         ┃               32位基地址       ┃ 16位界限 ┃
;         ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━┛

5.3 段选择子

前面讲到在保护模式下,段描述符替代了段值。但是一个段描述符有8个字节长,32位的段寄存器里放不下,这可怎么办?所以我们只能在段寄存器里放描述符在GDT或LDT中的索引,这也就是段选择子(Selector)。就像寄存器放不下一整个数组或对象,我们就用寄存器保存指向它们的指针是一个道理。也就是说在保护模式下,段寄存器不再直接保存段基地址,但它也放不下一整个段描述符,所以我们用它保存段选择子,通过段选择子间接定位到段基地址。我们先了解一下,段选择子究竟包含哪些东西?如何构造一个段选择子?

; 选择子图示:
;         ┏━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┳━━┓
;         ┃15┃14┃13┃12┃11┃10┃ 9┃ 8┃ 7┃ 6┃ 5┃ 4┃ 3┃ 2┃ 1┃ 0┃
;         ┣━━┻━━┻━━┻━━┻━━┻━━┻━━┻━━┻━━┻━━┻━━┻━━┻━━╋━━╋━━┻━━┫
;         ┃                 描述符索引            ┃TI┃ RPL ┃
;         ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━┻━━━━━┛
;
; RPL(Requested Privilege Level): 请求特权级,用于特权检查。
;
; TI(Table Indicator): 引用描述符表指示位
;   TI=0 指示从全局描述符表GDT中读取描述符;
;   TI=1 指示从局部描述符表LDT中读取描述符。

对照上面的图示可以看出,段选择子由三部分构成:

  • 段描述符索引:段选择子的高13位是段描述符索引,也就是段对应的描述符在描述符表中的序号。索引只有13位也是段描述符表最多只能包含8096个描述符的原因
  • TI:第二位是段描述符表的指示位(Table Indicator)。TI=0表示从GDT中读取描述符;TI=1表示从LDT中读取
  • RPL:最低两位是请求特权级(Requested Privilege Level),用于特权检查

5.4 进入保护模式

进入保护模式主要分为以下五步:

  1. 准备GDT准备好要跳转到的段描述符的段基地址,即LABEL_DESC_CODE32的基地址,初始时是0。我们模拟BIU,通过(cs << 4) + LABEL_SEG_CODE32手动计算出LABEL_SEG_CODE32代码段的真实物理地址,并将此物理地址“拆分”保存到LABEL_DESC_CODE32段描述符的基地址,其中ax和al两行代码会保存到第2~4字节的段基址1,ah一行代码会保存到第7字节的段基址2。
  2. 设置GDTR:同理,通过(cs << 4) + LABEL_GDT计算出GDT表基地址的真实物理地址,并将其保存到GdtPtr数据结构的高32位。GdtPtr与GDTR的结构是完全一致的,所以最后用lgdt指令将GdtPtr的值加载到GDTR中。
  3. 打开A20:为了兼容8086,A20地址线关闭时地址超过1MB时会被回卷,所以必须打开A20来激活32位的寻址能力
  4. 设置CR0的PE位:寄存器cr0的第0位是PE位。此位为0时CPU运行于实模式,为1时CPU运行于保护模式
  5. 跳转进入:现在就可以通过选择子跳转了。因为这段代码位于16位的Section,所以要用jmp dword保证偏移量不会被截断
org 07c00h
    jmp LABEL_BEGIN

[SECTION .gdt]
;                                   段基址,         段界限, 属性
LABEL_GDT:          Descriptor       0,                0, 0           ; 空描述符
LABEL_DESC_CODE32:  Descriptor       0, SegCode32Len - 1, DA_C + DA_32; 非一致代码段
LABEL_DESC_VIDEO:   Descriptor 0B8000h,           0ffffh, DA_DRW      ; 显存首地址

GdtLen      equ $ - LABEL_GDT   ; GDT长度
GdtPtr      dw  GdtLen - 1      ; GDT界限
            dd  0               ; GDT基地址

SelectorCode32      equ LABEL_DESC_CODE32   - LABEL_GDT
SelectorVideo       equ LABEL_DESC_VIDEO    - LABEL_GDT

[SECTION .s16]
[BITS   16]
LABEL_BEGIN:
    mov ax, cs
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0100h

    ; 1)初始化 32 位代码段描述符
    xor eax, eax
    mov ax, cs
    shl eax, 4
    add eax, LABEL_SEG_CODE32
    mov word [LABEL_DESC_CODE32 + 2], ax
    shr eax, 16
    mov byte [LABEL_DESC_CODE32 + 4], al
    mov byte [LABEL_DESC_CODE32 + 7], ah

    ; 2)加载 GDTR
    xor eax, eax
    mov ax, ds
    shl eax, 4
    add eax, LABEL_GDT
    mov dword [GdtPtr + 2], eax
    lgdt    [GdtPtr]

    ; 关中断
    cli

    ; 3)打开地址线A20
    in  al, 92h
    or  al, 00000010b
    out 92h, al

    ; 4)准备切换到保护模式
    mov eax, cr0
    or  eax, 1
    mov cr0, eax

    ; 5)真正进入保护模式
    jmp dword SelectorCode32:0  ; 执行这一句会把 SelectorCode32 装入 cs,
                                ; 并跳转到 Code32Selector:0  处

[SECTION .s32]
[BITS   32]
LABEL_SEG_CODE32:
    mov ax, SelectorVideo
    mov gs, ax                  ; 视频段选择子
    mov edi, (80 * 11 + 79) * 2 ; 屏幕第 11 行, 第 79 列。
    mov ah, 0Ch                 ; 0000: 黑底    1100: 红字
    mov al, 'P'
    mov [gs:edi], ax

    jmp $                       ; 到此停止

SegCode32Len    equ $ - LABEL_SEG_CODE32

6.总结:除了遗留还是遗留

至此,80x86在我们眼里不再那样“古怪”了吧!因为要兼容8086的段式寻址方式,所以80x86开机时会首先运行在实模式。因为要兼容8086的20位寻址,所以提供了A20地址线作为开关。一切看起来的不合理之处其实都是有历史原因的。从寄存器到NASM汇编,从系统引导过程到保护模式,我们终于掌握了底层编程需要的所有基础知识,也许不足以支撑我们Hack出一整个操作系统内核,但是对于下一部分BootLoader的制作应该是足够了,那就继续加油吧!


参考资料

  1. ELF BIN HEX
  2. AT&T 与Intel 汇编语法比较
  3. Writing A Useful Program With NASM
  4. Linux Assembly Tutorial - Step-by-Step Guide
  5. The Netwide Assembler: NASM

posted on 2015-10-09 21:22  毛小娃  阅读(737)  评论(0编辑  收藏  举报

导航