输入输出系统实战:字符设备、块设备与一切皆文件——公司的售前售后体系 输入输出系统实战字符设备、块设备与一切皆文件——公司的售前售后体系本文是《趣谈 Linux 操作系统》风格的实战续篇与上篇《进程间通信实战》配套。所有命令与输出均来自一台真实云服务器华为云 FlexusXUbuntu 24.04内核 6.8.0无一字虚构。建议边读边在自己的机器上复现。引子把 IO 系统想成公司的售前售后体系公司要对外做生意得有售前接需求、收材料和售后发货、维护。在 Linux 里所有硬件、所有能读写的东西都被抽象成一个统一的对象——文件。这就是著名的 “一切皆文件”Everything is a File。文件包括普通文件、设备、管道、socket 公司的对外窗口统一用open/read/write/close这套界面办事。VFS虚拟文件系统 公司总机的统一话术不管你找的是硬盘、键盘还是网卡前台都给你同一套受理/交接流程。字符设备Character Device售前窗口像键盘、鼠标、串口数据是一个个字符流式来的不能随机跳着读。块设备Block Device售后仓库像硬盘数据按**固定大小的块512B/4KB**存取支持随机寻址、缓存、批量发货。设备文件/dev/xxx 窗口的门牌号背后挂着主设备号次设备号内核靠它找到对应的部门驱动。设备驱动 具体干活的业务部门窗口接到请求就转给驱动去操作硬件。sysfs / udev 公司的内部通讯录与自动排号机内核把设备树暴露给用户空间udev 按规则自动在/dev下生成/命名窗口。ioctl 窗口之外那些特殊业务查窗口尺寸、问仓库容量走专门的密语通道。epoll 售后部一对多的高效接线员谁有活儿了才叫谁不用挨个打电话问。下面用一台真机把这套对外体系从门牌号到接线员全拆一遍。实验环境项目配置云服务器华为云 FlexusX ecs-44ec-0003系统Ubuntu 24.04 LTS内核6.8.0-106-genericCPU/内存8 核 / 16 GiB磁盘virtio 虚拟磁盘/dev/vda40 GiBext4 挂在/工具gcc 13.3.0、sysstat(iostat)、udevadm工作目录/root/io实验一一切皆文件——先认门牌号1.1 看看 /dev 下都挂了哪些窗口ls-l/dev|head-30真实输出节选total 0 crw-r--r-- 1 root root 10, 235 Jul 29 12:49 autofs drwxr-xr-x 2 root root 240 Jul 29 12:49 block crw-rw---- 1 root disk 10, 234 Jul 29 12:49 btrfs-control drwxr-xr-x 3 root root 60 Jul 29 12:49 bus drwxr-xr-x 2 root root 3500 Jul 29 12:49 char crw------- 1 root root 5, 1 Jul 29 12:49 console lrwxrwxrwx 1 root root 11 Jul 29 12:49 core - /proc/kcore drwxr-xr-x 10 root root 200 Jul 29 12:49 cpu crw------- 1 root root 1, 7 Jul 29 12:49 full crw-rw-rw- 1 root root 10, 229 Jul 29 12:49 fuse crw------- 1 root root 240, 0 Jul 29 12:49 hidraw0 brw-rw---- 1 root disk 7, 0 Jul 29 12:49 loop0 brw-rw---- 1 root disk 7, 1 Jul 29 12:49 loop1两个最该记住的类型位c 字符设备character如console、fuse、hidraw0。b 块设备block如loop0、loop1。行尾那两个数字比如10, 235、7, 0就是主设备号, 次设备号主号告诉内核用哪个驱动部门次号区分同部门下的第几个窗口。1.2 只看字符设备和块设备ls-l/dev|grep-E^(c|b)|head-12真实输出crw-r--r-- 1 root root 10, 235 Jul 29 12:49 autofs crw-rw---- 1 root disk 10, 234 Jul 29 12:49 btrfs-control crw------- 1 root root 5, 1 Jul 29 12:49 console crw-rw-rw- 1 root root 1, 7 Jul 29 12:49 full crw-rw-rw- 1 root root 10, 229 Jul 29 12:49 fuse crw------- 1 root root 240, 0 Jul 29 12:49 hidraw0 crw------- 1 root root 10, 228 Jul 29 12:49 hpet brw-rw---- 1 root disk 7, 0 Jul 29 12:49 loop0 brw-rw---- 1 root disk 7, 1 Jul 29 12:49 loop11.3 内核眼里的设备部门列表/proc/devices/dev下是文件节点而/proc/devices列出当前内核注册了哪些字符设备类、哪些块设备类head-25/proc/devices真实输出Character devices: 1 mem 4 /dev/vc/0 4 tty 4 ttyS 5 /dev/tty 5 /dev/console 5 /dev/ptmx 7 vcs 10 misc 13 input 21 sg 29 fb 89 i2c 108 ppp 128 ptm 136 pts 180 usb 189 usb_device 202 cpu/msr 204 ttyMAX 226 drm 240 hidraw左侧数字就是主设备号右侧是部门名mem、tty、input、usb、hidraw…。open(/dev/tty)时内核查到它主设备号 5就交给tty 部门的驱动去处理。实验二字符设备——售前窗口们的脾气2.1 /dev/zero永不枯竭的0泉读它得到无限多的\0常用来造文件或当占位数据源ddif/dev/zeroof/tmp/zero.binbs1Mcount50ls-l/tmp/zero.bin|awk{print \$5}真实输出500 records out 52428800 bytes (52 MB, 50 MiB) copied, 0.0339447 s, 1.5 GB/s 5242880050 MB 全是 0写入速度 1.5 GB/s——数据来自内核直接填零不真读硬件。2.2 /dev/null写进去就消失的碎纸机往它写什么都丢弃常用来屏蔽不需要的输出ddif/dev/urandomof/dev/nullbs1Mcount10021|tail-1真实输出104857600 bytes (105 MB, 100 MiB) copied, 0.238128 s, 440 MB/s100 MB 随机数写进黑洞约 440 MB/s瓶颈在/dev/urandom产随机数的速度。2.3 /dev/random vs /dev/urandom随机数兄弟很多人以为/dev/random更安全所以更慢、还会阻塞。在 5.6 之后的内核我们这是 6.8这已经过时了两者底层都是同一个 CSPRNG密码学安全伪随机数生成器/dev/random不再因熵池不足而阻塞。跑一下对比echo[urandom];ddif/dev/urandomof/dev/nullbs1Mcount821|tail-1echo[random ];timeout5ddif/dev/randomof/dev/nullbs1Mcount821|tail-1真实输出[urandom] 8388608 bytes (8.4 MB, 8.0 MiB) copied, 0.0194582 s, 431 MB/s [random ] 8388608 bytes (8.4 MB, 8.0 MiB) copied, 0.0194179 s, 432 MB/s两者 8 MB 都在 ~0.019 s、约 431~432 MB/s完全一致——正是内核 5.6 把/dev/random改成非阻塞、与 urandom 同源的直接证据。2.4 /dev/tty当前控制终端往/dev/tty写内容会显示在进程的控制终端上。但在非交互的远程执行里没有控制终端ttyechohello-from-dev-tty/dev/tty21echo(已写入当前控制终端)||echo(非交互终端, /dev/tty 不可写, 属正常现象)真实输出not a tty (非交互终端, /dev/tty 不可写, 属正常现象)这是预期之内脚本跑在没有终端的环境/dev/tty没有对讲机可连。实验三块设备——售后仓库的货架与调度3.1 lsblk -f仓库与货架总览lsblk-f真实输出NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS vda └─vda1 ext4 1.0 35f7b939-7473-4e43-8527-a2f647b4c6a2 34.3G 8% /一块虚拟磁盘vda上面一个分区vda1格式化为 ext4挂在根/还剩 34.3 G、用了 8%。3.2 fdisk -l仓库的建筑图纸fdisk-l2/dev/null|head-20真实输出Disk /dev/vda: 40 GiB, 42949672960 bytes, 83886080 sectors Units: sectors of 1 * 512 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x87ac7989 Device Boot Start End Sectors Size Id Type /dev/vda1 * 2048 83886046 83883999 40G 83 Linux磁盘 40 GiB、共 83886080 个扇区、每扇区 512 字节vda1从第 2048 扇区起到结尾约 40 G类型 Linux(83)。3.3 块大小逻辑块 vs 物理扇区echo-nvda : ;blockdev--getbsz/dev/vdaecho-nvda1 : ;blockdev--getbsz/dev/vda1echo-n物理扇区: ;blockdev--getpbsz/dev/vda真实输出vda : 4096 vda1 : 4096 物理扇区: 512逻辑块大小文件系统打交道的最小单位是4096 字节而物理扇区是512 字节。文件系统以 4 KB 为一块读写落到磁盘上由驱动/阵列再拆成 512 B 的扇区。3.4 IO 调度器仓库发货的排队策略echo-n当前调度器: ;cat/sys/block/vda/queue/schedulerecho-n只读/旋转: ;cat/sys/block/vda/queue/rotational真实输出当前调度器: [none] mq-deadline 只读/旋转: 1注意当前选中的是[none]。none就是noop/透传——对 virtio 这类飞快的虚拟磁盘真实的排队调度在宿主机那一侧做虚拟机里再排一遍纯属多余所以内核直接选了nonemq-deadline 作为备选保留着。rotational1表示内核认为它是机械盘虚拟设备上报的默认值实际是 SSD 般的虚拟盘。实验四IO 调度与性能观察——接线员怎么排活iostat来自 sysstat是看磁盘忙不忙的标配。我们先用空闲基线看看输出长啥样再验证一次真实写入确实落盘。4.1 空闲基线 iostat -x 1 3iostat-x13真实输出节选第三段已接近空闲Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz d/s dkB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util vda 0.00 0.00 0.00 0.00 0.00 0.00 4.00 48.00 8.00 66.67 0.75 12.00 0.00 0.00 0.00 0.00 0.00 0.00 2.00 0.00 0.00 0.20几个核心列的含义r/s, w/s每秒读/写请求数。rkB/s, wkB/s每秒读/写的数据量KB。r_await, w_await读/写请求的平均等待时间毫秒含排队 硬件处理越短越好。aqu-sz平均队列长度积压的请求数。%util设备Busy 时间占比接近 100% 说明仓库快被压满了。第一段采样里这台机还有些后台活儿vda 有 r/s 7.28、rkB/s 347、w/s 9.18、%util 0.96到第三段已归于平静。4.2 写压力下的真实统计用 /proc/diskstats 差值iostat的数据其实就来自/proc/diskstats。我们写 300 MB 到磁盘再算写前后的差值亲手验证数据真的落盘了echoBEFORE:;awk/vda1/{print}/proc/diskstatsddif/dev/zeroof/root/io/bigfilebs1Mcount300convfdatasync# 强制落盘echoAFTER :;awk/vda1/{print}/proc/diskstatsrm-f/root/io/bigfile真实输出已对齐字段BEFORE: 253 1 vda1 12328 3428 1184245 31051 18095 8255 5208224 563260 0 23097 594311 ... AFTER : 253 1 vda1 12328 3428 1184245 31051 18534 8268 5822744 680587 0 24714 711638 ... # 计算差值(写相关字段): # write I/O 次数: 18534 - 18095 439 # write 扇区数 : 5822744 - 5208224 614520 扇区 × 512 314,634,240 字节 ≈ 300 MB差值里write 扇区增量 614520 扇区 ≈ 300 MB与dd写的 300 MiB 严丝合缝——证明这笔写确实走到了vda1。实战提示在写压力下你用iostat -x 1会看到vda的w/s、wkB/s、w_await、%util明显爬升上面这组/proc/diskstats差值就是 iostat 背后读取并做差的那份原始账本。生产排障时盯w_await突增和%util长期 100% 往往就是磁盘瓶颈信号。实验五Buffered IO vs Direct IO——要不要走阅览室文件读写默认走Page Cache页缓存就是前面说的阅览室也可以O_DIRECT绕过缓存、直写磁盘。两者差距有多大echo[Buffered] 默认写(page cache):ddif/dev/zeroof/tmp/buf.binbs1Mcount20021|tail-1echo[Direct ] oflagdirect(绕过page cache, 直接写盘):ddif/dev/zeroof/tmp/direct.binbs1Mcount200oflagdirect21|tail-1rm-f/tmp/buf.bin /tmp/direct.bin真实输出[Buffered] 默认写(page cache): 209715200 bytes (210 MB, 200 MiB) copied, 0.127169 s, 1.6 GB/s [Direct ] oflagdirect(绕过page cache, 直接写盘): 209715200 bytes (210 MB, 200 MiB) copied, 1.86578 s, 112 MB/s差距非常直观Buffered IO 1.6 GB/sDirect IO 只有 112 MB/s。Buffered IO数据先写进内存里的页缓存立刻返回看起来飞快真正刷盘由内核的回写线程pdflush异步完成。好处是多次小写给合并、读可命中缓存。Direct IO绕过页缓存每次写都直达块设备速度被磁盘物理带宽卡死这台虚拟盘约 100~200 MB/s。但它避免了缓存污染和双份拷贝数据库如 MySQL InnoDB自己做缓存层时偏爱 Direct IO省掉一层内核缓存。注意Direct IO 要求内存地址、长度、偏移都对齐到块大小本例bs1M天然对齐否则会报EINVAL。实验六设备驱动与 sysfs——内部通讯录6.1 /sys/class按设备类别排的通讯录/sys/class/把设备按类组织每类下是具体设备ls/sys/class/|head-30真实输出accel ata_device ata_link ata_port backlight bdi block bsg devcoredump devfreq devfreq-event devlink dma dma_heap dmi drm extcon firmware gpio graphics hidraw hwmon i2c-adapter i2c-dev input intel_scu_ipc iommu leds mdio_bus memblock/、input/、mem/、graphics/……就是内核暴露给用户空间的设备分类树。6.2 udevadm查这个窗口的档案udev负责在/dev下按规则自动生成、命名设备节点。用udevadm info看/dev/vda的来龙去脉udevadm info--queryall--name/dev/vda2/dev/null|head-15真实输出P: /devices/pci0000:00/0000:00:07.0/0000:02:01.0/virtio5/block/vda M: vda U: block T: disk D: b 253:0 N: vda L: 0 S: disk/by-path/virtio-pci-0000:02:01.0 S: disk/by-id/virtio-73fb188a-a225-46cd-8 S: disk/by-dname/main_disk S: disk/by-path/pci-0000:02:01.0 S: disk/by-diskseq/9 Q: 9 E: DEVPATH/devices/pci0000:00/0000:00:07.0/0000:02:01.0/virtio5/block/vda E: DEVNAME/dev/vda这告诉我们/dev/vda的内核设备路径DEVPATH是一条 PCI→virtio→block 的总线树它的主:次设备号是b 253:0udev 还顺手建了几个软链接别名/dev/disk/by-id/...、/dev/disk/by-path/...、/dev/disk/by-dname/main_disk这就是为什么你在/etc/fstab里用/dev/disk/by-uuid/...比直接用/dev/vda1更稳——盘符可能会变UUID 不会。实验七ioctl——窗口之外的密语通道很多操作不适合走read/write这种流式界面比如问终端多大、“问磁盘多大”。这类特殊查询走ioctlI/O control一个万能的控制面系统调用。下面程序干了两件实事用BLKGETSIZE64问块设备容量用TIOCGWINSZ问终端窗口大小。#includestdio.h#includefcntl.h#includeunistd.h#includesys/ioctl.h#includelinux/fs.h#includetermios.hintmain(){intfdopen(/dev/vda1,O_RDONLY);unsignedlonglongsize0;if(ioctl(fd,BLKGETSIZE64,size)0)printf(BLKGETSIZE64 /dev/vda1 %llu 字节 (%.2f GB)\n,size,size/1024.0/1024/1024);else{perror(BLKGETSIZE64);}close(fd);structwinsizews;if(ioctl(STDOUT_FILENO,TIOCGWINSZ,ws)0)printf(TIOCGWINSZ 终端窗口: 行%d 列%d\n,ws.ws_row,ws.ws_col);elseprintf(TIOCGWINSZ: 当前 stdout 不是 tty, 无法获取窗口大小(属正常现象)\n);return0;}编译运行gcc-O2-oioctl_demo ioctl_demo.c./ioctl_demo真实输出BLKGETSIZE64 /dev/vda1 42948607488 字节 (40.00 GB) TIOCGWINSZ: 当前 stdout 不是 tty, 无法获取窗口大小(属正常现象)BLKGETSIZE64直接问出vda1是40.00 GB——和fdisk看到的 40 G 对上了。TIOCGWINSZ在非交互环境拿不到窗口大小属正常和前面/dev/tty一样的道理。ioctl 的第二个参数就是密语代号不同设备认不同的代号驱动里switch(cmd)分发处理。实验八epoll vs select——高效接线员当进程要同时盯着多个文件/连接比如一个网络服务器守着上千个 socket怎么知道谁有数据可读了select / poll每次调用都把我要监视的 fd 集合从用户态拷进内核内核挨个遍历所有 fd 看谁就绪复杂度 O(n)。fd 一多就慢。epollLinux 专属注册一次内核用红黑树存兴趣列表之后epoll_wait只返回就绪队列里的 fd复杂度O(1)且支持边缘触发ET。下面用 epoll 盯着一根管道等对方写数据#includestdio.h#includestdlib.h#includeunistd.h#includesys/epoll.h#includestring.h#includesys/wait.hintmain(){intfd[2];pipe(fd);intepepoll_create1(0);structepoll_eventev,events[1];ev.eventsEPOLLIN;ev.data.fdfd[0];epoll_ctl(ep,EPOLL_CTL_ADD,fd[0],ev);pid_tpidfork();if(pid0){close(fd[0]);sleep(1);write(fd[1],hello-epoll,12);close(fd[1]);exit(0);}else{close(fd[1]);printf(epoll_wait 等待管道可读(超时5s)...\n);intnepoll_wait(ep,events,1,5000);if(n0){charbuf[64];ssize_trread(events[0].data.fd,buf,sizeof(buf)-1);buf[r]0;printf(epoll 报告 %d 个就绪事件, 读到: %s\n,n,buf);}else{printf(epoll_wait 返回 %d (超时或出错)\n,n);}wait(NULL);close(fd[0]);close(ep);}return0;}编译运行gcc-O2-oepoll_demo epoll_demo.c./epoll_demo真实输出epoll_wait 等待管道可读(超时5s)... epoll 报告 1 个就绪事件, 读到: hello-epoll子进程 1 秒后往管道写 “hello-epoll”父进程没用忙等、也没用遍历所有 fd而是被 epoll精准通知你盯的那根管道现在可读了一次epoll_wait就拿到就绪事件并读出了数据。这就是高并发服务器Nginx、Redis用 epoll 扛住百万连接的根本原因。原理图一切皆文件的调用链路用户进程 内核空间 硬件 ┌──────┐ │ open │── 路径 /dev/vda1 ──► VFS 统一入口 └──────┘ │ ┌──────┐ │ 按 主/次设备号 查表 │ read │── 文件描述符 fd ──────► │ │write │ ▼ └──────┘ ┌──────────┐ ┌──────────────┐ │ 字符设备 │ │ 块设备驱动 │──► 磁盘/网卡 │ 驱动 │ │ (请求→bio→ │ │(流式逐字节)│ │ IO调度队列) │ └──────────┘ └──────────────┘ ioctl ─► 控制面 密语 ──► 驱动 switch(cmd) epoll ─► 就绪事件通知 ──► 内核事件表(红黑树)就绪队列 /dev/xxx (门牌) ──udev──► 由 sysfs 设备树 自动生成总结概念一句话关键命令/文件一切皆文件所有 IO 都走 open/read/write/closels -l /dev看c/b主/次设备号门牌号主号定驱动次号定第几个ls -l行尾主,次/proc/devices字符设备流式、逐字节售前窗口/dev/tty、/dev/zero、/dev/random块设备按块、可随机、可缓存仓库lsblk、fdisk、blockdevIO 调度器仓库发货排队策略/sys/block/dev/queue/scheduler性能观测看忙不忙、排多长iostat -x、底层的/proc/diskstatsBuffered/Direct IO走不走阅览室(页缓存)dd oflagdirectsysfs/udev内部通讯录 自动排号/sys/class、udevadm infoioctl窗口外的控制面密语BLKGETSIZE64、TIOCGWINSZepoll高效多路复用接线员epoll_create/ctl/waitvs select一句话记忆/dev是门牌主/次设备号是指路牌字符设备管流式售前、块设备管块状售后sysfs/udev 是自动通讯录ioctl 是密语通道epoll 是能同时盯上千个窗口的金牌接线员。一切皆文件不是一句口号而是让上层的 open/read/write 不必关心对面是硬盘、键盘还是网卡的统一契约。思考题为什么/dev/random在老教程里会阻塞而我们的 6.8 内核里和/dev/urandom一样快提示内核 5.6 的改动iostat的%util达到 100% 一定代表磁盘到顶了吗如果是 NVMe 这种能并行很多请求的设备这个结论还成立吗试试用strace dd if/dev/zero of/tmp/x bs1M count10 oflagdirect跟踪你能看到open(..., O_DIRECT)和fsync吗epoll 的边缘触发(ET)和水平触发(LT)有什么区别为什么 ET 模式要求配合非阻塞 fd 循环读既然一切皆文件那网络 socket 算文件吗ls -l /proc/pid/fd里 socket 类型的 fd 长什么样实验环境华为云 FlexusX ecs-44ec-0003Ubuntu 24.04内核 6.8.0-106-generic。所有命令与输出均在该真机上执行无虚构。上一篇《进程间通信实战》见05-进程间通信实战.md。