Linux内核分析(三)

开始

  • 跟踪分析Linux内核的启动过程
  • 使用gdb跟踪调试内核从start_kernel到init进程启动

环境搭建

使用自己的Linux系统环境搭建MenuOS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 下载内核源代码编译内核
cd ~/LinuxKernel/
wget https://www.kernel.org/pub/linux/kernel/v3.x/linux-3.18.6.tar.xz #wget是下载工具
xz -d linux-3.18.6.tar.xz #解压
tar -xvf linux-3.18.6.tar #解压
cd linux-3.18.6
make i386_defconfig
make # 一般要编译很长时间,少则20分钟多则数小时

# 制作根文件系统
cd ~/LinuxKernel/
mkdir rootfs
git clone https://github.com/mengning/menu.git # 如果被墙,可以使用附件menu.zip
cd menu
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

# 启动MenuOS系统
cd ~/LinuxKernel/
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img

重新配置编译Linux使之携带调试信息

在原来配置的基础上,在linux-3.18.6目录文件中,使用命令:

1
~$ make menuconfig

注意这里可能会报错:

fatal error: curses.h: No such file or directory

这时需要安装一个ncurses库(ubuntu系统默认没有安装),命令为:

1
~$ sudo apt-get install libncurses5-dev

直到出现如下图所示界面:
1
选择如下两个配置:

  • kernel hacking—>
  • [*] compile the kernel with debug info

详细操作过程如下图所示:
2
3
此时输入y,再save
4
注意最后保存的时候不用重命名,否则后面make无效。
然后输入命令:

1
~$ make

进行重新编译,此时编译时间会较长。

使用gdb跟踪调试内核

编译好之后我们就可以用gdb进行跟踪调试了,先输入如下命令:

1
~$ qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S

关于-s和-S选项的说明:

  • -S freeze CPU at startup (use ’c’ to start execution)
  • -s shorthand for -gdb tcp::1234 若不想使用1234端口,则可以使用-gdb tcp:xxxx来取代-s选项

另开一个shell窗口,打开gdb:

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

相关gdb调试命令如下:

(1) clear #清除所有断点
(2) delete [linenum] #清除代码中行数为linenum的断点
(3) disable [linenum] #取消断点,可通过enable恢复
(4) step [count] #单步调试count步,会进入调用的函数中
(5) next [count] #单步调试count步,不会进入调用的函数
(6) finish #当前函数执行到返回
(7) information frame #显示栈信息
(8) search < regexp> #搜索源代码
(9) print < var>/< register>/< memory> #查看数据
(10) information registers #查看所有寄存器信息

Linux内核代码目录结构

以下为Linux内核代码目录结构:(注意要与Linux文件系统目录结构相区别)

  • arch目录包括了所有和体系结构相关的核心代码。它下面的每一个子目录都代表一种Linux支持的体系结构,例如i386就是Intel CPU及与之相兼容体系结构的子目录。PC机一般都基于此目录。
  • include目录包括编译核心所需要的大部分头文件,例如与平台无关的头文件在include/linux子目录下。
  • init目录包含核心的初始化代码(不是系统的引导代码),有main.c和Version.c两个文件。这是研究核心如何工作的好起点。
  • mm目录包含了所有的内存管理代码。与具体硬件体系结构相关的内存管理代码位于arch/*/mm目录下。
  • drivers目录中是系统中所有的设备驱动程序。它又进一步划分成几类设备驱动,每一种有对应的子目录,如声卡的驱动对应于drivers/sound。
  • ipc目录包含了核心进程间的通信代码。
  • modules目录存放了已建好的、可动态加载的模块。
  • fs目录存放Linux支持的文件系统代码。不同的文件系统有不同的子目录对应,如ext3文件系统对应的就是ext3子目录。
  • Kernel内核管理的核心代码放在这里。
  • net目录里是核心的网络部分代码,其每个子目录对应于网络的一个方面。
  • lib目录包含了核心的库代码。
  • scripts目录包含用于配置核心的脚本文件。
  • documentation目录下是一些文档,是对每个目录作用的具体说明。

一般在每个目录下都有一个.depend文件和一个Makefile文件。这两个文件都是编译时使用的辅助文件。仔细阅读这两个文件对弄清各个文件之间的联系和依托关系很有帮助。另外有的目录下还有Readme文件,它是对该目录下文件的一些说明,同样有利于对内核源码的理解。

跟踪分析

那么,在内核启动的过程中:

  • start_kernel()到init进程启动的过程究竟是怎样的呢?
  • 操作系统是如何从内核态过渡到用户态的呢?

跟踪分析如下:

第一步:0号进程的创建

linux-3.18.6/init/main.c中的start_kernel()函数是入口函数,主要做一些初始化的工作,其全部注释代码如下:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
asmlinkage __visible void __init start_kernel(void)
{
//命令行,存放bootloader传递过来的参数
char *command_line;
char *after_dashes;

//初始化内核调试模块
lockdep_init();
//init_task即手工创建的PCB
set_task_stack_end_magic(&init_task);
//获取当前CPU的硬件ID
smp_setup_processor_id();
//初始化哈希桶
debug_objects_early_init();
//防止栈溢出
boot_init_stack_canary();
//初始化cgroups
cgroup_init_early();
//关闭当前CPU的所有中断
local_irq_disable();
//系统中断标志
early_boot_irqs_disabled = true;
//激活当前CPU
boot_cpu_init();
//初始化高端内存映射表
page_address_init();
//输出各种信息
pr_notice("%s", linux_banner);
//内核构架相关初始化函数
setup_arch(&command_line);
//每一个任务都有一个mm_struct结构来管理内存空间
mm_init_cpumask(&init_mm);
//对cmdline进行备份和保存
setup_command_line(command_line);
//设置最多有多少个nr_cpu_ids结构
setup_nr_cpu_ids();
//为系统中每个CPU的per_cpu变量申请空间
setup_per_cpu_areas();
//为SMP系统里引导CPU(boot-cpu)进行准备工作
smp_prepare_boot_cpu(); /* arch-specific boot-cpu hooks */
//设置内存管理相关的node
build_all_zonelists(NULL, NULL);
//设置内存页分配通知器
page_alloc_init();

pr_notice("Kernel command line: %s\n", boot_command_line);
//解析cmdline中的启动参数
parse_early_param();
//对传入内核参数进行解释
after_dashes = parse_args("Booting kernel",
static_command_line, __start___param,
__stop___param - __start___param,
-1, -1, &unknown_bootoption);
if (!IS_ERR_OR_NULL(after_dashes))
parse_args("Setting init args", after_dashes, NULL, 0, -1, -1,
set_init_arg);

jump_label_init();

//使用bootmeme分配一个记录启动信息的缓冲区
setup_log_buf(0);
//进程ID的HASH表初始化
pidhash_init();
//前期虚拟文件系统(vfs)的缓存初始化
vfs_caches_init_early();
//对内核异常表(exception table)按照异常向量号大小进行排序,以便加速访问
sort_main_extable();
//对内核陷阱异常进行初始化
trap_init();
//标记哪些内存可以使用
mm_init();
//对进程调度器的数据结构进行初始化
sched_init();
//关闭优先级调度
preempt_disable();
//这段代码主要判断是否过早打开中断,如果是这样,就会提示,并把中断关闭
if (WARN(!irqs_disabled(),
"Interrupts were enabled *very* early, fixing it\n"))
local_irq_disable();
//为IDR机制分配缓存
idr_init_cache();
//初始化直接读拷贝更新的锁机制
rcu_init();
context_tracking_init();
//内核radis 树算法初始化
radix_tree_init();
//前期外部中断描述符初始化,主要初始化数据结构
early_irq_init();
//对应架构特定的中断初始化函数
init_IRQ();
//初始化内核时钟系统
tick_init();
rcu_init_nohz();
//初始化引导CPU的时钟相关的数据结构
init_timers();
//初始化高精度的定时器
hrtimers_init();
//初始化软件中断
softirq_init();
//初始化系统时钟计时
timekeeping_init();
//初始化系统时钟
time_init();
sched_clock_postinit();
//CPU性能监视机制初始化
perf_event_init();
//为内核性能参数分配内存空间
profile_init();
//初始化所有CPU的call_single_queue
call_function_init();
WARN(!irqs_disabled(), "Interrupts were enabled early\n");
early_boot_irqs_disabled = false;
local_irq_enable();
//这是内核内存缓存(slab分配器)的后期初始化
kmem_cache_init_late();
//初始化控制台
console_init();
if (panic_later)
panic("Too many boot %s vars at `%s'", panic_later,
panic_param);
//打印锁的依赖信息
lockdep_info();
//测试锁的API是否使用正常
locking_selftest();

#ifdef CONFIG_BLK_DEV_INITRD
if (initrd_start && !initrd_below_start_ok &&
page_to_pfn(virt_to_page((void *)initrd_start)) < min_low_pfn) {
pr_crit("initrd overwritten (0x%08lx < 0x%08lx) - disabling it.\n",
page_to_pfn(virt_to_page((void *)initrd_start)),
min_low_pfn);
initrd_start = 0;
}
#endif
page_cgroup_init();
debug_objects_mem_init();
kmemleak_init();
setup_per_cpu_pageset();
numa_policy_init();
if (late_time_init)
late_time_init();
sched_clock_init();
calibrate_delay();
pidmap_init();
anon_vma_init();
acpi_early_init();
#ifdef CONFIG_X86
if (efi_enabled(EFI_RUNTIME_SERVICES))
efi_enter_virtual_mode();
#endif
#ifdef CONFIG_X86_ESPFIX64
/* Should be run before the first non-init thread is created */
init_espfix_bsp();
#endif
thread_info_cache_init();
cred_init();
fork_init(totalram_pages);
proc_caches_init();
//初始化文件系统的缓冲区
buffer_init();
//初始化内核密钥管理系统
key_init();
//初始化内核安全管理框架
security_init();
dbg_late_init();
vfs_caches_init(totalram_pages);
signals_init();
/* rootfs populating might need page-writeback */
page_writeback_init();
proc_root_init();
cgroup_init();
cpuset_init();
taskstats_init_early();
delayacct_init();
check_bugs();
sfi_init_late();
if (efi_enabled(EFI_RUNTIME_SERVICES)) {
efi_late_init();
efi_free_boot_services();
}
ftrace_init();
rest_init();//剩余的初始化
}

以上代码整体执行流程大致为:
5
首先来看start_kernel()里面的第二句

set_task_stack_end_magic(&init_task);

init_task在文件linux-3.18.6/init/init_task.c中定义如下:

struct task_struct init_task = INIT_TASK(init_task);

可见它其实就是一个task_struct,与用户进程的task_struct一样。相当于《Linux内核分析(二)》中的PCB结构体。
init_task中保存了一个进程的所有基本信息,如进程状态,栈起始地址,进程号pid等,其特殊之处在于它的pid=0,也就是通常所说的0号进程,0号进程就是我们这样通过手工创建出来的。也就是start_kernel()创建了0号进程。
0号进程的任务范围是从最早的汇编代码一直到start_kernel()的执行结束。

第二步:1号进程的创建

再来看start_kernel()里面的最后一句rest_init(),该函数的定义同样在linux-3.18.6/init/main.c文件中,在rest_init()函数中有这样一句话:

kernel_thread(kernel_init, NULL, CLONE_FS);

其中kernel_thread()的源码在文件linux-3.18.6/kernel/fork.c中定义,如下:

1
2
3
4
5
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);
}

这里相当于fork出了新进程来执行kernel_init()函数。
而kernel_init()函数的定义在文件linux-3.18.6/init/main.c中定义,如下:

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
34
35
36
37
38
39
40
41
//创建的内核线程运行本函数,在本函数里面启动run_init_process
static int __ref 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) {
//启动run_init_process
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);
}
/*try_to_run_init_process()通过嵌入汇编构造一个
类似用户态代码一样的sys_execve()调用,其参数就是
要执行的可执行文件名。
*/

/*这里是内核初始化结束并开始用户态初始化的阴阳界
*/

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.");
}

以上代码的最后部分,实际就是通过execve()执行磁盘文件上的init程序。而这里的init程序,就是用户态程序了,也就是大名鼎鼎的1号进程。由此实现了内核态向用户态的转化。
PS:在做实验的过程中,我纠结了很久的一个问题是磁盘上的init可执行文件从哪儿来的?
后来明白了,menu只是挂载的文件系统,在环境搭建部分,有一个命令:

gcc -o init linktable.c menu.c test.c -m32 -static –lpthread

由此生成的init可执行程序,也就是内核系统加载好之后,开始读取用户态的init可执行文件了。因此手工生成的MenuOS会有几个简单的命令,这是由init进程提供的。

第三步:0号进程的转变

在linux-3.18.6/init/main.c文件中看rest_init()函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static noinline void __init_refok rest_init(void)
{

int pid;
rcu_scheduler_starting();
//很重要,创建一个内核线程,PID=1,创建好了,但不能去调度它
kernel_thread(kernel_init, NULL, CLONE_FS);
numa_default_policy();
//很重要,创建第二个内核线程,PID=2,负责管理和调度其它内核线程。
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()在创建了1号、2号进程之后,系统可以正式对外工作了。之后执行最后一行代码:

cpu_startup_entry(CPUHP_ONLINE);

cpu_startup_entry()函数的定义在文件linux-3.18.6/kernel/sched/idle.c中,如下所示:

1
2
3
4
5
6
7
8
void cpu_startup_entry(enum cpuhp_state state)
{

#ifdef CONFIG_X86
boot_init_stack_canary();
#endif
arch_cpu_idle_prepare();
cpu_idle_loop();
}

其中,cpu_idle_loop()实际是一个while无限循环,也就是说,0号进程在fork了1号进程并且做了其余的启动工作之后,最后“进化”成为了idle进程。完成其使命,并一直处于内核态中无线循环。

整体流程

6

Linux启动过程总结

  1. 当计算机系统加电(Power on PC)后,CPU由CS:EIP = FFFF:0000H处取指令,BIOS代码被调用执行。BIOS例行程序检测完硬件并完成相应的初始化之后就会寻找可引导介质,找到后把引导程序加载到指定内存区域后,就把控制权交给了引导程序BootLoader。BootLoader指定kernel、initrd和root所在的分区和目录来启动操作系统。
  2. Linux内核初始化在平台相关的汇编代码执行完毕后会跳转到start_kernel()函数,开始c代码的内核初始化。也就是说start_kernel()是内核汇编和C语言的交接点,在该函数以前,内核代码都是用汇编写的,完成一些最基本的初始化与环境设置工作,比如为后面C代码的运行设置stack环境等,在start_kernel()中Linux将完成整个系统内核的初始化。内核初始化的最后一步就是启动init进程,这是所有用户态进程的祖先。
  3. 而init程序的执行常规分成如下三种:
  • 在系统启动阶段,操作系统内核部分初始化阶段的结尾,将运行init这个第一个用户态的程序(它将作为所有用户态进程的共同祖先),它将依据/etc/inittab配置文件来对系统进行用户态的初始化。
  • 在系统运行当中root用户可以运行init命令把系统切换到不同的运行级别(runlevel)。比如当前运行级别是3(Console界面的Full multiuser mode),而root想维护系统,他可以运行如下命令: /$: init 1 /,切换到Single user mode,即单用户模式,有点像Windows下的安全模式。用户启动的init命令并不真正运行runlevel切换的工作,只是通过pipe(管道)把命令打包成request,然后传递给作为daemon进程运行的init。
  • 在系统起来以后,init作为一个daemon进程运行,一是监控/etc/inittab配置文件中的相关命令的执行,二就是通过pipe(管道)接受2中发来的切换runlevel的request(请求)并处理之。

【版权声明】
本文首发于戚名钰的博客http://qimingyu.github.io/ ),欢迎转载,但是必须保留本文的署名戚名钰(包含链接)。如您有任何商业合作或者授权方面的协商,请给我留言:mingyuqi.java@qq.com
欢迎关注我的微信公众号:科技锐新

本文永久链接:http://qimingyu.github.io/2016/04/02/Linux内核分析(三)/

坚持原创技术分享,您的支持将鼓励我继续创作!

热评文章