UM DESASSEMBLADOR 6502 EM PYTHON

Atenção: esse artigo é uma tradução do que está em https://www.awsm.de/blog/pydisass/ escrito pelo Ingo Hinterding. Visite e comente no site dele também, se isso aqui for útil para você!!!

Neste artigo, eu compartilho alguns aprendizados sobre desassemblar o código de máquina do 6502. No fim das contas, 80% do trabalho é fácil, melhorar para 90% é algo mais complicado, e chegar a fazer funcionar 100% é, às vezes, quase impossível.

O computador Commodore 64 é, sem dúvidas, um clássico atemporal. Aliás, não me assusta se você estiver lendo esse artigo em algum momento muito mais pro futuro a partir de hoje. Talvez carros voadores finalmente já tenham se tornado comuns. Talvez as dogecoins tenham se tornado a moeda mundial principal. Ou ainda, esse seja o ano do Linux no desktop. Sinistro. Mas, é muito mais provável que a humanidade tenha tido sucesso em terraformar a Terra em um segundo, mortal e empoeirado Marte…

Para mim, o ano é 2021. Acabamos de sobreviver a primeira encarnação do imperador do mal Trump, e estamos celebrando por estar pouco mais de um ano travados numa porcaria de pandemia global, que começou como um simples vírus mortal mas depois se transformou em um verme cerebral muito mais perigoso, que elevou aos céus o número de uma estupidez decepcionante entre a população, resultando em um dia da marmota sem fim. Ai, ai…

Onde eu estava… ah, sim, escrevendo um desassemblador.

Mac, a versão moderna do Commodore 16

Meu primeiro computador foi um C16, e credito a esse fato a paixão e a profissão de programador. Daqueles garotos da minha escola que tinham um computador nos anos 80, a maioria tinha um C64. Eu os observava compartilhando os jogos mais legais o tempo todo, enquanto eu tinha apenas… Winter Games e Ghost Town. Mas meu computador tinha algo que era muito melhor que os jogos, era o BASIC 3.5, que era muito superior ao BASIC 2.0 do C64. Então, eu criei meus próprios jogos muito merdas.

A situação é similar ao suporte a ferramentas de retrocompatibilidade atual do Apple Mac. A maioria dos programas são feitos para Windows e fazer com que funcionem no Linux ou Mac iria demandas o Wine ou rodar uma máquina virtual pesada. Na verdade, a última vez que eu quis criar um jogo novo para o C64, não consegui achar um editor de sprites nativo bom, e por isso, naquele dia nasceu o spritemate. O mesmo é válido para desassembladores. Existem alguns incríveis para Windows, como o Regenerator e até soluções web como o masswerk’s 6502 disassembler. Mas meu cérebro de peixinho dourado pesava “hmmm… será que é muito difícil escrever meu próprio desassemblador?”

Tendo ficado em casa devido a uma lesão no músculo da panturrilha, decidi treinar meus conhecimentos enferrujados de assembly e trabalhar em um pequeno projeto. Eu queria desassemblar completamente um dos cracktros mais icônicos do C64, a intro famosa do Fairlight:

A intro é bem direta e avançada, com um charset customizado, uma música SID bem legal, um scroller suave e algumas lavagens de cores. Até as barras rasters são simples, já que na verdade são sprites simples e não rasterbars.

Assemblar, em poucas palavras

Assemblar (tambem chamado “compilar”) é converter o código de programas para um programa em código de máquina. Vejamos um exemplo simples:

      lda #$02          ; load the value $02 into the accumulator
      sta $d020         ; store the value at memory address $d020
      rts               ; return (to BASIC)

Quando isso é executado no C64, o código iria mudar a cor da borda (d020) para vermelho ($02). Esses comandos (lda = LoaD Accumulator e sta = STore Accumulator) são chamados de mnemônicos. A máquina na verdade não iria entender os comandos nativamente, mas eles são representados por números hexadecimais longos de 8 bits, de 00 a ff, chamados opcodes. Vamos adicionar esses opcodes na listagem:

  A9 02     lda #$02          ; load the value $02 into the accumulator
  8D 20 D0  sta $d020         ; store the value at memory address $d020
  60        rts               ; return (to BASIC)

A9 representa o lda, 8D é o sta, e 60 é o equivalente do mnemônico rts. Se você está imaginando porque D020 virou 20 D0, é por causa da chamada notação little endian, que coloca o byte menos significativo na frente. Um bom soco no estômago quando tento debugar meu código.

O programa inteiro residiria na memória do computador, nessa sequência de números hexadecimais:

    A9 02 8D 20 D0 60 

Você já tentou imaginar como os computadores fazem essa mágica, apenas jogando corrente (1) ou não-corrente (0) nos circuitos? Bem, converta os números acima para a notação binária e você vai literalmente ver a matrix:

    10101001 00000010 10001101 00100000 11010000 01100000
mind:blown.

Resumindo, escrever um assemblador não é tão difícil. Você converte mnemônicos em opcodes, toma cuidado com a notação little endian, e está quase pronto para seguir em frente. Claro que estou simplificando as coisas aqui, mas você pegou a ideia.

Desassemblar, em poucas palavras

Desassemblar é o converter código de máquina de volta para um programa assembly legível. Simples assim.

É o processo de converter

    A9 02 8D 20 D0 60 

para

      lda #$02          
sta $d020
rts

Se tentarmos fazer essa tarefa com um pseudo código, poderia ser tão simples assim?

  pegue um byte (opcode) 
  substitua o byte com a representação mnemônica dele
  repetir

Não muito. O resultado iria se parecer com este:

      lda
      jam
      sta
      jsr
      bne
      rts

Nossos dados seriam interpretados como código, resultando em um lixo que nenhum compilador aceitaria como sendo um programa válido. Isso nos traz ao nosso maior desafio ao escrever um desassemblador.

Distinguindo código e dados (parte I)

É importante entender que o conjunto de instruções do 6502 tem modos de endereçamento diferentes. Eles descrevem o comportamento real e nos dizem como interpretar o dados que vão com os mnemônicos. Para o mnemônico lda existem oito modos de endereçamento diferentes:

  A1  lda ($hh,x)   ; X-indexed, indireto
  A5  lda $hh       ; zeropage
  A9  lda #$hh      ; imediato
  AD  lda $hhll     ; absoluto
  B1  lda ($hh),y   ; indireto, Y-indexed
  B5  lda $hh,x     ; zeropage, X-indexed
  B9  lda $hhll,y   ; absoluto, Y-indexed
  BD  lda $hhll,x   ; absoluto, X-indexed

Eu não vou explicar isso em detalhes, existem vários sites que já fazem isso melhor que eu posso, como o Easy 6502 by skilldrick, apenas note que há 13 modos diferentes de endereçamento no total:

A	Accumulator	
abs	absolute	
abs,X	absolute, X-indexed	
abs,Y	absolute, Y-indexed	
#	immediate	
impl	implied	
ind	indirect	
X,ind	X-indexed, indirect
ind,Y	indirect, Y-indexed	
rel	relative	
zpg	zeropage	
zpg,X	zeropage, X-indexed	
zpg,Y	zeropage, Y-indexed	

Opcodes “legítimas” do 6502

E aqui você encontra todas as instruções legítimas ou “legais” do 6502 (a tabela abaixo é cortesia do excelente site masswerk). Existem opcodes ilegais também, que são os que preenchem os espaços em branco na tabela. Alguns deles podem ser usadas para alguns truques especiais, outros apenas quebram seu programa.

‐0‐1‐2‐3‐4‐5-6‐7‐8‐9‐A‐B‐C‐D‐E‐F
0‐BRK implORA X,indORA zpgASL zpgPHP implORA #ASL AORA absASL abs
1‐BPL relORA ind,YORA zpg,XASL zpg,XCLC implORA abs,YORA abs,XASL abs,X
2‐JSR absAND X,indBIT zpgAND zpgROL zpgPLP implAND #ROL ABIT absAND absROL abs
3‐BMI relAND ind,YAND zpg,XROL zpg,XSEC implAND abs,YAND abs,XROL abs,X
4‐RTI implEOR X,indEOR zpgLSR zpgPHA implEOR #LSR AJMP absEOR absLSR abs
5‐BVC relEOR ind,YEOR zpg,XLSR zpg,XCLI implEOR abs,YEOR abs,XLSR abs,X
6‐RTS implADC X,indADC zpgROR zpgPLA implADC #ROR AJMP indADC absROR abs
7‐BVS relADC ind,YADC zpg,XROR zpg,XSEI implADC abs,YADC abs,XROR abs,X
8‐STA X,indSTY zpgSTA zpgSTX zpgDEY implTXA implSTY absSTA absSTX abs
9‐BCC relSTA ind,YSTY zpg,XSTA zpg,XSTX zpg,YTYA implSTA abs,YTXS implSTA abs,X
A‐LDY #LDA X,indLDX #LDY zpgLDA zpgLDX zpgTAY implLDA #TAX implLDY absLDA absLDX abs
B‐BCS relLDA ind,YLDY zpg,XLDA zpg,XLDX zpg,YCLV implLDA abs,YTSX implLDY abs,XLDA abs,XLDX abs,Y
C‐CPY #CMP X,indCPY zpgCMP zpgDEC zpgINY implCMP #DEX implCPY absCMP absDEC abs
D‐BNE relCMP ind,YCMP zpg,XDEC zpg,XCLD implCMP abs,YCMP abs,XDEC abs,X
E‐CPX #SBC X,indCPX zpgSBC zpgINC zpgINX implSBC #NOP implCPX absSBC absINC abs
F‐BEQ relSBC ind,YSBC zpg,XINC zpg,XSED implSBC abs,YSBC abs,XINC abs,X

Agora temos todas as informações que precisamos para identificar quantos bytes seguindo a instrução são dados.

    A9    ; A9 = LDA immediate, expects one more byte
    02    ; 02 must therefore be a data byte
    8D    ; 8D = STA absolute, expects two more bytes
    20    ; 20 must be data
    D0    ; D0 must be data (and be switched in position with the previous byte because of little endian notation)
    60    ; 60 = RTS implied, expects no further byte

opcodes.json

Existem 256 (0 a 255, ou ff em hexa) opcodes possíveis para o processador 6502, mas só 151 delas são instruções definidas que produzem um comportamento esperado. Os outros 105 são chamados opcode ilegais. Se você quiser aprender sobre eles, recomendo fortemente o excelente artigo How MOS 6502 Illegal Opcodes really work do Michael Steil.

O primeiro (e entediante) passo para meu desassemblador foi tornar essas opcodes disponíveis para fazer o parsing do código. Escolhi fazer um arquivo JSON simples, que inclui todos os 256 opcodes. Aqui estão as primeiras linhas (o código completo você pode baixar do meu github repository).

{
  "00": {
    "ins": "brk"
  },
  "01": {
    "ins": "ora ($hh,x)"
  },
  "02": {
    "ins": "jam",
    "ill": 1
  },
  "03": {
    "ins": "slo ($hh,x)",
    "ill": 1
  },
  "04": {
    "ins": "slo ($hh),y",
    "ill": 1
  },
  "05": {
    "ins": "ora $hh"
  },
  "06": {
    "ins": "asl $hh"
  },
  "07": {
    "ins": "slo $hh"
  },
  "08": {
    "ins": "php"
  },
  "09": {
    "ins": "ora #$hh"
  },
  "0a": {
    "ins": "asl"
  },
  "0b": {
    "ins": "anc #$hh",
    "ill": 1
  },
  "0c": {
    "ins": "nop $hhll",
    "ill": 1
  },
  "0d":{
    "ins": "ora $hhll"
  },
  "0e":{
    "ins": "asl $hhll"
  },
  "0f": {
    "ins": "slo $hhll",
    "ill": 1
  }
}

Então eu posso escrever um programa Python desse tipo

ins = opcodes["a9"]   # returna lda #$hh

e obtemos um objeto com a a instrução. Checando o hh e ll, eu posso facilemnte identficar quantos dos bytes seguintes estão associados à instrução. Eu poderia ter incluído essa informação nos dados do JSON também, mas considerei redundante. Além da instrução, eu uso o ill para marcar um opcode ilegal, e rel para marcar uma instrução de “ramificação relativa” (vou falar mais sobre isso depois).

A Primeira Versão

Eis uma versão simplificada do código python que lê um byte, checa qual o opcode correspondente, cuida dos bytes na sequência, e constrói uma linha válida em assembly.

def bytes_to_asm(startaddr, bytes, opcodes):
    """
    inspeciona byte a byte e os converte em
    instruções baseado no json de opcodes
    retorna um array com objetos para cada linha de código
    """
    asm = []
    pc = 0
    end = len(bytes)
    while pc < end:
        byte = bytes[pc]
        opcode = opcodes[number_to_hex_byte(byte)]
        instruction = opcode["ins"]
        memory_location_hex = number_to_hex_word(startaddr + pc)
        byte_sequence = []
        byte_sequence.append(byte)

        instruction_length = 0
        if "hh" in instruction:
            instruction_length += 1
        if "ll" in instruction:
            instruction_length += 1

        if instruction_length == 1:
            pc += 1
            high_byte = bytes[pc]
            instruction = instruction.replace("hh", number_to_hex_byte(high_byte))
            byte_sequence.append(high_byte)

        if instruction_length == 2:
            pc += 1
            low_byte = bytes[pc]
            pc += 1
            high_byte = bytes[pc]

            # replace with new word
            instruction = instruction.replace("hh", number_to_hex_byte(high_byte))
            instruction = instruction.replace("ll", number_to_hex_byte(low_byte))

            # store the bytes - we might need them later
            byte_sequence.append(low_byte)
            byte_sequence.append(high_byte)

        line = {
            "a": memory_location_hex,
            "b": byte_sequence,
            "i": instruction
        }

        asm.append(line)
        pc = pc+1

    return asm

Let’s run our first test!

De novo, nosso programinha, incluindo o endereço inicial, que define onde na memória do C64 o programa seria armazenado:

* = $0810

lda #$02
sta $d020
rts

Agora o nosso programa vai ser compilado usando o ACME assembler. Estou usando meu Template para VSCODE do ACME Assembler para desenvolver, que suporta Windows, Linux e Mac. Carregando o PRG no emulador VICE e iniciando o programa com sys 2064 (que em hexadecimal seria 0810) nós vemos uma boa borda vermelha.

c64 with red border

Podemos dessassemblar o código binário e voltar a um código legível?

> python3 disass.py color.prg output.asm

Isso gera um array python

[
  {'a': '0810', 'b': [169, 2], 'i': 'lda #$02'}, 
  {'a': '0812', 'b': [141, 32, 208], 'i': 'sta $d020'}, 
  {'a': '0815', 'b': [96], 'i': 'rts'}
]

E, claro, depois de formatar os dados, o resultado seria um arquivo com esse conteúdo:

; converted with disass6502 by awsm of mayday!

* = $0810
lda #$02
sta $d020
rts

Adicionando Labels

Fazer o nosso programa básico desassemblar foi um grande passo inicial, mas temos muito mais a fazer para tornar isso em algo útil. Vamos modificar o programa um pouco, introduzindo um jump para uma posição diferente no código

* = $0810

loop
inc $d020
jmp loop

O resultado seria um loop infinito de aumento na cor da borda:

c64 with red border

O compilador vai gerar este programa:

$0810  EE 20 D0    inc $D020
$0813 4C 10 08 jmp $0810

Nosso desassemblador ainda funcionaria bem, mas podemos fazê-lo um pouco mais versátil, substituindo os endereços de jump de memória que estão no nosso range de código (ex. $0810 a $0816 no exemplo acima) com labels. Labels (rótulos) são referências a posições de memória. Poderia parecer pouco importante neste exemplo, mas se não fizermos isso, nosso programa desassemblado poderia quebrar com qualquer alteração que movesse o código para um endereço diferente. Estaríamos extremamente limitados. Vamos adicionar os labels.

Labels funcionam bem diretamente, aqui vai o pseudocódigo:

O endereço é absoluto (como $hhll)?
  Se sim, ele está dentro do nosso código?
    Se sim, substitua por um label

Para o programa acima, isso significa:

$0810  inc $D020    ; $d020 NÃO é um endereço em nosso programa, não substitua
$0813  jmp $0810    ; $0810 É um endereço em nosso programa, vamos substituir pelo label 'x0810'

Mas nós ainda não terminamos, precisamos também colocar o label no destino do jump.

Leia o endereço atual 
  O endereço atual está dentro do array que criamos com todos os nossos labels que definimos antes?
    Se sim, adicionar o label antes da instrução

Com os dois no lugar certo, nossa saída seria assim, agora:

* = $0810

x0810
inc $d020
jmp x0810

O que é incrível, porque podemos mudar o programa agora ou mesmo movê-lo para um endereço diferente na memória, e ele ainda vai rodar perfeitamente bem! \o/

Adicionando Ramificação Relativa

Com nosso desassemblador ficando mais esperto, podemos colocar mais um desafio para ele, que é a ramificação relativa. Com a instrução jmp $0810 antes nós tínhamos um endereço absoluto que podíamos substituir por um label. Mas isso não funcionaria para as instruções de ramificação do 6502, que são BPL, BMI, BVC, BVS, BCC, BCS, BNE e BEQ. Aqui está um pequeno programa com ramificações:

* = $0810

ldy #$07 ; load 07 into the Y register
loop
tya ; copy Y into the accumulator
sta $0400,y ; store the accumulator at $0400 + whatever Y is
dey ; decrease Y by one
bne loop ; if Y is NOT zero, jump to 'loop'
rts ; Y is zero, exit to BASIC

O programa armazena os bytes $00 - $07 na posição de memória $0400 - $0407, que é a tela, resultando nas letras ABCDEFG (mais um caracter vazio) sendo impressas no canto superior esquerdo.

c64 with letter

Olhando a memória no monitor do VICE, ele mostra:

.C:0810  A0 07       LDY #$07
.C:0812  98          TYA
.C:0813  99 00 04    STA $0400,Y
.C:0816  88          DEY
.C:0817  D0 F9       BNE $F9
.C:0819  60          RTS

A gente poderia esperar que o comando BNE fosse BNE $0812, mas é BNE $F9! Isso é porque ele ramifica em relação à sua própria posição. Instruções de ramificação podem pular somente 127 bytes para frente ou para trás. Em nosso exemplo, nós pularíamos 6 bytes para trás: a partir do endereço $0818 para o endereço $0812. Nós fazemos isso começando com th $ff (255) e subtraindo 6, então é $F9 (249). Se pularmos para frente, nós adicionamos 6 a $00, resultando em $06.

Com esse conhecimento, podemos colcoar uma checagem no código para ramificação relativa. Essa aqui é a função python completa, incluindo a verificação de labels, adicionando a informação sobre opcodes ilegais, e uma verificação de fim de arquivo.

def bytes_to_asm(startaddr, bytes, opcodes):
    """
    inspeciona byte a byte e os converte em 
    instruções com baseo no json de opcodes
    retorna um array com objetos para cada linha de código
    """
    asm = []
    pc = 0
    end = len(bytes)
    label_prefix = "x"
    while pc < end:
        byte = bytes[pc]
        opcode = opcodes[number_to_hex_byte(byte)]
        instruction = opcode["ins"]

        # checa a chave "rel" no json de opcode 
        # que significa "endereçamento relativo"
        # isso é necessário, por ex. para ramificações como BNE, BCS etc.
        if "rel" in opcode:
            is_relative = True
        else:
            is_relative = False

        memory_location_hex = number_to_hex_word(startaddr + pc)
        label = label_prefix + memory_location_hex
        byte_sequence = []
        byte_sequence.append(byte)

        instruction_length = 0
        if "hh" in instruction:
            instruction_length += 1
        if "ll" in instruction:
            instruction_length += 1

        # Seria o opcode mais longo que o fim do arquivo? Então, vamos setar o tamanho como sendo 0
        if pc + instruction_length > end:
            instruction_length = -1

        if instruction_length == 1:
            pc += 1
            high_byte = bytes[pc]

            # se uma instrução relativa como BCC ou BNE ocorrer
            if is_relative:
                if high_byte > 127:
                    # subtrair (255 - highbyte) do endereço atual
                    address = number_to_hex_word(
                        startaddr + pc - (255 - high_byte))
                else:
                    # somar highbyte ao endereço atual
                    address = number_to_hex_word(
                        startaddr + pc + high_byte + 1)
                instruction = instruction.replace(
                    "$hh", label_prefix + address)
            else:
                instruction = instruction.replace(
                    "hh", number_to_hex_byte(high_byte))

            byte_sequence.append(high_byte)

        if instruction_length == 2:
            pc += 1
            low_byte = bytes[pc]
            pc += 1
            high_byte = bytes[pc]

            # substituir com nova word
            instruction = instruction.replace(
                "hh", number_to_hex_byte(high_byte))
            instruction = instruction.replace(
                "ll", number_to_hex_byte(low_byte))

            # o endereço de memória está no nosso próprio programa?
            # então vamos substituir com um label para aquele endereço
            absolute_address = (high_byte << 8) + low_byte
            if (absolute_address >= startaddr) & (absolute_address <= startaddr+end):
                instruction = instruction.replace("$", label_prefix)
                is_relative = True
                address = number_to_hex_word((high_byte << 8) + low_byte)

            # armazena os bytes - podemos precisar disso depois
            byte_sequence.append(low_byte)
            byte_sequence.append(high_byte)

        if instruction_length == -1:
            instruction = "!byte $" + number_to_hex_byte(byte)

        line = {
            "a": memory_location_hex,
            "l": label,
            "b": byte_sequence,
            "i": instruction
        }

        # todos os dados relativos/label deveriam ter uma nova chave para podermos identificá-los
        # precisamos disso quando limparmos labels que não precisamos
        if is_relative:
            line["rel"] = label_prefix + address

        if "ill" in opcode:
            line["ill"] = 1

        asm.append(line)
        pc = pc+1

    asm = remove_unused_labels(asm)
    return asm

Ok, toquem os tambores, e vamos usar essa obra de arte em nosso último programa e desassemblar a parada toda!

; converted with disass6502 by awsm of mayday!

* = $0810
ldy #$07

x0812
tya
sta $0400,y
dey
bne x0812
rts

Funciona! Funciona! Funciona

Terminamos, certo? Certo? Ceeeeeeerto??

c64 with letter

Bem, até agora eu só fiz a programação do caminho feliz. Obviamente, meu caminho para a vitória não foi tão sem falhas como demonstrei aqui (e nunca é – não assuma que artigos como este não requerem muitas tentativas e erros, e baldes de ‘que porcaria é essa?’). Nosso dessassemblador funciona em muitas situações, mas falha na parte mais importante.

Distinguindo código e dados (parte II)

Até agora, o desassemblador está em falta de alguma inteligência em determinar se um byte é um dado ou um mnemônico. Ele apenas segue a regra de que nosso programa sempre começa com uma instrução (o que, realmente, é uma boa suposição) e é seguida de mais instruções (má suposição). Para programas que são apenas código (como os que vimos até agora), estaríamos mesmo prontos com nosso trabalho, e ter uma conversão bem confiável. Mas, esse é um caso raro, já que na maioria das vezes, os dados são partes importantes de qualquer programa. Vamos trabalhar nessa dificuldade de novo, e tentar algo mais comum e complexo ao mesmo tempo.

* = $0810

            lda #$00                ; o valor da cor
            sta $d020               ; mudar a cor do fundo
            sta $d021               ; mudar a cor da borda

            ldy #$0b                ; a string "hello world!" tem 12 caracteres

loop
            lda hello,y             ; carrega o caracter número y da string
            sta $0400,y             ; gaurda ele naposição y da memória da tela
            dey                     ; decrementa y em 1
            bpl loop                ; y é positivo? então, repete
            rts                     ; sair do programa

hello       !scr "hello world!"     ; nossa string a exibir

Ahh, o clássico “hello world!”. Você não o odeia tanto quanto eu? Eu adoraria usar um texto diferente, mas tenho certeza que é obrigatório por lei a sempre incluir isso em qualquer tutorial. Que bom que conseguimos fazer isso agora. E nossa máquina de pão iria setar as cores da borda e o fundo da tela para preto e então escrever o texto do topo esquerdo da tela.

c64 with letter

Vamos alimentar nosso desassemblador com o programa e verificar a saída.

; converted with disass6502 by awsm of mayday!

* = $0810
lda #$00
sta $d020
sta $d021
ldy #$0b

x081a
lda x0824,y
sta $0400,y
dey
bpl x081a
rts

x0824
php
ora $0c
nop $200f ; $0c, $0f, $20
slo $0f,x ; $17, $0f
jam ; $12
nop $2104 ; $0c, $04, $21

Tudo ia muito bem até que chegamos na seção de dados com o “hello world!”. Como tínhamos suposto que tudo era código, todos os bytes foram traduzidos em mnemônicos. Alguns deles eram opcodes ilegais, que eu sei do opcodes.json. Eu uso essa informação para exibir esses bytes adicionalmente como comentários, marcando essas linhas como… suspeitas.

c64 with letter

Fazendo suposições sobre código e dados

É realmente impossível estar 100% certo se algo é um código ou dado. Até o computador não “saberia”, ele apenas executa instrução a instrução, cegamente. Um pulo para uma posição de memória um byte antes ou depois de uma instrução válida iria resultar em um crash, certamente. A máquina não se importa. Nós, humanos, é que nos importamos. Queremos ler aquele código bem formatado e tirar algum sentido dele.

Porém, aqui vão alguns indicadores que podem nos guiar através da conversão.

  1. O ponto de entrada sempre começa com um código
  2. Contanto que nenhum jmp ou rts seja usado, é seguro dizer que ainda estamos analisando código
  3. O destino de um jmp, jsr absolutos, ou de instruções de ramificação relativa, igualmente será um código
  4. O destino de um lda ou sta absolutos (e mnemônicos similares) é bem provável de ser um dado

Outras regras podem se aplicar, que eu não adicionei ainda, se você souber de alguma, me avisa na seção de comentários. Claro que essas regras não são à prova d’água também. O endereço de entrada poderia ser o começo de um iniciador de BASIC, como 10 sys 2064. Ou – que Deus proíba – o código estar mudando a si próprio.

Nesse ponto, fez sentido refatorar o código e começar com uma nova abordagem.

Aplicando o conjunto de regras

O novo script gera dados bem diferentes que antes, e eu vou manter algumas informações que podem ser desnecessárias, mas ainda estou explorando essa ideia, e é útil ter o máximo de dados possível.

Vamos ver o código de novo, byte a byte, com nossas regras documentadas nos comentários.

* = $0810

; lda #$00
A9                ; ponto de entrada. Deve ser código
00                ; byte is tem que ser parte da instrução
; sta $d020
8D                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda
20                ; deve ser parte da instrução
D0                ; deve ser parte da instrução
; sta $d021              
8D                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda
21                ; deve ser parte da instrução
D0                ; deve ser parte da instrução
; ldy #$0b
A0                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda
0B                ; deve ser parte da instrução

; loop
; lda hello,y     ; ainda deve ser código, mas também marca a posição do "hello" como dado!
B9                ; deve ser parte da instrução
24                ; deve ser parte da instrução
08                ; deve ser parte da instrução
; sta $0400,y           
99                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda
00                ; deve ser parte da instrução
04                ; deve ser parte da instrução
; dey         
88                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda
; bpl loop                             
10                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda
f7                ; deve ser parte da instrução, mas também marca o destino da ramificação (loop) como código!
; rts                  
60                ; deve ser código, já que não tivemos um JMP ou RTS ou RTI ainda         

; hello   
; !scr "hello world!"    
08                ; deve ser dado, já qe foi definido pelo lda absoluto anteriormente 
05 0C 0C 0F 20 17 0F 12 0C 04 21    ; nenhuma regra se aplica, então por default código = 0 e dados = 0   

Os dados gerados se parecem com isso:

{'addr': 2064, 'byte': 'a9', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2065, 'byte': '00', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2066, 'byte': '8d', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2067, 'byte': '20', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2068, 'byte': 'd0', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2069, 'byte': '8d', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2070, 'byte': '21', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2071, 'byte': 'd0', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2072, 'byte': 'a0', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2073, 'byte': '0b', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2074, 'byte': 'b9', 'dest': 1, 'code': 1, 'data': 0}
{'addr': 2075, 'byte': '24', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2076, 'byte': '08', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2077, 'byte': '99', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2078, 'byte': '00', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2079, 'byte': '04', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2080, 'byte': '88', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2081, 'byte': '10', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2082, 'byte': 'f7', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2083, 'byte': '60', 'dest': 0, 'code': 1, 'data': 0}
{'addr': 2084, 'byte': '08', 'dest': 1, 'code': 0, 'data': 1}
{'addr': 2085, 'byte': '05', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2086, 'byte': '0c', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2087, 'byte': '0c', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2088, 'byte': '0f', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2089, 'byte': '20', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2090, 'byte': '17', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2091, 'byte': '0f', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2092, 'byte': '12', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2093, 'byte': '0c', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2094, 'byte': '04', 'dest': 0, 'code': 0, 'data': 0}
{'addr': 2095, 'byte': '21', 'dest': 0, 'code': 0, 'data': 0}

Analisando os dados com a função python que constrói o código assembly resulta em uma representação bem melhor:

; converted with disass6502 by awsm of mayday!

* = $0810

lda #$00
sta $d020
sta $d021
ldy #$0b

l081a
lda l0824,y
sta $0400,y
dey
bpl l081a
rts

l0824
!byte $08
!byte $05
!byte $0c
!byte $0c
!byte $0f
!byte $20
!byte $17
!byte $0f
!byte $12
!byte $0c
!byte $04
!byte $21

Este programa assembly vai compilar sem erros e desassemblar o binário PRG de novo vai revelar exatamente as mesmas linhas de código 🙂

É hora de um teste final, que inclui tudo que nosso fiel desassemblador aprendeu hoje. Vamos misturar dados e código, pular para endereços e ramificar para outras seções do código.

* = $0810

lda #$00 ; the color value
sta $d020 ; change border color

ldy #$0b ; the string "hello world!" has 12 characters

loop
lda hello,y ; load character number y of the string
sta $0400,y ; save it at position y of the screen ram
dey ; decrement y by 1
bpl loop ; is y positive? then repeat
jmp exit ; exit the program

hello !scr "hello world!" ; our string to display

exit
lda $d020 ; load value from border color into A
sta $d021 ; store A into background color
rts

É basicamente o mesmo código de antes, com um novo jump para “exit”, que seria um código depois da seção de dados. Vamos ver como o desassemblador lida com esse exame final:

; converted with disass6502 by awsm of mayday!

* = $0810

lda #$00
sta $d020
ldy #$0b

l0817
lda l0823,y
sta $0400,y
dey
bpl l0817
jmp l082f

l0823
!byte $08
!byte $05
!byte $0c
!byte $0c
!byte $0f
!byte $20
!byte $17
!byte $0f
!byte $12
!byte $0c
!byte $04
!byte $21

l082f
lda $d020
sta $d021
rts

Teste completado. Missão cumprida! \o/

party hard

E aqui está a função python (muito pouco otimizada) usada para a conversão. Não tem muito código, eu diria, embora muito tenha sido distribuído em funções auxiliares menores para fazer o código mais legível.

def analyze(startaddr, bytes, opcodes):
    bytes_table = generate_byte_array(startaddr, bytes)

    # JMP RTS RTI
    # usado para mudar o default para dados, para as seguintes instruções
    default_to_data_after = ["4c", "60", "40"]

    # JMP JSR
    # usado para identificar seções de código no programa
    abs_branch_mnemonics = ["4c", "20"]

    # LDA STA
    # usado para identificar seções de dado no programa
    abs_address_mnemonics = [
        "0d", "0e", "19", "1d", "1e", "2d", "2e", "39", "3d", "3e", "4d", "4e",
        "59", "5d", "5e", "6d", "6e", "79", "7d", "7e", "8c", "8d", "8e", "99", "9d,",
        "ac", "ad", "ae", "b9", "bc", "bd", "be", "cc", "cd", "ce", "d9", "dd", "de",
        "ec", "ee", "ed", "f9", "fd", "fe"
    ]

    # nosso ponto de entrada é código
    is_code = 1
    is_data = 0

    i = 0
    end = len(bytes_table)

    while i < end:
        byte = bytes_table[i]["byte"]
        opcode = opcodes[byte]

        if bytes_table[i]["data"]:
            is_data = 1

        if bytes_table[i]["code"]:
            is_code = 1

        if is_code:
            bytes_table[i]["code"] = 1
            instruction_length = get_instruction_length(opcode["ins"])

            if byte in default_to_data_after:
                # temos uma instrução de ramificação/pulo, então não temos mais certeza
                # se os bytes a seguir são mesmo código
                is_code = 0

            if "rel" in opcode:
                # se a instrução é relativa, temos que calcular o valor absoluto
                # do endereço da ramificação pra colocar um label depois, abaixo
                destination_address = get_abs_from_relative(
                    bytes_table[i+1]["byte"], startaddr+i+1)

            if instruction_length == 2:
                # esse é o endereço absoluto 
                destination_address = bytes_to_addr(
                    bytes_table[i+2]["byte"], bytes_table[i+1]["byte"])

            if byte in abs_branch_mnemonics or "rel" in opcode:
                if addr_in_program(destination_address, startaddr, startaddr + end):
                    # o endereço hhll tem qeu ser código, então marcamos essa entrada no array
                    table_pos = destination_address - startaddr
                    bytes_table[table_pos]["code"] = 1
                    bytes_table[table_pos]["dest"] = 1

            if byte in abs_address_mnemonics:
                if addr_in_program(destination_address, startaddr, startaddr + end):
                    # o endereço hhll tem que ser dado, então marcamos a entrada no array
                    table_pos = destination_address - startaddr
                    bytes_table[table_pos]["data"] = 1
                    bytes_table[table_pos]["dest"] = 1

            i += instruction_length
        i += 1
    return bytes_table

O que vem a seguir

Tem muito espaço para melhorias e o código poderia ser muito mais esperto e mais elegante. Mas já que eu não sou esperto nem elegante, eu meio que me sinto em boa companhia aqui. Também, esse artigo já se transformo no meu maior do blog, e o fato de você ainda estar aqui, lendo isso, me faz questionar suas escolhas na vida. Pense nisso. Você provavelmente deveria estar lá fora agora, correndo atrás de algumas borboletas ou cavando um buraco na terra em direção ao centro da Terra onde o Elvis mora com sua mãe.

Algumas ideias para melhoria futura seriam:

  • Adicionar mais regras espertas. Por exemplo, no último programa, usamos ldy #$0b e lda hello,y, que poderiam ser identificados de modo que os próximos bytes 0b depois de hello são marcados como dados
  • uma seção de bytes de dados poderia ser combinada em uma mesma linha de código, como !byte $08, $05, $0c, $0c, $0f, $20, $17, $0f, $12, $0c, $04, $21
  • padrões típicos de byte que representam instruções frequentemente usadas como 8D 20 0D para sta $d020 poderiam ter mais “peso”, significando que enquanto não se pidesse ter 100% de certeza que é código, poderíamos estar inclinados a acreditar que seja código. Se chegarmos a um certo threshhold de peso, poderíamos marcar algo como sendo código ou dados
  • interação com uma UI própria. Transformar isso em JavaScript é bem fácil e poderíamos deixar o usuário marcar as seções como código ou dado, e converter entre os dois estados durante a execução. Isso é provavelmente o maior passo em direção a um app funcional

Então é isso por enquanto. Sinta-se à vontade para dar uma olhada no código no github, fazer alterações nele, submeter pull requests. Foi uma experiência de aprendizado divertida para mim. Eu ainda preciso desassemblar aquele intro da Fairlight…

Obrigado por ler este artigo 🙂

Update (Abril 2021)

Fiquei abismado de encontrar esse arquivo no Hacker News pouco depois de postar, e o tráfego subiu bastante por causa disso.

Desde então, essas mudanças foram implementadas:

  • Adicionados pontos de entrada definidos pelo usuário, de modo que seções específicas podem ser marcadas explicitamente como sendo código ou dados. útil em áreas onde o algoritmo simples falha
  • Adicionados endereções de memória do C64 para comentários automáticos
  • Limpeza de código, e uma interface de linha de comando mais robusta

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *