Es más larga la lista de cosas que no pretendo que la de cosas que sí pretendo. Por ejemplo, no pretendo impartir lecciones a nadie. Sí, en cambio, espero que la gente participe enviando material: preguntas, explicaciones, ejemplos, nombres de libros, de utilitarios de asistencia a programadores, etc.
Quien más quien menos, todos hemos leído acerca de ventajas y desventajas de la programación en assembler. Lo bueno del assembler es que con él uno cobra mayor conciencia respecto del grado de complejidad de cada rutina. Una contra que tiene es que obliga al programador a estar mucho más concentrado de lo que estaría si usara C o Basic.
Creo que a todo aquel que le guste la programación (en el sentido más general del término), debería ser sensible a la estética de este lenguaje.
Se dice que el assembler es un lenguaje de bajo nivel, pero no creo que haya lenguajes de alto nivel o de bajo nivel. El nivel es una propiedad del programa, no del lenguaje. Si bien es cierto que es más cómodo escribir rutinas de bajo nivel en assembler, no todo lo que se escribe en assembler puede ser caracterizado como de bajo nivel. La BIOS y el DOS proveen una cantidad enorme de rutinas listas para usar que producen resultados notables a cambio de muy poco. Los que escribieron las rutinas de la BIOS programaron a bajo nivel. Nosotros, cuando simplemente las llamamos estamos programando en un nivel decididamente más alto.
En fin, espero que esta iniciativa encuentre algún eco entre los PC-Hostianos para que de alguna manera pensemos que tal vez podemos recurrir a alguno de nosotros en lugar de esperar que un amigo que viaja nos traiga una valija llena de "librerías" para el Clipper.
Desde el punto de vista del programador, la única diferencia entre el 8088 y el 8086 es la rapidez. El 8086 es más veloz gracias a la arquitectura de su "bus" de datos. Básicamente las facilidades que se agregan a los nuevos modelos están relacionadas con la "multiarea" (diferentes programas conviven en la máquina al mismo tiempo) algo para lo que DOS no fue dise¤ado.
Para iniciarse en la programación en assembler bajo DOS conviene pensar que uno está trabajando con el 8088.
Programa en RAM: .. .. 05 01 00 .. ..
| | |
| | +------- Segundo byte del operando
| +---------- Primer byte del operando
+------------- Código de instrucción
Registros dentro de la CPU:
+----+----+
AX | AH | AL |
+----+----+
BX | BH | BL |
+----+----+
| | |
...
El 8088 tiene 16 registros. Uno de ellos se llama AX. Si por ejemplo el
microprocesador está a punto de ejecutar la instrucción de arriba, leerá
el 05 y lo interpretará como una instrucción elemental. En el caso del
05, esa instrucción significa: sume a AX el número contenido en los dos
próximos bytes de la memoria (en nuestro ejemplo 01 00) y reemplace AX
con el resultado de esa suma. Una vez comprendido esto, el micro sabe
que los dos bytes siguientes al 05 son datos y no códigos de
instrucción. Interpreta estos datos como el número "1" (ya que por una
característica interna invierte el orden de los bytes antes de
interpretarlos como una cantidad), suma "1" al contenido de AX y
reemplaza el valor de AX con el resultado (AX = AX + 1). Después de
esto, el microprocesador lee el byte siguiente al 00, lo interpreta como
un código de instrucción y continúa con la ejecución del programa.
En assembler uno usa nemónicos para referirse a las instrucciones en código máquina. Por ejemplo el ensamblador generará el código de tres bytes: 05 01 00 a partir de la instrucción ADD AX,1
Código fuente assembler Código máquina
ADD AX,1 --------> 05 01 00
del lenguaje assembler. Aquí ADD es el nemónico para la suma. Otros
nemónicos son SUB para restar, MOV para mover, MUL para multiplicar, CMP
para comparar, etc. Un programa en assembler consiste principalmente de
transferencias de datos de la memoria a los registros, de operaciones
sobre los datos contenidos en los registros y del movimiento de los
resultados nuevamente a la memoria.
Lo primero que hay que saber (segunda parte)
Más sobre los registros
Los registros del 8088 son todos de 16 bits (2 bytes). Se dividen en
grupos según los usos para los que fueron pensados. El primero de estos
grupos lo constituye el de registros de trabajo o de uso general.
REGISTROS DE USO GENERAL. Sirven para contener cualquier tipo de
información. Sus nombres son AX, BX, CX y DX y pueden guardar datos de
2 bytes (1 word).
Se supone que los datos del programa residen en la RAM (que es externa a
la CPU). Para que el programa pueda operar con ellos debe llevarlos a
los registros, operar con los registros y devolver los resultados a la
memoria.
EJEMPLO. Si queremos tener dos variables de 1 word (2 bytes) de largo
escribimos
VALOR_1 DW 305
VALOR_2 DW 1076
Donde VALOR_1 y VALOR_2 son los nombres que les damos a las varibles, DW
le informa al programa ensamblador que las variables son "Data Word", o
sea van a ocupar 2 bytes cada una. Los valores 305 y 1076 son los
valores iniciales que tendrán estas variables.
Supongamos que queremos que nuestro programa sume las variables VALOR_1
y VALOR_2, entonces ponemos:
MOV AX,VALOR_1 ; Mover a AX el dato contenido en VALOR_1
ADD AX,VALOR_2 ; Sumar a AX el dato contenido en VALOR_2
La primera instrucción (MOV) mueve el contenido de los 2 bytes de la RAM
cuya dirección está dada por VALOR_1 al registro AX. Esto hace que se
borre el contenido que tenía AX y se reemplace por el valor 305.
La segunda instrucción suma el contenido de VALOR_2 al contenido de AX y
reemplaza a AX con el resultado.
Si después queremos salvar el resultado de la suma en la RAM, ponemos:
MOV RESULTADO,AX ; Mover a la variable RESULTADO el valor de AX
Para eso debimos haber definido previamente la variable RESULTADO
poniendo
RESULTADO DW ?
Donde el signo ? le dice al programa ensamblador que no deseamos
inicializar esos dos bytes de RAM.
Antes de ejecutarse la 1ra instrucción:
En RAM En la CPU
--------------- ---------
VALOR_1 : 305 AX : basura
VALOR_2 :1076
RESULTADO : ???
Después de ejecutarse la 1ra instrucción:
En RAM En la CPU
--------------- ---------
VALOR_1 : 305 AX : 305
VALOR_2 : 1076
RESULTADO : ???
Después de ejecutarse la 2da instrucción:
En RAM En la CPU
--------------- ---------
VALOR_1 : 305 AX : 1381
VALOR_2 : 1076
RESULTADO : ???
Después de ejecutarse la 3ra instrucción:
En RAM En la CPU
--------------- ---------
VALOR_1 : 305 AX : 1381
VALOR_2 : 1076
RESULTADO : 1381
El código fuente del programa sería algo así:
JMP SUMA ; Saltar por encima de la zona de datos
VALOR_1 DW 305 ; Variable de 2 bytes con valor incial
VALOR_2 DW 1076 ; Variable de 2 bytes con valor incial
RESULTADO DW ? ; Variable de 2 bytes sin incializar
SUMA: ; Nombre de la rutina
MOV AX,VALOR_1 ; AX := VALOR_1
ADD AX,VALOR_2 ; AX := AX + VALOR_2
MOV RESULTADO,AX ; RESULTADO := AX
RET ; Fin de la rutina
Lo primero que hay que saber (tercera parte)
Los registros de trabajo
En un mensaje anterior dijimos que los registros de trabajo sirven
para contener cualquier tipo de información. Si bien esto es cierto,
cada uno tiene una función específica, para la cual fue dise¤ado.
El registro AX que estuvimos usando hasta ahora, aparece en las
operaciones aritméticas y de entrada y salida de datos. Se llama
ACUMULADOR.
El registro CX se utiliza como contador en operaciones iterativas.
Aparece en conjunto con la instrucción LOOP para contar las veces que se
ejecutará un bucle.
El registro BX se utiliza especialmente para direccionar la memoria.
Ejemplo:
Queremos 'limpiar' una zona de memoria de 20 bytes llamada BUFFER,
llenándola con ceros:
JMP START
BUFFER DB 20 DUP(?) ; Define un buffer en memoria de 20 bytes
START:
MOV CX,20 ; Inicializar CX con la cantidad de bytes de BUFFER
MOV BX,OFFSET BUFFER ; BX = dirección de la variable BUFFER
L1:
MOV B[BX],0 ; Poner un 0 en el byte indicado por BX
INC BX ; Ubicar a BX apuntando al próximo byte
LOOP L1 ; Mientras CX no sea 0, repetir
RET ; Fin de la rutina
cosas nuevas
1) MOV BX,OFFSET BUFFER
OFFSET es una directiva al ensamblador (no una instrucción de la
CPU) para referirnos a la dirección de una variable en vez de a su
contenido.
Si hubiéramos escrito MOV BX,BUFFER, estaríamos guardando en BX los
dos primeros bytes de nuestro buffer.
2) MOV B[BX],0
Los corchetes nos indican que nos estamos refiriendo al lugar de la
memoria cuya dirección está en BX. Esta instrucción no pone un 0 en
BX sino que lee el contenido de BX, lo interpreta como una dirección
y pone el 0 en esa dirección.
CPU MEMORIA
+---------------+ 101 | ... |
BX | 1 0 3 | +-------------+
+----------|----+ 102 | ... |
| +-------------+
+-------------------> 103 | 0 |
MOV B[BX],0 +-------------+
104 | ... |
La letra B (de BYTE) antes de [BX] es para indicar que nos referimos
a un solo byte (el de direccion 103 en el ejemplo). Otra posibilidad
sería W[BX] (de WORD) con lo cual hubiéramos puesto un 0 en las
posiciones 103 y 104.
3) INC BX
Suma 1 al contenido de BX : BX:=BX + 1
4) LOOP L1
Esta instrucción hace lo siguiente:
- Decrementa el registro CX.
- Se fija si CX llego o no a 0.
- Si CX no es 0, salta a la direccion L1.
- Sino, continúa con la instrucción que sigue.
Todo en una sola instrucción de lenguaje máquina!!
El programa equivalente en Pascal sería:
var
Buffer : array[1..20] of byte;
i : word;
begin
For i:=1 to 20 do
Buffer[i]:=0;
end;
Nota:
Hay maneras mejores de programar este tipo de rutinas. Pero ese
---- es tema del próximo mensaje.
Lo primero que hay que saber (cuarta parte)
Primero un repaso.
Cuando uno trabaja con esa notación normalmente *no* necesita conocer el
equivalente decimal del número. La notación hexadecimal debe ser
entendida como una manera abreviada de referirse a los bits de un byte.
En assembler es conveniente pensar un byte como una tira de ocho 0s y
1s. Para no escribir tanto, esos 8 bits se dividen en dos grupos de 4
bits cada uno llamaodos nibbles. Cada nibble se representa por un dígito
hexadecimal de 0 a 9 y de A a F.
Hasta ahora vimos que el 8088 tiene 4 registros de trabajo AX, BX, CX y
DX. Cada uno puede guardar 16 bits o dos bytes o 4 dígitos
hexadecimales. Es común llamar *word* o *palabra* a estas unidades de
información. Por ejemplo, uno puede guardar en DX la palabra 0A13BH con
la instrucción:
MOV DX,0A13BH
Después de ejecutarse esta instrucción, DX pasa a contener la tira de 16
bits correspondiente a 0A13BH, o sea 1010 0001 0011 1011. El byte más
significativo o BYTE ALTO es 0A1H y el menos significativo o BYTE BAJO
es 03BH.
En el 8088 es posible acceder por separado a los bytes altos y bajos de
cada uno de los registros de trabajo (no así de los otros registros que
veremos más adelante). Ejemplos:
MOV BL,DH MOV DH,DL MOV AL,0
Estas instrucciones operan sobre bytes en lugar de words. La tercera
pone a cero el byte bajo de AX. (Aquí H es por High, no por Hexa. L es
por Low).
UN CERO A LA IZQUIERDA
Como habrán visto, siempre que escribimos un número hexadecimal ponemos
un cero a la izquierda del número y una H a la derecha. Ejemplo 0A1BH.
Si no lo hiciéramos no podríamos distinguir entre cosas como: MOV BL,AH
y MOV BL,0AH. La primera instrucción copia el registro AH al BL, la
segunda copia la constante 0AH (10 decimal) al mismo registro.
Hay sin embargo una redundancia que es la 'H' porque podríamos convenir
en que toda cantidad que empiece en '0' es hexadecimal. De hecho el A86
usa esta convención, no así otros ensambladores como MASM. A pesar de
esa redundancia tal vez sea una notación útil para poner más en
evidencia que se trata de cantidades hexadecimales. Por ejemplo 013 para
el A86 es igual a 013H pero con la segunda notación nos aseguramos de
que nadie piense que estamos hablando del 13 decimal.
Con respecto a otras variantes de la instrucción MOV digamos que también
es posible volcar el contenido de un registro en memoria. Ejemplos:
MOV [0100H],DX MOV BX,DX
En la primera instrucción, la palabra contenida en DX se transfiere a
los dos bytes de memoria cuyas direcciones son 0100H y 0101H. En la
segunda, el contenido de DX se copia en BX.
Miremos con más detalle la instrucción MOV [0100H],DX.
Aquí se trata de mover 2 bytes desde un registro de la CPU al lugar de
RAM indicado por 0100H. Bien, los dos bytes a mover son los contenidos de
DH y DL y las dos posiciones de memoria son la número 0100H y la número
0101H. ¨Pero cuál byte va en cuál dirección? el byte bajo va en la
dirección más baja y el alto en la más alta: DL en 0100H y DH en 0101H.
CPU RAM
+----+----+ MOV [0100H],DX +----+
DX | A1 | 3B | ----------------> 0100 | 3B |
+----+----+ +----+
DH DL 0101 | A1 |
+----+
Esto al principio puede parecer antinatural ya que uno tendería a
guardar primero A1 y después 3B. Sin embargo es una convención usada por
muchas CPU (Yo particularmente nunca supe el por qué esta convención, si
alguien sabe o se le ocurre algo avise!).
Esta convención también hay que respetarla cuando se efectúan
movimientos de datos en sentido inverso. Ejemplo:
MOV DX,[0100H]
copiará en DL el byte de la dirección 0100H y en DH el de la dirección
0101H.
Por supuesto, no tiene sentido una operación del tipo MOV 0A13BH,DX que
trata de mover el contenido de DX en una constante.
Convertir un valor a un string
Supongamos que queremos mostrar en pantalla el contenido de AL (el byte
bajo del registro AX).
En ese caso vamos a necesitar convertir el valor de AL a un string. Por
ejemplo si AL = 01AH, tenemos que imprimir el string de dos caracteres
'0', '1' y 'A' y a continuación la letra 'H' que indica notación
hexadecimal.
El '0' y la 'H' son constantes, no dependen del contenido de AL así que
concentremonos en '1' y 'A'.
El '1' corresponde al nibble alto de AL y la 'A' al bajo. La idea es
asilar el nibble alto, convertirlo a ASCII e imprimirlo, después aislar
el bajo y hacer lo mismo.
Aislar el nibble alto de AL
Para hacer esto tenemos que usar una instrucción que todavía no habíamos
visto SHR que significa SHift Right (correr hacia la derecha).
Esta instrucción básicamente mueve hacia la derecha la tira de bits del
operando:
+----------------------+
0-->| los bits se corren |--> y el último se cae
+----------------------+ a un lugar que se
Bits: 7->6->5->4->3->2->1->0 llama CARRY que por
ahora no nos interesa.
Ejemplo:
Si AL = 01AH, la instrucción SHR AL,1 desplaza los bits de modo que
después de ejecutada AL = 05H.
+---------+
AL |0001 1010| = 01AH
+---------+
|
SHR AL,1 |
|
+---------+
AL |0000 0101| = 05H
+---------+
Bien si aplicamos 4 veces la instrucción SHR AL,1 entonces lo que
estamos haciendo es desplazar el nibble alto hacia el bajo y llenando
con 0s los 4 bits del nibble alto:
Si AL = 01AH, entonces después de:
SHR AL,1
SHR AL,1
SHR AL,1
SHR AL,1
AL = 001H. Esto es lo que llamamos asilar el nibble alto de AL.
Aislar el nibble bajo de AL
Aquí la cosa es mucho más sencilla porque existe una instrucción que
hace el trabajo y es AND. Esta instrucción efectúa un AND lógico bit a
bit entre sus dos operandos (un AND lógico entre dos bits es igual a la
multiplicación de los bits)
Para aislar el nibble bajo, lo único que tenemos que hacer es reemplazar
el alto por ceros. Luego, a cada bit del nibble alto lo multiplicamos
por 0 y a cada bit del bajo por 1.
Esto corresponde a efectuar un AND con la constante binaria 0000 1111
que en hexadecimal se escribe 0FH.
Luego si AL = 01AH, después de
AND AL,0FH
AL = 0AH
AL=01AH Cte. 0FH
+---------+ +---------+
|0001 1010| |0000 1111|
+---------+ +---------+
\ /
AND AL,0FH
|
+---------+
AL |0000 1010| = 0AH
+---------+
Convertir AL a un ASCII
Supongamos ahora que ya hemos aislado un nibble. Entonces AL tiene 0s en
su nibble alto y un valor en su nibble bajo. Vamos a convertir ese valor
a un caracter ASCII imprimible en pantalla. Ese caracter ASCII debe
corresponder al del dígito hexadecimal representado por el nibble.
Si el nibble bajo es 0001 debemos imprimir '1'
0010 '2'
... ...
1000 '8'
1001 '9'
1010 'A'
1011 'B'
... ...
1111 'F'
Esto se puede hacer con una tabla:
TABLA_HEXA DB '0123456789ABCDEF'
La idea es usar el valor de AL como índice a la tabla TABLA_HEXA y
obtener de ahí el dígito correspondiente.
En assembler esto es trivial. Existe una instrucción que se llama XLAT
(por trans LATe) que hace exactamente eso. La dirección de la tabla debe
estar en el registro BX. El código es
MOV BX,OFFSET TABLA_HEXA
XLAT
y listo, AL sale con el valor ASCII correspondiente.
TABLA_HEXA
+--+
|30| '0'
+--+
+----+ |31| '1'
AL | 0E | +--+
+----+
| XLAT +--+
+-------------+ |39| '9'
| +--+
accedera a esta dirección: BX | |41| 'A'
+ | +--+
AL | | |
|
+------> +--+
|45| 'E'
+------< +--+
| |46| 'F'
+-------------+ +--+
|
+----+
AL | 45 | : 'E'
+----+
Poner todo junto.
PRINT_AL:
MOV AH,AL ; Copiar AL en AH para después
SHR AL,1 ; Ver explicación arriba
SHR AL,1 ; de como aislar el nibble alto
SHR AL,1 ; de AL con 4 instrucciones
SHR AL,1 ; SHift Right
MOV BX,OFFSET TABLA_HEXA ; BX = dirección de la tabla
XLAT ; Ver explicación arriba
CALL SHOW_AL ; Emitir ASCII del nibble alto
MOV AL,AH ; Recuperar AL de AH
AND AL,0FH ; Aislar nibble bajo
XLAT ; Ver explicación arriba
CALL SHOW_AL ; Imprimir ASCII del nibble bajo
RET
SHOW_AL es la rutina que imprime en pantalla el ASCII dado en AL. Hay
muchas maneras de hacer esto. Ahora damos una, en un próximo mensaje
discutiremos otras.
SHOW_AL:
MOV DX,AX ; Copiar AX en DX
MOV AH,02H ; Función para imprimir caracter en DL
INT 021 ; LLamar al DOS para que imprima
MOV AX,DX ; Recuperar AX de DX
RET
Lo primero que hay que saber (quinta parte)
Los Registros de Segmento
Ya vimos los registros de trabajo AX, BX, CX y DX. Ahora vamos a ver
otro grupo de 4 registros de la CPU que se llaman de *segmentos*:
DS Data Segment
ES Extra Segment
CS Code Segment
SS Stack Segment
Los dos primeros (DS y ES) se usan para acceder a los datos de la
memoria, los dos últimos (CS y SS) tienen más que ver con el código del
programa.
El Registro DS
En mensajes anterior discutimos instrucciones como MOV [0100H],DX.
Dijimos que los dos bytes contenidos en DX se copian a los bytes de
memoria con direcciones 0100H y 0101H. Bien, esta es una verdad
incompleta. Lo que realmente ocurre es algo un poco más complejo que se
llama segmentación y que vamos a describir ahora.
En las instrucciones, todos los direccionamientos son en realidad
*desplazamientos* (en inglés *offsets*) *relativos* a un lugar de memoria
que es el comienzo del segmento de datos.
De ese modo todas las referencias a los datos se hacen *con respecto* a
un segmento. La sintaxis MOV [0100H],DX sobreentiende que la dirección
aludida 0100H *no* se refiere a la posición absoluta 0100H (256 decimal)
de memoria, sino a la posición que se encuentra 256 bytes más arriba del
comienzo del segmento.
MEMORIA
+------+
| |
+------+
| |
...
SEGMENTO DE DATOS OFFSET
+------+
PRIMER BYTE DEL SEGMENTO --> | | 0000H
+------+
| | 0001H
+------+
| | 0002H
+------+
...
| |
+------+
| | FFFEH
+------+
ULTIMO BYTE DEL SEGMENTO --> | | FFFFH
+------+
...
| |
+------+
Los datos dentro del segmento se ubican por medio de su offset. El
offset es un valor de 16 bits (2 bytes).
En notación hexadecimal el offset está compuesto por 4 cifras. Por lo
tanto los posibles offsets cubren un rango de 64 Kbytes desde 0000H
hasta 0FFFFH. El primer byte del segmento tiene offset 0, el siguient 1,
y así siguiendo hasta llegar al último byte del segmento que tiene
offset 65535 (0FFFFH).
El criterio para fijar el comienzo del segmento de datos es el
siguiente:
Cualquier posición de memoria puede ser el comienzo de un segmento con
la úncia condición de que su dirección absoluta sea múltipLo de 16.
En hexadecimal 16 corresponde a 010H por lo tanto, cualquier dirección
ablsoluta cuyo valor hexadecimal termine en 0 puede ser definida como el
comienzo del segmento de datos.
El 8088 maneja direcciones absolutas de 20 bits (5 cifras hexa). Esto
significa que los comienzos de segmentos de datos tienen que tener
direcciones absolutas formadas por 4 cifras hexadecimales seguidas de la
cifra 0.
Ejemplos de direcciones absolutas de comienzos de segmentos *válidos*
00000H
00400H
0B800H
Ejemplos de direcciones absolutas que *no* pueden ser comienzos de
segmentos:
00208H
A0001H
DIREECIONES ABSOLUTAS:
+------------------------------------------------+
| EL 8088 MANEJA DIRECCIONES DE 20 BITS |
| O SEA, CADA DIRECCION DE MEMORIA |
| ESTA COMPUESTA POR 5 CIFRAS |
| HEXADECIMALES |
| |
| PRIMERA DIRECCION DE MEMORIA: 00000H |
| EJEMPLOS DE OTRAS DIRECCIONES: 0A178H, 00465H |
| ULTIMA DIRECCION DE MEMORIA: FFFFFH |
+------------------------------------------------+
DIRECCIONES LOGICAS:
+------------------------------------------------+
| LA MEMORIA TIENE UNA DIVISION LOGICA |
| (NO FISICA) EN SEGMENTOS DE 64 KB. |
| |
| EL SEGMENTO DE DATOS PUEDE COMENZAR EN |
| CUALQUIER POSICION CON DIRECCION |
| ABSOLUTA TERMINADA EN 0. |
| (MULTIPLO DE 16) |
| |
+------------------------------------------------+
Una vez definido el comienzo del segmento de datos, uno tiene acceso a
los 64 Kb del segmento con un offset que va desde 0000H hast FFFFH. En
las instrucciones solamente se indica el offset, el comienzo del
segmento se sobreentiende.
¨Como se fija el principio del segmento de datos?
Muy fácil. Dado que la última de las 5 cifras hexadecimales de la
dirección absoluta del comienzo de un segmento debe ser 0, resulta que
lo que hay que fijar es el valor de las primeras 4. Este valor se pone
en DS.
Ejemplos:
PRINT_ARROBA:
MOV AX,0B800H ; Cambiar por 0B000H si Hércules
MOV DS,AX ; Comienzo del Data Seg en B8000H (abs)
MOV AL,'@' ; Caracter a imprimir en pantalla
MOV [0],AL ; Ponerlo en el offset 0 del segmento
RET
SCROLL_LOCK:
MOV AX,040H ; Ya hablaremos del segmento 040H
MOV DS,AX ; lo usa la BIOS para sus variables
MOV AL,[017H] ; En el OFFSET 017H guarda los SHIFT STATUS
OR AL,BIT 4 ; El bit 4 corresponde a SCROLL LOCK
MOV [017H],AL ; Encender la lucesita del teclado
RET
NOTA: en los dos ejemplos se pasa el valor a DS por medio de AX. Esto se
debe a que *no* existen instrucciones como MOV DS,0B000H o MOV DS,040H.
Resumiendo.
Para ubicar un dato en la memoria se necesita conocer dos cosas (1) el
comienzo del segmento y (2) el offset dentro del segmento.
La CPU conoce la dirección absoluta del comienzo del segmento agregando
un cero al valor de DS. El offset dentro del segmento lo proporciona el
programa cada vez que quiere hacer un acceso a memoria.
Lo primero que hay que saber (sexta parte)
Los registros de segmento (continuación)
En el mensaje anterior vimos que
1) La CPU necesita 5 cifras hexadecimales para obtener una dirección de
memoria. Por lo tanto para acceder a un byte de la RAM se debe
proporcionar esas 5 cifras.
2) En las instrucciones de lenguaje máquina esas 5 cifras no se
suministran explícitamente. En cambio de eso aparece un OFFSET o
DESPLAZAMIENTO relativo al comienzo de un SEGMENTO. La CPU obtiene
internamente la dirección absoluta del comienzo del segmento
agregando la cifra hexadecimal 0 a las cuatro cifras hexa del
REGISTRO DE SEGMENTO.
Ejemplo:
Supongamos DS = 1B14H. Entonces la instrucción
MOV AL,[080H]
copia en AL el byte cuya dirección absoluta es
+------------------------ SEGMENTO
| +---------------- OFFSET
1B140H + 80H = 1B1C0H ----- DIRECCION
Notación y terminología.
Esta forma segmentada de calcular posiciones de memoria, es decir con un
OFFSET contado a partir de un SEGMENTO se simboliza frecuentemente en la
forma
SEGMENT:OFFSET
donde SEGMENT es un número Hexadecimal de 4 cifras que indica al
segmento aludido. La dirección absoluta de memoria del comienzo del
segmento se obtiene agregando la cifra hexadecimal 0 a las 4 cifras que
componen el valor de SEGMENT. OFFSET es el valor del desplazamiento
(considerado positivo) a partir del inicio del segmento en donde se
encuentra la posición en cuestión.
Ejemplos:
La notación corresponde a la dirección absoluta
0040H:0017H 00417H
1B14H:0080H 1B1C0H
La cuenta que internamente realiza la CPU es la siguiente
SEGMENT*10
+
OFFSET
-------------
DIRECCION ABS
00400H 1B140H
+ +
0017H 0080H
------- -------
00417H 1B1C0H
El registro ES
El registro EXTRA es, como su nombre lo indica, un registro que puede
servir para acceder a posiciones de memoria que no caen dentro del
segmento apuntado por el valor actual de DS. También tiene un uso
específico en el juego de instrucciones de cadenas de caracteres
(strings) que veremos más adelante.
Supongamos que nuestro registro DS apunta a una zona de memoria
cualquiera en donde tenemos nuestro segmento de datos. Supongamos que
necesitamos acceder a una posición de memoria que está fuera de los
límites de ese segmento. Entonces podemos hacer que ES apunte a ese
segmento particular para hacer nuestro acceso especial.
En la práctica hay dos segmentos especiales a los cuales un programa
necesita acceder ocasionalmente. Uno de ellos es el segmento donde se
encuentra la memoria de Video, el otro es el segmento donde la BIOS
almacena sus variables.
Ejemplo del uso de ES para acceder a Video RAM
La ubicación de la memoria de video depende del adaptador que esté
instalado. En la Hércules, es el segmento 0B0000H, en las demás es el
0B8000H.
En una pantalla de 80x25 en modo texto, el primer byte del segmento (el
que tiene offset 0) contiene el ASCII del caracter que se encuentra en
la primera fila y la primera columna de la pantalla. El byte siguiente
se llama de ATRIBUTO su valor sirve para determinar el color con el que
se visualiza el caracter. En la Hércules (sin color) el atributo dice se
el carácter está en Inverso, Subrrayado, Titilante, Intenso, Normal o
alguna combinación de éstos.
Ejemplo:
Hacer titilar el primer caracter de la pantalla
MOV AX,0B000H ; Suponemos Hércules
MOV ES,AX ; ES apunta al segmento de video RAM
OR ES:[1],10000000B ; Si bit 7 = 1, atributo BLINKING
RET
Existen otras formas equivalentes de escribir OR ES:[1],10000000B
Una de ellas es en dos líneas
ES:
OR [1],10000000B ; Valor binario
que es la forma que usa el programa DEBUG. La otra es
ES OR [1],BIT 7
que es la que se puede usar en el A86.
Ejemplo del uso de ES para acceder a varibles de la BIOS
Las variables utilizadas por las rutinas de la BIOS están en el segmento
00400H. Este valor tiene su razón de ser y paso a explicarla.
La primera dirección de memoria es la 00000H, a partir de ahí existe una
tabla de 256 direcciones correspondientes a rutinas especiales del
sistema que se llaman INTerrupciones y que están explicadas en otros
mensajes. Sin entrar en detalles sobre el tema de las interrupciones,
digamos que por tratarse de direcciones, cada uno de las 256 entradas de
la tabla consta de 4 bytes, 2 para el OFFSET y dos para el SEGMENT.
Bien, dado que 256 es 0100H, toda la tabla ocupa 4*0100H = 0400H. Como
la primera dirección de la tabla es la 0000H, entonces la primera
posición siguiente a la tabla (y fuera de ella) es la 0400H, es decir el
comienzo del segmento 040H. Bueno, es justamente en ese segmento en
donde la BIOS guarda sus varibles.
Ejemplo:
Determinar la posición del cursor.
MOV AX,040H ; Recordar que no se puede hacer MOV ES,040H
MOV ES,AX ; y por eso se pasa el valor mediante AX
MOV AX,ES:[050H] ; Leer la posición del cursor
MOV COLUMNA,AL ; Salvar número de columna en una variable
MOV FILA,AH ; Salvar número de fila en otra variable
RET
Este ejemplo es un poco más interesante que el anterior. Las variables
de nuestro programa (COLUMNA y FILA) se encuentran en el segmento de
datos apuntado por DS por lo tanto las instrucciones MOV COLUMNA,AL y
MOV FILA,AH copian los valores de AL y AH en las posiciones de memoria
del segmento de datos cuyos offsets están representados simbólicamente
por COLUMNA y FILA. En cambio, dado que ES se¤ala al segmento 040H, la
instrucción MOV AX,ES:[050H] copia en AX los dos bytes que se encuentran
en los offsets 050H y 051H del segmento 040H (no del segmento de datos
apuntado por DS).
Digamos por último, que si uno tiene que hacer varios accesos a un
segmento que está fuera del segmento de datos, conviene reeplazar
momentaneamente el valor de DS por el del segmento al que se desea
acceder y luego restablecer el valor de DS. Esto puede resultar más
cómodo y elegante que anteponer repetidamente ES: a los offsets de las
direcciones en cuestión.
Lo primero que hay que saber (séptima parte)
Los Registros de Segmento (continuación)
Nos queda por ver dos registros de segmento CS y SS. Mientras los
registros DS y ES sirven para ubicar datos dentro de un segmento, CS
sirve para definir el segmento del código ejecutable. El registro SS es
el de *pila* y lo vamos a ver con detenimiento en el próximo mensaje.
El registro CS
Dado que el código ejecutable de un programa reside en la memoria, la
CPU usa la misma forma de direccionamiento segmentado para ubicar las
diferentes instrucciones que lo componen.
Dentro de la memoria conviven muchas programas. En la zona más baja
están los residentes, luego viene el espacio disponible para los
programas de aplicación y finalmente en las zonas más altas se
encuentran las rutinas de la BIOS y el DOS. Todo esto abarca un total de
1 Mb de memoria y en consecuencia las diferentes porciones de código
se encuentran en distintos segmentos.
Para seguir el flujo de un programa, la CPU apunta a la próxima
instrucción mediante dos registros internos CS e IP. El registro CS
contiene el SEGMENT y el IP (Instruction Pointer) el OFFSET de la
instrucción dentro del segmento de código. De esta forma la dirección de
la próxima instrucción a ejecutar se puede simbolizar por CS:IP.
Cuando uno necesita efectuar un salto hacia otra zona del programa hace
cosas como:
VER_SI_ES_DIGITO:
CMP AL,'0' ; Compara AL con 030H = ASCII de '0'
JB L1 ; Si es menor, no era. Salir.
CMP AL,'9'+1 ; Si > o =, comparar con el sgte a '9'
CMC ; Invertir el resultado de la comparación
L1: ;
RET
Los nombres VER_SI_ES_DIGITO y L1 son rótulos o etiquetas (en inglés
"labels") que el programador pone como nombres simbólicos. Le indican al
programa ensamblador que reemplace cada referencia a ellos por la
dirección que representan según su ubicación. Observar los dos puntos
":" a continuación del nombre.
Esta rutina puede usarse para determinar si el byte contenido en AL
corresponde al ASCII de un dígito decimal:
CALL VER_SI_ES_DIGITO ; Ver si AL contiene ASCII de dígito decimal
JB NO_ES ; Si sale comparación por menor, no es
... ; Si sale comparación por > o =, sí es
Por ejemplo si VER_SI_ES_DIGITO cae en la dirección 0100H, entonces
La instrucción Ocupa las direcciones
-------------------------------------------------------
CMP AL,'0' 0100H 0101H
JB L1 0102H 0103H
CMP AL,'9'+1 0104H 0105H
CMC 0106H
RET 0107H
Por lo tanto L1 cae en la dirección 0107H y la instrucción JB L1
efectuará un salto condicional a 0107H, dependiendo del valor de AL.
1) Cuando se comienza a ejecutar VER_SI_ES_DIGITO, CS tiene el valor
correspondiente al inicio del SEGMENTo de código. Su valor *no* le
interesa al programador. La instrucción
CMP AL,'0'
está en la dirección 0100H (=256 decimal) posiciones más arriba del
comienzo del segmento. Bien, en ese momento el registro IP vale
0100H. El programa fuente se *desentiende* del registro IP.
2) Cuando se ejecuta la instrucción CMP AL,'0' el valor de IP se
incrementa en 2 (ya que CMP AL,'0' ocupa 2 bytes). Ahora la CPU
ejecuta la instrucción cuya dirección está dada por el offset 0102H
(en el segmento de código). Esta instrucción es JB L1 (Jump if
Below). Produce un salto condicional a la dirección simbolizada por
L1 que como vimos es 0107H. La condición para que el salto se efectúe
es el resultado de la comparación anterior (salta si es menor).
3) Si AL era menor que '0', entonces se produce un salto a la dirección
0107H. En otras palabras, JB L1 modifica internamente el
contenido de IP y lo pone en 0107H. De ese modo, la próxima
instrucción que se ejecutará será la que está en el offset 0107H del
segmento de código: RET que indica el fin de la rutina.
Si AL era mayor o igual que '0', entonces no se efectúa el salto y el
contenido de IP pasa a ser 0104H. En este caso la próxima instrucción
es CMP AL,'9'+1. Dado que el ASCII del caracter 9 es 039H, esta
instrucción compara el contenido de AL con 03AH. Ahora tenemos que
avisar que AL no era un dígito decimal en el caso que el resultado de
la comparación sea "mayor o igual". En este caso invertimos el
resultado de la comparación con la instrucción CMC (CoMplemnt Carry)
que invierte ese resultado (veremos en un próximo mensaje los
detalles sobre el Carry y otros Flags)
Ninguna de estas instrucciones altera el valor de CS. Es decir el flujo
del programa se mantiene dentro del segmento y pasa de una instrucción a
otra en el mismo segmento. El registro que cambia es IP y su
modificación la realiza la CPU a medida que ejecuta las diferentes
instrucciones.
Otra cosa. Si el programador supiera que L1 caerá en el offset 0107H,
podría poner JB 0107H en lugar de JB L1. Pero obviamente JB L1 es más
práctico.
Ahora bien. Supongamos que el programa necesita acceder a una rutina que
está en otro segmento. Esto se puede deber a dos motivos:
1) El programa de aplicación es demasiado largo para entrar en un solo
segmento de 64 K bytes.
2) El progrma necesita hacer una llamada a un programa residente en otro
segmento (ya sea un TSR - Terminate and Stay Resident- o a una rutina
de la BIOS o el DOS)
En esos casos, lo que se hace es un salto FAR (lejano). En este tipo de
saltos, no solamente se indica el OFFSET del comienzo de la rutina a
ejecutar sino el SEGMENTo de código correspondiente. Cuando se ejecuta
un salto FAR, la CPU cambia el contenido de ambos registro CS e IP de
acuerdo con lo indicado por la instrucción.
Ejemplos:
WARM_BOOT:
MOV AX,040H ; Pasar al segmento 040H de datos
MOV DS,AX ; a través de AX - BIOS RAM
MOV AX,01234H ; Clave para BOOT tipo Ctrl-Alt-Del
MOV [072H],AX ; debe ir en offsets 72H/72H del seg. 40H
JMP FAR 0FFFFH:0 ; Salto FAR, cambian CS e IP a FFFFH y 0
COLD_BOOT:
MOV AX,040H ; Parecida a la anterior pero produce un
MOV DS,AX ; Booteo tipo Botón Reset o de encendido
MOV [072H],AX ; Siempre que en 72H/72H no haya 1234H
JMP FAR 0FFFFH:0 ; Que es más profundo que el anterior
El registro de datos apunta a 040H y por lo tanto en ambas rutinas la
instrucción MOV [072H],AX se refiere a la posiciones de memoria
040H:072H y 040H:073H. Mientras tanto el segmento de código CS al
segmento en dónde está nuestra rutina.
A medida que se ejecutan las instrucciones, CS permanece constante e IP
aumenta justo lo necesario para pasar al offset de la próxima
instrucción. Finalmente la instrucción de salto incondicional JMP FAR
0FFFFH:0 modifica tanto CS como IP poniendo CS=FFFFH e IP=0.
Lo primero que hay que saber (octava parte)
Los registros de segmento. El registro SS.
El último registro de segmento que nos queda por ver es el SS: Stack
Segment o Segmento de Pila.
Para comprender la función que cumple el registro SS necesitamos saber
que es el Stack.
El stack es una zona de la memoria principal (RAM). Algunas
instrucciones del lenguaje máquina necesitan salvar temporalmente datos
en la memoria para recuperarlos más tarde. Por lo tanto hay que
indicarle a la CPU qué zona de memoria puede usar para guardar esas
varibles temporales.
Por ejemplo, la instrucción CALL del lenguaje máquina del 8088 se usa
para hacer una llamada a una subrutina. La idea es que cuando el micro
encuentra una instrucción
CALL SUBRUTINA
altera momentáneamente el flujo secuencial del programa, y salta a la
(dirección) SUBRUTINA. La subrutina efectúa su trabajo y termina con la
instrucción RET. Entonces la CPU vuelve su atención a la instrucción
siguiente a CALL SUBRUTINA y continúa secuencialmente.
Lo que se hace es destinar una porción de la memoria para este fin. Esa
zona especial se llama stack. Cuando la CPU accede al stack lo hace a
través de dos registros SS y SP. El registro SP se llama Stack Pointer y
contiene el offset dentro del segmento indicado por SS que corresponde a
la dirección que se desea acceder.
En el ejemplo de abajo se quiere imprimir el contenido del registro AL
como dos cifras hexadecimales consecutivas: una correspondiente al
nibble alto de AL y la otra al nibble bajo. Por ejemplo si AL contiene
el valor 1DH, habrá que imprimir el caracter '1' y a continuación el
caracter 'D'.
Como se va a imprimir dos veces, creamos una subrutina de impresión:
PRINT_CHAR. Esta subrutina se encarga de imprimir el caracter cuyo ASCII
está en AL.
JMP MOSTAR_AL ; Saltar por encima de los datos
TABLA_HEX DB '01234567890ABCDEF'
MOSTRAR_AL:
MOV AH,AL ; Salvara AL en AH
SHR AL,1 ; Pasar el nibble alto de AL al bajo
SHR AL,1 ; en el 80286 se puede poner SHR AL,4
SHR AL,1 ;
SHR AL,1 ;
MOV BX,OFFSET TABLA_HEX ; Es necesario para la instucción XLAT
XLAT ; reemplaza 0 por '0',..,0FH por 'F'
CALL PRINT_CHAR ; Imprimir caracter en AL
MOV AL,AH ; Recurperar AL
AND AL,0FH ; Aislar nibble bajo de AL
XLAT ; convertir valor a cifra hexa
CALL PRINT_CHAR ; Imprimir cifra hexa del nibble bajo
RET
PRINT_CHAR:
MOV DX,AX ; Salvar AX en DX
MOV AH,02 ; Función para imprimir caracter en DL
INT 021H ; imprimirlo a través del DOS
MOV AX,DX ; Recuperar AX
RET ; Volver a quien haya llamado
Cuando comienza la ejecución de MOSTRAR_AL, CS:IP apunta a la dirección
de memoria donde se encuentra la primera instrucción de la rutina, es
decir MOV AH,AL. A medida que las instrucciones se van ejecutando IP
aumenta la cantidad de bytes que ocupa cada instrucción. De ese modo
cuando termina la ejecución CS:IP está apuntado a la próxima
instrucción.
Cuando CS:IP llega a CALL PRINT_CHAR se debe *saltar* a PRINT_CHAR. Pero
como CALL se usa para ir_a_ejecutar_y_volver la CPU debe recordar cuál es
la dirección de la instrucción que sigue físicamente al CALL.
Cuando la CPU terminó de leer la instrucción CALL, el registro IP
contiene el offset de la instrucción que sigue físicamente a CALL.
Internamente la instrucción CALL produce lo siguiente:
1. Salva el contenido de IP en el stack
2. Reempla IP por el offset de la subrutina (en este caso PRINT_CHAR)
Luego de esto, CS:IP apunta a PRINT_CHAR. Se van ejecutando una a una
las instrucciones de PRINT_CHAR y a medida que IP aumenta. Cuando CS:IP
llega a RET entonces internamente pasa lo siguiente:
1. Se reemplaza el valor actual de IP por el valor que se encuentra en
el stack.
de este modo CS:IP vuelve a apuntar a la dirección siguiente al CALL y
el programa continúa su flujo normal. Observar que la memorización de la
dirección de retorno es temporal: una vez que se usó ya no se necesita
más y puede ser liberada.
La cuestión fundamental es la siguiente. Cómo sabe la CPU en que lugar
del stack debe depositar la dirección de retorno y, más tarde, dónde
debe ir a leer el dato que antes depositó en el stack?
El stack no podría tener lugar para una sola dirección. Para ver esto
imaginemos una situación en la que la RUTINA_1 llama a la RUTINA_2 y
esta a su vez llama a la RUTINA_3.
En este caso la CPU necesita guardar la dirección de retorno de RUTINA_2
a RUTINA_1 en un lugar y la dirección de retorno de RUTINA_3 a la
RUTINA_2 en otro.
Por otro lado las direcciones de retorno deben liberarse inmediatamente
después que hayan sido leídas porque de otro modo estarían ocupando
lugar del stack innecesariamente.
Para resolver esto la CPU usa una técnica especial, simple e ingeniosa
que paso a describir.
Cuando el DOS carga un progrma en memoria y le transfiere el control SS
contiene el valor del segmento de memoria donde va a estar el stack y SP
tiene el valor 0FFFEH. Esto significa que SS:SP apunta a la última word
del stack (los dos bytes con direcciones 0FFFEH y 0FFFFH). Esa word
contiene cierta información que no vamos a discutir en este momento.
El programa comienza a ejecutarse. Cuando se llega al primer CALL pasa
lo siguiente:
1. SP se *decrementa* en 2 (pasando a valer 0FFFCH)
2. La CPU guarda la dirección de retorno en la word con dirección
SS:SP; es decir, en los 2 bytes con offsets 0FFFCH y 0FFFDH.
Continua la ejecución de la subrutina hasta que ésta a su vez llama a
otra subrutina, aumentando en 1 el orden de anidamiento. Entonces
nuevamente:
1. SP = SP - 2
2. [SS:SP] <- dirección de retorno
De este modo, cada vez que se profundiza el anidamiento a través de un
CALL, internamente la CPU ejecuta los pasos 1. y 2.
El stack se va llenando desde el fondo hacia adelante, es decir desde
las direcciones altas de memoria hacia las más bajas en forma
descendente a lo largo del segmento reservado al stack.
Este descenso es acompa¤ado por el registro SP. Este registro en todo
momento apunta a la última word almacenada en el stack.
Cuando se llega al último nivel de anidamiento, SP está apuntando al
lugar del stack donde está guardada la dirección de retorno al nivel
inmediato superior. En ese momento se ejecuta la instrucción RET. Esta
hace el trabajo inverso al que hizo CALL:
1. IP = [SS:SP] (pone en IP la word contenida en los bytes con offsets
SP y SP+1)
2. SP = SP + 2
Esto produce la liberación de los dos bytes más bajos del stack y un
salto al nivel inmediato superior.
De ese modo, cada RET permite salir de un nivel y RETornar al nivel de
arriba en el orden inverso al que se fue descendiendo a traves de las
llamadas anidadas hacia subrutinas.
Cuando el progrma ternina, nuevamente SP tiene el valor 0FFFEH y DOS
recupera su palabra de retorno que se mantuvo intacta a lo largo de la
ejecución del programa.
Hay una manera excelente de comprender esta técnica: verla funcionar.
Para eso recomiendo FUERTEMENTE a quien lea esto que ensamble el
programa de ejemplo de arriba con el A86 y que después lo ejecute paso a
paso desde el D86.
Para hacer esto tiene que:
1) Copiar el programa a un archivo de texto (formato ASCII)
2) Salvar el archivo de texto con un nombre cualquiera, por ejemplo
PRUEBA.8 (en el A86 se acostumbra a usar la extensión .8 para los
fuentes.)
3) Salga al DOS y escriba
A86 PRUEBA.8
4) Si copió bien no debería producirse ningún error. Si hay error entrar
nuevamente al fuente. A86 insterta llamadas de atención en los
lugares donde están los errores.
5) Una vez que el ensamblado salga bien ejecutar desde el DOS
D86 PRUEBA.COM
6) Una vez adentro del D86, ejecutar las instrucciones 1 a 1 pulsando la
tecla F1.
7) Observar como cambian los registros. Especialmente el IP y el SP, y
observar como los datos son enviados al stack cuando se ejecuta un
CALL y son retirados del stack cuando se ejecuta un RET.
8) Para salir del D86 pulsar x, o escribir Q .
Buena suerte!
Lo primero que hay que saber (novena parte)
Los Registros de Segmento (Ultima parte).
Cómo usar el Stack.
En el mensaje anterior vimos que la CPU usa para sí una zona de memoria
llamada stack, guardando en ella las direcciones de retorno cuando
encuentra una instrucción CALL a una subrutina.
Cada vez que el programa encuentra una instrucción CALL, automáticamente
la CPU se encargará de *apilar* la dirección de la instrucción siguiente
(dirección de retorno). Es decir, decrementará el registro SP (Stack
Pointer) en 2 y guardará en las direcciones SS:SP y SS:SP+1 el offset de
la dirección de retorno.
Más tarde, cuando la CPU encuentre un RET, copiará la dirección de
retorno al registro IP (Instruccion Pointer) e incrementará SP en 2
(liberando 2 bytes de la pila). De ese modo el flujo del programa
continuará en la instrucción siguiente a la del CALL.
Vimos que este mecanismo aseguraba que las rutinas pueden llamarse unas
a otras produciendo diferentes niveles de anidación.
Dado que la pila tiene su propio registro de segmento SS, es posible
destinarle 64 Kb completos. Como cada nivel de anidamiento produce la
utilización de 2 bytes de la pila, obtenemos una capacidad de anidación
de más de 32000 niveles. Toda una exageración.
Sin embargo, la pila no solamente se usa para guardar las direcciones de
retorno, también se usa para guardar variables.
Por ejemplo, los lenguajes de alto nivel como el C y el Pascal, guardan
en la pila los parámetros que una rutina le pasa a una subrutina.
En Pascal, cuando la subrutina toma el control ejecuta la instrucción
BEGIN. Esta instrucción hace lo siguiente:
PUSH BP ; Apilar BP
MOV BP,SP ; Copiar SP en BP
1. PUSH BP, "apila" el contenido del reistro BP. Si una rutina (madre)
llama a una subrutina (hija), cuando la hija recibe el control, los 2
bytes más accesibles de la pila contienen la dirección de retorno a
la madre. Cuando se apila BP, se agregan 2 bytes a la pila. La
instrucción PUSH se encarga de agregar esos dos bytes a la pila y de
decrementar en 2 el registro SP:
Antes del PUSH BP
|Retorno|Parámetros pasados por la madre|Resto del stack
+-------+-------------------------------+---------------
| memoria alta --->
SP ---->+
Después del PUSH BP
|CopiaBP|Retorno|Parámetros pasados...
+-------+-------+--------------------------
| memoria alta --->
SP ---->+
2. Depués de copiar SP en BP, si el primer parámetro es de 2 bytes
(ejemplo), el Pascal hace algo así:
MOV AX,[BP+4] ; Saltar sobre Copia de BP y dir de Retorno
Observación:
No hay que olvidar que los parámetros están el segmento apuntado por SS.
La CPU asocia por defecto el registro de segmento SS al registro BP
cuando se lo usa como en la instrucción MOV de arriba.
Por este motivo se usa el registro BP y no otro. Si pusiéramos MOV
AX,[SI+4] la CPU copiaría en AX los 2 bytes con offset SI+4 del segmento
apuntado por DS. (En los programas .EXE los registros DS y SS
normalmente contienen valores diferentes, en los .COM no.)
-------------------------Fin observación-----------------------
En assembler, el uso más frecuente que se hace de la pila es para
preservar el contenido de registros que van a ser estropeados.
Ejemplo 1:
MATRIZ DB 10 DUP (20 DUP ?) ; Matriz de 10 x 20
RUTINA:
XOR AX,AX ; AX = 0
MOV SI,OFFSET MATRIZ ; DS:SI -> MATRIZ
MOV CX,10 ; Inicializar CX
L0: ; Repetir 10 veces
CALL SUBRUTINA ;
LOOP L0 ; CX=CX-1. Salto a L0 si CX<>0.
RET
Es importante que SUBRUTINA no estropee el valor de CX que está haciendo
de contador del bucle. Si la SUBRUTINA va a usar el registro CX, primero
debe apilarlo, y antes de salir despilarlo:
SUBRUTINA:
PUSH CX ; Salvar CX en el stack
MOV CX,20 ; Inicializar CX = 20
L0:
ADD AX,[SI] ; Sumar el elemento SI
INC SI ; Apuntar al elemento siguiente
LOOP L0 ; Repetir 20 veces
POP CX ; Recuperar CX
RET
También podríamos haber escrito:
...
PUSH CX
CALL SUBRUTINA
POP CX
...
en lugar de efectuar el PUSH y el POP en la subrutina.
Ejemplo 2:
La siguiente rutina sirve para transferir un bloque de la pantalla de
video a una variable de memoria.
El bloque se define por un sector de caracteres consecutivos con
atributo inverso (reverse).
La rutina primero busca el bloque en la pantalla de video y de
encontrarlo lo transfiere en forma de un StrinZ (string terminado con un
byte 0) a una variable en memoria llamada BUFFER.
En este ejemplo el uso que se hace de la pila es para intercambiar el
contenido de los registros de segmento DS y ES.
En el 8088 no existe una instrucción que intercambie el contenido de dos
registros de segmento. Sin embargo uno puede simular este intercambio de
la siguiente manera:
PUSH DS
PUSH ES
POP DS
POP ES
Después del segundo PUSH, la word más accesible del stack es la que
contiene el valor de ES. Al efectuar el POP DS, esa word pasa a DS y
luego POP ES recoge en ES el valor apilado por el PUSH DS.
El A86 tiene incorporada una macro XCHG que en el caso XCHG DS,ES se
desensambla como arriba. Notar que no podría haberse usado el código
PUSH DS
MOV DS,ES
POP ES
por la sencilla razón de que no existe la instrucción MOV DS,ES.
Precisamente en el A86 tiene una macro MOV incorporada que en el caso
MOV DS,ES se ensambla como PUSH ES, POP DS.
BUFFER DB 25*80 + 1 DUP ? ; Buffer para el StrinZ
VIDEO_SEG EQU 0B000 ; 0B800 para CGA, EGA o VGA
REVERSE EQU 70H ; Atributo de caracter inverso
TRAER_BLOCK:
MOV DI,OFFSET BUFFER ; DI indice al BUFFER
MOV AX,VIDEO_SEG ;
MOV ES,AX ; ES = Segmento de RAM de Video
XCHG DS,ES ; Esto es en realidad una macro
MOV CX,25*80 ; 25 filas x 80 columnas
CALL FIND_BEGIN ; Buscar comienzo de bloque
JNE > L3 ; Si no hay, bloque ...
DEC SI,2 ; DS:SI -> Comienzo del bloque
L1:
LODSW ; AL = ASCII, AH = Atributo
STOSB ; ASCII -> BUFFER
CMP AH,REVERSE ; Estaba dentro del bloque?
JNE > L2 ; No, fin transferencia
LOOP L1 ; Repetir hasta fin de pantalla
L2:
DEC DI,2 ; Retroceder 2 bytes
L3:
XOR AL,AL ; AX = 0
STOSB ; Marcar fin de StringZ
XCHG ES,DS ; Reparar ES y DS
RET
FIND_BEGIN:
PUSH CX ; Salvar CX en el Stack
XOR SI,SI ; SI = 0
L1:
LODSW ; AL = ASCII, AH = Atributo
CMP AH,REVERSE ; Comienzo de bloque?
JE > L2 ; Sí, salir
LOOP L1 ; Repetir hasta fin de pantalla
L2:
POP CX ; Recuperar CX
RET