从 `dd` 命令到 NuttX 伪设备:`/dev/zero` 与 `/dev/null` 的实现剖析
Overview
本文从一条常见的dd基准测试命令出发,逐步深入到/dev/zero和/dev/null这两个 Unix 经典伪设备的语义、用途,最后落到 NuttX RTOS 上的具体实现,并解释为什么驱动既要return total又要uio_advance(uio, total)。适合想理解操作系统 I/O 抽象、NuttX VFS 以及 readv/writev 接口的嵌入式开发者。
Topics Covered
Topic 1:dd if=/dev/zero of=/dev/null bs=2048 count=4096在做什么
背景
dd是 Unix 经典工具,按块复制数据。这条命令常用来粗略测内存带宽或验证dd本身的开销。
参数逐一解析
dd:data duplicator,按块复制数据。if=/dev/zero:inputfile,输入源。/dev/zero读出来是无限的\0字节。of=/dev/null:outputfile,输出目标。/dev/null写进去的数据被丢弃。bs=2048:blocksize,每次读/写的块大小(字节)。等价于同时设置ibs和obs。count=4096:复制多少个块。
实际效果
总数据量2048 × 4096 = 8 MiB。从/dev/zero读 8 MiB 全零数据,写入/dev/null丢弃。磁盘零开销,纯粹消耗 CPU 和内存带宽。执行后dd会打印类似:
4096+0 records in 4096+0 records out 8388608 bytes (8.4 MB, 8.0 MiB) copied, 0.00X s, X GB/s末尾的速率就是这次测试的吞吐量。要测更有意义的数值,通常bs=1M count=1024(1 GiB)。
关键学习点
dd的瓶颈在内存带宽 + 系统调用开销,不涉及磁盘。bs越大,单次 syscall 摊销到的字节越多,吞吐越高。
Topic 2:/dev/zero与/dev/null的语义
概念
两者都是伪设备——没有真实硬件,纯软件模拟的字符设备。可以像普通文件open/read/write,但行为是约定好的。
行为对比
| 设备 | read 行为 | write 行为 | 典型用途 |
|---|---|---|---|
/dev/zero | 无限返回\0 | 丢弃,假装写成功 | 初始化内存、填零文件、占位测试 |
/dev/null | 立即 EOF(返回 0) | 丢弃,假装写成功 | 丢弃命令输出、丢弃 stderr |
它们是文件 I/O 抽象的"恒等元"——/dev/zero是无限零源,/dev/null是无限黑洞。写端两者完全等价,只在读端分化。
典型用法
# 1. 丢弃命令的 stdoutmake2>&1>/dev/null# 2. 丢弃 stderr 但保留 stdoutls/nonexistent2>/dev/null# 3. 测试程序写入速度(不想真的占用磁盘)ddif=/dev/zeroof=/dev/nullbs=1Mcount=1024# 4. 清空文件>file.txt# 等价于 cat /dev/null > file.txt# 5. 检查命令是否存在但不关心输出command-vgcc>/dev/null2>&1&&echo"found"Topic 3: NuttX 上的实现
源码位置
nuttx/drivers/misc/dev_zero.c(约 140 行)nuttx/drivers/misc/dev_null.c(约 130 行)
设备注册
两者都通过register_driver()注册到 VFS,权限0666:
/* dev_zero.c:139 */register_driver("/dev/zero",&g_devzero_fops,0666,NULL);/* dev_null.c:129 */register_driver("/dev/null",&g_devnull_fops,0666,NULL);注册后用户态就能open("/dev/zero", ...)像普通文件一样访问。
file_operations表
两者都只挂了readv/writev/poll三个钩子,其他全是NULL。open/close为NULL时 VFS 默认放行;read/write为NULL但readv/writev存在时,VFS 会自动用 readv/writev 顶替(这是 NuttX 的新接口,把read视作只有 1 个 iovec 的readv)。
/dev/zero核心逻辑(dev_zero.c:74-91)
staticssize_tdevzero_readv(FARstructfile*filep,FARstructuio*uio){size_ttotal=uio->uio_resid;FARconststructiovec*iov=uio->uio_iov;intiovcnt=uio->uio_iovcnt;for(i=0;i<iovcnt;i++){memset(iov[i].iov_base,0,iov[i].iov_len);/* 把用户 buffer 全填零 */}uio_advance(uio,total);returntotal;/* 永远满足请求长度 */}写操作更简单(dev_zero.c:97-106)——啥都不干,只把uio计数器推到末尾,骗调用方"全写完了"。
/dev/null核心逻辑(dev_null.c:74-96)
staticssize_tdevnull_readv(FARstructfile*filep,FARstructuio*uio){return0;/* 直接 EOF */}staticssize_tdevnull_writev(FARstructfile*filep,FARstructuio*uio){size_tret=uio->uio_resid;uio_advance(uio,ret);returnret;/* 假装全部写成功,数据丢弃 */}poll实现
两者完全一样:永远返回POLLIN | POLLOUT,永不阻塞。
if(setup){poll_notify(&fds,1,POLLIN|POLLOUT);}与 Linux 实现对比
| 点 | Linux | NuttX |
|---|---|---|
| 文件 | drivers/char/mem.c(混合多个伪设备) | 每个设备独立.c |
/dev/zero读 | mmap 命中 ZERO_PAGE /clear_user() | memset用户 buffer |
| 注册 | register_chrdev+ udev | register_driver直接挂到 VFS |
| 代码量 | 几百行(含 mmap/lseek 等) | 各 ~140 行 |
NuttX 砍掉了mmap、lseek等不常用场景,保留了嵌入式真正常用的read/write/poll,符合资源紧约束的设计哲学。
Topic 4: “读出来都是 0” 的本质
用户视角
/dev/zero的read(fd, buf, n):
- 用户要多少给多少——
n多大,返回n,从不"短读",从不阻塞,从不 EOF。 - 数据"是"用户 buffer 被填零——驱动拿到用户
buf指针,直接memset(buf, 0, n),然后告诉你"读了 n 字节"。
数据流层次
普通文件 read: 磁盘 → 内核 page cache → 用户 buf (有真实数据流) /dev/zero read: (无源头) memset → 用户 buf (凭空生成零) /dev/null read: (无源头) ∅ (直接返回 0,buffer 不动)零是写到目的地的那一刻才"存在"的——底层没有任何"零数据源"在源头存着等你来取。
Topic 5: “写端是黑洞” 怎么理解
比喻
正常write至少做这几件事之一:
write(fd, buf, n) ├─ 写到磁盘文件 → 以后能 read 回来 ├─ 写到 socket → 对端能收到 ├─ 写到 pipe → 另一端 read 能拿到 └─ 写到串口/LCD → 硬件上能看到效果而/dev/zero和/dev/null的write函数体只做一件事:返回"我处理了 n 字节"这个谎言。
黑洞的"非动作"清单
devzero_writev没做任何这些事:
- ❌ 没有
memcpy(somewhere, iov[i].iov_base, ...)—— 数据没被拷走 - ❌ 没有
kmm_malloc(...)—— 没分配存储 - ❌ 没有写硬件寄存器 —— 没产生外部效果
- ❌ 没有唤醒等待队列 —— 没人在另一端等数据
- ❌ 没有 log、没有 trace —— 数据没留痕迹
这就是"黑洞"——write()调用语义上成功,但写入的字节被无条件丢弃,没有任何机制能取回。
类比
- 写到普通文件 = 把信投进邮箱,收件人能读
- 写到 pipe = 把纸条递给隔壁人,他能看
- 写到
/dev/null或/dev/zero= 把纸条扔进碎纸机,碎纸机告诉你"收到了 1 张纸",但没人能再看到这张纸
Topic 6: 为什么写函数除了return total,还要uio_advance(uio, total)
关键观察
return total是给上层用户的,uio_advance是给 VFS 框架内部用的。两者作用对象完全不同。
struct uio是什么
来自include/nuttx/fs/uio.h:44-54:
structuio{FARconststructiovec*uio_iov;/* iovec 数组指针 */intuio_iovcnt;/* 还剩几个 iovec */size_tuio_resid;/* 还剩多少字节没处理(resid = residual)*/size_tuio_offset_in_iov;/* 当前 iovec 内的偏移 */};它是 VFS 维护的"I/O 进度状态机"——记录"用户原本要传输 N 字节,目前还剩多少没处理、停在哪个 iovec 的哪个位置"。来自 BSD 传统,Linux 内核里叫iov_iter。
scatter/gather 场景
readv/writev处理多段 buffer:
structioveciov[3]={{.iov_base=bufA,.iov_len=100},{.iov_base=bufB,.iov_len=200},{.iov_base=bufC,.iov_len=300},};writev(fd,iov,3);/* 总共要写 600 字节 */VFS 内部初始化uio:uio_resid = 600,uio_iovcnt = 3,uio_offset_in_iov = 0。
驱动处理后,VFS 上层要根据uio决定:
- 是否要把这个
uio转交给下一层(mount layer/wrapper)? - 是否短写需要循环调用驱动?
- 给用户态的最终返回值(
原始 resid - 当前 resid)是多少?
uio_advance实现(fs_uio.c:82-114)
voiduio_advance(FARstructuio*uio,size_tsz){uio->uio_resid-=sz;while(iovcnt>0){if(sz<iov->iov_len-offset_in_iov){offset_in_iov+=sz;break;}sz-=iov->iov_len-offset_in_iov;iov++;iovcnt--;offset_in_iov=0;}uio->uio_iov=iov;uio->uio_iovcnt=iovcnt;uio->uio_offset_in_iov=offset_in_iov;}它把uio_resid减掉、滚动uio_iov指针、调整剩余 iovec 计数和段内偏移。
角色对比
| 调用 | 作用对象 | 谁看 |
|---|---|---|
return total | 函数返回值 | 直接调用者(VFS 的file_writev包装层) |
uio_advance(uio, total) | 修改*uio状态 | 后续可能再读这个uio的代码 |
漏调uio_advance会怎样
VFS 写流程(简化):
ssize_tfile_writev(...){uio_init(&uio,iov,iovcnt);/* uio_resid = 600 */ret=filep->f_inode->u.i_ops->writev(filep,&uio);written=original_resid-uio.uio_resid;returnwritten;/* 给用户态 */}如果驱动只return 600而不调uio_advance:uio.uio_resid仍是 600,written = 600 - 600 = 0——明明驱动说"我处理了 600",最终却报告"什么都没写"。返回值和 uio 状态两个数据源对不上。
类比
把uio想成"快递面单":
return total= 你打电话告诉发货方"我送了 600 件"uio_advance(uio, total)= 你在面单上划掉"已送达 600 件,剩余 0 件"
调用链上别的中间层只看面单,不接你电话。电话报数对了,但面单没改,下一个人接手时还以为这单一件没送。
/dev/zero写为啥也得调
虽然写"丢弃"语义上没消耗任何 buffer,但uio的语义是**“我处理了多少字节的请求”**——不是"我读/写了多少真实数据"。/dev/zerowritev 的"处理"就是"看了一眼,决定丢弃"。逻辑上 600 字节请求进来 → 驱动决定全部丢弃 →uio_resid必须减到 0。
Technical Stack
- NuttX RTOS:Apache 开源 RTOS,本文涉及 v12.x 系列
- VFS / file_operations:NuttX 的虚拟文件系统抽象
- uio / iovec:BSD 风格的 scatter/gather I/O 描述符
- POSIX
readv/writev/poll:标准多 buffer I/O 接口 - Unix 经典工具:
dd,伪设备/dev/zero、/dev/null
Complete Code Examples
/dev/zero完整驱动表
staticconststructfile_operationsg_devzero_fops={NULL,/* open */NULL,/* close */NULL,/* read - VFS 自动用 readv 顶替 */NULL,/* write - VFS 自动用 writev 顶替 */NULL,/* seek */NULL,/* ioctl */NULL,/* truncate */devzero_poll,devzero_readv,devzero_writev};注册到 VFS
voiddevzero_register(void){register_driver("/dev/zero",&g_devzero_fops,0666,NULL);}voiddevnull_register(void){register_driver("/dev/null",&g_devnull_fops,0666,NULL);}dd测内存带宽
ddif=/dev/zeroof=/dev/nullbs=1Mcount=1024Summary
/dev/zero和/dev/null是 Unix 抽象的两个"恒等元":一个是无限零源,一个是无限黑洞。两者写端等价,只在读端分化。- NuttX 上的实现极其精简——每个设备一个 .c 文件、~140 行。靠
register_driver挂到 VFS,靠file_operations表实现钩子。 - NuttX 现代驱动接口走
readv/writev+struct uio,比传统read/write多了 scatter/gather 能力和状态机记账。 - 驱动里
return total和uio_advance(uio, total)必须同时调用:一个对外报告,一个对内记账。漏掉记账是"说了但没记账"的潜在 bug。 - NuttX 相比 Linux 实现的删减(无 mmap、无 lseek)反映了嵌入式 RTOS 的资源约束哲学——只保留真正用到的语义。
References
- NuttX 源码:
nuttx/drivers/misc/dev_zero.cnuttx/drivers/misc/dev_null.cnuttx/include/nuttx/fs/uio.hnuttx/fs/vfs/fs_uio.c
- POSIX
readv/writev规范:The Open Group Base Specifications - Linux 对照实现:
drivers/char/mem.c(read_zero、write_null等) - NuttX 官方文档:https://nuttx.apache.org/docs/
