by Nick Morgan, licensed under CC BY 4.0
在这个网页中,我(原作者)将解析如何开始编写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
的值.
$05ff
).上面我们简单说明了寄存器
A
的操作, 那么其余的像X,Y到PC
等.),又表示什么??
其中第一排的A
, X
和 Y
寄存器中 (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.
TAX
. 那么指令TAY
是否也是这样呢, TXA
和 TYA
是否相同 ,编写代码进行测试.Y
代替寄存器X
.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指令.
BNE
相反的是 BEQ
. 试着编写BEQ
相关程序.BCC
和 BCS
(“branch on carry clear” and “branch on carry set”) 是进位清楚和进位设置分支.试着编写相关程序6502使用16bit地址总线,意味着处理器可以访问到 65536(2的16次方) bytes的内存.通常使用十六进制 $0000 -
$ffff
表示. 下面介绍内存寻址的方式.
可以使用内存监控器观察内存.其中的设置值都是十六进制表示 ,比如观察 $c000
, enter c000
开始的 10
个十六进制.
$c000
使用绝对地址时,所有的内存地址都可以作为参数使用. 比如:
STA $c000 ;存储寄存器A的值到$c000内存处
$c0
所有地址都支持绝对地址的指令(包括jump指令)都可以接受一个单字节地址参数, 这种地址称为零页地址缩写.仅仅用来访问开始的256bytes内存 ..
$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
$c0,Y
等价于上面的,不过只能用来寄存器X的操作 LDX
和STX
.
$c000,X
,$c000,Y
是分页的绝对地址版本.比如:
LDX #$01
STA $0200,X ;Store the value of A at memory location $0201 存储到$0201
#$c0
立即值不涉及内存操作,只是操作具体数值. 比如, LDX #$01
加载数值
$01
到寄存器 X
而LDX $01
加载内存地址$01中的值到寄存器 $01
into the
X
.
$c0
(or label)相对寻址通常在分支指令中使用,接受一个单byte地址.
编译并打印 Hexdump查看汇编代码.
十六进制应该是这样的结果:
a9 01 c9 02 d0 02 85 22 00
a9
and c9
表示操作码 LDA
和 CMP
. 01
和 02
是指令的参数. d0
操作码 BNE
, 对应的参数是 02
.以为跳过下面一条指令指令码+参数(85 22
,对应源代码 STA $22
). 试着修改代码 STA
接受一个2byte的绝对地址,(比如将 STA $22
修改为STA $2222
). 反编译查看 BNE
将不是 03
.
一些指令不操作内存地址,比如 (e.g. INX
- 递增寄存器
X
). 表示操作指定的隐式地址.
($c000)
代码如下:
上面的例子中, $f0
存储的值 $01
和 $f1
存储的值
$cc
. 指令JMP ($f0)
接受$f0
和 $f1
(中的$01
和 $cc
) 然后组成一个绝对地址 $cc01
, 试着单步调试 JMP
指令,后面的 Jumping会再次说明.
($c0,X)
寄存器间接寻址, 根据$c0和寄存器 X
的值组成的绝对地址寻址,比如:
A->0102(0705).X->01(0705)
($c0),Y
更为负责的寻址方式
.注意字节序
用来进行存储后进先出的值,包含push,pull(pop)2个操作.栈的当前深度使用栈指针寄存器保存.栈保存在内存的 $0100
到 $01ff
.栈指针初始化为$ff
, 指向内存的$01ff
.等压入byte值时则变为 $fe
, 也就是 $01fe
.
有2个栈操作指令 PHA
and PLA
, “push accumulator” and “pull
accumulator”. 压入累加器值到stack.弹窗stack顶部值到累加器
寄存器X
保存像素颜色,寄存器 Y
保存像素位置.绘制像素.
与分支指令功能相似,有2点不同。首先, jumps是无条件执行的, 还有,接受的是two-byte绝对地址. 小的代码程序中不需要操心后者.大型程序中jump是唯一的远距离跳转方式.
JMP
无条件跳转,比如:
JSR
和 RTS
(“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 #
.
下面的实例包含了所有代码.
除了注释和声明后,前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继续执行.
零页用来存储游戏状态.
init
包含2个子程序, initSnake
和
generateApplePosition
. 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)
指令 BIT
与AND
相似, 只用来设置标志位,忽略计算结果.例如, 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.
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
.