0%

操作系统实验二_加载用户程序

操作系统实验二_加载用户程序

众所周知,引导扇区程序会将操作系统加载到内存中,并把计算机的控制权交给操作系统。问题来了,引导扇区程序是怎么加载用户程序的?

这是一个复杂的问题,为了回答这个问题,首先我们需要了解用户程序的内容有什么样的格式,其次我们需要知道处理器与硬盘交互的方式,最后还要知道引导扇区程序怎么把控制权交给用户程序。

用户程序header段

在引导扇区程序中,处理器从0x7c00处开始执行代码。然而,对于引导扇区程序会把用户程序加载到哪一个位置,用户程序是不知道的。那就有问题了,如果我在用户程序中要访问数据段中的一个数据,却连这个数据在内存中的位置都不知道,还怎么访问呢?

分段的方法能很好的解决这个问题。用户程序被分为几段,每一段有一个起始地址,如果我们要访问一个数据,只需给出它在段中的偏移地址即可。用户程序会告诉引导扇区程序自己有几个段,每个段相对于程序开始处的偏移是多少,引导扇区程序会给用户程序在内存中分配一个位置,比如说,0xd000,然后假设用户程序的数据段相对于程序开始处偏移为0x100,则数据段会被加载到内存中的0xd100处。然后,引导扇区程序把ds置为0xd10,这样,用户程序就可以用段地址和偏移地址访问内存了。

为了告诉引导扇区程序一些必要的信息,用户程序会有一个header段,里面包含了程序的长度、第一条指令的位置、其他段的个数和其他段的相对偏移量。如图所示:

image-20200427172648671.png

代码如下:

1
2
3
4
5
6
7
8
9
10
11
SECTION header vstart=0 align=16 ;SECTION表明这是一个段的开头,vstart=0表明这个段里的所有标号
;都用的是偏移地址,align=16表明这个段的起始位置要为16的倍数
length dd program_end;program_end标号在程序的尾部,可以用来得到程序的长度
codeentry dw start;start标号指向程序的第一条指令的位置
dd section.code_1.start;section.code_1.start指的是code_1这个段的开始位置,第一条指令在这个段里面
;根据上面两行,可以知道用户程序的第一条指令的位置
segment_table dw (header_end-code_1_segment)/4;段表的长度,即这个程序还有几个段
code_1_segment dd section.code_1.start;code_1段的开始位置
data_1_segment dd section.data_1.start;data_1段的开始位置
stack_segment dd section.stack.start;stack段的开始位置
header_end:

有了这个header段,引导扇区程序就能得到加载用户程序所必须的信息。由于引导扇区程序要将用户程序加载进内存,我们也成引导扇区程序为加载器

加载器

我们来看一下加载器会做些什么。

首先,我们默认加载器知道用户程序在磁盘中的哪一个扇区,也明确了用户程序会被加载到哪一个位置。在加载器的开头,会有一个:

1
first_sector equ 100

这是一个伪指令,相当于%define first_sector 100。

然后,加载器像其他程序一样,会有一个段声明:

1
SECTION mbr align=16 vstart=0x7c00

注意这里vstart=0x7c00。也就是说,后面的标号的值都要加上0x7c00。接下来是正常的设置堆栈段和栈指针的位置:

1
2
3
mov ax,0
mov ss,ax
mov sp,ax

然后要处理的是用户程序在内存中的起始位置:

1
2
3
4
5
6
mov ax,[cs:start_loc]
mov dx,[cs:start_loc+2]
mov bx,16
div bx
mov es,ax
mov ds,ax

一开始时cs寄存器是0,start_loc标号在程序的尾部,内容是:

1
start_loc: dd 0x10000

这个值就是用户程序会被加载到的位置。

由于0x10000是一个20位的数,于是只能用dx:ax两个寄存器来存储。我们将dx:ax除以16,商保存在ax中,将它赋值给es和ds。这样,ds和es就是用户程序的起始段地址。接下来我们读入第一个扇区:

1
2
3
4
xor bx,bx
xor di,di
mov si,first_sector
call read_disk

call指令意思是过程调用,它会先将ip的值保存至栈中,转而取执行read_disk位置的指令。事实上,它就相当于高级语言中的函数。向这个”函数”传递参数的方式是把要用到值放在其他寄存器中。

既然我们要读磁盘,我们得知道把要读哪一个扇区和读出来的内容放在哪里告诉磁盘。这里我们把扇区的位置放置在di:si中,读出来的内容放在[ds:bx]里 。做完这些工作,就可以调用read_disk了,它的内容是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
read_disk:
push ax
push bx
push cx
push dx

mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数

inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0

inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8

inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16

inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;LBA地址27~24
out dx,al

inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al

disk_ok?:
in al,dx
and al,0x88
cmp al,0x08
jne disk_ok?

mov cx,256
mov dx,0x1f0

read_content:
in ax,dx
mov [bx],ax
add bx,2
dec cx
cmp cx,0
jne read_content

pop dx
pop cx
pop bx
pop ax

ret

看起来很复杂,但只要一条条分析,还是能分析清楚的。首先是把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
2
3
4
5
6
7
mov dx,[bx+2]
mov ax,[bx]
mov bx,512
div bx
cmp dx,0
je tem1
inc ax

用户程序的前4个字节是用户程序的大小,先把它读入到ds:ax中,然后除以512,得到用户程序在磁盘中的扇区数。注意,如果用户程序的大小恰好为512的倍数,则除法指令结束后,ax的值就是用户程序占的扇区数,此时dx为0;否则,这个数字要加1。

接下来要读入其他扇区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tem1:
dec ax
mov cx,ax
cmp cx,0
je tem3

push ds
tem2:
xor bx,bx
inc si
mov ax,ds
add ax,0x20
mov ds,ax
call read_disk
dec cx
cmp cx,0
jne tem2

首先将ax减1(刚才已经读了一个扇区了)。如果ax==0,则跳过下面的工作;否则进入tem2这个循环。

循环里每次将si加1(ds:si是要读的扇区号),将ds+=0x20(写入内存的位置每次往后推512个字节),将bx置为0,然后调用read_disk过程读入扇区。

把扇区读完了,接下来要确定用户程序的第一条指令的位置:

1
2
3
4
tem3:
mov dx,[0x08]
mov ax,[0x06]
call segment_reloc

回忆一下,第一条指令的位置是通过 段地址(第一条指令所在段的首地址)+偏移地址(第一条指令相对于段地址的差值) 给出的。在[ds:bx+0x06]到[ds:bx+0x09]之间的是段地址。我们把它的值与用户程序在内存中的首地址相加,就得到了这个段的真实地址,再把它右移4位,送到ax寄存器中。上述过程会被执行很多次(有好几个段),所以我们把它写成一个segment_reloc过程:

1
2
3
4
5
6
7
8
9
10
push dx

add ax,[cs:start_loc]
add dx,[cs:start_loc+0x02]
shr ax,4
shl dx,12
or ax,dx

pop dx
ret

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
2
mov cx,[0x0a]
mov bx,0x0c

接下来要一个个读:

1
2
3
4
5
6
7
8
9
segment_table_reloc:
mov dx,[bx+0x02]
mov ax,[bx]
call segment_reloc
mov [bx],ax
add bx,4
dec cx
cmp cx,0
jne segment_table_reloc

这部分内容跟开始时读段表差不多,不再赘述。

折腾完这些,加载器的工作差不多完成了,可以把控制权交给用户程序了:

1
jmp far [0x04]

jmp far是 16 位间接绝对远转移。上面一条指令会从 ds:0x04 处取出6个字节,把前两个字节作为一个字,它是指令的偏移地址;后4个字节被当成一个双字,是指令所在段的地址(这个地址已经被我们重定位过了)。这样就能跳到用户程序的第一条指令了。注意,此时ds,es的值都是用户程序在内存中的首地址右移4位的值。

用户程序其他内容

看看用户程序会做什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
SECTION code_1 align=16 vstart=0
start:
mov ax,[stack_segment]
mov ss,ax
mov sp,stack_end

mov ax,[data_1_segment]
mov ds,ax

mov ax,0xb800
mov es,ax

mov bx,msg
mov cx,12

xor ax,ax
mov di,0

do:
mov al,[bx]
inc bx
mov [es:di],al
inc di
mov byte [es:di],0x07
inc di
dec cx
cmp cx,0
jne do

jmp near $

SECTION data_1 align=16 vstart=0
msg db 'hello world!'

SECTION stack align=16 vstart=0
resb 256
stack_end:
SECTION trail align=16
program_end:

第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运行,结果如下:

image-20200427174119405.png

效果拨群。