肮脏的汇编程序黑客6502

本文列出了参加我的小型Commodore 64编程竞赛的参与者的一些技巧。 竞赛的规则很简单:创建一个可执行文件C64(PRG),该文件绘制两行以形成下面的图像。 文件较小的那个赢得了。


比赛参赛作品以公开鸣叫和私人消息发布,其中仅包含PRG文件的字节和MD5哈希。

与源代码链接的参与者列表:


(如果我想念某人,请让我知道,我将更新列表)。

本文的其余部分专门介绍了在比赛中使用的一些组装技巧。

基础知识


图形C64默认情况下在40x25字符编码模式下工作。 RAM中的帧缓冲区分为两个数组:

  • $0400 (屏幕RAM,40x25字节)
  • $d800 (彩色RAM,40x25字节)

若要设置一个字符,请将字节保存到屏幕RAM中,为$0400 (例如, $0400+y*40+x )。 默认情况下,颜色RAM由浅蓝色初始化(颜色14):这是我们用于线条的颜色,也就是说,颜色RAM可以保持不接触的状态。

您可以使用$d020 (边框)和$d021 (背景)中的内存I / O寄存器控制边框和背景的颜色。

如果直接编程固定线的斜率,则绘制两条线非常容易。 这是一个C实现,它绘制线条并将屏幕内容刷新到stdout( malloc()用于使代码在PC上工作):

 #include <stdint.h> #include <stdio.h> #include <stdlib.h> void dump(const uint8_t* screen) { const uint8_t* s = screen; for (int y = 0; y < 25; y++) { for (int x = 0; x < 40; x++, s++) { printf("%c", *s == 0xa0 ? '#' : '.'); } printf("\n"); } } void setreg(uintptr_t dst, uint8_t v) { // *((uint8_t *)dst) = v; } int main() { // uint8_t* screenRAM = (uint_8*)0x0400; uint8_t* screenRAM = (uint8_t *)calloc(40*25, 0x20); setreg(0xd020, 0); // Set border color setreg(0xd021, 0); // Set background color int yslope = (25<<8)/40; int yf = yslope/2; for (int x = 0; x < 40; x++) { int yi = yf >> 8; // First line screenRAM[x + yi*40] = 0xa0; // Second line (X-mirrored) screenRAM[(39-x) + yi*40] = 0xa0; yf += yslope; } dump(screenRAM); } 

上面的屏幕代码是$20 (空白)和$a0 (填充8×8块)。 如果运行,将看到两行的ASCII图片:

  ## .................................... ##
 ..#..................................#..
 ... ## .............................. ## ...
 .....#............................#.....
 ...... ## ........................ ## ......
 ........ ## .................... ## ........
 ..........#..................##.......
 ........... ## .............. ## ...........
 .............#............#.............
 .............. ## ........ ## ..............
 ................ ## .... ## ................
 ..................#..#..................
 ................... ## ...................
 ..................#..#..................
 ................ ## .... ## ................
 .............. ## ........ ## ..............
 .............#............#.............
 ........... ## .............. ## ...........
 ..........#..................##.......
 ........ ## .................... ## ........
 ...... ## ........................ ## ......
 .....#............................#.....
 ... ## .............................. ## ...
 ..#..................................#..
 ## .................................... ## 

这在汇编器中很容易实现:

 !include "c64.asm" +c64::basic_start(entry) entry: { lda #0 ; black color sta $d020 ; set border to 0 sta $d021 ; set background to 0 ; clear the screen ldx #0 lda #$20 clrscr: !for i in [0, $100, $200, $300] { sta $0400 + i, x } inx bne clrscr ; line drawing, completely unrolled ; with assembly pseudos lda #$a0 !for i in range(40) { !let y0 = Math.floor(25/40*(i+0.5)) sta $0400 + y0*40 + i sta $0400 + (24-y0)*40 + i } inf: jmp inf ; halt } 

事实证明PRG相当大,只有286个字节。

在进行优化之前,我们进行一些观察。

首先,我们使用适当的ROM例程来处理C64。 有很多有用的例程。 例如,使用JSR $E544清除屏幕。

其次,在8位处理器(例如6502)上进行地址计算可能很麻烦,并且占用大量字节。 该处理器也没有乘法器,因此像y*40+i这样的计算通常包括一堆逻辑移位或一个也占用字节的查找表。 为避免乘以40,最好逐步增加屏幕光标:

  int yslope = (25<<8)/40; int yf = yslope/2; uint8_t* dst = screenRAM; for (int x = 0; x < 40; x++) { dst[x] = 0xa0; dst[(39-x)] = 0xa0; yf += yslope; if (yf & 256) { // Carry set? dst += 40; yf &= 255; } } 

我们继续将直线的斜率加到固定计数器yf ,当8位加法设置进位标志时,加40。

这是一种增量汇编程序方法:

 !include "c64.asm" +c64::basic_start(entry) !let screenptr = $20 !let x0 = $40 !let x1 = $41 !let yf = $60 entry: { lda #0 sta x0 sta $d020 sta $d021 ; kernal clear screen jsr $e544 ; set screenptr = $0400 lda #<$0400 sta screenptr+0 lda #>$0400 sta screenptr+1 lda #80 sta yf lda #39 sta x1 xloop: lda #$a0 ldy x0 ; screenRAM[x] = 0xA0 sta (screenptr), y ldy x1 ; screenRAM[39-x] = 0xA0 sta (screenptr), y clc lda #160 ; line slope adc yf sta yf bcc no_add ; advance screen ptr by 40 clc lda screenptr adc #40 sta screenptr lda screenptr+1 adc #0 sta screenptr+1 no_add: inc x0 dec x1 bpl xloop inf: jmp inf } 

它有82个字节,仍然很大。 一个明显的问题是16位地址计算。 设置用于间接screenptrscreenptr值:

  ; set screenptr = $0400 lda #<$0400 sta screenptr+0 lda #>$0400 sta screenptr+1 

我们通过添加40将screenptr转换为下一行:

  ; advance screen ptr by 40 clc lda screenptr adc #40 sta screenptr lda screenptr+1 adc #0 sta screenptr+1 

当然,可以优化此代码,但是如果您完全摆脱了16位地址该怎么办? 让我们来看看如何做。

把戏1.滚动!


我们没有在屏幕上的RAM中建立一行,而是仅在最后一条屏幕上绘制Y = 24,然后向上滚动整个屏幕,并使用JSR $E8EA调用ROM滚动功能!

这是优化xloop的方法:

  lda #0 sta x0 lda #39 sta x1 xloop: lda #$a0 ldx x0 ; hardcoded absolute address to last screen line sta $0400 + 24*40, x ldx x1 sta $0400 + 24*40, x adc yf sta yf bcc no_scroll ; scroll screen up! jsr $e8ea no_scroll: inc x0 dec x1 bpl xloop 

这是渲染的外观:



这是该程序中我最喜欢的技巧之一。 几乎所有参赛者都是自己找到它的。

技巧2。自修改代码


存储像素值的代码如下所示:

  ldx x1 ; hardcoded absolute address to last screen line sta $0400 + 24*40, x ldx x0 sta $0400 + 24*40, x inc x0 dec x1 

它按以下14个字节的顺序编码:

 0803: A6 22 LDX $22 0805: 9D C0 07 STA $07C0,X 0808: A6 20 LDX $20 080A: 9D C0 07 STA $07C0,X 080D: E6 22 INC $22 080F: C6 20 DEC $20 

使用自修改代码(SMC),可以更紧凑地编写此代码:

  ldx x1 sta $0400 + 24*40, x addr0: sta $0400 + 24*40 ; advance the second x-coord with SMC inc addr0+1 dec x1 

...以13个字节编码:

 0803: A6 22 LDX $22 0805: 9D C0 07 STA $07C0,X 0808: 8D C0 07 STA $07C0 080B: EE 09 08 INC $0809 080E: C6 22 DEC $22 

窍门3.操作状态“开机”


在比赛中对工作环境做出疯狂的假设被认为是正常的。 例如,画线是在打开C64电源后首先开始的事情,并且不需要将输出干净地输出回BASIC命令行。 因此,您在进入PRG时在初始环境中找到的所有内容都可以并且应该被利用以发挥您的优势:

  • 寄存器A,X,Y为零
  • 清除所有CPU标志
  • 零页内容(地址$00 $ff

同样,在调用某些KERNAL ROM过程时,您可以充分利用任何副作用:返回的CPU标志,临时零页值等。

经过最初的优化之后,让我们在机器内存中寻找一些有趣的东西:



零页确实包含一些有用的值,以达到我们的目的:

  • $d5 :39 / $ 27 ==行长-1
  • $22 22:64 / $ 40 ==线斜率计数器的初始值

这将在初始化期间节省一些字节。 例如:

 !let x0 = $20 lda #39 ; 0801: A9 27 LDA #$27 sta x0 ; 0803: 85 20 STA $20 xloop: dec x0 ; 0805: C6 20 DEC $20 bpl xloop ; 0807: 10 FC BPL $0805 

由于$d5包含值39,因此可以将其指示给计数器x0 ,以摆脱LDA / STA对:

 !let x0 = $d5 ; nothing here! xloop: dec x0 ; 0801: C6 D5 DEC $D5 bpl xloop ; 0803: 10 FC BPL $0801 

比赛的获胜者菲利普将其代码做到极致。 调用字符串$07C0 (== $0400+24*40 )的最后一个字符的地址。 在初始化期间,该值在零页中不存在。 但是,作为ROM滚动例程如何使用临时零页值的副作用,该函数输出处的地址$D1-$D2将包含值$07C0 。 因此,为了存储一个像素,可以使用较短的间接索引寻址STA ($D1),y代替STA $07C0,x

绝招4.下载优化


典型的C64 PRG二进制文件包含以下内容:

  • 前2个字节:下载地址(通常$0801
  • BASIC引导序列的12个字节

主启动顺序如下所示(地址$801-$80C ):

 0801: 0B 08 0A 00 9E 32 30 36 31 00 00 00 080D: 8D 20 D0 STA $D020 

在不涉及有关BASIC标记化内存布局的细节的情况下,此序列或多或少地对应于“ 10 SYS 2061”。 当BASIC解释器执行SYS命令时,我们的实际机器代码程序将在地址2061$080D )处运行。

似乎14个字节太多了。 菲利普(Philip),马特列夫(Matlev)和盖尔(Geir)使用了几项棘手的技巧完全摆脱了主线。 这需要用PRINT LOAD"*",8,1加载PRG,因为LOAD"*",8忽略PRG加载地址(前两个字节),并且始终以$0801加载。



这里使用了两种方法:

  • 堆叠技巧
  • 基本热重置技巧

堆叠技巧


诀窍是在$01F8处理器堆栈, $01F8值指示我们想要的入口点。 这是通过创建一个以16位指针开头的PRG并将PRG加载到$01F8

  * = $01F8 !word scroll - 1 ; overwrite stack scroll: jsr $E8EA 

一旦BASIC加载程序(请参见拆卸后代码 )完成加载并希望使用RTS返回到调用对象,它将直接返回到我们的PRG。

基本热重置技巧


只需在拆卸后查看PRG,就可以轻松解释这一点。

 02E6: 20 EA E8 JSR $E8EA 02E9: A4 D5 LDY $D5 02EB: A9 A0 LDA #$A0 02ED: 99 20 D0 STA $D020,Y 02F0: 91 D1 STA ($D1),Y 02F2: 9D B5 07 STA $07B5,X 02F5: E6 D6 INC $D6 02F7: 65 90 ADC $90 02F9: 85 90 STA $90 02FB: C6 D5 DEC $D5 02FD: 30 FE BMI $02FD 02FF: 90 E7 BCC $02E8 0301: 4C E6 02 JMP $02E6 

注意最后一行( JMP $02E6 )。 JMP指令从$0301开始,跳转地址为$0302-$0303

当此代码从地址$02E6开始加载到内存中时,值$02E6写入地址$0302-$0303 。 好吧,该位置具有特殊含义:它包含一个指向“ BASIC等待周期”的指针(有关更多详细信息,请参见C64存储卡 )。 下载PRG会用$02E6覆盖它,因此,当热复位后BASIC解释器试图进入等待循环时,它永远不会进入此循环,而是进入渲染程序!

推出BASIC的其他技巧


Petri发现了另一个BASIC启动技巧 ,使您可以在零页中输入自己的常量。 用这种方法,您可以手动创建自己的标记化BASIC起始序列,并在BASIC程序的行号中编码常量。 在输入处,BASIC行号ahem,即常量将存储在地址$39-$3A 。 很聪明!

技巧5。自定义控制流程


这是x循环的稍微简化的版本,它只打印一行,然后停止执行:

  lda #39 sta x1 xloop: lda #$a0 ldx x1 sta $0400 + 24*40, x adc yf sta yf bcc no_scroll ; scroll screen up! jsr $e8ea no_scroll: dec x1 bpl xloop ; intentionally halt at the end inf: jmp inf 

但是有一个错误。 绘制最后一个像素时,我们无法再滚动屏幕。 因此,在记录最后一个像素后,需要其他分支来停止滚动:

  lda #39 sta x1 xloop: lda #$a0 ldx x1 sta $0400 + 24*40, x dec x1 ; skip scrolling if last pixel bmi done adc yf sta yf bcc no_scroll ; scroll screen up! jsr $e8ea no_scroll: jmp xloop done: ; intentionally halt at the end inf: jmp inf 

控制流程与C编译器将从结构化程序中生成的内容非常相似。 跳过最后滚动的代码引入了一个新的JMP abs指令,该指令占用3个字节。 条件跳转的长度只有两个字节,因为它们使用具有直接寻址功能的相对8位操作数对跳转地址进行编码。

通过将滚动调用移至循环的顶部并略微更改控制流结构,可以避免JMP“跳过最后一个滚动”。 Philip的实现方式如下:

  lda #39 sta x1 scroll: jsr $e8ea xloop: lda #$a0 ldx x1 sta $0400 + 24*40, x adc yf sta yf dec x1 ; doesn't set carry! inf: bmi inf ; hang here if last pixel! bcc xloop ; next pixel if no scroll bcs scroll ; scroll up and continue 

这完全消除了一个三字节的JMP,并将另一个JMP转换为两个字节的条件分支,总共节省了4个字节。

把戏6。行与位压缩


一些元素不使用线斜率计数器,而是将这些位压缩为8位常量。 这种封装基于以下事实:像素沿线的位置对应于重复的8像素图案:

 int mask = 0xB6; // 10110110 uint8_t* dst = screenRAM; for (int x = 0; x < 40; x++) { dst[x] = 0xA0; if (mask & (1 << (x&7))) { dst += 40; // go down a row } } 

这转化为相当紧凑的汇编程序。 但是,倾斜计数器选项通常更小。

优胜者


Philip 的34字节竞赛获奖程序 。 上述大多数技巧都可以在他的代码中很好地发挥作用:

 ov = $22 ; == $40, initial value for the overflow counter ct = $D5 ; == $27 / 39, number of passes. Decrementing, finished at -1 lp = $D1 ; == $07C0, pointer to bottom line. Set by the kernal scroller ; Overwrite the return address of the kernal loader on the stack ; with a pointer to our own code * = $01F8 .word scroll - 1 scroll: jsr $E8EA ; Kernal scroll up, also sets lp pointer to $07C0 loop: ldy ct ; Load the decrementing counter into Y (39 > -1) lda #$A0 ; Load the PETSCII block / black col / ov step value sta $D020, y ; On the last two passes, sets the background black p1: sta $07C0 ; Draw first block (left > right line) sta (lp), y ; Draw second block (right > left line) inc p1 + 1 ; Increment pointer for the left > right line adc ov ; Add step value $A0 to ov sta ov dec ct ; Decrement the Y counter bmi * ; If it goes negative, we're finished bcc loop ; Repeat. If ov didn't overflow, don't scroll bcs scroll ; Repeat. If ov overflowed, scroll 

但是为什么要停留在34个字节上呢?


竞赛结束后,每个人都分享了他们的代码和注释-并就如何进一步改进进行了一系列热烈的讨论。 在截止日期之后,提出了更多选择:


一定要看-有几颗真珍珠。



感谢您的阅读。 还要特别感谢Matlev,Phil,Geir,Petri,Jamie,Ian和David的参与(我希望我不会错过任何一个人-在Twitter上追踪所有提及确实很困难!)

PS Petri称我的比赛为“年度”,所以,嗯,可能明年见。

Source: https://habr.com/ru/post/zh-CN464377/


All Articles