Haga que el ensamblador "z80asm" coloque una instrucción en una dirección de memoria conocida

Dec 30 2020

Estoy escribiendo un sistema operativo muy básico para mi computadora Homebrew Z80. Como principiante absoluto del lenguaje ensamblador, logré obtener un "os más monitor de memoria" que puede mostrar el contenido de la memoria y cargar bytes en la RAM. Al hacerlo, escribí algunas "rutinas del sistema" para interconectar algunos dispositivos de E / S. Por ejemplo, tengo una rutina "Printc" que lee un byte y dibuja el carácter ASCII correspondiente en la pantalla.

Esto funciona con el código creado por el ensamblador porque el ensamblador decide dónde colocar el primer byte de la rutina y usa esa dirección cuando encuentra un comando jp con la misma etiqueta.

Ahora, me gustaría llamar a la rutina Printc desde un programa cargado dinámicamente. Puedo decir dónde colocó el ensamblador el primer byte de la rutina en la ROM gracias a la -lbandera, que produce una salida que contiene:

...
Print:    equ $043a Printc: equ $043e
Readc:    equ $0442 Readline: equ $0446
...

Ahora puedo escribir un programa como este:

ld a, 0x50     ; ASCII code for P
call 0x043e    ; Calls Printc

Este programa imprime la letra P con éxito: llamé a mi rutina Printc usando su dirección de memoria.

Esto está bien siempre que no cambie ningún código ensamblador que preceda a la declaración Printc en mi "sistema operativo". Si lo hago, la etiqueta Printc se asignará a otra dirección y mi programa existente dejará de funcionar.

¿Cuál es la solución canónica para este tipo de problemas? Lo único que me viene a la mente es crear una "tabla de salto" al comienzo de mi código ensamblador, antes de cualquier importación, con la lista de llamadas al sistema, esperando que obtengan siempre la misma dirección. Algo como:

...
; System routines
Sys_Print:
call Print
ret
Sys_Printc:
call Printc
ret
.... and so on

Pero esto parece bastante complicado ... ¿Es posible instruir al z80asmensamblador para que coloque la primera instrucción de la rutina en una dirección de memoria decidida por mí?

Respuestas

10 Raffzahn Dec 30 2020 at 04:31

¿Cuál es la solución canónica para este tipo de problemas?

No hay ninguna solución canónica, pero sí muchas variantes, todas útiles.

Lo único que me viene a la mente es crear una "tabla de salto" al principio.

Lo cual es perfecto y bueno. Excepto que, por lo general, se usarían saltos en lugar de llamadas para reducir la longitud del código, acelerar la ejecución y reducir la carga de la pila.


JUMP_TABLE:
PRINT    JP  _I_PRINT    ; First Function
READC    JP  _I_READC    ; Second Function
...

Pero esto parece bastante hackish ...

No, muchos sistemas 8080 y Z80 funcionan así.

El principal paso adelante es que todos los puntos de entrada se encuentran en una única ubicación y secuencia definida.

¿Es posible indicar al ensamblador z80asm que coloque la primera instrucción de la rutina en una dirección de memoria que decida yo?

Claro, use un ORG para ponerlo en la dirección que desee (* 1). Pero eso sería hackeo o al menos no muy progresista. Tener una tabla de salto de este tipo en una dirección definida es un gran comienzo. Por supuesto, consume algo de espacio. Tres bytes por entrada, pero solo dos son la dirección. ¿No sería mejor simplemente hacer una tabla de direcciones? Como:

SYS_TABLE:
         DW    _I_PRINT    ; First Function
         DW    _I_READC    ; Second Function

Llamar a una función sería como

         LD    HL, (SYS_TABLE+0)   ; Load Address of First Function - PRINT
         JP    (HL)                ; Do it

Esto se puede combinar fácilmente con una especie de selector de funciones:

SYS_ENTRY:
         PUSH  HL
         LD    H,0
         LD    L,A
         ADD   HL,HL
         ADD   HL,SYS_TABLE
         JP    (HL)

Ahora incluso la tabla de salto se puede mover en la ROM (o RAM) según sea necesario.

Llamarlo sería mediante el uso de un número de función, como lo han hecho muchos sistemas operativos, simplemente coloque el número de función en A y llame al punto de entrada del sistema predeterminado (SYS_ENTRY).

         LD    A,0   ; Print
         CALL  SYS_ENTRY

Por supuesto, se vuelve más legible si el sistema operativo proporciona un conjunto de equivalentes para los números de función :)

Hasta ahora, el programa cargado todavía necesita saber la dirección de la tabla (SYS_TABLE) o el punto de entrada para el selector (SYS_ENTRY). El siguiente nivel de abstracción movería su dirección a una ubicación definida, como 0100h, mejor tal vez en forma de JP, por lo que cualquier programa de usuario siempre llama a esa dirección fija (0100h) sin importar si su sistema operativo está en ROM o RAM o donde sea.

Y sí, si esto le parece familiar, lo es, ya que es de la misma manera que CP / M maneja las llamadas al sistema o MS-DOS.

Hablando de MS-DOS, proporciona una forma adicional (y más conocida) de llamar a una función del sistema operativo, las llamadas interrupciones de software, como la conocida INT 21h. Y hay algo bastante similar que ofrece el Z80 (y el 8080 antes): un conjunto de ocho vectores ReSTart distintos (0/8/16 / ...). El reinicio 0 está reservado para reinicio, todos los demás se pueden utilizar. Entonces, ¿por qué no utilizar el segundo (RST 8h) para su sistema operativo? Las llamadas a funciones se verían así:

         LD    A,0   ; Print
         RST   8h

Ahora, el código del programa de usuario está lo más separado posible de la estructura del sistema operativo y el diseño de la memoria, sin necesidad de ningún reubicador. La mejor parte es que, con un poco de manipulación, todo el selector encaja en los 8 bytes disponibles, lo que lo convierte en una codificación óptima.


Una pequeña sugerencia:

Si opta por alguno de estos modelos, asegúrese de que la primera función (0) de su sistema operativo sea una llamada que proporcione información sobre el sistema operativo, para que los programas puedan verificar la compatibilidad. Deben devolverse al menos dos valores básicos:

  • Número de versión de ABI
  • Número máximo de funciones admitidas.

El número de versión de ABI puede ser o no el mismo que el número de versión, pero no tiene por qué serlo. Debe incrementarse con cada cambio de API. Junto con el número máximo de funciones admitidas, un programa de usuario puede utilizar esta información para salir de manera ordenada en caso de incompatibilidad, en lugar de fallar a la mitad. Por lujo, la función también puede devolver un puntero a un

  • Estructura que contiene más información sobre el sistema operativo como
    • nombre / versión legible
    • Direcciones de varias fuentes
    • puntos de entrada 'especiales'
    • Información de la máquina como el tamaño de la RAM
    • interfaces disponibles, etc.

Solo digo...


* 1 - Y no, aparte de lo que algunos pueden suponer, ORG nunca debería agregar relleno o algo similar por sí solo. Los ensambladores que lo hacen son una mala elección. Org solo debe cambiar el nivel de dirección, no definir lo que hay en cualquier área 'saltada'. Hacerlo agrega muchos niveles de errores potenciales, al menos tan pronto como se realiza un uso avanzado de ORG, créame, ORG es una herramienta muy versátil cuando se realizan estructuras complejas.

Además, llenar las áreas 'vacías' con algo de relleno dará como resultado que este relleno sea parte del programa en lugar de una memoria intacta, lo que eliminará una herramienta principal para parches posteriores: el espacio EPROM no inicializado. Simplemente no definiendo y no cargando estas áreas, permanecerán en el estado despejado (todas en el caso de EPROM) y se pueden programar más tarde, por ejemplo, para retener algún código durante la depuración, o para aplicar un hotfix sin el necesidad de programar nuevos dispositivos.

Así que la memoria indefinida debería ser solo eso, indefinida. Y es por eso que incluso los formatos de salida / cargador de ensamblador más antiguos (piense en Motorola SREC o Intel HEX ) utilizados para la entrega de programas, desde la fabricación de ROM hasta los programas de usuario, admitían una forma de omitir áreas.

Para resumir: si uno quiere llenarlo, tiene que hacerlo expcit. z80asm lo hace bien.

12 WillHartung Dec 30 2020 at 04:55

El problema con Z80ASM específicamente es que toma la entrada del ensamblaje y escupe un archivo binario estático. Esto es bueno y malo.

En los sistemas "normales", la asignación de direcciones es, inevitablemente, responsabilidad del enlazador, no del ensamblador. Pero los ensambladores son lo suficientemente simples como para que muchos se salten ese aspecto del ciclo de construcción.

Dado que Z80ASM escupe imágenes binarias literales, en lugar de archivos "objeto", no necesita un enlazador. Pero tampoco le permitirá hacer necesariamente lo que quiere hacer.

Considere la omnipresente directiva ORG.

ORG le dice al ensamblador cuál es la dirección de inicio (origen, por lo tanto ORG) para el próximo código ensamblador.

Esto significa que si haces esto:

    ORG 0x100
L1: jp L1

El ensamblador ensamblará la instrucción JP a JUMP a la dirección de 0x100 (L1).

PERO, cuando escupe el archivo binario, el archivo tendrá solo 3 bytes. La instrucción de salto, seguida de 0x100 en formato binario. No hay nada en este archivo que diga, bueno, algo, que debe cargarse en 0x100 para "funcionar". Falta esa información.

Si lo haces:

    ORG 0x100
L1: jp L2

    ORG 0x200
L2: jp L1

Esto producirá un archivo de 6 bytes de longitud. Va a poner esas dos instrucciones JP una detrás de la otra. Lo único que hace la declaración ORG es decir cuáles deberían ser las etiquetas. Esto no es lo que cabría esperar.

Por lo tanto, simplemente agregar un ORG a su archivo no hará lo que desea hacer, a menos que tenga un método alternativo para cargar el código en el lugar específico donde desea que esté su código.

La única forma de hacer eso con Z80ASM listo para usar es rellenar su archivo de salida con bloques de bytes, espacios vacíos, que llenarán el binario para colocar su código en el lugar correcto.

Normalmente, esto es lo que hace el enlazador. El trabajo del enlazador es tomar sus piezas de código dispares y crear una imagen binaria resultante. Hace todo esto por ti.

En mi ensamblador, que no usó un enlazador, produjo un formato de archivo Intel HEX que incluye la dirección real para cada bloque de datos.

Entonces, para el ejemplo anterior, habría creado dos registros. Uno destinado a 0x100, el otro a 0x200, y luego el programa de carga hexadecimal pondría las cosas en el lugar correcto. Esta es otra alternativa, pero Z80ASM tampoco parece ser compatible.

Entonces.

Z80ASM es excelente si está creando imágenes ROM a partir de, digamos, arbitrariamente, 0x1000. Organizaría eso, obtendría un binario resultante y descargaría todo el archivo grabado en una EPROM. Es perfecto para eso.

Pero para lo que quiera hacer, necesitará rellenar su código para mover sus rutinas a los lugares correctos, o idear algún otro esquema de cargador para manifestar esto por usted.

5 GeorgePhillips Dec 30 2020 at 03:16

La orgdirectiva debe hacer específicamente lo que pide. Sin embargo, z80asm es un poco simplista en su formato de salida. En su lugar, puede usar dspara colocar rutinas en direcciones particulares:

        ds     0x1000
printc:
        ...
        ret

        ds     0x1100-$
readc:
        ...
        ret

Esto siempre se pondrá printcen 0x1000 y readcen 0x1100. Hay muchas desventajas. Si printccreciera más de 0x100, el programa no se ensamblaría y necesitará printcsepararse de alguna manera y poner el código adicional en otro lugar. Por esa y otras razones, una tabla de salto en una ubicación fija en la memoria es más fácil de administrar y más flexible:

           ds    0x100
v_printc:  jp    printc
v_readc:   jp    readc
           ...

Otra técnica es usar un solo punto de entrada y elegir la función usando un valor en el Aregistro. Esto será al menos un poco más lento, pero significa que solo se debe mantener un único punto de entrada a medida que cambia el sistema operativo.

Y en lugar de hacer un CALLal punto de entrada, colóquelo en una de las RSTubicaciones especiales (0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38) donde puede usarlo RST 0x18como una llamada de un solo byte a la ubicación de memoria 0x18. Por lo general, RST 0y RST 0x38se evitan ya que son el punto de entrada de encendido y las ubicaciones del controlador del modelo de interrupción 1 respectivamente.