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
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 impl | ORA X,ind | ORA zpg | ASL zpg | PHP impl | ORA # | ASL A | ORA abs | ASL abs | |||||||
1‐ | BPL rel | ORA ind,Y | ORA zpg,X | ASL zpg,X | CLC impl | ORA abs,Y | ORA abs,X | ASL abs,X | ||||||||
2‐ | JSR abs | AND X,ind | BIT zpg | AND zpg | ROL zpg | PLP impl | AND # | ROL A | BIT abs | AND abs | ROL abs | |||||
3‐ | BMI rel | AND ind,Y | AND zpg,X | ROL zpg,X | SEC impl | AND abs,Y | AND abs,X | ROL abs,X | ||||||||
4‐ | RTI impl | EOR X,ind | EOR zpg | LSR zpg | PHA impl | EOR # | LSR A | JMP abs | EOR abs | LSR abs | ||||||
5‐ | BVC rel | EOR ind,Y | EOR zpg,X | LSR zpg,X | CLI impl | EOR abs,Y | EOR abs,X | LSR abs,X | ||||||||
6‐ | RTS impl | ADC X,ind | ADC zpg | ROR zpg | PLA impl | ADC # | ROR A | JMP ind | ADC abs | ROR abs | ||||||
7‐ | BVS rel | ADC ind,Y | ADC zpg,X | ROR zpg,X | SEI impl | ADC abs,Y | ADC abs,X | ROR abs,X | ||||||||
8‐ | STA X,ind | STY zpg | STA zpg | STX zpg | DEY impl | TXA impl | STY abs | STA abs | STX abs | |||||||
9‐ | BCC rel | STA ind,Y | STY zpg,X | STA zpg,X | STX zpg,Y | TYA impl | STA abs,Y | TXS impl | STA abs,X | |||||||
A‐ | LDY # | LDA X,ind | LDX # | LDY zpg | LDA zpg | LDX zpg | TAY impl | LDA # | TAX impl | LDY abs | LDA abs | LDX abs | ||||
B‐ | BCS rel | LDA ind,Y | LDY zpg,X | LDA zpg,X | LDX zpg,Y | CLV impl | LDA abs,Y | TSX impl | LDY abs,X | LDA abs,X | LDX abs,Y | |||||
C‐ | CPY # | CMP X,ind | CPY zpg | CMP zpg | DEC zpg | INY impl | CMP # | DEX impl | CPY abs | CMP abs | DEC abs | |||||
D‐ | BNE rel | CMP ind,Y | CMP zpg,X | DEC zpg,X | CLD impl | CMP abs,Y | CMP abs,X | DEC abs,X | ||||||||
E‐ | CPX # | SBC X,ind | CPX zpg | SBC zpg | INC zpg | INX impl | SBC # | NOP impl | CPX abs | SBC abs | INC abs | |||||
F‐ | BEQ rel | SBC ind,Y | SBC zpg,X | INC zpg,X | SED impl | SBC abs,Y | SBC abs,X | INC 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.
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:
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.
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??
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.
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.
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.
- O ponto de entrada sempre começa com um código
- Contanto que nenhum
jmp
ourts
seja usado, é seguro dizer que ainda estamos analisando código - O destino de um
jmp
,jsr
absolutos, ou de instruções de ramificação relativa, igualmente será um código - O destino de um
lda
ousta
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/
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
elda hello,y
, que poderiam ser identificados de modo que os próximos bytes0b
depois dehello
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
parasta $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