嵌入式Linux驱动开发从入门到精通 - 第4节

4.5 用户空间与内核空间的数据拷贝

字符设备驱动的核心任务之一是在用户空间应用程序与内核空间驱动程序之间安全地交换数据。由于用户空间和内核空间使用不同的虚拟地址空间,且内核必须保护自身不受恶意或错误用户程序的影响,因此不能直接通过指针解引用来访问用户空间的内存。Linux 内核提供了两组专门的函数来完成这一任务:copy_from_user 和 copy_to_user。

4.5.1 为什么不能直接访问用户空间指针

  • 地址空间隔离:用户进程的虚拟地址(0~3GB)与内核虚拟地址(3GB~4GB)属于不同的页表映射。内核态代码直接解引用用户空间指针会导致缺页异常或访问非法内存。
  • 安全与稳定性:用户程序可能传递无效指针、未映射地址或只读地址。内核必须在使用前验证这些指针的合法性,防止系统崩溃或被利用。
  • 上下文切换:系统调用执行时,虽然 CPU 处于内核态,但当前进程的 mm 结构仍指向用户空间页表。内核需要借助 set_fs() 等机制(旧内核)或直接使用带 __user 注解的专用函数来正确访问用户内存。

4.5.2 copy_from_user 与 copy_to_user 函数原型

函数 原型 功能 返回值
copy_from_user unsigned long copy_from_user(void *to, const void __user *from, unsigned long n) 将用户空间 from 地址开始的 n 字节数据拷贝到内核空间 to 地址 返回未拷贝成功的字节数(0 表示全部成功)
copy_to_user unsigned long copy_to_user(void __user *to, const void *from, unsigned long n) 将内核空间 from 地址开始的 n 字节数据拷贝到用户空间 to 地址 返回未拷贝成功的字节数(0 表示全部成功)
关键检查点:这两个函数内部会调用 access_ok() 验证用户空间地址范围是否合法。如果返回非零值,驱动应立即返回错误码 -EFAULT 给用户空间。

4.5.3 典型 read/write 实现中的数据拷贝

在字符设备驱动的 read 和 write 方法中,数据拷贝是核心操作。以下是一个标准实现模板:

// 驱动中的 write 方法:从用户空间接收数据
static ssize_t mydev_write(struct file *filp, const char __user *buf,
                           size_t count, loff_t *f_pos)
{
    struct my_device_data *dev = filp->private_data;
    ssize_t retval;

    if (count > MAX_DATA_SIZE)
        count = MAX_DATA_SIZE;

    if (copy_from_user(dev->buffer, buf, count)) {
        retval = -EFAULT;
        goto out;
    }

    dev->data_len = count;
    retval = count;

out:
    return retval;
}

// 驱动中的 read 方法:将数据拷贝到用户空间
static ssize_t mydev_read(struct file *filp, char __user *buf,
                          size_t count, loff_t *f_pos)
{
    struct my_device_data *dev = filp->private_data;
    ssize_t retval;

    if (count < dev->data_len)
        count = dev->data_len;

    if (copy_to_user(buf, dev->buffer, count)) {
        retval = -EFAULT;
        goto out;
    }

    retval = count;

out:
    return retval;
}
注意事项:
1. 用户空间指针 buf 必须使用 __user 注解,以触发 sparse 静态检查工具。
2. 在调用 copy_from_user 或 copy_to_user 之前,不要使用 memcpy 直接操作用户指针。
3. 如果拷贝过程中发生页面错误,函数会返回剩余未拷贝的字节数,驱动应据此判断是否返回 -EFAULT。

4.5.4 数据拷贝流程示意图

用户空间与内核空间数据拷贝流程 用户空间 应用程序缓冲区 char user_buf[256]; read(fd, user_buf, 256); write(fd, user_buf, 256); 内核空间 驱动内核缓冲区 dev->buffer copy_from_user(dev->buffer, buf, count); copy_to_user(buf, dev->buffer, count); write: copy_from_user read: copy_to_user 内核不能直接解引用用户指针,必须通过 copy_from_user / copy_to_user 安全拷贝

4.6 ioctl 命令的实现

对于简单的设备,read 和 write 足以完成数据传输。但许多设备需要执行控制操作(如设置波特率、启动采集、复位硬件等),这些操作不适合用读写语义表达。Linux 提供了 ioctl(Input/Output Control)接口,允许应用程序向设备发送任意控制命令并携带参数。

4.6.1 ioctl 系统调用与驱动方法

用户空间调用:

int ioctl(int fd, unsigned long request, ...);

驱动中对应的 unlocked_ioctl 方法(Linux 2.6.36+ 内核):

long (*unlocked_ioctl)(struct file *filp, unsigned int cmd, unsigned long arg);
  • filp:文件结构指针,指向打开的设备文件。
  • cmd:由应用程序传入的命令码,通常使用宏定义生成。
  • arg:可选的参数,可以是整数、指针(指向用户空间的数据结构)或空。

4.6.2 命令码的编码规范

Linux 内核推荐使用 _IO、_IOR、_IOW、_IOWR 宏来生成标准化的 ioctl 命令码。这些宏将命令分解为以下字段:

字段 位宽 说明
类型(Type/Magic) 8 位 一个 ASCII 字符,用于区分不同设备驱动(如 'M' 表示 MMC,'S' 表示 SCSI)
序号(Number) 8 位 设备内部的命令编号,从 0 开始递增
方向(Direction) 2 位 数据方向:_IOC_NONE(无数据)、_IOC_READ、_IOC_WRITE、_IOC_READ|_IOC_WRITE
数据大小(Size) 14 位 参数结构体的大小(以字节为单位),用于内核自动检查用户缓冲区大小

常用宏定义:

// 无数据传递的命令
#define MY_IOCTL_RESET      _IO('M', 0)

// 从设备读取数据的命令(内核写数据到用户空间)
#define MY_IOCTL_GET_STATUS _IOR('M', 1, int)

// 向设备写入数据的命令(内核从用户空间读数据)
#define MY_IOCTL_SET_CONFIG _IOW('M', 2, struct my_config)

// 双向数据传输的命令
#define MY_IOCTL_EXCHANGE   _IOWR('M', 3, struct my_data)

4.6.3 ioctl 实现中的参数传递与数据拷贝

当 arg 是指针时,驱动必须使用 copy_from_user 或 copy_to_user 来访问用户空间的数据结构。以下是一个完整的 ioctl 实现示例:

struct my_config {
    int baud_rate;
    int data_bits;
    char parity;
};

static long mydev_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct my_device_data *dev = filp->private_data;
    struct my_config config;
    int status;
    int ret = 0;

    // 检查命令是否属于本设备
    if (_IOC_TYPE(cmd) != 'M')
        return -ENOTTY;
    if (_IOC_NR(cmd) >= MY_IOCTL_MAXNR)
        return -ENOTTY;

    switch (cmd) {
    case MY_IOCTL_RESET:
        // 无参数命令
        dev_reset_hardware(dev);
        break;

    case MY_IOCTL_GET_STATUS:
        // 从内核读取状态到用户空间
        status = dev_get_status(dev);
        if (copy_to_user((int __user *)arg, &status, sizeof(status)))
            ret = -EFAULT;
        break;

    case MY_IOCTL_SET_CONFIG:
        // 从用户空间读取配置到内核
        if (copy_from_user(&config, (struct my_config __user *)arg, sizeof(config))) {
            ret = -EFAULT;
            break;
        }
        // 验证配置参数合法性
        if (config.baud_rate < 0 || config.data_bits < 5) {
            ret = -EINVAL;
            break;
        }
        dev_apply_config(dev, &config);
        break;

    default:
        ret = -ENOTTY;
        break;
    }

    return ret;
}
最佳实践:
1. 始终使用 _IOC_TYPE 和 _IOC_NR 验证命令的合法性,防止其他驱动或恶意程序传入无效命令。
2. 对于带指针参数的命令,必须使用 copy_from_user/copy_to_user,不能直接解引用 arg。
3. 在 switch 的 default 分支返回 -ENOTTY(不适当的 ioctl 命令)。

4.6.4 ioctl 命令处理流程

ioctl 命令处理流程 用户应用程序 VFS sys_ioctl unlocked_ioctl 命令验证 switch 分发 数据拷贝/硬件操作 驱动需验证命令类型、序号,并根据方向使用 copy_from_user / copy_to_user 传递参数

4.7 设备文件的自动创建:udev / mdev 机制

在早期的 Linux 系统中,设备文件需要手动使用 mknod 命令创建,这既繁琐又容易出错。现代 Linux 使用 devtmpfs 配合 udev(桌面/服务器)或 mdev(嵌入式 busybox 环境)实现设备文件的自动创建和管理。

4.7.1 设备文件自动创建的原理

当驱动调用 class_create 和 device_create 时,内核会:

  1. 在 /sys/class/<class_name>/<device_name> 下创建 sysfs 属性文件。
  2. 通过 devtmpfs 在 /dev 目录下自动创建设备节点。
  3. 向用户空间的 udev 或 mdev 发送 uevent 事件,允许执行自定义规则(如设置权限、创建符号链接)。

4.7.2 驱动中的实现步骤

在驱动初始化代码中,需要按以下顺序调用内核 API:

步骤 函数 说明
1 alloc_chrdev_region 动态分配主次设备号
2 cdev_init + cdev_add 初始化并注册字符设备到内核
3 class_create 在 /sys/class 下创建一个设备类
4 device_create 在 /dev 下创建设备节点,并关联到设备号

完整示例代码:

#include <linux/device.h>
#include <linux/cdev.h>

static dev_t dev_num;
static struct class *my_class;
static struct cdev my_cdev;

static int __init my_driver_init(void)
{
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0)
        return ret;

    // 2. 初始化并添加字符设备
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0)
        goto err_cdev;

    // 3. 创建设备类
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        ret = PTR_ERR(my_class);
        goto err_class;
    }

    // 4. 创建设备节点(自动在 /dev 下生成 my_device0)
    device_create(my_class, NULL, dev_num, NULL, "my_device%d", 0);

    return 0;

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

static void __exit my_driver_exit(void)
{
    // 反向销毁:先销毁设备节点,再销毁类,最后删除 cdev 和释放设备号
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
}

module_init(my_driver_init);
module_exit(my_driver_exit);

4.7.3 udev 与 mdev 的对比

特性 udev mdev
适用环境 桌面/服务器 Linux(systemd 集成) 嵌入式 Linux(busybox 环境)
依赖 libudev、systemd-udevd 守护进程 busybox 中的 mdev 小程序
配置文件 /etc/udev/rules.d/ 下的 .rules 文件 /etc/mdev.conf
触发方式 内核通过 netlink 发送 uevent 内核通过 /sys 文件系统的 uevent 文件触发
资源占用 较大(守护进程常驻内存) 极小(按需执行,无守护进程)
启动方式 系统启动时自动启动 udevd 在 /sbin/init 中调用 mdev -s 扫描并创建已有设备节点

4.7.4 设备文件自动创建流程

设备文件自动创建流程(udev/mdev) 驱动初始化 内核核心 devtmpfs udev/mdev uevent 驱动调用 device_create() ↓ 内核在 /sys 创建属性,devtmpfs 在 /dev 创建设备节点 同时通过 netlink 发送 uevent 到用户空间 udev 根据规则设置权限、创建符号链接、加载固件等自定义操作

4.7.5 嵌入式环境中的 mdev 配置

在基于 busybox 的嵌入式系统中,使用 mdev 实现自动创建设备节点通常只需以下简单配置:

# 在 /etc/init.d/rcS 启动脚本中添加
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
echo /sbin/mdev > /proc/sys/kernel/hotplug
mdev -s

如果需要自定义设备权限或命名规则,可以在 /etc/mdev.conf 中添加规则:

# 格式:<设备正则> <用户:组> <权限> [<命令>]
my_device0  root:root 0660
ttyS0       root:dialout 0660
sd[a-z][0-9]* root:disk 0660
调试技巧:如果设备节点没有自动创建,可以检查:
1. 内核是否配置了 CONFIG_DEVTMPFS 和 CONFIG_DEVTMPFS_MOUNT。
2. /sys/class/my_class/my_device0 目录是否存在。
3. 使用 udevadm monitor(桌面)或查看 /sys/.../uevent 文件(嵌入式)确认 uevent 是否正常发送。