众所周知,Windows98之前的Win9x变体有在快速CPU上崩溃的趋势。“快速”的定义当然是模糊的,但早在1998年,这些问题就已经在运行350 MHz或更快的AMDK6-2处理器上出现了。当微软试图收取35美元的修复费用时,这导致了一些尖刻的批评。350 MHz部分的崩溃是间歇性的,但由于时钟速度更快,更难避免。
这个问题很快就开始影响其他频率更高的CPU,但暂时没有影响英特尔处理器。英特尔CPU在某种程度上更好吗?不完全是,但这是有原因的;稍后会有更多信息。
我早就意识到了这个问题,但从来没有仔细研究过细节。当我这么做的时候,一开始我并没有意识到这一点。一位熟人提到,Windows3.11 for Workgroup不再在虚拟机中运行。
经过调查,发现问题与主机CPU有关。一台较老的英特尔i7-2600主机出现了这次崩溃,但很少见。一架较新的Ryzen 73800X每次都会坠毁。英特尔/AMD有什么意想不到的区别吗?嗯,是的,也不是…。
也就是说,“崩溃”描述的更多的是原因,而不是症状。在运行‘win’之后,wfw 3.11会显示Windows徽标,并很快返回到DOS提示符,但没有给出任何原因的提示。
在搜索wfw 3.11调试内核(不是那么容易)并使用WDEB386之后,原因变得更加明显。在保护模式下发生被零除,导致Windows自行关闭。早些时候的实验表明,“win/n”运行没有问题,所以网络是首要的疑点。
现在,这条错误消息并不比只是默默地返回到DOS更有启发性。WDEB386也不清楚代码在哪里崩溃。
因此,我求助于在Windows二进制文件中查找分零码序列,很快就找到了罪魁祸首:NDIS.386。这就解释了为什么问题只出现在wfw3.11中的保护模式网络堆栈中,而实际上并不依赖于网络驱动程序或协议或任何配置细节。
使用IDA Pro几分钟后,真正的原因变得显而易见。NDIS模块校准NdisStallExecution API的延迟循环。请注意,这是32位保护模式(VxD)代码。核心算法如下:
现在,这种方法的问题是,尽管GET_SYSTEM_TIME直接使用8254 PIT读取时间(并且它可以提供微秒精度),但它只能精确到1毫秒。如果两次调用get_system_time并在其间运行循环指令大约一百万次所需的时间不到1毫秒,就会出现问题-因为开始和结束的毫秒可能相同,从而导致增量为零,并直接导致除以零(代码不是非常小心)。只要GET_SYSTEM_TIME不返回相同的值,就没有问题。
这正是在大约100兆赫兹和更慢的CPU上100%稳定的事情,但是当cpu时钟速度提高几倍并且循环指令执行…所需的周期也更少时。麻烦。考虑到wfw3.11的发布日期(1993),该代码不可能在任何比66 MHz奔腾更快的设备上进行测试(如果有的话)。一台350兆赫的K6-2仅在时钟速度上就会快五倍以上,但在实践中性能差异要大得多。
请注意,对GET_SYSTEM_TIME的调用使事情变得更加有趣。如上所述,这些芯片将访问PIT,这意味着端口I/O。不同的芯片组处理这些读取的速度很可能存在差异,更快的访问更有可能触发故障。
事实证明,Windows95中的NDIS.VXD具有用于校准NdisStallExecution的完全相同的代码。考虑到Windows95和Windows3.11之间的密切关系,这并不令人惊讶。因此,Windows 95用户可能已经看到以下屏幕:
应该指出的是,Windows95至少有礼貌地将矛头牢牢地指向了麻烦制造者的方向。
更有趣的是,Windows95还在其他几个模块中添加了相同的逻辑,即ESDI_506.PDR和SCSIPORT.PDR。
Microsoft修复了Windows 95 OSR2的问题并提供了更新。有点不幸的是,这被称为“AMD修复”(包含解决方案的文件名为AMDK6UPD.EXE),尽管微软很清楚这在AMD CPU中不是问题,而是在它们自己的代码中。
为什么英特尔CPU在AMD之前或大约同时受到影响?1998年8月,英特尔已经有了一台运行在450 MHz的奔腾II。它不应该比350兆赫的K6-2有更多的麻烦吗?事实并非如此,要找出为什么需要查看优化手册。
但首先让我们考虑一下最初的奔腾,它是编写代码时可用的最快处理器。根据奔腾处理器家族开发人员手册第3卷:架构和编程手册(英特尔订单号241430),循环指令。采用分支时的绝对最佳情况是6个时钟周期。英特尔手册指出,“执行无条件循环指令所需的时间比两条指令序列长,后者会递减计数寄存器,并在计数不等于零时跳转”。
然后,1,048,576次迭代将在奔腾上花费至少6,291,456个时钟周期,在66 MHz频率下,这将花费94毫秒多一点的时间来执行。请注意,这是最好的情况,实际上也是最坏的情况(最短的执行时间,最有可能导致除法溢出)。
现在考虑一下AMDK6-2的当代,一台350兆赫兹的英特尔奔腾II。信息来源是英特尔架构优化手册(英特尔订单编号242816-003)。手册再次建议:避免使用复杂的指令(例如,Enter、Leave、Loop)。取而代之的是使用简单的指令序列。当然,对于微软来说,在Intel CPU上循环指令速度很慢这一事实是可取的(如果有什么不同的话)。
在P6架构上,简单循环指令解码为4个μ操作。从英特尔手册中计算出…是多少个时钟周期。困难。Agner Fog似乎也有同样的困难,在他的工作中没有给出奔腾II/III的循环吞吐量。但是他给出了6个循环的奔腾M吞吐量。很可能这也是奔腾II/III所需要的。
在奔腾II处理器上的实验确实证明了这一点。具有1,048,576次迭代的循环需要略低于600万个时钟周期来执行,这表明吞吐量大致正好是6个周期。然后,350 MHz奔腾II将需要大约17毫秒来执行循环。以1 GHz运行的奔腾III仍然需要大约6毫秒来运行循环。
那么K6-2怎么样?与英特尔不同,AMD-K6处理器代码优化应用笔记(AMD出版物21924 Rev.D,2000年1月)实际上建议在适用的情况下使用循环指令,第89页上写道:JCXZ采用2个周期,不采用7个周期。循环需要1个周期。
这意味着在相同的时钟速度下,K6-2执行循环指令的速度比同时代的英特尔CPU快6倍。那是相当大的差别。
换句话说,运行在350 MHz的AMDK6每秒将吞噬350,000,000次循环迭代,而1,048,576次迭代将花费不到3毫秒。
但这并不合理,不是吗?即使NDIS停止校准循环的运行时间略低于3毫秒,测量的时间增量也不可能为零,除非GET_SYSTEM_TIME完全损坏。但事实并非如此。因此,我们刚刚发现为什么Windows for Workroups 3.11在350 MHz CPU上没有崩溃。然而,众所周知,Win95也存在与…相同的问题。但是为什么呢?
因为如上所述,其他Win95组件中也有类似的代码。这里的关键词是相似的。
例如,ESDI_586.PDR(非常常用的IDE磁盘端口驱动程序)包含以下逻辑:
SCSI端口驱动程序SCSIPORT.PDR包含实现ScsiPortStallExecution API的相同代码。Win95机器几乎可以保证使用ESDI_506.PDR或SCSIPORT.PDR,除非在安全模式下。
存储驱动程序算法不那么通用,因为ScsiPortStallExecution被指定为仅支持少于1毫秒的停顿,而NDIS变体可以支持任意长的停顿(使用不同的方法实现)。最后除以153是因为ScsiPortStallExecution接受参数(停止的微秒数),将其乘以计算出的常量,然后将结果右移16位。
存储端口校准算法更容易出现问题。它不会将校准循环执行的开始与时间刻度“对齐”,这会导致测量结果不稳定。它使用的循环迭代稍微少一些,也许刚好足以产生影响。最重要的是,它将一个非常大的数字(10,000,000,000或2540BE400h)除以可能很小的毫秒数。
因此,存储算法最大的问题是,它不仅容易被零除,而且与NDIS中使用的算法不同,当除数是1或2时,它也容易发生除法溢出。而这正是350 MHz AMD K6可能发生的情况。根据循环与毫秒刻度对齐的确切方式,测量结果通常可能是3毫秒(由于测量开销和不准确),但有时只有2毫秒。这正是会导致分裂溢出的原因。谜团解开了。
同样,由于英特尔CPU的循环指令执行速度慢得多,因此在当时有效地不受此问题的影响。今天的英特尔CPU自然也会崩溃,因为尽管它们执行循环指令的速度仍然有些慢,但它们的时钟速度大约是350 MHz奔腾II的10倍。
在现代(从Windows9x的角度来看是“极快的”)机器上,Winows95可以引导到安全模式,但不能通过联网进入安全模式。在后一种情况下,它仍然在NDIS中报告“Windows保护错误”。
根据以上情况,很容易看出其中的原因。在纯安全模式下,会跳过网络,因此不会加载NDIS。也不使用本机Windows 95存储驱动程序。这就绕过了导致分裂溢出的组件。
带有网络的安全模式将不使用本地存储驱动程序,但仍使用NDIS,这意味着在NDIS初始化时,它将被零除。
Windows 98(第一版)似乎修复了存储驱动程序中的分区溢出,但没有修复NDIS中的分区溢出。这几乎可以肯定,因为在1998年可以在可用的硬件上观察到存储驱动程序崩溃,但NDIS崩溃不能。事实上,Windows95OSR2的“AMD修复”同样纠正了存储驱动程序中的问题,但NDIS保持不变。
即使是存储驱动程序的修复也不是很好。修改了校准算法,以避免除以除零以外的任何值时溢出的可能性,并将其改为运行1000万个循环周期进行校准,而不是原来的100万个循环周期。如果1000万次循环迭代在1毫秒内完成(2020年没有能够做到这一点的硬件),代码仍然会崩溃,并且除以零。
2001年,Microsoft发布了针对Windows 98(但不是Windows 95)中NDIS崩溃的修复程序。如果第一次测量导致零毫秒增量,则固定校准算法重试一次。如果第二次尝试的结果也是0,则只需将其强制为1即可避免崩溃。因此,无论CPU有多快,修复的NDIS校准都不会崩溃。
Windows98SE(1999)已经为NDIS崩溃和存储驱动程序崩溃提供了修复,并且在今天(2020)的硬件上没有重大的速度相关问题;它在最近的AMD CPU上确实有其他无关的问题。
同样,在编写代码时,这是任何测试都无法发现的问题。也就是说,代码审查可以也应该提出这样的问题:“如果校准循环在一毫秒内执行,会发生什么?”要么是这种情况没有发生,要么是这种可能性被认为足够不可能被忽视。
将NDIS算法与存储端口算法进行比较同样有趣。两者都使用完全相同的核心逻辑(多次运行循环指令以延迟给定的微秒数),但是存储端口代码更容易出现问题,因为它在测量延迟长度时不太小心,而且因为额外的输入值会触发除法溢出。
这个问题还表明,软件和硬件工程师所做的看似可靠的假设有时并不可靠。软件工程师查看当前可用的CPU,看看最快的CPU表现如何,并假设CPU在短期内不会提高100倍的速度。但是,当时钟速度提高几倍,指令执行速度加快几倍时,它们就可以了。
在这种特殊情况下,从66 MHz Intel Pentium到350 MHz AMD K6-2只用了5年时间,将校准循环的执行时间从几乎100毫秒降到了3毫秒以下。
另一方面,硬件工程师认为使指令更快是一件好事。在这种情况下,AMD无疑在更高的时钟速度触发崩溃之前很久就优化了循环指令。
目前尚不清楚英特尔只是没有费心让循环指令快速执行(而只是有效地告诉所有人不要使用它),还是英特尔是否知道让循环快速执行可能会在编写不佳的软件中引发问题。一种或两种都有可能。
Windows for Workgroup 3.11并不是第一个使用循环指令进行软件计时的软件。1988年的IBM PC LAN Program 1.3以类似的方式使用循环,并且(在NETWORK1.CMD组件中)在CPU上使用除以零的速度比当时可用的要快得多。
赛拉在线的Sound Blaster驱动程序使用了一个略有不同的主题变体。驱动程序使用循环指令等待中断到达。在20世纪90年代末的一些机器上,延迟不够,驱动程序无法加载,认为中断不起作用。