Unix 系统分析大作业,主要实现在内核模块中截获键盘中断扫描码存入驱动程序的自定义缓冲区,并通过用户空间应用程序打开设备以获取相应的扫描码。

在学习 Kernel Exploitation 时接触到 2017-CISCN-babydriver 这道题,其中也是注册了一个字符设备,注册设备的过程直接可以搬来用。

一、 系统需求

  1. 设计一个字符设备驱动程序,要求完成以下功能:
    1. 通过键盘中断来记录用户按键,并保存在内部缓冲区中;
    2. 驱动程序提供 read 方法,应用程序可以来读按键缓冲区数据;
    3. 驱动程序实现 sysfs,提供 count 属性(读),用于获得当前缓冲区未读的按键数量,提供 buf_size 属性(读、写),用于获取和设置按键缓冲区大小。
    4. /dev 目录下,创建对应的设备文件(文件名、主设备号、次设备号),可以静态创建,也可以在驱动程序中动态创建。
  2. 其他要求:
    1. 编写一个应用程序来测试上述驱动,读取按键、读取 countbuf_size 等属性;
    2. 可以直接访问对应的 sysfs 下文件,访问 countbuf_size 属性。

二、 原理

  1. 中断指在接收到来自外围硬件的异步信号,或来自软件的同步信号之后,处理器将会进行相应的硬件/软件处理。在内核模块中,通过中断请求(IRQ)来截获键盘中断,并将对应按键的扫描码存储在自定义的缓冲区中。
  2. Linux 中的字符设备提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。相反,此类设备支持按字节/字符来读写数据。举例来说,键盘、串口、调制解调器都是典型的字符设备。通过编写内核模块,动态创建一个字符设备驱动程序,提供相应的 read 函数让用户空间对存储的键盘扫描码进行访问。
  3. Sysfs 是 Linux 2.6 所提供的一种虚拟文件系统。这个文件系统不仅可以把设备(devices)和驱动程序(drivers)的信息从内核输出到用户空间,也可以用来对设备和驱动程序做设置。在驱动程序中实现 sysfs,创建相关属性来读写对模块中的某些全局变量。

三、 核心源程序代码及说明

一开始对一些全局变量进行定义,驱动程序相关的有设备号、字符设备结构体以及对应的类。在截获键盘中断的部分设置了两个无符号字符型变量用于存储键盘扫描码和状态,key_countbuf_size 分别是字符设备驱动程序对应的两个属性,key_buf 是用于存储键盘扫描码的 kfifo 缓冲区队列,最后初始化一个互斥锁、一个自旋锁和一个等待队列:

// Variables for device creation
dev_t dev_id; // 设备号
struct cdev key_dev; // 字符设备结构体
struct class *key_class; // 字符设备对应的类
// Variables for keyboard interrupt
static unsigned char scancode, status; // 键盘扫描码、状态
static int key_count = 0; // 按键次数记录
static int buf_size = 20, original_size; // 存储扫描码的缓冲区大小
static struct kfifo key_buf; // 内核队列
DEFINE_MUTEX(buf_lock); // 互斥锁
DEFINE_SPINLOCK(key_lock); // 自旋锁
static DECLARE_WAIT_QUEUE_HEAD(waitq); // 等待

实现工作队列调度函数,其中获取每次键盘中断的扫描码,并且将按下按键的扫描码存入 kfifo 队列,并将 key_count 自加 1。如果队列满,则将第一个元素出队列。每次检测 buf_size 是否改变,如果改变则更新 original_size,并将原始的 key_buf 释放后重新申请一块内存作为新的 kfifo 队列。最后唤醒等待队列:

void key_work_func(struct work_struct *q) { // 工作队列调度函数
    int ret; // 返回值
    unsigned char code, t;

    spin_lock(&key_lock); // 加上自旋锁
    code = scancode; // 获取当前扫描码
    spin_unlock(&key_lock);

    if (code == 0xe0) { // 某些按键的特征符号
        ;
    } else if (code & 0x80) { // 释放按键
        ;
    } else { // 按下按键
        mutex_lock(&buf_lock);
        if (kfifo_is_full(&key_buf)) {
            kfifo_out(&key_buf, &t, sizeof(unsigned char));
        }
        kfifo_in(&key_buf, (void *)&code, sizeof(unsigned char)); // 将扫描码入队列
        key_count++;
        printk("[DEBUG] code = 0x%x, key_count = %d.\n", code, key_count);
        if (buf_size != original_size) { // 判断缓冲区大小是否改变
            printk("[DEBUG] buf_size = %d (original buf_size = %d).\n", buf_size, original_size);
            original_size = buf_size;
            kfifo_free(&key_buf); // 释放原本的kfifo队列
            ret = kfifo_alloc(&key_buf, buf_size, GFP_ATOMIC); // 重新申请一块内存作为新的队列
            if (ret) {
                printk("[!] Allocate memory failed.\n");
                return ret;
            }
        }
        wake_up_interruptible(&waitq); // 唤醒睡眠进程
        mutex_unlock(&buf_lock);
    }
}

声明工作队列 key_work,定义函数 key_int_handler 来获取每次键盘中断,并在中断下半部调度工作队列:

DECLARE_WORK(key_work, key_work_func); // 声明工作队列

irq_handler_t key_int_handler(int irq, void *dev) {
    spin_lock(&key_lock);
    scancode = inb(0x60); // 获取扫描码
    spin_unlock(&key_lock);

    spin_lock(&key_lock);
    status = inb(0x64); // 获取按键状态
    spin_unlock(&key_lock);

    schedule_work(&key_work); // 调度工作队列
    return (irq_handler_t)IRQ_HANDLED;
}

实现对字符设备驱动程序的 read 函数。先对 count 字段进行判断,然后在等待进程被唤醒且满足给定条件后开始读取扫描码。每次将队列中指定长度的扫描码读出并传递到用户空间:

static ssize_t myread(struct file *filp, char __user *buf, size_t count, loff_t *pos) { // 读设备函数
    unsigned char *c;
    int ret;

    if (count == 0) { // 传入的count值不能为0
        printk("[!] Count can not be 0.\n");
        return -1;
    }
    printk("[DEBUG] kfifo_len = %d.\n", kfifo_len(&key_buf));
    if (wait_event_interruptible(waitq, (kfifo_len(&key_buf) >= count))) // 睡眠并等待唤醒
        return -ERESTARTSYS;
    c = (unsigned char *)kmalloc(count, GFP_KERNEL); // 申请一块内存

    mutex_lock(&buf_lock);
    kfifo_out(&key_buf, c, count); // 将指定长度扫描码数据出队列
    mutex_unlock(&buf_lock);

    printk("[+] Copy buffer to user: %s.\n", c);
    ret = copy_to_user(buf, c, count); // 将出队列的扫描码传给用户空间
    kfree(c); // 释放申请的内存空间
    c = 0; // 防止UAF
    return count;
}

字符设备驱动程序对应的 openrelease 函数都不做任何额外的操作,最后初始化一个全局结构体 key_fops,即用于存储设备驱动程序的三种操作:

static int myopen(struct inode *inode, struct file *filp) { // 打开设备函数
    printk("[+] Device opened.\n");
    return 0;
}

static int myrelease(struct inode *inode, struct file *filp) { // 释放设备函数
    printk("[+] Device released.\n");
    return 0;
}

struct file_operations key_fops = { // 初始化文件访问操作函数
    .open = myopen,
    .read = myread,
    .release = myrelease,
};

为了实现 key_countbuf_size 两个属性,需要分别定义两个属性的读写函数:

static ssize_t key_count_show(struct device *o, struct device_attribute *attr, char *buf) { // 属性key_count的读函数
    return sprintf(buf, "%d\n", key_count);
}

static ssize_t key_count_store(struct device *o, struct device_attribute *attr, const char *buf, size_t count) { // 属性key_count的写函数
    sscanf(buf, "%d", &key_count);
    return count;
}

static ssize_t buf_size_show(struct device *o, struct device_attribute *attr, char *buf) { // 属性buf_size的读函数
    return sprintf(buf, "%d\n", buf_size);
}

static ssize_t buf_size_store(struct device *o, struct device_attribute *attr, const char *buf, size_t count) { // 属性buf_size的写函数
    sscanf(buf, "%d", &buf_size);
    return count;
}

// https://blog.csdn.net/njuitjf/article/details/16849333
static DEVICE_ATTR(key_count, 0444, key_count_show, key_count_store); // 创建字符设备属性key_count
static DEVICE_ATTR(buf_size, 0664, buf_size_show, buf_size_store); // 创建字符设备属性buf_size

接下来定义一个 attribute 结构体 key_attrs,存入两个全局变量对应的属性,并创建一个 attribute_group 结构体 key_group 存储 key_attrs

static struct attribute *key_attrs[] = {
    &dev_attr_key_count.attr,
    &dev_attr_buf_size.attr,
    NULL,
};

static const struct attribute_group key_group = {
    .attrs = key_attrs,
};

在模块的入口函数中,先创建一个 kfifo 队列,用于存储键盘扫描码;然后注册中断请求服务,并设置函数 key_int_handler 作为中断服务函数:

    // Allocate kfifo
    printk("[DEBUG] buf_size = %d.\n", buf_size);
    original_size = buf_size; // 存储原始缓冲区大小
    ret = kfifo_alloc(&key_buf, buf_size, GFP_ATOMIC); // 申请kfifo队列
    if (ret) {
        printk("[!] Allocate memory failed.\n");
        return ret;
    }
    printk("[+] Allocate kfifo successfully.\n");
    // Trigger interrupt
    ret = request_irq(1, (irq_handler_t)key_int_handler, IRQF_SHARED, "Key Hook", (void *)key_int_handler); // 申请IRQ
    if (ret) {
        printk("[!] Request irq failed.\n");
        return ret;
    }
    printk("[+] Request irq successfully.\n");

接下来按顺序分别分配设备编号、初始化字符设备、添加字符设备到系统中、创建一个类并注册到内核中以及创建一个设备驱动程序并注册到 sysfs 中。最后通过 sysfs 借口创建驱动程序对应的两个属性。中间如果有发生错误,就会依次删除先前创建的内容:

    // Register devices
    printk("[*] Invoke alloc_chrdev_region.\n");
    ret = alloc_chrdev_region(&dev_id, 0, 1, "ex5"); // 动态分配设备编号
    if (ret >= 0) {
        printk("[*] Invoke cdev_init.\n");
        cdev_init(&key_dev, &key_fops); // 初始化字符设备
        key_dev.owner = THIS_MODULE; // 设置实现驱动的模块为当前模块
        printk("[*] Invoke cdev_add.\n");
        ret = cdev_add(&key_dev, dev_id, 1); // 添加字符设备到系统中
        if (ret >= 0) {
            printk("[*] Invoke class_create.\n");
            key_class = class_create(THIS_MODULE, "ex5"); // 创建一个类并注册到内核中
            if (key_class) {
                printk("[*] Invoke device_create.\n");
                dev = device_create(key_class, NULL, dev_id, NULL, "ex5"); // 创建一个设备并注册到sysfs中
                if (dev) {
                    printk("[*] Invoke sysfs_create_group.\n");
                    // http://linux-kernel.2935.n7.nabble.com/kernel-BUG-at-fs-sysfs-group-c-65-td612891.html
                    printk("[DEBUG] struct device *dev->kobj = %p.\n", &dev->kobj);
                    ret = sysfs_create_group(&dev->kobj, &key_group); // 通过sysfs接口创建驱动对应的属性
                    if (ret == 0) { // 创建成功后直接跳转并返回0
                        goto success;
                    } else {
                        printk("[!] Invoke sysfs_create_group failed.\n");
                        device_destroy(key_class, dev_id); // 删除设备
                    }
                } else {
                    printk("[!] Invoke device_create failed.\n");
                    class_destroy(key_class); // 删除类
                }
            } else {
                printk("[!] Invoke class_create failed.\n");
            }
            cdev_del(&key_dev); // 删除字符设备
        } else {
            printk("[!] Invoke cdev_add failed.\n");
        }
        unregister_chrdev_region(dev_id, 1); // 释放设备编号
        return ret;
    }
failed:
    printk("[!] Invoke alloc_chrdev_region failed.\n");
    return ret;
success:
    printk("[+] Create charactor device successfully.\n");
    return 0;

最后在模块退出时分别释放 IRQ、释放 kfifo 队列、删除设备驱动程序、删除类、删除字符设备以及释放设备编号:

static void __exit hello_exit(void) { // 模块出口函数
    printk("========== [+] Remove module. ==========\n");

    printk("[*] Free irq.\n");
    free_irq(1, (void *)key_int_handler); // 释放IRQ
    printk("[*] Free kfifo.\n");
    kfifo_free(&key_buf); // 释放队列
    printk("[*] Invoke device_destroy.\n");
    device_destroy(key_class, dev_id); // 删除设备
    printk("[*] Invoke class_destroy.\n");
    class_destroy(key_class); // 删除类
    printk("[*] Invoke cdev_del.\n");
    cdev_del(&key_dev); // 删除字符设备
    printk("[*] Invoke unregister_chrdev_region.\n");
    unregister_chrdev_region(dev_id, 1); // 释放设备编号
}

在用户空间编写应用程序来读取驱动程序中存储的扫描码:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

#define BUF_SIZE 0x40 // 设置缓冲区

int main() {
    int fd, c, i;
    unsigned char buf[BUF_SIZE]; // 初始化缓冲区

    // sudo mknod /dev/ex4 c 444 0
    fd = open("/dev/ex5", O_RDONLY); // 打开字符设备
    if (fd < 0) {
        printf("[!] File open error.\n");
        return -1;
    }
    while (1) {
        c = read(fd, buf, BUF_SIZE); // 读取字符设备中的扫描码
        printf("[+] Read scancode: %s (%d).\n", buf, c); // 把扫描码以字符串的形式输出
    }
    close(fd); // 关闭字符设备
    return 0;
}

四、 程序测试及结果

安装模块后,看到字符设备驱动程序已经正常创建,且开始存储键盘的扫描码:

在运行编写好的用户程序,看到从 kfifo 队列中读取出了指定长度的字节:

查看 key_count 的值,使用 dmesg 查看得到 key_count 为 113,然后输入回车查看 /sys/class 下的存储的驱动程序属性 key_count 为 114,正好时输入了回车后自加了 1:

查看此时 buf_size 的值为 20,然后修改其值为 40(因为 kfifo 的申请会将传入的长度自动对其到 2 的幂次方,故原本的 kfifo 队列长度应该为 32,修改后的长度变为 64):

此时查看输出 buf_size 已改变:

再修改用户应用程序中的 BUF_SIZE 为 0x40,也可以正常读取到:

如果再把驱动程序中的 buf_size 改小,就会导致 kfifo 队列中的元素最多为 32 个(buf_size 为 20 会自动向上对齐到 2 的幂次方),而应用程序中必须要读 0x40 个字符,永远达到不了标准,就会导致永远读取不到数据:

五、 总结

在这次的实验中,熟悉了字符设备驱动程序以及 sysfs,并且将先前学过的内核数据结构、中断、进程调度、进程同步等相关知识结合,对 Linux 内核有了更深的理解。

附录:代码

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kfifo.h>
#include <linux/workqueue.h>
#include <linux/interrupt.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("assassinq");
MODULE_DESCRIPTION("create char device module");

// Variables for device creation
dev_t dev_id; // 设备号
struct cdev key_dev; // 字符设备结构体
struct class *key_class; // 字符设备对应的类
// Variables for keyboard interrupt
static unsigned char scancode, status; // 键盘扫描码、状态
static int key_count = 0; // 按键次数记录
static int buf_size = 20, original_size; // 存储扫描码的缓冲区大小
static struct kfifo key_buf; // 内核队列
DEFINE_MUTEX(buf_lock); // 互斥锁
DEFINE_SPINLOCK(key_lock); // 自旋锁
static DECLARE_WAIT_QUEUE_HEAD(waitq); // 等待

void key_work_func(struct work_struct *q) { // 工作队列调度函数
    int ret; // 返回值
    unsigned char code, t;

    spin_lock(&key_lock); // 加上自旋锁
    code = scancode; // 获取当前扫描码
    spin_unlock(&key_lock);

    if (code == 0xe0) { // 某些按键的特征符号
        ;
    } else if (code & 0x80) { // 释放按键
        // printk("In work: released \"%s\"\n", mappings[code - 0x80]);
    } else { // 按下按键
        // printk("In work: pressed \"%s\"\n", mappings[code]);
        mutex_lock(&buf_lock);
        // if (kfifo_is_full(&key_buf)) {
        //     kfifo_out(&key_buf, &t, sizeof(unsigned char));
        // }
        kfifo_in(&key_buf, (void *)&code, sizeof(unsigned char)); // 将扫描码入队列
        key_count++;
        printk("[DEBUG] code = 0x%x, key_count = %d.\n", code, key_count);
        if (buf_size != original_size) { // 判断缓冲区大小是否改变
            printk("[DEBUG] buf_size = %d (original buf_size = %d).\n", buf_size, original_size);
            original_size = buf_size;
            kfifo_free(&key_buf); // 释放原本的kfifo队列
            ret = kfifo_alloc(&key_buf, buf_size, GFP_ATOMIC); // 重新申请一块内存作为新的队列
            if (ret) {
                printk("[!] Allocate memory failed.\n");
                return ret;
            }
        }
        wake_up_interruptible(&waitq); // 唤醒睡眠进程
        mutex_unlock(&buf_lock);
    }
}

DECLARE_WORK(key_work, key_work_func); // 声明工作队列

irq_handler_t key_int_handler(int irq, void *dev) {
    spin_lock(&key_lock);
    scancode = inb(0x60); // 获取扫描码
    spin_unlock(&key_lock);

    spin_lock(&key_lock);
    status = inb(0x64); // 获取按键状态
    spin_unlock(&key_lock);

    // printk("Key interrupt: scancode = 0x%x, status = 0x%x\n", scancode, status);
    schedule_work(&key_work); // 调度工作队列
    return (irq_handler_t)IRQ_HANDLED;
}

static int myopen(struct inode *inode, struct file *filp) { // 打开设备函数
    printk("[+] Device opened.\n");
    return 0;
}

static ssize_t myread(struct file *filp, char __user *buf, size_t count, loff_t *pos) { // 读设备函数
    unsigned char *c;
    int ret;

    if (count == 0) { // 传入的count值不能为0
        printk("[!] Count can not be 0.\n");
        return -1;
    }
    printk("[DEBUG] kfifo_len = %d.\n", kfifo_len(&key_buf));
    if (wait_event_interruptible(waitq, (kfifo_len(&key_buf) >= count))) // 睡眠并等待唤醒
        return -ERESTARTSYS;
    c = (unsigned char *)kmalloc(count, GFP_KERNEL); // 申请一块内存

    mutex_lock(&buf_lock);
    kfifo_out(&key_buf, c, count); // 将指定长度扫描码数据出队列
    mutex_unlock(&buf_lock);

    printk("[+] Copy buffer to user: %s.\n", c);
    ret = copy_to_user(buf, c, count); // 将出队列的扫描码传给用户空间
    kfree(c); // 释放申请的内存空间
    c = 0; // 防止UAF
    return count;
}

static int myrelease(struct inode *inode, struct file *filp) { // 释放设备函数
    printk("[+] Device released.\n");
    return 0;
}

struct file_operations key_fops = { // 初始化文件访问操作函数
    .open = myopen,
    .read = myread,
    .release = myrelease,
};

static ssize_t key_count_show(struct device *o, struct device_attribute *attr, char *buf) { // 属性key_count的读函数
    return sprintf(buf, "%d\n", key_count);
}

static ssize_t key_count_store(struct device *o, struct device_attribute *attr, const char *buf, size_t count) { // 属性key_count的写函数
    sscanf(buf, "%d", &key_count);
    return count;
}

static ssize_t buf_size_show(struct device *o, struct device_attribute *attr, char *buf) { // 属性buf_size的读函数
    return sprintf(buf, "%d\n", buf_size);
}

static ssize_t buf_size_store(struct device *o, struct device_attribute *attr, const char *buf, size_t count) { // 属性buf_size的写函数
    sscanf(buf, "%d", &buf_size);
    return count;
}

// https://blog.csdn.net/njuitjf/article/details/16849333
static DEVICE_ATTR(key_count, 0444, key_count_show, key_count_store); // 创建字符设备属性key_count
static DEVICE_ATTR(buf_size, 0664, buf_size_show, buf_size_store); // 创建字符设备属性buf_size

static struct attribute *key_attrs[] = {
    &dev_attr_key_count.attr,
    &dev_attr_buf_size.attr,
    NULL,
};

static const struct attribute_group key_group = {
    .attrs = key_attrs,
};

static int __init hello_init(void) { // 模块入口函数
    int ret;
    struct device *dev;
    printk("========== [+] Init module. ==========\n");

    // ret = register_chrdev(444, "ex4HookDriver", &key_fops);
    // if (ret) {
    //     printk("[!] Unable to register character device.\n");
    //     return ret;
    // }

    // Allocate kfifo
    printk("[DEBUG] buf_size = %d.\n", buf_size);
    original_size = buf_size; // 存储原始缓冲区大小
    ret = kfifo_alloc(&key_buf, buf_size, GFP_ATOMIC); // 申请kfifo队列
    if (ret) {
        printk("[!] Allocate memory failed.\n");
        return ret;
    }
    printk("[+] Allocate kfifo successfully.\n");
    // Trigger interrupt
    ret = request_irq(1, (irq_handler_t)key_int_handler, IRQF_SHARED, "Key Hook", (void *)key_int_handler); // 申请IRQ
    if (ret) {
        printk("[!] Request irq failed.\n");
        return ret;
    }
    printk("[+] Request irq successfully.\n");
    // Register devices
    printk("[*] Invoke alloc_chrdev_region.\n");
    ret = alloc_chrdev_region(&dev_id, 0, 1, "ex5"); // 动态分配设备编号
    if (ret >= 0) {
        printk("[*] Invoke cdev_init.\n");
        cdev_init(&key_dev, &key_fops); // 初始化字符设备
        key_dev.owner = THIS_MODULE; // 设置实现驱动的模块为当前模块
        printk("[*] Invoke cdev_add.\n");
        ret = cdev_add(&key_dev, dev_id, 1); // 添加字符设备到系统中
        if (ret >= 0) {
            printk("[*] Invoke class_create.\n");
            key_class = class_create(THIS_MODULE, "ex5"); // 创建一个类并注册到内核中
            if (key_class) {
                printk("[*] Invoke device_create.\n");
                dev = device_create(key_class, NULL, dev_id, NULL, "ex5"); // 创建一个设备并注册到sysfs中
                if (dev) {
                    printk("[*] Invoke sysfs_create_group.\n");
                    // http://linux-kernel.2935.n7.nabble.com/kernel-BUG-at-fs-sysfs-group-c-65-td612891.html
                    printk("[DEBUG] struct device *dev->kobj = %p.\n", &dev->kobj);
                    ret = sysfs_create_group(&dev->kobj, &key_group); // 通过sysfs接口创建驱动对应的属性
                    if (ret == 0) { // 创建成功后直接跳转并返回0
                        goto success;
                    } else {
                        printk("[!] Invoke sysfs_create_group failed.\n");
                        device_destroy(key_class, dev_id); // 删除设备
                    }
                } else {
                    printk("[!] Invoke device_create failed.\n");
                    class_destroy(key_class); // 删除类
                }
            } else {
                printk("[!] Invoke class_create failed.\n");
            }
            cdev_del(&key_dev); // 删除字符设备
        } else {
            printk("[!] Invoke cdev_add failed.\n");
        }
        unregister_chrdev_region(dev_id, 1); // 释放设备编号
        return ret;
    }
failed:
    printk("[!] Invoke alloc_chrdev_region failed.\n");
    return ret;
success:
    printk("[+] Create charactor device successfully.\n");
    return 0;
}

static void __exit hello_exit(void) { // 模块出口函数
    printk("========== [+] Remove module. ==========\n");

    printk("[*] Free irq.\n");
    free_irq(1, (void *)key_int_handler); // 释放IRQ
    printk("[*] Free kfifo.\n");
    kfifo_free(&key_buf); // 释放队列
    printk("[*] Invoke device_destroy.\n");
    device_destroy(key_class, dev_id); // 删除设备
    printk("[*] Invoke class_destroy.\n");
    class_destroy(key_class); // 删除类
    printk("[*] Invoke cdev_del.\n");
    cdev_del(&key_dev); // 删除字符设备
    printk("[*] Invoke unregister_chrdev_region.\n");
    unregister_chrdev_region(dev_id, 1); // 释放设备编号
}

module_init(hello_init);
module_exit(hello_exit);

linux

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

工控安全基础概念初探
自定义CTFd颜色主题