暗色模式

Linux 特殊设备文件:从 /dev/null 到 mknod 自定义设备节点

技术教程
2026-09-30
5
0
本文要点
  • ls -l /dev 第一列的第一个字符 c / b 区分字符设备与块设备,后面那个 1, 3 才是关键:它是主设备号、次设备号。内核找驱动靠的是这组数字,跟文件名、跟路径毫无关系
  • 三个"垃圾桶式"特殊文件的行为完全不同:/dev/null 写入丢弃、读立即 EOF(实测读出 0 字节);/dev/zero 读出来是无穷个 0;/dev/full 读也是 0,但任何写入都报 ENOSPC(实测 echo 退出码 = 1)
  • 设备节点只是指向 (主设备号, 次设备号) 的一个指针。实测在 /tmp 里 mknod 造一个 c 1 3 的假节点,写 50MB 进去退出码 0;造一个 c 1 7 的,立刻 No space left on device——行为和 /dev 下的原件一模一样,全程没碰过 /dev。⚠️ 但建得出来不等于用得了:带 nodev 的挂载点上 mknod 照样成功、一 open 就 Permission denied
  • 内核 5.6 之后 /dev/random 不再阻塞:实测熵池只剩 6 位时读 1MB 仍然只要 8 毫秒。/proc/sys/kernel/random/entropy_avail 这个数字不能再用来判断"随机数够不够用"
  • 内核负责建节点、udev 负责改权限和起别名,而 mknod 是这两条路之外的第三条:手工造节点。它不需要驱动、不需要硬件,写错的代价是自己踩坑,不是系统崩

Linux 特殊设备文件:从 /dev/null 到 mknod 自定义设备节点

> /dev/null 2>&1 大概是 Linux 里被敲得最多的重定向。用得多了容易产生一种错觉:/dev/null 是个语法糖,是 shell 的某种特例。它不是。/dev/null 是一个货真价实的文件,有 inode、有权限位、有属主,只是它背后连着一个内核驱动,而这个驱动只做一件事——把写给它的字节扔掉。

同样的机制还造出了 /dev/zero、/dev/full、/dev/random 这几个"没有对应硬件"的特殊文件。它们的共同点很反直觉:你在 ls -l /dev 里看到的那个数字,才是它们全部的身份。

本文在一台 Ubuntu 22.04(内核 5.15.0-30-generic、ext4 根文件系统)上把这几个特殊文件逐个实测了一遍,包括 mknod 在 /tmp 里手工造节点的行为、nodev 挂载点的拦截位置,以及熵池只剩个位数时 /dev/random 到底会不会卡住。

设备节点:文件名不重要,设备号才重要

先看这几个最熟悉的特殊文件长什么样:

ls -l /dev/null /dev/zero /dev/full /dev/random /dev/urandom
echo "--- 块设备 vs 字符设备 ---"
ls -l /dev/vda /dev/null
echo "--- /proc/devices 里登记的主设备号 1 ---"
sed -n '1,3p' /proc/devices

列出 /dev 下五个特殊文件的权限、主次设备号,并与块设备 /dev/vda 对照,最后打印 /proc/devices 中主设备号 1 的驱动名

输出里值得盯住的是第 5 列:

crw-rw-rw- 1 root root 1, 7 /dev/full
crw-rw-rw- 1 root root 1, 3 /dev/null
crw-rw-rw- 1 root root 1, 8 /dev/random
crw-rw-rw- 1 root root 1, 9 /dev/urandom
crw-rw-rw- 1 root root 1, 5 /dev/zero
  • 第一列第一个字符 c 表示字符设备(character device),数据是一条字节流,只能顺序读写;/dev/vda 那个 b 是块设备(block device),有固定大小的块、可以被文件系统随机寻址和缓存。
  • 第 5 列 1, 7 是主设备号 1、次设备号 7。主设备号对应内核里注册的一个驱动,次设备号是这个驱动下的第几个设备。/proc/devices 的 Character devices: 段落就是主设备号的登记表,第一行 1 mem 说明主设备号 1 属于 mem 驱动——/dev/null、/dev/zero、/dev/full、/dev/random、/dev/urandom 全都挂在它下面,次设备号 3、5、7、8、9 分别对应这五个功能。
  • 对比 /dev/vda 的 252, 0:块设备主设备号 252 是 virtio_blk 驱动,同一个驱动下第 0 个设备就是系统盘。主设备号是动态分配的,不同机器上不保证一样,别把它写死进任何脚本。

这里的关键结论是:内核从来不靠文件名认设备。/dev/null 这个名字是 udev 按传统起的,你把节点挪到别处、改成别的名字,只要设备号还是 1, 3,它照样是 /dev/null。下一节的 mknod 就是来验证这件事的。

/dev/null:写得进,什么都不留

它的行为可以概括成两条:写入的字节被直接丢弃,读取立即返回 EOF。实测读它拿到 0 字节:

wc -c < /dev/null
0

因为读走的是"文件结尾",所以 cat /dev/null 什么都不输出、cat file > /dev/null 能安全地丢掉一切。日常两个高频用法:

command > /dev/null 2>&1     # 标准输出和错误全都丢掉
: > /dev/null                # 空操作,常用来占位

⚠️ 但"写进 /dev/null 不要钱"是错觉。每一次 write() 仍然是一次完整的系统调用,要走一遍内核路径,只是数据在 mem 驱动里被丢掉了而已。往 /dev/null 里灌数据的死循环照样能把一个核跑满——跑满的时候你去看 /proc/stat,还会发现相当一部分时间记在 system 而不是 user,因为时间都花在系统调用上了。

/dev/zero:读出来的全是 0

/dev/zero 从名字就能猜到行为:读它得到无穷无尽的 0x00 字节流,永远读不到 EOF。写它的行为和 /dev/null 一样——丢弃。

echo "--- /dev/zero:无限个 0 字节 ---"
dd if=/dev/zero of=/tmp/zeros.bin bs=1M count=8 status=none
ls -lh /tmp/zeros.bin
od -An -tx1 -N 16 /tmp/zeros.bin
echo "--- /dev/null:200MB 写进去就消失 ---"
{ time dd if=/dev/zero of=/dev/null bs=1M count=200 status=none ; } 2>&1
echo "--- /dev/urandom:随机字节与熵池 ---"
head -c 16 /dev/urandom | od -An -tx1
echo "熵池可用位数 = $(cat /proc/sys/kernel/random/entropy_avail)"

用 /dev/zero 生成 8MB 全零文件并查看前 16 字节,用 time 测量 200MB 写入 /dev/null 的耗时,最后读取 /dev/urandom 的 16 字节与熵池位数

实测结果:

-rw-r--r-- 1 root root 8.0M Sep 29 21:04 /tmp/zeros.bin
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
--- /dev/null:200MB 写进去就消失 ---

real    0m0.019s
user    0m0.000s
sys    0m0.017s

200MB 数据写进 /dev/null 用了 19 毫秒——因为根本没有磁盘参与,全是内存里的系统调用开销。dd if=/dev/zero of=/dev/sdX 这种"用零覆盖磁盘"的老写法,就是靠 /dev/zero 这个无限零流。

要注意 /dev/zero 和稀疏文件是两回事:dd if=/dev/zero 造出来的是实打实占满磁盘的零,而 truncate -s 1G 造出来的文件看着也是 1G、du 却几乎不占空间。两者的 ls 和 du 口径差异,之前单独写过一篇:Linux 稀疏文件:从 truncate 预分配到 du 与 ls 大小不一致。

/dev/full:专门用来失败的设备

/dev/full 是这几个特殊文件里最有意思的一个,也是最少人知道的。它的行为是:读它和 /dev/zero 一样返回 0,但任何写入都失败,错误码是 ENOSPC——磁盘满。

echo "--- /dev/full:永远写不进去 ---"
{ echo hello > /dev/full ; } 2>&1
echo "退出码=$?"

实测:

bash: line 2: echo: write error: No space left on device
退出码=1

明明磁盘还有 5.2G 可用,写 /dev/full 却报"空间不足"。这正是它的用途:制造一个可控的"磁盘写满"现场。

  • 测试程序处理 ENOSPC 的分支。想知道你的脚本在磁盘满时会不会静默丢数据?把输出重定向到 /dev/full 跑一遍,看它有没有检查返回值。
  • 测 dd 的写入错误处理:dd if=/dev/zero of=/dev/full bs=1M count=1 会立刻报错退出,而不是傻乎乎地写满磁盘。
  • 验证一句老话:只看 echo 自己的退出码看不出问题,> 重定向失败时是 shell 在报错。这也是为什么写文件时必须检查退出码。

一个容易踩的坑:echo hello > /dev/full 2>&1 写出来是没用的——2>&1 跟在 > 后面,会把 stderr 也一起指到 /dev/full 里,报错信息被自己吃了。要让报错留在屏幕上,得像上面那样用 { ... ; } 2>&1 包一层。

mknod:亲手造一个设备节点

知道了"设备号决定一切",就可以做那个最有说服力的实验:在 /tmp 里手工造两个节点,一个冒充 /dev/null,一个冒充 /dev/full。

echo "--- 在 /tmp 里手造两个设备节点(/dev 一字未动)---"
mknod /tmp/fakenull c 1 3
mknod /tmp/fakefull c 1 7
ls -l /tmp/fakenull /tmp/fakefull
echo "--- 行为与 /dev 下的原件一模一样 ---"
dd if=/dev/zero of=/tmp/fakenull bs=1M count=50 status=none
echo "写 fakenull 退出码=$?"
{ dd if=/dev/zero of=/tmp/fakefull bs=1M count=1 status=none ; } 2>&1
echo "写 fakefull 退出码=$?"

在 /tmp 下用 mknod 创建两个设备节点,写入 50MB 到冒充 /dev/null 的节点返回 0,写入冒充 /dev/full 的节点报 No space left on device

实测:

crw-r--r-- 1 root root 1, 7 Sep 29 21:04 /tmp/fakefull
crw-r--r-- 1 root root 1, 3 Sep 29 21:04 /tmp/fakenull
--- 行为与 /dev 下的原件一模一样 ---
写 fakenull 退出码=0
dd: error writing '/tmp/fakefull': No space left on device
写 fakefull 退出码=1

/tmp/fakenull 吞掉了 50MB 且退出码为 0,/tmp/fakefull 第一块数据就撞上 ENOSPC。两个文件的名字、路径、权限都跟 /dev 下的原件不同,但行为完全一致——这证明设备节点就是一组设备号加一个名字,没有别的魔法。

mknod 的语法是 mknod <路径> <c|b> <主设备号> <次设备号>:

  • c 造字符设备,b 造块设备。
  • 需要 root(CAP_MKNOD)。普通用户造不了,这也是容器里默认不给这个能力的原因。
  • 设备节点上的 rw- 权限位管的是"能不能 open 它",和普通文件的读写权限一个道理。/dev/null 是 crw-rw-rw-,所以谁都能往里写。

造得出来,不一定用得起来:nodev

上面的实验能成功,前提是 /tmp 所在的文件系统没有挂 nodev。实测这台机器的根文件系统选项是 rw,relatime,discard,errors=remount-ro,没有 nodev,所以节点可以直接用。

反过来,在带 nodev 的挂载点上,事情会变得很微妙:

mkdir -p /mnt/novdev
mount -t tmpfs -o nodev,nosuid tmpfs /mnt/novdev
mknod /mnt/novdev/n c 1 3
{ echo hi > /mnt/novdev/n ; } 2>&1; echo "写入退出码=$?"

实测输出:

mknod 成功(创建节点本身不受 nodev 限制)
bash: line 6: /mnt/novdev/n: Permission denied
写入退出码=1

⚠️ mknod 成功了,open 被拒绝。nodev 拦的不是"创建节点",而是"使用节点"——它让内核对这个挂载点上的设备节点直接返回 EACCES。所以看到 Permission denied 时先别急着查权限位,findmnt -no OPTIONS <挂载点> 看一眼有没有 nodev。

这解释了两类常见现象:

  1. 容器(以及一些系统加固方案)会把 /dev 挂成 nodev 的 tmpfs,或干脆不给 CAP_MKNOD;此时你在容器里 mknod 要么造不出来,要么造出来也打不开。
  2. 某些 mount 选项组合(nodev、nosuid、noexec)会让"文件明明在、权限也对"的操作失败,排查时挂载选项的优先级高于权限位。

那 /dev 里的节点是谁建的?

既然内核只认设备号,那 /dev 下那一堆节点是怎么冒出来的?这台机器上的链路是:内核发现设备 → 在 devtmpfs 上建出节点(此时权限是 0600)→ 发 uevent → udev 收到后按规则改属主属组、改权限、建符号链接。所以"节点是内核建的,权限是 udev 定的"。这套规则怎么改、怎么给设备起固定别名,之前单独写过一篇:Linux udev 设备管理:从 /dev 节点生成到自定义规则。

而 mknod 是这之外的第三条路:不依赖 uevent、不依赖驱动自动探测,手工把设备号写进一个节点。它在两种场合很有用——设备节点被误删而系统还在跑(不用重启就能补回来),以及在没有 udev 的精简环境(容器、initramfs)里搭出最小 /dev。

/dev/random 与 /dev/urandom:熵池那点事

/dev/random 长久以来的名声是"读它会阻塞",这个印象在 5.6 之后已经过期了。实测证据很直接——先看熵池,再读 1MB:

cat /proc/sys/kernel/random/entropy_avail
{ time head -c 1048576 /dev/random | wc -c ; } 2>&1
{ time head -c 1048576 /dev/urandom | wc -c ; } 2>&1

实测输出(熵池只剩 6 位):

6
1048576

real    0m0.008s
1048576

real    0m0.009s

熵池可用位数已经低到 6,/dev/random 照样 8 毫秒吐出 1MB,和 /dev/urandom 没有区别。原因是内核从 5.6 起两个设备共用同一个 ChaCha20 的 CSPRNG:/dev/urandom 永远不阻塞,/dev/random 只在系统刚启动、CRNG 还没初始化的那一小段时间里阻塞,之后它就等同于 /dev/urandom。

由此得到两条实用结论:

  • 写程序要用随机数:直接用 getrandom(2)(Python 的 os.urandom、Go 的 crypto/rand 最终都走到它),不要自己去读 /dev/random,更不要用"读 /dev/random 能阻塞"当熵不足的保护手段。
  • ⚠️ entropy_avail 不能用来判断随机数质量。它统计的是内核估计的"噪声贡献量",不代表随机数池子的剩余量;上面 6 位的例子已经说明,这个数字低到个位数也不影响输出随机性。

一张排查用的小表

现象真实原因
写文件报 No space left on device,但 df 显示有空间检查目标是不是 /dev/full,或是不是 df -i 的 inode 用尽
设备节点在、权限也对,open 却 Permission denied挂载点上有 nodev,或缺少 CAP_MKNOD / cgroup devices 限制
容器里 /dev 缺某个节点内核没探测到该设备(没 --device 透传),不是 udev 的问题
cat /dev/random 卡住只可能发生在 CRNG 尚未初始化的极早期启动阶段
往 /dev/null 灌数据的进程仍把 CPU 跑满每次 write() 都是系统调用,丢掉数据不等于不花时间

这五个特殊文件加起来不到 20 行驱动代码,但它们是理解"Linux 里设备到底是什么"的最短路径:一个名字、一组权限、一个 (主设备号, 次设备号),再加一个内核驱动。看懂之后,> /dev/null 就不再是语法糖,容器里缺设备节点、加固挂载点打不开设备的那些怪现象,也都能顺着同一条线索查下去。

发表评论

暂无评论,快来抢沙发吧!