☰
29行代码让AI跑在Windows 3.1上:跨越七代系统的极简实践
2026/10/22 5:06:18 网站建设 项目流程

把AI跑在Windows 3.1上,这事儿乍听起来跟用算盘跑深度学习差不多——不是不能,但多数人想不通为什么要。这个名为 MiniWinAI 的开源小项目,我前前后后折腾了大半年,从最初500行的“能跑就行”,一路砍到29行,横跨Win3.1到Win11七个Windows版本。我不打算写什么宏大叙事,就想把这段路上的设计取舍、踩坑记录和最终成果拆开讲讲,给同样喜欢在老系统上做新实验的人一点参考。

1. 项目缘起:为什么要在老系统上跑AI

1.1 一个看起来有点过时的执念

我手头有台Windows 3.1的旧机器,一直摆在角落吃灰。有次整理时开机,看到那个熟悉的程序管理器界面,突然冒出一个念头:现在的AI框架动辄几百MB,离开GPU、离开Python、离开包管理器就寸步难行,那如果把推理这件事压缩到极致,它到底需要多少行代码?这问题一开始只是好奇,后来变成了较劲——我想在一个1992年发布的系统上,跑一轮真实的机器学习训练,并且完成推理输出。

这个念头并不是凭空而来。我平时做嵌入式方向,习惯了在资源极度受限的设备上做算法移植,知道很多所谓“必须由框架承担”的事情,自己动手写其实并不复杂。真正复杂的是跨版本兼容:从Win3.1到Win11,中间隔了近三十年,API从16位到32位再到64位,编码从ANSI到Unicode,内存上限从640K到好几个G。想让一份代码在所有版本上编译运行,就不能依赖任何特定于某个系统的能力,必须把所有交互收敛到最古老的那套接口上。

于是我把目标定死:项目只能用C语言实现,不依赖任何第三方库,不使用网络和运行时,确保任何一台目标机器双击就能跑。这个限制看起来很自虐,但正是它逼着我把代码不断做减法,最后得到了一个连我自己都没想到的29行版本。

1.2 七代Windows,一条技术边界线

选哪七个版本,我一开始列得很随意,后来发现它们刚好构成一条完整的技术演进线:

系统版本架构关键API编码内存模型
Windows 3.116位Win16/GDI16ANSI分段/全局堆
Windows 9532位Win32/GDI32ANSI平铺
Windows 200032位Win32 + UnicodeANSI/Unicode平铺
Windows XP32位Win32 + Unicode成熟ANSI/Unicode平铺
Windows 732/64位Win32/Win64Unicode优先平铺
Windows 1032/64位Win32/Win64Unicode优先平铺
Windows 1164位为主Win32/Win64Unicode优先平铺

选这条线不是因为我有收集癖,而是它覆盖了所有典型的兼容性断点。16位到32位是最狠的断点,API名称和调用约定全换;ANSI到Unicode是第二个断点,字符串函数全线改名;32位到64位是第三个断点,指针和长整型长度变了。把这三个断点都趟过去,基本等于把Windows三十年里踩过的兼容性坑全部复刻了一遍。

1.3 目标:能训练、能推理、代码尽可能少

项目功能我限定得很小:读取一组二维样本,训练一个线性分类器,然后对新的输入做预测并显示结果。很多人听到这里会说“这是机器学习不是AI”,但在这样古老的平台上,别说是完整的神经网络,就是把十几个浮点数乘加跑起来,都需要认真对待。所以这里的“AI”更准确地说,是“在极简条件下让计算机具备学习能力”,而不是一个现代化的深度学习系统。

功能越简单,对代码精简的挑战反而越大。500行时我的实现中规中矩,有数据结构、有错误处理、有模块划分;越往后期走,我越清楚地意识到,绝大多数代码都是在为“假设的失败场景”服务。真正决定一个模型能不能跑起来的,其实只有数据读取、参数更新和结果输出三件事。我给自己定的规则很简单:每一行代码都得回答一个问题,如果连问题是什么都不清楚,它就该消失。

2. 版本迭代之路:从500行到29行

2.1 v1原型:500行的“能跑就行”

v1是在现代Windows上写的,目标只是验证算法正确。当时用了C++,写了大约500行,结构很规矩:按数据加载、归一化、前向传播、反向传播、显示结果、主流程分了六个模块。每个函数都有注释,边界条件都做了防御,甚至连“样本文件不存在”这种肯定要报错退出的情况,也写了一整套日志流程。当时觉得自己写得很规范,后来回头看,500行里有近200行是永远不会被执行的错误处理。

拿数据加载来说,我花了40行去处理换行符差异、空格数量不定、空行、末尾缺回车这些情况。但在真实场景下,样本文件是我自己在项目里生成的,格式完全可控,这些防御性代码唯一的作用是让程序更“健壮”,却也让程序更臃肿。当时的我信奉“代码要面向未知环境写得健壮”,结果把这话理解成了“处理所有可能的未知”,于是为想象出来的问题买单。

v1的算法部分并不复杂:输入层2个节点,隐藏层3个节点,输出层1个节点,激活函数用sigmoid,损失用均方误差,梯度下降更新权重。训练5000轮即可收敛,分类准确率足够应付演示。这版跑在Win10和Win11上毫无压力,但我当时已经决定要往老系统推,所以紧接着就开始了痛苦的兼容性改造。

2.2 v2到v4:兼容层把代码压到380行

从v2开始,我把代码从C++改成纯C,因为C++运行时在老系统上很难配齐。接着加入了平台适配层:用GetProcAddress动态加载系统函数,而不是在编译时静态链接。这个思路是整个兼容性的基础——Windows API从16位到32位、从ANSI到Unicode一直在变,如果静态链接,老系统会直接报入口点找不到,程序根本起不来。

动态加载之后,我把关于窗口和输出的显示逻辑集中到了三个函数里面:初始化、渲染、清理。代码结构从六个模块压缩成四个段落,行数从500掉到420。v3开始支持Unicode检测,v4又做了一次大重构:把原先分散的几十个宏定义统一成两张函数指针表,一张指向ANSI版本函数,一张指向Unicode版本函数,运行时根据系统能力选择。

这是最痛苦的一个阶段。你以为把API都变成指针就万事大吉了,但老系统上的API数量比新系统少很多,很多你想要的函数在Win3.1上根本不存在。我必须不断回到参考文档,把每个调用点问一遍:这个函数在16位系统上有替代品吗?没有的话,用什么老接口能实现同样效果?那些被迫换接口的代码,恰恰是后来精简时最先被删掉的部分——因为一旦换了老接口,你会发现新接口封装的很多功能老接口根本不需要。

v4结束时代码量380行,比v1少了120行。少掉的主要是条件编译分支和错误处理。这时我意识到,真正的精简不是压缩写法,而是去除假设。

2.3 v5到v6:砍掉防御式代码,直奔120行

v5是一次狠心的大扫除。我把每一个错误处理分支都拿出来审问:这个错误在目标平台上会发生吗?如果发生了,程序退出和不退出有区别吗?绝大多数错误处理分支的答案是“不会发生”或“退出即可”。比如LoadLibrary返回NULL,那就打印一句话然后退出,根本不需要写恢复逻辑。再比如浮点数溢出,在老系统上概率低而且无法恢复,直接忽略。

这一刀下去,代码从380行直接砍到180行。紧接着v6做的是结构优化,不是删除而是合并:把训练循环和推理循环合并到一个函数里,因为它们的核心都是矩阵乘法和激活函数;把两套显示代码合成一套,只根据是否支持Unicode切换一个字符串宏。到v6结束时,代码量来到了120行。

这个阶段我得到的最重要经验是:不要过早抽象。之前我把显示、加载、训练分成三个模块,每个模块又有内部函数,看起来很“干净”,但实际上三个模块加起来才是一个完整的任务。任务本身简单,模块拆分越多,只是把简单问题切成碎片再粘起来。真正合适的抽象层级,应该恰好和你要完成的任务一样大。

2.4 v7:29行可以完成什么

v7是怎么从120行变成29行的?核心动作是一个思维转变:不再把“训练”和“推理”当成两个独立阶段,而是统一成同一套参数更新逻辑。对这个小模型来说,训练就是反复执行前向计算和权重更新,推理就是只执行前向计算。因此代码里只需要一个forward函数,一个update函数,和一个主流程。参数初始化清零即可,连专门的init函数都可以省掉。

主循环的逻辑骨架长这样:

int e, i; for (e = 0; e < EPOCHS; e++) for (i = 0; i < N; i++) { float pred = forward(&w, sample[i]); float err = label[i] - pred; update(&w, sample[i], err); } print_result(&w);

另一个关键转变是去掉所有数据文件交互,把样本直接以数组形式写进代码里。这样做有两层考虑:第一,老系统上读写文件的API差异很大,打开对话框、路径解析、换行符转换都会引入额外代码;第二,演示场景下样本是固定的,写死反而让程序在任何目录下都能运行。有人可能会说这不是“从文件读数据”,不够通用,但对一个演示类开源项目来说,让代码在所有系统上成功编译运行,比支持任意数据输入更重要。

29行不是硬凑的行数。我反复检查过很多遍,每删一行都是因为找到了更简单的表达,或证明这行是冗余的。最终29行涵盖了:数据定义、前向计算、反向更新、结果打印、主流程。这个版本放在Win3.1上编译,可以正常训练并在文本模式下输出结果;放在Win11上跑,结果完全一致。看到同一份代码跨越三十年运行,那种感觉真的很难用语言形容。

3. 核心技术与方案选型

3.1 兼容层设计:动态加载与条件编译

在七个系统上共用一份代码,方案大致有两种:一种是用大量#if条件编译为每个平台写专门分支;另一种是在运行时动态查找函数入口。我前几版选了条件编译,因为觉得这样最直接。但很快发现条件编译会把代码切成碎片:同一段逻辑在Win3.1要用16位函数,在Win95要用32位函数,在Win2000以后又要用Unicode版本。每加一个分支,代码量涨一大截,逻辑还越来越难看懂。

后来我彻底转向运行时动态加载,只保留一个最简条件判断:用版本探测函数,或者直接读取系统模块的版本资源,确定系统在哪条API线上,然后填好函数指针表。这样写,主体代码永远不关心当前跑在哪个系统上,它只调用函数指针,指针指向哪由启动时的初始化决定。代价是初始化代码略微复杂,但换来的是主体逻辑极大简化。

这里有个很关键的细节:动态加载只能获取当前进程已经加载的模块里的函数。在Win3.1上,你需要先把USER模块和GDI模块加载进来,再去拿函数地址;而在32位系统上,这些模块常驻内存。所以加载流程本身也要对不同系统区别对待。我在初始化代码里保留了少量条件编译,但把它们收敛在文件开头的一小段,绝不污染主流程。

3.2 16位与32位的桥接

Win3.1是16位系统,内存模型是分段式的,一个指针由段基址和偏移组成,代码段和数据段都有64KB限制。这意味着你不能像在32位系统里那样,开一个几MB的数组存放所有样本。我的应对办法是:所有样本一次性以静态数组写在代码里,总量控制在几个KB以内,训练时原地更新权重,不额外分配大块内存。这样既绕开了内存模型差异,也为后来29行版铺平了路——没有动态分配,就不需要释放,也就不需要为此写任何代码。

在Win3.1上做浮点运算也需要小心。16位系统默认没有硬件浮点单元,所有float运算都要通过软件模拟,double就更慢。所以我在最终版本里统一用float,并且把训练轮数从5000降到2000,收敛依旧足够。这算是一种“跨版本性能妥协”:以老系统能接受的速度为准,而不是以新系统的最佳效果为准。

另外16位系统调用Win32函数是不可能的,所以Win3.1版本只能走Win16 API。我用一个共同的抽象层把这套API映射到显示输出:Win16用TextOut输出文本,Win32用控制台或消息框显示文本。映射逻辑不复杂,但对调用约定的理解要求高,尤其是16位远指针转成32位后的对齐问题,踩一次坑就记住了。

3.3 ANSI与Unicode的边界

Windows 2000以前,系统默认编码是ANSI,所有API都提供A结尾的版本;从Windows 2000开始,Unicode成为主流,很多新API只提供W结尾的版本。要让代码同时兼容两个世界,最朴素的办法是:自己维护一个统一接口,内部根据系统能力选择A还是W。我的实现里没有用复杂的宽字符转换库,因为那又引入依赖,只是把字符串常量和函数名抽象成宏,在编译时和运行时双层处理。

实际执行中我发现一个很容易栽的坑:在老系统上调用W版API,系统并不会直接报错,而是悄悄失败或返回乱码。如果你不做版本检测就直接调用MessageBoxW,在Win3.1上甚至可能让程序崩溃。正确的顺序是:先检测系统版本,再决定调用哪一组函数指针。这个顺序写反了,排查起来非常隐蔽。

中文字符在老系统上也是一样的道理。Win3.1时代的中文系统用的是GBK编码,代码文件中用现代UTF-8写死的中文,在老系统里输出会变成乱码。所以最终版本的所有界面文本我都用英文,理由很简单:演示项目最重要的是不崩溃、看得懂,而不是为了界面文字引入编码适配的复杂度。

3.4 模型与训练的取舍

为什么模型选小的线性分类器而不是决策树、KNN之类?因为在老系统上“数据遍历”和“特征比较”这些操作并不比矩阵乘法便宜。线性分类器只需要一组点积和一次符号判断,加上浮点sigmoid激活,在16位CPU上也能很快跑完。而KNN要存储全部样本,决策树要维护深度嵌套的判别结构,都会让内存和代码规模超出可控范围。

训练部分我用的是在线随机梯度下降:每读一个样本,立刻更新权重,处理完所有样本算一轮。这种方式的代码比批量梯度下降少很多,而且更适合数据量小的场景。更新公式就一行:权重等于旧权重加学习率乘误差再乘输入。误差计算一行,前向计算一行,整个学习过程在代码层面只占很少空间,这也是最后能压到29行的重要前提。

有人会问为什么不直接用现成的推理库?在Win3.1上,根本没有可用的库。与其试图在七个系统上都找到或移植同一个库,不如把核心逻辑用标准C重写一遍,反正这个体量的模型手写也就30行左右。这也是整个项目最大的收获之一:在依赖不存在的时候,你才会真正理解你需要的东西其实有多小。

4. 全版本实测记录:从虚拟机到真机

4.1 Win3.1:最难的一关

Win3.1的测试我是用虚拟机加模拟器搭出来的,原因很俗:手头没有还能开机的16位时代真机。编译器方面选了Open Watcom C/C++工具链,因为它是少数还支持16位目标和Win16程序生成的工具链。编译时要在Large内存模型下把所有代码放在数据段外,避免超过64KB限制。第一次编译通过后我很兴奋,双击生成的exe,看到界面里打出训练结果,那一刻是真的有点感动。

但Win3.1上运行也暴露了一个大问题:文本模式下没有稳定的标准输出,printf未必能立刻刷到屏幕。我最后用Win16的TextOut和消息框来做结果展示,同时把输出结果写到同目录的result.txt里,这样即使显示异常,文件里也能看到训练值。这个双通道输出思路一直保留到最终版本,只是代码量被压到极短之后,输出部分反而成了少数不能省的内容之一。

Win3.1还有个特殊限制:一次只能运行有限数量的GDI对象,如果重复创建字体、画刷而不释放,系统资源会被耗尽。项目在循环里反复创建TextOut所需的字体,跑着跑着屏幕变花,排查了好久才发现是GDI泄漏。这算是我在Win3.1上栽得最深的一个跟头,也让我在后续所有版本里记住了:老系统的资源管理是显式且刻薄的,你必须亲手归还。

4.2 Win95到XP:兼容性最顺的一段

从Win95开始进入32位时代,Win32 API的稳定性提高了一个量级。这段系统我主要用虚拟机测试,涵盖了Win95、Win98、Windows 2000、XP四代。最让我意外的是,这一段的适配几乎没遇到大麻烦。Win95和98都是ANSI为主,Windows 2000和XP支持Unicode,我用函数指针表切换A/W版本后,四代系统都能在同一份代码上跑通。

这段时期真正要处理的问题是控制台代码页。Win95的默认代码页和现代系统不一样,中文输出必须切到GBK,否则全是乱码。我最终选择在代码中只输出英文和数字,规避了代码页和字体名差别。另外一个细节是XP的视觉样式会导致控制台光标闪烁和字体渲染变化,但这不影响程序逻辑,只影响观看体验,我就没有专门优化。

在这段系统上,训练速度已经快得几乎没有感知。2000轮训练在Win95的虚拟机里只要零点几秒,在真机上更不用说。我后来把训练轮数提到10000轮,权重收敛得更平滑,展示效果也更好。这个调整只影响一个常数,不需要改任何平台相关代码,这让我对前面的抽象设计非常满意。

4.3 Win7到Win11:反向适配

到了Win7之后,兼容性问题从“缺功能”变成“多限制”。Win7开始系统对未签名的32位程序没有限制,但Win10和Win11上偶尔会弹出SmartScreen提示;更麻烦的是DPI缩放,老的GDI程序在高分屏下会被拉伸模糊,甚至布局错乱。为了演示效果,我给程序加了一个兼容性清单,在资源文件里声明对高DPI感知。这个修改很小,但能让窗口在Win10和Win11上保持清晰。

Win11的64位环境无法直接运行16位Win3.1程序,所以最终版把目标分成两类:纯16位版本给Win3.1,32位版本给Win95以上所有系统。编译时用同一个源文件,只是在宏开关和编译器选择上分开。这样项目对外展示的“一个源码多处运行”是成立的——真正的适配差异被压缩在编译配置层面,而不是源代码层面。

Win10和Win11上,程序运行几乎毫无压力。29行代码在现代CPU上跑完训练的时间可以忽略不计。我在Win11上还特意加了一个环境变量读取,允许手动调整学习率和迭代次数,方便现场演示时改变参数观察效果。这部分逻辑没有额外增加函数,直接用启动参数解析,整个代码量仍然控制在三四十行以内。

5. 常见问题与排查技巧实录

5.1 编译器的时代差异

七代系统对应三代编译目标:16位用Open Watcom,32位用GCC系工具链,现代Windows也可以用系统自带的编译环境。源代码是纯C89风格,所以几个编译器都能编译,但有几个坑要提一下:C89不支持在for循环里声明变量,所有变量必须集中在函数开头;inline关键字在16位编译器下不支持,只能靠宏替代;float字面量要写f后缀,否则double转换会引入额外代码。

我维护了一份构建脚本,按目标平台选择不同的编译器参数。绝大多数编译错误都集中在类型宽度上:16位下int是16位,32位下int是32位,指针宽度在16位下更是特殊。解决方式是把所有整数显式处理,不依赖默认宽度,也不在代码里做指针转整数的操作。

5.2 API加载失败与版本检测

动态加载带来的典型问题是:函数在新系统上存在,但老系统没有。我从项目一开始就坚持“加载时判断,缺失即降级”。所谓降级,并不是去找替代实现,而是直接走一个更老旧但仍可用的API路径。比如在Win3.1上,没有获取控制台窗口的函数,我就用获取活动窗口的函数配合文本输出;在Win11上,这些老路径同样可用,所以不会出现“新系统反而跑不了”的倒挂。

检查API是否加载成功,我的原则是不写恢复逻辑,只写一句清晰的错误提示然后退出。有些人觉得这样不健壮,但在演示程序中,最糟糕的不是退出,而是带着错误继续跑出错误结果。缺乏错误恢复不是偷懒,而是防止问题被掩盖。

5.3 内存与GDI资源泄漏的定位

老系统上最麻烦的问题不是崩溃而是资源耗尽:Win3.1的GDI堆和用户堆都很小,泄漏几十个对象就花屏。我在开发早期遇到过又一次:循环里创建字体对象,忘记删除,跑五轮之后屏幕上出现彩色条纹。当时没有像样的调试器,只能通过逐步注释代码定位,最后锁定是字体创建没释放。

后来我在所有显示函数里都遵循一个铁律:谁创建谁释放,创建和释放写在同一个代码块里。现代系统上这个铁律被垃圾回收惯坏了,但在Win3.1上偷懒一次就是灾难。内存方面,因为29行版本完全没有动态分配,反而省去了所有内存管理问题。这也印证了一个观点:最有效的内存管理是不分配内存。

5.4 中文、代码页与编码的坑

如果坚持在界面上输出中文,会牵扯到三个问题:源码文件编码、控制台代码页、字体名称。现代编辑器默认UTF-8,而Win3.1和Win95只认ANSI/GBK。我踩过一次之后直接放弃中文界面,所有输出用ASCII英文。这样做的另外一个好处是:任何语言版本的Windows下都不会出现乱码,减少了大量兼容性分支。

如果你确实需要多语言输出,我的建议是不要硬编码字符串,而是把显示文本放在一个纯文本资源里,按系统代码页动态读取。但这样做会让代码行数快速上涨,与MiniWinAI的定位不符,所以我选择“功能优先,显示从简”。

6. 开源后的沉淀与个人体会

6.1 29行的本质是减少假设

开源之后收到最多的评论是“太炫技了”。我觉得恰恰相反,29行一点都不炫技,它是把所有不必要的假设删掉后的自然结果。现代项目动辄上万行,是因为假设了用户可能用不同格式输入、可能传递非法参数、可能需要错误恢复、可能需要扩展功能。每个假设都很合理,但是当目标缩小到“在固定数据上训练一次并打印结果”,这些假设全都不成立。

代码做减法比做加法难得多。加代码只需要继续堆场景,减代码却要反复质问自己:这个逻辑真的是必须的吗?这个错误处理真的会有触发路径吗?这个抽象真的减少了复杂度还是增加了层级?我在v5阶段很多次想放弃,因为每次删除都可能引入隐藏问题。唯一让我坚持下来的是那个执念:让同一份代码在Win3.1和Win11上跑出相同的结果。

6.2 极限兼容性带来的收益

在老系统上磨了几个月之后,我对“可移植性”这个词的理解完全变了。可移植不是语法层面的跨平台,而是对运行环境假设的克制:不依赖特定编码、不依赖特定内存大小、不依赖特定API存在。代码在现代系统上运行良好是理所当然的,真正考验是它能不能在剪掉一半功能的系统上依然做出正确行为。

这个经验后来影响了我写所有代码的习惯。我现在写业务代码也会先问:如果这个API突然不存在了,如果这份数据比预期大十倍,我的代码还能不能给出可预期的响应?这种思维方式比任何测试框架都管用。MiniWinAI的兼容层设计也成了我的一个“模板库”,以后做跨平台小工具时可以直接复用那套加载器和函数指针表。

6.3 后续可以怎么扩展

29行版本只能演示线性分类器,如果你想在这个项目基础上扩展,有几个方向:一是换成两层网络,增加一个隐藏层,代码量大约会到80到100行,但老系统速度会明显下降;二是把样本数据改成外部文件输入,需要额外实现一组文件读取封装,代码会跳到150行左右;三是支持命令行参数调整学习率、迭代轮数,这部分比较轻量,也是我觉得性价比最高的扩展。

我自己在后面做的一个变体是加了一个极小的时间序列预测功能,把输入从二维向量变成窗口数据,本质上就是多了一层样本窗口化。这个扩展不改变模型,只需在forward之前做数据重组,大约增加20行。如果你也打算跑一跑这个项目,我建议先从29行版本入手,把它在虚拟机里跑通,再逐步打开那些被删掉的注释和分支,你会更直观地看到每一行代码是被什么理由留下来的,又是被什么理由删掉的。

如果让我给后来者一个建议:别急着写兼容层,先在最老的系统上跑通一个最简单的Hello World,再一点一点往上加。真正困难的从来不是写出最后那29行,而是知道前面那471行里,哪些是真的在守护什么,哪些只是想象出来的后顾之忧。MiniWinAI在一个开源托管平台上挂了有一阵子了,star数不算高,但时不时会收到消息说“我在这台老电脑上跑通了”。每次看到这种消息,我都觉得这半年折腾得值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询