操作系统实验二_加载用户程序
众所周知,引导扇区程序会将操作系统加载到内存中,并把计算机的控制权交给操作系统。问题来了,引导扇区程序是怎么加载用户程序的?
这是一个复杂的问题,为了回答这个问题,首先我们需要了解用户程序的内容有什么样的格式,其次我们需要知道处理器与硬盘交互的方式,最后还要知道引导扇区程序怎么把控制权交给用户程序。
用户程序header段
在引导扇区程序中,处理器从0x7c00处开始执行代码。然而,对于引导扇区程序会把用户程序加载到哪一个位置,用户程序是不知道的。那就有问题了,如果我在用户程序中要访问数据段中的一个数据,却连这个数据在内存中的位置都不知道,还怎么访问呢?
分段的方法能很好的解决这个问题。用户程序被分为几段,每一段有一个起始地址,如果我们要访问一个数据,只需给出它在段中的偏移地址即可。用户程序会告诉引导扇区程序自己有几个段,每个段相对于程序开始处的偏移是多少,引导扇区程序会给用户程序在内存中分配一个位置,比如说,0xd000,然后假设用户程序的数据段相对于程序开始处偏移为0x100,则数据段会被加载到内存中的0xd100处。然后,引导扇区程序把ds置为0xd10,这样,用户程序就可以用段地址和偏移地址访问内存了。
为了告诉引导扇区程序一些必要的信息,用户程序会有一个header段,里面包含了程序的长度、第一条指令的位置、其他段的个数和其他段的相对偏移量。如图所示:
代码如下:
1 | SECTION header vstart=0 align=16 ;SECTION表明这是一个段的开头,vstart=0表明这个段里的所有标号 |
有了这个header段,引导扇区程序就能得到加载用户程序所必须的信息。由于引导扇区程序要将用户程序加载进内存,我们也成引导扇区程序为加载器。
加载器
我们来看一下加载器会做些什么。
首先,我们默认加载器知道用户程序在磁盘中的哪一个扇区,也明确了用户程序会被加载到哪一个位置。在加载器的开头,会有一个:
1 | first_sector equ 100 |
这是一个伪指令,相当于%define first_sector 100。
然后,加载器像其他程序一样,会有一个段声明:
1 | SECTION mbr align=16 vstart=0x7c00 |
注意这里vstart=0x7c00。也就是说,后面的标号的值都要加上0x7c00。接下来是正常的设置堆栈段和栈指针的位置:
1 | mov ax,0 |
然后要处理的是用户程序在内存中的起始位置:
1 | mov ax,[cs:start_loc] |
一开始时cs寄存器是0,start_loc标号在程序的尾部,内容是:
1 | start_loc: dd 0x10000 |
这个值就是用户程序会被加载到的位置。
由于0x10000是一个20位的数,于是只能用dx:ax两个寄存器来存储。我们将dx:ax除以16,商保存在ax中,将它赋值给es和ds。这样,ds和es就是用户程序的起始段地址。接下来我们读入第一个扇区:
1 | xor bx,bx |
call指令意思是过程调用,它会先将ip的值保存至栈中,转而取执行read_disk位置的指令。事实上,它就相当于高级语言中的函数。向这个”函数”传递参数的方式是把要用到值放在其他寄存器中。
既然我们要读磁盘,我们得知道把要读哪一个扇区和读出来的内容放在哪里告诉磁盘。这里我们把扇区的位置放置在di:si中,读出来的内容放在[ds:bx]里 。做完这些工作,就可以调用read_disk了,它的内容是:
1 | read_disk: |
看起来很复杂,但只要一条条分析,还是能分析清楚的。首先是把ax,bx,cx,dx 4个寄存器push到栈中。因为我们要修改它们的值,得先把它们保护起来,等这个过程要返回了,就把它们pop回去。
从第7行到第30行,是在向I/O端口读写信息,这些端口是独立编址的。从I/O端口读入信息用in指令,用法是:
1 | in al,dx |
或者
1 | in ax,dx |
dx是要访问的端口号,al、ax是用来保存读入的值。注意不能用其他的寄存器。
相应的,向I/O端口写入信息,要用out指令,用法是:
1 | out dx,al |
和
1 | out dx,ax |
dx是访问的端口号,al、ax是要写入的值。
in、out指令的dx参数也可以用立即数代替。
主硬盘端口分配的端口号是0x1f0-0x1f7。
其中,0x1f0 是硬盘接口的数据端口,而且还是一个 16 位端口。一旦硬盘控制器空闲,且准备就绪,就可以连续从这个端口写入或者读取数据。
0x1f1 端口是错误寄存器,包含硬盘驱动器最后一次执行命令后的状态(错误原因)。
0x1f2端口用于设置要读取的扇区数量,0x1f3-0x1f6端口用于设置起始扇区号。扇区号有28个字节,0-7字节要放在0x1f3里,8-15字节要放在0x1f4里,16-23字节要放在0x1f5里,24-27字节要放在0x1f6里。0x1f6的高4位置为1110。
端口 0x1f7 既是命令端口,又是状态端口。在通过这个端口发送 读写命令之后,硬盘就忙乎开了。在它内部操作期间,它将 0x1f7 端口的第 7 位置 “1”,表明自己很忙。一旦硬盘系统准备就绪,它再将此位清零,说明自己已经忙完了,同时将第 3 位置“1”,意思是准备好了,请求主机发送或者接收数据。
了解了这些后,第7-30行的代码也就很容易理解了。
第32-36行是在不断判断硬盘是否准备好,如果没有则继续循环。
第38行将cx置为256,因为一个扇区是512个字节,一次读出2个字节。第39行将dx置为0x1f0,即从0x1f0读入数据。
第41-47行不断将数据读入到[ds:bx]处,然后将bx+=2。
最后恢复ax,bx,cx,dx寄存器,注意顺序要反过来。
第一次读完磁盘后,用户程序的第一个扇区就已经在内存里了。接下来要进行一些很重要的工作:
1 | mov dx,[bx+2] |
用户程序的前4个字节是用户程序的大小,先把它读入到ds:ax中,然后除以512,得到用户程序在磁盘中的扇区数。注意,如果用户程序的大小恰好为512的倍数,则除法指令结束后,ax的值就是用户程序占的扇区数,此时dx为0;否则,这个数字要加1。
接下来要读入其他扇区:
1 | tem1: |
首先将ax减1(刚才已经读了一个扇区了)。如果ax==0,则跳过下面的工作;否则进入tem2这个循环。
循环里每次将si加1(ds:si是要读的扇区号),将ds+=0x20(写入内存的位置每次往后推512个字节),将bx置为0,然后调用read_disk过程读入扇区。
把扇区读完了,接下来要确定用户程序的第一条指令的位置:
1 | tem3: |
回忆一下,第一条指令的位置是通过 段地址(第一条指令所在段的首地址)+偏移地址(第一条指令相对于段地址的差值) 给出的。在[ds:bx+0x06]到[ds:bx+0x09]之间的是段地址。我们把它的值与用户程序在内存中的首地址相加,就得到了这个段的真实地址,再把它右移4位,送到ax寄存器中。上述过程会被执行很多次(有好几个段),所以我们把它写成一个segment_reloc过程:
1 | push dx |
shr指令是右移4位,shl指令是左移12位,or指令把ax和dx合起来(ax的高4位为0,dx的低12位为0)。
得到真实段地址右移4位的值后,把它送回[ds:0x06]处。
1 | mov [0x06],ax |
接下来要读段表。首先从[ds:0x0a]处取出一个字放到cx中,表示段表的大小,然后将bx置为0x0c,这是段表的首地址相对用户程序起始处的偏移地址:
1 | mov cx,[0x0a] |
接下来要一个个读:
1 | segment_table_reloc: |
这部分内容跟开始时读段表差不多,不再赘述。
折腾完这些,加载器的工作差不多完成了,可以把控制权交给用户程序了:
1 | jmp far [0x04] |
jmp far是 16 位间接绝对远转移。上面一条指令会从 ds:0x04 处取出6个字节,把前两个字节作为一个字,它是指令的偏移地址;后4个字节被当成一个双字,是指令所在段的地址(这个地址已经被我们重定位过了)。这样就能跳到用户程序的第一条指令了。注意,此时ds,es的值都是用户程序在内存中的首地址右移4位的值。
用户程序其他内容
看看用户程序会做什么:
1 | SECTION code_1 align=16 vstart=0 |
第1行声明了一个段,第3行就是我们期待已经的第一条指令。前3条指令用于设置段指针和段寄存器的值。
第7,8行两行设置数据段寄存器ds的值,第9,10行两行设置附加段寄存器es的值。
从第12行开始是一个很正常的显示字符串的工作,不再赘述。
第32行声明了数据段,里面只有一个字符串。
第35行声明了堆栈段,里面用resb指令保留了256个字节。注意,stack_end标号是在resb指令的后面,因为栈指针的值是不断减小的。
最后,第38行声明了一个trail段,它没什么用,值得注意的是它没有vstart=0这个定义,所以标号program_end的值是从0开始算的,所以它的值就是用户程序的大小。
运行结果
用bochs运行,结果如下:
效果拨群。