从 `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/zeroi nput f ile,输入源。/dev/zero 读出来是无限的 \0 字节。
  • of=/dev/nullo utput f ile,输出目标。/dev/null 写进去的数据被丢弃。
  • bs=2048b lock s ize,每次读/写的块大小(字节)。等价于同时设置 ibsobs
  • 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 三个钩子,其他全是 NULLopen/closeNULL 时 VFS 默认放行;read/writeNULLreadv/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 砍掉了 mmaplseek 等不常用场景,保留了嵌入式真正常用的 read/write/poll,符合资源紧约束的设计哲学。


Topic 4: "读出来都是 0" 的本质

用户视角

/dev/zeroread(fd, buf, n)

  1. 用户要多少给多少 ------n 多大,返回 n,从不"短读",从不阻塞,从不 EOF。
  2. 数据"是"用户 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/nullwrite 函数体只做一件事:返回"我处理了 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 内部初始化 uiouio_resid = 600uio_iovcnt = 3uio_offset_in_iov = 0

驱动处理后,VFS 上层要根据 uio 决定:

  1. 是否要把这个 uio 转交给下一层(mount layer/wrapper)?
  2. 是否短写需要循环调用驱动?
  3. 给用户态的最终返回值(原始 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_advanceuio.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

  1. /dev/zero/dev/null 是 Unix 抽象的两个"恒等元":一个是无限零源,一个是无限黑洞。两者写端等价,只在读端分化
  2. NuttX 上的实现极其精简------每个设备一个 .c 文件、~140 行。靠 register_driver 挂到 VFS,靠 file_operations 表实现钩子。
  3. NuttX 现代驱动接口走 readv/writev + struct uio,比传统 read/write 多了 scatter/gather 能力和状态机记账。
  4. 驱动里 return totaluio_advance(uio, total) 必须同时调用:一个对外报告,一个对内记账。漏掉记账是"说了但没记账"的潜在 bug。
  5. NuttX 相比 Linux 实现的删减(无 mmap、无 lseek)反映了嵌入式 RTOS 的资源约束哲学------只保留真正用到的语义。

References

相关推荐
HIT_Weston2 个月前
170、【OS】【Nuttx】【ARMV7M】任务跳转(上下文切换)(一)
os·nuttx·armv7m
HIT_Weston2 个月前
171、【OS】【Nuttx】【ARMV7M】任务跳转(上下文切换)(二)
os·nuttx·armv7m
HIT_Weston2 个月前
169、【OS】【Nuttx】【栈溢出】up_initial_state(IPSR&EPSR)
os·栈溢出·nuttx
HIT_Weston3 个月前
162、【OS】【Nuttx】【栈溢出】中断栈行为(双栈模型)
os·栈溢出·nuttx
HIT_Weston3 个月前
158、【OS】【Nuttx】【栈溢出】中断栈不检查(一)
os·栈溢出·nuttx
HIT_Weston3 个月前
155、【OS】【Nuttx】【栈溢出】安全边距(二)
os·nuttx·栈监控
HIT_Weston3 个月前
154、【Nuttx】【OS】【启动】栈溢出检测(一)
os·nuttx·栈监控
HIT_Weston3 个月前
153、【Nuttx】【OS】【启动】回归!继续 Nuttx 探索(Stack Monitor)
os·nuttx·栈监控
HIT_Weston7 个月前
149、【OS】【Nuttx】【周边】效果呈现方案解析:VSCode 打开外部链接(二)
vscode·os·nuttx·文档渲染