Linux 内核黑客不可靠指南【ChatGPT】

发布时间 2023-12-09 11:20:59作者: 摩斯电码

Rusty Russell's "Unreliable Guide to Hacking the Linux Kernel"

作者

Rusty Russell

简介

欢迎阅读 Rusty's Remarkably Unreliable Guide to Linux Kernel Hacking。本文档描述了内核代码的常见例程和一般要求:其目标是为有经验的 C 程序员提供 Linux 内核开发的入门指南。我避免了实现细节:这就是代码的用途,我忽略了一些有用的例程。

在阅读本文之前,请了解我从未想过要写这个文档,因为我明显不够资格,但我一直想阅读它,这是唯一的方法。我希望它会成为最佳实践、常见起点和随机信息的汇编。

主要内容

参与者

在系统中,每个 CPU 都可能处于以下状态之一:

  • 未与任何进程关联,为硬件中断提供服务;
  • 未与任何进程关联,为软中断或任务队列提供服务;
  • 在内核空间运行,与进程关联(用户上下文);
  • 在用户空间运行进程。

这些之间存在一种顺序。底部两个可以相互抢占,但在它们之上是严格的层次结构:每个只能被其上方的部分抢占。例如,当一个软中断在 CPU 上运行时,没有其他软中断会抢占它,但硬件中断可以。然而,系统中的任何其他 CPU 都是独立执行的。

我们将看到用户上下文如何阻塞中断,以成为真正的不可抢占状态。

用户上下文

用户上下文是指从系统调用或其他陷阱进入时的状态:与用户空间一样,您可能会被更重要的任务和中断抢占。您可以通过调用 schedule() 进行休眠。

注意

在模块加载和卸载以及块设备层的操作中,您始终处于用户上下文中。

在用户上下文中,当前指针(指示当前执行的任务)是有效的,并且 in_interrupt()(include/linux/preempt.h)为 false。

警告

请注意,如果禁用了抢占或软中断(见下文),in_interrupt() 将返回错误的结果。

硬件中断(硬中断)

定时器滴答声、网络卡和键盘是实际硬件的例子,它们可以在任何时候产生中断。内核运行中断处理程序,为硬件提供服务。内核保证此处理程序永远不会重新进入:如果相同的中断到达,它将被排队(或丢弃)。由于它禁用了中断,因此此处理程序必须快速:通常它只是确认中断,标记要执行的“软中断”,然后退出。

您可以通过 in_hardirq() 返回 true 来判断是否在硬件中断中。

警告

请注意,如果禁用了中断(见下文),这将返回错误的结果。

软中断上下文:软中断和任务队列

每当系统调用即将返回到用户空间,或者硬件中断处理程序退出时,任何标记为挂起的“软中断”(通常由硬件中断标记)都会运行(kernel/softirq.c)。

大部分真正的中断处理工作都是在这里完成的。在转换到 SMP 的早期阶段,只有“底半部”(BH),它们没有利用多个 CPU。不久之后,我们放弃了这种限制,转而使用了“软中断”。

include/linux/interrupt.h 列出了不同的软中断。一个非常重要的软中断是定时器软中断(include/linux/timer.h):您可以注册让它在一定时间内为您调用函数。

软中断通常很难处理,因为同一个软中断会同时在多个 CPU 上运行。因此,更常用的是任务队列(include/linux/interrupt.h):它们是动态可注册的(这意味着您可以拥有尽可能多的任务队列),并且它们还保证任何任务队列一次只在一个 CPU 上运行,尽管不同的任务队列可以同时运行。

警告

名称“任务队列”是误导性的:它们与“任务”无关。

您可以通过使用 in_softirq() 宏(include/linux/preempt.h)来判断是否在软中断(或任务队列)中。

警告

请注意,如果持有底半部锁,这将返回错误的结果。

一些基本规则

没有内存保护

如果您在用户上下文或中断上下文中损坏内存,整台机器都会崩溃。您确定您不能在用户空间完成您想要的操作吗?

没有浮点或 MMX

FPU 上下文未保存;即使在用户上下文中,FPU 状态可能与当前进程不对应:您会干扰某个用户进程的 FPU 状态。如果您真的想这样做,您必须显式保存/恢复完整的 FPU 状态(并避免上下文切换)。这通常是一个坏主意;首先使用定点算术。

严格的堆栈限制

根据配置选项,大多数 32 位架构的内核堆栈约为 3K 到 6K:在大多数 64 位架构上约为 14K,并且通常与中断共享,因此您无法全部使用它。避免在堆栈上进行深度递归和大型本地数组(应该动态分配它们)。

Linux 内核是可移植的

让我们保持这种状态。您的代码应该是 64 位兼容的,并且与大小端无关。您还应该尽量减少特定于 CPU 的内容,例如内联汇编应该清晰地封装并最小化,以便于移植。通常应该将其限制在内核树的体系结构相关部分。

ioctl:不编写新的系统调用

系统调用通常如下所示:

asmlinkage long sys_mycall(int arg)
{
        return 0;
}

首先,在大多数情况下,您不希望创建新的系统调用。您应该创建一个字符设备,并为其实现适当的 ioctl。这比系统调用更灵活,不必在每个体系结构的 include/asm/unistd.h 和 arch/kernel/entry.S 文件中输入,而且更有可能被 Linus 接受。

如果您的例程只是读取或写入某些参数,请考虑实现 sysfs() 接口。

在 ioctl 中,您处于用户上下文中的一个进程。当发生错误时,您返回一个负的 errno(参见 include/uapi/asm-generic/errno-base.h、include/uapi/asm-generic/errno.h 和 include/linux/errno.h),否则返回 0。

在休眠后,您应该检查是否发生了信号:处理信号的 Unix/Linux 方法是临时退出系统调用,并返回 -ERESTARTSYS 错误。系统调用入口代码将切换回用户上下文,处理信号处理程序,然后您的系统调用将被重新启动(除非用户禁用了它)。因此,您应该准备处理重新启动,例如,如果您正在操作某些数据结构的中间。

if (signal_pending(current))
        return -ERESTARTSYS;

如果您正在进行较长的计算:首先考虑用户空间。如果您真的想在内核中执行它,您应该定期检查是否需要放弃 CPU(请记住每个 CPU 有合作式多任务处理)。习惯用法:

cond_resched(); /* 将休眠 */

关于接口设计的简短说明:UNIX 系统调用的座右铭是“提供机制而不是策略”。

死锁的解决方案

除非:

  • 您处于用户上下文中。
  • 您不拥有任何自旋锁。
  • 您已启用中断(实际上,Andi Kleen 表示调度代码将为您启用它们,但这可能不是您想要的)。

您不能调用任何可能休眠的例程。请注意,某些函数可能会隐式休眠:常见的是用户空间访问函数(*_user)和没有 GFP_ATOMIC 的内存分配函数。

您应该始终使用编译内核时的 CONFIG_DEBUG_ATOMIC_SLEEP 选项,它会在您违反这些规则时发出警告。如果您违反了这些规则,最终会锁定您的计算机。

真的。

常见的内核编程例程

printk()

在 include/linux/printk.h 中定义

printk() 将内核消息输出到控制台、dmesg 和 syslog 守护进程。它对调试和错误报告很有用,并且可以在中断上下文中使用,但需要谨慎使用:如果控制台被大量的 printk 消息淹没,那么机器将无法使用。它使用的格式字符串大部分兼容 ANSI C printf,并使用 C 字符串连接来给它一个第一个“优先级”参数:

printk(KERN_INFO "i = %u\n", i);

参见 include/linux/kern_levels.h;其他 KERN_ 值;这些值被 syslog 解释为级别。特殊情况:要打印 IP 地址,请使用:

__be32 ipaddress;
printk(KERN_INFO "my ip: %pI4\n", &ipaddress);

printk() 内部使用 1K 缓冲区,并且不会捕获溢出。确保这足够大。

注意

当你开始在用户程序中将 printf 打成 printk 时,你就知道自己是一个真正的内核黑客了 ?

注意

另一个侧面说明:Unix Version 6 的原始源代码在其 printf 函数顶部有一条注释:“Printf 不应该用于闲聊”。你应该遵循这个建议。

copy_to_user() / copy_from_user() / get_user() / put_user()

在 include/linux/uaccess.h / asm/uaccess.h 中定义

[睡眠]

put_user() 和 get_user() 用于从用户空间获取和放置单个值(如 int、char 或 long)。永远不应该简单地对用户空间的指针进行解引用:应该使用这些例程来复制数据。两者返回 -EFAULT 或 0。

copy_to_user() 和 copy_from_user() 更通用:它们可以在用户空间和内核空间之间复制任意数量的数据。

警告

与 put_user() 和 get_user() 不同,它们返回未复制的数据量(即 0 仍表示成功)。

[是的,这个令人反感的接口让我感到不安。每年都会引发激烈争论。--RR。]

这些函数可能会隐式地进入睡眠状态。这些函数永远不应该在用户上下文之外调用(这没有意义),也不应该在中断被禁用或持有自旋锁时调用。

kmalloc() / kfree()

在 include/linux/slab.h 中定义

[可能睡眠:见下文]

这些例程用于动态请求指针对齐的内存块,就像用户空间中的 malloc 和 free 一样,但 kmalloc() 需要额外的标志字。重要的值有:

GFP_KERNEL

可能会进入睡眠状态并交换以释放内存。只允许在用户上下文中使用,但这是分配内存的最可靠方式。

GFP_ATOMIC

不会进入睡眠状态。比 GFP_KERNEL 不太可靠,但可以从中断上下文中调用。你应该真的有一个良好的内存不足错误处理策略。

GFP_DMA

分配小于 16MB 的 ISA DMA。如果你不知道这是什么,那么你不需要它。非常不可靠。

如果你看到一个来自无效上下文的睡眠函数调用的警告消息,那么也许你在中断上下文中调用了一个睡眠分配函数,而没有使用 GFP_ATOMIC。你应该真的修复这个问题。赶紧动手。

如果你要分配至少 PAGE_SIZE(asm/page.h 或 asm/page_types.h)字节,考虑使用 __get_free_pages()(include/linux/gfp.h)。它接受一个 order 参数(0 表示页面大小,1 表示双倍页面,2 表示四倍页面等),以及上述相同的内存优先级标志字。

如果你要分配超过一页的字节数,你可以使用 vmalloc()。它将在内核映射中分配虚拟内存。这个块在物理内存中不是连续的,但是 MMU 会让它对你看起来像是连续的(所以它只对 CPU 看起来是连续的,对外部设备驱动程序来说不是)。如果你真的需要一些奇怪设备的大物理连续内存,你会遇到问题:在运行中的内核中,内存碎片化会使它变得困难。最好的方法是在引导过程的早期通过 alloc_bootmem() 例程分配块。

在发明自己的经常使用的对象缓存之前,考虑使用 include/linux/slab.h 中的 slab 缓存。

current

在 include/asm/current.h 中定义

这个全局变量(实际上是一个宏)包含指向当前任务结构的指针,因此只在用户上下文中有效。例如,当一个进程进行系统调用时,这将指向调用进程的任务结构。在中断上下文中它不是 NULL。

mdelay() / udelay()

在 include/asm/delay.h / include/linux/delay.h 中定义

udelay() 和 ndelay() 函数可用于小的暂停。不要使用它们的大值,因为你会面临溢出的风险 - 辅助函数 mdelay() 在这里很有用,或者考虑使用 msleep()。

cpu_to_be32() / be32_to_cpu() / cpu_to_le32() / le32_to_cpu()

在 include/asm/byteorder.h 中定义

cpu_to_be32() 等系列函数(其中的“32”可以替换为“64”或“16”,“be”可以替换为“le”)是内核中进行大小端转换的一般方式:它们返回转换后的值。所有变体都提供相反的功能:be32_to_cpu() 等。

这些函数有两个主要变体:指针变体,比如 cpu_to_be32p(),它接受一个指向给定类型的指针,并返回转换后的值。另一个变体是“原地”系列,比如 cpu_to_be32s(),它转换指针引用的值,并返回 void。

local_irq_save() / local_irq_restore()

在 include/linux/irqflags.h 中定义

这些例程在本地 CPU 上禁用硬中断,并恢复它们。它们是可重入的;它们将先前的状态保存在它们的一个 unsigned long flags 参数中。如果你知道中断是启用的,你可以简单地使用 local_irq_disable() 和 local_irq_enable()。

local_bh_disable() / local_bh_enable()

在 include/linux/bottom_half.h 中定义

这些例程在本地 CPU 上禁用软中断,并恢复它们。它们是可重入的;如果在之前已经禁用了软中断,那么在调用这对函数之后,软中断仍将被禁用。它们防止软中断和任务let在当前 CPU 上运行。

smp_processor_id()

在 include/linux/smp.h 中定义

get_cpu() 禁用抢占(这样你就不会突然被移动到另一个 CPU),并返回当前处理器编号,介于 0 和 NR_CPUS 之间。请注意,CPU 编号不一定连续。当你完成后,你应该在 put_cpu() 中再次返回它。

如果你知道自己不会被另一个任务抢占(即你在中断上下文中,或者禁用了抢占),你可以使用 smp_processor_id()。

__init/__exit/__initdata

在 include/linux/init.h 中定义

在引导后,内核会释放一个特殊的部分;用 __init 标记的函数和用 __initdata 标记的数据结构在引导完成后被丢弃:类似地,模块在初始化后丢弃这些内存。__exit 用于声明只在退出时需要的函数:如果这个文件没有编译为模块,那么这个函数将被丢弃。查看头文件以了解用法。请注意,用 __init 标记的函数导出到模块中使用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 是没有意义的 - 这会导致错误。

__initcall()/module_init()

在 include/linux/init.h / include/linux/module.h 中定义

内核的许多部分作为模块(内核中的动态可加载部分)使用效果很好。使用 module_init() 和 module_exit() 宏,可以编写不带 #ifdef 的代码,既可以作为模块,也可以内建到内核中运行。

module_init() 宏定义了在模块插入时调用的函数(如果文件编译为模块),或者在引导时调用的函数:如果文件没有编译为模块,module_init() 宏将等同于 __initcall(),通过链接器的魔术确保该函数在引导时被调用。

这个函数可以返回一个负的错误号,以导致模块加载失败(不幸的是,如果模块编译到内核中,这没有效果)。这个函数在用户上下文中被调用,中断被启用,因此它可以睡眠。

module_exit()

在 include/linux/module.h 中定义

这个宏定义了在模块移除时(或者从未,在文件编译到内核中的情况下)调用的函数。只有当模块使用计数达到零时,它才会被调用。这个函数也可以睡眠,但不能失败:在它返回时,一切必须被清理干净。

请注意,这个宏是可选的:如果不存在,你的模块将无法被移除(除非使用 'rmmod -f')。

try_module_get()/module_put()

在 include/linux/module.h 中定义

这些操作模块使用计数,以防止移除(如果另一个模块使用了它的导出符号,那么模块也不能被移除:见下文)。在调用模块代码之前,你应该在该模块上调用 try_module_get():如果它失败,那么模块正在被移除,你应该表现得好像它不存在一样。否则,你可以安全地进入模块,并在完成后调用 module_put()。

大多数可注册的结构都有一个 owner 字段,比如在 struct file_operations 结构中。将这个字段设置为宏 THIS_MODULE。

Linux内核中的等待队列和原子操作

在Linux内核中,等待队列(wait queue)和原子操作(atomic operations)是实现并发控制和同步的重要机制。等待队列用于在特定条件为真时等待某个进程唤醒,而原子操作用于确保对共享资源的原子性操作。下面将对这两个主题进行简要介绍。

等待队列

声明和排队

  • 使用DECLARE_WAIT_QUEUE_HEAD()宏或init_waitqueue_head()函数来声明wait_queue_head_t
  • 通过wait_event_interruptible()宏将进程放入等待队列,并在条件为真时返回,否则等待或收到信号时返回错误。

唤醒等待的任务

  • 使用wake_up()函数唤醒等待队列中的所有进程,除非某个进程设置了TASK_EXCLUSIVE标志。
  • 还有其他变体的唤醒函数可用。

原子操作

atomic_t类型操作

  • 使用atomic_t类型和相关函数(如atomic_read()atomic_set()atomic_add()等)来操作原子变量,确保操作的原子性。

位操作

  • 使用include/linux/bitops.h中定义的函数进行原子位操作,如set_bit()clear_bit()等。

总结

等待队列和原子操作是Linux内核中实现并发控制和同步的重要机制,它们确保了多个进程对共享资源的安全访问和操作。同时,开发者需要注意使用这些机制时的潜在风险,以避免出现竞争条件和其他并发问题。

以上是对Linux内核中等待队列和原子操作的简要介绍,希望能对您有所帮助。如果您有其他问题,欢迎继续提问。