ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Microsoft BASIC on M6502:十个经典程序实战与内存模型解析

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 END

RND(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 END

DIM 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: RETURN

ON ... 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 END

TAB函数负责水平定位,参数是列号。这里有个坑: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. 从这十个程序里能带走什么

写到这里,十个程序都过了一遍。如果非要挑一个我觉得最值钱的收获,那就是“对内存的敬畏”。现代编程里,内存几乎是无限的,你很少需要想一个字符串占多少字节、一个数组会不会把程序区挤爆。但在这套环境里,每一字节都要算,每一次分配都要想后果。这种约束反而逼着我把数据结构的本质看清楚了——数组就是连续内存,字符串就是字符序列加长度,子程序调用就是压栈弹栈。这些概念在高级语言里被封装得很好,好到你忘了它们的存在,而在这里,它们赤裸裸地摆在你面前。

另一个收获是调试的耐心。报错信息含糊,工具简陋,你只能靠打标记、看内存、推逻辑来定位问题。这个过程慢,但慢有慢的好处——每解决一个问题,你对整个系统的理解就深一层。我到现在还记得第一次把栈溢出定位出来时的感觉,那种“原来如此”的瞬间,比程序跑通本身更让人上瘾。

如果你打算动手试,我的建议是从程序一开始,别跳。每个程序都亲手敲进去,故意改错几个地方,看看报错长什么样,再改回来。这种“故意犯错”的练习比照着抄一遍有用得多。等你把十个程序都跑通、都改坏过、都修好过,这套环境的基本脾气你就摸透了。后面再想写点自己的东西,心里就有底了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进