本人最近在学linux0.11的源码,顺便写成博客可以供自己记忆还有分享给别人学习,以下的内容是基于作者本人的理解写的,如有疑问可以提出,大家一起探讨学习,如写得有错误,也欢迎指正。
head.s程序
head.s程序会被编译生成目标文件之后与其他内核程序一起被链接成system模块,而该程序是system模块最开始的部分,因而也被称为head。
在setup结尾处,我们已经完成了从实模式到保护模式的转换。并且head使用的汇编语法的AT&T的语法格式,与前面的bootsect和setup是不一样的,该汇编语法的赋值是从左向右赋值的。
head程序是位于0x0000地址处的,它首先加载各个数据段寄存器,重新设置中断描述符表IDT,之后再重新设置全局描述符表GDT,并且会开启分页模式,并把页目录和四个页表写到0x0000地址处,会覆盖掉head程序。
.text
.globl _idt,_gdt,_pg_dir,_tmp_floppy_area
_pg_dir: #页目录写入的地址
startup_32:
movl $0x10,%eax #%0x10表示0x10本身,直接写0x10表示0x10内存地址存放的数据
mov %ax,%ds #这里是在重新初始化各个段寄存器,在保护模式下,0x10表示GDT中的第二个描述符
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp #表示ss:sp赋值为_stack_start,初始化堆栈
call setup_idt #之前设了一个空的idt表,现在重新设置idt
call setup_gdt #重新设置gdt表
movl $0x10,%eax # reload all the segment registers
mov %ax,%ds # after changing gdt. CS was already
mov %ax,%es # reloaded in 'setup_gdt'
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp #初始化堆栈
xorl %eax,%eax
/* 检查A20地址线是否开启,如果未开启,就只能写入低于1MB地址的内容,如果写入高于1MB地址的内容,
就会直接忽略高于20位的地址,只取低20位作为地址,相当于mod1MB,例如在地址1MB处写入,则会
写入到0x0000处。
下面就通过不断的往地址1MB处写入不同的数字,并检测于0x0处数据是否相同,如果相同就不断尝试
写入新的数字,直到不相同,才会向下执行,确保已经开启A20地址线。 */
1: incl %eax # check that A20 really IS enabled
movl %eax,0x000000 # loop forever if it isn't
cmpl %eax,0x100000
je 1b
/*
* 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.
*/
movl %cr0,%eax # check math chip
andl $0x80000011,%eax # Save PG,ET,PE
/* "orl $0x10020,%eax" here for 486 might be good */
orl $2,%eax # set MP,开启MP位
movl %eax,%cr0
call check_x87 # 检测是否有数学协处理器芯片的函数
jmp after_page_tables # 设置返回地址并且开启和设置页表的函数
刚刚上面使用到的lss _stack_start,%esp是将结构体stact_start 的值传送到 ss:esp ,而stack_start定义在/kernel/sched.c文件中,定义如下
#define PAGE_SIZE 4096
long user_stack [ PAGE_SIZE>>2 ] ;
struct {
long * a;
short b;
} stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };
stact_start 的长指针为user_stack 数组的结尾处,还有一个short类型的数据为0x10,lss _stack_start,%esp 即令ss=0x10(段选择子)和esp=& user_stack [PAGE_SIZE>>2],让user_stack 数组作为栈,从后往前增长。
下面是setup_idt函数中的代码,这是一段设置中断门描述符的代码。每个中断描述符都由32位组成,与GDT和LDT类似,中断描述符的格式如下图所示。其中P表示段存在标志,DPL的描述符的优先级,需要与CPL和RPL配合使用。在head程序中,IDT的段选择子被设置成0x0008,偏移值被指向了ignore_int中断处理程序在head中的偏移值,表示中断之后会运行ignore_int程序。
中断门包含中断处理程序所在的段选择子和段内偏移地址,当通过此方式进入中断后,标志寄存器eflags中的IF位自动置0,表示把中断关闭,避免中断嵌套。中断门只存在于中断描述符表IDT。
/*
* 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:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */
/* %edx存储IDT描述符的高32位,%eax存储低32位 */
lea _idt,%edi # _idt是中断描述符表的地址
mov $256,%ecx # 写入256个描述符
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi) # 每个描述符都是高位写%edx,低位写%eax
addl $8,%edi # 每次写完加8字节,因为每个描述符都是8字节
dec %ecx # 使用%ecx来计数
jne rp_sidt
lidt idt_descr # 重新加载idt表地址到idtr中
ret
...
/* This is the default interrupt "handler" :-) */
/* 下面是默认的中断“向量句柄”,这是一个只报错的哑终端子程序 */
int_msg:
.asciz "Unknown interrupt\n\r"
.align 2
ignore_int:
pushl %eax
pushl %ecx
pushl %edx
push %ds
push %es
push %fs
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
pushl $int_msg
call _printk
popl %eax
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
iret # 中断返回
...
.align 2
.word 0
idt_descr:
.word 256*8-1 # idt contains 256 entries
.long _idt # idtr高16位表示表的长度,低32位表示idt的入口地址
.align 3
_idt: .fill 256,8,0 # idt is uninitialized,256项,每项8个字节,填0
在设置好中断描述符表之后,head又需要重新设置GDT全局描述符表。但是该GDT的段描述符除了表的长度之外(原8MB,现16MB),其余内容与先前在setup中设置的GDT表中描述符的内容是完全一样的,只是把GDT表位置从先前的0x902XX地址处搬运到了更适合的内核中。
当然在GDT表中第0项是不可用的表项,通常使用一个空描述符写入。之后就是两个有效的描述符,分别是段限长度为16MB的指向0x0的代码段描述符和数据段描述符,之后就填入253项空描述符,为之后每个进程的LDT和TSS段先申请空间。
/*
* 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:
lgdt gdt_descr # 将gdtr设置成gdt_descr地址处的6byte内容
ret
...
.align 2
.word 0
gdt_descr:
.word 256*8-1 # so does gdt (not that that's any
.long _gdt # magic number, but it works for me :^)
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don’t use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
设置好IDT、GDT之后,我们需要设置一个虚假的调用栈,来为main函数返回设置一个地址。
众所周知, C语言和汇编是通过栈来传递参数和存储返回地址eip的,栈中参数如下图所示,从右往左压入参数,最后压入eip(已经自动加一)作为返回地址。之后在函数中就可以使用esp+n来获得第n个参数的地址。这也就是为什么函数中改变形参不改变变量的值了,因为改变变量只会改变栈中参数的值,不会改变原变量。
after_page_tables:
pushl $0 # These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 # return address for main, if it decides to.
# 压入的L6为main函数的返回地址
pushl $_main # 压入main地址,为setup_paging的返回地址,方便在setup_paging函数中直接返回到main中执行
jmp setup_paging # 注意这里是使用jmp调用而不是call,call会自动压eip入栈
L6:
jmp L6 # main should never return here, but
# just in case, we know what happens.
# 从main函数返回之后,就在这执行死循环,因为正常情况下是不会从main函数返回
之后在setup_paging函数中就要开启分页模式,将页目录放在0x0绝对地址处,这也是head程序的地址,会覆盖head,再将四个页表依次放在页目录后面(每个页都是4KB),并设置它们的页表项,完成页表中0-16MB与绝对地址0-16MB之间的恒等映射(每个页有1K个页表项,4K*4KB=16MB)。
下图是页目录表项与页表项结构的说明,每次页表只需要使用12-31位高位就可以映射到每个页贞的首地址(每4KB一个页贞,意味着低12位为0),低12位就可以用来表示权限,每个页表项权限被设置成0x7表示该页存在、用户可读写。
P:页面存在于内存标志
R/W:读写标志
U/S:用户/超级用户标志
A:页面已访问标志
D:页面已修改标志(dirty位)
Tips:不是很清楚段页地址转换的同学,可以先去看看这一部分
/*
* I put the kernel page tables right after the page directory,
* using 4 of them to span 16 Mb of physical memory. People with
* more than 16MB will have to expand this.
*/
# 在0x1000处打上pg0的标记,也是第0个页表的起始地址
.org 0x1000
pg0:
# 在0x2000打上pg1标记,作用类似
.org 0x2000
pg1:
.org 0x3000
pg2:
.org 0x4000
pg3:
.org 0x5000
/*
* tmp_floppy_area is used by the floppy-driver when DMA cannot
* reach to a buffer-block. It needs to be aligned, so that it isn’t
* on a 64kB border.
*/
_tmp_floppy_area: /* 申请1KB作为软盘高速缓冲区 */
.fill 1024,1,0
...
/*
* 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 :-( )
*/
.align 2
setup_paging:
movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables,1024*5个页表项 */
xorl %eax,%eax
xorl %edi,%edi /* pg_dir is at 0x000, 页目录设置在0x0处,最后再让cr3=0x0即可 */
cld;rep;stosl /* 清空前5个页,每个页表项都置零 */
movl $pg0+7,pg_dir /* set present bit/user r/w */
movl $pg1+7,pg_dir+4 /* 写页目录的第一个表项,pg_dir+4因为每个表选项为4字节的内容 */
movl $pg2+7,pg_dir+8 /* $pg2+7是因为要表明权限7=4+2+1分别代表rwx的权限 */
movl $pg3+7,pg_dir+12 /* --------- " " --------- */
movl $pg3+4092,%edi /* 计算最后一个页表的最后一个表项的地址 */
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* 从后往前填充页表指向的内存地址,效率会更高一些 */
subl $0x1000,%eax /* 完成恒等映射填写4个页表的页表项 */
jge 1b /* 1b表示向后跳转到标号1的地方去 */
xorl %eax,%eax /* pg_dir is at 0x0000 */
movl %eax,%cr3 /* cr3 - page directory start,cr3页目录设置为0x0 */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* set paging (PG) bit,开启分页 */
ret /* this also flushes prefetch-queue */
/* 最后这个ret就会弹出栈中的第一个数据作为返回地址,而刚刚在after_page_tables函数中,
最后压入了$main构造了一个假的返回地址,所以ret之后就直接返回main中执行 */
head.s程序结束后,就到了main函数中去执行了。head主要完成了重新设置IDT和GDT表,开启分页模式还有在页表中完成了0-16MB的恒等映射。执行完head之后,system在内存中的分布如下图所示。
当然有同学就又要问了,为什么head存放在0x0处,而页表也存放在0x0处,页表覆盖了head的代码,这不会出错吗。
当然如果覆盖了之后还需要执行的代码的话,肯定是会出错的;但如果覆盖了不会再执行的head代码,就不会出错了。发生页表填写时是执行到setup_paging函数中,而after_page_tables函数是位于0x5400处的,而页表和软盘缓冲区就覆盖到了0x5400,将不会覆盖after_page_tables函数和setup_paging函数以及之后的所有数据。
以上是我最近学习linux0.11源码的理解,参考至赵炯老师的《linux内核完全注释 3.0》。