从零开始的操作系统 (第三章:引导扇区编程(16位实模式))

译至: http://www.cs.bham.ac.uk/~exr/lectures/opsys/10_11/lectures/os-dev.pdf

上一篇: 从零开始的操作系统 (第二章:计算机体系结构和引导过程)

即使提供了示例代码,您无疑会发现在二进制编辑器中编写机器代码简直丧心病狂。 您必须记住或引用机器代码来使CPU工作。 幸运的是,并不是你一个人感到困扰,因此才有人编写了汇编程序,将更人性化的指令转换为特定CPU的机器代码。
在本章中,我们将尝试编写一些更复杂的引导扇区程序,以熟悉程序集以及我们的操作系统将运行的环境。

3.1 再探引导扇区

现在,我们将使用汇编语言重新创建二进制编辑的引导扇区,这样我们才能更好的欣赏这些底层语言。
我们可以将它汇编语言编译成实际的机器代码(我们的CPU可以解释为指令的字节序列):

$nasm boot_sect.asm -f bin -o boot sect.bin

其中boot_sect.asm是保存源代码的文件,如图3.1所示,引导sect.bin是我们可以在磁盘上作为引导扇区安装的组装机器代码。
请注意,我们使用-f bin选项指示nasm生成原始机器代码,而不是具有其他元信息的代码包,以便在更典型的操作系统环境中编程时可以和程序进行链接。 除了底层BIOS例程,我们现在是这台计算机上运行的唯一软件。 我们现在是操作系统,虽然现在我们的操作系统只有一个无限循环 - 但我们很快就会建立起来。
从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第1张图片
我们可以通过运行Bochs来方便地测试这个程序,而不是将其保存到软盘的引导扇区并重启我们的机器:

$bochs

或者,我们也可以使用QEmu,如下所示:

$qemu boot_sect.bin

或者,您可以将映像文件加载到虚拟化软件中,或将其写入某些可启动媒体并从真实计算机启动。 请注意,当您将镜像文件写入某个可启动媒体时,并将文件添加到媒体的文件系统中:而是您必须使用适当的工具从底层直接写入媒体(例如直接写入 磁盘的扇区)。

如果我们想更准确地看到汇编程序创建的字节数,我们可以运行以下命令,该命令以易于阅读的十六进制格式显示文件的二进制内容:

$od -t x1 -A n boot sect.bin

这条命令的输出大家应该会很熟悉。
恭喜,您刚刚用汇编语言编写了引导扇区。 正如我们将要看到的,所有操作系统必须以这种方式启动,然后将自己拉入更高级别的抽象(例如更高级别的语言,例如C / C ++)

3.2 16位实模式

CPU制造商必须竭尽全力保持其CPU(即其特定指令集)与早期CPU兼容,以便旧版软件,特别是较旧的操作系统仍可在最现代的CPU上运行。
英特尔和兼容CPU实施的解决方案是模拟该系列中最老的CPU:Intel 8086,它支持16位指令而没有内存保护概念:内存保护对于现代操作系统的稳定至关重要,因为 它可以让操作系统有能力来限制用户的进程访问,比如内核内存,防止这样的进程绕过安全机制甚至关闭整个系统。
因此,为了向后兼容,最重要的是CPU最初以16位实模式启动,随后根据现代操作系统明确要求下切换到更高级的32位(或64位)保护模式,但允许较旧的操作系统 继续运行在16为实模式下,幸福地不知道他们在现代CPU上运行。 稍后,我们将详细介绍从16位实模式到32位保护模式的这一重要步骤。
通常,当我们说CPU是16位时,我们的意思是它的指令一次执行16位的运算,例如:16位CPU将有一个特殊指令将两个16位数 在一个CPU周期相加; 如果一个进程需要将两个32位数字加在一起,则需要更多的周期,即使用16位加法。

首先我们将探索这个16位实模式环境,因为所有操作系统必须从这里开始,然后我们将看到如何切换到32位保护模式以及这样做的主要好处。

3.3呃,你好?

现在我们将编写一个看似简单的引导扇区程序,在屏幕上打印一条短消息。 要做到这一点,我们必须学习一些关于CPU如何工作的基础知识以及如何使用BIOS来帮助我们操作屏幕设备。

首先,让我们考虑一下我们在这里要做的事情。 我们想在屏幕上打印一个字符,但我们不知道如何与屏幕设备进行通信,因为可能有许多不同类型的屏幕设备,它们可能有不同的界面。 这就是我们需要使用BIOS的原因,因为BIOS已经对硬件进行了一些自动检测,显然BIOS先前在屏幕上打印了关于自检等信息,因此可以为我们提供帮助。

那么,接下来,我们要求BIOS为我们打印一个字符,但我们如何让BIOS这样做呢? 没有用于打印到屏幕的Java库 - 你在做梦。 但是,我们可以肯定的是,在计算机内存中的某个位置会有一些知道如何写入屏幕的BIOS机器代码。 事实上,我们可能会在内存中找到BIOS代码并以某种方式执行它,但是这太过复杂,而且不同机器上的BIOS代码不一样。

在这里,我们可以利用计算机的基本机制:中断。

3.3.1中断

中断是一种机制,它允许CPU暂时停止正在执行的操作,并在返回原始任务之前运行其他一些优先级较高的指令。 你可以通过软件指令(例如,int 0x10)或者需要高优先级动作例如某些硬件设备(例如,从网络设备读取一些输入数据)来引发中断。

每个中断由唯一编号表示,该编号是中断向量的索引,该中断向量表最初由BIOS在存储器开始时(即物理地址0x0)设置,该表包含中断服务程序(ISR)的地址指针。ISR只是一系列机器指令,与我们的引导扇区代码非常相似,它处理特定的中断(例如,可能从磁盘驱动器或从网卡读取新数据)。

因此,简而言之,BIOS将一些自己的ISR添加到专门用于计算机某些方面的中断向量中,例如:中断0x10导致调用与屏幕相关的ISR; 和中断0x13,磁盘相关的I / O ISR。

3.3.2 CPU寄存器

就像我们在更高级别的语言中使用变量一样,我们同样需要在操作系统中保存一些临时数据。 所有x86 CPU都有四个通用寄存器ax,bx,cx和dx,正是出于这个目的。 此外,与访问主存储器相比,这些寄存器每个可以保存一个字(两个字节,16位)数据,可以由CPU读取和写入,延迟可以忽略不计。 在汇编程序中,最常见的操作之一是在这些寄存器之间移动(或更准确地说,复制)数据:

mov ax, 1234          ; 在AX寄存器中保存数字1234
mov cx, 0x234         ; 保存16进制数 0x234 到 CX寄存器
mov dx, ’t’           ; 保存ASCLL码  ’t’ in DX寄存器
mov bx, ax            ; 将 AX 寄存器的值复制到 BX寄存器,现在BX == 1234

请注意,目标是mov操作的第一个参数而不是第二个参数,但此规定因不同的汇编程序而异。

有时使用单个字节更方便,因此这些寄存器允许我们单独设置其高字节和低字节:

mov ax, 0             ; ax -> 0x0000 , or in  binary  0000000000000000
mov ah, 0x56          ; ax -> 0x5600
mov al, 0x23          ; ax -> 0x5623
mov ah, 0x16          ; ax -> 0x1623

3.3.3 全部放在一起

所以,回想一下,我们希望BIOS为我们在屏幕上打印一个字符,并且我们可以通过将ax设置为某些BIOS定义的值然后触发特定的中断来调用特定的BIOS例程。 我们想要的具体例程是BIOS滚动远程类型例程,它将在屏幕上打印单个字符并前进光标,为下一个字符做好准备。 发布了一整套BIOS例程,显示了使用哪个中断以及如何在中断之前设置寄存器。 在这里,我们需要中断0x10并设置ah到0x0e(表示远程类型模式)和al中我们要打印的字符的ASCII码。
从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第2张图片

图3.2显示了整个引导扇区程序。 请注意,在这种情况下,我们只需要设置一次AH,然后为根据不同的字符设置AL即可。

从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第3张图片
为了完整起见,图3.3显示了该引导扇区的原始机器代码。 这些是告诉CPU确切要做什么的实际字节。 如果您对编写这样一个几乎没有 - 或许有用的程序所付出的努力感到惊讶,那么请记住这些指令非常贴近CPU的电路,所以它们必然非常简单 ,但也很快。 你现在就开始了解你的电脑了。

3.4 你好,世界!

现在我们将尝试更高级版本的’hello’程序,它引入了一些CPU基础知识,并能够使我们了解引导扇区被BIOS引入的内存结构。

3.4.1 内存,地址和标签

我们之前说过CPU如何从内存中获取和执行指令,以及BIOS如何将我们的512字节引导扇区加载到内存中,然后在完成初始化后告诉CPU跳转到代码的开头,于是 开始执行我们的第一条指令,然后是下一条指令,然后是下一条指令。
所以我们的引导扇区代码在内存中的某个地方; 但是哪里? 我们可以将主存储器想象为可以通过地址(即索引)单独访问的长字节序列,因此如果我们想要找出存储器的第54个字节中的内容,那么54就是我们的地址,这通常是 以十六进制表示更方便:0x36。
因此,我们的从引导扇区代码的开始,即第一个机器代码字节,位于内存中的某个地址。除非我们另有说明,否则我们可能会假设BIOS在内存开头加载了我们的代码,地址为0x0。但这并不是那么简单,因为我们知道BIOS在加载我们的代码之前已经在计算机上进行了初始化工作,并且实际上将持续为时钟,磁盘驱动器等提供硬件中断服务。因此,这些BIOS例程(例如,ISR,用于屏幕显示的服务等)本身必须存储在存储器中的某处,并且必须在它们仍在使用时被保留(即不被覆盖)。此外,我们之前注意到中断向量位于内存的开头,BIOS会将我们放到那儿,所以我们的代码会踩到表,并且在下一次中断发生时,计算机可能会崩溃并重新启动。
事实证明,BIOS总是喜欢将引导扇区加载到地址0x7c00,在那里肯定不会被重要的例程占用。 图3.4给出了刚刚加载引导扇区时计算机典型低内存布局的示例[?]。 因此,虽然我们可能会指示CPU将数据写入内存中的任何地址,但它可能会导致不良事件发生,因为某些内存正被其他例程使用,例如定时器中断和磁盘设备。

3.4.2 'X’标记点

现在我们将玩一个名为“查找字节”的游戏,它将演示内存引用,汇编代码中标签的使用,以及知道BIOS加载我们的位置的重要性。 我们将编写一个汇编程序,为一个字符保留一个字节的数据,然后我们将尝试在屏幕上打印出该字符。 要做到这一点,我们需要弄清楚它的绝对内存地址,所以我们可以将它加载到al并让BIOS打印它,就像上一次练习一样:
从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第4张图片

;
; A simple  boot  sector  program  that  demonstrates  addressing.
;
mov ah, 0x0e            ; int  10/ah = 0eh -> scrolling  teletype  BIOS  routine
; First  attempt
mov al, the_secret
int 0x10                 ; Does  this  print an X?
; Second  attempt
mov al, [the_secret]
int 0x10                 ; Does  this  print an X?
; Third  attempt
mov bx, the_secret
add bx, 0x7c00
mov al, [bx]
int 0x10                 ; Does  this  print an X?
; Fourth  attempt
mov al, [0 x7c1e]
int 0x10                 ; Does  this  print an X?
jmp $                     ; Jump  forever.
the_secret:
db "X"
; Padding  and  magic  BIOS  number.
times  510-($-$$) db 0
dw 0xaa55

首先,当我们在程序中声明一些数据时,我们在其前面加上一个标签(the_secret)。 我们可以在我们的程序中的任何位置放置标签,其唯一目的是为了给我们提供从代码开始到特定指令或数据的方便的偏移。

从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第5张图片
果我们查看图3.5中的组装机器代码,我们可以看到我们的’X’,它具有十六进制ASCII代码0x58,在代码开始之前的偏移量为30(0x1e)字节。

如果我们运行程序,我们会看到只有第二次尝试才能成功打印’X’。
第一次尝试的问题是它试图将立即偏移加载到al中作为要打印的字符,但实际上我们想要在偏移处而不是偏移本身打印字符,如下所示,其中方括号指示 CPU做这件事 - 存储地址的内容。

那么为什么第二次尝试失败了呢? 问题是,CPU将偏移视为来自内存的起始,而不是我们加载的代码的起始地址,它将在中断向量中将其置于其周围。 在第三次尝试中,我们将秘密的偏移添加到我们相信BIOS加载我们的代码0x7c00的地址,使用CPU添加指令。 我们可以将add视为更高级别的语言语句bx = bx + 0x7c00。 我们现在已经计算了’X’的正确内存地址,并且可以使用指令mov al,[bx]将该地址的内容存储在al中,为BIOS打印功能做好准备。

所以现在我们已经看到BIOS确实如何将我们的引导扇区加载到地址0x7c00,我们还看到了寻址和汇编代码标签是的关系。

总是不得不考虑这个标签 - 代码中的内存偏移量,如果在代码顶部包含以下指令,那么很多汇编器将在汇编期间更正标签引用,告诉它你希望代码加载到内存中的特定位置:

[org 0x7c00]

3.4.3定义字符串

假设您想在某个时刻将预定义的消息(例如“引导操作系统”)打印到屏幕上; 你如何在汇编程序中定义这样的字符串? 我们必须提醒自己,我们的计算机对字符串一无所知,并且字符串只是存储在内存中的数据单元序列(例如字节,字等)。
在汇编程序中,我们可以按如下方式定义字符串:

my_string:
    db ’Booting  OS’

我们实际上已经看过db,它转换为“声明数据字节”,它告诉汇编器将后续字节直接写入二进制输出文件(即不要将它们解释为处理器指令)。 由于我们用引号包围我们的数据,汇编器知道将每个字符转换为其ASCII字节代码。 请注意,我们经常使用标签(例如我的字符串)来标记数据的开头,否则我们就没有简单的方法在代码中引用它。
在这个例子中我们忽略的一件事是知道一个字符串与知道它在哪里同等重要。 由于我们必须编写处理字符串的所有代码,因此必须有一个一致的策略来知道字符串的长度。 有一些可能性,但惯例是将字符串声明为空终止,这意味着我们总是将字符串的最后一个字节声明为0,如下所示:

my_string:
    db ’Booting  OS’,0

当稍后迭代一个字符串时,可以依次打印它的每个字符,并且很容易地确定字符串的结尾。

3.4.4 使用堆栈

在关于底层编程的主题时,我们经常听到人们谈论堆栈,就像它是一些特殊的东西。 堆栈实际上只是解决以下不便之处的简单解决方案:CPU具有有限数量的寄存器,用于临时存储我们的例程的局部变量,但是我们经常需要比这些寄存器更多的临时存储空间。 现在,我们显然可以使用主存储器,但是在读写时指定特定的存储器地址是不方便的,特别是因为我们不关心数据存储的确切位置,只是我们可以很容易地检索它。 而且,正如我们稍后将看到的,堆栈对于参数传递以实现函数调用也很有用。

因此,CPU提供了两个push和pop指令,分别允许我们存储一个值并从堆栈顶部检索一个值,因此不必担心它们存储的确切位置。 但请注意,我们无法在堆栈上和从堆栈中弹出单个字节:在16位模式下,堆栈仅适用于16位边界。

该堆栈由两个特殊的CPU寄存器bp和sp实现,它们分别保持堆栈基址(即堆栈底部)和堆栈顶部的地址。 由于堆栈随着数据推送而扩展,我们通常将堆栈的基础设置为远离重要的内存区域(例如BIOS代码或我们的代码),因此如果堆栈变得太大,它们就没有覆盖的危险。 关于堆栈的一个令人困惑的事情是它实际上从基指针向下增长,所以当我们发出推送时,该值实际上存储在bp的地址下面—而不是上面 - 并且sp减少了 价值的大小。

图3.6中的以下引导扇区程序演示了堆栈的使用。

3.4.5控制结构

如果我们不知道如何编写一些基本的控制结构,例如if…then…elseif…else,for和while,我们将永远不会习惯使用编程语言。

编译后,这些高级控制结构简化为简单的跳转状态。 实际上,我们已经看到了最简单的循环示例:

some_label:
    jmp  some_label   ; 跳转到some_label所在的位置

或者,具有相同的效果:

jmp $ ; 跳转到当前指令,也就是无限循环了。

所以这条指令为我们提供了一个无条件的跳跃(即它总会跳跃); 但我们经常需要根据某些条件跳转(例如继续循环,直到我们循环十次,等等)

从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第6张图片

通过首先运行比较指令,然后通过发出特定的条件跳转指令,以汇编语言实现条件跳转。

	cmp ax, 4         ; 比较 ax 和 4
	je  then_block    ; 如果上面的结果相等则跳转
	mov bx, 45       ; 否则执行下一条指令
jmp  the_end      ; 直接跳转到 the_end标签所在的指令并执行
; 所以下面这行代码不会被执行
then_block:
	mov bx, 23
the_end:

如果在C或Java之类的语言中,如下所示:

if(ax == 4) {
bx = 23;
} else {
bx = 45;
}

我们可以从汇编示例中看到,幕后发生的事情是将cmp指令与其进行的je指令相关联。 这是CPU的特殊标志寄存器用于捕获cmp指令结果的示例,以便后续的条件跳转指令可以确定是否跳转到指定的地址。
根据早期的cmp x,y指令,可以使用以下跳转指令:

je   target   ; jump if equal                     (i.e. x == y)
jne  target   ; jump if not  equal                (i.e. x != y)
jl   target   ; jump if less  than                (i.e. x < y)
jle  target   ; jump if less  than or  equal     (i.e. x  <= y)
jg   target   ; jump if  greater  than            (i.e. x > y)
jge  target   ; jump if  greater  than or  equal (i.e. x  >= y)

3.4.6调用函数

在高级语言中,我们将大问题分解为函数,这些函数基本上是我们在整个程序中反复使用的通用例程(例如打印消息,写入文件等),通常会更改我们的参数 传递给函数以某种方式改变结果。 在CPU级别,一个函数只不过是跳转到有用例程的地址,然后在第一次跳转后立即再次跳回到指令。
我们可以像这样模拟一个函数调用:

...
...
    mov al, ’H’           ; Store  ’H’ in al so our  function  will  print  it.
    jmp  my_print_function
    return_to_here:         ; This  label  is our life -line so we can  get  back.
...
...
my_print_function:
    mov ah, 0x0e          ; int =10/ah=0x0e  -> BIOS tele -type  output
    int 0x10               ; print  the  character  in al
    jmp  return_to_here   ; return  from  the  function  call.

首先,请注意我们如何使用寄存器al作为参数,通过设置它为函数使用做好准备。 这就是如何在更高级别的语言中实现参数传递的方式,其中调用者和被调用者必须就将传递的参数的位置和数量达成一致.
遗憾的是,这种方法的主要缺陷是我们需要在调用函数后明确说明返回的位置,因此无法从程序中的任意点调用此函数 - 它将始终返回 相同的地址,在这种情况下标签返回到这里.
借用参数传递的思想,调用者代码可以在一些众所周知的位置存储正确的返回地址(即紧接在调用之后的地址),然后被调用的代码可以跳回到该存储的地址。 CPU跟踪特殊寄存器ip(指令指针)中正在执行的当前指令,遗憾的是,我们无法直接访问。 然而,CPU提供了一对指令,call和ret,它们正是我们想要的:调用行为类似于jmp,但另外,在实际跳转之前,将返回地址推送到堆栈; ret然后从堆栈中弹出返回地址并跳转到它,如下所示:

...
...
mov al, ’H’           ; Store  ’H’ in al so our  function  will  print  it.
call  my_print_function
...
...
my_print_function:
mov ah, 0x0e          ; int =10/ah=0x0e  -> BIOS tele -type  output
int 0x10               ; print  the  character  in al
ret

我们的功能现在几乎都是独立的,但是还有一个丑陋的问题,如果我们现在不厌其烦地考虑它,我们将在以后感谢自己。 当我们在汇编程序中调用函数(如print函数)时,内部该函数可能会改变几个寄存器的值来执行其工作(实际上,寄存器是稀缺资源,它几乎肯定会这样做),所以 当我们的程序从函数调用返回时,可能不安全地假设,例如,我们存储在dx中的值仍然存在。
因此,一个函数通常是合理的(并且是礼貌的),用于立即将其计划改变的任何寄存器推送到堆栈上,然后在它返回之前立即再次将它们弹出(即恢复寄存器的原始值)。 由于函数可以使用许多通用寄存器,因此CPU实现了两个方便的指令,即pusha和popa,它们分别方便地将所有寄存器推入和弹出堆栈,例如:

...
some_function:
pusha                  ; Push  all  register  values  to the  stack
mov bx, 10
add bx, 20
mov ah, 0x0e          ; int =10/ah=0x0e  -> BIOS tele -type  output
int 0x10               ; print  the  character  in al
popa                   ; Restore  original  register  values
ret

3.4.7 包含文件

在即使是看似最简单的汇编程序之后,您也可能希望在多个程序中重用代码。 nasm允许您按字面包含外部文件,如下所示:

%include "my_print_function.asm"    ; this  will  simply  get  replaced  by
; the  contents  of the  file
...
mov al, ’H’         ; Store  ’H’ in al so our  function  will  print  it.
call  my_print_function

3.4.8 全部整合

我们现在对CPU和程序集有足够的了解,可以编写更复杂的“Hello,World”引导扇区程序。

将本节中的所有想法放在一起,以创建一个用于打印以null结尾的字符串的自包含函数,可以按如下方式使用:

;
; A boot  sector  that  prints a string  using  our  function.
;
[org 0x7c00]    ; Tell  the  assembler  where  this  code  will be  loaded
mov bx, HELLO_MSG    ; Use BX as a parameter  to our  function , so
call  print_string    ; we can  specify  the  address  of a string.
mov bx, GOODBYE_MSG
call  print_string
jmp $                  ; Hang
%include "print_string.asm"
; Data
HELLO_MSG:
db ’Hello , World!’, 0 ; <-- The  zero on the  end  tells  our  routine
;      when to stop  printing  characters.
GOODBYE_MSG:
db ’Goodbye!’, 0
; Padding  and  magic  number.
times  510-($-$$) db 0
dw 0xaa55

3.4.9摘要

尽管如此,我们还是觉得我们没有走得太远。 这没关系,这很正常,考虑到我们一直在努力的原始环境。如果你已经了解了所有这些,那么我们就在路上。

3.5 护士小姐,请把我的听诊器拿过来

到目前为止,我们已经设法让计算机打印出我们已加载到内存中的字符和字符串,但很快我们将尝试从磁盘加载一些数据,因此如果我们可以显示存储的十六进制值将非常有用 在任意内存地址,以确认我们是否确实设法加载任何东西。 请记住,我们没有一个漂亮的开发GUI,配有一个调试器,可以让我们仔细检查并检查我们的代码,当我们犯错时计算机可以给我们的最好的反馈是什么都不发生,所以我们需要照顾好自己。

我们已经编写了一个例程来打印出一串字符,所以我们现在将这个想法扩展到一个十六进制的打印程序。
让我们仔细考虑一下我们将如何做到这一点,从考虑我们如何使用这个例程开始。 在高级语言中,我们喜欢这样的东西:print hex(0x1fb6),这将导致在屏幕上打印字符串’0x1fb6’。 我们已经在XXX节中看到了如何在汇编中调用函数以及如何使用寄存器作为参数,所以让我们使用dx寄存器作为参数来保存我们希望打印hex函数的值:

mov dx, 0x1fb6   ; store  the  value  to  print in dx
call  print_hex   ; call  the  function
; prints  the  value  of DX as hex.
print_hex:
...
...
ret

由于我们正在将一个字符串打印到屏幕上,我们不妨重复使用我们早期的打印功能来完成实际的打印部分,然后我们的主要任务是查看我们如何从参数中的值构建该字符串,dx。 我们绝对不希望在组装工作时混淆比我们需要的更多的事情,所以让我们考虑以下技巧让我们开始使用这个功能。 如果我们在代码中将完整的十六进制字符串定义为一种模板变量,正如我们之前定义的“Hello,World”消息,我们可以简单地获取字符串打印功能来打印它,然后我们的print hex例程的任务是 更改该模板字符串的组件以将十六进制值反映为ASCII代码:

mov dx, 0x1fb6   ; store  the  value  to  print in dx
call  print_hex   ; call  the  function
; prints  the  value  of DX as hex.
print_hex:
; TODO: manipulate  chars  at  HEX_OUT  to  reflect  DX
mov bx, HEX_OUT    ; print  the  string  pointed  to
call  print_string ; by BX
ret
; global  variables
HEX_OUT: db ’0x0000 ’,0

3.6读取磁盘

我们现在已经介绍了BIOS,并且在计算机的低级环境中发挥了一点作用,但是我们遇到了一个小问题,这妨碍了我们编写操作系统的计划:BIOS加载了我们的启动代码 磁盘的第一个扇区,但这就是它加载的全部; 如果我们的操作系统代码更大 - 我猜它将超过512字节,该怎么办?

操作系统通常不适合单个(512字节)扇区,因此他们必须做的第一件事就是将其余代码从磁盘引导到内存中,然后开始执行该代码。 幸运的是,正如之前所暗示的,BIOS提供了允许我们操作驱动器上的数据的例程。

3.6.1使用段扩展内存访问

当CPU以其初始16位实模式运行时,寄存器的最大大小为16位,这意味着我们可以在指令中引用的最高地址是0xffff,这相当于今天的标准,达到64 KB(65536)字节)。 现在,也许我们想要的简单操作系统不会受到这个限制的影响,但是日常操作系统永远不会舒适地坐在这么紧凑的盒子里,所以我们理解分割的解决方案很重要 ,对这个问题。

为了解决这个限制,CPU设计人员添加了一些特殊的寄存器,cs,ds,ss和es,称为段寄存器。 我们可以想象主存储器被分成由段寄存器索引的段,这样,当我们指定一个16位地址时,CPU会自动计算绝对地址,作为由我们指定的地址所覆盖的相应段的起始地址[?]。 通过适当的段,我的意思是,除非另有明确说明,否则CPU将从适合于我们指令上下文的段寄存器中偏移地址,例如:指令mov ax,[0x45ef]中使用的地址默认为 偏离数据段,由ds索引; 类似地,堆栈段ss用于修改堆栈的基指针bp的实际位置。
关于段寻址最令人困惑的事情是相邻段几乎完全重叠但是对于16个字节,因此不同的段和偏移组合实际上可以指向相同的物理地址; 但足够的话题:在我们看到一些例子之前,我们不会真正掌握这个概念。

要计算绝对地址,CPU将段寄存器中的值乘以16,然后再添加偏移地址; 因为我们使用十六进制,当我们将数字乘以16时,我们只需将数字向左移位(例如0x42 * 16 = 0x420)。 因此,如果我们将ds设置为0x4d然后发出语句mov ax,[0x20],则存储在ax中的值实际上将从地址0x4d0(16 * 0x4d + 0x20)加载。

图3.7显示了我们如何设置ds来实现类似的标签寻址校正,就像我们在XXX节中使用[org 0x7c00]指令一样。 因为我们不使用org指令,所以当BIOS将代码加载到地址0x7c00时,assmebler不会将我们的标签偏移到正确的内存位置,因此首次尝试打印’X’将失败。 但是,如果我们将数据段寄存器设置为0x7c0,CPU将为我们执行此偏移(即0x7c0 * 16 +the_secret),因此第二次尝试将正确打印’X’。 在第三次和第四次尝试中,我们做同样的事情,得到相同的结果,但是在计算物理地址时使用明确的状态向CPU说明要使用哪个段寄存器,而是使用通用段寄存器es。
请注意,CPU的电路限制(至少在16位实模式下)在这里显示出来,当看似正确的指令如mov ds,0x1234实际上不可能时:只是因为我们可以将文字地址直接存储到通用目的中 寄存器(例如mov ax,0x1234或mov cx,0xdf),并不意味着我们可以对每种类型的寄存器执行相同的操作,例如段寄存器; 因此,如图3.7所示,我们必须采取额外步骤通过通用寄存器传输值。

因此,基于段的寻址使我们能够进一步进入内存,最高可达1 MB(0xffff * 16 + 0xffff)。 稍后,当我们切换到32位保护模式时,我们将看到可以访问更多内存,但是现在我们可以理解基于16位实模式段的寻址。

3.6.2磁盘驱动器的工作原理

在机械方面,硬盘驱动器包含一个或多个堆叠的盘片,这些盘片在读/写头下旋转,就像旧的记录播放器一样,只是潜在地增加容量,几个记录一个堆叠在另一个上面,头部移入和移出 覆盖整个特定旋转盘片的表面; 并且由于特定的盘片在其两个表面上都是可读和可写的,因此一个读/写头可能在其上方和另一个下面。 图3.8显示了典型硬盘驱动器的内部,其中放置了一叠盘片和头部。 请注意,相同的想法适用于软盘驱动器,而不是几个堆叠的硬盘,通常有一个单面的双面软盘介质。
从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第7张图片

盘片的金属涂层赋予它们表面的特定区域可被磁头磁化或消磁的特性,有效地允许任何状态永久记录在它们上面[?]。 因此,重要的是能够描述磁盘表面上要读取或写入某个状态的确切位置,因此使用Cylinder-Head-Sector(CHS)寻址,这实际上是一个3D坐标系统(参见图3.9):

  • 柱面:柱面描述了磁头与磁盘外边缘的离散距离,因此,当几个磁盘堆叠起来时,您可以看到所有磁头都通过所有磁盘选择柱面
  • 磁头:(柱面内的哪个特定盘片表面)。
  • 扇区:循环轨道被划分为扇区,通常容量为512字节,可以用扇区索引引用

从零开始的操作系统 (第三章:引导扇区编程(16位实模式))_第8张图片

3.6.3 使用BIOS读取磁盘

正如我们稍后会看到的那样,特定设备需要编写特定的例程来使用它们,因此,例如,软盘设备要求我们明确地打开和关闭在读取和旋转下旋转磁盘的电机。 在我们可以使用它之前先编写头,而大多数硬盘设备在本地芯片上自动执行更多功能[? 但是,这些设备连接到CPU的总线技术(例如ATA / IDE,SATA,SCSI,USB等)也会影响我们访问它们的方式。 值得庆幸的是,BIOS可以提供一些磁盘例程来抽象普通磁盘设备的所有这些差异。
在将寄存器al设置为0x02后,通过提升中断0x13来访问我们感兴趣的特定BIOS例程。 此BIOS例程要求我们设置一些其他寄存器,其中包含要使用的磁盘设备的详细信息,我们希望从磁盘读取的块以及将块存储在内存中的位置。 使用此例程最困难的部分是我们必须使用CHS寻址方案指定要读取的第一个块; 否则,只是填写预期寄存器的情况,详见下一个代码片段。

mov ah, 0x02 ; BIOS  read  sector  function
mov dl, 0     ; Read  drive 0 (i.e.  first  floppy  drive)
mov ch, 3     ; Select  cylinder 3
mov dh, 1     ; Select  the  track  on 2nd side of  floppy
; disk , since  this  count  has a base of 0
mov cl, 4     ; Select  the 4th  sector  on the  track  - not
; the 5th , since  this  has a base of 1.
mov al, 5     ; Read 5 sectors  from  the  start  point
; Lastly , set  the  address  that we’d like  BIOS to read  the
; sectors to, which  BIOS  expects  to find in ES:BX
; (i.e.  segment  ES with  offset  BX).
mov bx, 0xa000   ; Indirectly  set ES to 0xa000
mov es, bx
mov bx, 0x1234   ; Set BX to 0x1234
; In our case , data  will be read to 0xa000:0x1234 , which  the
; CPU  will  translate  to  physical  address 0xa1234
int 0x13      ; Now  issue  the  BIOS  interrupt  to do the  actual  read.

请注意,由于某种原因(例如我们索引超出磁盘限制的扇区,尝试读取有故障的扇区,软盘未插入驱动器等),BIOS可能无法读取 我们的磁盘,所以知道如何检测这个是很重要的; 否则,我们可能认为我们已经读取了一些数据,但实际上目标地址只包含它在发出read命令之前所做的相同的随机字节。 幸运的是,BIOS更新了一些寄存器,让我们知道发生了什么:特殊标志寄存器的进位标志(CF)设置为发出一般故障信号,而al设置为实际读取的扇区数,而不是 要求的号码。 在发出BIOS磁盘读取中断后,我们可以执行如下简单检查:

...
...
int 0x13      ; Issue  the  BIOS  interrupt  to do the  actual  read.
jc  disk_error ; jc is  another  jumping  instruction , that  jumps
; only if the  carry  flag  was  set.
; This  jumps if what  BIOS  reported  as the  number  of  sectors
; actually  read in AL is not  equal  to the  number  we  expected.
cmp al, 
jne  disk_error
disk_error :
mov bx, DISK_ERROR_MSG
call  print_string
jmp $
; Global  variables
DISK_ERROR_MSG:   db "Disk  read  error!", 0

3.6.4 全部放在一起

如前所述,能够从磁盘读取更多数据对于引导我们的操作系统至关重要,因此在这里我们将把本节中的所有想法都放到一个有用的例程中,该例程将简单地读取后面的前n个扇区。 从指定的磁盘设备引导扇区。

; load DH  sectors  to ES:BX from  drive  DL
disk_load:
push dx           ; Store  DX on  stack  so  later  we can  recall
; how  many  sectors  were  request  to be read ,
; even if it is  altered  in the  meantime
mov ah, 0x02     ; BIOS  read  sector  function
mov al, dh       ; Read DH  sectors
mov ch, 0x00     ; Select  cylinder 0
mov dh, 0x00     ; Select  head 0
mov cl, 0x02     ; Start  reading  from  second  sector (i.e.
; after  the  boot  sector)
int 0x13          ; BIOS  interrupt
jc  disk_error    ; Jump if error (i.e.  carry  flag  set)
pop dx            ; Restore  DX from  the  stack
cmp dh, al       ; if AL (sectors  read) != DH (sectors  expected)
jne  disk_error   ;    display  error  message
ret
disk_error :
mov bx, DISK_ERROR_MSG
call  print_string
jmp $
; Variables
DISK_ERROR_MSG   db "Disk  read  error!", 0

为了测试这个例程,我们可以编写一个引导扇区程序,如下所示:

; Read  some  sectors  from  the  boot  disk  using  our  disk_read  function
[org 0x7c00]
mov [BOOT_DRIVE], dl ; BIOS  stores  our  boot  drive  in DL, so it’s
; best to  remember  this  for  later.
mov bp, 0x8000         ; Here we set our  stack  safely  out of the
mov sp, bp             ; way , at 0x8000
mov bx, 0x9000         ; Load 5 sectors  to 0x0000(ES):0 x9000(BX)
mov dh, 5               ; from  the  boot  disk.
mov dl, [BOOT_DRIVE]
call  disk_load
mov dx, [0 x9000]      ; Print  out  the  first  loaded  word , which
call  print_hex         ; we  expect  to be 0xdada , stored
; at  address 0x9000
mov dx, [0 x9000 + 512] ; Also , print  the  first  word  from  the
call  print_hex           ; 2nd  loaded  sector: should  be 0xface
jmp $
%include "../print/print_string.asm" ; Re -use  our  print_string  function
%include "../hex/print_hex.asm"       ; Re-use  our  print_hex  function
%include "disk_load.asm"
; Include  our new  disk_load  function
; Global  variables
BOOT_DRIVE: db 0
; Bootsector  padding
times  510-($-$$) db 0
dw 0xaa55
; We know  that  BIOS  will  load  only  the  first 512-byte  sector  from  the disk ,
; so if we  purposely  add a few  more  sectors  to our  code by  repeating  some
; familiar  numbers , we can  prove  to  ourselfs  that we  actually  loaded  those
; additional  two  sectors  from  the  disk we  booted  from.
times  256 dw 0xdada
times  256 dw 0xface

实践部分: 从零开始的操作系统 (第三章:引导扇区编程(16位实模式)) 实践
下一篇: 从零开始的操作系统 (第四章:进入32位保护模式)

你可能感兴趣的:(从零开始的操作系统,引导扇区编程,从零开始的操作系统)