简单描述Linux0.11的启动与初始化过程。
启动过程中需要关注:IDT, GDT, LDT, TSS, 页表, 堆栈这些数据。
一:启动过程
启动的代码文件为bootsect.s、setup.s、head.s
bootsect.s也就是启动扇区的代码。这段代码主要是将setup.s和head.s中的内容读入内存的相应区域。然后开始执行setup.s
setup.s
1:使用BIOS中断来获得相关系统信息:内存大小,硬盘分区信息,显示卡信息
2:将head.s代码拷贝到内存地址为0X0000的地方。
3:加载idt表和gdt表地址
4:开启A20地址线,只有开启它了才能访问高于1M地址的内存
5:重新设定中断控制器。这之后以前的BIOS中断号就没用了。
6:置位CR0寄存器的最后一位进入保护模式, 然后用jmpi 0, 8指令跳转到地址0x08:0x0000处开始执行,也就是head.s的起始代码处。
这里设定的idt表全部为空,也即这时并不处理任何中断。
gdt表中有三个描述符:0--NULL, 1--内核代码段, 2--内核数据段描述符。
此时内核代码段与内核数据段:基地址为0x00000000, 限长为:8MB
head.s
1:将堆栈设定在static_stack处,堆栈大小为1KB
2:重新设定设定IDT和GDT,此时全部IDT的都设置为ignore_int,即仍然忽略中断。
GDT中包含256个描述符。
0--NULL, 1--内核代码段, 2--内核数据段, 3--保留, 4--进程0的TSS段, 5--进程0的LDT段, 6--进程1的TSS段, 7--进程1的LDT段......
可见系统的GDT中为每个进程都设定了一个TSS和LDT段。
内核代码段和内核数据段:基地址(0x00000000),限长(16MB)
3:设定分页(由于内存管理部分目前没看到,因此关于进程的页表如何设定暂不明白,这里是内核的页表)
在0x00000000处的第一页存放“页目录”,随后存放4页“页表”,每个页表对应于页目录中的一项。
设定的页表,要求线性地址等于物理地址。
4个页表能映射16MB的物理空间,因此这16MB的物理内存地址与线性地址是相同的。(0.11的内核没有PAGE_OFFSET)
4:开始执行init/main.c中的main函数。
方式如下:
pushl $_main # 将main函数地址入栈
jmp setup_paging # 开始分页
......
setup_paging:
.......
ret # 分页完成后,用ret指令弹出堆栈中的main函数入口地址,并开始执行main函数。
好像内核代码中常用这种弹出堆栈的方式来执行其他的函数
二:main函数
在启动过程中设定好GDT和页表后,开始在main函数中设定其他的内容。
主要是:设定IDT,设备初始化,创建进程0,fork出进程1,用进程1来执行init
main.c中主要的初始化函数是:trap_init,sched_init
trap_init中调用set_trap_gate来设定相应的中断描述符表。
下面以0号中断为例,描述其实现过程
1:调用set_trap_gate(0, ÷_error)
这个宏定义用来设定IDT表中的第0项的陷阱门描述符。
#define set_trap_gate(n, addr) _set_gate(&idt[n], 15, 0, addr)
在_set_gate中:&idt[n]为第n个描述符的地址, 15为描述符的类型(陷阱门),0为描述符的权限(最高权限),addr为要调用的代码的地址。
_set_gate宏会调用相应的汇编指令,在&idt[n]处写入8字节的描述符。
2:divide_error的实现
该函数是以汇编码的形式实现。与该函数对应有一个处理函数do_divide_error(用C语言实现)
对其他的多数陷阱门处理方式也是如此,有一个汇编方式实现的,还有一个C语言实现的处理函数。
当中断0发生时,先调用divide_error,该函数再调用do_divide_error。
void do_divide_error(long esp, long error_code)
上面为函数原型。第一个参数为出错时的代码地址的指针,第二个参数是错误码。(有些中断不产生错误码,则错误码设成0)
因此在divide_error的汇编代码中,主要的功能就是将出错地址的指针和错误码这两个参数传递给do_divide_error函数,
同时将目前的数据段设定为内核数据段。
sched_init函数
该函数设定了进程0的TSS和LDT描述符,并将它们的选择子加载进了TR和LDTR寄存器。
另外该函数设定了时钟中断和系统调用
这里主要说下系统调用的执行,以fork函数为例。
1:在sched_init中初始化系统调用
set_system_gate(0x80, &system_call)
#define set_system_gate(0x80, &system_call) _set_gate(&idt[n], 15, 3, addr)
可见系统调用也是一个陷阱门。区别是权限值为3, 因此用户进程能通过int 0x80的中断进入内核,执行系统调用。
2:每个系统调用都有一个对应的编号,fork为第二个系统调用,因此fork的调用号为2。
当执行fork函数的时候,它会用int 0x80来调用system_call函数。
此时fork的调用号被放入eax寄存器中。
3:全部的系统调用函数指针都保存在数组sys_call_table中。
在system_call函数中会执行指令
call _sys_call_table(,%eax, 4)来跳转到eax指定的系统函数代码上,对fork来说就是sys_fork函数。
4:system_call
i) system_call首先将相应的寄存器入栈。
pushl %edx
pushl %ecx
pushl %ebx 这三个寄存器对应了相应的系统调用函数的3个参数
因此0.11中,系统函数最多只能有3个参数。
ii)将ds和es设定为内核数据段,将fs设定为用户进程的数据段,需要用户进程的数据时,可使用fs来访问
iii)用call _sys_call_table(,%eax, 4)来执行系统调用
iii)检查当前进程是否处于可执行状态,检查当前进程的时间片是否用完,相应的执行schedule
iv)最后是对进程信号的处理。(信号机制没看完)
三:进入用户态
在main函数中相关初始化后,main以进程0的身份进入用户态。
然后调用fork函数,创建进程1,进程1调用init函数
init函数加载根文件系统,运行初始化配置命令,然后执行shell程序,这样便进入了命令行窗口。
0.11内核中,每个进程都有一个TSS段和一个LDT段,它们保存在进程描述符strut task_struct结构中。
相应段的描述符保存在GDT表中。
在LDT段中,有3个LDT描述符,0--NULL, 1--进程代码段, 2--进程数据段。
进程n的代码段和数据段:基地址=n*64MB,限长=64MB。(进程0和1的限长为640KB)
因此系统中最多有64个进程。
进程0的task_struct为INIT_TASK,进程0的TSS和LDT描述符在sched_init中设定。
main函数调用move_to_user_mode函数来执行进程0,进入用户态。
0.11内核中所有进程都是属于用户态,不像之后的Linux内核里有内核线程。
move_to_user_mode函数
此函数使用iret返回的方式,从内核态进入用户态。
+------------+
+ ss + pushl $0x17
+------------+
+ esp + pushl %%eax #eax中保存了esp
+------------+
+ eflags + pushfl
+------------+
+ cs + pushl $0x0f
+------------+
+ eip + pushl $1f #目的代码的偏移地址
+------------+
首先采用上面的push指令,将相关的数据压入堆栈,然后执行iret将它们弹出堆栈。
于是进程0从堆栈中的cs:eip指向的代码开始执行。
四:fork函数
进程0执行fork函数创建出进程1.
1:调用get_free_page为进程描述符分配内存。
p = (struct task_struct *) get_free_page();
这一页内存,前面保存task_struct内存, 页尾为进程的内核栈,
当一个用户程序调用系统函数进入内核态后,系统函数执行时使用的栈就是这个。
2:设定进程的task_struct结构体
3:内存拷贝,将父亲进程的内存拷贝给新进程。
4:设定新进程在GDT中的TSS和LDT描述符
有关fork最主要的是弄明白了,为什么它可以“返回”两次。
1:调用fork时,CPU自动将父进程的返回地址入栈(即eip寄存器入栈)
2:创建子进程的task_struct后,将TSS段中的eax字段设成0,eip字段设成父进程的返回地址。
3:将子进程的状态设成TASK_RUNNING(就绪状态)
4:fork函数以子进程的pid返回。
5:等到执行schedule,调度到子进程时。会自动将子进程的TSS内容加载进寄存器。
因此这时CPU中eax寄存器值为0, eip为父进程的返回地址。所以子进程从fork函数的下一条指令开始执行,返回值在eax中,为0。
阅读(3829) | 评论(0) | 转发(0) |