嵌入式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 数据拷贝流程示意图
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 命令处理流程
4.7 设备文件的自动创建:udev / mdev 机制
在早期的 Linux 系统中,设备文件需要手动使用 mknod 命令创建,这既繁琐又容易出错。现代 Linux 使用 devtmpfs 配合 udev(桌面/服务器)或 mdev(嵌入式 busybox 环境)实现设备文件的自动创建和管理。
4.7.1 设备文件自动创建的原理
当驱动调用 class_create 和 device_create 时,内核会:
- 在
/sys/class/<class_name>/<device_name>下创建 sysfs 属性文件。 - 通过
devtmpfs在/dev目录下自动创建设备节点。 - 向用户空间的
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 设备文件自动创建流程
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 是否正常发送。