本文要点
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
输出里值得盯住的是第 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/null0因为读走的是"文件结尾",所以 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)"
实测结果:
-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.017s200MB 数据写进 /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 退出码=$?"
实测:
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。
这解释了两类常见现象:
- 容器(以及一些系统加固方案)会把
/dev挂成nodev的 tmpfs,或干脆不给CAP_MKNOD;此时你在容器里mknod要么造不出来,要么造出来也打不开。 - 某些 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 就不再是语法糖,容器里缺设备节点、加固挂载点打不开设备的那些怪现象,也都能顺着同一条线索查下去。
评论 (0)
暂无评论,快来抢沙发吧!