C语言驱动开发实战:从零编写一个Linux字符设备

前言:深入Linux内核的心脏

在浩瀚的软件世界中,驱动程序(Driver)扮演着一个独特而关键的角色。它们是硬件和操作系统之间的桥梁,是无名的幕后英雄,确保了我们日常使用的打印机、鼠标、键盘、显卡等一切外设能够与操作系统顺畅地沟通。对于一名追求技术深度的C语言开发者来说,学习编写驱动程序无疑是一次激动人心的探险,它将带你深入Linux内核的心脏地带。

本篇博客是一篇长篇实战教程,我们将从零开始,一步步在Linux环境下,使用C语言编写一个功能完整的字符设备驱动。这个过程不仅能让你掌握驱动开发的核心概念,更能极大地加深你对操作系统工作原理的理解。

我们将要做什么?

我们将创建一个名为 gemini_char_dev 的虚拟字符设备。它虽然不对应任何真实的物理硬件,但能完美地模拟驱动的核心行为:

  1. 动态加载与卸载:像模块一样插入内核或从中移除。
  2. 创建设备文件:在 /dev 目录下生成一个设备文件节点。
  3. 响应读写操作:当用户程序读取或写入这个设备文件时,我们的驱动能做出响应,在内核空间和用户空间之间传递数据。

准备好了吗?让我们一起开始这场深入内核的旅程!

第一部分:准备工作与环境搭建

工欲善其事,必先利其器。在开始编码之前,我们需要确保开发环境已经准备就绪。

1. 确认Linux环境

本教程适用于任何主流的Linux发行版,如 Ubuntu, Debian, CentOS, Arch Linux等。你可以使用物理机,也可以使用VMware或VirtualBox安装的虚拟机。

2. 安装编译工具链

我们需要 gcc 编译器和 make 构建工具。通常它们都是系统自带的,你可以通过以下命令检查和安装:

1
2
3
4
5
6
# 对于Debian/Ubuntu系统
sudo apt update
sudo apt install build-essential

# 对于CentOS/RHEL系统
sudo yum groupinstall "Development Tools"

3. 安装Linux内核头文件

驱动程序是内核的一部分,编译驱动需要引用内核的头文件和数据结构。必须保证安装的内核头文件版本与当前正在运行的内核版本完全一致。

首先,使用 uname -r 查看当前内核版本:

1
2
$ uname -r
5.15.0-78-generic

然后,安装对应的头文件包:

1
2
3
4
5
# 对于Debian/Ubuntu系统 (将版本号替换为你自己的)
sudo apt install linux-headers-5.15.0-78-generic

# 如果你想让头文件自动跟随内核更新,可以安装通用包
sudo apt install linux-headers-$(uname -r)

环境搭建完成!现在,我们可以开始编写第一个内核模块了。

第二部分:驱动初体验:编写第一个内核模块

在正式实现字符设备之前,我们先来学习一下Linux内核模块(Kernel Module)的基础。内核模块是一种可以动态加载到内核或从内核移除的代码块,我们的驱动正是以模块的形式存在的。

1. Hello, Kernel!

让我们编写一个最简单的内核模块,它只在加载和卸载时通过内核日志打印信息。

创建一个名为 hello_kernel.c 的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

// 模块加载时执行的函数
static int __init hello_init(void) {
printk(KERN_INFO "Hello, Kernel! Gemini driver is loaded.\n");
return 0;
}

// 模块卸载时执行的函数
static void __exit hello_exit(void) {
printk(KERN_INFO "Goodbye, Kernel! Gemini driver is unloaded.\n");
}

// 注册模块的入口和出口
module_init(hello_init);
module_exit(hello_exit);

// 模块许可证声明
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Gemini");
MODULE_DESCRIPTION("A simple Hello World kernel module.");

代码解读:

  • linux/init.h, linux/module.h, linux/kernel.h: 驱动开发最基本、最常用的头文件。
  • printk: 内核空间的 printf,用于输出日志。KERN_INFO 是日志级别。
  • __init__exit: 这两个宏告诉编译器,hello_init 函数仅在模块初始化时需要,hello_exit 仅在退出时需要。内核在模块加载成功后,可能会回收这部分内存。
  • module_init()module_exit(): 用于注册模块的初始化和清理函数。
  • MODULE_LICENSE("GPL"): 必须的许可证声明。没有它,模块加载时内核会发出警告。

2. 编写Makefile

内核模块的编译方式与普通用户程序不同,需要一个特殊的 Makefile 文件。在与 hello_kernel.c 相同的目录下创建 Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
# 内核模块的源码文件名
obj-m += hello_kernel.o

# 内核源码树的路径
KDIR := /lib/modules/$(shell uname -r)/build

# 默认目标
all:
$(MAKE) -C $(KDIR) M=$(shell pwd) modules

# 清理目标
clean:
$(MAKE) -C $(KDIR) M=$(shell pwd) clean

Makefile解读:

  • obj-m += hello_kernel.o: 表示我们要将 hello_kernel.c 编译成一个名为 hello_kernel.ko 的内核模块(.ko 代表 Kernel Object)。
  • -C $(KDIR): 指示 make 命令切换到内核源码树目录去执行。
  • M=$(shell pwd): 告诉内核构建系统,我们的模块源码在当前目录。

3. 编译与测试

现在,我们可以编译和测试我们的第一个内核模块了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 编译模块
make
# 此时目录下会生成 hello_kernel.ko 文件

# 加载模块
sudo insmod hello_kernel.ko

# 查看已加载的模块
lsmod | grep hello_kernel

# 查看内核日志,确认我们的信息已打印
dmesg | tail

# 卸载模块
sudo rmmod hello_kernel

# 再次查看内核日志
dmesg | tail

如果一切顺利,你将在 dmesg 的输出中看到 “Hello, Kernel!” 和 “Goodbye, Kernel!” 的信息。恭喜你,你已经成功迈出了驱动开发的第一步!

第三部分:核心功能:实现字符设备

有了内核模块的基础,我们现在可以开始实现真正的字符设备驱动了。

1. 核心概念

  • 设备号 (Device Number): 在Linux中,每个设备都由一个设备号唯一标识。设备号分为主设备号(Major Number)和次设备号(Minor Number)。主设备号标识设备类型(例如哪一个驱动程序),次设备号标识该类型的具体设备(例如同一驱动管理的第一个或第二个设备)。
  • file_operations: 这是一个函数指针结构体,定义了字符设备所有可能的入口点,如 open, read, write, close 等。我们将实现其中的一些函数,并将这个结构体注册到内核。
  • cdev: 这是内核中代表字符设备的结构体。我们需要申请并初始化一个 cdev 结构体,并将其与我们的 file_operations 关联起来。

2. 驱动代码框架

我们将创建一个新文件 gemini_char_dev.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h> // For file_operations
#include <linux/cdev.h> // For cdev structure
#include <linux/uaccess.h> // For copy_to_user, copy_from_user

// --- 模块元信息 ---
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Gemini");
MODULE_DESCRIPTION("A practical Linux Character Device Driver.");

// --- 全局变量 ---
static dev_t dev_num; // 设备号
static struct cdev gemini_cdev; // 字符设备结构体
static struct class *dev_class; // 设备类

#define DRIVER_NAME "gemini_char_dev"
#define CLASS_NAME "gemini_class"

// --- file_operations 函数声明 ---
static int dev_open(struct inode *inodep, struct file *filep);
static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset);
static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset);
static int dev_release(struct inode *inodep, struct file *filep);

// --- file_operations 结构体定义 ---
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = dev_open,
.read = dev_read,
.write = dev_write,
.release = dev_release,
};

// --- 模块初始化函数 ---
static int __init gemini_driver_init(void) {
printk(KERN_INFO "Initializing Gemini Character Driver...\n");

// 1. 动态分配主设备号
if (alloc_chrdev_region(&dev_num, 0, 1, DRIVER_NAME) < 0) {
printk(KERN_ERR "Failed to allocate major number\n");
return -1;
}
printk(KERN_INFO "Allocated major number: %d\n", MAJOR(dev_num));

// 2. 初始化 cdev 结构体
cdev_init(&gemini_cdev, &fops);

// 3. 将 cdev 添加到内核
if (cdev_add(&gemini_cdev, dev_num, 1) < 0) {
printk(KERN_ERR "Failed to add the cdev to the kernel\n");
goto r_cdev_add;
}

// 4. 创建设备类
dev_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(dev_class)) {
printk(KERN_ERR "Failed to create the device class\n");
goto r_class_create;
}

// 5. 创建设备文件
if (IS_ERR(device_create(dev_class, NULL, dev_num, NULL, DRIVER_NAME))) {
printk(KERN_ERR "Failed to create the device file\n");
goto r_device_create;
}

printk(KERN_INFO "Gemini Character Driver initialized successfully.\n");
return 0;

// 错误处理:goto标签用于在失败时按相反顺序清理资源
r_device_create:
class_destroy(dev_class);
r_class_create:
cdev_del(&gemini_cdev);
r_cdev_add:
unregister_chrdev_region(dev_num, 1);
return -1;
}

// --- 模块卸载函数 ---
static void __exit gemini_driver_exit(void) {
device_destroy(dev_class, dev_num);
class_destroy(dev_class);
cdev_del(&gemini_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "Gemini Character Driver unloaded.\n");
}

// --- 注册模块入口和出口 ---
module_init(gemini_driver_init);
module_exit(gemini_driver_exit);

这个框架完成了驱动的初始化和清理工作。init函数按顺序:分配设备号 -> 初始化cdev -> 添加cdev到内核 -> 创建设备类 -> 创建设备文件。exit函数则以完全相反的顺序销毁这些资源。这种对称的资源管理是驱动开发中的一个重要原则。

接下来,我们将实现 file_operations 中的四个核心函数。

第四部分:完整代码与测试

现在,我们来填充 dev_open, dev_read, dev_write, dev_release 的具体实现,并给出完整的测试流程。

1. 完整驱动代码

gemini_char_dev.cfile_operations 定义之前,加入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// --- 全局变量 ---
// ... (之前的全局变量) ...
#define MEM_SIZE 1024
static char kernel_buffer[MEM_SIZE]; // 用于存储写入数据的内核缓冲区

// --- file_operations 函数实现 ---

static int dev_open(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "Gemini device opened.\n");
return 0;
}

static int dev_release(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "Gemini device closed.\n");
return 0;
}

static ssize_t dev_read(struct file *filep, char __user *buffer, size_t len, loff_t *offset) {
int bytes_read = 0;
char *msg_ptr = "Hello from Gemini's driver!\n";
int msg_len = strlen(msg_ptr);

// 如果偏移量超出了消息长度,说明已经读完
if (*offset >= msg_len) {
return 0;
}

// 还能读取的字节数
bytes_read = msg_len - *offset;
if (bytes_read > len) {
bytes_read = len;
}

// 将内核数据拷贝到用户空间
if (copy_to_user(buffer, msg_ptr + *offset, bytes_read) != 0) {
return -E_FAULT;
}

// 更新偏移量
*offset += bytes_read;

printk(KERN_INFO "Sent %d characters to the user.\n", bytes_read);
return bytes_read;
}

static ssize_t dev_write(struct file *filep, const char __user *buffer, size_t len, loff_t *offset) {
// 确保写入长度不超过我们的缓冲区大小
if (len > MEM_SIZE - 1) {
len = MEM_SIZE - 1;
}

// 将用户空间数据拷贝到内核空间
if (copy_from_user(kernel_buffer, buffer, len) != 0) {
return -E_FAULT;
}
kernel_buffer[len] = '\0'; // 添加字符串结束符

printk(KERN_INFO "Received %zu characters from the user: %s\n", len, kernel_buffer);
return len;
}

代码解读:

  • kernel_buffer: 我们在内核中分配的一个1KB的缓冲区,用于模拟设备内存。
  • dev_open/dev_release: 目前只打印日志,但在真实驱动中可以用于初始化设备或释放资源。
  • dev_read:
    • 它计算还能发送给用户多少数据。
    • 核心是 copy_to_user(),这个函数实现了从内核空间到用户空间的安全数据拷贝。绝不能直接用 memcpy 操作用户空间指针!
    • loff_t *offset 记录了当前文件的读写位置,cat 等命令会自动更新它。
  • dev_write:
    • 核心是 copy_from_user(),它从用户空间安全地拷贝数据到内核空间。
    • 我们接收到数据后,将其打印到内核日志中。

2. 最终的 Makefile

gemini_char_dev.c 创建 Makefile

1
2
3
4
5
6
7
8
9
obj-m += gemini_char_dev.o

KDIR := /lib/modules/$(shell uname -r)/build

all:
$(MAKE) -C $(KDIR) M=$(shell pwd) modules

clean:
$(MAKE) -C $(KDIR) M=$(shell pwd) clean

3. 端到端测试流程

现在,激动人心的时刻到了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 1. 清理旧文件并编译
make clean
make

# 2. 加载驱动模块
sudo insmod gemini_char_dev.ko

# 3. 验证加载情况
lsmod | grep gemini_char_dev
dmesg | tail -n 10 # 你应该能看到初始化成功的日志和分配的主设备号

# 4. 验证设备文件是否创建
# 注意:我们的驱动是自动创建设备文件的,所以不需要手动 mknod
ls -l /dev/gemini_char_dev

# 5. 测试写操作
echo "Hello, this is a test from user space." | sudo tee /dev/gemini_char_dev

# 查看内核日志,确认收到了消息
dmesg | tail

# 6. 测试读操作
sudo cat /dev/gemini_char_dev
# 你应该会在终端看到 "Hello from Gemini's driver!"

# 7. 卸载模块
sudo rmmod gemini_char_dev

# 8. 验证卸载
dmesg | tail
ls -l /dev/gemini_char_dev # 文件应该已被自动删除

第五部分:总结与展望

恭喜你!你已经成功地设计、编码、编译并测试了一个功能虽简但五脏俱全的Linux字符设备驱动。

我们回顾一下关键知识点:

  • 模块化编程: 使用 module_initmodule_exit 管理驱动的生命周期。
  • 对称资源管理: 在 init 中申请的资源,必须在 exit 中以相反的顺序释放。
  • 设备号: 内核用于识别设备的唯一ID。
  • File Operations: 连接用户空间系统调用和驱动程序的关键枢纽。
  • 内核与用户空间隔离: 必须使用 copy_to_user()copy_from_user() 进行安全的数据交换。
  • 设备文件自动创建: 通过 class_createdevice_create 组合,可以实现 udev 自动创建设备文件,提升用户体验。

未来可以探索的方向:

  • IOCTL: 实现 ioctl (Input/Output Control) 接口,允许用户程序对设备进行更复杂的、非标准化的控制。
  • 并发控制: 目前的驱动在多个进程同时读写时存在竞态条件。学习使用互斥锁(Mutex)、自旋锁(Spinlock)等机制来保护共享资源。
  • 中断处理: 如果是真实硬件,学习如何申请和处理硬件中断。
  • 更复杂的设备: 尝试编写块设备驱动或网络设备驱动。

驱动开发的世界广阔而深邃。希望这篇详尽的实战教程能为你打开一扇门,激发你继续探索Linux内核的兴趣。祝你在这条硬核的道路上越走越远!


C语言驱动开发实战:从零编写一个Linux字符设备
https://zhenyu-cpu.github.io/2025/08/22/2025-08-22-c-driver-development-in-action/
作者
zhenyu
发布于
2025年8月22日
许可协议