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