4. 字符设备读写实现
字符设备驱动的核心价值,最终要落到“用户程序能打开它、读它、写它、控制它”这条链路上。前面的注册、设备号分配、cdev 绑定只是把设备“挂”进了内核,而 open、read、write、ioctl 才是驱动与应用程序之间的真实接口。本节围绕这四个入口,讲清它们各自该做什么、参数如何理解、数据如何跨越用户态与内核态边界,以及最容易踩坑的地方。
一、四个入口的职责边界
在 file_operations 结构体中,四个回调函数分工明确:
open:做“准入”和“初始化”。检查次设备号、判断是否首次打开、初始化硬件、分配私有数据private_data、增加引用计数。它不负责搬运业务数据。read:把设备侧的数据复制到用户缓冲区。方向是“内核 → 用户”。write:把用户缓冲区的数据复制到设备侧。方向是“用户 → 内核”。ioctl:处理无法用读写表达的带外控制,如设置波特率、清空 FIFO、查询状态、配置工作模式。它靠命令码区分语义。
read/write 的返回值是“成功传输的字节数”,不是 0/1 布尔值;ioctl 的返回值通常是 0 或负的 -errno。混淆返回值语义是初学者最常见的错误。
二、用户空间与内核空间的数据拷贝
用户态指针不能在内核里直接解引用。原因有两层:一是用户地址空间与内核地址空间是两套页表映射,内核直接访问用户虚拟地址可能触发缺页异常且无法安全处理;二是用户指针可能非法、可能指向内核地址,直接访问会带来安全漏洞。因此必须使用专用拷贝函数:
| 函数 | 方向 | 典型使用位置 | 说明 |
|---|---|---|---|
copy_to_user | 内核 → 用户 | read | 把内核缓冲数据送到用户 buf |
copy_from_user | 用户 → 内核 | write | 把用户 buf 数据取到内核缓冲 |
get_user | 用户 → 内核 | ioctl | 读取单个简单变量(int/long 等) |
put_user | 内核 → 用户 | ioctl | 回写单个简单变量 |
这四个函数返回“未能拷贝的字节数”,返回 0 才表示全部成功。因此判断逻辑应写成 if (copy_to_user(...)) return -EFAULT;,而不是与 0 比较相等。
三、接口实现骨架
下面给出一个带内核缓冲的字符设备骨架,重点观察四个入口如何协作:
#define DEV_BUF_SIZE 1024
struct mydev {
char buf[DEV_BUF_SIZE];
int len; /* 当前有效数据长度 */
struct mutex lock; /* 保护 buf/len */
};
static int my_open(struct inode *inode, struct file *filp)
{
struct mydev *dev = container_of(inode->i_cdev, struct mydev, cdev);
filp->private_data = dev; /* 后续 read/write 直接取用 */
return 0;
}
static ssize_t my_read(struct file *filp, char __user *ubuf,
size_t count, loff_t *ppos)
{
struct mydev *dev = filp->private_data;
int n;
if (*ppos >= dev->len) /* 已读到末尾 */
return 0;
n = min_t(int, count, dev->len - *ppos);
if (copy_to_user(ubuf, dev->buf + *ppos, n))
return -EFAULT;
*ppos += n;
return n;
}
static ssize_t my_write(struct file *filp, const char __user *ubuf,
size_t count, loff_t *ppos)
{
struct mydev *dev = filp->private_data;
int n = min_t(int, count, DEV_BUF_SIZE);
if (copy_from_user(dev->buf, ubuf, n))
return -EFAULT;
dev->len = n;
return n;
}
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
struct mydev *dev = filp->private_data;
int val;
switch (cmd) {
case MYDEV_CLEAR:
mutex_lock(&dev->lock);
dev->len = 0;
mutex_unlock(&dev->lock);
break;
case MYDEV_GETLEN:
val = dev->len;
if (put_user(val, (int __user *)arg))
return -EFAULT;
break;
default:
return -ENOTTY; /* 未识别命令的标准返回 */
}
return 0;
}
四、ioctl 命令码与参数约定
ioctl 的 cmd 不是随便一个整数,推荐用内核提供的宏构造,把“方向、类型、序号、数据大小”编码进去,避免不同驱动之间命令冲突:
#define MYDEV_MAGIC 'M'
#define MYDEV_CLEAR _IO(MYDEV_MAGIC, 0)
#define MYDEV_GETLEN _IOR(MYDEV_MAGIC, 1, int)
#define MYDEV_SETMODE _IOW(MYDEV_MAGIC, 2, int)
其中 _IO 表示无数据、_IOR 表示内核向用户回写、_IOW 表示用户向内核传入、_IOWR 表示双向。参数 arg 是 unsigned long,当它表示指针时,必须用 get_user/put_user 或 copy_*_user 访问,绝不能直接解引用。
五、完整数据流与状态关系
下图把用户程序、系统调用、驱动四个入口、内核缓冲与硬件寄存器串成一条完整链路,并标出数据拷贝方向与关键返回值:
六、典型场景:一个可读可写可配置的采集设备
假设设备每次采集产生 64 字节数据,用户程序希望“读取一帧、清空缓冲、查询当前帧长度”。驱动侧可以这样组织:read 从内核缓冲拷贝一帧并推进 *ppos;write 用于下发配置帧;ioctl 用 MYDEV_CLEAR 清空、MYDEV_GETLEN 回写长度。用户程序侧对应:
int fd = open("/dev/mydev", O_RDWR);
char frame[64];
read(fd, frame, sizeof(frame)); /* 取一帧 */
int len;
ioctl(fd, MYDEV_GETLEN, &len); /* 查询长度 */
ioctl(fd, MYDEV_CLEAR, 0); /* 清空缓冲 */
close(fd);
注意 read 的 *ppos 由内核维护,用户程序连续 read 会自然推进偏移;若希望每次从头部读,可在 open 中重置或使用 llseek。而 ioctl 的 arg 传的是用户地址,驱动必须用 put_user 回写,不能写成 *(int *)arg = len;。
七、误区与复盘
read/write 中直接访问用户指针,如 memcpy(dev->buf, ubuf, n)。这在部分架构上可能“看似能跑”,但属于未定义行为,必须改为 copy_from_user。常见错误二:把
copy_to_user 的返回值当布尔用,写成 if (copy_to_user(...) == 0) return -EFAULT;,逻辑正好反了。常见错误三:
ioctl 未识别命令返回 -EINVAL。标准做法是返回 -ENOTTY,便于用户态区分“命令不支持”和“参数非法”。常见错误四:多进程并发读写同一设备时未加锁,导致
len 与 buf 不一致。应在 read/write/ioctl 中统一用互斥锁保护共享状态。