ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从EBUSY一码多义到细粒度错误码:内核错误排查与设计实践

从EBUSY一码多义到细粒度错误码:内核错误排查与设计实践 如果你在 Linux 下写过设备驱动、文件系统或跟内核打过交道大概率被 EBUSY 教育过。这个错误码全称是 Device or resource busy但重点是它背后藏的语义实在太多了设备忙、文件被占用、资源未释放、甚至某些条件下内核不想让你干某件事……统统都能映射到一个 16 上。我用一个实际的 f2fs 挂载问题作为切入带大家把一码多义这件事扯开看顺便聊聊颗粒度更细的错误码到底该怎么做。先说一个典型场景。执行mkfs.f2fs /dev/loop2时我碰到过这样的输出mkfs.f2fs -f /dev/loop2 info: Force to format the filesystem. Error: Failed to open /dev/loop2 with errno 13第一次看这个错误直觉是权限问题但 sudo 之后依然是 errno 13。仔细检查才发现loop2 已经被某个进程挂载并持有了。这个例子恰好说明另一个问题errno 13 是 EACCES跟 EBUSY 的事儿看起来不搭边但实际上在文件系统、设备映射、loop 设备这些场景里内核返回的错误码常常是被层层吞掉、再按上一层逻辑重新映射过的。一码多义不仅在错误码本身还有一层含义是原始错误细节在传递过程中丢了。这篇文章想聊三件事EBUSY 到底为什么会一码多义、在内核和用户态分别怎么用更细的错误码来弥补、以及我在实际项目里踩过的坑和最终采用的方案。1. errno 与 EBUSY一码多义问题的由来1.1 errno 机制的设计考量与历史包袱errno 是 Unix/Linux 系统最早的错误报告机制它的设计朴素而直接每次系统调用失败用一个小整数来代表失败类型。这套机制有它的历史合理性——几十年前内存极有限内核态能留给错误传递的空间就是那么几个比特不可能让每个错误都独享一个码值。但正因为如此它注定要面临一码多义的问题。Linux 内核用include/uapi/asm-generic/errno-base.h和errno.h定义了一百多个错误码而实际使用它们的系统调用和内核子系统数量级在几千个以上。打个比方相当于一个街道只有一个门牌号但街道里有几百户人家。具体到 EBUSY内核代码里搜索-EBUSY的返回点能搜到几百处。这些返回点的共同点是资源的当前状态不允许你这么做但具体是什么资源、什么状态、什么原因errno 本身完全无法表达。1.2 EBUSY 的高频触发场景盘点先给大家梳理一下 EBUSY 在哪些场景下最容易冒出来这决定了我们后续设计细粒度错误码时要覆盖哪些方向。第一个高频场景是挂载与文件系统操作。尝试挂载一个已经挂载的设备、umount 一个正在被访问的挂载点、在 f2fs/xfs/ext4 上强制格式化一个正在使用的块设备都会收到 EBUSY。这个场景的本质是设备引用计数不为零内核不让破坏底层数据。# 典型触发场景 mount /dev/sdb1 /mnt/data mount /dev/sdb1 /mnt/data2 # mount: /mnt/data2: /dev/sdb1 already mounted or mount point busy.第二个高频场景是设备文件与驱动交互。open(/dev/ttyS0)被占用、ioctl 操作遇上设备正在处理关键事务、释放一个被 DMA 占用的 buffer都可能返回 EBUSY。第三个场景是网络配置。修改一个正在使用的网络接口的属性比如在网卡有 IP 地址时尝试将其加入 bridge、或者删除一个非 down 状态的 vlan 接口内核会直接用-EBUSY拒绝。第四个场景是用户态资源管理。O_EXCL打开已存在的文件、flock/fcntl锁冲突、sem_trywait信号量被占用、socket绑定端口时地址已在使用中等等。这些场景有一个共同特征它们确实都涉及忙但忙的程度和原因差异极大。有的忙只是瞬时状态有的忙是死锁有的是配置冲突。把这一切压缩进一个整型值等于把所有诊断信息都丢掉了。2. 一码多义的痛点为什么需要更细的错误码2.1 从开发者调试视角看 EBUSY 的模糊性我维护过一个块设备驱动曾经被一个奇怪的 EBUSY bug 折磨了两天。现象是拔掉 USB 设备后再插上用户态程序重新打开设备节点总是返回 Device or resource busy。最初以为程序哪里没 close fd排查半天无果。后来只能在内核驱动里加了大量 dev_dbg 打印才定位到问题是 usb 核心层的 device 结构体引用计数没有在 disconnect 路径里正确释放导致device_add阶段校验设备是否已存在时直接短路返回 EBUSY。这个问题的核心矛盾在于用户态看到的是忙内核态知道的是引用计数泄漏两者之间的信息鸿沟只有靠打日志才能填平。如果错误码能细分到资源已被持有持有者 PID 1234或设备已在其他命名空间挂载排障时间可以从两天缩短到两小时。2.2 从程序健壮性看一码多义的伤害对应用程序开发者来说一码多义意味着很难根据 errno 做精确恢复策略。比如你写了一个文件管理器试图重命名一个目录时收到 EBUSY你的代码应该怎么处理是稍后重试、还是给用户弹窗目录忙、还是检查是否有其他进程占用如果你只能拿到 EBUSY 这三个字母任何分支都是瞎猜。更麻烦的是很多程序把 errno 当协议使用。比如 QEMU/KVM 虚拟机磁盘热插拔、Docker 容器挂载卷、systemd 管理挂载单元这些上层系统都依赖 errno 来判断下一步动作。一码多义导致它们要么选择宽泛的重试 超时要么直接放弃并报错极大地降低了系统的自适应能力。2.3 错误传递链中的二次丢失这是一个在坐的各位很容易忽略的细节内核内部的错误返回路径并不是一路通畅的。很多系统调用会调用多个子函数子函数返回一个错误码父函数未必会原样透传而是根据上下文重新映射。比如 VFS 层的do_sys_open可能收到底层文件系统返回的-ENOSPC设备上没有空间但中间经过了may_open的一次权限检查如果权限检查先失败就会直接返回-EACCES原始的空间不足错误就丢了。在一个真实的运维体验中我用 f2fs 格式化一个 loop 设备时遇到了open /dev/loop2 failed errno:13 error: not available on mounted device!。title 表面上是权限错误但根因其实是这个 loop 设备已经被 kernel 自动挂载到了某个只读的 snapshot 上mkfs.f2fs 的 open 内部做了O_EXCL检查发现设备已被内核持有才返回了错误。它把设备已被挂载翻译成了 EACCES一码多义的二次伤害就在这里。3. 颗粒度更小的错误码实现思路与方案既然 EBUSY 一码多义的根因在于信息量不足那解决方向就很清晰要么让错误码本身携带更丰富的语义要么为错误码配套辅助信息通道。我实际调研和落地过三类方案都分享出来。3.1 方案一内核扩展子错误码sub-errno第一种思路是在内核态保留 errno 的同时引入一个子错误码sub_errno字段。具体做法是在内核新增一个每任务per-task的字段current-sub_errno或者放到 thread_info 里。在返回-EBUSY的代码路径里额外写一句current-sub_errno EBUSY_DEVICE_IN_USE;用户态通过syscall的直接返回值拿不到子错误码所以需要一个新的系统调用比如get_last_sub_errno()或者用rt_sigaction的 siginfo 额外传递。这个方案的好处是兼容性好旧程序依然能正常工作新程序可以选择性读取子错误码。坏处是你得维护一个新 ABI而且很多系统调用在内核里跨越了多层函数在每一层都更新 sub_errno 是一个极大的改动。我个人的看法是这种错误码 子码的模式适合用在特定子系统内部不适合全局铺开。举个例子在 io_uring 里cqe-res本身是一个 32 位整数它可以同时承载错误码和额外的标志位。io_uring 其实已经是一个天然的错误码扩展试验田了。3.2 方案二辅助错误信息通道error context channel第二种思路也是最实用的思路是在保留 errno 的同时为特定场景提供额外的错误理解信息。常用的载体有内核日志printk / dev_err最原始但最容易实现内核里在返回 EBUSY 的地方打一句dev_err(dev, device busy, holder pid%d\n, pid)用户态排障时直接dmesg | tail就能看到详细信息。这不算传统意义的错误码但确实是实际生产环境中最常用的补充手段。tracefs / function trace挂上trace-cmd record -e syscalls:*抓系统调用上下文可以看到内核在返回 EBUSY 之前执行了哪些函数反推出忙的根源。perf / eBPF用 kprobe 在内核返回-EBUSY之前抓一把调用栈和参数把一码多义变成一码一栈。netlink / genetlink在网络子系统里RTM_GETLINK 和 IFLA_INFO_DATA 这类属性消息天然支持携带扩展信息不需要额外设计。这套方案的核心价值在于不需要改动 syscall ABI就能让 errno 从一个数字变成一个数字 一段可观测的数据。它落地成本最低也是我在处理 EBUSY 相关问题时用得最多的方法。3.3 方案三自定义用户态错误码映射层第三种思路是在用户态建立一个错误码翻译层。内核返回的 errno 只是最底层信号用户态的库和中间件可以把它翻译成更细粒度的业务错误码。我做过一个网络配置守护进程底层调用 netlink 配置链路。内核返回 EBUSY 的时候netlink 消息里通常会附带extack扩展确认消息里面有详细的错误原因字符串。我们的代码这样处理static int parse_netlink_error(const struct nlmsghdr *nlh) { const struct nlattr *attr; int rem; int err; const char *msg NULL; err *(int *)NLMSG_DATA(nlh); if (err 0) return 0; // 解析 NLMSG_ERROR 里面的 extack 扩展属性 attr nlmsg_find_attr(nlh, sizeof(struct nlmsgerr), IFLA_EXT_ACK); if (attr) { msg (const char *)RTA_DATA(attr); } if (err -EBUSY msg) { // 将内核的 extack 字符串翻译成业务错误码 if (strstr(msg, device is already in use by a bridge)) { return ERR_DEV_ALREADY_BRIDGED; } else if (strstr(msg, link is not down)) { return ERR_LINK_NOT_DOWN; } } return err; }这个方案的核心思想是错误码不是终点而是入口。用户态拿到 errno 后必须通过辅助信息来判断具体业务语义而不是直接停止处理。4. 实操案例在一个虚拟块设备项目中落地细分错误码4.1 项目背景与初始困境我曾参与过一个用户态块设备服务架构是用户态一个核心进程通过 io_uring 与内核 nbd网络块设备模块通信对外提供块设备节点。在测试过程中我们频繁遇到一个问题当客户端意外断开后重连时 ioctlNBD_SET_SOCK返回 EBUSY导致无法重新初始化连接。最初我们的处理方式是接到 EBUSY 就 sleep 1 秒重试重试 5 次后上报设备忙无法重连。但在高并发测试下这个策略经常导致服务长时间不可用因为设备明明已经清理干净了只是内核里的 nbd 配置没有全部释放。后来我把整个错误处理重写了一遍思路是三层快速感知ioctl 返回 EBUSY 后立即通过dmesg或内核 trace读取最近一条错误信息判断内核实际拒绝原因。状态查询通过 sysfs 读取/sys/block/nbd*/pid、/sys/block/nbd*/size等节点确认设备连接状态。分级恢复根据状态决定是等待自动清理、主动发送NBD_DISCONNECTioctl还是走设备重连流程。这套改造在我们自己的场景里效果很明显断线重连的恢复时间从 5 秒左右降到 300 毫秒以内核心原因就是把EBUSY不可能改成了EBUSY需要更多信息才能下结论。4.2 内核态用 io_uring 承载更丰富的错误码在用户态与内核的交互链路中io_uring 是一个很适合做错误码扩展的接口因为它的完成队列条目struct io_uring_cqe里的res字段是 32 位的虽然它同时携带系统调用返回值但你可以通过IORING_SETUP_CQE32这个特性来扩展。IORING_SETUP_CQE32允许每个 CQE 增加额外的 64 位用户数据字段我们可以把原始错误码 子错误码打包进这 64 位。这是一个非常适合做细粒度错误码的通道。在内核 nbd 驱动里nbd_ioctl函数返回-EBUSY之前我们可以把具体的子错误码写到一个辅助字段里用户态通过 io_uring 的 CQE32 拿到它。下面是简化思路的伪代码/* 内核侧 nbd_ioctl 关键路径 */ static int nbd_ioctl(struct block_device *bdev, fmode_t mode, unsigned int cmd, unsigned long arg) { struct nbd_device *nbd bdev-bd_disk-private_data; int error; switch (cmd) { case NBD_SET_SOCK: mutex_lock(nbd-config_lock); if (nbd-config-sock) { /* 传统做法直接返回 -EBUSY信息丢失 */ // error -EBUSY; /* 改进做法把子错误码放进 io_uring 扩展字段 */ error -EBUSY; current-sub_errno NBD_EBUSY_SOCK_ALREADY_SET; break; } error nbd_alloc_and_connect(nbd, (void __user *)arg); mutex_unlock(nbd-config_lock); break; default: error -ENOTTY; } return error; }用户态配合解析时通过io_uring_enter的IORING_CQE32拿到下面的 64 位扩展字段。这个方案的实现成本比直接全局改 errno 要低得多因为 io_uring 本身就是一个较新的接口不需要背负老 ABI 的兼容负担。4.3 trace 与 eBPF 辅助排查 EBUSY 的完整操作在另一个场景里我需要排查一个为什么我的 f2fs 格式化工作负载总是被 EBUSY的问题。这种问题常规经验完全不够用因为用户态根本看不到内核态的原因为什么 EBUSY。我用 eBPF 挂在内核 vfs 层做了一次完整的错误码语义捕获这比盲猜编码要高效得多。第一步是抓内核栈。挂载一个 tracepoint每次-EBUSY被生成时记录调用栈# 用 bpftrace 或者 libbpf 工具 bpftrace -e kfunc:__kernel_restore_lpee kprobe:*name*这里我不展开所有命令细节但有一个核心思路值得分享先抓调用栈再抓函数参数。EBUSY 往往发生在某个函数的中途调用栈会告诉你路径参数的取值会告诉你是哪个对象忙。比如我在跟踪nbd_ioctl时发现调用栈是nbd_ioctl - nbd_alloc_and_connect - nbd_start_device_ioctl - sock-ops-connect而第二个参数cmd NBD_SET_SOCK的 sock 指针非空就能确定是 NBD 层的 socket 冲突而不是底层的网络问题。第二步是抓生命周期。用kprobe监视nbd_config_put的引用计数变化看是不是有进程没释放引用。在 f2fs loop 设备的场景里问题定位后是 systemd 的某个 unit 自动挂载了 loop 设备导致 mkfs 时 EBUSY。查一下/proc/mounts立刻水落石出。5. 常见问题与排查技巧实录这部分整理了我在日常运维和内核开发中遇到的各种 EBUSY 问题做成一个速查表加几条独家技巧希望能帮你省下几个深夜。5.1 EBUSY 问题速查表场景错误提示根因方向推荐排查手段mkfs 格式化设备open /dev/loop2 failed errno:13设备已被挂载或持有mount检查挂载lsof检查占用NBD 连接重置ioctl(NBD_SET_SOCK) failed: EBUSY内核未清理完旧连接检查/sys/block/nbd*ioctlNBD_CLEAR_SOCK网桥配置失败RTNETLINK answers: Device or resource busy接口状态不是所期望的ip link show确认接口状态目录删除失败rmdir: failed to remove xxx: Device or resource busy目录被挂载点覆盖mountpoint -q dir或findmnt设备热插拔失败usb: device descriptor read/64, error -110USB 设备不稳定dmesg查看 URB 错误上下文Docker 卷挂载冲突mount: /var/lib/docker/...: device or resource busy挂载点在容器内被占用检查容器挂载传播nsenter进入命名空间磁盘 unmount 失败umount: /mnt/data: target is busy有进程在访问挂载点fuser -m /mnt/data或lsof f -- /mnt/dataO_DIRECT 打开文件失败open(... O_DIRECT) failed: EBUSY文件已被其他方式映射fuser -v查看进程访问方式这张表最核心的经验是EBUSY 出现时永远先问三个问题谁占用的、以什么方式占用的、内核认为它忙多久了。三个问题搞清楚错误原因基本就出来了。5.2 排查 EBUSY 的独家技巧技巧一用 /proc 和 /sys 比 errno 更早说话。为了快速判断 busy 的根本来源你在看到 EBUSY 之后马上做的第一个动作不应该是看代码而是看/proc/mounts和/sys/kernel/debug/block/*/holders。这一招能快速区分设备被挂载和设备被进程绑定因为后者的holders文件会列出持有者。技巧二顺藤摸瓜查询最近一次挂载路径是哪个。碰到 loop 设备上 EBUSYlosetup -a能列出所有 loop 设备和对应的 backing file多看一眼你就能定位到是哪个镜像还在占用。技巧三strace 的-e traceioctl配合-f可以快速定位用户态错误来源。如果你在开发用户态程序strace -f -e traceopen,ioctl,fcntl,close -o /tmp/trace.log ./myapp会把所有相关系统调用都记录下来在日志里搜索EBUSY就能找到最先触发错误的那个调用点。技巧四内核开发时给 EBUSY 返回值打上唯一标签。我习惯在返回-EBUSY的代码路径前面加一个pr_debug或者 trace 点标签是独一无二的字符串比如pr_debug(%s: device busy, tagNBD_SOCK_ALREADY_SET\n, __func__)。这样 dmesg 一搜 tag 就能区分不同的忙。5.3 一个 f2fs 格式化 plus loop 设备的实战复盘这里把开头提到的案例完整复盘一遍因为它非常典型地展示了一码多义的危害和解决路径。执行mkfs.f2fs -f /dev/loop2输出Error: Failed to open /dev/loop2 with errno 13errno 13 是 EACCES第一反应是提权。但 sudo 之后仍然失败。用losetup -a检查 loop 设备关联关系时发现/dev/loop2被一个 systemd 自动挂载的只读镜像占用了。为什么会报 EACCES 而不是 EBUSY因为 f2fs 的工具链在打开设备时用了O_EXCL标志内核检测到设备已被持有但是工具内部的错误映射把它翻译成了 EACCES。排查步骤losetup -a找到 loop2 的 backing file。mount | grep loop2确认挂载状态。systemctl查看是否有自动挂载单元停掉相关 unit。umount /dev/loop2释放设备。mkfs.f2fs -f /dev/loop2格式化成功。这个案例给我们的最大的启示是错误码只是第一层信号永远不要只凭 errno 下结论尤其当错误来自文件系统/块设备工具链时先查一下挂载表和 loop 状态。5.4 细粒度错误码落地中的三个担心与取舍我在设计细粒度错误码时有过几次摇摆也总结出一些心得。第一个担心是兼容性。改动 errno 本身会影响无数老程序所以细粒度错误码必须是一个增量方案不能是替换方案。子错误码、辅助信息、扩展字段这些都不能影响既有程序的 errno 判断逻辑。实践中io_uring 的 CQE32 和 netlink 的 extack 就是很好的例子——它们都是在原有协议上做加法老代码不感知也能工作。第二个担心是可观测性。错误码扩展之后谁来输出这些细节如果是硬编码在代码里调试时还是要打日志。我倾向于把细粒度错误信息和 eBPF/trace 结合让采集哪段错误信息变成运行时动态决策而不是编译期写死。第三个担心是 AB 兼容。如果你给某个 ioctl 命令扩展了参数结构体老用户态程序传老结构内核怎么判断我的经验是永远通过_IOC_SIZE()和sizeof来做版本协商。比如if (_IOC_SIZE(cmd) sizeof(struct my_ioctl_v1)) { // 老版本只有 errno } else if (_IOC_SIZE(cmd) sizeof(struct my_ioctl_v2)) { // 新版本支持 sub_errno } else { return -EINVAL; }这套逻辑虽然老套但在错误码扩展时非常管用因为它让新旧协议同时存在。6. 我的实操体会今天聊了 errno 一码多义的来龙去脉分享了三种细粒度错误码的实现思路也给了几个实战排查的案例。说到底这套讨论真正想传达的是错误码的表意能力是有限的但我们对系统运行状态的理解不应该被限制在 errno 的粒度里。我做内核排查有个习惯每次看到-EBUSY心里先打一个问号然后马上寻找第二手信息。内核日志、trace、eBPF、/proc、/sys、losetup这些都是让错误码颗粒度变小的钥匙。等到 io_uring 这类新接口把扩展错误码变成常态或许我们就不需要花那么多时间在猜上了。最后分享一个小技巧在你自己写的内核 driver 里在所有会返回-EBUSY的路径上加一个带唯一 tag 的pr_debug。这个 tag 的成本只有一个字符串但排查时的收益是指数级的。我曾经靠这一个习惯把一次设备热插拔的问题定位时间从两天压缩到了半小时。细节虽小但这就是颗粒度。
返回列表