无他,唯手熟尔。

昨天完成了在IT侠的第50单预约单,虽然和xygg比起来连零头都不到,但好歹也算是小小的里程碑。因为自己实在手拙,好几次拆机险些翻车,所以这50单中大多数都是软件维修单;而在这些软件维修单中,绝大多是又是和Linux系统相关的预约。Linux系统的开放性导致用户可能会遇到各种各样的问题,这些问题往往没有通用的、可供推广的解决办法。因此,学习如何在Linux环境下定位问题(trouble-shooting),从而自助地解决问题(bug-fixing)成为了在Linux桌面环境下获得良好体验的关键所在。

如果你接触过南京大学的计算机系统基础(ICS)课程,实验课老师一定讲过STFW、RTFM等字眼。无论是读手册(RTFM)还是Google(STFW),这些其实都是在已经定位问题(找到关键字)之后的收尾工作。那么,怎么定位问题呢?ICS同样介绍过调试理论,虽然调试理论更多着眼的是计算机程序错误的定位,但考虑到操作系统本身也是一个庞杂的程序,其中的部分观点似乎依然成立。因此,这篇博客的剩余部分将借助调试理论的框架来展开,简单讲一讲如何调试Linux系统的问题,希望能够给还不是那么熟悉Linux的同学一点帮助。

本文有非常多的内容来自Archwiki、Wikipedia和Reddit,你也可以直接refer这些材料获得一手认知。另外值得说明的是,本文中所举的例子大多来源于IT侠日常处理相关预约时会碰到的例子,尤其是系统启动过程中的问题;至于各种乱七八糟的软件在runtime时的行为,在这篇文章我们就不多赘述了。

Linux系统中的调试理论

- 为什么debug这么困难?
- 因为bug的触发经历了漫长的过程...

  • 需求 -> 设计 -> 代码 -> Fault(bug) -> Error(程序状态错) -> Failure
    我们

    • 只能观测到failure
    • 可以手动设计检查点检测状态是否符合预期(但非常费时)
    • 无法预知bug在哪里

Lemma1 (调试理论):如果我们能判定任意程序的状态的正确性,那么给定一个Failure,我们可以通过二分查找定位到第一个error的状态,此时的代码就是fault。

调试理论给出了一个简单的推论:单步调试。当计算机程序的每个语句行为都有限时,我们就可以通过从一个假定的状态出发,不断单步执行并判断程序的正确性,最终找到Error发生的位置。Linux系统的错误调试其实也是同样的道理,通过观察系统启动或者执行程序时的轨迹,我们可以一步步缩小错误状态(error)的可能范围。但是,在应用单步调试时,我们需要细致地考虑以下两个问题:

  1. Linux程序/系统的执行流?
  2. 如何判断状态的正确性?

执行流:Linux系统是如何一步步启动的

很多和Linux系统相关的预约单都有一句简单描述:开机后黑屏。但是黑屏与黑屏不能一概而论,系统在不同的阶段产生黑屏需要不同的解决办法。具体而言,Linux系统的启动过程大致经过以下几个阶段

  1. 阶段一:System initialization。通电后,主板需要在启动操作系统前初始化硬件并提供硬件的抽象。经过历史沿革和技术迭代,现在的初始化过程一般有两种不同的方式,分别是BIOS(Legacy)和UEFI。二者在设计原则上是一致的,大致会经历三个阶段:

    • ROM Stage:即运行烧写在ROM/NVRAM上的代码。这个阶段因为系统硬件还没有初始化,因此是没有内存的,所以往往使用汇编语言直接在ROM空间上运行。
    • RAM Stage:在这一阶段,会继续初始化芯片组、CPU等核心过程
    • Find something to boot:在这一阶段会根据各自的specifications查找可以启动的程序,启动并移交系统的控制。其中BIOS会从Master Boot Record(MBR)后的下一个分区、VBR、BIOS boot partition中选择一个分区作为启动分区,而UEFI通常会查找磁盘上的efi分区,遍历该分区中的EFI应用(通常以.EFI结尾),然后从其中选择一个启动。
  2. 阶段二:Boot loader。这就是你在启动Linux时最开始所看到的选单。

    Boot loader实际上是一种EFI应用,所以你可以尝试在efi分区的EFI目录里找一找。它的作用是使用一定的参数来加载kernel和initramfs,然后将控制权移交给kernel进入下一流程。grub在加载Kernel时所使用的参数实际上是跟随/boot/grub/grub.cfg文件的,简单查看就可以发现其中的内容和我们在grub页面按e能看到的内容是相同的:

    ...
    menuentry 'Arch Linux' --class arch --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-f1a0a0f2-b7c1-43a1-a7fb-3f7018f24abf' {
       load_video
       set gfxpayload=keep
       insmod gzio
       insmod part_gpt
       insmod ext2
       search --no-floppy --fs-uuid --set=root f1a0a0f2-b7c1-43a1-a7fb-3f7018f24abf
       echo    'Loading Linux linux ...'
       linux    /boot/vmlinuz-linux root=UUID=f1a0a0f2-b7c1-43a1-a7fb-3f7018f24abf rw  loglevel=3 quiet i8042.dumbkbd i915.enable_psr=0
       echo    'Loading initial ramdisk ...'
       initrd    /boot/intel-ucode.img /boot/initramfs-linux.img
    }
    ...

    但是要注意,如果我们希望修改其中的一些配置(比如禁用开源驱动),请不要修改这个文件,而应当修改/etc/default/grub文件,然后使用grub-mkconfig或者update-grub等命令来更新/boot/grub/grub.cfg

  3. 阶段三:Kernel。Kernel也就是Linux操作系统的核心部分,提供了硬件管理与抽象、进程调度等计算机程序运行所必要的功能。Kernel的文件位置可以通过Boot loader中linux开头的那一行得到,比如我的boot loader就会启动/boot/vmlinuz-linux这个kernel。
  4. 阶段四:initramfs。initramfs是一个可选的镜像文件,当kernel被启动后,它会在内存中unpack initramfs,从而得到一些系统加载时所需要的工具和binary。有一部分initramfs是被一起变一进kernel的,但同时kernel往往也会使用一些外部的initramfs,比如我的boot中使用了/boot/intel-ucode.img/boot/initramfs-linux.img
  5. 阶段五:init process。此时会挂载真正的root分区并执行/sbin/init。在Archlinux下这个文件被软链接到了/lib/systemd/systemd
  6. 阶段六:Getty and Display Manager。init启动并做了必要的初始化之后,用户会进入virtual terminals,也就是TTY。注意这里要分两种情况:

    • 如果没有安装display manager,那么过程会由Getty接管。Getty会对TTY进行必要的初始化,询问用户的用户名和密码,然后调用login登陆。如果密码正确,你就进入了virtual terminal提供的界面,理论上就可以使用linux了。
    • 如果安装了display manager并且被systemd enable了,那么会自动进入TTY7,由display manager初始化一个login界面。如果成功login,后续会由display manager唤起window manager,后者可以理解为提供了linux桌面、窗口管理器等一切必要的图形界面。
  7. 图形界面,Xorg,display manager,window manager和desktop environments。理论上到第六步,linux系统已经被成功启动了。但是在日常使用过程中经常会遇到上述名词,在这里对它们的含义作一点简单的解释。

    • Linux系统使用X Window System(X11, or simply X)来显示图形界面以及处理键鼠交互,而Xorg提供了X Window System的一套开源实现,除此以外还有xming等闭源实现等。当然除了X之外,还有wayland等display server,由于笔者很少使用,在此不表。
    • 当我们需要图形界面的时候,我们可以在Getty认证后从TTY使用startx命令或者xinit命令手动启动Xserver,如果启动成功,startx会依据~/.xserverrc~/.xinitrc分别启动一个Xserver和Xclient。Xserver管理着显示画面和键鼠,当某个应用程序(Xclient)需要显示画面的时候,它会首先将画面数据发送给Xserver,然后由Xserver显示出来。至于它们是怎么沟通的可以参考这个文档
    • 言归正传,我们在linux系统中所看到的画面本身也是一个Xclient,因此它所遵从的机制也是上述这种server-client的机制。但与上面手动启动Xserver不同,现代的Linux系统往往借助display manager来完成这一过程。Display manager说白了就是一个管家,帮你take care有关login、启动Xserver、启动Window manager等多项繁琐的工作。一般情况下它所拉起的图形界面会显示在Linux预留的TTY7,但部分display manager(比如SDDM)的图形界面会放在TTY1。
    • 上面我们遗漏了一个小问题,display manager会启动Xclient,那这个client是什么呢?在大多数成熟的桌面系统下,这个启动的应用程序被叫做window manager。Window manager会统筹调度桌面上的各个窗口的布局,并且会提供桌面(Desktop)、任务栏等等大家熟知的元素,一般情况下也就是大家所说的“GUI”。至此,有关Linux系统的GUI机制我们已经略窥一二,大致可以总结成下面这张图:
    • 最后,有关桌面环境(desktop environment)。桌面环境实际上是window manager和各类应用,甚至可能还包括display manager的总和。比如KDE,它实际上就分别提供了SDDM和KWin分别作为display/window manager,除此以外还有KWallet等一大堆应用。现在比较常用的桌面环境一般有KDEGNOMEXfce等,它们一般各自有各自的配置模式,比如笔者发现GNOME的display manager GDM3往往和KWin不兼容,这个就具体问题具体分析了~

关于Linux启动的执行流已经说的够多了。但是,“调试理论”最关键的一点在于如何hack执行状态、判断状态的正确性并趁系统不注意对它动点手脚,这也就是下面的内容⬇️

“断点” ?

断点,调试器的功能之一,可以让程序中断在需要的地方,从而方便我们分析现场和上下文。

读者不妨仔细回想一下自己调试程序的过程:打个断点,启动调试,然后等待系统运行到断点处自行停止。断点的意义在于,我们可以让系统运行到一个发生Error之前的正确状态,然后对程序的状态和变量的值(一般叫做上下文)进行检查。
当然,Linux系统的启动过程中并没有真正意义上的断点,但我们可以通过某种工具让启动过程停下来,并利用命令行来窥探彼时的系统状态,这种工具叫做“Recovery Shell”。如果我们进一步放宽断点的定义,实际上除了Recovery Shell之外还有很多其他可供我们调试的命令行,包括Recovery Mode和LiveCD等等。利用这些Shell,熟悉Linux操作的同学就可以查看系统在启动过程中的日志并确认系统状态,找到并修复Bug也就轻而易举了。

下面我们介绍几种维修过程中比较常用的Shell种类:

Recovery Shell

我们可以在启动内核时添加一些参数(也就是在grub页面按e),从而让Linux启动过程在某些阶段停下来,进入可供debug的shell界面。这些参数有:

  • rescue:将会把系统的根分区/重新挂载为可读可写模式,然后进入debug shell。因为根分区是可读写的,在这一步你可以通过删除文件释放容量,或者安装一些破损的依赖;
  • emergency:将会在大多数文件系统挂载前启动一个shell,相较上一个选项时刻更早;
  • init=/bin/sh:将会将系统的init process从systemd更改为/bin/sh,相较上一个选项时刻更早。rescueemergency都需要首先启动systemd初始化一些对象和服务,因此当systemd也挂掉的时候,你应当试试这个内核参数。

LiveCD

使用Recovery Shell的前提是你能够成功启动Linux Kernel,但万一Kernel/Boot Loader也挂掉了呢?
这时候你需要LiveCD,也就是对应系统的安装盘。LiveCD一般都会有“try without install”选项,这个选项会启动一个完全运行在内存中的临时的Linux系统,通过这个临时的操作系统我们可以很方便地对原本损坏的系统修修补补。

举个在IT侠维修中经常出现的例子:系统的Boot Loader神秘失踪,需要重新安装Grub引导作为案例。我们首先进入LiveCD,通过try without install启动临时的系统,然后在这个系统中使用fidsk -l寻找原系统的各个分区:

Device     Boot   Start       End   Sectors   Size Id Type
/dev/sda1  *       2048   1050623   1048576   512M  b W95 FAT32
/dev/sda2       1052672 468860927 467808256 223.1G 83 Linux

显然,我的磁盘上只有两个分区,其中/dev/sda1EFI分区/dev/sda2真正的Linux系统的根。问题在于,此时直接grub-install,命令将会把LiveCD的临时系统的根当作根分区,而这并不是我们想要的结果。
为此,我们需要首先挂载原系统的根分区和EFI分区,然后通过chroot切换到原系统的根分区下:

sudo mount /dev/sda2 /mnt  # 挂载根
for i in /dev /dev/pts /proc /sys /run; do sudo mount -B $i /mnt/$i; done # 挂载一些文件系统API
sudo mount /dev/sda1 /mnt/boot/efi # 挂载EFI分区
sudo chroot /mnt  # 切换!

此时你就可以在原系统的根分区下为所欲为了!比如装个grub:

sudo grub-install \
    --target=x86_64-efi \
    --efi-directory=/boot/efi
    --bootloader-id=GRUB

然后关机,拔掉LiveCD,启动,不出意外引导就已经修复完成了。

printf ?

如果你碰巧是个懒鬼,那想必你一定也是printf调试法的高手。但是直接修改Linux内核的源代码、加点printf语句进去自然是不切实际的,因此Kernel的设计者也很贴心地给我们准备了用于调试的礼物:JournalKernel Ring Buffer。通过观察系统的日志和dmesg的输出,我们可以快速浏览系统启动的过程并定位问题。

Kernel Ring Buffer

其实我也不知道Kernel Ring Buffer具体是什么,但是使用在终端dmesg命令将会打印出一堆可供debug的命令。比如:

其中有来自dockeretho等一系列软硬件的信息。你可以搭配grep一起食用从而过滤出需要关注的设备(比如网卡驱动):

> sudo dmesg | grep Network

[    1.331259] e1000e: Intel(R) PRO/1000 Network Driver
[    1.594932] e1000e 0000:00:19.0 eth0: Intel(R) PRO/1000 Network Connection

我个人感觉dmesg比较适合排查和系统硬件/驱动程序相关的问题,有兴趣的话可以自行阅读dmesg文档~。

Journal

毫不夸张地讲,Journal机制可能是故障排除中最为重要的工具。大部分桌面端的Linux‘系统都会使用systemd来管理系统服务和关键对象,而systemd自带的systemd/Journal会自行收集这些服务、进程产生的日志,并保存在/var/log/journal目录下。对于用户,可以使用journalctl命令来快速查看和筛选这些日志。

生也有涯,而Journal无涯。journalctl也要搭配一些过滤器(参数)来使用:

  • journalctl -b:仅仅展示本次启动后产生的日志。-b -<n>可以展示倒数第n+1次启动之后产生的所有日志。
  • journalctl -f:时刻追踪最新产生的日志。
  • journalctl /usr/lib/systemd/systemd:后面跟一个可执行文件,可以仅展示该可执行文件产生的日志。
  • journalctl _PID=<n>:展示PID为n的进程产生的日志。
  • journalctl -u sshd:展示某一个systemd unit(比如sshd)产生的日志。关于system unit是什么,可参考这个文档。
  • journalctl -k:展示Kernel Ring Buffer的输出。
  • journalctl -p <n>:展示优先级大于等于n的输出。一般设置为3,因为3所对应的优先级是Error,优先级更高的往往更加严重。(Warning是什么,我看不见!)

写在最后:General Trouble Shooting

最后的最后,回到最开始的话题:我们应当怎样去定位Linux系统的问题。 回答很简单,我总结成三点:

  1. Check the journal. 好好看dmesgjournalctl的输出,如果是GUI的问题再看看Xorg的日志;
  2. Check the relevant issues. 段错误并不一定意味着是你的错,也有可能是阿三程序员的锅;
  3. Seek Additional Support. 带着日志和详细的问题描述去社区/Linux Users Group/IT侠寻求专业人士的帮助。在别人解决问题的过程中,自己也能受益良多。
最后修改:2022 年 03 月 03 日
ヽ(✿゚▽゚)ノ