我的第一个内核模块:调试的噩梦

2020-11-21 18:47:37

这是我编写一些代码,将其部署到生产中并通过油炸内核来使正在运行的服务器变砖的时候的故事。

这篇文章是关于并发和竞争条件的危险。我的代码几乎是正确的,但是最终,有两个主要的同步错误将其杀死。

这是一篇很长的文章,有时会杂草丛生,但我已尝试撰写它,以便您可以跳至任何部分并希望从中学习一些内容:

上下文:C Playground visualdebugger:Linux / proc文件如何工作,以及Linux如何存储处理和打开文件信息。我绘制了一些漂亮的图表,以便您可以直观地看到它的工作原理!

在本文中,我假设您对Unix系统上的文件和并发工作有一些了解。我将尽力解释其他一切!

一段时间以来,我一直在为C Playground构建图形调试器,以允许用户在浏览器中运行代码并可视化其程序的执行方式。作为这项工作的一部分,我必须实现一个kernelmodule。 (我将在下一部分中解释有关该项目的更多信息。)在本地对代码进行几个月的测试后,我将其投入生产。

第二天早上,我醒来了一个室友的短信:“我认为服务器崩溃了。”嗯,那不应该发生的。我迅速拿出笔记本电脑,尝试通过SSH进入服务器以获取日志,但令我惊讶的是,我无法到达服务器:

出了点问题。我登录DigitalOcean重新启动计算机,但是在这样做的时候,我注意到室友给我发短信时服务器的CPU使用率图表出现了峰值。 CPU干净地钉在100%上。通常,如果机器上运行的进程占用CPU,我们应该期望看到100%左右的细微波动,但事实并非如此-这是一条干净的水平线。

通过DigitalOcean强制重启服务器并回滚调试功能之后,我开始浏览日志以了解发生了什么。我的内核模块有打印语句,这些语句保存到/var/log/kern.log中的内核日志中。我浏览了这个文件,希望能找到一些线索,但这只会使我更加困惑。服务器锁定后的任何时间,内核日志中都没有任何内容。看来我的内核模块当时甚至没有运行。但是我觉得我的内核模块一定是个问题:用户空间中的任何内容都不会导致计算机锁定它的运行方式,完全无响应,并固定在100%CPU上。

我一直在挖。如果内核日志对我没有任何帮助,则可能存在一些应用程序端记录来指示发生了什么。但是,当我检查C Playground日志文件时,我感觉事情只会变得更糟:

[2020-03-25T14:47:00-0400] [INFO] [p5wDiTxoeX4hdYwDAAEm] Websocket connection received from <redacted>[2020-03-25T14:47:00-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Program is at alias lion-eland-echidna[2020-03-25T14:47:00-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Run logged with ID 55229[2020-03-25T14:47:00-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Saving code to /srv/cplayground/data/dfb5e628-595f-464d-a4dd-1559db7b78d8[2020-03-25T14:47:00-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Successfully created gdb socket at /srv/cplayground/data/dfb5e628-595f-464d-a4dd-1559db7b78d8-gdb.sock[2020-03-25T14:47:00-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Starting container: docker run -it --name dfb5e628-595f-464d-a4dd-1559db7b78d8 --read-only --tmpfs /cplayground:mode=0777,size=32m,exec -v /srv/cplayground/data/dfb5e628-595f-464d-a4dd-1559db7b78d8:/cplayground/code.cpp:ro -v /srv/cplayground/data/dfb5e628-595f-464d-a4dd-1559db7b78d8-include.zip:/cplayground/include.zip:ro -e COMPILER=g++ -e CFLAGS=-g -std=c++17 -O0 -Wall -no-pie -lm -pthread -e SRCPATH=/cplayground/code.cpp --cap-drop=all --memory 96mb --memory-swap 128mb --memory-reservation 32mb --cpu-shares 512 --pids-limit 16 --ulimit cpu=10:11 --ulimit nofile=64 --network none -v /srv/cplayground/data/dfb5e628-595f-464d-a4dd-1559db7b78d8-gdb.sock:/gdb.sock --cap-add=SYS_PTRACE -e CPLAYGROUND_DEBUG=1 cplayground /run.py[2020-03-25T14:47:00-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Initial terminal size 80x24[2020-03-25T14:47:01-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Resize info received: 17x74[2020-03-25T14:47:01-0400] [LOG] [p5wDiTxoeX4hdYwDAAEm] Resize info received: 17x75^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@<truncated for brevity>

经过一番谷歌搜索后,我发现^ @更少显示空字节,因此,我的日志文件中填充了空字节。为什么会这样呢?

然后我想到:文件系统写入不是同步的。当程序写入文件时,通常不会立即将数据写入磁盘。相反,为了提高性能,将数据写入内存中的缓冲区。通常,内核会定期将此缓冲区刷新到磁盘,以便持久存储数据。但是,如果内核无法使用,则数据将无法到达磁盘,并且当机器强制重启时,数据将永远丢失。从上面的输出中,我可以看到部分日志记录到了磁盘上,但是后面的部分记录并不那么幸运,而是显示为混乱的空字节。

我开始感觉到恐惧的蔓延。这让我想起了我上操作系统课的那晚,深夜和令人费解的调试会话。否则情况可能更糟:此问题仅在生产中发生,我无法重现,也无法获得任何日志来解释问题所在。

在下一部分中,我将解释内核模块的功能,以便您可以遵循我的调试过程。然后,在上一节中,我将通过很长的过程与您进行交谈,以找出导致此问题的两个错误。 (扰流板警报:有两种竞争情况导致两次释放后使用,在释放释放后,我尝试使用该内存。)

我从事C Playground已有一段时间,这是一个在线沙箱,用于快速测试C和C ++代码。它是专门为学习系统编程而设计的,并且我一直在使用一些功能来生成图表,以说明当您在计算机上运行代码时幕后发生的事情。

最近,我正在生成内核用来跟踪程序打开文件的数据结构图。此博客文章中对此进行了更详细的描述。作为一个简短的摘要,此功能旨在帮助学生理解系统调用,例如open,close,dup2,fork,pipe等。使用这些系统调用需要了解它们代表您执行的操作。通常,我们用vnode表来解释这些系统调用,该表在系统上缓存有关文件的信息,打开文件表在其中存储会话信息(例如,程序X具有文件Y已打开以供读取,并且到目前为止已读取100个字节)和文件描述符表,该文件将指针存储到打开的文件表中,指示进程已打开了哪些会话。

在教授这些概念时,我们通常会手工绘制许多图表。 CPlayground旨在自动生成图表,帮助学生无需访问TA即可建立并确认自己的直觉。该平台允许学生设置断点并逐行浏览代码。CPlayground会在每行上生成上述表格的图表,如下所示:

这些图可以帮助学生确认对系统调用方式的理解,还可以帮助学生调试未按预期工作的代码。例如,上述屏幕截图中的代码在子进程中生成并使用管道从管道中发送单词。父进程到子排序进程。但是,它挂起了,因为它在第23行的子进程中丢失了一个close()调用。由于缺少了close()调用,管道的writeend永远不会完全关闭,因此当从管道中读取sort时,它会在那里思考该图可以帮助学生识别这一点,因为他们可以直观地识别出管道的写端尚未完全关闭。

不幸的是,Linux内核没有任何API公开生成这些图表所需的数据。因此,我需要修改内核并访问内核空间中所需的数据。如上述博客文章中所述,我编写了一个内核模块,该模块创建了一个虚拟的/ proc / cplayground文件;每当NodeJS后端读取该文件时,该内核模块都会通过迭代并序列化内核数据结构来动态生成文件内容。

我真的很想在4月初的斯坦福大学春季季度开始之前发布此功能。由于COVID-19,该季度已在线上转移,这使我们的TA很难与学生一起工作,并且在办公时间内更难绘制图表。我确实希望学生能够使用此工具,以便他们能够自己可视化事物而无需访问TA。因此,该版本的发行量比原本应该的多。尽管如此,我从没想过会遇到调试的噩梦。

接下来的几个小节将说明如何实现此功能,以便您可以进行调试。您可以根据需要跳过这些部分,但它们可能对阅读有所帮助,以便您了解我的代码的基本结构。

如前所述,我的内核模块创建了一个/ proc文件,以将数据从内核空间公开给用户空间。我正在使用seq_file API,这是一个用于生成proc文件的强大工具,该文件公开了类似于printf的界面,在此您“打印”的字符串被“写入”文件。实际上,proc文件是虚拟文件,因此我们并没有真正写入磁盘上的任何文件。在幕后,seq_file为“文件”内容分配了一个缓冲区;然后,任何seq_printfcalls都会追加到缓冲区的内容中。如果缓冲区空间不足,则seq_file会分配一个两倍大小的新缓冲区,然后重新运行用于生成文件内容的代码。

这是实际的样子。内核模块__init函数创建/ proc / cplayground,告诉内核使用seq_file处理所有文件操作:

// Note: code modified for bloggy readability static int __init cplayground_init ( void ) { // Create the proc file, with the cplayground_file_ops struct dictating how file // operations should be handled cplayground_dirent = proc_create ( "cplayground" , 0400 , NULL , & cplayground_file_ops ); if ( cplayground_dirent == NULL ) { return - ENOMEM ; } return 0 ; } // Use `seq_file` API functions to handle all file ops (except for `open`, which // is handled by `ct_open` below) static struct file_operations cplayground_file_ops = { . owner = THIS_MODULE , . open = ct_open , . read = seq_read , . llseek = seq_lseek , . release = single_release , }; static int ct_open ( struct inode * inode , struct file * file ) { // Call `single_open` from the `seq_file` API, telling it to use our function // `ct_seq_show` in order to generate the virtual file contents return single_open ( file , ct_seq_show , NULL ); }

然后,在ct_seq_show函数中,我们可以简单地调用seq_printf,使用该函数打印的任何字符串都将显示在虚拟proc文件中。不错!

我决定保持/ proc / cplayground文件格式的简单,为每个进程包含一个“段落”,并提供有关该进程及其openfiles的信息:

line containing process infoline containing file descriptor / open file infoline containing file descriptor / open file infoline containing file descriptor / open file info...<blank line>line containing process infoline containing file descriptor / open file infoline containing file descriptor / open file infoline containing file descriptor / open file info...

“名称空间ID”,使我们能够识别哪些进程是同一运行的C Playground程序的一部分。

filedescriptor引用的打开文件表条目的标识符,以便我们可以判断两个文件描述符是否指向同一个打开文件(即共享相同的游标,模式等)

0791fb96d681f840b6fcd13eb38b7d159d2ac75fcf04f38ebe9a3b7d3a0597c8 1179 14 12 14 cplayground0 0 1458ec504828d89f546feab9475b15da1bf2d01c00af28fdaf29805666fec7d6 0 0100002的/ dev / PTS / 01 0 1458ec504828d89f546feab9475b15da1bf2d01c00af28fdaf29805666fec7d6 0 0100002的/ dev / PTS / 02 0 1458ec504828d89f546feab9475b15da1bf2d01c00af28fdaf29805666fec7d6 0 0100002的/ dev / PTS / 03 0 a555b7207e23ce40a5a172b1f0367a39090e58dd23ab468f847494389717ee38 0 00管:[24005915] 4 0 6ca33d7e8f8fe9363291d579507735619dc939cffcff3174fa7cbab9a62c7ed1 0 01管:[24005915] 0791fb96d681f840b6fcd13eb38b7d159d2ac75fcf04f38ebe9a3b7d3a0597c8 1184 18 14 14 cplayground0 0 1458ec504828d89f546feab9475b15da1bf2d01c00af28fdaf29805666fec7d6 0 0100002的/ dev / PTS / 01 0 1458ec504828d89f546feab9475b15da1bf2d01c00af28fdaf29805666fec7d6 0 0100002的/ dev / PTS / 02 0 1458ec504828d89f546feab9475b15da1bf2d01c00af28fdaf29805666fec7d6 0 0100002的/ dev / PTS / 03 0 a555b7207e23ce40a5a172b1f0367a39090e58dd23ab468f847494389717ee38 0 00管:[24005915] 4 0 6ca33d7e8f8fe93 63291d579507735619dc939cffcff3174fa7cbab9a62c7ed1 0 01管道:[24005915]

由此可见,有两个进程在同一个名称空间ID(即同一个容器中运行),它们是同一个正在运行的C Playground程序的一部分。在这些进程中,文件描述符0、1和2分别指向同一打开的文件表条目1458ec504 ...,该条目指向/ dev / pts / 0(即终端)。这是一种我们可以用来重建内核数据结构的可视表示的数据。

使用seq_file这样的好API,生成我们的proc文件很容易。 (对吗?)下面是一些伪代码:

对于在主机上运行的每个进程:检查该进程是否是C Playground程序的一部分,还是在主机上运行的其他无关的进程。如果该过程无关紧要,请跳过该过程并继续进行下一个过程。

将进程的PID,PPID等和命令名称打印到/ proc文件中。

对于该过程中的每个文件描述符:打印文件描述符编号,close_on_exec标志,打开文件ID,文件位置/偏移量,标志以及打开/ proc文件的文件路径。

Linux有一个for_each_process宏,可用于遍历进程列表,该列表存储为双向链接列表:

struct task_struct *task; // Linux calls processes "tasks"for_each_process(task) { // do some stuff with the process!}

每个进程都以structtask_struct的形式存储。如果您想略读定义,那么结构中会存储很多有趣的东西!

现在,我们需要跳过C Playground程序不包含的所有过程。为此,我们可以检查它属于哪个名称空间。 C Playground使用名称空间隔离用户的程序,以使他们无法看到或彼此交互。计算机上的大多数进程将使用相同的(全局)命名空间,但C Playground进程将放置在单独的命名空间中。其他名称空间。

接下来,我们要将有关该进程的信息打印到/ proc / cplayground。内核提供了一系列内联函数,用于从task_struct(例如task_pid_nr)中提取有关进程的信息,并且我们可以使用这些函数来获取大多数信息。需要。打印过程信息非常简单,然后:

seq_printf ( sfile , "%s \t " // namespace ID (i.e. hash of pid_namespace pointer) "%d \t " // global PID "%d \t " // container PID "%d \t " // container PPID "%d \t " // container PGID "%s \n " , // command namespace_id , task_pid_nr ( task ), task_pid_nr_ns ( task , ns ), task_ppid_nr_ns ( task , ns ), task_pgrp_nr_ns ( task , ns ), task -> comm );

最后,我们需要为每个打开的文件描述符打印信息。这一点有点棘手,因为我们想要的信息分布在几个不同的地方。下面的图中总结了一些相关的结构:

task->files (of type structfiles_struct– not sure why they didn’t simply name it struct files) is a structcontaining information about a process’s open files. It contains things likea pointer to the file descriptor table, a number for the next file descriptorthat should be allocated (this is how file descriptors are allocatedsequentially, even if you close fds in between), a spinlock (similar to amutex) for maintaining data consistency, and some other bookkeeping things.

该结构包含一个指向structfdtable的指针,该表使用一个struct filepointers数组实现文件描述符表。

每个structfile是打开文件表中的一个条目。 (我们教它的“打开文件表”实际上并不存在于一个中央位置。它只是一系列分散的结构文件分配。)此结构包含许多内容,包括:与打开文件相关联的标志(例如,常规文件,目录或其他内容?它可以读取,写入还是同时开放?)

甚至一旦我了解了如何从相关结构中获取数据,我都很难将所有内容放在一起。最终,我将以下代码拼凑在一起,从内核中找到的另一个函数中复制了许多代码,但没有完全理解它,因为这里没有注释,并且这些函数都没有文档说明。

// Get struct containing info about open files struct files_struct * files = task -> files ; // Acquire a spinlock (like a mutex) to avoid reading data while some other // kernel thread is modifying it spin_lock ( & files -> file_lock ); // Iterate over the entries in the file descriptor table for ( int fd = 0 ; fd < files_fdtable ( files ) -> max_fds ; fd ++ ) { // Get the `struct file` (i.e. open file table entry) pointed to by the // given file descriptor number `fd`. This will return NULL if the file // descriptor number is not in use. struct file * file = fcheck_files ( files , fd ); // If this file descriptor doe

......