排查32位嵌入式系统中的Go运行时Bug
摘要
本文基于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程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。
追捕 EtherSlip(DOS 网络)中潜伏 34 年的指针 Bug
一位开发者讲述如何利用 Open Watcom 的堆损坏哨兵,追踪并修复 EtherSlip DOS 包驱动里一个存在了 34 年的 NULL 指针错误。
核心转储流行病学:修复一个18年的旧bug
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
深入理解Go运行时:性能分析
深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。