此内核教程部分内容是翻译sysprog21/lkmpg而来, 本教程中的所有代码示例编译测试机器为: 6.+内核的安同系统与5.10.+内核的2K0300蜂鸟板, 如有纰漏欢迎指正。
学习编写驱动前,  需要您对Linux内核有最基本的了解, 至少会自行编译、替换、配置Linux内核。
目录索引:
- 1 准备工作
 
- 2 编写驱动(字符型驱动)
 
- [未发布] 3 驱动相关文件
 
- [未发布] 4 ioctl
 
- [未发布] 5 系统调用
 
- [未发布] 6 阻塞进程和线程
 
- [未发布] 7 内核模块的锁
 
- [未发布] 8 驱动与用户交互
 
- [未发布] 9 调度与中断
 
- [未发布] 10 设备驱动
 
- [未发布] 11 优化与常见问题
 
字符设备驱动程序
注: Linux驱动整体可以分为如下三类, 本书主要已字符型驱动为主, 后续我会编写其他内核教程来阐述块设备和网络设备的驱动
字符设备: 以字节为单位顺序访问的设备驱动程序。它们通常没有缓存,并且数据必须按照先后顺序进行读取或写入。字符设备的例子包括鼠标、键盘、串口等。这些设备的特点是处理的数据流是线性的,即数据的读写操作需要按顺序进行。 
块设备:块设备的读写都有缓存来支持,并且块设备必须能够随机存取。块设备主要包括硬盘、软盘设备和CD-ROM等。一个文件系统要安装进入操作系统必须在块设备上。块设备的驱动程序负责管理设备的存储空间和数据传输。它们通常使用定长的数据块来存储和访问数据,因此被称为块设备。块设备的驱动程序通常包含设备的启动和停止、数据的读写和错误处理等功能。 
网络设备:网络设备是Linux中用于实现网络通信的设备类型。网络设备在Linux中被专门处理,基于BSD Unix的socket机制。系统中支持对发送数据和接收数据的缓存,提供流量控制机制,提供对多协议的支持。一个设备可以属于多种设备驱动类型,比如USB WIFI,其使用USB接口,所以属于字符设备,但是其又能上网,所以也属于网络设备驱动。网络设备的驱动程序主要负责实现网络协议栈和网络接口的初始化和管理。它们通常包含设备的启动和停止、数据的发送和接收等功能。在网络设备的驱动程序中,还需要处理网络协议的数据包和报文,以及网络通信中的各种问题,如数据包的丢失、重复和乱序等。
文件操作结构
在 Linux 内核中, struct file_operations 这个结构体(常被简称为fops)用于定义了一个文件或设备可以执行的操作, 它包含了多个函数指针, 这些函数指针指向不同的操作函数, 这些操作函数负责实现与文件相关的系统调用(如读取、写入、打开等), 可以在内核源码:include/linux/fs.h 找到此结构体的定义。
每个字符驱动程序都必然需要此函数来进行文件操作。如下是内核6.11的定义的file_operations:
注:上述链接会指向最新的稳定内核,file_operations函数可能会和下述有所区别
struct file_operations {
	struct module *owner;
	fop_flags_t fop_flags;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
	ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
	int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
			unsigned int flags);
	int (*iterate_shared) (struct file *, struct dir_context *);
	__poll_t (*poll) (struct file *, struct poll_table_struct *);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, loff_t, loff_t, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
	void (*splice_eof)(struct file *file);
	int (*setlease)(struct file *, int, struct file_lease **, void **);
	long (*fallocate)(struct file *file, int mode, loff_t offset,
			  loff_t len);
	void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
	unsigned (*mmap_capabilities)(struct file *);
#endif
	ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
			loff_t, size_t, unsigned int);
	loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
				   struct file *file_out, loff_t pos_out,
				   loff_t len, unsigned int remap_flags);
	int (*fadvise)(struct file *, loff_t, loff_t, int);
	int (*uring_cmd)(struct io_uring_cmd *ioucmd, unsigned int issue_flags);
	int (*uring_cmd_iopoll)(struct io_uring_cmd *, struct io_comp_batch *,
				unsigned int poll_flags);
} __randomize_layout;
注: 属性说明
struct module *owner: 表示文件操作所属模块的指针。 
fop_flags_t fop_flags: 文件操作的标志。 
llseek: 用于设置文件指针的位置。 
read: 从文件中读取数据。 
write: 向文件中写入数据。 
read_iter: 通过 io_uring 实现高效的文件读取。 
write_iter: 基于 io_uring 执行异步写入操作。 
iopoll: io_uring 的轮询机制,用于高效地检查 I/O 操作是否完成。 
iterate_shared: 用于遍历文件系统中的目录,返回目录项信息。 
poll: 用于多路复用机制的文件描述符事件监视。用于实现 select、poll 等系统调用,检查文件的可读、可写或异常事件。 
unlocked_ioctl: 处理不需要锁的 I/O 控制命令。这是 ioctl 系统调用的实现,用于执行设备控制命令。 
compat_ioctl: 处理兼容模式下的 I/O 控制命令, 用于 32 位应用程序与 64 位内核之间的兼容性。 
mmap: 映射文件到内存。该函数将文件映射到进程的虚拟地址空间。 
open: 打开文件时的处理函数, 进行文件状态初始化等操作。 
flush: 刷新文件的内容到磁盘, 用于刷新文件缓存。 
release: 在文件被关闭时调用, 用于清理资源。 
fsync: 将文件的数据同步到磁盘, 确保数据持久化。 
fasync: 将文件异步通知与信号处理相关联,用于异步 I/O 操作。 
get_unmapped_area: 获取映射文件时的虚拟内存地址。为文件映射(mmap)操作提供合适的虚拟内存区域。如果映射无法进行, 它会返回一个错误码。通常在没有 CONFIG_MMU 时使用。 
check_flags: 检查文件的打开标志, 通常在文件操作前验证文件是否具备某些条件或状态。 
flock: 对文件执行锁定操作。实现文件锁定的功能, flock 系统调用通常通过此函数完成, 支持共享锁和独占锁等类型。 
splice_write: 将数据从内核中的管道传输到文件,类似于文件系统的写入操作,但涉及到管道和文件之间的数据传输。 
splice_read: 将文件数据读取到管道的功能。通过 splice 技术, 可以实现零拷贝数据传输, 减少内存拷贝的开销。 
splice_eof: 处理文件末尾的特定操作, 通常与 splice 相关联, 例如管道数据处理结束后需要调用该函数。 
setlease: 设置文件租赁, 允许进程声明对某个文件的独占访问权或共享访问权。文件租赁可用于实现文件锁或避免文件内容修改。 
fallocate: 为文件分配磁盘空间, 通常与文件系统相关, 确保文件在进行写入操作时有足够的空间。 
show_fdinfo: 输出文件描述符的相关信息,通常在 /proc 文件系统中用来显示文件的详细状态。 
mmap_capabilities: 用于检查文件是否支持内存映射(mmap)。在没有内存管理单元(MMU)的情况下,映射能力会有所不同。 
copy_file_range: 在两个文件之间直接复制数据,而不需要将数据读取到用户空间。 
remap_file_range: 在两个文件之间直接映射数据块。不同于 copy_file_range,它不涉及对数据的复制,而是通过内核机制直接将源文件的一个区域映射到目标文件的对应区域。 
fadvise: 为文件访问提供建议,帮助内核优化文件的缓存和 I/O 调度。 
uring_cmd: 通过 io_uring 提交一个 I/O 操作命令, 在不阻塞应用程序的情况下处理大量 I/O 请求, 适用于高并发、低延迟的 I/O 请求场景。 
uring_cmd_iopoll: 通过 io_uring 执行 I/O 操作的完成轮询。
只需传入想要进行的操作的函数指针即可, 不需要的参数可以设置为NULL。调用file_operations传参的方式有两种, 其中一种是基于GCC扩展实现的类似高级语言中的结构体赋值操作如下:
struct file_operations fops = {
	read: device_read,
	write: device_write,
	open: device_open,
	release: device_release
};
以及基于C99标准的方式来分配结构元素, 即指定初始化器(designated initializers), 与上述使用GNU扩展相比这种基于C99的分配方法适用性更加广泛。使用此语法有助于增加驱动的兼容性:
 
struct file_operations fops = {
	.read = device_read,
	.write = device_write,
	.open = device_open,
	.release = device_release
};
没有显式分配的结构的任何成员都将由GCC初始化为 NULL。
在 Linux 内核 3.14 版本开始,引入了一种机制,确保对 f_pos 位置的访问是互斥的,也就是说,通过使用锁来防止多个线程或进程同时修改该位置,确保在并发环境下文件的读取、写入和寻址操作不会发生冲突。
注:  
文件的读写操作通常涉及一个文件位置指针(f_pos),它记录了文件或设备当前的读取或写入位置。多线程或多进程环境下,多个线程/进程对同一个文件进行读写时,需要确保这些操作是线程安全的,以避免出现竞态条件(race condition)和数据不一致问题。
从 Linux 5.6 开始,内核为 /proc 文件系统引入了一个新的结构体 proc_ops,来取代原来用于文件操作的 file_operations 结构,以更好地支持 /proc 文件系统的特殊操作需求, /proc相关模块我们后续会详细讲解。
注:  
/proc 文件系统提供了一种机制,使得用户可以访问和操作内核中有关系统、进程、硬件等信息的虚拟文件。/proc 中的每个文件(如 /proc/cpuinfo、/proc/meminfo)通常都与特定的内核数据或操作有关,用户程序可以通过读取或写入这些文件与内核交互。
  
- 旧的机制(
file_operations):以前,/proc 文件系统的操作是通过 file_operations 结构来实现的。这意味着,每个 /proc 文件的操作(如读取、写入、打开等)都需要通过 file_operations 结构中的相应函数来进行定义。   
- 新的机制(
proc_ops):从 Linux 5.6 版本开始,内核引入了一个新的结构体 proc_ops 来代替 file_operations。这种变化的目的可能是为了更好地适应 /proc 文件系统的特殊需求,并简化一些与 /proc 文件操作相关的代码。 
文件结构
每个设备在内核中由一个 文件结构对象(struct file object) (通常被简称为: filp)表示, 该结构在include/linux/fs.h中定义。
这里的文件是内核级结构, 从不出现在用户空间程序中。他与我们常见的由glibc定义FILE是不同的, 由glibc定义的FILE不会出现在内核空间函数中。
内核中的 文件结构对象(struct file object) 是一个抽象的概念,它用于表示一个已打开的文件或设备。在内核中,每个设备、文件或虚拟文件都可以通过一个 文件结构对象(struct file object) 来进行管理。当应用程序打开一个文件时,内核会创建一个 文件结构对象(struct file object) 来表示这个文件,并将其与文件系统中的某个 节点(inode) 关联起来。
注: 在文件系统中,节点(inode) 结构用于表示磁盘上的文件或目录的信息,它包含了文件的元数据(如大小、权限、创建时间等)。内核中的 文件结构对象(struct file object) 是通过 节点(inode) 来管理文件的,它们通过指针互相关联。
static inline struct inode *file_inode(const struct file *f)
{
	return f->f_inode;
}
filp是由文件系统相关的代码(如虚拟文件系统 VFS)来创建和管理的,设备驱动程序并不直接参与filp的创建和填充。驱动程序通过文件系统提供的接口来间接操作文件,而不是直接操作filp结构体中的数据。
注册设备
字符设备通常通过 /dev 目录下的设备文件来进行访问。虽然在开发或测试阶段,可以将设备文件放置在当前目录中进行调试,但在生产环境中,应将其放置在 /dev 目录下,以遵循系统的约定和标准。驱动程序通过 主编号(Major Number) 来识别应该调用哪个设备驱动程序进行操作,次编号(Minor Number) 则用于驱动程序内部来区分该驱动程序控制的不同设备。在一个驱动程序管理多个设备时,次号可以用来标识它正在操作的是哪一个设备。
向系统中添加驱动程序意味着向内核注册它。这等同于在模块初始化期间为其分配主编号。你可以通过使用register_chrdev函数来实现此需求, 该函数由include/linux/fs.h定义。
static inline int register_chrdev(unsigned int major, const char *name,
				  const struct file_operations *fops)
- 参数说明: 成功返回分配的主设备号, 如果是负值则表示注册失败。
  
major: 设备的主编号, 如果设置为 0, 内核会自动为该设备分配一个主设备号。 
  name: 设备的名称, 这个名字用于在 /dev 目录下创建设备文件。内核会将这个名字与设备文件相关联(会写入/proc/devices中)。 
  fops: 指向 file_operations 结构体的指针,file_operations 结构体定义了设备的文件操作函数。这些函数描述了对设备进行的各种操作,比如打开、关闭、读、写、ioctl等。
 
内核不关心次编号, 不需要将其传入register_chrdev, 只有驱动程序才需要区分次编号。
主设备号处理
如果你不想自动分配主设备号可以查看Documentation/admin-guide/devices.txt文件来来选择一个未被使用的编号。但这种做法并不十分推荐。
动态分配设备号能避免因为内核更新导致设备号出现占用冲突的问题, 但随着而来会导致无法提前知道创建的设备号, 从而导致无法提前创建设备文件。解决这个问题有如下几种方式:
- 打印分配的主设备号: 驱动程序可以在 register_chrdev 成功返回后,打印出分配到的主设备号,这样你可以知道分配到的具体设备号。
int major = register_chrdev(0, "我的驱动", &my_device_fops);
if (major < 0) {
    printk(KERN_WARNING "注册驱动失败\n");
} else {
    printk(KERN_INFO "分配的主设备号: %d\n", major);
}
 
- 通过脚本创建设备文件: 你可以编写一个 shell 脚本来创建设备文件。这个脚本可以读取 
/proc/devices 文件中的设备信息(主设备号),然后使用 mknod 创建设备文件。
#!/bin/bash
major=$(grep "我的驱动" /proc/devices | awk '{print \$1}')
mknod /dev/我的驱动 c $major 0
 
- 使用 
device_create 和 device_destroy 自动创建设备文件: 驱动程序可以在 register_chrdev 成功注册之后,使用 device_create 来自动创建设备文件。这样,设备文件就可以通过内核代码动态创建,而不需要手动或通过脚本来操作。
dev_t dev;
int major = register_chrdev(0, "我的驱动", &my_device_fops);
if (major < 0) {
    printk(KERN_WARNING "注册驱动失败\n");
    return major;
}
dev = MKDEV(major, 0);
device_create(my_class, NULL, dev, NULL, "我的驱动");
 
使用第三种方法, 卸载模块的时候可以使用device_destroy(my_class, dev);的方式来销毁设备文件
现代注册方式-cdev
register_chrdev() 是一个旧接口, 会一次性占用该主设备号下的所有次设备号, 这可能导致资源浪费,尤其是在你只需要部分次设备号时。为了减少字符设备注册时的资源浪费, 推荐使用更现代的cdev接口。
注: 许多早期的驱动程序和代码依赖于 register_chrdev(), 因此, 如果直接淘汰这个接口,会导致大量现有代码无法工作。为了避免破坏已有的代码和驱动程序, Linux 保留了这个接口, 即使它被认为是较为过时的做法。而且 register_chrdev() 提供了一个相对简单的接口来注册字符设备, 它只需要一个主设备号、设备名称和一些设备操作。对于一些简单的设备驱动或者不需要复杂功能的场景, 这个接口就足够使用了。它不需要额外的 cdev 结构体管理, 也不需要复杂的设备号范围管理, 这使得它在某些情况下仍然很方便。
新接口通过两个步骤完成字符设备的注册。首先注册一系列设备号, 可以用 register_chrdev_region 或 alloc_chrdev_region 来完成。
int register_chrdev_region(dev_t from, unsigned count, const char *name);
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
注: 函数介绍说明
register_chrdev_region: 注册一个连续的主设备号范围。它分配并初始化一个或多个字符设备的主设备号, 并将这些设备号与指定的名称关联起来。成功时返回0, 失败时返回负的错误码。
   - from: 起始设备号(主设备号)。这是一个 dev_t 类型的值, 通常由宏 MKDEV(major, minor) 生成, 其中 major 是主设备号, minor 是次设备号。
 
   - count: 要注册的设备数量。
 
   - name: 设备名称, 用于在 
/proc/devices 中显示。 
 
alloc_chrdev_region: 分配一个连续的主设备号范围, 并且可以选择性地分配次设备号。它不仅分配主设备号, 还初始化这些设备号, 并将它们与指定的名称关联起来。成功时返回0, 失败时返回负的错误码。
   - dev: 指向 dev_t 类型变量的指针, 用于存储分配到的编号。
 
   - baseminor: 次编号的基数。如果为0,则内核会自动分配次编号。
 
   - count: 要分配的设备数量。
 
   - name: 设备名称,用于在 
/proc/devices 中显示。 
- 当你需要为设备分配一个固定的主设备号时, 可以使用 
register_chrdev_region(), 这通常在你知道设备号不会改变的情况下使用, 例如某些标准设备(如控制台、虚拟终端等), 对于简单的驱动程序,或者当设备数量较少且固定时,其可以简化代码。 
- 当你希望内核自动为你分配主设备号时, 可以使用 
alloc_chrdev_region()。这在设备数量不确定或需要灵活分配设备号的情况下非常有用。对于更复杂的驱动程序, 特别是那些需要支持热插拔或动态加载卸载的设备, 其提供了更大的灵活性。 
其次, 我们应该初始化字符驱动的数据结构 struct cdev 并将其与设备号相关联。初始化 struct cdev 的代码序列可以参照以下示例来实现。
struct cdev *my_dev = cdev_alloc();
my_cdev->ops = &my_fops;
通常的做法是将 struct cdev 嵌入到设备特定的结构中。在这种情况下, 我们需要使用 cdev_init 来进行初始化。
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
一旦完成初始化, 就可以使用cdev_add将字符驱动添加到系统中。
int cdev_add(struct cdev *p, dev_t dev, unsigned count);
注:
cdev_add 参数说明:
- p: 指向要添加的 cdev 结构体的指针。这个结构体包含了字符设备的相关信息和操作函数。
 
- dev: 设备号,表示字符设备的主编号和次编号。
 
- count: 要添加的设备数量。对于大多数字符设备来说, 这个值通常是1, 但对于某些多设备的情况, 比如磁盘阵列这个值可能会大于1。
static struct cdev my_cdev;
static dev_t dev_num;
static int __init my_module_init(void) {
    int ret;
    
    ret = alloc_chrdev_region(&dev_num, 0, 1, "我的字符驱动");
    if (ret < 0) {
        printk(KERN_ALERT "分配字符设备区域失败\n");
        return ret;
    }
    
    cdev_init(&my_cdev, &fops); 
    my_cdev.owner = THIS_MODULE;
    my_cdev.ops = &fops;
    
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ALERT "添加字符设备失败\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    printk(KERN_INFO "用主编号添加字符设备 %d\n", MAJOR(dev_num));
    return 0;
}
 
后续的 ioctl.c 实例会详细讲解此驱动注册方式。
注销设备
我们不能让内核模块在root的情况下随意使用rmmod删除模块。如果有一个进程打开了设备文件, 然后我们移除了内核模块, 再使用该文件将会导致调用已删除函数(如读/写)的内存位置。如果幸运的话, 那里没有加载其他代码, 我们将收到一条错误消息。如果我们运气不好, 另一个内核模块被加载到相同的位置, 这意味着跳转到内核中另一个函数的中间。这种情况的结果是无法预测的, 但肯定不会有什么好结果。
在不想允许某些操作时, 应该从尝试执行该操作的函数返回错误代码(一个负数)。但 cleanup_module 是一个无返回值的函数。不过有一个计数器可以跟踪有多少进程正在使用你的模块。你可以通过执行命令 cat /proc/modules 或 sudo lsmod 查看该计数器的值, 它显示在第三个字段。该数字不为零的时候rmmod将执行失败。
需要注意的是我们不需要检查cleanup_module这个计数器, 因为检查将由在include/linux/syscalls.h中定义的系统调用sys_delete_module中执行。虽然不建议直接使用这个计数器, 但在 include/linux/module.h 中定义了一些函数, 允许手动增加、减少和显示这个计数器的值:
try_module_get(THIS_MODULE): 增加当前模块的引用计数 
module_put(THIS_MODULE): 减少当前模块的引用计数 
module_refcount(THIS_MODULE): 返回当前模块的引用计数的值
保持计数器的准确性是很重要的时期, 如果让计数器数值出现了异常可能会导致模块永远无法被卸载了, 碰到这种情况了那只能靠重启来解决问题了。不过在模块开发过程中, 迟早会碰到。
chardev.c
接下来我们会创建一个名为chardev的字符型驱动, 后续可以通过cat /proc/devices来查看我们创建的驱动。
这个驱动程序的作用是将设备文件被读取(或用程序打开文件)的次数记录到文件中。这里不支持写入文件(如 echo "hi" > /dev/hello), 但会捕获这些尝试并告诉用户该操作不被支持。不用担心为什么你没有看到这里对读入缓冲区的数据进行了什么操作,因为并没有什么处理。这里只是简单地读入数据后打印一条消息来确认我们收到了它。
在多线程环境中, 如果没有任何保护, 对同一内存的并发访问可能会导致争用情况, 并且会影响性能。在内核模块中, 这个问题可能由于多个实例访问共享资源而发生。因此, 一种解决方案是强制独占访问。我们使用原子性的 CAS(比较和交换, Compare-And-Swap) 来维护状态CDEV_NOT_USED和CDEV_EXCLUSIVE_OPEN以确定文件当前是否被打开。
注: CAS 是一种硬件原语,用于实现无锁同步。它通过以下步骤工作:
- 将存储器位置的内容与期望值进行比较。
 
- 如果两者相等,则将该存储器位置的内容修改为新的期望值。
 
- 如果两者不相等,则不进行任何修改。
 
硬件原语(Hardware Primitive)是指直接由硬件实现的基本操作或功能,这些操作通常是底层的、原子性的,并且执行速度非常快。硬件原语通常用于构建更高层次的软件抽象和系统功能。  
CDEV_NOT_USED 和 CDEV_EXCLUSIVE_OPEN 是两个状态标志,用于确定文件当前是否被打开。通过使用 CAS 操作,可以确保只有一个线程能够成功地将状态从 CDEV_NOT_USED 修改为 CDEV_EXCLUSIVE_OPEN,从而实现独占访问。  
CAS 操作在多线程环境中非常有用,因为它可以在不使用锁的情况下安全地更新共享状态,从而避免竞争条件并提高性能。
#include <linux/atomic.h>
#include <linux/cdev.h>
#include <linux/delay.h>
#include <linux/device.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/kernel.h> /* 引入 sprintf() */
#include <linux/module.h>
#include <linux/printk.h>
#include <linux/types.h>
#include <linux/uaccess.h> /* 引入 get_user 和 put_user */
#include <linux/version.h>
#include <asm/errno.h>
static int device_open(struct inode *, struct file *);
static int device_release(struct inode *, struct file *);
static ssize_t device_read(struct file *, char __user *, size_t, loff_t *);
static ssize_t device_write(struct file *, const char __user *, size_t,
                            loff_t *);
#define SUCCESS 0
#define DEVICE_NAME "chardev" 
#define BUF_LEN 80 
static int major; 
enum {
    CDEV_NOT_USED = 0,
    CDEV_EXCLUSIVE_OPEN = 1,
};
static atomic_t already_open = ATOMIC_INIT(CDEV_NOT_USED);
static char msg[BUF_LEN + 1]; 
static struct class *cls;
static struct file_operations chardev_fops = {
    .read = device_read,
    .write = device_write,
    .open = device_open,
    .release = device_release,
};
static int __init chardev_init(void)
{
    major = register_chrdev(0, DEVICE_NAME, &chardev_fops);
    if (major < 0) {
        pr_alert("注册字符设备失败 %d\n", major);
        return major;
    }
    pr_info("分配的主编号 %d.\n", major);
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0)
    cls = class_create(DEVICE_NAME);
#else
    cls = class_create(THIS_MODULE, DEVICE_NAME);
#endif
    device_create(cls, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
    pr_info("设备创建于 /dev/%s\n", DEVICE_NAME);
    return SUCCESS;
}
static void __exit chardev_exit(void)
{
    device_destroy(cls, MKDEV(major, 0));
    class_destroy(cls);
    
    unregister_chrdev(major, DEVICE_NAME);
}
static int device_open(struct inode *inode, struct file *file)
{
    static int counter = 0;
    if (atomic_cmpxchg(&already_open, CDEV_NOT_USED, CDEV_EXCLUSIVE_OPEN))
        return -EBUSY;
    sprintf(msg, "我已经跟你说了 %d 次 Hello world!\n", counter++);
    try_module_get(THIS_MODULE);
    return SUCCESS;
}
static int device_release(struct inode *inode, struct file *file)
{
    
    atomic_set(&already_open, CDEV_NOT_USED);
    
    module_put(THIS_MODULE);
    return SUCCESS;
}
static ssize_t device_read(struct file *filp, 
                           char __user *buffer, 
                           size_t length, 
                           loff_t *offset)
{
    
    int bytes_read = 0;
    const char *msg_ptr = msg;
    if (!*(msg_ptr + *offset)) { 
        *offset = 0; 
        return 0; 
    }
    msg_ptr += *offset;
    
    while (length && *msg_ptr) {
        
        put_user(*(msg_ptr++), buffer++);
        length--;
        bytes_read++;
    }
    *offset += bytes_read;
    
    return bytes_read;
}
static ssize_t device_write(struct file *filp, const char __user *buff,
                            size_t len, loff_t *off)
{
    pr_alert("对不起,不支持此操作.\n");
    return -EINVAL;
}
module_init(chardev_init);
module_exit(chardev_exit);
MODULE_LICENSE("GPL");
为多个内核版本编写模块
系统调用是进程调用内核的主要接口,在不同版本中通常保持不变。在添加新的系统调用时往往会保证旧的系统调用不会发生变化,这对于系统的向后兼容性是非常重要的。内核新版本不会破坏旧进程的正常运行。在大多数情况下,设备文件也应当保持不变。另一方面,内核内部的接口在不同版本之间会存在一些变化。
不同的内核版本之间存在差异, 如果想支持多个内核版本, 则需要编写条件编译指令。实现这一点的方法是比较LINUX_VERSION_CODE与KERNEL_VERSION这两个宏。在内核的a.b.c版本中, 该宏的值为 2<sup>16</sup>a + 2<sup>8</sup>b + c