Easy 6502

by Nick Morgan, licensed under CC BY 4.0

from gitee

介绍

在这个网页中,我(原作者)将解析如何开始编写6502汇编语言. 6502处理器在70到80年代广泛应用到各种知名电脑中 BBC Micro, Atari 2600, Commodore 64, Apple II, 还有 Nintendo Entertainment System(NES任天堂游戏机). Bender in Futurama (飞出个未来) 中有个使用6502模拟的大脑. Even the Terminator(终结者游戏)也是用 6502实现的.

那么,为什么要学习6502,这个已经快要失传的语言。除了 Q.E.D.还在开设教学6520

(事实上,6502还在生成中Western Design Center and 出售给业余爱好者,那么可以说6502 并未失传! 谁又能说清楚这其中的缘由?)

严肃来讲,我(原作者)能够理解汇编语言价值非凡.汇编语言是计算机描述中最低级的可阅读的代码.汇编语言直接翻译为计算机处理器可识别的字节码.如果能够掌握汇编,那么您将被称为 操控计算机的高级巫师.

那么为什么是6502汇编?而不是一门常用的汇编语言,比如 x86? 好吧,我觉得学些x86并不是那么有用. 现在的工作中很少使用x86汇编,通常只是个人的教学练习,或者是闲暇时组织的思维.然后6502是在那个时代真正被广泛应有的语言,不像现在的汇编是高级语言的产物.所有6502汇编是设计为人类编写友好的汇编语言。所以还是值得学些.

第一个程序

那么,让我开始吧。下面是一个转为本网页设计的js实现的的6502汇编器与模拟器. 首先点击汇编编译,然后点击 运行,将会编译和执行其中的汇编代码.

一切顺利的话,右边的黑色区域的前三个像素(如果没有看到效果,请确认使用谷歌浏览器或者火狐刘拉去.)

让我们通过debugger单步调试看看其中发生的事情. 首先点击 重置,然后勾选上调试Debugger开始调试. 点击一下Step(单步) . 如果仔细看的话 A$00 变为了 $01, and PC=$0600 变为了 $0602.

6502中以 $开始的数字表示为16进制(hex)格式.

6502中以#开头的十六进制是一个数字字面量

其他的十六进制数字都表示内存的地址.

那么让我根据上面所说的分析下汇编代码 LDA #$01 加载十六进制$01到寄存器A. 寄存器在下面的小节介绍.

再次点击Step 执行第二个指令.显示模拟器的第一个像素变为了白色.

显示器模拟器使用内存的$0200$05ff保存对应的像素值. 十六进制 $00$0f代表16种颜色 ($00 是黑色,$01 是白色), 那么第二条指令STA 将寄存器A的值 storing the value $01 存储到内存$0200表示在第一个像素绘制白色.不要惊讶,这是电脑显示的真实原理,即使现在也是这样。

那么第二条指令 STA $0200存储寄存器 A 的值到内存中$0200. 接下来点击4次Step观察寄存器A的值.

练习

  1. 试着修改前3个像素的颜色.
  2. 试着修改内存中的最后一个像素的颜色 (内存地址是) $05ff).
  3. 试着绘制更多的像素.

寄存器与标志位

上面我们简单说明了寄存器 A的操作, 那么其余的像X,Y到PC等.),又表示什么??

其中第一排的A, XY 寄存器中 (A 称为累加器(accumulator).每个寄存器保存一个字节(byte),也就是8位二进制.大多数操作是操作A,X,Y3个寄存器.

SP表示栈(stack)指针,栈将在下面进行说明,现在只需要知道SP每次都以字节的大小增减,.

PC程序计数器,表示程序执行的指令位置. 可以看做汇编脚本的行数,js模拟器中代码从内存的$0600开始,所以默认 PC 开始值就是$0600.

最后的部分就是CPU的标志位.每个标志位(flag)使用一个二进制(0或者1),使用一个字节(8个二进制)保存了所有的标志位(flag) . 标志位根据前一条指令的执行结果进行赋值.点击链接 了解更多的寄存器与标志位.

指令集

指令集可以看做汇编语言中的预定义函数.所有的指令可以接受0个或者1个参数, 下面的代码使用了一些部分指令:

编译上面的代码,然后开始单步调试,注意观察寄存器 A和寄存器X .在ADC #$c4指令中出现了一些不一样的事情.正常情况下$c4 + $c0 应该得到 $184, 然而cpu只是将寄存器A设置为 $84. 其中发生了什么事情?

原来是因为, $184已经超出了一个寄存器能够容纳的最大的值$FF),寄存器只保存了小于$FF的内容. cpu并没有计算错误.如果仔细观察可以发现进位标志位(carry flag)被设置为1 . 这就是加法中进位标志位的用法.

复制下面的代码:


LDA #$80
STA $01
ADC $01

注意比较ADC #$01 and ADC $01的区别.前一个将值 $01累加到寄存器 A , 后一个将内存中 $01 累加到寄存器A .

汇编编译,然后打开监控器Monitor , 单步调试指令.监控器用来观察指令运行时内存中的值. STA $01 将寄存器A的值转存到内存中$01, 然后 ADC $01 将内存中的值$01 累加到寄存器 A . $80 + $80应该是 $100,超过最大值进位, 寄存器 A 设置为 $00 进位标志位置1. 另外零标志位(zero flag)也置1.

6502的所有指令 在这里 列出and 和这里.

练习

  1. 看到上面的TAX. 那么指令TAY是否也是这样呢, TXATYA是否相同 ,编写代码进行测试.
  2. 重新代码使用寄存器Y代替寄存器X .
  3. 与ADC累加相反的是 ADC is SBC 借位减.试着编写代码.

分支

前面的代码都是按照顺序执行的,下面说明分支执行.

6502汇编包含多个分支指令,都是基于标志位的状态进行判断,实例中以BNE(Branch on not equal)进行说明.

首先我们加载 $08 到寄存器 X . 接着是一个label声明.用来作为跳转标记.接着是自增寄存器X, 然后存储到内存中 $0200 (the top-left pixel),并与 $03进行比较. CPX 指令比较寄存器 X 与另一个值.如果相等标志位 Z设置为 1, 否则设置为00.

接着的, BNE decrement,会根据标志位 Z 是否为 0进行跳转.也就是直到指令CPX得到相等的结果为1,然后将寄存器 X 存储到 $0201, 最后结束.

在汇编语言中,经常使用label作为分支跳转标记.代码编译后label将转换为相对地址,因此分支指令可以前进或者后退,然后只能在256bytes使用branch分支指令,如果需要跳转更远的就需要jump指令.

练习

  1. BNE 相反的是 BEQ. 试着编写BEQ相关程序.
  2. BCCBCS (“branch on carry clear” and “branch on carry set”) 是进位清楚和进位设置分支.试着编写相关程序

寻址模式Addressing modes

6502使用16bit地址总线,意味着处理器可以访问到 65536(2的16次方) bytes的内存.通常使用十六进制 $0000 - $ffff表示. 下面介绍内存寻址的方式.

可以使用内存监控器观察内存.其中的设置值都是十六进制表示 ,比如观察 $c000, enter c000 开始的 10 个十六进制.

绝对地址: $c000

使用绝对地址时,所有的内存地址都可以作为参数使用. 比如:

STA $c000 ;存储寄存器A的值到$c000内存处

零页定位: $c0

所有地址都支持绝对地址的指令(包括jump指令)都可以接受一个单字节地址参数, 这种地址称为零页地址缩写.仅仅用来访问开始的256bytes内存 ..

零页,X: $c0,X

从零页偏移寄存器 X 的内存地址.比如:


LDX #$01   ;X is $01 寄存器x设置为01
LDA #$aa   ;A is $aa 
STA $a0,X ;Store the value of A at memory location $a1 存储到$a1
INX        ;Increment X
STA $a0,X ;Store the value of A at memory location $a2 存储到$a2

如果超过单个byte,则会返回从头开始.比如:


LDX #$05 
STA $ff,X ;Store the value of A at memory location $04 存储到$04

零页,Y: $c0,Y

等价于上面的,不过只能用来寄存器X的操作 LDXSTX.

绝对地址,X 和 绝对地址,Y: $c000,X,$c000,Y

是分页的绝对地址版本.比如:


LDX #$01
STA $0200,X ;Store the value of A at memory location $0201 存储到$0201

立即寻址: #$c0

立即值不涉及内存操作,只是操作具体数值. 比如, LDX #$01 加载数值 $01 到寄存器 XLDX $01加载内存地址$01中的值到寄存器 $01 into the X .

相对寻址: $c0 (or label)

相对寻址通常在分支指令中使用,接受一个单byte地址.

编译并打印 Hexdump查看汇编代码.

十六进制应该是这样的结果:

a9 01 c9 02 d0 02 85 22 00

a9 and c9 表示操作码 LDACMP . 0102 是指令的参数. d0 操作码 BNE, 对应的参数是 02.以为跳过下面一条指令指令码+参数(85 22,对应源代码 STA $22). 试着修改代码 STA接受一个2byte的绝对地址,(比如将 STA $22 修改为STA $2222). 反编译查看 BNE将不是 03.

Implicit

一些指令不操作内存地址,比如 (e.g. INX - 递增寄存器 X ). 表示操作指定的隐式地址.

Indirect(间接寻址): ($c000)

代码如下:

上面的例子中, $f0 存储的值 $01$f1 存储的值 $cc. 指令JMP ($f0) 接受$f0$f1 (中的$01$cc) 然后组成一个绝对地址 $cc01, 试着单步调试 JMP指令,后面的 Jumping会再次说明.

索引间接寻址Indexed indirect: ($c0,X)

寄存器间接寻址, 根据$c0和寄存器 X 的值组成的绝对地址寻址,比如:

A->0102(0705).X->01(0705)

间接索引Indirect indexed: ($c0),Y

更为负责的寻址方式

.注意字节序

练习

  1. 试着编写寻址模式的代码.

调用栈

用来进行存储后进先出的值,包含push,pull(pop)2个操作.栈的当前深度使用栈指针寄存器保存.栈保存在内存的 $0100$01ff.栈指针初始化为$ff, 指向内存的$01ff.等压入byte值时则变为 $fe, 也就是 $01fe.

有2个栈操作指令 PHA and PLA, “push accumulator” and “pull accumulator”. 压入累加器值到stack.弹窗stack顶部值到累加器

寄存器X 保存像素颜色,寄存器 Y 保存像素位置.绘制像素.

跳转Jumping

与分支指令功能相似,有2点不同。首先, jumps是无条件执行的, 还有,接受的是two-byte绝对地址. 小的代码程序中不需要操心后者.大型程序中jump是唯一的远距离跳转方式.

JMP

JMP 无条件跳转,比如:

JSR/RTS

JSRRTS (“jump to subroutine” and “return from subroutine”) 经常一起使用. JSR 用来从当前位置跳转到另一个位置. RTS 返回到跳转前的位置.类似于函数调用与返回.

实现机制是,指令 JSR将下一条指令的地址压入栈,然后跳转到目标. RTS则弹出栈中的位置,跳转到对应指令 An example:

创建一个简单游戏

最后使用以上的功能实现一个贪吃蛇游戏.

首先定义常量constants代码对应数字,在后面代码中使用.

Here’s an example. Note that immediate operands are still prefixed with a #.

下面的实例包含了所有代码.

Overall structure

除了注释和声明后,前2条指令:

jsr init
jsr loop

init and loop 是所2个核心内容. init 初始化游戏状态, and loop是游戏循环.

loop 调用中,只是调用其他的子程序:

loop:
  jsr readkeys
  jsr checkCollision
  jsr updateSnake
  jsr drawApple
  jsr drawSnake
  jsr spinwheels
  jmp loop

首先, readkeys 检查是否有 (W, A, S, D) was 按下, 如果有则修改前进放些. 然后, checkCollision检查碰撞. updateSnake 更新蛇的形状,接着,绘制apple和蛇. , spinWheels可以看做sleep.返回loop继续执行.

Zero page usage

零页用来存储游戏状态.

初始化Initialization

init 包含2个子程序, initSnakegenerateApplePosition. initSnake 初始化蛇的方向 长度 然后加载内存中的头部和身体y. $10 保存了头的位置 $12 保存了身体开始部位的信息, and $14 保存了尾部:


lda #$11
sta $10
lda #$10
sta $12
lda #$0f
sta $14
lda #$04
sta $11
sta $13
sta $15

经过上面的初始化. 内存结构如下:

0010: 11 04 10 04 0f 04

表示了显示的3个地址信息 $0411, $0410$040f 这里使用了间接寻址.

generateApplePosition, 初始化为随机位置.首先加上随机数到累加器 ($fe 会产生随机数). 然后存储到内存$00. 接着再次加载一个随机数,然后AND $03.进行位运算.

十六进制 $03写成二进制 00000011. AND 操作码表示参数与累加器A进行AND位运算. 比如,累加器保存的是 10101010,与 AND with 00000011 AND运算后就是 00000010.

将0到255的数转换到0到3.

然后,数字 2 添加到累加器A,则产生了一个2到5的随机数.

这个子程序最终产生一个随机数到 $00, 和一个2到5的随机申诉到$01. 也就是 $0200$05ff: 内存的一个随机位置,表示模拟显示的位置.

游戏循环中

与大多数游戏的循环相似,接受用户输入,更新游戏状态,渲染游戏状态.

渲染输入

readKeys, 接受用户的输入, $ff模拟保存了最后一个键盘按键信息.被加载到累加器A,然后与 $77 (the hex code for W), $64 (D), $73 (S) 和 $61 (A)比较. 如果相等执行修改方向的逻辑(upKey, rightKey, etc.) 用来检查是否反向操.

方向使用 1, 2, 4 和 8. 其二进制如下 1:

1 => 0001 (up)
2 => 0010 (right)
4 => 0100 (down)
8 => 1000 (left)

指令 BITAND相似, 只用来设置标志位,忽略计算结果.例如, 0001 AND 0001不是0, but 0001 AND 0010是0.

So, looking at upKey, 如果当前方向是 down (4), 检查零位标志位. BNE 就是 “branch if the zero flag is clear”,因此跳转到分支illegalMove.否则设置新的方向.

更新游戏状态

checkCollision中, 分为 checkAppleCollision and checkSnakeCollision. checkAppleCollision 只是检查是否与头部碰撞,如果碰撞则随机产生新的,.

checkSnakeCollision 检查蛇的身体与头的碰撞.

  
0    1    2    3    4
Head                 Tail

[1,5][1,4][1,3][1,2][2,2]    Starting position

[1,5][1,4][1,3][1,2][1,2]    Value of (3) is copied into (4)

[1,5][1,4][1,3][1,3][1,2]    Value of (2) is copied into (3)

[1,5][1,4][1,4][1,3][1,2]    Value of (1) is copied into (2)

[1,5][1,5][1,4][1,3][1,2]    Value of (0) is copied into (1)

[0,5][1,5][1,4][1,3][1,2]    Value of (0) is updated based on direction

At a low level, this subroutine is slightly more complex. First, the length is loaded into the X register, which is then decremented. The snippet below shows the starting memory for the snake.

Memory location: $10 $11 $12 $13 $14 $15

Value:           $11 $04 $10 $04 $0f $04

The length is initialized to 4, so X starts off as 3. LDA $10,x loads the value of $13 into A, then STA $12,x stores this value into $15. X is decremented, and we loop. Now X is 2, so we load $12 and store it into $14. This loops while X is positive (BPL means “branch if positive”).

Once the values have been shifted down the snake, we have to work out what to do with the head. The direction is first loaded into A. LSR means “logical shift right”, or “shift all the bits one position to the right”. The least significant bit is shifted into the carry flag, so if the accumulator is 1, after LSR it is 0, with the carry flag set.

To test whether the direction is 1, 2, 4 or 8, the code continually shifts right until the carry is set. One LSR means “up”, two means “right”, and so on.

The next bit updates the head of the snake depending on the direction. This is probably the most complicated part of the code, and it’s all reliant on how memory locations map to the screen, so let’s look at that in more detail.

You can think of the screen as four horizontal strips of 32 × 8 pixels. These strips map to $0200-$02ff, $0300-$03ff, $0400-$04ff and $0500-$05ff. The first rows of pixels are $0200-$021f, $0220-$023f, $0240-$025f, etc.

As long as you’re moving within one of these horizontal strips, things are simple. For example, to move right, just increment the least significant byte (e.g. $0200 becomes $0201). To go down, add $20 (e.g. $0200 becomes $0220). Left and up are the reverse.

Going between sections is more complicated, as we have to take into account the most significant byte as well. For example, going down from $02e1 should lead to $0301. Luckily, this is fairly easy to accomplish. Adding $20 to $e1 results in $01 and sets the carry bit. If the carry bit was set, we know we also need to increment the most significant byte.

After a move in each direction, we also need to check to see if the head would become out of bounds. This is handled differently for each direction. For left and right, we can check to see if the head has effectively “wrapped around”. Going right from $021f by incrementing the least significant byte would lead to $0220, but this is actually jumping from the last pixel of the first row to the first pixel of the second row. So, every time we move right, we need to check if the new least significant byte is a multiple of $20. This is done using a bit check against the mask $1f. Hopefully the illustration below will show you how masking out the lowest 5 bits reveals whether a number is a multiple of $20 or not.

$20: 0010 0000
$40: 0100 0000
$60: 0110 0000

$1f: 0001 1111

I won’t explain in depth how each of the directions work, but the above explanation should give you enough to work it out with a bit of study.

Rendering the game

Because the game state is stored in terms of pixel locations, rendering the game is very straightforward. The first subroutine, drawApple, is extremely simple. It sets Y to zero, loads a random colour into the accumulator, then stores this value into ($00),y. $00 is where the location of the apple is stored, so ($00),y dereferences to this memory location. Read the “Indirect indexed” section in Addressing modes for more details.

Next comes drawSnake. This is pretty simple too - we first undraw the tail and then draw the head. X is set to the length of the snake, so we can index to the right pixel, and we set A to zero then perform the write using the indexed indirect addressing mode. Then we reload X to index to the head, set A to one and store it at ($10,x). $10 stores the two-byte location of the head, so this draws a white pixel at the current head position. As only the head and the tail of the snake move, this is enough to keep the snake moving.

The last subroutine, spinWheels, is just there because the game would run too fast otherwise. All spinWheels does is count X down from zero until it hits zero again. The first dex wraps, making X #$ff.