Optimización de 8 bits para Z80 y 6502 en 2026

por Oscar Toledo G. 26-mar-2026
La guerra de las CPU
Recientemente desarrollé CVBasic, un compilador de BASIC para procesadores Z80, 6502 y TMS9900. Además de los desafíos de optimización de código, también existe el problema de las librerías. Los procesadores Z80 y TMS9900 tienen instrucciones para suma y resta de 16 bits, pero el procesador 6502 solo puede hacerlo con una secuencia de instrucciones. Las instrucciones de multiplicación y división solo las admite directamente el TMS9900.
Esto significa que si un programa para Z80 o 6502 require multiplicación o división entonces una subrutina debe ser llamada para hacer el trabajo. Las operaciones no se pueden integrar directamente en el código porque son complicadas.

Corto pero lento

Para la versión inicial de CVBasic, preparé rutinas razonables para multiplicación y división. Por ejemplo, aquí está el código original para la rutina de multiplicación:

	; Fast 16-bit multiplication.
_mul16:
	ld b,h	; 5
	ld c,l	; 5
	ld a,16	;  8
	ld hl,0	; 11
.1:
	srl d	; 10
	rr e	; 10
	jr nc,.2	; 8/13
	add hl,bc	; 12
.2:	sla c	; 10
	rl b	; 10
	dec a	; 5
	jp nz,.1	; 11
	ret	; 11
Esta subrutina realiza la operación HL = HL x DE. El bucle se ejecuta 16 veces, cada vez corriendo un bit el multiplicador y si es uno, añade el multiplicando. El multiplicando se corre un bit a la izquiera para tomar en cuenta el valor diferente en cada posición de bit.
Es una subrutina pequeña y luce eficiente. Sin embargo, es lenta. Para empezar siempre corre 16 veces. Supongamos el peor caso donde todos los bits del multiplicador son 1 (esto es DE = $ffff). Los ciclos usados son 5 + 5 + 8 + 11 + 16 * (10 + 10 + 8 + 12 + 10 + 10 + 5 + 11) + 11 para un total de 1256 ciclos.
Los ciclos de cada instrucción son referidos de Grauw.nl (mira la columna Z80+M1). Como CVBasic se escribió originalmente para MSX y Colecovision, ambas plataformas añaden un estado de espera en cada ciclo M1.
¿Porqué 1256 ciclos son demasiado? El MSX y Colecovision están basados en el mismo procesador de video, y típicamente visualizan 60 cuadros por segundo en una pantalla de televisión. Un procesador Z80 corre a 3.58 mhz. Esto significa que el Z80 corre aproximadamente 59659 ciclos en cada cuadro de video.
Estos sesenta mil ciclos son todo el tiempo disponible para un juego antes de que se comienza a ilustrar el siguiente cuadro de video. La instrucción Z80 más simple usa 5 ciclos, por ejemplo, NOP o LD A,B. Esto significa un máximo de 11931 instrucciones por cuadro, pero algunas instrucciones son aún más lentas. Por ejemplo, ADD A,5 usa 8 ciclos, o LD HL,5000 que usa 11 ciclos.
Ahora 59659 / 1256 ciclos = 47 multiplicaciones por cuadro. Este es un valor muy bajo y ni siquiera toma en cuenta la actualización del VRAM, sobrecarga de la lógica de interrupción, el reproductor de música en el fondo, y la lógica del juego.
Por supuesto, casi ningún juego Z80 realiza operaciones de multiplicación, en lugar de eso muchos juegos utilizan tablas precalculadas, o operaciones de corrimiento de bits.

Salida temprana

Sin embargo, hay unos pocos casos que requieren usar esta operación, y para esto necesitamos una mejor subrutina.
Mi primer idea fue desenrollar el bucle para ahorra algunos ciclos, utilizar una salida temprana si el multiplicador se vuelve cero, y también intercambiar operandos para que el multiplicador sea siempre el valor más pequeño.

; Fast 16-bit multiplication.
_mul16:
	or a	; 5
	sbc hl,de	; 17
	add hl,de	; 12
	jr nc,$+3	; 8/13
	ex de,hl	; 5 Smallest operand in DE.

	ld b,h	; 5
	ld c,l	; 5
	ld hl,0	; 11
.1:
	srl d	; 10
	rr e	; 10
	jp nc,$+4	; 11
	add hl,bc	; 12
	sla c	; 10
	rl b	; 10
	srl d	; 10
	rr e	; 10
	jp nc,$+4	; 11
	add hl,bc	; 12
	sla c	; 10
	rl b	; 10
	ld a,d	; 5
	or e	; 5
	jp nz,.1	; 11
	ret	; 11
Supongamos que solo multiplica por cero, uno, dos o tres. El prólogo tomará 5+17+12+13+5+5+11 = 68 ciclos.
La tabla de ciclos para los diferentes operandos:
Después de añadir el prólogo es 202, 214, y 226 ciclos, esta es una mejora de 6 veces sobre la subrutina previa que tenía un tiempo de ejecución constante. Y el caso más complicado es 1255 ciclos (un ciclo menos que la rutina más sencilla)

Reducción de intensidad

Así que esta subrutina se comporta casi igual que la original cuando el multiplicador es un número grande. ¿Qué tal una subrutina más optimizada? Me di cuenta que el byte alto estaba usando operaciones de 16 bits para cálculos que ni siquiera eran utilizados. Por ejemplo, $100 x $ff = $ff00, pero $100 x $100 = $10000, esto significa que solo el byte bajo del multiplicador es usado para el byte alto del resultado.
Dividí el bucle en dos partes, una para el byte alto que solo usa operaciones de 8 bits, y otra para el byte bajo con el código de salida temprana.

_mul16:
	or a		; 5
	sbc hl,de	; 17
	add hl,de	; 12
	jr nc,$+3	; 13/8
	ex de,hl	; 5 Smallest operand in DE.

	ld b,h		; 5
	ld c,l		; 5
	ld hl,0		; 11
	ld a,d	; 5
	or a		; 5 High-byte is zero?
	jp z,.2		; 11, Yes, jump.
	xor a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	add a,a		; 5
	sla d		; 10
	jp nc,$+4	; 11
	add a,c		; 5
	ld h,a		; 5
.2:			;
.3:	srl e		; 10
	jp nc,$+4	; 11
	add hl,bc	; 12
	ret z		; 6/12
	sla c		; 10
	rl b		; 10
	srl e		; 10
	jp nc,$+4	; 11
	add hl,bc	; 12
	ret z		; 6/12
	sla c		; 10
	rl b		; 10
	jp .3		; 11
Calculemos de nuevo los tiempos para multiplicar por 0, 1, 2 y 3:
Ciclos totales para cada caso: 122, 134, 181, y 193 ciclos. Esto significa que la prueba extra para el byte alto a cero es compensada por la optimización.
Ahora el caso más complicado, los registros con el valor más alto 65535:
El total es 89 + 253 + 491 = 833 ciclos, 37% de mejora de velocidad sobre el código original.
Dada la buena eficiencia de multiplicar por 0, 1, 2, y 3, se vuelve una alternativa razonable a las tablas. En un ejemplo del mundo real, mi juego Metro Wars se benefició mucho de esto al calcular las posiciones de origen para copiar los tiles del fondo en el scroll pixel a pixel, y también permitiendo más tiempo para la lógica del juego evitando lentitud cuando aparecen muchos enemigos y balas.
Por supuesto, la rutina es más larga (96 bytes ahora contra 23 de la original), pero me parece que la velocidad es más importante y que es una adición valuable a las herramientas de programación.
En una nota separada, la rutina _div16 aparentemente no podía ser optimizada, hasta que descubrí que si el byte alto del dividendo es cero, se puede evitar ejecutar la mitad de la subrutina ¡Una mejora de velocidad de casi el doble!

Miremos las rutinas 6502

Como dije antes, CVBasic apoya tres procesadores diferentes. Uno de estos es el 6502. La librería 6502 viene en dos "sabores": Creativision (usando el mismo VDP como el Colecovision) y NES/Famicom (un VDP completamente diferente)
Ambas tienen las mismas rutinas _mul16 y _div16. Esta es la rutina _mul16:

	; 16-bit multiplication.
_mul16:
	PLA
	STA result
	PLA
	STA result+1
	PLA
	STA temp2+1
	PLA
	STA temp2
	LDA result+1
	PHA
	LDA result
	PHA
	LDA #0
	STA result
	STA result+1
	LDX #15
.1:
	LSR temp2+1
	ROR temp2
	BCC .2
	LDA result
	CLC
	ADC temp
	STA result
	LDA result+1
	ADC temp+1
	STA result+1
.2:	ASL temp
	ROL temp+1
	DEX
	BPL .1
	LDA result
	LDY result+1
	RTS
La operación asume que algunos datos están en memoria (temp para el multiplicador) y extrae el multiplicando de la pila en temp2. Note que usa result para guardar la dirección de retorno.
El 6502 es un procesador estilo RISC. Solo hay tres registros principales: A (o acumulador), X y Y. Instrucciones comunes usan 2 ciclos, las instrucciones con acceso a memoria usan 3 ciclos, y las instrucciones indexadas usan 4 o 5 ciclos.
Usa la misma técnica de correr el valor completo de 16 bits para calcular el resultado. Sin embargo, la detección de cero tomaría mucho tiempo porque el operando esta en memoria, y aún así todavía es más rápida que la rutina Z80, porque las instrucciones del 6502 son más rápidas incluso si el procesador tiene un reloj más lento. Por ejemplo, la instrucción INC toma 2 ciclos en el 6502, y la misma instrucción toma 4 ciclos en el Z80. Esto significa que el 6502 corre a la mitad de la velocidad (2 mhz. tipico) y es más rápido o igual que el Z80.
Después de un breve vistazo al bucle principal, descubrí que el registro Y nunca era usado. El 6502 no puede realizar muchas operaciones con X o Y. En lugar de eso, usamos Y como memoria, leyéndolo con TIA (copiando Y en A), y guardando con TAY.

	LDA #0
	STA result
	TAY
	LDX #15
.1:
	LSR temp2+1
	ROR temp2
	BCC .2
	LDA result
	CLC
	ADC temp
	STA result
	TYA
	ADC temp+1
	TAY
.2:	ASL temp
	ROL temp+1
	DEX
	BPL .1
	LDA result
	RTS
El nuevo código es más rápido, y todavía estoy evaluando la posibilidad de desenrollar el bucle en dos bucles de 8 bits. Ahorrando una instrucción en cada bucle (3 ciclos * 16 veces = 48 ciclos * 2 = total 96 ciclos) usando memoria extra.
También optimicé la rutina de división. Miremos el bucle interno:

	LDX #15
.2:
	ROL temp2
	ROL temp2+1
	ROL result
	ROL result+1
	LDA result
	SEC
	SBC temp
	STA result
	LDA result+1
	SBC temp+1
	STA result+1
	BCS .3
	LDA result
	ADC temp
	STA result
	LDA result+1
	ADC temp+1
	STA result+1
	CLC
.3:
	DEX
	BPL .2
De nuevo un bucle corriendo 16 veces, corriendo el dividendo, y checando si el divisor puede ser substraído. Cada vez que realiza una substracción exitosa, pone un uno en el resultado, de lo contrario suma de nuevo el divisor para restaurar el valor. Esto es un port directo de la rutina Z80 (SUB HL,DE / JR NC / ADD HL,DE)
De nuevo el registro Y no es usado. El resultado intermedio puede ser calculado en A y Y, y solo guardado si la substracción es exitosa:

	LDX #15
.2:
	ROL temp2
	ROL temp2+1
	ROL result
	ROL result+1
	LDA result
	SEC
	SBC temp
	TAY
	LDA result+1
	SBC temp+1
	BCC .3
	STY result
	STA result+1
.3:	DEX
	BPL .2
El byte bajo es guardado en el registro Y, y el byte alto se mantiene en el acumulador. La bandera de acarreo hace la comparación, y si la substracción es posible, los valores de Y y A son guardados en el resultado. result es de hecho el resto de la división, mientras que el resultado actual de la división esta disponible en temp2. Técnicamente, hace una comparación con substracción, y el resultado esta disponible si es requerido; esta es una forma completamente diferente de pensar en relación con el Z80.
La mejora de velocidad es significativa.
Espero que haya disfrutado este artículo. Puedo pasar todo el día optimizando código pero otro trabajo tiene que hacerse, y mientras escribía este artículo puede ver otra oportunidades para optimizar el código. La historia nunca acaba.
El código fuente para el compilador CVBasic y sus librerías esta disponible en https://github.com/nanochess/cvbasic
¿Te gustó este artículo? Invitame un café en ko-fi o considera apoyarme mensualmente.

Enlaces

Última actualización: 26-mar-2026