Microsoft BASIC on M6502:十个经典程序实战与内存模型解析
1. 为什么要在今天折腾一台四十多年前的“老古董”第一次跟身边朋友聊起想用 Microsoft BASIC 在 M6502 上写点东西十个里有八个的反应是“你图啥”。这反应我完全理解——现在随便一台设备上的运行时都比当年整间机房的算力强用现代工具几分钟能搞定的事搁在这套环境里可能要抠半天内存布局。但恰恰是这种“抠”才是我想聊的核心。Microsoft BASIC 跑在 M6502 上这套组合是个人计算启蒙时代最具代表性的软件栈之一一个精简到极致的 8 位处理器配一个把解释器塞进几 KB 空间的 BASIC 方言。它解决的问题很朴素——让没有昂贵大型机资源的人也能用接近自然语言的方式指挥机器干活。适合谁来参考我觉得有三类人想理解“解释器到底怎么工作”的初学者、对底层内存与指令周期有执念的嵌入式爱好者以及想给孩子找一个没有图形界面干扰、纯粹练逻辑的编程入口的家长。这篇内容我会用十个经典小程序当线索把环境搭建、内存模型、代码实现、踩坑排查串起来所有代码都可以直接照着敲。先说清楚一个前提我这里说的“实战”不是让你去淘一台原装老机器。基于常见实践更稳妥的方式是用成熟的 M6502 模拟器加载 Microsoft BASIC 的 ROM 镜像在宿主机上跑起来。这样既保留了真实的指令集行为和内存布局又免去了硬件老化、接口稀缺带来的麻烦。模拟器选型上我倾向于那些能显示完整内存视图、支持单步调试、并且对 BASIC 的零页使用有清晰标注的实现。原因很简单——你写的每一行 BASIC最终都会落到具体的零页地址和堆栈操作上看不见这些调试就是盲人摸象。十个程序我按难度递进排了序从最基础的计算与输出到字符串处理、数组、子程序、简易游戏逻辑最后收在一个带状态机的小型模拟程序上。每个程序我都会讲清楚它练的是什么能力、关键行为背后的原理、以及我实际敲进去之后遇到的坑。你会发现早期编程的乐趣不在于功能多花哨而在于每一行代码你都能在脑子里“跑”一遍知道它占了多少字节、花了多少周期。2. 环境搭建与内存模型先把地基摸清楚2.1 模拟器与 ROM 镜像的准备思路搭建这套环境核心就两样东西一个行为准确的 M6502 模拟器和一份 Microsoft BASIC 的 ROM 镜像。模拟器我建议选那种默认就带内存监视器memory monitor的能实时看到零页、堆栈页和程序区的变化。ROM 镜像的获取渠道这里不展开重点是加载之后要确认两件事一是 BASIC 的启动提示符能正常出现二是PRINT一条简单表达式能得到正确结果。这两步过了说明解释器主循环、输入缓冲和表达式求值链路都是通的。加载完成后第一件该做的事不是急着写代码而是搞清楚 BASIC 把用户程序放在哪、变量放在哪、字符串放在哪。基于这套方言的常见布局用户程序通常从某个固定地址开始向上增长变量区紧随其后而字符串则在内存顶端向下分配。这个“两头向中间挤”的模型是理解后续所有内存相关问题的钥匙。我习惯在模拟器里先手动往程序区写几个字节再用监视器观察地址变化确认自己对布局的判断没错再动手写正式代码。注意不同模拟器对 ROM 加载地址的默认设置可能不同务必先核对 BASIC 启动后FRE(0)返回的可用字节数这个数值能反推出当前内存布局是否符合预期。2.2 零页、堆栈与程序区的分工M6502 的零页是地址 0x00 到 0xFF 这 256 个字节寻址最快所以 BASIC 解释器把最热的状态都塞在这里当前行号指针、表达式求值栈指针、临时结果、以及各种标志位。堆栈页是 0x0100 到 0x01FF硬件栈指针从 0xFF 往下走。用户程序区一般在零页之上、字符串区之下。理解这三块的分工直接决定了你写代码时会不会莫名其妙地“程序跑飞”。举个我实际遇到的例子早期我写了一个递归调用很深的子程序结果运行到一半报错提示信息含糊得很。后来在监视器里盯着堆栈页看发现硬件栈指针已经快撞到 0x00 了——BASIC 的GOSUB每次调用都要压返回地址递归层数一多栈就溢出了。这个教训让我养成了一个习惯凡是涉及GOSUB或数组的代码先在纸上估算一下栈深度和数组占用再决定要不要拆分成多段。零页的临时结果区也是同理表达式嵌套太深会把它撑爆虽然这种情况少见但一旦出现报错信息往往指向一个完全无关的行号极难排查。2.3 输入输出与字符集的基本约定这套 BASIC 的输入输出走的是最朴素的字符流PRINT往输出设备写字节INPUT从输入缓冲读一行。字符集通常是 ASCII 的一个子集大小写处理、控制字符的行为各家实现略有差异。我在实际使用中发现PRINT后面跟分号会抑制换行跟逗号会跳到下一个制表位这两个细节在排版表格类输出时特别有用。另外INPUT对输入的容错很有限用户敲了非法字符程序可能直接报错退出所以正式一点的程序我都会自己写一个带校验的输入子程序而不是裸用INPUT。字符集这块还有一个容易忽略的点字符串比较是按字符编码逐位比的所以大小写敏感。如果你的程序要做菜单选择最好在比较前统一转成大写否则用户敲个小写字母就匹配不上。这个坑我在第三个程序里踩得结结实实后面会细说。3. 十个经典小程序逐个拆解3.1 程序一温度换算器——把表达式求值吃透第一个程序我选温度换算因为它足够简单又能把变量、表达式、PRINT格式化三件事一次练到。核心逻辑就是读一个摄氏温度按公式换算成华氏输出结果。代码大致是这样10 INPUT C TEMP; C 20 F C * 9 / 5 32 30 PRINT F ; F 40 END看着平平无奇但这里有个值得琢磨的地方9 / 5在这套 BASIC 里是浮点除法结果是 1.8不是整数除法。如果你从别的语言过来习惯性地以为整数相除会截断就会在这里栽跟头。我特意验证过PRINT 9/5输出的是 1.8说明解释器内部用的是浮点表示。这也解释了为什么早期 BASIC 的数值精度总被人吐槽——它用的是有限位数的浮点大数运算会有舍入误差。这个程序练的是“表达式求值链路”解释器先扫描 token再按优先级构建求值顺序乘除先于加减括号最高。你在纸上把C * 9 / 5 32的求值树画一遍就能理解为什么零页里需要一块临时结果区来存放中间值。实操心得是写复杂表达式时宁可多加括号也别赌解释器的优先级实现和你脑子里的一致尤其是涉及取负号和乘方混用的时候。3.2 程序二九九乘法表——循环与输出排版第二个程序打印九九乘法表练的是FOR循环嵌套和输出对齐。代码结构是两层循环外层控制行内层控制列10 FOR I 1 TO 9 20 FOR J 1 TO I 30 PRINT J; X; I; ; I * J; 40 NEXT J 50 PRINT 60 NEXT I 70 END这里的关键细节在第 30 行的分号和第 50 行的空PRINT。分号让同一行的多个输出连在一起不换行空PRINT则负责在每行结束后换行。我第一次写的时候忘了第 50 行结果整个表挤成一条长龙完全没法看。另一个坑是列宽不同位数的乘积占的字符数不一样导致列对不齐。解决办法是在输出前用字符串函数补空格或者干脆接受这种“原始感”——毕竟早期终端本来就不讲究排版。这个程序还顺带暴露了FOR循环的一个实现细节循环变量在循环体里是可以被修改的但改了之后NEXT的行为取决于具体实现有的会重新读取变量值判断有的用内部计数器。基于常见实践我建议循环变量只读不写避免依赖这种未定义行为。嵌套循环的层数也别太深每层都要占栈空间三层以上就要留意栈深度了。3.3 程序三猜数字游戏——随机数与输入校验猜数字是经典中的经典练的是随机数生成、循环加条件判断、以及输入校验。核心代码10 R INT(RND(1) * 100) 1 20 G 0 30 INPUT GUESS; N 40 G G 1 50 IF N R THEN PRINT TOO LOW: GOTO 30 60 IF N R THEN PRINT TOO HIGH: GOTO 30 70 PRINT GOT IT IN; G; TRIES 80 ENDRND(1)返回 0 到 1 之间的浮点数乘 100 取整再加 1得到 1 到 100 的整数。这里有个必须知道的坑很多实现里RND的种子是固定的每次启动程序得到的序列一模一样。想让每局不同得在程序开头用RND(-1)之类的写法重新播种具体语法看实现文档。我实测过不播种的话连续运行三次第一次的答案永远是同一个数这在演示时特别尴尬。输入校验这块裸用INPUT的风险在于用户敲了非数字程序直接报错。稳妥的做法是自己写一个读字符、逐位校验、再转数值的子程序。虽然代码长了点但健壮性完全不是一个级别。这个程序我还踩过一个字符集的坑提示信息里用了小写字母结果在某些终端配置下显示成乱码后来统一改成大写才稳定。这也印证了前面说的字符集约定要提前确认。3.4 程序四字符串反转——字符串处理与内存分配字符串反转练的是字符串函数和内存模型。思路是读一个字符串从末尾往前逐字符拼出新串10 INPUT TEXT; A$ 20 B$ 30 FOR I LEN(A$) TO 1 STEP -1 40 B$ B$ MID$(A$, I, 1) 50 NEXT I 60 PRINT B$ 70 END这段代码在功能上没问题但性能上有个隐患每次B$ B$ ...都会创建一个新字符串旧串变成垃圾。字符串在内存顶端向下分配垃圾多了就会和程序区“对撞”触发OUT OF MEMORY。短字符串无所谓长字符串或者循环次数多的时候这个问题会很明显。优化思路是预先分配一个足够长的字符串用MID$的赋值形式逐位填充避免反复创建。MID$在这里有两种用法作为函数取值作为语句赋值。后者是这套 BASIC 的一个特色语法上容易混淆我第一次写的时候把赋值形式当函数用报错信息完全看不懂。经验是取值用MID$(A$, I, 1)赋值用MID$(B$, I, 1) X两者位置和上下文不同多写几遍就分清了。字符串比较的大小写敏感问题在这个程序里也冒出来了如果要做回文判断记得先统一大小写。3.5 程序五冒泡排序——数组与算法可视化数组排序练的是DIM声明、下标访问和算法逻辑。冒泡排序虽然效率不高但过程直观适合观察数组在内存里的变化10 DIM A(10) 20 FOR I 1 TO 10 30 A(I) INT(RND(1) * 100) 40 NEXT I 50 FOR I 1 TO 9 60 FOR J 1 TO 10 - I 70 IF A(J) A(J 1) THEN T A(J): A(J) A(J 1): A(J 1) T 80 NEXT J 90 NEXT I 100 FOR I 1 TO 10: PRINT A(I);: NEXT I 110 ENDDIM A(10)声明了 11 个元素下标 0 到 10这是很多初学者容易搞错的地方——声明大小是 10实际可用下标到 10共 11 个。下标从 0 开始还是从 1 开始不同方言有差异这套 BASIC 默认从 0 开始但你可以用OPTION BASE 1改成从 1 开始。我建议显式写清楚别依赖默认值。数组在内存里是连续存放的每个元素占固定字节数浮点数组通常每元素 5 字节左右。声明大数组前先算一下占用元素数乘每元素字节数再和FRE(0)对比留足余量。我见过有人声明了一个几千元素的数组结果程序区被挤爆报错指向DIM那一行提示信息却只说内存不足不告诉你具体差多少。调试时在DIM前后各打一次FRE(0)差值就是数组实际占用这个方法很实用。3.6 程序六素数筛——标志数组与效率权衡素数筛练的是标志数组和循环边界控制。用埃氏筛的思路用一个布尔数组标记合数10 N 100 20 DIM F(100) 30 FOR I 2 TO N 40 IF F(I) 1 THEN 80 50 PRINT I; 60 FOR J I * I TO N STEP I 70 F(J) 1 80 NEXT I 90 END这里有个效率细节内层循环从I * I开始而不是从2 * I开始因为小于I * I的倍数已经被更小的素数筛过了。这个优化在 N 小的时候看不出差别N 大了能省不少周期。另一个细节是STEP I步长直接跳到下一个倍数比逐个判断快得多。标志数组用 0 和 1 表示但注意这套 BASIC 里没有真正的布尔类型IF F(I) 1是比较不是直接判断真假。如果你写IF F(I) THEN非零值都算真也能工作但可读性差一些。我倾向于显式比较代码意图更清楚。这个程序还让我第一次直观感受到“算法复杂度”是什么——N 从 100 加到 1000运行时间肉眼可见地变长虽然具体周期数算不清但趋势很明显。3.7 程序七简易计算器——子程序与状态管理这个程序练的是GOSUB子程序和简单的状态管理。做一个循环菜单让用户选加减乘除读两个数输出结果然后回到菜单10 PRINT 1 ADD 2 SUB 3 MUL 4 DIV 5 QUIT 20 INPUT CHOICE; C 30 IF C 5 THEN END 40 INPUT A,B; A, B 50 ON C GOSUB 100, 200, 300, 400 60 GOTO 10 100 PRINT A B: RETURN 200 PRINT A - B: RETURN 300 PRINT A * B: RETURN 400 IF B 0 THEN PRINT DIV BY ZERO: RETURN 410 PRINT A / B: RETURNON ... GOSUB是个很实用的多路分支结构按表达式的值跳转到对应的子程序。这里的关键是每个子程序末尾必须有RETURN否则栈会失衡。除法子程序里加了除零判断这是必须的——这套 BASIC 遇到除零会直接报错终止不会返回一个特殊值。子程序调用会压栈所以嵌套层数受栈深度限制。我实测过简单的菜单循环里嵌套两三层没问题但如果子程序里再调子程序层数多了就要小心。另一个经验是子程序里尽量别修改全局变量除非你很清楚调用方的状态。早期 BASIC 没有局部变量所有变量都是全局的子程序之间靠约定来避免冲突这也是为什么老代码里变量命名都特别小心。3.8 程序八字符画——循环与字符输出的组合字符画练的是循环控制和字符输出的精确配合。画一个简单的菱形10 N 5 20 FOR I 1 TO N 30 PRINT TAB(N - I 1); 40 FOR J 1 TO 2 * I - 1 50 PRINT *; 60 NEXT J 70 PRINT 80 NEXT I 90 FOR I N - 1 TO 1 STEP -1 100 PRINT TAB(N - I 1); 110 FOR J 1 TO 2 * I - 1 120 PRINT *; 130 NEXT J 140 PRINT 150 NEXT I 160 ENDTAB函数负责水平定位参数是列号。这里有个坑TAB的行为在不同实现里不完全一致有的从 1 开始计数有的从 0 开始还有的在目标列已经过了当前列时会换行。我建议先用小例子测一下TAB的实际行为再写正式代码。分号的使用也很关键PRINT TAB(...)后面跟分号才能让后续输出接在同一行否则会换行。这个程序让我体会到早期编程的“手工感”——没有图形库所有视觉效果都靠字符排列算出来。每一行的星号数量是2 * I - 1前面的空格数是N - I这些公式得自己在纸上推一遍推错了画出来就是歪的。调试时我习惯先把*换成数字看清楚每行到底输出了几个字符再换回星号。3.9 程序九简易记账——并行数组与数据组织记账程序练的是多个并行数组的组织和增删改查逻辑。用两个数组分别存日期和金额支持录入和汇总10 DIM D$(50), M(50) 20 C 0 30 PRINT 1 ADD 2 TOTAL 3 LIST 4 QUIT 40 INPUT CMD; K 50 IF K 4 THEN END 60 IF K 1 THEN GOSUB 200: GOTO 30 70 IF K 2 THEN GOSUB 300: GOTO 30 80 IF K 3 THEN GOSUB 400: GOTO 30 90 GOTO 30 200 C C 1 210 INPUT DATE; D$(C) 220 INPUT AMT; M(C) 230 RETURN 300 T 0 310 FOR I 1 TO C: T T M(I): NEXT I 320 PRINT TOTAL; T 330 RETURN 400 FOR I 1 TO C: PRINT D$(I); M(I): NEXT I 410 RETURN并行数组是早期 BASIC 里组织结构化数据的标准做法——没有结构体就用多个数组、相同下标对应同一条记录。这里的关键是计数器C的管理录入时自增遍历时以它为边界。数组大小 50 是硬上限录入超过就会越界所以正式代码里应该在录入前判断C是否已达上限。字符串数组D$的每个元素都指向一个字符串字符串本身在内存顶端分配。这意味着大量录入时字符串区的消耗比数值数组快得多。我实测过录几十条短日期没问题但如果日期字段写得很长内存会明显吃紧。经验是字符串字段尽量精简能用编码表示的比如日期用数字就别用长字符串。3.10 程序十状态机小模拟——综合运用最后一个程序把前面练的东西综合起来做一个简单的状态机模拟一个角色在网格里移动根据指令改变位置碰到边界就停。这个程序练的是状态表示、指令解析和边界判断的综合运用10 X 5: Y 5 20 INPUT MOVE (U/D/L/R/Q); A$ 30 IF A$ Q THEN PRINT AT; X; Y: END 40 IF A$ U AND Y 1 THEN Y Y - 1 50 IF A$ D AND Y 10 THEN Y Y 1 60 IF A$ L AND X 1 THEN X X - 1 70 IF A$ R AND X 10 THEN X X 1 80 PRINT POS; X; Y 90 GOTO 20状态就是两个变量X和Y指令是单字符边界判断用AND组合条件。这里有个细节AND在这套 BASIC 里是逻辑运算两边都是比较表达式时返回真或假短路求值的行为取决于实现所以我把边界判断放在同一个条件里避免依赖短路。字符串比较A$ U是大小写敏感的用户敲小写就匹配不上稳妥的做法是先转大写再比较。这个程序最接近“真实项目”的形态——有状态、有输入、有规则、有边界。把它跑通之后你会发现前面九个程序练的每一项能力都在这里汇合了表达式求值、循环、条件、子程序、数组、字符串、内存意识。我建议把这个程序当作一个模板往里面加功能比如加障碍物、加计分、加多角色每加一项都是一次综合练习。4. 常见问题与排查技巧实录4.1 报错信息含糊时怎么定位这套 BASIC 的报错信息普遍很简短经常只给一个错误类型加行号不告诉你具体原因。我的排查套路是三步走第一步在可疑行前后各插一条PRINT打标记确认程序到底执行到哪第二步用FRE(0)看内存余量排除内存耗尽第三步在模拟器监视器里看零页和栈指针确认没有越界。这三步能覆盖大部分“莫名其妙”的报错。举个真实例子有次程序运行到一半报OUT OF MEMORY但代码里根本没有大数组。用FRE(0)一测发现可用内存确实在持续减少。最后定位到是一个循环里反复拼接字符串垃圾回收跟不上分配速度。改成预分配加MID$赋值后问题消失。这个经历让我养成了一个习惯凡是循环里涉及字符串拼接的先想想有没有更省内存的写法。4.2 数值精度与比较的陷阱浮点表示带来的精度问题在早期 BASIC 里很常见。最典型的坑是用比较两个浮点数理论上相等的值可能因为舍入误差判为不等。稳妥的做法是比较差值是否小于一个很小的阈值。另一个坑是INT对负数的处理INT(-1.5)得到的是 -2 而不是 -1因为它是向下取整。如果你想要向零取整得用SGN配合处理。问题现象可能原因排查方法解决思路循环次数不对浮点步长累积误差打印循环变量实际值改用整数步长或加容差判断比较结果反直觉浮点舍入打印两数差值用阈值比较代替等号取整方向错INT向下取整测试负数输入用SGN配合修正大数运算失真有效位数有限打印中间结果拆分计算或接受精度损失4.3 内存耗尽的预防与应急内存问题分两类一类是静态的声明了过大的数组或字符串启动就报错另一类是动态的运行过程中字符串垃圾累积跑到一半才报错。静态问题好办声明前算一下占用和FRE(0)对比。动态问题需要养成习惯循环里少做字符串拼接多用预分配大数组用完及时用DIM重新声明小尺寸来释放有些实现支持有些不支持要实测。应急手段是在关键位置插入FRE(0)打印观察内存曲线。如果发现持续下降就顺着下降的位置往回找分配点。我一般会在主循环的末尾打一次跑几十轮看趋势趋势平稳就放心持续下降就重点查循环体里的字符串操作。4.4 输入健壮性的加固套路裸用INPUT的程序用户一敲错就崩体验很差。加固套路是写一个通用的输入子程序逐字符读取过滤非法字符遇到回车结束再转成数值或保留字符串。虽然代码多了几十行但换来的是程序不会因为一个手滑就退出。这个子程序我一般放在程序开头后面所有输入都走它复用性很好。提示输入子程序里要处理好退格键和回车键的边界情况退格时缓冲区指针要正确回退回车时要在缓冲区末尾补上结束标记否则后续的字符串处理会读到脏数据。5. 从这十个程序里能带走什么写到这里十个程序都过了一遍。如果非要挑一个我觉得最值钱的收获那就是“对内存的敬畏”。现代编程里内存几乎是无限的你很少需要想一个字符串占多少字节、一个数组会不会把程序区挤爆。但在这套环境里每一字节都要算每一次分配都要想后果。这种约束反而逼着我把数据结构的本质看清楚了——数组就是连续内存字符串就是字符序列加长度子程序调用就是压栈弹栈。这些概念在高级语言里被封装得很好好到你忘了它们的存在而在这里它们赤裸裸地摆在你面前。另一个收获是调试的耐心。报错信息含糊工具简陋你只能靠打标记、看内存、推逻辑来定位问题。这个过程慢但慢有慢的好处——每解决一个问题你对整个系统的理解就深一层。我到现在还记得第一次把栈溢出定位出来时的感觉那种“原来如此”的瞬间比程序跑通本身更让人上瘾。如果你打算动手试我的建议是从程序一开始别跳。每个程序都亲手敲进去故意改错几个地方看看报错长什么样再改回来。这种“故意犯错”的练习比照着抄一遍有用得多。等你把十个程序都跑通、都改坏过、都修好过这套环境的基本脾气你就摸透了。后面再想写点自己的东西心里就有底了。