读书笔记-《Linux内核设计与实现》(1~5章)

来源:互联网 发布:udp端口测试工具 编辑:程序博客网 时间:2024/06/06 20:41

第1章 Linux内核的简介

1.1 Unix

Unix强大的根本原因

  1. Unix很简洁:仅提供几百个系统调用并且有一个非常明确的设计目的;
  2. 所有东西都被当做文件对待:提供一套系统调用接口—open()、read()、write()、lseek()和close();
  3. 用C语言编写而成:移植力强;
  4. 进程创建非常迅速:调用fork();
  5. 拥有一套简单又稳定的进程间通信元语。

1.2 Linux

Linux系统的基础

Linux系统的基础:内核、C库、工具集和系统的基本工具

操作系统

操作系统是指整个系统中负责完成最基本功能和系统管理的那些部分。
其中系统管理的那些部分应该包括:

  • 内核
  • 设备驱动程序
  • 启动引导程序
  • 命令行shell
  • 其他种类的用户界面
  • 基本的文件管理工具
  • 系统工具

1.3 Linux与Unix的区别

  1. Linux支持动态加载内核模块;
  2. Linux支持对称多处理(多CPU系统)机制(SMP),而传统的Unix不支持只有部分变体的Unix才支持;
  3. Linux内核可以抢占,而Unix大多数不支持仅有少数变体的Unix才支持;
  4. Linux内核不区分线程和一般进程;
  5. Linux提供具有设备类的面向对象的设备模型、热插拔事件以及用户空间的设备文件系统(sysfs);
  6. Linux忽略了一些被认为是设计得拙劣的Unix特性和过时的标准;
  7. Linux体现了自由这个词的精髓。

第2章 从内核出发

内核源码树

这里写图片描述

编译内核

配置内核

配置选项可以用来决定哪些文件编译进内核,也可以通过预处理命令处理代码。这些选项要么是二选一,要么是三选一。其中三选一是加多了module选项,编译的时候这部分功能被编译成以模块的形式生成,驱动程序一般都用三选一的配置选项。
配置选项可以是字符串或者整数。

配置工具
命令行工具:

make config

图形界面工具:

make menuconfig   #ncurse库make gconfig      #gtk+

配置文件—.config

.config被放置在内核源码树中根目录下。修改了配置文件之后,或者更新新源码树的时候,应该验证与更新配置:

make oldconfig

编译

减少编译的垃圾信息

make > ../detritus 或make > /dev/null   #永无放回值的黑洞

内核开发的特点

1. 无libc库抑或无标准头文件

原因:(速度与大小)保证内核高效和简练。
头文件
基本头文件:内核源代码顶级目录下的include中
体系结构相关头文件:内核源代码树的arch/architecture/include/asm目录下
printk()函数:把格式化好的字符串拷贝到内核日志缓冲区上,syslog程序可以通过读取该缓冲区来获取内核信息。
printk()和printf()的最主要的区别:printk()允许通过设置优先级让syslog决定是否显示这条系统消息。

2. 必须使用GNU C

(1)内联函数

static inline void wolf(unsigned long tail_size);- static:关键字- inline:用于限定关键字

内联函数:编译时在它被调用的地方展开。
优点:减少了函数调用的开销,性能较好。
缺点:频繁的使用内联函数也会使代码变长,从而在运行时占用更多的内存。
定义内联函数特点:时间要求高,本身长度较短的函数,一般在头文件中定义。

在内核中,为了类型安全和易读性,优先使用内联函数而不是复杂的宏。

(2)内联汇编
Linux的内核混合使用了C语言和汇编语言。汇编语言用于偏近底层或对执行时间严格要求的地方。

(3)分支声明
对于条件选择语句,在一个条件经常/很少出现时,编译器可通过gcc内建的一条指令对条件分支选择进行优化。比如likely()(条件为真的频率高)和unlikely()(条件为真的频率很低)。内核把这条指令封装成了宏。

3. 没有内存保护机制

4. 难以执行浮点运算

5. 容积小而固定的栈

内核栈的大小是编译内核时决定的,对于不用的体系结构,内核栈的大小不一样,但都是固定的。每个处理器都有自己的栈。(不像用户空间的栈可以动态增长)

6. 同步和并发

原因:

  • Linux是抢占多任务操作系统
  • 内核支持对称多处理器系统(SMP)
  • 中断是异步到来的
  • 内核可以抢占

常用解决方法:自旋锁和信号量

7. 可移植性的重要性

大部分C语言代码与体系结构无关。必须把与体系结构相关的代码从内核代码树的特定目录中适当地分离出来。

第3章 进程管理

进程与线程

进程是出于执行期的程序。线程是进程中活动的对象,每个线程都拥有一个独立的程序计数器、进程栈、和一组进程寄存器。(内核调度的对象是线程,不是进程)
进程提供两种虚拟机制:虚拟处理器和虚拟内存。同一进程中的线程之间可以共享虚拟内存。

进程与线程的区别

进程的存放和表示(task_struct和thread_info)

进程描述符(task_struct)

进程描述符(一个结构体)中包含的数据能完整地描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态等等。

1、分配进程描述符
Linux通过slab分配器分配task_struct结构,这样能达到对象的复用和缓存着色的目的(通过预先分配和重复使用task_struct,可以避免动态分配和释放所带来的资源消耗)。

2.6以前的内核:各个进程的task_struct存放在他们内核栈的尾端。
2.6及2.6以后:用slab分配器动态生成task_struct,只需要在内核栈的尾端创建一个struct thread_info。

thread_info结构中有个指向该任务实际task_struct的指针。

2、进程描述符的存放

  • 内核通过唯一的进程标识值或PID来标识每个进程。
  • 内核把每个进程的PID存放在它们各自的进程描述符中
  • 内核访问任务通常需要获得指向其task_struct的指针
  • 通过current宏查找当前正在运行进程的进程描述符

3、进程状态

  • TASK_RUNNING(运行)—在用户空间中执行的唯一可能的状态(因为在用户进程看来,它是独享处理器的,就不存在像内核空间那样的就绪态和执行态的区别了)
  • TASK_INTERRUPTIBLE(可中断)—等待事件发生或接收信号
  • TASK_UNINTERRUPTIBLE(不可中断)—对信号不做响应外和可中断相同
  • TASK_TRACED(被跟踪)
  • TASK_STOPPED(停止)—没有投入运行也不能投入运行

4、设置当前进程的状态
内核调整某个进程的状态

set_task_state(task,state);    /*将任务task的状态设置为state*/

5、进程上下文
用户空间的进程陷入内核空间执行,称内核“代表进程执行”并处于进程上下文中。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行—对内核的所有访问都必须通过这些接口。

6、进程家族树

  • 进程间存在明显的继承关系
  • 所有进程都是PID为1进程的后代
  • 进程间的关系存放在进程描述符中
  • 每个进程描述符都有一个parent父进程指针和一个children的子进程链表

进程的创建(通过fork(),实际上最终是clone())

进程的创建分为两个步骤:fork()和exec().

  1. fork():通过拷贝当前进程创建一个子进程
  2. exec():负责读取可执行文件并将其载入地址空间开始运行
    进程创建调用流程:
    fork()/vfork()/_clone() -> clone() -> do_fork() -> copy_process()

copy_process()实现流程

  1. 调用dup_task_struct()为新进程分配内核栈,task_struct等,其中的内容与父进程相同。
  2. check新进程(进程数目是否超出上限等)
  3. 清理新进程的信息(比如PID置0等),使之与父进程区别开。
  4. 新进程状态置为 TASK_UNINTERRUPTIBLE
  5. 更新task_struct的flags成员。
  6. 调用alloc_pid()为新进程分配一个有效的PID
  7. 根据clone()的参数标志,拷贝或共享相应的信息
  8. 做一些扫尾工作并返回新进程指针

写时拷贝

写时拷贝是一种可以推迟甚至免于拷贝数据的技术。Linux的fork()使用的就是写时拷贝页实现,此时内核并不复制地址空间,以只读的方式共享,资源的复制只有在需要写入(父进程或子进程需要写入)的时候才进行。

  • 优势:一般的情况下,进程的创建马上就会运行一个可执行的文件,可以避免拷贝大量不会被使用的数据。

fork()

内核有意选择子进程首先执行(但是并非总能如此),因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能开始向地址空间写入。

vfork()

除了不拷贝父进程页表项外,vfork()系统调用和fork()的功能相同。
在进程创建的过程中,子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针像它发送信号。

fork()和vfork()的区别

  1. fork():子进程拷贝父进程的数据段和代码段
    vfork():子进程与父进程共享数据段
  2. fork():父子进程的执行次序不确定
    vfork():保证子进程先运行,在调用exec()或exit()之前与父进程数据共享,调用之后父进程才被调度运行
  3. vfork()保证子进程先运行,在调用exec()或exit()之前与父进程数据共享,调用之后父进程才被调度运行,如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁

线程在Linux中的实现

创建线程

创建线程和进程的步骤一样,只是最终传给clone()函数的参数不同。

比如,通过一个普通的fork来创建进程,相当于:clone(SIGCHLD, 0)
创建一个和父进程共享地址空间,文件系统资源,文件描述符和信号处理程序的进程,即一个线程:clone(CLONE_VM | CLONE_FS | CLONE_FILES |CLONE_SIGHAND, 0)

内核线程

内核线程和普通线程的区别在于内核线程没有独立的地址空间,它们只运行在内核空间,同样可以被调度,被抢占。

进程终结

进程终结的流程

进程终结需要调用do_exit(),具体流程如下:

  1. 设置task_struct中的标识成员设置为PF_EXITING
  2. 调用del_timer_sync()删除内核定时器, 确保没有定时器在排队和运行
  3. 调用exit_mm()释放进程占用的mm_struct
  4. 调用sem__exit(),使进程离开等待IPC信号的队列
  5. 调用exit_files()和exit_fs(),释放进程占用的文件描述符和文件系统资源
  6. 把task_struct的exit_code设置为进程的返回值
  7. 调用exit_notify()向父进程发送信号,并把自己的状态设为EXIT_ZOMBIE
  8. 切换到新进程继续执行

删除进程描述符

在调用了do_exit()后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。
父进程收尸—wait()
父进程受到子进程发送的exit_notify()信号后,将该子进程的进程描述符和所有进程独享的资源全部删除。

孤儿进程

当父进程先于子进程结束,那么必须保证子进程能够找到新的父亲。
子进程在调用exit_notify()时已经考虑到了这点。

如果子进程的父进程已经退出了,那么子进程在退出时,exit_notify()函数会先调用forget_original_parent(),然后再调用find_new_reaper()来寻找新的父进程。

find_new_reaper()函数先在当前线程组中找一个线程作为父亲,如果找不到,就让init做父进程。(init进程是在linux启动时就一直存在的)

第4章 进程调度

第5章 系统调用

0 0