EL ENSAMBLADOR Y EL LENGUAJE C

Anuncio
Lenguaje Ensamblador
1
EL ENSAMBLADOR Y EL LENGUAJE C
El lenguaje C es sin duda el más apropiado para la programación de sistemas, pudiendo sustituir al
ensamblador en muchos casos. Sin embargo, hay ocasiones en que es necesario acceder a un nivel más bajo
por razones de operatividad e incluso de necesidad (programas residentes que economicen memoria,
algoritmos rápidos para operaciones críticas, etc.). Es entonces cuando resulta evidente la necesidad de
poder emplear el ensamblador y el C a la vez.
Para comprender este capítulo, basta tener unos conocimientos razonables de C estándar. Aquí se
explicarán las funciones de librería necesarias para acceder al más bajo nivel, así como la manera de
integrar el ensamblador y el C.
USO DEL TURBO C y BORLAND C A BAJO NIVEL.
A continuación veremos algunas funciones, macros y estructuras de la librería DOS.H del Turbo C.
ACCESO A LOS PUERTOS DE E/S.
int inp (int puerto);
/* leer del puerto E/S una palabra (16 bits) */
int inport (int puerto);
/* leer del puerto E/S una palabra (16 bits) */
unsigned char inportb (int puerto);
/* leer del puerto E/S un byte (8 bits) */
int outp (int puerto, int valor);
/* enviar al puerto E/S una palabra (16 bits) */
void outport (int puerto, int valor);
/* enviar al puerto E/S una palabra (16 bits) */
void outportb (int puerto, unsigned char valor); /* enviar al puerto E/S un byte (8 bits) */
Aunque pueden parecer demasiadas, algunas son idénticas (caso de inp() e inport()) y otras se
diferencian sólo ligeramente en el tipo de los datos devueltos, lo cual es irrelevante si se tiene en cuenta que
el dato devuelto es descartado (caso de outp() y outport()). En general, lo normal es emplear inport() e
inportb() para la entrada, así como outport() y outportb() para la salida. Por ejemplo, para enviar el EOI al
final de una interrupción hardware se puede ejecutar: outportb(0x20, 0x20);
ACCESO A LA MEMORIA.
int peek (unsigned seg, unsigned off);
char peekb (unsigned seg, unsigned off);
void poke (unsigned seg, unsigned off, int valor);
void pokeb (unsigned seg, unsigned off, char valor);
unsigned FP_OFF (void far *puntero);
unsigned FP_SEG (void far *puntero);
void far *MK_FP (unsigned seg, unsigned off);
/* leer la palabra (16 bits) en seg:off */
/* leer el byte (8 bits) en seg:off */
/* poner palabra valor (16 bits) en seg:off */
/* poner byte valor (8 bits) en seg:off */
/* obtener offset de variable tipo far */
/* obtener segmento de variable tipo far */
/* convertir seg:off en puntero tipo far */
Las funciones peek(), peekb(), poke() y pokeb() tienen una utilidad evidente de cara a consultar y
modificar las posiciones de memoria. Cuando se necesita saber el segmento y/o el offset de una variable del
programa, las macros FP_OFF y FP_SEG devuelven dicha información. Por último, con MK_FP es posible
asignar una dirección de memoria absoluta a un puntero far.
Por ejemplo, si se declara una variable:
char far *pantalla_color;
se puede hacer que apunte a la memoria de vídeo del modo texto de los adaptadores de color con:
pantalla_color = MK_FP (0xB800, 0);
Lenguaje Ensamblador
2
y después se podría limpiar la pantalla con un bucle:
for (i=0; i<4000; i++) *pantalla_color++=0;
CONTROL DE INTERRUPCIONES.
void enable(void);
/* habilitar interrupciones hardware, equivalente a STI */
void disable(void);
/* inhibir interrupciones hardware, equivalente a CLI */
LLAMADA A INTERRUPCIONES.
Para llamar a las interrupciones es conveniente conocer antes ciertas estructuras y uniones.
struct WORDREGS {
unsigned int ax, bx, cx, dx, si, di, cflag, flags;
};
struct BYTEREGS {
unsigned char al, ah, bl, bh, cl, ch, dl, dh;
};
union REGS {
struct WORDREGS x;
struct BYTEREGS h;
};
struct SREGS {
unsigned int es; unsigned int cs; unsigned int ss; unsigned int ds;
};
struct REGPACK {
unsigned r_ax, r_bx, r_cx, r_dx;
unsigned r_bp, r_si, r_di, r_ds, r_es, r_flags;
};
A continuación, se listan las funciones que permiten invocar las interrupciones:
int int86(int interrupción, union REGS *entrada, union REGS *salida);
int int86x(int interrupción, union REGS *entrada, union REGS *salida, struct REGS *rsegmento);
void intr(int interrupción, struct REGPACK *registros);
Las dos primeras funciones se basan en la declaración de dos uniones: una para entrada y otra para
salida, que simbolizan los valores iniciales (antes de llamar a la interrupción) y finales (tras la llamada) en
los registros. Si se desea que la misma unión que indica los valores iniciales devuelva los finales, se puede
indicar por duplicado:
union REGS regs;
regs.h.ah = 0;
regs.h.al = 0x13;
int86 (0x10, ®s, ®s);
/* VGA 320x200 - 256 colores */
/* cambiar modo de vídeo */
La diferencia entre int86() e int86x() reside en que la última permite trabajar con los registros de
segmento (la estructura SREGS se puede inicializar con los valores que tienen que tener los registros de
segmento antes de llamar a la interrupción; a la vuelta, dicha estructura habrá sido modificada para indicar
el valor devuelto en los registros de segmento tras la interrupción).
Hay quien prefiere trabajar con REGPACK, que con una sola estructura permite también operar
con los registros de segmento y la emplea tanto para enviar como para recibir los resultados. El
Lenguaje Ensamblador
3
inconveniente, poco relevante, es que sólo admite registros de 16 bits, lo que suele obligar a hacer
desplazamientos y forzar el empleo de máscaras para trabajar con las mitades necesarias:
struct REGPACK bios;
bios.r_ax = 0x13;
intr (0x10, &bios);
/* VGA 320x200 - 256 colores */
/* cambiar modo de vídeo */
CAMBIO DE VECTORES DE INTERRUPCIÓN.
void interrupt (*getvect(int interrupción))();
void setvect (int interrupción, void interrupt (*rutina)());
/* obtener vector de interrupción */
/* establecer vector de interrupción */
La función getvect() devuelve un puntero con la dirección del vector de interrupción indicado. La
función setvect() permite desviar un vector hacia la rutina de tipo interrupt que se indica. Interrupt es una
palabra clave del Turbo C que será explicada en el futuro.
Por ahora, baste el siguiente programa de ejemplo:
void interrupt nueva_rutina();
/* nuestra función de interrupción */
void interrupt (*vieja_rutina)(); /* variable para almacenar el vector inicial */
int main()
{
vieja_rutina = getvect (5); /* almacenar dirección de INT 5 (activada con Print Screen) */
setvect (5, nueva_rutina); /* desviar INT 5 a nuestra propia rutina de control */
...
...
/* resto del programa */
...
setvect (5, vieja_rutina); /* restaurar rutina inicial de INT 5 */
}
void interrupt nueva_rutina() /* rutina de control de INT 5 */
{
...
}
PROGRAMAS RESIDENTES.
void keep (unsigned char errorlevel, unsigned tamaño);
La función anterior, basada en el servicio 31h del DOS, permite a un programa realizado en C quedar
residente en la memoria. Además del código de retorno, es preciso indicar el tamaño del área residente (en
párrafos). Es difícil determinar con precisión la memoria que ocupa un programa en C. Sin embargo, en
muchos casos la siguiente fórmula puede ser válida:
keep (0, (_SS + ((_SP + area_de_seguridad)/16) - _psp));
En los casos en que no lo sea, se le puede hacer que vuelva a serlo aumentando el tamaño del área de
seguridad (que en los programas menos conflictivos será 0). Tanto _psp como _SS y _SP están definidas ya
por el compilador, por lo que la línea anterior es perfectamente válida (sin más) al final de un programa.
VARIABLES GLOBALES PREDEFINIDAS INTERESANTES.
_version
/* devuelve la versión del DOS de manera completa */
_osmajor
/* devuelve el número principal de versión del DOS: ej., 5 en el DOS 5.0 */
_osminor
/* devuelve el número secundario de versión del DOS: ej., 0 en el DOS 5.0 */
_psp
/* segmento del PSP */
_stklen
/* contiene el tamaño de la pila, en bytes */
Lenguaje Ensamblador
_heaplen
4
/* almacena el tamaño inicial del heap, en bytes (0 para maximizarlo) */
De estas variables predefinidas, las más útiles son quizá las que devuelven la versión del DOS, lo
que ahorra el esfuerzo que supone averiguarlo llamando al DOS o empleando la función de librería
correspondiente. También es útil _psp, que permite un acceso a este área del programa de manera inmediata.
INSERCIÓN DE CÓDIGO EN LÍNEA
.
void _ _emit_ _ (argumento,...);
void geninterrupt (int interrupción);
Por medio de _ _emit_ _() se puede colocar código máquina de manera directa dentro del programa
en C. No es conveniente hacerlo así porque así, ya que alterar directamente los registros de la CPU acabará
alterando el funcionamiento esperado del compilador y haciendo fallar el programa. Sin embargo, en un
procedimiento dedicado exclusivamente a almacenar código inline (en línea), es seguro este método, sobre
todo si se tiene cuidado de no alterar los registros SI y DI (empleados muy a menudo por el compilador como
variables de tipo register). Por medio de geninterrupt() se puede llamar directamente a una interrupción:
geninterrupt (interr) es exactamente lo mismo que _ _emit_ _(0xCD, interr) ya que 0xCD es el código de
operación de INT. Por ejemplo, para volcar la pantalla por impresora se puede ejecutar geninterrupt(5). Con
los símbolos _AX, _AL, _AH, _BX, _BL, _BH, _CX, _CL, _CH, _DX, _DL, _DH, _SI, _DI, _BP, _SP, _CS,
_DS, _ES, _SS y _FLAGS se puede acceder directamente a los registros de la CPU. Hay que tomar también
precauciones para evitar efectos laterales (una asignación tipo _DS=0x40 no afectará sólo a DS).
LAS PALABRAS CLAVE INTERRUPT Y ASM.
Con interrupt <declaración_de_función>; se declara una determinada función como de tipo
interrupción. En estas funciones, el compilador preserva y restaura todos los registros al comienzo y final de
las mismas; finalmente, retorna con IRET. Por tanto, es útil para funciones que controlan interrupciones.
Para emplear esto, se debería compilar el programa con la opción test stack overflow y las variables tipo
registro desactivadas. Con asm se pueden insertar instrucciones en ensamblador, como se verá más adelante.
INTERFAZ C (BORLAND/MICROSOFT) - ENSAMBLADOR.
MODELOS DE MEMORIA.
Los modelos de memoria constituyen las diversas maneras de acceder a la memoria por parte de los
compiladores de C. En el caso del Turbo C se pueden distinguir los siguientes:
TINY: Se emplea en los programas donde es preciso apurar el consumo de memoria hasta el último
byte. Los 4 registros de segmento (CS, DS, ES, SS) están asignados a la misma dirección, por lo que existe un
total de 64 Kb donde se mezclan código, datos y pila. Los programas de este tipo pueden convertirse a
formato
COM.
SMALL: Se utiliza en aplicaciones pequeñas. Los segmentos de código y datos son diferentes y no se
solapan. Por ello, hay 64 kb para código y otros 64 Kb a repartir entre datos y pila.
Segmentos
Punteros
Modelo
Código Datos
Tiny
64 Kb
near
near
Small
64 Kb 64 Kb
near
near
far
near
Medium 1 Mb
64 Kb
Pila
Código Datos
Lenguaje Ensamblador
5
Compact 64 Kb 1 Mb
near
far
Large
1 Mb
1 Mb
far
far
Huge
1 Mb
1
Mb
far
(Bloques > 64 Kb)
far
MEDIUM: Este modelo es ideal para programas largos que no manejan demasiados datos. Se utilizan
punteros largos para el código (que puede extenderse hasta 1 Mb) y cortos para los datos: la pila y los datos
juntos no pueden exceder de 64 Kb.
COMPACT: Al contrario que el anterior, este modelo es el apropiado para los programas pequeños que
emplean muchos datos. Por ello, el programa no puede exceder de 64 Kb aunque los datos que controla
pueden alcanzar el Mb, ya que los punteros de datos son de tipo far por defecto.
LARGE: Empleado en las aplicaciones grandes y también por los programadores de sistemas que no tienen
paciencia para andar forzando continuamente el tipo de los punteros (para rebasar el límite de 64 Kb). Tanto
los datos como el código pueden alcanzar el Mb, aunque no se admite que los datos estáticos ocupen más de
64 Kb. Este modo es el que menos problemas da para manejar la memoria, no siendo quizá tan lento y
pesado como indica el fabricante.
HUGE: Similar al anterior, pero con algunas ventajas: por un lado, todos los punteros son normalizados
automáticamente y se admiten datos estáticos de más de 64 Kb. Por otro, y gracias a esto último, es factible
manipular bloques de datos de más de 64 Kb cada uno, ya que los segmentos de los punteros se actualizan
correctamente. Sin embargo, este modelo es el más costoso en tiempo de ejecución de los programas.
INTEGRACIÓN DE MÓDULOS EN ENSAMBLADOR.
LA SENTENCIA ASM
La sentencia asm permite incluir código ensamblador dentro del programa C, utilizando los mnemónicos
normales del ensamblador. Sin embargo, el uso de esta posibilidad está más o menos limitado según la
versión del compilador. En Turbo C 2.0, los programas que utilizan este método es necesario salir a la línea
de comandos para compilarlos con el tradicional compilador de línea, lo cual resulta poco atractivo. En
Turbo C++ 1.0, se puede configurar adecuadamente el compilador para que localice el Turbo Assembler y lo
utilice automáticamente para ensamblar, sin necesidad de salir del entorno integrado. Sin embargo, es a
partir del Borland C++ cuando se puede trabajar a gusto: en concreto, la versión Borland C++ 2.0 permite
ensamblar sin rodeos código ensamblador incluido dentro del listado C. El único inconveniente es la
limitación del hardware disponible: para un PC/XT, el Turbo C 2.0 es el único compilador aceptablemente
rápido. Sin embargo, en un 286 es más recomendable el Turbo C++, mientras que en un 386 modesto (o
incluso en un 286 potente) resulta más interesante emplear el Borland C++ 2.0: las versiones 3.X de este
compilador
son
las
más
adecuadas
para
un
486
o
superior
(bajo
DOS).
La sintaxis de asm se puede entender fácilmente con un ejemplo:
main()
{
int dato1, dato2, resultado;
printf("Dame dos números: "); scanf("%d %d", &dato1, &dato2);
asm push ax; push cx;
asm mov cx,dato1
asm mov ax,0h
Lenguaje Ensamblador
6
mult:
asm add ax,dato2
asm loop mult
asm mov resultado,ax
asm pop cx; pop ax;
printf("Su producto por el peor método da: %d", resultado);
}
Como se ve en el ejemplo, los registros utilizados son convenientemente preservados para no alterar el
valor que puedan tener en ese momento (importante para el compilador). También puede observarse lo fácil
que resulta acceder a las variables. Ah, cuidado con BP: el registro BP es empleado mucho por el
compilador y no conviene tocarlo (ni siquiera guardándolo en la pila). De hecho, la instrucción MOV
CX,DATO1 será compilada como MOV CX,[BP-algo] al ser una variable local de main().
Esta es la única sintaxis soportada por el Turbo C 2.0; sin embargo, en las versiones más modernas del
compilador se admiten las llaves '{' y '}' para agrupar varias sentencias asm:
asm {
push ax; push cx;
mov cx,dato1
mov ax,0h }
mult: asm {
add ax,dato2
loop mult
mov resultado,ax
pop cx; pop ax;
}
SUBRUTINAS EN ENSAMBLADOR
Cuando las rutinas a incluir son excesivamente largas, resulta más conveniente escribirlas como
ficheros independientes y ensamblarlas por separado, incluyéndolas en un fichero de proyecto (*.PRJ)
seleccionable en los menús del compilador.
Para escribir este tipo de rutinas hay que respetar las mismas definiciones de segmentos que realiza
el compilador. Hoy en día existe algo más de flexibilidad; sin embargo, aquí se expone el método general
para mezclar código de ensamblador con C.
Veamos el siguiente programa en C:
int variable;
extern dato;
extern funcion();
main()
{
int a=21930; char b='Z';
variable = funcion (a, b, 0x12345678);
}
La variable variable es una variable global del programa a la que no se asigna valor alguno en el
momento de definirla. Tanto a como b son variables locales del procedimiento main() y son asignadas con un
Lenguaje Ensamblador
7
cierto valor inicial; funcion() no aparece por ningún sitio, ya que será codificada en ensamblador en un
fichero independiente. A dicha función se le pasan 3 parámetros. La manera de hacerlo es colocándolos en la
pila (empezando por el último y acabando por el primero). Por ello, el compilador meterá primero en la pila
el valor 1234h y luego el 5678h (necesita dos palabras de pila porque es un dato de tipo long). Luego coloca
en la pila el carácter almacenado en la variable b: como los valores que se apilan son siempre de 16 bits, la
parte alta está a 0. Finalmente, deposita el dato entero a. Seguidamente, llama a la función funcion() con un
CALL que puede ser de dos tipos: corto (CALL/RET en el mismo segmento) o largo (CALL/RETF entre
distintos segmentos). Esta llamada a la función, por tanto, provoca un almacenamiento adicional de 2 bytes
(modelos TINY, SMALL y COMPACT) o 4 (en los restantes modelos de memoria, que podríamos llamar
largos).
El esqueleto de la subrutina en ensamblador que ha de recibir esos datos y, tras procesarlos,
devolver un resultado de tipo int es el siguiente:
DGROUP
GROUP
_DATA, _BSS
_DATA
SEGMENT WORD PUBLIC 'DATA'
PUBLIC _dato
; _dato será accesible desde el programa C
_dato
DW 0
; valor inicial a 0
_DATA
ENDS
_BSS
_info
_BSS
_TEXT
SEGMENT WORD PUBLIC 'BSS'
EXTRN _variable:WORD ; variable externa
DW ?
; sin valor inicial
ENDS
SEGMENT BYTE PUBLIC 'CODE'
ASSUME CS:_TEXT,DS:DGROUP,SS:DGROUP
PUBLIC _funcion
_funcion
; _funcion será accesible desde el programa C
PROC NEAR
; funcion() del C
PUSH BP
MOV BP,SP
MOV BX,[BP+4]
; recuperar variable 'a'
MOV CX,[BP+6]
; recuperar variable 'b'
MOV AX,[BP+8]
; AX = 5678h
MOV DX,[BP+10]
; DX = 1234h -> DX:AX = 12345678h
; ...
; ...
ADD CX,BX
; cuerpo de la función
ADD CX,AX
SUB CX,DX
; ...
; ...
MOV AX,CX
; resultado (tipo int)
MOV SP,BP
POP BP
RET
_funcion
ENDP
_TEXT
ENDS
Lenguaje Ensamblador
8
END
Como se puede observar, se respetan ciertas convenciones en cuanto a los nombres de los segmentos y
grupos. En el segmento _DATA se definen las variables inicializadas (las que tienen un valor inicial): _dato
podría haber sido accedida perfectamente desde el programa en C, ya que es declarada como pública. Por
otro lado, en el segmento _BSS se definen o declaran las variables que no son inicializadas con un valor
inicial (como es el caso de la variable _variable del programa C, que fue definida simplemente como int
variable: en el listado ensamblador se la declara como externa ya que está definida en el programa C). El
compilador de C precede siempre de un subrayado a todas las variables y funciones cuando compila, motivo
por el cual hay que hacer lo propio en el listado ensamblador. Al tratarse de un modelo de memoria pequeño,
_BSS y _DATA están agrupados. En el segmento _TEXT se almacena el código, es decir, las funciones
definidas: en nuestro caso, sólo una (el procedimiento _funcion). Como es de tipo NEAR, sólo se podrá
emplear con programas C compilados en un modelo de memoria TINY, SMALL o COMPACT (para los
demás modelos hay que poner FAR en lugar de NEAR). Esta función de ejemplo en ensamblador no utiliza
ninguna variable, pero tanto _variable (la variable del programa C) como, por supuesto, _info o _dato son
plenamente accesibles.
A la hora de acceder a las variables, hay que tener en cuenta el modelo de memoria: como no emplea más
de 64 Kb para código (modelos TINY, SMALL o COMPACT), el compilador sólo ha colocado en la pila el
offset de la dirección de retorno (registro IP). Nosotros apilamos después BP (ya que lo vamos a manchar)
por lo que el último dato que apiló el programa C antes de llamar a la rutina en ensamblador habrá de ser
accedido en [BP+4]. La ventaja de inicializar BP es que luego se pueden introducir datos en la pila sin
perder la posibilidad de acceder a los parámetros de la rutina que llama. Si el procedimiento fuera de tipo
FAR (modelos MEDIUM, LARGE y HUGE), todos los accesos indexados sobre la pila se incrementarían en
dos unidades (por ejemplo, [BP+6] en vez de [BP+4] para acceder a la variable a) debido a que también se
habría almacenado CS en la llamada. Como se puede observar, la rutina no preserva ni restaura todos los
registros que va a emplear: sólo es necesario devolver intactos DS, SS, BP y (por si se emplean variables
register) SI y DI; los demás registros pueden ser libremente alterados. Como la función es de tipo entero,
devuelve el resultado en AX; si fuera de tipo long lo devolvería en DX:AX.
El modelo de memoria también cuenta en los parámetros que son pasados a la rutina en ensamblador
cuando no son pasados por valor (es decir, cuando se pasan punteros). En el ejemplo, podríamos haber
pasado un puntero que podría ser de tipo corto (para cargarlo en BX, por ejemplo, y efectuar operaciones
tipo [BX]). Sin embargo, si se pasan punteros a variables de tipo far (o si se emplea un modelo de memoria
COMPACT, LARGE o HUGE) es necesario cargar la dirección con una instrucción LES de 32 bits.
Esta rutina de ejemplo en ensamblador es sólo demostrativa, por lo que no debe el lector intentar
encontrar alguna utilidad práctica, de ahí que incluso ni siquiera emplee todas las variables que define.
Evidentemente, cuando el programa C retome el control, habrá de equilibrar la pila sumando 8 unidades
a SP (para compensar las 4 palabras que apiló antes de llamar a la función en ensamblador). En general, el
funcionamiento general del C en las llamadas a procedimientos se basa en apilar los parámetros empezando
por el último y llamar al procedimiento: éste, a su vez, preserva BP y lo hace apuntar a dichos parámetros (a
los que accederá con [BP+desp]); a continuación, le resta a SP una cantidad suficiente para que quepan en
la pila todas las variables locales (a las que accederá con [BP-desp]); antes de retornar restaura el valor
inicial de SP y recupera BP de la pila. Es entonces cuando el procedimiento que llamó, al recuperar el
control, se encarga de sumar el valor adecuado a SP para equilibrar la pila (devolverla al estado previo a la
introducción de los parámetros).
Desde las rutinas en ensamblador también se puede llamar a las funciones del compilador, apilando
adecuadamente los parámetros en la pila (empezando por el último) y haciendo un CALL al nombre de la
función precedido de un subrayado: no olvidar nunca al final sumar a SP la cantidad necesaria para
reequilibrar la pila.
Lenguaje Ensamblador
9
AVISO IMPORTANTE: Algo a tener en cuenta es que el compilador de C es sensible a las mayúsculas:
funcion() no es lo mismo que FUNCION(). Por ello, al ensamblar, es obligatorio emplear como mínimo el
parámetro /mx del ensamblador con objeto de que no ponga todos los símbolos automáticamente en
mayúsculas (con /mx se respetan las minúsculas en los símbolos globales y con /ml en todos los símbolos). En
MASM 6.0, el equivalente a /mx es /Cx y la opción /Cp se corresponde con /ml.
Lenguaje Ensamblador
10
Pascal – Lenguaje Ensamblador
Optimización del código
En esta parte trataremos de hacer nuestro código un poco más eficiente, es decir, que teniendo la
misma funcionalidad, sea mucho más rápido. Haré una selección de las cosas más importantes que hemos de
optimizar:
Código en ensamblador
Turbo Pascal 7.0 permite incluir sentencias en lenguaje máquina en nuestro programa mediante la
directiva ASM. Las ventajas de hacer esto son básicamente la rapidez y el trabajo directo con el hardware, y
las desventajas son la ilegibilidad del código, lo facil que es cometer errores y la cantidad de intrucciones
que hay que escribir en equivalencia con una sentencia de alto nivel, como un simple write("hola"). Esto
último, lo hariamos en pascal, claro está.
Cuando escribimos en un leguaje de alto nivel (en nuestro caso PASCAL, pero ocurre con todos), el
compilador lo que hace en esencia es convertir las sentencias de alto nivel en leguaje máquina. Entonces te
preguntarás: ¿y por qué escribir ensamblador puro, si al final el compilador lo va a hacer por nosotros?
Pues muy buena pregunta, pero desgraciadamente no siempre se genera un código máquina lo más optimo
posible. Esto ya es algo propio de cada compilador, es decir, que si existiera (de hecho los hay) más
compiladores de PASCAL, cada uno generaría un ejecutable diferente (con la misma funcionalidad, por
supuesto).
Por ejemplo, el compilador Turbo Pascal 7.0 no es capaz de utilizar los registros propios de los
procesadores 386 y superiores (los registros extendidos). Esto es una gran desventaja, evidentemente, al no
poder aprovechar todas la ventajas de la operaciones en 32 bits (más rápidas que 16 bits).
Es precisamente en este campo donde C da mil vueltas a Pascal, y a todos los lenguajes de alto
nivel: los compiladores de C son, en general, muy eficientes; una pequeña anecdota en este sentido es una
referente a Java: siempre se ha dicho que, a pesar de su facilidad de programación, sintaxis simple y
portabilidad a otras plataformas, era muy ineficiente. Esto es cierto a medias, lo que ocurre es que siempre
ha tenido la mala fortuna de ser comparado con C, y claro, aquí es donde C gana por mucho a cualquier
lenguaje. Lo cierto es que Java es tan bueno como cualquier otro.
Por contra, al haber tantos compiladores de C, este lenguaje ha ido sufriendo pequeñas variaciones
con cada uno de ellos (diferencias insignificantes, pero cambios, al fin y al cabo). Nos podemos encontrar
con código en C que compila perfectamente en Turbo C, y no lo hace en DJGPP.
EL ENSAMBLADOR INCORPORADO
Esta es la mejor y más cómoda manera de incorporar ensamblador a tus programas pascal.
Cuando quieras poner algo de código en ensamblador pon la palabra reservada ASM, y añade
detrás todas las órdenes ensamblador que quieras y cuando hayas acabado pon un END y un punto
y coma, así:
BEGIN
ASM
MOV AX,13h
INT 10h
END;
Write('¡Modo 13h!');
ReadLn;
Lenguaje Ensamblador
11
ASM
MOV AX,3h
INT 10h
END;
END.
Pues bien, antes de seguir, hay que entender algo de código binario (base 2): si tenemos el número
1011 y hacemos un desplazamiento hacia la izquierda, es decir 1011 -> 10110, lo que hemos hecho es
multiplicar ese número por 2. (1011 = 11 (en decimal), y 10110 = 22). Esto es semejante cuando, trabajando
en base 10, si tenemos el número 32 y ponemos un cero, obtenemos 320 ( x10, base 10).
Bien, una vez aclarado esto, podemos descomponer:
320*y+x = (256+64)*y+x = (2^8+2^6)*y+x = y*2^8 + y*2^6 + x
Entonces, lo que hemos de hacer es desplazar a la izquierda "y" ocho veces e "y" seis veces, que es
mucho más rápido que hacer multiplicaciones. Con todo esto, obtenemos el siguiente código en ensamblador:
(shl = shift left = desplazamiento a la izquierda)
PROCEDURE Ponpixel (X,Y : Integer; Color : Byte; segmento:word);ASSEMBLER;
ASM
mov ax,segmento
mov es,ax
mov bx,X
mov dx,y
mov di,bx
mov bx, dx
shl dx, 8
shl bx, 6
add dx, bx
add di, dx
mov al, Color
mov es:[di],al
END;
Aquí, por ejemplo, es donde vemos la eficacia de poner código ensamblador directamente: nuestro
compilador de Pascal, aunque es muy listo, no es mágico, y lo que haría es generar el código máquina
necesario para hacer la multiplicación por 320, y no lo que hemos hecho nosotros.
Tablas precalculadas
Esta parte es extremadamente importante. Como todos sabremos a estas alturas (si no, mal
andamos), las rotaciones del objeto son el fundamento de nuestro motor 3d. Ya vimos en la sección rotación
del objeto debemos hacer un motón de operaciones trigonométricas por vértice. Cada operación de este tipo
requiere mucho cálculo (aunque no lo parezca), y echando unas pequeñas cuentas, tenemos que por vertice
hacen falta unos 10 senos y cosenos, y suponiendo que tenemos un objeto de 100 vértices, nos queda que
hemos de hacer 1000 por frame de animación. Además en un segundo deben haber unos 50 frames, con lo
que hay que calcular 50.000 relaciones trigonometricas por segundo. Como verás, la cosa empieza a
adquirir dimensiones preocupantes.
Pero no pasa nada!! Es aquí donde entram en juego las famosas tablas precalculadas (en inglés se
traducen como lookup tables). La idea consiste en calcular todos los senos y cosenos desde 0 hasta 359
Lenguaje Ensamblador
12
grados, y guardar los resultados en un array. Así, en lugar de escribir resultado:=sin(25) haremos
resultado:=seno[25]
Como siempre, ahí va el código de la inicialización. (ojo: este código está en la unidad trigon.pas en
la parte final; este parte se ejecuta simplemente con llamar a la unidad, de manera que ni siquiera hace falta
"invocar" a este código)
UNIT trigon;
INTERFACE
TYPE tabla=array[0..255] of shortint;
VAR seno,cose:tabla;
...
BEGIN {* se llama siempre que se usa esta unidad *}
{calculo de tablas precalculadas}
for i:=0 to 255 do begin
seno[i]:=round(sin(i*Pi/128)*128);
if (i*180/128>70) AND (i*180/128<110) AND (seno[i]=-128) then
seno[i]:=127;
{para que no se pase del limite de 127 -> -128}
cose[i]:=round(cos(i*Pi/128)*128);
if (i*180/128>340) OR (i*180/128<20) AND (cose[i]=-128) then cose[i]:=127;
end;
END.
Punteros en el paso de parámetros a funciones
Esta es una de las cosas de la que no te das cuenta si no conoces en profundidad la traducción que
hace el compilador del código en alto nivel.
Cuando invocamos a una función con una serie de parámetros, el código máquina generado es
similar a una llamada a una función, pero a nivel de lenguaje ensamblador. Antes de ser invocada, todos los
parámetros son metidos en la pila mediante la intrucción PUSH. Después de llama a la función, y ésta saca
todos los parámetros mediante POP. Pero veamoslo con un ejemplo
Function suma (a,b,c,d,e,f,g,h:integer):integer;
BEGIN
suma:=a+b+c+d+e+f+g+h;
END
Que se traduciría
PUSH a
PUSH
PUSH
PUSH
PUSH
PUSH
b
c
d
e
f
Lenguaje Ensamblador
13
PUSH g
PUSH h
CALL suma
POP h
POP g
POP f
POP d
POP c
POP b
POP a
...
Evidentemente este ejemplo es un poco exagerado, pero ilustra muy bien el problema de velocidad
que supone el pasar muchos parámetros a una función. No lo he comentado, pero las intrucciones PUSH y
POP consumen muchos ciclos de reloj, y es algo intoloreble en una aplicación cuya máxima es la velocidad
(casualmente, nuestro programa es uno de esos :O ). Y más aun si esta función ha de llamarse veinte veces
por frame; es bastante "doloroso" que la mitad del tiempo de ejecución se emplee únicamente en invocar a
una función (no ejecutarla)
La solución de esto es el empleo de punteros. El ejemplo anterior se puede resecribir de la siguiente
manera
TYPE
Mi_tipo = RECORD
a,b,c,d,e,f,g,h:integer;
end;
puntero = ^Mi_tipo;
...
function suma(p:puntero);
BEGIN
suma:=p^.a+p^.b+...p^.h;
END;
...
Con esto vemos que solo hacemos un PUSH y un solo POP
Lenguaje Ensamblador
14
REFERENCIAS
http://decada.hypermart.net/prog/pascal/tpasm.htm
http://ditec.um.es/interno/GDAC/ECI-ii.pdf
Descargar