为什么需要学驱动开发?
做嵌入式开发这么久,一直都是在用户空间折腾:写应用、调库、玩框架。但有时候你会发现,有些功能用户空间就是搞不定——比如精确的时序控制、直接操作寄存器、或者硬件中断处理。这时候,你就需要踏入内核空间,写驱动了。
很多人对内核驱动有畏惧心理,觉得"内核编程很危险,搞错了系统就挂了"。确实如此,但没那么可怕。今天我们就从零开始,写一个最简单的字符设备驱动,让你迈出第一步。
需要准备什么?
| 物品 | 型号/规格 | 价格 |
|---|---|---|
| 开发板 | 树莓派 4B / Jetson Nano | ¥350-500 |
| 或者 | 任何运行 Linux 的 ARM 板子 | – |
| 电脑 | 用于交叉编译 | 已有 |
| 杜邦线 | 公对母若干 | ¥10 |
| LED | 5mm 红色 | ¥0.5 |
| 电阻 | 220Ω | ¥0.1 |
| 总计 | ¥360-510 |
如果你已经有树莓派或者 Jetson Nano,那成本几乎为零。没有的话,用虚拟机跑 Ubuntu 也能学习大部分内容(只是没法操作真实 GPIO)。
步骤 1:搭建内核开发环境
首先得安装内核头文件和编译工具链。以 Ubuntu/Debian 为例:
# 更新包列表
sudo apt-get update
# 安装内核头文件和编译工具
sudo apt-get install linux-headers-$(uname -r) build-essential dkms
# 验证安装
ls /lib/modules/$(uname -r)/build
如果看到一堆头文件,说明安装成功了。⚠️ 注意: 内核头文件版本必须和当前运行的内核版本一致,否则编译出来的模块加载会失败。
检查内核版本:
uname -r
# 输出示例:5.15.0-76-generic
步骤 2:编写第一个内核模块
先来个"Hello World"级别的模块,感受一下内核编程的套路。创建文件 hello_module.c:
#include
#include
#include
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World kernel module");
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Kernel! Module loaded.\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, Kernel! Module unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
几个关键点:
MODULE_LICENSE("GPL"):必须声明许可证,否则内核会"污染"警告__init和__exit:告诉内核这些函数只在加载/卸载时调用,可以释放内存printk:内核里的 printf,用KERN_INFO等宏指定日志级别
编写 Makefile:
obj-m += hello_module.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
编译:
make
如果一切顺利,会生成 hello_module.ko 文件。加载它:
# 加载模块
sudo insmod hello_module.ko
# 查看内核日志
dmesg | tail -5
# 应该看到:Hello, Kernel! Module loaded.
# 卸载模块
sudo rmmod hello_module
# 再次查看日志
dmesg | tail -5
# 应该看到:Goodbye, Kernel! Module unloaded.
恭喜,你已经成功编写并运行了第一个内核模块!
步骤 3:编写字符设备驱动
Hello World 太简单了,来点真的。我们要写一个字符设备驱动,可以通过 /dev 文件进行读写。
创建 char_device.c:
#include
#include
#include
#include
#include
#include
#define DEVICE_NAME "mychardev"
#define CLASS_NAME "myclass"
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");
// 设备号
static int major_number;
static struct class* char_class = NULL;
static struct device* char_device = NULL;
static struct cdev char_cdev;
// 设备缓冲区
static char message_buffer[256] = {0};
static int message_size = 0;
// 文件操作函数
static int dev_open(struct inode *inodep, struct file *filep)
{
printk(KERN_INFO "Device opened\n");
return 0;
}
static int dev_release(struct inode *inodep, struct file *filep)
{
printk(KERN_INFO "Device 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_read;
// 计算要读取的字节数
bytes_to_read = min((int)(message_size - *offset), (int)len);
if (bytes_to_read <= 0) {
return 0;
}
// 从内核空间复制到用户空间
bytes_read = bytes_to_read - copy_to_user(buffer,
message_buffer + *offset,
bytes_to_read);
*offset += bytes_read;
printk(KERN_INFO "Device read: %d bytes\n", bytes_read);
return bytes_read;
}
static ssize_t dev_write(struct file *filep, const char __user *buffer,
size_t len, loff_t *offset)
{
int bytes_to_write;
int bytes_written;
// 计算要写入的字节数
bytes_to_write = min((int)len, (int)(sizeof(message_buffer) - 1));
// 从用户空间复制到内核空间
bytes_written = bytes_to_write - copy_from_user(message_buffer,
buffer,
bytes_to_write);
message_buffer[bytes_written] = '\0';
message_size = bytes_written;
printk(KERN_INFO "Device written: %d bytes\n", bytes_written);
return bytes_written;
}
// 文件操作结构体
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = dev_open,
.release = dev_release,
.read = dev_read,
.write = dev_write,
};
// 模块初始化
static int __init char_device_init(void)
{
dev_t dev;
int ret;
// 动态分配设备号
ret = alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
if (ret < 0) {
printk(KERN_ALERT "Failed to allocate major number\n");
return ret;
}
major_number = MAJOR(dev);
printk(KERN_INFO "Registered with major number %d\n", major_number);
// 初始化 cdev 结构
cdev_init(&char_cdev, &fops);
char_cdev.owner = THIS_MODULE;
// 添加字符设备到系统
ret = cdev_add(&char_cdev, dev, 1);
if (ret < 0) {
unregister_chrdev_region(dev, 1);
printk(KERN_ALERT "Failed to add cdev\n");
return ret;
}
// 创建设备类
char_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(char_class)) {
cdev_del(&char_cdev);
unregister_chrdev_region(dev, 1);
printk(KERN_ALERT "Failed to create device class\n");
return PTR_ERR(char_class);
}
// 创建设备节点
char_device = device_create(char_class, NULL, dev, NULL, DEVICE_NAME);
if (IS_ERR(char_device)) {
class_destroy(char_class);
cdev_del(&char_cdev);
unregister_chrdev_region(dev, 1);
printk(KERN_ALERT "Failed to create device\n");
return PTR_ERR(char_device);
}
printk(KERN_INFO "Character device driver initialized\n");
return 0;
}
// 模块退出
static void __exit char_device_exit(void)
{
dev_t dev = MKDEV(major_number, 0);
device_destroy(char_class, dev);
class_destroy(char_class);
cdev_del(&char_cdev);
unregister_chrdev_region(dev, 1);
printk(KERN_INFO "Character device driver unloaded\n");
}
module_init(char_device_init);
module_exit(char_device_exit);
对应的 Makefile(和之前类似):
obj-m += char_device.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
编译并加载:
make
sudo insmod char_device.ko
ls -l /dev/mychardev
# 应该能看到设备节点
测试读写:
# 写入数据
echo "Hello from user space!" > /dev/mychardev
# 读取数据
cat /dev/mychardev
# 应该输出:Hello from user space!
# 查看内核日志
dmesg | tail -10
步骤 4:添加 GPIO 控制功能
光读写字符串太无聊了,我们来点硬件交互。添加 GPIO 控制,让 LED 闪烁。
在原有代码基础上添加 GPIO 操作(以树莓派 GPIO 17 为例):
#include
#include
#define LED_GPIO 17
// 在初始化函数中添加
ret = gpio_request(LED_GPIO, "mychardev-led");
if (ret) {
printk(KERN_ALERT "Failed to request GPIO %d\n", LED_GPIO);
// 错误处理...
}
gpio_direction_output(LED_GPIO, 0);
// 添加 ioctl 命令控制 LED
#include
#define IOCTL_MAGIC 'k'
#define IOCTL_LED_ON _IO(IOCTL_MAGIC, 1)
#define IOCTL_LED_OFF _IO(IOCTL_MAGIC, 2)
#define IOCTL_LED_TOGGLE _IO(IOCTL_MAGIC, 3)
static long dev_ioctl(struct file *filep, unsigned int cmd, unsigned long arg)
{
switch (cmd) {
case IOCTL_LED_ON:
gpio_set_value(LED_GPIO, 1);
printk(KERN_INFO "LED turned ON\n");
break;
case IOCTL_LED_OFF:
gpio_set_value(LED_GPIO, 0);
printk(KERN_INFO "LED turned OFF\n");
break;
case IOCTL_LED_TOGGLE:
gpio_set_value(LED_GPIO, !gpio_get_value(LED_GPIO));
printk(KERN_INFO "LED toggled\n");
break;
default:
return -EINVAL;
}
return 0;
}
// 更新 file_operations
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = dev_open,
.release = dev_release,
.read = dev_read,
.write = dev_write,
.unlocked_ioctl = dev_ioctl,
};
在退出函数中释放 GPIO:
gpio_free(LED_GPIO);
用户空间测试程序 test_gpio.c:
#include
#include
#include
#include
#define IOCTL_MAGIC 'k'
#define IOCTL_LED_ON _IO(IOCTL_MAGIC, 1)
#define IOCTL_LED_OFF _IO(IOCTL_MAGIC, 2)
#define IOCTL_LED_TOGGLE _IO(IOCTL_MAGIC, 3)
int main()
{
int fd = open("/dev/mychardev", O_RDWR);
if (fd < 0) {
perror("Failed to open device");
return 1;
}
printf("Turning LED ON...\n");
ioctl(fd, IOCTL_LED_ON);
sleep(1);
printf("Turning LED OFF...\n");
ioctl(fd, IOCTL_LED_OFF);
sleep(1);
printf("Toggling LED 5 times...\n");
for (int i = 0; i < 5; i++) {
ioctl(fd, IOCTL_LED_TOGGLE);
sleep(0.2);
}
close(fd);
return 0;
}
编译并运行:
gcc -o test_gpio test_gpio.c
sudo ./test_gpio
常见问题排查
问题 1:模块加载失败,提示"Invalid module format"
- 原因: 内核头文件版本与运行内核不匹配
- 解决: 运行
uname -r检查内核版本,确保安装的 headers 版本一致sudo apt-get install linux-headers-$(uname -r)
问题 2:设备节点创建失败
- 原因: 设备类或设备创建失败,可能是权限问题
- 解决: 检查
dmesg输出,确认错误信息。确保以 root 权限加载模块
问题 3:GPIO 操作无反应
- 原因: GPIO 引脚已被其他驱动占用,或者引脚号错误
- 解决:
# 查看 GPIO 占用情况 cat /sys/kernel/debug/gpio # 更换未占用的 GPIO 引脚
问题 4:用户空间程序编译警告
- 原因: ioctl 宏定义需要包含正确的头文件
- 解决: 确保包含
<sys/ioctl.h>和<linux/ioctl.h>,ioctl 命令定义要一致
问题 5:内核日志太多刷屏
- 原因: printk 级别设置过低
- 解决: 使用
KERN_DEBUG代替KERN_INFO,或者调整控制台日志级别:echo "4" > /proc/sys/kernel/printk
总结
从零开始写内核驱动,其实没有想象中那么可怕。关键步骤:
- 搭建环境:安装正确的内核头文件
- 理解框架:模块初始化/退出、文件操作结构体
- 用户/内核空间交互:copy_to_user/copy_from_user
- 硬件操作:GPIO 请求、配置、读写
- 调试技巧:printk + dmesg 是好朋友
字符设备驱动是最基础的驱动类型,掌握了这个,后续学习平台设备驱动、设备树、中断处理等高级内容会容易很多。
进阶建议:
- 学习设备树(Device Tree)配置
- 理解中断处理和底半部机制
- 研究平台设备驱动模型
- 阅读内核源码中的参考驱动
希望这篇博客文章对您有所帮助!
相关资源: