当前位置: 首页 > Linux

《Linux内核设计与实现》笔记

时间:2023-04-06 18:53:13 Linux

《Linux内核设计与实现》笔记参考:《Linux内核设计与实现》读书笔记第1章介绍运行在内核地址空间的LinuxKernel。1.简单。2.效率:所有内核都在一个很大的地址空间中,所以内核函数之间的调用类似于调用函数,几乎没有性能开销。一个函数的崩溃会导致整个内核无法使用。微内核内核在功能上分为独立的进程。每个进程都在自己的地址空间中独立运行。1、安全性:内核各项服务独立运行,一个服务出现故障不会影响其他服务。内核服务之间的进程间通信复杂且低效。因为IPC机制的开销要大于函数调用,而且因为涉及到内核空间和用户空间的上下文切换,所以消息传递需要一定的周期,而单内核中简单的函数调用则没有这些开销。尽管Linux内核基于单个内核,但它运行在单独的内核地址空间中。但是经过这么多年的发展,它也具备了微内核的一些特点。(体现Linux实用性至上的原则)主要有以下特点:模块化设计,支持动态加载内核模块,支持对称多处理(SMP)内核可抢占(preemptive),让内核运行的任务有能力先执行,支持内核线程,不区分线程和进程2.内核版本号内核的版本号主要由四个数组组成。例如版本号:2.6.26.1其中:2-主版本号6-次版本号或次版本号26-修订号1-稳定版本号次版本号表示这个版本是稳定版本(偶数)还是开发版(奇数),上例中的版本号为稳定版。稳定版本可用于企业级环境。修订号升级包括错误修复、新驱动程序和添加新功能。稳定版本号主要是对一些关键bug的修改。第二章从内核说起1.获取内核源码内核是开源的,获取源码非常方便。参考以下网址通过git或直接下载压缩源码包。http://www.kernel.org2。内核源代码目录结构arch特定架构codeblock块设备I/O层cryo加密APIDocumentation内核源代码文档driversdevicedriverfirmwaredevicefirmwarerequiredtousecertaindriversfsVFSandvariousfilesystemsincludekernelheaderfilesinitkernelboot和初始化ipc进程间通信代码内核核心子系统,如调度程序lib相同的内核函数mm内存管理子系统和VMnet网络子系统示例示例,示例代码脚本编译内核使用的脚本securityLinux安全模块声音语音子系统usr早期用户空间代码(所谓的initramfs)toolsLinux开发中有用的工具virt虚拟化基础设施内核已经用yum更新了。这部分将在手动编译后添加。安装新内核后,重启时会提示进入哪个内核。多次安装新内核时,引导列表会很长(因为内核版本很多),不是很方便。这里有3种删除那些不用的内核的方法:(如何安装选择相应的删除方法)rpm删除方法rpm-qa|grepkernel*(查找所有linux内核版本)rpm-ekernel-(要删除的版本)yumdelete方法yumremovekernel-(要删除的版本)手动删除删除/lib/modules/目录下不需要的内核库文件delete/usr/src/kernel/目录下不需要的内核源代码删除/boot目录下启动的核心文件和内核镜像更改grub的配置,删除不需要的内核启动列表连最常用的printf函数都没有,但幸运的是有一个printk函数代替。4.2使用GNUC因为使用了GNUC,所以GNUC的一些扩展经常被用在所有内核中:内联函数内联函数在编译时会在调用的地方进行扩展,减少了函数调用的开销,性能更好.但是,频繁使用内联函数也会使代码变长,从而在运行时占用更多的内存。因此,使用内联函数最好满足以下几点:函数小,会被重复调用,对程序的时间要求比较严格。内联函数的例子:staticinlinevoidsample();内联汇编内联汇编用于靠近底层或对执行时间要求严格的地方。例子如下:unsignedintlow,high;asmvolatile("rdtsc":"=a"(low),"=d"(high));/*low和high分别包含64位时间戳的低32位和32位的高位*/branchstatement如果你能提前判断一个if语句是常为真还是常为假,那么你就可以使用unlikely和likely来优化这个判断的代码。/*如果错误大部分时间为0(假)*/if(不太可能(错误)){/*...*/*如果成功大部分时间不为0(真)*/if(可能(success)){/*...*/}4.3没有内存保护因为内核是最底层的程序,如果内核访问非法内存,整个系统就会挂掉!因此,内核开发的风险要大于用户程序开发的风险。内核中的内存没有分页。每使用一个字节的内存,物理内存就会减少一个字节。所以在内核中使用内存一定要慎重。4.4不使用浮点数,内核无法完美支持浮点运算。在使用浮点数时,需要手动保存和恢复浮点寄存器等繁琐的操作。4.5内核栈的大小小且固定。内核堆栈的大小是在编译内核时确定的。对于不同的体系结构,内核堆栈的大小是固定的,尽管有所不同。如何查看内核堆栈的大小:ulimit-a|grep"stacksize"4.6同步与并发Linux是一个多用户操作系统,因此必须处理好同步与并发操作,以防止因竞争而导致死锁。内核容易出现竞争条件。与单线程用户空间程序不同,内核的许多特性需要并发访问共享数据,这就需要同步机制来确保不会出现竞争条件,尤其是:Linux是一个抢占式多任务操作系统。内核的进程调度器动态地调度和重新调度进程。内核必须与这些任务同步。Linux内核支持对称多处理器系统(SMP)。因此,如果没有适当的保护,同时在两个或多个处理器上执行的内核代码很可能同时访问同一个共享资源。中断异步到达,不考虑当前正在执行的代码。换句话说,如果保护不当,完全有可能在代码访问资源的时候中断到来,使得中台处理程序访问同一个资源。Linux内核是抢占式的。因此,如果保护不当,内核中的一段正在执行的代码可能会被另一段代码抢占,从而可能导致多段代码同时访问同一资源。常用的争用解决方案是自旋锁和信号量。4.7可移植性Linux内核可用于不同的实现结构,支持多种硬件。因此,在开发时要始终注意可移植性,尽量使用架构无关的代码。第三章进程管理1.进程程序本身不是进程,进程是对正在执行的程序及其相关资源的总称。可能有两个或多个不同的进程在执行同一个程序。两个或多个并发进程还可以共享许多资源,例如打开的文件和地址空间。进程和线程是程序运行时的状态,是动态变化的。进程和线程的管理操作(如创建、销毁等)均由内核实现。Linux并没有严格区分进程和线程。对于Linux,线程只是一个特殊的进程。在现代操作系统中,进程提供了两种虚拟机制:虚拟处理器和虚拟内存。虚拟处理器给进程一种错觉,让这些进程感觉它们在独占使用处理器。虚拟内存让一个进程在分配和管理内存时,感觉自己拥有整个系统的所有内存资源。每个进程都有自己的虚拟处理器和虚拟内存。进程中的线程可以共享虚拟内存,但每个线程都有自己的虚拟处理器。进程创建和退出:进程在创建的那一刻就活跃起来。在Linux系统上,这通常是调用fork()系统的结果,它通过复制现有进程来创建一个全新的进程。调用fork()的进程称为父进程,新创建的进程称为子进程。在调用结束时,在与返回点相同的位置,父进程恢复执行,子进程开始执行。fork()系统调用从内核返回两次:一次返回到父进程,一次返回到新生成的子进程。创建一个新的进程就是立即执行一个新的不同的程序,然后调用exec()这??组函数就可以创建一个新的地址空间并将新的程序载入其中。在现代Linux内核中,fork()实际上是由clone()系统调用实现的。最终,程序通过exit()系统调用退出执行。该函数终止进程并释放其占用的资源。进程退出执行后,它被设置为僵尸状态,直到其父进程调用wait()或waitpid()。内核中进程的信息主要存放在task_struct(include/linux/sched.h)中。对于同一进程或线程,进程IDPID和线程IDTID是相等的。在Linux中,可以使用ps命令查看所有进程的信息:ps-eopid,tid,ppid,comm2。进程描述符和任务结构内核将进程列表存储在一个称为任务队列(tasklist)的双向循环链表中。链表中的每一项都是一个task_struct类型的结构,称为进程描述符(processdescriptor),定义在include/linux/sched.h文件中。进程描述符包含有关特定进程的所有信息。进程描述符中包含的数据可以完整地描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态等等。每个任务的thread_info结构都分配在其内核栈的末尾。结构中的task域存储了一个指向该任务实际task_struct的指针。2.2进程描述符的存储内核中大部分的进程处理代码都是直接通过task_struct进行的,因此通过current宏查找当前运行进程的进程描述符的速度就显得尤为重要,这个宏的实现对于不同的硬件架构是不同的,并且必须针对特定的硬件架构进行处理.有些硬件架构可以取出一个专门的寄存器来存放指向当前进程的task_struct的指针,从而加快访问速度.而有些架构如x86(其寄存器是notabundant),只能在内核栈尾创建一个thread_info结构,通过计算偏移量间接找到task_struct结构。2.3进程状态进程描述符中的state字段描述了进程的当前状态。系统中的每个进程都必须处于五个进程状态之一。TASK_RUNNING(运行中)——进程可执行;它要么正在执行,要么在运行队列中等待执行。这是在用户空间中执行的进程唯一可能的状态;此状态也适用于在内核空间中执行的进程。TASK_INTERRUPTIBLE(可中断)——进程正在休眠(即被阻塞),等待满足某些条件。一旦满足这些条件,内核就会将进程状态设置为运行。这个状态的进程也会因为收到信号而被提前唤醒,随时准备运行。TASK_UNINTERRUPTIBLE(不可中断)——这个状态和可中断状态是一样的,除了它不会被唤醒或者即使接收到信号也不会准备运行。这种状态通常发生在进程必须等待而不被中断或等待事件即将发生时。由于处于此状态的任务不响应信号,因此它们的使用频率低于可中断状态。TASK_TRACED(traced)—一个进程被另一个进程跟踪,例如通过ptrace的调试器。TASK_STOPPED(停止)——进程停止执行;该进程未运行且无法运行。通常这种状态发生在接收到诸如SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号时。此外,在调试期间接收到的任何信号都会使进程进入这种状态。进程状态之间的转换构成了进程的整个生命周期。2.4设置当前进程状态内核经常需要调整一个进程的状态。这时候最好使用set_task_state(task,state)函数:set_task_state(task,state);/*设置task任务的状态为state*/该函数将指定进程设置为指定状态。它设置内存屏障以强制其他处理器在必要时重新排序。否则等价于:task->state=state;2.5进程上下文一般程序都是在用户空间执行的。当程序执行系统调用或触发异常时,它会陷入内核空间。此时,我们说内核“代表进程执行”,处于进程上下文中。当前宏在此上下文中有效。除非在这段时间里有更高优先级的进程需要执行,并且调度器做出相应的调整,否则当内核退出时,程序会重新在用户空间执行。系统调用和异常处理程序是定义明确的内核接口。只有通过这些接口,进程才能被困在内核执行中——所有对内核的访问都必须通过这些接口。2.6进程族树Linux系统中进程之间存在明显的继承关系,所有进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。init进程读取系统的初始化脚本(initscript)并执行其他相关程序,最终完成系统启动的整个过程。系统中的每个进程都必须有一个父进程。相应地,每个进程也可以有零个或多个子进程。每个task_struct都包含一个指向其父进程tast_struct的parent指针,同时还包含一个子进程。子进程列表。init进程的进程描述符静态分配为init_task。3.进程创建在Linux和其他系统中创建进程有一个主要区别。Linux中创建进程分为fork()和exec()两步,而其他系统通常提供spawn()函数来创建进程并将其读入可执行文件,并开始执行。fork:通过复制当前进程创建一个子进程exec:读取可执行文件,加载到内存中并运行这里的exec()是指所有exec()家族函数。内核实现了execve()函数,在此之上还实现了execlp()、execle()、execv()和execvp()。3.1Copy-on-write传统的fork()系统调用:直接将所有资源复制到新创建的进程中。这种实现简单且低效,因为它复制的数据可能无法共享,或者更糟的是,如果新进程试图立即执行新图像,那么所有副本都将丢失。Linux的fork()是使用写时复制页面实现的:写时复制是一种延迟甚至消除复制数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一份。数据只在需要写入的时候才复制,这样每个进程都有自己的副本。资源的复制仅在需要写入时执行。在此之前,只有在只读模式下共享才会将地址空间上的页面复制推迟到实际写入发生时。在页面根本没有写入的情况下(例如,在fork()之后立即调用exec()它们不必被复制。fork()的实际成本是复制父页表并创建一个唯一的进程描述符。一般情况下,进程创建后会立即运行一个可执行文件。这种优化可以避免复制大量根本不会使用的数据(地址空间通常包含数十兆字节的数据)。由于Unix强调进程快速执行的能力,所以这个优化非常重要。3.2fork()Linux通过clone()系统调用来实现fork(),这个调用指定了需要共享的父子进程通过一个一系列的参数flagsResources.fork(),vfork()和_clone()库函数都是根据自己需要的参数flags调用clone(),然后clone()调用do_fork().do_fork()完成大部分工作在creation中,它在kernel/fork.c文件中定义。这个函数调用copy_process()函数,然后让进程开始运行。copy_process()函数所做的工作如下:调用dup_task_struct()为新进程分配内核栈、thread_info和task_struct等,内容与父进程相同。这时候子进程和父进程的描述符是完全一样的。检查新进程(进程数是否超过上限等),清理新进程的信息(如PID设置为0等),使其区别于父进程。新进程状态设置为TASK_UNINTERRUPTIBLE,保证不会投入运行。调用copy_ftags()更新task_struct的标志成员。调用alloc_pid()根据clone()的参数flag为新进程分配一个有效的PID,复制或共享相应的信息,做一些收尾工作,返回新进程的指针。copy_process()函数执行完毕后,返回do_fork()函数。如果copy_process()函数返回成功,那么新创建的子进程就被唤醒了,好投入运行。内核故意选择子进程先执行。因为一般子进程会立即调用exec()函数,这样可以避免copy-on-write的额外开销。创建进程的fork()函数实际上以调用clone()函数结束。创建线程和进程的步骤是一样的,只是最后传给clone()函数的参数不同。例如,通过普通的fork创建一个进程相当于:clone(SIGCHLD,0)创建一个与父进程共享地址空间、文件系统资源、文件描述符和信号处理程序的进程,即一个线程:clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND,0)在内核中创建的内核线程与普通进程还有一个主要区别:内核线程没有独立的地址空间,它们只能在内核空间中运行。这与前面提到的Linux内核是单内核有关。4.进程的终止与进程的创建是一样的。终止一个进程也有很多步骤:对子进程的操作(do_exit)将task_struct中的标识成员设置为PF_EXITING,调用del_timer_sync()删除内核定时器,确保没有定时器排队,并调用exit_mm()释放进程占用的mm_struct。调用sem__exit()使进程离开等待IPC信号的队列。调用exit_files()和exit_fs()释放进程占用的文件描述符和文件系统资源。将task_struct的exit_code设置为进程的返回值调用exit_notify()向父进程发送信号,并将其状态设置为EXIT_ZOMBIE以切换到新进程继续执行。子进程进入EXIT_ZOMBIE后,虽然永远不会被调度,相关资源也被释放,但是它占用的内存并没有被释放,比如创建时分配的内核栈,task_struct结构等,这些都被释放了由父进程。对父进程的操作(release_task)父进程收到子进程发送的exit_notify()信号后,删除子进程的进程描述符和所有进程独占的资源。从上面的步骤可以看出,需要保证每个子进程都有一个父进程。如果父进程在子进程结束之前结束怎么办?子进程在调用exit_notify()时已经考虑到这一点。如果子进程的父进程已经退出,那么当子进程退出时,exit_notify()函数会先调用forget_original_parent(),然后再调用find_new_reaper()寻找新的父进程。find_new_reaper()函数首先在当前线程组中寻找一个线程作为父进程,如果没有找到,就让init作为父进程。(linux启动时init进程一直存在)