扫码加入

  • 正文
  • 相关推荐
申请入驻 产业图谱

嵌入式 Linux 字符设备驱动通用框架全解析:从内核架构到代码实现

3小时前
314
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

字符设备是 Linux 驱动中最基础、最常用的设备类型,以字节流方式进行数据传输,无缓存、按序读写,串口、键盘、RTC、LED 等均为典型的字符设备。本文从内核架构层面出发,详解字符设备驱动的通用设计框架,涵盖用户空间与内核空间的交互原理、核心内核 API 调用、设备号管理、驱动注册与注销、设备文件创建等关键环节,并给出标准化的代码实现模板,无需涉及具体硬件寄存器操作,可直接适配各类伪设备 / 实际字符设备的驱动开发。

资料获取:字符设备通用框架

1. 字符设备驱动核心架构:用户 - 内核 - 硬件的交互逻辑

字符设备驱动是连接用户空间应用程序硬件设备的中间层,核心依托 Linux 内核的虚拟文件系统(VFS) 实现跨空间的系统调用转发,整体交互流程呈分层结构,无硬件相关的伪设备驱动可简化为用户空间 - VFS - 字符设备驱动三层架构。

1.1 整体交互流程

  1. 用户空间:应用程序通过标准 C 库调用open/read/write/close等系统调用,操作/dev目录下的设备文件(如/dev/pseudo_chrdev);
  2. VFS 层:内核虚拟文件系统接收系统调用,解析设备文件对应的设备号,在驱动注册列表中匹配对应的字符设备驱动;
  3. 驱动层:VFS 将用户请求转发至驱动中实现的file_operations文件操作接口,由驱动完成实际的数据读写 / 设备控制;
  4. 硬件层:驱动通过操作硬件寄存器、设备树等完成与物理设备的交互(伪设备驱动可省略此层,用内存模拟数据读写)。

1.2 核心设计原则

  • 与硬件解耦:驱动框架本身不涉及具体硬件操作,仅提供标准化接口,硬件相关逻辑在接口实现中单独编写;
  • 依托 VFS 注册:驱动必须向 VFS 注册设备号和文件操作接口,否则用户空间无法感知设备;
  • 动态资源管理:设备号、类、设备文件等资源采用动态分配方式,避免静态指定导致的资源冲突;
  • 对称的注册与注销:驱动加载时注册的所有资源,在卸载时必须按逆序注销,防止内核内存泄漏

2. 字符设备驱动核心基础:设备号管理

设备号是 VFS 匹配设备文件与驱动的唯一标识,是字符设备驱动开发的第一步,内核通过dev_t类型管理设备号,所有操作均需包含头文件#include <linux/fs.h>#include <linux/kdev_t.h>

2.1 设备号的组成

dev_t是 32 位无符号整数,分为主设备号次设备号

  • 主设备号(12 位):标识驱动程序,同一个驱动对应唯一的主设备号;
  • 次设备号(20 位):标识驱动管理的具体设备实例,一个驱动可管理多个设备实例(如多个串口),对应不同的次设备号。

例如设备号100:0,主设备号100表示驱动,次设备号0表示该驱动的第 1 个设备实例。

2.2 设备号的核心操作宏

内核提供专用宏完成主 / 次设备号与dev_t的相互转换,定义在linux/kdev_t.h中:

// 从dev_t中提取主设备号
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
// 从dev_t中提取次设备号
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
// 将主/次设备号组合为dev_t类型
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))

其中MINORBITS=20MINORMASK=(1U << 20) - 1,为内核默认定义。

2.3 设备号的分配方式

推荐使用动态分配(避免静态指定主设备号导致的冲突),内核 API 原型:

// 动态分配设备号
extern int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
  • 参数说明
    • dev:输出参数,保存分配到的起始设备号;
    • baseminor:起始次设备号,一般设为 0;
    • count:分配的设备号数量,一个设备实例设为 1;
    • name:设备名(非设备文件名),用于内核标识,可通过/proc/devices查看。
  • 返回值:成功返回 0,失败返回负的错误码。

静态分配(不推荐):适用于确定主设备号无冲突的场景,API 为register_chrdev_region(dev_t dev, unsigned count, const char *name)

2.4 设备号的注销

驱动卸载时必须注销已分配的设备号,API 原型:

extern void unregister_chrdev_region(dev_t dev, unsigned count);
  • 参数与alloc_chrdev_region一致,dev为分配的起始设备号,count为分配的数量。

3. 字符设备驱动核心结构体:cdev 与 file_operations

Linux 内核通过struct cdev描述字符设备本身,通过struct file_operations定义驱动对系统调用的实现接口,两者是字符设备驱动的核心,操作均需包含头文件#include <linux/cdev.h>

3.1 字符设备结构体:struct cdev

struct cdev是内核管理字符设备的核心结构体,包含设备的文件操作接口、所属模块、设备号等信息,核心定义:

struct cdev {
    struct kobject kobj;
    struct module *owner;  // 所属模块,必须设为THIS_MODULE
    const struct file_operations *ops;  // 设备的文件操作接口
    struct list_head list;
    dev_t dev;  // 设备号
    unsigned int count;  // 设备实例数量
};
  • 关键成员owner用于防止模块在设备使用时被卸载,ops指向驱动实现的文件操作接口,dev为设备号。

3.2 cdev 的初始化与注册

cdev 的使用分为初始化注册两步,先初始化再向 VFS 注册,完成驱动与设备号的绑定。

(1)cdev 初始化:cdev_init ()

初始化struct cdev结构体,将其与file_operations绑定,API 原型:

void cdev_init(struct cdev *cdev, const struct file_operations *fops);
  • 参数
    • cdev:待初始化的 cdev 结构体指针;
    • fops:驱动实现的文件操作接口指针。
  • 必做操作:初始化后必须设置cdev->owner = THIS_MODULE,防止模块被非法卸载。

(2)cdev 注册:cdev_add ()

向 VFS 注册字符设备,完成驱动与设备号的关联,API 原型:

int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);
  • 参数
    • cdev:已初始化的 cdev 结构体指针;
    • dev:分配到的起始设备号;
    • count:设备实例数量,与alloc_chrdev_regioncount一致。
  • 返回值:成功返回 0,失败返回负的错误码。

(3)cdev 注销:cdev_del ()

驱动卸载时注销字符设备,API 原型:

void cdev_del(struct cdev *cdev);
  • 参数为已注册的 cdev 结构体指针,注销后该设备将从 VFS 中移除。

3. 文件操作接口:struct file_operations

struct file_operations驱动与 VFS 的接口桥梁,定义了驱动对open/read/write/close等系统调用的具体实现,内核通过函数指针的方式调用驱动实现的方法,核心成员(定义在linux/fs.h):

struct file_operations {
    struct module *owner;  // 所属模块,设为THIS_MODULE
    int (*open) (struct inode *, struct file *);  // 打开设备
    int (*release) (struct inode *, struct file *);  // 关闭设备
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);  // 读取设备
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);  // 写入设备
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);  // 设备控制
    __poll_t (*poll) (struct file *, struct poll_table_struct *);  // 轮询操作
    loff_t (*llseek) (struct file *, loff_t, int);  // 移动文件指针
};

(4)核心接口说明

  • open/release:设备的打开 / 关闭接口,可用于设备的初始化 / 资源释放,如初始化缓冲区、使能硬件;
  • read/write:设备的数据读写接口,核心是完成内核空间与用户空间的数据拷贝,必须使用内核专用函数copy_to_user()(内核→用户)和copy_from_user()(用户→内核),禁止直接指针访问(用户空间地址在内核中不可见);
  • __user:内核注解,标识参数为用户空间地址,用于编译检查,防止非法内存访问。

(5)read/write 核心数据拷贝函数

// 内核空间数据拷贝到用户空间:成功返回0,失败返回未拷贝的字节数
unsigned long copy_to_user(char __user *to, const void *from, unsigned long n);
// 用户空间数据拷贝到内核空间:成功返回0,失败返回未拷贝的字节数
unsigned long copy_from_user(void *to, const char __user *from, unsigned long n);

4. 设备文件的创建:sysfs 与 udev 协同

用户空间通过/dev目录下的设备文件操作设备,设备文件由udev守护进程动态创建,驱动通过内核 API 向sysfs文件系统导出设备信息,udev监听sysfs的事件并自动创建设备文件,无需手动执行mknod命令,核心操作需包含头文件#include <linux/device.h>

4.1 核心 API:class_create () 与 device_create ()

设备文件的创建分为两步:先创建设备类,再创建设备实例,类用于对设备进行分类管理(如/sys/class/chrdev)。

(1)创建设备类:class_create ()

/sys/class目录下创建设备类目录,API 原型:

struct class *class_create(struct module *owner, const char *name);
  • 参数
    • owner:所属模块,设为 THIS_MODULE;
    • name:类名,对应/sys/class/下的目录名。
  • 返回值:成功返回类结构体指针,失败返回ERR_PTR(err)(需用IS_ERR()判断)。

(2)创建设备文件:device_create ()

在指定的设备类下创建设备实例,udev会根据该实例在/dev目录下创建设备文件,API 原型:

struct device *device_create(struct class *cls, struct device *parent,
                             dev_t devt, void *drvdata, const char *fmt, ...);
  • 参数
    • cls:已创建的设备类指针;
    • parent:父设备指针,无则设为 NULL;
    • devt:设备号;
    • drvdata:设备私有数据,无则设为 NULL;
    • fmt:设备文件名,对应/dev/下的文件名(如 "pseudo_chrdev")。
  • 返回值:成功返回设备结构体指针,失败返回ERR_PTR(err)

4.2 设备文件的注销

驱动卸载时按逆序注销设备文件和设备类,防止资源泄漏:

// 注销设备文件,与device_create()对应
void device_destroy(struct class *cls, dev_t devt);
// 注销设备类,与class_create()对应
void class_destroy(struct class *cls);

5. 驱动模块的加载与卸载:module_init/module_exit

Linux 字符设备驱动以内核模块(.ko 文件) 形式动态加载,核心依托module_initmodule_exit两个宏完成驱动入口出口的注册,无需主函数main(),所有操作需包含头文件#include <linux/module.h>

5.1 模块加载:module_init ()

注册驱动的初始化函数,当执行insmodmodprobe加载驱动时,内核自动调用该函数,宏定义:

#define module_init(initfn)
  • initfn:驱动初始化函数,原型为static int __init 驱动名_init(void)
  • __init:内核注解,标识该函数仅在模块加载时执行,执行后函数占用的内存会被释放;
  • static:限定函数作用域为当前模块,防止与其他内核模块冲突。

初始化函数的核心工作(按顺序):

  1. 动态分配设备号;
  2. 初始化 cdev 结构体并绑定 file_operations;
  3. 向 VFS 注册 cdev;
  4. 创建设备类;
  5. 创建设备文件。

5.2 模块卸载:module_exit ()

注册驱动的注销函数,当执行rmmodmodprobe -r卸载驱动时,内核自动调用该函数,宏定义:

#define module_exit(exitfn)
  • exitfn:驱动注销函数,原型为static void __exit 驱动名_exit(void)
  • __exit:内核注解,标识该函数仅在模块卸载时执行。

注销函数的核心工作(与初始化逆序):

  1. 注销设备文件;
  2. 注销设备类;
  3. 从 VFS 注销 cdev;
  4. 注销设备号;
  5. 释放驱动申请的其他资源(如缓冲区、自旋锁)。

5.3 模块管理命令

# 加载驱动(不处理依赖)
insmod 驱动名.ko
# 加载驱动(自动处理依赖)
modprobe 驱动名.ko
# 卸载驱动
rmmod 驱动名.ko
# 查看已加载的内核模块
lsmod
# 查看已分配的字符设备号
cat /proc/devices
# 查看设备类信息
ls /sys/class/类名/

6. 字符设备驱动通用代码模板(伪设备)

以下为无硬件依赖的伪字符设备驱动模板,用内核内存模拟数据读写,包含所有核心步骤,可直接编译为.ko 文件,适配所有 Linux 内核版本(3.10+),只需根据实际硬件修改read/write接口的实现逻辑。

6.1 完整代码模板

#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/uaccess.h>

// 宏定义
#define DEV_NAME        "pseudo_chrdev"  // 设备名(/proc/devices)
#define DEV_FILE_NAME   "pseudo_chrdev"  // 设备文件名(/dev/)
#define DEV_COUNT       1                // 设备实例数量
#define BUF_SIZE        256              // 数据缓冲区大小

// 全局变量
static dev_t dev_num;                   // 设备号
static struct cdev chr_dev;             // 字符设备结构体
static struct class *chr_dev_class;     // 设备类
static struct device *chr_dev_device;   // 设备实例
static char dev_buf[BUF_SIZE];          // 内核数据缓冲区

// 文件操作接口声明
static int chrdev_open(struct inode *inode, struct file *filp);
static int chrdev_release(struct inode *inode, struct file *filp);
static ssize_t chrdev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos);
static ssize_t chrdev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos);

// 定义文件操作接口
static struct file_operations chrdev_fops = {
    .owner = THIS_MODULE,
    .open = chrdev_open,
    .release = chrdev_release,
    .read = chrdev_read,
    .write = chrdev_write,
};

// 打开设备
static int chrdev_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "chrdev open success!n");
    // 硬件初始化逻辑可在此添加(如使能设备、初始化寄存器)
    return 0;
}

// 关闭设备
static int chrdev_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "chrdev release success!n");
    // 硬件资源释放逻辑可在此添加(如禁能设备、释放缓冲区)
    return 0;
}

// 读取设备:内核→用户
static ssize_t chrdev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
    int ret;
    // 检查读取长度,超过缓冲区则截断
    if (count > BUF_SIZE)
        count = BUF_SIZE;
    // 内核缓冲区数据拷贝到用户空间
    ret = copy_to_user(buf, dev_buf, count);
    if (ret != 0) {
        printk(KERN_ERR "copy to user failed! ret = %dn", ret);
        return -EFAULT;
    }
    printk(KERN_INFO "chrdev read: %s, count = %ldn", dev_buf, count);
    return count;
}

// 写入设备:用户→内核
static ssize_t chrdev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
    int ret;
    // 检查写入长度,超过缓冲区则截断
    if (count > BUF_SIZE)
        count = BUF_SIZE;
    // 清空内核缓冲区
    memset(dev_buf, 0, BUF_SIZE);
    // 用户空间数据拷贝到内核缓冲区
    ret = copy_from_user(dev_buf, buf, count);
    if (ret != 0) {
        printk(KERN_ERR "copy from user failed! ret = %dn", ret);
        return -EFAULT;
    }
    printk(KERN_INFO "chrdev write: %s, count = %ldn", dev_buf, count);
    return count;
}

// 驱动初始化函数
static int __init chrdev_init(void)
{
    int ret;
    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, DEV_COUNT, DEV_NAME);
    if (ret < 0) {
        printk(KERN_ERR "alloc chrdev region failed! ret = %dn", ret);
        goto err_alloc;
    }
    printk(KERN_INFO "alloc chrdev region success! major = %d, minor = %dn", MAJOR(dev_num), MINOR(dev_num));

    // 2. 初始化cdev并绑定file_operations
    cdev_init(&chr_dev, &chrdev_fops);
    chr_dev.owner = THIS_MODULE;

    // 3. 注册cdev到VFS
    ret = cdev_add(&chr_dev, dev_num, DEV_COUNT);
    if (ret < 0) {
        printk(KERN_ERR "cdev add failed! ret = %dn", ret);
        goto err_cdev_add;
    }

    // 4. 创建设备类
    chr_dev_class = class_create(THIS_MODULE, DEV_NAME);
    if (IS_ERR(chr_dev_class)) {
        printk(KERN_ERR "class create failed!n");
        ret = PTR_ERR(chr_dev_class);
        goto err_class_create;
    }

    // 5. 创建设备文件
    chr_dev_device = device_create(chr_dev_class, NULL, dev_num, NULL, DEV_FILE_NAME);
    if (IS_ERR(chr_dev_device)) {
        printk(KERN_ERR "device create failed!n");
        ret = PTR_ERR(chr_dev_device);
        goto err_device_create;
    }

    printk(KERN_INFO "chrdev init success!n");
    return 0;

    // 错误处理:逆序释放资源
err_device_create:
    class_destroy(chr_dev_class);
err_class_create:
    cdev_del(&chr_dev);
err_cdev_add:
    unregister_chrdev_region(dev_num, DEV_COUNT);
err_alloc:
    return ret;
}

// 驱动注销函数
static void __exit chrdev_exit(void)
{
    // 1. 注销设备文件
    device_destroy(chr_dev_class, dev_num);
    // 2. 注销设备类
    class_destroy(chr_dev_class);
    // 3. 注销cdev
    cdev_del(&chr_dev);
    // 4. 注销设备号
    unregister_chrdev_region(dev_num, DEV_COUNT);

    printk(KERN_INFO "chrdev exit success!n");
}

// 模块注册
module_init(chrdev_init);
module_exit(chrdev_exit);

// 模块元数据(必须添加)
MODULE_LICENSE("GPL");  // 许可证,GPL兼容,否则内核加载时报警
MODULE_AUTHOR("XXX");   // 驱动作者
MODULE_DESCRIPTION("Linux Pseudo Character Device Driver");  // 驱动描述
MODULE_VERSION("V1.0"); // 驱动版本

2. 核心说明

  • 错误处理:初始化过程中任何一步失败,都需通过goto语句逆序释放已申请的资源,防止内核内存泄漏;
  • 内核打印:使用printk()而非printf()KERN_INFO/KERN_ERR为日志级别,可通过dmesg查看打印信息;
  • 数据拷贝:严格使用copy_to_user/copy_from_user完成跨空间数据传输,避免直接访问用户空间地址;
  • 模块元数据MODULE_LICENSE("GPL")是必加项,否则内核会认为驱动为非开源,加载时触发警告并禁用部分内核功能。

3. 编译 Makefile 模板

创建Makefile文件,与驱动代码同目录,修改KERNELDIR为当前系统的内核源码路径:

KERNELDIR ?= /lib/modules/$(shell uname -r)/build  # 系统内核源码路径
PWD := $(shell pwd)

obj-m := pseudo_chrdev.o  # 驱动模块名,与代码文件名一致

all:
    make -C $(KERNELDIR) M=$(PWD) modules

clean:
    make -C $(KERNELDIR) M=$(PWD) clean

编译命令make,生成pseudo_chrdev.ko驱动模块。

4. 测试应用程序(用户空间)

创建test.c,编译为可执行文件,用于测试驱动的read/write接口:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define DEV_FILE "/dev/pseudo_chrdev"
#define BUF_SIZE 256

int main()
{
    int fd;
    char buf[BUF_SIZE];
    // 打开设备文件
    fd = open(DEV_FILE, O_RDWR);
    if (fd < 0) {
        perror("open dev file failed");
        return -1;
    }
    // 写入数据
    strcpy(buf, "Hello Linux Character Device!");
    write(fd, buf, strlen(buf));
    // 清空缓冲区并读取数据
    memset(buf, 0, BUF_SIZE);
    read(fd, buf, BUF_SIZE);
    printf("read from dev: %sn", buf);
    // 关闭设备文件
    close(fd);
    return 0;
}

编译与运行

gcc test.c -o test
sudo ./test
dmesg  # 查看内核打印信息

7. 字符设备驱动开发关键注意事项

  1. 资源冲突避免:所有内核资源(设备号、类名、设备文件名)均采用动态分配 / 命名,避免与系统原有设备冲突;
  2. 内存访问安全:用户空间地址在内核中不可直接访问,必须使用copy_to_user/copy_from_user完成数据传输,失败时返回-EFAULT
  3. 并发访问保护:若驱动被多个进程同时访问,需添加并发保护机制(如自旋锁spinlock_t、互斥体mutex_t),防止数据竞争;
  4. 错误码返回规范:内核驱动的错误码需遵循 Linux 内核规范,如-EINVAL(参数无效)、-EFAULT(内存访问错误)、-ENOMEM(内存分配失败);
  5. 日志打印规范:使用printk()打印关键信息,避免过多打印影响内核性能,日志级别根据信息类型选择(KERN_INFO/KERN_ERR/KERN_WARNING);
  6. 模块卸载安全:确保驱动卸载时,设备已被关闭,无进程持有设备文件的文件描述符,可通过try_module_get()module_put()实现模块引用计数管理。

Linux 字符设备驱动的通用框架围绕VFS 核心展开,核心流程可概括为设备号分配→cdev 初始化与注册→设备类与设备文件创建→file_operations 接口实现,驱动的加载与卸载严格遵循对称的资源管理原则

本文提供的代码模板为硬件无关的伪设备驱动,剥离了具体的硬件操作逻辑,仅保留通用框架,实际开发中只需在open/release/read/write接口中添加硬件相关的操作(如寄存器配置、数据读写、中断处理),即可快速适配各类字符设备的驱动开发。

掌握该通用框架后,可轻松开发串口、LED、按键、RTC 等常见字符设备的驱动,是嵌入式 Linux 驱动开发的基础,也是后续学习块设备、网络设备驱动的前提。

相关推荐