排查32位嵌入式系统中的Go运行时Bug

Lobsters Hottest 工具

摘要

本文基于Go项目中的一个未解决问题,描述了查找并修复导致32位嵌入式Linux系统间歇性崩溃的Go netpoll机制Bug的过程。

<p><a href="https://lobste.rs/s/apjhyj/hunting_down_go_runtime_bug_on_32_bit">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/25 13:43

# 追踪 32 位嵌入式系统中的 Go 运行时错误 来源:https://sigma-star.at/blog/2026/08/go-runtime-netpoll-bug/ 我们日常工作通常围绕 Linux 和安全主题,深入软件栈底层。然而,比你想象中更常见的是,我们最终需要调试位于栈更高层级的应用程序。有时问题根源在 Linux 内核,有时则不在。在这篇博文中,我们将展示如何在 Go 运行时中定位并修复一个错误。 最近,一位客户报告称,他们编写的 Go 应用程序在某嵌入式 Linux 系统上间歇性崩溃。 ## 引言 崩溃总是以相同的致命错误呈现,签名如下: ``` runtime: netpoll: eventfd ready for 5 fatal error: runtime: netpoll: eventfd ready for something unexpected ...stack trace... ``` 起初我们假设应用程序本身存在缺陷,需要修复。但在仔细检查错误信息后,发现这更像是 Go 的 netpoll 机制内部假设不再成立。 该错误信息源自 `netpoll()`,位于 src/runtime/netpoll_epoll.go(https://github.com/golang/go/blob/c97cfcb37fced87a43a3dbab8983d6f76b8b84d1/src/runtime/netpoll_epoll.go#L141): ``` if ev.Events != linux.EPOLLIN { println("runtime: netpoll: eventfd ready for", ev.Events) throw("runtime: netpoll: eventfd ready for something unexpected") } ``` 在此代码路径中,netpoll 代码期望 `EPOLLIN` 是唯一触发的事件,但收到了其他值。在我们的案例中收到了 `5`,即 `EPOLLIN|EPOLLOUT`。为什么当代码仅请求 `EPOLLIN` 时,epoll 会突然报告超出预期的事件? 在深入调查之前,我们将错误信息输入搜索引擎,期望有人曾遇到过相同问题。这直接引导我们进入 Go 项目问题跟踪器中的一份报告:runtime: netpoll: eventfd ready for something unexpected(https://github.com/golang/go/issues/72900)。 该问题描述的正是我们看到的致命错误,同样发生在 32 位 ARM 嵌入式 Linux 系统上!报告者还指出崩溃发生在长时间运行的应用程序中。这与客户描述完全吻合。中了! 该问题自 2025 年 3 月起开放未解决。Go 维护者还拒绝了一项修复尝试(https://go-review.googlesource.com/c/go/+/658755)。 从问题讨论中我们了解到,该致命错误仅出现在 32 位 ARM 和 i386 Linux 系统上,涵盖各类内核版本。有些内核较旧,有些较新。没有任何报告者在 x86_64 或 arm64 系统上遇到过此问题。 ## 接受挑战 该问题有多位报告者但无修复方案,因此我们决定自行深入调查。至少可以向 Go 团队提供更好的信息。 起初我们怀疑 epoll 在 32 位 ARM 或 i386 上表现不同。但很快放弃了这一想法,因为 epoll 是内核中通用的核心代码。为何它仅在 32 位 ARM 或 i386 上返回虚假事件集? 然而,错误仅出现在 32 位系统上的事实让我们耿耿于怀。下一步我们审查了 Go netpoll 代码中的 epoll 使用方式。借助 LLM,我们逐行分析了 `src/runtime/netpoll_epoll.go`,重点关注 32 位陷阱,如整数转换。 审查发现 Go 的 netpoll 代码使用了 `struct epoll_event` 的 `data` 字段。Linux epoll 可在内核中存储 8 字节的 cookie,并在触发事件时将其返回给用户空间。应用程序使用此 cookie 为事件附加元数据,例如区分不同事件源。 ``` struct epoll_event { __poll_t events; /* ev.Events in Go netpoll */ __u64 data; /* ev.Data in Go netpoll */ }; ``` 深入 Go 运行时内部,主事件处理程序需要知道事件属于事件 fd 还是 socket fd。它通过比较 `ev.Data` 与内部事件 fd 对象的地址来判断: ``` if *(**uintptr)(unsafe.Pointer(&ev.Data)) == &netpollEventFd { ... } ``` 进一步阅读代码显示,`ev.Data` 要么持有 `netpollEventFd` 的原始指针,要么持有每 socket 对象 `pollDesc` 的标签指针。指针标签是一个计数器 `fdseq`,用于区分回收的 `pollDesc` 对象。 到目前为止一切正常。在同一字段中混合原始指针和标签指针看起来可疑。但这与崩溃的关联尚不清楚。 检查 Go 如何在内存中布局标签指针,最终揭示了问题的核心。 ## 豁然开朗 在 32 位平台上,Go 的标签指针逻辑将完整的 32 位地址和最多 32 位标签打包到一个 8 字节的字中。标签放入低 4 字节,地址放入高 4 字节。 存储一个地址为 `0x00123456`、标签为 `0x12` 的标签指针时,`ev.Data` 的填充方式如下: ``` ev.Data[0:4] ev.Data[4:8] +------------------+-------------------+ | fdseq | *pollDesc | | e.g. 0x00000012 | e.g. 0x00123456 | +------------------+-------------------+ ``` 而存储原始指针时,`ev.Data` 的内容如下: ``` ev.Data[0:4] ev.Data[4:8] +------------------+-------------------+ | &netpollEventFd | 0 (untouched) | | e.g. 0x00123456 | 0x00000000 | +------------------+-------------------+ ``` 低 4 字节存放对象的地址。高 4 字节保持不变,因为在 32 位系统上地址仅占 4 字节。 这最终揭示了问题根源。`ev.Data` 与 `&netpollEventFd` 的比较仅评估 `ev.Data` 的低 4 字节。代码将 `ev.Data` 转换为 `uintptr`,在 32 位平台上这是 4 字节。因此 `netpollEventFd` 对象的地址与 `fdseq` 发生了别名。 一旦 `fdseq` 增长到足够大以匹配 `&netpollEventFd`,netpoll 逻辑就会将 socket fd 误判为事件 fd。内部假设随之崩溃,包括就绪事件仅为 `EPOLLIN` 的假设。 请注意,这种别名仅能发生在 32 位小端系统上。在 64 位系统上,比较始终覆盖完整的 8 字节。在 32 位大端系统上,比较会读取包含地址的高 4 字节。 `fdseq` 需要增长到数百万才能匹配 `&netpollEventFd`。因此该问题仅出现在长时间运行、随时间创建大量 `pollDesc` 对象的程序中。在典型的 32 位 ARM Linux 系统上,`netpollEventFd` 位于地址空间前 3 MiB 内的只读段中,如我们的测试程序内存映射所示: ``` $ pmap `pidof netpoll_test` 204: /opt/netpoll_test 00010000 2696K r-x-- netpoll_test 002c0000 2192K r---- netpoll_test 004f0000 180K rw--- netpoll_test ... ``` 因此 `fdseq` 需要达到约 300 万的值,崩溃才可能发生。 ## 测试案例 我们还为此问题创建了独立测试案例(https://github.com/richardweinberger/go_netpoll_32bit_testcase)。在我们的测试系统上,它在几分钟内触发了崩溃: ``` $ /tmp/repro.arm.system netpoll eventfd-alias reproducer (GOOS=linux GOARCH=arm) runtime.netpollEventFd is at address 0x223008 crash expected around cycle 2240520 cycle 2236988runtime: netpoll: eventfd ready for 4 fatal error: runtime: netpoll: eventfd ready for something unexpected runtime stack: ... ``` ## 修复问题 我们提出了一项修复方案(https://github.com/golang/go/pull/81037),改变了 netpoll 区分事件 fd 和 socket fd 的方式。该修复方案不再存储原始指针 `&netpollEventFd`,而是存储一个标签化的 `nil` `pollDesc`。当解包标签指针得到 `nil` 时,事件属于事件 fd,否则属于 socket fd。这样 `ev.Data` 始终包含标签指针,别名问题得以消除。 ## 总结 一个 Go 应用程序在 32 位 ARM 嵌入式 Linux 系统上间歇性崩溃,出现致命的 netpool 错误。该错误看似 epoll 问题,但 epoll 运行正常。Go 运行时将指向 `netpollEventFd` 的原始指针和标签化的 `pollDesc` 指针都存储在 8 字节的 `ev.Data` 字段中。在 32 位小端系统上,原始指针与 `fdseq` 标签发生别名。一旦长时间运行的程序回收了数百万个 `pollDesc` 对象,netpoll 就会将 socket fd 误判为事件 fd 并崩溃。我们的修复方案改为存储标签化的 `nil` `pollDesc` 作为事件 fd,从而消除了别名问题。 该漏洞于 2020 年随 Go 1.14 进入 Go 运行时。直到 2025 年 3 月首次报告前一直未被发现,最终在 2026 年得到修复。我们只能推测,这表明 Google 自身已不再运行任何 32 位 Go 程序。否则他们早在我们之前就应该遇到过这个错误。 我们要感谢 Frequentis AG 提供预算以分析和修复此问题。

相似文章

调试挂起的Go程序的技巧

Michael Stapelberg

一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。

核心转储流行病学:修复一个18年的旧bug

OpenAI Blog

OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

深入理解Go运行时:性能分析

Hacker News Top

深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。