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:i nput f ile,输入源。/dev/zero读出来是无限的\0字节。of=/dev/null:o utput f ile,输出目标。/dev/null写进去的数据被丢弃。bs=2048:b lock s ize,每次读/写的块大小(字节)。等价于同时设置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 是无限黑洞。写端两者完全等价,只在读端分化。
典型用法
bash
# 1. 丢弃命令的 stdout
make 2>&1 > /dev/null
# 2. 丢弃 stderr 但保留 stdout
ls /nonexistent 2>/dev/null
# 3. 测试程序写入速度(不想真的占用磁盘)
dd if=/dev/zero of=/dev/null bs=1M count=1024
# 4. 清空文件
> file.txt # 等价于 cat /dev/null > file.txt
# 5. 检查命令是否存在但不关心输出
command -v gcc > /dev/null 2>&1 && echo "found"
Topic 3: NuttX 上的实现
源码位置
nuttx/drivers/misc/dev_zero.c(约 140 行)nuttx/drivers/misc/dev_null.c(约 130 行)
设备注册
两者都通过 register_driver() 注册到 VFS,权限 0666:
c
/* 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)
c
static ssize_t devzero_readv(FAR struct file *filep, FAR struct uio *uio)
{
size_t total = uio->uio_resid;
FAR const struct iovec *iov = uio->uio_iov;
int iovcnt = uio->uio_iovcnt;
for (i = 0; i < iovcnt; i++)
{
memset(iov[i].iov_base, 0, iov[i].iov_len); /* 把用户 buffer 全填零 */
}
uio_advance(uio, total);
return total; /* 永远满足请求长度 */
}
写操作更简单(dev_zero.c:97-106)------啥都不干,只把 uio 计数器推到末尾,骗调用方"全写完了"。
/dev/null 核心逻辑(dev_null.c:74-96)
c
static ssize_t devnull_readv(FAR struct file *filep, FAR struct uio *uio)
{
return 0; /* 直接 EOF */
}
static ssize_t devnull_writev(FAR struct file *filep, FAR struct uio *uio)
{
size_t ret = uio->uio_resid;
uio_advance(uio, ret);
return ret; /* 假装全部写成功,数据丢弃 */
}
poll 实现
两者完全一样:永远返回 POLLIN | POLLOUT,永不阻塞。
c
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:
c
struct uio
{
FAR const struct iovec *uio_iov; /* iovec 数组指针 */
int uio_iovcnt; /* 还剩几个 iovec */
size_t uio_resid; /* 还剩多少字节没处理(resid = residual)*/
size_t uio_offset_in_iov; /* 当前 iovec 内的偏移 */
};
它是 VFS 维护的"I/O 进度状态机"------记录"用户原本要传输 N 字节,目前还剩多少没处理、停在哪个 iovec 的哪个位置"。来自 BSD 传统,Linux 内核里叫 iov_iter。
scatter/gather 场景
readv/writev 处理多段 buffer:
c
struct iovec iov[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)
c
void uio_advance(FAR struct uio *uio, size_t sz)
{
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 写流程(简化):
c
ssize_t file_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;
return written; /* 给用户态 */
}
如果驱动只 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/zero writev 的"处理"就是"看了一眼,决定丢弃"。逻辑上 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 完整驱动表
c
static const struct file_operations g_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
c
void devzero_register(void)
{
register_driver("/dev/zero", &g_devzero_fops, 0666, NULL);
}
void devnull_register(void)
{
register_driver("/dev/null", &g_devnull_fops, 0666, NULL);
}
dd 测内存带宽
bash
dd if=/dev/zero of=/dev/null bs=1M count=1024
Summary
/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/