深入理解Linux内核之内核线程(下)

2021-08-06 16:01:08 浏览数 (1)

虽然讲解完了内核线程的创建过程,但是似乎又少点什么,那么下面我们来看两个细节:内核线程执行处理函数和内核线程上下文切换细节:

7.内核线程执行处理函数细节

内核线程执行到处理函数要从fork说起:

7.1 fork准备调度上下文

代码语言:javascript复制
kernel_thread  //kernel/fork.c
->struct kernel_clone_args args = {
        .stack          = (unsigned long)fn,      //借用线程栈指针 指向内核线程执行函数
        .stack_size     = (unsigned long)arg //借用线程栈大小 指向内核线程执行函数传递的参数
};
->kernel_clone(&args)
    ->copy_process
        ->copy_thread (clone_flags, args->stack, args->stack_size, p, args->tls)
            if (likely(!(p->flags & PF_KTHREAD))) {
                ...
            } else {  //处理内核线程创建
                        ...                                                          
               p->thread.cpu_context.x19 = stack_start;     //内核线程执行函数赋值给调度上下文的   x19       
               p->thread.cpu_context.x20 = stk_sz;          //内核线程执行函数传递的参数赋值给调度上下文的   x20              
       }                                                              
       p->thread.cpu_context.pc = (unsigned long)ret_from_fork;  //设置第一次被调度后执行的 函数     
       p->thread.cpu_context.sp = (unsigned long)childregs;      // 设置第一次被调度后  内核栈  
                                                                  

上面fork 对于创建内核线程已经注释的很清楚,这是为内核线程第一次被调度执行做准备。

7.2 使用调度上下文

当内核线程被唤醒,在合适的时机被调度时,会执行如下内核路径:

代码语言:javascript复制
__schedule  //kernel/sched/core.c
->context_switch
    ->switch_to
        ->__switch_to  //arch/arm64/kernel/process.c
            ->cpu_switch_to 
                -> 进行处理器上下文切换,即切换调度上下文  arch/arm64/kernel/entry.S

会将内核线程的 p->thread.cpu_context.pc 恢复到pc,然后就执行了ret_from_fork:

代码语言:javascript复制
arch/arm64/kernel/entry.S
951 /*
952  * This is how we return from a fork.
953  */
954 SYM_CODE_START(ret_from_fork)
955         bl      schedule_tail
956         cbz     x19, 1f                         // not a kernel thread
957         mov     x0, x20
958         blr     x19
959 1:      get_current_task tsk
960         b       ret_to_user
961 SYM_CODE_END(ret_from_fork)

首先调用schedule_tail对前一个进程进程收尾工作,然后就判断x19寄存器的值是否为0, 其实有一个细节在copy_thread中首先就对p->thread.cpu_context做了清零操作

代码语言:javascript复制
copy_thread
         memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context))

上面copy_thread中我们已经看到对 p->thread.cpu_context.x19 设置为了线程执行函数,调度的时候,设置进了x19 中,接着ret_from_fork将 x20赋值到x0, 就做了内核线程参数的传递动作,接着就执行958行跳转到了线程执行函数中执行了,于是新创建的内核线程才开始真正的欢乐执行

8.内核线程上下文切换细节

现在来说下内核线程进行上下文切换时的技术细节:

8.1 关于mm_struct的借用

我们知道内核线程比较特殊没有用户地址空间的概念,共享内核地址空间,而mm_struct结构专门用来描述用户地址空间的,我们知道对于arm64架构来说,有两个页表基址寄存器ttbr0_el1和ttbr1_el1, ttbr0_el1用来存放用户地址空间的页表基地址,在每次调度上下文切换的时候从tsk->mm->pgd加载,ttbr1_el1是内核地址空间的页表基地址,内核初始化完成之后存放swapper_pg_dir的地址。

所以,当切换下一个任务为内核线程的时候不需要切换用户地址空间:

代码语言:javascript复制
__schedule  //kernel/sched/core.c
->context_switch
    ->if (!next->mm) {   //是内核线程
        next->active_mm = prev->active_mm;    //下一个内核线程的active_mm指向 prev->active_mm
        if (prev->mm)                           // from user    前一个任务是用户任务
            mmgrab(prev->active_mm);        //将前一个任务的    prev->active_mm引用计数加1 防止被释放           
        else                    //前一个任务是内核线程                              
            prev->active_mm = NULL;   //将前一个内核线程的 active_mm  情况,下一次再次切换的时候会赋值 
        
    } } else { 
    
         switch_mm_irqs_off(prev->active_mm, next->mm, next)   //切换地址空间
    }

内核线程的tsk->mm永远为空,因为它没有用户地址空间的概念,但是他在调度的时候需要借用前一个用户任务的active_mm赋值到自己的active_mm,为什么要这样做呢?

这个问题可能很多人都想搞明白,那就还从地址空间切换说起:我们知道对于用户任务来说(用户进程或者线程),他们的tsk->mm = tsk->active_mm,并且在fork的时候已经分配好mm_struct结构,而且申请好了私有的pgd页赋值到tsk->mm->pgd。如果是user1 -> user2 这样的切换,因为是两个不同的用户进程,所以必须切换地址空间,但是如果是user1 -> kernel1 ->user1 这样的情况会是怎样?首先我们知道的是:user1 -> kernel1的时候不需要切换地址空间,但是需要做kernel1->active_mm = user1->active_mm的处理,而当 kernel1 ->user1切换时,情况就不一样了,这个时候next->mm!= NULL, 所有会走下面的逻辑switch_mm_irqs_off(prev->active_mm, next->mm, next):

代码语言:javascript复制
switch_mm_irqs_off
->switch_mm  // arch/arm64/include/asm/mmu_context.h
    ->if (prev != next)  
        __switch_mm(next)

switch_mm中,prev=prev->active_mm 而next= next->mm,在我们上面分析的场景user1 -> kernel1 ->user1,则prev= kernel1->active_mm =user1->active_mm=user1->mm,而next= user1->mm,可以发现两者相等,所以这种情况下是不需要切换地址空间的。 以下场景都不会导致地址空间切换:user1 -> kernel1 -> kernel2 -> kernel3 ->user1 user1 -> kernel1 -> user1 -> kernel2 -> kernel3

下图给出了地址空间切换图示:

我们只关注内核线程的切换情况,从Ub->ka->kb->Ub切换过程中,都不需要切换地址空间。

8.2 内核线程虚拟地址转换情况

下面我们来看下,内核线程虚拟地址转换的情况,我们都知道,对于用户任务,调度时会切换地址空间,即是将tsk->mm->pgd放到ttbr0_el1(对于arm64来说)中,我们访问用户虚拟地址的时候,mmu通过ttbr0_el1查询各级页表最终找到物理地址(当然mmu首先会从tlb中查询页表项查询不到才进行多级页表遍历),那么对于内核线程怎么办,它可没有tsk->mm结构,那么它是如何进程地址转换的呢?

答案就是:内核线程共享内核地址空间,也只能访问内核地址空间,使用swapper_pg_dir去查询页表就可以,而对于arm64来说swapper_pg_dir在内核初始化的时候被加载到ttbr1_le1中,一旦内核线程访问内核虚拟地址,则mmu就会从ttbr1_le1指向的页表基地址开始查询各级页表,进行正常的虚实地址转换。当然,上面是arm64这种架构的处理,它有两个页表基地址寄存器,其他很多处理器如x86, riscv处理器架构都只有一个页表基址寄存器,如x86的cr3,那么这个时候怎么办呢?答案是:使用内核线程借用的prev->active_mm来做,实际上前一个用户任务(记住:不一定是上一个,有可能上上个任务才是用户任务)的active_mm=mm,当切换到前一个用户任务的时候就会将tsk->mm->pgd放到cr3, 对于x86这样的只有一个页表基址寄存器的处理器架构来说,tsk->mm->pgd存放的是整个虚拟地址空间的页表基地址,在fork的时候会将主内核页表的pgd表项拷贝到tsk->mm->pgd对于表项中(有兴趣可以查看fork的copy_mm相关代码,对于arm64这样的架构没有做内核页表同步)。

9. 内核中创建内核线程用例

下面我们来看下,内核中创建内核线程为系统服务的用例,我们只提及不讲解具体的服务逻辑。

用例1:linux系统中,当内存不足时,会唤醒kswapd内核线程来进行异步内存回收,下面我们来看他的创建过程:

代码语言:javascript复制
mm/vmscan.c

kswapd_init
->for_each_node_state(nid, N_MEMORY)  //对于每个内存节点都创建一个kswapd内核线程
        kswapd_run(nid);
        ->pgdat->kswapd = kthread_run(kswapd, pgdat, "kswapd%d", nid)   //使用kthread_run结构创建并唤醒创建的内核线程 执行kswapd函数

用例2:Linux软中断是下半部的一种机制,一般对效率要求较高的场景会使用到,如网卡收发包,每当上半部执行完了会执行到软中断,软中断会抢占进程上下文执行,但是如果软中断处理太频繁,会导致高优先级的进程得不到执行,所以在软中断执行的时候会对执行次数和执行时间做限制,会将超过限制的软中断处理推到ksoftirqd来执行。

我们来看下ksoftirqd内核线程的创建:

代码语言:javascript复制
kernel/softirq.c

static struct smp_hotplug_thread softirq_threads = {
        .store                  = &ksoftirqd,
        .thread_should_run      = ksoftirqd_should_run,
        .thread_fn              = run_ksoftirqd,
        .thread_comm            = "ksoftirqd/%u",
};  


spawn_ksoftirqd
->for_each_online_cpu(cpu) { //每个cpu创建一个ksoftirqd
    __smpboot_create_thread(plug_thread, cpu)
    ->kthread_create_on_cpu
        ->kthread_create_on_node
            ->__kthread_create_on_node  //创建内核线程
                  -> create->threadfn = threadfn;  //填充内核线程创建信息  执行函数即为run_ksoftirqd
                     create->data = data;        
                    list_add_tail(&create->list, &kthread_create_list)  //添加到kthreadd的kthread_create_list链表中
                    wake_up_process(kthreadd_task)  //唤醒kthreadd来创建内核线程
        ->kthread_bind(p, cpu)   //内核线程绑定cpu

可以看到这里虽然没有使用kthread_run这样的api创建内核线程,但是还是和kthread_run实现一样将内核线程创建信息添加到kthread_create_list链表 然后唤醒kthreadd来创建内核线程,最后会绑定到对应的cpu上去。

9.实践环节

前面我们分析了内核线程的创建过程,也分析了很多的源代码,最后我们来实战一下,来使用内核的api来创建内核线程为我们服务(这里我们创建一个内核线程,然后每隔一秒打印一串字符 :I am kernel thread: 小写字母循环)。

内核模块代码:kthread_demo.c

代码语言:javascript复制
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/kthread.h>
#include <linux/delay.h>


static struct task_struct *tsk;

static int kthread_fn(void *data)
{
        int i = 0;
        do {
                if (i ==24)
                        i = 0;

                msleep(1000);
                pr_info("I am kernel thread: %c!n", 'a'   i  );
        } while(!kthread_should_stop());

        return 0;
}

static int __init kthread_demo_init(void)
{

#if 1
 tsk = kthread_run(kthread_fn, NULL, "mykthread");  
 if (!tsk) {
  pr_err("fail to create kthreadn");
  return -1;
 }
#else
 tsk = kthread_create(kthread_fn, NULL, "mykthread");
 if (!tsk) {
  pr_err("fail to create kthreadn");
  return -1;
 }


 wake_up_process(tsk);
#endif


 pr_info("###### %s:%d ######n", __func__, __LINE__);
 return 0;
}

static void __exit kthread_demo_exit(void)
{
 kthread_stop(tsk);
 pr_info("###### %s:%d ######n", __func__, __LINE__);
}

module_init(kthread_demo_init);
module_exit(kthread_demo_exit);
MODULE_LICENSE("GPL");

Makefile代码:

代码语言:javascript复制
export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-

KERNEL_DIR ?= ~/kernel/linux-5.11
obj-m := kthread_demo.o

modules:
 $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

clean:
 $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

install:
 cp *.ko $(KERNEL_DIR)/kmodules

测试:

代码语言:javascript复制
编译
$ make modules

拷贝到qemu共享目录下
$ make install


在qemu的共享目录下加载内核模块:
# insmod kthread_demo.ko

# [  127.213718] I am kernel thread: a!
[  128.236550] I am kernel thread: b!
[  129.260426] I am kernel thread: c!
[  130.285099] I am kernel thread: d!
[  131.309761] I am kernel thread: e!
[  132.332991] I am kernel thread: f!
[  133.357061] I am kernel thread: g!
[  134.380538] I am kernel thread: h!
[  135.405403] I am kernel thread: i!
[  136.429392] I am kernel thread: j!
[  137.453583] I am kernel thread: k!
[  138.477794] I am kernel thread: l!
[  139.501392] I am kernel thread: m!
[  140.524980] I am kernel thread: n!
[  141.549121] I am kernel thread: o!
[  142.573509] I am kernel thread: p!

查看我们创建的内核线程
# ps |grep mythread
1726 0         0:00 grep mythread

0 人点赞