微软早期编译器中的浮点仿真代码历史可追溯至1982年
来源标题:Code Smells of Desperation (2023)
MS Pascal 3.0 早在1982年底或1983年初就内置了浮点仿真库,时间早于微软首款自研C编译器MS C 3.0。QuickBASIC 3于1987年才加入8087支持,QuickBASIC 4默认同时使用8087代码和仿真器,而BASIC 6又改回更快的非8087浮点库。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
这段代码大概比 Microsoft C 还要古老。MS Pascal 3.0 就带有一个仿真库,那是 1982 年底或 1983 年初发布的,远远早于 MS C 3.0(微软内部编写的第一个 C 编译器)。
我甚至怀疑其中有些代码可以追溯到 MS BASIC。也许不是,或者说可能并不完全一样,因为最早的微软工具在二进制数字格式上用的是另一套方案。MS Pascal 3.0 采用 IEEE 784 格式,并且也支持真实的 8087 协处理器。据我所知,MS Pascal 2.0 既不支持仿真,也不支持真实的 8087。这倒不奇怪,毕竟 8087 在早期的 PC 时代根本买不到。
确实如此,微软的老语言使用了一个「无限深」的浮点栈,在正常操作过程中就可能触发异常。不过我相信到 1980 年代末左右,这种做法就已经不再使用了。
MSBASIC 在 1987 年通过 QuickBASIC 3 加入了对 8087 的支持,而 QuickBASIC 4 则默认使用 8087 代码并附带一个仿真器。所以 BASIC 是在 C 之后才获得这项能力的,而且很可能用的是 C 版 8087 仿真器的一个变种。到了 BASIC 6,又改回了类似 8087 之前那套浮点库的方案,因为那些库比 8087 仿真器更快。
当时有不少第三方库可以把 MS BASIC 和 8087 对接起来,但我从没用过,快速搜索也找不到什么相关信息。Startz 那本书里介绍了一些把 ASM 例程嵌进来与 8087 对接的技巧,看起来相当繁琐。这让我很庆幸,80 年代那些用到数值协处理器的代码,我大部分都是用 FORTRAN 写的。
我没找到 WIN87EM 的代码,但我猜总得有什么东西来替代那些浮点 WAIT 指令——它们要求系统里装有一块 8087 才能正常响应。
哈哈,读这种帖子真是怀旧,让我想起 90 年代在台式机上写代码的日子。
你可能会对我那本小长篇《Cheap Complex Devices》感兴趣,里面有一篇很长的短篇小说叫《Bees, or The Floating Point Error》。
其实,关于《Cheap Complex Devices》的作者身份,有几种可能的解读,其中一种是:整部小说可能是一个昏迷中的计算机设计师大脑的输出,经过一个略有知觉、且肯定满是 bug 的浮点处理器处理后形成的。不管怎样,你都能在里面找到一些只有真正的浮点错误偏执狂才能欣赏的极客笑话。
我就不拿链接刷屏了,这本书在各大常见网站上都能买到(好评如潮)。也可以在这里免费下载:https://dl.bookfunnel.com/1e7142jomu
在我看来,这段代码是在拼命地、极其拼命地避免使用 FWAIT。如果 80287 与 80286 之间的连接不可靠(但又可靠到足以让普通的 80286 代码正常工作),我大概就会写出这种代码。当年 80287 芯片是否存在常见的不可靠问题,或者只是插槽没插好?
那些注释确实透着一股绝望的气息(你可以在 Google 上搜「emoemwin.asm」找到这个处理程序的源码,然后看 __fpIRQ13),但其中也有一些有趣的细节,比如:
「486 bug——必须在最后一次 “out f0” 之后等待,才能清除浮点异常,否则 IGNNE# 会永久处于激活状态。」
考虑到微软当年处在早期步进的 386 和 486 各种怪异行为的最前沿,这可能不只是一派胡言。
我认为,尽管最终完成的代码看起来很像胡言乱语,但它很可能确实解决了某个当时尚未被充分理解的问题。而且,这个问题很可能出在预发布阶段的硬件上,那些硬件本身就有 bug。内置 FPU 的 486 相比老款 CPU/FPU 需要不同的主板支持电路。基本可以确定微软当时拿到了预发布的 486 系统,而那些系统很可能存在一些「有意思」的 bug。
Intel 在 486 数据手册的早期修订版中修改了 FERR# 和 IGNNE# 的相关文档,这说明当时的文档即使不算错误,至少也是让人难以理解的。
这段源码还揭示了这类代码的一个主要问题:它没有说明自己到底想解决什么问题。这意味着下一个程序员很难轻易删掉这种看似毫无意义的代码,因为他们无法确定它是不是在解决某个相对普遍且合理的问题。测试也帮不上太大忙,因为你不能假设出问题的那种硬件真的还在手边可供测试。
当我在这类复古的东西里看到那些看似疯狂的做法时,我反而会把它们当作某种扭曲的智慧来欣赏。
在 CPUID 出现之前的年代,如果同一段代码需要在跨越多个 CPU + FPU 世代、带有一堆已知勘误(外加更多受 NDA 保密、未公开的勘误,甚至可能还有你自己在官方文档记录之前就发现的 bug)的系统上运行,我会认为能在所有系统上运行的最小公分母方案必然是个大杂烩。在 I/O 端口写操作之间穿插的 POP/PUSH 看起来像是某种 NOP/等待状态,我记得在某些高速系统上确实需要这样做,以应对慢速 I/O 访问,就像这里讨论的:https://forum.vcfed.org/index.php?threads/io-delays-on-8088-class-computers.45713/
我想,如果能看看开发者的笔记,了解这段代码是如何随着测试系统一个个失败而演变、直到最终定稿的,应该会很有趣。
这不是智慧,这是绝望。我自己也经历过;当硬件不按预期工作时,你就只能不断尝试不同的做法,直到某个方案(但愿)奏效。但你可能永远不知道它为什么有效,更肯定不知道它是不是最好的变通方案。
没错,那些 PUSH/POP 循环是为了放慢速度,比单纯一条「空」的 LOOP 指令更慢。问题在于,这些循环的速度在不同 CPU 型号之间变化极大。结果就是在较慢的 CPU 上浪费了大量时间,而在较新的 CPU 上可能还是跑得太快。
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。