第三节:和内核模块进行交互
| 2025-7-11
Words 9809Read Time 25 min

/sys 的小伙伴

如果有操作过 sysfs 的,会发现 /sys 下面塞着很多东西,而很多时候我们都会用 echo “xxx” > /sys/xxx 来对某个设备进行初始化、重置等操作,那它是怎么做到的呢?学了前面的知识以后,后面想这个东西其实就很简单了,和 /dev 、/proc 一样,对各个需要操作的文件实现其 read 、 write 方法就好了,然后再传一个文件结构指定其名字。只不过这里分别叫 show 和 store,通过 kobj_attribute 结构来设定其展示和存储方式。
 

系统调用

前面的各种操作其实都是在对这些设备或者是 proc 进行操作,并没有涉及对系统的更改操作。实际上,一个程序运行的时候,经过了不少的系统调用,比如像 open, execve 之类的,用 strace 能看到运行一个程序的时候执行的具体的系统调用。一般来说,普通的用户程序不能直接访问内核,但它们可以调用系统调用。如参考资料所示,也可以通过 arch/$(architecture)/kernel/entry.S 找到其系统调用的表。
但是由于安全性的不断提升,在Linux6.9+,为了防御分支历史注入,新版内核不再使用 sys_call_table 的函数指针表了,而是使用一个巨大的 switch 语句。而且,如果多个模块同时劫持了一个系统调用,卸载顺序出错的时候,系统就会因为调用一个不存在的函数地址而崩溃。
修改系统调用表还不能直接修改它,必须先修改 CPU 的 cr0 寄存器第16位(写保护位),即解除系统的写保护。要修改系统调用,还需要替换系统调用表里某个系统调用的地址来实现。根据不同的系统版本,实现方法有多种。
一种是先找到系统调用表的地址,再找到所需要劫持方法在系统调用表中的地址(注意不是方法的地址,而是方法在调用表中的地址,一般通过系统调用号就可以对应到地址上),然后在这个地址将目标方法的地址替换掉,这样执行系统调用的时候就会执行到我们想要跑的地址上了。同时要记得记录一下原始的call函数存成(void *)方法,然后在最后调用一下这个方法。(查表,换地址,记原始,跑劫持,调原始)结束的时候把地址还原回去就行了。如果还原的时候发现当前的openat地址和我们劫持的地址不同了,那寄了,有另一个模块正在修改这个调用,会出大问题,因为另一个模块可能存的原始地址是我们这个模块的方法,它还原的时候可能会把地址还原成这个模块,但是这个模块已经被移除了,这样就抛空指针异常了。
另一种就是本身内核自带有hook方法,把想要hook的方法定义起来,然后设置一下类似 pre_handler 之类的东西就行了,这种 kprobe 的方法还是比较安全且方便的。结束的时候反注册一下kprobe就行了

进程&线程阻塞

sleep

参考资料说,有个sleep.c,当进程尝试打开 /proc/sleep 的时候,B进程又想打开这个块文件的时候,会尝试阻塞B进程,等到A进程释放后,才会让B读取。核心是这个方法 wait_event_interruptible(waitq, !atomic_read(&already_open))
后面有个代码是这个意思,比如A进程请求了/proc/sleep,B进程接着请求了,然后我Ctrl+C尝试把B进程杀死,那就是对B进程进行信号唤醒,这时候模块会有反应,,然后把引用计数-1并给B进程返回 -EINTR 。也就是说,唤醒 sleep 的不一定是A已经完事了,而有可能是B不想干了,这时候就要直接return -EINTR。
current->pending.signal.sig[i] & ~current->blocked.sig[i] 指的是存在一个待处理的,又没有被屏蔽的信号。比如说B进程有个信号是 SIGINT 需要被处理,然后这个信号又没被屏蔽(屏蔽是指B进程愿意主动忽略),这个时候就表明B不想等了,要直接退出了。
关键是这个头 #include <linux/wait.h>
如果一个C语言一下子都不想等,那就可以在传入的时候指定 O_NONBLOCK ,表明以非块模式读,返回的时候如果错误是 EAGAIN ,那就表明启动时会阻塞,那就不读了。

completion

首先要用到这个头 #include <linux/completion.h>
这个部分的功能是,让一个线程等待另一个线程完成某项任务后,再继续执行;即A执行完毕后,任务B才能开始。这个线程是在内核模块中的。使用 wait_for_completion(&crank_comp);complete_all(&crank_comp); 配套实现。上面那个sleep是针对用户态进程访问块文件阻塞的,这个是内核内部函数进行多线程操作时等待另一个线程完成。

同步

死去的操作系统原理知识突然开始攻击我……

互斥锁

该操作使用到了头文件 #include <linux/mutex.h> ,以及方法 mutex_trylock(&mymutex)mutex_unlock(&mymutex); 同时使用了 mutex_is_locked(&mymutex) == 0 做判断。
就是一个操作开始的时候,另一个操作不能插手。这个方法好像之前在第二节的时候也有实现过,不过那时候使用 test_and_set_bit 来实现的来着。

自旋锁

该操作使用到了头文件 #include <linux/spinlock.h> ,以及 spin_lock_init(&sl_dynamic);spin_lock_irqsave(&sl_dynamic, flags); spin_unlock_irqrestore(&sl_dynamic, flags); 方法。
与互斥锁不同,自选锁会占用100%的cpu来忙等,保证自旋锁一释放出来就立刻抢占。因为它会占满CPU,所以被保护的代码必须十分简短,在几毫秒内就能完成,不然持有锁的代码进入睡眠,CPU就会卡在忙等带的循环,但等不到睡眠线程来解锁,然后就死机了。

自旋锁的不同版本

  • spin_lock(): 普通的自旋锁。
  • spin_lock_irq(): 加锁时禁用中断,但不保存之前的状态。
  • spin_lock_irqsave(): 加锁时禁用中断,并保存之前的状态(更安全)。
  • spin_lock_bh(): 只禁用“软中断 (softirqs)”,不影响硬件中断。

读写锁

该操作使用到了头文件 #include <linux/rwlock.h> ,以及 read_lock_irqsave(&myrwlock, flags);配套read_unlock_irqrestore(&myrwlock, flags);write_lock_irqsave(&myrwlock, flags); 配套write_unlock_irqrestore(&myrwlock, flags);
同时使用 static DEFINE_RWLOCK(myrwlock); 定义读写锁。
无人操作时可写,有人写时不可写,有人写时不可读,有人读时可以读,有人读时不可写。

原子操作

  • 头文件: #include <linux/atomic.h>
  • 定义: static atomic_t my_atomic_var = ATOMIC_INIT(initial_value);
  • 常用函数:
    • atomic_read(&my_atomic_var);:原子地读取值。
    • atomic_set(&my_atomic_var, new_value);:原子地设置新值。
    • atomic_inc(&my_atomic_var);:原子地将值加一。
    • atomic_dec(&my_atomic_var);:原子地将值减一。
    • atomic_add(i, &my_atomic_var);:原子地将值增加 i
    • atomic_sub(i, &my_atomic_var);:原子地将值减少 i
    • atomic_dec_and_test(&my_atomic_var);:原子地将值减一,并检查结果是否为0。如果是0,返回真;否则返回假。这在实现引用计数时非常有用。
为啥不用互斥锁呢?因为互斥锁或者自旋锁太大了,我只是为了做加减操作和读写操作的话,没必要特地整个锁,我只要保证在操作期间不要被人打断就行了,也就是我在修改这个整数的时候,其它的cpu或线程无法中途插入或同时修改。

置换打印宏

我要是想将内核模块的信息直接打印到我眼前呢,而不是我自己去dmesg找信息,能够做到这一点吗?
能的兄弟,能的,你只需要将你目前的tty拿出来,然后给tty写不就行了吗
  • 头文件: #include <linux/tty.h>
  • 常用函数与操作:
    • get_current_tty():获取当前进程所关联的终端(tty)结构体指针。如果当前进程没有终端(例如它是一个后台守护进程),则返回 NULL
    • tty->driver->ops->write(tty, buf, count):这不是一个直接的函数,而是通过 tty_struct 指针链,找到并调用具体终端驱动的 write 函数来输出字符串。
      • tty->driver: 指向管理这个 tty 的驱动程序。
      • driver->ops: 指向该驱动的操作函数列表。
      • ops->write: 指向真正的“写”函数。这是一个典型的函数指针调用,是 Linux 内核驱动模型的核心之一。

用键盘LED闪烁来演示 timer 定时器功能

  • 头文件:
    • #include <linux/timer.h>: 包含了内核定时器的核心定义,如 struct timer_list
    • #include <linux/kd.h>: 包含了键盘相关的命令定义,如 KDSETLED
    • #include <linux/tty.h>#include <linux/vt_kern.h>: 用于访问虚拟控制台(console)和TTY驱动。
  • 关键结构与定义:
    • struct timer_list: 代表一个内核定时器的核心结构体。
    • timer_setup(&my_timer, my_callback_func, 0);: 初始化一个定时器,并将其与一个回调函数绑定。这是现代内核推荐的使用方式。
  • 常用函数与操作:
    • add_timer(&my_timer): 启动或重新启动一个已经设置好到期时间的定时器。
    • del_timer(&my_timer): 从系统中删除一个定时器,确保它不会再被触发。这在模块退出时是必须的
    • jiffies: 内核的一个全局变量,记录了自系统启动以来所经过的“节拍”数。通常用 jiffies + DURATION 的形式来计算未来的到期时间。
    • ioctl(tty, KDSETLED, value): 通过TTY驱动的ioctl接口发送 KDSETLED 命令,这是实际控制键盘LED灯亮灭的操作。
由这个代码可以看出,设置定时器的操作,其实是在定时器里面再设置定时器,以此达到无限循环。

控制 GPIO?

使用开发板的时候,可以控制gpio接口来设置其处于高电平还是低电平,可以通过 cat /sys/kernel/debug/gpio 看到这些信息,比如这是树莓派的信息
  • 头文件: #include <linux/gpio.h>
  • GPIO 控制:
    • gpio_request(): 向内核申请使用一个特定的GPIO引脚。
    • gpio_direction_output(): 将申请到的GPIO引脚配置为输出模式。
    • gpio_set_value(): 设置GPIO引脚的电平(1为高电平,0为低电平)。
    • gpio_free(): 释放之前申请的GPIO引脚。
再看参考资料的代码,这里定义GPIO,使用引脚编号为4,然后使用 gpio_set_vaule 来控制高低电平。
请求gpio
配置引脚为输出模式
通过read获得信号的时候,设置value

DHT11 传感器

有时候我们也不是只有控制 LED 灯这么简单的任务,还需要从外部gpio设备获取一点信息,比如温度啊亮度啊声音大小之类的。我们也直击重点,看它调取数据的部分

调度任务

Tasklet

在某个时刻安排函数在未来执行,通常是尽快执行,但又不阻塞当前的代码流程;适用于一些不那么紧急但不能耗时太长的工作,简单来说就是同步进行。这个方法不能获得函数返回结果,但是执行过程可以影响static变量。
tasklet 的优先级比普通进程要高,只能用自旋锁,而不能用互斥锁,否则会导致死锁。因为系统会优先给tasklet任务响应,而一旦一个普通进程获得了互斥锁而进入睡眠,tasklet立刻执行就会导致死锁。而获取自选锁的代码本身被要求设计为执行得非常快,所以进程会很快完成临界区代码并释放锁。
有点类似PHP的 fastcgi_finish_request(); 函数,会提前响应前端的请求,然后继续在后端进行处理。
  • 头文件 (Header File):
    • #include <linux/interrupt.h>: 包含了 Tasklet 相关的核心数据结构和函数声明。
  • 关键结构与定义 (Key Structures & Definitions):
    • struct tasklet_struct: 代表一个 Tasklet 任务的核心数据结构。
    • DECLARE_TASKLET(name, func)DECLARE_TASKLET_OLD(name, func): 用于静态地声明并初始化一个 tasklet_struct 变量的宏。它将一个名字和一个函数绑定在一起。
  • 常用函数与操作 (Common Functions & Operations):
    • tasklet_schedule(&my_tasklet): 安排一个 Tasklet 在未来(尽快)被执行。这是一个非阻塞函数,会立即返回。
    • tasklet_kill(&my_tasklet): 从调度队列中移除一个 Tasklet。如果该 Tasklet 正在执行,此函数会等待其完成后再返回。这是模块卸载时推荐使用的清理函数。
    • tasklet_init(t, func, data): 动态初始化一个 tasklet_struct 结构体的方式(与静态的 DECLARE_TASKLET 宏相对)。

Work queues 工作队列

从队列中取出一个工作来执行,一般来说允许睡眠。
  • 头文件 (Header File):
    • #include <linux/workqueue.h>: 包含了工作队列机制所需的所有数据结构和函数声明。
  • 关键结构与定义 (Key Structures & Definitions):
    • struct workqueue_struct: 代表一个工作队列(“收件箱”)。
    • struct work_struct: 代表一个待处理的工作项(“任务便签”)。
    • INIT_WORK(&my_work, my_work_handler);: 用于初始化一个 work_struct 变量,并将其与一个处理函数关联起来的宏。
  • 常用函数与操作 (Common Functions & Operations):
    • alloc_workqueue(): 创建一个新的、自定义的工作队列。
    • destroy_workqueue(): 销毁一个之前创建的自定义工作队列。
    • queue_work(my_queue, &my_work): 将一个工作项提交到指定的工作队列中。
    • schedule_work(&my_work): 一个更简单的函数,它将工作项提交到系统共享的、默认的工作队列中。对于简单的任务,使用这个函数可以避免自己创建和管理队列。
    • flush_workqueue(my_queue): 等待指定工作队列中的所有工作完成。
    • cancel_work_sync(&my_work): 尝试取消一个尚未开始执行的工作项。如果工作已在执行,则等待其完成。

中断处理

内核不仅仅是和普通的程序打交道,它也要和硬件打交道,而且要及时地处理硬件发来的请求。硬件交流分为CPU和其它计算机硬件。一般计算机硬件也有一些小量的RAM,但是不读它就会消失。当硬件发送来终端请求的时候,必须立刻停下手中正在做的工作,并处理这个硬件的请求,否则这个硬件的数据就会丢失。
中断处理分为两个步骤,取数据与处理数据。取数据是一个紧急处理的步骤,它必须特别快,保存现场(比如寄存器内容之类的),接收数据,告知硬件收到数据(清除中断标志),恢复现场。到了处理数据的时候,已经脱离了紧急的中断上下文,在一个相对宽松的环境中运行,这时候就可以用上面的tasklet和work queues来进行了。
当然,你还可以自己注册一个中断处理程序,称之为软中断,每个中断都对应一个唯一的数字编号。

GPIO 的变化怎么引起中断?

开发板上有许许多多的gpio口,之前做单片机开发的时候就想着,我用按钮按下开关的时候,led灯亮这不就是基本操作吗?如果没记错的话,我当时做单片机开发的时候是通过一个死循环,中间插一个极小的sleep不断判断指定两个口的电平,然后通过电平进而控制led灯的电平。之前对DHT11的查询应该就是这个例子,但是这也只适合只干一个活的单片机啊;那么,对于linux内核,这是怎么实现的呢?
首先,我们肯定需要不只有一个gpio口,起码是led一个,开关一个;原参考资料这里,写了两个口,一个用来开,一个用来关。看代码,关键在这里,它将gpio口转换成中断编号,然后再注册中断处理程序,使其电平发生变化(从低到高或者从高到低)的时候触发 button_isr 这个中断处理程序,这里 button_irqs 是中断处理号,看代码就能看得出,如果是第0个按钮按下就打开led灯,如果是第1个按钮按下就关闭led灯。
当然,结束也要记得将中断清除干净,将gpio释放

我按下按钮了,不急的话还能干点别的事吧?

那当然可以嘞~
你还可以进行一些。。。不那么紧急的计划任务嘛~
切,我还以为是什么涩涩的东西
 
在原本的button_isr里面塞入其它的东西,比如一个scheduler,因为中断处理是一个很紧急的事,你只要知道我按了按钮就行了,你后面想干啥你挂个schedule_work慢慢弄,别挡着人家进出。

好麻烦哦,有没有更直接的

在上一个部分,我们如果要将中断处理和后续处理分开,我们需要在中断处理程序的肚子里塞一个后续处理的 schedule_work ;但有个东西叫线程化中断,就不需要我们再自己在函数内考虑塞另一个tasklet的事,我们把两个方法都一股脑都塞进去就行了。它会自己按先处理上半部分,再处理不紧急的下半部分来处理。

从虚拟输入设备来理解框架模块与具体实现

实际上,我们在看一些具体设备的时候,并不会用一些自己写的类似 /dev/capture 这种设备,而是会出现一个统一的 /dev/videoX 这种设备,这种设备能够很好地被一些视频采集软件直接调用,这是怎么做到的呢?参考文档使用了一个虚拟输入设备的方法来跟我们解释了这是怎么做到的,其实就是分层和抽象的思想。
在参考资料这里,vinput则是这个框架,而vkbd则是其其中一个设备,其实有点像写一个插件了。需要注意的是,实际上 vinput 并没有干啥实质性的落地的活,真正干活的都是 vkbd 干的,而 vinput 就是干了一些本来应该重复的活,比如,如果没有 vinput 框架,写一遍 vkbd 需要进行:
  • 写一套完整的字符设备驱动代码(alloc_chrdev_region, cdev_init, cdev_add...)。
  • 写一套自己的 sysfs 接口来让用户可以创建虚拟键盘设备。
  • 自己管理设备实例的ID(比如 vkbd0, vkbd1...)。
  • 处理所有与用户空间的数据拷贝和交互。
  • 最后再写核心的键盘模拟逻辑。
然后,如果你还有个虚拟鼠标 vmouse ,你还得把上面的工作再进行一遍。
vinput.h ,参考资料先定义了 vinput_devicevinput_ops, vinput 结构
vinput.c 中,先从init看起,init中 vinput 注册了一个类和export、unexport文件,用于给驱动创建 /dev/vinputX
其中,export 和 unexport store操作的方法又在这里。在export的时候,需要确保子模块已经被注册。我们先看看 vkbd 这个子模块是怎么被注册的:
我感觉这里的链表添加逻辑应该有点问题,此时 &dev→list 应该是空的,list→add 的逻辑如下;但也不对诶, list_add 本身有检查节点的逻辑,如果有问题的话会直接抛出来。
等下,list_head 并不是一个指针,而是一个结构体,里面包含prev和next两个指针。
这下懂了,其实list_add同时要设置两个东西,一个是将new插到head的后面,将new的前置指针指向head,将后置指针指向原本head的后置指针指向,然后将head的后置指针指向new,也就是这么改嘛。
notion image
好,子模块已经注册给框架了,那接下来看看怎么创建一个虚拟设备,先看 export 和 unexport 的操作
根据 vinput_alloc_vdevice 这个方法,看看是怎么分配一个新设备的
最后就是将虚拟设备实例注册到 input_dev 子系统
这时候就回来调用 vkbd 自己指定的 init 函数啦
最后才是到最底层的,对设备文件的读写操作。对vinput的读写操作,当然也是先经过框架,然后再到子模块上。

优化

likely 和 unlikely

有时候一段重复的代码会被运行很多次,中间如果夹着一个判断,由于遇到了条件分支,那CPU会经常没法预测之后的结果。每次都要判断完再跳转的话,CPU流水线就经常在这里卡一下,没法提前做接下来的工作。这时候,作为程序员,我们有可能比编译器更清除一个if条件的结果在大概率下是true还是false,比如检查一个表示错误的指针是否为NULL,它的条件就大概率是false。
当使用 unlikely(condition) 的时候,就是告诉编译器,这条命令执行的极大概率是 false,那么编译器会优化生成的机器码,让代码顺序执行 if 失败后的路径,只有条件为真的时候才会进行一次跳转。反之,编译器优化代码,让if成功后的路径顺序执行。

静态键

还有一种更牛逼的优化技巧,用到了静态键。设置了一个类似与bool值的变量,但是,当它被设成false的时候,使用 if (static_branch_unlikely(&fkey)) 进行判断,在编译后内核会直接将它替换成一个 NOP 指令,CPU 执行到这里的时候,就根本没有任何判断和跳转,性能开销几乎为0。而如果用 static_branch_enable 将它启用的时候,内核就会找到之前那个设置成 NOP 的内存的位置,将它设置成无条件跳转来覆写它,无条件地跳转到语句块来执行。
  • 头文件 (Header File):
    • #include <linux/jump_label.h>: 包含了静态键相关的核心宏和函数。
  • 关键结构与定义 (Key Structures & Definitions):
    • struct static_key: 代表一个静态键的核心数据结构。
    • DEFINE_STATIC_KEY_FALSE(name): 静态地声明并初始化一个键,使其默认状态为假(关闭)
    • DEFINE_STATIC_KEY_TRUE(name): 静态地声明并初始化一个键,使其默认状态为真(开启)
  • 常用函数与操作 (Common Functions & Operations):
    • static_branch_unlikely(&key) / static_branch_likely(&key): 在 if 语句中使用,定义一个可以被动态修改的分支。当键被禁用时,这行代码的性能开销几乎为零。
    • static_branch_enable(&key): 在运行时动态地启用一个键(将 NOP 指令修改为 JMP 指令)。
    • static_branch_disable(&key): 在运行时动态地禁用一个键(将 JMP 指令改回 NOP 指令)。
    • static_key_enabled(&key): 一个普通的检查函数,用于查询一个静态键当前是开启还是关闭状态。
Loading...