尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从零构建Linux字符设备驱动:Ubuntu环境下的内核模块开发实践

从零构建Linux字符设备驱动:Ubuntu环境下的内核模块开发实践 1. 项目概述为什么从字符设备驱动开始如果你刚开始接触Linux内核开发面对庞大的内核源码树和复杂的子系统可能会感到无从下手。驱动开发听起来像是系统编程的“深水区”需要理解硬件、内核机制和复杂的API。但事实上有一个绝佳的切入点能让你用最小的代价快速建立起对Linux驱动开发最核心的认知框架——那就是编写一个最简单的字符设备驱动。这个“最简单”的驱动通常指的是一个不依赖真实物理硬件的“虚拟”字符设备驱动。它运行在你日常使用的PC Ubuntu系统上通过内核模块Kernel Module的形式加载。你不需要额外的开发板、JTAG调试器或者示波器只需要一台安装了Ubuntu的电脑和基本的编译工具就能亲手构建、加载并测试一个属于你自己的内核驱动。这就像学习编程时写的“Hello, World!”程序其目的不是实现复杂功能而是验证整个开发流程是否跑通并理解最基础的概念骨架。为什么选择字符设备在Linux中一切皆文件设备也不例外。字符设备Character Device是其中一类它提供的是面向字节流的访问接口数据像水流一样按顺序读写不支持随机存取。我们熟悉的终端/dev/tty、串口/dev/ttyS*、键盘、鼠标等都是字符设备。它的驱动模型相对块设备如硬盘或网络设备来说更为直观和简单是理解驱动与用户空间User Space如何通过“文件操作”这个桥梁进行交互的最佳范例。在Ubuntu这样的桌面发行版上做驱动实验环境搭建成本极低。你不需要配置交叉编译工具链直接用系统自带的GCC和内核头文件即可。当你的模块被加载后会在/dev目录下创建一个设备文件比如/dev/my_char_dev用户程序就可以像读写普通文件一样用open、read、write、close等标准系统调用来与你的驱动“对话”。这个过程清晰地揭示了驱动开发的本质为内核填补一个“文件操作”的实现让抽象的“文件”概念能够操作具体的“设备”哪怕是虚拟的。接下来我将带你从零开始一步步构建这个最简单的字符设备驱动。我们会涵盖从环境准备、代码编写、编译、加载测试到卸载清理的完整闭环。我会重点解释每一个步骤背后的“为什么”并分享那些在官方文档里不会写的、只有踩过坑才知道的实操细节。2. 环境准备与内核头文件在开始写代码之前我们必须把“厨房”准备好。对于内核模块开发最重要的“食材”就是与你当前运行的内核版本完全匹配的内核头文件Kernel Headers。内核头文件包含了编译模块所需的所有类型定义、函数声明和宏比如file_operations结构体、printk函数、MODULE_LICENSE宏等。如果头文件版本与运行的内核不匹配轻则编译警告重则模块加载失败甚至导致内核崩溃。2.1 确认内核版本与安装头文件首先打开你的Ubuntu终端查看当前内核版本uname -r你会看到类似6.8.0-45-generic的输出。记住这个完整的版本号。接下来安装对应版本的内核头文件和必要的编译工具链sudo apt update sudo apt install linux-headers-$(uname -r) build-essentiallinux-headers-$(uname -r)这个包提供了精确匹配你当前内核版本的头文件。$(uname -r)是一个shell命令替换会自动填入你刚才查到的版本号确保安装的绝对是对应的版本。build-essential这个元包包含了GCC编译器、make工具、libc库等开发基础组件是编译任何C项目的必需品。注意有些教程会建议安装linux-headers-generic。在大多数情况下这也能工作因为它会安装当前Ubuntu发行版默认内核系列的最新头文件。但为了绝对避免版本漂移带来的潜在问题我强烈推荐使用上面那种精确匹配版本号的方式这是最稳妥的做法。2.2 验证头文件位置安装完成后头文件通常位于/lib/modules/$(uname -r)/build目录下。这个目录实际上是一个符号链接指向/usr/src/linux-headers-$(uname -r)。你可以通过ls -l命令查看ls -l /lib/modules/$(uname -r)/build这个build目录就是我们在编写Makefile时需要指定的内核源码树路径。即使你没有完整的内核源码这个头文件包也足以编译绝大多数模块。2.3 一个常见的“坑”与解决方案有时在安装头文件后编译模块可能会遇到类似“/lib/modules/xxx/build: No such file or directory”的错误。这通常有几个原因内核刚升级但头文件未安装系统自动更新了内核重启后运行了新内核但对应的头文件包没有安装。解决方法是先uname -r确认新版本再安装对应的linux-headers包。头文件路径符号链接损坏极少数情况下符号链接可能丢失。可以尝试手动创建sudo ln -s /usr/src/linux-headers-$(uname -r) /lib/modules/$(uname -r)/build。架构不匹配在64位系统上误装了32位头文件反之亦然。用dpkg -l | grep linux-headers检查已安装包的架构amd64, i386等。确保这一步无误是后续所有工作的基石。3. 驱动代码逐行解析现在我们进入核心环节——编写驱动代码。我将创建一个名为my_char_dev.c的文件并逐段解释其含义。这是一个完整的、可工作的最简单字符设备驱动。3.1 模块的元信息与初始化、退出函数#include linux/module.h #include linux/fs.h // 包含 file_operations 结构体等 #include linux/cdev.h // 包含 cdev 结构体及相关函数 #include linux/device.h // 用于自动创建设备文件class_create, device_create #include linux/uaccess.h // 包含 copy_to_user, copy_from_user #define DEVICE_NAME my_char_dev #define CLASS_NAME my_char_class static int major_num 0; // 动态分配的主设备号 static struct class* char_class NULL; static struct cdev my_cdev; static int dev_open(struct inode *inodep, struct file *filep) { printk(KERN_INFO my_char_dev: Device has been opened.\n); return 0; } static int dev_release(struct inode *inodep, struct file *filep) { printk(KERN_INFO my_char_dev: Device has been closed.\n); return 0; } static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset) { char message[] Hello from the kernel driver!\n; size_t message_len strlen(message); // 检查偏移量是否已超出数据范围 if (*offset message_len) { return 0; // 表示 EOF (End Of File) } // 计算本次可读取的字节数 size_t bytes_to_read message_len - *offset; if (len bytes_to_read) { bytes_to_read len; } // 将内核空间的数据拷贝到用户空间缓冲区 if (copy_to_user(buffer, message *offset, bytes_to_read) ! 0) { return -EFAULT; // 拷贝失败返回错误码 } // 更新文件偏移量 *offset bytes_to_read; printk(KERN_INFO my_char_dev: Sent %zu characters to the user.\n, bytes_to_read); return bytes_to_read; // 返回实际读取的字节数 } static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset) { char kernel_buffer[256]; size_t bytes_to_copy (len sizeof(kernel_buffer)-1) ? len : sizeof(kernel_buffer)-1; // 将用户空间的数据拷贝到内核空间缓冲区 if (copy_from_user(kernel_buffer, buffer, bytes_to_copy) ! 0) { return -EFAULT; } kernel_buffer[bytes_to_copy] \0; // 确保字符串终止 printk(KERN_INFO my_char_dev: Received %zu characters from the user: %s\n, bytes_to_copy, kernel_buffer); return bytes_to_copy; // 返回实际写入的字节数 } // 定义文件操作结构体将系统调用映射到我们的函数 static struct file_operations fops { .owner THIS_MODULE, .open dev_open, .read dev_read, .write dev_write, .release dev_release, }; static int __init my_char_dev_init(void) { printk(KERN_INFO my_char_dev: Initializing the module.\n); // 1. 动态申请一个主设备号 major_num register_chrdev(0, DEVICE_NAME, fops); if (major_num 0) { printk(KERN_ALERT my_char_dev: Failed to register a major number.\n); return major_num; } printk(KERN_INFO my_char_dev: Registered correctly with major number %d.\n, major_num); // 2. 创建一个设备类在/sys/class/下可见 char_class class_create(CLASS_NAME); if (IS_ERR(char_class)) { unregister_chrdev(major_num, DEVICE_NAME); printk(KERN_ALERT my_char_dev: Failed to register device class.\n); return PTR_ERR(char_class); } // 3. 在/dev/下自动创建设备节点权限为0666所有人可读可写 device_create(char_class, NULL, MKDEV(major_num, 0), NULL, DEVICE_NAME); // 4. 初始化并添加cdev结构到内核更现代、推荐的方式 cdev_init(my_cdev, fops); my_cdev.owner THIS_MODULE; if (cdev_add(my_cdev, MKDEV(major_num, 0), 1) 0) { device_destroy(char_class, MKDEV(major_num, 0)); class_destroy(char_class); unregister_chrdev(major_num, DEVICE_NAME); printk(KERN_ALERT my_char_dev: Failed to add cdev to the system.\n); return -1; } printk(KERN_INFO my_char_dev: Device node created at /dev/%s\n, DEVICE_NAME); return 0; } static void __exit my_char_dev_exit(void) { // 清理顺序与创建顺序相反 device_destroy(char_class, MKDEV(major_num, 0)); class_destroy(char_class); cdev_del(my_cdev); unregister_chrdev(major_num, DEVICE_NAME); printk(KERN_INFO my_char_dev: Module removed successfully.\n); } module_init(my_char_dev_init); module_exit(my_char_dev_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple character device driver for learning.); MODULE_VERSION(0.1);关键点解析头文件每个头文件都有其明确职责。linux/fs.h是文件系统的核心定义了file_operationslinux/cdev.h提供了字符设备对象管理linux/device.h是设备模型用于自动创建设备文件linux/uaccess.h提供了内核与用户空间安全拷贝数据的函数。printk与日志级别printk是内核的“printf”输出到内核日志缓冲区。KERN_INFO、KERN_ALERT是日志级别分别表示普通信息和警报。你可以用dmesg命令查看这些打印信息。注意printk格式字符串里通常不需要换行符\n因为内核日志系统会自动添加。file_operations结构体这是驱动与VFS虚拟文件系统的契约。我们将自己实现的函数指针open,read,write,release赋值给这个结构体的对应成员。当用户程序调用read(fd, buf, size)时VFS最终就会调用我们注册的dev_read函数。copy_to_user/copy_from_user这是内核编程的铁律。用户空间和内核空间的内存是隔离的不能直接通过指针访问。这两个函数在拷贝数据的同时会进行合法性检查如用户空间指针是否有效、是否可读写防止内核因非法访问而崩溃或产生安全漏洞。如果拷贝失败它们返回未拷贝的字节数成功时为0我们据此返回-EFAULT错误给用户。初始化与退出函数__init和__exit是给编译器的提示表明这些函数只在模块加载/卸载时调用一次其代码占用的内存在执行后可以被释放。module_init和module_exit宏则将我们定义的函数注册为模块的入口和出口。设备号与cdevregister_chrdev是旧式接口它一次性注册主设备号和0-255的次设备号范围。而cdev_init和cdev_add是更精细、更现代的字符设备管理方式。这里我们结合使用先用register_chrdev申请主设备号再用cdev_add添加具体的设备对象。主设备号标识驱动类型次设备号这里为0标识该驱动下的具体设备实例。自动创建设备节点通过class_create和device_create我们利用了内核的udev或mdev机制。当模块加载时udev会监听到内核发出的设备事件并自动在/dev目录下创建对应的设备文件并设置我们指定的权限通过device_create的默认规则或udev规则。这比手动使用mknod命令方便、可靠得多。模块信息MODULE_LICENSE(“GPL”)是必须的表明模块采用GPL许可证否则加载时会有警告且某些GPL-only的内核符号将无法使用。4. 编写Makefile与编译驱动内核模块不能直接用普通的GCC命令编译它需要链接内核的构建系统。我们需要一个简单的Makefile。创建一个名为Makefile的文件注意M大写内容如下obj-m my_char_dev.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) cleanMakefile解析obj-m my_char_dev.o告诉内核构建系统我们要将my_char_dev.c编译成一个可加载的模块-m表示模块。KERNEL_DIR指定内核头文件/源码的位置就是我们之前安装的/lib/modules/$(uname -r)/build。PWD获取当前目录路径。$(MAKE) -C $(KERNEL_DIR) M$(PWD) modules这是核心命令。-C $(KERNEL_DIR)让make先切换到内核目录。M$(PWD)告诉内核构建系统模块的源码位于$(PWD)目录。modules执行内核Makefile中定义的modules目标它会根据obj-m的指示来编译我们的模块。在终端中进入包含my_char_dev.c和Makefile的目录执行make命令make如果一切顺利你会看到编译输出并生成以下关键文件my_char_dev.ko这就是编译好的内核模块文件Kernel Object。.ko是内核模块的标准扩展名。my_char_dev.mod.c,my_char_dev.mod.o,my_char_dev.o,modules.order,Module.symvers等这些是编译过程中的中间文件或辅助文件由内核构建系统自动生成。实操心得如果编译报错最常见的原因是内核头文件路径不对或版本不匹配。请再次用uname -r确认运行内核版本并检查/lib/modules/$(uname -r)/build是否存在且是一个有效的链接。另一个常见错误是源代码语法错误注意内核代码通常使用-stdgnu89或-stdgnu11标准并且对代码规范如函数声明要求较严格。5. 加载、测试与卸载模块编译成功后我们就可以和这个驱动进行交互了。5.1 加载模块加载模块需要使用insmod命令但通常需要root权限sudo insmod my_char_dev.ko加载成功后不会有明显输出除非模块初始化函数里有printk。我们可以通过以下命令验证查看内核日志dmesg | tail -10或sudo dmesg -c查看并清空。你应该能看到我们写在my_char_dev_init函数里的打印信息例如“Registered correctly with major number X”。检查模块是否在列表中lsmod | grep my_char_dev。lsmod列出所有已加载模块grep过滤出我们的模块。检查设备号cat /proc/devices | grep my_char_dev。这个文件列出了所有注册的设备及其主设备号。你应该能看到类似250 my_char_dev的输出250是动态分配的主设备号每次可能不同。检查设备文件ls -l /dev/my_char_dev。由于我们使用了device_createudev应该已经自动在/dev目录下创建了设备节点权限通常是crw-rw-rw-。开头的c表示这是一个字符设备文件。5.2 编写用户空间测试程序创建一个名为test_driver.c的用户空间测试程序#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include string.h #define DEVICE_PATH /dev/my_char_dev int main() { int fd; char read_buffer[100]; char write_buffer[] Test message from user program.; // 1. 打开设备 fd open(DEVICE_PATH, O_RDWR); if (fd 0) { perror(Failed to open the device); return -1; } printf(Device opened successfully, fd %d.\n, fd); // 2. 写入数据到驱动 ssize_t bytes_written write(fd, write_buffer, strlen(write_buffer)); if (bytes_written 0) { perror(Failed to write to the device); close(fd); return -1; } printf(Wrote %zd bytes to the device: %s\n, bytes_written, write_buffer); // 3. 从驱动读取数据 ssize_t bytes_read read(fd, read_buffer, sizeof(read_buffer)-1); if (bytes_read 0) { perror(Failed to read from the device); close(fd); return -1; } read_buffer[bytes_read] \0; // 添加字符串终止符 printf(Read %zd bytes from the device: %s, bytes_read, read_buffer); // 4. 关闭设备 close(fd); printf(Device closed.\n); return 0; }编译并运行这个测试程序gcc -o test_driver test_driver.c ./test_driver如果一切正常程序会输出写入和读取的信息。同时再次运行sudo dmesg | tail -10你应该能看到驱动代码中dev_write和dev_read函数里的printk信息例如“Received … characters from the user”和“Sent … characters to the user”。这证明了用户空间的write和read系统调用确实触发了我们内核模块中对应的函数。5.3 卸载模块测试完毕后卸载模块sudo rmmod my_char_dev注意rmmod的参数是模块名在代码中通过MODULE_LICENSE等宏定义的名字或者查看.ko文件的基本名而不是文件名。卸载后再次检查lsmod | grep my_char_dev应该没有输出。ls -l /dev/my_char_dev设备文件应该已被自动移除。sudo dmesg | tail -5应该能看到my_char_dev_exit函数中的打印信息“Module removed successfully”。重要注意事项卸载模块前必须确保没有任何进程正在使用该设备即/dev/my_char_dev没有被任何程序打开。如果有程序仍持有文件描述符rmmod会失败并提示“Module in use”。你可以用sudo lsof /dev/my_char_dev命令查看是哪个进程占用了它。确保你的测试程序已经退出是避免这个问题的最简单方法。6. 问题排查与深度调试技巧即使按照步骤操作你也可能会遇到各种问题。这里汇总了一些常见坑点及其解决方法。6.1 编译阶段问题fatal error: linux/module.h: No such file or directory原因内核头文件未安装或路径错误。解决确保已执行sudo apt install linux-headers-$(uname -r)并确认/lib/modules/$(uname -r)/build目录存在。error: implicit declaration of function ‘class_create’原因缺少必要的头文件#include linux/device.h或者内核版本较旧/较新函数有变动。解决检查头文件包含。对于高版本内核如5.x以上class_create已变为class_create但函数原型基本一致。如果仍有问题可以尝试搜索特定内核版本的API变化。Makefile执行报错提示找不到目标或规则原因Makefile格式错误例如使用了空格缩进而不是Tab。解决Makefile中的命令部分如$(MAKE) -C ...必须用Tab键缩进不能用空格。用cat -A Makefile查看行尾如果是^I开头则表示Tab如果是空格则显示为空格。请用编辑器修正。6.2 加载与运行阶段问题insmod: ERROR: could not insert module my_char_dev.ko: Invalid parameters原因模块依赖的符号函数或变量在内核中未找到或者模块许可证问题。解决检查dmesg输出通常有更详细的错误信息。确保所有用到的内核API在当前内核版本中可用。对于简单的字符设备驱动标准API通常很稳定。确认模块中使用了MODULE_LICENSE(“GPL”)某些符号仅对GPL模块导出。insmod: ERROR: could not insert module my_char_dev.ko: Operation not permitted原因没有使用sudo或者系统启用了安全启动Secure Boot且未签名模块。解决使用sudo。对于Secure Boot在开发环境中可以临时禁用它在BIOS/UEFI设置中或者学习如何为内核模块签名更复杂。设备文件/dev/my_char_dev没有自动创建原因device_create执行失败检查dmesg错误。udev服务没有运行或规则未触发。解决检查my_char_dev_init函数中device_create的返回值处理示例代码中未检查生产代码应检查。手动创建设备节点sudo mknod /dev/my_char_dev c 250 0将250替换为你的实际主设备号。这可以验证驱动注册是否成功。确保udev服务正常运行systemctl status systemd-udevd对于systemd系统。测试程序open失败提示No such file or directory原因设备文件不存在。解决如上所述检查设备文件是否创建。也可能是主设备号不对手动创建时需使用cat /proc/devices查到的正确主设备号。测试程序write或read失败提示Bad address原因用户空间测试程序提供的缓冲区地址非法。但在我们的简单测试程序中很少见。解决检查测试程序中的缓冲区是否有效。更常见于驱动代码中的copy_to_user/copy_from_user使用错误例如传递了错误的指针或长度。rmmod失败提示Module in use原因设备正在被使用。解决关闭所有打开该设备文件的程序。使用sudo lsof /dev/my_char_dev找出占用进程并终止它kill PID或等待程序自然退出。6.3 调试技巧printk是你的好朋友这是内核开发最基础、最有效的调试手段。合理使用不同的日志级别KERN_DEBUG,KERN_INFO,KERN_WARNING,KERN_ERR。可以通过/proc/sys/kernel/printk文件或dmesg -n level命令调整控制台打印的日志级别。使用%p打印指针在调试时打印函数地址、结构体指针等有助于理解执行流和数据位置。检查返回值内核函数调用失败时通常会返回负的错误码如-ENOMEM,-EBUSY。务必检查每个可能失败的函数调用并在初始化失败时进行正确的资源回滚清理就像我们在my_char_dev_init函数中做的那样。并发与竞态初探我们这个最简单的驱动没有考虑并发访问。如果多个进程同时打开、读写同一个设备可能会产生不可预知的行为。在实际驱动中你需要使用**互斥锁mutex或信号量semaphore**来保护共享数据。这是驱动开发中一个更深入但至关重要的主题。使用strace跟踪系统调用在用户空间你可以用strace ./test_driver来跟踪测试程序发出的所有系统调用如open,read,write,close观察参数和返回值这对于判断问题是出在用户程序还是内核驱动非常有帮助。7. 从“最简单”到“更实用”的思考通过这个最简单的驱动你已经掌握了Linux字符设备驱动的核心骨架模块初始化/退出、设备号注册、文件操作集合定义、以及用户/内核空间数据交换。但这仅仅是起点。一个实用的驱动还需要考虑更多因素次设备号与多设备实例我们的驱动只创建了一个设备次设备号为0。通过扩展次设备号一个驱动可以管理多个同类型的硬件设备。你需要修改代码为每个次设备号维护独立的状态信息。I/O控制ioctlread/write是流式操作对于设备的配置、状态查询等非流式操作需要通过ioctl系统调用实现。这需要在file_operations中实现.unlocked_ioctl函数。阻塞与非阻塞I/O默认情况下用户空间的read/write调用在设备未就绪时会阻塞进程。驱动可以通过实现.poll函数来支持非阻塞O_NONBLOCK模式或者使用等待队列wait queue来管理阻塞的进程。内存管理驱动中动态分配内存需使用kmalloc/kfree类似用户空间的malloc/free但要注意内核内存有限且无虚拟内存交换。对于大块内存可以考虑使用vmalloc。中断处理真实的硬件驱动几乎都需要处理硬件中断。你需要申请中断号注册中断处理函数并在其中快速完成关键操作将可能耗时的任务推送到工作队列或任务队列中。电源管理对于移动设备或节能需求驱动可能需要支持挂起suspend和恢复resume操作。这个最简单的字符设备驱动项目就像一张地图上的第一个清晰坐标。它验证了你的开发环境让你理解了驱动与内核、驱动与用户程序交互的基本模式。当你下次面对一个真实的硬件比如一个GPIO控制器、一个传感器、或一个自定义的PCIe设备时你知道从哪里开始搭建代码框架如何注册设备如何响应应用层的请求。所有的复杂驱动都是在这个基本骨架上根据硬件特性和功能需求一点点添加血肉而成的。
返回列表