上一篇把设备注册出来了,这篇讲数据怎么进出:read/write 的返回值语义,copy_to_user 一族的安全职责,ioctl 命令编码,还有 mmap 零拷贝.实验模块一个设备四条通道,每条通道的边界行为都实测到,中间还有一个 f_pos 的翻车,比正文还精彩.
这篇回答几件事:
- read/write 返回值的完整语义,短读和 EOF 怎么区分
- copy_to_user 为什么不能换成 memcpy,失败时该返回什么
- ioctl 的命令号是怎么编码的,为什么裸数字会撞车
- mmap 映射内核页的工作原理
先说 read/write 的语义,这是新手问题最密集的地方.规则一共三条:返回值是实际完成的字节数,可以小于请求(短读不是错误);零表示 EOF;负数是 errno.实测一个存了 "hello xfer" 的设备,小口读:
[read 4] 4: hell
[read 4] 4: o xf
[read 4] 2: er
[read 4] 0 (EOF)
4,4,2,0 — 第三次只剩 2 字节,给 2 不给错,第四次没了,给 0.驱动侧的记账靠 file 里的 f_pos(回调参数里的 loff_t *off),读多少挪多少.写侧同理,返回值是吃进去的字节数.
然后是我翻的车,关于 f_pos 本身.测完一遍整体读,再 write 一段新数据,再 read — 满屏的 0,新数据像人间蒸发.原因:f_pos 是每个打开文件(fd)的会话状态,读到 EOF 时它停在末尾,write 只往缓冲里塞数据,根本不碰它,下一次 read 一看 *off >= len,直接 EOF.设备又不是文件,没有"重新读"的天然语义,想从头再来要么 close 重开拿新 fd,要么设备实现 lseek.这个行为让"字符设备的 offset 只是驱动自己的记账,不是文件系统那个 offset"变得非常具体.顺带一提 6.18 的一个 API 变化:no_llseek 被移除了,非 seekable 设备现在用 noop_llseek,我编译时撞上才知道.
copy 家族是内核和用户空间的安全边界.内核永远不能直接解引用用户指针 — 1.3 那篇的软件 PAN 实测已经证明,内核直接碰用户地址会当场页表级 fault.copy_to_user/copy_from_user 做三件事:陷入时安全地跨边界拷贝,逐页检查权限,失败时告诉你拷了多少.实测给 read 喂一个坏地址:
[bad read] ret=-1 errno=14 (Bad address)
驱动里 copy_to_user 返回非零(未拷完的字节数),驱动把它翻译成 -EFAULT,glibc 再翻译成 errno 14.这条链上每一环都不能省:有人偷懒用 memcpy,在他自己的 x86 老内核上恰好能跑,换到带 PAN 的 arm64 立刻死给你看.
ioctl 是带外通道,read/write 装不下的"配置与控制"走这里.命令号必须用宏编码,1.3 那篇的 FIGETBSZ 事故已经演示过裸数字的下场,这次把编码本身拆开看.内核侧把收到的 cmd 拆位打印:
ioctl cmd=0x40046b02 dir=WRITE type=0x6b nr=2 size=4 (_IOW('k', 2, int))
ioctl cmd=0x80046b01 dir=READ type=0x6b nr=1 size=4 (_IOR('k', 1, int))
ioctl cmd=0x00006b09 dir=NONE type=0x6b nr=9 size=0 (_IO('k', 9))
32 位命令号四段:方向(高 2 位,0x4000 写,0x8000 读),魔数 type(次高 8 位,'k' 是 0x6b),序号 nr(低 8 位),数据大小(中间 13 位... 实际按位域拼,4 字节 int 就是 0x4).用户态和内核必须 include 同一组宏定义,两边算出的 cmd 天然一致.实测 set/get 往返 777 成功,发一个驱动不认识的 _IO('k',9),返回 ENOTTY(errno 25,Inappropriate ioctl for device)— 注意是 ENOTTY 不是 EINVAL,这是历史约定,别"纠正"它.数据传输尽量用 put_user/get_user,单个标量比 copy_from_user 整结构体干净.
最后 mmap,零拷贝通道.驱动实现一个 .mmap 回调,用 remap_pfn_range 把内核持有的物理页直接映射进用户的地址空间:
return remap_pfn_range(vma, vma->vm_start,
virt_to_pfn((void *)shared_page), PAGE_SIZE, prot);
实测用户 mmap 完第一眼就看到内核预置的 0xcafebabe — 同一个物理页,两个地址空间,没有拷贝.用户写 0x1234 进去,内核侧立即可见.大缓冲,视频帧,DMA 共享内存,走的都是这条路:read/write 每次一个 memcpy,mmap 一次映射终身免费.代价是同步责任全给你:用户改了内核什么时候"看见",缓存属性对不对(writecombine 那个页保护参数),多进程同时映射的竞争,这些 read/write 模式下框架兜着,mmap 模式下自己兜.
最后留几个容易忽略的细节:
- read 的 count 是 size_t,驱动的缓冲减法先 clamp 再拷,别信用户给的长度,栈上 char buf[64] 接一个 count 上亿的 read 是经典溢出.
- copy_from_user 的返回值是"没拷到的字节数",不是负的错误码,0 才是全部成功,判断条件别写反.
- 一次 ioctl 带结构体时,结构体里别放指针,用户态指针在内核侧毫无意义,要嵌套地址请用可变长 ioctl 或 read/write.
- mmap 的 vma->vm_pgoff 是用户传的偏移,多区域设备(寄存器组,显存分区)用它分发,单页设备记得校验它和长度,不然用户能映射出你的页之外的东西.
- remap_pfn_range 不能映射普通 kmalloc 内存当可缓存页用,缓存属性和别名一致性会出灵异问题,要么 VM_RESERVED 风格的预留页,要么走 dma_mmap_coherent.
- ENOTTY 的名字是历史化石,它就是"命令不认识"的标准答案,新驱动跟随即可.
- f_pos 在多线程同时 read 同一 fd 时没有锁保护,要并发安全自己加,或者干脆 .read 不依赖 *off(每次读都"取新数据"的流式设备常见).