linux0.11上电时把启动盘第1扇区bootsect.s的代码拷贝到0x7c00位置处,这段代码自己把自己拷贝到0x90000 这个位置然后开始执行,利用bios预先设置好的中断函数,把第2扇区setup程序拷贝到0x90200处,一共4个扇区。把第6扇区开始的240个扇区system代码读取到内存地址 0x10000处共120KB,整个操作系统的代码已经读取到内存了,然后再确定根文件设备保存到root_dev这个位置,跳转到setup程序运行。
SYSSIZE = 3000h ;// 指编译连接后system模块的大小。
;// 这里给出了一个最大默认值。
SETUPLEN = 4 ;// setup程序的扇区数(setup-sectors)值
BOOTSEG = 07c0h ;// bootsect的原始地址(是段地址,以下同)
INITSEG = 9000h ;// 将bootsect移到这里
SETUPSEG = 9020h ;// setup程序从这里开始
SYSSEG = 1000h ;// system模块加载到10000(64kB)处.
ENDSEG = SYSSEG + SYSSIZE ;// 停止加载的段地址
; 设备号具体值的含义如下:
; 设备号=主设备号*256 + 次设备号(也即 dev_no = ( major <<8 ) + minor )
; (主设备号:1-内存,2-磁盘,3-硬盘,4-ttyx,5-tty,6-并行口,7-非命名管道)
; 0x300 - /dev/hd0 - 代表整个第 1 个硬盘;
; 0x301 - /dev/hd1 - 第 1 个盘的第 1 个分区;
; …
; 0x304 - /dev/hd4 - 第 1 个盘的第 4 个分区;
; 0x305 - /dev/hd5 - 代表整个第 2 个硬盘盘;
; 0x306 - /dev/hd6 - 第 2 个盘的第 1 个分区;
; …
; 0x309 - /dev/hd9 - 第 2 个盘的第 4 个分区;
; 从Linux内核0.95版后已经使用与现在相同的命名方法了。
! After that we check which root-device to use. If the device is
! defined (!= 0), nothing is done and the given device is used.
! Otherwise, either /dev/PS0 (2,28) or /dev/at0 (2,8), depending
! on the number of sectors that the BIOS reports currently.
; 此后,我们检查要使用哪个根文件系统设备(简称根设备)。如果已经指定了设备(!= 0)就直
; 接使用给定的设备。否则就需要根据 BIOS 报告的每磁道扇区数来确定到底使用/dev/PS0 (2,28)
; 还是 /dev/at0 (2,8)。
; 上面一行中两个设备文件的含义:
; 在 Linux 中软驱的主设备号是2(参见第 78 行的注释),次设备号 = type * 4 + nr,其中
; nr 为 0 - 3 分别对应软驱A、B、C 或D;type 是软驱的类型(2->1.2M 或 7->1.44M 等)。
; 因为 7*4 + 0 = 28,所以 /dev/PS0 (2,28)指的是1.44M A 驱动器,其设备号是 0x021c
; 同理 /dev/at0 (2,8)指的是1.2M A 驱动器,其设备号是0x0208。
seg cs
mov ax,root_dev ; 取出 root_dev 的值,判断根设备号是否被定义
or ax,ax
jne root_defined
seg cs ; 取出 sectors 的值(每磁道扇区数);sectors = 15 则说明是 1.2MB 的驱动器;
mov bx,sectors ; sectors = 18 则说明是 1.44MB 的软驱。因为是可引导的驱动器,所以是A驱。
mov ax,#0x0208 ! /dev/PS0 - 1.2Mb
cmp bx,#15
je root_defined
mov ax,#0x021c ! /dev/PS0 - 1.44Mb
cmp bx,#18
je root_defined
undef_root: ; 都不等于的情况下则进入死循环
jmp undef_root
root_defined: ; 将检查过的设备号保存到 root_dev 中。
seg cs
mov root_dev,ax
! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:
; 到此,所有程序都加载完毕,我们就跳转到被加载在 bootsect 后面的 setup 程序去。
jmpi 0,SETUPSEG
.
.
.
.
.
sectors:
.word 0
msg1:
.byte 13,10
.ascii "Loading"
.org 506
; 表示下面语句从地址506(0x1FC)开始,所以swap_dev在启动扇区的第506开
; 始的2个字节中,root_dev在启动扇区的第508开始的2个字节中。
swap_dev:
.word SWAP_DEV
root_dev:
.word ROOT_DEV
; 下面是启动盘具有有效引导扇区的标志。仅供BIOS中的程序加载引导扇区时识别使用.它必须位于引
; 导扇区的最后两个字节中。
boot_flag:
.word 0xAA55
.text
endtext:
.data
enddata:
.bss
endbss:
bootsect程序总结:
- 读取setup程序到0x90200处,一共4个扇区2K
- 读取系统代码到0x10000处,一共240扇区120KB
- 检查文件设备类型把设备号写到root_dev这个位置(508-510字节+0x90000)
setup程序说明:
1.首先设置DS段寄存器为0x9000,去取硬件信息存到内存0x90000处共510字节
内存地址 | 字节 | 名称 | 描述 |
0x90000 | 2 | 光标位置 | 行,列 |
0x90002 | 2 | 扩展内存数 | 从1M开始计算 KB |
0x90004 | 2 | 显示页面 | |
0x90006 | 1 | 显示模式 | |
0x90007 | 1 | 字符列数 | |
0x90008 | 2 | ||
0x9000A | 1 | 显示内存 | |
0x9000B | 1 | 显示状态 | |
0x9000C | 2 | 特性参数 | |
....... | |||
0x90080 | 16 | 硬盘参数表 | 第一个硬盘参数表 |
0x90090 | 16 | 硬盘参数表 | 第二个硬盘参数表 |
0x901FC | 2 | 根设备号 | 根文件系统设备号 |
2.关闭cpu中断,把system代码从0x10000移动到0x00000处,此时实模式的中断向量已经被system代码段覆盖。
! now we want to move to protected mode ...
; 这里开始,我们开始进入保护模式
cli ! no interrupts allowed ! ; 这里开始不允许任何中断
! first we move the system to it's rightful place
; 首先我们将system模块移到正确的位置。
; bootsect引导程序是将system模块读入到从0x10000(64k)开始的位置。由于当时假设
; system模块最大长度不会超过0x80000(512k),也即其末端不会超过内存地址0x90000,
; 所以bootsect会将自己移动到0x90000开始的地方,并把setup加载到它的后面。
; 下面这段程序的用途是再把整个system模块移动到0x00000位置,即把从0x10000到0x8ffff
; 的内存数据块(512k),整块地向内存低端移动了0x10000(64k)的位置。
mov ax,#0x0000
cld ! 'direction' = 0, movs moves forward ; 清方向位
do_move:
mov es,ax ! destination segment ; es:di是目的地址(初始为0x0:0x0)
add ax,#0x1000
cmp ax,#0x9000 ; 已经把最后一段(从0x8000段开始的64KB)代码移动完.
jz end_move ; 判断是否移动完成
mov ds,ax ! source segment
sub di,di
sub si,si
mov cx,#0x8000 ; 移动 0x8000个字
rep
movsw
jmp do_move
3.加载lgdt和lidt,打开A20地址实现1M内存以上的寻址,设置8259芯片的中断向量地址为0x20-0x28,跳转到代码段描述符1开始执行开始执行,也就是系统代码段执行。
; 全局描述符表开始处.描述符表由多个8字节长的描述符项组成.这里给出了3个描述符项.
; 第1项无用,但须存在.第2项的系统代码段描述符,第3项是系统数据段描述符.
gdt:
.word 0,0,0,0 ! dummy ;第1个描述符,不用
; 在GDT表 这里的偏移量是0x08.它是内核代码段选择符的值.
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec ; 代码段为只读,可执行
.word 0x00C0 ! granularity=4096, 386 ; 颗粒度4K,32位
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write ; 数据段为可读可写
.word 0x00C0 ! granularity=4096, 386 ; 颗粒度4K,32位
; 下面是加载中断描述符表寄存器idtr的指令lidt要求的6字节操作数.前2字节的IDT 的限长,后4字节是idt表在线性地址
; 空间中的32位基地址.CPU要求在进入保护模式之前需设置IDT表,因此这里先设置一个长度为0的空表.
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L
; 加载全局描述符表寄存器 gdtr 的指令 lgdt 要求的 6 字节操作数。
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries ; 表限长2k,8个字节等于1个描述符,共有256项
.word 512+gdt,0x9 ! gdt base = 0X9xxxx ; 0x90200 + gdt
setup.s代码段总结:
- 读取硬件参数覆盖以前的bootsect.s代码处(0x90000-0x90200)。
- 关闭中断,移动系统代码从0x10000到0x0000处(共拷贝内存512KB)。
- 设置idt gdt 打开A20地址实现1M以上寻址,初始化8259A中断向量基地址为0x20-0x28(因为cpu使用0x00-0x20中断为其他用处),跳转到系统代码运行。
中断请求号 | 中断号 | 用途 |
IRQ0 | 0x20(32) | 8253发出的100HZ时钟中断 |
IRQ1 | 0x21(33) | 键盘中断 |
IRQ2 | 0x22(34) | 连接从芯片 |
IRQ3 | 0x23(35) | 串行口2 |
IRQ4 | 0x24(36) | 串行口1 |
IRQ5 | 0x25(37) | 并行口2 |
IRQ6 | 0x26(38) | 软盘驱动器 |
IRQ7 | 0x27(39) | 并行口1 |
IRQ8 | 0x28(40) | 实时时钟 |
IRQ9 | 0x29(41) | 保留 |
IRQ9 | 0x2A(42) | 保留 |
IRQ9 | 0x2B(43) | 保留(网络接口) |
IRQ9 | 0x2C(44) | PS/2鼠标接口 |
IRQ9 | 0x2D(45) | 数学协处理器中断 |
IRQ9 | 0x2E(46) | 硬盘中断 |
IRQ9 | 0x2F(47) | 保留 |
head.s程序说明:
1.head程序和系统c语言代码是分别编译链接到一起组成system模块,head在最前面占用大概25KB+184B字节空间,head程序首先初始化所有段寄存器,初始化堆栈地址指针,设置中断向量,设置gdt描述符,段限长有所改变,所以再次重新设置段寄存器清除段寄存器缓存,设置idt中断处理函数,统一设置成忽略中断打印函数,检查A20地址是否被打开(往0处写一个数再去0x100000处读取判断数值是否一致,不一致则A20已经打开)。
/*
* setup_idt
*
* sets up a idt with 256 entries pointing to
* ignore_int, interrupt gates. It then loads
* idt. Everything that wants to install itself
* in the idt-table may do so themselves. Interrupts
* are enabled elsewhere, when we can be relatively
* sure everything is ok. This routine will be over-
* written by the page tables.
*/
/*
* 下面这段是设置中断描述符表子程序setup_idt
*
* 将中断描述符表idt设置成具有256个项,并都指向ignore_int中断门.然后加载中断描述符表寄存器(lidt指令)。
* 真正实用的中断门以后再安装.当我们在其他地方认为一切都正常时再开启中断.该子程序将会被页表覆盖掉.
*/
# 中断描述符表中的项虽然也是8字节组成,但其格式与全局表中的不同,被称为门描述符.它的0-1,6-7字节是偏移量,
# 2-3字节是选择符,4-5字节是一些标志.该描述符,共256项.eax含有描述符低4字节,edx含有高4字节.内核在随后
# 的初始化过程中会替换安装那些真正实用的中断描述符项。
setup_idt:
lea ignore_int, %edx # 将ignore_int的有效地址(偏移值)值->eax寄存器
movl $0x00080000, %eax # 将选择符0x0008置入eax的高16位中.
movw %dx, %ax /* selector = 0x0008 = cs */
# 偏移值的低16位置入eax的低16位中.此时eax含有门描述符低4字节的值。
movw $0x8E00, %dx /* interrupt gate - dpl=0, present */
# 此时edx含有门描述符高4字节的值.
lea idt, %edi # idt是中断描述符表的地址.
mov $256, %ecx
rp_sidt:
movl %eax, (%edi) # 将哑中断门描述符存入表中.
movl %edx, 4(%edi)
addl $8, %edi # edi指向表中下一项.
dec %ecx
jne rp_sidt
lidt idt_descr # 加载中断描述符表寄存器值.
ret
.align 4
ignore_int:
pushl %eax
pushl %ecx
pushl %edx
push %ds
push %es
push %fs
movl $0x10, %eax # 设置段选择符(使ds,es,fs指向gdt表中的数据段)
mov %ax, %ds
mov %ax, %es
mov %ax, %fs
pushl $int_msg
call printk # 该函数在kernel/printk.c中
popl %eax
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
iret # 中断返回
/*
* setup_gdt
*
* This routines sets up a new gdt and loads it.
* Only two entries are currently built, the same
* ones that were built in init.s. The routine
* is VERY complicated at two whole lines, so this
* rather long comment is certainly needed :-).
* This routine will beoverwritten by the page tables.
*/
/*
* 设置全局描述符表项setup_gdt
* 这个子程序设置一个新的全局描述符表gdt,并加载.该子程序将被页表覆盖掉.
*
*/
setup_gdt:
lgdt gdt_descr # 加载全局描述符表寄存器(内容已设置好)
ret
# 下面是加载全局描述符表寄存器gdtr的指令lgdt要求的6字节操作数.前2字节是gdt表的限长,后4字节是gdt表的
# 线性基地址.这里全局表长度设置为2KB字节(0x7ff即可),因为每8字节组成一个描述符项,所以表中共可有256项。
# 符号gdt是全局表在本程序中的偏移位置.
gdt_descr:
.word 256 * 8 - 1 # so does gdt (not that that's any
.long gdt # magic number, but it works for me :^)
.align 8 # 按8(2^3)字节方式对齐内存地址边界.
idt: .fill 256, 8, 0 # idt is uninitialized # 256项,每项8字节,填0.
# 全局表,前4项分别是空项(不用),代码段描述符,数据段描述符,系统调用段描述符,其中系统调用段描述符并没有
# 派用处,Linus当时可能曾想把系统调用代码专门放在这个独立的段中。同时还预留了252项的空间,用于放置所创
# 建任务的局部描述符(LDT)和对应的任务状态段TSS的描述符.
# (0-nul, 1-cs, 2-ds, 3-syscall, 4-TSS0, 5-LDT0, 6-TSS1, 7-LDT1, 8-TSS2 etc...)
gdt:
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */ # 0x08,内核代码段最大长度16MB.
.quad 0x00c0920000000fff /* 16Mb */ # 0x10,内核数据段最大长度16MB.
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252, 8, 0 /* space for LDT's and TSS's etc */ # 预留空间.
2.检查协x87数学协处理器是否存在,芯片不存在,需要设置CR0中的协处理器仿真位EM(位2),并复位协处理器存在标志MP(位1)。
/*
* NOTE! 486 should set bit 16, to check for write-protect in supervisor
* mode. Then it would be unnecessary with the "verify_area()"-calls.
* 486 users probably want to set the NE (#5) bit also, so as to use
* int 16 for math errors.
*/
/*
* 注意!在下面这段程序中,486应该将位16置位,以检查在超级用户模式下的写保护,此后"verify_area()"
* 调用就不需要了。486的用户通常也会想将NE(#5)置位,以便对数学协处理器的出错使用int 16。
*
*/
# 上面原注释中提到的486CPU中CR0控制器的位16是写保护标志WP,用于禁止超级用户级的程序向一般用户只读
# 页面中进行写操作。该标志主要用于操作系统在创建新进程时实现写时复制方法。
# 下面这段程序用于检查数学协处理器芯片是否存在
# 方法是修改控制寄存器CR0,在假设存在协处理器的情况下执行一个协处理器指令,如果出错的话则说明协处理器
# 芯片不存在,需要设置CR0中的协处理器仿真位EM(位2),并复位协处理器存在标志MP(位1)。
movl %cr0, %eax # check math chip
andl $0x80000011, %eax # Save PG,PE,ET
/* "orl $0x10020,%eax" here for 486 might be good */
orl $2, %eax # set MP
movl %eax, %cr0
call check_x87
jmp after_page_tables
/*
* We depend on ET to be correct. This checks for 287/387.
*/
/*
* 我们依赖于ET标志的正确性来检测287/387存在与否.
*
*/
# fninit向协处理器发出初始化命令,它会把协处理器置于一个末受以前操作影响的已和状态,设置其控制字为默认值,
# 清除状态字和所有浮点栈式寄存器。非等待形式的这条指令(fninit)还会让协处理器终止执行当前正在执行的任何
# 先前的算术操作。
# fstsw指令取协处理器的状态字。
# 如果系统中存在协处理器的话,那么在执行了fninit指令后其状态字低字节肯定为0。
check_x87:
fninit
fstsw %ax
cmpb $0, %al # 初始化状态字应该为0,否则说明协处理器不存在
je 1f /* no coprocessor: have to set bits */
movl %cr0, %eax
xorl $6, %eax /* reset MP, set EM */
movl %eax, %cr0
ret
.align 4 # 按4字节方式对齐内存地址, 为了提高32位CPU访问内存中代码或数据的速度和效率
# 两个字节值是80287协处理器指令fsetpm的机器码。其作用是把80287设置为保护模式。
# 80387无需该指令,并且将会把该指令看作是空操作
1: .byte 0xDB,0xE4 /* fsetpm for 287, ignored by 387 */ # 287协处理器码
ret
3.设置页目录和页表,然后返回到main函数开始运行,页目录一共设置了4项,页表也设置了4项,映射了16M地址线性地址与物理地址16M相对应映射。这里可能没有16m物理地址,只是页表实现了映射而已。
/***** 为跳转到init/main.c中的main()函数作准备工作 *****/
# 前面3个入栈0值应该分别表示envp,argv指针和argc的值(main()没有用到)
# pushl $L6 压入返回地址
# pushl $main 压入main函数的入口地址
# 当head.s最后执行ret指令时就会弹出main()的地址
after_page_tables:
pushl $0 # These are the parameters to main :-)
pushl $0 # 这些是调用main程序的参数(指init/main.c).
pushl $0
pushl $L6 # return address for main, if it decides to.
pushl $main
jmp setup_paging # 跳转至setup_paging
L6:
jmp L6 # main should never return here, but
# just in case, we know what happens.
# main程序绝对不应该返回到这里,不过为了以防万一,所以
# 添加了该语句。这样我们就知道发生什么问题了。
/*
* Setup_paging
*
* This routine sets up paging by setting the page bit
* in cr0. The page tables are set up, identity-mapping
* the first 16MB. The pager assumes that no illegal
* addresses are produced (ie >4Mb on a 4Mb machine).
*
* NOTE! Although all physical memory should be identity
* mapped by this routine, only the kernel page functions
* use the >1Mb addresses directly. All "normal" functions
* use just the lower 1Mb, or the local data space, which
* will be mapped to some other place - mm keeps track of
* that.
*
* For those with more memory than 16 Mb - tough luck. I've
* not got it, why should you :-) The source is here. Change
* it. (Seriously - it shouldn't be too difficult. Mostly
* change some constants etc. I left it at 16Mb, as my machine
* even cannot be extended past that (ok, but it was cheap :-)
* I've tried to show which constants to change by having
* some kind of marker at them (search for "16Mb"), but I
* won't guarantee that's all :-( )
*/
/*
* 这个子程序通过设置控制寄存器cr0的标志(PG位31)来启动对内存的分页处理功能,并设置各个页表项
* 的内容,以恒等映射前16MB的物理内存。分页器假定不会产生非法的地址映射(也即在只有4MB的机器上
* 设置出大于4MB的内存地址)
*
* 注意!尽管所有的物理地址都应该由这个子程序进行恒等映射,但只有内核页面管理函数能直接使用>1MB
* 的地址。所有"普通"函数仅使用低于1MB的地址空间,或者是使用局部数据空间,该地址空间将被映射到
* 其他一些地方去--mm(内存管理程序)会管理这些事的.
*
*/
# 上面英文注释第2段的含义是指在机器物理内存中大于1MB的内存空间主要被用于主内存区。主内存区空间
# 由mm模块管理,它涉及页面映射操作。内核中所有其它函数就是这里指的"普通"函数。
# 初始化页目录表前4项和4个页表
.align 4
setup_paging:
movl $1024 * 5, %ecx /* 5 pages - pg_dir+4 page tables */
xorl %eax, %eax
xorl %edi, %edi /* pg_dir is at 0x000 */
# 页目录从0x0000地址开始
cld;rep;stosl # eax内容存到es:edi所指内存位置处,且edi增4.
# 设置页目录表中的前4个页目录项
# 例如第1个页目录项:
# 页表所在地址 = 0x00001007 & 0xfffff000 = 0x1000
# 页表属性标志 = 0x00001007 & 0x00000fff = 0x07 表示该页存在,用户可读写.
movl $pg0 + 7, pg_dir /* set present bit/user r/w */
movl $pg1 + 7, pg_dir + 4 /* --------- " " --------- */
movl $pg2 + 7, pg_dir + 8 /* --------- " " --------- */
movl $pg3 + 7, pg_dir + 12 /* --------- " " --------- */
# 设置4个页表中所有项的内容(共4096项),从最后一个页表的最后一项开始按倒退顺序填写
movl $pg3 + 4092, %edi # edi->最后一页的最后一项.
movl $0xfff007, %eax /* 16Mb - 4096 + 7 (r/w user,p) */
std # 方向位置位,edi值递减(4字节)
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000, %eax # 每填好一项,物理地址值减0x1000。
jge 1b # 如果小于0则说明全填写好了
cld
# 设置页目录表基地址寄存器cr3(保存页目录表的物理地址)
xorl %eax, %eax /* pg_dir is at 0x0000 */
movl %eax, %cr3 /* cr3 - page directory start */
# 设置启动使用分页处理(cr0的PG标志,位31)
movl %cr0, %eax
orl $0x80000000, %eax # 添上PG标志
movl %eax, %cr0 /* set paging (PG) bit */
ret /* this also flushes prefetch-queue */
# 在改变分页处理标志后要求使用转移指令刷新预取指令队列,这里用的是返回指令ret。
# 该返回指令ret的另一个作用并跳转到/init/main.c程序去运行。
head.s程序总结:
- 设置了GDT和IDT ,检查A20地址,重新初始化段寄存器,特别注意设置的堆栈地址。
- 检查x87处理器是否存在设置仿真标志位,复位协处理器存在MP位。
- 压栈main可能使用的参数,设置16M的页表映射,然后跳转到main函数执行。
boot总结:
读取setup到0x90200和system代码到0x10000,设置根设备号到内存0x90000+508跳转到steup运行,setup读取硬件参数到0x90000-0x90200,关闭cpu中断,初始化8259A芯片,设置idt(限制和基地址都设为0)和gdt描述符(1个代码段,1个数据段都是(0-8M字节)),打开A20地址实现1M以上地址的寻址,打开保护模式,跳转到地址0运行head的代码,head代码重新设置了GDT代码限制改为16M,数据段也改为16M都从0-16M地址,一共就2段,idt设置256个一样的中断门都打印一样的数据,设置堆栈为c语言定义的stack_start,4K字节,设置DS等指向数据段,这里重新设置了几次主要是修改了gdt需要刷新段寄存器隐藏的部分缓存,最后设置页表页目录覆盖从0开始的部分代码,实现的映射16M对应物理地址16M没做转换,最后打开分页管理,跳转到main函数执行,这里使用提前压栈的main用的ret指令执行main函数。
现在操作系统已经有了简单的中断处理函数,页管理16M,代码段描述符和数据段描述符都为16M,描述符基地址限制是一样的只是属性不同,段寄存器都指向的数据段描述符,esp指向的stack_start,硬件参数存放到0x90000-0x90000+510,然后返回到压栈的c语言的main函数开始运行。
参考资料:
《Linux内核设计的艺术》【作者:新设计团队】
《Linux内核完全注释》 【作者:赵炯】
《Orange‘s一个操作系统的实现》【作者:于渊】
《x86汇编语言-从实模式到保护模式》【作者:李忠】