数学很难(VAX 上的 OpenBSD)
摘要
OpenBSD 开发者细数 VAX 浮点异常的恼人怪癖,以及它们如何给内核移植添堵。
<p><a href="https://lobste.rs/s/nvwn1i/math_is_hard_openbsd_on_vax">评论</a></p>
查看缓存全文
缓存时间: 2026/04/22 05:38
# 数学很难
来源:http://miod.online.fr/software/openbsd/stories/vaxfp.html
在 Unix 环境下开发软件时,你通常可以复用同一套系统调用,并享受完善的开发工具,而无需关心具体平台——大多数处理器都提供丰富的指令集和虚拟内存等特性。
可一旦“翻围墙”进入内核世界,所有平台差异的丑陋细节就再也躲不掉了;某些处理器架构的短板,会让你真切体会到什么叫“屁股疼”。
如果你读过 m88k 的辛酸史(http://miod.online.fr/software/openbsd/stories/m88kall.html),大概还记得:操作系统异常处理程序必须在返回前完成所有悬而未决的 load/store,这一需求曾让那帮黑客头疼多年。
88100 并不是唯一让内核开发者怀疑人生的 CPU。今天给你讲一段“处理器设计一时爽,内核填坑火葬场”的往事。
---
## VAX 浮点异常:教科书式的设计,地狱式的实现
VAX(https://en.wikipedia.org/wiki/VAX)架构诞生于 1977 年末,是最早的 32 位架构之一。
指令集庞大、寻址方式多到眼花,但没什么“花活”:没有乱序执行、没有分支延迟槽、没有寄存器重命名、没有超线程,最早连缓存都没有——因为根本跑不过内存刷新周期(当年 CPU 速度用周期时间描述,5 MHz 就是 200 ns;内存刷新约 120 ns,80 ns、70 ns、60 ns 是 90 年代以后的事了)。
VAX 的异常模型同样朴素,《VAX 体系结构参考手册》第一版“异常与中断”一章只有 36 页(第二版 43 页,主要是字体大了)。
> 陷阱(trap)是在**导致异常的指令执行完毕后**才发生的异常,因此压栈的 PC 指向**下一条**指令。
> 故障(fault)发生在**指令执行过程中**,此时寄存器与内存状态仍保持一致,消除故障条件后**重新执行该指令**可得到正确结果;压栈的 PC 指向**故障指令本身**。
很标准的定义:
- 不可恢复 → 陷阱,进程直接被杀。
- 有机会补救 → 故障,内核先抢救,不行再杀。
举例:
- 访问未映射页面 → 故障,合法地址就换入,非法就发 SIGSEGV。
- 整数除零 → 陷阱,直接发 SIGFPE(整数除零也会报“浮点异常”,`siginfo_t` 会告诉你到底是 `FPE_INTDIV` 还是 `FPE_FLTDIV`)。
于是 VAX 的异常处理函数 `trap()`(`sys/arch/vax/vax/trap.c`)自 3BSD 以来几乎没变:缺页就交给 VM,算术陷阱就递 SIGFPE。
---
### 冷知识:1980 年的信号名
当年可没有 SIGILL、SIGSEGV 这些“洋气”名字:
```c
#define SIGINS 4 /* illegal instruction */
#define SEGSEG 11 /* segmentation violation */
#define SIGFPT 8 /* floating exception */
```
---
## 2002 年 4 月:Perl 5.8 快照在 i386 / VAX 上“转圈自杀”
**Todd Miller** 负责维护 OpenBSD base 里的 Perl。他测试即将发布的 Perl 5.8 快照时发现:
miniperl(构建阶段用的小号 Perl)会在 i386 和 VAX 上**死循环**——CPU 占满但啥也不干。
Todd 很快给出了最小复现:
```c
signal(SIGFPE, SIG_IGN);
int i = 1 / 0;
```
i386 的问题迅速解决,VAX 却卡住了。
5 月 7 日,OpenBSD 开发者聊天室:
> “Todd,那堆 SIGFPE 破事咋样了?”
> “还能咋样,依旧完蛋。Perl 升级后 VAX 又得跪。”
一周后:
> “所以正确行为应该是?”
> “忽略 SIGFPE 后就不该再递信号,而是跳过那条指令。”
第二天我插话:
> “我刚在 Linux-vax 的待办清单里看到:
> 算术故障类型 8/9/10 会把 PC 回卷到故障指令;若信号被忽略又没人处理,就会无限循环。”
---
## 内核自救:自己把指令“跳”过去
VAX 的异常模型**不允许**“返回并跳过”这条指令。
内核只能自己算长度,把 PC 掰到下一指令。
VAX 指令变长,极端情况下一条指令能超 16 字节;想算长度就得**反汇编**。
高层逻辑很清晰(`trap.c` 片段):
```c
if (code == (T_ARITHFLT | T_USER) && frame->code >= 8) {
extern void *skip_opcode(void *);
frame->pc = skip_opcode(frame->pc);
}
```
我把内核调试器的反汇编代码“借”过来,6 小时后凑出第一版 diff。
结果被大家喷成狗:调试器代码不能进内核常态路径。
于是重写 `skip_opcode()`,独立实现,仅百来行,DDB 与否都能编译。
新 diff 顺利通过,commit 4833dad9b0 立即进树。
---
## 余波
7 年后 NetBSD 合并了同一修复,两天后被 **Michael Hitch** 发现顺序 bug:
`trapsignal()` 会改 `frame->sp` 以调用用户信号处理函数,因此**必须先** `skip_opcode()`。
我 3 年后才把修正 cherry-pick 回 OpenBSD——因为 base 里新引进的 SQLite 在 VAX 上编译又触发了同一坑。
---
## 悬案:1979 年就跑 BSD 的 VAX,为何拖到 2002 年才修?
早期几乎**没有程序**会忽略 SIGFPE;信号一来进程就死,根本没机会死循环。
但我觉得真正原因是——
> 早年的 VAX,这些算术故障根本**不存在**。
相似文章
浮点数不与自己一致
开发者发布了 `exact-poly`,这是一个使用精确整数算术而非浮点数的二维几何库,旨在消除因 IEEE 754 实现差异导致的跨平台重现性问题。
@no_stp_on_snek: https://x.com/no_stp_on_snek/status/2052833502475833384
使用 Qwen2.5-32B-Instruct 搭配 longctx 和 vllm-turboquant 的单个 AMD MI300X 开源技术栈,在 MRCR v2 百万级上下文基准测试中取得了与 SubQ 闭源模型(0.659)相竞争的结果(0.601-0.688),表明开源权重方法已接近达到同等水平。
追捕 EtherSlip(DOS 网络)中潜伏 34 年的指针 Bug
一位开发者讲述如何利用 Open Watcom 的堆损坏哨兵,追踪并修复 EtherSlip DOS 包驱动里一个存在了 34 年的 NULL 指针错误。
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。
你的生日是什么时候?哈希碰撞背后的数学
一篇教育性文章,解释生日悖论的数学原理及其在密码学中哈希碰撞的应用,涵盖匹配生日的概率计算以及理查德·冯·米泽斯贡献的历史背景。