• 还是得接触一下Linux内核驱动开发,这样才更了解Linux内核pwn

  • 首先我们给一个C语言编写的内核驱动版本的Hello world,逐行理解这个代码,并配置环境。

  • 网站:The Linux Kernel Module Programming Guide

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*  
* hello-1.c - The simplest kernel module.
*/
#include <linux/module.h> /* Needed by all modules */
#include <linux/kernel.h> /* Needed for KERN_INFO */

int init_module(void)
{
printk(KERN_INFO "Hello world 1.\n");

/*
* A non 0 return means init_module failed; module can't be loaded.
*/
return 0;
}

void cleanup_module(void)
{
printk(KERN_INFO "Goodbye world 1.\n");
}

MODULE_LICENSE("GPL");

内核模块基础

内核模块相关概念

  • 内核模块,英文名称为Kernel Module,内核模块的可执行文件后缀通常是.ko,如hello.ko
    • 内核模块是一段可以根据需要加载和卸载内核中的代码。
    • 内核模块可以在不重启系统的情况下扩展内核的功能。
    • 最常见的内核模块其实就是设备驱动程序,它使得内核能够访问连接到系统上的硬件。例如,鼠标其实就是一个硬件,鼠标连接到电脑上,就是由某个内核模块使得内核能与鼠标相互交互,进而操作电脑。
    • 如果没有内核模块,在添加一个新的内核功能,我们就必须修改内核文件,并重新编译内核,这不仅会使得内核变得很大,而且编译内核之后还需要重启电脑,非常不方便。

注意:在具体了解内核模块之前,我将设备驱动等同于内核模块了,这是不对的,设备驱动是内核模块的其中一种。

拓展:内核模块除了有设备驱动程序,还包括了文件系统模块网络协议模块非硬件驱动类型的模块内核功能拓展模块安全模块虚拟化或容器相关模块伪文件系统、接口模块,其实任何可以动态插入内核的功能单元都可以是模块。

内核模块操作

  • Linux中我们将介绍几个对内核模块的操作,注意在执行这些操作一般都需要root权限:

    • 查看已经载入到内核正在运行的内核模块
    • 将未被载入的内核模块载入到内核中。
    • 将被载入的内核模块从内核中移除。
  • 要查看已经载入内核的内核模块,在Linux中可以使用lsmod命令来查看。

    • 运行lsmod就会显示出已经载入内核的内核模块

    image-20260521232109954

    • 运行lsmod命令本质上是读取/proc/modules这个文件,并输出到终端上。

    image-20260521232216580

  • 要将内核模块载入到内核中(其实就是运行内核模块,与运行用户程序是不一样的。)insmodmodprobe这两个命令都可以使用。而这两个命令稍微会有区别:

    • modprobe:最后是调用insmod命令将内核模块载入内核,该命令会自动寻找模块的默认位置,懂得如何找出依赖关系并按正确的顺序加载模块。相对来说更灵活。
    • insmod:这个就是直接将内核模块载入内核,在载入内核模块的时候需要输入内核模块完整的路径,相对来说比较死板。
    • 注意:对于初学来说,就使用insmod进行内核模块的载入即可,modprobe等之后再说。
    • 假设我们这边有一个内核模块,使用insmod载入内核模块就需要输入如下命令,如下图所示,当然图中还是没有hello.ko模块的:

    image-20260521233139705

1
2
insmod /home/myheart/program/my_module/hello.ko
modprobe hello
  • 要将已经载入到内核的内核模块移除,可以使用rmmod命令,也可以使用modprobe -r命令。
1
2
3
4
rmmod 模块名
rmmod /home/myheart/program/my_module/hello.ko
modprobe -r 模块名
modprobe -r hello

环境准备

在编写一个内核程序之前,还需要解决几个问题,也就是环境问题:

  • 内核版本问题:选择内核版本,有些魔改后的内核,或者打了补丁这就会导致一些问题,并且有些Linux发行版/usr/src/是缺少头文件的,就导致无法编译,所以我们尽量下载、编译并启动一个全新的、纯净的Linux内核。
  • 模块版本管理问题:当一个内核模块编译是给linux kernel 5.8.1使用的,那如果在linux kernel 5.8.2载入该模块基本上会报错,因为大部分Linux发行版的stock 内核都默认开启了modversioning选项。最好是编译一个关闭modversioning的内核。
  • 编译问题:注意到前面的内核模块的hello world程序,引入了与用户态的C语言不同的头文件<linux/module.h><linux/kernel.h>。而gcc默认引用在/usr/src/这个文件夹中的这两个头文件。如果/usr/src/文件夹中没有这两个头文件,gcc编译就会报错。
  • 调试问题:内核模块不能像printf()那样打印到屏幕上,它会记录一些信息和警告,这些信息最终只会打印到控制台中这个控制台指的是tty终端,而不是xterm等图形终端,如果使用xterm载入内核模块,只会有日志、信息和警告会被记录,而不会被输出到控制台中。我们可以输入tty命令,如果输出的是/dev/pts/tty0那么就是伪终端信息不会打印,如果是/dev/tty1那么就是真实的控制台。

我的环境

  1. 使用重新编译内核后的WSL2这样支持使用qemu,再下载一个干净的内核源码和与之对应的头文件。
  2. 编译干净的内核源码,并关闭modversioning,并使用busybox文件系统,配合qemu模拟出一个简单的系统,而qemu模拟出来的系统终端是/dev/tty
  3. WSL2中修改gcc编译的一些设置,使得编译时能正确引用头文件,这样实现在WSL2使用gcc编译好的内核模块,可以与内核打包成cpio文件格式,使用qemu模拟的时候放入所模拟的操作系统中,从而对内核进行调试与操作。

WSL2内核重新编译

  • 重新编译WSL2内核是为了能够使用qemu,这一步在我博客上已经写过了,这里不多说了。

编译纯净内核源码

  • 由于WSL2版本对应的是5.15.153.1-microsoft-standard-WSL2,为了兼容,同时也算是省事吧,我就选择与5.15.153.1-microsoft-standard-WSL2相近的Linux kernel 5.15版本。
  • 使用wget命令从linux kernel网站上下载对应版本的内核源码,这里选择下载的是linux kernel 5.15.1版本的:
1
wget https://www.kernel.org/pub/linux/kernel/v5.x/linux-5.15.1.tar.gz

image-20260522025139966

  • 下载完整后使用命令将源码的压缩包进行解压操作:
1
tar -zxvf linux-5.15.1.tar.gz
  • 接着下载编译linux kernel所需要的工具
1
2
3
sudo apt update
sudo apt upgrade
sudo apt install -y build-essential libncurses-dev bison flex libssl-dev libelf-dev bc git
  • 这里为了不多事,就直接将WSL2的内核配置文件作为该内核编译的配置文件(不太建议这样,因为不能保证WSL2的编译配置有没很大改动)
1
2
cd linux-5.15.1/
zcat /proc/config.gz > .config
  • 接着关闭内核模块版本管理:
1
scripts/config --disable MODVERSIONS
  • 接着使用make menuconfig命令,进入配置界面,把虚拟化部分给关了,可以减少一点编译时间,然后保存配置。

image-20260522032334678

  • 接着给编译所要运行的脚本文件添加可执行权限:
1
2
chmod +x scripts/*.sh
chmod +x scripts/remove-stale-files
  • 接着直接使用全部cpu核心开始编译内核
1
make -j$(nproc)

image-20260522032754919

  • 在编译的时候还是遇到报错了,处理方案在这里

image-20260522033243885

1
2
3
scripts/config --disable DEBUG_INFO_BTF --disable DEBUG_INFO_BTF_MODULES
scripts/config --disable MODVERSIONS
make -j$(nproc)
  • 编译完成

image-20260522034201387

  • 将内核复制一份过来,这样就有编译好的内核了。
1
cp ./arch/x86/boot/bzImage ../

image-20260522034256460

使用qemu启动内核

  • 接着使用busybox作为文件系统,配合qemu进行模拟从而启动内核。编译busybox这里也不多说了。这里先使用命令创建文件
1
mkdir -p initramfs/{bin,sbin,proc,sys,dev,etc}
  • 接下来复制busybox并创建符号链接,先将busybox复制到该文件夹的/bin目录下,并进入initramfs/bin目录中
1
2
cp /home/myheart/busybox-1.36.1/busybox initramfs/bin/busybox
cd initramfs/bin
  • 接着将进行创建符号链接操作,并回到initramfs的父目录中
1
2
3
for cmd in $(./busybox --list); do ln -sf busybox $cmd; done
cd ..
cd ..
  • 在内核启动之后,会执行一个init文件,这个了解一下即可,具体是在linux启动流程中会具体介绍,在initramfs这个目录下创建init文件
1
2
3
4
5
6
7
8
cat > initramfs/init << 'EOF'
#!/bin/busybox sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
echo "Welcome to BusyBox Linux!"
exec setsid cttyhack sh
EOF
  • 接着给这个文件赋予可执行权限:
1
chmod +x initramfs/init
  • 接着就是将文件打包为cpio文件
1
2
3
cd initramfs
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
cd ..
  • 然后使用qemu命令直接启动即可,这里我直接创建一个sh文件,就免得总是要输入这么一大串命令:
1
2
3
4
5
6
7
8
# vim qemu.sh
qemu-system-x86_64 \
-kernel /home/myheart/program/my_module/bzImage \
-initrd /home/myheart/program/my_module/initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic
# chmod +x ./qemu.sh
# ./qemu.sh
  • 运行之后查看tty,发现是ttyS0,而不是/dev/pts/tty0,所以控制台的环境也解决了。

image-20260523025346163

image-20260523025502549

编译hello_world.ko文件

  • 对于编译内核的时候,gcc编译在引用内核头文件的时候会稍微有点问题,所以我们直接采用写Makefile来构建。
  • 我们直接将上面hello world的代码复制到hello_world.c文件中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*  
* hello-1.c - The simplest kernel module.
*/
#include <linux/module.h> /* Needed by all modules */
#include <linux/kernel.h> /* Needed for KERN_INFO */

int init_module(void)
{
printk(KERN_INFO "Hello world 1.\n");

/*
* A non 0 return means init_module failed; module can't be loaded.
*/
return 0;
}

void cleanup_module(void)
{
printk(KERN_INFO "Goodbye world 1.\n");
}

MODULE_LICENSE("GPL");
  • 接下来我们要编译该文件,编译内核的时候通常不用gcc命令,而是使用make文件,所以我们要写一个简单的make文件,先确保有make这个程序。

image-20260523030755701

  • 接着创建Makefile文件,并写入这些编译信息:
1
2
3
4
5
6
7
obj-m += hello_world.o

all:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules

clean:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) clean
  • 接着输入make命令即可进行编译操作,运行之后就会看到编译好的内核模块文件.ko
1
make

image-20260523031504337

  • 此时我们就可以将编译好的内核模块文件复制一份到initramfs中,然后再重新打包cpio文件。
1
cp ./hello_world.ko ./initramfs/hello_world.ko
  • 重新打包cpio
1
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
  • 接着再运行qemu.sh脚本,尝试挂载hello_world.ko这个内核模块,可以成功挂载。

image-20260523032415085

image-20260523032512485

  • 在内核模块中printk,并不会将字符串输出到控制台终端,而是写入内核日志缓冲区。我们可以使用dmesg | tail查看内核日志缓冲区。

image-20260523033032634

  • 至此,内核模块的环境就已经搭建好了。如果我们想要在控制台中显示Hello world 1.我们需要将printk(KERN_INFO "Hello world 1.\n");替换成printk(KERN_EMERG "Hello world 1.\n");,并重新编译内核文件打包cpio文件,挂载即可。

image-20260523034141518

内核模块基础知识

内核模块基础知识1

  • 首先我们将详细学习一下,上面遇到的一些内容,大致如下内容:
    • hello_world.c中的每一行代码。
      • 包括引入的头文件。
      • init_module()函数、cleanup_module()函数、printk()函数、MODULE_LICENSE()宏。
    • makefile的语法
  • 首先回顾一下上面的hello_world.c的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*  
* hello-1.c - The simplest kernel module.
*/
#include <linux/module.h> /* Needed by all modules */
#include <linux/kernel.h> /* Needed for KERN_INFO */

int init_module(void)
{
printk(KERN_INFO "Hello world 1.\n");

/*
* A non 0 return means init_module failed; module can't be loaded.
*/
return 0;
}

void cleanup_module(void)
{
printk(KERN_INFO "Goodbye world 1.\n");
}

MODULE_LICENSE("GPL");

内核头文件

  • 在使用Windows进行系统编程的时候,一般我们有这么一个头文件引入,该头文件就是Windows开发提供的系统级的API
1
#include<windows.h>
  • 在开发Linux内核模块的时候,我们一般是引入下面两个头文件,也为开发者提供了一些与内核交互的规范接口,允许用户空间程序或内核模块通过这些接口与内核交互:
    • 位置1:如果使用的是当前Linux系统自带的内核头文件,那么该头文件一般就会放在/usr/src/linux-headers-<内核版本>/这个目录下,在安装第三方库或软件时,其头文件会被放置在/usr/local/include目录下,与系统的头文件区分开,避免发生冲突
    • 位置2:如果使用的是通过下载内核源码编译过的内核,一般是位于源码的linux_kernel/include/linux/这个目录下。
    • 与用户空间头文件的区别:内核头文件专注于内核内部接口,而进程头文件主要用于标准库函数的调用。
1
2
#include<linux/module.h>
#include<linux/kernel.h>

三函数、一宏

  • 在源码中有编写两个函数,首先是int init_module(void)函数,以及int cleanup_module(void)函数。这两个的功能如下:
    • 对于int init_module(void)函数:在使用挂载内核模块的命令时会自动调用这个函数
    • 对于int cleanup_module(void)函数:在使用移除内核模块的命令时会自动调用这个函数。
    • 注1:这两个函数的功能就像进程程序中的main函数一样,运行进程程序的时候程序会自动从main函数开始执行用户编写的程序。
    • 注2:除了固定编写这两个函数的名称,后续还可以使用module_init()module_exit()这两个宏,将自己编写的函数绑定为挂载内核时执行和移除内核时执行。
  • 在学习进程程序的编写,一开始学习的是printf(),而对于内核模块开发的学习一开始也从打印函数开始,内核模块的打印函数为printk
  • printk函数的用法与printf函数的用法差不多,只不过printk多了一个日志级别,关于日志级别的宏定义如下所示,在内核源码linux_kernel/include/linux中:
1
2
3
4
5
6
7
8
#define KERN_EMERG	KERN_SOH "0"	/* system is unusable */
#define KERN_ALERT KERN_SOH "1" /* action must be taken immediately */
#define KERN_CRIT KERN_SOH "2" /* critical conditions */
#define KERN_ERR KERN_SOH "3" /* error conditions */
#define KERN_WARNING KERN_SOH "4" /* warning conditions */
#define KERN_NOTICE KERN_SOH "5" /* normal but significant condition */
#define KERN_INFO KERN_SOH "6" /* informational */
#define KERN_DEBUG KERN_SOH "7" /* debug-level messages */
  • 所以printk函数的大致用法就和示例程序差不多:
1
2
3
4
5
6
7
8
9
int init_module(void)
{
int a = 5;
// 注意宏定义KERN_EMERG与日志的输出内容之间是没有逗号的。
printk(KERN_EMERG "hello world");
printk(KERN_EMERG "the count is %d",a);
return 0;
}

  • 在内核2.4以及更高版本中加载专有模块的时候出现了一些警告,该警告是关于使用无许可证代码污染内核的提示。
    • 从内核2.4开始引入了一套系统标识基于 GPL(及同类许可证)授权的代码,以便在代码为非开源时向用户发出警告。
    • 这个警告可以通过MODULE_LICENSE()宏来实现,如果要消除警告,则可以将许可证设置为GPL,就和上面的示例代码所示:MODULE_LICENSE("GPL");
    • 下面是可接受的自由软件许可证标识符:
      • GPL:GNU通用公共许可证v2或更高版本
      • GPL v2:GNU 通用公共许可证 v2
      • Dual BSD/GPL:GPL v2 或 BSD 许可证选择
      • Dual MIT/GPL :GPL v2 或 MIT 许可证选择
      • Dual MPL/GPL :GPL v2 或 Mozilla 许可证选择
      • Proprietary:非自由产品
  • 其他文档宏如下:
    • MODULE_DESCRIPTION():描述模块的功能
    • MODULE_AUTHOR():声明模块的作者
    • MODULE_SUPPORTED_DEVICE():声明模块支持的设备类型(这个宏在更后面的内核版本好像被移除了。)
    • 注意:这些宏定义可以在linux_kernel/module.h

makefile

  • 首先回顾一下makefile这个文件里面的内容:
1
2
3
4
5
6
7
obj-m += hello_world.o

all:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules

clean:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) clean
  • 接下来逐句介绍一下这个语法:
    • obj-m:是内核构建系统里面的一个变量,表示要编译成内核模块,所以obj-m += hello_world.o也就是要将hello_world.c编译成hello_world.ko
    • all表示目标:
      • make -C:其中-C的意思是切换到Linux内核源码目录去执行Make
      • M=$(PWD)M表示模块文件所在的目录,$(PWD)当前目录,你执行Make命令所在的目录。
      • modules:表示编译模块
      • 其中make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules表示的是我要编译$(PWD)目录下的模块。
    • clean也是调用内核构建系统:清理当前模块目录生成的文件
  • 基本过程如下:
    • 当输入make命令,首先会执行all中的命令
      • cd/home/myheart/program/my_module/linux-5.15.1这个目录中
      • 然后再读取obj-m += hello_world.o
      • 接着会自动进行gcc -c hello_world.c,以及对编译好的hello_world.ko进行链接操作。(具体过程还是比较复杂,之后再了解)
    • 接着编译好之后执行clean命令。
  • 使用make命令就会输出如下编译的内容:

image-20260603161621757

  • 内核 2.6 为内核模块引入了 .ko 扩展名(取代了旧的 .o 扩展名),以区别于常规的目标文件。这些 .ko 文件包含一个额外的 .modinfo 段,用于存放模块的附加元数据。我们可以使用modinfo查看.modinfo段:

image-20260603161750029

补充知识

  • 对于所有已经加载的模块,都会出现在 /proc/modules 文件中。如下图所示,我们可以看到这个模块运行的起始地址。

image-20260603162149042

  • 查看该文件即可确认你的模块已作为内核的一部分加载。使用 rmmod hello_world.ko 移除模块,然后查看 /var/log/messages 来观察系统中记录的模块日志。

内核模块练习1

  • init_module() 中的返回值改为负数,重新编译并加载模块。观察会发生什么。

  • 首先我们打开上面的hello_world.c文件,并定位到init_module的返回值中。

image-20260603151354304

  • 修改返回值为-1

image-20260603151408395

  • 重新编译该文件、复制该文件到文件系统、重新打包文件系统,最后使用qemu进行模拟。

image-20260603151652024

  • 接下来进行挂载操作,输入挂载命令之后会发现操作无法挂载该模块,提示操作不许可

image-20260603151828675

内核模块练习2

  • 编写一个简单的内核模块要求如下:
    • 只要写init_module函数和cleanup_module函数即可
    • 使用许可证宏,添加上许可证GPL
    • 使用描述模块宏,简单描述一下这个模块。
    • 使用声明模块作者宏,指定作者为"aaa"
  • 编写的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
内核模块练习2
*/

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

int init_module(void)
{
printk(KERN_EMERG "Hello world 4.\n");
return 0;
}

void cleanup_module(void)
{
printk(KERN_INFO "Goodbye world 4.\n");
}

MODULE_LICENSE("GPL");

MODULE_DESCRIPTION("This is a simple hello world module.");
MODULE_AUTHOR("aaa");

内核模块基础知识2

  • 主要学习以下内容:
    • 使用宏绑定的方法编写模块。
    • 使用Makefile包含两个模块的编译。

宏绑定编写模块

  • Linux2.4开始,模块的初始化和清理函数不再必须命名为init_module()cleanup_module()
  • 开发者可以使用module_init()module_exit()这两个宏linux/init.h中,允许使用自定义的函数名。
  • 但一个关键的要求是:初始化和清理函数必须在宏调用之前定义,否则会导致编译错误。下面给出一个示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* hello-2.c - 演示 module_init() 和 module_exit() 宏的使用。
* 推荐使用这种方式,而非直接使用 init_module() 和 cleanup_module()。
*/

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h> // 包含 module_init 和 module_exit 的宏定义

static int __init hello_2_init(void)
{
printk(KERN_EMERG "Hello world 2.\n");
return 0;
}

static void __exit hello_2_exit(void)
{
printk(KERN_INFO "Goodbye world 2.\n");
}

// 在初始化和清理函数定义之后,使用宏来指定它们,否则会编译错误
module_init(hello_2_init);
module_exit(hello_2_exit);

MODULE_LICENSE("GPL");

Makefile两个模块

  • 通过上面的编写,已经有了两个文件hello_world.chello_world2.c。使用Makefile可以编译独立编译这两个文件为两个ko文件。
  • 可以通过obj-m +=***.o这样的形式添加要编译的文件,这样就可以实现一次make编译两个独立文件成为两个ko文件。
  • 回顾一下obj-mobj-m:是内核构建系统里面的一个变量,表示要编译成内核模块,所以obj-m += hello_world.o也就是要将hello_world.c编译成hello_world.ko
1
2
3
4
5
6
7
8
obj-m += hello_world.o
obj-m += hello_world2.o

all:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules

clean:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) clean
  • hello_world.chello_world2.c放入到相同文件夹下面,使用make命令就可以进行独立的编译操作了。

image-20260603182107732

image-20260603182126040

内核模块基础知识3

  • 主要学习:__init__exit等几个宏
  • 在内核2.2以及后续版本引入了一项新特性,对初始化清理函数的定义进行了改进。多出了几个宏:
    • __init宏:使得初始化函数在执行后被丢弃并释放其内存。仅适用于内置驱动程序,对可加载模块则无效。
    • __initdata宏:其作用于__init类似,但它用于变量而非函数。
    • __exit宏:使函数在模块被编译进内核时被忽略,与__init类似,对可加载模块没有影响。因为内置驱动不需要清理函数,而可加载模块则需要注意:编译进内核的时候是直接编译成内核vmlinux中,而不是编译成独立ko模块
    • 在一些书上说:当启动系统可能就会看的Freeing unused kernel memory: 236k freed,这就是上面几个宏的作用,内核正在释放相关内存。
    • 对于上面__init宏和__initdat宏的具体行为,在gdb调试linux内核模块中会详细调试。
  • 对于这几个宏详细的说明和定义,在linux/init.h中有比较详细的说明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* These macros are used to mark some functions or 
* initialized data (doesn't apply to uninitialized data)
* as `initialization' functions. The kernel can take this
* as hint that the function is used only during the initialization
* phase and free up used memory resources after
*
* Usage:
* For functions:
*
* You should add __init immediately before the function name, like:
*
* static void __init initme(int x, int y)
* {
* extern int z; z = x * y;
* }
*
* If the function has a prototype somewhere, you can also add
* __init between closing brace of the prototype and semicolon:
*
* extern int initialize_foobar_device(int, int, int) __init;
*
* For initialized data:
* You should insert __initdata or __initconst between the variable name
* and equal sign followed by value, e.g.:
*
* static int init_variable __initdata = 0;
* static const char linux_logo[] __initconst = { 0x32, 0x36, ... };
*
* Don't forget to initialize data not at file scope, i.e. within a function,
* as gcc otherwise puts the data into the bss section and not into the init
* section.
*/

/* These are for everybody (although not all archs will actually
discard it in modules) */
#define __init __section(".init.text") __cold __latent_entropy __noinitretpoline __nocfi
#define __initdata __section(".init.data")
#define __initconst __section(".init.rodata")
#define __exitdata __section(".exit.data")
#define __exit_call __used __section(".exitcall.exit")
  • 接下来给出一个示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* hello-3.c - 演示 __init、__initdata 和 __exit 宏的使用。
*/

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

// 使用 __initdata通常放在变量名之后赋值符号之前对变量进行修饰,表示该变量仅在初始化阶段使用,之后可以被丢弃以节省内存。
static int hello3_data __initdata = 3;

// 使用 __init修饰函数,在声明返回值类型之后,函数名之前,表示该函数仅在初始化阶段使用,之后可以被丢弃以节省内存。
static int __init hello_3_init(void)
{
printk(KERN_EMERG "Hello world 3. Data: %d\n", hello3_data);
return 0;
}

// 使用 __exit修饰函数,在声明返回值类型之后,函数名之前,表示该函数在编译的时候会被忽略
static void __exit hello_3_exit(void)
{
printk(KERN_INFO "Goodbye world 3.\n");
}

module_init(hello_3_init);
module_exit(hello_3_exit);

MODULE_LICENSE("GPL");

内核模块基础知识4

  • 内核模块是支持命令行参数的,但不是像用户态程序那样使用main函数的argc/argv机制。
    • 实现方式是声明全局变量,然后使用module_param()宏进行绑定,该宏定义在linux/moduleparam.h中。
    • 通过使用insmod加载模块时,可以通过类似./insmod mymodule.ko my_variable=5这样的语法进行参数值的传递。
    • 规范:变量声明和宏的调用应放在模块文件的顶部附近,以保持代码清晰。
  • 接下来先介绍一下module_param()宏如何使用:
    • 首先该宏接收三个参数:变量名、变量类型以及sysfs文件权限。
    • 其中整数类型支持有符号和无符号两种变体
    • 其中整数数组或字符串,需要使用module_param_array()宏定义和module_param_string()宏定义。
    • 基本用法示例:
1
2
int myint = 3; // 声明一个变量
module_param(myint, int ,0); // 变量名称,变量类型,文件权限。
  • 对于数字的使用,旧版本内核2.4内核和新版本内核不太一样。需要传入一个指向计数变量的指针作为第三个参数(如果不需要计数,则可以传NULL)
  • 示例如下:
1
2
3
4
5
6
7
8
9
10
11
// 不传入计数
int myintarray[2];
/* 不关心计数 */
module_param_array(myintarray, int, NULL, 0);

int myshortarray[4];
int count;
/* 将计数存入count变量 */
/* 当使用命令insmod mymodule.ko myshortarray=1,2,3 该数组被传递了3个参数 这样count就会被赋值成3 */
module_param_array(myshortarray, int, &count,0);

拓展1:一个实际使用场景:为端口或 IO 地址等变量设置默认值。如果默认值保持不变,则执行自动检测;否则保留用户提供的值。

拓展2:文件权限一般会设置为0444只读,或者说是0644root可写。而参数0不导出到sysfs,并且权限位也有相关的宏定义。

  • 内核模块设置好参数之后还需要写记录参数的文档,这个时候就需要使用MODULE_PARM_DESC()宏用于描述模块接收的参数。它接受变量名和一段自由格式的描述字符串。
  • 一个简单的示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/*

hello_world5.c 演示想模块传递命令行参数

*/

#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/stat.h>

// 写入GPL协议和示例程序的作者
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Peter Jay Salzman");

// 定义一些变量
static short int myshort = 1;
static int myint = 420;
static long int mylong = 9999;
static char *mystring = "blah";
static int myintArray[2] = { -1, -1 };
static int arr_argc = 0;

/*
module_param(foo, int, 0000);
第一个参数是参数名称
第二个参数是数据类型
第三个参数是权限位: 用于在后续阶段将参数暴露在 sysfs 中
*/

// 添加参数,并描述参数的使用
module_param(myshort, short, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
MODULE_PARM_DESC(myshort, "A short integer");

// 继续添加参数,并描述参数的使用
module_param(myint, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
MODULE_PARM_DESC(myint, "An integer");
// 继续添加参数,并描述参数的使用
module_param(mylong, long, S_IRUSR);
MODULE_PARM_DESC(mylong, "A long integer");
// 继续添加参数,并描述参数的使用,并且这个参数是一个字符串
module_param(mystring, charp, 0000);
MODULE_PARM_DESC(mystring, "A character string");


/*

module_param_array(name, type, num, perm);
第一个参数是参数名称
第二个参数是数组元素的数据类型
第三个参数是一个指针,指向将存储用户在模块加载时初始化的数组元素数量的变量
第四个参数是权限位: 用于在后续阶段将参数暴露在 sysfs 中

*/

// 添加一个数组参数,并描述参数的使用
module_param_array(myintArray, int, &arr_argc, 0000);
MODULE_PARM_DESC(myintArray, "An array of integers");

static int __init hello_5_init(void)
{
int i;
printk(KERN_INFO "Hello, world 5\n=============\n");
printk(KERN_INFO "myshort is a short integer: %hd\n", myshort);
printk(KERN_INFO "myint is an integer: %d\n", myint);
printk(KERN_INFO "mylong is a long integer: %ld\n", mylong);
printk(KERN_INFO "mystring is a string: %s\n", mystring);
for (i = 0; i < (sizeof myintArray / sizeof (int)); i++)
{
printk(KERN_INFO "myintArray[%d] = %d\n", i, myintArray[i]);
}

printk(KERN_INFO "got %d arguments for myintArray.\n", arr_argc);
return 0;
}


static void __exit hello_5_exit(void)
{
printk(KERN_INFO "Goodbye, world 5\n");
}

module_init(hello_5_init);
module_exit(hello_5_exit);
  • 编写好后直接挂载看看:

image-20260603220803617

  • 接下来移除该模块,重新挂载并赋予参数:insmod hello_world5.ko myintArray=1,2 mylong=1234 myshort=1

image-20260603221050811

  • 接下来继续移除,并将程序继续挂载,并在整数参数变量输入非数字看看系统如何处理,发现是这样的。

image-20260603221411286

内核模块基础知识5

  • 该部分主要是学习如何将多个c文件编写成一个内核模块文件.ko。这个部分要与前面使用Makefile编译独立的多个文件区别开来。

  • 首先创建一个start.c,用于编写init_module函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
start.c 演示多文件内核模块
*/

// 每个源文件都要引入头文件
#include <linux/module.h>
#include <linux/kernel.h>

int init_module(void)
{
printk(KERN_EMERG "Hello world 6.\n");
return 0;
}
MODULE_LICENSE("GPL");
  • 接着编写一个stop.c,用于编写cleanup_module()函数:
1
2
3
4
5
6
7
8
9
10
11
12
/*
stop.c 演示多个文件内核模块
*/

// 每个源文件都需要引入头文件
#include <linux/module.h>
#include <linux/kernel.h>

void cleanup_module(void)
{
printk(KERN_INFO "Short is the life of a kernel module.\n");
}
  • 接着编写Makefile
    • 其中obj-m += startstop.o表示要构建的内核模块文件,最终文件为startstop.ko文件
    • 其中startstop-objs := start.o stop.o表示startstop.ko这个文件是由start.ostop.o链接起来的。大致流程如下:
      • 首先使用gcc编译器gcc -c start.c -> start.o、gcc -c stop.c -> stop.o,得到start.o和stop.o
      • 再使用链接器ldstart.ostop.o链接成startstop.o
      • 最后再利用链接器将startstop.o 转换为内核模块startstop.ko
1
2
3
4
5
6
7
8
9
obj-m += startstop.o
startstop-objs := start.o stop.o

all:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules

clean:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) clean

  • 使用make命令之前:

image-20260603231504521

  • 使用make命令之后:

image-20260603231642804

内核模块基础知识6

  • 本部分主要说明一下如何为预编译内核构建模块,首先来理解一下两个名词:
    • 自行编译内核:从源码中编译完整的内核,可以开启特定功能。
    • 预编译内核:别人已经编译好的内核,比如Linux发行版自带的内核。
    • 为预编译内核构建模块:在别人已经编译好的内核中添加新的模块功能。
  • 首先第一个要考虑的就是内核模块在加载时,预编译的内核会检查其版本魔数版本魔数是一个字符串,记录了如下内容:
    • 编译内核模块时的内核版本
    • 编译内核模块的编译器版本
    • 编译内核模块支持的CPU架构,等其他的关键信息
    • :只有当内核模块的版本魔数当前内核的版本魔术完全一致的模块才能被内核加载进去。
    • :这就出现了一个问题,当内核添加新模块,但是条件不允许重新编译内核(如服务器在给用户提供服务,编译重新编译内核会使得服务器宕机,造成经济损失)
    • 核心问题:虽然我们有内核源码,并且使用它编译了模块;但系统所运行的内核是由别人编译的,两者的编译环境不完全相同,这就导致版本魔数不匹配,无法成功加载内核模块。
    • 一个经典场景就是使用insmod ***..ko,会出现如下报错,并且/var/log/messages会显示版本魔数不匹配的信息
1
2
3
4
5
6
// 命令行报错
insmod: error inserting 'poet_atkm.ko': -1 Invalid module format

// /var/log/messages报错
Jun 4 22:07:54 localhost kernel: poet_atkm: version magic '2.6.5-1.358custom 686
REGPARM 4KSTACKS gcc-3.3' should be '2.6.5-1.358 686 REGPARM 4KSTACKS gcc-3.3'
  • 解决这个问题的方法有两种:

    • 第一种就是直接使用--force-vermagic选项,但是这个选项是有风险的。

    • 第二种就是正常的安全的方法:

      • 首选确保有一个与当前内核版本匹配的内核源码
      • 找到用于构建预编译内核的配置文件,一般位于/boot目录下面,文件名类似于config-2.6.x,将其复制到内核源码文件中。
      1
      cp /boot/config-`uname -r` /usr/src/linux-`uname -r`/.config
      • 接着仔细审查版本魔数,对比还有哪些不同,有的时候即使配置文件完全相同,版本魔数上的微小差异也会阻止模块插入。
      • 有的时候模块模数出现custom字符串(内核魔数没有),这个原因是发行版对Makefile的修改,这个时候就需要检查/usr/src/linux/Makefile确认版本信息与当前内核匹配例如其开头可能是这样:
      1
      2
      3
      4
      5
      VERSION = 2
      PATCHLEVEL = 6
      SUBLEVEL = 5
      EXTRAVERSION = -1.358custom
      ...
      • EXTRAVERSION恢复为-1.538。保留保留 /lib/modules/2.6.5-1.358/build 中原始 Makefile 的备份。可以使用以下简单的复制命令:
      1
      cp /lib/modules/`uname -r`/build/Makefile /usr/src/linux-`uname -r`
      • 如果你已经使用了错误的 Makefile 启动了内核构建,可以重新运行 make,或者直接修改 /usr/src/linux-2.6.x/include/linux/version.h 中的 UTS_RELEASE,使其与 /lib/modules/2.6.x/build/include/linux/version.h 中的内容一致。然后运行 make 来更新配置、版本头文件和目标文件:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      [root@pcsenonsrv linux-2.6.x]# make
      CHK include/linux/version.h
      UPD include/linux/version.h
      SYMLINK include/asm -> include/asm-i386
      SPLIT include/linux/autoconf.h -> include/config/*
      HOSTCC scripts/basic/fixdep
      HOSTCC scripts/basic/split-include
      HOSTCC scripts/basic/docproc
      HOSTCC scripts/conmakehash
      HOSTCC scripts/kallsyms
      CC scripts/empty.o
      ...
  • 还可以试着重新编译内核以启用调试功能,例如:MODULE_FORCE_UNLOAD,该选项允许通过rmmod -f module强制卸载模块,但这样做不安全。