Linux内核学习总结(附录linux协议栈函数调用图)

来源:互联网 发布:卖家版淘宝下载 编辑:程序博客网 时间:2024/05/22 02:28

Linux内核学习总结


作者: 北京—小武

邮箱:night_elf1020@163.com

新浪微博:北京-小武


Linux操作系统以GPL作为限制条款进行开源,对计算机界产生了巨大影响。在短短的二十年里迅速壮大。Linux内核从产生到现在一直在不断被改进,现在就我最近对其学习内容和体会进行下总结。学习所用书籍是美国Robert Love著的《linux内核设计和实现》。下面段落中用红色字体标注知识点关键字。

内核是操作系统的核心(操作系统主要完成系统中最基本的功能和系统管理,包括自动引导程序、设备驱动程序、内核、命令行或窗口之类的用户界面等),一般内核的内容包括:负责相应中断的中断服务程序、管理多个进程共享CPU的调度程序、管理进程地址空间的内存管理程序、进程间通信机制等。Linux内核是一个单内核(本身是一个大进程),本身采用GNU C编程,难以执行浮点数,而且可移植性非常重要。Linux是一个支持多线程、可抢占、虚拟内存、虚拟处理器、换页、动态链接库和TCP/IP网络协议栈的操作系统。

类似于我们的交换机的芯片SDK的代码,linux内核拥有受保护的内存空间和访问硬件设备的所有权限,称为内核空间;不能直接访问硬件设备,通常也不能访问其他进程的内存范围,称为用户空间。用户空间的程序通过系统调用来与内核进行通信,这时候称为内核运行于进程上下文;当下层硬件想与内核通信时,需要发出一个异步的中断信息来打断CPU中断对内核的执行,并根据中断号来查找对应的中断处理程序。

系统调用的作用是为用户空间进程和硬件提供一个中间层,屏蔽硬件差异并封装操作硬件的安全接口。系统调用是通过软中断实现(引发一个异常促使系统从用户空间切换到内核空间去执行处理程序)。一个系统调用我们要考虑参数类型及检验、返回值和错误类型,以及为将来的发开发。Linux提供了一组宏_syscalln()从用户空间访问系统调用,每个宏有2+2*n个参数,比如int sysinfo(struct sysinfo * info)函数对应的系统调用为_syscall1(int, sysinfo, struct sysinfo *, info);系统调用的添加新接口上有一套相应的流程需要我们遵循。

系统调用对应的应是在内核执行相应的线程。线程是进程中活动的对象,每一个线程都有独立的程序计数器、进程栈和一组进程寄存器,而进程则是处于执行前代码和资源的总称。创建进程的进程称为父进程,被创建的进程称为子进程,相同父进程的子进程称为兄弟进程,每一个进程有一个唯一PID作为标记,且所有进程都是init进程的子孙(PID为1),linux创建进程的最大数目是被控制的;进程在linux中由fork和vfork函数创建,,二者的区别是vfork创建的子进程作为父进程的一个单独线程在父进程地址空间运行,而父进程被阻塞直到子进程退出。所有的进程创建后会对应一个task_struct来对其表述,并添加到task_list链表里进行调度。进程被创建后最终会被终结,通过调用exit()函数退出而被设置为僵死状态,并释放所占资源和删除描述符等,并通知父进程;父进程可以用wait()函数查询子进程是否终结(这个函数使得进程有了等待特定进程执行完毕的能力);当父进程先于子进程结束时就需要给子进程再寻找新的父进程,防止出现孤儿进程在退出时成为僵死进程。进程的状态有运行、停止、可中断阻塞与不可中断阻塞等几个状态,可以通过函数set_task_state设置进程状态。Linux线程操作的函数有kthread_create、kthread_run和kthread_stop等几个函数。线程的创建和进程的创建最终会调用一个clone()函数,只是穿进去的标记CLONE_VM | CLONE_FS | CLONE_FILES| CLONE_SIGHAND来共享资源。clone()函数的运行过程大概是先复制父进程的资源(linux对此进行了优化,称为写时拷贝,即在需要写入的时候才复制,不然子进程和父进程一直以只读的方式共享同一个拷贝),检查是否超过系统允许的进程数限制,没有的话分配进程描述符并置阻塞不可中断状态,然后分配PID,最后返回一个指向子进程的指针即可,然后进程就可以被调度了。Linux的进程调度目的是最大限度的利用CPU的时间,为达到此目的,原则上是只要有可以执行的进程,那么CPU就会有进程正在执行。多任务系统是指能同时并发地交互执行多个进程的操作系统,单处理器上给用户的感觉是多进程同时执行,但是只有多核处理器才会有真正的多进程同时执行。多任务系统有抢占式(由调度程序决定哪一个进程被执行与执行多少时间)和非抢占式(除非进程主动放弃CPU,否则其他进程就永远得不到CPU的处理权)。Linux内核一直到2.4版本调度程序都非常简单,到2.5引入了O(1)调度程序,但是由于对I/O消耗型进程尤其在多处理器上表现不佳,到2.6版本已经基本被CFS调度程序替代。原先的调度程序根据进程的优先级(高优先级先执行、低优先级后执行、同优先级轮询执行)来分配进程运行的时间片,CFS根据进程优先级来分配进程消耗的CPU使用比来确定,一个新进程如果比当前进程的使用比少则立刻投入运行(这个使用比和时间的粒度有关系,有点类似meter的粒度问题);linux的CFS调度(调度器描述结构体为struct_sched_entity,作为进程描述符task_struct的一个成员变量se)分为四个步骤:时间记账(用调度器实体的一个变量vruntime代表的虚拟运行时间来代表)、进程选择(CFS用红黑树组织可调度进程,选择具有vruntime最小的进程投入运行)、调度器入口(调用schedule()运行执行进程)、睡眠和唤醒(进程休眠对应的操作是将进程从可执行进程红黑树中移出到等待队列wake_queue_head_t中,唤醒是相反的过程;具体操作函数有add_wait_queue、finish_wait、wake_up等);用户代码无需显示调用schedule()函数,我们可以用set_task_need_resched函数指定进程被调度,相关函数还有clear_task_need_resched清除函数和need_resched判断函数;用current宏返回当前被调度的进程。CFS的调度称为normal调度,还有RR调度和INFO调度,三种调度类似于Eventloop的三种事件类型的调度。对于多核系统进程还可以通过设置sched_setaffinity()函数来配置可以再系统上哪些CPU上执行。

linux内核还负责管理用户进程的内存,这个内存称为linux的进程地址空间,由可寻址的虚拟内存组成,可远远大于系统物理内存,但是有一个访问权限的问题。通过内核,进程可以动态添删内存区域。内存区域包括:代码段、数据段、bss段、链接库相应内存区域信息、打开文件、共享内存和malloc等方式占用的内存映射等。内存使用mm_struct来描述进程的地址空间;在创建进程时,fork函数调用copy_mm()函数复制父进程的内存描述符(用allocate_mm()函数分配),当线程创建时就会共享父进程的内存描述符。对内存区域的操作函数有find_vma、find_vma_pre、find_vma_intersection等函数。创建删除地址区间,用户态分别用mmap和munmap两个函数,内核空间分别用do_mmap()和do_munmap()函数。进程上下文切换就是从一个可执行进程切换到另一个可执行进程,分为两步,首先调用 switch_mm将内存映射从上一个进程切换到新进程,然后调用switch_to将处理器状态切换到新的进程的处理器状态,包括保存、恢复栈信息和寄存器信息。当一个进程被调度时,该进程的mm域指向的地址空间被装载到内存;如果一个线程被调度,由于其mm域为空,调度程序会保留上一个进程的地址空间,这样需要时内核线程可以使用前一个进程的也表。应用程序访问一个虚拟地址时,必须通过页表将虚拟地址转成物理地址,处理器才能解析地址访问请求。页表分三级:全局页表(用pgd_t类型数组记录)、中间页表(用pmd_t类型数组记录)和将最后一级页表(用pte_t类型页表示)。

Linux的内存管理是以物理页为单位(32位系统4KB,64位8KB),对应描述结构体为page;分为DMA、NORMAL和HIGHMEM等几个区,对应描述结构体为zone;以X86为例,DMA区范围是0~16MB,NORMAL是16MB~896MB,大于896MB的为高端内存。页分配函数是*alloc_pages函数,返回第一个page的结构体指针,再通过page_adddress()函数转成逻辑地址。另外__get_free_pages()函数可以直接分配并返回所请求页的第一个page的逻辑结构地址,还可以调用get_zoned_page()返回一个全清零的逻辑页地址。alloc_page和__get_free_page则返回一页的地址,__fress_pages、free_pages和free_page是相应的释放函数。我们还可以用kmalloc分配一个不小于指定大小的块的内存空间,相应的释放函数是kfree.而vmalloc类似于kmalloc返回虚拟地址连续,只是其对应的物理地址不一定连续,对应的释放函数是vfree。对于频繁分配和释放内存的管理,linux还建立了slab层以减少碎片、提高性能;kmalloc和任务描述符task_struct的内存使用就是在slab层的基础上。Slab的使用原则是先使用部分空闲的slab、没有的话再使用完全空的slab、仍没有的话可以分配新的slab;slab分配器接口有kmem_cache_create、kmem_cache_alloc、kmem_cache_free、kmem_cache_destroy等,非常类似于交换芯片的group创建和销毁流程。高端内存不是永久映射到内核地址空间上,通过kmap和kunmap来建立映射和解除映射,还可以通过kmap_atomic和kunmap_atomic来建立临时映射和解除临时映射。另外linux还有支持每个CPU分配的相应体制。

Linux的常用数据结构有链表、队列、二叉树和映射等,BCM SDK有一套与linux相似的数据结构操作接口或宏的封装。Linux也需要对时间进行管理,常见的HZ和jiffies等均支持;定时器具体用法如下:

struct timer_listmy_timer;

init_timer(&my_timer);

my_timer.expires =jiffies + delay;

my_timer.data = 0;

my_timer.function  = my_function;

add_timer(&my_timer);

这样就可以执行定时器事件了,也可以用mod_timer()和del_timer()进行修改和删除操作,一般用 del_timer_sync()进行操作,防止定时器事件正在其他处理器上执行造成的并发访问;还可以timer_before函数实现长等待,也可以用udelay、ndelay和mdelay函数实现短等待。更理想的是用schedule_timeout()函数,采用的是让需要延迟的任务睡眠到指定的延迟时间耗尽后再重新执行,单位是jiffies。Linux还提供虚拟文件系统(VFS),无需考虑具体的文件系统类型和物理介质,VFS使得用户可以直接调用对文件的系统调用操作;这个是因为VFS提供了一个通用的文件系统模型,包括了所有文件系统的常用功能集和行为。VFS抽象了四个主要对象类型:超级块对象(一个已经安装的文件系统)、索引节点对象(一个文件)、目录项对象(目录项,文件路径的一部分)和文件对象(进程打开的文件);它们相应地有一系列的操作函数。并用file_system_type来描述特定的文件系统类型,和vfsmount来描述一个文件系统实例。VFS与进程相关的数据结构有file_struct(进程描述符中的目录指向)、fs_struct(进程描述符中的当前工作目录和根目录)和namespace(进程的命名空间)等。

上面是内核与上层用户态应用的通信和内部实现,下面就内核与硬件通信的相关功能进行总结。Linux采取当硬件有需要的时候向内核发出信息的中断机制,而非轮询方式。中断是一种特殊的电信号,可以让处理器终止当前的工作转而去处理中断,响应中断就是去调用一个处理函数,称为中断处理函数(中断服务例程),每个中断都有一个对应的中断号(多个中断可以共享一个中断号),称为中断请求线(IRQ)。调用request_irq函数注册中断,可以对其参数flags置IRQF_SHARED表示为共享中断号(需要该中断号当前空闲或所有已注册的中断都有此标记);另外request_irq可能会睡眠,不能再中断上下文或不允许阻塞的代码调用。;对应的释放函数是free_irq函数。中断处理程序运行在中断上下文中,不能调用可能睡眠的函数;中断处理程序栈在内核中有两页(可以配置为一页,以节省内存)。Linux内核提供另一组用于操作机器中断状态的接口(目的是需要同步,确保中断处理程序不会抢占当前执行的代码):用local_irq_disable& local_irq_enable禁止和激活当前处理器的中断,同样的另一种安全方式的函数为local_irq_save&local_irq_restore,宏irqs_disable返回当前处理中断是否被禁止;可以对特定中断号进行操作的disable_irq、disable_irq_nosync&enaable_irq、enable_irq_nosync函数;判断内核是否处于执行中断处理程序的两个宏:in_interrupt()和in_irq,加上软中断的in_softirq宏三者区别是:

#definein_irq()   (hardirq_count())      //判断当前是否在硬件中断上下文
#define in_softirq()  (softirq_count())  //判断当前是否在软件中断上下文
#define in_interrupt()  (irq_count())    //判断当前是否在硬件、软件、后半部中断上下文。

上面说的中断后半部是指中断必须被尽快处理,比如网卡收包,当数据包到来时必须尽快通知linux内核收包;linux接到通知后进行数据包从缓存到内存的拷贝,然后就返回了。至于后面的数据包处理则由中断后半部来出处理。后半部机制有BH(最原始的后半部处理机制,由32个bottom halves组成,现在已经基本不用)、任务队列(和进程上下文一样被调度,与BH一样在linux内核2.5 版本中被去除)、tasklet和软中断(linux现在使用的机制,tasklet可以动态注册,软中断必须编译时静态注册,tasklet也是一种软中断,开发者应当尽可能选择tasklet)。

Linux在读取设备时,如果能够随机选择固定大小的块读取,那么该设备成为块设备(blkdev);如果是按照字节流方式有序读取成为字符设备(cdev);另外一种是跟网络有关的称为网络设备(ethernetdevices);相应设备的驱动程序也是这么区分的。Linux的设备驱动程序采用了sysfs表示设备树的一个文件系统(相关操作函数是kobject_add、kobject_create_and_add、kobjec_del),设备的数据结构也采用了简单的面相对象技术,还支持按需加载和卸载目标代码的模块化技术,有三个很重要的数据结构kobject、ktype和kset。对于块设备最小寻址单位是扇区(一般是2的整数倍),内核要求块大小必须是扇区的2的整数倍,但是又不能大于一个物理页的大小。一个块调入内存时需要一块缓存区,用结构体buffer_head表示;而用bio结构体来以链表形式组织活动的块I/O操作。负责提交I/O请求并将磁盘I/O资源分配给系统中挂起I/O请求的程序称为I/O调度程序;所有的I/O请求用结构体request表示,被保存在一个双向链表reques_queue结构体中。I/O调度程序的两个基本工作时合并(对多个相邻磁盘区操作的I/O请求进行何必)和排序(将请求按照扇区增长方向进行有序排列),以减少磁盘寻址时间。调度算法有linus电梯调度(首先如果有一个相邻磁盘请求则合并,其次如果存在一个驻留时间长的请求则插入到请求队列队尾,然后如果请求队列以扇区方向有序排列则插入到合适位置,否则最后插入队尾)、最终期限I/O调度(以降低全局吞吐量为代价解决linus电梯调度的饥饿问题,将调度队列新增读FIFO队列和写FIFO队列,当请求到来时在插入排序队列同时相应的根据动作类型以时间顺序插入到FIFO队列中;调度时从排序队列中将请求发送到派发队列进行执行;如果写FIFO或读FIFO队列对头请求有超时:写500ms或读5s,则执行读或写FIFO里的请求)、预测I/O调度(解决最终期限I/O调度带来的全局吞吐量低的问题,用的方式是请求提交后不直接返回处理其他请求,而是会有意空闲片刻,这段时间应用程序可以提交读请求,以减少进行I/O操作期间处理新到读请求所带来的寻址数量)、完全公正队列I/O调度(将调度请求放入多个队列中的特定队列,调度程序以时间片轮转来调度队列)和空操作I/O调度(只有合并,没有排序,适合用于随机访问设备的I/O请求)等。另外,还用高速缓存机制减少对磁盘的I/O操作,即当内核开始读操作时先去缓存中看是否已有缓存的数据,如果有则称为缓存命中,直接读取;如果缓存中没有则称为缓存没有命中,需要从磁盘中读取,并拷贝到缓存,以后的读操作就可以从缓存中直接读取。页高速缓存用address_space对象描述,里面有一个page_tree来保存radix tree以指定文件偏移量,以便能被快速检索。当linux内核对数据写操作时,有三种策略:不写缓存直接写硬盘、同时写缓存和硬盘和回写策略(当缓存脏数据驻留时间超过一个阈值、内存低于一个阈值或进程调用sync和fsync系统调用时才将数据写回磁盘,而且用flusher多回写线程)。当有更重要的缓存、缓存数据清除或收缩缓存大小时,Linux缓存有时候需要回收,可采用的算法有最近最少使用算法(LRU)和双链策略(两个队列,一个是活跃链表,一个是非活跃链表)。

另外随着linux内核支持多处理器和抢占式调度,会造成并发访问(中断、软中断、tasklet、内核抢占、睡眠、多处理器同时执行)或竞争条件(两个线程处于同一个临界区中同时执行,临界区应当对共享数据的访问和操作不能被打断);我们用对数据加锁来对数据进行保护,但是会有锁的争用问题(当锁被占用时,其他线程试图获取该资源),甚至会产生死锁的问题(线程相互等待已被对方占用的资源)。Linux提供了内核同步的方法:原子操作(原子数操作-atomic_set、atomic_add、atomic_inc、atomic_dec_and_test,原子位操作-set_bit、clear_bit、change_bit、test_and_set_bit)、自旋锁(spin lock,最多被一个可执行线程持有,如果另外一个线程试图获取,那么一直将会忙循环-旋转-等待锁重新可用,可以用于都处理器程序和中断处理程序,相关的函数有spin_lock&spin_unlock、spin_lock_irq&spin_unlock_irq、read_lock&read_unlock、write_lock&write_unlock等)、信号量(一种睡眠锁,如果一个进程试图获取一个不可用的信号量时会被推进一个等待队列然后让其睡眠从而释放CPU;可用于进程上下文;分为可有多个持有者的计数信号量和互斥信号量,相关函数有sema_init 、DECLARE_MUTEX、init_MUTEX、down_interruptible、up_sem、DECLARE_RWSEM、down_read&up_read、down_write &up_write)、互斥体(一个更简单的睡眠锁,持有者进程不可以退出,不能用于中断或者下半部,同时只有有一个持有者,相关函数有DEFINE_MUTEX、mutex_init、mutex_lock、mutex _unlock等)、完成变量(一个任务发出信号通知另一个任务特定时期发生,相关操作有DECLARE_COMPLETION,init_completion,wait_for_ completion和complete等函数)、BLK(大内核锁,一个全局自旋锁,相关操作有lock_kernel、unlock_kernel和kernel_locked函数)、顺序锁(依靠一个序列计数器用于读写共享数据,先关操作有DEFINE_SEQLOCK、write_seqlock、write_sequnlock等函数)、禁止抢占内核(相关操作函数有preempt_disable和preempt_enable等)、顺序和屏障(主要是rmb、wmb和mb函数来保证以指定的顺序发出对内存的读写操作指令)等技术。

Linux的内容其实还应有对linux设备驱动程序和协议栈部分的描述,但是书中未有详尽的讲解。由于linux内核知识内容博大精深,上面简短的几页内容仅仅是其部分内容一个摘要,再加上linux内核的飞速发展,需要不断学习才能跟上linux内核的发展步伐,有兴趣学习linux内核的同学一起来吧,还希望有经验的同学多多指导。


linux协议栈函数调用图:



0 0