Linux内核教程(1) - 道路千万条,调试最重要

简介: 学习Linux内核,首要的第一件事情就是搞清楚调试的方法

Linux内核教程(1) - 道路千万条,调试最重要

从信号量说起

大家可能都学过操作系统,在操作系统课上,在进程同步互斥中,图灵奖获得者Dijkstra的信号量Semphone。

Linux中当然也提供了semphone的实现,用做最普通的睡眠锁。所谓睡眠锁,意思是如果有一个任务试图去获取一个被占用的信号量时,会被推到等待队列中,然后让其睡眠。这样CPU资源就可以用来处理别的事情,实现资源的合理利用。这与一直等待的自旋锁形成鲜明的对比。当占有信号量的任务运行结束后,会唤醒队列里等待的任务,这个信号量也会被唤醒的任务占有。
针对于P和V两种原语的Linux实现是down和up两个操作。还有支持被中断的down_interruptible,可被杀的down_killable,不等待的down_trylock,带超时的down_timeout,考虑得非常周到。
不仅如此,信号量也是支持读/写信号量分离的。
一切看起来很美好,不是么?

我们看看semphone的作者在semphone.c的开头是如何写的:

2  /*
3   * Copyright (c) 2008 Intel Corporation
4   * Author: Matthew Wilcox <willy@linux.intel.com>
5   *
6   * This file implements counting semaphores.
7   * A counting semaphore may be acquired 'n' times before sleeping.
8   * See mutex.c for single-acquisition sleeping locks which enforce
9   * rules which allow code to be debugged more easily.
10   */

对于懒得看英文的同学,我简单翻译一下,如果只是获取一次的锁,建议改用mutex.h,这样会使调试更容易。

在Linux kernel中,为了方便调试,基本上每种机制都有自己的调试宏,以CONFIG_DEBUG_*开头。下面是我随便搜的几个:
config_debug.png

比如自旋锁,就有CONFIG_DEBUG_SPINLOCK,打开之后,会增加追踪如下例:

3492  static inline void
3493  prepare_lock_switch(struct rq *rq, struct task_struct *next, struct rq_flags *rf)
3494  {
3495      /*
3496       * Since the runqueue lock will be released by the next
3497       * task (which is an invalid locking op but in the case
3498       * of the scheduler it's an obvious special-case), so we
3499       * do an early lockdep release here:
3500       */
3501      rq_unpin_lock(rq, rf);
3502      spin_release(&rq->lock.dep_map, _THIS_IP_);
3503  #ifdef CONFIG_DEBUG_SPINLOCK
3504      /* this is a valid case when another task releases the spinlock */
3505      rq->lock.owner = next;
3506  #endif
3507  }

很不幸,semaphone不支持自动调试宏,连受限的也做不到。
所以,信号量最好的场景是特别复杂的场景,比如跨内核空间和用户空间的复杂交互类的,反正调试也不靠这个。
而对于内核代码中正常的使用,应该使用semaphone的受限版本mutex。
mutex是通过怎样的自律来获取自由的呢:

  • 任何时间,只能有一个任务持有mutex
  • 因为只有一个,所以加锁者必须负责给mutex解锁
  • 因为要负责解锁,所以持有mutex的进程不得退出
  • mutex不得用于中断处理程序,也包括下半部。不了解中断和下半部原理的我们后面会介绍
  • mutex不能复制
  • mutex不能手动初始化
  • mutex只能初始化一次

加了这些自律之后,我们终于可以为mutex写一些调试用的功能了。不像自旋锁只是在处理上加了几条语句,mutex专门设计了调试专用函数来做这些事情:

17  extern void debug_mutex_lock_common(struct mutex *lock,
18                      struct mutex_waiter *waiter);
19  extern void debug_mutex_wake_waiter(struct mutex *lock,
20                      struct mutex_waiter *waiter);
21  extern void debug_mutex_free_waiter(struct mutex_waiter *waiter);
22  extern void debug_mutex_add_waiter(struct mutex *lock,
23                     struct mutex_waiter *waiter,
24                     struct task_struct *task);
25  extern void mutex_remove_waiter(struct mutex *lock, struct mutex_waiter *waiter,
26                  struct task_struct *task);
27  extern void debug_mutex_unlock(struct mutex *lock);
28  extern void debug_mutex_init(struct mutex *lock, const char *name,
29                   struct lock_class_key *key);

注:本文中的代码取自kernel 5.9.10版。

从信号量的例子我们就可以看到可调试性在内核中的重要性。

同样,有很多在内核开发中被重点强调的内容,其重要原因也是因为难以调试,比如栈溢出。
因为不像用户空间的应用程序容易退出,内核本身是一直长期运行的,这就导致内核不得不面对内存严重碎片化的情况,想要分配连续页的内存会越来越困难。而且用作栈的内存也没有办法换出到辅助存储中去,所以尽管栈溢出调试困难,也只能分配4k大小的栈。所以就要求开发者以自律享受自由,尽量避免在栈上分量大的对象。一旦栈溢出了,产生的结果是难以预测的。

所以,我们把内核的可调试性当作第一要务来强调。后面我们会调用各种手段来对内核进行调试,包括打日志,调试文件系统,perf和ftrace等工具,甚至SystemTap和eBPF这样的自动生成内核模块的脚本工具等,以及通过模拟器进行调试等各种手段。

编译内核

讲了调试的重要性之后,我们身体力行,首先讨论如何编译内核,如何在模拟器上跑起内核。
目前Linux的两个最主要的应用场景:一是跑在电脑上,主要场景是给自己的电脑更换内核;另一个是跑在嵌入式设备上,比如手机等。

下载内核

内核源代码地址可以在kernel.org上下载,比如我写此文时最新的稳定版是5.10.1,我们就可以下载这个包:

wget -c https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.1.tar.xz

如果要下载源码树的话,可以去clone kernel主线的代码库:

git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git

在运行Linux的电脑上,我们可以通过包管理系统获取到当前使用的系统内核的源代码。

比如在Ubuntu上,可以通过apt install linux-source来安装源代码:

root@iZ8vb39159pi4fttv8aaoyZ:/boot# apt search linux-source
Sorting... Done
Full Text Search... Done
linux-source/focal-updates,focal-updates,focal-security,focal-security,now 5.4.0.58.61 all [installed]
  Linux kernel source with Ubuntu patches

linux-source-5.4.0/focal-updates,focal-updates,focal-security,focal-security,now 5.4.0-58.64 all [installed,automatic]
  Linux kernel source for version 5.4.0 with Ubuntu patches

源代码会下载到/usr/src目录下。

编译Ubuntu 20.04的内核

下载之后,我们将其解压之后,就可以进行编译了。
安装gcc, flex, bison, bc之类的常规操作就不多说了,有遇到问题的请在评论区里提问。

编译之前需要对内核进行一些配置,比如调试信息等。这些配置会写在一个config文件中。
以Ubuntu 20.04系统为例,在/boot目录下可以看到一些config开头的文件,比如config-5.4.0-58-generic,这些就是我们使用的Ubuntu系统的config文件。

这些配置项,以CONFIG_* = y这样的形式来表示这个配置被支持。而如果不支持的话,则把这个配置项用“#”注释掉,并将=y改为is not set以利于理解。

比如我们上面讨论的锁的调试信息,在config文件中的描述如下:

#
# Lock Debugging (spinlocks, mutexes, etc...)
#
CONFIG_LOCK_DEBUGGING_SUPPORT=y
# CONFIG_PROVE_LOCKING is not set
# CONFIG_LOCK_STAT is not set
# CONFIG_DEBUG_RT_MUTEXES is not set
# CONFIG_DEBUG_SPINLOCK is not set
# CONFIG_DEBUG_MUTEXES is not set
# CONFIG_DEBUG_WW_MUTEX_SLOWPATH is not set
# CONFIG_DEBUG_RWSEMS is not set
# CONFIG_DEBUG_LOCK_ALLOC is not set
# CONFIG_DEBUG_ATOMIC_SLEEP is not set
# CONFIG_DEBUG_LOCKING_API_SELFTESTS is not set
# CONFIG_LOCK_TORTURE_TEST is not set
# CONFIG_WW_MUTEX_SELFTEST is not set
# end of Lock Debugging (spinlocks, mutexes, etc...)

我们以Ubuntu 20.04的源码为例,看看如何去编译内核。
进入/usr/src/linux-source-5.4.0目录,我们会看到linux-source-5.4.0.tar.bz2,将其解压。
进入解压后的目录,将/boot/config-5.4.0-58-generic文件复制过来。
然后执行

make ./config-5.4.0-58-generic

成功后,运行make menuconfig,在字符图形界面下可以进行一些手动的配置:
kernel_config.png

配置好之后,保存到.config中,最后执行make -j4来进行编译。j后面是编译开启的线程数。

编译成功后,会看到类似于下面的输出:

Setup is 16380 bytes (padded to 16384 bytes).
System is 8697 kB
CRC 1a8c27e4
Kernel: arch/x86/boot/bzImage is ready  (#1)

编好的kernel在arch/x86/boot/bzImage。

如果我们是想在模拟器上运行内核的话,没有Ubuntu给我准备config。这也不怕,我们可以用x86_64的默认config,在其基础上进行修改。首先我们需要设置下ARCH变量为x86_64,这样不用写路径,make就知道去哪里找x86_64_defconfig

export ARCH=x86_64
make x86_64_defconfig

然后make menuconfig和make不变。

成功后输出如下:

Kernel: arch/x86/boot/bzImage is ready  (#2)

编译ARM64内核

x86_64的搞定了,换成别的架构就是照方抓药了。只不过需要装交叉编译的工具链。
在Ubuntu上,我们可以通过apt install gcc-aarch64-linux-gnu来安装支持ARM64的工具链。
安装好之后,我们配置下CROSS_COMPILE环境变量:

export CROSS_COMPILE=aarch64-linux-gnu-

针对于arm64,只有一个defconfig,设好ARCH之后就可以自动找到了:

export ARCH=arm64

ARCH后面的名字以arch下的子目录名为准,目前kernel支持的架构如下:

  • arc
  • arm64
  • csky
  • hexagon
  • m68k
  • mips
  • nios2
  • parisc
  • riscv
  • sh
  • um
  • x86_64
  • alpha
  • arm
  • c6x
  • h8300
  • ia64
  • microblaze
  • nds32
  • openrisc
  • powerpc
  • s390
  • sparc
  • x86
  • xtensa

我们执行make defconfig

make defconfig

然后运行make -j8之类就可以了。

init程序

但是这样编出来的内核,真的只是一个内核,没有任何shell之类的可以用。内核启动的最后,是要启动一个init程序的,当然我们也可以手写一个。但是为了能有个shell,我们选择用busybox的init.

我们去busybox.net去下载源码:

wget -c https://busybox.net/downloads/busybox-1.32.0.tar.bz2

刚才编译内核时已经设置好ARCH和CROSS_COMPILE了,正好busybox也能用到。
busybox没那么多defconfig,上来就make menuconfig就好。
busybox.png

我们只需要一个busybox程序,所以选择Build static binary。
退出保存之后,执行make -j4去编译。
最后,执行make install,会安装到_install目录下。

准备好了之后,我们需要给busybox的init准备一个配置文件,一般是/etc/init.d/inittab。这时候别说inittab了,我们连目录还没建呢。

第一步:在_install目录下创建etc,dev,mnt和etc/init.d/目录:

mkdir etc
mkdir dev
mkdir mnt
mkdir -p etc/init.d/

mkdir加-p参数的意思是如果父目录没有创建,则创建之。

第二步:创建inittab文件。
这个我们哪会写,看busybox给我们的例子:

::sysinit:/etc/init.d/rcS
# /bin/sh invocations on selected ttys
#
# Note below that we prefix the shell commands with a "-" to indicate to the
# shell that it is supposed to be a login shell.  Normally this is handled by
# login, but since we are bypassing login in this case, BusyBox lets you do
# this yourself...
#
# Start an "askfirst" shell on the console (whatever that may be)
::askfirst:-/bin/sh
# Start an "askfirst" shell on /dev/tty2-4
tty2::askfirst:-/bin/sh
tty3::askfirst:-/bin/sh
tty4::askfirst:-/bin/sh

# /sbin/getty invocations for selected ttys
tty4::respawn:/sbin/getty 38400 tty5
tty5::respawn:/sbin/getty 38400 tty6

# Stuff to do when restarting the init process
::restart:/sbin/init

# Stuff to do before rebooting
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
::shutdown:/sbin/swapoff -a

我们把注释删一删,tty也用不了这么多,精简一下:

::sysinit:/etc/init.d/rcS
::askfirst:-/bin/sh
::restart:/sbin/init
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
::shutdown:/sbin/swapoff -a

另外,我们不想用::respawn:-/sbin/getty或者login之类的登陆界面,直接进入系统,所以加一条直接调shell: ::respawn:-/bin/sh。
这个参考自busybox的examples/bootfloppy/etc下面的inittab

最后写出来如下:

::sysinit:/etc/init.d/rcS
::askfirst:-/bin/sh
::respawn:-/bin/sh
::restart:/sbin/init
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
::shutdown:/sbin/swapoff -a

写完之后,我们欠Busybox一个启动脚本/etc/init.d/rcS。

第三步:在etc/init.d目录下创建rcS文件,如下:

mkdir -p /proc
mkdir -p /tmp
mkdir -p /sys
mkdir -p /mnt
/bin/mount -a
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts
echo /sbin/mdev > /proc/sys/kernel/hotplug
mdev -s

/proc是内核向进程发送消息的机制。比如cat /proc/cpuinfo可以查看cpu运行信息,而cat /proc/meminfo是内存信息。
/sys与/proc类似,也是内核用于展示信息的虚拟文件系统,于2.5版引入,主要展示设备树。
/tmp是临时目录
/mnt是挂载点
/dev/pts是通过ssh等远程登陆时创建的控制台设备文件

mdev是busybox提供的管理热插拔的程序。

BusyBox v1.32.0 (2020-12-15 16:24:44 CST) multi-call binary.

Usage: mdev [-s] | [-df]

mdev -s is to be run during boot to scan /sys and populate /dev.
mdev -d[f]: daemon, listen on netlink.
    -f: stay in foreground.

Bare mdev is a kernel hotplug helper. To activate it:
    echo /sbin/mdev >/proc/sys/kernel/hotplug

如上面说明所示,-s用于启动时扫描,激活命令我们也照抄。

rcS写好了之后需要通过chmod +x rcS赋给可执行权限。

有同学问了,mount -a是挂载啥的?这是按照/etc/fstab来mount所有里面写的文件系统的,我们马上就写一个fstab。
参照busybox-1.32.0/examples/bootfloppy/etc/init.d/rcS,mount -a调用fstab也是busybox的传统操作。

第四步, 创建fstab文件
内容如下:

proc /proc proc defaults 0 0
tmpfs /tmp tmpfs defaults 0 0
sysfs /sys sysfs defaults 0 0
tmpfs /dev tmpfs defaults 0 0
debugfs /sys/kernel/debug debugfs defaults 0 0

到此为止,欠busybox init的连环债算是还清了,下面我们就以此文件系统去编译内核。

在qemu中运行

有了busybox的init程序,我们重新编译下内核,在menuconfig中将刚才的_install目录设置进去,在General配置的Init RAM filesystem中:
initramfs
将我们刚才准备好的_install目录的路径设置进去就好。

配置好保存之后,make -j4开始编译。

经过一段欢快的编译,生成Image:

...
  LD      vmlinux.o
  MODPOST vmlinux.symvers
  MODINFO modules.builtin.modinfo
  GEN     modules.builtin
  LD      .tmp_vmlinux.kallsyms1
  KSYMS   .tmp_vmlinux.kallsyms1.S
  AS      .tmp_vmlinux.kallsyms1.S
  LD      .tmp_vmlinux.kallsyms2
  KSYMS   .tmp_vmlinux.kallsyms2.S
  AS      .tmp_vmlinux.kallsyms2.S
  LD      vmlinux
  SORTTAB vmlinux
  SYSMAP  System.map
  MODPOST Module.symvers
  OBJCOPY arch/arm64/boot/Image
  GZIP    arch/arm64/boot/Image.gz

下面我们调用qemu来运行这个内核,qemu可以通过apt来安装。我们模拟4核A72,16G内存:

qemu-system-aarch64 -machine virt -cpu cortex-a72 -machine type=virt -nographic -m 16384 -smp 4 -kernel arch/arm64/boot/Image --append "rdinit=/linuxrc console=ttyAMA0"

然后我们就可以登陆进我们的aarch64的Linux啦,我们可以uname看看,是不是我们编的5.10.1:

/ # uname -a
Linux (none) 5.10.1 #4 SMP PREEMPT Wed Dec 16 18:19:47 CST 2020 aarch64 GNU/Linux

我们再看看cpuinfo:

/ # cat /proc/cpuinfo
processor    : 0
BogoMIPS    : 125.00
Features    : fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid
CPU implementer    : 0x41
CPU architecture: 8
CPU variant    : 0x0
CPU part    : 0xd08
CPU revision    : 3

processor    : 1
BogoMIPS    : 125.00
Features    : fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid
CPU implementer    : 0x41
CPU architecture: 8
CPU variant    : 0x0
CPU part    : 0xd08
CPU revision    : 3

processor    : 2
BogoMIPS    : 125.00
Features    : fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid
CPU implementer    : 0x41
CPU architecture: 8
CPU variant    : 0x0
CPU part    : 0xd08
CPU revision    : 3

processor    : 3
BogoMIPS    : 125.00
Features    : fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid
CPU implementer    : 0x41
CPU architecture: 8
CPU variant    : 0x0
CPU part    : 0xd08
CPU revision    : 3

最后的秘技是如何退出qemu,按Ctrl-a x,就可以退出了,显示:

/ # QEMU: Terminated

恭喜,一个可玩的内核已经可以工作啦。

懂源码是根本

Linus反对在内核里加入调试器也不是没有道理,调试器只是手段,我们也不能舍本逐末,有了方便的调试手段就不去钻研原理和源码了。
我们希望在解剖kernel的时候能让大家有更丰富的视角,但是最近我们的目标还是理解内核的逻辑和代码。
请大家跟我一起沉下心来,我们一步一步开始探索之旅。

目录
相关文章
|
23天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
61 4
|
12天前
|
缓存 网络协议 Linux
深入探索Linux操作系统的内核优化策略####
本文旨在探讨Linux操作系统内核的优化方法,通过分析当前主流的几种内核优化技术,结合具体案例,阐述如何有效提升系统性能与稳定性。文章首先概述了Linux内核的基本结构,随后详细解析了内核优化的必要性及常用手段,包括编译优化、内核参数调整、内存管理优化等,最后通过实例展示了这些优化技巧在实际场景中的应用效果,为读者提供了一套实用的Linux内核优化指南。 ####
38 1
|
17天前
|
算法 Linux 开发者
Linux内核中的锁机制:保障并发控制的艺术####
本文深入探讨了Linux操作系统内核中实现的多种锁机制,包括自旋锁、互斥锁、读写锁等,旨在揭示这些同步原语如何高效地解决资源竞争问题,保证系统的稳定性和性能。通过分析不同锁机制的工作原理及应用场景,本文为开发者提供了在高并发环境下进行有效并发控制的实用指南。 ####
|
22天前
|
Linux Python
Linux 中某个目录中的文件数如何查看?这篇教程分分钟教会你!
在 Linux 系统中,了解目录下文件数量是常见的需求。本文介绍了四种方法:使用 `ls` 和 `wc` 组合、`find` 命令、`tree` 命令以及编程实现(如 Python)。每种方法都附有详细说明和示例,适合不同水平的用户学习和使用。掌握这些技巧,可以有效提升系统管理和日常使用的效率。
106 6
|
25天前
|
缓存 资源调度 安全
深入探索Linux操作系统的心脏——内核配置与优化####
本文作为一篇技术性深度解析文章,旨在引领读者踏上一场揭秘Linux内核配置与优化的奇妙之旅。不同于传统的摘要概述,本文将以实战为导向,直接跳入核心内容,探讨如何通过精细调整内核参数来提升系统性能、增强安全性及实现资源高效利用。从基础概念到高级技巧,逐步揭示那些隐藏在命令行背后的强大功能,为系统管理员和高级用户打开一扇通往极致性能与定制化体验的大门。 --- ###
58 9
|
24天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
39 6
|
25天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
39 5
|
25天前
|
算法 Unix Linux
深入理解Linux内核调度器:原理与优化
本文探讨了Linux操作系统的心脏——内核调度器(Scheduler)的工作原理,以及如何通过参数调整和代码优化来提高系统性能。不同于常规摘要仅概述内容,本摘要旨在激发读者对Linux内核调度机制深层次运作的兴趣,并简要介绍文章将覆盖的关键话题,如调度算法、实时性增强及节能策略等。
|
26天前
|
存储 监控 安全
Linux内核调优的艺术:从基础到高级###
本文深入探讨了Linux操作系统的心脏——内核的调优方法。文章首先概述了Linux内核的基本结构与工作原理,随后详细阐述了内核调优的重要性及基本原则。通过具体的参数调整示例(如sysctl、/proc/sys目录中的设置),文章展示了如何根据实际应用场景优化系统性能,包括提升CPU利用率、内存管理效率以及I/O性能等关键方面。最后,介绍了一些高级工具和技术,如perf、eBPF和SystemTap,用于更深层次的性能分析和问题定位。本文旨在为系统管理员和高级用户提供实用的内核调优策略,以最大化Linux系统的效率和稳定性。 ###
|
25天前
|
Java Linux Android开发
深入探索Android系统架构:从Linux内核到应用层
本文将带领读者深入了解Android操作系统的复杂架构,从其基于Linux的内核到丰富多彩的应用层。我们将探讨Android的各个关键组件,包括硬件抽象层(HAL)、运行时环境、以及核心库等,揭示它们如何协同工作以支持广泛的设备和应用。通过本文,您将对Android系统的工作原理有一个全面的认识,理解其如何平衡开放性与安全性,以及如何在多样化的设备上提供一致的用户体验。