| 订阅 | 在线投稿
分享
 
 
 

Linux内核源代码漫游

来源:互联网网民  宽屏版  评论
2006-11-24 00:03:57

本章试图以顺序的方式来解释Linux源代码,以帮助读者对源代码的体系结构以及很多相关的unix特性的实现有一个很好的理解。目标是帮助对Linux不甚了解的有经验的C程序员对整个Linux的设计有所了解。这也就是为什么内核漫游的入点选择为内核本身的启始点:系统引导(启动)。

这份材料需要对C语言以及对Unix的概念和PC机的结构有很好的了解,然而本章中并没有出现任何的C代码,而是直接参考(指向)实际的代码的。有关内核设计的最佳篇幅是在本手册的其它章节中,而本章仍趋向于是一个非正式的概述。

本章中所参阅的任何文件的路径名都是指主源代码目录树,通常是/usr/src/linux。

这里所给出的大多数信息都是取之于Linux发行版1.0的源代码。虽然如此,有时也会提供对后期版本的参考。这篇漫游中开头有 图标的任何小节都是强调1.0版本后对内核的新的改动。如果没有这样的小节存在,则表示直到版本1.0.9-1.1.76,没有作过改动。

有时候本章中会有象这样的小节,这是指向正确的代码以对刚讨论过的主题取得更多信息的指示符。当然,这里是指源代码。

引导(启动)系统 当PC的电源打开后,80x86结构的CPU将自动进入实模式,并从地址0xFFFF0开始自动执行程序代码,这个地址通常是ROM-BIOS中的地址。PC机的BIOS将执行某些系统的检测,在物理地址0处开始初始化中断向量。此后,它将可启动设备的第一个扇区读入内存地址0x7C00处,并跳转到这个地方。启动设备通常是软驱或是硬盘。这里的叙述是非常简单的,但这已经足够理解内核初始化的工作过程了。

Linux的最最前面部分是用8086汇编语言编写的(boot/bootsect.S),它将由BIOS读入到内存0x7C00处,当它被执行时就会把自己移到绝对地址0x90000处,并将启动设备(boot/setup.S)的下2kB字节的代码读入内存0x90200处,而内核的其它部分则被读入到地址0x10000处。在系统加载期间将显示信息"Loading..."。然后控制权将传递给boot/Setup.S中的代码,这是另一个实模式汇编语言程序。

启动部分识别主机的某些特性以及vga卡的类型。如果需要,它会要求用户为控制台选择显示模式。然后将整个系统从地址0x10000移至0x1000处,进入保护模式并跳转至系统的余下部分(在0x1000处)。

下一步是内核的解压缩。0x1000处的代码来自于zBoot/head.S,它初始化寄存器并调用decompress_kernel(),它们依次是由zBoot/inflate.c、zBoot/unzip.c和zBoot/misc.c组成。被解压的数据存放到了地址0x10000处(1兆),这也是为什么Linux不能运行于少于2兆内存的主要原因。[在1兆内存中解压内核的工作已经完成,见 Memory Savers--ED]

将内核封装在一个gzip文件中的工作是由zBoot目录中的Makefile以及工具完成的。它们是值得一看的有趣的文件。

内核发行版1.1.75将boot和zBoot目录下移到了arch/i386/boot中了,这个改动意味着对不同的体系结构允许真正的内核建造,不过我将仍然只讲解有关i386的信息。

解压过的代码是从地址0x10100处开始执行的[这里我可能忘记了具体的物理地址了,因为我对相应的代码不是很熟],在那里,所有32比特的设置启动被完成: IDT、GDT以及LDT被加载,处理器和协处理器也已确认,分页工作也设置好了;最终调用start_kernel子程序。上述操作的源代码是在boot/head.S中的,这可能是整个内核中最有诀窍的代码了。

注意如果在前述任何一步中出了错,计算机就会死锁。在操作系统还没有完全运转之前是处理不了出错的。

start_kernel()是位于init/main.c中的,并且没有任何返回结果。从现在起的任何代码都是用C语言编制的,除了中断管理和系统调用的入/出代码(当然,还有大多数的宏都嵌入了汇编代码)。

让轮子转动起来 在处理了所有错综复杂的问题之后,start_kernel()初始化了内核的所有部分,尤其是:

设置内存边界和调用paging_init(); 初始化中断、IRQ通道和调度; 分析(解析)命令行; 如果需要,就分配一个数据缓冲区(profiling buffer)以及其它一些小部分; 校正延迟循环(计算“BogoMips”数); 检查中断16是否能与协处理器工作。 最后,为了生成初始进程,内核准备好了移至move_to_user_mode(),它的代码也是在同一个源代码文件中的。然后,所谓的空闲任务,进程号0就进入无限的空闲循环中运行。

接着初始进程(init process)尝试着运行/etc/init、/bin/init或者/sbin/init。

如果它们没有一个运行成功的,就会去执行代码“/bin/sh /etc/rc”并且在第一个终端上生成一个根命令解释程序(root shell)。这段代码回溯至Linux 0.01,当时操作系统只有一个内核,并且没有登录进程。

在从一个标准的地方(让我们假定我们有)用exec()执行了init初始化程序之后,内核就对程序的执行没有了直接的控制。从现在起它的规则是提供对系统调用的处理,以及为异步事件服务(比如硬件中断等)。多任务的环境已经建立,从现在起是init程序通过fork()派生出的系统进程和登录进程来管理多用户的访问了。

由于内核是负责提供服务的,这个漫游文章将通过观察这些服务(“系统调用”)以及通过提供基本数据结构的原理和代码的组织结构继续讨论下去。

内核是如何看见一个进程的 从内核的观点来看,一个进程只是进程表中的一个条目而已。

而进程表以及各个内存管理表和缓冲存储器则是系统中最为重要的数据结构。进程表中的各个单项是task_struct结构,是定义在include/linux/sched.h中的非常大的数据结构。在task_struct中保留着从低层到高层的信息,范围从某些硬件寄存器的拷贝到进程工作目录的inode信息。

进程表既是一个数组和双链表,也是一个树结构。它的物理实现是一个静态的指针数组,它的长度是定义在include/linux/tasks.h中的常量NR_TASKS,并且每个结构都位于一个保留内存页中。这个列表结构是通过指针next_task和pre_task构成的,而树结构则是非常复杂的并且我们在此将不加以讨论。你可能希望改动NR_TASKS的默认值128,但你要保证所有源文件中相关的适当文件都要被重新编译过。

在启动引导过程结束后,内核将总是代表某个进程而工作,并且全局变量current --- 一个指向某个task_struct条目的指针 --- 被用于记录正在运行的进程。current仅能通过在kernel/sched.c中的调度程序来改变。然而,由于所有的进程都必须访问它,所以使用了宏for_each_task。当系统负荷很轻时,它要比数组的顺序扫描快得多。

进程总是运行于“用户模式”或“内核模式”。用户程序的主体是运行于用户模式而其中的系统调用则运行于内核模式中。在这两种执行模式中进程所用的堆栈是不一样的 -- 常规的堆栈段用于用户模式,而一个固定大小的堆栈(一页,由该进程所有)则用于内核模式。内核堆栈页是从不交换出去的,因为每当一个系统调用进入时它就必须存在着。

内核中的系统调用(system calls)是作为C语言函数存在的,它们的‘正规'名称是以‘sys_'开头的。例如一个名为burnout的系统调用将调用内核函数sys_burnout()。

系统调用机制在本手册的第三章中进行了讨论。观看在include/linux/sched.h中的for_each_task和SET_LINKS能够帮助理解进程表中的列表和树结构。

创建和结束进程 unix系统是通过fork()系统调用创建一个进程的,而进程的终止是通过exit()或收到一个信号来完成的。它们的Linux实现位于kernel/fork.c和kernel/exit.c中。 派生出一个进程是很容易的,所以fork.c程序很短并易于理解。它的主要任务是为新的进程填写数据结构。除了填写各个字段以外,相关的步骤有:

取得一个空闲内存页面来保存task_struct 找到一个空闲的进程槽(find_empty_process()) 为内存堆栈页kernel_stack_page取得另一个空闲的内存页面 将父辈的LDT拷贝到子进程 复制父进程的mmap信息 sys_fork() 同样也管理文件描述符和inode。 1.0的内核也对线程提供某些不够完善的支持,所以fork()系统调用对此也给出了某些示意。内核的线程是主流内核以外的过程产品。

从一个进程中退出是比较有窍门的,因为父进程必须被通告有关任何子进程的退出。而且,一个进程可以由另外一个进程使用kill()而退出(这些是Unix的特性),所以除了sys_exit()之外,sys_kill()以及sys_wait()的各种特性也存在于exit.c之中了。

这里不对exit.c的代码加以讨论---因为它一点也不令人感兴趣。为了以一致的状态退出系统,它涉及到许多细节。而POSIX标准对于信号则是要求相当严格的,所以这里必须对其加以叙述。

执行程序 在调用了fork()之后,就有同一个程序的两个拷贝在运行了,通常一个程序使用exec()执行另一个程序。exec()系统调用必须定位该执行文件的二进制映像,加载并执行它。词语‘加载'并不一定意味着“将二进制映像拷贝进内存”,因为Linux支持按需加载。 exec()的Linux实现支持不同的二进制格式。这是通过linux_binfmt结构来达到的,其中内嵌了两个指向函数的指针--一个是用于加载可执行文件的,另一个用于加载库函数,每种二进制格式都实现有这两个函数。共享库的加载是在exec()同一个源程序中实现的,但我们只讨论exec()本身。 Unix系统提供了六种exec()函数。除了一个以外,所有都是以库函数的形式实现的,并且,Linux内核是单独实现sys_execve()调用的。它执行一个非常简单的任务:加载可执行文件的头部,并试着去执行它。如果头两个字节是“#!”,那么就会解析该可执行文件的第一行并调用一个解释器来执行它,否则的话,就会顺序地试用各个注册过的二进制格式。 Linux本身的格式是由fs/exec.c直接支持的,并且相关的函数是load_aout_binary和load_aout_library。对于二进制,函数将加载一个“a.out”可执行文件并以使用mmap()加载磁盘文件或调用read_exec()而结束。前一种方法使用了Linux的按需加载机理,在程序被访问时使用出错加载方式(fault-in)加载程序页面,而后一种方式是在主机文件系统不支持内存映像时(例如“msdos”文件系统)使用的。

新近的1.1内核内嵌了一个修订的msdos文件系统,它支持mmap()。而且linux_binfmt结构已是一个链表而不是一个数组了,以允许以一个内核模块的方式加载一个新的二进制格式。最后,结构的本身也已经被扩展成能够访问与格式相关的核心转储程序了。

访问文件系统 众所周知,文件系统是Unix系统中最为基本的资源了,它如此的基本和普遍存在以至于它需要一个更为便利的名字--我将忠于标准的称呼简单地称之为“fs”。

我将假设读者早已知道基本的Unix文件系统的原理--访问(权限)许可、i节点(inode)、超级块、加载(mount)和卸载(umount)文件系统。这些概念在标准的Unix文献中由比我聪明的作者给出了很好的解释,所以我就不重复他们的工作并且我将只专注于有关Linux方面的问题。

早期的Unix通常只支持一个文件系统(fs)类型,它的代码散布于整个内核中,现今的实现是在内核和fs之间使用一个标准的接口,以便于在不同的体系结构中进行数据的交换。Linux本身提供了一个标准层以在内核和每种fs模块之间传递数据。这个接口层称为VFS,即“虚拟文件系统”("virtual filesystem")。

因而文件系统的代码被分割成了两层:上层是关于内核表格的管理和数据结构的,而低层是由与各文件系统相关的函数集构成的,并且是由VFS数据结构进行调用的。

所有与文件系统独立的资料都位于fs/*.c文件中。它们涉及如下的问题:

管理缓冲寄存器(buffer.c); 对fcntl()和ioctl()系统调用作出响应(fcntl.c和ioctl.c); 在inode和缓冲区上映射管道和fifo(fifo.c,pipe.c); 管理文件 - 和inode - 表(file_table.c,inode.c); 锁定和解锁文件和记录(lock.c); 将名称映射到inode(namei.c,open.c); 实现错综复杂的select()函数(select.c); 提供信息(stat.c); 加载和卸载文件系统(super.c); 使用exec()执行可执行程序以及转储核心程序(exec.c); 加载各种二进制格式(bin_fmt*.c,如上面所述)。 而VFS接口则由一组相对比较高层次的操作组成,并从与文件系统独立的代码中调用而实际上是由每种文件系统类型执行的。最为相关的数据结构是inode_operations和file_operations,尽管它们不是独自存在的:同样存在着其它一些数据结构。它们都定义在include/linux/fs.h文件中。

到实际文件系统的内核入口点是数据结构file_system_type。file_system_types的一个数组包含在fs/filesystems.c中,并且每当发出了一个加载(mount)命令时都会引用它。然后,相应fs类型的函数read_super就负责填写结构super_block的一个项,而该项又内嵌了结构super_struct和结构type_sb_info。前者为当前的fs类型提供了指向一般fs操作的指针,而后者对相应fs类型内嵌了特定的信息。

文件系统类型数组已经转换成了一个链表,以允许用内核模块的形式加载新的fs类型。函数(un-)register_filesystem代码包含在fs/super.c中。

一个文件系统类型的快速剖析 一个文件系统类型的任务是执行用于映射相应高层VFS操作到物理介质(磁盘、网络等等)的低层任务。VFS接口有足够的灵活性来支持传统的Unix文件系统和外来的象msdos和umsdos文件系统类型。

每一个fs类型除了它自己的源代码目录以外,是由下列各项组成的:

file_systems[]数组中的一个条目(项) (fs/filesystems.c); 超级块(superblock)的include文件(include/linux/type_fs_sb.h); i节点(inode)的include文件(include/linux/type_fs_i.h); 普通自己专用的include文件(include/linux/type_fs.h); include/linux.fs.h中的两行#include,以及在结构super_block和inode中的条目。 对于特定fs类型自己的目录,包含有所有的实际代码、inode和数据的管理程序。

本手册中有关procfs的章节,揭示了所有有关那种fs类型的低层代码和VFS接口。在阅读过那个章节之后,fs/procfs中的源代码就显得非常容易理解了。

现在我们来观察VFS机制的内部工作情况,并以minix文件系统的代码作为一个实际例子。我选择minix类型是因为它比较短小但却是完整的;而且,Linux中的所有其它的fs类型都衍生于它。在最近Linux安装中的事实上的标准文件系统类型ext2,要比它复杂得多,对ext2这个文件系统的探索就留给聪明的读者作为一个练习了。

当一个minix-fs被加载后,minix_read_super就会把从被加载的设备中读取的数据添入super_block数据结构中。此时,该结构中的s_op域将保留有一个指向minix_sops的指针,该指针将被一般文件系统代码用于分派超级块的操作。

在全局系统树结构中链接新加载的fs依赖于下列各数据项(假设sb是超级块数据结构,而dir_i是指向加载点的inode的指针):

sb->s_mounted指向被加载文件系统的根目录i节点(MINIX_ROOT_INO); dir_i->i_mount保存有sb->s_mounted; sb->s_covered保存有dir_i 卸载操作将最终通过do_umount来执行,而它会依次调用minix_put_super。

每当访问一个文件时,minix_read_inode就会开始执行;它会使用minix_inode各字段中的数据填写系统范围的inode数据结构。inode->i_op字段是依照inode->i_mode来填写的,它将负责该文件的任何其它操作。上述minix函数的代码可以从fs/minix/inode.c中找到。

inode_operations数据结构是用于把inode操作分派给特定fs类型的内核函数;该数据结构的第一项是一个指向file_operations项的指针,它等同于数据管理的i_op。minix文件系统类型允许有inode操作集中的三种方式(用于目录、文件和符号链接)和文件操作集中的两种(符号链接不需要文件操作)。

目录操作(仅minix_readdir)位于fs/minix/dir.c中;文件操作(读read和写write)位于fs/minix/file.c中而符号操作(读取并跟随着链)位于fs/minix/symlink.c。

minix源代码目录中的其余部分用于实现以下任务:

bitmap.c用于管理i节点与块的分配和释放(而ext2文件系统却有两个不同的代码文件); fsynk.c用于fsync()系统调用--它管理直接、间接和双重间接块(我假定你是知道这些术语的,因为这是Unix的普通知识); namei.c内嵌有所有与名字有关的i节点的操作,比如象节点的创建和消除、重命名和链接; truncate.c执行文件的截断操作。 控制台驱动程序(console driver) 作为大多数Linux系统上的主要I/O设备,控制台驱动程序是应该受到某些关注的。有关控制台和其它字符驱动程序的源代码可以在drivers/char中找到,当我们指称文件时,我们将使用这个特定的目录。

控制台的初始化是由tty_io.c中的tty_init()函数来执行的。这个函数仅仅涉及取得每个设备集的主设备号并调用每个设备集的init函数。而con_init()则是与控制台相关的函数,并存在于console.c中。

在内核1.1的开发中,控制台的初始化已经有了很大的变化。console_init()已经从tty_init()中脱离出来了,并且是由../../main.c直接调用的。现在虚拟控制台是动态分配的,其代码也已有了很大的变化。所以我将跳过初始化、分配等等的详细讨论。

文件操作是如何分派给控制台的 这一节是相当底层的讨论,你可以放心地跳过本节。

毫无疑问,Unix设备是通过文件系统来访问的。本节将详细描述从设备文件到实际控制台函数的所有步骤,而且,以下的信息是从内核的1.1.73源代码中抽取来的,它与1.0的代码可能少许有点不同。

当打开一个设备i节点时,在../../fs/devices.c中的chrdev_open()函数(或者是blkdev_open(),但我只专注于字符设备)将被执行。这个函数是通过数据结构def_chr_fops取得的,而它又是被chrdev_inode_operations引用的,是被所有文件系统类型使用的(见前面有关文件系统的部分)。

chrdev_open通过在当前操作中替换具体设备的file_operations表并且调用特定的open()函数来管理指定的设备操作的。具体设备的表结构是保存在数组chrdevs[]中的,并由主设备号作为索引,位于同一个../../devices.c中。

如果该设备是一个tty类型的(我们不是只关注控制台吗?),我们就来讨论tty的设备驱动程序,它们的函数在tty_io.c之中,由tty_fops作为索引。这样,tty_open()就会调用init_dev(),而init_dev()就会根据次设备号为设备分配任何所需的数据结构。

次设备号也用于检索已经使用tty_register_driver()注册登记过的设备的实际驱动程序。而且,该驱动程序仍是另一个用于分派计算的数据结构,正如file_ops一样;它是与设备的写操作和控制有关的。最后一个用于管理tty的数据结构是线路规程,这将在后面叙述。控制台(以及任何其它的tty设备)的线路规程是由initialize_tty_struct()设置的,并由init_dev调用的。

在这一节中我们所涉及的所有事情都是与设备无关的,仅有与特定控制台相关的是console.c,在con_init()操作期间已经注册了自己的驱动程序。相反,线路规程是与设备无关的。

The tty_driver 数据结构在linux/tty_driver.h中有着完整的描述。

上述信息是从1.1.73源代码中取得的。它是有可能与你的内核有所不同的(“如信息有所变动将不另行通知”)。

控制台写操作 当往一个控制台设备进行写操作时,就会调用con_write函数。这个函数管理所有控制字符和换码字符序列,这些字符给应用程序提供全部的屏幕管理操作。所实现的换码序列是vt102终端的;这意味着当你使用telnet连接到一台非Linux主机时,你的环境变量应该有TERM=vt102;然而,对于本地操作最佳的选择是设置TERM=console,因为Linux控制台提供了一个vt102功能的超集。

因而,con_write()主要是由转换语句组成的,用于处理每一次一个字符的有限长状态自动换码序列的解释。在正常方式下,所打印的字符是使用当前属性直接写到显示内存中的。在console.c中,数据结构vc的所有域使用宏都是可访问的,所以(例如)任何对attr的引用,只要currcons是所指的控制台的号码,确实是引证了数据结构vc_cons[currcons]中的域。

实际上,新内核中的vc_cons已不再是一个数据结构数组了,现在它是指针的数组,其内容是用kmalloc()操作的。宏的使用大大地简化了代码修改的工作,因为许多代码都不需要被重写。

控制台内存到屏幕内存的实际映射和非映射是由函数set_scrmem()(它把控制台缓冲区中的数据拷贝到显示内存中)和get_srcmem()(它把数据拷贝回控制台缓冲区中)执行的。为了减少数据传输的次数

[1] [2] [3] 下一页

 
免责声明:本文为网络用户发布,其观点仅代表作者个人观点,与本站无关,本站仅提供信息存储服务。文中陈述内容未经本站证实,其真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
 
本章试图以顺序的方式来解释Linux源代码,以帮助读者对源代码的体系结构以及很多相关的unix特性的实现有一个很好的理解。目标是帮助对Linux不甚了解的有经验的C程序员对整个Linux的设计有所了解。这也就是为什么内核漫游的入点选择为内核本身的启始点:系统引导(启动)。 这份材料需要对C语言以及对Unix的概念和PC机的结构有很好的了解,然而本章中并没有出现任何的C代码,而是直接参考(指向)实际的代码的。有关内核设计的最佳篇幅是在本手册的其它章节中,而本章仍趋向于是一个非正式的概述。 本章中所参阅的任何文件的路径名都是指主源代码目录树,通常是/usr/src/linux。 这里所给出的大多数信息都是取之于Linux发行版1.0的源代码。虽然如此,有时也会提供对后期版本的参考。这篇漫游中开头有 图标的任何小节都是强调1.0版本后对内核的新的改动。如果没有这样的小节存在,则表示直到版本1.0.9-1.1.76,没有作过改动。 有时候本章中会有象这样的小节,这是指向正确的代码以对刚讨论过的主题取得更多信息的指示符。当然,这里是指源代码。 引导(启动)系统 当PC的电源打开后,80x86结构的CPU将自动进入实模式,并从地址0xFFFF0开始自动执行程序代码,这个地址通常是ROM-BIOS中的地址。PC机的BIOS将执行某些系统的检测,在物理地址0处开始初始化中断向量。此后,它将可启动设备的第一个扇区读入内存地址0x7C00处,并跳转到这个地方。启动设备通常是软驱或是硬盘。这里的叙述是非常简单的,但这已经足够理解内核初始化的工作过程了。 Linux的最最前面部分是用8086汇编语言编写的(boot/bootsect.S),它将由BIOS读入到内存0x7C00处,当它被执行时就会把自己移到绝对地址0x90000处,并将启动设备(boot/setup.S)的下2kB字节的代码读入内存0x90200处,而内核的其它部分则被读入到地址0x10000处。在系统加载期间将显示信息"Loading..."。然后控制权将传递给boot/Setup.S中的代码,这是另一个实模式汇编语言程序。 启动部分识别主机的某些特性以及vga卡的类型。如果需要,它会要求用户为控制台选择显示模式。然后将整个系统从地址0x10000移至0x1000处,进入保护模式并跳转至系统的余下部分(在0x1000处)。 下一步是内核的解压缩。0x1000处的代码来自于zBoot/head.S,它初始化寄存器并调用decompress_kernel(),它们依次是由zBoot/inflate.c、zBoot/unzip.c和zBoot/misc.c组成。被解压的数据存放到了地址0x10000处(1兆),这也是为什么Linux不能运行于少于2兆内存的主要原因。[在1兆内存中解压内核的工作已经完成,见 Memory Savers--ED] 将内核封装在一个gzip文件中的工作是由zBoot目录中的Makefile以及工具完成的。它们是值得一看的有趣的文件。 内核发行版1.1.75将boot和zBoot目录下移到了arch/i386/boot中了,这个改动意味着对不同的体系结构允许真正的内核建造,不过我将仍然只讲解有关i386的信息。 解压过的代码是从地址0x10100处开始执行的[这里我可能忘记了具体的物理地址了,因为我对相应的代码不是很熟],在那里,所有32比特的设置启动被完成: IDT、GDT以及LDT被加载,处理器和协处理器也已确认,分页工作也设置好了;最终调用start_kernel子程序。上述操作的源代码是在boot/head.S中的,这可能是整个内核中最有诀窍的代码了。 注意如果在前述任何一步中出了错,计算机就会死锁。在操作系统还没有完全运转之前是处理不了出错的。 start_kernel()是位于init/main.c中的,并且没有任何返回结果。从现在起的任何代码都是用C语言编制的,除了中断管理和系统调用的入/出代码(当然,还有大多数的宏都嵌入了汇编代码)。 让轮子转动起来 在处理了所有错综复杂的问题之后,start_kernel()初始化了内核的所有部分,尤其是: 设置内存边界和调用paging_init(); 初始化中断、IRQ通道和调度; 分析(解析)命令行; 如果需要,就分配一个数据缓冲区(profiling buffer)以及其它一些小部分; 校正延迟循环(计算“BogoMips”数); 检查中断16是否能与协处理器工作。 最后,为了生成初始进程,内核准备好了移至move_to_user_mode(),它的代码也是在同一个源代码文件中的。然后,所谓的空闲任务,进程号0就进入无限的空闲循环中运行。 接着初始进程(init process)尝试着运行/etc/init、/bin/init或者/sbin/init。 如果它们没有一个运行成功的,就会去执行代码“/bin/sh /etc/rc”并且在第一个终端上生成一个根命令解释程序(root shell)。这段代码回溯至Linux 0.01,当时操作系统只有一个内核,并且没有登录进程。 在从一个标准的地方(让我们假定我们有)用exec()执行了init初始化程序之后,内核就对程序的执行没有了直接的控制。从现在起它的规则是提供对系统调用的处理,以及为异步事件服务(比如硬件中断等)。多任务的环境已经建立,从现在起是init程序通过fork()派生出的系统进程和登录进程来管理多用户的访问了。 由于内核是负责提供服务的,这个漫游文章将通过观察这些服务(“系统调用”)以及通过提供基本数据结构的原理和代码的组织结构继续讨论下去。 内核是如何看见一个进程的 从内核的观点来看,一个进程只是进程表中的一个条目而已。 而进程表以及各个内存管理表和缓冲存储器则是系统中最为重要的数据结构。进程表中的各个单项是task_struct结构,是定义在include/linux/sched.h中的非常大的数据结构。在task_struct中保留着从低层到高层的信息,范围从某些硬件寄存器的拷贝到进程工作目录的inode信息。 进程表既是一个数组和双链表,也是一个树结构。它的物理实现是一个静态的指针数组,它的长度是定义在include/linux/tasks.h中的常量NR_TASKS,并且每个结构都位于一个保留内存页中。这个列表结构是通过指针next_task和pre_task构成的,而树结构则是非常复杂的并且我们在此将不加以讨论。你可能希望改动NR_TASKS的默认值128,但你要保证所有源文件中相关的适当文件都要被重新编译过。 在启动引导过程结束后,内核将总是代表某个进程而工作,并且全局变量current --- 一个指向某个task_struct条目的指针 --- 被用于记录正在运行的进程。current仅能通过在kernel/sched.c中的调度程序来改变。然而,由于所有的进程都必须访问它,所以使用了宏for_each_task。当系统负荷很轻时,它要比数组的顺序扫描快得多。 进程总是运行于“用户模式”或“内核模式”。用户程序的主体是运行于用户模式而其中的系统调用则运行于内核模式中。在这两种执行模式中进程所用的堆栈是不一样的 -- 常规的堆栈段用于用户模式,而一个固定大小的堆栈(一页,由该进程所有)则用于内核模式。内核堆栈页是从不交换出去的,因为每当一个系统调用进入时它就必须存在着。 内核中的系统调用(system calls)是作为C语言函数存在的,它们的‘正规'名称是以‘sys_'开头的。例如一个名为burnout的系统调用将调用内核函数sys_burnout()。 系统调用机制在本手册的第三章中进行了讨论。观看在include/linux/sched.h中的for_each_task和SET_LINKS能够帮助理解进程表中的列表和树结构。 创建和结束进程 unix系统是通过fork()系统调用创建一个进程的,而进程的终止是通过exit()或收到一个信号来完成的。它们的Linux实现位于kernel/fork.c和kernel/exit.c中。 派生出一个进程是很容易的,所以fork.c程序很短并易于理解。它的主要任务是为新的进程填写数据结构。除了填写各个字段以外,相关的步骤有: 取得一个空闲内存页面来保存task_struct 找到一个空闲的进程槽(find_empty_process()) 为内存堆栈页kernel_stack_page取得另一个空闲的内存页面 将父辈的LDT拷贝到子进程 复制父进程的mmap信息 sys_fork() 同样也管理文件描述符和inode。 1.0的内核也对线程提供某些不够完善的支持,所以fork()系统调用对此也给出了某些示意。内核的线程是主流内核以外的过程产品。 从一个进程中退出是比较有窍门的,因为父进程必须被通告有关任何子进程的退出。而且,一个进程可以由另外一个进程使用kill()而退出(这些是Unix的特性),所以除了sys_exit()之外,sys_kill()以及sys_wait()的各种特性也存在于exit.c之中了。 这里不对exit.c的代码加以讨论---因为它一点也不令人感兴趣。为了以一致的状态退出系统,它涉及到许多细节。而POSIX标准对于信号则是要求相当严格的,所以这里必须对其加以叙述。 执行程序 在调用了fork()之后,就有同一个程序的两个拷贝在运行了,通常一个程序使用exec()执行另一个程序。exec()系统调用必须定位该执行文件的二进制映像,加载并执行它。词语‘加载'并不一定意味着“将二进制映像拷贝进内存”,因为Linux支持按需加载。 exec()的Linux实现支持不同的二进制格式。这是通过linux_binfmt结构来达到的,其中内嵌了两个指向函数的指针--一个是用于加载可执行文件的,另一个用于加载库函数,每种二进制格式都实现有这两个函数。共享库的加载是在exec()同一个源程序中实现的,但我们只讨论exec()本身。 Unix系统提供了六种exec()函数。除了一个以外,所有都是以库函数的形式实现的,并且,Linux内核是单独实现sys_execve()调用的。它执行一个非常简单的任务:加载可执行文件的头部,并试着去执行它。如果头两个字节是“#!”,那么就会解析该可执行文件的第一行并调用一个解释器来执行它,否则的话,就会顺序地试用各个注册过的二进制格式。 Linux本身的格式是由fs/exec.c直接支持的,并且相关的函数是load_aout_binary和load_aout_library。对于二进制,函数将加载一个“a.out”可执行文件并以使用mmap()加载磁盘文件或调用read_exec()而结束。前一种方法使用了Linux的按需加载机理,在程序被访问时使用出错加载方式(fault-in)加载程序页面,而后一种方式是在主机文件系统不支持内存映像时(例如“msdos”文件系统)使用的。 新近的1.1内核内嵌了一个修订的msdos文件系统,它支持mmap()。而且linux_binfmt结构已是一个链表而不是一个数组了,以允许以一个内核模块的方式加载一个新的二进制格式。最后,结构的本身也已经被扩展成能够访问与格式相关的核心转储程序了。 访问文件系统 众所周知,文件系统是Unix系统中最为基本的资源了,它如此的基本和普遍存在以至于它需要一个更为便利的名字--我将忠于标准的称呼简单地称之为“fs”。 我将假设读者早已知道基本的Unix文件系统的原理--访问(权限)许可、i节点(inode)、超级块、加载(mount)和卸载(umount)文件系统。这些概念在标准的Unix文献中由比我聪明的作者给出了很好的解释,所以我就不重复他们的工作并且我将只专注于有关Linux方面的问题。 早期的Unix通常只支持一个文件系统(fs)类型,它的代码散布于整个内核中,现今的实现是在内核和fs之间使用一个标准的接口,以便于在不同的体系结构中进行数据的交换。Linux本身提供了一个标准层以在内核和每种fs模块之间传递数据。这个接口层称为VFS,即“虚拟文件系统”("virtual filesystem")。 因而文件系统的代码被分割成了两层:上层是关于内核表格的管理和数据结构的,而低层是由与各文件系统相关的函数集构成的,并且是由VFS数据结构进行调用的。 所有与文件系统独立的资料都位于fs/*.c文件中。它们涉及如下的问题: 管理缓冲寄存器(buffer.c); 对fcntl()和ioctl()系统调用作出响应(fcntl.c和ioctl.c); 在inode和缓冲区上映射管道和fifo(fifo.c,pipe.c); 管理文件 - 和inode - 表(file_table.c,inode.c); 锁定和解锁文件和记录(lock.c); 将名称映射到inode(namei.c,open.c); 实现错综复杂的select()函数(select.c); 提供信息(stat.c); 加载和卸载文件系统(super.c); 使用exec()执行可执行程序以及转储核心程序(exec.c); 加载各种二进制格式(bin_fmt*.c,如上面所述)。 而VFS接口则由一组相对比较高层次的操作组成,并从与文件系统独立的代码中调用而实际上是由每种文件系统类型执行的。最为相关的数据结构是inode_operations和file_operations,尽管它们不是独自存在的:同样存在着其它一些数据结构。它们都定义在include/linux/fs.h文件中。 到实际文件系统的内核入口点是数据结构file_system_type。file_system_types的一个数组包含在fs/filesystems.c中,并且每当发出了一个加载(mount)命令时都会引用它。然后,相应fs类型的函数read_super就负责填写结构super_block的一个项,而该项又内嵌了结构super_struct和结构type_sb_info。前者为当前的fs类型提供了指向一般fs操作的指针,而后者对相应fs类型内嵌了特定的信息。 文件系统类型数组已经转换成了一个链表,以允许用内核模块的形式加载新的fs类型。函数(un-)register_filesystem代码包含在fs/super.c中。 一个文件系统类型的快速剖析 一个文件系统类型的任务是执行用于映射相应高层VFS操作到物理介质(磁盘、网络等等)的低层任务。VFS接口有足够的灵活性来支持传统的Unix文件系统和外来的象msdos和umsdos文件系统类型。 每一个fs类型除了它自己的源代码目录以外,是由下列各项组成的: file_systems[]数组中的一个条目(项) (fs/filesystems.c); 超级块(superblock)的include文件(include/linux/type_fs_sb.h); i节点(inode)的include文件(include/linux/type_fs_i.h); 普通自己专用的include文件(include/linux/type_fs.h); include/linux.fs.h中的两行#include,以及在结构super_block和inode中的条目。 对于特定fs类型自己的目录,包含有所有的实际代码、inode和数据的管理程序。 本手册中有关procfs的章节,揭示了所有有关那种fs类型的低层代码和VFS接口。在阅读过那个章节之后,fs/procfs中的源代码就显得非常容易理解了。 现在我们来观察VFS机制的内部工作情况,并以minix文件系统的代码作为一个实际例子。我选择minix类型是因为它比较短小但却是完整的;而且,Linux中的所有其它的fs类型都衍生于它。在最近Linux安装中的事实上的标准文件系统类型ext2,要比它复杂得多,对ext2这个文件系统的探索就留给聪明的读者作为一个练习了。 当一个minix-fs被加载后,minix_read_super就会把从被加载的设备中读取的数据添入super_block数据结构中。此时,该结构中的s_op域将保留有一个指向minix_sops的指针,该指针将被一般文件系统代码用于分派超级块的操作。 在全局系统树结构中链接新加载的fs依赖于下列各数据项(假设sb是超级块数据结构,而dir_i是指向加载点的inode的指针): sb->s_mounted指向被加载文件系统的根目录i节点(MINIX_ROOT_INO); dir_i->i_mount保存有sb->s_mounted; sb->s_covered保存有dir_i 卸载操作将最终通过do_umount来执行,而它会依次调用minix_put_super。 每当访问一个文件时,minix_read_inode就会开始执行;它会使用minix_inode各字段中的数据填写系统范围的inode数据结构。inode->i_op字段是依照inode->i_mode来填写的,它将负责该文件的任何其它操作。上述minix函数的代码可以从fs/minix/inode.c中找到。 inode_operations数据结构是用于把inode操作分派给特定fs类型的内核函数;该数据结构的第一项是一个指向file_operations项的指针,它等同于数据管理的i_op。minix文件系统类型允许有inode操作集中的三种方式(用于目录、文件和符号链接)和文件操作集中的两种(符号链接不需要文件操作)。 目录操作(仅minix_readdir)位于fs/minix/dir.c中;文件操作(读read和写write)位于fs/minix/file.c中而符号操作(读取并跟随着链)位于fs/minix/symlink.c。 minix源代码目录中的其余部分用于实现以下任务: bitmap.c用于管理i节点与块的分配和释放(而ext2文件系统却有两个不同的代码文件); fsynk.c用于fsync()系统调用--它管理直接、间接和双重间接块(我假定你是知道这些术语的,因为这是Unix的普通知识); namei.c内嵌有所有与名字有关的i节点的操作,比如象节点的创建和消除、重命名和链接; truncate.c执行文件的截断操作。 控制台驱动程序(console driver) 作为大多数Linux系统上的主要I/O设备,控制台驱动程序是应该受到某些关注的。有关控制台和其它字符驱动程序的源代码可以在drivers/char中找到,当我们指称文件时,我们将使用这个特定的目录。 控制台的初始化是由tty_io.c中的tty_init()函数来执行的。这个函数仅仅涉及取得每个设备集的主设备号并调用每个设备集的init函数。而con_init()则是与控制台相关的函数,并存在于console.c中。 在内核1.1的开发中,控制台的初始化已经有了很大的变化。console_init()已经从tty_init()中脱离出来了,并且是由../../main.c直接调用的。现在虚拟控制台是动态分配的,其代码也已有了很大的变化。所以我将跳过初始化、分配等等的详细讨论。 文件操作是如何分派给控制台的 这一节是相当底层的讨论,你可以放心地跳过本节。 毫无疑问,Unix设备是通过文件系统来访问的。本节将详细描述从设备文件到实际控制台函数的所有步骤,而且,以下的信息是从内核的1.1.73源代码中抽取来的,它与1.0的代码可能少许有点不同。 当打开一个设备i节点时,在../../fs/devices.c中的chrdev_open()函数(或者是blkdev_open(),但我只专注于字符设备)将被执行。这个函数是通过数据结构def_chr_fops取得的,而它又是被chrdev_inode_operations引用的,是被所有文件系统类型使用的(见前面有关文件系统的部分)。 chrdev_open通过在当前操作中替换具体设备的file_operations表并且调用特定的open()函数来管理指定的设备操作的。具体设备的表结构是保存在数组chrdevs[]中的,并由主设备号作为索引,位于同一个../../devices.c中。 如果该设备是一个tty类型的(我们不是只关注控制台吗?),我们就来讨论tty的设备驱动程序,它们的函数在tty_io.c之中,由tty_fops作为索引。这样,tty_open()就会调用init_dev(),而init_dev()就会根据次设备号为设备分配任何所需的数据结构。 次设备号也用于检索已经使用tty_register_driver()注册登记过的设备的实际驱动程序。而且,该驱动程序仍是另一个用于分派计算的数据结构,正如file_ops一样;它是与设备的写操作和控制有关的。最后一个用于管理tty的数据结构是线路规程,这将在后面叙述。控制台(以及任何其它的tty设备)的线路规程是由initialize_tty_struct()设置的,并由init_dev调用的。 在这一节中我们所涉及的所有事情都是与设备无关的,仅有与特定控制台相关的是console.c,在con_init()操作期间已经注册了自己的驱动程序。相反,线路规程是与设备无关的。 The tty_driver 数据结构在linux/tty_driver.h中有着完整的描述。 上述信息是从1.1.73源代码中取得的。它是有可能与你的内核有所不同的(“如信息有所变动将不另行通知”)。 控制台写操作 当往一个控制台设备进行写操作时,就会调用con_write函数。这个函数管理所有控制字符和换码字符序列,这些字符给应用程序提供全部的屏幕管理操作。所实现的换码序列是vt102终端的;这意味着当你使用te[url=http://www.pccode.net]lnet[/url]连接到一台非Linux主机时,你的环境变量应该有TERM=vt102;然而,对于本地操作最佳的选择是设置TERM=console,因为Linux控制台提供了一个vt102功能的超集。 因而,con_write()主要是由转换语句组成的,用于处理每一次一个字符的有限长状态自动换码序列的解释。在正常方式下,所打印的字符是使用当前属性直接写到显示内存中的。在console.c中,数据结构vc的所有域使用宏都是可访问的,所以(例如)任何对attr的引用,只要currcons是所指的控制台的号码,确实是引证了数据结构vc_cons[currcons]中的域。 实际上,新内核中的vc_cons已不再是一个数据结构数组了,现在它是指针的数组,其内容是用kmalloc()操作的。宏的使用大大地简化了代码修改的工作,因为许多代码都不需要被重写。 控制台内存到屏幕内存的实际映射和非映射是由函数set_scrmem()(它把控制台缓冲区中的数据拷贝到显示内存中)和get_srcmem()(它把数据拷贝回控制台缓冲区中)执行的。为了减少数据传输的次数 [1] [url=http://www.chinamx.com.cn/Article/os/Linux/200605/20060530123340_28301_2.html][2][/url] [url=http://www.chinamx.com.cn/Article/os/Linux/200605/20060530123340_28301_3.html][3][/url] [url=http://www.chinamx.com.cn/Article/os/Linux/200605/20060530123340_28301_2.html]下一页[/url]
󰈣󰈤
 
 
 
>>返回首页<<
 
 热帖排行
 
 
静静地坐在废墟上,四周的荒凉一望无际,忽然觉得,凄凉也很美
©2005- 王朝网络 版权所有