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

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

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内核中等待队列和原子操作的简要介绍,希望能对您有所帮助。如果您有其他问题,欢迎继续提问。

相关文章
|
23天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
64 4
|
13天前
|
缓存 网络协议 Linux
深入探索Linux操作系统的内核优化策略####
本文旨在探讨Linux操作系统内核的优化方法,通过分析当前主流的几种内核优化技术,结合具体案例,阐述如何有效提升系统性能与稳定性。文章首先概述了Linux内核的基本结构,随后详细解析了内核优化的必要性及常用手段,包括编译优化、内核参数调整、内存管理优化等,最后通过实例展示了这些优化技巧在实际场景中的应用效果,为读者提供了一套实用的Linux内核优化指南。 ####
38 1
|
18天前
|
算法 Linux 开发者
Linux内核中的锁机制:保障并发控制的艺术####
本文深入探讨了Linux操作系统内核中实现的多种锁机制,包括自旋锁、互斥锁、读写锁等,旨在揭示这些同步原语如何高效地解决资源竞争问题,保证系统的稳定性和性能。通过分析不同锁机制的工作原理及应用场景,本文为开发者提供了在高并发环境下进行有效并发控制的实用指南。 ####
|
25天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
40 6
|
26天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
39 5
|
26天前
|
算法 Unix Linux
深入理解Linux内核调度器:原理与优化
本文探讨了Linux操作系统的心脏——内核调度器(Scheduler)的工作原理,以及如何通过参数调整和代码优化来提高系统性能。不同于常规摘要仅概述内容,本摘要旨在激发读者对Linux内核调度机制深层次运作的兴趣,并简要介绍文章将覆盖的关键话题,如调度算法、实时性增强及节能策略等。
|
25天前
|
缓存 运维 网络协议
深入Linux内核架构:操作系统的核心奥秘
深入Linux内核架构:操作系统的核心奥秘
46 2
|
23天前
|
监控 Linux
如何检查 Linux 内存使用量是否耗尽?这 5 个命令堪称绝了!
本文介绍了在Linux系统中检查内存使用情况的5个常用命令:`free`、`top`、`vmstat`、`pidstat` 和 `/proc/meminfo` 文件,帮助用户准确监控内存状态,确保系统稳定运行。
139 6
|
24天前
|
Linux
在 Linux 系统中,“cd”命令用于切换当前工作目录
在 Linux 系统中,“cd”命令用于切换当前工作目录。本文详细介绍了“cd”命令的基本用法和常见技巧,包括使用“.”、“..”、“~”、绝对路径和相对路径,以及快速切换到上一次工作目录等。此外,还探讨了高级技巧,如使用通配符、结合其他命令、在脚本中使用,以及实际应用案例,帮助读者提高工作效率。
65 3
|
24天前
|
监控 安全 Linux
在 Linux 系统中,网络管理是重要任务。本文介绍了常用的网络命令及其适用场景
在 Linux 系统中,网络管理是重要任务。本文介绍了常用的网络命令及其适用场景,包括 ping(测试连通性)、traceroute(跟踪路由路径)、netstat(显示网络连接信息)、nmap(网络扫描)、ifconfig 和 ip(网络接口配置)。掌握这些命令有助于高效诊断和解决网络问题,保障网络稳定运行。
60 2