4 - Curso de Ensamblador I
∞ k_mbe_t ∞
Hola a todo el mundo
Primero de todo quería presentarme. No soy ningún experto, soy sólo un estudiante que está aprendiendo, y quele gusta transmitir lo que aprende. Por lo tanto es muy probable que tenga fallos. Si creeis que algo de lo aquí expuesto es incorrecto o incompleto, por favor hacédmelo saber, y así aprenderé yo también. Ah, y al que le interese la bilbiografía que manejo, les proporcionaré las referencias.
Muchas gracias por vuestra atención, empezamos.....
CAPITULO 1 --> INTRODUCCION. ARQUITECTURA INTERNA DEL 80X86
1.- ENSAMBLADOR ¿PA QU…?
Bueno, es una interesante pregunta. Hoy día, la persona que se pone a programar utiliza normalmente el Visual Basic, Delphi o similares.
Para programar en estos lenguajes se suelen utilizar unas mega-suites acojonantes con asistentes hasta para mover el ratón. Arrastras botones, ventanas, etc con el ratón sobre un formulario en blanco y la suite te genera el código del componente en cuestión. Por supuesto no se quedan ahí, tienen muchas más posibilidades. Pero si hay algo que tienen todas en común es que DEVORAN MEMORIA. Y que alejan al programador del Hardware de la máquina.
Antes los programas se hacían en un editor normal y corriente, modo texto,corrían bajo DOS (voy a obviar en este curso el Linux) y funcionaban en un 286. Los programadores competían entre ellos por ver que programa ocupaba menos recursos o iba más rápido (básicamente porque no había más recursos), y se hacían cosas verdaderamente increibles.
Si volvemos al presente, hoy día, los ordenadores vienen con tropecientos MB de RAM y con una frecuencia de reloj de chorrocientos Mhz. La economía de recursos, por lo tanto, no está muy de moda, y eres el más guay si haces un programa o juego que necesite 128 MB de RAM y un Pentium 3 para sacar la pantalla de presentación, en fin...
Vale, y después de meter este rollo, ¿vamos a decir ya pa que sirve el saber programar en Ensamblador ahora mismo?
Bueno, pues para empezar podría decir que el saber no ocupa lugar, y que todo lo que podamos aprender en el maravilloso campo de la informática nos será útil alguna vez. Pero tal vez esto no suene muy convincente para todo el mundo. Así que podemos dar más razones.
Si sabes programar en ensamblador saber como funciona tu máquina por dentro (en este caso un procesador 80x86), lo cual es bastante útil. Puedes acceder directamente al hardware y manejarlo como te convenga, por lo que tienes mucha más libertad de movimientos que si accedes mediante una interfaz con otro lenguaje de programación. Puedes optimizar tus programas al máximo, ganando en velocidad. Necesitas mucha menos memoria y tiempo que si utilizas cualquier otro lenguaje de programación. Tienes mucha mayor libertad para acceder a la memoria o al disco de tu máquina (incluida la FAT). Controlas todo lo que entra o sale de tu sistema antes que cualquier otro programa escrito en un lenguaje convencional (ya veo que algunos han empezado a pensar en lo útil que puede resultar esto para programar virus y demás hierbas). En resumen, las ventajas que te puede dar programar al nivel más bajo que hay por encima del hardware.
2.- ARQUITECTURA DEL 80X86
Bueno esta parte es meramente teórica, el que se la quiera saltar puede hacerlo, es útil pero no necesario. Esto es un poco rollo pero vendrá bien saberlo. Además prometo no extenderme demasiado.
Los componentes básicos de un ordenador son:
- La CPU
- La Memoria
- Los controladores
- Las unidades de E/S
La CPU
Es el chip que lo controla todo. Realiza el procesamiento de datos e instrucciones. Se comunica con el resto de componentes del ordenador mediante lineas de comunicaciones que llamamos buses. En los sistemas 80x86 hay 3 de estos canales: bus de direcciones, bus de datos y bus de control.
El bus de direcciones lleva direcciones de memoria. Esta compuesto por varios cables paralelos. En el 8086 este bus tiene 20 lineas, pero solo se usan 16 (bus de 16 bits).
El bus de datos lleva datos de o hacia el microprocesador. También es de 16 bits en el 8086.
El bus de control coordina las tareas entre los distintos chips del ordenador, mandándoles señales.
Además, la CPU está conectada a un oscilador (o reloj) que genera impulsos igualmente espaciados en el tiempo. Su frecuencia de oscilación es de 14.32 Mhz (millones de ciclos por segundo).
La CPU guarda la información que intercambia con los distintos dispositivos en unos circuitos biestables llamados registros. Dichos registros, en un 8086, pueden almacenar 2 bytes de información (lo que se llama una palabra). El número de estos registros y su significado los veremos más adelante.
La Memoria
Sirve para almacenar datos. Esta compuesta por varios circuitos biestables agrupados en celdas. Cada una de estas celdas es capaz de almacenar 1 byte (siempre en un sistema 8086). Los bytes de memoria se numeran en forma consecutiva empezando por 0.
Existen 2 tipos de memoria en un 8086. La memoria RAM (de acceso aleatorio) y la memoria ROM (memoria de sólo lectura). La memoria RAM se vacía cada vez que apagamos el ordenador y esta disponible para el usuario. La memoria ROM está en un chip especial y sólo puede ser leída. Permanece inalterable aun cuando se apaga el ordenador. En esta zona de memoria se encuentra la BIOS, que es una especie de "pre-sistema operativo". Cuando encendemos el PC, la BIOS realiza unas operaciones básicas, comprobaciones, inicia el sistema básico de E/S, la autodetección de disco y pasa el control al sistema operativo de la máquina. Para que os hagáis una idea, aquí va un mapa básico de memoria de un 8086
Dirección inicio
|---------------------------------| ----------------------------------
| Sistema base de ROM (64 KB) |
F0000 |---------------------------------|
| Area de expansion ROM (192 KB) |
| | Memoria Superior
| |
C0000 |---------------------------------|
| Memoria de video (RAM) (128 KB) |
| |
A0000 |---------------------------------| ---------------------------------
| |
| |
| Memoria RAM (640 KB) | Memoria Convencional
| |
| |
00000 |---------------------------------| ---------------------------------
Debo resaltar que, aunque la RAM y la ROM no se encuentren físicamente en el mismo sitio, lógicamente van numeradas de forma consecutiva.
Los controladores y las unidades de E/S se irán viendo más adelante. Ahora prefiero contaros cómo se comunica la CPU con la memoria y cuáles son los registros concretos que contiene este procesador.
Hemos dicho que la CPU utiliza unos registros para almacenar datos y comunicarse con el resto del ordenador. Por ejemplo, si queremos acceder a una dirección de memoria debemos cargar dicha dirección en uno de estos registros, pasarla al bus de direcciones y a través de éste llegar
hasta la memoria. Pues aquí viene el primer problema. Hemos dicho que el bus de direcciones del 8086 tiene 20 lineas cada una de las cuales transmite un bit. Esto implica que podríamos direccionar con este bus hasta 2^20 posiciones de memoria = 1 MB (esto es el máximo de memoria convencional que puede tener instalado un 8086. Luego se inventaron cosas como la memoria extendida o expandida). Pero los registros internos de la CPU son sólo de 16 bits. ¿De dónde sacamos los 4 bits que nos faltan para completar una dirección de 20 bits? Bueno, por eso os dije antes que de los 20 bits del bus sólo se usaban 16. Lo que se hizo fue implementar un sistema que se conoce como SEGMENTACION. Me explico.
La memoria se divide, lógicamente, en varios segmentos. Digamos que un segmento es un "bloque de memoria". Estos segmentos tienen un tamaño de 64 KB, por lo que debería haber 16 de ellos para completar 1 MB de RAM física. Vamos a ver como "numeramos" la memoria con este esquema.
Para formar una dirección de memoria la CPU utiliza 2 tipos de registros (ambos de 16 bits). Un registro de propósito general y un registro de segmento. Con el valor que la CPU almacena en el registro de segmento direccionaremos el segmento (bloque) de memoria al que nos referimos, y con el valor almacenado en el registro de propósito general direccionamos el desplazamiento dentro del segmento. Vale, pero un momento, algo no me casa. Hay 16 posibles segmentos según hemos dicho antes, y yo con mi registro de segmento de 16 bits me puedo referir hasta a 216 = 65535 segmentos. ¿No estamos desaprovechando la capacidad del registro? Bueno, vamos a verlo. Veamos una solución alternativa que finalmente se desechó.
Imaginemos que sólo utilizamos los 4 primeros bits del registro de segmento para direccionar uno cualquiera de los 16 segmentos:
Registro de Segmento Registro de propósito general
XXXX XXXX XXXX 1010 1101 1010 0010 0010
|||| |||| |||| |||| ||||
|||| |||| |||| |||| ||||
|||| |||| |||| |||| ||||
1010 1101 1010 0010 0010 ==> Dirección de memoria =1010 1101 1010 0010 0010
Por cierto, para los no iniciados diré que se puede simplificar esta notación escribiendola en hexadecimal. En hexadecimal tenemos 16 dígitos para representar números (0,1,2,3,4,5,6,7,8,9,a,b,c,d,e,f), cada uno de los cuales representa una agrupación de 4 bits, desde 0000 (que sería el 0 hexadecimal) hasta 1111 (que sería la f), por lo que la dirección anterior se puede escribir como ada22h (la h es para indicar que el número está en hexadecimal). Si esto fuera una dirección de memoria absoluta caería dentro del area de memoria de video. De todas formas raramente se trabaja con direcciones absolutas en ensamblador (para acceder a la pantalla = memoria RAM de video sí se hace, por ej.)
Bueno que me distraigo. Supongamos que hemos formado así la dirección de 20 bits. Fijémonos que de este modo cada posición de memoria viene referenciada unívocamente por un par segmento:offset. Esto tiene un problema. Si queremos acceder a unos datos sin cambiar el segmento estos datos deben encajar exactamente dentro de uno de los 16 segmentos. Si parte de los datos están en un segmento y parte en otro tendremos que variar el segmento para acceder a todos. Esto es muy rígido. ¿No sería mejor fijar el inicio del segmento donde nos interese cada vez y movernos con el offset a través de ese segmento para acceder a los datos que necesitemos? Pues eso es lo que se diseñó. Para formar las direcciones de memoria lo que se hace es coger el valor de 16 bits del registro de segmento y añadirle 4 ceros detras (esto es como si multiplicaramos el valor por 16) y a este valor de 20 bits sumarle los 16 bits del registro de propósito general para formar la dirección completa. De esta manera tendremos 65535 posibles inicios de segmento (los 16 bits del registro de segmento posibilitan 216 = 65536 segmentos), y cada uno de los posibles inicios está separado 16 bytes de otro (porque cada uno de los 65535 números consecutivos se multiplica por 16).
De esta manera, se puede elegir cualquier zona de memoria para poner los datos y elegir un comienzo de segmento como mucho 15 bytes antes del comienzo de los datos. Fijaos que ahora una misma posición de memoria es referenciable por más de un par segmento:offset. De esta manera, la dirección 0300:0000 es la misma que la dirección 0000:0300 (todo depende de donde se inicie el segmento).
Bueno esto es complicado de entender, pero no os preocupeis, no es fundamental a la hora de srogramar en ensamblador, y además a medida que vayais haciendo programas ireis viendo familiar la forma de acceder a memoria. Para tranqulizaros os diré que, a la hora de programar, básicamente se manejan 4 segmentos: el segmento de código (su dirección se almacena en el registro de segmento CS), donde estarán las instrucciones, el segmento de datos (registro DS), el de pila (SS) y el extra (ES). No os preocupeis porque veréis que la cosa es fácil de entender en la práctica, y que no teneis que andar haciendo operaciones de multiplicar por 16 y eso.
CAPITULO 2.- INTRODUCCIÓN AL LENGUAJE ENSAMBLADOR
**** 2.1 : Los registros ****
Una parte muy importante de la programación en ensamblador es conocer el significado y uso de los registros internos del 8086, así que vamos a ver los tipos de registros que hay con detalle.
REGISTROS DE SEGMENTO
Ya nombrados. Son:
- Registro CS : Es donde se guarda la dirección del comienzo del código de un programa. El valor almacenado en este registro más el offset almacenado en otro registro llamado IP (puntero de instrucciones) indican la dirección de cada una de las instrucciones que componen el programa.
- Registro DS : Almacena la dirección de comienzo del segmento de datos de un programa. El valor almacenado en este registro más el offset dado por una instrucción indican un byte específico (una variable, por ej.) en el área de datos del programa.
- Registro SS : Llamado también registro de pila. Almacena la dirección de una zona específica de memoria que trabajará como una pila guardando datos temporales.
- Registro ES : Es un registro que almacena memoria auxiliar. Se suele utilizar en manejo de cadenas de caracteres asociado con el registro DI.
PUNTERO DE INSTRUCCIONES (REGISTRO IP)
Contiene el desplazamiento dentro del segmento de código (CS) de la siguiente instrucción a ejecutar, como ya dijimos.
PUNTEROS DE PILA
- Registro SP : El registro SP (Stack Pointer) funciona como un puntero que apunta a la cima de la pila (es decir, guarda la primera dirección libre dentro del segmento de pila).
- Registro BP : El registro BP (Base Pointer) se utiliza para apuntar a una zona de la pila donde se han guardado variables locales o parametros para procedimientos. Es decir, que si pasamos parámetros a procedimientos vía pila utilizaremos este puntero para acceder a ellos y sacarlos (es lo que pasa cuando llamamos a una función en C : los parametros de la función se almacenan en la pila y luego se accede a ellos para utilizarlos)
REGISTROS DE PROPÓSITO GENERAL
- Registro AX : También llamado acumulador. Se usa para operaciones de E/S y para operaciones aritméticas (multiplicación y división, por ejemplo, dejan el resultado en AX).
- Registro BX : Registro base. Se suele utilizar con direccionamiento indirecto, almacenando direcciones de comienzo de tablas. También se suele usar en operaciones aritméticas.
- Registro CX : Registro contador. Se suele usar como contador en bucles. La orden LOOP, por ejemplo, lo usa en ese sentido (Compara el valor de CX con 0. Si es mayor vuelve a donde se le indique con una etiqueta. En cada comprobación decrementa CX en 1 unidad. Ya veremos esto). Las operaciones de desplazamiento de bits también lo usan.
- Registro DX : Registro de datos. Se suele usar en multiplicación y división con cifras grandes, como apoyo a AX. También en operaciones de E/S para referenciar puertos.
Otra cosa importante. Estos registros son de 16 bits, pero cada uno de ellos se
puede usar como 2 registros de 8 bits (byte superior o inferior de AX) cambiando la X por H o L según sea el byte superior o inferior. Así, si en AX tenemos un valor, usando AH accederíamos al byte superior de AX, y usando AL al byte inferior. Lo mismo con los otros 3 registros.
REGISTROS INDICE
- Registro SI : Source Index. Se usa como indice en direccionamiento indirecto, y en algunas operaciones con cadenas de caracteres. Está asociado normalmente al registro DS.Por ejemplo, si en nuestro segmento de datos tenemos una variable que es una cadena podemos cargar SI con el offset (desplazamiento dentro de la zona de datos) de la misma e ir incrementando este valor, de tal manera que con la direccion generada por DS:SI iriamos recorriendo la cadena byte a byte.
- Registro DI : Destination index. Se usa para lo mismo que SI, pero normalmente asociado al registro ES.
REGISTRO DE ESTADO (DE BANDERAS, DE FLAGS)
Es también un registro de 16 bits. De esos 16 bits, 9 son usados para indicar ciertas situaciones producidas por instrucciones de nuestro programa.
Ciertas instrucciones que ejecutemos cambiaran el valor de alguno o varios de estos bits.
La estructura es :
15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00
OF DF IF TF SF ZF _ AF _ PF _ CF
El significado de cada bit es:
- OF (Overflow, desbordamiento) : el resultado de una operción no cabe en el destino
- DF (Direction Flag) : designa la dirección (izq. o der.) del desplazamiento al manipular bloques de memoria, como cadenas de caracteres.
- IF (Interruption flag) : Indica si se permiten interrupciones. Si está a 1 quiere decir que se permiten. Veremos lo que son más adelante.
- TF (Trap flag, flag de trampa) : Los depuradores, como el DEBUG, activan este flag, lo que permite la ejecución paso a paso.
- SF (Sign flag) : Contiene el signo resultado de una operación aritmética o comparación. 0 indica positivo, 1 negativo.
- ZF (Zero flag) : Indica el resultado de una operación aritmética o comparación. 0 indica resultado diferente de 0, 1 indica resultado igual a 0 (vaya lío).
- AF (Auxiliary flag) : Se usa como ajuste en operaciones BCD. ¿Suena extraño? No os preocupeis por esto ahora.
- PF (Parity flag, flag de paridad) : Se activa tras algunas operaciones aritmético-lógicas para indicar que el número de bits a uno resultante es par.
- CF (Carry flag, flag de acarreo) : Indica el acarreo ("lo que nos llevamos") al hacer una operación como suma o resta.
2.2 : Las interrupciones
Una vez que ya ha quedado claro (espero) lo de los registros (que los usareis con el 90% de las instrucciones de Ensamblador), paso a explicar otro concepto importante antes de entrar en lo que es ensamblador puro y duro : las interrupciones. Vamos a ver lo que son.
Lo que hace un procesador es leer instrucciones, decodificarlas (pasarlas a código máquina y ejecutarlas). Pero algunas veces esta actividad se ve interrumpida por algún suceso, este suceso es lo que se conoce como INTERRUPCION. Para empezar debemos distinguir entre interrupciones HARDWARE, que son las producidas por un dispositivo externo al procesador, e interrupciones
SOFTWARE, que son las debidas a la ejecución de la instrucción INT seguida de un número que indica que interrupción se produce. Vamos a ver los 2 tipos por separado:
INTERRUPCIONES HARDWARE
Imaginad por un momento que el procesador debiera de comprobar miles de veces por segundo el buffer de teclado para comprobar la pulsación de una tecla, o acceder continuamente a un puerto para ver si hay datos. Esto sería una pérdida de tiempo bestial. Entonces lo que se hace es que, cuando un dispositivo externo se tiene que comunicar con el procesador (por ejemplo, el teclado) envía una señal que el microprocesador recibe por la patilla INTR (bueno, en realidad la señal se la envía el dispositivo a un chip llamado PIC o controlador de interrupciones 8259, y es éste el que gestiona todo y se comunica con el micro). A la vez, el procesador lee de los 8 bits más bajos del bus de datos un código que identifica al dispositivo. ¿Qué pasa a continuación? Pues
que la ejecución del programa en curso se interrumpe. Se almacena en la pila el registro de estado, el registro CS y el registro IP, por este orden, y se cargan los registros CS e IP con la dirección de una rutina que responderá a esta interrupción. ¿Qué rutina?, ¿de dónde se saca el microprocesador la dirección para almacenar en el CS:IP? Bueno, vamos por partes.
La rutina que se ejecuta es una de las llamadas rutinas de servicio de interrupción (RSI), y existe una de estas rutinas para cada dispositivo susceptible de generar interrupción. A lo mejor ya habeis deducido que habrá 256 posibles dispositivos, dado que el dispositivo se identifica con los 8 bits más bajos del bus de datos, por lo que tendremos 28 = 256 posibles dispositivos.
¿Y de dónde se sacan estas direcciones? El primer KB (1024 Bytes) de memoria RAM (entre las direcciones 00000h y 003ffh) se reserva para la llamada tabla de vectores de interrupción. Dicha tabla contiene 4 bytes para cada código de interrupción (4 bytes * 256 códigos = 1024 Bytes = 1 KB) que son el offset y el segmento donde reside la RSI correspondiente. Así, lo que habría que hacer para obtener la dirección de una determinada RSI es multiplicar por 4 el número de la interrupción y leer de la dirección de memoria obtenida 4 bytes, que se almacenarán en los registros IP y CS. Los 8086 hacen esto solitos, pero es útil saberlo para, por ejemplo, cambiar algún vector de interrupción. O sea que si quereis que cuando se produzca una interrupción de teclado (que es la 16, os informo) lo que se ejecute sea un código vuestro en vez del estandar
lo que tendríais que hacer es ir a esta tabla y cambiar el valor correspondiente a la entrada 16 (dirección 00010h) por la dirección donde tengais vuestra rutina (veo vuestras sonrisas maliciosas.....).
Otra cosa, a lo mejor en vuestro código hay veces que os interesa que nada os interrumpa por la razón que sea; pues eso es fácil. El lenguaje Ensamblador proporciona 2 instrucciones la mar de útlies para esto:
- STI (SeT Interruption flag) : pone el flag IF a 1, lo que hace que se puedan recibir interrupciones
- CLI (CLear Interruption flag) : pone IF a 0, por lo que no se permiten interrupciones hasta que ejecutemos STI o se ponga IF a 0 por alguna razón (por ejemplo , cuando se entra en el código de una RSI se almacena en la pila el registro de estado y se pone IF a 0. Cuando se sale de la rutina se recupera el registro de estado, por lo que se sobreescribe y se vuelve a poner el flag a 1)
De todas formas hay interrupciones que no se pueden desactivar. Son las interrupciones no enmascarables, y se reciben por la patilla NMI del micro, en vez de por la patilla INTR. Un ejemplo, cuando algún chip de memoria es defectuoso. Por último decir que la mayoria de las RSI están proporcionadas por la BIOS o el SO (aunque como os he dicho podeis escribir las vuestras si quereis)
INTERRUPCIONES SOFTWARE
No sólo se produce una interrupción cuando un dispositivo externo desea comunicarse con el micro, también podeis generar vosotros mismos este proceso, mediante la orden INT n (donde n indica el número de la interrupción a generar).
Como ya hemos dicho la mayoría de RSI las proporcionan la BIOS o el DOS, y además, para una misma interrupción, puede haber distintos comportamientos deseados. Por ejemplo, si generásemos una interrupción de video (la 10) con INT 10, lo que podríamos desear es cambiar el modo de video, escribir un dato por pantalla, leer un dato de la memoria de pantalla, etc. ¿Cómo diferenciamos cada caso si todos se ejecutarían cuando usamos INT 10? Pues la manera de hacerlo (tanto cuando usamos interrupciones BIOS como interrupciones DOS) es indicar en el registro AH qué función queremos ejecutar, identificando la función con un número, y en otros registros (el AL, el BX....) los datos que necesitará la RSI, si es que le hacen falta. De esta manera:
............
MOV AH, 0 ; Función a realizar --> cambiar el modo de
video
MOV AL, 0 ; Modo de video 0 (40 x 25, 16 colores)
INT 10 ; Llamada a la interrupción.
............
Bueno, el primer trozo de código real (XD). Supongo que se entiende todo, pero os adelanto que MOV mueve al primer operando el segundo, y que el ";" se usa para indicar comentarios. Este código podría hacerse más rápido así
MOV AX, 0
INT 10
O aun mejor
XOR AX, AX
INT 10
Los que estéis familiarizados con el álgebra de Boole sabréis lo que es el XOR, si no de todas maneras lo explicaré más adelante.
Bueno, pues esto es generar de manera explícita lo visto en las interrupciones hardware. Si queréis podeis ver el mnemónico (mnemónico = instrucción en ensamblador) INT como una llamada a función, dicha función se especifica en el registro AH y los parámetros (si los hay)se pasan en el resto de registros de propósito general.
Para acabar las interrupciones os diré que hay identificadores no asociados a ningún dispositivo, así que os podríais montar uno y asociarlo a una interrupción. Por ejemplo, imaginad que construis un circuitillo que haga algo y lo enchufais al puerto paralelo. Pues bien, escribir un driver para manejar este dispositivo sería escribir todo el código que querais que se ejecute dependiendo de lo que pase con el dispositivo y "asociar" el dispositivo a una interrupción. ¿Cómo?, pues eligiendo una vacía (por ejemplo, la 61) y rellenando la tabla de vectores de interrupción con la dirección de vuestras rutinas. Así, si por ejemplo queréis leer un dato del dispositivo por el puerto, meteis en AH el valor que sea para identificar a la función de lectura (el que vosotros elijais, es totalmente libre) y llamais a la INT 61. Se ejecutará el código que vosotros querais (leer del puerto) y dejaréis lo leido en donde os de la gana. Esto se hace así a grosso modo, y tiene unas posibilidades bestiales.
Ah, se me olvidaba. El código de una RSI siempre termina con la instrucción RET, para volver al punto donde se interrumpió el programa.
Bueno, creo que con esto ya os he torturado bastante por esta vez. Soy consciente de que esta introducción es un coñazo teórico, lo siento.
En la próxima entrega veremos el "esqueleto" de un programa en ensamblador y empezaremos a ver instrucciones.
Un saludo
¿Dudas, consultas, sugerencias? --> k_mbe_t@hotmail.com