======================================================= Introducción a la programación en lenguaje ensamblador para procesadores Intel serie x86 y compatibles (II) ======================================================= Por nmt numit_or@cantv.net ================================ CONSIDERACIONES PRELIMINARES II ================================ -------------------------------------------------------- CONTENIDO - Consideraciones necesarias sobre el hardware - La maldición del lenguaje ensamblador - Dispositivos relevantes · Nombres y direcciones · Rutinas y subrutinas · Ejecución - Dos tipos de programador - Registros internos del CPU - Nota sobre las interrupciones I - Uso de DEBUG de DOS I - Sistemas de numeración - Uso de DEBUG II: Trazar un Programa - Uso de DEBUG III: Escribir un Programa - Observaciones --------------------------------------------- Consideraciones necesarias sobre el hardware --------------------------------------------- Antes de entrar en el calor de la batalla, son indispensables algunas consideraciones sobre la arquitectura del hardware de la PCs. Había quedado pendiente explicar que es un registro y cuántos tienen los procesadores ix86. También ha quedado pendiente qué es una interrupción. A continuación intentaré explicar estas cuestiones. -------------------------------------- La maldición del lenguaje ensamblador -------------------------------------- Vimos en el ejemplo anterior que necesariamente cada paso de un algoritmo casi siempre exige la ejecución de varias instrucciones del CPU. Lo que podría considerarse una maldición del lenguaje ensamblador es que sus términos y expresiones están en relación uno a uno con las instrucciones de cada CPU: es un lenguaje, si así puede llamarse, totalmente dependiente del hardware. A esta situación se agrega que no existe un estándar para los lenguajes ensambladores, por lo que la primitiva sintaxis de estos lenguajes también depende del ensamblador usado. Sólo aquellos ensambladores que suministren un preprocesador poderoso pueden salvar estas circunstancias otorgando facilidades que podrían hacer más portable los programas escritos para estos ensambladores. Eso lo demostraremos pronto. La maldición de los lenguajes ensambladores condena a los programadores a un conocimiento estricto de hardware de su máquina, sin el cual se podría terminar escribiendo programas que resultarían más lentos que los que podríamos haber escrito en un lenguaje de alto nivel. No siempre un programa escrito en lenguaje ensamblador es necesariamente más rápido y eficiente que otro escrito en un lenguaje de alto nivel, que en la actualidad generan código máquina optimizado. La eficiencia del código escrito en ensamblador se subordina a dicho conocimiento del hardware. Curiosamente los lenguajes ensambladores se encuentran a la sombra de una paradoja. Cuando escribo código en esta forma: main proc far _init _display string _pause _exit main endp puedo entender a primera vista qué está ocurriendo: es un algorritmo que dice: iniciar, desplegar una cadena de caracteres, esperar y salir. No se necesita saber mucho de programación. Pero si la misma secuencia la escribo así: main proc far mov ax, @data mov ds, ax lea dx, cadena mov ah, 9 int 33 mov ah, 16 int 22 mov ax, 0 ret main endp necesito de un entrenamiento especial para entender qué pasa y aún así no es fácil comprender el código. La paradoja del lenguaje ensamblador es que hace menos comprensible un programa debido a que muestra literalmente lo que corre en una máquina: mientras más muestra, más incomprensible hace lo que ocurre. Posiblemente esta maldición, como la he llamado, sea una bendición, vista desde otro ángulo: cuando se quiere un control estricto sobre el hardware del sistema. Ahora que nuestra tarea es programar en ensamblador, necesitamos dicho entrenamiento y un conocimiento del hardware. Estamos obligados a representar nuestras ideas sobre acciones (como iniciar, desplegar) en los términos de una máquina. ------------------------ Dispositivos relevantes ------------------------ De los dispositivos del hardware del PC, son más mportantes para nuestra labor de programadores dos de ellos: El procesador (CPU: Central Processor Unit) La memoria principal (RAM: Random Acces Memory) Esto no quiere decir que otros dispositivos, como el monitor, el teclado o el ratón, no sean importantes. Lo que pasa es que para comunicarnos con ellos debemos pasar por la memoria y el CPU. Hay posiblidades de una comunicación directa con ciertos dispositivos de hardware, pero son casos especiales. La memoria principal es una especie de gran array o vector: una serie de casillas dispuestas en serie lineal cuyo contenido puede ser actualizado con cierta libertad. Debido a que el contenido de esta memoria es dinámico, se actualiza con mucha frecuencia, se le llama memoria de acceso aleatorio (RAM: Random Access Memory), en oposición a otro tipo de memoria que sólo permite acceso para lectura (ROM: Read Only Memory). Nombres y direcciones --------------------- Cada casilla de la memoria se identifica con un número que llamamos su dirección. En lenguaje ensamblador esa casilla la podemos marcar con un término que la identifique, como en nuestro ejemplo poníamos en el segmento de datos: string db "Hola gente", 36 o en el segmento de código: _display: lea dx, string aquí la palabra "string" identifica un dato, y "display" identifica el inicio de una secuencia en el código. Estos marcadores son traducidos a direcciones de memoria, a un número que identifica la casilla donde se encuentra un dato o una instrucción y que es lo que entiende la máquina: el lenguaje de los números. Rutinas y subrutinas -------------------- Cuando un programa, o mejor dicho su imagen binaria en código máquina, va a ejecutarse, es montado por el sistema en algún espacio disponible de la memoria, todo junto: código y datos, aunque generalmente en segmentos distintos. Una vez montado, el sistema debe localizar el punto de entrada del código del programa. El punto de entrada es la primera instrucción del programa. Una vez ubicada la instrucción, se le pasa el control del sistema al programa y empieza a ejecutarse. En nuestro programa, el punto de entrada está indicado con la expresión: main proc far En esta expresión, "main" es un nombre, "proc" es el objeto nombrado, en ese caso un procedimiento de cómputo, y "far" es una propiedad del objeto (un procedimiento) con el nombre indicado (proc): la propiedad de ser lejano (far), lo que dice al ensamblador que debe considerar esta situación e incluir el código correspondiente a procedimientos de este tipo. Tenemos un objeto de tipo procedimiento llamado "main" y que tiene como propiedad el ser lejano (far). Un procedimiento de cómputo no es más que una secuencia de instrucciones que realiza alguna operación. Nuestro programa sólo tiene un procedimiento: main, que es "far", lejano. Pero un programa puede tener más de un procedimiento: donde se encuentra la entrada del programa se considera el procedimiento principal, los demás se consideran procedimientos secundarios o subrutinas. Si los procedimientos secundarios están en un mismo segmento se deben declarar NEAR; en caso de que puedan accederse desde otros segmentos, deben declararse FAR. Sobre los procedimientos ahondaremos luego. Ahora estamos tratando de revisar la memoria y el procesador. Ejecución --------- Cada instrucción que va leyendo el sistema es puesta en un lugar llamado bus de datos. Un bus simplemente es un espacio que comunica un dispositivo con otro. Así que lo que se coloca en el bus es puesto de inmediato en una cola de instrucciones que van siendo leídas una por una, de forma secuencial, por el procesador. En realidad esta lectura es un poco más compleja a partir de los procesadores +486 de Intel, cuya serie es nuestro objeto de estudio. Pero asumamos ahora que es simplemente secuencial. Una a una las instrucciones van siendo leídas por el procesador que las va tomando desde la cola de instrucciones y las va ejecutando a su turno. Lo que va leyendo el procesador es una combinación lineal de unos y ceros que le dice qué debe hacer. La serie de combinaciones que puede entender el procesador constituye su código de operación. Después explicaremos esto mejor, por qué deben ser unos y ceros, sólo dos valores. Para realizar las diversas operaciones, el procesador dispone de, además del bus mencionado y otro, el bus de direcciones, un conjunto de pequeños almacenes de datos llamados registros. En nuestro ejemplo, instrucciones como: mov ds, ax es una operación en la que se involucran dos registros: ds (data segment: segmento de datos) y ax (acumulador). La palabra "mov" al comienzo indica la operación que se realiza entre los dos registros, en este caso es "MOVER" lo que está en el registro acumulador del procesador a otro registro en el procesador llamado DS. [Después nos extenderemos sobre la sintaxis del lenguaje ensamblador.] Cada uno de estos almacenes internos del procesador, que llamamos registros, tiene una capacidad que puede variar de un procesador a otro. Esa capacidad s e llama su tamaño y se mide generalmente en bits, la unidad mínima de información que puede ser memorizada en un sistema digital, es decir, basado en dos valores: 1 y 0. En nuestro ejemplo, hemos usado registros de 16 bits, que pueden guardar hasta 2 bytes. Un byte es una unidad formada de 8 bits. Otros dispositivos serán descritos en su momento. Revisemos el procesador con más detalle, especialmente sus registros. ------------------------- Dos tipos de programador ------------------------- Para comprender mejor la serie ix86 de procesadores, es importante distinguir entre el programador de aplicaciones y el programador de sistema. El primero se dedica a diseñar aplicaciones nivel usuario. Así que su ámbito se reduce al conocimiento de · los registros internos del procesador, necesarios para manipular datos y direcciones de memoria · el repetorio básico de instrucciones del procesador · modos de direccionamiento El programador de sistemas tiene a su haber otra misión: el diseño de sistemas lo más óptimo posible de explotación de los recursos del hardware para dar soporte a las aplicacions previstas. Para realizar su tarea, debe conocer · registros denominados registro del sistema, indispensables para gestionar aspectos como el modo de operación del procesdor, etc. --------------------------- Registros internos del CPU --------------------------- El Intel 8086, el primero de la serie x86, dispone de varios registros que pueden clasificarse en tres grupos: · Registros de propósito general (8 registros): - De datos: AX: Acumulador BX: Base CX: Contador DX: Datos - Punteros: SP: Puntero de pila (Stack Pointer) BP: Puntero de base - Índices: SI: Índice de origen (Source Index) DI: Índice de destino (Destiny Index) · Registros de puntero de instrucciones y registro de señalizadores (2 registros) IP: Puntero de Instrucciones (Instruction Pointer) Señalizadores (Flags) · Registros de segmento (3 registros) CS: Segmento de código DS: Segemento de datos SS: Segemento de pila ES: Segmento extendido En el 8086 estos registros tienen un tamaño de 16 bits. En el 80386, el tamaño de los registros generales, del puntero de instrucciones y del de señalizadores se extendió a 32 bits. También se agregaron tres registros de segmentos adicionales: FS GS Estos registros constituyen el nivel del programador de aplicaciones. Esto quiere decir que son totalmente visibles a este programador. Hay otros registros que ya no son transparentes para el programador de aplicaciones: los registros del sistema. Dado la complejidad de estos registros, aplazamos su discusión para el momento oportuno. Al extenderse a 32 bits, los registros de propósito general y al puntero de señalizaciones se le prefijó una "E" (extended) al comienzo. De manera que AX ahora se llamaría EAX, SP se llamaría ESP, etc. Los registros de segmento quedarían iguales: registros de 16 bits. De todos modos los registros de 32 bits aún pueden ser usados como registros de 16 bits. Simplemente hacemos referencia a ellos como tales, sin el prefijo "E". Los registros de datos pueden ser usados también como registros de 8 bits. Cada registro de 16 bits, digamos AX, puede ser dividido en dos partes, una alta (AH, H=high) y una baja (AL, L=low). Esto es válido para todos los registos de datos. Con frecuencia se encontrará que se habla de la parte más significativa de un registro general para referirse a su parte alta, y de la menos sigificativa para referirse a su parte baja. La razón de esto la veremos luego. ------------------------------- AX: | AH: 8bits | AL: 8bits | --------------------------------------------------------------- EAX: | 32 bits | --------------------------------------------------------------- La estructura mostrada en la figura de arriba es común a los registros EBX, ECX y EDX. Es posible realizar operaciones sobre la parte alta o baja de un registro general de datos con idependencia. Por ejemplo, es posible la siguiente instrucción: mov al, bh Mover el contenido de la parte alta de BX a la parte baja de AX. Lo mismo no puede hacerse con los demás registros que no sean los de datos de propósito general. Luego hablaremos sobre el uso que se reserva a estos cuatro registros. Los registros punteros están destinados a guardar direcciones. SP guarda la dirección donde se encuentra el último dato introducido en la pila. BP, puntero base, se emplera para guardar direcciones bases en operaciones con direccionamiento indirecto. Direccionar, en programación, es hacer referencia a una dirección en la memoria. Un direccionamiento puede constar de dos partes: una base y un índice o desplazamiento. El índice o desplazamiento es un puntero o una dirección relativa a la base. Los registros índices se emplean en direccionamiento indirecto pero en combinación con los registros de segmento, especialmente en operaciones con cadenas de caracter. En dichas operaciones, SI apunta a una dirección dentro de un segmento ubicado donde lo especifica el registro del segmento de datos DS. DI apunta en cambio a una dirección ubicada en un segmento de memoria ubicado donde lo indica el registro de segmento ES. Los términos Source (fuente) y Destiny (destino) refieren al papel que juegan dichos registros en operaciones de movimiento de cadenas de caracteres desde una dirección de memoria a otra. El registro IP es el puntero de instrucciones. En él se guarda la dirección de la instrucción que se está ejecutando actualmente. El registro de señalizadores o banderas (flags) es un registro donde cada uno de los bits tiene un significado muy preciso respecto al resultado de una operación. Debido a su complejidad lo explicaremos luego. Sólo diremos que es la esencia de las estructuras de control en lenguaje ensamblador, ya que las operaciones lógicas de comparación activan o no algunos de sus bits para indicar el resultado. Muchas instrucciones, especialmente las que suponen una desición sobre la base de una condición, revisan alguno de los bits de este registro para ejecutarse. Los registros de segmento son usados para guardar las direcciones de los segmentos de nuestro programa. DS se usa para guardar la dirección del segmento de datos; CS para guardar la dirección del segmento de código y SS para guardar la dirección del segmento de pila. -------------------- | | | | | | -------------------- ----------- | | SS | dirección |---->| Segmento de pila | ----------- | | DS | dirección |- -------------------- ----------- +-->| | CS | dirección |- | Segmento de datos | ----------- | | | | -------------------- Regitros | | | de -->| Segmento de código | segmento | | -------------------- | | | | | | | | | | -------------------- Memoria La segmentación de la memoria permite reubicar objetos. Para ello sólo basta con establecer sus propiedades y su dirección. El uso de los registro de datos de propósito general es mejor revisarlo directamente en los programas que vayamos escribiendo. Detengo momentáneamente mi explicación sobre los registros ya que lo mejor es verlo en la práctica. ------------------------------- Nota sobre las interrupciones I ------------------------------- A nivel de hardware, el sistema está controlado generalmente por interrupciones. Supongamos que inicias el PC en DOS. Llegarás a un puntero de línea de órdenes: C:\> Parecierá que el sistema se ha quedado dormido, esperando las órdenes del usuario. Supongamos ahora que pulsamos una tecla ¿Cómo sabe el sistema que ha tenido lugar ese evento? Posiblemente el evento produce algún tipo de perturbación que _interrumpe_ el sueño del sistema. Éste despierta y revisa el motivo de la perturbación, donde ocurrió y qué la causó. Dependiendo de donde ocurrió y de su importancia, el sistema deberá atender esa interrupción. La atención consiste en una suspensión de lo que en ese momento esté haciendo el sistema, y en el paso del control a una rutina llamada manipulador de interrupción, que se encuentra en un sitio determinado de la memoria. Más o menos es así como funcionan las cosas en la máquina: a cada dispositivo se le asigna una línea de interrupción. Cuando el dispositivo entra en funcionamiento, activa la línea, produciendo seguramente un cambio de voltaje que es captado por el procesador. A esa línea a veces se le conoce como IRQ (Interrupt ReQuest: Solicitud de Interrupción). Para cada línea no sólo se asigna un número, sino también un manipulador (handler) de la interrupción. Este manipulador consiste en una rutina que se ejecuta para atender la interrupción y, luego, después de atenderla, devuelve el control a la rutina que fue interrumpida. Un mecanismo semejante ha sido adoptado para el software. Se escriben o diseñan una serie de rutinas básicas, los manipuladores (handlers) de las interrupciones, y se les asigna a cada una de ellas un número. Todas estas rutinas pueden ser montadas en memoria en direcciones específicas al iniciarse el sistema. Sus direcciones pueden colocarse en serie, una después de otra, para formar una tabla en la memoria, generalmente llamada tabla de servicios de interrupciones. Aquí vector significa una serie de casillas de memoria del mismo tamaño con información del mismo tipo. En este caso, el vector de interrupciones es una serie de casillas del mismo tamaño (aquí 32 bits o 4 bytes) cada una conteniendo una dirección. A cada casilla le hacemos corresponder el número del correspondiente manipulador de interrupción. Supongamos que elegimos para manejar las pulsaciones de teclado la interrupción 1. Entonces cuando pulsemos una tecla, se activará una línea que hará que el procesador pase el control a la dirección que se encuentra en la casilla 1 de la tabla de servicios. Generalmente, cada manipulador incluye varios servicios. Para saber cuál servicio se requiere puede revisar el contenido en registros específicos del procesador, generalmente en la parte alta del registro acumulador (AH). El número que encuentre en este registro indica cuál servicio está requieriéndose. Más o menos, así funcionan las interrupciones de software, a través de las cuales el sistema satisface los requerimientos de sus servicios desde los programas. Así que, en principio, tenemos dos tipos de interrupciones: · Interrupciones de software o internas · Interrupciones de hardware o externas Estas interrupciones pueden subdividirse en otros tipos, pero dejamos su estudio para luego. Basta por ahora hacer observar que en nuestro ejemplo hemos usado varias interrupciones. En los ix86, la interrrupción de software se provoca a través de un operador unario: int XX donde XX es el número de la entrada en el vector de interrupción con la dirección donde se halla la rutina que trabaja como manipulador o manipulador de la interrupción. El servicio a solicitar de la interrupción se pasa generalmente a través de la parte alta del registro Acumulador (AX, la parte alta es AH). Así que para solicitar el servicio 9 de la interrupción 33, que despliega una cadena de caracteres, cuya dirección debe ponerse en el registro de datos DX, debemos escribir algo como: lea dx, cadena mov ah, 9 int 33 La instrucción "lea dx, cadena" pone en dx la dirección del marcador "cadena" (lea = Load Efective Address), donde se debe encontrar una cadena de caracteres cuyo final debe ser marcado con el símbolo "$". La instrucción "mov ah, 9", mueve a la parte alta del acumulador el número 9, el servicio requerido. La instrucción "int 33" dispara una interrupción que pasa el control a la dirección que se encuentra en la entrada 33 del vector de interrupciones del sistema. En los PCs, la tabla con los vectores de interrupción del sistema se ubica al comienzo de la memoria, ocupando 1024 bytes, para soportar 256 interrupciones (256 entradas de 4 bytes = 1024 bytes). En DOS, hay dos fuentes principales de interrupciones. Las que se incluyen en el chip ROM que sirve de BIOS del sistema y las propias del sistema operativo. Las entradas para los servicios del BIOS son 31, ocupando los primeros 124 bytes de la tabla. Las entradas de los servicios de DOS inician con la 32. Arriba, "int 33" designa un servicio de interrupción de DOS. En la mayoría de los textos y códigos fuente encontraremos que esta misma interrupción se designa con la cifra 21. Lo que pasa es que en mi ejemplo he usado el código decimal, el que los mortales occidentales manejamos a diario. En cambio los programadores iniciados usan el sistema de numeración llamado código hexadecimal, más cercano al código con el que trabaja la máquina. En Linux, a los servicios el sistema se accede a través de la interrupción 108 (80 en hexadecimal). Es la única que se emplea en este sistema operativo. Linux corre en modo protegido e implementa memoria virtual: existe una diferencia entre las direcciones que ve el programador y las reales de memoria, donde corren los programas. Este no es el caso tradicional de DOS donde sólo se trabaja con la memoria real. Lo cierto es que, así como no se accede directamente a la memoria física, en Linux tampoco se accede directamente a los servicios del sistema. La interrupción 80 (en hexadecimal) es la interface que conecta la aplicaciones con los servicios. Así que un código como lea dx, string mov ah, 9 int 33 que en DOS despliega la cadena cuya dirección hemos cargado en edx, en Linux debería ser: mov eax, 4 ; número de servicio o llamada de sistema: ; 4 = sys_write mov ebx, 1 ; descriptor de archivo: 1 = stdout lea ecx, string ; dirección de la cadena a escribir mov edx, len ; tamaño de la cadena int 0x80 ; llamada al núcleo Vemos que Linux trabaja con registros de 32 bits: los registros extendidos. En el registro extendido del acumulador EAX ponemos el número del servicio, 4 para el servicio de escritura en ficheros. En el registro extendido de la base EBX ponemos el descriptor del fichero; como queremos que la cadena se despliegue en la cónsola, ponemos 1, que indica la salida estándar. La dirección de la cadena la ponemos en ecx, el registro extendido del contador. En el registro extendido de datos ponemos el tamaño de la cadena. Esta cadena debe terminar en cero. Luego pasamos el control al núcleo de Linux, produciendo la interrupción 108, que en hexadecimal es 0x80 u 80h. Windows, como Linux, corre en modo protegido. Igual incluye una interrupción para comunicar entre las aplicaciones que corren en modo usuario y los servicios delsistema, que corren en modo núcleo. En Windows 95 es la interuupción 32 (o 20 en hexadecimal); en NT, 2000 y XP es la interrupción 46 (o 2E en hexadecimal). ---------------------- Uso de DEBUG de DOS I ---------------------- DOS y la cónsola de Windows incluyen un programa llamado DEBUG. La palabra "debug" podríamos traducirla por depurador, aunque realmente ésta no sea su traducción literal. Supuestamente, un depurador está destinado a la eliminación de "bichos", cucarachas en nuestros programas. Es una especie de insecticida. En terminos de programación, podemos decir que se trata de un programa para la localización de errores que afectan sensiblemente el funcionamiento de nuestros programas. Con el depurador podemos revisar paso por paso la ejecución de nuestro programa, revisar como va modificándose el contenido de los registros hasta ubicar donde está el "bicho" y aplastarlo. Imagino que el nombre se debe a que los primeros desperfectos en las primeras máquinas de cómputo electrónicas se debía generalmente a la presencia de cucarachas u otros insectos en los circuitos de tubos. El debug de DOS permite, entre otras cosas, editar archivos ejecutables y hasta escribir programas en lenguaje ensamblador. Ahora vamos a hacerlo: escribir un programa en ensamblador con el DEBUG. Antes hay que hacer unas consideraciones sobre los sistemas de numeración con los que se trabaja. ----------------------- Sistemas de numeración ----------------------- Los occidentales trabajamos a diario con el sistema de numeración decimal o base 10. En este sistema, se usan diez símbolos: 0 1 2 3 4 5 6 7 8 9 cada número es el resultado de una suma de potencias con base 10. Me explico con un ejemplo. El número 4501.56 es la suma de: (4 x 10^3) + (5 x 10^2) + (0 x 10^1) + (1 x 10^0) + (5 x 10^-1) + (6 x 10^-2) = 4000 + 500 + 10 + 1 + 0.5 + 0.06 = 1501.56 Las PCs trabajan sobre otro sistema numérico: el sistema binario, con base 2. Se usan dos símbolos: 0 1 cada número es el resultado de una suma de potencias con base 2. El número 1101, por ejemplo, sería: (1 X 2^3) + (1 x 2^2) + (0 x 2^1) + (1 x 2^0) = 8 + 2 + 0 + 1 = 11 decimal Hay varias circunstancias que favorecen el uso del sistema binario en las computadoras. Por ahora sólo haré mención de una de orden práctico. Las investigaciones que dieron inicio a la era digital de las computadoras determinaron que los sistemas electrónicos digitales superaban una serie de problemas presentes en las máquinas mecánicas y analógicas. Electrónicamente hablando, un código basado en el sistema binario parece simple de implementar: · se asigna el valor 1 a la presencia de voltaje X en un terminal de un circuito. · se asigna el valor 0 a la ausencia del voltage X en el terminal. Supongamos entonces un dispositivo con dos conjuntos definidos de terminales de este tipo, el conjunto I de terminales de entrada y el conjunto O de terminales de salida. Podemos diseñar un circuito que compute una función µ que asigne valores específicos de salida para valores específicos de entrada. De esta forma podemos diseñar un código de operaciones para un sistema: para una combinación específica de unos y ceros ß el sistema realizará una operación µ, que dará como salida una combinación de unos y ceros þ. Desde este punto de vista, podemos definir un registro de procesador como una célula dentro del procesador que puede almacenar información en forma de una secuencia de unos y ceros, que tendrá el significado que le asigne nuestro código de operaciones. Cada terminal del registro puede almacenar un uno o un cero, es decir, puede almacenar un bit de información. El número de combinaciones que pueden establecerse en un registro depende de este número de terminales: 2 ^ No.de terminales. Los registros del i386 tienen un ancho de 32 bits, por lo tanto pueden almacenar una de 2 ^ 32 = 4 GB combinaciones de unos y ceros. Una representación más adecuada del código máquina, es el sistema de numeración hexadecimal, de base 16, de dieciseis símbolos: 0 1 2 3 4 5 6 7 8 9 A B C D E F [A es 10 en decimal, B es 11, C es 12,... y F es 15.] y donde cada número es el resultado de la suma de potencias base 16. El número 4A7B sería, en decinal: (4 x 16^3) + (10 X 16^2) + (7 x 16^1) + (B x 16^0) = 16384 + 2560 + 112 + 11 = 19067 ¿Por qué se escogió la notación hexadecimal? Por que la unidad con la que generalmente trabajan las PCs es el byte=8 bits. Con un símbolo hexadecimal puedo representar 16 valores, es decir, 4 bits (2 ^ 4 = 16). Así que con sólo dos hexdecimales puedo representar un byte. Una secuencia de un byte de información, como 1111 1111, tiene la siguiente representación hexadecimal. 1 1 1 1 - 1 1 1 1 8 4 2 1 = 15 - 8 4 2 1 = 15 F - F Es decir FF = 15x16^1 + 15x16^0 = 15x16 + 15 = 255. En el lenguaje C los valores en hexadecinal se representan antecediendo el valor con "0x". Por ejemplo el ABC8 se expresaría en C así: 0xABC8 La misma notación es empleada en algunos ensambladores, pero en TASM y en MASM la misma cantidad se expresaría en hexadecimal haciendo seguir la cantidad con una 'h' mayúscula o minúscula. Si la cifra comienza con una letra, hay que prefijarle un cero. Así que la cifra anterior habría que escribirla: 0ABC8h Hay ocasiones en las que podemos necesitar escribir valores en binario. En ese caso debemos sufijar la expresión con 'b' minúscula o mayúscula: 1101b = 0Dh = 13 En cambio: 1101h = 4096+256+0+1= 4353 Ahora podríamos reescribir nuestro primer programa especificando los números en hexadecimal: ; -------------------------------------------------------- TITLE PRIMER.ASM: Primer programa escrito en ensamblador ; -------------------------------------------------------- .MODEL SMALL ; -------------------------------------------------------- .stack 40h ; -------------------------------------------------------- .data cadena db 'Hola gente!', 24h ; -------------------------------------------------------- .code init proc far mov ax, @data mov ds, ax _display: lea dx, cadena mov ah, 1001b int 21h _pause: mov ah, 16 int 16h _exit: mov ax, 0 ret init endp ; -------------------------------------------------------- end init ; ------------------------------------------------------- Si lo ensamblas, dará el mismo resultado que antes. La instrucción "mov ah, 9" la reemplacé a propósito por "mov ah, 1001b", cambiando el 9 decimal por su equivalente binario, como un ejemplo. ------------------------------------ Uso de DEBUG II: Trazar un Programa ------------------------------------ DEBUG, como muchos otros programas auxilares de la programación, trabaja con el sistema hexadecimal. Para ejecutar DEBUG escribimos DEBUG en el puntero de DOS o en el cuadro de ejecución (Inicio-Ejecutar) de Windows. Aparecerá sólo un guión que es el apuntador del DEBUG. Las diversas operaciones del DEBUG se indican con letras: A: assemble (ensamblar) C: compare (comparar) D: display (desplegar) E: enter (ingresar) F: full (llenar) G: go (ir) H: hexadecimal I: in (entrada) L: load (cargar) M: move (mover) N: name (nombrar) O: out (salida) P: proceed (proceder) Q: quit (quitar) R: register (registro) S: search (buscar) T: trace (trazar o rastrear) U: unassemble (desensamblar) W: write (escribir) Ahora no las usaremos todas. Antes de escribir un programa con DEBUG, primero revisemos el que escribimos al comienzo. Cargamos el programa en el DEBUG: C:\tasm\works\primer>DEBUG primer.exe Ahora ingresamos la orden T, y obtendremos: -t AX=0E00 BX=0000 CX=0020 DX=0000 SP=0040 BP=0000 SI=0000 DI=0000 DS=0DEF ES=0DEF SS=0E01 CS=0DFF IP=0003 NV UP EI PL NZ NA PO NC 0DFF:0003 8ED8 MOV DS,AX Se trata de la segunda instrucción de nuestro programa. La instrucción MOV es una instrucción de asignación, parecida al operador "=" de C. Lo que hace es mover el contenido del segundo operando, aquí AX, que para el momento tiene la dirección del segmento de datos, al primer operando, DS. Antes de la ejecución de la instrucción DS=0DEF y AX=0E00; son valores hexadecimales. Ingresemos de nuevo t: -t AX=0E00 BX=0000 CX=0020 DX=0000 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=0005 NV UP EI PL NZ NA PO NC 0DFF:0005 BA0400 MOV DX,0004 Ahora DS=0E00. Se ha movido lo que estaba en AX a DS. La siguiente instrucción mueve a DX la dirección de nuestra cadena. Habíamos escrito "LEA DX, string"; DEBUG lo ha interpretado como "mover a DX lo que está 4 bytes por encima del inicio del segmento de datos". Si se quiere verificar escribimos: D DS:4, desplegar lo que está 4 bytes después del inicio del segmento de datos: -d ds:4 0E00:0000 48 6F 6C 61-20 67 65 6E 74 65 21 24 Hola gente!$ 0E00:0010 C7 06 0E 96 3E 2B 2E C7-06 10 96 3D 3B E8 83 09 ....>+.....=;... 0E00:0020 73 13 B8 FF FF 53 26 8B-1D 26 3A 0F 73 03 B8 02 s....S&..&:.s... En 0E00+4 vemos el código hexadecimal correspondiente a nuestra cadena, que en la columna derecha podemos ver en ASCII. Podemos ver que el símbolo "$", que originalmente habíamos introducido como 36, y que en la columna del medio aparece en hexadecimal: 24. Sigamos trazando: -t AX=0E00 BX=0000 CX=0020 DX=0004 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=0008 NV UP EI PL NZ NA PO NC 0DFF:0008 B409 MOV AH,09 Ahora moveremos a la parte superior de AX un nueve: -t AX=0900 BX=0000 CX=0020 DX=0004 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=000A NV UP EI PL NZ NA PO NC 0DFF:000A CD21 INT 21 Vemos ahora que "AX=0900", que confirma que hemos movido 9 a la parte alta de AX. Se va a ejecutar la interrupción 21h, servicio 9; recuérdese que anteriormente habíamos escrito "int 33". DEBUG trabaja en hexadecimal, así que despliega la versión hexadecimal de lo que escribamos. Para trazar por encima de la interrupción, no es bueno usar T, ya que esto nos llevará a la rutina de la interrupción. Así que mejor ejecutamos P: -p Hola gente! AX=0924 BX=0000 CX=0020 DX=0004 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=000C NV UP EI PL NZ NA PO NC 0DFF:000C B410 MOV AH,10 Esto despliega nuestra cadena en la cónsola. Observa que todos los registros se han mantenido iguales después de la interrupción, excepto AX; vemos que en la parte baja de AX, que es AL, aparece un 24h. Se trata del caracter "$". Quiere decir que el servicio 9 de la interrupción 21h usa el registro AL para revisar la cadena. Ahora moveremos 16 a AH. DEBUG lo presenta como "MOV AH, 10"; 10h = 16 decimal. Hacemos T: AX=1024 BX=0000 CX=0020 DX=0004 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=000E NV UP EI PL NZ NA PO NC 0DFF:000E CD16 INT 16 Se va a ejecutar el servicio 16 o 10h de la interrupción 22 o 16h. Lo que hace este servicio es esperar a que el usuario pulse un tecla, cuyo código devuelve en AH el código de rastreo y en AL el código de la tecla. Para teclas de función extendida (F1 - F12) más importante es el código de rastreo, ya que como código de tecla se pasará 00, para las teclas F1-F12, E0h para para otras teclas de control como Inicio o RePag. Hacemos P y se ejecuta nuestra pausa; pulsamos cualquier tecla, en mi caso "enter": -p AX=1C0D BX=0000 CX=0020 DX=0004 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=0010 NV UP EI PL NZ NA PO NC 0DFF:0010 B80000 MOV AX,0000 Código de rastreo en AH=1Ch y código de tecla en AL=0Dh. Ahora vamos a salir. Devolvemos el valor FALSE (=0) en AX a DOS. Un simple gesto de educación. AX=0000 BX=0000 CX=0020 DX=0004 SP=0040 BP=0000 SI=0000 DI=0000 DS=0E00 ES=0DEF SS=0E01 CS=0DFF IP=0013 NV UP EI PL NZ NA PO NC 0DFF:0013 CB RETF Por último regresamos a DOS. La instrucción RETF es una operación ceroaria (sin operandos) que significa RETURN FAR, regresar lejos. Recordemos que hemos declarado nuestro procedimiento así: main proc far "main" es el nombre del procedimiento; proc es una directiva que indica inicio de una rutina o función (en ensamblador se llaman procedimientos a las funciones) y far indica que el procedimiento puede ser accedido desde otros segmentos. La instrucción RETF nos saca del procedimiento a la línea de órdenes. Hay otras formas más limpias de salir de un programa, pero con esta basta por ahora. Nota también que a la derecha de cada instrucción aparece la dirección en memoria, en sistema hexadecimal, de la instrucción, y luego es seguida por otra cifra en hexadecimal. Ésta última es la versión en hexadecimal del código de operación de la instrucción. Si no fuera por el sistema hexadecimal, ahí aparecerían una serie de unos y ceros. Con DEBUG también podemos obtener el código fuente en ensamblador de un fichero ejecutable. Esto se hace cargando el fichero y ejecutando la orden U, seguida por la dirección a partir de la cual queremos que se inicie el desensamblado y el número de líneas a desensablar: -u cs:00 0CE2:0000 B8E30C MOV AX,0CE3 0CE2:0003 8ED8 MOV DS,AX 0CE2:0005 BA0400 MOV DX,0004 0CE2:0008 B409 MOV AH,09 0CE2:000A CD21 INT 21 0CE2:000C B410 MOV AH,10 0CE2:000E CD16 INT 16 0CE2:0010 B80000 MOV AX,0000 0CE2:0013 CB RETF Tememos aquí un desensamblado de nuestro código. También podemos obtener este desensamblado en un archivo de texto aparte: DEBUG primer.exeprim.asm Esta orden volcará en prim.asm un desensablado de los primeros 20 (14h) bytes de primer.exe, que podemos revisar con un editor. Podríamos escribir un fichero disasm.bat: cls @echo off @echo U CS:00 13>t_$ @echo.>>t_$ @echo Q>>t_$ 2echo.>>t_$ @echo Desensamblando.... DEBUG %1.exe%1_.asm EDIT %1_.asm Luego ejecutamos: disasm primer y ya! Podríamos mejorarlo para que desensamble exactamente las instruccciones del segmento de código; e incluso hacer que nuestro .BAT cambie algunos valores hexadecimales en el .EXE, pero no es este el lugar para explicarlo. --------------------------------------- Uso de DEBUG III: Escribir un Programa --------------------------------------- Escribamos ahora un programa con el DEBUG. Antes una observación sobre el formato de los ejecutables en DOS. Existen dos formatos: .COM y .EXE. El primero, .COM, es el formato original. En este formato, todo, código y datos, es puesto en un único segmento cuyo tamaño no debe exeder los 64KB. En el formato .EXE, que es posterior, se reserva un segmento para datos, uno para código y uno para la pila. Nuestro programa fue escrito en formato .EXE. Con el DEBUG se pueden escribir programas en formato .COM, que son bastante más pequeños. El programa debe comenzar a ejecutarse en la dirección 256 [100h], ya que los ejecutables de DOS reservan los primeros 256 bytes para colocar ahí una estructura de datos conocida como PSP, cuando es cargado en la memoria. El PSP (Program Segment Prefije: Prefijo de Segmento del Programa) contine información que será utilizada por el cargador de DOS. Así que comenzamos escribiendo A 100 [enter] -a 100 0DAB:0100 jmp 108 0DAB:0102 db "hola$" 0DAB:0107 nop 0DAB:0108 mov dx,102 0DAB:010B mov ah,9 0DAB:010D int 21 0DAB:010F mov ah,10 0DAB:0111 int 16 0DAB:0113 iret La primera instrucción ,"jmp 108", es saltar a la localidad 108. Necesitamos un espacio para nuestra cadena. Como no sabemos el tamaño del programa y no podemos determinar al comienzo donde estará la cadena si la ponemos al final del programa, la ponemos al comienzo, y para evitar que el programa comience a ejecutarse en la cadena (lo que daría error) le pasamos por encima. La instrucción jmp 108 tiene dos bytes, reservamos 6 bytes para la cadena. Nos sobra un byte, así que escribimos "nop" en 107, un operador ceroario que significa No OPeración. Vemos que la dirección de la cadena es 102, así que ponemos este valor en DX; luego llamamos al servicio 9 (mov ah, 9) de la interrupción 21h (int 21) para desplegar la cadena. Luego ejecutamos el servicio 10h de la interrupción 16h (que detiene la ejecución del programa) y por último regresamos a DOS con "iret", regreso de interrupción. Pulsamos dos veces enter para salir de la orden A. Ahora elegimos un nombre para el programa, como pr.com. La extensión debe ser .COM; la orden es: -n pr.com Luego lo escribimos en disco: -w Eso es todo. Luego lo ejecutamos. Salimos de DEBUG: -q y ejectamos pr.com: hola ¿Todo bien? Observa el tamaño del archivo: 20 bytes (14h = 20 decimal) -------------- Observaciones -------------- Estas últimas explicaciones muestran un poco las exigencias del lenguaje ensamblador, la cantidad de conocimientos que se requiere para escribir un simple programa. Por eso se dice que si alguien programa en ensamblador es un buen programador. A estas alturas del partido, el lenguaje ensamblador ha quedado bastante excluido del mundo de la programación, precisamente por su nivel de exigencia. Sin embargo, aunque no lo parezca, en ocasiones es la mejor opción; no sólo donde se necesite velocidad o ahorro en el tamaño del programa. También es la mejor opción cuando nos interesa un contacto estrecho, a muy bajo nivel, con cualquiera de los componentes del sistema. La programación de sistema será su medio ambiente por antonomacia, por ejemplo, en la escritura de controladores (drivers) de dipositivos o de algunos módulos esenciales de los sistemas operativos. Como es la mejor opción para velocidad, es muy empleado en la programación gráfica, en la creación de animaciones en tercera dimensión, por ejemplo. El ensamblador se requiere donde son exigentes las condiciones de operación. No imaginamos hoy a nadie invirtiendo los recursos que exige la programación en ensamblador para escribir programas que sólo digan "Hola gente". A esto se agrega que, en general, la interface de programación (API) de los sistemas operativos modernos está pensada para lenguajes de alto o medio nivel. Trataremos de ser consecuentes y de concentraremos entonces en la programación de sistema, en aspectos poco documentados de la programación en lenguaje ensamblador. Por supuesto, todavía tenemos que revisar la sintaxis y alguna semántica del lenguaje ensamblador. También en los cabos que hemos dejado sueltos. Esto lo haremos sobre la marcha, escribiendo programas: nuestro enfoque intenta ser práctico. Ahora unas indicaciones sobre la sintaxis del lenguaje ensamblador, antes de escribir nuestra primera utilidad. -----------------------------------------------------------> TO BE CONTINUED ----> emailme: numit_or@cantv.net webZ: http://mipagina.cantv.net/numetorl869/ http://oberon.spaceports.com/~tutorial/aks/