嵌入式 Linux 驱动开发入门:编写你的第一个字符设备驱动

嵌入式 Linux 驱动开发入门:编写你的第一个字符设备驱动

为什么需要学驱动开发?

做嵌入式开发这么久,一直都是在用户空间折腾:写应用、调库、玩框架。但有时候你会发现,有些功能用户空间就是搞不定——比如精确的时序控制、直接操作寄存器、或者硬件中断处理。这时候,你就需要踏入内核空间,写驱动了。

很多人对内核驱动有畏惧心理,觉得"内核编程很危险,搞错了系统就挂了"。确实如此,但没那么可怕。今天我们就从零开始,写一个最简单的字符设备驱动,让你迈出第一步。

需要准备什么?

物品 型号/规格 价格
开发板 树莓派 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

总结

从零开始写内核驱动,其实没有想象中那么可怕。关键步骤:

  1. 搭建环境:安装正确的内核头文件
  2. 理解框架:模块初始化/退出、文件操作结构体
  3. 用户/内核空间交互:copy_to_user/copy_from_user
  4. 硬件操作:GPIO 请求、配置、读写
  5. 调试技巧:printk + dmesg 是好朋友

字符设备驱动是最基础的驱动类型,掌握了这个,后续学习平台设备驱动、设备树、中断处理等高级内容会容易很多。

进阶建议:

  • 学习设备树(Device Tree)配置
  • 理解中断处理和底半部机制
  • 研究平台设备驱动模型
  • 阅读内核源码中的参考驱动

希望这篇博客文章对您有所帮助!


相关资源: