30款热门AI模型一站整合DeepSeek/GLM/Claude 随心用限时 5 折。 点击领海量免费额度最近在做一个嵌入式项目需要为一块自定义的硬件板卡编写驱动程序。在查阅资料时发现网上关于 Linux 驱动开发的教程要么过于理论要么代码片段零散很难直接上手实践。对于很多从应用层开发转向底层驱动的朋友来说如何从零开始写一个真正能加载、能运行的驱动程序往往是第一个拦路虎。本文将从一个最基础的“Hello World”式内核模块出发手把手带你编写、编译、加载和卸载你的第一个 Linux 驱动程序。我们会深入理解内核模块的机制并在此基础上构建一个简单的字符设备驱动框架。无论你是嵌入式开发者还是对操作系统底层感兴趣的学习者跟着本文的步骤走一遍你就能掌握驱动开发的核心流程和关键概念为后续开发更复杂的设备驱动打下坚实基础。1. 驱动与内核模块核心概念扫盲在动手写代码之前我们必须先厘清几个核心概念内核、驱动、内核模块。这能帮你理解我们到底在做什么以及为什么要这么做。内核 (Kernel)是操作系统的核心负责管理系统的所有硬件资源CPU、内存、磁盘、网络等并为上层应用程序提供统一的、安全的访问接口。你可以把它看作是一个大管家所有硬件相关的操作都必须通过它。驱动程序 (Driver)则是内核中专门用于管理和控制特定硬件设备的代码。每一种硬件如网卡、声卡、USB设备都需要对应的驱动内核通过驱动才能知道如何与硬件“对话”。没有驱动硬件就是一块无法使用的废铁。那么内核模块 (Kernel Module)又是什么它是解决驱动开发灵活性的关键。想象一下如果把所有可能的硬件驱动都直接编译进内核内核会变得无比臃肿启动缓慢而且每次添加新硬件都需要重新编译整个内核这显然不现实。内核模块就是一种可以动态加载到运行中的内核或从内核动态卸载的代码块。驱动程序通常就是以内核模块的形式存在的。它的优势非常明显减小内核体积只在需要时才加载特定驱动。方便开发和调试修改驱动代码后只需重新编译模块并加载无需重启整个系统。扩展内核功能除了驱动文件系统、网络协议等也可以模块化。所以我们常说的“编写Linux驱动”在大多数情况下就是指“编写一个可以编译成内核模块的程序”。本次实战我们就从创建一个最简单的内核模块开始。2. 环境准备与开发须知工欲善其事必先利其器。驱动开发环境与应用层开发有显著不同请务必准备好以下环境。2.1 操作系统与内核版本操作系统推荐使用 Ubuntu 20.04 LTS 或 22.04 LTS 等主流的 Linux 发行版。本文示例基于 Ubuntu 环境。内核头文件这是编译内核模块所必需的。你需要安装与你当前运行内核版本一致的内核头文件包。# 查看当前内核版本 uname -r # 示例输出5.15.0-91-generic # 安装对应版本的内核头文件和开发工具 sudo apt update sudo apt install linux-headers-$(uname -r) build-essentialbuild-essential包含了gcc,make等编译工具链。2.2 开发注意事项非常重要权限要求加载和卸载内核模块需要root权限。后续操作请使用sudo或在root用户下进行。开发环境隔离强烈建议在虚拟机如 VirtualBox/VMware中进行驱动开发练习。因为一个有 bug 的内核模块可能导致系统崩溃内核恐慌Kernel Panic在虚拟机中操作可以轻松恢复快照避免物理机系统损坏。代码谨慎内核模块运行在内核空间拥有最高权限。错误的指针操作、内存越界等问题不仅会导致模块加载失败更可能直接让整个系统宕机。请仔细检查每一行代码。准备好环境后我们就可以开始编写第一个模块了。3. 第一个内核模块Hello World让我们从一个最简单的模块开始它不控制任何硬件只是在加载和卸载时向内核日志中打印信息。这能让我们快速验证整个编译、加载、卸载的流程是否通畅。3.1 创建项目目录和源文件首先创建一个专门的工作目录。mkdir ~/my_first_driver cd ~/my_first_driver然后创建我们的第一个模块源文件hello.c// hello.c - 最简单的Linux内核模块 #include linux/init.h // 包含模块初始化和清理函数的宏 #include linux/module.h // 编写模块必需的头文件定义了模块信息、加载卸载函数等 #include linux/kernel.h // 提供内核打印函数 printk 所需的头文件 // 模块加载时执行的函数 static int __init hello_init(void) { // printk 是内核空间的“printf”用于向内核日志缓冲区打印信息。 // KERN_INFO 是日志级别表示普通信息。消息会出现在系统日志如 /var/log/syslog或 dmesg 命令输出中。 printk(KERN_INFO My First Driver: Hello, Kernel World!\n); return 0; // 返回 0 表示初始化成功返回负值表示失败。 } // 模块卸载时执行的函数 static void __exit hello_exit(void) { printk(KERN_INFO My First Driver: Goodbye, Kernel World!\n); } // 以下宏用于向内核注册模块的入口和出口函数 module_init(hello_init); // 告诉内核hello_init 是这个模块的加载函数 module_exit(hello_exit); // 告诉内核hello_exit 是这个模块的卸载函数 // 以下宏定义模块的元信息 MODULE_LICENSE(GPL); // 声明模块采用 GPL 许可证这是大多数开源内核模块的要求 MODULE_AUTHOR(Your Name); // 模块作者 MODULE_DESCRIPTION(A simple hello world kernel module); // 模块描述 MODULE_VERSION(0.1); // 模块版本代码解析__init和__exit是给编译器看的宏提示这些函数只在初始化/卸载阶段使用内核可能会在完成后释放它们占用的内存。module_init和module_exit是必须的它们将我们定义的函数与模块的生命周期钩子绑定。MODULE_LICENSE(“GPL”)非常重要没有它模块可能会被标记为“污染内核”某些内核功能将不可用。3.2 编写 Makefile内核模块不能直接用gcc编译需要借助内核的构建系统kbuild。我们需要编写一个Makefile。# Makefile for building the hello kernel module # 指定模块名称生成的文件将是 hello.ko obj-m : hello.o # 指定内核源码目录。$(shell uname -r) 会自动获取当前内核版本。 # 如果你的内核头文件安装在标准位置这通常指向 /lib/modules/$(uname -r)/build KERNEL_DIR ? /lib/modules/$(shell uname -r)/build # 当前模块源码所在目录 PWD : $(shell pwd) all: # -C 切换到内核源码目录读取那里的顶层 Makefile # M$(PWD) 告诉内核构建系统模块源码在当前位置 # modules 是内核 Makefile 中定义的目标表示编译外部模块 $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: # 清理编译生成的文件 $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean注意Makefile中的缩进必须是Tab键不能是空格。3.3 编译模块在hello.c和Makefile所在的目录下直接执行make命令make如果一切顺利你会看到类似以下的输出并生成几个新文件其中最重要的就是hello.ko.ko即 Kernel Object内核模块文件。make -C /lib/modules/5.15.0-91-generic/build M/home/yourname/my_first_driver modules make[1]: Entering directory /usr/src/linux-headers-5.15.0-91-generic CC [M] /home/yourname/my_first_driver/hello.o MODPOST /home/yourname/my_first_driver/Module.symvers CC [M] /home/yourname/my_first_driver/hello.mod.o LD [M] /home/yourname/my_first_driver/hello.ko BTF [M] /home/yourname/my_first_driver/hello.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic使用ls命令查看应该能看到hello.ko。3.4 加载、查看与卸载模块加载模块使用insmod(insert module) 命令。sudo insmod hello.ko命令执行后没有输出是正常的因为printk的信息输出到了内核日志。查看模块和日志使用lsmod命令查看当前已加载的所有模块并过滤出我们的模块lsmod | grep hello应该能看到hello模块及其占用内存大小。使用dmesg命令查看内核环形缓冲区的最新消息或者用tail查看系统日志dmesg | tail -5 # 或 tail -f /var/log/syslog你应该能看到我们打印的“My First Driver: Hello, Kernel World!”。卸载模块使用rmmod(remove module) 命令。sudo rmmod hello注意rmmod后面跟的是模块名hello而不是文件名hello.ko。再次查看日志确认卸载信息也被打印dmesg | tail -5现在你应该能看到两条信息加载时的Hello和卸载时的Goodbye。恭喜你已经成功完成了第一个内核模块的完整生命周期编码 - 编译 - 加载 - 卸载。这标志着你已经踏入了 Linux 内核编程的大门。4. 进阶实战构建一个简单的字符设备驱动仅仅打印日志还不够一个真正的驱动需要与用户空间即我们的普通应用程序进行交互。在 Linux 中一切皆文件设备也被抽象成文件。字符设备如键盘、鼠标、串口是一种常见的设备类型我们接下来就实现一个最简单的字符设备驱动它提供一个虚拟的“文件”我们可以对它进行读、写操作。4.1 字符设备驱动框架字符设备驱动的核心是定义一个struct file_operations结构体其中包含了一系列函数指针如open,read,write,release等。当用户空间程序对这个设备文件调用read()系统调用时内核就会调用我们驱动中对应的read函数。创建新的源文件my_char_dev.c// my_char_dev.c - 一个简单的字符设备驱动示例 #include linux/init.h #include linux/module.h #include linux/kernel.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 函数用于内核与用户空间数据交换 #include linux/slab.h // 提供 kmalloc, kfree 函数用于内核空间内存分配 #define DEVICE_NAME my_char_dev // 设备名称将出现在 /proc/devices 和 sysfs 中 #define CLASS_NAME my_char_class // 设备类名称用于 sysfs static int major_number; // 主设备号由内核动态分配 static struct class* my_char_class NULL; // 设备类指针 static struct device* my_char_device NULL; // 设备指针 static struct cdev my_cdev; // 字符设备结构体 // 我们用一个简单的全局缓冲区来模拟设备数据 #define BUFFER_SIZE 1024 static char device_buffer[BUFFER_SIZE]; static int buffer_offset 0; // 模拟当前“读”位置 // 当设备文件被打开时调用 static int dev_open(struct inode *inodep, struct file *filep){ printk(KERN_INFO MyCharDev: Device has been opened.\n); return 0; } // 当设备文件被关闭时调用 static int dev_release(struct inode *inodep, struct file *filep){ printk(KERN_INFO MyCharDev: Device has been closed.\n); return 0; } // 当从设备文件读取时调用 static ssize_t dev_read(struct file *filep, char __user *buffer, size_t len, loff_t *offset){ int bytes_to_read; int bytes_not_copied; // 计算还能从缓冲区读取多少字节 bytes_to_read BUFFER_SIZE - buffer_offset; if(bytes_to_read len) { bytes_to_read len; } if (bytes_to_read 0) { printk(KERN_INFO MyCharDev: No more data to read.\n); return 0; // 返回 0 表示文件结束 (EOF) } // 将内核缓冲区 (device_buffer buffer_offset) 的数据拷贝到用户空间 buffer // copy_to_user 返回未能成功拷贝的字节数0 表示全部成功。 bytes_not_copied copy_to_user(buffer, device_buffer buffer_offset, bytes_to_read); if (bytes_not_copied) { printk(KERN_ERR MyCharDev: Failed to send %d bytes to user.\n, bytes_not_copied); return -EFAULT; // 返回一个错误码表示地址错误 } printk(KERN_INFO MyCharDev: Sent %d bytes to user.\n, bytes_to_read); buffer_offset bytes_to_read; // 更新读取位置 return bytes_to_read; // 返回成功读取的字节数 } // 当向设备文件写入时调用 static ssize_t dev_write(struct file *filep, const char __user *buffer, size_t len, loff_t *offset){ int bytes_not_copied; // 检查写入长度是否超过我们的缓冲区 if (len BUFFER_SIZE) { printk(KERN_WARNING MyCharDev: Write request too large (%zu bytes).\n, len); return -ENOMEM; // 返回内存不足错误 } // 将用户空间 buffer 的数据拷贝到内核缓冲区 device_buffer // 注意这个简单示例会覆盖之前的数据且每次写入都从缓冲区开头开始。 bytes_not_copied copy_from_user(device_buffer, buffer, len); if (bytes_not_copied) { printk(KERN_ERR MyCharDev: Failed to receive %d bytes from user.\n, bytes_not_copied); return -EFAULT; } buffer_offset 0; // 重置读位置准备从头开始读 printk(KERN_INFO MyCharDev: Received %zu bytes from user. Buffer: %s\n, len, device_buffer); return len; // 返回成功写入的字节数 } // 定义文件操作结构体将我们的函数与标准操作绑定 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){ int retval; dev_t dev_num; printk(KERN_INFO MyCharDev: Initializing the module.\n); // 1. 动态申请一个主设备号也可以静态指定但动态分配更安全避免冲突 retval alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (retval 0) { printk(KERN_ERR MyCharDev: Failed to allocate device number.\n); return retval; } major_number MAJOR(dev_num); // 从设备号中提取主设备号 printk(KERN_INFO MyCharDev: Registered with major number %d.\n, major_number); // 2. 初始化 cdev 结构体并将其与 file_operations 关联 cdev_init(my_cdev, fops); my_cdev.owner THIS_MODULE; // 3. 将 cdev 添加到内核系统 retval cdev_add(my_cdev, dev_num, 1); if (retval 0) { printk(KERN_ERR MyCharDev: Failed to add cdev to system.\n); unregister_chrdev_region(dev_num, 1); return retval; } // 4. 在 /sys/class/ 下创建设备类可选但强烈推荐便于udev自动创建设备节点 my_char_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_char_class)) { printk(KERN_ERR MyCharDev: Failed to create device class.\n); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_char_class); } // 5. 在 /dev/ 下自动创建设备节点 // 设备节点名字就是 DEVICE_NAME权限为 0666 (rw-rw-rw-) my_char_device device_create(my_char_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(my_char_device)) { printk(KERN_ERR MyCharDev: Failed to create the device.\n); class_destroy(my_char_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_char_device); } // 初始化设备缓冲区 memset(device_buffer, 0, BUFFER_SIZE); buffer_offset 0; printk(KERN_INFO MyCharDev: Module initialized successfully. Device node: /dev/%s\n, DEVICE_NAME); return 0; } // --- 模块清理函数 --- static void __exit my_char_dev_exit(void){ dev_t dev_num MKDEV(major_number, 0); // 根据主设备号和次设备号0生成完整的设备号 printk(KERN_INFO MyCharDev: Removing the module.\n); // 清理顺序与初始化相反 device_destroy(my_char_class, dev_num); class_destroy(my_char_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO MyCharDev: Module removed.\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 example); MODULE_VERSION(1.0);这个驱动比hello.c复杂得多但它展示了一个字符设备驱动的完整骨架。核心步骤是申请设备号 - 初始化cdev- 添加cdev到系统 - 可选但推荐创建设备类和设备节点。4.2 编译与加载新驱动为这个驱动也创建一个Makefile或者修改之前的将obj-m : hello.o改为obj-m : my_char_dev.o。然后编译make加载模块sudo insmod my_char_dev.ko使用dmesg | tail -10查看日志你会看到模块初始化成功并打印出分配的主设备号例如247以及设备节点路径/dev/my_char_dev。4.3 在用户空间测试驱动现在我们可以像操作普通文件一样操作/dev/my_char_dev了。写入数据需要sudo因为/dev/下设备文件默认属主是rootecho Hello from userspace! | sudo tee /dev/my_char_dev查看dmesg会看到驱动收到了数据。读取数据sudo cat /dev/my_char_dev你应该能看到刚才写入的“Hello from userspace!”。再次cat由于我们的简单实现读位置已到末尾会返回空。查看设备信息# 查看 /proc/devices 中注册的字符设备找到我们的主设备号 cat /proc/devices | grep my_char # 查看设备节点的详细信息 ls -l /dev/my_char_dev4.4 卸载模块测试完成后卸载模块sudo rmmod my_char_dev检查/dev/my_char_dev文件是否被自动删除是的device_destroy会负责这个清理工作。5. 常见问题与排查思路在驱动开发过程中你一定会遇到各种问题。下面是一些常见错误及其解决方法。问题现象可能原因排查与解决思路make失败提示找不到内核头文件1. 未安装linux-headers。2.KERNEL_DIR路径错误。1. 运行sudo apt install linux-headers-$(uname -r)。2. 检查/lib/modules/$(uname -r)/build是否存在并确认Makefile中的路径。insmod失败提示Invalid module format模块编译所用的内核版本与当前运行的内核版本不一致。确保编译环境linux-headers与运行内核uname -r版本完全一致。在虚拟机中开发时重启后内核可能更新需要重新安装头文件并编译。insmod失败提示Operation not permitted权限不足。使用sudo执行insmod。insmod失败无明确错误但dmesg显示模块初始化函数返回负值模块的初始化函数 (__init) 执行失败返回了错误码如-ENOMEM内存不足。仔细检查初始化函数中的每一步特别是资源申请如kmalloc,alloc_chrdev_region是否成功并查看dmesg中更早的详细错误信息。rmmod失败提示Module XXX is in use模块正在被使用例如设备文件被某个进程打开着。1. 使用lsof /dev/your_device或fuser /dev/your_device查看是哪个进程在使用。2. 关闭使用该设备的程序或终端。3. 在驱动的release函数中确保正确释放了资源。模块加载后系统卡死或重启模块代码存在严重错误如空指针解引用、死循环、锁未释放等导致内核恐慌 (Kernel Panic)。1.务必在虚拟机中开发。2. 简化代码使用printk逐步调试。3. 检查所有指针在使用前是否有效。4. 避免在中断上下文或持有锁时进行可能导致睡眠的操作。用户程序读写/dev/xxx失败返回Permission denied设备节点的权限不正确默认由udev规则或驱动中device_create的参数决定。1. 加载模块后检查ls -l /dev/your_device的权限。可以手动sudo chmod 666 /dev/your_device临时解决。2. 更规范的做法是在驱动代码中可以通过device_create的devt参数或后续的sysfs属性来设置权限或者编写udev规则。copy_to_user/copy_from_user导致读写失败用户空间缓冲区地址无效或者长度参数有问题。1. 确保传入的len参数是合理的正数。2. 在调用copy_*_user前可以使用access_ok()函数检查用户空间地址是否可访问虽然copy_*_user内部会检查但提前检查更安全。6. 驱动开发最佳实践与工程建议从“能跑”到“好用、稳定、安全”是驱动开发的关键跨越。以下是一些重要的工程实践6.1 错误处理与资源管理内核编程必须极其严谨地处理错误和释放资源。一个黄金法则是申请资源的顺序和释放资源的顺序应该相反。看我们my_char_dev.c的退出函数my_char_dev_exit就是完美的反向操作device_destroy(最后创建的)class_destroycdev_delunregister_chrdev_region(最先申请的) 在初始化函数中任何一步失败都必须回滚释放之前申请的所有资源。6.2 内核内存与用户空间内存内核内存使用kmalloc/kfree类似malloc/free或vmalloc用于大块非连续内存。永远不要直接访问用户空间指针数据交换必须使用copy_to_user和copy_from_user这两个专用函数在内核与用户空间之间拷贝数据。它们会进行必要的安全检查。6.3 并发与同步如果设备可能被多个进程同时打开和操作就必须考虑并发问题。内核提供了多种同步机制信号量 (semaphore)/互斥锁 (mutex)用于保护临界区防止数据竞争。自旋锁 (spinlock)用于在中断上下文或持有时间极短的场景。 在简单的学习驱动中可能用不到但一旦涉及真实硬件或复杂逻辑这是必须考虑的一环。6.4 调试与日志printk是你最好的朋友。使用不同的日志级别KERN_DEBUG,KERN_INFO,KERN_WARNING,KERN_ERR来区分信息重要性。可以使用#define DEBUG宏来包裹调试信息在发布时关闭。更高级的调试可以使用proc文件系统、sysfs接口或内核调试器 (kgdb)。6.5 代码风格与可维护性遵循Linux 内核编码风格Kernel Coding Style。这不仅是规范也影响代码被社区接受的程度。可以使用checkpatch.pl脚本检查代码。为你的驱动编写清晰的注释说明关键数据结构和函数的作用。将驱动代码模块化不同功能的函数放在不同的文件里。6.6 生产环境考量稳定性优先驱动 bug 可能导致系统崩溃测试必须充分。电源管理对于移动设备或笔记本需要实现suspend/resume回调以支持睡眠唤醒。热插拔对于 USB、PCI 等支持热插拔的设备需要完善的热插拔事件处理。兼容性考虑不同内核版本的 API 变化使用#ifdef或内核版本宏来保持兼容。通过本文你不仅学会了如何编写和运行一个内核模块更构建了一个具备完整“打开-读-写-关闭”功能的字符设备驱动框架。这是理解更复杂驱动如网络设备、块设备、USB设备的基石。驱动开发的学习曲线陡峭但回报丰厚。它让你能直接与硬件对话深入理解操作系统的工作原理。建议你以本文的代码为起点尝试以下扩展练习增加ioctl接口实现自定义的命令控制。使用互斥锁保护device_buffer使其支持多进程安全访问。实现llseek函数让驱动支持随机访问文件位置。阅读内核源码找一些简单的真实驱动如drivers/char/mem.c即/dev/null,/dev/zero的实现来学习。内核编程的世界很大从这里出发保持耐心勤于实践你一定能成为驾驭硬件的开发者。如果在实践中遇到问题多查阅内核源码下的Documentation/目录以及dmesg输出的日志它们是最好的老师。 30款热门AI模型一站整合DeepSeek/GLM/Claude 随心用限时 5 折。 点击领海量免费额度