20169215 《Linux内核原理与分析》 第十周作业
进程地址空间
进程地址空间是指用户空间中进程的内存,是每个用户空间进程所看到的内存。Linux采用虚拟内存技术,进程之间以虚拟的方式共享内存,每个进程好像都可以访问整个系统的所有物理内存。
进程地址空间由进程可寻址的虚拟内存组成。可以被访问的合法地址空间称为内存区域,进程只能访问有效内存区域内的内存地址。访问了不在有效范围内的内存区域或者以不正确的方式访问了有效地址的进程会被内存终止,并返回“段错误”信息。内存区域包含各种内存对象:
- 代码段,即可执行文件代码的内存映射。
- 数据段,即可执行文件的已初始化全局变量的内存映射。
- 包含未初始化全局变量即bss段的零页的内存映射。
- 用于进程用户空间栈的零页的内存映射。
- 每一个诸如C库或动态链接程序等共享库的代码段、数据段和bss段也会被载入进程的地址空间。
- 任何内存映射文件。
- 任何共享内存段。
- 任何匿名的内存映射,如malloc分配的内存。
内核使用内存描述结构体表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息。内存描述符由mm_struct
结构体表示。进程的进程描述符task_struct
中的mm
域中存放着该进程使用的内存描述符。调用clone()时,设置CLONE_VM标志可以使子进程和父进程共享地址空间,共享地址空间的进程称为线程。
内核线程没有进程地址空间,也没有相关的内存描述符,内核线程对应的进程描述符中mm域为空——内核线程没有用户上下文。
内存区域(Linux内核中称作虚拟内存区域VMAs)由结构体vm_area_struct
结构体描述。该结构体描述了指定地址空间内连续区间上的一个独立内存范围。vm_start
指向区间首地址,vm_end
指向区间尾地址的后一个字节,两者之差是区间的长度。VMA标志是一种位标志,包含在vm_flags
域内,标志所包含的页面的行为和信息。vm_ops域指向与指定内存区域相关的操作函数表。
可以使用/proc文件系统和pmap(1)工具查看给定进程的内存空间和其中所含的内存区域。/proc/pid/maps可以显示进程标识值为pid的进程的地址空间中全部内存区域。
内核提供了find_vma()
函数来找到给定的内存地址属于哪一个区域。struct vm_area_struct * find_vma(struct mm_struct *mm, unsigned long addr)
,该函数在地指定地址空间中搜索第一个vm_end大于addr的内存区域。find_vma_prev()
函数返回第一个小于addr的VMA。find_vma_intersection()
返回第一个和指定地址区间相交的VMA,是个内联函数。
内核使用do_mmap()
函数创建一个新的线性地址区间。如果创建的地址区间和一个已经存在的地址区间相邻且具有相同的访问权限,则将两个地址区间和并,否则创建一个新的VMA。在用户空间可以通过mmap()
系统调用获取内核函数do_mmap()功能。
do_munmap()
函数从特定的进程地址空间中删除指定地址区间。系统调用munmap()
给用户空间程序提供了一种从自身地址空间中删除指定地址区间的方法。
页高速缓存和页回写
页高速缓存是Linux内核实现磁盘缓存,其存在有两个重要因素:第一,访问磁盘速度远远低于访问内存的速度;第二,数据一旦被访问,就很有可能在短时间内再次被访问到(临时局部原理)。
当内核开始一个读操作,先检查需要的数据是否在页高速缓存中,如果在则直接从内存中读取数据,不在则将读来的数据同时也要放入页缓存中。
写缓存有三种策略:
- 不缓存,高速缓存不缓存任何写操作,跳过缓存写到磁盘上,同时使缓存中数据失效。
- 写透缓存,自动更新内存缓存,同时也更新磁盘文件。
- “回写”,写操作直接写到缓存中,后端存储不会立刻直接更新,将页高速缓存中被写入的页面标记成“脏”,由回写进程将脏页写回磁盘。
Linux缓存回收通过选择干净页进行简单替换。策略有LRU和双链策略。当空闲内存低于一个特定的阀值或者脏页在内存中驻留时间超过一个特定的阀值时,会通过flusher_thread()
唤醒flusher线程将脏页写回磁盘。
Linux页高速缓存使用address_space
结构体管理缓存项和页I/O操作。可能多个vm_area_struct对应一个address_space,即文件可以有多个虚拟地址,但是在物理内存中只能有一份。
实验楼实验
进程的调度和进程的切换有很多进程调度和切换算法,每个算法对cpu和i/o的需求不一样,大致有两种分类:第一种按照对硬件的需求分为I/O密集型进程和CPU密集型进程;第二种分类分为批处理进程、实时进程和交互进程。不同的分类需要选择不同的进程调度规则。
因为schedule()是内核函数,也不是系统调用用户态进程无法直接调用,只能通过中断处理过程或者是返回用户态时根据need_resched标记调用schedule()或者被动调用。内核线程可以直接主动调用schedule()函数。
schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换:
next = pick_next_task(rq, prev);//使用了某种进程调度策略,总是选择下一个进程,进程调度算法都封装这个函数内部
context_switch(rq, prev, next);//进程上下文切换
switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程。
Linux系统的一般执行过程分析
最一般的运行过程可以这样抽象,有一个用户态进程x,这个x进程需要切换到y进程:
- 正在运行的用户态进程X;
- 发生中断——save cs:eip/esp/eflags(current) to kernel stack,then load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack).使其有机会陷入内核态;
- SAVE_ALL //保存现场
- 中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换,做内核堆栈的切换;
- 标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行);
- restore_all //恢复现场;
- iret - pop cs:eip/ss:esp/eflags from kernel stack;
- 继续运行用户态进程Y。
在time系统调用返回前,要调用schedule(),在schedule设置断点:
进入sched_submit_work,看看它都干了些什么:
可以看到这个函数时检测tsk->state是否为0 (运行态)若为运行态时则返回,其中tsk_is_pi_blocked(tsk),检测tsk的死锁检测器是否为空,若非空的话就return。
然后检测是否需要刷新plug队列,用来避免死锁。sched_submit_work主要是来避免死锁。
然后进入__schedule()函数:
选择next进程,需要调度算法:
进入函数内部会发现是调用fair_sched_class中的一个函数:
判断prev和next是否相同,不同的话要进行上下文切换:
context_switch主要实现了切换全局页表项:
context_switch函数结尾调用了switch_to,这是个宏,无法追踪,稍后将会比对代码解释。
__schedule()是切换进程的真正代码,我们来分析一下具体的关键代码。
首先创建局部变量:
struct task_struct *prev, *next;//当前进程和一下个进程的进程结构体
unsigned long *switch_count;//进程切换次数
struct rq *rq;//就绪队列
int cpu;
然后关闭内核抢占,初始化部分变量:
need_resched:
preempt_disable();//关闭内核抢占
cpu = smp_processor_id();
rq = cpu_rq(cpu);//与CPU相关的runqueue保存在rq中
rcu_note_context_switch(cpu);
prev = rq->curr;//将runqueue当前的值赋给prev
选择next进程:
next = pick_next_task(rq, prev);//挑选一个优先级最高的任务排进队列
clear_tsk_need_resched(prev);//清除prev的TIF_NEED_RESCHED标志。
clear_preempt_need_resched();
完成进程调度:
if (likely(prev != next)) {//如果prev和next是不同进程
rq->nr_switches++;//队列切换次数更新
rq->curr = next;
++*switch_count;//进程切换次数更新
context_switch(rq, prev, next); /* unlocks the rq *///进程上下文的切换
cpu = smp_processor_id();
rq = cpu_rq(cpu);
} else//如果是同一个进程不需要切换
raw_spin_unlock_irq(&rq->lock);
其中context_switch相当重要,它完成了进程上下文的切换:
static inline void context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
struct mm_struct *mm, *oldmm;//初始化进程地址管理结构体mm和oldmm
prepare_task_switch(rq, prev, next);//完成进程切换的准备工作
mm = next->mm;
oldmm = prev->active_mm;
/*完成mm_struct的切换*/
if (!mm) {
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);
enter_lazy_tlb(oldmm, next);
} else
switch_mm(oldmm, mm, next);
if (!prev->mm) {
prev->active_mm = NULL;
rq->prev_mm = oldmm;
}
switch_to(prev, next, prev);//进程切换的核心代码
barrier();
finish_task_switch(this_rq(), prev);
}
在context_switch中使用switch_to(prev,next,prev)来切换进程,switch_to是一个宏定义的:
#define switch_to(prev, next, last)
do {
unsigned long ebx, ecx, edx, esi, edi;
asm volatile(
"pushlfl\n\t" /* save flags,保存进程A的ebp和eflags */
"pushl %%ebp\n\t" /* save EBP */
"movl %%esp,%[prev_sp]\n\t" /* save ESP,保存当前esp到A进程内核描述符中 */
"movl %[next_sp],%%esp\n\t" /* restore ESP,从next(进程B)的描述符中取出之前从B切换出去时保存的esp_B */
//从这个时候开始,CPU当前执行的进程已经是B进程了,因为esp已经指向B的内核堆栈。但是,现在的ebp仍然指向A进程的内核堆栈,所以所有局部变量仍然是A中的局部变量
"movl $1f,%[prev_ip]\n\t" /* save EIP,把标号为1的指令地址保存到A进程描述符的ip域,当A进程下次被switch_to回来时,会从这条指令开始执行 */
//将返回地址保存到堆栈,然后调用__switch_to()函数,__switch_to()函数完成硬件上下文切换
"pushl %[next_ip]\n\t" /* restore EIP */
"jmp __switch_to\n" /* regparm call */
"1:\t"
//从__switch_to()返回后继续从1:标号后面开始执行,修改ebp到B的内核堆栈,恢复B的eflags
"popl %%ebp\n\t" /* restore EBP */
"popfl\n" /* restore flags */
//这时候ebp已经指向了B的内核堆栈,所以上面的prev,next等局部变量已经不是A进程堆栈中的了,而是B进程堆栈中的(B上次被切换出去之前也有这两个变量,所以代表着B堆栈中prev、next的值了)
/* output parameters */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip),
"=a" (last), //将eax写入last,以在B的堆栈中保存正确的prev信息,即last_B <== %eax
/* clobbered output registers: */
"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)
/* input parameters: */
: [next_sp] "m" (next->thread.sp),
[next_ip] "m" (next->thread.ip),
/* regparm parameters for __switch_to(): */
//复制两个变量到寄存器, 这里prev和next都是A进程的局部变量
[prev] "a" (prev), //即eax <== prev_A 或 eax <==%p(%ebp_A)
[next] "d" (next) //edx <== next_A 或 edx <==%n(%ebp_A)
: /* reloaded segment registers */
"memory");
} while (0)
schedule是主调度函数,涉及到一些调度算法,这里不讨论。当schedule()需要暂停A进程的执行而继续B进程的执行时,就发生了进程之间的切换。进程切换主要有两部分:1、切换全局页表项;2、切换内核堆栈和硬件上下文。这个切换工作由context_switch()完成。其中switch_to和__switch_to()主要完成第二部分。更详细的,__switch_to()主要完成硬件上下文切换,switch_to主要完成内核堆栈切换。
阅读switch_to时请注意:这是一个宏,不是函数,它的参数prev, next, last不是值拷贝,而是它的调用者context_switch()的局部变量。局部变量是通过%ebp寄存器来索引的,也就是通过n(%ebp),n是编译时决定的,在不同的进程的同一段代码中,同一局部变量的n是相同的。在switch_to中,发生了堆栈的切换,即ebp发生了改变,所以要格外留意在任一时刻的局部变量属于哪一个进程。关于__switch_to()这个函数的调用,并不是通过普通的call来实现,而是直接jmp,函数参数也并不是通过堆栈来传递,而是通过寄存器来传递。
static void __sched __schedule(void)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq *rq;
int cpu;
need_resched:
preempt_disable(); //关闭内核抢占,关于内核抢占详见注释1
cpu = smp_processor_id();
rq = cpu_rq(cpu); //跟当前进程相关的runqueue的信息被保存在rq中
rcu_note_context_switch(cpu);
prev = rq->curr; //当前进程放入prev
schedule_debug(prev);
if (sched_feat(HRTICK))
hrtick_clear(rq);
raw_spin_lock_irq(&rq->lock);
switch_count = &prev->nivcsw;
//如果内核态没有被抢占,并且内核抢占有效
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
//如果当前进程有非阻塞等待信号,并且它的状态是TASK_INTERRUPTIBLE
if (unlikely(signal_pending_state(prev->state, prev))) {
prev->state = TASK_RUNNING; //将当前进程的状态设为:TASK_RUNNING
} else {
deactivate_task(rq, prev, DEQUEUE_SLEEP);//将当前进程从runqueue(运行队列)中删除
prev->on_rq = 0; //标识当前进程不在runqueue中
//这里涉及到工作队列的知识,我们在以后的章节里在来说,这里略过
if (prev->flags & PF_WQ_WORKER) {
struct task_struct *to_wakeup;
to_wakeup = wq_worker_sleeping(prev, cpu);
if (to_wakeup)
try_to_wake_up_local(to_wakeup);
}
}
switch_count = &prev->nvcsw;
}
pre_schedule(rq, prev);
if (unlikely(!rq->nr_running))//如果runqueue中没有正在运行的进程
idle_balance(cpu, rq); //就会从其它CPU拉入进程
put_prev_task(rq, prev); //通知调度器,当前进程要被另一个进程取代,做好准备
next = pick_next_task(rq); //从runqueue中选择最适合的进程
clear_tsk_need_resched(prev); //清除当前进程的重调度标识
rq->skip_clock_update = 0;
//当前进程与所选进程是否是同一进程,不属于同一进程才需要切换
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next; //所选进程代替当前进程
++*switch_count;
context_switch(rq, prev, next); //负责底层上下文切换
cpu = smp_processor_id();
rq = cpu_rq(cpu);
} else
raw_spin_unlock_irq(&rq->lock); //如果不需要切换进程,则只需要解锁
post_schedule(rq);
sched_preempt_enable_no_resched();
if (need_resched())
goto need_resched;
}
参考文献
【内核】进程切换 switch_to 与 __switch_to:http://www.cnblogs.com/visayafan/archive/2011/12/10/2283660.html