Linux内核重点精要
Posted zhou_chenz
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux内核重点精要相关的知识,希望对你有一定的参考价值。
- 内核版本 Linux Kernel 2.6.34, 与 Robert.Love的《Linux Kernel Development》(第三版)所讲述的内核版本一样
- 源代码下载路径: https://www.kernel.org/pub/linux/kernel/v2.6/linux-2.6.34.tar.bz2
1. 进程与线程
1) 进程(process)与线程(thread)在Linux内核中都是通过 task_struct对象来描述的。也就是说,只有站在用户态(user space)的视角,才区分进程和线程。在内核态(Kernel space),不管是进程还是线程,都是一个 task_struct 的对象实例。其大致关系如图1所示
Figure1进程、线程与task_struct
1) 用户态创建进程使用的是sys_fork()或者sys_vfork()系统调用,创建线程(POSIX线程库的pthread_create()函数,需要链接到libpthread.so库)使用sys_clone()系统调用会多一些,。虽然用的系统调用API不同,但是sys_fork()、sys_vfork()、sys_clone()这三个系统调用在内核态中都会使用do_fork()函数来执行实际的进程(线程)创建流程,do_fork()最终会在内核中都会创建一个task_struct对象实例,但是由于进程与线程API传入参数不同,因而创建的不同task_struct实例所维护的资源以及资源的权限是不同的。相关系统调用的代码如下:
注意到,一般pthread_create()调用sys_clone()创建线程时,是可以设置新的线程栈的(newsp表示new stack pionter)
int sys_fork(struct pt_regs *regs)
return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
int sys_vfork(struct pt_regs *regs)
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs->sp, regs, 0,
NULL, NULL);
long
sys_clone(unsigned long clone_flags, unsigned long newsp,
void __user *parent_tid, void __user *child_tid, struct pt_regs *regs)
if (!newsp)
newsp = regs->sp;
return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid);
3) 一个task_struct是一个程序在操作系统运行的基本单位,也可以说是一个cpu调度的基本单位,一个task_struct可以在某时刻占有某一个CPU。
4) 一个task_struct所维护的资源包括虚拟地址空间,文件系统信息、文件描述符、信号状态与信号处理函数等,具体参考Linux 内核源码中 include/linux/sched.h 文件中task_struct对象的声明。
5) 内核线程
- 只运行在内核态的线程
- 内核线程也是用task_struct对象来描述
- 内核线程没有独立的虚拟地址空间,不能切换到用户态运行。
6) 进程上下文(context)与中断上下文
- 不管是当前CPU执行的是用户态还是内核态程序,只要该运行的程序和进程关联,我们就说当前程序运行在进程上下文
- 与进程上下文对立的,当前CPU如果执行的是中断处理程序,那么就和进程无关,我们就说当前程序运行在中断上下文
7) fork() 创建进程的执行机制
- 在当前进程中执行fork(),会进入内核态,创建一个新的task_struct,进而创建出新的进程
- 新的进程是调用fork()的原进程的子进程
- 写时拷贝机制。新进程刚创建时,与父进程共享资源,同一个虚拟地址空间,只有新建子进程有发出写的命令时,Linux系统会产生缺页的exception,进而将新进程相关的数据资源拷贝到新的子进程的虚拟地址空间。
8) task_struct的存放与查找
- 内核可以通过current宏来找到当前进程的task_struct对象
- 不同体系结构的current宏实现方法不同,有的体系结构有硬件寄存器存放task_struct对象指针。
- 旧的X86架构没有存放task_struct对象指针的寄存器,通过在进程内核栈的尾端保存thread_info对象指针-- >在内核堆栈尾地址找到thread_info对象指针-- > 通过thread_info->task来找到task_struct对象指针。
2. 进程调度
1) 进程的主要状态
- TASK_RUNNING, 可执行状态。 进程此时可能已经获得CPU正在执行,也可能尚未获取CPU资源。如果处于TASK_RUNNING状态下的进程没有获取CPU,则该进程是可以被调度的,需要排队,按照调度器制定的规则,等候获取CPU资源。
- TASK_INTERRUPTIBLE, 被阻塞处于睡眠状态,此时不能被调度器调度, 既可以被信号唤醒,也可以被其它进程主动唤醒,变成可调度的TASK_RUNNING状态。
- TASK_UNINTERRUPTIBLE, 被阻塞处于睡眠状态,不能被信号唤醒,只能被其它进程主动唤醒,才能恢复到可调度的TASK_RUNNING状态。
- __TASK_STOPPED, 停止状态,没有投入运行,也不能被投入运行
- __TASK_TRACED, 被其它进程跟踪状态,一般用在ptrace系统调用中,专门用来检查程序运行轨迹,以便debug。
- EXIT_ZOMBIE, 僵尸进程,只进程被kill掉之后,其获取的资源没有被父进程及时回收,处于僵尸状态。
- EXIT_DEAD, 死亡的进程
2) 调度器
- 调度器是一个对象,该对象通过一套规则来选择一个在在调度队列中的TASK_RUNNING状态的进程投入CPU运行
- 调度器类, 内核的include/linux/sched.h 文件中声明了,struct sched_class, 这是一个调度器的基类,开发者可以通过实现该sched_class对应的函数方法,从而定制自己的调度器。
- Linux 内核中可以拥有多种调度器,以实现不同的调度策略。进程、调度器与CPU的关系如图2所示。
Figure2 进程,调度器与CPU的关系
3) Linux 内核中已经实现的调度器
- 完全公平调度器(CFS)
代码在kernel/sched_fair.c中实现。不同于Unix系统的进程调度,CFS调度器没有时间片的概念。而是采用一种加权虚拟实时的方式对进程运行的时间进行记账(task_struct.se.vruntime变量的值)。所以的进程task_struct对象通过vruntime排序,插入等待调度的进程队列,该队列采用红黑树进行管理和查找,使得查找效率最高(时间复杂度log(N))。CFS调度器会从调度队列的红黑树中选择vruntime最小的进程(红黑树最左边的节点)投入运行。
CFS是非实时的调度策略,用户态通过SCHED_NORMAL的宏设置该调度策略。
- 实时调度器(RT)
代码在kernel/sched_rt.c中实现。Linux内核的实时调度器不是硬实时的,而是一种软实时的调度方法。即内核尽力保证进程在它限定时间之前运行,但不保证总能满足这些进程的要求。
实时调度可以设置SCHED_FIFO和 SCHED_RR两种调度策略。实时进程对应的优先级范围0-99,数值越大,优先级越高。
实时调度器的优先级高于非实时调度器。
- Idle Task 调度器
代码在kernel/sched_idletask.c中实现。优先级最低的调度器,顾名思义,用于管理空闲进程。
4) 上下文切换与抢占
- 调度器通过算法选择下一个投入CPU运行的进程后,需要通过上下文切换函数,切换CPU中运行的进程。Kernel/sched.c 中context_switch()函数用于实现上下文的切换。
- 用户抢占。当进程从内核态返回(包括中断返回)用户态时,如果need_resched标志被置位,则内核会选择一个更适合的进程投入运行。
- 内核抢占。Linux支持运行在内核态的进程被抢占,只要进程的thread_info. preempt_count 为0时,即没有加锁,那么内核态的进程就是可以抢占的。内核抢占可以发生在中断返回时,锁全部释放掉(再次具备可抢占性),显示调用schedule(),被阻塞时。
3. 系统调用
1) 系统调用是用户态与内核态程序交互通信的一组接口, 该接口限定了用户态程序访问内核的机制,为系统稳定性提供保障。
2) 程序员使用系统调用访问Linux内核的一般流程:应用程序 -- > C 程序库 -- > 系统级POSIX 标准API函数-- > 系统调用-- > 内核代码
3) 系统调用的实现。
- 在X86系统中,系统调用到内核代码调用,实现过程是通过int $0x80指令产生的软中断(中断号128)异常,在异常代码处理中通过系统调用号,查找相应的内核函数来实现的。
- X86 系统处理该异常的代码和体系结构有关,一般在arch/x86/kernel/entry_32.S(entry_64.S)中实现。
- 不同的系统调用参数不同(例如open和mmap)。不同个数参数的系统调用函数在内核中SYSCALL_DEFINEX来定义(例如: SYSCALL_DEFINE3(chown,const char __user *, filename, uid_t, user, gid_t, group)),系统调用函数的参数一般通过寄存器的方式进行传递,
4) 系统调用号。
- 每个Linux系统调用都有一个系统调用号。
- 一般系统调用的函数都放在sys_call_table的数组中,内核通过用户态传入的系统调用号,在sys_call_table中查找内核中具体的系统调用函数。
5) Linux内核X86体系结构现有的系统调用
- Linux内核现有的系统调用表sys_call_table在 arch/x86/kernel/syscall_table_32.S中定义, 每个系统调用号对应着该系统调用函数在该表中的索引号。Linux内核也是通过该表查找对应的系统调用函数。
- 已经实现的系统调用函数声明在include/linux/syscalls.h 中, 系统调用号索引数字对应的宏在arch/x86/include/asm/ unistd_32.h 中定义。
- 一般系统API都是用unistd_32.h中调用号对应的宏 -- > 在sys_call_table中查找到对应的函数签名-- >通过include/linux/syscalls.h中的函数声明调用到具体的函数最终实现调用。
6) 自制系统调用
- 实现一个自己的系统调用一定要有合理的理由,并且考虑兼容性和未来维护性的问题(内核社区一般不维护额外的系统调用)。另外,传入参数验证,调用的性能也是需要考虑的。如果能用ioctl方式来实现内核访问(大部分多媒体相关的功能都通过该接口实现特殊的功能),则无需自制系统调用。
- 如果需要自制系统调用,大概需要的流程如下:
在arch/x86/include/asm/ unistd_32.h 中的现有系统调用之后按照格式加入自己的调用后与宏-- >
include/linux/syscalls.h中声明自己的调用函数 -- >
在sys_call_table中调用号对应的位置加入自己声明的系统调用函数 -- >
实现自己的系统调用函数 -- >
函数实现的定义之处主要用SYSCALL_DEFINEX()声明系统调用传入参数的个数。
4. 中断与中断处理
1) 中断是外界与CPU的一种异步交互机制。外设低速设备长期占有CPU将造成资源极大浪费,而外设有事件需要CPU处理时,需要正在处理其它任务的CPU及时响应,因而需要中断这个异步机制,通过电信号,让CPU暂停下来去处理响应的事件。
2) 异常与中断类似,但异常一般是CPU执行指令遇到错误(除0或者缺页)主动产生的,异常与CPU的时钟周期是同步的,不像中断是完全的异步事件。软中断就是一种特殊的异常。中断大部分是硬件引发的,而异常大部分是软件导致CPU主动发起的。
3) 使用中断的条件
- CPU有中断控制器
- 用户要请求对应的中断事件(对应特殊的中断线),并且为该中断事件注册处理函数(ISR)
- CPU使能系统中断,并激活相应的中断线
- 使用中断的条件
4) 内核中断相关的API
- 使用include/linux/interrupt.h 中的request_irq()为对应的中断注册相应的中断处理函数。
- 中断处理函数ISR的形式有irq_handler_t声明,并且返回值应该是enum irqreturn枚举中声明的类型值。
- Irq 支持的中断号一般和体系结构有关,相关平台的BSP代码会有描述。
- IRQF_SHARED是request_irq()时很重要的一个标志,因为Linix内核支持中断共享,如果注册中断时没有设置该标志,则申请的中断是不可共享的,此时如果相应中断已经被使用或者再次有人申请该中断时,将会报错。
- free_irq() 是 对应的中断注销函数
- 同一个中断线上的中断处理函数是不可重入的。在执行ISR时,内核会屏蔽当前中断线上的新中断,从而实现ISR函数不可重入。
5) 中断处理函数的执行机制
- 一旦一个中断被N次注册了IRQF_SHARED的中断,那么当该中断发生时,这N个中断处理程序ISR都会被依次调用。
6) 中断上下文
- 与之前进程上下文相对应的是中断上下文
- 当内核陷入中断,运行在中断上下文时,便与进程毫无瓜葛, 与current宏指向的task_struct也没有必然的关系
- 因而,如果中断上下文执行过程中,使用了可休眠的函数,将无法被唤醒。因而类似信号量,kmalloc之类的可休眠函数在中断上下文中被紧张使用。
- 中断处理函数的函数栈是可配置的。如果没有配置独立的中断函数栈,中断ISR将共享被中断进程的栈,一般情况中断处理函数都会配置1K大小的栈,所以需要节约使用,不要在ISR中定义太多局部数据。
7) 中断的限制
- 函数栈太小,中断上下文不可休眠进而导致ISR中不能调用可休眠函数,处理工作太繁重会影响其它的中断。
- 因而,中断处理函数应该尽量简洁,仅处理时间紧迫的必要的工作。
- 与该中断关联的繁重工作,可以留给中断处理结束之后的软中断,tasklet等机制来处理。
5. 中断处理函数结束后的任务
1). 统一命名与概念
- Robert.Love的LDK原书上这一段叫做中断下半部,但是中断下半部还是中断上下文吗?request_irq()函数注册的irq_handler_t函数到底是中断处理函数还是中断下半部执行的函数?BH机制,任务队列机制,软中断和中断下半部是什么关系。
- 混乱的命名让中断下半部的问题沟通起来容易产生概念混淆
- 因而统一将这一节称为中断处理函数结束后的任务,这样概念就明确了,这里讨论的都是request_irq()函数注册的irq_handler_t中断处理函数ISR结束之后做的事情。
2). 中断函数结束后要处理哪些工作
- 与之前产生的中断有关联的任务
- 对时间不太敏感,与硬件实时响应关系不大,不怕被相同的中断打断的任务
- 能不在irq_handler_t中断处理函数ISR中处理的任务,尽量移动到中断函数结束后的任务中处理
3). 目前常用的用来处理中断函数结束后的任务的方法
- 软中断与tasklet
- 工作队列
4). 软中断与tasklet
- 一个软中断由include/linux/interrupt.h中的structsoftirq_action对象描述
- 每个被系统注册的软中断在softirq_to_name[]数组中保留一个位置,最大支持32个软中断
- 软中断被触发后,softirq_action.action()函数将会执行
- 一个软中断只能被真正的硬件中断所抢占,软中断无法抢占软中断
- 一个注册过的软中断被raise_softirq()函数触发后,不会马上执行,但是会在一个合适的时机才执行。
- 被raise_softirq()函数触发后的软中断的softirq_action.action()函数可能在硬件中断返回处,ksoftirqd内核守护线程中以及类似网络子系统那种显示检查待处理软中断的地方被执行。
- 软中断的处理流程参考do_softirq()函数。每一轮都会把所有raise_softirq()触发后pending的软中断处理函数softirq_action.action()执行一轮。
- tasklet其实是软中断的一种,内核目前支持的软中断在include/linux/interrupt.h的宏中有定义如图3所示。除了tasklet之外,内核还默认支持网络,定时器,块设备等软中断。
Figure3 软中断类型的枚举,tasklet只是其中一种
- 除非有足够的理由,否则一般不直接增加自己的软中断,大部分情况下使用内核提供的tasklet。
- include/linux/interrupt.h的structtasklet_struct 用于描述tasklet, 一般使用DECLARE_TASKLET()宏定义自己的tasklet。
- struct tasklet_struct中的func()是tasklet被处理时候执行的函数,data是传入函数的参数。
- 用户一般在硬件中断处理函数IRQ结束后通过tasklet_schedule()函数触发tasklet,该函数最终会唤醒软中断的TASKLET_SOFTIRQ, 这样在TASKLET_SOFTIRQ的softirq_action.action()函数会回调structtasklet_struct中注册的tasklet出来函数func。
- tasklet只有有机会就会尽早处理,在一个tasklet注册函数运行之前,如被多次tasklet_schedule()调度,func也只会运行一次。
- tasklet有两种不同的软中断优先级HI_SOFTIRQ与TASKLET_SOFTIRQ,分别用tasklet_hi_schedule()与tasklet_schedule()函数触发调度。
- 当软中断或者tasklet触发的频率比较高时(相关中断大量产生),ksoftirqd对协助处理这些高吞吐量的软中断比较有效。
- 软中断和tasklet有可能在中断返回时触发,这样程序还处于中断上下文,没有和进程关联,因而不能使用可睡眠的API。
5). 工作队列
- 工作队列是一种允许休眠的中断处理函数后的任务执行机制,软中断、tasklet不允许睡眠,因而要在推后执行任务中睡眠时,应该使用工作队列。
- 工作队列上的任务是通过创建worker thread内核线程来处理的,运行在进程上下文,所以是可以睡眠的。
- 工作队列相关的内核API在 include/linux/workqueue.h中声明。在使用时定义DECLARE_WORK()与work_func_t函数。
- 中断处理函数返回时,通过schedule_work()触发工作队列的调度。
- schedule_work()调度函数会唤醒相应处理器上的内核线程或者创建内核线程来处理工作队列上pending的任务。
6. 内核同步与加锁机制
1). 什么时候需要使用内核同步加锁机制
- 内核态需要共享数据,同步机制保证数据能够不失效,数据被修改后,该资源的所有的共享者应该知悉。
- 程序执行的某些阶段,是不能被抢占或者中断的,否则无法保证执行的正确性和完整性。这些代码段称为临界区。临界区代码需要独占CPU资源执行。
2). 加锁的副作用
- 死锁,进程之间形成资源死锁等待条件。
3). 原子操作
- 原子操作可以保证原子指令执行的过程不会被打断,即不会被抢占,不会休眠等。
- 对原子操作的支持和具体机器体系结构有关,可以在asm/atomic.h 下面找到相应原子操作的API。
4). 自旋锁(spinlock)
- 只能被一个可执行线程获取持有
- 如果自旋锁已经被A线程持有,B线程如果试图获取自旋锁,不同于信号量,B线程不会阻塞休眠,而是会占用一个CPU,不停得循环-旋转-等待。
- 自旋锁一般都是用在内核态,SMP的情况下, 线程A持有自旋锁在CPU1运行,线程B试图获取自旋锁失败,在CPU2不停得循环-旋转-等待。因而,最好不要长期持有自旋锁,不然将造成CPU资源的浪费。
- 自旋锁相关的API在include/linux/spinlock.h中,其具体的实现与机器体系结构,以及内核是否支持SMP有关。
- 自旋锁不可递归获取,已经获取自旋锁的线程,如果再次尝试获取自旋锁,将导致自旋死锁。
- 中断、软中断可以使用自旋锁,但是在持有锁之后,需要禁止本地CPU中断(即如果中断发生在CPU1,则禁止CPU1的中断,CPU2中断还是可以打开)。否则,再次发生的新中断,可能打断当前的中断,并尝试再次获取自旋锁,使得自旋锁被中断递归获取,造成双重死锁。
- 如果CPU1上的中断持有自旋锁,CPU2上发生中断,并且尝试获取该自旋锁,那么CPU2上的中断将会自旋-循环-等待,直到CPU1上的中断释放自旋锁为止。
- 单CPU情况下,自旋锁的实现很简单,自旋锁加锁函数只需要禁止内核抢占就可以了。因为单处理器禁止内核抢占后,线程A持有锁,并占有唯一的CPU运行在内核态。不可能有第二个线程运行在内核态并试图持有自旋锁。同理,如果内核不支持抢占,那么自旋锁就是空函数,不可能存在第二个试图获取自旋锁而无法获取的线程。
- 综上所述,自旋锁大部分情况下用在SMP的场景。
5). 读写自旋锁
- 顾名思义,读写自旋锁就是在自旋锁的基础上,加上读写相关的场景。
-读写自旋锁相关的API在include/linux/rwlock.h中.
- 读写自旋锁基本模型类似生产者/消费者模型。
- 读写场景有所不同,多个读者可以获取同一个锁,只能有一个写者获取锁。写者锁具有排他性,写者持有锁时,其它读者尝试获取锁时,将会处于自旋状态。
- 读写自旋锁不会造成休眠,只会自旋。
6). 信号量
- 信号量可以被一个或多个线程获取,信号量有初始值,当信号量的值降到0时,再次试图获取信号量的线程将休眠。
- 由于信号量会导致休眠,进而产生进程调度,因而加锁的开销比较大,大于自旋锁。是否使用信号量需要谨慎考虑系统调度开销问题。短期持有的情况下,不建议使用信号量,更推荐自旋锁。
- 信号量会产生阻塞,因而不可用于中断和软中断。
-信号量相关的API在include/linux/semaphore.h中.
7). 读写信号量
- 类似与信号量,加入了读写场景,无法获取锁时将被阻塞休眠。
8). 互斥体
- 互斥体和信号量基本类似,区别在于它只有0、1二值,任何时候只能被一个线程获取,不像信号量有初始值,可以被多个线程获取。
- 互斥体的上锁者必须负责给互斥体解锁。
- 互斥体不允许递归地获取,这样会导致死锁。
- 互斥体相关的API在include/linux/mutex.h中.
- 互斥体试图获取而无法获取时,会引发阻塞睡眠,因而不适应中断、软中断的场合。
9). 完成变量
- 一种任务之间互相通知同步的简单方案, 用于替代信号量
- 完成变量相关的API在include/linux/completion.h中.
10). 大内核锁
- 一种全局的锁,能够锁住整个内核禁止抢占。
- 新的用户不建议使用打内核锁。
- 如果有必要,大内核锁相关的API在include/linux/smp_lock.h中.
11). 顺序锁
- 有疑义的数据写入,会得到锁,并且会有计数器序列值增加。
- 读取数据前后,与数据相关的计数器的序列值都会被读取。
- 比较读取前后的序列值,如果读取前后序列值相同,证明读取过程未被打断。如果序列值是偶数,则表明没有写操作发生。
- 顺序锁相关的API在include/linux/seqlock.h中.
- 顺序锁大部分情况用在读写者相关的地方,一般读者多,写者少,写优先于读,且读者不能让写者饥饿。
12). 禁止抢占
- 禁止抢占相关的API在include/linux/preempt.h中。
- 用在某些不需要自旋锁,但是也要禁止抢占的场合。
13). 顺序与屏障
- 某些需要和机器硬件打交道的场合,代表外部硬件交互的变量,读写顺序要有保障,不能被打乱。
- 编译器和处理器可能自作聪明地打乱命令的执行顺序。
- 此时需要读/写屏障,保障屏障前后的顺序不会被打乱。
- 相关的API实现与具体机器架构有关,一般放在asm/system.h中。
14). 通知链机制
- 通知链是Linux内核态中,模块之间,通过注册回调函数,互相异步通知的机制。其设计思路类似于设计模式中的订阅者-发布者(也叫观察者)模型。
- 通知链相关的API在include/linux/notifier.h中。
- 通知链的工作原理如图4所示。
- notifier_head在通知链里担任发布者角色,每个对发布者事件感兴趣的模块,可以注册一个notifier_block作为订阅者。
- 一旦发布者产生对应的事件,会依次调用通知链上notifier_block所注册的callback函数,通知所有感兴趣的订阅者。
- 通知链有很多种类,有原子级(不可阻塞),可阻塞的,支持SRCU的,具体细节根据自己的应用场景,选择合适的通知链。
Figure 4 通知链的工作原理
7. 定时器与时间管理
1). 概念整理
- 墙上时间,真实世界的世界,比如2017-01-27-17:21:36秒就是一个墙上时钟。
- 系统运行时间,系统开机启动后所经历流逝的时间。
- RTC时钟,用外部电池供电,关机后持续记录世界时间流逝的设备。系统开机后用RTC时钟的值来初始化墙上时钟。
- 系统定时器,一般是SOC或者说计算机体系结构中的一个设备,通过产生定时器中断发出时钟节拍,为Linux系统提供系统基准时钟.
- 系统定时器每发生一次中断产出一个时钟节拍(tick)。
- 系统定时器每秒钟产生的时钟节拍tick数,即系统定时器频率,在内核中存放在全局变量HZ。
- jiffies是一个全局变量,用来计算系统启动后产生的tick时钟数。
- jiffies由于历史原因,32位的jiffies变量可以有时间溢出问题,目前有64位的jiffies_64可以修复该问题。
- 系统运行时间(秒)可以用jiffies / HZ计算出来。
2). 系统定时器中断处理程序
- 系统定时器中断是Linux内核最重要的中断,其中断服务函数ISR负责系统时钟相关的一系列更新工作。
- 系统定时器中断处理函数主要任务流程如下:
访问墙上时钟-- > 响应重新设置系统时钟的请求-- > 周期性地使用墙上时钟更新RTC时间-- >调用tick_periodic()处理剩下的体系结构无关工作。
- tick_periodic()所处理的任务流程:
更新jiffies --> 更新当前进程消耗的系统时间和用户时间 -- > 执行到期的动态定时器timer相关的 -- > 通过调用scheduler_tick() 更新当前CPU进程调度器的时钟统计,并且适时决定是否将当前进程设置为need_scheded以便下一步进行抢占-- > 更新墙上时钟– > 计算更新平均负载值
3). 墙上时钟
- 墙上时钟相关的的API 和描述结构在include/linux/time.h中描述。
- 用户态通过相关的API和结构可以从内核获取墙上时钟。
4). 动态定时器timer
- 动态定时器是一种非常重要的资源,内核其它驱动函数和模块通过动态定时器timer获取定时功能。
- 动态定时器timer相关的的API 和描述结构在include/linux/timeer.h中描述。
- timer的API使用者,定义定时器回调函数,设置超时时间,将自己的timer注册添加到系统的timer_list上。
- 当系统定时器中断发生后,中断处理函数通过tick_periodic()检查timer_list上已注册的超时定时器timer, 执行timer注册的回调函数。
- 动态定时器超时,并且回调函数被执行过以后,需要通过mod_timer()更新到期时间,才能在下次到期时被系统时钟定时器处理执行。
- 动态定时器timer的实现实际上是一种软中断,在系统定时器中断处理函数IS通过tick_periodic()触发的软中断TIMER_SOFTIRQ,该软中断就是动态定时器软中断,相关的hanlder函数会处理timer_list上超时定时器的回调函数。
- 内核会对动态定时器timer分组排序,以提高插入,删除,搜索超时定时器的效率。
5). Timeout延迟机制
- 除了动态定时器timer, Linux内核timebefore(),udelay(), ndelay(), mdelay(),schedule_timeout()等一系列延迟等待的机制和API。
8. VFS虚拟文件系统
1). 什么是虚拟文件系统
- VFS虚拟文件系统是Linux内核一个重要的抽象层。
-VFS为用户态程序提供了一个系统级的Unify的访问文件系统的接口,不管具体的文件系统是哪种类型,存储方式如何,具体的文件是何种类型的数据文件,还是硬件设备的抽象文件或者是远程网络上的问题。用户都可以通过一致的文件访问函数来访问这些抽象文件,而无需知道文件过多的细节。
- Linux内核在上层,通过把万物抽象成文件,从而不管是为文件存储还是驱动开发,提供了一个标准化的上层抽象框架(基类),内核的开发者只需按照框架,继承、实现、扩展相应的文件系统函数或者设备驱动,从而满足了面向对象的开闭原则(对用户态的修改关闭,对内核态的扩展开放)。
2). 用户态通过VFS层与具体的文件交互流程
- 用户程序通过POSIX 标准API访问具体文件的路径如图4所示。
Figure 5 应用程序通过VFS访问具体文件的路径
3). VFS层实现的基本原理
- VFS层的API与数据结构位于include/linux/fs.h文件中
- 实现VFS的关键对象之间的关系如图5所示:
- file是文件在进程中描述,随着生命周期随着进程消失而消失
- inode对象是一个具体的文件在内核中的描述实例,inode生存周期伴随着内核存在而存在。file被进程拥有,实际上是建立其进程对文件inode的引用。
- VFS定义和file通过super_block找到inode,然后调用inode实现的具体API访问文件数据的一系列框架,而file、super_block与inode等对象的访问数据文件的方法则由具体的文件系统去实现。
- fs/目录下的各种文件系统代码都是内核现有的支持的具体文件系统的实现,它们也是在VFS框架上,实现file、super_block, inode管理等一系列的具体的函数方法。各种具体的文件系统实现都比较复杂,细节之处需要仔细钻研代码。
Figure 6 VFS中核心对象之间的关系
9. 内存管理
1) 物理内存管理
- CPU最小的可寻址单位是字或者字节(根据位数和体系结构不同)
- 通过MMU管理物理内存的最小单位是页。
- 内核使用include/linux/mm_types.h中的struct page对象来描述物理页。每个物理页都需要一个struct page对象实例来描述。
2) 物理内存分区
- 由于计算机硬件限制,某些物理页有特殊的任务,一些硬件需要特定内存地址执行DMA, 有些机器实际物理内存地址比虚拟寻址范围要大,因而需要将物理页分区。
- 主要的物理页分区在include/linux/mm_zone.h下面有定义,主要的分区以及用途如下:
* ZONE_DMA, 这个区包含用来执行DMA的页。
* ZONE_DMA32, 与ZONE_DMA类似,特殊处在于,该区的页只能被32位设备访问,部分体系结构中,该区域比ZONE_DMA要大。
* ZONE_NORMAL, 正常的内存地址映射区域。
* ZONE_HIGHEM,高端内存区,该区域不能被永久自动地映射到内核的虚拟地址空间(32位X86中,kernel space约1G, user space 3G,所以在内核虚拟地址空间,超过1G的内存为高端内存)。需要动态的映射。能否直接被虚拟地址空间映射也要取决于体系结构。在X86上,ZONE_HIGHEM为高于896MB的所有物理内存。
- 高端内存相关的API一般在include/linux/hignmem.h中,一般通过gfp_mask标志,用alloc_pages()获取高端内存的page描述符后,可以通过kmap()将高端内存映射到虚拟地址空间,但是允许使用kmap()映射的内存数量是有限的,不再需要映射时,应该用kunmap取消映射。
- 系统的页分区,是由Linux内核在逻辑上划分的,目的是为了形成不同的物理内存池,在特殊需要的时候,从不同的物理内存池取出对应的页面。
- 分配内存页的时候,不可能同时从两个不同的区划分物理页。
3). 获得与释放物理页
- 获取与释放物理页相关的API在include/linux/gfp.h中声明
- gfp.h中的API获取页的API有两种类型返回值,alloc_pages()获取2^n个物理页面,返回获取第一个物理页面的对象描述实例。__get_free_pages()直接返回所分配的物理页面的映射的逻辑地址(虚拟地址)的值。
- 在分配物理页面时,GFP_*开头的宏作为分配函数gfp_mask的参数,提示内核在哪些区域以何种方式分配物理页(比如DMA区域允许WAIT方式分配内存)。
- 以free开头的函数就是对称的释放已分配物理页的函数.
4). 内核态虚拟内存申请
- kmalloc()函数用于内核态申请动态虚拟内存(新手注意内核态不能使用用户态的libc库,工具链提供的库和标准C函数不能使用),类似用法类似于用户态的malloc函数。其多出的gfp_mask如上一节获取物理内存页描述的,提示底层在哪个物理内存区以何种方式分配内存页面。gfp_mask的标志含义和细节如图7所示。
Figure 7 不同gfp_mask的含义
- vmalloc() 与 kmalloc()一样,都是内核态申请分配虚拟内存的函数。差异在于,vmalloc()只保证分配的内存,在虚拟地址空间上是连续的,不保证其映射的物理地址是连续的。而kmalloc()分配的内存,虚拟地址和物理地址都是连续的。
- vmalloc()分配的内存,不保证物理地址空间上的连续,因而容易引发内存TLB(翻译后的物理页缓存器,一般由SOC或者CPU的硬件部分实现)抖动,因而只有在获取大块内存,且不考虑物理地址连续性的条件下才使用vmalloc()。
5). 关于虚拟地址和物理地址的问题
- CPU使用虚拟地址查找页表,从而把虚拟地址转换为物理地址。
- 一般Linux内核有三级页表,32位虚拟地址(如0x8ed20000)可以分为4个部分(域),4个部分都是由含义的分别代表各级页表与页帧的索引,可以通过这些索引依次从各级页表和页帧中找到该32位虚拟地址(0x8ed20000)所对应的真正的物理地址。32位虚拟每个域的含义和索引方法如图8所示。
Figure 8 32位虚拟地址分为4个域,每个域对应一张表的索引,最终查找到其物理地址
- PGD(全局页目录) --> PMD(中间页目录) -- > PTE(保存page entry的数组) -- > 页帧(物理地址所在物理页) -- > 加上物理页首地址+ offset就是物理地址
- 在一个进程的 task_struct -> mm ->pgd指针指向Linux内核全局共享的pgd表,因而系统可以从这里开始,顺利成章地索引到进程虚拟地址对应的物理地址
- 物理内存的页经常被称为页帧(page frame),Linux内核中经常出现的带有pfn的变量,pfn是指page frame number,即该页帧在页表(PTE)中索引号,即该页是PTE表中的第几页。
6) slab分配器
- slab分配器是类似kmalloc()这里上层虚拟内存分配函数的底层实现机制。
- 请注意,kmalloc()函数有很多种实现机制,除了使用slab分配器,还可以使用更复杂的slob, slub分配机制,这里只将较为简单的slab机制。
- slab分配器相关的API在include/linux/slab.h中声明。
- slab分配器思想是,为需要经常alloc/free的数据结构对象建立一个slab高速缓存组,每一个进入高速缓存的对象实例作为一个节点。需要分配一个A类型的数据对象时,当如果A类型的slab高速缓存组有未用完的A对象的数据节点,就直接从缓存中获取A对象实例,再重新初始化。如果slab高速缓存中没有现存的节点,再重新申请分配物理内存。
- 同理,释放对象的时候,把失效的对象A作为数据节点放回A对象的slab高速缓存,以便需要时再从中获取。
- slab分配器提高了常用数据结构对象的内存分配效率,无需每次都申请物理内存再转换为虚拟内存,缓解了内存碎片化的问题。
Figure 9 slab分配的高速缓存,作为一个缓冲池来管理和分配特殊对象的虚拟内存
7) .内核栈上的内存
- 内核态中可以光明正大的使用函数局部变量从而使用内核栈的内存,没有太多诀窍,谨记节约使用。一般进程的内核栈空间是1-2页,,所以不要定义过大的静态数据结构,不要随意使用递归,内核栈溢出(stack overflow)悄无声息,没有提醒,会造成不可预知的错误。
8). SMP中每个CPU的私有数据
- include/linux/percpu.h声明了为每个CPU分配自己私有数据的API
10. 进程虚拟地址空间管理
1). 进程虚拟地址空间
- 进程地址空间由进程可寻址虚拟内存组成,是进程最重要的资源。进程拥有独立的虚拟地址空间,而线程没有。这也是进程和线程最重要的差别。
- 进程地址空间的大小有机器的位数决定。
- 进程地址空间可以分为很多区域(memoryareas),每个区域有不同的读、写、执行权限。代码段(text)、数据段(data)、未初始化内存(bss)、内存映射文件区域(mmap映射文件)、共享内存区域,匿名内存映射堆区域(malloc分配内存)等都是进程虚拟地址空间中的不同区域。
2). 进程虚拟地址空间的管理
- 进程的虚拟地址空间用struct mm_struct 对象来描述。
- 进程描述对象 task_struct.mm域指向当前进程的进程虚拟地址空间对象实例。
- 内核线程没有用户态,因而它的虚拟地址空间task_struct.mm为空。
- 内核线程在调度时,使用了小技巧。当一个进程被调度时,该进程的task_struct.mm 域被装载到内存,同时task_struct.active_mm域被更新,指向新的进程地址空间。内核线程task_struct.mm 域为空,无法装载到内存,因而在内存中保存上一个进程的地址空间,同时更新task_struct.active_mm域指向上一个进程有效的地址空间。内核线程可以访问上一个进程的页表,但是只会使用与内核态内存相关的区域。
- struct mm_struct 对象还管理着进程的页表。 Linux系统采用三级页表,即通过全局页目录(pgd)-- > 中间页目录(pmd) --> 页表(pte) -- > 物理页面的方式索引物理页。
- struct mm_struct的pgd域指向的是全局的页目录pgd,这个全局页目录由所有进程所共享的,体系结构相关的硬件可以通过这个pgd来索引进程相关的所有物理页。
3). 虚拟地址空间的区域
- struct mm_struct中的mmap域指向了当前进程所有的memory area,所有不同的区域用链表连起来。
- struct vm_area_struct是一个内存区域的描述对象,每个struct vm_area_struct对象都对应这进程地址空间中的唯一区域。对象相关的域记录了该区域的虚拟内存起始地址,尾地址,读、写、执行标志(vm_flags),访问控制权限等。
- 每个特定struct vm_area_struct还有一组由 struct vm_operations_struct描述的方法函数vm_ops,这些方法函数声明了该memory map的open/close/fault/acess相关的方法,具体实现根据不同区域而确定。
- struct vm_area_struct有一个vm_rb的域作为红黑树节点,便于被struct mm_struct对象中的mm_rb通过红黑树节点管理。mm_rb可以通过红黑树的方法,快速查找遍历特殊的内存区域。
4). 虚拟地址空间的操作
- 操作内存区域vm_area的API都在include/linux/mm.h中声明
- mmap()函数的实现大部分都是采用vm_area相关的API,mmap()调用需要大量创建,合并,删除vm_area区域。
11. 块设备与块I/O
1). 块设备与缓冲区
- 块设备是内核中不按照顺序,随机访问的设备。
- Linux内核中”块”是高层的抽象结构。实际的大多数块设备(例如硬盘,光盘)最小寻址单元是扇区。内核的块是高层的抽象,一般块大小是扇区大小的2^n倍。
- 块设备磁盘I/O数据读入的缓冲区,其上层的访问可以通过页高速缓存来管理,这时块设备I/O的缓冲区称为缓冲区页高速缓存(全名,很绕)。在Linux内核2.4之后的版本,为了统一管理,避免数据不同步问题,块I/O的读写缓冲区已经和页缓存机制统一。
- 也就是说,上层是否使用页缓存机制与块设备的底层操作本身没有关系(当然绝大多数情况下上层会使用高速页缓存),如果上层使用高速也缓存,那么块设备I/O的缓冲区将于高速页缓存保持统一。如果上层不支持页高速缓存,块设备I/O操作还是有自己的缓冲区和调度策略。
- 块设备I/O是作为执行者,执行上层的块I/O读写命令,并且在I/O读写的调度上提供一定的效率优化措施。而高速页缓存更像上层文件数据管理策略的制定者,制定管理文件数据页面缓存的机制,必要时才调用块设备I/O这个执行者执行块设备I/O的机制。
- 以下是引用知乎某大神对缓冲区与缓存的解释,非常清晰:
* Buffer(缓冲区)是系统两端处理速度平衡(从长时间尺度上看)时使用的。它的引入是为了减小短期内突发I/O的影响,起到流量整形的作用。比如生产者——消费者问题,他们产生和消耗资源的速度大体接近,加一个buffer可以抵消掉资源刚产生/消耗时的突然变化。
* Cache(缓存)则是系统两端处理速度不匹配时的一种折衷策略。因为CPU和memory之间的速度差异越来越大,所以人们充分利用数据的局部性(locality)特征,通过使用存储系统分级(memory hierarchy)的策略来减小这种差异带来的影响。
* 假定以后存储器访问变得跟CPU做计算一样快,cache就可以消失,但是buffer依然存在。比如从网络上下载东西,瞬时速率可能会有较大变化,但从长期来看却是稳定的,这样就能通过引入一个buffer使得OS接收数据的速率更稳定,进一步减少对磁盘的伤害。
* TLB(Translation Lookaside Buffer,翻译后备缓冲器)名字起错了,其实它是一个cache。
2). 旧的buffer_head与缓冲区管理
- 实际的块设备读写的开销比较大,为了提高效率,需要批量读入数据放到内存中缓冲。
- 内存中采用buffer_head结构管理读入块数据的缓冲区,buffer_head对象是缓冲区的头,用于描述和管理整个缓冲区的状态和数据。
- 缓冲区中的数据,就是真正磁盘块设备中的数据的映射。
3). 新的bio矢量数组聚散I/O方法
- bio 结构相关的API在include/linux/bio.h中声明。
- bio采用一种向量I/O的策略,将一组I/O访问操作聚集起来,统一执行。
- bio操作将加入块I/O请求队列request, 等待被执行。
- request与request_list相关的API在include/linux/blkdev.h中声明。
- request请求队列通过Linus电梯提供给的对象选择调度测试,在适当的时机访问真正的块设备。
4).块 I/O调度
- request队列需要有调度策略支持,才能调度分配资源执行I/O读写。
- Linus 电梯, 是以Linus命名的块I/O读写调度策略的一个模板基类,该。
- Linus 电梯相关的方法和框架函数在include/linux/elevator.h中声明。
- Linus 电梯执行流程是以类似电梯上客的方式,调用框架中声明的API读写磁盘的扇区。
- as/deadline/CFQ/noop 调度策略都继承了Linus电梯的框架函数,实现了自己的调度策略函数。
- 内核可以通过命令行,配置elevator=as/cfq等,来配置系统的块I/O调度测试。
12. 页缓存与回写
1). 内存页高速缓存的作用
- 内存读写时间与磁盘块设备读写时间有几个数量级的差距。
- 数据访问具有局部性统计规律,级最近访问的数据有大的概率再次访问。
- 内存页高速缓cache存技术通过将磁盘块设备中经常访问的物理块映射到物理页,以加快读写速度,提高访问效率。
2). 内存页缓存的更新机制
- 在不缓存的情况下,修改数据直接读到磁盘,当以后要用到之前修改的数据,每次都要从磁盘再次读取,增加了读的次数。
- write-through-cache, 在更新内存页缓存的同时,更新磁盘数据。这样能随时保持数据同步,但是磁盘I/O写操作过于频繁,还是影响系统效率。
- write-back, 先更新内存页缓存,将缓存标页记为dirty, 磁盘块设备不会立即更新,等到合适的时机,通过回写进程写回到磁盘。这样系统效率将大大改善,当然也增加和缓存管理的复杂度。
3). 内存页缓存回收机制
- 系统需要适当回收页缓存,否则将会使得其它应用程序遇到内存不够用的问题.
- LRU是最近最少使用的回收原则,通过标记页面时间戳,在LRU链表中选择最近最少使用的缓存页回收。
- Linux内核采用双链LRU缓存回收机制,维护两个LRU链表,一个活跃链表,一个非活跃链表,即冷热缓存链表。非活跃链表上的缓存页才会在必要时候被换出回收。活跃缓存中最近最少使用的缓存页被换出的不活跃链表。
- 当多文件打开时,将文件数据读入高速页缓存,在文件间切换时,将节约I/O次数,有效提高块磁盘I/O的效率。
4). Linux内核高速缓存页相关代码
- 因为本身可能分布在磁盘块不同物理位置,物理页可能4KB,而磁盘块扇区大小可能只有512Byte, 所以一个页高速缓存可能映射到多个不连续的物理磁盘块。
- 在file对象中,有一个struct address_space对象的f_mapping域,address_space对象的命名有些文不对题,应该叫page_cache_entity或者physical_pages_of_a_file比较合适。
- struct address_space对象作为一个文件的页高速缓存cache的抽象描述符,用于管理磁盘文件映射的页高速缓存状态,并且提供的函数方法用户访问同步回写页高速缓存与磁盘块设备上的文件数据。
- struct address_space对象所声明的struct address_space_operations函数方法,不同文件系统(如ext3/ubifs等)实现不同,不同文件系统有不同的同步,回写机制,文件系统的设计者要思考如何具体实现这些函数方法。
- 所以页I/O操作都要用执行内核定义的流程,使用struct address_space_operations函数方法,
- struct address_space对象内嵌有radix_tree对象,在文件I/O操作之前,可以内核通过基树管理页高速缓存,内核可以通过radix tree搜索的方式,检查文件是否通过物理页映射,已经存在于页高速缓存中了。
- 通过页Hash表检索文件页高速缓存的方法在Linux 2.6 之后的内核被弃用了,原因在于Hash表的锁机制导致的独占性问题,存储太大的问题,键值冲突时链表搜索效率问题等。
- 页高速缓存会通过flusher线程在适当的时机,通过调用块设备磁盘I/O相关的函数方法,回写到磁盘,与使得磁盘数据与内核保持一致同步。Flusher线程有一系列避免拥塞和饥饿的调度机制。
13. 模块管理
1). 模块
- Linux内核模块提供一个在Linux内核启动后,内核程序动态装载/卸载的机制。
- 模块相关的API在include/linux/module.h中。
- 内核模块区别与普通内核代码,配置成模块后,一般在make modules 目标中编译,当然make all也会执行make modules的目标。
- 通过内核代码只要编译成.ko格式的模块代码,可以通过modprobe insmode rmmod等命令或者相关函数,在运行时对代码进行动态装载链接。
2). 导出符号和参数
- 模块与符号管理紧密相关,内核中通过EXPORT_SYMBOL()导出的函数或者变量,在模块insmod时候,能够被模块识别并且链接上,这样就解决了内核态代码动态链接与加载的问题。
- 用module_param()声明的模块参数,在模块装载时,能够动态的设置值,这样为模块管理增加了灵活性,也方便调试。
- 在文件系统的/lib/modules/modules.dep文件中,描述了模块之间的依赖关系,modprobe操作通过这个文件依次装载互相依赖的模块。
14. IPC机制
15. Linux设备模型
Linux 设备模型将在面向对象的思想分析Linux设备驱动模型中详细讲解。
以上是关于Linux内核重点精要的主要内容,如果未能解决你的问题,请参考以下文章