2011-12-17 17:52:43 wangnanjun 阅读数 4655

编译Linux0.00内核(《Linux内核完全剖析-基于0.12内核》)

老早就买了一本赵炯编写的《Linux内核完全剖析-基于0.12内核》,看来前四章的原理部分,却一直没有勇气来自己动手来实践。最近终于下定决心好好看看这本书,并打算坚持写一些学习笔记来记录学习过程中的一些收获和经验,一来可以强迫自己复习所学的东西,二来也可以和其他正在学习Linux内核的同志们分享一下自己的学习经验和在学习过程中的遇到的一些问题。今天是我真正动手学习Linux的第二天,学习编译书上140~146页的Linux-0.00。

首先,忙活了几个小时,总算正确无误的录入了书上的代码,接下来就是编译了。

OK,boot编译连接成功,接下来该head了。

非常不幸,编译出错了,根据提示对源代码作如下修改:

第103行: movl scr_loc, %bx改为movl scr_loc, %ebx

第240行: movl $65, %al 改为movb $65, %al

第249行: movl $66, %al改为movb $66, %al

同时,将head.s中所有的的.align 2改为.align 4,.align 3改为.align 8

然后再次编译连接head.s。

编译虽然通过,但是连接出错,通过查找资料,终于找到了解决方法:

先在程序的.text段中添加一行:.globl startup_32

然后通过使用命令:ld -m elf_i386 -Ttext 0 -e startup_32 -o head head.o进行编译。

OK,到现在为止boot和head都编译成功了。下面就要将二进制代码写到磁盘上面进行测试了,按照907页键入如下命令:

$:sudo dd bs=32 if=boot of=/dev/fd0 skip=1

$:sudo dd bs=512 if=head of=/dev/fd0 skip=2 seek=1

写入成功。

太好了,现在可以在虚拟机上面进行测试了。 但是不幸的是,我在VirtualBox和Bochs 2.3.6上都没有正常引导,运行时一直是黑屏。

问题出在哪里呢?无奈之下只好到赵炯博士的网站上下载了Linux-0.00-050613.zip。先将自己编译连接生成的boot和head文件按上面的方法写入软盘镜像文件vfd(2).img,在将Image写入vfd.img。通过二进制文件比较程序UltraCompare程序比较vfd.img和vfd(2).img发现,从0x00000200开始两个文件的内容就不同了,前面的部分两者一样,如下图所示:

这说明boot在两个img文件中都正确写入了,但是在写入head的时候出了差错。

初步假定是文件写入的时候定位错误,通过在vfd中搜索0x00000200处的数据B8 10验证了这一假设,如下图所示:

图中说明vfd.img中从地址x00000200开始的数据和vfd(2)中的从0x00000e00开始的数据相同。说明确实是错位引起的错误。

好了,问题找到了就好办了,通过hexdump命令查看head的二进制编码,如下:

$: hexdump head>head.txt

head.txt的内容如下:

我们看到从0x0001000开始的数据正是我们需要的,我们只需要将从0x0001000处开始的数据写入软盘(也就img文件)中的x00000200处就可以了:

$:sudo dd bs=32 if=boot of=/dev/fd0 skip=1

$: sudo dd bs=512 if=head of=/dev/fd0 skip=8 seek=1

OK,写入成功。再次进行测试,在VirtualBox中的测试结果如下:

 

 

从记:

上述文章是从别处转载的一篇笔记。总的来说,写的比较详细了。这里再补充一些解释。

1、上文使用的是赵炯老师的linux-0.00-050613开发包,里面的makefile中使用两条dd命令来生成image文件(这个文件包含了引导部分boot和系统主体部分system,其实这个system就是head.o而已)。用高级版本的gcc编译这个系统和用gcc1.4(linux-1.11内置编译器版本,可以直接编译linux-0.00,不需任何修改)的区别是,上述方式生成了elf格式的可执行文件,与标准的a.out可执行文件的头部是有区别的。image文件只需要system文件的.text之后的内容,不需要之前的各种头部信息。故而需要去掉头部信息。用gas和gld生成的a.out文件有1024B的头部信息,之后才是os运行需要的第一条指令,故而需要使用

$:sudo dd bs=512 if=head of=/dev/fd0 skip=2 seek=1

去掉512*2B的无用内容。而elf格式的system文件的text地址在哪里呢?可以在linux下使用readelf工具查看system的信息,得到表:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf A
l
  [ 0]                   NULL            00000000 000000 000000 00      0   0
0
  [ 1] .text             PROGBITS        00000000 001000 00159a 00  AX  0   0
8
  [ 2] .data             PROGBITS        0000259c 00259c 000000 00  WA  0   0
4
  [ 3] .bss              NOBITS          0000259c 00259c 000000 00  WA  0   0
4
  [ 4] .shstrtab         STRTAB          00000000 00259c 00001c 00      0   0
1
可以看到text段从0x001000开始,即从4096开始。

2、如果使用linux-0.00-041217开发包在现在使用的ubuntu或redhat下编译,除了上文所做的修改以外,还需要修改build.c程序,因为这个开发包没用dd命令生成image,而是用了linux一般使用的build程序,从boot、system文件中抽取需要的部分,生成image。那么就需要修改build.c源程序。原来的build.c程序在读取system文件时,先读取1024字节的文件头,然后将剩下的内容写入image。而当system是elf格式时,text的偏移是0x1000,即4096B,那么在写之前就需要读4个1024,方法如下:

 if ((id=open(argv[2],O_RDONLY,0))<0)
  die("Unable to open 'system'");
 if (read(id,buf,GCC_HEADER) != GCC_HEADER)
  die("Unable to read header of 'system1'");
 if (read(id,buf,GCC_HEADER) != GCC_HEADER)
  die("Unable to read header of 'system2'");
 if (read(id,buf,GCC_HEADER) != GCC_HEADER)
  die("Unable to read header of 'system3'");
 if (read(id,buf,GCC_HEADER) != GCC_HEADER)
  die("Unable to read header of 'system4'");

还需要将判断a.out头的语句注释掉:

 //if (((long *) buf)[5] != 0)
 // die("Non-GCC header of 'system'");

之后再保存,make,让bochs从image文件启动,便万事大吉了。

2016-01-04 13:40:33 fyh2003 阅读数 7383

http://blog.csdn.net/songguozhi/article/details/3496455

我是通过阅读赵炯老师编的厚厚的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中执行。

2setup.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

3head.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在写保护中断中调用,取消页表保护,实现写时复制。

(五)文件系统模块fs:

1.总体结构:

Linux把所有设备都做为文件来看待。提供统一的打开,关闭,读写系统调用接口。          下面是文件系统层次关系:

<!--[if !vml]--><!--[endif]-->

图4

总体来说,文件系统提供两类外部接口(系统调用),文件读写和文件管理控制。

上图中Read_write代表的是文件读写系统调用接口read,wirte。它根据操作文件的类型分别调用了四种读写函数:

字符型文件tty_read,tty_write,在kernel/chr_drv驱动模块中定义;
FIFO文件  pipe_read,pipe_write 都是内存操作。Fs/pipe.c中定义
block_dev块设备文件 :block_read,block_wirte,间接调用bread。

File_dev 常规文件。File_read,file_write,   涉及的内容是fs主要的内容。

图中Open stat fcntl 则是文件系统的系统管理控制接口如创建打开关闭,状态访问修改功能。这主要针对常规文件,如根文件系统下的全部文件。这些都需要底层文件系统函数支持,主要包括文件系统超级块,i结点位图和逻辑块位图,i结点,文件名解析等操作。而这些底层文件系统函数则建立于buffer提供的缓冲管理机制之上。这些是对上图的大体归纳吧!

在上面总结kernel的时候,没有提及blk_drv和chr_drv,因为我觉得把它们放在文件系统里面来更合适。

Blk_drv目录是块设备驱动代码。实现了HD(硬盘),FD(软盘),RD(Ramdisk)三种块设备的底层驱动,并提供一个外部调用的接口ll_rw_block(dev,nr)。就是上图中右下虚框示意的层次上。

图5

 

同样的,char_drv实现了字符设备(串行终端)的驱动,包括控制台(键盘屏幕),两个串口。实现供上层调用的读写接口read_tty , write_tty。下面是源码关系图:

<!--[if !vml]--><!--[endif]-->

图6

2下面分别从从底层向高层总结一下各个层次中源码实现的主要细节:

2.1 块设备驱动部分 kernel/blk_drv

块设备工作流程(粗略):

1)文件设备接口调用底层块设备读写函数ll_rw_block(int rw,buffer_head *bh).这里bh要读的设备号,块号,已经写入bh, rw是读或者写指令

2)ll_rw_block(int rw,buffer_head *bh)取主设备号major,调用make_request(major,rw,bh);

3)make_request(major,rw,bh)申请一个请求项,根据rw和bh相应设置填充req各字段值,     并调用add_request (major + blk_dev, req)插入到设备major的请求队列。

4)add_request (major + blk_dev, req)检查设备等待队列是否为空,为空则把req添加到队列中并马上调用设备的请求执行函数。
       对于硬盘,这个函数就是do_hd_request,它将根据请求项的各个字段设置向硬盘发出相应的命令. 如果请求队列不为空,则按照电梯算法把req加到队列中。
       ll_rw_block函数返回。

整个ll_rw_block()返回到上层调用(缓冲管理部分buffer.c)。然后调用进程将执行等待wait_on_buffer(bh);进程切换。

硬盘接受命令后,完成req要求的读/写一个扇区后将发出中断。hd_interrupt(定义于kernel/system_call.s)被执行。调用_do_hd。do_hd是设备当前要调用的中断处理函数的指针。根据当前请求,do_hd_request在调用hd_out向硬盘控制器发命令(如读写或复位等)时根据命令类型指定do_hd为read_intr, write_intr或其它。如果为读,do_hd=read_intr。写则do_hd=write_intr.

read_intr 将判断当前请求项请求读的扇区数是否已经全部读完,如果没有,再次设置do_hd=read_intr,然后返回。如果全部完成,则调用end_request(1)去唤醒等待的进程。然后调用do_hd_request去处理其余请求项。

 

write_intr 将判断当前请求项请求写的扇区数是否已经写完,如果没有,把一扇区数据复制到硬盘缓冲区内,然后再次设置do_hd=write_intr并返回。如果写完,则调用end_request(1),更新并有效缓存块,然后调用do_hd_request去处理其余请求项。

整个硬盘读写流程如下 :


图7

 

对于软盘,大体的流程差不多。只是软盘有启动马达等延时写时操作,比较琐碎一些。

对于ramdisk,速度很快所以不需要中断机制,当然请求队列也最多只有当前一个。像上面的过程一样,make_request会调用add_request,而由于前面的请求队列一定为空,所以会马上执行do_rd_request. 在do_rd_request中直接读、写数据。然后就end_request(1).

2.2 字符设备驱动 kernel/chr_drv

串行/字符设备在linux下叫TTY,每个TTY对就一个tty_struct结构。0.11版本一共三个,一个控制台两个串口。每个tty_struct有三个缓冲区,read_q,  write_q,  secondary 。
read_q保存原始的输入字符队列,write_q保存的是输出的字符队列,secondary里面是输入字符序列通过行规则处理后的字符序列。
tty_struct中的termios保存的是终端IO属性等。这个结构通过tty_ioctl.c中tty_ioctl()来对tty进行相应的控制或设置。
避开非常琐碎的行规则,从char_dev.c中函数rw_tty调用来看整个过程的粗略脉络。 rw_tty(int rw,unsigned minor,char * buf,int count, off_t * pos)。检测进程有无终端,有则根据rw调用tty_read 或者tty_write.
先看tty_read(minor,buf,count),对于要求读取的字节数nr,在定时时间内,循环读取secondary中的字符,直到读到nr个为止。如果secondary空了,  进程等待于secondary的等待队列上。如果超时,则返回。

再看tty_write(minor,buf,count),如是tty写缓冲write_q已经满了,睡眠sleep_if_full (&tty->write_q); 对于要求写字节数nr,循环拷贝到write_q中去。如果拷贝过程中write_q满了或者已经拷贝完调用写函数。没拷贝完则切换进程。剩下的工作交给中断处理程序去完成。

 

对于读操作,当tty收到一个字符,比如串口收到一个字符或者是用户按下键盘,系统将进入相应中断程序。中断程序对收到的字符进行处理,然后把字符放入对应tty的read_q中,调用do_tty_interrupt,do_tty_interrupt直接调用copy_to_cooked(tty).  copy_to_cooked(tty)把read_q的全部字符通过行规则处理。然后放到secondary队列中去。如果tty回显标志置位,则把转换后的字符也放到写队列write_q中,并调用tty->write (tty); 如果中ISIG 标志置位,收到INTR、QUIT、SUSP 或DSUSP 字符时,需要为进程产生相应的信号。最后唤醒等待该辅助缓冲队列的进程(如果有的话)。wake_up (&tty->secondary.proc_list); 中断返回。

对于写操作,如果tty是控制台,其tty写操作为con_write (tty),这个函数直接把write_q中所有字符按照格式写到显存中去或者调整光标。如果是串口,tty写操作为rs_write(tty);这个函数更简单,打开串口发送缓冲空闲允许中断位就返回。这样,CPU会马上收到一个中断,在中断程序中,写操作才会真正进行。串口写缓冲空中断执行时先判断写队列是否为空,如果为空,唤醒可能的等待队列,并且禁止发送缓冲空中断允许并中断返回。如果不空,但是写队列字符数小于256,也唤醒可能的写等待队列,然后从写队列中取一个字符写入串口发送寄存器。中断返回。

2.3文件系统之缓冲管理fs/buffer.c

缓冲管理部分两个作用,利用cache机制提供更高效的使用外部块设备,给使用块设备的其它程序提供简单的接口如bread。使得上层程序对设备操作全部变成对缓冲块的操作。给块设备如软硬盘提供一种cache机制。每个缓冲块buffer_head都对应一个设备dev和逻辑块号block,引用计数count,修改标志dirt,有效标志uptodate。 类比CPU,修改标志与有效标志是cache机制必需的,而dev和block号则相当于地址。缓冲管理负责设备数据块与缓冲映射块数据一致性。缓冲管理具体去调用设备驱动程序ll_rw_block().

Buffer.c主要提供的函数有申请、释放缓存,同步(buffer与设备内容一致),读取。而写操作则总是在缓冲不足的情况下利用同步进行的。读取的时候总是根据给定的dev和block先查找当前缓存中是否存在有效的对应块,如果存在就不再访问设备。否则取一个空闲缓冲,调用设备驱动ll_rw_block。

缓冲块链表实现了hash链表和LRU算法,所有的缓冲块都连接于链表中,链表头总是空闲度最高的缓冲块,链表尾则是最近刚申请的块。查找空闲缓冲从头开始比较空闲度,找最大的。这就实现了LRU算法。 所有具有相同hash值的缓冲块连接于同一个hash_table[nr]项上。nr值由设备号和块号经过一个hash算法得到。这样查找速度会快好多倍。

所有对外设逻辑块的读写都在这里被转化为对缓冲块的读写。每次读写前总是先根据设备号和逻辑块号到hash_table[]表中查找hash链表,若已经存在且有效,则直接对缓冲读写。写后要置修改标志dirt。这样当执行同步操作,或者getblk()找不到干净的空闲块的时候会把所有dirt为1的未被占用(count=0)的缓冲块写入磁盘。

2.4 文件系统之文件系统底层操作。

文件底层操作。(bitmap.c,inode.c,namei.c,super.c,truncate.c,)这部分按照文件系统对硬盘等块设备的使用规则,实现了相应规则的操作函数。文件系统把块设备按逻辑块管理,功能划分:

引导块

超级块

i结点位图块区

逻辑块位图块区

i结点块区

数据块区

    超级块指明了整个文件系统各区段的信息。两个位图区分别指示i结点区和数据块区的占用和空闲状况,i结点区中每个i结点都代表一个文件或者目录。整个文件系统的根目录在第1个i结点处。通过它可以找出任何路径下的文件的i结点。

i结点指示一个文件或者目录,一个i结点的内容主要是文件占用的数据块号。直接块号,一、二次间接块号提供了灵活的机制来线性地查找文件中一个数据块对应在哪一个具体的物理块上。目录文件的数据块内容是目录项,它包含的所在目录的全部文件和子目录的信息。每个目录项保存一个文件或者子目录的inode号和文件名。

相应地,bitmap.c提供了i结点位图,数据块位图的占用和释放操作。super.c实现对超级块的读定安装卸载操作。inode.c实现是获取指定nr的inode(iget(dev,nr)),写回inode ( iput(inode) )等操作。

namei.c则实现了按照文件名来获取inode的操作。从而提供了通过文件名来管理文件(inode)的方法。

这些操作之间的层次并不十分清晰,相互调用很多。注意块设备是按块为最小单位访问的,这些操作不过是按照文件系统对设备块使用的定义对各个块以及块中的数据做解析和操作罢了。文件底层操作都貌似在访问块设备,但是却仅仅调用了缓冲管理提供的接口。它们操作了内存。缓冲管理去实现设备的读写。比如在系统安装根文件系统的时候,超级块已经读如缓冲,根据超级块的信息,将i结点位图块,逻辑位图块读入到内存缓冲中了。

下面对各个源文件的实现进行小结:

bitmap.c: 位图操作,主要提供文件系统中i结点位图,逻辑块位图相关操作,包括申请和释放inode,申请和释放block.首先文件系统的位图块在mount_root中已经缓存到buffer中,缓冲块指针由超级块s_zmap[],s_imap[]指向。所以申请释放操作主要的一部分------对位图相应位置位或者复位就变成对缓冲块置位复位了,然后修改标志dirt=1就行了。
new_block除了要对找到的空闲位置位外,还要申请一块空闲缓冲(清0)并填申请块的dev和block。置有效和修改标志。这么做其实就等于一个写操作,即把申请的设备块清0。(当然,可能申请后马上就要写这一块,所以这么做最高效了。)

truncate.c: 对文件(inode)长度清0。主要调用free_block对位图进行操作。直接块直接释放,对一次间接块上所有有效块号释放,然后再释放一次间接块。二次间接块同理。

inode.c:   主要提供三个函数,iget,iput,bmap.  iget是获取指定设备和i结点号的内存i结点。使用计数加1。主要调用read_inode(调用buffer管理部分)  ;iput是把一个内存i节点写回到设备中去。使用计数减1。主要调用write_inode(调用buffer管理部分)
bmap是把文件块号对应到设备块号(逻辑块号)中去。文件块号是按直接块,一直间接,二次间接顺序计算的索引。逻辑块号则是保存在它们里面的块号。有点像页表,页的线性地址对应文件块号,页的物理地址对应逻辑块号。页表项中的保存的地址就是页物理地址。bmap有创建和不创建两种方式。创建时会根据文件块号给文件(inode)申请逻辑块存放可能需要的一、二次间接块和数据块。

super.c:对文件系统超级块的相关操作。如get_super,put_super,read_super,sys_mount,sys_umount;超级块对应一个文件系统。
get_super(dev)在系统超级块数组中查找并返回匹配的超级块。
put_super(dev)释放超级块数组中超级块信息,并释放超级块i结点,逻辑块位图占用的缓冲区。
read_super(dev)先在超级块数组中查找,有直接返回,没有则先在超级块数组找一空闲项。读dev 1号块,取得超级块信息,如位图占多少块,再读位图块(i位图,逻辑块位图)到缓冲中。设置完毕返回。
sys_mount(devname,dirname,rw_flag) 在目录dirname上安装devname设备的文件系统。取dirname和devname的i结点判断二者都有效?然后读dev超级块read_super(dev),置超级块安装结点为direname的i结点 sb->s_imount=dir_i. 置目录i结点安装标志1。所以i结点的安装标志表明该目录是否安装了一个文件系统。而要知道安装的文件系统的具体信息则要查找超级块数组,看看哪一个超级块的s_imount等于该i结点。。。

namei.c:提供文件路径名到i结点的操作。大部函数参数都直接给出文件路径名,所以它实现了通过文件名来管理文件系统的接口。如打开创建删除文件、目录,创建删除文件硬连接等。
大部分函数的原理都差不多:调用get_dir取得文件最后一层目录的i结点dir。如果是查找就调用find_entry从dir的数据块中查找匹配文件名字符串的目录项。这样通过目录项就取得了文件的i结点。如果是创建(sys_mknod)就申请一个空的inode,在dir的数据块中找一个空闲的目录项,把文件名和inode号填入目录项。创建目录的时候要特殊一些,要为目录申请一个数据块,至少填两个目录项,.和..  (sys_mkdir)。
删除文件和目录的时候把要释放i结点并删除所在目录的数据块中占用的目录项。
打开函数open_namei()基本上实现了open()的绝大部分功能。它先按照上述过程通过文件路径名查找 最后一层目录i结点,在目录数据块中查找目录顶。如果没找到且有创建标志,则创建新文件,申请一个空闲的inode和目录项进行设置。 对于得到的文件inode,根据打开选项进行相应处理。成功则通过调用参数返回inode指针。
这个文件用得最多的功能函数莫过于namei();根据文件名返回i节点。
这里任何对inode的操作都是通过iget,iput这类更底层的函数去实现,iget和iput所在的层次基于buffer管理和内存inode表的操作之上。

 

2.5 文件系统之文件数据访问操作  这提供读写系统调用接口read,write

主要包括文件:
block_dev.c:定义了块设备文件的读写函数,block_write,block_read.
file_dev.c :定义正规文件读写函数。file_read,  file_write
pipe.c   :定义FIFO文件读写及管道系统调用。read_pipe, write_pipe, sys_pipe

char_dev.c :定义字符型设备访问读写,rw_ttyx, rw_tty, rw_char. 最终都调用tty_read,tty_write
read_write.c:实现文件系统读写接口 read,write,lseek。
read,write的参数是文件句柄fd,这需要通过系统调用sys_open来获取。函数根据进程task的filp[fd]指向的系统打开文件表项获取inode、读写权限、当前读写位置等。由inode的类型(上面四种之一)调用相应读写函数(参看图4)。对于正规文件,过程如下:由inode指向的内存inode项获取文件在设备上的位置大小等信息。通过inode和bmap计算要读取的文件数据在设备的逻辑块号。通过bread读数据块,然后对缓冲块进行读写。到此就不用管了。缓冲管理的作用真的是太神奇了。

2.6 文件系统高层操作&管理部分

包括文件open.c,exec.c,stat.c,fcntl.c,ioctl.c 实现文件系统中对文件的管理和操作,如创建,打开,设置/读取文件信息,执行一个文件程序。这个层次位于文件底层操作之上,调用底层函数去实现。

open.c: 定义了系统调用sys_ustat,sys_access,sys_chmod,sys_chdir,sys_chroot,sys_open,sys_close.参数基本上是文件名或者文件句柄。
可以分为三类:修改inode属性类(前3个),修改进程根/当前目录,打开关闭文件。    
第一类通过namei找到i结点,修改相关属性域,iput写回设备。
第二类通过namei找到i结点,把进程task数据相应域设为对应inode.
第三类打开时主要调用open_namei返回一个文件名对应的i结点,并设置系统打开文件表和进程打开文件表。返回文件句柄。关闭则清进程打开文件表项,处理对应的系统打开文件表项(引用减1),写回i结点。

execv.c:主要一个函数do_execve.往往在系统执行完fork之后会调用execve簇函数去执行一个全新的程序。必须重新对进程空间进行初始化。
主要流程:找到执行程序inode,进行相应判断(如如权限等),读文件头(第1个数据块)信息。
如果是脚本文件,则取shell文件名和参数,以shell为执行程序去执行该脚本文件,这时重新以shell为文件名,以本脚本文件为参数,执行上述过程。
根据文件头信息得到文件各段长度,entry位置,修改进程任务结构中相应的数据。然后拷贝参数到进程空间末端,设置堆栈指针。
清空原进程空间占用的页目录和页表。 修改系统调用返回地址为进程空间的起始地址。
该系统调用返回后,新进程执行第一条语句,会引起一个缺页中断。根据要求的线性地址和executable,在中断中执行do_no_page进行共享或者需求加载。

stat.c : 系统调用sys_stat,sys_fstat.取文件状态信息。

fcntl.c: 实现sys_dup,sys_dup2,sys_fcntl.
dup复制到从0开始找最小的空闲句柄。
dup2指定开始搜索的最小句柄。
fcntl主要根据flag参数不同。可以实现四方面的操作:复制文件句柄同dup,设/取close_on_exec标志。设/取文件状态和访问模式。给文件上/解锁。

ioctl.c:主要实现系统调用sys_ioctl,间接调用tty_ioctl.主要对终端设备进行命令控制、参数设置、状态读取等。

 

结束.

看这本书的剖析的linux代码之前觉得LINUX很神秘,现在觉得亲切多了。心里面对内核的动作已经有了比较清晰的概念。但是还远远不足以运用到嵌入式中去,最新的内核与0.11相比,我觉得好像自己还是啥也不知道一样,差得太多,显得很陌生。下一步必须看看2.6版本的内核分析一类的书籍,了解最新的内核。


2017-09-06 21:28:11 asmartkiller 阅读数 1533
读书笔记:LINUX内核完全剖析
  IBM PC及其兼容机主要使用独立编址方式,采用独立的I/O地址空间对控制设备中的寄存器进行寻址和访问,IBM PC也部分地使用统一编址。对于使用EISA、PCI等总线结构的PC,有64KB的I/O地址空间可供使用。在普通Linux系统下通过查看/proc/ioports文件可以得到相关控制器或设置使用的I/O地址(cat /proc/ioports)。
 
基本输入/输出程序BIOS
1981年IBM PC刚推出时系统只带有640KB的RAM主存储器,由于8088/8086CPU只有20根地址线,因此内存寻址范围最高为1024KB(1MB)。现在CPU的物理内存寻址范围已经高达4GB(甚至更高)。为了与原来的PC在软件上兼容,系统1MB以下物理内存使用分配上仍然保持与原来PC基本一致。只是原来系统ROM中的基本输入输出程序BIOS一直处于CPU能寻址的内存最高端位置处,现在BIOS原来所在的位置将在计算机开机初始化时被用作BIOS的影子区域,即BIOS代码仍然会被复制到这个区域(参考BIOS研发技术剖析)。除了地址从0xA0000-0xFFFFF(640KB-1MB)和0xFFFE0000-0xFFFFFFFF(4GB最后64KB)范围以外的所有内存都可以用做系统内存。这两个特定范围被用于I/O设备和BIOS程序。
当计算机系统上电后,CPU会自动把代码段寄存器CS设置为0xF000,其段基地址被设置为0xFFFF0000,段长度设置为64KB(详细解释可见protected mode software architecture 第70页)。而IP被设置为0xFFF0,因此CPU代码指针指向0xFFFFFFF0处(4GB空间最后16B)。BIOS会在这里存放一条跳转指令JMP,跳转到BIOS代码中64KB范围内某一条指令执行。由于目前PC/AT中BIOS容量大多有1MB-2MB,为了执行BIOS中超过64KB的其他BIOS代码或数据。BIOS程序会首先使用32位访问方式把数据段寄存器的访问范围设置成4GB(而非原来的64KB)。这样CPU就可以在0-4GB范围执行和操作数据。此后,BIOS在执行一些硬件检测和初始化操作后,就会把与原来PC兼容64KB BIOS代码和数据复制到内存低端1MB末端的64KB处,然后跳转到这个地方让CPU真正运行在实地址模式下(类似于8086运行方式 20根地址线)。具体BIOS的发展是随着CPU的发展而发展的(参考BIOS研发技术剖析)。
 
 
保护模式和实地址模式:
关于实地址模式和保护模式:http://blog.csdn.net/heiworld/article/details/24371677 注意这里面介绍的这句话:Intel选择了在段寄存器的基础上构筑保护模式,并且保留段寄存器16位。
在保护模式下,它的段范围不再受限于64K,可以达到4G。
 
分段机制:
  80x86处理器使用了一种称为段的寻址技术,从内存中寻址需要使用段地址和段内偏移地址。段地址部分使用16位的段选择符指定,段内偏移地址部分使用32位的值来指定。这两部分构成48位地址称为逻辑地址。80x86从逻辑地址到物理地址变换过程使用了分段分页两种机制。第一阶段使用分段机制把程序的逻辑地址变换为线性地址,第二阶段使用分页机制把线性地址转换为物理地址。
  分段提供了一种机制,用于把处理器可寻址的线性地址空间划分成一些较小的称为段的受保护地址空间区域。段可以用于存放程序代码、数据、堆栈,或者用来存放系统数据结构(如TSS或LDT)。
段选择符是16位标识符,用于提供段描述符表的偏移量,包含3个字段:请求特权级RPL(位0、1)、表指示标志TI(位2)、索引值(位3-15)。表索引字段TI用来指出包含指定段描述符的段描述符表GDT或LDT。对应用程序来说段选择符是作为指针变量的一部分而可见,但选择符的值通常是由链接编辑器或链接加载程序进行设置或修改。由于段选择符中有14位用于作为索引项,所以逻辑地址空间可包含最多16K(2^14)个段。每个段最长可达4GB(偏移地址32位)。段寄存器是用于存放段选择符的寄存器。为了避免每次访问内存时都去引用描述符表,段寄存器除去存放段选择符部分,其余的是描述符缓冲区(影子寄存器)。当一个段选择符被加载到一个段寄存器可见部分时,处理器同时把段选择符指向的段描述符中的段地址、段限长以及访问控制信息加载到段寄存器的影子寄存器区域。
段描述符表是段描述符的数组。有两种描述符表:全局描述符表GDT和局部描述符表LDT。虚拟空间被分割成大小相等的两半,一半是由GDT映射的全局虚拟地址空间,一半是由LDT映射的局部虚拟地址空间。包含LDT表的段必须在GDT表中有一个段描述符项。而GDT本身并不是一个段,而是线性空间中的一个数据结构。访问LDT需要使用其段选择符。为了在访问LDT时减少地址转换次数,LDT的段选择符、基地址、段限长以及访问权限需要存放在LDTR寄存器中。
全局描述符表寄存器GDTR和局部描述符表寄存器LDTR:GDTR寄存器用于存放全局描述符表GDT的32位的线性基地址和16位的表限长值。基地址指定GDT表中字节0在线性地址空间中的地址。GDT的限长以字节位单位,因为段描述符总是8字节长,所以GDT的限长值应该设置为总是8的倍数减1,GDT的基地址应该进行内存8字节对齐。LDTR寄存器用于存放局部描述符表LDT的32位线性基地址、16位限长和描述符属性值以及段选择符。
 
段描述符用于向处理器提供有关一个段的位置和大小信息以及访问控制的装态信息。每个段描述符的长度是8字节,含3个主要字段:段基地址、段限长和段属性。段描述符通常由编译器、链接器、加载器或者操作系统来创建。段类型字段用行指定段或门的类型、说明段的访问种类以及段的扩展方向。
 
把逻辑地址转换成线性地址:
(1)使用段选择符中的偏移值在GDT或LDT表中定位相应的段描述符(仅当一个新的段选择符加载到段寄存器中时才需要这一步)。
(2)利用段描述符检验段的访问权限和范围,以确保该段是可访问的并且偏移量位于段界限内。
(3)把段描述符中取得的段基地址加到偏移量上,最后形成一个线性地址。
 
使用LDTR寄存器中的存放的局部描述符表线性基地址定位局部描述符表LDT,通过段选择符(TI = 1)来指定局部描述符表的序列。
 
分段机制保护:
 
Conforming and Non-Conforming Code Segment
代码段可以是一致性的或者非一致性的。向更高特权级一致性代码段的执行控制转移(只要DPL <= CPL就可以转移),允许程序以当前特权级CPL执行。向一个不同特权级(高于或低于)的非一致性代码段的转移将导致一般保护异常(即DPL = CPL 才能转移),除非使用了一个调用门或任务门。所有数据段都是非一致性的,即意味着它们不能被低特权级的程序或过程访问,但可以被更高特权级的程序或过程访问,而无需使用特殊的访问门。
 
 
请求特权级RPL(Requested Privilege Level)处于段选择符的1、0位
描述符特权级字段DPL(Descriptor privilege level)处于段描述符的14、13位
当前特权级CPL(Current Privilege Level)处于CS和SS段寄存器的1、0位
protected mode software architecture中提法:
the CPL(current privilege level) of the current program
the RPL(requestor privilege level) in the segment register
the DPL(descriptor privilege level) of the target code segment
 
the RPL represent the privilege level of the program that created the 16-bit value (the 16-bit value that is palced in the CS register during execution of a far jump or a far call instruction may have been created either by the program currently executing, or may have ben passed to it by another program as a parameter)。 RPL根据不同段跳转确定,以动态刷新CPL(比如 JMP 00D0:0003 中就指定了RPL为00)。
 

CPL是当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。 (个人认为可以看成是段描述符未加载入CS前,该段的DPL,加载入CS后就存入CS的低两位,所以叫做CPL,其值就等于原段DPL的值)

 

RPL说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择符而言的,每个段选择符有自己的RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样虽然它对该段仍然只有特权为3的访问权限。(个人认为是以CPL来访问段DPL所出示的“证件(RPL)”,如出示的“证件”权级范围在CPL之内且满足DPL的特权检查规则:DPL >= max{CPL,RPL},就能正常通过DPL;反之则不会通过还会发生错误)

DPL存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。当进程访问一个段时,需要进程特权级检查,一般要求DPL >= max {CPL, RPL}

 

普通转跳(没有经过Gate 这东西):即JMP或Call后跟着48位全指针(16位段选择子+32位地址偏移),且其中的段选择子指向代码段描述符,这样的跳转称为直接(普通)跳转。普通跳转不能使特权级发生跃迁,即不会引起CPL的变化,看下面的详细描述:

    目标是一致代码段:
     要求:CPL >= DPL ,RPL不检查。

          转跳后程序的CPL = 转跳前程序的CPL
     
    目标是非一致代码段:
     要求:CPL = DPL AND  
RPL<= DPL

          转跳后程序的CPL = 转跳前程序的CPL

 

 

 

通过调用门的跳转:当段间转移指令JMP和段间转移指令CALL后跟着的目标段选择子指向一个调用门描述符时,该跳转就是利用调用门的跳转。这时如果选择子后跟着32位的地址偏移,也不会被cpu使用,因为调用门描述符已经记录了目标代码的偏移。使用调门进行的跳转比普通跳转多一个步骤,即在访问调用门描述符时要将描述符当作一个数据段来检查访问权限,要求指示调用门的选择子的 RPL≤门描述符DPL,同时当前代码段CPL≤门描述符DPL,就如同访问数据段一样,要求访问数据段的程序的CPL≤待访问的数据段的DPL,同时选择子的RPL≤待访问的数据段或堆栈段的DPL。只有满足了以上条件,CPU才会进一步从调用门描述符中读取目标代码段的选择子和地址偏移,进行下一步的操作。

   从调用门中读取到目标代码的段选择子和地址偏移后,我们当前掌握的信息又回到了先前,和普通跳转站在了同一条起跑线上(普通跳转一开始就得到了目标代码的段选择子和地址偏移),有所不同的是,此时,CPU会将读到的目标代码段选择子中的RPL清0,即忽略了调用门中代码段选择子的RPL的作用。完成这一步后,CPU开始对当前程序的CPL,目标代码段选择子的RPL(事实上它被清0后总能满足要求)以及由目标代码选择子指示的目标代码段描述符中的DPL进行特权级检查,并根据情况进行跳转,具体情况如下:

 

   目标是一致代码段:
     要求:CPL >= DPL ,RPL不检查,因为RPL被清0,所以事实上永远满足RPL <= DPL,这一点与普通跳转一致,适用于JMP和CALL。
          转跳后程序的CPL = 转跳前程序的CPL,因此特权级没有发生跃迁。
                           

    目标是非一致代码段:

  当用JMP指令跳转时:
     要求:CPL = DPL (RPL被清0,不检查),若不满足要求则程序引起异常。
          转跳后程序的CPL = DPL

     因为前提是CPL=DPL,所以转跳后程序的CPL = DPL不会改变CPL的值,特权级也没有发生变化。如果访问时不满足前提CPL=DPL,则引发异常。

    当用CALL指令跳转时:

     要求:CPL >= DPL(RPL被清0,不检查),若不满足要求则程序引起异常。

          转跳后程序的CPL = DPL

    当条件CPL=DPL时,程序跳转后CPL=DPL,特权级不发生跃迁;当CPL>DPL时,程序跳转后CPL=DPL,特权级发生跃迁,这是我们当目前位置唯一见到的使程序当前执行优先级(CPL)发生变化的跳转方法,即用CALL指令+调用门方式跳转,且目标代码段是非一致代码段。

http://blog.csdn.net/feijj2002_/article/details/4597174
http://blog.csdn.net/csujiangyu/article/details/46531931
 
分页机制:
 
分页机制
 
80x86使用4K(2^12)字节固定大小的页面,对齐于4K地址边界处。分页机制把4GB(2^32)的线性地址和物理地址空间划分成2^20个页面,通过管理线性地址和物理地址映射关系实现分页(线性地址的低12位可作为页内偏移量直接作为物理地址的低12位)。
如果包含线性地址的页面当前不在物理内存中,处理器就会产生一个页错误异常,处理器把用于线性地址转换成物理地址时所需的信息及处理器产生错误异常所需的信息存储于页目录和页表中。为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的缓冲器件(转换查找缓冲区TLB)中。
CR3含有存放页目录表页面的物理地址。页目录表页面是页对齐的,所以该寄存器只有高20位是有效的,在往CR3中加载新值时低12位必须设置为0。使用MOV指令加载CR3时,会让TLB无效。80x86处理器并没有维护页转换高速缓存和页表中数据的相关性,但是需要操作系统软件来确保它们一致,因此操作系统必须在改动过页表以后刷新高速缓冲以确保两者一致。通过简单重新加载寄存器CR3,就可对高速缓存器的刷新操作。
 
页表用于记录2^20个页面的物理基地址(20位)和相关属性(12位),每项占4字节。
 
两级页表结构
 
物理地址的转换分成两步进行,每步转换其中10bit
第一级称为页目录(存放于1个4K页面),具有2^10个4B的表项,线性地址的最高10位用于索引第二级(页表),故需要2^10(1K)个页表。
第二级称为页表(存放于1个4K页面),具有2^10个4B的表项,线性地址的中间10位用于获取页面20位物理基地址。
部分二级页表存放于磁盘上,页目录中的目录项有一个存在属性用于指明二级页表是否存在
 
分页机制保护:
读写标志R/W和用户/超级用户标志U/S提供分页机制保护。特权级0,1,2为超级用户级,特权级3为普通用户级
0-1MB内存空间用于内核系统(其实内核指使用0-640KB,剩下的部分被高速缓冲和设备内存占用)

 

 
2017-12-08 17:40:07 RunInProgram 阅读数 969

声明:

参考《linux内核完全剖析基于linux0.11》--赵炯    节选

sys.c 程序

1.功能描述

       sys.c程序主要包含有很多系统调用功能的实现函数。其中,若返回值为 -ENOSYS ,则表示本版的Linux 还没有实现该功能,可以参考目前的代码来了解它们的实现方法。所有系统调用的功能说明请参见头文件 include/linux/sys.h 。

       该程序中含有很多有关进程ID、进程组ID、用户ID、用户组ID、实际用户ID、有效用户ID以及会话ID(session )等的操作函数。下面首先对这些 ID 作一说明。

       一个用户有用户ID(uid)和用户组ID(gid)。这两个ID是passwd文件中对该用户设置的ID,通常被称为实际用户ID(ruid)和实际组ID(rgid)。而在每个文件的i节点信息中都保存着宿主的用户 ID和组 ID ,它们指明了文件拥有者和所属用户组。主要用于访问或执行文件时的权限判别操作。另外,在一个进程的任务数据结构中,为了实现不同功能而保存了 3 种用户 ID 和组 ID 。见下表所示。

表 1 与进程相关的用户 ID 和组 ID



       保存的用户 ID ( suid )和保存的组 ID ( sgid )用于进程访问设置了 set-user-ID 或 set-group-ID 标志的文件。当执行一个程序时,进程的 euid 通常就是实际用户 ID , egid 通常就是实际组 ID 。因此进程只能访问进程的有效用户、有效用户组规定的文件或其它允许访问的文件。但是如果一个文件的 set-user-ID标志置位时,那么进程的有效用户 ID 就会被设置成该文件宿主的用户 ID ,因此进程就可以访问设置了这种标志的受限文件,同时该文件宿主的用户 ID 被保存在 suid 中。同理,文件的 set-group-ID 标志也有类似的作用并作相同的处理。       进程的 uid 和 gid 分别就是进程拥有者的用户 ID 和组 ID ,也即实际用户 ID ( ruid )和实际组 ID ( rgid )。超级用户可以使用 set_uid() 和 set_gid() 对它们进行修改。有效用户 ID 和有效组 ID 用于进程访问文件时的许可权判断。

例如,如果一个程序的宿主是超级用户,但该程序设置了 set-user-ID 标志,那么当该程序被一个进程运行时,则该进程的有效用户 ID ( euid )就会被设置成超级用户的 ID ( 0 )。于是这个进程就拥有了超级用户的权限。一个实际例子就是 Linux 系统的 passwd 命令。该命令是一个设置了 set-user-Id 的程序,

因此允许用户修改自己的口令。因为该程序需要把用户的新口令写入 /etc/passwd 文件中,而该文件只有超级用户才有写权限,因此 passwd 程序就需要使用 set-user-ID 标志。

       另外,进程也有标识自己属性的进程 ID ( pid )、所属进程组的进程组 ID ( pgrp 或 pgid )和所属会话的会话 ID ( session )。这 3 个 ID 用于表明进程与进程之间的关系,与用户 ID 和组 ID 无关。

2.代码注释

linux/kernel/sys.c 程序
/*
* linux/kernel/sys.c
*
* (C) 1991 Linus Torvalds
*/

include <errno.h> // 错误号头文件。包含系统中各种出错号。(Linus 从 minix 中引进的)。

include <linux/sched.h> // 调度程序头文件,定义了任务结构 task_struct、初始任务 0 的数据,
// 还有一些有关描述符参数设置和获取的嵌入式汇编函数宏语句。
#include <linux/tty.h> // tty 头文件,定义了有关 tty_io,串行通信方面的参数、常数。
#include <linux/kernel.h> // 内核头文件。含有一些内核常用函数的原形定义。
#include <asm/segment.h> // 段操作头文件。定义了有关段寄存器操作的嵌入式汇编函数。
#include <sys/times.h> // 定义了进程中运行时间的结构 tms 以及 times()函数原型。
#include <sys/utsname.h> // 系统名称结构头文件。

// 返回日期和时间。
int sys_ftime()
{
	return -ENOSYS;
}

//
int sys_break()
{
	return -ENOSYS;
}

// 用于当前进程对子进程进行调试(degugging)。
int sys_ptrace()
{
	return -ENOSYS;
}

// 改变并打印终端行设置。
int sys_stty()
{
	return -ENOSYS;
}

// 取终端行设置信息。
int sys_gtty()
{
	return -ENOSYS;
}

// 修改文件名。
int sys_rename()
{
	return -ENOSYS;
}

//
int sys_prof()
{
	return -ENOSYS;
}

// 设置当前任务的实际以及/或者有效组 ID(gid)。如果任务没有超级用户特权,
// 那么只能互换其实际组 ID 和有效组 ID。如果任务具有超级用户特权,就能任意设置有效的和实际
// 的组 ID。保留的 gid(saved gid)被设置成与有效 gid 同值。
int sys_setregid(int rgid, int egid)
{
	if (rgid>0) {
		if ((current->gid == rgid) || suser())
			current->gid = rgid;
		else
			return(-EPERM);
	}
	if (egid>0) {
		if ((current->gid == egid) ||
			(current->egid == egid) ||
			(current->sgid == egid) ||
			suser())
			current->egid = egid;
		else
			return(-EPERM);
	}	
	return 0;
}

// 设置进程组号(gid)。如果任务没有超级用户特权,它可以使用 setgid()将其有效 gid
// (effective gid)设置为成其保留 gid(saved gid)或其实际 gid(real gid)。如果任务有
// 超级用户特权,则实际 gid、有效 gid 和保留 gid 都被设置成参数指定的 gid。
int sys_setgid(int gid)
{
	return(sys_setregid(gid, gid));
}

// 打开或关闭进程计帐功能。
int sys_acct()
{
	return -ENOSYS;
}

// 映射任意物理内存到进程的虚拟地址空间。
int sys_phys()
{
	return -ENOSYS;
}

int sys_lock()
{
	return -ENOSYS;
}

int sys_mpx()
{
	return -ENOSYS;
}

int sys_ulimit()
{
	return -ENOSYS;
 }

// 返回从 1970 年 1 月 1 日 00:00:00 GMT 开始计时的时间值(秒)。如果 tloc 不为 null,则时间值
// 也存储在那里。
 int sys_time(long * tloc)
 {
	 int i;

	 i = CURRENT_TIME;
	 if (tloc) {
		 verify_area(tloc,4); // 验证内存容量是否够(这里是 4 字节)。
		 put_fs_long(i,(unsigned long *)tloc); // 也放入用户数据段 tloc 处。
	 }
	 return i;
 }

  /*
  * Unprivileged users may change the real user id to the effective uid
  * or vice versa.
  */
/*
* 无特权的用户可以见实际用户标识符(real uid)改成有效用户标识符(effective uid),反之也然。
*/
// 设置任务的实际以及/或者有效用户 ID(uid)。如果任务没有超级用户特权,那么只能互换其
// 实际用户 ID 和有效用户 ID。如果任务具有超级用户特权,就能任意设置有效的和实际的用户 ID。
// 保留的 uid(saved uid)被设置成与有效 uid 同值。
 int sys_setreuid(int ruid, int euid)
 {
	 int old_ruid = current->uid;

	 if (ruid>0) {
		 if ((current->euid==ruid) ||
			(old_ruid == ruid) ||
			suser())
			current->uid = ruid;
		 else
			return(-EPERM);
	 }
	 if (euid>0) {
		 if ((old_ruid == euid) ||
		 (current->euid == euid) ||
		 suser())
			current->euid = euid;
		 else {
			current->uid = old_ruid;
			return(-EPERM);
		 }
	 }
	 return 0;
 }

// 设置任务用户号(uid)。如果任务没有超级用户特权,它可以使用 setuid()将其有效 uid
// (effective uid)设置成其保留 uid(saved uid)或其实际 uid(real uid)。如果任务有
// 超级用户特权,则实际 uid、有效 uid 和保留 uid 都被设置成参数指定的 uid。
 int sys_setuid(int uid)
 {
	return(sys_setreuid(uid, uid));
 }

// 设置系统时间和日期。参数 tptr 是从 1970 年 1 月 1 日 00:00:00 GMT 开始计时的时间值(秒)。
// 调用进程必须具有超级用户权限。
 int sys_stime(long * tptr)
 {
	 if (!suser()) // 如果不是超级用户则出错返回(许可)。
		return -EPERM;
	 startup_time = get_fs_long((unsigned long *)tptr) - jiffies/HZ;
	 return 0;
 }

// 获取当前任务时间。tms 结构中包括用户时间、系统时间、子进程用户时间、子进程系统时间。
 int sys_times(struct tms * tbuf)
 {
	 if (tbuf) {
		 verify_area(tbuf,sizeof *tbuf);
		 put_fs_long(current->utime,(unsigned long *)&tbuf->tms_utime);
		 put_fs_long(current->stime,(unsigned long *)&tbuf->tms_stime);
		 put_fs_long(current->cutime,(unsigned long *)&tbuf->tms_cutime);
		 put_fs_long(current->cstime,(unsigned long *)&tbuf->tms_cstime);
	 }
	 return jiffies;
 }

// 当参数 end_data_seg 数值合理,并且系统确实有足够的内存,而且进程没有超越其最大数据段大小
// 时,该函数设置数据段末尾为 end_data_seg 指定的值。该值必须大于代码结尾并且要小于堆栈
// 结尾 16KB。返回值是数据段的新结尾值(如果返回值与要求值不同,则表明有错发生)。
// 该函数并不被用户直接调用,而由 libc 库函数进行包装,并且返回值也不一样。
 int sys_brk(unsigned long end_data_seg)
 {
	 if (end_data_seg >= current->end_code && // 如果参数>代码结尾,并且
	 end_data_seg < current->start_stack - 16384) // 小于堆栈-16KB,
		current->brk = end_data_seg; // 则设置新数据段结尾值。
	 return current->brk; // 返回进程当前的数据段结尾值。
 }

  /*
  * This needs some heave checking ...
  * I just haven't get the stomach for it. I also don't fully
  * understand sessions/pgrp etc. Let somebody who does explain it.
  */
/*
* 下面代码需要某些严格的检查…
* 我只是没有胃口来做这些。我也不完全明白 sessions/pgrp 等。还是让了解它们的人来做吧。
*/
// 设置进程的进程组 ID 为 pgid。
// 如果参数 pid=0,则使用当前进程号。如果 pgid 为 0,则使用参数 pid 指定的进程的组 ID 作为
// pgid。如果该函数用于将进程从一个进程组移到另一个进程组,则这两个进程组必须属于同一个
// 会话(session)。在这种情况下,参数 pgid 指定了要加入的现有进程组 ID,此时该组的会话 ID
// 必须与将要加入进程的相同(193 行)。
 int sys_setpgid(int pid, int pgid)
 {
	 int i;

	 if (!pid) // 如果参数 pid=0,则使用当前进程号。
		pid = current->pid;
	 if (!pgid) // 如果 pgid 为 0,则使用当前进程 pid 作为 pgid。
		pgid = current->pid; // [??这里与 POSIX 的描述有出入]
	 for (i=0 ; i<NR_TASKS ; i++) // 扫描任务数组,查找指定进程号的任务。
		if (task[i] && task[i]->pid==pid) {
			if (task[i]->leader) // 如果该任务已经是首领,则出错返回。
				return -EPERM;
			if (task[i]->session != current->session) // 如果该任务的会话 ID
				return -EPERM; // 与当前进程的不同,则出错返回。
			task[i]->pgrp = pgid; // 设置该任务的 pgrp。
			return 0;
	 }
	 return -ESRCH;
 }

// 返回当前进程的组号。与 getpgid(0)等同。
 int sys_getpgrp(void)
 {
	return current->pgrp;
 }

// 创建一个会话(session)(即设置其 leader=1),并且设置其会话号=其组号=其进程号。
// setsid -- SET Session ID。
 int sys_setsid(void)
 {
	 if (current->leader && !suser()) // 如果当前进程已是会话首领并且不是超级用户
		return -EPERM; // 则出错返回。
	 current->leader = 1; // 设置当前进程为新会话首领。
	 current->session = current->pgrp = current->pid; // 设置本进程 session = pid。
	 current->tty = -1; // 表示当前进程没有控制终端。
	 return current->pgrp; // 返回会话 ID。
 }

// 获取系统信息。其中 utsname 结构包含 5 个字段,分别是:本版本操作系统的名称、网络节点名称、
// 当前发行级别、版本级别和硬件类型名称。
 int sys_uname(struct utsname * name)
 {
	 static struct utsname thisname = { // 这里给出了结构中的信息,这种编码肯定会改变。
	 "linux .0" , "nodename" , "release " , "version " , "machine "
	 };
	 int i;

	 if (!name) return -ERROR; // 如果存放信息的缓冲区指针为空则出错返回。
		verify_area(name,sizeof *name); // 验证缓冲区大小是否超限(超出已分配的内存等)。
	 for(i=0;i<sizeof *name;i++) // 将 utsname 中的信息逐字节复制到用户缓冲区中。
		put_fs_byte(((char *) &thisname)[i],i+(char *) name);
	 return 0;
 }

// 设置当前进程创建文件属性屏蔽码为 mask & 0777。并返回原屏蔽码。
 int sys_umask(int mask)
 {
	 int old = current->umask;

	 current->umask = mask & 0777;
	 return (old);
 }


2009-03-11 18:21:00 weide001 阅读数 1928
书推荐,正在学习中!感谢 赵炯 老师!
那些动辄就要金币银币的人,我真鄙视你们!估计赵老师也鄙视你们!
=========================================================================
Linux内核完全剖析—基于0.12内核    赵炯
本书对早期Linux内核(v0.12)全部代码文件进行了详细、全面的注释和说明,书中首先以Linux源代码版本的变迁为主线,介绍了Linux的历史,同时着重说明了各个内核版本的主要区别和改进,给出了选择0.12版内核源代码作为研究对象的原因。
在正式描述内核源代码之前,概要介绍了运行Linux的PC的硬件组成结构、编制内核使用的汇编语言和C语言扩展部分,并且重点说明了80x86处理器在保护模式下运行的编程方法。接着详细介绍了Linux内核源代码目录树组织结构,并依据该结构对所有内核程序和文件进行了注释和详细说明。
有关代码注释的章节安排基本上都分为具体研究对象的概述、每个文件的功能介绍、代码内注释、代码中难点及相关资料介绍等部分。试验中所使用的相关程序均可从本书配套网站(www.oldlinux.org)上下载。
=========================================================================
出版社:机械工业出版社 出版日期:2009年1月 ISBN:978-7-111-25047-0 定 价:99.00元
关键字: Linux  内核  Linux内核完全剖析—基于0.12内核  
免责声明:图书版权归出版社和作者所有,51CTO.com 仅提供试读
=========================================================================
本书详细信息

================================================================

完毕!