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 访问,绝不能直接解引用。

五、完整数据流与状态关系

下图把用户程序、系统调用、驱动四个入口、内核缓冲与硬件寄存器串成一条完整链路,并标出数据拷贝方向与关键返回值:

字符设备 open / read / write / ioctl 数据流 用户空间 应用程序 open("/dev/mydev") read/write/ioctl 用户缓冲区 ubuf VFS / 系统调用 sys_open / sys_read sys_write / sys_ioctl 查 file_operations 传递 inode / filp 驱动入口函数 my_open my_read my_write my_ioctl 内核空间 struct mydev { char buf[1024]; int len; } filp->private_data 指向它 硬件 / 寄存器 FIFO / 状态寄存器 readl / writel 跨空间拷贝 read: copy_to_user write: copy_from_user ioctl: get/put_user 返回值约定:read/write 返回实际字节数(<0 为 -errno);ioctl 返回 0 或 -errno;copy_*_user 返回未拷贝字节数,0 表示成功。 open 返回 0 表示成功,负值表示失败;未识别 ioctl 命令应返回 -ENOTTY。

六、典型场景:一个可读可写可配置的采集设备

假设设备每次采集产生 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 中统一用互斥锁保护共享状态。
复盘要点:先确认“方向”再写代码——read 是内核到用户,write 是用户到内核,ioctl 按命令码方向选择 get/put。再确认“返回值”——拷贝函数返回未拷贝字节数,read/write 返回实际字节数。最后确认“并发”——共享缓冲必须加锁。把这三件事固定成检查清单,字符设备接口的实现就会非常稳定。