Linux驱动基础概念:内核模块、驱动与应用程序的关系、主设备号与次设备号、file_operations结构体

在嵌入式Linux开发中,驱动开发人员首先需要建立正确的内核态与用户态分层认知。驱动并不是一个独立的应用程序,而是内核中负责管理具体硬件设备的软件实体。本章围绕四个核心概念展开:内核模块的加载与卸载机制、驱动与应用程序的协作关系、设备号的分配与解析、以及file_operations结构体如何成为驱动与虚拟文件系统之间的桥梁。理解这些基础概念,是后续编写字符设备驱动、解析设备树、实现中断与并发控制的前提。

用户态 (User Space) 应用程序 open/read/write/ioctl 系统调用接口 sys_read/sys_write 内核态 (Kernel Space) VFS 虚拟文件系统 file_operations .open/.read/.write 字符设备驱动 实现具体硬件操作 内核模块 insmod/modprobe 设备号管理 主设备号 (Major) 标识设备驱动类型 如 89 = /dev/i2c-0 次设备号 (Minor) 标识具体设备实例 如 0/1/2 多个设备 /dev 设备节点 mknod /dev/xxx c 主设备号 次设备号 register_chrdev_region() alloc_chrdev_region() 动态分配 cdev_add() 添加字符设备

2.1 内核模块:驱动的载体与生命周期

核心原理

Linux内核采用宏内核架构,但为了支持驱动的动态加载与卸载,引入了内核模块(Loadable Kernel Module, LKM)机制。模块是编译成独立目标文件(.ko)的代码,可以在系统运行期间插入内核或从内核移除,无需重新编译整个内核。每个模块必须实现两个关键入口函数:module_init() 指定的初始化函数和 module_exit() 指定的清理函数。初始化函数在模块加载时执行,负责注册设备、分配资源;清理函数在模块卸载时执行,负责释放资源、注销设备。

模块还通过 MODULE_LICENSE()、MODULE_AUTHOR()、MODULE_DESCRIPTION() 等宏声明元信息。其中 MODULE_LICENSE("GPL") 尤为重要——如果声明为非GPL兼容许可证,内核将无法使用某些导出给GPL模块的符号(EXPORT_SYMBOL_GPL)。

模块的加载与卸载流程

# 编译生成 .ko 文件
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

# 加载模块
sudo insmod my_driver.ko

# 查看已加载模块
lsmod | grep my_driver

# 查看内核日志
dmesg | tail -20

# 卸载模块
sudo rmmod my_driver

加载过程中,内核依次执行:模块依赖检查 → 分配内核内存 → 解析符号引用 → 调用 module_init 函数。若初始化失败,模块加载会回滚,已分配的资源会被释放。卸载时,内核调用 module_exit,并检查模块引用计数(lsmod 中 "Used by" 列),若仍有进程持有该模块资源,则拒绝卸载。

案例拆解:一个最小字符设备模块

以下代码展示了一个仅注册设备号但不实现具体读写操作的模块骨架:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>

#define DEVICE_NAME "demo_dev"
static dev_t dev_num;
static struct cdev demo_cdev;

static int __init demo_init(void)
{
    int ret;
    // 动态分配设备号(主设备号由内核分配)
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to alloc dev num\n");
        return ret;
    }
    pr_info("Allocated major=%d minor=%d\n", 
            MAJOR(dev_num), MINOR(dev_num));
    
    // 初始化并添加字符设备
    cdev_init(&demo_cdev, &demo_fops);
    ret = cdev_add(&demo_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    return 0;
}

static void __exit demo_exit(void)
{
    cdev_del(&demo_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Module unloaded\n");
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Course");
MODULE_DESCRIPTION("Minimal char device module");

加载后执行 cat /proc/devices 可以看到动态分配的主设备号。此时虽然设备号已注册,但尚未创建 /dev 节点,需要手动执行 mknod /dev/demo_dev c 主设备号 0 才能通过文件接口访问。

常见误区:初学者常混淆 module_init 与驱动初始化。模块加载时执行的是 module_init 指定的函数,而驱动的 probe 函数是在设备与驱动匹配成功后才调用。在设备树环境下,模块加载后若没有匹配的设备节点,probe 不会执行。

2.2 驱动与应用程序的关系:内核态与用户态的协作

核心原理

驱动运行在内核态,应用程序运行在用户态,两者通过系统调用和虚拟文件系统(VFS)交互。应用程序调用 open()、read()、write()、ioctl() 等标准C库函数,这些函数通过软中断陷入内核,由内核根据文件路径找到对应的设备驱动,调用驱动注册的 file_operations 回调函数。

这种分层设计带来三个关键优势:

  • 安全性:用户态程序无法直接访问硬件寄存器,必须通过驱动校验权限和参数。
  • 可移植性:应用程序只需面向标准文件接口编程,无需关心底层硬件差异。
  • 资源管理:内核统一管理中断、DMA、内存映射等共享资源,避免多进程冲突。

数据交互方式

交互方式 系统调用 典型场景 注意事项
阻塞读写 read / write 串口读取、按键输入 驱动需实现等待队列,进程睡眠
非阻塞读写 read / write + O_NONBLOCK 传感器数据轮询 驱动返回 -EAGAIN
控制命令 ioctl 设置波特率、GPIO方向 需定义命令码,注意权限检查
内存映射 mmap LCD framebuffer、DMA缓冲区 驱动实现 mmap 回调,注意缓存一致性
事件通知 poll / epoll 多路复用监听多个设备 驱动实现 poll 回调,返回掩码

案例拆解:应用程序与驱动的典型交互流程

假设一个温度传感器驱动,应用程序读取当前温度:

// 应用程序代码
int fd = open("/dev/temp_sensor", O_RDWR);
if (fd < 0) { perror("open"); return -1; }

int temp;
ssize_t n = read(fd, &temp, sizeof(temp));
if (n != sizeof(temp)) { perror("read"); close(fd); return -1; }

printf("Temperature: %d.%d °C\n", temp/10, temp%10);
close(fd);

驱动侧对应的 read 回调需要完成:

  1. 检查用户缓冲区是否可写(access_ok 或使用 copy_to_user)。
  2. 通过I2C或SPI读取传感器寄存器。
  3. 将原始数据转换为温度值(如 12-bit ADC 值 × 0.0625)。
  4. 使用 copy_to_user 将数据拷贝到用户空间。
关键点:内核态不能直接访问用户态指针。必须使用 copy_to_user() / copy_from_user() 进行安全拷贝,否则可能导致内核崩溃或安全漏洞。

2.3 主设备号与次设备号:设备寻址机制

核心原理

Linux使用设备号唯一标识一个设备。设备号是一个 dev_t 类型(32位无符号整数),高12位为主设备号,低20位为次设备号。主设备号标识设备对应的驱动程序类型,次设备号标识由该驱动管理的具体设备实例。

例如,/dev/ttyS0 和 /dev/ttyS1 共享同一个主设备号(4),但次设备号分别为64和65。内核通过主设备号找到驱动,通过次设备号区分不同的硬件端口。

设备号分配策略

分配方式 函数 优点 缺点
静态指定 register_chrdev_region() 设备号固定,便于创建节点 可能冲突,需查 /proc/devices
动态分配 alloc_chrdev_region() 内核自动分配,避免冲突 设备号不固定,需动态创建节点

动态分配时,驱动可以通过 MAJOR(dev_t) 和 MINOR(dev_t) 宏提取主次设备号。在设备树环境下,设备节点中的 reg 属性可以指定设备号,但更推荐使用动态分配配合 udev 自动创建设备节点。

设备节点与设备号的关联

设备节点(/dev/xxx)是用户空间访问设备的入口。创建节点时需指定类型(c为字符设备,b为块设备)和设备号:

# 手动创建
sudo mknod /dev/mydev c 240 0

# 查看设备号
ls -l /dev/mydev
# crw-r--r-- 1 root root 240, 0 日期 /dev/mydev

# 查看已注册设备号
cat /proc/devices

在实际产品中,通常使用 udev 规则根据驱动注册的设备号自动创建节点,无需手动干预。

风险提示:静态指定设备号时,若与其他驱动冲突,模块加载会失败。建议优先使用动态分配,并在 /sys/class/ 下创建类接口,配合 udev 自动生成稳定的设备节点。

2.4 file_operations结构体:驱动与VFS的接口契约

核心原理

file_operations 是字符设备驱动最核心的数据结构,定义在 <linux/fs.h> 中。它是一组函数指针的集合,每个指针对应一个系统调用。当应用程序对设备文件执行操作时,VFS 根据设备号找到驱动,然后调用 file_operations 中对应的回调函数。

常用的回调函数包括:

  • open():打开设备,可进行初始化、权限检查、递增引用计数。
  • release():关闭设备,释放资源、递减引用计数。
  • read():从设备读取数据,将内核数据拷贝到用户空间。
  • write():向设备写入数据,从用户空间拷贝数据到内核。
  • unlocked_ioctl():发送控制命令,替代旧的 ioctl()(无大内核锁)。
  • poll():实现多路复用,返回设备可读/可写状态。
  • mmap():将设备内存映射到用户空间。

结构体定义与初始化

static int my_open(struct inode *inode, struct file *filp);
static ssize_t my_read(struct file *filp, char __user *buf, 
                       size_t count, loff_t *f_pos);
static ssize_t my_write(struct file *filp, const char __user *buf, 
                        size_t count, loff_t *f_pos);
static int my_release(struct inode *inode, struct file *filp);
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg);

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .read    = my_read,
    .write   = my_write,
    .release = my_release,
    .unlocked_ioctl = my_ioctl,
};

注意 .owner 字段必须设置为 THIS_MODULE,用于管理模块引用计数,防止驱动在使用中被卸载。

关键参数解析

参数 含义 典型用途
struct inode * 内核内部文件索引节点 通过 iminor() 获取次设备号
struct file * 文件描述符对应的内核结构 保存私有数据 filp->private_data
char __user *buf 用户空间缓冲区指针 必须使用 copy_to_user/copy_from_user
loff_t *f_pos 文件读写偏移量 支持 lseek 时更新

案例拆解:多设备实例的区分

当一个驱动管理多个同类型设备(如两个UART端口)时,open 回调中通过次设备号区分具体硬件:

static int my_open(struct inode *inode, struct file *filp)
{
    int minor = iminor(inode);  // 获取次设备号
    struct my_device *dev;
    
    if (minor >= MAX_DEVICES) {
        return -ENODEV;
    }
    
    dev = &my_devices[minor];  // 根据次设备号索引设备结构体
    filp->private_data = dev;  // 保存到 file 结构
    
    // 初始化硬件(如配置GPIO、使能时钟)
    dev->ref_count++;
    return 0;
}

后续的 read、write 回调通过 filp->private_data 获取对应的设备结构体,实现多实例隔离。

经验总结:编写 file_operations 时,务必检查每个回调的返回值。read/write 返回实际传输的字节数;出错时返回负的错误码(如 -EIO、-ENOMEM)。ioctl 中未识别的命令应返回 -ENOTTY。poll 返回可读/可写掩码(POLLIN | POLLOUT)。

2.5 综合案例:完整字符设备驱动的骨架

结合本章所有概念,以下是一个完整的字符设备驱动骨架,包含设备号分配、file_operations 实现、模块生命周期管理:

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

#define DEVICE_NAME "demo"
#define CLASS_NAME "demo_class"

static dev_t dev_num;
static struct cdev demo_cdev;
static struct class *demo_class;
static char kernel_buffer[256];
static int buffer_len = 0;

static int demo_open(struct inode *inode, struct file *filp)
{
    pr_info("Device opened\n");
    return 0;
}

static ssize_t demo_read(struct file *filp, char __user *buf, 
                         size_t count, loff_t *f_pos)
{
    int bytes_to_copy = min(count, (size_t)buffer_len - *f_pos);
    if (bytes_to_copy <= 0) return 0;
    
    if (copy_to_user(buf, kernel_buffer + *f_pos, bytes_to_copy)) {
        return -EFAULT;
    }
    *f_pos += bytes_to_copy;
    return bytes_to_copy;
}

static ssize_t demo_write(struct file *filp, const char __user *buf, 
                          size_t count, loff_t *f_pos)
{
    if (count > sizeof(kernel_buffer)) {
        return -ENOMEM;
    }
    if (copy_from_user(kernel_buffer, buf, count)) {
        return -EFAULT;
    }
    buffer_len = count;
    *f_pos = 0;
    return count;
}

static int demo_release(struct inode *inode, struct file *filp)
{
    pr_info("Device closed\n");
    return 0;
}

static struct file_operations demo_fops = {
    .owner   = THIS_MODULE,
    .open    = demo_open,
    .read    = demo_read,
    .write   = demo_write,
    .release = demo_release,
};

static int __init demo_init(void)
{
    int ret;
    
    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) return ret;
    pr_info("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));
    
    // 2. 初始化并添加 cdev
    cdev_init(&demo_cdev, &demo_fops);
    demo_cdev.owner = THIS_MODULE;
    ret = cdev_add(&demo_cdev, dev_num, 1);
    if (ret < 0) goto err_cdev;
    
    // 3. 创建类并在 /dev 下创建设备节点
    demo_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(demo_class)) {
        ret = PTR_ERR(demo_class);
        goto err_class;
    }
    device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME);
    
    return 0;

err_class:
    cdev_del(&demo_cdev);
err_cdev:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

static void __exit demo_exit(void)
{
    device_destroy(demo_class, dev_num);
    class_destroy(demo_class);
    cdev_del(&demo_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Demo module removed\n");
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Complete char device driver example");

该驱动加载后,/dev/demo 节点自动创建。应用程序可以像操作普通文件一样读写该设备。通过 echo "hello" > /dev/demo 写入数据,再通过 cat /dev/demo 读取。

排障提示:若 insmod 失败,使用 dmesg 查看内核日志。常见错误包括:设备号冲突(-EBUSY)、内存不足(-ENOMEM)、类创建失败(-EEXIST)。卸载时若提示 "Device or resource busy",说明有进程仍持有设备文件句柄,需先关闭相关进程。

2.6 常见误区与总结复盘

误区 正确做法 后果
在 read/write 中直接访问用户指针 使用 copy_to_user/copy_from_user 内核崩溃或安全漏洞
忘记设置 .owner = THIS_MODULE 始终设置 owner 字段 模块卸载时可能崩溃
静态设备号不检查冲突 使用动态分配或检查 /proc/devices 模块加载失败
open 中不检查次设备号范围 验证 minor 值,防止越界访问 数组越界,内核崩溃
ioctl 不校验命令码和参数 使用 _IOC 宏定义命令,检查 arg 非法操作硬件

本章的核心逻辑可以概括为:内核模块是驱动的载体,设备号是驱动的寻址标识,file_operations 是驱动的功能接口,而应用程序通过 VFS 与这三者协作。在后续的设备树章节中,设备节点将替代手动创建 /dev 节点的过程,但设备号与 file_operations 的机制保持不变。掌握本章基础,才能正确理解设备树如何与驱动绑定、如何实现自动探测与资源分配。