#176 Linux 内核分析 之三:Linux内核启动函数start_kernel()的简单分析

说明

欧长坤 原创作品转载请注明出处 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000 这学期学校恰好有操作系统的课程,上个学习就开始寻思研究研究Linux内核代码,恰好MOOC有这个课程,遂选了此课。

一、准备工作

废话不多说,命令一行行敲下去,搭建好环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cd ~/Work/  
wget https://www.kernel.org/pub/linux/kernel/v3.x/linux-3.18.6.tar.xz
xz -d linux-3.18.6.tar.xz
tar -xvf linux-3.18.6.tar
cd linux-3.18.6
make i386_defconfig
make
cd ~/Work/
mkdir rootfs
git clone https://github.com/mengning/menu.git # 话说这里为什么用MenuOS 我个人觉得老师一来是节约编译时间 二来也可以做做广告
cd menu
sudo apt-get install libc6:i386 lib32stdc++6 # 这两行安装非常有必要
sudo apt-get install lib32readline-gplv2-dev # 在64bit的Ubuntu环境下不能编译这个MenuOS的roofs 需要这些包来支持 即使用了-m32
gcc -o init linktable.c menu.c test.c -m32 -static -lpthread
cd ../rootfs
cp ../menu/init ./
find . | cpio -o -Hnewc |gzip -9 > ../rootfs.img
cd ~/Work/
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img
sudo apt-get install libncurses5-dev # 保证make menuconfig可用
make menuconfig
kernel hacking->
copile-time checks and compile options
[*] compile the kernel with debug info
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S

然后打开另一个shell,执行下面的命令:

1
2
3
4
gdb
file linux-3.18.6/vmlinux # 在gdb界面中targe remote之前加载符号表
target remote:1234 # 建立gdb和gdbserver之间的连接,按c 让qemu上的Linux继续运行
break start_kernel # 断点的设置可以在target remote之前,也可以在之后

设置完断点后,可以使用c让内核继续进行加载,加载到第一个断点start_kernel时,实验情形是这样的:

二、分析

在这个实验过程中,我们得先阅读以下源代码,才能够逐步设置断点。 我们可以在linux-3.18.6/init/main.c这里找到start_kernel所在函数的源代码。 这个页面的好处是可以随时跟踪不用麻烦的寻找各个代码存放的位置。 因此,我们来看看start_kernel()的代码(删掉了一些我们不关心的多余的代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
void start_kernel(void)
{
………………
page_address_init();
// 内存相关的初始化
trap_init();
mm_init();
………………
// 调度初始化
sched_init();
………………
rest_init();
}

在执行start_kernel时,期初会对CPU、内存等各种硬件设备进行初始化,这期间涉及到非常多的不同内核模块的加载。 在start_kernel的最后一项初始化,就是有关内核进程管理的初始化了。一旦这一项初始化完成,内核就加载成功了。如果你看过我上一篇文章的分析,就能知道,在上一次我们自行编写的简单系统内核,实际上是在rest_init前插入了一段我们自己的函数my_start_kernel,插入这个函数之后,我们自己的内核通过PCB的进程管理单元来管理了我们依次创建的四个简单进程,并通过时间片轮转的方式进行了调度。那么在实际的linux内核代码中,rest_init()到底是干什么才使得我们需要在它之前执行my_start_kernel呢?原因就是rest_init实际上是linux内核初始化进程的函数。如果我们在它执行之前自行创建我们自己的进程,并且利用自己的调度算法来调度之后创建的进程,那么rest_init则永远不会被执行,因为在它执行之前,我们自己的进程已经在轮转调度不会结束了。 下面我们就来看看实际linux初始化进程的内核代码rest_init(删掉了不关心的部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void rest_init(void)
{
int pid;
………………
kernel_thread(kernel_init, NULL, CLONE_FS);
numa_default_policy();
pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
rcu_read_lock();
kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
rcu_read_unlock();
complete(&kthreadd_done);

init_idle_bootup_task(current);
schedule_preempt_disabled();
cpu_startup_entry(CPUHP_ONLINE);
}

在rest_init的代码中,kernel_thread,被定义在文件arch/x86/kernel/fork.c中,它的功能是用来fork一个内核线程。

1
2
3
4
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags){
return do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn,
(unsigned long)arg, NULL, NULL);
}

上面的代码我们可以看到,kernel_thread实际上就是取fork一个线程。

在执行kernel_thread时,kernel_init作为将要执行的函数指针传入,进程ID会被置为1。所以在这里,kernel_init内核线程被创建,进程号为1。 在完成内核进程的创建后,会创建kthreadd内核线程,作用则是管理和调度其他的内核线程。

1
pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);

在kthreadd函数中kthread_create_list全局链表中维护的内核线程。当调用kthread_create时,会创建一个kthread,并被添加到kthread_create_list链表中。当进程执行完毕后,就会被从链表中删除。下面的代码我们可以看到,在tast_struct的当前进程被设定为kthread。 那么,我们会产生这样的疑惑:这里的for(;;)什么时候会被停止呢?怎么让CPU执行其他的代码呢?

容易注意到,在循环内部,当list_empty()进行判断时,如果为真,那么就会调用schedule(),而这个schedule会执行很多复杂庞大的调度,其核心任务就是为了让出CPU的使用权,因此,这个线程也没有必要关闭。限于篇幅,就不继续展开了,有机会在后面的博文里聊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int kthreadd(void *unused)
{
struct task_struct *tsk = current;
set_task_comm(tsk, "kthreadd");
ignore_signals(tsk);
set_cpus_allowed_ptr(tsk, cpu_all_mask);
set_mems_allowed(node_states[N_MEMORY]);
current->flags |= PF_NOFREEZE;
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (list_empty(&kthread_create_list))
schedule();
__set_current_state(TASK_RUNNING);

spin_lock(&kthread_create_lock);
while (!list_empty(&kthread_create_list)) {
struct kthread_create_info *create;

create = list_entry(kthread_create_list.next,
struct kthread_create_info, list);
list_del_init(&create->list);
spin_unlock(&kthread_create_lock);

create_kthread(create);

spin_lock(&kthread_create_lock);
}
spin_unlock(&kthread_create_lock);
}

return 0;
}

schedule()代码:

1
2
3
4
5
6
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
__schedule();
}

总之,代码绕开kernel_initkthreadd并会继续执行到

1
complete(&kthreadd_done);

这时,说明kthreadd已经创建成功了,并通过一个complete变量kthreadd_done来通知kernel_init线程。

我们先回过头来,看看kernel_init。 kernel_init既然是将要执行,我们就来看看kernel_init又会执行什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static int kernel_init(void *unused)
{
int ret;
kernel_init_freeable();
async_synchronize_full();
free_initmem();
mark_rodata_ro();
system_state = SYSTEM_RUNNING;
numa_default_policy();
flush_delayed_fput();
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
if (!ret)
return 0;
pr_err("Failed to execute %s (error %d)\n",
ramdisk_execute_command, ret);
}
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
pr_err("Failed to execute %s (error %d). Attempting defaults...\n",
execute_command, ret);
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;

panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/init.txt for guidance.");
}

事实上,kernel_init会继续进行内核的最后一些初始化的工作,直到最后一行实际上整个内核的初始化工作就已经正式完成了。

注意,我们创建的进程ID实际上是从1开始的。其中在kernel_init中创建的是1号进程,在刚才的kthreadd中创建的是2号进程。

那么接下来,为了让系统能够运作起来,剩下的这三行代码完成了非常重要的工作,它完成了CPU对任务的调度初始化,让内核真正的开始进入用户主导的阶段:

1
2
3
init_idle_bootup_task(current);
schedule_preempt_disabled();
cpu_startup_entry(CPUHP_ONLINE);

首先,init_idle_bootup_tast()会初始化一个idle(闲置)进程,这个进程不做任何其他事情,只负责消耗时间片。

然后通过schedule_preempt_disabled来设置这个进程是不会被调度。因为CPU显然利用率越高越好,不可能让调度程序调度一个只消耗时间片的进程。

最后,cpu_startup_entry 就会使得CPU在idle这样一个循环内进行工作,不断往复,从不返回。

1
2
3
4
5
void cpu_startup_entry(enum cpuhp_state state)
{
arch_cpu_idle_prepare();
cpu_idle_loop();
}

自此,整个内核的启动过程就全部完成了。

三、实验过程

好,那么我们现在就来正式进行实验。 我们来逐步加载idel进程和1号进程。

通过上面的分析,我们注意到有下面几个比较重要的断点需要我们设置: start_kernel, page_address_init, trap_init, mm_init, rest_init, kernel_init, kthreadd, init_idle_bootup_task, cpu_startup_entry

下面是一些关键性的过程:

可以看到,其实在这里,内核的加载工作已经完成了,符合我们的预期。

以上就是对内核启动的一个跟踪了。

四、总结

我们来总结一下上面的全部分析过程: 首先,几乎所有的内核模块均会在start_kernel进行初始化。在start_kernel中,会对各项硬件设备进行初始化,包括一些page_address、tick等等,直到最后需要执行的rest_init中,会开始让系统跑起来。 那么,rest_init这个过程中,会调用调用kernel_thread来创建内核线程kernel_init,它创建用户的init进程,初始化内核,并设置成1号进程,这个进程会继续做相关的系统初始化。 然后,start_kernel会调用kernel_thread并创建kthreadd,负责管理内核中得所有线程,然后进程ID会被设置为2。 最后,会创建idle进程(0号进程),不能被调度,并利用循环来不断调号空闲的CPU时间片,并且从不返回。当然,不同的内核版本对这个状态的描述会有所差异,至于这个进程能不能够被抢占,本文描述的内核版本为3.18.6,是可以的。如图所示:

如果我的文章对你起到了帮助,你可以选择金额不限的捐助,帮助我写出更多的文章。