The time I spent three months investigating a 7-year old bug and fixed it in 1 line of code
原文作者:@ch00f
译者:UmVnYW5ZdWU=
我曾在另一个平台上分享过这段经历,现在想在这里也说一说!
我当时在为 OG iPad 研发一款硬件配件。这款配件通过 USB 接口连接到iPad,提供 MIDI 输入/输出及音频输入/输出,非常适合音乐创作者利用 Garage Band 软件进行音乐创作。
这款产品之所以成功,是因为它的核心内容源自我们为 PC 制作的一款 USB 产品,而这款产品已历经近十年的市场考验。我们只需要一个小小的微控制器(microcontroller),就能使 iPad 进入 USB 主机模式(这还是在使用 30 针接口(30-pin connector)的时代),然后让其与一款几乎成品化的设备连接。
事实上,这款产品实在过于老旧,团队里已经没人记得如何编译源代码。当需要使其兼容运行时,就得手动修改二进制文件中的 USB 描述符(USB descriptors),使其能够匹配新产品名称以及确认其从 iPad USB 接口取电小于 10mA (原产品是通过接口供电,但若向 iPad 要求超过 10mA 的电流,即便设备本身能自供电,iPad 也会报错)。这一点看起来非常滑稽🤪,因为原产品的名称仅有 4 个字符,而新产品的名称则长达 7 个字符。我们没办法为这 3 个字符腾出空间,只能对名称进行裁剪,以便适配二进制文件且保证系统稳定运行。
但是产品上市后,我们发现了一个问题:每隔一段时间,就会丢失一条 MIDI 消息。这是一种用于传输音乐音符的数据格式,这些音符可由各种处理器/音卡(processor/voice)转换为音频。本产品传输的一条标准消息包括音符(如A、B、升F等)、敲击强度(击键的力度)以及是否为按键或释放键的判断信息。因此,按下与释放钢琴键会产生两条不同的消息。
偶尔丢失音符信息一般不是什么大问题,除非是像管风琴这样具有无限延音效果的乐器。如果用户在使用我们的设备时选择了管风琴的音色,可能会出现接收到了按键信号,却没收到释放键信号的情况。这样会导致 iPad 误以为用户持续按着琴键。
对于在没有收到释放键信号的情况下再次接收到同一音符的按键信号,MIDI 官方文档并未明确规定应如何处理,但 Apple 以最糟糕的方式处理了这一问题。iPad 只会在按键与释放键数量相等时,才认定琴键已被释放。因此,想要解决卡住的管风琴音,唯一的办法就是寄希望于它能忽略后续同键的按键信号,然后接收到释放键信号。然而,发生这种情况的概率几乎为零,于是大多数用户只能无奈地选择强制退出应用。
关于造成这一现象的原因,用户讨论区充斥着各种猜测 ———— 可能是 iOS 系统更新惹的祸?或者需要关闭所有其他应用程序?各种天马行空的传言满天飞,但没人能给出确凿的答案。
我是公司的新员工,刚从大学毕业不久,所以由我负责把这个问题搞清楚。
首先,我得找出一种方法来复现这个 bug 。为此,我编写了一个 Python 脚本,让它不断地向我们的产品输入音符序列,同时监听是否有按键出现卡顿。时至今日,我依然清晰地记得那如同一头过量注射💉兴奋剂的大象🐘连续数小时狂乱敲击键盘所发出的混乱声响。
经过一番努力,我终于能够大约每隔 10 分钟就复现一次这个 bug 。我观察到一个现象 ———— 只有同时按下多个琴键时才会出现这个问题。如果每次仅按下一个琴键,则完全不会出现这种问题。
借助一根专门为 Apple hardware developers 准备的高级数据线,我得以监控我们产品与 iPad 之间的 USB 通信过程。经过长时间的调试(USB 调试工具只能捕获一小部分数据,因此我必须在听到卡键声音的那一刻立即进行数据采集),我最终确定,正是那条引发问题的释放键信号未能成功抵达iPad。由此可见,问题并非出自 Apple,而是我们的固件在某些情况下未能正确传递 MIDI 消息。
接下来的任务是编译源代码。具体细节已经记不太清了,但我知道该步骤依赖于一个叫“hex3bin”的工具,我猜这大概是某个极客自创的“hex2bin”替代品,据称在某些方面更好。此外,我还得找到一个深藏在某个大学网站深处的 Perl 脚本。我推测,在七年前编写固件时,这些工具应该是相当常见的,但现在寻找它们的过程着实让我费了不少周折。尽管我对 Perl 语言一窍不通,但我还是成功让那个脚本运行起来了。👍👍👍
因为能够编译固件,我得以在固件代码中插入指令,使得设备内部的几个调试用 LED 灯(用户看不到这些灯)在特定时刻闪烁。因为这款设备上搭载的是一个简单的 8 位处理器(8-bit processor),而且没有配备实时调试工具(live debugger),这已是所能尝试的最佳调试方案。
问题的症结在于时间同步。处理器需要同时处理音频和 MIDI 数据流。在处理音频数据包时,它会暂时搁置一切其他任务。MIDI 数据是缓存的,因此,如果在处理音频过程中有按键或释放键信号传入,一旦音频数据包处理结束,就会立即处理这些信号。
但问题在于,它只有一个缓存区。因此,如果第二条 MIDI 消息在处理器处理音频时到达,第二条音符信息将覆盖第一条,导致第一条音符信息永久丢失。通过 USB 传输 MIDI 音符信息有速度上限,而这刚好比处理音频所需的时间略快。因此,如果第一条音符信息刚好在处理器开始处理音频后到达,那么下一条音符信息可能在处理器恢复之前到来,覆盖前一条。
接下来,我们来看看具体解决方案。尽管我对 USB 音频处理的技术细节了解不多,但我的大学时光就是在与 8 位的 8051 处理器打交道中度过的,因此我对哪些操作相对耗时有着比较清晰的了解。我按下 Ctrl+F 搜索“%”,很快就在音频处理代码中定位到了一个16位模运算。
这个 16 位模运算只是对发送字节数或比特数的最后一步验证(期望余数为零),而分母在每次运算中都保持不变。但是,代码的编写方式让编译器默认分母可能每次都会变化,因此在后台,它引入了一整套针对 8 位处理器的 16 位模运算处理逻辑。
我上网搜索“优化模运算(optimize modulo)”,很快了解到,在分母固定的情况下,任何 16 位模运算都可以改写为三个 8 位模运算。
我尝试着修改了一行代码(single-line change),音频处理器的处理时间从原先的每包 90 微秒大幅降至约 20 微秒。这一改动彻底修复了困扰我们的这个 bug。
不幸的是,由于无法现场升级固件,这仍给客户服务部门带来了不小的麻烦。
关于为何这个 bug 在产品销售的前七年都未曾被发现,可能是因为 PC 端的大部分用户仅将设备当作音频录音机或 MIDI 录音器使用。在只启用 MIDI 功能的情况下,不会涉及音频处理,因此这个 bug 也不会出现。然而,iPad 端的用户总是会全面启用所有功能。所以,这个 bug 其实一直都在,只是没有用户留意到。(此外,许多 MIDI 应用程序并不像苹果那样要求按键与释放键事件严格匹配。所以如果某个琴键被卡住,再次按下它就能恢复正常。)
经历了仿佛撒旦在管风琴上肆意敲打的三个月痛苦体验后,只需改动一行代码,就能解决一个七年前的 bug ...
P.S. 原文作者没有审校过本译文,且译者在翻译本内容时带有个人对原文的理解。可能理解有误,麻烦请在评论区指出!