昔时因 今日意 侃侃微软的CRT

[作者按] 此文的原名本来是 "Visual C/C++ Runtime (CRT)的历史演化,及其即将发布的大型重构",但写下这个冗长枯燥的标题后,我感到很不好意思——题目都这么乏味,内容就可想而知了。果断删了重拟一个,也趁机往人文的路口挪上两步。


多年来,Visual Studio 几经沉浮,一直是为数不多的有竞争力的开发工具之一。而其提供的 C 语言运行时环境(C Runtime,简称 CRT),是其中一块至关重要却又默默无闻的基石。某种意义上讲,庞大的 Windows 帝国和上面运行着的大部分应用和游戏,正是构建在这薄薄的一片运行时之上。而所谓“昔时因,今日意”,正是意在正本清源,循着脉络将 CRT 的来龙去脉梳理一下,也就能回答“从何处来,向何处去”这个问题。全文分为两部分,“昔时因”回顾 CRT 与 Windows 相生相伴的历史,“今日意”则着眼于当下正在进行的重大重构,于未来趋势亦可管窥一二。


“昔时因,今日意,胡汉恩仇,须倾英雄泪。虽万千人吾往矣,悄立雁门,绝壁无余字。” 
    —— 《苏幕遮》,《天龙八部》回目名

昔时因

在很久很久以 前,曾经有一个 dll 叫做 MSVCRT.DLL,故事就是从它开始的。

在 Windows 95 的洪荒年代,这个 dll 就是 Visual C/C++ (确切地说是VC++ 4.2)的运行时库。在那时,每当 Visual C++ 团队有新的版本要发布时,Windows 团队就同步更新操作系统的 msvcrt.dll 来跟 Visual C++ 团队保持一致。这样,开发人员使用新版VC写出来的程序,才可以在更新后的系统上正常工作。而假如 Windows 团队想修一个 crt 的 bug,他们不能手一挥直接改掉,而是必须得通知 Visual C++ 团队也对同一份代码做对应的改动。俺估计呢这也实属寻常,主要有两个原因:a) 因为他们总是新 dll 的头一批用户,为了配合对应的 Windows 新特性的市场宣传,只能给 Visual C++ 当小白鼠,做做义务 QA 了。 b) 微软收到的用户反馈,比如“某游戏在某次系统更新之后无法运行了”这种事,往往不能第一时间确认是不是 crt 的兼容性问题,这些黑锅都只能 Windows 团队先背着了。

大家可能觉得 Visual C++ 团队很惬意,只管自顾自往前做就行了,反正出了事有 Windows 团队兜着(我估计事实上还真是)。在 "Windows is not a Microsoft Visual C/C++ Run-Time delivery channel"一文中, Raymond Chen 就举了个例子,有一次 Visual C++ 这边修了个 Y2K(千年虫)问题,结果系统一更新之后客户的程序就都崩溃了。一查之后发现,这个 crt 的修复影响了栈的行为,把客户代码里一个没初始化的变量给暴露出来了。

其实现在回过头去看看 Win95/98 的兼容性就知道,像这样的事情数不胜数。微软的每次发布都忙着四处救火,也就罢了,软件开发者们提心吊胆,生怕自己写的程序在某次系统更新后就不能用了。但凡 Visual C++ 出了新 Patch(往往还是重大安全更新),负责 Windows 系统更新的团队就得以最快的速度把这份最新的 msvcrt.dll 部署(其实就是直接把老的 msvcrt.dll 直接覆盖掉)到数以百万计的客户机器上,而客户环境配置千差万别,各种程序还能不能正常运行,只能看老天爷的眼色了。而某个中二程序直接拿它自己开发测试环境里的一个 msvcrt.dll(实际上是个相对较老的) 在安装过程中把系统的 msvcrt.dll 给偷梁换柱的事情更是屡见不鲜,导致的各种蓝屏死机随机故障也就随处可见了。


说了这么多大家可能已经明白了,考虑到这两个团队的规模,这种高度的同步是很难搞的;而且在那个动态链接都还是新鲜玩意的年代,像 COM 这样相对规范的模型被应用的范围就更小了。总的来看,微软还未演化出有效的机制和流程,去缓和 DLL Hell,系统地维护兼容性。强制高度协同(人的因素)和缺乏控制手段(技术因素)两个因素交织起来,足以搞垮任何为了维护兼容性所作出的努力。这种维护难度体现在什么地方呢?举个栗子吧,某 C++ 语言功能要求改进一下 ostream 这个类,为了不破坏二进制兼容性,维护人员不能改变对象尺寸,不能改变任何成员的偏移,他们的改动都要经过精心的设计,谨慎地绕过各种沟沟坎坎,避免打乱已有的虚函数的调用时机,还要注意避免效率上的损失。

有过程序经验的同学应该了解,即使是一段不起眼的“Hello World”程序,从语义到运行环境,内部暗含的上下文约束的信息量也是非常大的。维护代码时,要想不干扰明面上的逻辑很容易做到,但要想保证各种暗含的假定完好如初可就难了,有时甚至根本就不可能有效地证明自己是否已经做到(俺认为,这一点才是致命的)。如果你觉得对于一个像 CRT 这样规模的程序库来讲,这简直是痴人说梦的话,那你说对了,微软确实最终没做到这一点—— Win95 和 Win98 最终就有两个相互不兼容的 msvcrt.dll。


经过了无数个系统更新导致的鸡飞狗跳和西雅图夜未眠之后,微软的经理们终于想通了——按Windows怪兽般的膨胀速度,系统里鸡零狗碎的组件数量的增长情况,再这么搞下去, Win98 之后……就木有然后了。经过某次决定性的会议后,经理们手挽着手,一脸沉痛地向全世界的开发者们郑重宣布:兄弟们,msvcrt.dll 这个 dll 已经被我们玩废了,以后仅供我们 Windows 内部使用(OS DLL),大家就不要再@它了,Let it go 吧。

自那以后,每个新版本的 Visual C++ 都带有一个拥有独立版本号的 crt dll ——这就是 msvcr71.dll, msvcr80.dll, msvcr90.dll 等文件的由来。他们彼此独立更新,微软也终于撇下了历史包袱。


世事难料,尽管距离 msvcrt.dll 被声明为非公开的系统 dll 已经过去很多年了,可仍然有很多不明真相的同学孜孜不倦前仆后继地@这个库;并且由于 crt 的代码是公开的(为了满足那些喜欢 hacking,依赖 undocumented behavior 的同学的需要),大量历史遗留代码(和源源不断新写出来的代码)依赖了 crt 的实现细节。这些年来,微软的技术团队为这群人操碎了心(像不像 IE6 和 WinXP?),例子我就不详述了,大家如果感兴趣的话,可以去 MSDN 的 Knowledge Base 里翻翻,那里是微软工程师们的200,000部伤心的历史记录汇总。唉,满纸荒唐言,一把辛酸泪,都云作者痴,谁解其中味。程序猿们,你们懂的。


总之对于多年来微软在保持向后兼容性这个方面做出的努力来看,俺是真心佩服的。以俺的见识,也的确很难找到另一家能如此做到尽量为自己的黑历史负责任的公司。正如一句评论所说

“I continue to be amazed at the level of effort Microsoft go to in order to accommodate other people's stupid design and implementation choices.”

大家可能时常会觉得,微软背负了太多历史包袱,像是一头行动迟缓的年迈骆驼。可是对于历史遗留问题和历史决策失误,微软的确没怎么逃避过买单。虽然姿势可能一贯不怎么优雅,品味和情怀也是一贯被嘲讽的水准,不过这种负责任的精神,俺认为仍然是相当值得肯定和称道的。


今日意

说完了过去的种种,那么现今的情况如何呢?


自那以后,Visual Studio 在十多年间发布了若干个版本,微软的后缀版本号策略也一路用到了最新版本的 Visual Studio 2013(也就是对应的 msvcr120.dll 和 msvcp120.dll)。这个模型虽然把单个库文件成功地扩编成了一个加强连,但也的确解决了不少问题,至少 Visual C++ 可以放心大胆地加新功能了,不用担心破坏已有软件的行为。

可是(重点来了),在这个模型上,微软收到的负面反馈越来越多。越来越多的大型软件项目发现,系统内诸多组件对不同版本的老旧 crt 的依赖,让他们很难迁移到新版本的 crt 上;有时,还需要付出不菲的精力去支持一些古老的使用某个特定版本的 crt 的 dll 插件(有的甚至连代码都没有)。

作为软件开发者,俺也发现,在一个大型系统里,很难快速明确地查知,不同的二进制文件分别依赖了哪个版本的 crt 。考虑到一些 server 组件会在远端的机器上神不知鬼不觉地更新,没事儿就用 Dependency Walker (depends.exe) 去彻查一遍也不现实,此其一。当发现大量的不一致后,要把它们改成一致,更是难以充分预估的工作量。

口说无凭,俺就举个俺被坑过的例子吧。较新的 CRT 中,对 time_t 的定义默认情况下是 __time64_t,而这个值以前曾经有很长一段时间内是 __time32_t。也就是说,如果你用到的第三方库使用了较老的 32 位 time_t 而你用了较新的 64 位版本,那么不会有编译和链接错误,只会在运行时冒出一些难查的 bug,诸如(微小几率) sprintf() crash,(微小几率)附近的内存被写坏,等等等等。这类问题的麻烦之处在于,即使你已经费了很大劲把出错的区域定位到一段不长的局部代码,也很难联想到是 CRT 的兼容性在捣乱。

与此同时,由于受到了强大的外部压力,微软加快了 Visual Studio 的发布周期,再加上一些特定用途的定制版 crt,比如主机版,移动版,等等,需要维护的版本线以排列组合的方式加速增长。微软自己也发现,维护和支持所有老版本的 crt,把一些重要的补丁向后移植回大部分已经冷却的老版本,带来的开发和测试成本已经大的吓人。

终于又到了不得不变的时候了。


在 Visual C++ Team Blog 的 "The Great C Runtime (CRT) Refactoring" 一文中,我“惊喜”地发现,下一代的 Visual Studio "14" (实际产品名称可能会是 Visual Studio 2015) 中的 crt 又回到了早先单个 "msvcrt.dll" 的模式。VC团队决定不再添加新的版本号后缀,从那以后将一直使用同一个 dll 并自始至终保持它的兼容性。

历史在这里兜了个圈,又回到了原点。


我想此刻大家最好奇的问题可能是:那个方案不是早就被淘汰了吗,为什么这回又重走老路呢?

这篇 blog 里没有正面回答这个问题。俺来试着答一下吧:

  • 第一,这些年来 crt 的特性集变动不大,整体趋于稳定,在可预见的将来,不太会出现架构性的变化,给了微软的工程师足够的信心保持整个协议层(俺把所有公开给程序员的接口统称“协议层”)的兼容度。
  • 第二,从工程师们的行文笔调看,微软这些年来在无数的口水声中积累了一套相对完整的测试套件,覆盖各种边边角角的情况(这甚至包括巨量的对以往的各种未修复的 bug 的行为的兼容性),这个套件的价值难以估量,可以帮助微软把绝大多数破坏兼容性的情况消弭于日常开发之中。
  • 第三,也是最重要的一点,就是时代变了。现在已经不再是那个通过在内存里共享代码段来节省内存的年代了。任何一个应用程序,只要它愿意,可以部署若干个 crt 的副本而不用考虑任何边际的开销。说白了,即使程序需要某个特定版本的 crt,它自己部署一个就好了。微软已经发展出各种成熟的通道把每个应用程序用到的运行时环境跟其他程序隔离开来,甚至同一个应用程序,也可以在启动时指定运行在不同的运行环境下(所谓的 WinXP / Win98 兼容模式)那么,通过版本号来区分就显得画蛇添足了。

不知道这样的回答客官您是否满意。需要说明的是,这只是俺的个人理解,欢迎讨论。


好了,解释清楚了这些情由,我们还是一起来看看新的 crt 有什么特色吧。

新的 CRT 将被切成三块:VCRuntime,AppCRT 和 DesktopCRT。如下图所示:
Image Title

功能也很明确:VCRuntime 是基础模块,是核心中的核心,运行时的运行时(the runtime of the runtime);AppCRT 是兼容移动设备的模块;DesktopCRT 是传统的桌面环境的模块。

从介绍中我们看到后两者大体上是包含关系,但我倾向于认为它们将会发展成为下面的结构:
Image Title

也就是说,俺推测,随着时间的推移,AppCRT 和 DesktopCRT 之间的公共部分会被下推到 VCRuntime 中,而 AppCRT 可能会发展出一些移动平台独有的特性,与DesktopCRT的功能集将不再重叠。当然了,俺只是随便说说,大家看看就行。


说完了架构,再来说说实现吧。在新版本的 CRT 里,微软的工程师做了大量的重构来简化代码。

按惯例,那些吐槽以往的实现有多难维护的巴拉巴拉巴拉咱们还是跳过吧——慢着,这个关于 printf 的吐槽还是蛮有趣的,跳过了怪可惜的。据说大家都很熟悉的 printf 系列函数有 142 般变化,基本都实现在 output.c 里。别看这个文件不长(2696行)——里面有223个 #ifdef 条件编译开关(其中一大半都在一个1400 行长的函数里),更骇人听闻的是,这个文件以不同的编译开关反复编译达12次(!)来生成所有的142般变化。

12次。

虽然不太想用,不过我一时找不到别的词了——我伙呆了。大家可以在自己机器上,找到“C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\crt\src\output.c”去瞅瞅看。顺便说一下,那个1400行的函数是 _output 函数族,这个函数光是 Signature (就是函数体前的声明)就长达59行,预编译宏多达36个,相当壮观,绝对值得你前去拜一下。


大家可能想不到的是,在新的 CRT 里,

  • 呃,绝大部分代码现在都改用 C++ 实现了(然后在头文件里 extern "C" 出去)。
  • 呃,绝大部分手工资源管理现在都改为某种形式的智能指针了。
  • 呃,大部分的 #ifdef 都改成模板和重载了。

微软大概可以机智而自豪地宣称,俺们家的C语言运行时是用C++写的,独此一份哦。

唉,冤冤相报何时了啊。


明天的明天 - 写在最后的话

好了,关于CRT的故事,到这里也快要结束了。不过临走之前,众位客官请先留步,且容俺把话题稍微扯远一点吧。

曾经有段时间(新世纪的头几年),微软全力向开发者兜售托管平台(.net),希望籍此一举摆脱掉各种历史包袱,在一个崭新的,统一的,规范的,和谐的,幸福的,最重要的是,由微软主导的平台上,重建整个生态环境。奈何若干年过去了,买账的开发者十分有限不说,相当的 Windows 平台开发者反而被吸引到了竞争对手的移动平台。可见光是把平台做好还不够,商业模式也要跟上。当然了,事后诸葛谁都会当,不过在我看来,.net 吃亏就吃亏在没有与之相配的明确的商业模式。当年微软宣传了无数次的“.net 大法好”,开发者们还是云里雾里,没看出来这玩意究竟好在哪儿,也没看出来微软究竟葫芦里卖的是什么药。苹果那边一声吆喝“AppStore + 三七分成”,开发者纷纷响应,用脚投票,直接光速构造了应用生态圈。商业模式之威力竟至于斯!

看到这里大家可能会想,这跟你这儿说了半天的 CRT 有什么关系,这不跑题了吗?别急,就要来了。被软硬兼施的苹果和以Google领头的一帮九零后网络杀马特(你还真别急,估计那年头在微软眼里,这群小子还真就是这个层次)前后夹击三拳两脚给打蒙了的微软,这两年逐渐回过味来,开始收复失地。新的CEO上任以来,也的确有了新的气象。对于广大开发者对 Visual Studio 在 Native 方面急需强化的呼声,微软终于敞开怀抱,开展了轰轰烈烈的 GoingNative 运动(2012和2013两年的 GoingNative Conference 全部是可移植C++的内容),Build大会上Native相关的内容迅速增加,对新标准C++11/14的支持也以前所未见的速度展开(C++11/14 Feature Tables For Visual Studio 14 CTP1),包括最近的 Parallel STL,以及将于2014年9月份举办的首届 cppcon (GoingNative的组织者和Visual C++团队一起操办),等等等等。关于这几年 Visual C++ 新增的各种新玩意,包括工具链诸如 MSBuild 的改进,光是 nuget 带来的方便就值得击节了,那也不必多提,俺下回抽时间再写个经验贴吧。不过这里俺还是要忍不住提上一句,凡是在nuget上配置好的第三方库,在你按下F5开始调试的时候,nuget 会自动帮你下载对应VS版本的库,缓存到 Solution,配好所有 Include/Lib 等依赖关系,并将对应的 DLL 放入你的分发目录。


在 Apple 和 Google 的步步进逼下,在移动互联的阵阵浪潮之中,微软这艘驶过了无数风雨的巨轮,仍在不断调整自己的航向,缓缓前行。作为前互联网时代整个软件行业最大的开拓者,微软承受了许多其他的组织从未有机会面对过的考验,挫败,彷徨和挣扎,也催生出很多外人难以想像的成熟(或曰老迈?)机制和文化。是的,人们总是习惯于追捧,著迷于精美,新奇,有趣的流行科技,而蔑视,唾弃一个多年陪伴的老面孔。这是社会风尚的天性使然,却也并不是专业人员用来评价好坏的标准。须知社会风尚会随着流行风向而变化,而真正的价值却总是来源于时间的考验和岁月的沉淀。


谨以此文向微软表达一个普通开发者发自内心的敬意。

好吧,气氛好像严肃了点。那么,最后这句话,我来替微软问了吧——明天的明天,你还会用俺家的 CRT 吗?

[完]
Gu Lu
[2014-06-28]

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。