我是通过阅读赵炯老师编的厚厚的linux内核完全剖析看完LINUX0.11的代码,不得不发自内心的说Linus真的是个天才。虽然我觉得很多OS设计的思想他是从UNIX学来的,但是他自己很周全很漂亮很巧妙地实现了如此庞大一个系统的绝大多数代码。这里面有太多环节需要注意,很难得。。。
读完之后觉得很有收获,虽然版本很低,但是已经对OS有一个很具体的认识了,比理论上的要来得深刻、真实。下面是我自己学习过程的思考和总结,在看完细节之后主要从LINUX各个功能模块其及相互之间和内部的层次关系去考虑的,本文图片均取自该书。我觉得这篇总结性质的文章对还没有接触linux0.11内核的人来说肯定没有什么意义。应该只有读过的代码的人才会有同感吧。另外我看代码的时候使用了VC版的内核源码工程,代码中的注释与书中几乎一样。用VC可以更容易地在函数定义中跳转查看,节约时间,我的方法是看书上代码前给出的知识介绍,然后在电脑上看代码实现,一共用了十天把这本书主要部分看完了。这里给希望阅读代码的人分享一下:
http://www.mcuol.com/download/upfile/20071011080428_linux011VC.rar。
一.源码目录
<!--[if !vml]--><!--[endif]-->
图1
二.系统总体流程:
系统从boot开始动作,把内核从启动盘装到正确的位置,进行一些基本的初始化,如检测内存,保护模式相关,建立页目录和内存页表,GDT表,IDT表。然后进入main进行初始化设置,main完成系统各个模块要用到的所有数据结构和外部设备的初始化。使得系统可以正常的工作。然后才进入用户模式。执行第一个fork生成进程1执行init,运行shell,接受并执行用户命令.
这里整个系统建立起来了,OS就处于被动状态,靠中断和系统调用来完成每一项服务。
三.各个目录的阅读总结:
(一) boot
1.bootsect.s :
bootsect.s编译结果生成一个512BYTE(一个扇区)镜像。这个扇区的最后一个字是0xAA55,倒数第二个字是root_dev变量,值为ROOT_DEV(306),即根文件系统所在的设备号。这段代码必须写入到启动盘的启动扇区,也就第一个物理扇区上。这样机器启动后,BIOS自动把它加载到7C00H处并跳到那里开始执行。bootsect将自己移动到90000H(576K)处,并跳至那里相应位置执行。然后利用BIOS中断将setup直接加载到自己的后面(90200h)(576.5K处),并将system加载到地址10000h处。 跳到setup中执行。
2.setup.s:
利用BIOS中断把系统参数如显卡,硬盘参数保存到内存90000开始的位置,即覆盖原bootsect所在的内存位置。再把整个system 模块移动到00000 位置。加载GDTR和IDTR,这里的GDT表是临时的,保存了两个描述符,即内核代码、内核数据段描述符,其段基地址为0。而加载IDT除了进入保护模式需要加载IDTR之外,没有任何意义。开启A20 地址线开启扩展内存。重新设置8259中断码0x20~0x2f。进入保护模式(PE置1)跳转到system模块中的head.s中(0处)执行。Bootsect.s和setup.s执行时内存变化情况。
<!--[if !vml]--><!--[endif]-->
图2
3.head.s:
前4KB的代码将被页目录覆盖掉。这些代码执行的操作包括:设置系统堆栈为_stack_start。重新设置GDTR和IDTR。gdt,idt表都定义在head.s的末端,长度均为256项(2KBYTE)。第2个页面到第5个页面是系统的4张页表。最后一个页表后面的代码执行分页操作,即填充的4个页目录和4张页表的内容,实现对等映射,即物理地址=线性地址。每个页表项属性为存在并且用户可读写。设置好后置CR3页目录地址即0。启动分页标志,CR0的PG标志置1。跳到main函数中执行。
跳到main之前,内存布局如下:从0到16M
页目录4K(0x0开始)
页表1 4K
页表2 4K
页表3 4K
页表4 4K
软盘缓冲区1K
head.s后半部分代码
IDT表2K
GDT表2K
main.o代码部分
内核其余部分(大约到512K,end值为结束地址)
setup保存的系统参数(90000H~900200)这个区间还保存着root_dev.
BIOS(640K-1M)
主内存区(1M-16M)
现在初始化好了内核工作依赖的主要的数据结构是GDT和IDT表,还有页表。
(二)内核初始化init
main.c将进行进一步初始化工作。主要方面:分配主内存功能,IDT表各中断描述符重新设定,对内核其它模块如mm,fs进行初始化,然后移到用户模式下生成进程1执行init,常驻进程0死循环执行pause。进程init加载根文件系统,设置终端标准IO,创建进程2以/etc/rc为标准输入文件执行shell.完成rc文件中的命令。
init等进程2退出,进入死循环:创建子进程,建立新会话,设置标准IO终端,以登录方式执行shell.
至此系统动作起来了。
所以整个系统的建立起来后除了两个死循环的进程idle和init,其它的动作都是由用户在shell下执行命令,产生系统调用来工作的。
通过执行move_to_usermdoe(),idle和init进程都属于用户态下的进程。而内核则完全是中断驱动的。也就是说只有通过中断才能进入系统,如时钟和系统调用等。
所以问题的重点就在于内核各部分数据结构的建立、初始化、操作是怎样进行的。这些初始化流程涉及到内核各个模块全部重要的数据结构。
现在从main执行的一系列初始化代码来浅窥一下:
1.根据内存的大小,设置高速缓冲的末端。16M内存把高速缓冲末端设为4M。缓冲末端到主存末端为主内存区。
2.mem_init(main_memory_start,memory_end);主内存区初始化
设置高端内存HIGH_MEMORY=memory_end,
设置内存映射字节图mem_map [ PAGING_PAGES ],将不可用的全部置为USED,可用的置为0。mem_map数组是系统mm模块核心数据结构,记载了每个内存页使用计数。
3.trap_init().硬件中断向量表设置。
向IDT中填充各个中断描述符,使其指向对应的中断处理程序。对于错误,基本是结束当前进程。其他如外设中断都是各个模块初始化的时候向IDT表中相应项进行设置。
4.blk_dev_init(); // 块设备初始化。
初始化请求数组request[],将所有请求项置为空闲项(dev = -1)。
5.chr_dev_init(); // 字符设备初始化。尚为空操作。
6.tty_init(); // tty 初始化。
/// tty 终端初始化函数。
// 初始化串口终端和控制台终端。
void tty_init (void)
{
rs_init (); // 初始化串行中断程序和串行接口1 和2。(serial.c, 37)
con_init (); // 初始化控制台终端。(console.c, 617)
}
rs_init 初始化两个串口,安装串口中断处理IDT项。
con_init 初始化显示器和键盘。安装键盘中断处理IDT项。
7.time_init().取CMOS 时钟,并设置开机时间 startup_time(为从1970-1-1-0 时起到开机时的秒数)
8.sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr)
这里初始化与进程调度有关的数据结构。
手工设置了任务0的TSS和LDT到GDT表中。
清GDT表和task[NR_TASKS]数组其余部分。
ltr (0); // 将任务0 的TSS 加载到任务寄存器tr。
lldt (0); // 将局部描述符表加载到局部描述符表寄存器。
设置内核的工作心跳--8253定时器,安装定时器中断。
设置系统调用中断门:set_system_gate (0x80, &system_call);
9.buffer_init(buffer_memory_end);// 缓冲管理初始化,建内存链表等。
在内核的结束地址end(由连接程序生成)到buffer_memory_end之间(除掉640kb-1M的BIOS范围)区域中,建立缓冲区链表(表头start_buffer)并分配缓冲块(1KB)。
初始化空闲表free_list,HASH表hash_table。
10.hd_init();// 硬盘初始化。
blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST; //设置硬盘的设备请求函数为do_hd_request.
设置硬盘中断处理IDT项。
11.floppy_init();//软盘初始化。
blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST;
设置软盘中断处理IDT项。
12.sti()开中断。
13.move_to_user_mode();移动到用户态执行。
这是个宏。由嵌入汇编代码组成。设置内核堆栈中的CS为任务0代码段(用户态),通过中断返回iret, 自动加载了LDT0的代码段到CS,数据段到SS,DS等,完成了从特权级从0跳到3。其实执行的代码在内存中的位置完全相同。只是完成执行权跳到用户态而已。这样,内核执行变成了任务0的执行。
14.fork();生成进程1,执行init();
这里的fork()是内联函数。为了不使用用户栈。
进程0从此死循环执行pause();
15 init();
进程1执行init()函数。
调用setup取硬盘分区信息hd.加载虚拟盘,进程init加载根文件系统,设置终端标准IO,创建进程2以/etc/rc为标准输入文件执行shell.完成rc文件中的命令。加载完根文件系统之后,整个OS就已经完整地运行起来了。
init等进程2退出,进入死循环:创建子进程,建立新会话,设置标准IO终端,以登录方式执行shell.,剩下的动作由用户来决定了。
(三)kernel:
<!--[if !vml]-->
<!--[endif]-->
图3
个人认为最主要的是中断代码,然后是中断代码会调用的通用代码。为什么这么说呢,无论是调度schedule,还是fork,都只有在用户进程执行int 0x80 中断进行系统调用或者是硬件中断才能进入内核代码,执行内核函数。当内核初始化结束后所有进程都是用户态进程,只有通过IDT表中定义的那些中断函数去执行内核代码。所以中断是OS的主线,只是在功能上分成了多个模块。
在traps.c中,设置了绝大多数中断向量,通过set_trap_gate()或者set_intr_gate设置对应IDT描述符。set_trap_gate()不会屏蔽中断,而set_intr_gate屏蔽外部中断,中断处理程序都是用汇编定义的,大部分在asm.s中定义,其余在system_call.s,keyboard.s,rs_io.s中定义。 汇编程序中再调用C语言程序做具体的处理。
比较重要的中断时钟中断int 0x20,系统调用中断int 0x80,页故障中断int14,还有一些外部设备如键盘,硬盘等也很重要,不过属于fs模块的内容。大多数异常只是简单调用sys_exit()结束当前进程,并重新调度其他进程schedule()。
从几个重要中断去中断执行流程去弄清OS怎么工作的:
1)int 0x20 时钟中断。
时钟是整个OS工作的心跳。8253每10ms产生一个中断。中断服务执行do_timer(),然后do_signal();
do_timer主要判断当前进程时间片是否用完,如果用完且处于用户态则执行schedule()重新调度。如果中断时当前进程正在内核态执行,则不能进行切换。也是说linux在内核态不支持任务抢占。这样使得内核的设计大大的简化了,因为除了进程自己放弃执行(如sleep,wait类)不用担心临界区资源竞争的问题。
如果当前进程是用户进程,判断当前信号位图中是否有未处理的信号,取最小信号然后调用do_signal()。这个函数想要执行用户定义的信号处理函数。
do_signal()把信号对应的处理函数插入到内核堆栈eip处,并修改用户堆栈使中断返回后用户进程执行信号处理函数,
信号处理函数返回后执行一个sa_restorer,恢复用户堆栈为正常中断退出之后的状态。(这是一个技巧,它实现了内核空间调用用户空间的函数!!!)
另外内核空间与用户空间数据交换默认通过fs来完成。
用户进程总是通过中断进入内核态,信号判断总是发生在时钟中断和系统调用中断处理之后。所以实时性也很强,因此称为软中断。
关于信号,在schedule()中,对当前系统中的所有进程alarm信号定时判断,可睡眠打断进程如果有未屏蔽信号置位唤醒(状态改为就绪)。
因此时钟,系统调用,以及最频繁调用的schedule()里面都会处理信号。因此信号总是可以及时地得到"触发"。
2)int 0x80 系统调用
系统调用的架构就是一个统一的中断入口和出口_system_call,保护现场,准备参数(最多三个),取调用号,调用系统调用函数列表中对应处理函数,多是名为sys_XXX()的C函数。C处理函数返回后的后期流程:
如果进程系统调用后状态不为就绪态或者时间片用完,执行调度schedule();判断中断前是用户进程且有未处理的信号?执行do_signal(),中断返回。
最重要的系统调用莫过于fork()和execve;
首先进程的重要组成部分:
任务数组task[],每个任务占一项(假定序号为nr),每个虚拟地址空间都是64M, 范围从nr*64M到(nr+1)*64M-1 ,在页目录表中最多占16项,每项对应一个页表即4M空间。进程任务数据结构task和内核栈共用一页空间,内核栈顶在页空间末端,task数据在页起始端。
进程的页表占用的页需要通过内存管理提供的接口get_free_page()来申请。 每个进程在GDT表中占用两个描述符项,LDT(nr)和TSS(nr)。
fork()流程:
调用find_empty_process()找一个空闲的进程任务task[]下标即任务号nr,并取得一个不重复的pid.
调用copy_process(...),里面一堆参数都是系统栈中保存的全部内容。(汇编和C混合编程技巧!)。copy_process 向mm申请一页内存保存task数据和设置内核堆栈。把父进程也就是当前进程的task数据全部拷贝,然后修改。
设置tss内容,(需要修改的主要是ss0=内核数据段,esp0=申请的页底部,eax=0)。
copy_mem拷贝进程空间。注意任务号nr意义在于进程的虚拟地址空间在nr*64M~(nr+1)*64M范围内。copy_mem先计算子进程虚拟地址空间基址和父进程空间大小,设置子进程LDT中的代码段和数据段描述符基址和段限。调用copy_page_tables复制进程空间。copy_page_tables就是把父进程占用的页目录项和全部页表中指定的有效的物理页面全部拷贝到子进程的页目录项和页表中去。同时把父子进程的页表设为共享的也就是只读的,一旦任意一个进程执行写内存操作,将发生页错误中断。这个中断将导致系统为进程重新分配可写的内存页。copy_page_tables先计算父子进程虚拟地址空间占用的目录项(16个最多)开始地址,对每个有效的目录项先为子进程分配一个页面作为页表,然后对该目录项下所有有效的页表项进行复制。同时把r/w位都置0。把对应物理页的mem_map[]加1。这样做非常高效而且非常巧妙。最大限度地共享了本身就只读或者不需要再写的页面。每当进程和内核之间要交换数据时尤其是内核向进程空间写数据时总是要先验证进程给的线性地址是否有效。如verify_area,write_verify..这两个函数最终会调用un_wp_page,取消页面的写保护。对mem_map[]=1的直接置r/w为1,mem_map[]>1表明页面共享了,内存页映射表mem_map[]-1然后申请空闲物理页,设置到页表项中,并复制页面copy_page。可见,父子进程先写进程者将申请空闲页并拷贝页面内容,另一个则可以直接使用原来的页面,因为这时mem_map[]=1了。
进程空间拷贝完毕之后,再设置一些task结构数据。给GDT表填加两项LDT(nr),TSS(nr).进程状态设为就绪,等待被调度就OK了。。
这就是所谓的写时复制,太神奇了。。
execve提供了需求加载的机制。
它加载一个程序文件到进程中执行,因此把原来进程拥有的页表项和页表全部释放掉。同时分配一页内存存放参数。
根据可执行文件头把进程任务数据结构task[nr]所有数据都设置好,但是并不加载一页代码数据。所以整个进程就是一副空架子。
从进程空间的第一条语句开始执行就会产生中断,然后根据PC的值从外设中加载所在页到内存中。这个中断将执行do_no_page.
这个函数在fs模块中定义,在fs模块中再仔细分析。
3)缺页中断int14
这个中断是十分有用的,它实现写时复制。fork和execve没做完的事情都会由这个中断提供的功能来了结。
中断错误号为出错页表项的最后3位。根据P位0或1判断是缺页中断或写保护中断。
缺页中断调用do_no_page,写保护调用do_wp_page.
do_wp_page提供写时复制机制,
取消页面保护(对于主内存区而言),页面不是共享状态,即mem_map[]=1,则置r/w=1返回。
如果页面是共享状态(mem_map[]>1),mem_map[]--,申请一页内存,并拷贝,映射到进程空间。
do_no_page提供的需求加载机制。
CR2提供发生错误时的线性地址。如果当前进程没有可执行文件且该地址比数据段末地址大,这可能是因为堆栈伸长引起的,直接申请一页内存映射到该线性地址所在页。
否则尝试页面共享,即如果有执行文件而且其inode使用计数>1,表明系统中可能有进程也在执行这个程序,这样可以查找到这个进程并把其对应地址处的页面共享到自己空间,也就是修改这两个进程对应的页表项。而且是共享方式,所以只能读不能写,如果有一个进程要执行写,则会引起写保护中断,系统再给写的进程另外再分配内存页并拷贝一页内容。如果尝试页面共享失败,没办法只得从外设中加载,找到可执行文件的inode.计算要读的逻辑块号(注意第一块是文件头),读一页(4块)到内存。分配页面,复制缓冲中的4块数据,把页面映射到进程空间引起中断的线性地址处。
(四)mm内存管理
linux的mm虽然只有两个文件memory.c和page.s,但是内容却很不简单。必须对分页机制有很好的理解才能读明白。
这个版本的内核每个进程虚拟空间64M,共支持4G/64M=64的任务数。所有进程共用一个页目录,但是却有自己的页表。
对虚拟地址的划分使得在页目录中也存在划分。每个进程虚拟空间最大占用16个目录项,每个目录项指向一个页表(1024个内存页),对应4M空间。
线性地址分三段,每段都是一个索引index或者叫偏移(offset),第一段索引是在页目录(基址在CR3)中找到页目录项,页目录项里保存的是一张页表的基地址。以线性地址的第二段为索引加上这个基地址,得到的页表项保存的是实际内存页的起始地址。再加上线性地址第三段为偏移,得到线性地址映射的实际物理地址。
内存管理提供的功能主要有管理页面,操作进程空间,缺页中断处理(写时复制,需求加载),共享内存页。其中大多数函数都会访问页目录和页表,都使用上述的计算的原理。
内存管理mm和内核kernel两部分代码联系十分密切
内存管理提供的主要的功能函数可以分为
1管理页面 :取一个空闲页get_free_page,释放一页free_page.
2操作进程空间:free_page_tables释放进程页目录和页表
copy_page_tables在进程空间之间复制页目录和页表。主要提供给fork()使用,实现写时分配。
put_page 把一页内存映射到进程空间中去。
write_verify进程空间有效性验证,当内核向用户空间写数据之前必须进行验证。为可能为无效的地址区域分配页面
3页面共享&页故障中断:
try_to_share 尝试在打开文件表中找当前执行程序inode,已经存在的话就查找所有进程中executalbe与当前进程相同的任务。有则尝试共享对应地址的映射的物理页面。即添加到自己相应位置的页表项中去。(share_page)
do_no_page 缺页中断。判断地址是否超出end_data,是则可能是堆栈伸长,分配页面到相应位置(get_free_page,put_page),否则表示地址在可执行文件内部,先尝试共享,不成功则从线性地址计算需加载部分在文件上内部的块号,通过bmap把文件内部块号映射的设备逻辑块号计算出来。申请空闲页并通过bread_page读取一页,最近由put_page把这页映射到发生中断的进程页面上。
un_wp_page在写保护中断中调用,取消页表保护,实现写时复制。