前言
阅读书籍——Linux内核设计与实现的读书笔记
一、进程管理
1.1、进程创建
Linux中将传统的fork拆分成fork和exec两个函数,其中fork系统调用使用写时拷贝(Copy-On-Write,COW)技术来提高效率。写时拷贝是一种内存管理策略,用于在创建子进程时最小化内存的复制。具体来说,写时拷贝的概念如下:
- 当父进程调用fork创建子进程时,操作系统并不会立即复制父进程的整个地址空间(包括数据、堆、栈等)给子进程。这将是非常耗时和昂贵的操作,特别是当父进程的地址空间很大时。
- 相反,操作系统使用写时拷贝策略。在fork时,操作系统将父进程的地址空间标记为“只读”(read-only)。这意味着父进程和子进程共享相同的物理内存页面,而且不会立即复制数据。
- 如果父进程或子进程尝试修改这些只读页面中的数据(写入),则操作系统会觕发一个异常。在这种情况下,操作系统会为子进程创建一个新的物理内存页面,复制父进程的数据,并使子进程修改该数据而不影响父进程。
写时拷贝的优点是:
- 在创建子进程时,不需要立即复制整个内存空间,因此创建速度更快。
- 如果子进程不修改数据,父进程和子进程可以共享相同的内存,节省了内存空间和时间。
- 写时拷贝使得fork操作更加高效,特别是在创建大型进程时,因为它避免了不必要的内存复制。这种策略充分利用了现代操作系统提供的虚拟内存管理和硬件内存保护机制,以实现高效的进程创建和共享。
fork创建子进程依次调用clone, do_fork, copy_process,其中copy_process工作流程如下:
- 调用dup_task_struct创建新的内核栈、thread_info结构和task_struct,这些进程描述符的值和父进程相同
- 检查资源限制
- 除task_struct中的大部分值,其他进程描述符中的许多成员都要被置0,以和父进程区分
- 将子进程状态设为TASK_UNINTERRUPTIBLE
- 调用copy_flags更新task_struct的flags成员
- 调用alloc_pid为新进程分配一个有效pid
- 根据clone传来的参数,copy_process拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等
- 返回指向子进程的指针
整个fork结束后,内核将子进程的状态设为run,并会选择让子进程优先执行(但并不总是能得到优先执行)。子进程开始运行时都会马上调用exec函数来加载新程序的代码和数据,在这个过程中,原来的进程属性(如PID)保持不变,但进程的内容完全被替换。 fork和vfork区别在于vfork不拷贝父进程的页表项。
1.2、 进程终结
进程终结的大部分任务靠do_exit完成
- 将task_struct中的标志成员设置为PF_EXITING
- 调用del_timer_sync删除任一内核定时器
- 如何BSD功能开启(BSD功能记录和追踪操作系统中运行的各个进程的活动,包括其资源使用情况和行为),就调用acct_updata_integrals来输出记账信息
- 调用exit_mm函数释放进程占用的mm_struct(mm_struct记录进程的内存管理信息。它包含了有关进程内存使用和管理的各种信息,如虚拟内存区域的布局、物理内存页面的分配和释放等)
- 调用sem_exit函数
- 调用exit_files和exit_fs来处理文件描述符和文件系统数据的引用计数
- 修改task_struct中的标志位exit_code
- 调用exit_notify向父进程发送信号,并将自身的状态设置为EXIT_ZOMBIE(处于EXIT_ZOMBIE状态的任务是不会再被调度的)
- do_exit调用schedule切换到新的进程
二、进程调度
2.1 公平调度
参考:
一文搞懂linux cfs调度器
深度解析linux进程调度器—名词解释
Linux进程睡眠和唤醒以及无效唤醒
2.1.1 概念
CFS允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法。nice值作与处理器使用权重相关,CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice值来计算时间片。每个进程按其权重在全部可运行进程中所占比列的时间片来运行。
它给cfs_rq(cfs的run queue)中的每一个进程设置一个虚拟时钟,vruntime。如果一个进程得以执行,随着时间的增长(一个个tick的到来),其vruntime将不断增大。没有得到执行的进程vruntime不变。调度器总是选择vruntime最小的那个进程来执行。这就是所谓的“完全公平”。为了区别不同优先级的进程,优先级高的进程vruntime增长得慢,以至于它可能得到更多的运行机会。
runtime 和 vruntime 计算如下:
runtime =调度周期 * weight / cfs_rq_weight
vruntime = runtime*nice_n_weight/weight
其中weight代表该进程的权重,nice_n_weight代表着nice值为n的权重cfs_rq_weight代表cfs中所有进程的权重合。weight是可变的而nice_n_weight是个固定值
2.1.2 实现
linux调度部分由时间记账、进程选择、调度器入口、睡眠和唤醒四个部分组成
2.1.2.1 时间记账
无论进程处于可运行态还是阻塞态,系统定时器周期性的通过updata_curr来更新当前进程的执行时间,并计算出对应的vruntime来记录一个程序运行了多久以及它还应该运行多久。
2.1.2.2 进程选择
CFS的核心就是挑选一个vruntime最小的进程开始运行,因此,cfs是抢占式的,并且linux最大抢占延时是6ms,调度粒度是0.75ms,即意味着一个进程最小运行时间为0.75ms,并且被抢占后最迟6ms能重新被运行。
- 任务的选取在__pick_next_entity函数中实现,通过寻找红黑树最左端节点,即每次都选择vruntime最小的任务。为了效率,缓存了一个最左侧节点,并在每次更新红黑树后更新缓存。__pick_next_entity的返回值就是下一个要运行的进程,如果返回值为NULL,代表红黑树中没有任何节点,CFS便会选择idle任务运行。
- 当进程变为可运行态或者是通过fork被第一次创建出来时,会调用enqueue_entity中实现向树中添加进程。在插入过程中维护了一个标志位letfmost,一旦转向右分支意味着不是最新节点,letfmost为0,否则只有当插入的进程一直向左移动,才说明需要更新缓存中的最左侧节点
2.1.2.3 调度器入口
调度器入口为schedule函数,该函数调用pick_next_task来选择下一个要运行的进程。pick_next_task的核心在于for循环遍历,从最高优先级的调度类往低优先级遍历,挨个调用对应类的pick_next_task函数,直到找到下一个要运行的进程。
优先级如下
2.1.2.4 睡眠与唤醒、
1、无效唤醒
//A进程:
spin_lock(&list_lock);
if(list_empty(&list_head)) {
spin_unlock(&list_lock);
//无效唤醒点
set_current_state(TASK_INTERRUPTIBLE);
schedule();
spin_lock(&list_lock);
}
/* Rest of the code ... */
spin_unlock(&list_lock);
//B进程:
spin_lock(&list_lock);
list_add_tail(&list_head, new_node);
spin_unlock(&list_lock);
wake_up_process(processa_task);
A进程执行到无效唤醒点的时候,B进程被另外一个处理器调度投入运行。在这个时间片内,B进程执行完了它所有的指令,因此它试图唤醒A进程,而此时的A进程还没有进入睡眠,所以唤醒操作无效。在这之后,A 进程继续执行,它会错误地认为这个时候链表仍然是空的,于是将自己的状态设置为TASK_INTERRUPTIBLE然后调用schedule()进入睡眠。由于错过了B进程唤醒,它将会无限期的睡眠下去,这就是无效唤醒问题,因为即使链表中有数据需要处理,A 进程也还是睡眠了。
2、内核的实现
无效唤醒在逻辑上有点类似于线程竞争的虚假唤醒,linux内核会在逻辑判定时多层while循环的判定,并且最先就将自身状态设置为不可打断状态。内核实现等待与唤醒的流程如下:
- 调用宏DECLARE_WAIT创建等待队列的项
- 调用add_wait_queue
- 调用prepare_to_wait变更自身状态为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE
- 当满足条件且进程状态为非运行态时,该进程就会被唤醒
- 将自身状态设置为TASK_RUNNING,并通过调用finish_wait将自身移除等待队列
参考代码如下:
DECLARE_WAIT(wait,current);
add_wait_queue(q,&wait);
/*condition是等待的条件*/
while(!condition){
prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
if (signal_pending(current)) schedule();
}
set_current_state(TASK_RUNNING);
finish_wait(q,&wait);
内核的实现和函数inotify_read(此函数负责从通知文件描述符中读取信息)的功能类似,inotify_read的实现是等待队列的一个典型用法。
2.2 抢占和上下文切换
抢占分为内核抢占和用户进程抢占,抢占发生的时机:
- 用户抢占:
- 从系统调用返回用户空间时
- 从中断处理程序返回用户空间时 - 内核抢占:
- 中断处理程序正在执行,且返回内核空间时
- 内核代码再一次具有可抢占性的时候
- 内核中任务显式地调用schedule
- 内核中的任务阻塞
判断是否可以抢占
- 内核提供了need_resched标志来表明是否需要重新执行一次调度,当某个进程应该被抢占时,scheduler_tick就会设置这个标志,当一个高优先级的进程进入可执行状态时,try_to_wake_up也会设置这个标志,内核检查该标志确认被设置后,调用schedule来切换到一个新的进程
- 通过标志位preempt_count判断是否持有锁,使用锁时数值加1,释放时减1
三、系统调用
在linux中每个系统调用都被分配了一个独一无二的调用号,当系统调用被删除后只会的调用sys_ni_syscall(sys_ni_syscall除了返回-ENOSYS外不做任何工作),而不会被回收和删除。这样做是防止被以前编译过的代码通过调用这个系统调用时,实际上却变成调用其他的系统调用。
应用程序调用系统函数时,内核是通过软终端实现的,通过引发一个异常来促使系统切换到内核态去执行异常处理程序,此时的异常处理程序就是系统调用的处理程序。在调用系统函数时,传入的参数按顺序放在ebx、ecx、edx、esi、edi存放前五个参数。
这些参数在调用时会被系统检查是否有效,例如如果参数是指针,内核需要检查指针指向的地址必须是该进程的用户空间,不允许指向内核空间或是其他进程的地址空间。
四、内核数据结构
内核的数据结构主要为链表、队列、映射和二叉树,其中队列、映射和二叉树和stl没什么区别
4.1 链表
内核的链表区别于stl链表的地方在于它不是将数据结构塞入链表,而是将链表节点塞入数据结构,即
struct list_head
{
struct list_head* next;
struct list_head* prev;
};
struct person
{
uint32_t age;
uint32_t height;
int gender;
struct list_head list;
};
内核提供的链表方法只接受list_head作为参数,且链表为环形链表,从任意list_head开始遍历都能找到所有节点
五 中断和中断处理
5.1 中断
对于 Linux 内核来说,中断信号分为两类:硬件中断和软件中断,每个中断是由 0 - 255 之间的一个数字来标识。对于中断 int0 - int31 来说,每个中断的功能都由 intel 制定或保留用,这些属于软件中断。中断 int32 - int255 可以由用户自己设定。
硬件中断:本质是一种特殊的电信号,由硬件设备发向处理器,硬件设备生成中断的时候并不会考虑与处理器的时钟同步(硬件根本不知道处理器的时钟)。每个中断都有唯一的一个数字标识,不同的中断对应着不同的中断处理程序。硬中断由中断控制器来对中断进行排序和控制,通常在执行中断程序时应该会屏蔽中断线甚至是屏蔽所有中断,因此中断程序应该快速、简单。更加耗时、实时性不强的操作应该都放在中断下半部来完成。
5.2 中断处理程序
中断处理可以分为上半部和下半部,上半部也就是中断处理程序,在所有中断被禁止的情况下,只做有严格时限的工作,例如对接收的中断进行应答或复位硬件。下半部可以被稍稍延迟,主要用于处理和操作数据。
针对每个设备或者驱动都有一个对应的中断处理程序,中断处理程序的本质是一段专门处理中断的c语言程序函数,但是它和内核其他函数的区别在于,它运行在中断上下文(也称原子上下文),该上下文的执行是不可阻塞的。
注册中断回调函数为:
int request_irq(unsigned int irq, irq_hander_t hander, unsigned long flags, const char* name, void *dev),参数含义分别为 要分配的中断号、要注册的中断处理程序、标志位掩码、中断名、共享中断线的一些信息。此函数执行成功返回0,由于在函数中会出现分配内存而可能导致阻塞,所以不可在中断上下文或其他不允许阻塞的代码中调用此函数
5.3 共享中断线
共享中断线上的驱动设备共享同一个中断号,断控制器负责协调和管理系统中的中断请求。中断控制器负责分派中断,决定哪个设备触发了中断,并调用相应的中断处理程序。多个不同中断线上的任务可以共享同一个中断处理程序,靠中断号识别。
5.4 中断上下文
中断上下文有严格的时间和空间限制,每个处理有一个中断处理程序的栈,大小为一页(4KB)。由于没有后备进程,所以中断上下文中不可睡眠,否则无法对它再重新调度。
5.5 proc
procfs是一个虚拟的文件系统,存放的是系统中与中断有关的统计信息。四列信息分别为中断线、每个处理器上中断数目的计数器、处理这个中断的中断控制器、与这个中断相关的设备名
5.6 中断控制
内核提供了针对某个处理器的禁止中断接口
local_irq_disable(); //禁止中断(只针对于某一个处理器)
/*中断行为*/
local_irq_enable(); //恢复中断
// 在2.5以前的这两个函数是通过cli()和sti()来实现的,cli()函数能够禁止系统中所有处理器上的中断,在一个处理器调用此函数后,另一个处理器想要调用这个方法就必须等待,知道中断重新被激活。在2.5以后得中断同步必须结合本地中断控制器和自旋锁来完成。
六 中断下半部
运行在中断上下文或任务上下文中,看具体的场景
6.1 linux提供的方式
参考 :
Linux中断子系统之软中断、tasklet和工作队列
6.1.1 bottom half
提供一个全局静态创建、由32个bottom halvers组成的链表。在上半部通过一个32位整数中的一位来标识除哪个bottom half可以执行。因为是全局的,所以即使分属于不同的处理器,也不允许任何两个bottom half同时执行。在2.6后的版本被废除。
6.1.2 任务队列
内核定义了一组队列,其中每个队列都包含了一个由等待调用的函数组成链表。根据在队列中的位置,这些函数在某个时刻会运行。但是对于一些要求高性能的场景比如网络部分,任务队列无法胜任。在2.6后的版本被废除
6.1.3 软中断
6.1.3.1 软中断的数据结构
- 软中断是编译期静态分配的在内核中有一个软件中断的数组:
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;
- softirq_action 这个数据结构用于描述一个软件中断。默认情况下,系统上只能使用 32 个软中断。
struct softirq_action
{
void (*action)(struct softirq_action *);
};
- 软中断的类型是枚举类型,共32个,内核暂时只用到了9个
enum
{
HI_SOFTIRQ=0, /* 高优先级tasklet软中断 */
TIMER_SOFTIRQ, /* 定时器软中断 */
NET_TX_SOFTIRQ, /* 发送网络包软中断 */
NET_RX_SOFTIRQ, /* 接收网络包软中断 */
BLOCK_SOFTIRQ, /* 用于处理块设备「block device」的软中断 */
IRQ_POLL_SOFTIRQ, /* 用于执行IOPOLL的回调函数 */
TASKLET_SOFTIRQ, /* tasklet软中断 */
SCHED_SOFTIRQ, /* 用于进程调度与负载均衡的软中断 */
HRTIMER_SOFTIRQ, /* 用于高精度定时器的软中断 */
RCU_SOFTIRQ, /* 用于RCU服务的软中断 */
NR_SOFTIRQS
};
6.1.3.2 软中断的执行
- 软中断在执行过程中只能被中断处理程序抢占,无法被另一个软中断抢占,但是其他的处理器上可以同时运行甚至同一个类型的软中断。
- 一个注册的软中断必须在被标记后才会执行,这被称作触发软中断。待处理的软中断被检查和执行的地方为:从一个硬件中断返回时、在ksoftirqd内核线程中、在显式检查和执行待处理的软中断代码中(如网络子系统)。
- 软中断在do_softirq()中执行,函数简化后如下
__u32 pending;
unsigned long flags;
if (in_interrupt())
return;
pending = local_softirq_pending(); //确定当前 CPU 软中断位图中所有置位的比特位,最多只有32位,因此最多循环32次
if (pending)
{
struct softirq_action* h;
set_softirq_pending(0);//重设待处理的位图,这里需要禁止本地中断,防止被新的软中断被清零。
h = softirq_vec;
do {
if (pending & 1)
h->action(h);
h++;
pending >> 1;
} while (pending);
}
- 当多个处理器同时运行同一个类型的软中断时,任何共享数据都需要加锁保护,因此大部分软中断处理程序都采用单处理器数据或其他技巧来避免显式的加锁。
6.1.3.3 ksoftirqd
每个处理器都有一组辅助处理软中断(和tasklet)的线程,在最低的优先级上运行(nice值为19),能够避免软中断和重要的任务抢夺资源,也能保证在空闲时有很好的软中断处理速度。
6.1.4 tasklet
tasklet_struct 结构体如下
struct tasklet_struct
{
struct tasklet_struct *next; /* 将多个tasklet链接成单向循环链表 */
unsigned long state;
atomic_t count; /* 0:激活tasklet 非0:禁用tasklet */
bool use_callback;
union {
void (*func)(unsigned long data); /* 指向tasklet函数指针,即tasklet的处理函数 */
void (*callback)(struct tasklet_struct *t);
};
unsigned long data; /* 用作函数执行时的参数 */
};
state表示任务的当前状态,有两个标志:
- TASKLET_STATE_SCHED 表示 tasklet是挂起的,等待调度执行时
- TASKLET_STATE_RUN 表示 tasklet 当前正在执行
tasklet有两个优先级的软中断分别是HI_SOFTIRQ和TASKLET_SOFTIRQ,分别存放在链表 tasklet_hi_vec 和 tasklet_vec ,由 tasklet_hi_schedule() 和 tasklet_schedule()两个函数进行调度,这两个函数区别仅在于调度的优先级。tasklet由于不加锁,相同类型的tasklet同一时间只能运行一个。同一个处理器上的tasklet之间不允许抢占,因为它们的优先级相同。
6.1.5 工作队列
如果推后执行的任务不需要睡眠,就选软中断或tasklet,如果需要睡眠选择工作队列。
每个处理器都有一个缺省的工作队列线程,驱动程序或子系统也可以创建一个自用的内核线程。
信号
参考链接深入理解Linux内核信号处理机制原理(含源码讲解)
流程
User Space
| kill()
Kernel Space
| sys_kill()
| kill_something_info()
| kill_proc_info()
| find_task_by_pid()
| send_sig_info()
| bad_signal()
| handle_stop_signal()
| ignored_signal()
| deliver_signal()
| send_signal()
| | kmem_cache_alloc()
| | sigaddset()
| signal_wake_up()
从内核态返回到用户态时,CPU要从内核栈中找到返回到用户态的地址(就是调用系统调用的下一条代码指令地址),Linux为了先让信号处理程序执行,所以就需要把这个返回地址修改为信号处理程序的入口,这样当从系统调用返回到用户态时,就可以执行信号处理程序了。当处理完信号处理程序后需要返回内核态,Linux的做法就是在用户态栈空间构建一个 Frame(帧),构建这个帧的目的就是为了执行完信号处理程序后返回到内核态,并恢复原来内核栈的内容。返回到内核态的方式是调用一个名为 sigreturn() 系统调用,然后再 sigreturn() 中恢复原来内核栈的内容。
内核处理信号就类似于在内核态的运行代码栈中插了一个用户栈帧。
七 内核同步
7.1 自旋锁
- 自旋锁不可递归,自己等待自己已经获取的锁,会导致死锁。
- 由于自旋锁是忙等待而不休眠,所以中断上下文中可以使用自旋锁,但是一个线程获取了一个锁,但是被中断处理程序打断,中断处理程序也获取了这个锁(但是之前已经被锁住了,无法获取到,只能自旋),中断无法退出,导致线程中后面释放锁的代码无法被执行,导致死锁。因此中断上下文中也尽量不要使用自旋锁。
7.1.1自旋锁的死锁
- 内核抢占场景
同一cpu上,进程a获取了自旋锁,但是中断唤醒了高优先级的b,b会一直自旋等待这把锁,造成死锁。因此在获取自旋锁的时候,linux会禁止本cpu的抢占 - 中断上下文场景
同一cpu如果进程a获取了自旋锁,但是被中断处理程序抢占,中断处理程序尝试获取同一把锁,造成死锁。因此如果在进程中涉及到中断上下文的访问,一般自旋锁都会结合禁止本cpu中断使用。 - 中断下半部场景
进程在和下半部访问同样数据时,进程需要加锁并禁止中断;下半部和中断处理程序访问同样数据时,下半部需要加锁并禁止中断。由于同类tasklet不能同时运行,并且同一cpu上的不同tasklet无法抢占,因此同类tasklet中不需要加锁。软中断由于不同类型也可一起运行,所以访问共享数据时需要加锁。
7.2 读写自旋锁
基于自旋锁,读和读之间不会争锁。
7.3 信号量
在初始化的时候可以设置同一时间进入临界区的线程数量,即同一时间锁的持有者的数量。当数量设置为1时,就变成互斥锁,此时用法和mutex类似。
7.4 互斥体
即mutex
7.5 顺序锁
顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元不会被写执行单元阻塞,也就 是说,读执行单元在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其他写执行单元必须自旋在那里,直到写执行单元释放了顺序锁。
对于顺序锁而言,尽管读写之间不互相排斥,但是如果读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新读取数据,以便确保得到的数据是完整的。所以,在这种情况下,读端可能反复读多次同样的区域才能读到有效的数据。
7.6 禁止抢占
7.7 顺序和屏障
八 定时器和时间管理
8.1 系统定时器频率
高频率的优势
- 带来更高的频度和精准度
- 依赖定时器的调用类似 poll() 和 select() ,能够以更高的进度运行
- 对资源消耗和系统运行时间等的测量会有更加精细的解析度
- 提高进程抢占的准确度
高频率的劣势 - 时钟频率越高,中断处理程序所占用的处理器时间越多,也就意味着系统负担越重
8.2 jiffies
jiffies作为全局变量来记录系统启动以来产生的节拍数,被声明为 unsigned long volatile jiffies,1秒等于1个jiffies*定时器频率,即1秒的时间内jiffies增加的值等于定时器频率。所有参数与jiffies相关的计时函数都会存在 1/系统定时器频率的误差,误差单位为秒
8.3 系统时钟
在linux中xtime表示系统时钟(墙上时钟),读写xtime变量需要用到seqlock锁。用户空间读取墙上时间的接口为gettimeofday(), 内核中对应的系统调用为sys_gettimeofday()
8.4 定时器 timer_list
- 定时器 timer_list被激活后会运行指定函数,自动撤销此定时器。
- 内核无法保证到期一定立马执行,只能保证在下一次jiffies增加前运行,即如果系统定时器频率为100Hz,那么定时器的误差可能会到10ms,因此此定时器不能用于实现任何硬实时任务
- 此定时器作为软中断在下半部的上下文中执行
- 此定时器以链表的形式存放在一起,但是内核为了避免对整个链表进行遍历,将定时器按它们的超时时间划分为5组,当定时器超时时间接近时,定时器将随组一起下移。
8.5 延迟执行
8.5.1 忙等待
通过while循环调用time_before(jiffies, timeout),其中timeout为需要延迟的拍数,列如timeout = jiffies + 10 。可以在while循环中调用cond_resched来调度一个新程序投入运行,但这个函数只有在设置完need_resched标志后才能生效。因为这个方法需要调用调度程序,因此只能在进程上下文而不是中断上下文中运行,并类似的延迟执行无论什么情况下都不应该在持有锁或禁止中断时发生。因为参数与jiffies有关,因此误差与jiffies误差相关。
8.5.2 短延时
如果仅仅需要短暂的延时且精度高,可以调用 udelay、ndelay、mdelay,分别对应us、ns、ms级别的延迟函数,它们的实现通过根据BogoMIPS值计算出执行一定次数的循环来实现。udelay由于参数是unsigned long,因此应该只在小延迟中使用,否则有溢出的危险。
内核在启动时利用calibrate_delay函数来计算loops_per_jiffy值,该值中存放着BogoMIPS值,BogoMIPS代表着处理器空闲时的处理速度,或者说处理器在给定时间内忙循环执行的次数。
8.5.3 schedule_timeout
该函数参数为jiffies,会让需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行,由于存在休眠,必须运行在进程上下文中且不能持有锁。
九 内存管理
9.1 内存区
- ZONE_DMA:DMA内存区域。包含0MB~16MB之间的内存页框,可以由老式基于ISA的设备通过DMA使用,直接映射到内核的地址空间。
- ZONE_NORMAL:普通内存区域。包含16MB~896MB之间的内存页框,常规页框,直接映射到内核的地址空间。
- ZONE_HIGHMEM:高端内存区域。包含896MB以上的内存页框,不进行直接映射,可以通过永久映射和临时映射进行这部分内存页框的访问。
linux用三个struct zone结构体来分别记录这三个区
9.2 基础内存分配
9.2.1 页的申请
- struct page* alloc_pages(gfp_t gfp_mask, unsigned int order),此函数分配2^order个连续的物理页面,可被void* page_address(struct page* page)函数转化成对应的逻辑地址
- unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order) 函数返回的是一个虚拟地址,__get_free_pages()就是比alloc_pages()多了一个地址转换的工作
9.2.2 内存块的申请
- kmalloc函数:该函数仅仅比malloc函数多了一个flag,分配的地址在物理上是连续的。
- vmalloc函数:该函数申请的内存块的虚拟地址是连续的
9.3 gfp_mask标志
该标志可以分为三类:行为修饰符、区修饰符以及类型
- 行为修饰符代表分配内存是可以进行哪些操作,比如是否可以睡眠、启动磁盘或io这些操作。
- 区修饰符表示内存区应该从何处分配,默认是从ZONE_NORMAL区进行分配,可指定参数改变内核分配的区
- 类型标识符指定所需的行为和区描述符以完成特殊类型的处理,最常用的参数为GFP_KERNEL(尽力而为的可能阻塞的分配方式)和GFP_ATOMIC(不能睡眠可能导致在内存紧缺时分配失败的方式)
9.3 slab
slab的主要作用
- slab分配器分配内存以字节为单位,基于伙伴分配器的大内存进一步细分成小内存分配
- 维护常用对象的缓存,例如内核中对task_struct 等常用数据结构维护一个slab的高速缓存
- 提高CPU硬件缓存的利用率
slab主要维护在kmem_cache_node 这一数据结构种,共维护了3种状态的slab,满的、部分满的和空闲的,当有分配内存需求时优先从部分满的slab种分配。如果不存在部分满的和空闲的slab,将会申请新的页并添加到空闲的slab链表中
struct kmem_cache_node {
spinlock_t list_lock;
struct list_head slabs_partial; /* partial list first, better asm code */
struct list_head slabs_full;
struct list_head slabs_free;
unsigned long total_slabs; /* 所有 slab list 的长度 */
unsigned long free_slabs; /* 仅 free slab list 的长度*/
unsigned long free_objects;
...
};
十 文件系统
参考文献
浅谈Linux虚拟文件系统
10.1 VFS
vfs能兼容各种文件系统的原因在于它抽象了一个通用的文件系统模型,定义了通用文件系统都支持的、概念上的接口。新的文件系统只要支持并实现这些接口,并注册到Linux内核中,即可安装和使用。linux提供的文件写函数int ret = write(fd, buf, len);实现的简要如下:
- 首先,勾起VFS通用系统调用sys_write()处理。
- 接着,sys_write()根据fd找到所在的文件系统提供的写操作函数,比如op_write()。
- 最后,调用op_write()实际的把数据写入到文件中。
vfs由四个部分组成:超级块对象、索引节点、目录项、文件对象,有点类似于子类和父类,只要重载这四个部分,vfs就能支持对应的文件系统
10.2 超级块
超级块用于存储文件系统的元信息,由super_block结构体表示,定义在<linux/fs.h>中,元信息里面包含文件系统的基本属性信息,主要有:
- 索引节点信息
- 挂载的标志
- 操作方法 super_operations
- 安装权限
- 文件系统类型、大小、区块数
其中操作方法 super_operations 对每个文件系统来说,是非常重要的,它指向该超级块的操作函数表,包含一系列操作方法的实现,当文件系统需要对超级块执行操作时需要查询这个结构体找到对应操作的指针,这些方法主要有:
- 分配inode
- 销毁inode
- 读、写inode
- 文件同步
10.3 索引节点
索引节点对象包含Linux内核在操作文件、目录时,所需要的全部信息,这些信息由inode结构体来描述,定义在<linux/fs.h>中,主要包含:
- 超级块相关信息
- 目录相关信息
- 文件大小、访问时间、权限相关信息
- 引用计数
一个索引节点inode代表文件系统中的一个文件,只有当文件被访问时,才在内存中创建索引节点,即调用open后返回的fd,这个fd就是索引节点。与超级块类似的是,索引节点对象也提供了许多操作接口,供VFS系统使用,这些接口包括:
- create(): 创建新的索引节点(创建新的文件)
- link(): 创建硬链接
- symlink(): 创建符号链接。
- mkdir(): 创建新的目录。
10.4 目录项
目录项是描述文件的逻辑属性,只存在于内存中,并没有实际对应的磁盘上的描述,更确切的说是存在于内存的目录项缓存,为了提高查找性能而设计。
前面提到VFS把目录当做文件对待,比如/usr/bin/vim,usr、bin和vim都是文件,也都对应一个目录项对象,不过vim是一个普通文件,usr和bin都是目录文件,都是由索引节点对象标识。由于目录也是一种文件(所以也存在对应的inode)。打开目录,实际上就是打开目录文件。
目录项由dentry结构体标识,定义在<linux/dcache.h>中,主要包含:
- 父目录项对象地址
- 子目录项链表
- 目录关联的索引节点对象
- 目录项操作指针
目录项有三种状态:
- 被使用:该目录项指向一个有效的索引节点,并有一个或多个使用者,不能被丢弃。
- 未被使用:也对应一个有效的索引节点,但VFS还未使用,被保留在缓存中。如果要回收内存的话,可以撤销未使用的目录项。
- 负状态:没有对应有效的索引节点,因为索引节点被删除了,或者路径不正确,但是目录项仍被保留了。
将整个文件系统的目录结构解析成目录项,是一件费力的工作,为了节省VFS操作目录项的成本,内核会将目录项缓存起来。
10.5 文件对象
文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。但是由于文件是唯一的,那么inode就是唯一的,目录项也是定的。进程通过文件描述符来操作文件,每个文件都有一个32位的数字来表示下一个读写的字节位置,这个数字叫做文件位置。类似目录项,文件对象实际上没有对应的磁盘数据。
file结构体的主要字段如下:
struct file {
union {
struct llist_node fu_llist; /* 每个文件系统中被打开的文件都会形成一个双链表 */
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
#define f_dentry f_path.dentry 782 struct inode *f_inode; /* cached value */
const struct file_operations *f_op; /* 指向文件操作表的指针 */
spinlock_t f_lock; /*单个文件结构锁*/
atomic_long_t f_count; /* 文件对象的使用计数 */
unsigned int f_flags; /* 打开文件时所指定的标志 */
fmode_t f_mode; /* 文件的访问模式(权限等) */
struct mutex f_pos_lock;
loff_t f_pos; /* 文件当前的位移量 */
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra; /* 预读状态 */
u64 f_version; /* 版本号 */
void *private_data; /* 私有数据 */
};
- f_flags、f_mode和f_pos代表的是这个进程当前操作这个文件的控制信息,由于一个文件可以被多个进程打开,因此操作文件是异步操作,会涉及到竞争,这3个参数是 为了解决竞争。
- 引用计数f_count,当关闭一个进程的某一个文件描述符时候,其实并不是真正的关闭文件,仅仅是将f_count减一,当f_count=0时候,才会真的去关闭它。对于dup,fork这些操作来说,都会使得f_count增加。
- 结构体f_op在调用read/write接口时,最终都会调用file_operations中的对应的操作
10.6 进程相关的数据结构
与进程相关的数据结构有三个:file_struct、fs_struct、namespace,这三个数据结构是通过文件描述符来访问的。对大部分进程而言,file_struct、fs_struct和文件描述符是一一对应的,对使用CLONE_FILES或CLONE_FS创建出来的线程会共享这两个结构体。
十一 块I/O层
11.1 bio结构体
struct bio {
sector_t bi_sector;//磁盘上的起始扇区
struct bio *bi_next;//链表的下一个节点
struct block_device *bi_bdev;
unsigned long bi_flags;
unsigned long bi_rw;
struct bvec_iter bi_iter;
unsigned int bi_phys_segments;
unsigned int bi_seg_front_size;
unsigned int bi_seg_back_size;
atomic_t bi_remaining;
bio_end_io_t *bi_end_io;//io操作的回调函数
void *bi_private;
unsigned short bi_vcnt;//所使用的bio_vec结构体的数量
unsigned short bi_max_vecs;
struct bio_vec bi_inline_vecs[0]; //数组表示完整的缓冲区
};
struct bio_vec {
struct page *bv_page; //指向缓冲区的实际物理页面
unsigned int bv_len;//缓冲区的长度
unsigned int bv_offset;//所在页的偏移,也就是缓冲区在这个页的起始位置
};
11.2 I/O调度
11.2.1 请求队列
io请求被保存在请求队列中,再统一处理。当有新的请求进入等待队列时,总共可能发生四种操作
- 队列中存在相邻的扇区操作,就合并请求
- 队列中存在驻留时间过长的请求,为防止饥饿,将此请求放在末尾
- 队列中存在合适的插入位置
- 不存在合适的插入位置就放置在末尾
11.2.2 调度算法
通过合并和排序io请求来减少磁盘寻址时间。合并相邻扇区的请求,并将整个请求队列按扇区增长方向有序排列,又称为电梯调度。linux中的I/O调度如下:
- 最终期限调度程序:默认情况下读请求的超时时间为500ms,写请求的超时时间为5s。读操作是阻塞的,写操作是异步的。linux将读写操作分别按请求时间以链表的方式排列,又以红黑树的方式按截止时间排序,因此deadline调度器总共有4个队列:
- 按照扇区访问顺序排序的读队列;
- 按照扇区访问顺序排序的写队列;
- 按照请求时间排序的读队列;
- 按照请求时间排序的写队列。
- 完全公平(CFQ)调度程序:为每一个进程都分配对应的队列,以时间片轮转来调度这些队列,从每个队列中选取固定的请求数(默认为4),这样做保证了进程级别的公平
十二 进程地址空间
12.1 内存地址
内存区域可包含的对象:
- 代码段(text section): 可执行文件代码
- 数据段(data section): 可执行文件的已初始化全局变量(静态分配的变量和全局变量)。
- bss段:程序中未初始化的全局变量,零页映射(页面的信息全部为0值)。
- 进程用户空间栈的零页映射(进程的内核栈独立存在并由内核维护)
- 每一个诸如C库或动态连接程序等共享库的代码段、数据段和bss也会被载入进程的地址空间
- 任何内存映射文件
- 任何共享内存段
- 任何匿名的内存映射(比如由malloc()分配的内存)
这些内存区域不能相互覆盖,每一个进程都有不同的内存片段。
12.2 内存描述符
进程的内存描述符表示用户态的进程地址空间,在linux中区分线程和进程的标志就是是否共享地址空间,即在调用clone时是否设置CLONE_VM标志,可通过访问进程描述符task_struct获得即current->mm
struct mm_struct
{
struct vm_area_struct *mmap;
rb_root_t mm_rb;
...
atomic_t mm_users;//代表正在使用该地址的线程数目,当该值为0时mm_count也变为0;
atomic_t mm_count;
struct list_head mmlist;
unsigned long start_code;//代码段的首地址
unsigned long end_code;//代码段的结束地址
unsigned long start_data;//数据段
unsigned long end_data;
unsigned long start_brk;//堆
unsigned long brk;
unsigned long start_stack;//进程栈的首地址,进程栈通常是通过堆栈帧(stack frame)的方式来组织的,每次函数调用都会在栈上分配一部分空间,函数返回时再释放。
//由于栈的大小在运行时动态变化,因此不需要在 struct mm_struct 中专门存储进程栈的结束地址。
...
};
内核线程没有用户态的进程地址空间,因此也没有内存描述符,current->mm是空指针,因此在内核在调度到内核线程时,内存描述符指向的地址未被加载到内存中。因此可以说内核线程使用的是前一个进程的内存描述符,内核线程仅仅只使用和内核内存相关的信息。
十三 页高速缓存和页回写
针对写缓存,linux内核采用了脏页回写的策略,清除缓存算法在最近最少使用策略的基础上使用了双链策略。linux维护了活跃和非活跃两个链表,链表中的每个页面都有两个标志位:PG_active(标志页面是否活跃,也就是表示此页面是否要移动到活跃链表)、PG_referenced (表示页面是否被进程访问到),非活跃链表中的页是可以被换出的。
页面移动的流程如下:
- 当页面第一次被被访问时,PG_active 置为1,加入到活动链表
- 当页面再次被访问时,PG_referenced 置为1,此时如果页面在非活动链表,则将其移动到活动链表,并将PG_active置为1,PG_referenced 置为0
- 系统中 daemon 会定时扫描活动链表,定时将页面的 PG_referenced 位置为0
- 系统中 daemon 定时检查页面的 PG_referenced,如果 PG_referenced=0,那么将此页面的 PG_active 置为0,同时将页面移动到非活动链表
linux 页高速缓存中的回写是由内核中的一个线程(flusher 线程)来完成的,flusher 线程在以下3种情况发生时,触发回写操作。
- 当空闲内存低于一个阀值时,空闲内存不足时,需要释放一部分缓存,由于只有不脏的页面才能被释放,所以要把脏页面都回写到磁盘,使其变成干净的页面。
- 当脏页在内存中驻留时间超过一个阀值时,确保脏页面不会无限期的驻留在内存中,从而减少了数据丢失的风险。
- 当用户进程调用 sync() 和 fsync() 系统调用时