30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度在嵌入式、服务器、工业控制以及各类智能设备领域Linux 系统占据着核心地位。而让硬件与这个强大系统对话的桥梁正是驱动程序。对于开发者而言理解并掌握 Linux 驱动开发意味着能够深入内核定制硬件行为解决底层兼容性问题这是从应用层开发迈向系统级开发的关键一步。很多开发者对驱动开发望而却步认为其涉及复杂的内核机制和晦涩的代码。本文旨在打破这种认知通过一个从零开始的实践案例带你亲手编写一个最简单的 Linux 内核模块驱动程序的雏形理解其核心框架、编译加载流程以及关键的数据结构。无论你是嵌入式工程师、系统软件开发者还是对操作系统底层原理充满好奇的学习者跟随本文完成这个“Hello World”级别的驱动模块都将为你打开 Linux 内核编程的大门。1. 理解 Linux 驱动程序与内核模块在动手编写代码之前必须厘清几个核心概念驱动程序、内核模块、内核空间与用户空间。这是后续所有操作的理论基础。1.1 驱动程序与内核模块的关系驱动程序是一种特殊的软件它充当硬件设备与操作系统内核之间的翻译官。当应用程序通过标准接口如读写文件访问硬件时内核会调用对应的驱动程序由驱动程序将通用指令翻译成硬件能理解的特定命令如寄存器操作并处理硬件返回的数据。在 Linux 中驱动程序通常以内核模块的形式存在。内核模块是一段可以动态加载到正在运行的内核中或从内核中卸载的代码。这带来了极大的灵活性无需重启可以随时加载新驱动或卸载旧驱动无需重新编译整个内核或重启系统。节省内存只有被使用的驱动才会占用内核内存。便于调试可以快速迭代修改和测试。因此我们学习驱动开发通常从编写一个可加载的内核模块开始。1.2 内核空间与用户空间这是 Linux 系统最重要的安全与隔离机制之一。系统内存被划分为两个区域用户空间普通应用程序运行的地方。它们不能直接访问硬件或内核内存只能通过系统调用如open,read,write,ioctl向内核发起请求。权限受限一个进程崩溃通常不会影响整个系统。内核空间操作系统内核运行的地方。驱动代码就运行在此处。它拥有最高权限可以直接操作硬件、访问所有内存。但一旦内核模块代码出现错误如空指针解引用很可能导致整个系统崩溃内核恐慌Kernel Panic。编写驱动本质上是在内核空间编程这就要求代码必须极其严谨资源管理内存、锁必须正确因为这里没有用户空间那种宽松的错误处理环境。1.3 驱动开发的基本要求开始之前请确保你的开发环境满足以下要求项目要求说明操作系统Linux 发行版推荐 Ubuntu 20.04 LTS 或更高版本、CentOS 8 等主流发行版。本文命令以 Ubuntu/Debian 为例。内核头文件已安装编译模块需要与当前运行内核版本匹配的内核头文件。编译工具链GCC, Make用于编译C代码。Root权限需要加载和卸载内核模块需要超级用户权限。可以通过以下命令快速检查并安装必要环境以 Ubuntu 为例# 1. 检查内核版本确保后续安装的头文件版本一致 uname -r # 2. 更新软件包列表并安装内核头文件和编译工具 sudo apt update sudo apt install build-essential linux-headers-$(uname -r) # 3. 验证GCC和Make是否安装成功 gcc --version make --version2. 编写第一个内核模块Hello World我们将从一个最简单的内核模块开始它不控制任何硬件仅仅在加载时打印“Hello, World!”在卸载时打印“Goodbye, World!”。这个例子包含了内核模块的所有基本要素。2.1 创建项目目录与源文件首先创建一个独立的工作目录避免文件散落。mkdir ~/hello_driver cd ~/hello_driver然后使用文本编辑器如vim,nano,gedit创建驱动源文件hello.c。// hello.c #include linux/init.h // 包含模块初始化和清理函数的宏 #include linux/module.h // 包含内核模块相关的所有基本宏和函数 #include linux/kernel.h // 包含内核打印函数 printk 等 // 模块许可证声明必须 MODULE_LICENSE(GPL); // 模块作者声明可选 MODULE_AUTHOR(Your Name); // 模块描述可选 MODULE_DESCRIPTION(A simple Hello World Linux driver.); // 模块版本可选 MODULE_VERSION(0.1); // 模块加载函数当使用 insmod 加载时调用 static int __init hello_init(void) { // printk 是内核空间的“printf”KERN_INFO 是日志级别 printk(KERN_INFO Hello, World! Driver loaded.\n); return 0; // 返回 0 表示初始化成功 } // 模块卸载函数当使用 rmmod 卸载时调用 static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, World! Driver unloaded.\n); } // 指定模块的入口和出口函数 module_init(hello_init); module_exit(hello_exit);关键代码解释头文件linux/module.h是所有模块必需的。linux/init.h包含module_init和module_exit宏。linux/kernel.h包含printk。MODULE_LICENSE必须声明。通常使用 “GPL”否则模块可能无法使用某些内核GPL-only的符号函数/变量甚至加载失败。__init和__exit这些是宏用于提示编译器将相关函数放到特定的内存段。__init标识的代码在初始化后可以被释放以节省内存__exit标识的代码在模块内置而非可加载时直接被丢弃。printk内核日志函数。KERN_INFO是日志级别定义消息的紧急程度。输出不会直接显示在终端而是写入内核日志缓冲区。module_init与module_exit这两个宏将我们定义的函数注册为模块的入口和出口。这是驱动程序的固定套路。2.2 编写 Makefile内核模块的编译不能使用普通的gcc命令需要借助内核构建系统。我们需要编写一个Makefile来告诉make工具如何编译。在同一目录下创建Makefile文件注意首字母大写# 指定内核模块名称最终生成的 .ko 文件 obj-m : hello.o # 获取当前内核的构建目录 KERNEL_DIR ? /lib/modules/$(shell uname -r)/build # 定义默认的构建目标 all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules # 清理编译生成的文件 clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) cleanMakefile 解释obj-m : hello.o告诉内核构建系统我们要构建一个名为hello.ko的模块它由hello.o生成。KERNEL_DIR指向当前运行内核的构建目录通常是/lib/modules/$(uname -r)/build的符号链接。-C选项让make先切换到该目录使用内核顶层的Makefile。M$(PWD)告诉内核构建系统模块的源代码位于当前目录。modules是内核顶层Makefile中定义的目标专门用于编译外部模块。2.3 编译模块在包含hello.c和Makefile的目录下执行make命令make如果一切顺利你将看到类似以下的输出并生成几个新文件其中最重要的是hello.ko内核模块文件。make -C /lib/modules/5.15.0-91-generic/build M/home/yourname/hello_driver modules make[1]: Entering directory /usr/src/linux-headers-5.15.0-91-generic CC [M] /home/yourname/hello_driver/hello.o MODPOST /home/yourname/hello_driver/Module.symvers CC [M] /home/yourname/hello_driver/hello.mod.o LD [M] /home/yourname/hello_driver/hello.ko BTF [M] /home/yourname/hello_driver/hello.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic生成的文件中hello.ko最终的内核模块文件用于加载。hello.o编译后的目标文件。hello.mod.c,hello.mod.o模块信息文件。Module.symvers模块符号版本信息。.hello.ko.cmd,.hello.o.cmd等编译过程文件。3. 加载、测试与卸载模块编译成功后我们将在系统中动态操作这个模块。3.1 加载模块使用insmodinsert module命令加载模块需要 root 权限sudo insmod hello.ko命令执行后看似没有输出因为printk的信息输出到了内核日志中。3.2 查看模块与日志查看已加载模块列表使用lsmod命令可以过滤查看我们的模块lsmod | grep hello你应该能看到一行输出包含模块名hello、大小和被引用次数。查看内核日志printk的输出需要通过dmesg命令查看内核环形缓冲区。为了看到最新的日志我们通常查看尾部sudo dmesg | tail -5或者使用-c参数清空并显示当前缓冲区内容生产环境慎用sudo dmesg -c在输出中你应该能看到类似这样的信息[ 1234.567890] Hello, World! Driver loaded.这证明我们的模块加载函数hello_init被成功调用了。3.3 卸载模块使用rmmodremove module命令卸载模块sudo rmmod hello注意rmmod后面跟的是模块名hello而不是文件名hello.ko。再次查看内核日志确认卸载函数被调用sudo dmesg | tail -5输出中应该新增了一行[ 1234.654321] Goodbye, World! Driver unloaded.3.4 模块信息查询使用modinfo命令可以查看模块的元信息这些信息来自源代码中的MODULE_*宏modinfo hello.ko输出示例filename: /home/yourname/hello_driver/hello.ko version: 0.1 description: A simple Hello World Linux driver. author: Your Name license: GPL srcversion: ABCDEF1234567890ABCDEF0 depends: name: hello vermagic: 5.15.0-91-generic SMP mod_unload modversions4. 进阶创建一个简单的字符设备驱动仅打印日志的模块意义有限。真正的驱动需要与用户空间交互。字符设备驱动是最常见的一类它像字节流一样被顺序访问如键盘、鼠标、串口。接下来我们创建一个虚拟的字符设备驱动它允许用户空间程序通过文件接口/dev/hello_dev进行读写。4.1 字符设备驱动框架Linux 内核提供了一套完整的框架来简化字符设备驱动的编写核心是cdev字符设备结构和file_operations文件操作结构。创建新文件hello_char.c// hello_char.c #include linux/module.h #include linux/kernel.h #include linux/fs.h // 包含 file_operations 结构 #include linux/cdev.h // 包含 cdev 结构 #include linux/device.h // 用于自动创建设备节点可选但推荐 #include linux/uaccess.h // 包含 copy_to_user/copy_from_user #define DEVICE_NAME hello_dev #define CLASS_NAME hello_class static int major_num 0; // 动态分配主设备号 static struct class* hello_class NULL; static struct cdev hello_cdev; // 简单的设备内存缓冲区 static char device_buffer[256] {0}; static int buffer_len 0; // 设备打开函数 static int dev_open(struct inode *inodep, struct file *filep) { printk(KERN_INFO HelloDev: Device opened.\n); return 0; } // 设备读函数 static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset) { int bytes_to_copy; int ret; // 计算还剩多少数据可读 bytes_to_copy buffer_len - *offset; if (bytes_to_copy 0) { return 0; // EOF } if (bytes_to_copy len) { bytes_to_copy len; } // 将内核空间的数据复制到用户空间缓冲区 ret copy_to_user(buffer, device_buffer *offset, bytes_to_copy); if (ret) { printk(KERN_ERR HelloDev: Failed to copy %d bytes to user.\n, ret); return -EFAULT; } *offset bytes_to_copy; printk(KERN_INFO HelloDev: Sent %d bytes to user.\n, bytes_to_copy); return bytes_to_copy; } // 设备写函数 static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset) { int bytes_to_copy; // 防止写入超过缓冲区大小 if (len sizeof(device_buffer)) { printk(KERN_WARNING HelloDev: Write length exceeds buffer size.\n); return -ENOMEM; } // 将用户空间的数据复制到内核空间缓冲区 if (copy_from_user(device_buffer, buffer, len)) { printk(KERN_ERR HelloDev: Failed to copy data from user.\n); return -EFAULT; } buffer_len len; *offset len; printk(KERN_INFO HelloDev: Received %zu bytes from user: %s\n, len, device_buffer); return len; } // 设备释放函数 static int dev_release(struct inode *inodep, struct file *filep) { printk(KERN_INFO HelloDev: Device closed.\n); return 0; } // 定义文件操作结构关联我们的函数 static struct file_operations fops { .owner THIS_MODULE, .open dev_open, .read dev_read, .write dev_write, .release dev_release, }; // 模块初始化函数 static int __init hello_char_init(void) { dev_t dev_num; int ret; printk(KERN_INFO HelloDev: Initializing character device driver.\n); // 1. 动态申请一个主设备号和次设备号 ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { printk(KERN_ERR HelloDev: Failed to allocate chrdev region. Error %d\n, ret); return ret; } major_num MAJOR(dev_num); printk(KERN_INFO HelloDev: Allocated major number %d.\n, major_num); // 2. 初始化 cdev 结构并将其与 file_operations 关联 cdev_init(hello_cdev, fops); hello_cdev.owner THIS_MODULE; // 3. 将 cdev 添加到内核系统 ret cdev_add(hello_cdev, dev_num, 1); if (ret 0) { printk(KERN_ERR HelloDev: Failed to add cdev. Error %d\n, ret); unregister_chrdev_region(dev_num, 1); return ret; } // 4. 使用 udev/class 接口自动创建设备节点推荐 hello_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(hello_class)) { printk(KERN_ERR HelloDev: Failed to create device class.\n); cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(hello_class); } // 在 /dev/ 下创建设备文件 hello_dev device_create(hello_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO HelloDev: Device node /dev/%s created.\n, DEVICE_NAME); return 0; } // 模块卸载函数 static void __exit hello_char_exit(void) { dev_t dev_num MKDEV(major_num, 0); // 清理顺序与初始化相反 device_destroy(hello_class, dev_num); class_destroy(hello_class); cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO HelloDev: Character device driver unloaded.\n); } module_init(hello_char_init); module_exit(hello_char_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple character device driver example.);4.2 更新 Makefile 并编译修改Makefile使其能编译新的模块obj-m : hello.o hello_char.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean执行make编译将生成hello_char.ko。4.3 加载模块与测试设备加载模块sudo insmod hello_char.ko检查设备加载成功后应该自动在/dev/下创建了设备节点。ls -l /dev/hello_dev输出类似crw------- 1 root root 241, 0 ...其中241是动态分配的主设备号0是次设备号。测试写入使用echo命令通过 shell 向设备写入数据。sudo echo Hello from userspace /dev/hello_dev查看内核日志sudo dmesg | tail -5会看到驱动收到了数据。测试读取使用cat命令从设备读取数据。sudo cat /dev/hello_dev输出应为之前写入的 “Hello from userspace”。再次查看日志确认读操作被记录。卸载模块sudo rmmod hello_char检查/dev/hello_dev文件应已自动删除。5. 常见问题与排查路径驱动开发过程中编译、加载、运行各阶段都可能出错。以下是典型问题及排查方法。问题现象可能原因检查与解决步骤make编译失败提示找不到内核头文件1. 内核头文件未安装。2.KERNEL_DIR路径错误。1. 运行sudo apt install linux-headers-$(uname -r)。2. 确认/lib/modules/$(uname -r)/build是否存在且是有效链接。insmod失败提示Invalid module format模块编译所用的内核版本与当前运行内核版本不匹配。1. 检查uname -r与编译时内核头文件版本是否一致。2. 运行sudo depmod -a更新模块依赖然后重试。3. 彻底清理 (make clean) 后重新编译。insmod失败提示Operation not permitted没有使用sudo或当前用户无权限。使用sudo执行insmod。insmod失败提示Unknown symbol in module模块引用了未导出的内核符号函数或变量。1. 检查代码中是否使用了非公开的内核API。2. 使用sudo cat /proc/kallsyms | grep symbol_name查看符号是否存在及是否以T或t标记表示全局可见。3. 可能需要修改代码或将该模块与定义符号的模块一起编译。rmmod失败提示Module hello is in use模块正在被使用如设备文件被打开。1. 关闭所有使用该设备文件的进程。2. 使用sudo lsof | grep /dev/hello_dev查找并关闭相关进程。3. 使用lsmod查看模块的“Used by”计数。加载后dmesg看不到printk输出1. 日志级别过低。2. 内核printk日志级别设置过滤了信息。1. 确保printk使用KERN_INFO或更高优先级如KERN_WARNING。2. 查看当前控制台日志级别cat /proc/sys/kernel/printk。第二个值控制台级别如果低于消息级别如KERN_INFO是6则消息不会打印到控制台但仍可用dmesg查看。3. 使用sudo dmesg -w实时监控内核日志。用户程序读写/dev/hello_dev失败权限不足设备节点默认权限为600仅 root 可读写。1. 使用sudo运行测试程序。2. 在驱动初始化代码中使用device_create时设置权限较复杂。3. 创建 udev 规则在加载模块时自动修改设备文件权限生产环境做法。系统不稳定或崩溃内核恐慌驱动代码存在严重错误如空指针解引用、内存越界、死锁等。1. 这是内核编程的严重错误。2. 重启系统。3. 仔细检查代码尤其是内存操作和指针使用。4. 使用printk增加调试信息定位崩溃前最后执行的语句。5. 考虑在内核编译时启用CONFIG_DEBUG_KERNEL、CONFIG_DEBUG_KMEMLEAK等调试选项。6. 驱动开发最佳实践与扩展方向掌握了基础框架后要写出稳定、可靠的驱动还需要遵循以下实践并了解更广阔的方向。6.1 开发与调试最佳实践增量开发与测试不要一次性写太多功能。先让模块能加载卸载再加一个open函数然后逐步增加read/write/ioctl等。每步都测试。善用printk这是内核调试最直接的工具。使用不同的日志级别KERN_DEBUG,KERN_INFO,KERN_WARNING,KERN_ERR。可以使用pr_debug,pr_info,pr_warn,pr_err等宏简化书写。资源管理遵循“谁申请谁释放”的原则。在init函数中申请的资源内存、设备号、cdev、class必须在exit函数中按相反顺序精确释放。错误处理内核函数调用后必须检查返回值。一旦失败要跳转到错误处理路径清理已申请的资源。并发控制如果设备可能被多个进程同时访问必须使用内核提供的同步机制如信号量semaphore、互斥锁mutex、自旋锁spinlock来保护共享数据如我们例子中的device_buffer和buffer_len。这是生产驱动与示例驱动最大的区别之一。代码规范遵循 Linux 内核编码风格可使用scripts/checkpatch.pl检查。变量和函数命名清晰添加必要的注释。6.2 下一步学习方向完善字符设备驱动实现ioctl接口用于实现更复杂的设备控制命令。添加poll/select支持使应用程序能够进行非阻塞或异步I/O操作。实现mmap将设备内存映射到用户空间实现零拷贝高性能访问。探索其他设备类型平台设备驱动用于描述片上系统SoC中的内置外设与设备树Device Tree紧密相关是嵌入式 Linux 驱动开发的核心。块设备驱动用于磁盘、SD卡等以数据块为单位访问的设备。网络设备驱动用于网卡。深入内核机制中断处理硬件设备通过中断通知CPU事件驱动需要注册中断处理函数。内核定时器与工作队列实现延迟操作和将工作推后到进程上下文执行。DMA直接内存访问用于大数据量传输减轻CPU负担。电源管理实现设备的休眠、唤醒等省电功能。使用更现代的框架设备树学习如何编写和修改设备树.dts文件将硬件资源配置从代码中解耦出来。设备模型深入理解bus,device,driver,class等核心结构以及probe,remove等标准接口。实战项目找一个简单的真实硬件如一个GPIO控制的LED一个I2C温度传感器为其编写完整的驱动并编写对应的用户空间测试程序。这是巩固知识的最佳途径。驱动开发是连接硬件与软件的桥梁需要耐心、严谨和对细节的关注。从本文的“Hello World”模块出发理解每一个步骤背后的原理然后逐步挑战更复杂的功能和真实的硬件你就能真正掌握 Linux 内核编程的精髓。记住内核编程无小事每一次insmod都请做好可能需重启的准备尤其在虚拟机或开发板上进行并养成随时查看dmesg日志的习惯。 30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度
Linux驱动开发入门:从内核模块到字符设备驱动实践
30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度在嵌入式、服务器、工业控制以及各类智能设备领域Linux 系统占据着核心地位。而让硬件与这个强大系统对话的桥梁正是驱动程序。对于开发者而言理解并掌握 Linux 驱动开发意味着能够深入内核定制硬件行为解决底层兼容性问题这是从应用层开发迈向系统级开发的关键一步。很多开发者对驱动开发望而却步认为其涉及复杂的内核机制和晦涩的代码。本文旨在打破这种认知通过一个从零开始的实践案例带你亲手编写一个最简单的 Linux 内核模块驱动程序的雏形理解其核心框架、编译加载流程以及关键的数据结构。无论你是嵌入式工程师、系统软件开发者还是对操作系统底层原理充满好奇的学习者跟随本文完成这个“Hello World”级别的驱动模块都将为你打开 Linux 内核编程的大门。1. 理解 Linux 驱动程序与内核模块在动手编写代码之前必须厘清几个核心概念驱动程序、内核模块、内核空间与用户空间。这是后续所有操作的理论基础。1.1 驱动程序与内核模块的关系驱动程序是一种特殊的软件它充当硬件设备与操作系统内核之间的翻译官。当应用程序通过标准接口如读写文件访问硬件时内核会调用对应的驱动程序由驱动程序将通用指令翻译成硬件能理解的特定命令如寄存器操作并处理硬件返回的数据。在 Linux 中驱动程序通常以内核模块的形式存在。内核模块是一段可以动态加载到正在运行的内核中或从内核中卸载的代码。这带来了极大的灵活性无需重启可以随时加载新驱动或卸载旧驱动无需重新编译整个内核或重启系统。节省内存只有被使用的驱动才会占用内核内存。便于调试可以快速迭代修改和测试。因此我们学习驱动开发通常从编写一个可加载的内核模块开始。1.2 内核空间与用户空间这是 Linux 系统最重要的安全与隔离机制之一。系统内存被划分为两个区域用户空间普通应用程序运行的地方。它们不能直接访问硬件或内核内存只能通过系统调用如open,read,write,ioctl向内核发起请求。权限受限一个进程崩溃通常不会影响整个系统。内核空间操作系统内核运行的地方。驱动代码就运行在此处。它拥有最高权限可以直接操作硬件、访问所有内存。但一旦内核模块代码出现错误如空指针解引用很可能导致整个系统崩溃内核恐慌Kernel Panic。编写驱动本质上是在内核空间编程这就要求代码必须极其严谨资源管理内存、锁必须正确因为这里没有用户空间那种宽松的错误处理环境。1.3 驱动开发的基本要求开始之前请确保你的开发环境满足以下要求项目要求说明操作系统Linux 发行版推荐 Ubuntu 20.04 LTS 或更高版本、CentOS 8 等主流发行版。本文命令以 Ubuntu/Debian 为例。内核头文件已安装编译模块需要与当前运行内核版本匹配的内核头文件。编译工具链GCC, Make用于编译C代码。Root权限需要加载和卸载内核模块需要超级用户权限。可以通过以下命令快速检查并安装必要环境以 Ubuntu 为例# 1. 检查内核版本确保后续安装的头文件版本一致 uname -r # 2. 更新软件包列表并安装内核头文件和编译工具 sudo apt update sudo apt install build-essential linux-headers-$(uname -r) # 3. 验证GCC和Make是否安装成功 gcc --version make --version2. 编写第一个内核模块Hello World我们将从一个最简单的内核模块开始它不控制任何硬件仅仅在加载时打印“Hello, World!”在卸载时打印“Goodbye, World!”。这个例子包含了内核模块的所有基本要素。2.1 创建项目目录与源文件首先创建一个独立的工作目录避免文件散落。mkdir ~/hello_driver cd ~/hello_driver然后使用文本编辑器如vim,nano,gedit创建驱动源文件hello.c。// hello.c #include linux/init.h // 包含模块初始化和清理函数的宏 #include linux/module.h // 包含内核模块相关的所有基本宏和函数 #include linux/kernel.h // 包含内核打印函数 printk 等 // 模块许可证声明必须 MODULE_LICENSE(GPL); // 模块作者声明可选 MODULE_AUTHOR(Your Name); // 模块描述可选 MODULE_DESCRIPTION(A simple Hello World Linux driver.); // 模块版本可选 MODULE_VERSION(0.1); // 模块加载函数当使用 insmod 加载时调用 static int __init hello_init(void) { // printk 是内核空间的“printf”KERN_INFO 是日志级别 printk(KERN_INFO Hello, World! Driver loaded.\n); return 0; // 返回 0 表示初始化成功 } // 模块卸载函数当使用 rmmod 卸载时调用 static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, World! Driver unloaded.\n); } // 指定模块的入口和出口函数 module_init(hello_init); module_exit(hello_exit);关键代码解释头文件linux/module.h是所有模块必需的。linux/init.h包含module_init和module_exit宏。linux/kernel.h包含printk。MODULE_LICENSE必须声明。通常使用 “GPL”否则模块可能无法使用某些内核GPL-only的符号函数/变量甚至加载失败。__init和__exit这些是宏用于提示编译器将相关函数放到特定的内存段。__init标识的代码在初始化后可以被释放以节省内存__exit标识的代码在模块内置而非可加载时直接被丢弃。printk内核日志函数。KERN_INFO是日志级别定义消息的紧急程度。输出不会直接显示在终端而是写入内核日志缓冲区。module_init与module_exit这两个宏将我们定义的函数注册为模块的入口和出口。这是驱动程序的固定套路。2.2 编写 Makefile内核模块的编译不能使用普通的gcc命令需要借助内核构建系统。我们需要编写一个Makefile来告诉make工具如何编译。在同一目录下创建Makefile文件注意首字母大写# 指定内核模块名称最终生成的 .ko 文件 obj-m : hello.o # 获取当前内核的构建目录 KERNEL_DIR ? /lib/modules/$(shell uname -r)/build # 定义默认的构建目标 all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules # 清理编译生成的文件 clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) cleanMakefile 解释obj-m : hello.o告诉内核构建系统我们要构建一个名为hello.ko的模块它由hello.o生成。KERNEL_DIR指向当前运行内核的构建目录通常是/lib/modules/$(uname -r)/build的符号链接。-C选项让make先切换到该目录使用内核顶层的Makefile。M$(PWD)告诉内核构建系统模块的源代码位于当前目录。modules是内核顶层Makefile中定义的目标专门用于编译外部模块。2.3 编译模块在包含hello.c和Makefile的目录下执行make命令make如果一切顺利你将看到类似以下的输出并生成几个新文件其中最重要的是hello.ko内核模块文件。make -C /lib/modules/5.15.0-91-generic/build M/home/yourname/hello_driver modules make[1]: Entering directory /usr/src/linux-headers-5.15.0-91-generic CC [M] /home/yourname/hello_driver/hello.o MODPOST /home/yourname/hello_driver/Module.symvers CC [M] /home/yourname/hello_driver/hello.mod.o LD [M] /home/yourname/hello_driver/hello.ko BTF [M] /home/yourname/hello_driver/hello.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic生成的文件中hello.ko最终的内核模块文件用于加载。hello.o编译后的目标文件。hello.mod.c,hello.mod.o模块信息文件。Module.symvers模块符号版本信息。.hello.ko.cmd,.hello.o.cmd等编译过程文件。3. 加载、测试与卸载模块编译成功后我们将在系统中动态操作这个模块。3.1 加载模块使用insmodinsert module命令加载模块需要 root 权限sudo insmod hello.ko命令执行后看似没有输出因为printk的信息输出到了内核日志中。3.2 查看模块与日志查看已加载模块列表使用lsmod命令可以过滤查看我们的模块lsmod | grep hello你应该能看到一行输出包含模块名hello、大小和被引用次数。查看内核日志printk的输出需要通过dmesg命令查看内核环形缓冲区。为了看到最新的日志我们通常查看尾部sudo dmesg | tail -5或者使用-c参数清空并显示当前缓冲区内容生产环境慎用sudo dmesg -c在输出中你应该能看到类似这样的信息[ 1234.567890] Hello, World! Driver loaded.这证明我们的模块加载函数hello_init被成功调用了。3.3 卸载模块使用rmmodremove module命令卸载模块sudo rmmod hello注意rmmod后面跟的是模块名hello而不是文件名hello.ko。再次查看内核日志确认卸载函数被调用sudo dmesg | tail -5输出中应该新增了一行[ 1234.654321] Goodbye, World! Driver unloaded.3.4 模块信息查询使用modinfo命令可以查看模块的元信息这些信息来自源代码中的MODULE_*宏modinfo hello.ko输出示例filename: /home/yourname/hello_driver/hello.ko version: 0.1 description: A simple Hello World Linux driver. author: Your Name license: GPL srcversion: ABCDEF1234567890ABCDEF0 depends: name: hello vermagic: 5.15.0-91-generic SMP mod_unload modversions4. 进阶创建一个简单的字符设备驱动仅打印日志的模块意义有限。真正的驱动需要与用户空间交互。字符设备驱动是最常见的一类它像字节流一样被顺序访问如键盘、鼠标、串口。接下来我们创建一个虚拟的字符设备驱动它允许用户空间程序通过文件接口/dev/hello_dev进行读写。4.1 字符设备驱动框架Linux 内核提供了一套完整的框架来简化字符设备驱动的编写核心是cdev字符设备结构和file_operations文件操作结构。创建新文件hello_char.c// hello_char.c #include linux/module.h #include linux/kernel.h #include linux/fs.h // 包含 file_operations 结构 #include linux/cdev.h // 包含 cdev 结构 #include linux/device.h // 用于自动创建设备节点可选但推荐 #include linux/uaccess.h // 包含 copy_to_user/copy_from_user #define DEVICE_NAME hello_dev #define CLASS_NAME hello_class static int major_num 0; // 动态分配主设备号 static struct class* hello_class NULL; static struct cdev hello_cdev; // 简单的设备内存缓冲区 static char device_buffer[256] {0}; static int buffer_len 0; // 设备打开函数 static int dev_open(struct inode *inodep, struct file *filep) { printk(KERN_INFO HelloDev: Device opened.\n); return 0; } // 设备读函数 static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset) { int bytes_to_copy; int ret; // 计算还剩多少数据可读 bytes_to_copy buffer_len - *offset; if (bytes_to_copy 0) { return 0; // EOF } if (bytes_to_copy len) { bytes_to_copy len; } // 将内核空间的数据复制到用户空间缓冲区 ret copy_to_user(buffer, device_buffer *offset, bytes_to_copy); if (ret) { printk(KERN_ERR HelloDev: Failed to copy %d bytes to user.\n, ret); return -EFAULT; } *offset bytes_to_copy; printk(KERN_INFO HelloDev: Sent %d bytes to user.\n, bytes_to_copy); return bytes_to_copy; } // 设备写函数 static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset) { int bytes_to_copy; // 防止写入超过缓冲区大小 if (len sizeof(device_buffer)) { printk(KERN_WARNING HelloDev: Write length exceeds buffer size.\n); return -ENOMEM; } // 将用户空间的数据复制到内核空间缓冲区 if (copy_from_user(device_buffer, buffer, len)) { printk(KERN_ERR HelloDev: Failed to copy data from user.\n); return -EFAULT; } buffer_len len; *offset len; printk(KERN_INFO HelloDev: Received %zu bytes from user: %s\n, len, device_buffer); return len; } // 设备释放函数 static int dev_release(struct inode *inodep, struct file *filep) { printk(KERN_INFO HelloDev: Device closed.\n); return 0; } // 定义文件操作结构关联我们的函数 static struct file_operations fops { .owner THIS_MODULE, .open dev_open, .read dev_read, .write dev_write, .release dev_release, }; // 模块初始化函数 static int __init hello_char_init(void) { dev_t dev_num; int ret; printk(KERN_INFO HelloDev: Initializing character device driver.\n); // 1. 动态申请一个主设备号和次设备号 ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { printk(KERN_ERR HelloDev: Failed to allocate chrdev region. Error %d\n, ret); return ret; } major_num MAJOR(dev_num); printk(KERN_INFO HelloDev: Allocated major number %d.\n, major_num); // 2. 初始化 cdev 结构并将其与 file_operations 关联 cdev_init(hello_cdev, fops); hello_cdev.owner THIS_MODULE; // 3. 将 cdev 添加到内核系统 ret cdev_add(hello_cdev, dev_num, 1); if (ret 0) { printk(KERN_ERR HelloDev: Failed to add cdev. Error %d\n, ret); unregister_chrdev_region(dev_num, 1); return ret; } // 4. 使用 udev/class 接口自动创建设备节点推荐 hello_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(hello_class)) { printk(KERN_ERR HelloDev: Failed to create device class.\n); cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(hello_class); } // 在 /dev/ 下创建设备文件 hello_dev device_create(hello_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO HelloDev: Device node /dev/%s created.\n, DEVICE_NAME); return 0; } // 模块卸载函数 static void __exit hello_char_exit(void) { dev_t dev_num MKDEV(major_num, 0); // 清理顺序与初始化相反 device_destroy(hello_class, dev_num); class_destroy(hello_class); cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO HelloDev: Character device driver unloaded.\n); } module_init(hello_char_init); module_exit(hello_char_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple character device driver example.);4.2 更新 Makefile 并编译修改Makefile使其能编译新的模块obj-m : hello.o hello_char.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean执行make编译将生成hello_char.ko。4.3 加载模块与测试设备加载模块sudo insmod hello_char.ko检查设备加载成功后应该自动在/dev/下创建了设备节点。ls -l /dev/hello_dev输出类似crw------- 1 root root 241, 0 ...其中241是动态分配的主设备号0是次设备号。测试写入使用echo命令通过 shell 向设备写入数据。sudo echo Hello from userspace /dev/hello_dev查看内核日志sudo dmesg | tail -5会看到驱动收到了数据。测试读取使用cat命令从设备读取数据。sudo cat /dev/hello_dev输出应为之前写入的 “Hello from userspace”。再次查看日志确认读操作被记录。卸载模块sudo rmmod hello_char检查/dev/hello_dev文件应已自动删除。5. 常见问题与排查路径驱动开发过程中编译、加载、运行各阶段都可能出错。以下是典型问题及排查方法。问题现象可能原因检查与解决步骤make编译失败提示找不到内核头文件1. 内核头文件未安装。2.KERNEL_DIR路径错误。1. 运行sudo apt install linux-headers-$(uname -r)。2. 确认/lib/modules/$(uname -r)/build是否存在且是有效链接。insmod失败提示Invalid module format模块编译所用的内核版本与当前运行内核版本不匹配。1. 检查uname -r与编译时内核头文件版本是否一致。2. 运行sudo depmod -a更新模块依赖然后重试。3. 彻底清理 (make clean) 后重新编译。insmod失败提示Operation not permitted没有使用sudo或当前用户无权限。使用sudo执行insmod。insmod失败提示Unknown symbol in module模块引用了未导出的内核符号函数或变量。1. 检查代码中是否使用了非公开的内核API。2. 使用sudo cat /proc/kallsyms | grep symbol_name查看符号是否存在及是否以T或t标记表示全局可见。3. 可能需要修改代码或将该模块与定义符号的模块一起编译。rmmod失败提示Module hello is in use模块正在被使用如设备文件被打开。1. 关闭所有使用该设备文件的进程。2. 使用sudo lsof | grep /dev/hello_dev查找并关闭相关进程。3. 使用lsmod查看模块的“Used by”计数。加载后dmesg看不到printk输出1. 日志级别过低。2. 内核printk日志级别设置过滤了信息。1. 确保printk使用KERN_INFO或更高优先级如KERN_WARNING。2. 查看当前控制台日志级别cat /proc/sys/kernel/printk。第二个值控制台级别如果低于消息级别如KERN_INFO是6则消息不会打印到控制台但仍可用dmesg查看。3. 使用sudo dmesg -w实时监控内核日志。用户程序读写/dev/hello_dev失败权限不足设备节点默认权限为600仅 root 可读写。1. 使用sudo运行测试程序。2. 在驱动初始化代码中使用device_create时设置权限较复杂。3. 创建 udev 规则在加载模块时自动修改设备文件权限生产环境做法。系统不稳定或崩溃内核恐慌驱动代码存在严重错误如空指针解引用、内存越界、死锁等。1. 这是内核编程的严重错误。2. 重启系统。3. 仔细检查代码尤其是内存操作和指针使用。4. 使用printk增加调试信息定位崩溃前最后执行的语句。5. 考虑在内核编译时启用CONFIG_DEBUG_KERNEL、CONFIG_DEBUG_KMEMLEAK等调试选项。6. 驱动开发最佳实践与扩展方向掌握了基础框架后要写出稳定、可靠的驱动还需要遵循以下实践并了解更广阔的方向。6.1 开发与调试最佳实践增量开发与测试不要一次性写太多功能。先让模块能加载卸载再加一个open函数然后逐步增加read/write/ioctl等。每步都测试。善用printk这是内核调试最直接的工具。使用不同的日志级别KERN_DEBUG,KERN_INFO,KERN_WARNING,KERN_ERR。可以使用pr_debug,pr_info,pr_warn,pr_err等宏简化书写。资源管理遵循“谁申请谁释放”的原则。在init函数中申请的资源内存、设备号、cdev、class必须在exit函数中按相反顺序精确释放。错误处理内核函数调用后必须检查返回值。一旦失败要跳转到错误处理路径清理已申请的资源。并发控制如果设备可能被多个进程同时访问必须使用内核提供的同步机制如信号量semaphore、互斥锁mutex、自旋锁spinlock来保护共享数据如我们例子中的device_buffer和buffer_len。这是生产驱动与示例驱动最大的区别之一。代码规范遵循 Linux 内核编码风格可使用scripts/checkpatch.pl检查。变量和函数命名清晰添加必要的注释。6.2 下一步学习方向完善字符设备驱动实现ioctl接口用于实现更复杂的设备控制命令。添加poll/select支持使应用程序能够进行非阻塞或异步I/O操作。实现mmap将设备内存映射到用户空间实现零拷贝高性能访问。探索其他设备类型平台设备驱动用于描述片上系统SoC中的内置外设与设备树Device Tree紧密相关是嵌入式 Linux 驱动开发的核心。块设备驱动用于磁盘、SD卡等以数据块为单位访问的设备。网络设备驱动用于网卡。深入内核机制中断处理硬件设备通过中断通知CPU事件驱动需要注册中断处理函数。内核定时器与工作队列实现延迟操作和将工作推后到进程上下文执行。DMA直接内存访问用于大数据量传输减轻CPU负担。电源管理实现设备的休眠、唤醒等省电功能。使用更现代的框架设备树学习如何编写和修改设备树.dts文件将硬件资源配置从代码中解耦出来。设备模型深入理解bus,device,driver,class等核心结构以及probe,remove等标准接口。实战项目找一个简单的真实硬件如一个GPIO控制的LED一个I2C温度传感器为其编写完整的驱动并编写对应的用户空间测试程序。这是巩固知识的最佳途径。驱动开发是连接硬件与软件的桥梁需要耐心、严谨和对细节的关注。从本文的“Hello World”模块出发理解每一个步骤背后的原理然后逐步挑战更复杂的功能和真实的硬件你就能真正掌握 Linux 内核编程的精髓。记住内核编程无小事每一次insmod都请做好可能需重启的准备尤其在虚拟机或开发板上进行并养成随时查看dmesg日志的习惯。 30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度