目录

进程

进程是一个正在执行的程序实例,各进程拥有自己独立的地址空间:

  • 只能访问系统分配给它们的内存地址

  • 进程也不能直接访问内核功能,必须通过 system call 来完成

  • 0号进程为调度进程,是内核的一部分

  • 1号进程(init进程)由内核启动,读取 /etc/rc* /etc/inittab /etc/init.d 配置,是系统后来所有进程的祖先,是所有孤儿进程的父进程

内核活动:

  • 进程 process

  • 系统调用 system call

  • 内核线程 kernel thread

  • 硬件中断 Hardware IRQ

  • 软件中断 Software IRQ

  • tasklet

  • bottom half

硬件中断

外部设备用硬件中断来通知操作系统有重要的事件发生,中断发生后,CPU会暂时停止当前程序的执行,转去执行中断处理程序,中断处理程序结束后再恢复原来被停止程序的执行。

硬件中断是一种系统资源,当我们为设备编写中断处理程序来处理外部事件时,要向系统申请中断资源(即硬件中断信号线,通常称为中断号),并将中断处理程序与中断源相关联。

// 申请中断资源,将中断号irq_num与设备(名字devname+设备索引号dev_id)相连,中断处理程序handler 中断类型为flags
int request_irq(irq_num, handler, flags, devname, dev_id);
// 释放中断资源
void free_irq(int irq, dev_id);
// 想查看当前CPU是否在中断活动中
bool in_irq();

中断类型flags:

  • 快速中断 SA_INTERRUPT : 中断时间非常短,屏蔽当前运行中断处理程序CPU的其他中断,这样中断处理程序的执行就不会被别的中断打断。

  • 慢速中断 : 中断处理程序在执行期间可以被别的中断打断,不同的中断在几个CPU上可以同时运行,但某个中断的处理程序一次只能在一个CPU上执行。

  • 硬件中断可以打断当前CPU上所有其他的活动,并会屏蔽CPU其他硬件中断。

top half 和 bottom half

但不是所有中断事件处理都可以在很短时间内完成。

网络数据包的接收处理,从网络适配器收到的数据包到将数据包传送到用户接收进程,需要几千个CPU时钟才能完成。接收网络数据包这类事件虽然是由中断触发,但对网络数据包的处理却不能全部放在中断处理程序中来做。

为了节省系统资源,使中断处理程序的运行时间尽可能短,像上述这类费时的事件处理在Linux内核中将其分成两个部分来完成。

  • top half :只完成中断触发后最重要的任务处理,这里top half就对应中断处理程序。网络适配器的中断处理程序只将收到的数据包复制到内核的缓冲区后就立即结束返回,释放占有的中断和CPU。对数据包的协议分析和处理不在中断处理程序中进行。

  • bottom half : bottom half完成所有非紧急部分的处理。bottom half代码由top half调度,放在以后某个安全的时间运行。比如上述的网络数据包复制到内核以后的分析处理,就在这部分代码中完成。

bottom half与top half最大的区别是 :在bottom half执行期间,打开所有硬件中断。它们之间的关系是:top half将在设备缓冲区的数据复制到内核地址空间缓冲区,调度bottom half后退出,这个过程非常快。bottom half执行余下的处理任务,这样在bottom half工作过程中,CPU可以响应新的外部中断请求。

Linux 中有两种不同的机制来实现bottom half过程:tasklet和workqueue。

tasklet

tasklet是可以被调度执行的特殊函数,在系统某个特定的安全时间运行在软件中断的执行现场:

  • 由函数tasklet_schedule调度执行,一个tasklet只运行一次

    tasklet_schedule(struct tasklet_struct *t)
  • 一个tasklet一次只能在一个CPU上执行

  • 不同的tasklet可以同时在不同的CPU上运行

  • 所有的tasklet代码必须是原子操作

实现tasklet:

// 1. 定义tasklet处理函数
void my_func(unsigned long data){ ... } 
// 2. 准备 tasklet 处理的数据
char tasklet_data[] = "this is new tasklet";
// 3. 声明自定义的tasklet
DECLARE_TASKLET(taskletName, my_func, (unsigned long)&tasklet_data);
// 4. 放入调度表中
tasklet_schedule(&taskletName);

软件中断

是在硬件中断执行完后由内核的调度器(scheduler)调度执行的活动。软件中断和硬件中断的主要区别在于:硬件中断可以随时立刻打断CPU现行活动(如中断允许);软件中断是由内核调度器调度执行的活动。软件中断必须要等到调度器调用它才能执行,软件中断的调度由内核函数do_softirq完成。

void do_softirq(void); // 软件中断的处理程序在do_softirq后开始执行

软件中断的执行时间只有两处:

  • 系统调度结束后(在schedule中)被调度执行

  • 硬件中断结束后(在do_IRQ中)被调度执行

  • Linux内核中最多可以定义32个软件中断

网络子系统使用的软件中断有

  • NET_RX_SOFTIRQ:处理网络接收到的数据包

  • NET_TX_SOFTIRQ:处理要发送的网络数据包

  • TASKLET_FOFTIRQ: 用于实现 tasklet 的概念

软件中断与 tasklet 和 bottom half 有很大区别,软件中断最重要的特性是:

  • 软件中断可以同时在多个处理器上运行,所以在编写软件中断处理程序时必须要考虑重入问题。如果在软件中断处理程序中要访问共享全局变量,必须采用锁定机制执行并发访问。

  • 软件中断本身不能被同类的软件中断打断

  • 软件中断在执行时只能被硬件中断打断

互斥机制 mutex

在Linux内核中几种不同的活动可能被相互打断;在多处理器环境下,不同的活动可以并行执行。如果内核中的各活动相互独立,没有交叉访问,就不会引起任何问题。一旦几个活动要访问同一数据结构,如果不采取相应的保护机制,即使在单CPU的系统中都可能会引起预想不到的结果。

spin lock

这种锁定机制在执行时间一次只能由一个线程持有锁。如果一个线程已持有了锁,另一个执行线程想要获取锁时,就只能循环等待直到前一个线程将锁释放,也即在此期间处理器不做别的处理,一直在循环测试锁的状态。

因此spin lock只适用于多处理器系统运行环境,而且通常用于预计锁可以在很短时间内就能获取的情况下。

使用spin lock时,要求持有锁的线程不能休眠,否则会造成别的线程因不能获取锁而发生系统死锁的情况。

PS : 当要保护的程序片段执行时间非常短时,如果我们采用的不是spin lock锁定机制,而是采用另一种方式,比如,当CPU测试到锁被别的活动持有,就用调度器将当前活动换出CPU,调度别的进程来执行;锁有效后再把该活动调度进来执行,其中调度进程进出CPU所花费的时间可能比处理器忙而等待所用的时间更长,效率更低。

#include <linux/spinlock.h>
spinlock_t my_spinlock = SPIN_LOCK_UNLOCKED; // 初始化锁变量,或使用函数初始化锁变量
spin_lock_init(&my_spinlock); // 将锁初始化为未锁定状态

// 获取锁
spin_lock(spinlock_t *my_spinlock); 
// 获取锁,自动屏蔽中断,将CPU的当前状态寄存器值保存到变量flags中
spin_lock_irqsave(spinlock_t *my_spinlock, unsigned long flags);
// 获取锁,不保存CPU状态寄存器的值,它假定中断已经屏蔽了
spin_lock_irq(spinlock_t *my_spinlock);
// 获取锁,同时阻止bottom half的运行
spin_lock_bh(spinlock_t *my_spinlock); 

// 释放锁
spin_unlock(spinlock_t *my_spinlock);
// 释放锁,并置中断允许
spin_unlock_irqrestore(spinlock_t *my_spinlock);
// 释放锁,允许中断
spin_unlock_irq(spinlock_t *my_spinlock);
// 释放锁,并允许立即处理bottom half
spin_unlock_bh(spinlock_t *my_spinlock)

读写 spin lock

对于读操作数量远大于写操作数量的优化锁。

它允许多个读操作的活动同时运行其保护部分的代码,但一旦锁被写操作获取后,所有的活动只能等到写操作释放锁后才能访问共享数据。

Read-Copy-Update 锁机制

这种锁定机制在以下特定的条件下执行效率非常高:

  • 相对于只读锁的要求,要求读-写锁的次数非常少(考虑的是执行效率,要是不满足,在需要使用锁定机制时,更好的方式就是使用读-写spin lock)

  • 持有锁的代码是以原子方式执行,绝不会休眠(RCU工作原理的基础)

  • 被锁保护的数据结构是通过指针访问的(RCU工作原理的基础)

当数据需要修改时,写线程获取一个数据的拷贝,修改拷贝,随后将相关的指针改到新版本的数据结构上,当内核确定不再有对旧版本数据的引用时,旧数据就可以释放了。

设计内核活动时:

  • 使这些活动能并行执行

  • 在多处理器环境下也要考虑:

    • 同一活动的同一实例是否可以在不同处理器上并行运行

    • 同一活动的不同的实例是否可以同时在不同处理器上并行

活动同一实例不同实例
硬件中断NY
软件中断YY
taskletNY
bottom halfNN

一种活动是否可以被其他的活动中断,即活动的优先级:

活动硬件中断软件中断taskletbottom half
硬件中断YNNN
软件中断YNNN
taskletYNNN
bottom halfYNNN
system callYYYY
processYYYY
getpid(void);         // 当前进程 ID
getppid(void);        // 当前进程的 父进程ID
getuid(void);         // 当前进程的 用户ID
geteuid(void);        // 当前进程的 有效用户ID
getgid(void);         // 当前进程的 组ID
getegid(void);        // 当前进程的 有效组ID
setuid(uid_t uid);    // 改变进程所有者
seteuid(uid_t uid);
setgid(gid_t gid);
setegid(gid_t gid);

创建进程

pid_t fork(void);           // 分叉,创建子进程,返回0;父进程返回 子进程pid

创建子进程后,子进程复制父进程的文件描述符、数据空间、堆和栈,独立出来,共享的只有正文段。fork后,父子进程处于同样起跑线,谁先执行是不确定的。

由于文件描述符是指针,所以父、子进程的文件描述符实际上还是指向同一个文件表,因此子进程对文件描述符的操作会影响到父进程,反过来也一样。所以fork后,为了避免父、子进程相互影响,两者都应该关闭自己不用的文件描述符(删除指针)。

子进程几乎继承了父进程的所有属性(各种用户ID、工作目录、umask、环境),除了文件锁、闹钟、信号集。

fork一般有两种应用,下面分别说明。

网络服务进程

父进程收到到请求后,希望复制自己,通过子进程执行代码段里不同的代码分支,处理请求。而父进程继续等待下一个客户端的请求。

执行不同程序

这是shell的常见情况,fork子进程返回后,立即调用exec加载另一个程序的代码段来执行。

这种特殊情况,人们对fork进行了优化,重新实现了一个vfork()系统调用。主要改进有:

  • vfork()子进程不再复制父进程的地址空间内容,所以效率更高

  • vfork()保证子进程一定先运行,在它调用exec或者exit之后,父进程才能运行

进程退出

进程正常退出的话,会有return; exit(); _exit(); 线程return; pthread_exit()5 种方式。不正常退出有abort()、接受某些信号 和 线程cancellation取消。

pid_t wait(int *statloc);
pid_t waitpid( pid_t pid, int *statloc, int options );
int   waitid( idtype_t idtype, id_t id, siginfo_t *infop, int options );
pid_t wait3(int *statloc,int options,struct rusage *rusage);
pid_t wait4(pid_t pid,int *statloc,int options,struct rusage *rusage);

PCB(Process control block)进程控制块:

  • 进程状态

  • 程序计数器:要执行的下个指令的地址

  • CPU 寄存器

  • CPU 调度信息:进程优先级、调度队列指针、调度参数

  • 内存管理信息:内存基址、界限寄存器的值、页表、段表

  • 记账信息:CPU 时间、实际使用时间、时间界限、记账数据、作业或进程数量

  • I/O 状态信息:分配给进程的 I/O 设备列表、打开的文件列表

内核模块系统

当我们要为内核扩展一个新的功能时,特别是某些组件需要不断更新(如设备驱动程序,随着新设备的不断推出,要不断增加新驱动程序)时,每次小的改动,都需要编译整个操作系统,这是一个非常耗时的过程。Linux解决这个问题的方式是内核模块(module)。在需要的时候这些内核模块可以在运行时动态地加入到系统中。当不再需要的时候,可以将其从系统中移走。

可以使用模块方式来扩展功能的组件有:

  • 设备驱动程序

  • 文件系统

  • 网络协议和网络设备驱动程序

模块的使用实际上并不仅仅局限于这些组件,它可以作为一个独立功能加入到内核中。向内核加入新功能时,内核中也需要相应的接口去通知内核的其他部件关于新功能的信息。Linux网络体系结构中的接口和在其中扩展新功能的实现方式是本书要讨论的一个主要问题。

内核模块由目标代码组成,它在运行时装载到内核地址空间并运行。在系统启动时,内核事先并不知道会有什么功能的模块会装载到系统中,所以模块必须自己通知内核,让相应的组件知道模块加载与否。当模块移走时,它也需要移走所有在内核地址空间对它的引用,释放占用的系统资源。这里有两个方法是用来完成以上任务的。

  • init_模块:向内核注册由模块提供的所有功能_

  • cleanup 模块:撤销任何由init_模块所做的功能

# 用于装载一个内核模块到内核地址空间。成功后,模块的目标代码就被链接到内核中了,这样模块就可以访问内核的符号(函数和数据结构
$ insmod 模块name.o [arguments] #调用后,会执行以下system call
# sys_create_模块:给模块在内核地址空间分配其驻留所需的内存  
# sys_get_kernel_syms:返回内核的符号表,解决模块中尚未连接的对内核符号的引用
# sys_init_模块:复制模块的目标代码到内核地址空间,并调用模块的初始化函数(init_模块)执行模块的初始化功能
    
$ insmod mylan_cs.o eth=1 network_name="myWavlan"

$ rmmod 模块name  # 从内核空间移除模块
# 引起系统调用函数sys_delete_模块的执行
# 而sys_delete_模块系统会调用模块的清除函数cleanup_模块
# 这样模块就从内核地址空间卸载了
    
$ lsmod # 列表当前所有装载了的模块以及它们相互的依赖关系和引用计数
$ modinfo # 给出关于模块的信息(它的功能、参数和所有者等)使用宏MODULE_DESCRIPTION,MODULE_AUTHOR在模块的源代码中定义。