Linux 操作系统分析实验:使用 GDB 跟踪调试 Linux 内核的启动过程

本博客基于 Ubuntu 20.04.5 LTS,内核版本 5.4.34,其它 Linux 发行版以及内核版本请自行对个别命令进行调整。

一、环境配置

sudo apt install build-essential
sudo apt install qemu # 安装 QEMU 虚拟机
sudo apt install libncurses5-dev bison flex libssl-dev libelf-dev

二、下载内核源码

wget https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.4.34.tar.xz
xz -d linux-5.4.34.tar.xz
tar -xvf linux-5.4.34.tar
cd linux-5.4.34

三、配置内核编译选项

make defconfig # Default configuration is based on 'x86_64_defconfig'
make menuconfig

执行完毕后会出现以下界面。

分别进入以下项目打开、关闭对应选项。

Kernel hacking  --->
    Compile-time checks and compiler options  --->
        [*] Compile the kernel with debug info
        [*]   Provide GDB scripts for kernel debugging
    [*] Kernel debugging
# 关闭KASLR,否则会导致调试的时候打断点失败
Processor type and features ---->
    [] Randomize the address of the kernel image (KASLR)

四、编译内核

make -j$(nproc)

编译完成后,内核其实还不能正确加载运行,因为缺少相应的文件系统。

五、制作内存根文件系统

电脑加电启动首先由 bootloader 加载内核,内核紧接着需要挂载内存根文件系统,其中包含必要的设备驱动和工具。bootloader 加载根文件系统到内存中后,内核会将其挂载到根目录下,然后运行根文件系统中 init 脚本执行一些启动任务,最后才挂载真正的磁盘根文件系统。本实验为了简化环境,仅制作内存根文件系统,这里借助 BusyBox 构建极简内存根文件系统,提供基本的用户态可执行程序。

首先下载 busybox 源码并解压。

wget https://busybox.net/downloads/busybox-1.36.0.tar.bz2
tar -jxvf busybox-1.36.0.tar.bz2
cd busybox-1.36.0

解压完成后,配置编译选项,采用静态链接,不使用动态链接库。

make menuconfig
Settings  --->
    [*] Build static binary (no shared libs)

接着编译安装,默认会安装到源码目录下的 _install 目录中。

make -j$(nproc) && make install

然后制作内存根文件系统镜像,命令如下。

mkdir rootfs
cd rootfs
cp ../busybox-1.36.0/_install/* ./ -rf
mkdir dev proc sys home
sudo cp -a /dev/{null,console,tty,tty1,tty2,tty3,tty4} dev/

准备 init 脚本放在根文件系统根目录下(rootfs/init),内容如下。

#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
echo "Wellcome LouyuOS!"
echo "--------------------"
cd home
/bin/sh

记得给 init 脚本添加可执行权限。

chmod +x init

最后打包成内存根文件系统镜像。

find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz

六、尝试启动内核

在 Linux 内核源码目录用以下命令启动内核,若看到 init 脚本的输出,说明内核启动成功。

qemu-system-x86_64 -kernel arch/x86/boot/bzImage -initrd rootfs.cpio.gz -nographic -append "console=ttyS0"

如果提示内存根文件系统镜像文件找不到,需要根据实际情况调整命令中的路径。

七、在命令行中调试 Linux 内核

要在命令行中使用 gdb 跟踪调试内核,我们需要加两个参数,一个是 -s,表明在 TCP 1234 端口上创建一个 gdb-server。我们此时另外打开一个窗口,用 gdb 把带有符号表的内核镜像 vmlinux 加载进来,然后连接 gdb-server,设置断点跟踪内核(若不想使用默认的 1234 端口,可以使用 -gdb tcp:xxxx 来替代 -s 选项)。另一个是 -S 代表启动时暂停虚拟机,等待 gdb 执行 continue 指令后再开始执行(可以简写为 c)。

qemu-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"

打开另一个终端窗口,启动 gdb,把内核符号表加载进来,建立与 gdb server 的连接。

cd linux-5.4.34/
gdb vmlinux
(gdb) target remote:1234 # 指定连接remote,这里连接到了上面创建的本地gdb server
(gdb) b start_kernel
(gdb) c、bt、list、next、step....

八、在 VSCode 中调试 Linux 内核

在命令行下打断点跟踪代码不够方便,下面介绍如何使用 VSCode 来调试代码。首先在 Linux 内核源码目录的 .vscode 文件夹(若没有则新建一个)中放置四个配置文件,接着在 VSCode 中安装 C/C++、C/C++ Themes、GDB Debug 扩展。

由于 C/C++ Intellisense 需要依赖 GNU Global,需要使用如下命令安装 GNU Global。

sudo apt install global

在用 VSCode 打开源码项目前,还要在内核源码根目录下运行以下脚本,否则后面的调试会报错。

python ./scripts/gen_compile_commands.py

由于 Linux内核高度定制化,所以没有办法直接通过配置 includePath等让 Intellisense 正常提示,这里借助一个 Python 脚本来生成 compile_commands.json文件帮助 Intellisense正常提示(包括头文件和宏定义等)。在 Linux源代码目录下直接运行上面的命令就可以生成 compile_commands.json 了。

上述配置完毕后,在 VSCode 的运行菜单选择启动调试即可。

九、简要分析 Linux 内核的启动过程

Linux 内核的启动主要由 init/main.c 下的 start_kernel 函数完成。start_kernel 函数完成了设置 init_task(0 号进程)栈底、初始化处理器 ID、初始化追踪对象、初始化内存分页、初始化中断向量表、初始化内存分配器、初始化进程调度器、系统时钟设置、vfs 初始化等操作,最后执行 arch_call_rest_init() 调用 rest_init()。5.4.34 版本的内核中,rest_init() 函数代码及关键注释如下。

noinline void __ref rest_init(void)
{
  struct task_struct *tsk;
  int pid;

  rcu_scheduler_starting();

  /*
   * 调用kernel_thread()创建1号进程,后续会演变为init进程
   * 是系统中用户进程的祖先进程,Linux中的所有进程都是有init进程创建并运行的
   * 在系统启动完成完成后,init将变为守护进程监视系统其他进程
   */
  pid = kernel_thread(kernel_init, NULL, CLONE_FS);

  /*
   * Pin init on the boot CPU. Task migration is not properly working
   * until sched_init_smp() has been run. It will set the allowed
   * CPUs for init to the non isolated CPUs.
   */
  rcu_read_lock();
  tsk = find_task_by_pid_ns(pid, &init_pid_ns);
  set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id()));
  rcu_read_unlock();

  numa_default_policy();

  /*
   * 调用kernel_thread(),创建2号进程,负责所有内核线程的创建、调度和管理
   * 所有的内核线程都是直接或者间接的以kthreadd为父进程
   */
  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();

  /*
   * Enable might_sleep() and smp_processor_id() checks.
   * They cannot be enabled earlier because with CONFIG_PREEMPTION=y
   * kernel_thread() would trigger might_sleep() splats. With
   * CONFIG_PREEMPT_VOLUNTARY=y the init task might have scheduled
   * already, but it's stuck on the kthreadd_done completion.
   */
  system_state = SYSTEM_SCHEDULING;

  complete(&kthreadd_done);

  /*
   * 内含对函数schedule()的调用,切换当前进程(0号进程)为1号进程
   * 在调用该函数之前,Linux系统中只有两个进程,即0号进程init_task和1号进程kernel_init
   */
  schedule_preempt_disabled(); 
  /* Call into cpu_idle with preempt disabled */
  cpu_startup_entry(CPUHP_ONLINE); // 调用cpu_idle(),0号线程init_task进入idle函数的循环(即变成了“闲逛进程”),一旦系统有其它任务到来,就会退出这个状态
}

值得一提的是,作为 “万物之源” 的 0 号进程 init_task(所有的进程都是由 0 号进程创建)是在源码中写死的,其定义在 init/init_task.c,代码如下。

struct task_struct init_task
#ifdef CONFIG_ARCH_TASK_STRUCT_ON_STACK
  __init_task_data
#endif
= {
#ifdef CONFIG_THREAD_INFO_IN_TASK
  .thread_info	= INIT_THREAD_INFO(init_task),
  .stack_refcount	= REFCOUNT_INIT(1),
#endif
  .state		= 0,
  .stack		= init_stack,
  .usage		= REFCOUNT_INIT(2),
  .flags		= PF_KTHREAD,
  .prio		= MAX_PRIO - 20,
  .static_prio	= MAX_PRIO - 20,
  .normal_prio	= MAX_PRIO - 20,
  .policy		= SCHED_NORMAL,
  .cpus_ptr	= &init_task.cpus_mask,
  .cpus_mask	= CPU_MASK_ALL,
  .nr_cpus_allowed= NR_CPUS,
  .mm		= NULL,
  .active_mm	= &init_mm,
  .restart_block	= {
    .fn = do_no_restart_syscall,
  },
  .se		= {
    .group_node 	= LIST_HEAD_INIT(init_task.se.group_node),
  },
  .rt		= {
    .run_list	= LIST_HEAD_INIT(init_task.rt.run_list),
    .time_slice	= RR_TIMESLICE,
  },
  .tasks		= LIST_HEAD_INIT(init_task.tasks),
#ifdef CONFIG_SMP
  .pushable_tasks	= PLIST_NODE_INIT(init_task.pushable_tasks, MAX_PRIO),
#endif
#ifdef CONFIG_CGROUP_SCHED
  .sched_task_group = &root_task_group,
#endif
  .ptraced	= LIST_HEAD_INIT(init_task.ptraced),
  .ptrace_entry	= LIST_HEAD_INIT(init_task.ptrace_entry),
  .real_parent	= &init_task,
  .parent		= &init_task,
  .children	= LIST_HEAD_INIT(init_task.children),
  .sibling	= LIST_HEAD_INIT(init_task.sibling),
  .group_leader	= &init_task,
  RCU_POINTER_INITIALIZER(real_cred, &init_cred),
  RCU_POINTER_INITIALIZER(cred, &init_cred),
  .comm		= INIT_TASK_COMM,
  .thread		= INIT_THREAD,
  .fs		= &init_fs,
  .files		= &init_files,
  .signal		= &init_signals,
  .sighand	= &init_sighand,
  .nsproxy	= &init_nsproxy,
  .pending	= {
    .list = LIST_HEAD_INIT(init_task.pending.list),
    .signal = {{0}}
  },
  .blocked	= {{0}},
  .alloc_lock	= __SPIN_LOCK_UNLOCKED(init_task.alloc_lock),
  .journal_info	= NULL,
  INIT_CPU_TIMERS(init_task)
  .pi_lock	= __RAW_SPIN_LOCK_UNLOCKED(init_task.pi_lock),
  .timer_slack_ns = 50000, /* 50 usec default slack */
  .thread_pid	= &init_struct_pid,
  .thread_group	= LIST_HEAD_INIT(init_task.thread_group),
  .thread_node	= LIST_HEAD_INIT(init_signals.thread_head),
#ifdef CONFIG_AUDIT
  .loginuid	= INVALID_UID,
  .sessionid	= AUDIT_SID_UNSET,
#endif
#ifdef CONFIG_PERF_EVENTS
  .perf_event_mutex = __MUTEX_INITIALIZER(init_task.perf_event_mutex),
  .perf_event_list = LIST_HEAD_INIT(init_task.perf_event_list),
#endif
#ifdef CONFIG_PREEMPT_RCU
  .rcu_read_lock_nesting = 0,
  .rcu_read_unlock_special.s = 0,
  .rcu_node_entry = LIST_HEAD_INIT(init_task.rcu_node_entry),
  .rcu_blocked_node = NULL,
#endif
#ifdef CONFIG_TASKS_RCU
  .rcu_tasks_holdout = false,
  .rcu_tasks_holdout_list = LIST_HEAD_INIT(init_task.rcu_tasks_holdout_list),
  .rcu_tasks_idle_cpu = -1,
#endif
#ifdef CONFIG_CPUSETS
  .mems_allowed_seq = SEQCNT_ZERO(init_task.mems_allowed_seq),
#endif
#ifdef CONFIG_RT_MUTEXES
  .pi_waiters	= RB_ROOT_CACHED,
  .pi_top_task	= NULL,
#endif
  INIT_PREV_CPUTIME(init_task)
#ifdef CONFIG_VIRT_CPU_ACCOUNTING_GEN
  .vtime.seqcount	= SEQCNT_ZERO(init_task.vtime_seqcount),
  .vtime.starttime = 0,
  .vtime.state	= VTIME_SYS,
#endif
#ifdef CONFIG_NUMA_BALANCING
  .numa_preferred_nid = NUMA_NO_NODE,
  .numa_group	= NULL,
  .numa_faults	= NULL,
#endif
#ifdef CONFIG_KASAN
  .kasan_depth	= 1,
#endif
#ifdef CONFIG_TRACE_IRQFLAGS
  .softirqs_enabled = 1,
#endif
#ifdef CONFIG_LOCKDEP
  .lockdep_depth = 0, /* no locks held yet */
  .curr_chain_key = INITIAL_CHAIN_KEY,
  .lockdep_recursion = 0,
#endif
#ifdef CONFIG_FUNCTION_GRAPH_TRACER
  .ret_stack	= NULL,
#endif
#if defined(CONFIG_TRACING) && defined(CONFIG_PREEMPTION)
  .trace_recursion = 0,
#endif
#ifdef CONFIG_LIVEPATCH
  .patch_state	= KLP_UNDEFINED,
#endif
#ifdef CONFIG_SECURITY
  .security	= NULL,
#endif
};
EXPORT_SYMBOL(init_task);

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注