🚀 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.koprintk的信息输出到了内核日志。 - 查看模块和日志 :
- 使用
lsmod命令查看当前已加载的所有模块,并过滤出我们的模块:
应该能看到lsmod | grep hellohello模块及其占用内存大小。 - 使用
dmesg命令查看内核环形缓冲区的最新消息,或者用tail查看系统日志:
你应该能看到我们打印的dmesg | tail -5 # 或 tail -f /var/log/syslog“My First Driver: Hello, Kernel World!”。
- 使用
- 卸载模块 :使用
rmmod(remove module) 命令。
注意 :sudo rmmod hellormmod后面跟的是 模块名 (hello),而不是文件名(hello.ko)。 - 再次查看日志 ,确认卸载信息也被打印:
现在你应该能看到两条信息:加载时的dmesg | tail -5Hello和卸载时的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/下设备文件默认属主是root):
查看echo "Hello from userspace!" | sudo tee /dev/my_char_devdmesg,会看到驱动收到了数据。 - 读取数据 :
你应该能看到刚才写入的sudo cat /dev/my_char_dev“Hello from userspace!”。再次cat,由于我们的简单实现,读位置已到末尾,会返回空。 - 查看设备信息 :
# 查看 /proc/devices 中注册的字符设备,找到我们的主设备号 cat /proc/devices | grep my_char # 查看设备节点的详细信息 ls -l /dev/my_char_dev
4.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_destroy -
cdev_del -
unregister_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 折。 👉 点击领海量免费额度


2371

被折叠的 条评论
为什么被折叠?



