说明:本文据 bootsect.s 分析 这篇博文修改而来,增加了一些内容。读者看这篇就可以。
为了节省篇幅,完整的代码就不贴了。感兴趣的朋友可以去下载,下载地址是:
http://oldlinux.org/Linux.old/
本文,我打算详解bootsect.s。如有纰缪,还请各位看官斧正。为了不使代码片太长,我删去了一些原来的注释。
; 在 as86 汇编语言程序中,凡是以感叹号’!’或分号’;’开始的语句均为注释 SYSSIZE = 0x3000 ;system模块的长度 ; globl 表明标识符是全局的,使得符号对连接器可见,变为对整个工程可用的全局变量 .globl begtext, begdata, begbss, endtext, enddata, endbss ; .text, .data, .bss 分别定义当前代码段、数据段和未初始化数据段。 ; 在链接 多个目标模块时,链接程序 ld86 会根据它们的类别把各个目标模块中的相应段 ; 分别组合在一起。这里把三个段都定义在同一重叠地址范围中,因此本程序实际上不分段。 .text begtext: .data begdata: .bss begbss: .text SETUPLEN = 4 ! setup模块的长度,4个扇区 BOOTSEG = 0x07c0 ! original address of boot-sector INITSEG = 0x9000 ! bootsect把自身搬运到0x90000 SETUPSEG = 0x9020 ! setup模块被加载到 0x90200 SYSSEG = 0x1000 ! system模块被加载到0x10000 ENDSEG = SYSSEG + SYSSIZE ! where to stop loading, 0x1000 + 0x3000 = 0x4000, 停止加载的段地址 ; 这个可以根据情况修改,比如我的实验环境,就修改为 0x301 ROOT_DEV = 0x306 !第2个硬盘的第1个分区
ROOT_DEV = 0x306
,这里的0x306
表示第2个硬盘的第1个分区,当年Linus是在第2个硬盘的第1个分区上安装了Linux-0.11操作系统。
设备号 = 主设备号 * 256 + 次设备号
或者说:
dev_no = (major << 8) + minor
这里的主设备号是事先定义好的(1-内存,2-磁盘,3-硬盘,4-ttyx,5-tty,6-并行口,7-非命名管道)。譬如对于硬盘,主设备号为3,因此3*256+0=0x300即为系统中第一个硬盘的设备号。更多的例子如下表:
设备号 | 设备文件 | 对应的设备 |
---|---|---|
0x300 | /dev/hd0 | 系统中第一个硬盘 |
0x301 | /dev/hd1 | 系统中第一个硬盘的第一分区 |
0x302 | /dev/hd2 | 系统中第一个硬盘的第二分区 |
0x303 | /dev/hd3 | 系统中第一个硬盘的第三分区 |
0x304 | /dev/hd4 | 系统中第一个硬盘的第四分区 |
0x305 | /dev/hd5 | 系统中第二个硬盘 |
0x306 | /dev/hd6 | 系统中第二个硬盘的第一分区 |
0x307 | /dev/hd7 | 系统中第二个硬盘的第二分区 |
0x308 | /dev/hd8 | 系统中第二个硬盘的第三分区 |
0x309 | /dev/hd9 | 系统中第二个硬盘的第四分区 |
entry _start ; 告知链接程序,程序从 start 标号开始执行 _start: mov ax,#BOOTSEG mov ds,ax ! ds = 0x07c0 mov ax,#INITSEG mov es,ax ! es = 0x9000 mov cx,#256 ! 搬运 256 次 sub si,si ! si = 0 sub di,di ! di = 0 ! ds:si = 0x07c0:0x0 ! es:di = 0x9000:0x0 rep movw ! 每次搬运 2 字节 jmpi go,INITSEG ! 跳转到 0x9000:go
以上代码表示把 ds:si 处(物理地址 0x7c00)的内容搬运到 es:di(物理地址 0x90000),一共搬运 512 字节,即主引导扇区把自己移动到了 0x90000 处。
jmpi go,INITSEG 是段间跳转,INITSEG 是段地址,go 是偏移地址。这句话执行完,CPU 一下子跑到了0x9000:go 处。
对于movw指令,可以参考我的博文。
http://blog.csdn.net/longintchar/article/details/50949923
我的疑问是,Linus 为什么没有清除 DF 标志呢?是不是设置 DF=0 会更严谨呢?
CPU 上电后,EFLAGS 的初始值是 00000002H,所以 DF 是 0
跳转后继续执行下面的指令
go: mov ax,cs mov ds,ax mov es,ax ; cs = ds = es = ss = 0x9000 ! put stack at 0x9ff00. ! 因为从 0x90200 地址开始处还要放置 setup 程序,setup 大约 ! 为 4 个扇区,因此 SP 要指向大于(0x200 *5 + 堆栈大小)处。 ! 0x200 * 5 = 0xa00 , 0xFF00 - 0xA00 = 0xF500, 栈的大小是 0xF500,足够大 mov ss,ax mov sp,#0xFF00 !es:sp = 0x9000:0xff00 ,栈的设置
load_setup: mov dx,#0x0000 ! 驱动器号(DL)0,磁头号(DH)0 mov cx,#0x0002 ! 起始扇区号2, 磁道号0 mov bx,#0x0200 ! 偏移地址0x200 mov ax,#0x0200+SETUPLEN ! 功能号AH=0x02,AL=要读的扇区数目=SETUPLEN=4 int 0x13 ! read it jnc ok_load_setup ! CF = 0 则跳转 mov dx,#0x0000 !需要复位的驱动器号=DL=0 mov ax,#0x0000 !功能号AH=0 int 0x13 ! 复位磁盘 j load_setup
以上代码利用 INT 13H, AH=02H
把 setup 模块从磁盘(2~5扇区)加载到 0x90200 后面。
注意:柱面(或者磁道)号和磁头号都从 0 开始,扇区号从 1 开始。
此功能从磁盘上把一个或更多的扇区内容读进内存。这是一个低级功能,在一个操作中读取的全部扇区必须在同一条磁道上。
参数 | 说明 |
---|---|
入口参数 | |
AH | =02H ,指明调用读扇区功能。 |
AL | 要读的扇区数目,不允许使用读磁道末端以外的数值,也不允许使该寄存器为0。 |
DL | 需要进行读操作的驱动器号,0表示软盘,80H表示硬盘。 |
DH | 所读磁盘的磁头号。 |
CH | 磁道号的低8位数(磁道号共10位)。 |
CL | 低5位放入所读起始扇区号,位7-6表示磁道号的高2位。 |
ES:BX | 读出数据的缓冲区地址。 |
返回参数 | |
CF | =0,操作成功;=1,操作失败。 |
AH | 错误返回码。 |
AL | 实际读到的扇区数。 |
此功能用于复位磁盘(软盘和硬盘)。当磁盘I/O功能调用出现错误时,需要调用此功能。
参数 | 说明 |
---|---|
入口参数 | |
AH | =00H,指明调用复位磁盘功能。 |
DL | 需要复位的驱动器号。软盘:00H-7FH;硬盘:80H-FFH |
返回参数 | |
CF | =0,操作成功;=1,则操作失败 |
AH | 错误返回码。 |
ok_load_setup: ! Get disk drive parameters, specifically nr of sectors/track mov dl,#0x00 !驱动器号为0,说明是软盘 mov ax,#0x0800 ! AH=8 is get drive parameters int 0x13 mov ch,#0x00 !这里用不上软盘的最大磁道号,可以使CH=0 ; seg 用于将段超越前缀中指明的段寄存器去取代指令中默认的段寄存器,它只影响其下一条语句 ; 实际上,由于本程序代码段和数据段重叠,因此本程序中此处可以不使用该指令 seg cs mov sectors,cx ; 以上两句也可以写为 mov cs:[sectors], cx ; 保存每磁道最大扇区数。对于软盘,最大磁道号不会超过256,所以CH足以表示,CL[7:6]为0 mov ax,#INITSEG mov es,ax !因为上面ES的值被修改,所以令 ES=0x9000
INT 13H AH=08H:读取驱动器参数
参数 | 说明 |
---|---|
入口参数 | |
AH | =08H,读取驱动器参数 |
DL | 驱动器号(如果是硬盘则[7]=1) |
返回参数 | |
CF | 0-操作成功;1-操作失败 |
AH | 错误返回码 |
BL | 驱动器类型 |
CH | 最大磁道号的[7:0] |
CL[7:6] | 最大磁道号的[9:8] |
CL[5:0] | 每磁道最大扇区数 |
DH | 最大磁头数 |
DL | 驱动器数量 |
ES:DI | 指向软驱磁盘参数表 |
mov ah,#0x03 !读光标的位置 xor bh,bh !bh=页号 int 0x10
我们主要是用行号(DH中)和列号(DL中)。
参数 | 说明 |
---|---|
入口参数 | |
AH | =03H,读光标的位置 |
BH | 页号 |
返回参数 | |
CH | 行扫描开始 |
CL | 行扫描结束 |
DH | 行号 |
DL | 列号 |
这里的“行扫描开始”和“行扫描结束”如何理解?
其实这两个参数描述了光标的形状。通常一个字符单元有 8 个扫描行(0-7)。所以,CX = 0607H 是一个正常的光标,CX = 0007H 是一个完整块光标。如果设置 CH 的第 5 位,这通常意味着“隐藏光标”,所以 CX = 2607H 是一种无形光标。
—— 摘自《维基百科“INT 10H”词条》
页号如何理解?
在屏幕上显示的字符及其属性被保存在显示缓冲区中,可以认为页号是显示缓冲区的编号,可通过五号功能设置显示页(即选择活动的显示页),默认第 0 页是活动的显示页。
参数 | 说明 |
---|---|
入口参数 | |
AH | =13H,写字符串 |
BH | 页码 |
BL | 属性(若 AL=00H 或 01H) |
CX | 要显示的字符串的长度 |
DH、DL | 坐标(行、列) |
ES:BP | 指向要显示的字符串 |
AL | 显示输出方式 |
返回参数 | |
无 |
AL 表示显示输出方式,取值如下:
取值 | 说明 | 字符串格式 |
---|---|---|
0 | 字符串中只含显示字符,显示属性在BL中;显示后,光标位置不变 | char1,char2,…,charN |
1 | 字符串中只含显示字符,显示属性在BL中;显示后,光标位置跟随字符串改变 | char1,char2,…,charN |
2 | 字符串中含有显示字符和显示属性;显示后,光标位置不变 | char1,attri1,char2,attri2,…,charN,attriN |
3 | 字符串中含有显示字符和显示属性;显示后,光标位置跟随字符串改变 | char1,attri1,char2,attri2,…,charN,attriN |
mov cx,#24 ! 24个字符 mov bx,#0x0007 ! page 0, attribute 7 , 黑底白字 mov bp,#msg1 mov ax,#0x1301 ! write string, move cursor int 0x10
msg1: .byte 13,10 .ascii "Loading system ..." .byte 13,10,13,10
13是回车,10是换行。它们的区别如下表。
! we want to load the system (at 0x10000) mov ax,#SYSSEG ! SYSSEG=0x1000 mov es,ax ! segment of 0x010000 call read_it call kill_motor
3~5行,把 system 模块加载到 0x10000。
第 6 行,关闭驱动器马达。
read_it
这个过程的功能是把还未读取的扇区加载到 es:0x0000 处。注意:es 作为参数,必须是 0x1000 的整数倍,否则会陷入死循环。每读 64KB,都会使 es 的值增加 0x1000(0x1000 左移 4 位,是 0x10000,刚好是 64KB),当 es=0x4000 的时候,停止读取,实际上读取了 192KB,也就是说,system 模块最大是 192 KB.
这段代码有几个难点:
调用了过程 read_track(后文会分析),read_track 调用的是 int 13H(AH = 02H),这是一个低级功能,在一个操作中读取的全部扇区必须在同一条磁道上。 所以就要小心,时刻记住磁道上还有多少扇区没有读
int 13H(AH = 02H),每次调用的时候,读出数据的缓冲区起始地址是 ES:BX,这就需要注意 ES 和 BX 的值,要适时调整。
太过底层,需要考虑磁盘的工作原理,读扇区的顺序是什么,什么时候换磁头,什么时候换磁道。
对于软盘,读取顺序是这样的:
假设软盘有 2 个盘面,每个盘面有 M 个磁道,每个磁道有 N 个扇区,
0面0道1扇区
0面0道2扇区
…
0面0道N扇区
1面0道1扇区
1面0道2扇区
…
1面0道N扇区
0面1道1扇区
0面1道2扇区
…
0面1道N扇区
1面1道1扇区
1面1道2扇区
…
1面1道N扇区
0面2道1扇区
…
可以看到,扇区增加最快,扇区增到头了,盘面(或磁头)增,盘面增到头了,磁道增。
这 3 个难点导致这段代码比较复杂,可读性不好。
sread: .word 1+SETUPLEN ! 当前磁道已经读取的扇区数(引导扇区 + setup模块 = 5) head: .word 0 ! current head,当前磁头号 track: .word 0 ! current track,当前磁道号 read_it: mov ax,es test ax,#0x0fff !使ax与0xfff按位与,测试es是否为0x1000的整数倍 die: jne die !结果不为0(说明es不是0x1000的整数倍)则陷入死循环 xor bx,bx ! bx(作为段内偏移地址)清零 rp_read: mov ax,es cmp ax,#ENDSEG ! 实际上求(ax-ENDSEG),看是否到了末尾 jb ok1_read ! jump when below,当CF=1(ax<ENDSEG, 有借位)时跳转 ret ! 当ax>=ENDSEG时返回(我认为不会出现大于的情况) ok1_read: seg cs mov ax,sectors ! 这两句相当于 mov ax, cs:[sectors]; 获得每磁道扇区数 sub ax,sread ! ax = ax - sread, 得出本磁道未读扇区数 mov cx,ax shl cx,#9 ! cx乘以512,求出字节数 add cx,bx ! 以上3行相当于 cx = ax * 512 + bx ! bx是段内偏移,加上欲读的字节数,看看是否越界(和0x10000比较) jnc ok2_read ! 若cx < 0x10000(CF=0,没有进位)则跳转到ok2_read,不会越界 je ok2_read ! 若cx = 0(ZF=1),说明刚好不越界,则跳转到ok2_read xor ax,ax ! 执行这里说明越界,令 ax = 0x0000 sub ax,bx ! 计算bx离边界有多远,用0减去bx,结果在ax中 shr ax,#9 ! 除以512,得到要读的扇区数,AL 作为参数,传给read_track ok2_read: call read_track !调用read_track过程,用AL传参,读取AL个扇区到ES:BX !read_track 中有出错检测,能返回,说明读取成功(AH=0) mov cx,ax !AL中是返回值,即实际读到的扇区数目,cx是该次操作已经读取的扇区数 add ax,sread !ax是当前磁道已经读取的扇区数 seg cs cmp ax,sectors jne ok3_read !如果当前磁道还有扇区未读,跳转到ok3_read mov ax,#1 !说明当前磁道的扇区都已读完 sub ax,head !ax = 1 - 磁头号 jne ok4_read !不相等说明磁头号为0,跳转到 ok4_read,ax=1,读完0磁头,再读1磁头 inc track !说明磁头号为1,ax=0,设置磁头号为0,磁道号增加1 ok4_read: mov head,ax ! 保存当前磁头号 xor ax,ax !ax=0, 当前磁道已读扇区数置0 ok3_read: mov sread,ax !更新当前磁道已经读取的扇区数 shl cx,#9 !cx是该次操作已经读取的扇区数,乘以512字节 add bx,cx !更新偏移地址 jnc rp_read !没有进位,则跳转到rp_read,继续读 mov ax,es !有进位,说明BX达到了64KB边界 add ax,#0x1000 mov es,ax !es增加0x1000 xor bx,bx !bx = 0 jmp rp_read !继续读取
以上汇编代码看起来实在是费劲。为了便于理解,写成C语言伪代码如下:
void read_it(es)//参数是es { if((es & 0xFFF) != 0) //es 必须是0x1000的倍数,否则进入死循环 while(1); //dead loop bx = 0; while(es < ENDSEG){ // 1. 看看要读多少个扇区,用ax表示 // 2. sread:本磁道已经读取的扇区数 ax = SECTORS - sread; if((ax * 512 + bx) > 0x10000){ ax = (0x10000 - bx) / 512; } read_track(ax); //调用读扇区过程,al:要读的扇区数,es:bx->缓冲区 cx = ax; //该次操作读取的扇区数 ax += sread; //ax是本磁道已读取的扇区总数 if(ax==SECTORS){ //本磁道的扇区全部读完 if(head == 1){ //0和1磁头都已经读完,更新磁头和磁道 ++track; head = 0; //从0磁头开始 } else{ head = 1; //切换到1磁头 } ax = 0; //本磁道已读扇区数置为0 } sread = ax; //更新本磁道已读扇区数 bx += cx * 512; 更新偏移地址bx if(bx == 0x10000) { //如果偏移地址到达0x10000,则更新es,并使bx=0 es += 0x1000; bx = 0; } } return; }
read_track
读取 AL
个扇区到 ES:BX
。此过程的入口参数是:
AL-要读的扇区数目
ES:BX-缓冲区地址
这个过程可读性不好,因为参数太多,需要耐心理解。
read_track: push ax push bx push cx push dx mov dx,track !当前磁道号 mov cx,sread !已经读取的扇区数 inc cx !CL是起始扇区号 mov ch,dl !CH是磁道号 mov dx,head !当前磁头号 mov dh,dl !DH是磁头号 mov dl,#0 !DL是驱动器号,0表示软盘 and dx,#0x0100 !DH是磁头号,磁头号不大于 1 mov ah,#2 !功能号2,读扇区 int 0x13 jc bad_rt !CF=1,表示出错,复位磁盘 pop dx pop cx pop bx pop ax ret bad_rt: mov ax,#0 !AH=0,磁盘复位功能 mov dx,#0 !DL是驱动器号,0表示软盘 int 0x13 pop dx pop cx pop bx pop ax jmp read_track !重新读取
kill_motor
kill_motor: push dx mov dx,#0x3f2 !软盘控制器的端口-数字输出寄存器端口,只写 mov al,#0 !驱动器A,关闭FDC,禁止DMA和中断请求,关闭马达 outb !将al的值写入端口dx pop dx ret
DOR是一个8位寄存器,他控制驱动器马达的开启、驱动器选择、启动/复位FDC以及允许/禁止DMA及中断请求。
位 | Name | Description |
---|---|---|
7 | MOT_EN3 | Driver D motor:1-start;0-stop |
6 | MOT_EN2 | Driver C motor:1-start;0-stop |
5 | MOT_EN1 | Driver B motor:1-start;0-stop |
4 | MOT_EN0 | Driver A motor:1-start;0-stop |
3 | DMA_INT | DMA and IRQs; 1 enable; 0-disable |
2 | RESET | 0= enter reset mode;1= normal operation |
1 and 0 | DRV_SEL1, DRV_SEL0 | “Select” drive number(A-D) for next access |
至于为什么要调用 kill_motor,我还无法理解,这里存疑,估计后面就懂了。
seg cs mov ax,root_dev ! ax = ROOT_DEV cmp ax,#0 jne root_defined ! 如果 ROOT_DEV 不等于 0 则跳转到 root_defined seg cs mov bx,sectors ! 取每磁道扇区数 mov ax,#0x0208 ! /dev/ps0 - 1.2Mb cmp bx,#15 ! 判断每磁道扇区数是否等于15 je root_defined ! 如果等于15,说明是1.2MB的软盘 mov ax,#0x021c ! /dev/PS0 - 1.44Mb cmp bx,#18 ! 判断每磁道扇区数是否等于18 je root_defined ! 如果等于18,说明是1.44MB的软盘 undef_root: jmp undef_root ! 死循环 root_defined: seg cs mov root_dev,ax ! 将检查过的设备号保存到 root_dev 中
软驱的主设备号是 2,次设备号 = type * 4 + nr.
其中,type 是软驱的类型(比如 2 表示 1.2MB,7 表示 1.44MB)
nr 等于 0~3 时分别对应软驱 A、B、C、D
因为是可引导的驱动器,所以肯定是 A 驱,所以 nr = 0;
前文已经说过,设备号 = (主设备号 << 8) + 次设备号
对于 1.2MB 的软驱,设备号 = 2 << 8 + 2 * 4 + 0 = 0x208;
对于1.44MB 的软驱,设备号 = 2 << 8 + 7 * 4 + 0 = 0x21C;
这就可以解释上面代码第 7 行和第 10 行了。
.org 508 ; .org 伪指令的格式是 .org new_lc, fill ; 把当前区的位置计数器设置为 new_lc ; 当位置计数器值増长时,所跳跃过的字节将被填入值 fill ; 如果省略了逗号和 fill,则填入 0 root_dev: .word ROOT_DEV !存放根文件系统所在设备号(init/main.c中会用) boot_flag: .word 0xAA55
ROOT_DEV
到底有何用,怎么用,这里先存疑,后面再探究。
jmpi 0,SETUPSEG !到此本程序就结束了。
段间跳转,跳转到0x9020:0x0000(setup.s程序开始处)去执行。
本程序干的工作有:
bootsect 把自己搬运到 0x90000,并跳转
设置栈指针
加载 setup 模块到 0x90200
获得磁盘驱动器参数(主要是每磁道的扇区数)
打印 “Loading system …”
加载 system 到 0x10000
调用 kill_motor
确认根文件系统设备号
跳转到 setup 去执行
代码分析到这里,就差不多明白了。虽然是一个引导扇区,编译后只有512字节,可是涉及的知识点还真不少。真是太佩服Linus了,一个大学生就能写出这样的代码,实属出众。