Linux内核简述

进程

创建

    创建进程用fork()函数。fork()为子进程创建新的地址空间并且拷贝页表。子进程的虚拟地址空间和父进程是相等的,子进程的物理内存与父进程是共用的。但是,此时物理内存中的数据是只读的。fork()函数使用写时拷贝,即一旦有其中一个人去写数据x,那么发生缺页异常,系统会新建一个物理页来存储独立的数据x并修改这个人的页表,指向新的物理页。这时,父子之间的数据x就不再共享了。

    创建进程还有一个函数,即vfork()。vfork()不拷贝父进程的地址空间和页表,而是直接在父进程的地址空间里执行。所以,对vfork()中执行的数据进行更改的时候,则会修改父进程的数据,因为父子进程的地址空间是完全共享的。于此同时父进程被堵塞,直到vfork()的_exit()或者exec*()调用之后。

    exec*()创建新的地址空间并且把新的程序拷贝进来。

    进程通过exit()函数退出,或者是有特殊情况而被动地终结,无论如何,都要调用do_exit()来执行实际操作。do_exit()释放大部分资源,执行完do_exit()以后,进程处于ZOMBIE状态,即成为僵尸进程,且还拥有三项资源:(内核栈、task_struct、thread_info)。父进程调用wait4()挂起并询问子进程是否死亡,如果有孩子死了,那么调用release_task()回收上述三项资源。如果父进程在子进程死之前死了,那么子进程变成孤儿进程,必须要在当前线程组找一个父亲,如果找不到那么用init进程当父亲。

    线程共享:(地址空间、files_struct、fs_struct、信号处理函数)。最重要的是,Linux中进程和线程的本质区别就在于是否共享父进程的地址空间。内核线程没有独立的地址空间,而是在内核空间里执行。线程共享同一个files_struct,即线程共享进程描述符表。而父子进程不共享进程描述符表,只是父子进程的进程描述符表在fork()之后是一样的。父子进程共享进程描述符指向的struct file。

表示

    进程用struct task_struct结构体来描述,每一个进程(或线程)对应一个task_struct对象。Linux通过slab来分配task_struct。为了找到task_struct,内核还有一个结构体struct thread_info,thread_info里面有一个指针指向task_struct,同时也含有一些关于进程的信息。thread_info放在内核栈的最高处,由这个特性,我们就可以经由thread_info快速找到进程的task_struct。我们讨论在现在的语境下有必要介绍的task_struct的数据成员。task_struct.state存储进程的五种状态,分别是:RUNNING、INTERRUPTIBLE、UNINTERRUPTIBLE、TRACED、STOPPED。task_struct.parent指向父进程,task_struct.children是子进程的链表。task.cpu_allowed来指定进程特定的cpu,这样进程可以强制绑定到当前的cpu。

    current宏代表当前正在执行的进程,current的存在是必要的,有些硬件体系结构寄存器比较富裕,可以有一个专门的寄存器存放当前的task_struct的首地址,而有的硬件体系结构则只能经由thread_info的指针找到。由软件工程基本定理知,加入一层抽象可以解决任何问题。所以current宏可以隐藏这种硬件的不一致,让不同的硬件都发挥相应的效能。当一个进程执行系统调用或者触发了异常的时候,进程陷入内核空间,内核代替进程执行,此时内核执行于进程上下文中,current指向当前进程,尤为有用。而在中断时,内核代替硬件执行,与被打断的进程没有任何关系,处于中断上下文中,current没有用处。

调度

    Linux有两种不同的优先级范围,一种是nice,-20~19;一种是实时优先级,0~99。任何实时优先级都优先于nice普通优先级。进程的调度通过调度器类,一般进程用CFS(即完全公平调度)调度器类,实时进程用实时调度器类。

    CFS之所以叫完全公平调度,是因为它在一定的时间粒度上是完全公平的。在一个调度周期里面,权重比就是运行时间的比,并且每个进程的加权增长速率都是相同的。CFS选取vruntime最小的进程来运行,所以根据vruntime为键,内核把进程组织成一个红黑树来方便选取这个最小值。其中,分配给进程的运行时间 = 调度周期 * 进程权重 / 所有进程权重之和。vruntime = 实际运行时间 * 1024 / 进程权重。每个CPU的运行队列cfs_rq都维护一个min_vruntime字段,记录该运行队列中所有进程的vruntime最小值,新进程的初始vruntime值就以它所在运行队列的min_vruntime为基础来设置,与老进程保持在合理的差距范围内。内核调用__pick_next_entity()来运行红黑树最小的节点。进程调度的函数接口是schedule(),它的调用层次为schedule() -> pick_next_task() -> pick_next_entity()。

    休眠的进程移出红黑树,通过add_wait_queue()加入到等待队列,当wake_up()调用以后,进程调用try_to_wake_up()来尝试苏醒,如果苏醒的进程优先级高于正在运行的,那么设置need_resched标志,指示schedule()重新调度。只要内核返回用户空间,就需要检查need_resched标志,如果设置了那么必须调用schedule()进行调度,这叫用户抢占。而于此同时,只要当前的任务不带有锁,即thread_info.preempt_count为0,那么这个任务也可以被内核抢占。注意,need_resched保存在thread_info中,这样访问比把need_resched当成全局变量要快。schedule()也负责进程的切换,调用层次是schedule() -> context_switch()。其中context_switch()分别调用switch_mm()切换地址空间,调用switch_to()切换进程的状态并保存上一个进程的信息。

    实时调度有两种策略,一种是FIFO,高优先级执行完再执行低优先级,同优先级之间保持先进先出;一种是RR,高优先级执行完再执行低优先级,同优先级之间保持时间片轮转。进程还可以调用sched_yield()来转让给其他进程执行,自己移动到过期队列。如果是实时进程调用sched_yield()的话,则移动到优先级队列的最后面。

中断

原理

    中断是硬件设备产生的一种特殊的电信号,通过总线发送给中断控制器,如果中断线是激活的,那么中断控制器把中断发送给cpu,如果cpu没有禁止中断,那么cpu立刻停止当前正在做的工作,禁止中断,并且执行中断处理程序。执行中断处理程序最后总是要调用do_IRQ(),do_IRQ()首先禁止这个中断线,然后调用handle_IRQ_event()运行这条中断线上的所有处理程序。中断是硬件产生的,是异步的,不考虑系统时钟,而异常则是cpu自己产生的,和时钟同步。每一个中断都有唯一的中断号,称为中断线,中断线可以共享。一个设备的中断处理程序是其驱动程序的一部分,中断处理程序被内核调用来响应中断。驱动程序通过request_irq()注册中断处理程序,卸载中断处理程序则通过free_irq(),如果中断线不是共享的,那么删除处理程序之后禁用中断线,如果共享的话仅当删除的是当前中断线的最后一个处理程序时,才禁用中断线。中断处理程序运行在中断上下文中,代替硬件执行硬件相关的程序,打断了当前正在执行的进程,所以要尽快且不能睡眠。为了快速从中断处理程序中退出,Linux把中断分成了两部分,一部分是上半部,即中断处理程序;一部分是下半部。

系统调用

    有一种特殊的中断是系统调用,它不是硬件产生的,而是进程调用int 0x80触发的。这条指令产生一个异常,让系统切换到内核态去执行0x80号(即8 * 16 = 128号)中断处理函数,这个函数叫做system_call()。所有的系统调用存放在一个表里,叫做sys_call_table,内核通过表的索引值来查找应该调用哪一个系统调用,这个索引就叫做系统调用号。系统调用的调用约定是:系统调用号通过eax寄存器来传递。系统调用参数通过五个寄存器,即ebx ecx edx esi edi来传递。如果参数多于五个,那么ebx存放所有参数在内存中的起始地址。系统调用的返回值存储在eax中。注意,系统调用执行在进程上下文中,它代替进程执行,并且寄存器的初始值来源于进程,所以处在进程的环境里。current有效,并且可以休眠,可以被抢占。

中断栈

    一个进程的所有地址空间可以分为用户空间(0~3G)和内核空间(3G~4G),这两个空间都有相应的栈,分别叫用户栈和内核栈。内核栈一般是两页的大小。中断原来是执行在内核栈里面的,但是现在有一个可选的选项,即把内核栈缩小成一页,并且存在一个中断栈,也是一页,每个处理器一个。

中断状态

    想要得知中断的状态有三个函数接口,irq_disable()得知本地处理器中断是否禁止,in_interrupt()得知内核是否处于中断,in_irq()得知内核是否在执行中断处理程序。

中断处理程序

    中断处理程序(即上半部分)一般只处理必须要在中断上下文中的、和硬件关系非常紧密的操作,例如对中断的确认和从硬件拷贝数据。做完就立刻返回,而其他的操作交给下半部分做。

中断下半部

    下半部分可以有多种实现方法,现存的有:(软中断、tasklet、工作队列)。

    软中断是静态定义的,有32个。实际上软中断就是一个struct softirq_action softirq_vec[32]数组,其中softirq_action就是每一个软中断,其中有一个函数指针。唯一可以打断软中断执行的就是中断处理程序。有一个位图pending来表示软中断数组的每一位是否置位。软中断的执行的实际操作最后都依赖do_softirq()函数,它遍历软中断,根据pending是否置位来决定执行哪些软中断。目前只有两个系统使用软中断,即网络和SCSI。

    使用软中断的话,首先要注册,即调用open_softirq()函数。启动软中断有两个时机。一、在中断处理程序中,可以调用raise_softirq()设置相应的pending位,在硬中断的中断处理程序中的do_IRQ()里面会会调用irq_exit(),这个函数会判定是否有pending的某一位置位,如果有那么调用invoke_softirq(),invoke_softirq()调用__invoke_softirq(),__invoke_softirq()调用__do_softirq()遍历软中断数组执行pending置位的位相应的软中断。二、在非中断上下文中调用rasie_softirq_irqoff()函数设置相应的pending位,然后唤醒内核线程ksoftirqd/n()执行软中断(和tasklet),ksoftirqd/n优先级最低,为19。

    tasklet是动态的,是用软中断来实现的。实际上tasklet就是一个struct tasklet_struct链表。tasklet用struct tasklet_struct来表示,其中有tasklet_struct.count来代表tasklet是否被禁止。tasklet_struct.func指向处理函数。tasklet_struct.data是传给处理函数的参数。tasklet_struct.next用来聚合成链表。tasklet_schedule()或tasklet_hi_schedule()把tasklet_struct添加到两个链表之一(即tasklet_vec或tasklet_hi_vec)并且分别置位TASKLET_SOFTIRQ(软中断号是5)或HI_SOFTIRQ(软中断号是0)两个软中断。这两个软中断的处理程序分别是tasklet_action()和tasklet_hi_action(),他们遍历相应的链表,执行链表上所有的tasklet。

    使用tasklet的话,可以调用DECLARE_TASKLET或DECLARE_TASKLET_DISABLED静态创建tasklet,也可以调用tasklet_init()来动态创建tasklet。然后就可以在中断处理程序中调用tasklet_schedule()或tasklet_hi_schedule()来把tasklet添加到两个链表其中一个并置位软中断。

    工作队列把推后的工作交给内核线程去执行,也就是说,工作队列是在进程上下文中执行的,可以参与调度,可以睡眠。工作队列实际上就是内核线程。Linux有一个默认的工作队列,用struct workqueue_struct来表示,其中有一个struct cpu_workqueue_struct[N_CPU]数组,即每一个CPU都对应一个struct cpu_workqueue,这个结构体有一个worklist链表,链表的每一个节点都代表一个需要执行的中断下半部。每一个处理器都有一个默认的工作内核线程叫events/n,来执行worklist中的所有中断下半部函数。每一个内核线程调用worker_thread()函数,这个函数执行一个死循环并且休眠,如果有新的函数加入到了worklist里面,那么就唤醒执行,没有操作了就睡眠。

    使用工作队列的话,可以调用DECLARE_WORK()静态创建work_struct,也可以用INIT_WORK()动态创建。可以在硬中断处理程序中调用schedule_work(&work)把工作交给events/n,函数会立马被执行。也可以通过schedule_delayed_work(&work, delay)来指定delay。flush_scheduled_work()来保证工作队列的所有对象都被执行完才会返回,可以用于一些同步的情况。

时钟

时钟

    全局变量jiffies是记录自系统启动以来所产生的节拍总数,是unsigned long volatile类型的变量,volatile指示编译器每次访问变量都从内存中获得,而不是从寄存器中来访问。jiffies = HZ * seconds。每次执行时钟中断处理程序都会增加jiffies。内核有两个jiffies变量,一个叫jiffies,一个叫jiffies_64,其中jiffies = jiffies_64,即在32位机器,jiffies是jiffies_64的后32位,而64位机器上,这两个值等价。32位jiffies在1000HZ情况下50天溢出,100HZ情况下500天溢出。而64位不可能溢出。为了解决溢出带来的问题,内核使用四个宏解决比大小,即:time_after(),time_before(),time_after_eq(),time_before_eq()。

    实时时钟(RTC)是用来持久存放时间的设备,即使设备关机后仍然可以依靠主板上的电池维持自己。一般RTC和CMOS是集成在一起的。当系统启动时,内核读取RTC初始化墙上时间,该墙上时间存储在xtime中。xtime是struct timespec{ tv_sec, tv_nsec }类型的,tv_sec是从1970.1.1至今的秒数,tv_nsec是ns数。读写xtime要用xtime_lock()锁,这时一个seq锁(即顺序锁)。用户取得墙上时间用gettimeofday()。

定时器

    定时器有两种,一种是系统定时器,一种是动态定时器。

    系统定时器是硬件提供的,以一定的频率(即节拍率)自行触发时钟中断,然后内核去执行时钟中断处理程序。两次时钟中断间隔时间即是节拍,节拍等于节拍率分之一。x86默认时钟节拍率是100HZ。而在2.5内核中,时钟节拍率提高到了1000HZ,提高节拍率的好处是更高的频度和准确度,而坏处是系统负担变重了,时钟中断处理程序频繁打断进程并占用处理器,打乱了处理器的高速缓存并且增加了耗电。时钟处理程序需要做的事情非常多,其中包括:设置各种时钟值、调用体系结构无关的tick_periodic()。tick_periodic()做了很多,包括:jiffies_64加一、更新各种值、置位软中断的pending的第1位(动态定时器)、执行scheduler_tick()、更新xtime墙上时间、计算负载。其中scheduler_tick()减少进程的时间片,并且在时间片用光的时候设置need_resched标志,以及平衡各个处理器上的运行队列。

    动态定时器不是周期执行的,而是使得任务能够在指定的时间执行。动态定时器由struct timer_list表示,是一个链表。使用动态定时器要先定义并初始化,即:struct timer_list my timer; init_timer(&my_timer)。注意,init_timer()只初始化系统内部的变量,timer_list.expires,timer_list.data,timer_list.function都需要再手动设定。注意,my_timer.expires是超时时刻,是一个时间点,所以一般这样赋值:my_timer.expires = jiffies + delay。这样定义和初始化阶段就完成了。还需要激活timer,调用add_timer(&my_timer)。还可以用mod_timer(&my_timer)来更改激活或者未激活(如果未激活,mod_timer()会把它激活)的动态定时器。如果要删除一个定时器要调用del_timer(),注意已经超时的会自行删除,所以这里有一个竞争条件,即调用del_timer()的时候已经删除,但是定时器中断已经在别的处理器上执行了,del_timer()却直接返回。而del_timer_sync()则等到当前的定时器中断执行完才返回,这个函数不能在中断上下文使用。一般情况下应该使用del_timer_sync(),很保险。动态定时器是依靠软中断来实现的,软中断号是TIMER_SOFTIRQ(即是1)。动态定时器的执行是作为时钟中断处理函数的下半部分来执行的。时钟中断处理程序会执行update_process_times(),该函数调用run_local_timers(),该函数调用raise_softirq()来设置pending的第1位。TIMER_SOFTIRQ对应的软中断处理程序是run_timer_softirq(),这个函数在当前处理器上遍历timer_list链表,运行所有的超时定时器。为了提高效率,timer_list的链表分为5组,当超时时间接近时,定时器随着组一起下移。

延迟执行

    延迟执行除了动态定时器和下半部机制以外(实际上动态定时器就是时钟中断处理程序的下半部),还有:忙等待、短延迟、schedule_timeout()。

内存

物理页

    物理页(也叫页框、页帧、page frame)把内存(DRAM)分为一页一页的大小来管理,页框是内存管理的基本单位。大多数32位机器里面,页框是4KB,而64位的页框大多是8KB。内核用struct page来表示物理页。内核用struct page来管理每一页框的原因是,内核需要知道每个页框的详细信息。内核用alloc_pages()来分配2^n个页框,返回指向第一个页框结构体struct page的指针。内核用page_address()把页框转换成逻辑地址。分配页框和释放页框还有好多函数,不过都是基于alloc_pages()。

内存分区

    内核把页框(即内存)分为不同的区,Linux主要有四种内存分区,即:DMA、DMA32、NORMAL、HIGHEM。每一个区域用struct zone来表示。分区的原因是,每个进程有它独立的地址空间,地址空间在32位机器上是4GB,而内存可以大于4GB,所以地址空间不能和内存进行一一映射。如果要想充分利用内存,就一定要在地址空间和内存两个集合中分别预留出来一部分来做非永久的映射,这样地址空间就能访问到所有内存了。

    一般来说,物理内存中0~16M是DMA区域,16M~896M的是NORMAL区域,896M以上的内存就都是HIGHEM的内存区域了。

    而对于x86-64这种64位机器来说,地址空间高达无数,基本上都可以保证内存可以映射到地址空间里面去,所以就不需要分区了。

内存分配

    在内核中,kmalloc()用来分配内存空间,释放函数是kfree()。kmalloc()函数有一些标志,GFP_KERNEL会睡眠,GFP_ATOMIC不会睡眠,GFP_NOIO不启动磁盘IO,GFP_NOFS不启动文件系统,GFP_DMA必须从DMA区分配。kmalloc()保证在地址空间和物理内存空间都是连续分配的,所以,kmalloc()分配的是上述所说的一一映射的区域(DMA和NORMAL,一共896MB)。而vmalloc()分配的内存只是在地址空间连续,而不在物理内存空间连续,所以vmalloc()映射的是HIGHEM区域中vmalloc区的内存。kmalloc()由于是直接一一映射,地址空间和物理内存的转换极为简单,只相差一个PAGE_OFFSET(即内核地址空间和用户地址空间的分界,32位系统即3G)而且连续,所以速度很快;但是vmalloc()就要通过内核页表的缺页异常来映射了,动作很慢,而且有可能会睡眠。但是vmalloc()可以分配很大块的内存。vmalloc()分配的物理内存用vfree()释放。

    地址空间中的内核空间的高端映射区域分为:(vmalloc区、可持久映射区、临时映射区)。其中vmalloc()映射的是vmalloc区的内存。其中可持久映射区的使用方式是这样的:先从alloc_pages()返回一页的指针,然后调用kmap(struct page*)来在可持久映射区映射一页的内存区域。kmap()可以睡眠,而且应当在不使用时调用kunmap()解除映射。可以调用kmap_atomic()不让这个函数睡眠。kamp_atomic()函数是映射在内核空间的临时映射区的。临时映射区又叫原子映射区。当然解除的时候调用kunmap_atomic()。

    分配和释放数据结构是使用物理内存的最普遍的操作。所以为了便于频繁地分配和释放同一个数据结构,可以采用高速缓存struct kmem_cache。每一个数据结构都对应于一个高速缓存。kmem_cache里面含有一个struct kmem_list3,这其中有3个slab链表,分别是:满的、部分的、空的。每一个slab都含有一个或者多个物理页。可以用kmem_cache_create()创建新的高速缓存,用kmem_cache_destroy()删除高速缓存,用kmem_getpages()分配新的slab,用kmem_freepages()释放slab。用kmem_cache_alloc()分配一个对象,如果高速缓存中对象没有可用的,那么就先调用kmem_getpages()创建新的slab。实际上,slab就是一个或多个页框的头,用来把这一个或多个页框组成一个整体并且串起来。用kmem_cache_free()把一个对象还给高速缓存。而一般的内存分配则通过伙伴系统来分配,伙伴系统通过每一个struct zone的zone.free_area数组来组织。

页高速缓存

    在物理页struct page中,有一个struct address_space *mapping成员,这个成员代表一个IO缓存,即页高速缓存。页高速缓存是内存对磁盘的缓存,来减少对磁盘IO的调用。页高速缓存把磁盘的数据缓存到物理内存中。

    当一个文件打开后,内核在物理内存中创建一个inode,其中inode.i_mapping指向的就是这个文件在内存中的页高速缓存,即struct address_space结构。address_space.host指向对应的inode。一个磁盘文件对应一个struct inode,也对应一个struct address_space,但是对应多个struct file。address.a_ops指向一个struct address_space_operations函数表,里面有具体的回写,读入内存数据等函数。

    对于read()调用,进程会先去inode.i_mapping页高速缓存看看读的东西是否存在,如果存在直接返回,如果不命中则产生缺页异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页。

    如果是write()调用,进程会先去inode.i_mapping页高速缓存看看读的东西是否存在,如果存在那么直接修改,如果不命中则产生缺页异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页,然后修改。被写入的页框标记成脏的并且加入一个脏页链表中,然后在合适的时机,由内核线程来回写到磁盘,然后删除脏页的标志。

    回写的条件是:空闲内存低于某个阈值、脏页停留时间高于某个阈值、用户进程调用sync()或fsync()。在2.6中,内核调用一组内核线程flusher来进行回写。内核当:内存低于阈值、显式调用sync()或fsync()时,通过函数flusher_threads()调用一个或多个flusher线程,线程调用bdi_writeback_all()开始回写,直到:指定的页数写完了、空闲内存超过阈值、所有脏页都写完了时,停止运行。当然,flusher线程群也会周期运行,将驻留时间过长的脏页写回。Linux还有一种回写策略,叫做膝上型计算机模式,以硬盘转动最小为目标,不会专门为了回写而主动调用磁盘IO,而且上述的两个阈值也非常大。多数Linux在电池供电时自动用这个,而交流电供电时用正常的。

虚拟内存

    Linux也有虚拟内存。Linux对页的换出采取了双链策略,即维护两个链表:活跃与非活跃。非活跃的链表里面的页面是可以换出的,两个链表需要维持平衡,这种页面置换算法叫做LRU/2。

地址空间

分类

    地址空间是逻辑上的,即是虚拟的。所有的进程都有它独立的地址空间,一般来说32位机器上是4G,其中0G~3G为用户空间,3G~4G为内核空间。内核空间的3G~3G+16M被内存的DMA区域映射,3G+16M~3G+896M被内存的NORMAL区域映射,3G+896M~4G被内存的HIGHEM区域映射。其中3G+896M~4G还分为:vmalloc区域、可持久映射区域、临时映射区域。

分区

    地址空间中能被进程访问的部分叫做内存区域。当一个进程访问了它不能访问的,或者是以错误的方式访问的地址空间时,那么内核终止该进程并返回一个段错误内存区域包含:数据段、代码段、BSS段、用户空间栈、映射的文件,等等。对于Linux,地址空间是平坦的、不分段的。即所有进程所拥有的地址空间都是一块大的连续的虚拟空间。虚拟地址是地址空间范围内的一个值。对于用户角度,所有的指针的值、变量的地址,只要是用户看见的,都是虚拟的地址。

表示

    内核使用struct mm_struct来描述一个进程的地址空间。在task_struct中,用mm指针指向这个进程的mm_struct。其中mm_users代表使用计数,mm_count代表主引用数。mmap和mm_rb都表示全部的内存区域,只不过一个用链表聚合,一个用红黑树聚合。所有的进程的mm_struct通过一个双向链表mmlist相连。链表头是init_mm,是init进程的地址空间。

    内核线程没有独立的地址空间,也没有相关的mm_struct和page table。即,该线程的mm = NULL。但是当内核线程想使用相关数据的时候,就会使用前一个进程的mm_struct。即,当一个内核线程被调度时,内核发现mm = NULL,所以就保留前一个进程的mm_struct,并用内核线程的active_mm指向该mm_struct。

    内存区域用struct vm_area_struct来描述。其中有vm_mm指向了它所在的地址空间mm_struct,有vm_start和vm_end来指明它在这个地址空间里面的范围。同时还有一个链表节点next和红黑树节点vm_rb,就是mm_struct里面的链表和红黑树。vm_area_struct采用了面向对象的设计思路,有一个vm_ops指向了一个struct vm_operations函数表。

创建地址空间

    当我们用fork()创建进程的时候,fork()调用allocate_mm()从slab中分配一个mm_struct,fork()还调用copy_mm()来复制父进程的mm_struct。然而如果在clone中指定了CLONE_VM标志,那么fork()不调用allocate_mm(),而是直接在copy_mm()中让子进程的mm指针指向父进程的mm_struct。当进程退出时,调用exit_mm()函数,这个函数会掉用mmput()减少mm_users,如果为0那么调用mmdrop()减少mm_count,如果mm_count为0那么调用free_mm(),这个函数调用kmem_cache_free()将mm_归还给slab。

    exec*()也会创建新的地址空间。

创建内存区域

    内核用do_mmap()来为地址空间创建新的区域,这个函数把文件(struct file)和地址空间的区域(struct vm_area_struct)来进行映射。这样的话,可以根本不用文件的相关的系统调用,就能像访问内存那样去读写文件,即使文件关闭,照样可以使用这片映射来读写文件。如果创建的区域和现有的相邻,那么就合并加入到里面,如果不相邻,那么就从slab中分配一个vm_area_struct,并且调用vma_link()来把vm_area_struct添加到链表和红黑树中。函数do_mmap()原型是unsigned long do_mmap(file, addr, len, prot, flag, offset)。如果调用的时候file = NULL且offset = 0,这就叫匿名映射。可以创建匿名映射后再调用fork(),这样父子进程就可以实现对内存的共享。函数do_munmap()从特定的地址空间中删除一个vm_area_struct。

地址空间与物理内存的转换

    应用程序操作的都是虚拟的地址空间,而cpu却操作的都是真实的物理内存,所以需要一个转换,这个转换由MMU这个硬件来完成。MMU实现虚拟地址到物理内存地址的转换,并且检查访问权限。

    Linux使用三级页表(PGD、PMD、PTE),多级列表可以节省很多空间。每一个进程都有它自己的用户页表,而内核空间里面也有一个内核页表,内核页表也可以产生缺页异常,因为内核的地址空间和物理内存也有不是一一映射的时候,即3G+896M~4G。

    线程会共享地址空间和页表。

    其中mm_struct.pgd就指向PGD。为了加快转化,Linux使用了TLB这个硬件来缓存,当cpu想知道一个虚拟地址空间的地址所对应的物理内存地址时,就先去检查TLB,如果没有,再通过页表一级级索引。

虚拟文件系统

    虚拟文件系统(VFS)是Linux为用户空间的程序提供了操作文件的相关接口,VFS可以屏蔽掉(一定程度上屏蔽掉)各种类型的文件系统的不一致,并提供了所有文件系统都支持(或者说应该支持)的数据结构和操作。

    例如用户调用write(),先会进入sys_write()系统调用,然后调用vfs_write(),然后vfs_write()就会去调用f -> f_op -> write(f),即和文件系统相关的,由文件系统完成的write()函数去写文件。Linux的VFS将文件和文件相关的信息分开存储。一般来说在磁盘里也是这样的,如果不是这样的,也可以使用VFS,但是需要在转化的过程中付出沉重的开销。Linux的VFS设计参考了面向对象的一些思维,即把对对象的操作放在函数表中,并用对象的指针指向这个表,例如上面举的例子f -> f_op -> write(f),还要注意要把自己传给write()函数,这样才能操纵f里面的数据成员。这相当于面向对象里面的this指针。

    Linux的VFS为了描述一个文件系统,提供了四个结构。分别是:struct super_block代表一个文件系统的信息。struct inode代表一个磁盘里面的文件元信息。struct dentry代表一个目录项,dentry存储文件名。struct file代表一个进程打开的文件,struct file里面存储文件的打开时指定的标志、文件的offset指针。注意这里面的dentry不是一个具体的存在于磁盘里面的结构,它的存在是为了快速的定位一个文件的路径。dentry存储在一个目录项缓存dcache中,VFS会先去目录项缓存搜索路径名,如果没有的话再去文件树遍历,然后把相关的目录加入到dcache中。注意缓存目录项的同时也会去缓存相应的inode。

    在task_struct中,有一个files指针指向struct files_struct。files_struct里面有一个fd_array指针数组,指向的是struct file,即这个进程打开的文件。fd_array的索引就是fd,即文件描述符。在64位系统中,这个数组大小是64个,如果一个进程打开了超过64个的文件,那么会再为多出来的文件分配一个文件指针数组,这个数组由files_struct的fdt指向。也就是说如果访问多于64个的文件的话,就要多通过一个指针。

    父进程先open得到文件描述符fd之后再fork,子进程拥有父进程打开的文件描述符fd。这时,父子进程的两个fd指向同一个struct file,也就是操作同一个文件,拥有同样的文件的打开时指定的标志,拥有同样的文件的offset指针。

    task_struct的fs指针指向struct fs_struct,这是当前进程的工作目录和根目录。task_struct的mmt_namespace指向struct namespace,这是进程所在的命名空间。一般来说,每一个进程都有属于它自己的,独立的一个files_struct和fs_struct,但是所有进程都指向同一个namespace。当然,对于特殊的,即调用clone的时候指明CLONE_FILES的进程则和父进程共享files_struct,指明CLONE_FS的进程和父进程共享fs_struct,指明CLONE_NEWS的话,会为这个进程创建一个属于它的新的命名空间。

    一个inode对应一个磁盘文件。因为硬链接,所以一个inode可以对应多个dentry。因为多个进程可以打开同一个目录项对应的文件,一个进程也可以多次打开同一个目录项对应的文件,所以一个dentry可以对应多个file。

块设备

简述

    Linux一共有两种硬件存储设备,即字符设备和块设备。块设备是可以随机访问的设备,常见的块设备是磁盘。字符设备是只能按照字符流来有序访问的硬件设备,例如键盘。

    对于硬件来说,块设备最小的可寻址单元是扇区,一般是512B。扇区是一个设备的物理属性。而最小的逻辑可寻址单元是块,内核所有执行块设备的操作都是按照块来的。块倍数于扇区,但是要小于页框。通常块是:512字节、1KB、4KB。

缓冲区表示

    当块调入内存时,会有一个缓冲区和它对应,内核用struct buffer_head来表示缓冲区的信息。buffer_head.b_bdev指明对应的块设备,buffer_head.bblocknr指明块设备的起始块号,buffer_head.page指明用于存储这个块的页框,buffer_head.b_data用来指明块在页内的起始地址,buffer_head.b_size指明块的大小。所以块在buffer_head.page的(b_data, b_data + b_size)区间处。一个buffer_head只能指明一个块和一个物理页的对应关系,只能描述一个块。

块IO表示

    在2.5中,引入了struct bio结构体来表示一个正在进行的块IO操作。bio里面有一个struct bio_vec *bi_io_vec动态数组,这个动态数组包含了这个IO操作所需要的所有片段(即块在内存的缓冲区)。其中struct bio_vec是一个{ page, offset, len }结构,描述一个块。bio.vcnt表示这个动态数组的长度。bio.bi_idx表示正在操作的IO片段。所有的块请求保存在一个请求队列struct request_queue中。每一个请求用struct request表示,一般来说一个bio代表一个请求,但是因为有合并操作,所以一个请求也可以有多个bio。

块IO调度

    在2.4版本中使用的调度程序是Linus电梯调度,即:1.如果请求队列中存在前相邻或者后相邻的,那么合并。2.如果队列中存在驻留时间过长的请求,直接加到队列尾部。3.如果存在一个合适的插入位置,那么插入。4.如果不存在插入位置则加入尾部。在2.6中有新的IO调度算法,即最后期限IO调度,基础还是Linus电梯,但是拥有三个链表,其中多出来的两个是FIFO的读和写链表,分别有超时时间(默认为500ms和5s),如果超时了则优先从这两个链表里取请求。预测IO调度和最后期限一样,只是请求提交完会停留6ms,来给相邻的请求提交的机会,预测IO会跟踪并统计每个进程的习惯和行为。完全公平调度是每个提交了IO的进程都有自己的队列,按照时间片轮转执行每个队列的请求。空操作IO调度除了合并以外什么也不干,这个调度程序是给真正可以随机访问的设备用的,例如闪存卡。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342