Programando como si fuera 1982. Parte IV – Instrucciones
En el post anterior construimos el mapa. Sabemos dónde viven los datos, cómo está organizada la memoria en direcciones y por qué ciertas regiones valían más que otras. Ahora falta la pieza central. Lo que el procesador realmente hace mientras ejecuta un programa.
El programa también son bytes
En el post sobre el ciclo de ejecución vimos que el procesador lee un byte, lo interpreta como instrucción, lo ejecuta y avanza al siguiente. Pero nunca hablamos de cómo un byte se convierte en una instrucción.
La respuesta es simple. Cada instrucción del procesador tiene un número fijo que la representa. Ese número se llama opcode. El procesador lee el byte $A9 y sabe, por construcción en silicio, que debe cargar el siguiente byte en el registro A. Lee $8D y sabe que debe tomar el valor de A y escribirlo en una dirección de memoria especificada por los dos bytes siguientes. Lee $60 y sabe que debe retornar de una subrutina.
El procesador no lee texto. No lee nombres. Lee números, y cada número tiene un significado grabado en el chip para siempre.
El assembly es la capa que existe para que los humanos no tengan que memorizar esos números. En lugar de escribir $A9 $05, escribes LDA #$05. En lugar de $8D $00 $02, escribes STA $0200. El ensamblador, el programa que compila código assembly, convierte ese texto de vuelta a los bytes que el procesador entiende. Es una traducción directa, sin interpretación, sin optimización. Cada línea se convierte exactamente en los bytes correspondientes.
Lo que esto significa en la práctica. Cuando escribes LDA #$05, no estás describiendo una intención a un compilador inteligente. Estás especificando literalmente qué bytes se sentarán en memoria, en qué orden, y qué hará el procesador cuando llegue a ellos.
Las instrucciones que usarás todo el tiempo
Con ese modelo en mente, las instrucciones se vuelven naturales. El 6510 tiene alrededor de 56 mnemonics en total, pero un conjunto pequeño aparece en prácticamente cualquier programa.
LDA y STA son el par de movimiento de datos. LDA carga un valor en el acumulador A, STA escribe el valor de A en alguna dirección de memoria. Todo lo que entra y sale del procesador pasa por ellos. Las versiones para X e Y siguen el mismo patrón. LDX/STX y LDY/STY.
ADC y SBC son suma y resta. ADC suma un valor a lo que ya hay en A, SBC resta. El 6510 no tiene multiplicación ni división nativa. Esas operaciones se implementaban a mano, con bucles de sumas o restas repetidas. Nada que un compilador de C no haga por debajo hasta hoy en ciertas arquitecturas.
INX, INY, DEX, DEY incrementan y decrementan los registros X e Y. Son el i++ y el i-- del assembly, y aparecen en prácticamente todo bucle.
JMP, JSR y RTS controlan el flujo. JMP es un goto incondicional. El Program Counter salta a la dirección especificada y el procesador continúa desde allí. JSR llama a una subrutina, guardando la dirección de retorno en la pila. RTS retorna de esa subrutina, releyendo la dirección de la pila y volviendo a donde estaba. Es el mecanismo detrás de cada llamada a función en assembly.
Cuando JSR salta a una subrutina, el procesador tiene un problema. Necesita recordar dónde volver cuando se llame a RTS. El lugar donde guarda esa información es la pila, una región fija de memoria de $0100 a $01FF que funciona como una pila física de platos.
JSR empuja la dirección de retorno al tope de la pila y el SP se mueve un slot hacia abajo para reflejar que se añadió algo. RTS lee esa dirección de vuelta desde el tope, el SP sube un slot y la ejecución continúa desde donde se hizo la llamada. Cada JSR empuja, cada RTS desapila, siempre equilibrado.
Por eso las llamadas anidadas a subrutinas funcionan sin romper nada. Cada llamada empuja una nueva dirección de retorno, cada retorno desapila una. La pila tiene 256 bytes, espacio suficiente para unas 128 llamadas anidadas antes de desbordarse.
El detalle que confunde a todos, # o sin #
Existe una pequeña distinción en la sintaxis que causa más confusión que cualquier otra cosa cuando se empieza. El símbolo # antes de un valor cambia completamente lo que hace la instrucción.
LDA #$05 ; A = 5 (el número cinco en sí)
LDA $05 ; A = RAM[$05] (lo que esté guardado en la dirección 5)
Con #, estás trabajando con el valor literal. Sin #, estás trabajando con el contenido de la dirección de memoria. En Python, la diferencia sería entre x = 5 y x = RAM[5].
En la práctica. LDA #$00 pone el acumulador a cero. LDA $00 carga en el acumulador lo que esté guardado en la posición cero de la Zero Page, que puede ser cualquier cosa dependiendo del programa. Son instrucciones completamente diferentes con una diferencia de un carácter.
Un programa completo, línea a línea
Este programa carga el número 5 en el acumulador y lo guarda en la dirección $0200:
LDA #$05 ; A = 5
STA $0200 ; RAM[$0200] = A
RTS ; retorna
Así de simple. Pero lo interesante es ver en qué se convierte en memoria. Cada instrucción ocupa un número diferente de bytes dependiendo de cuánta información necesita cargar:
LDA #$05se convierte en 2 bytes, el opcode$A9seguido del valor$05STA $0200se convierte en 3 bytes, el opcode$8D, el byte bajo de la dirección$00y el byte alto$02RTSse convierte en 1 byte, el opcode$60, solo
Seis bytes en total. Ese es el programa completo. Cuando el procesador lea el primer byte en $0801, ejecutará el LDA. Cuando avance a $0803, ejecutará el STA. Cuando llegue a $0806, retornará. No hay nada más allá de eso.
Fíjate en el orden de los bytes de la dirección en STA. El byte bajo va antes que el byte alto. $0200 se almacena como $00 $02, no como $02 $00. Esto se llama little-endian, y es una característica de toda la familia 6502. Aparecerá siempre que una dirección se almacene en dos bytes consecutivos en memoria.
En el próximo post juntamos todo esto y escribimos algo que realmente hace algo visible. Un bucle que llena la pantalla, usando lo que ahora sabemos sobre registros, memoria, instrucciones y el ciclo de ejecución.