Fundamentos de computadores TEMA 1 JUEGOS DE INSTRUCCIONES Y MODOS DE DIRECCIONAMIENTO 1. INSTRUCCIONES GENERALES Ya conocemos la secuencia de ejecución de instrucciones estudiada en la asignatura Fundamentos de Computadores 1: Fetch, decodificación, lectura de datos, realización de la operación, almacenamiento del resultado y actualización del contador de programas. Debido a que las instrucciones se realizan una detrás de la otra, se habla de flujo de ejecución lineal, excepción hecha de las instrucciones de bifurcación que modifican el contador de programa. El juego de instrucciones de un computador está formado por el conjunto de instrucciones de máquina que es capaz de ejecutar. Éste suele ser reducido, de unas 60 instrucciones para procesadores RISC a unas 250 para procesadores CISC y constituye una característica muy importante de su arquitectura. Se denomina lenguaje nativo de máquina aquel que ésta puede entender de forma directa. Los términos de dicho lenguaje se encuentran determinados por el juego de instrucciones y se deben expresar en forma de cadenas de unos y ceros. Las instrucciones de máquina cumplen las siguientes propiedades: Realizan una función sencilla única: suma, resta, AND…. Operan sobre uno o dos operandos simples e un formato determinado. Trabaja en un formato sistemático y lo más compacto posible a fin de minimizar las necesidades de memoria. Son autocontenidas, es decir, incluyen toda la información necesaria para ser ejecutadas. Son independientes del contexto, su efecto no depende de ningún elemento externo a la instrucción en sí (a excepción de las bifurcaciones condicionales). La ejecución de las instrucciones es atómica, es decir, o se lleva a cabo toda ella de forma indivisible o completa; o bien, no se ejecuta. El modelo de programación del computador Von Neumann se compone de varios elementos: Mapa de memoria: El espacio de todas las posiciones de memoria que la máquina es capaz de direccional. El espacio viene limitado por la longitud de las direcciones con las que el PC trabaja. Suponiendo que estas tengan m bits, sus posibles valores van de 0 a 2m-1, y, por tanto, el mapa de memoria es de 2m posiciones. Mapa de E/S: Permite la comunicación entre los periféricos y la Unidad Central de Proceso. Registro de usuario: Es el banco de registros que el programador puede manipular de forma directa con las instrucciones de máquina. Se seleccionan con una dirección de pocos bits, puesto que suelen tener de 8 a 32 registros de usuario. Registro de estado: Depende en gran medida, de la arquitectura específica de que se trate, y podemos tener: Bits de resultado (transporte, positivo, negativo, cero, desbordamiento…); nivel de ejecución (en núcleo o kernel donde se ejecutan todo el juego de instrucciones o de usuario donde solo se llevan a cabo un subconjunto de los primeros); máscara de interrupción…. 2 · Fundamentos de computadores En cuanto al formato de las instrucciones como hemos dicho que estas son autocontenidas, deben prever toda la información necesaria para su ejecución; por ello y, en principio, deben especificar los siguientes elementos: Operación a efectuar Operandos sobre los que actúa la operación anterior Formato de representación de los datos Ubicación del resultado, o sea, registro o dirección de memoria donde se debe almacenar Identidad de la siguiente instrucción Esto es así en teoría, en realidad se utiliza el concepto de información implícita con la que determinados valores se dan por defecto y solo se especifican si son diferentes a ellos. Por ejemplo la identidad de la instrucción siguiente se sobreentiende que es la siguiente, no se especifica (solo en caso de instrucciones de bifurcación). Modelos de ejecución encontramos varios, dependiendo de los dispositivos que encontremos, estos son: la pila, los registros de usuario y la memoria principal. 2. MODOS DE DIRECCIONAMIENTO Un modo de direccionamiento constituye un procedimiento que permite acceder a un operando de una instrucción, tanto si es un dato, como un resultado, como la dirección de la instrucción hacia donde se bifurca. Fundamentos de computadores · 3 A primera vista quizá sorprenda la tremenda variedad de modos de direccionamiento, pero esto se debe a varias causas: Ahorro de espacio: Hay que reducir al máximo el número de bits utilizados en la instrucción. Si se pretende que el código sea reubicable (se puede cargar a partir de cualquier posición de memoria y se ejecuta de forma correcta) se precisan modos de direccionamiento relativos. El manejo de tablas, vectores o matrices se simplifica con los modos de direccionamiento relativos. 1. Direccionamiento inmediato: Únicamente puede utilizarse para datos y consiste en incluir el valor del dato en la instrucción, es un método muy poco flexible, puesto que el valor es fijo y constante. 2. Direccionamiento directo absoluto: Se utiliza para expresar que la dirección obtenida es realmente la del operando de la instrucción; la instrucción contiene la dirección del operando, tanto a registro como a memoria (a registro suele utilizar menos bits pues hay menos registro de usuario, 16 ó 32, a memoria ocupa más bits por la razón opuesta). 3. Direccionamiento directo relativo: La instrucción no contiene la dirección real del operando, sino una dirección D (desplazamiento) relativa a un puntero RB (registro base). La dirección real del operando se calcula realizando la operación de suma D+RB. Este modo se caracteriza por ser mucho más compacto (ya que el número d de bits requerido es menor que el número m de bits utilizado en el modo absoluto a memoria), permite la reubicación (solo hay que cambiar el registro base para que se modifiquen las direcciones de todos los datos e instrucciones), se utiliza para recorrer muchas estructuras de datos de manera eficiente; además como requiere una operación de suma, muchos computadores incluyen una unidad sumadora específica para el cálculo de dirección, así se ahorra utilizar la que se encuentra en la UAL. 4. Direccionamiento relativo a registro base: Se utiliza como registro base RB uno de los registros de usuario o uno especial de direcciones; dicho registro no se modifica en la operación de direccionamiento. 5. Direccionamiento relativo al contador de programa: Se toma como registro base el PC, por esta razón se obtiene la dirección sumando el desplazamiento al PC. Este direccionamiento permite acceder a posiciones de memoria que se encuentren próximas a la instrucción actual, lo que provoca que sea muy adecuado para bifurcaciones de programa y para construir bucles. 6. Direccionamiento relativo al registro índice: Es un registro cuyo contenido se modifica al establecer el direccionamiento, sumándole o restándole un incremento. Así se pueden recorrer estructuras de datos, puesto que sólo es necesario que el incremento sea igual al tamaño de los datos de la estructura. 7. Direccionamiento en pila: Es un caso particular del caso anterior que utiliza el puntero de pila como registro índice, éste apunta hacia el encabezamiento de la pila y se debe incrementar o decrementar adecuándose a los accesos de la pila. 8. Direccionamiento indirecto: Parte de otro tipo, absoluto o relativo, al que añade un paso más que consiste en que la dirección calculada no apunta al elemento X deseado, sino a una posición de memoria que contiene la dirección del elemento. 9. Direccionamiento implícito: El formato de instrucción no incluye ningún campo para especificar el operando, ya que es un lugar predeterminado, constituyendo un registro usuario preferente denominado acumulador. La ventaja es que no ocupa espacio en la instrucción, pero el inconveniente es que reduce la flexibilidad de la misma, que solo puede tener el operando en un lugar fijo. 4 · Fundamentos de computadores Direccionamiento inmediato 1000000011000 / 010 / 00110101 Suma Reg Inmediato DL DL + Inmediato Direcciona a la posición Suma Direccionamiento directo absoluto Reg4 M(Absoluto) Reg4 OR M(Pg Báse) Reg2 AND M(Pg base) 1000 100 001111001 0010011001000111111100000111110 OR Reg tipo datos (Absoluto) 1100 010 110111000 0010001100100011 AND Reg Tipo datos Página base Se hace OR del Reg4 y la M(Absoluta) y se guarda en Reg4 Se hace AND del Reg2 y la M(Pg base) y se guarda en esta última Direccionamiento relativo al contador del programa PC=0E25h PC+IR=37FCh M M(PC+D) IR=0004h; dirección= El valor del PC no es el de la instrucción actual en curso, sino el de la siguiente (+ bits que ocupe) Direccionamiento relativo a registro base 11110111 00100110 0100011110101101 Multipl. Mod R/m Desplazamiento M M(D+RB) Direccionamiento relativo al registro índice RI=7436 , Inc: 4 y despl.: -65 Dirección RI + D 7436 – 65 = 7371 RI RI + Inc 7436 + 4 = 7440 RI=7436 , Inc: 4 y despl.: -65 Dirección RI + D 7436 – 65 = 7371 RI RI - Inc 7371 - 4 = 7375 RI=7436 , Inc: 4 y despl.: -65 RI RI + Inc 7436 + 4 = 7440 Dirección RI + D 7440 -65 = 7375 RI=7436 , Inc: 4 y despl.: -65 RI RI - Inc 7436 - 4 = 7432 Dirección RI + D 7432 – 65 = 7367 PostAutoincremento PostAutodecremento Preautoincremento Preautodecremento El Fe factor de escala, se aplicará en los casos necesarios Direccionamiento indirecto D (Dirección en operación) D y puntero B en instrucción la la D Dir(X) X Absoluto D + Puntero B Dir (X) X Relativo 3. INSTRUCCIONES DE MÁQUINA Podemos clasificar las instrucciones de máquina según su función en: Instrucciones de transferencia o movimiento de datos Instrucciones de interrupción de la secuencia del programa Instrucciones aritméticas y lógicas Instrucciones de entrada/salida y misceláneas Existe una correspondencia muy estrecha entre las instrucciones en código máquina y en ensamblador, además como este último es mucho más cómodo para las personas que la binaria, es el que vendremos utilizando. Una instrucción o sentencia ensamblador se compone del mnemónico que expresa la función de la operación y la segunda parte que especifica los operandos que no nos implícitos en la operación. Instrucciones de transferencia o movimiento de datos Permiten copiar la información contenida en un registro o posición de memoria a otro registro oposición de memoria, constan de dos operandos: el origen y el destino. Pueden parecer instrucciones poco útiles, pero en realidad son las más utilizadas, ya que suponen del 30 al 40% de las instrucciones que ejecuta un computador. Los mnemónicos más importantes son: Fundamentos de computadores · 5 MOVE ST LD PUSH POP MOVEM XCH CLR SET Es una transferencia general entre registros y posiciones de memoria Transmite el contenido de un registro a una posición de memoria Transmite el contenido de una posición de memoria a un registro Introduce el dato de origen en la pila y lo convierte en el encabezamiento Extrae el dato de encabezamiento de la pila Transfiere bloques de datos de una zona de memoria a otra Intercambia el contenido de los dos operandos especificados Pone el destino en ceros Pone el destino en unos Store Load Move Block Exchange Clear Instrucciones de interrupción de la secuencia del programa Rompen la cadena normal de ejecución, basada en el incremento del programa PC, cargando un nuevo valor en éste. Suelen suponer de un 24 a un 33% del total de las instrucciones de uso ejecutadas. Y encontramos 3 tipos principales: Bifurcaciones incondicionales: Se carga un nuevo valor en el contador del programa, Se suelen utilizar los mnemotécnicos Jump y Branco Bifurcaciones condicionales: Constituyen las únicas cuya ejecución depende del contexto. Se debe especificar una condición de bifurcación que si se cumple se ejecutará la bifurcación y si no, no. Los nemotécnicos más usuales son: B[-] Con [-] se indica la condición de bifurcación; por ejemplo BZ de Branco if Zero bifurcará si el Bit 0 de la palabra de estado se encuentra activo. Si se cumple la condición de bifurcación [-] se saltará la instrucción siguiente. Branch SKIP [-] Y las condiciones de bifurcación más usuales son: Z E C P V PE Zero Equal Carry Positive Overflow Parity even Cero Igual Transporte Positivo Desbordamiento Paridad de pareja: Paridad impar si el numero de ceros o unos es par. CALL RET Return Bifurcaciones con retorno: Una de las estructuras de programación más utilizadas es la subrutina. Es un programa autocontenido que se incorpora a otro programa en una o varias posiciones. Al llegar a la posición de incorporación, el control pasa a la subrutina, hasta que cabe su ejecución; luego el control retorna al programa. Las dos grandes ventajas son la economía (pues puede llamarse varias veces al mismo programa y economizar líneas de código) y la modularidad (se permite dividir el programa en varios trozos más pequeños y manejables. Tenemos dos instrucciones: Instrucción que cede el control a la subrutina Devuelve el control al programa que realiza la llamada Cuando una subrutina llama a otra, se considera una nidificación de subrutinas. La instrucción Call solo tiene un operando que especifica la dirección a partir de la cual se encuentra almacenada la subrutina; además esta instrucción debe guardar la dirección de la instrucción que la sigue (dirección de retorno) para poder volver a este punto cuando acabe de ejecutarse la subrutina; y eso es lo que hace la instrucción Ret, que no necesita ningún operador más, pues la dirección de retorno ya hemos dicho que se encuentra implícito. Instrucciones aritméticas y lógicas y misceláneas Son instrucciones muy variadas, según sea monádicas o diádicas necesitaran uno o dos registros sobre los que operar. Las instrucciones aritméticas que solemos encontrar son: 6 · Fundamentos de computadores ADD ADDC SUB SUBC MUL DIV INC DEC NEG ABS Addition Addition with carry Subtract Subtract with borrow Multiply Divide Increment Decrement Negative Absolute Suma Suña añadiendo el bit de transporte de la palabra de estado Resta Resta teniendo en cuenta el transporte de la palabra de estado Multiplica Divide Incrementa una unidad Decrementa una unidad Cambia de signo o niega Obtiene el valor absoluto Entre las instrucciones lógicas encontramos: SHR SHL SHRA ROR ROLC Shift rifht Shift Left Shift right Arithmethic Rotate Right Rotate left through carry Desplazamiento Desplazamiento Desplazamiento Desplazamiento Desplazamiento lógico a la derecha lógico a la izquierda aritmético a la derecha circular a la derecha circular a la izquierda con transporte Entre estos dos grupos de instrucciones, venimos a tener el 26 a 35% de las instrucciones generadas para un programa. Otros tipos de instrucciones son las de comparación, que no modifican el resultado, solo actualizan el registro de estado (probablemente para efectuar un cálculo o un salto posterior) CMP TEST Compare Realiza una operación SUB entre los dos operandos sin guardar el resultado Compara el operando con cero Las instrucciones de entrada/salida suelen ser privilegiadas, es decir, solo se reservan al nivel de ejecución del núcleo, no de usuario. Su función consiste en realizar una transferencia entre una posición del mapa de e/s y un registro o posición de memoria: con este tipo de funciones se controlan los periféricos. Tenemos las instrucciones: IN Input OUT Output Transmite información de un puerto de entrada a un registro o pos. de memoria Transfiere información de un registro o memoria a un puerto de salida Luego encontramos otras instrucciones difíciles de clasificar, entre las que podemos estudiar: NOP SETC CLRC SETV CLRV EI DI HALT WAIT No operation Set carry Clear carry Set overflow Clear overflow Enable interrupt Disable interrupt Instrucción que no hace nada, se puede utilizar en bucles de espera Activa el bit de trnasporte de la palabra de estado Desactiva el bit de transporte de la palabra de estado Activa el bit de desbordamiento de la palabra de estado Desactiva el bit de desbordamiento de la palabra de estado Activa el bit de interrupción y permite que se acepten interrupciones Desactiva el bit de interrupción e inhibe las interrupciones Es muy raro su uso pues para volver a arrancar hay que volver a encender la máquina Intrucción privilegiada que para el procesador, pero con cualquier interrupción se vuelve a poner en marcha, se utiliza mucho para reducir el consumo Fundamentos de computadores · 7 4. MÁQUINAS CISC Y RISC El diseño de los computadores hasta mediados de los 80 propugnaba un rico y variado juego de instrucciones complementando la arquitectura del PC. Cada fabricante se esforzaba por sacar a la luz un juego de instrucciones amplio, aunque de estructura irregular y que presentaban muchas excepciones. Pero a mediado de los 80 esto cambió, se propugna la arquitectura RISC (Reduced instrucción set computer) con un juego de instrucciones sencillo, ante la clásica CISC (Complex) como podían ser los IBM370, Motorota 680xx e Intel xx86. Hoy día se acepta una buena arquitectura (y por tanto un buen juego de instrucciones) que sea regular y no presente casos excepcionales, es decir que sea regular y ortogonal. Ello permite: Diseñar compiladores más sencillos y eficaces. Programar más fácilmente en ensamblador, pues el usuario no debe recordar casos especiales ni saber utilizarlos. El hecho de que un juego de instrucciones sea regular, viene determinado por la ortogonalidad, esto quiere decir que cada operación se debe poder efectuar con cualquier tipo de operando y de direccionamiento; es necesario por tanto que disponga de todas las posibilidades que tengan sentido, aunque existan combinaciones de poca frecuencia de uso. Pero en cambio, se gana en que las funciones que realiza el computador son simples y por tanto rápidas, y un juego de instrucciones sencillo simplifica el diseño (y posiblemente abarate costes). El caso es que, aunque desde mediados de los 80 se conoce la mejora de los formatos RISC hasta casi hoy día no ha evolucionado, porque claro, el coste de cambiar, equipos, nuevos diseños, nuevo software y paquetes de programa que lo soporten, es muy elevado. 8 · Fundamentos de computadores TEMA 2 LOS SISTEMAS DE ENTRADA/SALIDA 1. ASPECTOS BÁSICOS DE LA ENTRADA/SALIDA El objetivo del sistema entrada/salida del computador consiste en intercambiar información entre los periféricos y la memoria principal o los registros del procesador. Evidentemente, todos los periféricos no son iguales y nos interesa clasificarlos por un parámetro fundamental para el proceso de entrada/salida como es el ancho de banda, es decir, la capacidad máxima que tiene de intercambiar información el periférico con el resto del sistema por unidad de tiempo. Características básicas de los periféricos Dispositivo Sentido transferencias Teclado Entrada Ratón Entrada Monitor gráfico Salida Disco magnético Entrada/salida Cd-Rom (12x) Entrada Impresora láser Salida Desencadenante transferencias Usuario Usuario Computador Computador Computador Computador Ancho de banda 10 bytes/s 20 bytes/s 30 Mb/s 2 Mb/s 1,8 Mb/s 100 Kb/s Al observar la tabla anterior es preciso comprender que existen multitud de periféricos con características distintas y el hecho de intercambiar datos con ellos implica amoldarse a su sentido, desencadenante y, sobre todo, ancho de banda para realizar la transferencia. En principio se podría crear un software específico para cada dispositivo, no obstante debido al gran número de ellos que existen la opción es impracticable, por ello se definen módulos normalizados de interconexión que se denominan módulos o controladores de entrada/salida. La función básica de estos módulos es ocultar al computador las particularidades de cada periférico y facilitar una visión y un control genéricos de éstos El módulo de entrada/salida constituye un controlador específico de uno o varios periféricos del mismo tipo y tiene dos elementos básicos: una interfaz específica con el periférico que controlan y una conexión normalizada con el resto del sistema. Es decir, existen unas especificaciones de todas sus características básicas como: El conjunto de líneas de conexión que lo componen Los circuitos equivalentes de todos los elementos conectados al bus normalizado El tamaño y otras características físicas de los conectores Cronogramas de funcionamiento Hablando en plata: la conexión entre el computador y el módulo de entrada/salida es una conexión normalizada a la que se deben adaptar tanto el PC como el módulo de entrada/salida una vez que se haya definido un estándar de conexión. Con frecuencia, un computador tiene conexiones normalizadas de varios tipos, así admite periféricos desarrollados según diferentes normas. Esta conexión se establece mediante un conjunto de registros (ver gráfico) que se agrupan según el tipo de información que almacenan. La transferencia de información a través de los módulos de entrada/salida tienen dos partes primordiales: la sincronización y el intercambio o transferencia de la información en sí. ¿Porqué hace falta una sincronización? Pues muy sencillo, el computador y el periférico tienen temporizaciones diferentes, como la velocidad de ambos es distinta, se hace necesario establecer un protocolo mediante el cual ambos subsistemas puedan determinar cual es el momento apropiado para intercambiar cada elemento de información. La sincronización es necesaria para evitar las Fundamentos de computadores · 9 pérdidas de información. Por ejemplo para enviar datos a una impresora de matriz de puntos, es preciso saber en qué momento está dispuesta a recibir el carácter siguiente y esto dependerá generalmente de cual ha sido el carácter anterior recibido; así el tiempo de espera será mayor en los retornos de carro, cambios de línea o tabulaciones. Una vez hecho lo anterior, se consigue que el periférico y el PC estén disponibles para establecer el intercambio o transferencia de información, que es cuando uno se envía al otro (dependiendo del sentido de la transferencia) el dato que constituye el objeto de la transacción. En cualquier computador convencional (un único procesador) es éste el que gobierna cada transacción de entrada/salida, o sea, decide leer o escribir información de/sobre un controlador de periférico; pero no es el procesador el que decide en qué momento o de qué forma se va a realizar la transferencia; es el periférico el que debe poder indicarle al procesador en qué momento se encuentra dispuesto para transmitir o aceptar información hacia o del procesador. En función del método utilizado para establecer la sincronización y llevar a cabo el intercambio de información entre procesador y controlador de periférico, encontrados tres técnicas básicas de entrada/salida que ahora vamos a enumerar y posteriormente desarrollar: 1. Entrada/salida programada: La sincronización y el intercambio de información son responsabilidad del procesador. 2. Entrada/salida por interrupciones: El controlador de periférico se responsabiliza de la sincronización y notifica al procesador cuando está dispuesto para realizar el intercambio de información. Esto último lo lleva a cabo el procesador. 3. Entrada/salida por DMA: El procesador se encarga de preparar la transferencia entre periférico y memoria y luego es el controlador del periférico el que asegura la sincronización y lleva a cabo sin la intervención del procesador, el intercambio de información. 2. ENTRADA/SALIDA PROGRAMADA También se denomina directa y se caracteriza porque toda la responsabilidad de la transferencia de información recae sobre el procesador. Éste, por medio de la ejecución del programa adecuado se encarga de establecer la sincronización con el periférico y la posterior transferencia de información. En el caso de un periférico de entrada, cada vez que el procesador comprueba que el controlador de periférico dispone de un nuevo dato, lo toma y queda disponible para recibir otro posterior; si el periférico es de salida, cuando el procesador dispone de un dato para enviar al periférico y detecta que éste está disponible, efectúa el envío. A continuación, estudiaremos algunos aspectos relacionados con la entrada/salida programada, que influyen también en el funcionamiento de otras técnicas de entrada/salida. Mapa de entrada/salida La comunicación con los periféricos se efectúa mediante sus módulos de entrada/salida correspondientes y, por tanto, es preciso que el procesador tenga acceso a los registros de datos, control y estado de estos módulos. Para poder identificar cada uno de los registros existen dos posibilidades: Mapa de memoria: los registros se encuentran en determinadas posiciones del mapa de memoria. En este caso se utilizan decodificadores que se activan a partir de las líneas de bus de direcciones y utilizan las mismas señales de control que sirven para seleccionar la memoria Que los registros se ubiquen en un espacio de direcciones independientes de la memoria, el espacio ENTRADA/SALIDA o mapa independiente de entrada/salida. Aquí se suelen compartir las líneas de direcciones y para distinguirlas se dan conjuntos de señales de control: 10 · Fundamentos de computadores o o READ, WRITE e IO/MEM: marcan la temporización de la transferencia. MEMR, MEMW, IOR e IOW: Hacen la transferencia según el tipo a que se refieran. Cronograma Los accesos al registro de datos, control o estado de un módulo de entrada/salida son similares a los de acceso a memoria que ya hemos estudiado en el semestre anterior, la única diferencia es que se utilizan otras señales de control en el caso de los sistemas con entrada/salida independiente de la memoria. Los accesos pueden ser: Síncronos: De duración predeterminada. En el ejemplo del gráfico (página anterior) se efectúa la carga del dato en el extremo de bajada del reloj. Asíncronos: De duración arbitraria, establecida por el periférico. En el ejemplo, la carga en el registro de datos se produce en el momento en que la señal WAIT deja de estar activa. Semisíncronos: De duración establecida por el periférico pero siempre múltiplo del período de reloj. En el ejemplo, la carga del dato se produce al detectarse el flanco de bajada de la señal de reloj, pero sólo si la señal WAIT está desactivada. Instrucciones de entrada/salida No todos los computadores disponen de instrucciones específicas de entrada/salida, solo aquellos en los que se establece una distinción entre el mapa de memoria y el de entrada/salida. Cada vez que estos computadores ejecuten una instrucción de lectura o escritura en memoria, además de activar el resto de las líneas de conexión con el exterior, ponen en marcha la línea o líneas que distinguen entre entrada/salida y memoria y las pasan al estado que corresponde a una operación con memoria (por ejemplo IO/MEM pasaría al estado lógico L). Las ventajas de disponer de instrucciones específicas de entrada/salida son cuestionables; de hecho muchos diseñadores han optado por no distinguir entre entrada/salida y memoria al hablar de arquitectura básica de la máquina; no obstante con instrucciones específicas de entrada/salida se consigue establecer con más facilidad un mecanismo de protección adecuado, ya que se tratan de instrucciones privilegiadas que solo pueden ejecutarse en la modalidad de supervisor y así, un usuario cualquiera no puede acceder a estos controladores de entrada/salida. Identificación del periférico Esta función que realiza el procesador es importantísima pues cuando éste debe intercambiar información con más de un periférico, debe identificar en cada Fundamentos de computadores · 11 instante qué periférico está disponible para llevar a cabo el intercambio y, en ese caso, ejecutarlo. Es más típico del caso siguiente la entrada/salida por interrupciones; no obstante en entrada/salida programada también se puede plantear esta situación, es decir, que en el mismo instante haya varios periféricos con información disponible para el procesador. Aquí debe decidirse si todos ellos se deben tratar de manera equitativa o si conviene establecer algún esquema de prioridades entre periféricos. En este último caso hay que tener en cuenta que este esquema de prioridades se implementará en forma de programa, sin disponer de ayuda hardware de ningún tipo. El procedimiento consiste en realizar una consulta o polling sobre todos aquellos periféricos que en un momento determinado puedan disponer de información para intercambiar. Existen dos tipos de prioridad que se puede establecer en estos casos: Prioridad fija: El programa de consulta comienza siempre comunicándose con el mismo controlador de periférico y los recorre todos siguiendo la misma secuencia fija. Prioridad rotativa: Se asigna un número de orden a cada periférico. Tras atender a la petición de uno de ellos, se pasa a consultar el estado del siguiente de acuerdo con el número de orden, en lugar de volver a empezar por el primero. 3. ENTRADA/SALIDA POR INTERRUPCIONES El sistema de entrada/salida por interrupciones, como ya apuntábamos anteriormente, se caracteriza porque la responsabilidad de la sincronización entre procesador y periférico se encuentra en el módulo de entrada/salida. Para hacer esto posible es preciso que el procesador disponga de una línea de entrada, denominada línea de petición de interrupción (INT), mediante la cual el módulo de entrada/salida puede reclamar la atención del procesador cada vez que esté dispuesto a transferir un dato desde o hacia el periférico. La ventaja de este sistema frente al de entrada/salida programada es que aquí no es preciso que el procesador consulte de forma reiterada el estado del periférico, sino que puede estar ejecutando otro programa. El periférico se encarga de activar la INT en el momento en que está dispuesto a intercambiar información con el procesador; y esto sucede en las siguientes etapas: 1. Antes de iniciar cada instrucción, el procesador comprueba el estado de la INT. 2. Si la línea INT no está activada, realiza la lectura y ejecución de la instrucción y pasa a la siguiente. 3. Si la línea INT está activada, por el contrario, se altera la ejecución del flujo normal del programa: a. Se pasa a la rutina de servicio de interrupción b. Para ello es necesario pasar de la clase que se encuentra (usuario o supervisor) a la de supervisor. c. Una vez ejecutada y finalizada la interrupción el procesador debe continuar ejecutando el programa tal como se venía realizando, como si nada hubiese pasado, y en este sentido será fundamental la salvaguarda del estado del procesador. Un aspecto importante es la inhibición de las interrupciones; pues existen situaciones excepcionales que requerirán el ignorarlas. Esta situación se almacena normalmente como un bit de registro de estado; si el procesador tiene permitida la interrupción al comprobar este bit de registro, entonces valora el estado de la línea INT antes de cada instrucción. Además la línea INT se considera activa por nivel y no por flanco, lo cual quiere decir que cuanto se vuelvan a 12 · Fundamentos de computadores permitir las interrupciones tras un tiempo de inhibición, solo se atenderán las de aquel periférico que la esté solicitando todavía. Dado que las interrupciones pueden llegar (y llegan) al procesador en cualquier momento, es fundamental la salvaguarda del estado del procesador, como ya apuntábamos anteriormente para que, una vez terminada la ejecución de la interrupción, el programa continúe como si nada hubiese pasado. La salvaguarda del sistema consiste en guardar el valor de los flags de estado y de ciertos o todos los registros para poder seguir utilizándolos después de la interrupción. Esto se puede hacer de dos formas: De manera automática: El procesador está obligado a almacenar todo el estado, es decir, los flags de estado y el conjunto de registros de toda la arquitectura. Es un método más lento, pues se guardan más cantidades de información, pero más seguro. De manera manual: Se guardan los valores de los flags de estado y las instrucciones adecuadas para almacenar el estado actual. Lo más habitual y eficiente consiste en que el procesador guarda de forma automática los flags de estado y el contador de programa (PC) y el programador de la rutina de servicio de interrupción guarda el resto de los registros que la rutina modifica. Explicado todo lo anterior, deducimos 3 formas diferentes de conectar los controladores de periféricos, es decir, cual es la conexión física entre el procesador y los controladores de periféricos que interrumpen, y esto debe resolvernos 3 dudas importantes: Poder determinar de entre todos los periféricos conectados al sistema, cual es el que está reclamando la atención del procesador. Cual es el periférico prioritario o prioritarios que debemos atender cuando varios reclamen la atención del procesador Decidir si durante una interrupción, puede activarse otra del mismo o de otros periféricos. Como decía antes tres posibilidades importantísimas se nos abren y son las que vamos a estudiar: la línea única de petición de interrupción, la conexión en Daisy Caín y las líneas independientes de petición y reconocimiento de interrupción. LA LÍNEA ÚNICA DE PETICIÓN DE INTERRUPCIÓN Es el método más sencillo de conexión entre un procesador y varios periféricos (página siguiente) y consiste en compartir la línea de petición de interrupción entre todos los controladores de periférico conectados al sistema. De esta forma el procesador es capaz de determinar si algún procesador está activado, pero para saber cual es en concreto, necesita establecer la lectura del registro de estado de cada uno de ellos y decidir por programa cual es (software). La ventaja evidente de este método es la sencillez y flexibilidad a la hora de determinar a cual de los periféricos que solicitan una interrupción es preciso atender en primer lugar. No obstante el inconveniente es el retraso que introduce (que no es despreciable ni mucho menos, pues es comparable en tiempo a la ejecución de varias líneas de código de programa), pues cada vez que un periférico activa la interrupción es preciso ejecutar una única rutina de atención a interruptores: salvaguardar los registros, identificar que periférico es el que interrumpe (realizando un muestreo de todos ellos por si es solo uno o hay más de Fundamentos de computadores · 13 uno). Otra gran ventaja es la de permitir con el programa de atención a periféricos si establecemos una prioridad a uno o más de un periférico sobre el resto a la hora de activar su interrupción. Otro inconveniente es la nidificación de interrupciones al compartir la misma línea. Esta nidificación consiste en permitir que un periférico más prioritario sea capaz de interrumpir a su vez la rutina de servicio de interrupción correspondiente a otro menos prioritario, pero no en el caso contrario o cuando los dos tengan el mismo grado de prioridad. Al compartir una única línea y estar ejecutándose ya una interrupción y producirse otra, es imposible saber a priori si la nueva interrupción es prioritaria sobre la que ya se está ejecutando: es necesario activar de nuevo la rutina de interrupciones y ver cual es el periférico que interrumpe y qué grado de prioridad tiene. Así que si activamos la atención a interruptores puede ocurrir la degradación del sistema por agotamiento de la memoria (y ni que decir que cuando se ha empezado a atender una interrupción ésta debe desactivarse, pues de lo contrario y teniendo activada la posibilidad de interrumpir entraríamos en un bucle infinito) Línea única de petición de interrupción Ventajas Inconvenientes Sencillez en las conexiones Flexibilidad por software para atender por prioridades a los periféricos Velocidad lenta en cualquier interrupción por poco prioritaria que sea Imposibilidad de nidificación de interrupciones al no saber, a priori, si la nueva interrupción es más prioritaria que la anterior. CONEXIÓN DE INTERRUPCIONES EN DAISY CHAIN Una forma de evitar el retraso que supone el tener que ejecutar un programa cada vez que hay una interrupción se basa en la utilización de una nueva línea de control: la línea de reconocimiento de interrupción, conocida como INTA o INTACK. Si están habilitadas las interrupciones, el procesador comprueba al principio de cada instrucción si la línea de petición está activada (hasta aquí, como antes). En caso de estar activada, activa la INTA para que el periférico reconozca que el procesador está atendiendo su petición y deja de hacer la solicitud de interrupción. Para poder conectar más de un periférico se establece una cadena daisy Caín. El primer controlador de periférico si no es el activo, activa una prolongación de línea hacia el segundo, y así hasta que se encuentre cual esta solicitando interrupción. Como esta circuitería es muy sencilla, la identificación del periférico que interrumpe es muy rápida y así no se ejecuta un programa específico para esta función (lo hemos resuelto por hardware). No obstante, solo hemos hecho saber al periférico que conocemos de su solicitud de interrupción, pero todavía no sabemos cual es, ni que grado de prioridad tiene. Para ello se utiliza la técnica de vectorización, que consiste en que el periférico cuando recibe la señal por el INTA, deposita en el bus de datos un determinado valor, denominado valor de interrupción. Al procesador le llega este valor y lo utiliza para saber, a través de este índice recibido en una tabla de vectores de interrupción (con un valor único, obviamente para cada periférico), y así se activa la rutina de servicio de interrupción para ese controlador. La ventaja de una línea de petición compartida y líneas de reconocimiento encadenadas (Daisy Chain) radica que en un único ciclo de bus, sabemos que periférico está interrumpiendo y activamos la rutina asociada a este periférico; y 14 · Fundamentos de computadores aunque sean varios los que interrumpen, de manera rápida y automática determinamos el grado de prioridad (se comienza siempre por los más próximos al procesador). Pero la desventaja principal es la rigidez del esquema basado en esta prioridad por cercanía (que es además fija e inalterable, debido a que es una solución hardware) y además seguimos sin resolver el problema de nidificación de interrupciones, pues seguimos sin conocer si la nueva interrupción es más prioritaria que la que ya pudiésemos estar llevando a cabo. Daisy Chain: Línea de petición compartida y líneas de reconocimiento encadenadas Ventajas Inconvenientes Sencillez en las conexiones Velocidad en la detección del periférico/s que interrumpe/n (un ciclo de bus) Rápida determinación de la prioridad de los periféricos que interrumpen. Rigidez y poca flexibilidad para variar la prioridad de los periféricos, dado que se trata de una solución hardware fija e inmutable. Seguimos sin resolver la nidificación de interrupciones pues desconocemos si la nueva interrupción es más prioritaria que la que ya estamos llevando a cabo. LÍNEAS INDEPENDIENTES DE PETICIÓN Y RECONOCIMIENTO DE INTERRUPCIÓN La única posibilidad para distinguir de forma inmediata (sin la obligación de ejecutar un programa) si una interrupción que acaba de llegar al procesador es más prioritaria que la que ya estamos ejecutando, consiste en disponer de líneas de petición independientes para los diferentes controladores de entrada/salida (ver figura en la página siguiente). La identificación del periférico que interrumpe es directa pues cada línea de petición se encuentra conectada con un solo periférico; pero además se flexibiliza la gestión de prioridades al poder decidir que línea consideramos ordenadamente más prioritaria que otras. Así resolvemos el problema que antes ya nos habíamos planteado es decir, la nidificación de interrupciones. El proceso que se sigue es el siguiente para cada código de instrucción: Se valora si existe una interrupción activada, de no ser así, se ejecuta la siguiente instrucción. Si existe una activación de interrupción, se valora si es de mayor nivel que la que se tiene almacenada (de no ser de mayor nivel, se continúa como si nada hubiese ocurrido). En caso de ser de mayor nivel, el proceso consiste en salvaguardar PC y los registros de estado; en activar la modalidad de supervisor, en actualizar el nivel de interrupción almacenado, en leer el vector de interrupción y posteriomrnete en finalizar esta rutina de interrupción. Esquema de un ciclo fetch en un procesador con líneas independientes de petición y reconocimiento de interrupción ¿Hay una línea de interrupción activa? NO Ir al final del fetch SI ¿Es nivel de la interrupción > Nivel_alamacenado? NO Ir al final del fetch SI Salvaguardar PC y registros de estado | Actualizar modalidad supervisor | Actualizar el nivel almacenado=Nivel de interrupción | Leer vector de interrupción | Encontrar dirección | Ir a final fecth Final Fetch: Leer instrucción señalada por el PC | Actualizar PC | Descodificar y ejecutar Fundamentos de computadores · 15 El problema es que no todos los procesadores disponen de un número suficiente de líneas de petición y reconocimiento de interrupción para implementar el esquema descrito anteriormente. Como muchos de ellos solo disponen de una línea de petición y de reconocimiento, lo que se hace es añadir un controlador de interrupciones, o sea, circuitos especializados en la gestión de interrupciones de sistema. Así conseguimos prácticamente la misma funcionalidad pero con dos diferencias apreciables: 1. La salvaguarda y recuperación del nivel de ejecución no se lleva a cabo de forma automática, dado que el controlador de interrupciones es externo al procesador, y este, sin ejecutar un programa, no es capaz de decidir el nivel de interrupción que está atendiendo. 2. Como el procesador solo dispone de una línea de petición de interrupción, se debe identificar el periférico que interrumpe igual que si se tratase de un encadenamiento, estableciendo un esquema de vectorización o bien a partir de la lectura de los registros internos del controlador de periférico. Ahora bien, en el esquema inicial de líneas independientes de petición y reconocimiento de interrupción no todo son ventajas, ya que el número de periféricos conectables al sistema se encuentra limitado por las líneas disponibles. Esto se solventa con los dos sistemas representados a continuación: un esquema híbrido de líneas de petición y reconocimiento de interrupción y conexión de controladores de interrupciones en cascada. Las famosas interrupciones de software, excepciones o traps, se tratan de igual forma que si se tratasen de una interrupción de entrada/salida ; con la 16 · Fundamentos de computadores salvedad de que las interrupciones de software se señalan por medio de la activación de una señal externa, mientras que las de entrada/salida se generan dentro del procesador. Entre estas excepciones se encuentran las correspondientes a los intentos de accesos a recursos restringidos, operaciones aritméticas como la división por cero o la raiz cuadrada de un número negativo, etc. Repasemos ahora el conjunto de operaciones que debe llevar a cabo un sistema genérico con entrada/salida por interrupciones. Éstas corresponden a un procesador con un número suficiente de líneas de petición y con capacidad para tratar interrupciones sectorizadas y autovectorizadas: Fase Inicial Fase de funcionamiento normal Fase de retorno de interrupción Se activa el sistema y se programan los dispositivos que se deben utilizar y se define el valor inicial de la variables utilizadas Programación de los controladores de entrada/salida, definición del vector de interrupción, etc. Se especifica el nivel de ejecución menos prioritario (programa principal). Se habilitan las interrupciones. Se pasa a ejecutar el programa principal Se ejecuta en esta fase el programa principal, constituido por norma general, por un bucle de espera donde se comprueba el estado del sistema. En la fase de fetch se realiza: Comprobación de las líneas de petición de interrupción con nivel superior al actual de ejecución, por si existe alguna activada. Si no es así, se continúa la lectura del código de la operación de la instrucción actual. Si alguna línea de petición con suficiente prioridad está activada se ejecuta el ciclo de reconocimiento de interrupción que consiste en: o Si la entrada al procesador indica que es una interrupción autovectorizada, utiliza como vector de ésta el número de línea de interrupción que está activo. Como tabla de vectores de interrupción utiliza la de autovectores (pasa al punto 4) o Si no es autovectorizada, activa la línea de reconocimiento de interrupción correspondiente al nivel detectado. o Lee el vector de interrupción proporcionado por el controlador de entrada/salida. o Accede a la entrada de la tabla de vectores o autovectores de interrupción señalada por el vector que se acaba de almacenar. o Obtiene de la tabla correspondiente, la dirección de la rutina de servicio de interrupción o Almacena el contador de programa, registro de estado actual en la pila de modalidad supervisor del sistema. o Actualiza el registro de estado y almacena el nuevo nivel de ejecución y se activa el bit que indica la modalidad de ejecución correspondiente al supervisor o Actualiza el contador del programa con el valor obtenido de la tabla correspondiente (vectores o autovectores). o Reinicia la ejecución de instrucciones (empieza un nuevo ciclo de fetch). Se desarrolla al ejecutar la instrucción RETI, que debe ser la última de cada rutina de servicio de interrupción: solo debe recuperar el estado (que incluye información sobre el nivel de ejecución) que se había guardado en la pila del sistema en el momento de responder a la interrupción. Se recupera el contador de programa almacenado en la pila del supervisor. Se recupera el registro de estado almacenado en la pila del sistema (con esto además se recuperan el nivel de ejecución y el bit de modalidad usuario/supervisor). Se ejecuta un nuevo ciclo de fetch utilizando los nuevos valores de los registros contador de programa y registro de estado 4. ENTRADA/SALIDA POR DMA En las entrada/salida que hemos visto antes, para cada interrupción que se genera es preciso desviar el flujo normal del programa, por lo que es necesario realizar una salvaguarda del estado actual, para luego volver al mismo estado que había antes de la interrupción. Esto se traduce en un consumo de tiempo de ejecución para cada interrupción, suponiendo asimismo una importante limitación del ancho de banda máximo que queda libre para la comunicación con el periférico. Esto hace que la entrada/salida por interrupciones sea adecuada para dispositivos de ancho de banda no muy grande, pero para los más rápidos, el procesador estaría prácticamente dedicado a ejecutar las instrucciones de los servicios de interrupción. El objetivo de la entrada/salida por DMA es que el procesador pueda desentenderse de la sincronización y de la transferencia de información con el periférico, pero para ello es imprescindible que el módulo de entrada/salida sea capaz de acceder de manera automática a la memoria del sistema. No obstante Fundamentos de computadores · 17 aunque el procesador se desentienda de este proceso si necesita programar la operación con antelación y además necesita ser avisado por el periférico cuando el intercambio de información haya finalizado y esto suele hacerse generando una interrupción. Así que resumiendo, la transferencia por DMA está compuesta de: Programación de la operación llevada a cabo por el procesador. Transferencia de información entre periférico y memoria principal, gestionado por el controlador de entrada/salida. Culminación de la operación, donde el controlador de entrada/salida avisa al procesador del desenlace de la transferencia. Claro, observando este proceso que ha de desarrollarse se comprende de la ineficacia de utilizar DMA para transferir datos individuales; por este motivo se utiliza para transferir bloques de datos relativamente grandes, por ejemplo sectores entre el disco y la memoria, pero no para enviar datos del teclado al procesador. Pero este tipo de entrada/salida añade un nuevo problema que no se había presentado hasta ahora, es preciso establecer un mecanismo mediante el cual ambos elementos utilicen la memoria sin que por ello se genere ningún tipo de conflicto. Hay dos posibilidades: Utilizar una conexión a memoria independiente para cada elemento, memoria con doble puerta (no se usa demasiado pues es un tipo de memoria más cara). Aplicar una única conexión a memoria, compartida por el procesador y el módulo de entrada/salida proporcionando asimismo un mecanismo para evitar conflictos en el acceso mutuo a memoria. Esto se denomina robo de ciclo. Este robo de ciclo es el sistema que suele utilizarse y que vamos a describir. Para llevarlo a cabo se necesitan líneas triestado para la conexión del procesador y los módulos de entrada/salida en el bus (direcciones, datos y señales de control) y además dos nuevas líneas de control: BUSREQ y BUSACK. El funcionamiento se basa en que el procesador es más prioritario a la hora de cualquier transacción, pero comprueba con frecuencia el estado de la señal de petición del bus BUSREQ, de manera que cuando el procesador la encuentre activada responda a tal demanda. Esto consiste en activar BUSACK; lo que hace que el bus quede ahora gobernado por el 18 · Fundamentos de computadores controlador de entrada/salida, y así se asegura que solo uno de los dos dispositivos gobierna la memoria. Ahora bien, nos encontramos con diferentes maneras de conectar los módulos: la forma más sencilla es la primera gráfica de la figura de la página anterior. Tenemos un único módulo entrada/salida que interactúa con el procesador para la petición de los buses; pero claro, si necesitamos más de un módulo (que es lo lógico) se puede actuar creando un daisy Caín de los módulos de entrada/salida por DMA (figura siguiente) o finalmente introduciendo un nuevo dispositivo: el controlador DMA: que es un dispositivo capaz de gestionar las transferencias de varios periféricos. El funcionamiento del sistema por DMA tiene las 3 partes ya anunciadas anteriormente: Programación de la operación, transferencia de los bloques de datos y finalización de la operación. PROGRAMACIÓN DE LA OPERACIÓN El procesador debe informar al controlador de Entrada/salida por DMA al menos de los siguientes datos: Número de datos que se deben transferir. Dirección de memoria a partir de la cual se deben almacenar o depositar los datos que se intercambian. Sentido de la transferencia. Si el módulo entrada/salida se utiliza para controlar varios periféricos, también es preciso identificar el periférico involucrado en la transferencia. Evidentemente, para que el controlador DMA sea capaz de guardar estos datos, hay que dotarlo de nuevos registros, estos son: RCOMPT: contador que guarda el número de datos a transferir; y RADRM: se almacena la dirección inicial de la memoria involucrada en la transferencia. TRANSFERENCIA DE LA INFORMACIÓN Una vez programada la transferencia, el módulo entrada/salida debe esperar hasta que el periférico que controla se encuentre disponible para transmitir o recibir y entonces solicita el bus y tras serle concedido por el procesador efectúa la transferencia. En el proceso de entrada/salida por interrupciones podíamos inhibir la transferencia si ésta era de un nivel prioritario inferior a la que ya estábamos ejecutando; esta opción por DMA no se contempla (el bus se cede de forma automática ante la petición de BUSREQ). Esto se debe a que la cesión del bus constituye una operación muy rápida y que además no afecta al estado del procesador, no tiene que iniciar sistemas de salvaguarda de datos, solamente congela la ejecución de las instrucciones que está ejecutando (salvo que exista una transferencia a la memoria en ese momento, con lo cual el procesador termina la transferencia y luego cede el bus o, en otros casos, corta inmediatamente la transferencia para iniciarla posteriormente a la transferencia DMA). Además, como la cesión siempre se encuentra habilitada, el tiempo de respuesta es muy breve. En caso de una lectura del periférico cada vez que éste indica a su controlador que dispone de un dato para transferir y siempre que el contenido de RCOMPT sea distinto de 0 (esto indica que existen datos pendientes de transmitir), el controlador lo almacena en RDATO, activa BUSREQ para solicitar el bus al procesador, éste lo cede activando BUSACK, el módulo entrada/salida lanza un ciclo de escritura en memoria por el bus y deposita sobre el bus de direcciones el contenido del registro RADRM y sobre el bus de datos el de RDATO; tras finalizar el ciclo de escritura en memoria, el controlador desactiva BUSREQ y actualiza los registros RADRM (incrementa para aumentar a la posición de memoria Fundamentos de computadores · 19 siguiente) y RCOMPT (Decrementa, para contener el número de datos pendiente de transmitir). Si después de esta actualización RCOMPT es distinto de cero, el controlador vuelve a esperar la llegada de un nuevo dato; si es cero se pasa a la fase de finalización de la operación. En el caso de que se utilice un controlador de acceso directo a memoria para gestionar varios periféricos, varía ligeramente el funcionamiento, puesto que el registro de datos se encuentra por norma general en el módulo entrada/salida mientras que el de direcciones forma parte del controlador DMA; estas son las únicas diferencias importantes. Funcionamiento del controlador de periférico por DMA Programación de la operación If OP=WRITE While RCOMPT>0 Activar BUSREQ Esperar BUSACK Leer [ADRM] y llevar el dato leído a RDATO Desactivar BUSREQ Esperar a periférico preparado y enviarle el contenido de RDATO Incrementar ADRM y decrementar RCOMPT End-while If OP=READ While RCOMPT>0 Esperar DATO del periférico y llevarlo a RDATO Activar BUSREQ Esperar BUSACK Escribir sobre [ADRM] el valor contenido en RDATO Desactivar BUSREQ Incrementar ADRM y decrementar RCOMPT End-while Finalización de la operación Una forma de optimizar el rendimiento de esta actividad consiste en minimizar el tiempo que se pierde mientras se obtiene el control del bus y se retorna al procesador; esto se realiza con lo que se denomina modalidad de ráfagas, consistente en acumular en el módulo de entrada/salida o el controlador de DMA una cantidad importante de datos antes de solicitar el bus (eso sí, este dispositivo debe tener capacidad para acumular todos estos datos que luego queremos enviar). Así una vez acumulado el número requerido de datos, o pasado el número de ciclos necesario para poder realizar otra transferencia; pedimos el bus, realizamos la transferencia de todos los datos cumulados y evitamos liberar y retomar el bus cada vez que tenemos un dato que enviar. De una u otra forma, una vez que RCOMPT es cero, pasamos a la siguiente fase. CULMINACIÓN DE LA OPERACIÓN Una vez transferido el bloque o bloques de información programado por el procesador, el módulo entrada/salida o controlador DMA genera una interrupción INT con la que se notifica al procesador que ya se ha transferido el bloque solicitado y con la ejecución de la correspondiente rutina de servicio de la interrupción, se considera acabada la operación de transferencia del bloque. 5. COMPARACIÓN DE LAS TÉCNICAS BÁSICAS DE ENTRADA/SALIDA Vamos a estudiar el rendimiento del sistema, para ello vamos a poner el ejemplo de que se quiere analizar el tiempo de transferencia de información de un 20 · Fundamentos de computadores disco magnético utilizando estas 3 técnicas de entrada/salida. Los datos del problema son los siguientes: Ejecución de cada instrucción por el procesador: 150 ns. Ciclo de lectura o escritura: 100 ns. Cesión / recuperación de los buses: 50 ns. Tiempo de latencia del sico: 15 ms. Velocidad de transferencia: 2Mb/s Conexión de 32 bits entre el módulo de entrada/salida y el procesador Con estos datos, e independientemente de la técnica de entrada/salida que se trate, se invertirá el tiempo siguiente (dado que es necesario programar la operación, esperar el tiempo de latencia adecuado y transferir la información: Total: Latencia + Transferencia = 15 ms + 210 bytes(1kbyte) =15,49 ms 2 * 2 20 ( Bytes / s ) En este tiempo, el procesador es capaz de realizar (como máximo y sin 15,49·10 6 interrupciones) = 103267 instrucciones. 150 A través de la entrada/salida programada el controlador dice que proporciona un dato cada: 1 =1,91 s; el número de instrucciones, por tanto que se puede 2·2 bytes / s 4 Bytes / palabra 20 ejecutar entre dato y dato es de 1910/150 = 12 instrucciones; normalmente con 12 instrucciones hay de sobra para realizar el bucle de sincronización con el controlador y toda la rutina de ejecución: Hay que tener en cuenta que si la conexión no fuera de 32 bits, sino de 8, la cosa se complicaría, pues se dispondría de un dato cada 0,4775 milisegundos y ahí si que no daría tiempo a ejecutarse. En fin, en resumen, tardamos 15,49 ms en transferir la información, pero el procesador queda ocupado durante todo este tiempo. A través de entrada/salida por interrupciones, el tiempo en que se dispone de un dato sigue siendo 1,91 s, igual que antes, y ello permite ejecutar 12 instrucciones; en este caso hay que preparar una rutina un pelín ajustada; la rutina de servicio de interrupción puede constar como máximo de 11 instrucciones, pues se debe prever el tiempo empleado en el reconocimiento de la instrucciones, que suponemos es similar al de la ejecución. Conseguimos hacer una rutina de ejemplo de 11 instrucciones (ajustado como digo), no obstante seguimos adelante; y suponiendo que la programación de la operación como su culminación se efectúan con 30 instrucciones, el tiempo que el procesador utiliza para llevar a cabo la transferencia es de: Tiempo ocupado = 30·150ns + 210 Bytes ·11·150 ns + 30 ·150 ns = 4 Bytes / palabra 0,43ms; Por tanto, disponemos de tiempo libre del procesador: 15,49 - 0,43 = 15,06 ms; en los que puede efectuar 15,06·10 6 =100.400 instrucciones de las 103.267 posibles; 150 lo cual no está nada mal. A través de la entrada/salida por DMA suponiendo que la programación de la transferencia y la rutina de interrupción duren unas 40 instrucciones, el tiempo de ocupación del procesador es: Tiempo ocupado = 40·150 ns +Tiempo transferencia + 40·150 ns. Fundamentos de computadores · 21 El tiempo de trasnferencia es igual al tiempo que tarda en ceder el bit, efectuar una lectura, recuperar el bit y todo ello multiplicado por buses de 1024/4 datos; en total: Tiempo transferencia = (1024/4) · (50 ns + 100 ns + 50 ns) = 51.200 ns. Por tanto, el tiempo ocupado es: 6000 + 51200 + 6000 = 63200 ns. Con lo que el tiempo disponible es 15,49 – 0,0632 = 15,43; y se pueden efectuar 15,43·10 6 = 102.867 instrucciones de trabajo, de las 100400 disponibles. 150 Todavía podemos mejorar más si suponemos que trabamos en modalidad de ráfaga y por tanto cedemos y recuperamos el bus cada, digamos 8 palabras, por lo que el tiempo de transferencia pasa a ser: Tiempo transferencia = (256/8)·(50ns + 8·100 ns + 50 ns) = 28800 ns; y ello nos hace reducir el tiempo de ocupación a 40,800 ns y por tanto poder ejecutar muchas más instrucciones. 6. ASPECTOS COMPLEMENTARIOS DE LA ENTRADA/SALIDA Dos apartados vamos a ver de forma escueta como aspectos complementarios de la entrada/salida que no hemos tratado con anterioridad. Estos aspectos son la relación de la entrada/salida con el sistema de memoria y mejoras que añade el controlador DMA en las transferencias de este tipo. Hasta ahora hemos hecho un estudio conciso de la relación entre el sistema de entrada/salida y la memoria del ordenador; pero en realidad la memoria de un ordenador suele estar compuesta por una memoria caché, una principal y un mecanismo de memoria virtual; esta jerarquía la estudiaremos en el capítulo siguiente pero se diseña con la finalidad de incrementar la velocidad de los accesos que realiza el procesador. La relación de la entrada/salida con la memoria caché es un poco caótica. Existen dos opciones: o conectar el módulo entrada/salida con la memoria caché o hacerlo con la principal (en realidad con el bus que va a la principal. En el caso de que se conecte a la memoria caché tenemos dos grandes desventajas: Disminución del ancho de banda en la comunicación entre el procesador y la memoria caché. Aumento de la tasa de errores de caché pues se almacena información que no se corresponde a lo que el procesador espera utilizar. Debido a ello, éste no es el método de conexión más utilizado; pero la conexión con la memoria principal del controlador DMA tampoco está exenta de desventajas: la principal dificultad es asegurar la coherencia de la información entre estos dos niveles de memoria; se suele resolver haciendo las operaciones sobre una zona de memoria no cacheable. Otro problema lo establece la memoria virtual. Si el dispositivo que establece el acceso al DMA lo hace a través de direcciones virtuales; en cada acceso a memoria lanzado por el dispositivo DMA es preciso realizar la traducción a la dirección completa correspondiente, con la consecuente pérdida de prestaciones. Esto suele resolverse añadiendo un dispositivo para acelerar la traducción: el TLB (solución eficaz pero compleja) Si, por el contrario, el programador DMA utiliza direcciones físicas, se hace preciso limitar el número de datos para transferir en una sola operación al tamaño 22 · Fundamentos de computadores de una página, pues no se puede asegurar que las páginas virtuales consecutivas se encuentren localizadas en un determinado momento sobre marcos de página contiguos. Por otro lado, una mejora importante del funcionamiento de la entrada/salida por DMA lo constituyen los canales de entrada/salida: su función consiste en facilitar la programación aprovechando la capacidad que poseen de acceder de forma autónoma a la memoria.; así se consiguen varias ventajas: Programación de las operaciones de entrada/salida más rápida. Si el controlador DMA está ocupado en una transferencia cuando el procesador está programando una nueva operación; éste la puede generar, llevarla a memoria y arrancarse en cuanto el controlador DMA esté disponible (de nuevo eficiencia temporal). El lenguaje de programación propio del canal de Entrada/salida, aún siendo básico, puede tener una instrucción que indique el final de la operación de transferencia, con lo que solo se ejecuta una única instrucción de finalización y no al finalizar la transferencia de cada bloque. Elimina el conflicto con la memoria virtual en los marcos de páginas contiguos. 7. EL BUS DE ENTRADA/SALIDA Tal como hemos estudiado la estructura básica del computador , parece que únicamente exista un bus de propósito general para conectar el procesador con la memoria y con los adaptadores de entrada/salida (figura a). Esto realmente no es así ya ni el los ordenadores más sencillos, está todo basado en una jerarquía de buses que, aunque no está tan definida como la jerarquía de memorias, empieza a estar cada vez más delimitado. Actualmente, los ordenadores poseen un adaptador conmutador de memoria bus PCI (figura b), cuya función consiste en conectar en cada instante los dos elementos involucrados en una transferencia, por ejemplo, la memoria caché de segundo nivel con la memoria principal o con un controlador de entrada/salida. Se pueden establecer así flujos simultáneos de información que aumenta la velocidad y eficiencia de la máquina. Características del Bus entrada/sal: Tipo de funcionamiento y temporización (síncrono, asíncrono, semisíncrono). Multiplexación de direcciones/datos Admisión de varios maestros del bus. Número de líneas dedicadas a transferir datos (incide directamente en el ancho de banda que puede alcanzar). Número de líneas dedicadas a propagar direcciones de memoria o entrada/salida (espacio de direccionamiento) Normalización del bus que, a la larga, Fundamentos de computadores · 23 redundará en una mayor difusión de éste. Aunque no hay una clara definición del bus de entrada/salida diremos que se trata de un bus normalizado, encargado de conectar los diferentes módulos de entrada/salida o bien los periféricos de un tipo determinado con el computador. Así, observamos en la figura lateral, que los buses local y de memoria no interconexionan ni periféricos ni controladores; por el contrario los buses EISA y SCSI (incluso el PCI) sí. Es hoy día característica primordial de los buses de entrada/salida que se deben normalizar, de namera que varios fabricantes de controladores de E7s o de dispositivos periféricos, pueden adoptar sus diseños en la especificación de cada bus normalizado. Otras características más particulares del Bus dependen del propósito que se le quiera dar, de manera que se pueden observar diferencias importantes entre: Bus de almacenamiento: Se trata de conseguir un ancho de banda elevado a un coste de interconexión bajo y sin que la latencia sea importante Bus de soporte: Para la interconexión de módulos de entrada/salida, que proporciona un ancho de banda elevado, con baja latencia y debe admitir varios maestros de bus. Bus serie: Para la conexión de periféricos, cuyo objetivo principal es la reducción de costes, pero no de rendimiento. Es fundamental el direccionamiento del bus, que se lleva a cabo en una sola etapa y que puede ser: Direccionamiento lógico: Los diferentes módulos tanto de memoria como de entrada/salida se deben encargar de decodificar las señales del bus de direcciones y determinar si se trata de una transacción dirigida a ellos. Para llevarlo a cabo a cada módulo del sistema se asigna un rango de direcciones único (a través de microinterruptores que se deben configurar en el momento de colocar la placa correspondiente dentro del computador) y tiene el inconveniente de constituir una importante fuente de errores de configuración. Direccionamiento geográfico: Es el más seguro dado que se obvian los posibles errores de configuración por parte del usuario (lo realiza automáticamente el sistema operativo) y el que más se utilizada en la actualidad. Consiste en separar la selección de un módulo de memoria o entrada/salida del direccionamiento de éste en sus diferentes celdas de memoria o posiciones de entrada/salida. Se establece un mecanismo para seleccionar el módulo conectado a una determinara ranura o slot para facilitar la configuración automática de los sistemas y para proporcionar la selección de una posición concreta del módulo. Así no hay que configurar los adaptadores de entrada/salida manualmente, puesto que la dirección que ocupan en el espacio de direcciones se encuentra determinada por la ranura donde se ha situado. Otra característica es la gestión del bus: el problema se establece a la hora de utilizar buses que admiten más de un maestro potencias que pretende emplear el bus para realizar una transacción, y es entonces cuando hace falta un maestro que determine cual tiene la autorización para poder utilizarlo. Para ello existe el gestor del bus único, con varios pares de líneas de petición y concesión del bus, similar al gestor de interrupciones visto anteriormente. Como ejemplos de buses de entrada salida tenemos el ISA: utilizado anteriormente como base de los ordenadores iBM-PC, ha quedado relegado como soporte de módulos entrada/salida y simplemente por un problema de compatibilidad con los periféricos; el bus PCI: es el sucesor del anterior iSA y es el más generalizado, inicialmente solo iba a ser soporte del microprocesador Pentium, pero se cedieron sus especificaciones al dominio público y de ahí su generalización, el bus SCSI tiene un elevado ancho de banda y es típico en unidades de disco o cinta magnética; y por último el bus USB que contrario al resto, solo utiliza dos hilos (señal y referencia) para transmitir información entre los periféricos. 24 · Fundamentos de computadores 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. Conector de alimentación Conector de teclado Pila BIOS Ranuras expansión con buses ISA de 8 ó 16 bits. Ranuras expansión con buses locales, PCI, EISA, de 32 bits. Chips controladores DMA Zócalo ZIF para el procesador. Buses 10. Memoria caché Zócalos de memoria RAM Fundamentos de computadores · 25 TEMA 3 INTRODUCCIÓN A LA JERARQUÍA DE MEMORIAS Desde los orígenes de la informática, los programadores han pretendido disponer de computadores con mucha capacidad de memoria; actualmente esta cantidad de memoria solo constituye un problema de coste. La rapidez de los procesadores se ha multiplicado en los últimos 20 años por 3000 mientras que la de los dispositivos de memoria solo por 10. Esta gran diferencia que además tiende a aumentar constituye uno e los factores críticos en el rendimiento de un computador. La solución ofrecida por los diseñadores para este problema consiste en el uso de una jerarquía de memorias: memorias estructuradas en distintos niveles con distintas velocidades y capacidades. El objetivo de la jerarquía de memorias consiste en que la información que el procesador requiera en cada momento se encuentra almacenada en el nivel más próximo y rápido posible y, por otro, en que la transferencia de información entre los distintos niveles se lleve a cabo por medio de mecanismos automáticos (sin la intervención explícita del programador o usuario). 1. TIPOS DE MEMORIAS Según su perdurabilidad: Memoria volátil (pierde la información almacenada cuando deja de tener alimentación eléctrica o memoria no volátil. Escritura por parte del usuario o no escribibles por parte del usuario Según estos antecedentes tenemos lo siguientes tipos de memoria “comerciales”: o ROM: (Read only memory): Memoria de solo lectura de semiconductor (como la BIOS). o RAM: (Randon Access Memoru): Memoria de acceso aleatorio o directo, memoria de semiconductor. A su vez encontramos los tipos SRAM (estáticas) o DRAM (dinámicas que pierden el contenido de la memoria a los pocos nanosegundos, son muy utilizadas porque son baratas y rápidas, aunque haya que “refrescar” constantemente sus contenidos). o Disco: Dispositivos magnéticos de acceso directo. o Cinta: Su acceso es secuencial y lento, por ello solo se utilizan como copia de seguridad. o CD. 2. ACCESO A LOS DATOS Y A LA JERARQUIA DE LAS MEMORIAS La memoria se puede considerar un vector unidimensional en el que es preciso especificar un número que indique su posición dentro de ella para acceder a los elementos individuales. Esta cifra se denomina dirección de memoria. El objetivo que se busca en el diseño del sistema de memoria de un computador es que tenga gran capacidad y un tiempo de acceso reducido al menor precio posible. Como no existe ninguna tecnología que cumpla de forma simultánea ambos requisitos, la memoria del computador se estructura en varios niveles, formando la jerarquía de memorias. El objetivo final de esta jerarquía es conseguir que cuando el procesador acceda a un dato, éste se encuentre en el nivel más rápido de la jerarquía (y por ello el de menor capacidad). Se suelen dividir por ello en: Primer nivel: constituido por los registros visibles del lenguaje máquina. Es de poca capacidad. Segundo nivel de memoria caché: constituye una memoria RAM auxiliar de alta velocidad. Puede existir más de un nivel de esta modalidad (como en el ejemplo de la imagen) Memoria principal Niveles posteriores o memorias secundarias: cds, discos duros, etc. Como además se puede ver en la imagen, cada nivel se relaciona solo con los siguientes, y el procesador solo puede acceder al nivel más bajo. Si el dato que necesita el procesador no se encuentra en él, debe transferirse del siguiente o de los siguientes niveles hasta este para que el procesador pueda leerlo. Por otro lado si se modifica algún dato, el procesador lo hará en el nivel más bajo y es necesario que tal modificación se efectúe en el resto de niveles donde se encuentra almacenada una copia del dato original (de lo contrario podríamos incurrir en un problema de coherencia). 26 · Fundamentos de computadores Pero claro, nosotros podríamos pensar ¿Tanto flujo de información de un nivel a otro tiene que perjudicar al rendimiento del procesador en lugar de ayudarle y además como vamos a tener todos los datos que necesitamos en un área de memoria muy pequeña, como es la memoria más próxima al ordenador? Esto tiene fácil respuesta: El flujo de datos que se mueve entre niveles está controlado por el sistema de entrada/salida, que, como ya sabemos, apenas interrumpe el número de instrucciones que es capaz de desarrollar el procesador. Por otro lado, el conjunto de datos que se necesitan para ejecutar un programa y el de instrucciones, a pesar de ser inicialmente grande, en realidad es pequeño: se sabe que el 1% de código de un programa corresponde al 96% de las instrucciones realizadas; y el 6% de todas las posiciones de memoria suele abarcar el 90% de los accesos a la misma. Esto se debe a una proximidad referencial: en un intervalo de tiempo, un proceso determinado solo utiliza una pequeña parte de toda la información almacenada, debida sobre todo a: o Proximidad temporal: En un intervalo de tiempo determinado, la probabilidad de que un proceso acceda varias veces a las mismas posiciones de memoria es muy alta; los bucles en los programas producen proximidad temporal y las llamadas repetidas a subrutinas que emplean las mismas variables locales y los mismos parámetros. o Proximidad espacial: la distancia entre referencias próximas es muy pequeña en intervalos de tiempo determinados: es decir a excepción de las bifurcaciones el flujo de ejecución es lineal; además los accesos a estructuras de datos recorridas de manera secuencial es muy frecuente. 3. LA MEMORIA CACHÉ La memoria caché se caracteriza por tener una alta velocidad y un tamaño reducido. Es en esta memoria donde vamos a ejemplificar todo lo visto anteriormente. Una cuestión importante es la tasa de acierto/fallo, es decir, la tasa de fallos en es número de veces que se accede a la memoria caché y no se encuentra allí el dato, dividido por el número de accesos a memoria; es justo lo contrario a la tasa de aciertos, que es lo que nos interesa tener. Cuando se lanza un acceso a la memoria, el algoritmo de emplazamiento es el encargado de buscar este dato: Se busca este dato en cualquier posición de la memoria caché (asociativa por completo) Este dato solo puede encontrarse en una sola posición de la memoria caché (emplazamiento directo). Se puede ubicar en un conjunto limitado de posiciones de la memoria caché (nasociativa). La memoria caché completamente asociativa es la que produce porcentaje de fallos más bajos, pero debido a su elevado precio y lentitud no suele utilizarse; la directa es la que produce más fallos, pero al ser más barata y sobre todo rápida, compensa este elevado número de fallos con el rápido tratamiento que hace de los mismos. Cuando se encuentra un fallo (o sea el dato buscado por el procesador no está en la memoria caché), el hardware de la memoria caché se encarga de las siguientes tareas: Solicitar el dato que ha producido el fallo en el siguiente nivel de jerarquía (si este no lo encuentra ira al siguiente). Esperar a que llegue el dato Buscar un lugar en la memoria caché para el dato leído y almacenarlo en esta. Servir el dato al procesador como si se hubiera producido un acierto en la memoria caché. Este proceso es entre 5 y 10 veces más costoso en tiempo que si hubiese sido un acierto, es por ello muy importante intentar obtener una tasa de fallos baja. Hemos evidenciado que cuando se produce un fallo, los datos se trasladan secundariamente de la memoria principal a la caché, pero se traslada una línea entera pues es muy posible por la proximidad referencial, que necesitemos esos datos que rodean al que buscamos, por ello se traslada una línea o bloque completa a la memoria caché, pues es muy probable que la necesitemos. El tamaño de esta línea cuenta con unos cuantos bytes de información ( que no debe ser ni muy grande –pues borra otras líneas de la limitada caché que quizá sigamos necesitando- ni muy pequeña –pues corremos el riesgo de dejarnos atrás datos que posiblemente necesitemos). Fundamentos de computadores · 27 Eso sí, cuando una línea puede almacenarse en varios lugares de la memoria caché (asociativa o n-asociativa) es necesario determinar el algoritmo de reemplazo que se encargará de esta tarea, puede ser: Aleatorio: Es un algoritmo simple y funciona razonablemente bien. FIFO: Se elimina la línea que lleva más tiempo almacenada en la memoria; puede producir resultados indeseables puesto que esta línea no tiene porque ser la que menos se utilice. LRU: se elige la línea que lleva más tiempo sin utilizarse. Teóricamente es el más eficiente, pero es muy difícil implementarlo en forma hardware, se suelen utilizar algoritmos pseudo-LRU. La política de escritura se refiera al momento en que se actualiza la memoria principal; es decir cuando en lugar de tener un acceso a memoria de lectura es de escritura, el dato que se pasa al procesador es variado en la memoria más próxima del procesador, pero es necesario variar este dato también en el resto de copias, se puede hacer mediante dos sistemas: Escritura inmediata: Como su nombre indica se hace el cambio a la vez en todas las copias, es un sistema fácil de implementar pero que provoca un elevado intercambio de información entre ambas memorias (write no allocate). Escritura aplazada: Se efectúa siempre las escrituras sobre la memoria caché, y solo cuando se borra la línea afectada en la memoria caché, se trasladan los cambios a la memoria principal (write allocate). El rendimiento de la memoria caché por tanto, se medirá como el tiempo medio de acceso a memoria, que consistirá en: la tasa de aciertos por el tiempo en caso de acierto más la tasa de fallos por el tiempo que tarda en efectuar el acceso en caso de fallo. Lo ideal, evidentemente, es reducir la tasa de fallos todo lo posible. 28 · Fundamentos de computadores TEMA 4 ENSAMBLADOR 1. HERRAMIENTAS DE PROGRAMACIÓN Para poder escribir un programa, sea en alto nivel, sea en ensamblador, precisaremos de un editor. Éste tiene que ser un editor de texto sin formato, ya que el fichero debe contener exclusivamente las instrucciones del programa, sin códigos de control sobre el estilo de la letra, tamaño, colores, márgenes del documento, etc. El fichero que contendrá el programa de origen (source), es decir, en formato texto, recibe el nombre de fichero de origen o fichero de font. Si el fichero de origen contiene un programa escrito con un lenguaje de alto nivel, el proceso de traducción será realizado mediante un compilador. El programa compilador, por su parte, analiza el significado del programa en alto nivel y genera instrucciones de lenguaje de máquina que llevan a cabo la tarea especificada en el alto nivel. De otro lado, si el fichero de origen contiene un programa escrito con lenguaje de ensamblador, el proceso de traducción se genera mediante un programa ensamblador. El ensamblaje es un proceso más sencillo porque la traducción de lenguaje de ensamblador a lenguaje de máquina es directa: cada instrucción de ensamblador representa una instrucción en lenguaje de máquina. Como resultado de la compilación o del ensamblaje, se genera un fichero objeto que ya contiene instrucciones de lenguaje de máquina, pero que todavía no se puede ejecutar. Para finalizar, un fichero objeto o varios de ellos se pueden combinar y generar un fichero ejecutable. Este proceso se desarrolla mediante un montador o linker. Aunque se puede realizar el montaje con varios ficheros objeto, también es un paso necesario en caso de tener un solo fichero objeto. El fichero ejecutable ya es el fichero binario con el lenguaje de máquina y el formato adecuado para poder llevar a cabo su ejecución. 2. ESTRUCTURA DE UN PROGRAMA EN ENSAMBLADOR En este primer ejemplo tenemos el código de un programa en ensamblador que escribe por pantalla el mensaje "¡Buenos días!". El bloque identificado por la directiva .data que contiene los datos del programa (segmento de datos), que contiene una única variable llamada mensaje, que inicialmente contiene el texto que nos interesa escribir por pantalla. El bloque identificado por la directiva .code que contiene las instrucciones del programa (segmento de código), además de unas macros que indican el inicio y el final del programa, contiene tres instrucciones. Sin entrar en detalles sobre la función de estas instrucciones, podéis observar que cada una de ellas siempre ocupa una línea. En función del modelo de programa que vamos a seguir durante el módulo, siempre empezaremos el segmento de código con una etiqueta (en este caso inicio) seguida de la macro STARTUPCODE, que inicializa algunos registros del procesador. Para finalizar el programa, utilizaremos la macro EXITCODE 0, y para indicar la finalización del segmento de código tendremos que poner end seguido del nombre de la etiqueta de comienzo de código: inicio. Las tres instrucciones restantes, por tanto, son las que consiguen que se escriba un mensaje por pantalla. Los comentarios son otro aspecto importante de cualquier programa en ensamblador, son especialmente útiles en ensamblador debido a que el bajo nivel Fundamentos de computadores · 29 de este lenguaje provoca que los programas presenten algunas dificultades de comprensión. Así pues, en ensamblador, los comentarios son el texto que hay detrás de un punto y coma y llega hasta el final de línea. Una vez tenemos el fichero con el código ensamblador, hay que traducir el código escrito en lenguaje de ensamblador a lenguaje de máquina y generar, así, un programa ejecutable. El proceso de traducción consta de dos pasos: Ensamblaje (assembling). El ensamblaje traduce cada línea de código ensamblador a la correspondiente instrucción en lenguaje de máquina. Si el código ensamblador no contiene ningún error, se genera, entonces, un fichero objeto cuyo contenido consta de instrucciones en lenguaje de máquina. Nosotros utilizaremos el programa Turbo Assembler para ensamblar, un programa que genera un fichero objeto con extensión .obj. Montaje (linking). El montaje toma un fichero objeto (o más de uno) y, si no hay ningún error, genera el fichero ejecutable. Para montar, utilizaremos el programa Turbo Linker, que genera un fichero ejecutable con extensión .exe. En concreto, para ensamblar y montar el programa del ejemplo, ejecutaremos los siguientes comandos: tasm /zi buenosdias.asm y tlink /v buenosdias.obj 3. LECTURAS Y ESCRITURAS En este apartado estudiaremos como hacer lecturas de teclado y escrituras por pantalla de los elementos que nos interesen: Para poder escribir un texto por pantalla, éste tiene que estar inicialmente almacenado en la memoria en forma de cadena de texto. Podemos especificar el código ASCII de los caracteres poniendo directamente los caracteres entre comillas, o bien, en caso de encontrarnos con caracteres de control, poniendo el código ASCII separado por comas. Existen diferentes maneras de escribir texto por pantalla y de leer caracteres del teclado. De todas las posibilidades existentes, en un primer momento vamos a utilizar la interrupción 21h, mecanismo basado en interrupciones de software que permite utilizar rutinas del sistema operativo para acceder a los recursos del sistema. Para especificar qué función de sistema se quiere utilizar, se pone el valor correspondiente al registro ah. El ejemplo de la página siguiente nos muestra el código de un programa que escribe un mensaje inicial por pantalla, lee una tecla y escribe un segundo mensaje antes de finalizar. Para poder escribir un texto, éste tiene que estar inicialmente almacenado en la memoria. En nuestro caso tenemos las variables de memoria mensaje1, mensaje2 y saltolinea, cada una de las cuales contiene una cadena de texto y la variable de memoria tecla, que no está inicializada. Las dos primeras cadenas de texto contienen un mensaje y acaban con los caracteres 10 y 13, que codifican el salto de línea en código ASCII. La variable saltolinea, por su parte, sólo contiene un salto de línea. Y, por último, la variable tecla, que está sin inicializar (especificado con el carácter ?), servirá para guardar la tecla leída por el teclado. Para escribir un texto por pantalla, el registro dx debe contener la dirección de memoria a partir de la cual, y hasta encontrar el carácter "$", se halla el texto que estamos interesados en escribir. A continuación, tenemos que activar la interrupción 21h, poniendo antes 09h en el registro ah, que indica que se quiere escribir una cadena de caracteres por pantalla. De manera similar, para leer un carácter del teclado activaremos la interrupción 21h, pero, en este caso, poniendo previamente un 01h en el registro ah (de esta manera, el programa se esperará hasta que el usuario pulse una tecla). Veremos por pantalla la tecla pulsada, y ésta es almacenada en el registro al. Por lo general, es importante guardar el valor de la tecla pulsada en alguna 30 · Fundamentos de computadores variable de memoria (en el ejemplo, la variable tecla), ya que el registro al puede sufrir modificaciones en las interrupciones que se produzcan a continuación. Escritura de un texto por pantalla La forma de escribir un texto por pantalla es la que se ha descrito en el ejemplo 2; es decir: lea dx, [text] mov ah, 09 h int 21 h, donde text es la variable que contiene el texto (acabado con el carácter '$') que deseamos escribir. Escritura de un carácter por pantalla Si nuestra intención es escribir un solo carácter, no es necesario crear una cadena de dos caracteres acabada con la letra '$', puesto que hay otra interrupción que permite escribir un carácter con más facilidad, que es la siguiente: mov dl, car mov ah, 02 h int 21 h, donde car es el código ASCII del carácter que queremos escribir, o una variable o registro que contiene este código. Escritura de un número por pantalla No existe ninguna rutina de sistema que escriba directamente números por pantalla. Dado que la escritura por pantalla siempre se realiza con el código ASCII del carácter que nos interesa escribir, la escritura de números es una tarea más complicada. Si el número que hay que escribir es de un solo dígito, y teniendo en cuenta que sabemos que el código ASCII de los dígitos 0, ..., 9 son valores consecutivos, podemos utilizar la interrupción descrita en el caso anterior de este modo: mov dl,'0' add dl, núm mov ah, 02h int 21h, donde núm es el valor del número entre 0 y 9 que estamos interesados en escribir. Fijaos en que el valor del código ASCII del dígito '0' se suma al valor del número que se quiere escribir. El resultado de esta suma es el valor del código ASCII del dígito que queremos escribir. Si el número que queremos escribir contiene varios dígitos, hay que hacer la escritura dígito a dígito. Por esta razón, se deben realizar sucesivas divisiones por 10 y hay que escribir cada uno de los dígitos; pero la operación de división no la veremos hasta un poco más adelante. Lectura de un carácter por el teclado La lectura del teclado mediante la interrupción 21h es de tipo carácter a carácter. Existen dos variantes para realizar la lectura del teclado: con echo o sin echo. Pues bien, con echo significa que el carácter que se introduce por el teclado es escrito al mismo tiempo por la pantalla. Sin echo, significa que el carácter introducido por el teclado no se escribe por pantalla. En ambos casos, y de manera similar a la que vimos en el ejemplo 2, el carácter introducido queda almacenado en el registro al. La lectura con echo se hace de la siguiente manera: mov ah, 01 h int 21 h mov [car], al, donde car es una variable de memoria en la que guardamos el valor introducido por el teclado. De manera análoga, la lectura sin echo se realiza como veremos acto seguido: mov ah, 08 h int 21 h mov [car], al. Fundamentos de computadores · 31 4. ESTRUCTURAS CONDICIONALES En los ejemplos introducidos hasta ahora, las instrucciones han sido ejecutadas secuencialmente. Por lo general, sin embargo, podemos encontrar diferentes partes de un programa que se ejecutan en función de una serie de valores introducidos o calculados durante la ejecución del programa. Para poder identificar la instrucción a la que nos interesa saltar, es necesario poner una etiqueta. A continuación vamos a ver el código de un programa (página siguiente) que escribe un mensaje por pantalla en función del valor de la tecla introducida. Si se pulsa una "D", escribe "Buenos días". Si se pulsa una "T", escribe "Buenas tardes". Y, de lo contrario, no escribe nada. En concreto, se compara la tecla introducida con la letra "D". Si la comparación es igual, no se salta y el código continúa ejecutándose de forma secuencial, de manera que se escribe el mensaje2. De lo contrario, se salta a la instrucción identificada por la etiqueta tardes. De manera análoga, si la tecla introducida no es una "D" y se salta a la instrucción etiquetada con tardes, se realiza, entonces, una nueva comparación. En este caso, se compara la tecla introducida con la letra "T": si es igual, no se salta y se sigue ejecutando el código de forma secuencial y escribiendo el mensaje3; de lo contrario, se salta a la instrucción identificada por la etiqueta final. Así pues, el mensaje2 sólo se escribirá si la tecla introducida es la "D", y el mensaje3, sólo si la tecla pulsada es la "T". Fijaos en que cuando nos encontramos con dos condiciones excluyentes (es decir, que se ejecuta un bloque de instrucciones u otro bloque diferente) es necesario poner una instrucción de salto incondicional jmp al final del primer bloque de instrucciones para, de este modo, evitar que el segundo bloque sea ejecutado a continuación del primero. Además, prestad atención al hecho de que la etiqueta días carece de uso alguno. En general, sin embargo, las etiquetas (de la misma manera que los comentarios) también sirven para aportar más claridad al programa. En este caso, podéis observar que, en el programa del ejemplo, las etiquetas nos identifican cuatro bloques: inicio, días, tardes y final. Debemos hacer hincapié en el hecho de que las comparaciones se realizan con los caracteres "D" y " T", caracteres que corresponden a letras en mayúscula. En realidad, lo que se está comparando es el código ASCII de estos caracteres con el código ASCII introducido por el teclado. 32 · Fundamentos de computadores Instrucciones de salto Formato: Cmp al, “D” Jne tardes La primera instrucción compara el valor guardado en al, con la letra D (además mayúscula). La segunda instrucción provocará un salto hasta la etiqueta tardes, siempre y cuando el valor en Al y D sean distintos. Comparación de valores con signo jg Salta si el primer valor es mayor que el segundo valor jge Salta si el primer valor es mayor o igual que el segundo valor jl Salta si el primer valor es menor que el segundo jle Salta si el primer valor es menor o igual que el segundo Comparación de valores sin signo ja Salta si el primer valor es mayor que el segundo jae Salta si el primer valor es mayor o igual que el segundo jb Salta si el primer valor es menor que el segundo jbe Salta si el primer valor es menor o igual que el segundo Otras instrucciones de salto je Salta si los dos valores comparados son iguales jz jne Salta si los dos valores comparados son diferentes jnz jmp Salta incondicionalmente 5. ESTRUCTURAS ITERATIVAS Otra técnica importante de la programación (tanto en alto nivel como en ensamblador) son las estructuras iterativas, es decir, la ejecución de un conjunto de instrucciones un número repetido de veces. En general, el conjunto de instrucciones que se encuentran dentro de una estructura iterativa se puede ejecutar en un número fijo de iteraciones, o bien hasta que se cumpla una determinada condición. El programa que tenemos a continuación escribe un mensaje por pantalla un número repetido de veces. En una primera versión de este código, el mensaje es escrito diez veces; en la segunda versión, el mensaje es escrito cada vez que se pulsa una tecla, hasta que la tecla pulsada es la "X". Como podéis ver en el ejemplo, esta estructura iterativa está formada por una estructura condicional y un salto hacia atrás. La estructura condicional evalúa el número de veces que se ha ejecutado el código sabiendo que cx cuenta el número de iteraciones. Cuando cx es mayor o igual que 10, se produce un salto hacia final; de lo contrario, se ejecutan las instrucciones que se encuentran en el interior de la estructura iterativa y se salta hacia atrás, es decir, hacia las instrucciones que vuelven a evaluar la condición. Por lo general, para que el tipo de estructura iterativa que aparece en el siguiente ejemplo funcione de forma correcta es necesario: Inicializar el contador (en este caso el registro cx). Incrementar el contador al final de cada iteración. Fijaos en que si no hubiésemos inicializado cx, este registro podría corresponderse con cualquier valor, por ejemplo 10. En este caso, el código que se encuentra en el interior de la estructura iterativa no se ejecutaría nunca. Fundamentos de computadores · 33 Ejemplo 1 Ejemplo 2 De la misma forma, si cx no se hubiera incrementado al final de cada iteración, el contador valdría siempre 0 y, por lo tanto, la estructura iterativa nunca acabaría y el programa se iteraría de manera indefinida. En esta segunda versión del mismo programa, cada vez que se pulsa una tecla se escribe el mensaje, hasta que la tecla pulsada es "X". Fijaos en esta segunda versión del programa, en la que la estructura de evaluación de la condición es muy similar a la del ejemplo de la versión anterior. En este caso, sin embargo, se compara la tecla pulsada con el carácter "X". Pues bien, si éstas son iguales, se salta hacia el final del programa; de lo contrario, se escribe el mensaje y se vuelve a saltar hacia atrás (hacia la introducción de la tecla). En este caso, la condición de evaluación es el valor de la tecla introducida por el teclado. Tened en cuenta que antes de comparar por primera vez, siempre se lee una tecla, y antes de cada nueva iteración, se lee una nueva tecla. Las estructuras iterativas mostradas en los ejemplos 4.1 y 4.2 sirven tanto para contar un número determinado de iteraciones como para iterar hasta que se cumpla una condición. En el primer caso, sin embargo, en el que se pretende contar un número determinado de iteraciones, el lenguaje de ensamblador nos ofrece otra manera más eficiente de hacer lo mismo: la instrucción loop. La instrucción loop sirve para contar iteraciones utilizando de forma implícita el registro cx para el recuento. Al ejecutarse la instrucción loop, el registro cx decrece en una unidad y, si su valor es diferente de 0, se salta a la etiqueta especificada en la misma instrucción. 6. TIPOS DE OPERANDO Una instrucción en ensamblador ejecuta una determinada acción, utilizando uno o varios operandos de origen, sobre (por lo general) un operando de destino. Además, tanto el operando de origen como el de destino deben tener el mismo tamaño. Cada uno de los operandos de origen, es decir, los operandos que son consultados para ejecutar la instrucción, puede ser de tres tipos: una constante, un registro, una variable de memoria. 34 · Fundamentos de computadores Del mismo modo, el operando de destino, es decir, el operando que se actualiza como consecuencia de la ejecución de una instrucción, puede ser de dos tipos: un registro, una variable de memoria. No tiene ningún sentido hablar de operando de destino constante, porque el operando de destino es, por definición, el operando que se actualiza al ejecutar una instrucción, y una constante no varía. A pesar de que tanto el operando de origen como el de destino pueden ser de tipo variable de memoria, el ensamblador del i8086 posee una restricción: los dos operandos no pueden ser de tipo variable de memoria en la misma instrucción; por lo que, o bien lo es uno, o bien lo es el otro. En el ejemplo, aparece en negrita la instrucción mov, que ya conocéis. Esta instrucción tiene dos operandos y se comporta de la siguiente manera: mov destino, origen Es decir, copia el valor del operando de origen (lo lee) sobre el operando de destino (lo escribe). En este caso concreto, podéis ver que la instrucción utiliza como operando de origen un valor constante (aquí el número 65). La constante especificada puede estar expresada en los diferentes formatos que mostramos a continuación: en decimal: mov dl, 65; en hexadecimal: mov dl, 41h; en ASCII: mov dl, 'A' A continuación, en el caso2 podéis observar que el operando de origen de la instrucción mov es un registro; de hecho, en esta instrucción el operando de destino también es un registro. El acceso a un registro es muy rápido, pero, a pesar de todo, existen pocos registros disponibles. Así pues, se acostumbra a utilizar operandos de tipo registro siempre que esto sea posible. Además, hay operaciones que fuerzan el uso de algún registro concreto. Prestad atención, ahora, al hecho de que en el caso3, la instrucción mov utiliza un operando de origen de tipo variable de memoria. En este caso, aparece especificada entre corchetes una variable de memoria declarada en el segmento de datos. Como en el caso anterior, para utilizar un operando de origen de tipo variable de memoria, ésta tiene que haber sido declarada e inicializada con anterioridad. Tened en cuenta el hecho de que la variable carácter1 ha sido declarada en el segmento de datos e inicializada en el valor 65 durante su definición. Utilizaremos un operando de tipo variable de memoria para guardar los datos de nuestro programa temporalmente (debido a que como no suele ser suficiente con los registros de que disponemos, éste es el lugar habitual en el que se guardan los datos). Y ya por último, a continuación pasamos a ver (en negrita) un caso algo diferente. Fijaos en que la variable de memoria carácter2 ha sido declarada en el segmento de datos con dw, en lugar de con db. db significa que la variable de memoria ocupa 8 bits (los necesarios para codificar un carácter en código ASCII); dw, por su parte, significa que la variable de memoria ocupa 16 bits. En consecuencia, en este segundo caso, la constante 65 se codifica con 16 bits (los 8 bits de mayor peso serán cero). En el caso4, el operando de origen es, del mismo modo, una variable de memoria; no obstante, en este caso es una variable de 16 bits. Como ya hemos comentado, tanto el operando de origen como el de destino tienen que ser del mismo tamaño, motivo por el que ahora se utilizará un registro de 16 bits (dx). Fundamentos de computadores · 35 7. VARIABLES DE MEMORIA: DECLARACIÓN Y USO Las variables de memoria sirven para almacenar información en memoria de forma temporal. Esta información puede ir cambiando durante la ejecución del programa, o bien puede mantenerse constante (según nos interese). Con el fin de especificar a qué parte de la información nos interesa acceder, identificaremos cada variable de memoria con un nombre. Una variable de memoria se representa mediante un nombre declarado dentro del segmento de datos, para el que se define un tamaño y, de forma opcional, un valor inicial. Cuando se ejecuta el programa ensamblador, el sistema reserva en la memoria el espacio correspondiente al tamaño de las variables. Estas posiciones de memoria estarán inicializadas o no en función de la manera en que hayamos declarado la variable. Desde el programa ensamblador podremos acceder al valor de la variable especificando su nombre entre corchetes. Las variables de memoria aparecen declaradas en el segmento de datos. Al declarar una variable de memoria, hay que indicar su tamaño y, de forma opcional, el valor inicial de la misma. En el ejemplo, en negrita, podéis ver las tres posibilidades que nos ofrece el ensamblador para indicar el tamaño: db indica que el tamaño es un byte. dw indica que el tamaño es un word (dos bytes). dd indica que el tamaño es un double (cuatro bytes). Por lo tanto, tened en cuenta que var1 es una variable de 8 bits, var2, una de 16 bits y var3, una de 32 bits. Después del tamaño, aparecerá especificado el valor inicial. Dicho valor puede aparecer especificado en cualquier formato: decimal, hexadecimal o bien en código ASCII. En el ejemplo, hemos inicializado todas las variables en 1. No obstante, fijaos en que la primera variable ocupa 8 bits, la segunda, 16 bits y la tercera, 32 bits. Si nos es indiferente el valor en el que queremos inicializar una variable, lo podemos especificar de la siguiente manera: var dw ? Se puede declarar una lista de variables del mismo tipo (un vector) tras haber indicado el tamaño que queremos que tenga cada elemento (db, dw o dd), poniendo los valores iniciales de forma consecutiva. En el siguiente ejemplo podéis observar que var4 es una lista de diez elementos de tamaño byte inicializados con códigos ASCII. Podemos especificar los códigos ASCII escribiendo los caracteres entre comillas, o bien escribiendo los códigos separados por comas. De forma similar, var5 es una lista de cinco elementos de tamaño word inicializados en los valores 1, 3, 5, 7, y 9, respectivamente. En caso de que tengamos números naturales o enteros, éstos se ponen directamente separados por comas. Por último, disponemos de una alternativa para declarar listas de una gran cantidad de elementos. Ésta es la forma en la que se ha declarado la variable var6, es decir, se ha indicado que consta de diez elementos de tamaño word, inicializado cada uno de ellos en el valor cero. Se puede declarar una lista de variables del mismo tipo (un vector) tras haber indicado el tamaño que queremos que tenga cada elemento (db, dw o dd), poniendo los valores iniciales de forma consecutiva. En el siguiente ejemplo podéis observar que var4 es una lista de diez elementos de tamaño byte inicializados con códigos ASCII. Podemos especificar los códigos ASCII escribiendo los caracteres entre comillas, o bien escribiendo los códigos separados por comas. De forma similar, var5 es una lista de cinco elementos de tamaño word inicializados en los valores 1, 3, 5, 7, y 9, respectivamente. En caso de que 36 · Fundamentos de computadores tengamos números naturales o enteros, éstos se ponen directamente separados por comas. Por último, disponemos de una alternativa para declarar listas de una gran cantidad de elementos. Ésta es la forma en la que se ha declarado la variable var6, es decir, se ha indicado que consta de diez elementos de tamaño word, inicializado cada uno de ellos en el valor cero. Se accede al resto de los elementos de la lista sumando un desplazamiento con respecto al elemento inicial. Así pues, para acceder al quinto elemento de la lista de caracteres, se suma un desplazamiento de 4 con respecto al elemento inicial. Tened en cuenta que se han creado los dos accesos mencionados de diferente manera: en el primer caso, se ha movido una constante, y, en el segundo, un registro. En cualquier caso, es importante que veamos que el tamaño de ambos operandos en cada instrucción es el mismo: 8 bits. Al acceder a los elementos de una lista de words debemos tener en cuenta que cada elemento ocupa dos bytes. En consecuencia, para llevar a cabo el acceso, hay que sumar un desplazamiento de dos por cada elemento. Las siguientes instrucciones no tienen ningún efecto sobre lo que se escribe por pantalla, pero podéis apreciar el acceso a la lista de words por el desplazamiento de 8 con respecto al inicio de la variable de memoria var5. Es decir, que se accede al quinto elemento de esta lista (el último elemento, que contiene el valor 9). Además, dado que se accede a un elemento de 16 bits, tendremos que utilizar un registro de 16 bits (en este caso, el registro ax). Como habréis podido observar, estas instrucciones copian el último elemento de la lista sobre el elemento inicial. En teoría, podríamos hacer esta acción de la siguiente manera: mov [var5], [var5 + 8] Sin embargo, recordad que los dos operandos de una misma instrucción no pueden ser variables de memoria, motivo por el que el movimiento se ha tenido que efectuar en dos instrucciones. Tras la ejecución de estas dos instrucciones, el contenido del vector será: 9, 3, 5, 7 y 9. 8. VARIABLES DE MEMORIA: MODALIDADES DE ACCESO Las variables de memoria están declaradas en el segmento de datos y se identifican mediante un nombre; utilizaremos este nombre para acceder a la variable. Para acceder a una variable en concreto, sólo tenemos que poner el nombre entre corchetes. De forma parecida, si nos interesa acceder a una posición concreta de una variable de tipo lista, sólo tenemos que que poner entre corchetes el nombre de la variable y el desplazamiento apropiado. En algunos casos, puede ser interesante acceder a una variable a priori indeterminada, o bien a una posición indeterminada (o variable) de una variable de tipo lista. Pues bien, en estos casos, para especificar el dato de memoria al que queremos acceder, es necesario utilizar registros entre corchetes.Para el acceso de variables de memoria con registros, podemos utilizar dos tipos de registros: los registros base y los registros índice. Los registros base sirven para especificar la dirección inicial (dirección base) de una variable de memoria. En ensamblador, podemos encontrar un registro base para referenciar datos llamado bx. Existe un segundo registro base que se utiliza para referenciar información de la pila llamado bp, aunque, de momento, sólo utilizaremos el bx. Los registros índice sirven para especificar un desplazamiento (índice) a partir de una dirección base. En ensamblador, podemos encontrar dos registros índice, los cuales reciben el nombre de si y di. El programa que encontraréis a continuación escribe una lista de números pares o impares, según la tecla que el usuario haya introducido. En pricipio, hay dos vectores con números pares, y a uno de éstos se le suma +1 para hacerlos impares. La utilidad de este programa sólo es ilustrar las diferentes modalidades de acceso a las variables de memoria. Fijaos en el segmento de datos, en el que a Fundamentos de computadores · 37 parte del mensaje hay dos listas de números: par e impar. En un principio, la lista impar contiene números pares, así que el primer bucle del programa recorre esta lista e incrementa cada uno de sus elementos. Para hacer esto, la instrucción marcada en negrita utiliza el nombre de la variable en cuestión y suma el valor del registro índice si. Como podéis observar, el registro si vale inicialmente cero, y en cada iteración del bucle se incrementa en dos unidades. Así, se va generando el desplazamiento adecuado para acceder a cada elemento del vector. En tal caso, diremos que esta instrucción accede a unas posiciones indeterminadas (de hecho, unas posiciones variables) de una variable determinada (concreta) de tipo lista. Observad que, como siempre, el registro índice se ha inicializado con anterioridad, en este caso en cero, puesto que, de lo contrario, se accedería a posiciones indeterminadas de memoria (dependiendo del valor del registro si). Después del primer bucle que incrementa los elementos del vector impar, se escribe el mensaje por pantalla y se pide al usuario que pulse una tecla. A continuación tenemos el condicional que comprueba si la tecla pulsada es una "P" o una "S". En caso de que no sea ni una "P" ni una "S", el programa salta al final y no hace nada. Dependiendo de la tecla pulsada, se ejecuta la instrucción lea utilizando como operando de origen el vector par o el vector impar. La instrucción lea pone la dirección de memoria que tiene el operando de origen en el operando de destino. Por lo tanto, el operando de origen tiene que ser por fuerza de tipo variable de memoria. En este caso, en el registro bx ponemos la dirección de memoria del vector par o del vector impar, según la tecla introducida. Fijaos en la diferencia existente entre la instrucción mov y la instrucción lea. En el primer caso, accedemos al valor de la variable de memoria, y, en el segundo, a la dirección de la variable de memoria. El objetivo de poner la dirección de una variable de memoria en el registro bx es poder utilizar el registro entre corchetes para efectuar el acceso a la variable de memoria a la que apunta. Una vez tenemos una dirección de memoria en el registro bx, podemos acceder a ella poniendo el registro bx entre corchetes de la siguiente manera (de hecho, podemos utilizar ax o cualquier otro registro): mov ax, [bx] En el ejemplo podéis observar que se accede a una dirección de memoria formada por la suma de bx y si. Es decir, a una variable no fija (la que apunta al registro bx) con un desplazamiento variable (el que indica el registro si). Es necesario tener en cuenta que para realizar este tipo de acceso no podemos utilizar ningún registro diferente del registro base o del índice. De momento, utilizaremos bx como registro base, y si o di, como registros índice. Más adelante, cuando estemos en la pila, también utilizaremos el registro base bp. En general, este hito podemos resumirlo determinando que se puede acceder a una posición de memoria mediante la siguiente modalidad de direccionamiento: mov ax, [base + indice + despl], donde base puede ser el nombre de una variable o un registro base (o nada), indice hace referencia en un registro índice (o nada), y despl puede ser cualquier constante (o nada). La expresión que especifica la dirección de memoria a la que se quiere acceder sólo puede contener el operando "+", la suma, o para constantes, también el operando "-", la resta. Por lo tanto, los siguientes accesos serían incorrectos: mov ax, [var * 2] mov ax, [bx – si] mov ax, [var + 2 * si] 38 · Fundamentos de computadores 9. INSTRUCCIONES ARITMÉTICAS Consideremos las instrucciones aritméticas que podemos implementar de forma directa con el lenguaje de ensamblador. Las instrucciones aritméticas del lenguaje de ensamblador permiten realizar una operación aritmética con uno o dos operandos de origen y guardar el resultado sobre un operando de destino. Las instrucciones de ensamblador del i8086 no permiten especificar más de dos operandos. Así pues, uno de los operandos de origen siempre es, además, el operando de destino. Por ejemplo, para sumar dos números, la sintaxis es: add num1, num2 En este caso, se desarrolla la operación: num1 = num1 + num2. De forma similar, otras operaciones que requieren un solo operando de origen, como el incremento (sumar + 1), se especifican con un solo operando. Es decir: inc num En este caso, la operación que se realiza es: núm = núm + 1. Existen otras instrucciones en las que, para ejecutar la operación, se toma siempre un operando (por lo general un registro) de manera implícita. Es decir, uno de los operandos no se especifica y se toma siempre un determinado registro. Ejemplos de estas instrucciones son la multiplicación y la división, que veremos más adelante. El primer bloque de instrucciones hace una suma. Cuando queremos sumar dos números y guardar el resultado obtenido, tenemos que hacer, forzosamente, el cálculo por medio de un registro auxiliar (en el ejemplo utilizamos el registro ax) y después guardar el resultado en la variable de memoria correspondiente. Otra posibilidad la encontramos cuando queremos sumar dos números y guardar el resultado obtenido sobre uno de estos dos números. En este caso, en teoría podríamos hacer: add [resultado], [numero] Sin embargo, recordad que existe la limitación de que los dos operandos no pueden ser variables de memoria. En consecuencia, en este caso también tendremos que efectuar el cálculo por medio del registro ax, pero de una manera algo más eficiente que en el ejemplo: mov ax, [numero] add [resultado], ax Para la resta sólo debemos tener cuidado con el orden en que se especifican los operandos, ya que la operación resta el operando de origen del operando de destino (sobre el que guarda el resultado). Es decir, la operación op1 = op1 - op2 se hace de la siguiente manera: sub op1, op1 Ahora bien, la operación op1 = op2 - op1, siendo op1 y op2 variables de memoria, tendría que llevarse a cabo de una forma más sutil: mov ax, [op1] mov bx, [op2] sub bx, ax mov [op1], bx Fundamentos de computadores · 39 Las operaciones de suma y resta se ejecutan igual si se realizan sobre números naturales o enteros, ya que el algoritmo de suma (o resta) para números naturales y enteros (codificados en complemento a 2) es el mismo. La operación de multiplicación es más delicada que la suma y la resta; su funcionamiento depende del tamaño del operando que se especifique: Si el operando es de 8 bits, se ejecuta: ax = operando × al. Si el operando es de 16 bits, se ejecuta: dx _ ax = operando × ax. Cuando la multiplicación es de 8 bits, se multiplica el operando por al y se guarda el resultado obtenido en el registro ax. Es decir, se utiliza el registro ax de manera implícita. Además, fijaos en que el resultado de haber multiplicado dos números de 8 bits podría ocupar hasta 16 bits; por consiguiente, esta operación guarda el resultado en ax. De forma parecida, cuando la multiplicación es de 16 bits, ésta utiliza de manera implícita el registro ax como segundo operando y guarda el resultado en los registros dx (los 16 bits de más peso) y ax (los 16 bits de menos peso), ya que, en este caso, el resultado ocupará hasta 32 bits. Aquí, sí debemos diferenciar si la multiplicación se realiza con números naturales o enteros. De hecho, la instrucción imul multiplica números enteros, mientras que la instrucción mul, hace lo propio con números naturales. Tanto en la instrucción mul como en la instrucción imul, el operando que se especifica tiene que ser, por fuerza, de tipo registro o variable de memoria. Por último, veamos la operación de división, que también depende del tamaño del operando especificado: Si el operando es de 8 bits, se ejecuta: al = ax / operando ah = ax % operando. Si el operando es de 16 bits, se ejecuta: ax = dx_ax / operando dx = dx_ax % operando. Donde / representa el cociente, y %, el resto de la división. Es decir, el operando especificado en la instrucción representa el divisor, y el dividendo se toma de forma implícita del registro ax (y del dx para divisores de 16 bits). En este segundo caso, el dividendo será un número de 32 bits formado por dx (los 16 bits de más peso) y ax (los 16 bits de menos peso). De manera similar, hallamos las instrucciones div y idiv para hacer divisiones: la primera la realiza asumiendo que los números son naturales, y la segunda, asumiendo que los números son enteros. Fijaos en que si queremos dividir dos números de 16 bits, la operación de división utiliza igualmente el registro dx de manera implícita. Por lo tanto, en dx tiene que estar la extensión de signo, si los números son enteros, o simplemente cero, si los números son naturales. De manera análoga, si estamos interesados en dividir dos números de 8 bits, en la parte alta del registro al (ah) tendrá que estar la correspondiente extensión de signo. La instrucción cbw representa una extensión de signo del valor de al sobre ah. Asimismo, la instrucción cwd forma una extensión de signo del valor de ax sobre dx. Prestad atención al hecho de que en el ejemplo hay que utilizar la instrucción cwd para realizar la extensión de signo correspondiente sobre el registro dx. Las operaciones aritméticas mostradas en el ejemplo son las operaciones básicas, suficientes para realizar cualquier cálculo. A continuación, pasamos a describir otras operaciones aritméticas que también pueden ser interesantes: Incremento y decremento Existen unas instrucciones más eficientes que las instrucciones básicas de suma y resta para sumar o restar +1 a un operando, y son éstas: inc operando dec operando 40 · Fundamentos de computadores Fijaos en que el operando debe ser un registro o una variable de memoria, ya que carece de sentido incrementar o decrementar una constante. Complemento a 2 Para cambiar un número entero de signo disponemos de la siguiente instrucción: neg operando Puesto que el i8086 representa los números enteros con complemento a 2, de hecho, esta instrucción hace el complemento a 2 del operando especificado. Como siempre, este operando tiene que ser un registro o una variable de memoria. Multiplicación por números potencia de 2 En binario, una multiplicación por un número potencia de 2 (por ejemplo, 2N) equivale a desplazar este número N posiciones hacia la izquierda; es decir, a poner N ceros por la derecha (pensad en decimal: una multiplicación por 1.000 equivale a poner tres ceros a la derecha del número en cuestión). Es evidente que desplazar un número a la izquierda es más fácil que hacer una multiplicación. Por lo tanto, en ensamblador se acostumbran a utilizar las instrucciones de desplazamiento para efectuar una multiplicación por un número potencia de 2. Estas instrucciones son: shl operando, 1 shl operando, cl sal operando, 1 sal operando, cl shl se utiliza para números naturales y sal, para números enteros. Cuando deseamos desplazar una sola posición hacia la izquierda (multiplicación por 2), podemos utilizar la constante 1 como segundo operando; de lo contrario, hay que poner en el registro cl el número de posiciones que queréis desplazar hacia la izquierda. División por números potencia de 2 Parecido al caso de la multiplicación, es el de la división en binario por un número potencia de 2, que equivale a desplazar este número las posiciones correspondientes hacia la derecha. Las instrucciones en ensamblador para realizar desplazamientos hacia la derecha son las siguientes: shr operando, 1 shr operando, cl sar operando, 1 sar operando, cl 10. INSTRUCCIONES LÓGICAS Las instrucciones aritméticas realizan cálculos sobre valores de 8, 16 ó 32 bits, interpretando todos los bits de manera unificada según una codificación. De forma complementaria, también hay otro tipo de operaciones que trabajan bit a bit, que son las instrucciones lógicas. Las instrucciones lógicas tienen mucha importancia en áreas específicas como, por ejemplo, la programación lógica. Sin embargo, para nosotros serán útiles de un modo especial cuando manipulemos los registros de los controladores de los periféricos. Las instrucciones lógicas del lenguaje de ensamblador permiten desarrollar una operación lógica bit a bit utilizando uno o Fundamentos de computadores · 41 dos operandos de origen y guardar el resultado obtenido sobre un operando de destino. Las operaciones lógicas básicas son and, or y not, aunque el lenguaje de ensamblador también nos ofrece la operación xor. Además, de manera similar a las instrucciones aritméticas, uno de los operandos de origen es siempre el operando de destino. En el ejemplo hallamos declaradas dos variables de memoria de 8 bits: el valor original (una con valor inicial igual a 75 especificado en decimal) y la copia (con un valor inicial cualquiera especificado, en este caso, en hexadecimal). Nuestra intención es observar bit a bit el valor original y, según este bit, poner un 1 o un 0 sobre la copia. En concreto, el valor original vale 75 y el valor inicial de la copia es f0h. Para aislar el valor de un solo bit utilizaremos lo que se conoce como una máscara de bits. Una máscara de bits es una lista de bits en la que sólo los bits que nos interesa tratar valen 1; los demás valen 0. En nuestro caso, dado que queremos tratar los bits uno a uno, inicializamos el registro al con una máscara de bits en la que todos los bits valen 0 excepto uno. En el ejemplo, hemos inicializado el registro al en la constante 1, pero hemos expresado esta constante en binario. Éste es un recurso común cuando se manipulan bits, ya que queda mucho más claro lo que pretendemos hacer si el valor 00000001 b está especificado. Utilizaremos esta máscara para aislar el bit que nos interese tratar. Teniendo en cuenta que querremos tratar todos los bits, al final del bucle hacemos un desplazamiento hacia la izquierda del registro al; es decir, desplazamos este 1 a la siguiente posición de la izquierda. Haremos este desplazamiento ocho veces para, de este modo, tratar todos los bits del valor original. Con la máscara que tenemos en el registro al podemos aislar un bit del valor original haciendo una operación and lógica del valor original con la máscara. Fijaos en que la operación and de cualquier valor con 0 es siempre 0. Por lo tanto, el resultado de la operación and de cualquier valor con la máscara dejará todos los bits en 0, excepto el bit que se encuentra en la misma posición que el 1 que hay en la máscara. Este bit se mantendrá en 1, si el valor original era 1, o bien se quedará en 0, si el valor original era 0. Prestad atención al hecho de que antes de desarrollar la operación and hemos copiado el valor original en el registro bl, ya que and modifica el operando de destino, y en este programa nos interesa mantener el valor original. Así pues, tras haber ejecutado la operación and, el registro bl valdrá 0, si el bit correspondiente era un 0, ó 1, en caso contrario. Continuamos con el ejemplo. Se ejecutará el código de la etiqueta bit1, si el bit analizado es un 1, o bien se ejecutará el código que hay en la etiqueta bit0, si el bit analizado es un 0. Cuando el bit es un 1, queremos forzar la escritura de un 1 en el valor copia, respetando el valor de los otros bits. Para forzar la escritura de un bit en 1 (el bit correspondiente a la máscara), se ejecuta una operación or de la máscara sobre el operando de destino. Fijaos en que la operación or con ceros mantiene los valores originales, y con un 1, fuerza que el bit correspondiente valga 1.De manera análoga, cuando el bit es un 0, queremos forzar la escritura de un 0 en el valor copia, respetando el valor que tienen los otros bits. Para forzar la escritura de un bit en 0 (el bit correspondiente a la máscara), se ejecuta una operación and del inverso de la máscara sobre el operando de destino. Así pues, para forzar la escritura de un 0 necesitamos el inverso de la máscara, es decir, nos hace falta otra máscara con los bits en 0, si los bits de la máscara original eran 1, y los bits en 1, en caso de que los bits de la máscara original fuesen 0. Y, esto se consigue mediante la instrucción not. Tened en cuenta que al hacer la operación and con 1 se mantienen los valores de los bits originales; y al hacerla con 0, se fuerza la escritura de un 0. En el ejemplo habéis podido ver el mecanismo para la consulta de un bit a partir de la operación lógica and, que modifica el operando de destino. El inconveniente de este mecanismo es que si sólo nos interesa consultar el valor (sin modificarlo), primero hay que hacer una copia para que la modificación se realice sobre la copia. 42 · Fundamentos de computadores Existe otra instrucción que permite realizar la misma consulta, pero sin modificar ningún operando: ésta es la instrucción test. Su funcionamiento es el mismo que el de la instrucción and, excepto en el hecho de que no modifica el operando de destino. Por lo tanto, el mismo ejemplo se habría podido hacer de forma más eficiente si se hubiera utilizado la instrucción test. Por otra parte, la instrucción xor hace la O exclusiva bit a bit entre los dos operandos especificados. Esta instrucción, además de utilizarse para realizar la operación lógica correspondiente, a menudo se utiliza para inicializar un registro en cero de la siguiente manera: xor reg, reg Apreciad que la O exclusiva de cualquier valor consigo mismo es siempre cero. Este mecanismo es más eficiente que mover directamente un cero al registro, ya que el acceso a un operando registro es más eficiente que el acceso a un operando constante. 11. LA PILA Nos podemos imaginar la pila como una estructura vertical: en la cual se puede insertar un dato encima, o bien de la cual podemos recuperar el dato que se había insertado encima. La pila es una zona auxiliar de memoria que sirve para guardar información de manera temporal. Por lo general se accede a la pila utilizando unas instrucciones específicas. El funcionamiento de la pila es parecido al de un conjunto de elementos colocados uno encima del otro en forma de pila (de aquí viene su nombre). En esta pila, se puede insertar un elemento encima, o se puede sacar su elemento de encima, tal y como podemos ver a continuación: Es importante que al representar una pila marquemos siempre la cima, puesto que de esta manera siempre sabremos dónde es "encima". Para insertar un dato en la cima de la pila, utilizaremos una instrucción llamada push. Asimismo, para extraer el dato que se encuentra en la cima de la pila, utilizaremos una instrucción llamada pop. De momento, vamos a ver que la finalidad de una pila es guardar de forma temporal el contenido de un registro que necesitamos para desarrollar otra tarea y así poder recuperar, más adelante, el valor de este registro, o bien servirá para guardar temporalmente cualquiera otro dato en un orden determinado. Una pila debe estar declarada en el segmento correspondiente para que se pueda llevar a cabo su utilización. De forma parecida a los segmentos de datos y código, utilizamos la directiva .stack para hacer la declaración. La declaración de la pila se realiza mediante la directiva .stack seguida del tamaño, en bytes, deseado para la pila. Es difícil saber con exactitud qué tamaño debe tener la pila, así que se acostumbra a declarar un tamaño lo bastante grande como para que no tengamos problemas. Por lo general, en nuestros programas, suele ser suficiente con un tamaño de pila de 1.024 bytes. Prestad atención al código. Está formado por dos bucles, el primero sirve para recorrer los elementos del vector, en el que se utiliza el registro como registro índice. Para inicializar el registro si en cero hemos utilizado la operación lógica xor tal como hemos explicado. En general, siempre es mejor utilizar operandos registros que operandos constantes. Como siempre, recordad que si tenemos que recurrir a un vector de elementos de tamaño word, el índice debe verse incrementado en dos unidades. En el primer bucle del ejemplo hallamos colocados en la pila los elementos del vector mediante la instrucción push. Recordad que esta instrucción inserta el operando especificado en la cima de la pila. La instrucción push inserta un dato de 16 bits en la cima de la pila. El Fundamentos de computadores · 43 operando de esta instrucción tiene que ser, obligatoriamente, de tipo registro o variable de memoria. Dado que se han insertado los elementos del vector de forma ordenada, desde el primer elemento hasta el último. En el segundo bucle se extraen de la pila los elementos del vector mediante la instrucción pop. Recordad que esta instrucción extrae el elemento de la cima de la pila y lo guarda sobre el operando especificado. La instrucción pop extrae un dato de 16 bits de la cima de la pila. Así como en la instrucción push, el operando de esta instrucción tiene que ser obligatoriamente de tipo registro o variable de memoria. Dado que los elementos se han extraído igualmente de manera ordenada desde el primer elemento hasta el último, al acabar el segundo bucle tendremos la pila vacía. No obstante, además, el vector se habrá invertido, ya que el primer elemento que se extrae fue el último elemento insertado; el segundo elemento que se extrae fue el penúltimo elemento insertado, y así sucesivamente para todos los elementos. De hecho, la pila es una zona de memoria que se gestiona de forma implícita con dos registros: ss y sp. Podemos considerar que el registro ss apunta estáticamente al inicio de la zona de memoria que representa la pila, algo que en este curso vamos a pasar por alto. El registro sp apunta constantemente a la cima de la pila; por norma general, tampoco lo tocaremos directamente, aunque las instrucciones push y pop sí lo modifican de forma implícita. Por último, existe un tercer registro, el bp, relacionado con la pila, un registro que utilizaremos más adelante para tareas de soporte a la pila. 12. SUBRUTINAS: LLAMADAS Y RETORNO Por lo general, en un programa existen tareas comunes (por ejemplo, escribir un mensaje por pantalla) que tienen que ejecutarse desde diferentes partes del programa, para lo cual contamos, básicamente, con dos posibilidades: Replicar el código correspondiente (escribir el mensaje por pantalla) a cada parte del programa en el que queremos ejecutar la tarea. Definir una subrutina que escribe el mensaje y llamarla desde cada parte del programa en que nos interesa ejecutar la tarea. El objetivo de este hito es conocer los mecanismos para declarar una subrutina en ensamblador, así como las instrucciones de llamada y retorno a la subrutina. En primer lugar definiremos qué es una subrutina. Una subrutina es un bloque de código que desempeña una tarea determinada. Este bloque de código puede ser llamado desde diferentes partes del programa y, al finalizar, vuelve al lugar desde donde ha sido llamado. Las subrutinas, por diferentes motivos, son preferibles a la replicación de código. Por una parte, replicar código implica hacer mayor el programa (diferentes copias del mismo código); por otra, si se pretende modificar el comportamiento de este bloque de código (porque existe un error o porque queremos mejorarlo) y el código está replicado, entonces hay que modificar cada una de las copias del mencionado código. De hecho, sin embargo, la principal ventaja de las subrutinas es que permiten un diseño modular del programa, hecho que facilita su diseño y comprensión. Veamos en el siguiente ejemplo un programa que llama tres veces a una subrutina. Esta subrutina escribe un mensaje por pantalla, así que, este programa escribe el mensaje tres veces por pantalla. Para poder llamar una subrutina, en primer lugar es necesario tener una pila declarada. El motivo de esto es que las subrutinas utilizan la pila de manera 44 · Fundamentos de computadores implícita, tal y como veremos más adelante. Como siempre, el tamaño de la pila tiene que ser lo suficientemente grande. Para llamar una subrutina se utiliza la instrucción call. Un llamamiento a una subrutina es parecido a una instrucción de salto con la diferencia de que, después de ejecutar el código correspondiente, se vuelve al punto en el que se ha llamado la subrutina. Fijaos en que el código del ejemplo llama tres veces consecutivas a una subrutina con el nombre escribir. Para escribir una subrutina, ésta tiene que encontrarse fuera de los límites marcados por las macros STARTUPCODE y EXITCODE, tal y como vemos en el ejemplo. Apreciad el hecho de que la subrutina se identifica con una etiqueta que marca el punto de entrada a la subrutina. Cualquier subrutina acaba con la instrucción ret, que provoca un salto de retorno a la instrucción posterior a la que ha llamado la subrutina. La instrucción call se comporta, en apariencia, como una instrucción de salto hacia una etiqueta. La instrucción ret, sin embargo, se comporta como una instrucción de salto hacia la instrucción posterior a la que ha ejecutado el call, algo que conseguimos gracias a que la instrucción call, a diferencia de una instrucción de salto, guarda la dirección a la que tiene que volver: la dirección de retorno. Una tarea importantísima que conviene realizar al programar una subrutina es salvar los registros que utiliza la subrutina. Tal y como podéis ver en el ejemplo, sólo se salvan los registros que se modifican dentro de la subrutina; los registros se salvan en la pila. Así pues, para salvar los registros utilizaremos la instrucción push y para restaurarlos, la instrucción pop. La subrutina del ejemplo escribe un mensaje determinado por la pantalla. Fijaos en cómo se salvan los registros ax y dx, que son los que se utilizan para ejecutar el código correspondiente. Por lo tanto, el siguiente programa escribe tres veces el mensaje por la pantalla. 13. SUBRUTINAS: NO PARÁMETROS Y RETORNO DE RESULTADO En algunas ocasiones, puede sernos de interés el hecho de que el código de la subrutina sea ejecutado sobre unos determinados valores de entrada; por ejemplo, una subrutina que escriba por pantalla un número determinado. En este caso, hay que definir un mecanismo para poder pasar a la subrutina uno o varios parámetros. Asimismo, puede interesarnos que la subrutina devuelva un resultado tras su ejecución; por ejemplo, una subrutina que sume dos números determinados y que devuelva el resultado. En este caso, sería conveniente definir un mecanismo para el retorno del resultado. Una de las ventajas del diseño modular es que permite descomponer un programa en tareas independientes (las subrutinas), de manera que diferentes programadores se puedan repartir la implementación de dichas tareas. En este caso, es corriente que un programador escriba un código que llame a subrutinas escritas por otro programador. A una subrutina se le puede pasar cualquier número de parámetros. El mecanismo de paso de parámetros más utilizado es el paso de parámetros por la pila, ya que permite pasar un número de parámetros en principio ilimitado. Pues bien, éste va a ser el mecanismo que utilizaremos en nuestros programas. Por otra parte, también existen muchas formas diferentes de devolver un resultado. A diferencia del paso de parámetros, por norma general las subrutinas devuelven un solo resultado. El mecanismo para el retorno del resultado que utilizaremos nosotros es el retorno de resultado por medio de un registro. En este ejemplo tenemos un programa que llama a una subrutina que devuelve como resultado la suma de dos números que se han pasado como parámetros. Fundamentos de computadores · 45 El paso de parámetros a una subrutina se hace mediante la pila, lo cual significa que antes de llamar a una subrutina, hay que dejar los parámetros correspondientes en la pila. De esta manera, al ejecutarse la subrutina, los valores determinados de entrada sobre los que se ejecuta el código de la subrutina se encuentran en la pila. En el ejemplo han aparecido destacadas en negrita las instrucciones de paso de parámetros y llamamiento de la subrutina. En este caso, se pasan como parámetro los valores de las variables de memoria núm1 y núm2. Recordad que, al llamar a una subrutina, la dirección de retorno queda guardada en la pila. Así pues, el estado de la pila tras haber saltado a la subrutina es el que podemos ver en la figura. Fijaos en que los parámetros se deben pasar antes de llamar a la subrutina, puesto que, de lo contrario, se llamaría a la subrutina sin haber puesto ningún valor en la pila y, al ejecutarse la subrutina, no encontraría los parámetros de entrada. Por último, es importante tener en cuenta el orden en el que se ponen los parámetros en la pila, ya que la subrutina tiene que saber en qué posición se hallan los parámetros. En este ejemplo, sin embargo, no es importante el orden, dado que la suma es conmutativa. Una vez se han pasado los parámetros y se ha llamado a la subrutina, ésta tiene que poder acceder al valor de los parámetros para, de este modo, ejecutar el código correspondiente; algo que se consigue mediante el registro bp. El registro bp es un registro base que se utiliza como registro auxiliar para la manipulación de la pila. Y, puesto que es un registro base, se puede utilizar para acceder a una variable de memoria; en nuestro caso, servirá para acceder a los parámetros de la pila. Así pues, en una subrutina, en primer lugar se hace el enlace dinámico, que consiste en salvar el registro bp (que es un registro que se utiliza en la subrutina), y después se apunta el bp en una posición fija de la pila (por ejemplo, la posición de la cima). Y esto podéis verlo con las dos primeras instrucciones de la subrutina. Para deshacer el enlace dinámico, sólo hay que restaurar el registro bp que se ha salvado al inicio de la subrutina. Cuando ya tenemos el registro bp apuntando a una posición fija de la subrutina, entonces podemos salvar los registros (tantos como sean necesarios, ya que este hecho no va a afectar a la posición a la que el bp apunta). El estado de la pila es el que aparece reflejado en la figura. Tened en cuenta, por lo tanto, que los parámetros se ubican a partir de la posición [bp + 4] en adelante. Conociendo la posición inicial a partir de la que se encuentran los parámetros, el acceso se realizará mediante el uso del registro bp, tal y como muestran las instrucciones del ejemplo resaltadas en negrita: [bp + 4], corresponde al segundo parámetro (el valor 4), y [bp + 6], al primer parámetro (el valor 3).Así pues, la subrutina accede a los dos operandos, los guarda en bx y cx, los suma y un registro devuelve el resultado obtenido. En general, es indiferente qué registro se utiliza para devolver el resultado; lo más importante es que tanto al devolver el resultado (dentro de la subrutina), como al tratarlo (fuera de la subrutina) se utilice el mismo registro. El registro ax es el que se utiliza convencionalmente, de manera que nosotros también lo utilizaremos. Fijaos en que si hay que devolver el resultado por el registro ax, no es necesario salvar este registro dentro de la subrutina (y, por lo tanto, tampoco hay que restaurarlo ). Este hecho implicará que, cuando el programador llame a una subrutina que devuelve un resultado, en el registro ax ya no habrá ningún valor importante. Por último, al volver de la subrutina (una vez se ha ejecutado la instrucción ret que extrae la dirección de retorno de la pila) sería conveniente tratar el resultado y sacar los parámetros de la pila: Para tratar el resultado basta con saber que éste se halla en el registro ax, así que si nos interesa asignar el resultado a la variable de memoria nada, sólo tenemos que ejecutar la instrucción mov que muestra el ejemplo. En cuanto a extraer los parámetros de la pila, es responsabilidad de quien 46 · Fundamentos de computadores pone los parámetros sacarlos más adelante, algo que se puede hacer rápidamente si se suma la medida adecuada al registro sp. La mayor parte de los lenguajes de programación en alto nivel permiten tener variables locales dentro de una subrutina, es decir, variables que se crean al activarse la subrutina y que se destruyen cuando ésta acaba. Las variables locales son variables de memoria que ocupan de forma temporal un espacio en la pila, tal y como muestra la siguiente figura: Por lo tanto, la declaración de la subrutina será un código parecido al siguiente: push bp mov bp, sp120 sub sp, N push ... push ... donde N es una constante igual al tamaño que necesitan las variables locales. Así pues, el acceso a las variables locales se realizará mediante el uso de [bp - x], mientras que el acceso a los parámetros se realizará utilizando [bp + x], donde x es, en ambos casos, el desplazamiento necesario para acceder a la variable deseada. Al acabar la subrutina, hay que liberar el espacio reservado para las variables locales, algo que podemos hacer rápidamente de la siguiente manera: pop ... pop ... mov sp, bp pop bp ret 14. SUBRUTINAS: PARAMETROS POR VALOR O POR REFERENCIA Otro recurso común utilizado por los lenguajes de programación en alto nivel al programar una subrutina es el paso de parámetros por referencia. El paso de parámetros por referencia consiste en pasar como parámetro una referencia (la dirección de memoria) a una variable que contiene el valor en lugar de pasar una copia de un valor. La diferencia entre pasar un parámetro por valor o por referencia consiste en que, en el segundo caso, el valor original de la variable se puede modificar. Los parámetros se pueden pasar por valor o por referencia. Se pasa un parámetro por valor cuando se pone en la pila una copia del valor de este parámetro. En tal caso, si el parámetro se modifica, también se modifica la copia, de manera que el valor original no se ve alterado. Se pasa un parámetro por referencia cuando la dirección de memoria de la variable que contiene el valor está colocada en la pila. En tal caso, si se modifica el contenido de la dirección de memoria que se ha pasado como parámetro, se modifica, también, la variable original. En general, se pasa un parámetro por valor cuando no interesa que el valor original cambie por la ejecución de la subrutina; y se pasa un parámetro por referencia, cuando nos interesa que el valor original quede modificado por la ejecución de la subrutina. Este ejemplo, igual que el ejemplo 12, realiza la suma de dos números pasados como parámetros. Sin embargo, en este caso, en lugar Fundamentos de computadores · 47 de devolver el resultado por el registro ax, se devuelve mediante un tercer parámetro que se pasa por referencia. Como ya habéis visto, el paso de parámetros por valor se realiza sólo poniendo en la pila una copia del valor correspondiente mediante la instrucción push, tal y como muestran las dos primeras instrucciones marcadas en negrita. No obstante, para pasar un parámetro por referencia es necesario poner en la pila la dirección de la variable de memoria correspondiente. Para poner en la pila la dirección de una variable de memoria es necesario acceder primero a la dirección con la instrucción lea. Entonces ya se puede poner la dirección de la variable de memoria utilizando de la misma forma la instrucción push. Tras haber pasado los tres parámetros, el estado de la pila será el que muestra la figura. Fijaos en que tendremos debajo la copia de los dos números núm1 y núm2, a continuación, la dirección de la variable de memoria nada y, por último, hallaremos la dirección de retorno después de ejecutar el call. Para acceder a los parámetros pasados por valor se utiliza igualmente el registro bp. Es necesario que tengamos cuidado a la hora de calcular el desplazamiento de los parámetros, ya que, a diferencia del ejemplo 12, existe un tercer parámetro (la dirección de la variable nada) entre núm2 y la dirección de retorno). Una vez calculada la suma, la subrutina del ejemplo guarda el resultado en el tercer parámetro pasado por referencia. Es decir, en la pila se encuentra la dirección de memoria en la que queremos escribir. Para acceder a la misma, utilizamos el registro base bx, que permite escribir en una variable de memoria inicialmente desconocida (la que haya en la pila). Así pues, ponemos la dirección correspondiente en el registro bx y escribimos en la posición de memoria a la que apunta bx, es decir, a la variable nada. Aquí la subrutina no devuelve ningún resultado por el registro ax. En consecuencia, si se utiliza este registro dentro de la subrutina, primero será necesario salvarlo y restaurarlo al final de la subrutina. El hecho de que la subrutina devuelva o no resultado no depende de si existe algún parámetro pasado por referencia, puesto que en este caso ha sido casualidad. Fijaos, por ejemplo, en el código del ejemplo 11 en el que no hay parámetro alguno (ni por valor ni por referencia), y tampoco devuelve ningún resultado. Y ya para acabar, recordad que, al volver de la subrutina, tenemos que sacar los parámetros que han sido colocados de antemano en la pila. Esta operación podemos hacerla fácilmente sumando a sp el tamaño que ocupan los parámetros (en este caso, 6 bytes). Fijaos en que no es necesario recuperar el resultado, ya que al volver de la subrutina el resultado ya está en la variable de memoria nada. Un parámetro pasado por referencia sirve, por lo general, para pasar la dirección de una variable de memoria que nos interesa poder modificar dentro de la subrutina; aunque, también sirve para pasar un vector como parámetro. Así pues, cuando estemos interesados en pasar un vector como parámetro, éste será pasado por referencia; esto es, se pondrá en la pila la dirección del primer elemento del vector. Para acceder a los elementos del vector, se puede hacer de dos formas: con el registro bx, apuntando al inicio del vector, y con el registro si, especificando el desplazamiento correspondiente. Por último, recordad que tanto el operando de origen como el operando de destino siempre deben tener el mismo tamaño. Cuando se utilizan registros o variables de memoria declaradas en el segmento de datos, se puede saber el tamaño del operando según el nombre del registro o la declaración de la variable en el segmento de datos. Cuando se accede a la dirección de una variable que no conocemos a priori, sin embargo, puede suceder que no esté claro el tamaño de los operandos. Por ejemplo, si se especifica la siguiente instrucción: mov [bx + si], al 48 · Fundamentos de computadores está claro que el tamaño de los operandos es 8 bits, dado que el registro al es un registro de 8 bits. Sin embargo, si se especifica la siguiente instrucción: mov [bx + si], 0 entonces, no está claro si la dirección [bx + si] hace referencia a una variable de 8 bits o a una de 16 bits. En este caso, al ensamblar el código, el mismo ensamblador detecta la posible confusión y da el mensaje que encontramos a continuación: *Warning* fitxer.asm(...) Argument needs type override Es decir, es preciso que esté especificado el tipo del argumento (el operando). En este caso, se pueden utilizar los siguientes modificadores: mov byte ptr [bx + si], 0 mov word ptr [bx + si], 0 para informar de que la variable de memoria es una variable de 8 bits o 16 bits. 15. INSTRUCCIONES DE SOPORTE AL SISTEMA DE E/S Los dispositivos periféricos son manipulados mediante lecturas y/o escrituras en los registros del correspondiente controlador. Estos registros pueden estar guardados en memoria, como es el caso de la pantalla, o bien en el espacio de direcciones de entrada/salida. Para acceder a registros guardados en memoria, se utilizan las instrucciones normales de acceso a memoria que hemos visto hasta ahora; pero, para acceder a registros guardados en el espacio de direcciones de entrada/salida, el lenguaje de ensamblador del i8086 proporciona unas instrucciones específicas. Veamos ahora un programa en el que se capturan 10 movimientos de las teclas: make o break. En cada caso, se escribe por pantalla un mensaje que indica el suceso acaecido. Después de escribir el mensaje en el que se pide que se pulsen las teclas, hallamos un bucle principal con un contador de 10 iteraciones. Para la construcción de este programa, dado que no existe ninguna subrutina, no se declara la pila. Al pulsar o soltar una tecla, se pone en 1 el bit cero del registro de estado del controlador de teclado y la encuesta finaliza. En este momento, la siguiente acción que tenemos que realizar será comprobar el motivo de esta acción: se ha pulsado la tecla (make) o se ha dejado la tecla (break). El registro que contiene información acerca de este hecho es el registro de datos del controlador de teclado. El bit 7 de este registro indica que la tecla ha sido pulsada (bit7 = 0) o bien que la tecla ha sido soltada (bit7 = 1). Los restantes 7 bits (del 0 al 6) codifican la tecla que se ha pulsado (o soltado). Para comprobar si es un make o un break, leemos el registro de datos (dirección 60h) mediante la instrucción in y utilizamos la máscara 80h que permite aislar el valor del bit 7. Si este bit vale 0, es un make; de lo contrario, es un break. Antes de continuar con el análisis del código, es importante que conozcamos el funcionamiento de los registros del controlador del teclado. En concreto, nos interesa gestionar dos registros: el registro de estado y el registro de datos, ambos de 8 bits, que se encuentran en las direcciones 64h y 60h, respectivamente, del espacio de direccionamiento de entrada/salida. Fundamentos de computadores · 49 Los bits que nos interesan de estos registros son los siguientes: Cuando el bit 0 del registro de estado vale 1, esto indica que se ha tocado alguna tecla. Al leer del registro de datos, este bit se pone de forma automática en cero. Cuando se ha tocado una tecla, el bit 7 del registro de datos vale 0, si ésta ha sido pulsada (make), o 1, si ha sido liberada (break). Una vez tenemos claro el funcionamiento de los registros del controlador del teclado, podemos implementar el acceso al teclado mediante encuesta. El acceso mediante encuesta consiste en leer de forma constante el registro de estado del controlador del dispositivo hasta que detectamos que ha ocurrido un acontecimiento determinado. Por último, podéis observar en el ejemplo que, dependiendo de si se pulsa una tecla o se suelta, el registro dx será inicializado con la dirección del mensaje que nos interesa escribir: <make> o <break>. A continuación, y antes de acabar el bucle, se escribe el mensaje correspondiente. Este ejemplo detecta los 10 primeros acontecimientos. Así pues, si queréis que detecte cinco veces que se pulsa y se suelta una tecla, es necesario pulsarla rápidamente para que, de este modo, no se generen repeticiones de la tecla. Las instrucciones in y out El acceso en ensamblador a los registros guardados en el espacio de entrada/salida se realiza mediante las instrucciones in y out. Estas instrucciones se pueden utilizar de la siguiente manera: in al, [xxh], para leer un valor de 8 bits. in ax, [xxh], para leer un valor de 16 bits. Podemos utilizar esta modalidad para direcciones entre la 000h y la 0 ffh del espacio de entrada/salida. Si la dirección es superior, entonces hay que utilizar el registro dx para especificarla: in al, dx, para leer un valor de 8 bits. in ax, dx, para leer un valor de 16 bits. De manera similar, para escribir un registro del espacio de direccionamiento de entrada/salida: out out out out [xxh], al, para escribir un valor de 8 bits. [xxh], ax, para escribir un valor de 16 bits. dx, al, para escribir un valor de 8 bits en direcciones > 255. dx, ax, para escribir un valor de 16 bits a direcciones > 255. El registro de datos del controlador de teclado Tal y como hemos visto, el registro de datos del controlador de teclado codifica la tecla pulsada en los siete bits de menor peso. La codificación de este registro no se corresponde con el código ASCII de la tecla, ya que el teclado tiene que codificar teclas especiales que no aparecen en el código ASCII (por ejemplo, las teclas F1, F2, ..., Shift, Intro, Ctrl, Alt, etc.). El controlador de teclado define el código de rastreo, y este código codifica la posición que ocupa la tecla en el teclado. Por consiguiente, para las siguientes teclas tenemos los códigos: código código código ... código código código código código ... 1: tecla <ESC> 2: tecla <1> 3: tecla <2> 16: 17: 18: 19: 20: tecla tecla tecla tecla tecla <q> <w> <e> <r> <t> 50 · Fundamentos de computadores Para pasar del código de rastreo al código ASCII, se acostumbra a definir un vector que contenga en cada posición correspondiente al código de rastreo el código ASCII de la tecla asociada. Es decir, el vector: traduccion db - 1,-1,'1234567890',-1,-1,-1,-1,'qwertyuiop'... De manera que, dado un código de rastreo C, el valor traducción[C] corresponde al código ASCII de la tecla asociada al código de rastreo C, o bien -1, si la tecla en cuestión no tiene código ASCII asociado. En el caso del controlador del teclado, el acontecimiento que nos interesa detectar es que se pulsa una tecla. En el ejemplo podéis ver cómo se lee del registro de la dirección 64h del espacio de direccionamiento de entrada/salida mediante la instrucción in. A continuación, se compara el bit cero del valor leído con la máscara de bits 01h para, de este modo, aislar el bit cero. Mientras este bit sea 0, el código salta de nuevo a la instrucción que lee el registro de estado. Fijaos, también, en que la etiqueta encuesta es utilizada tanto por el bucle que cuenta 10 iteraciones, como por el bucle que implementa la encuesta. Este hecho no va a suponer ningún problema desde el punto de vista del lenguaje de ensamblador. 16. RUTINAS DE ATENCIÓN A LOS PERIFÉRICOS Los dispositivos periféricos leen o escriben información en el mundo exterior de manera asíncrona con respecto a la ejecución de las instrucciones por parte del procesador. Es decir, un usuario pulsa las teclas sin tener que sincronizarse para ello con el reloj del procesador. Con el fin de acceder a esta información, el procesador se tiene que sincronizar con el controlador de los dispositivos periféricos. En este tema 16 se repasarán las técnicas de sincronización con los controladores de los dispositivos periféricos. El objetivo es que aprendáis a implementar en ensamblador las rutinas de atención a la interrupción de los dispositivos periféricos. Existen dos técnicas básicas que permiten la sincronización entre el procesador y los controladores de los dispositivos de entrada/salida, la sincronización por encuesta y la sincronización por interrupción. La sincronización por encuesta es una técnica que consulta de forma activa el registro de estado del controlador (por lo general registrado en el espacio de direccionamiento de entrada/salida) hasta que detecta que se ha producido un acontecimiento (por ejemplo, se pulsa una tecla). La sincronización por interrupción es un mecanismo del procesador que permite que éste pueda ejecutar cualquier código y que, al producirse un acontecimiento (por ejemplo, se pulse una tecla), se ejecute una rutina de atención a la interrupción que alcance este acontecimiento. Para asociar una rutina a la interrupción de un dispositivo periférico disponemos del vector de interrupciones. Este vector está situado al principio de la memoria (a partir de la dirección 0) que contiene la dirección de una rutina asociada, a su vez, a cada dispositivo. Cuando se produce una interrupción, se ejecuta la rutina asociada al correspondiente dispositivo. Por lo tanto, si queremos que se ejecute una rutina determinada al producirse la interrupción de un dispositivo, sólo es necesario que pongamos la dirección de la rutina en cuestión en la posición correspondiente del vector de interrupciones. Una rutina de atención a la interrupción es una rutina cualquiera, como las que hemos visto hasta ahora, pero con dos Fundamentos de computadores · 51 particularidades: Al acabar, el controlador de interrupciones debe informar de que ya ha finalizado, algo que hay que hacer escribiendo el código EOI (End Of Interruption, 'final de interrupción') en el registro de control del controlador de interrupciones. En vez de utilizar la instrucción habitual de retorno, hay que utilizar IRET (retorno de una interrupción). Fijaos en que una rutina de atención a la interrupción se puede ejecutar en cualquier momento, de forma independiente de la ejecución de nuestro programa. Por lo tanto, es difícil que podamos pasar parámetros desde nuestro programa, ya que no sabemos en qué momento se llevará a cabo la ejecución de la rutina. Antes de programar la rutina de atención a la interrupción de un dispositivo periférico, tenemos que saber qué posición del vector de interrupciones ocupa este dispositivo. El teclado ocupa la posición 9. El vector de interrupciones está situado en memoria a partir de la dirección física cero, y cada dirección de una rutina de atención a la interrupción ocupa 4 bytes. Por lo tanto, la rutina de atención a la interrupción del teclado estará situada a partir de la dirección 36. Para que se ejecute una determinada rutina cuando haya interrupción del teclado, sólo es necesario que se ponga la dirección de esta rutina a partir de la dirección 36 de la memoria; aunque, de forma previa, será necesario guardar la dirección de la rutina que haya en aquella posición y restaurarla antes de acabar nuestro programa. Para acceder a las direcciones de nuestro programa, ponemos la dirección entre corchetes, tal y como hemos visto hasta ahora; y, para acceder a direcciones fuera del espacio de direccionamiento de nuestro programa, sin embargo, utilizaremos de forma adicional unos registros de propósito específico llamados registros de segmento. Existen cuatro registros de segmento, llamados cs, ds, es y ss, de los cuales utilizaremos sólo el registro es para acceder a direcciones fuera de los límites de nuestro programa. Los otros registros tienen un uso muy determinado, y no los tenemos que manipular explícitamente. En el ejemplo podéis ver que se utiliza el registro es para generar la dirección cero y salvar la dirección de la rutina que hay a partir de la dirección 36 de memoria: xor ax, ax mov es, ax ; ponemos cero en el registro es push es:[36] ; salvamos los dos primeros bytes push es:[38] ; salvamos los dos bytes restantes Asimismo, se utiliza este registro para poner la dirección de nuestra rutina a partir de la dirección 36 de memoria: lea ax, [RAIteclado] ; tomamos la dirección de la rutina mov es:[36], ax ; ponemos la dirección mov es:[38], cs ; ponemos el segmento Fijaos en que primero ponemos la dirección de la rutina en el registro ax y después la ponemos en la posición 36 de memoria. A continuación completamos la dirección (de 4 bytes) para que la rutina pueda ser ejecutada desde fuera de nuestro programa. Para ello, utilizaremos el registro cs. En este curso no entraremos en detalles sobre el uso de los registros de segmento. Pero lo que sí debemos tener claro es que hay que modificar el vector de interrupciones y la manera de llevar a cabo dicha modificación. Por último, fijaos en las instrucciones cli y sti, que sirven para inhibir interrupciones y desinhibir interrupciones, respectivamente, y es conveniente ponerlas antes y después de modificar el vector de interrupciones, puesto que, de lo contrario, si por casualidad se recibe una interrupción de teclado cuando sólo se 52 · Fundamentos de computadores ha modificado la mitad de la dirección (los 2 primers bytes), se saltaría a una posición aleatoria de memoria y seguramente el programa se quedaría colgado Una vez modificado el vector de interrupciones (después de haber salvado el valor original), ya podemos escribir el programa. En este ejemplo, tras escribir un mensaje, tenemos una espera activa: mientras la variable numTeclas sea menor que 10, nos esperamos. Atended al hecho de que si el programa contuviera sólo este código, se quedaría permanentemente bloqueado. Sin embargo, en realidad existe otro fragmento de código que se ejecuta de forma concurrente con éste: la rutina de atención a la interrupción del teclado Raiteclado. Cada vez que se pulse una tecla, la ejecución del bucle de espera activa se detendrá y se ejecutará la rutina de atención a la interrupción. En esta rutina, podemos hacer lo que queramos: por ejemplo, incrementar el número de acontecimientos de teclado númTeclas. La rutina de atención a la interrupción es una subrutina normal y corriente que puede desarrollar cualquier tarea (por lo general relacionada con la gestión del dispositivo que ocasiona la interrupción). En nuestro ejemplo, la subrutina salva y restaura los registros, escribe un mensaje según la tecla pulsada, e incrementa el número de acontecimientos detectados relacionados con el teclado. Ahora bien, tal y como podemos ver en el código, existen dos particularidades en las rutinas de atención a la interrupción: Por una parte, hay que poner el valor 20 h en el registro del espacio de entrada/salida 20 h para generar la señal de final de interrupción (End Of Interruption): mov al, 20 h out [20 h], al Por otra, al volver de la subrutina se debe utilizar la instrucción iret (interruption return), en lugar de la instrucción ret. Por último, tenemos que comentar que antes de acabar el programa, si queremos que el teclado funcione tal y como funcionaba antes de ejecutar el programa, se tienen que restaurar los valores originales de la rutina de atención a la interrupción en el teclado (que habíamos salvado con anterioridad) en el vector de interrupciones. Si el registro es no se ha modificado y todavía mantiene el valor 0, restauramos directamente los valores salvados en la pila y los ponemos en el vector de interrupciones en las posiciones correspondientes: pop es:[38] pop es:[36] Igual que al modificar el vector de interrupciones al inicio del programa, es necesario restaurarlo inhibiendo con anterioridad las interrupciones y desinhibiéndolas más adelante, mediante el uso de las instrucciones cli y sti. En este curso gestionaremos sólo dos dispositivos: el teclado y el reloj. La gestión del teclado ya la hemos visto en los dos últimos hitos, por lo tanto, nos falta conocer el dispositivo reloj. El reloj es un dispositivo muy sencillo que permite medir el tiempo. Genera de manera fija unas 18 interrupciones por segundo y, por lo tanto, para medir el tiempo tiene que ir contabilizándose el número de interrupciones que lleva generadas. Cada 18 interrupciones contabilizadas implica que ha pasado un segundo. El reloj trabaja siempre por interrupción, y la rutina de atención a la interrupción del reloj está en la posición 8 del vector de interrupciones. No dispone de ningún registro de datos ni de control, así que su gestión se limita a poner en el vector de interrupciones la rutina de atención en la interrupción adecuada. Registros en el i8086 Los registros del i8086 son todos de 16 bits, aunque en algunos se puede acceder por separado a la parte baja (8 bits) y a la alta (8 bits). Existen cuatro Fundamentos de computadores · 53 registros de uso general, que son los que tenemos ocasión de ver en la siguiente figura: Como podéis comprobar, los registros ax, bx, cx y dx son de 16 bits, pero se puede acceder sólo a los 8 bits de más o menos peso (por ejemplo, el registro ah contiene los 8 bits de más peso del registro ax, y el registro al, los 8 bits de menos peso del registro ax). A pesar de que estos registros son de propósito general, veremos que el registro ax y el dx se suelen utilizar para algunas tareas predeterminadas, que bx es utilizado para guardar en él direcciones de memoria, y que cx se utiliza como contador. Asimismo, son registros muy utilizados el registro si y el di. Estos registros, siempre de 16 bits, se pueden utilizar como registros de propósito general, aunque, por norma general, serán usados como registros índice. Otros registros que vamos a utilizar son los registros con un uso más dependiente; esto es, el registro bp y el registro sp. Los mencionados registros sirven para gestionar un bloque de memoria llamado pila, aunque no los utilizaremos ahora, sino más adelante. Por último, hay otros cuatro registros de segmento (cs, ds, es, ss) que sirven para gestionar las direcciones de memoria. En esta asignatura, no veremos el uso de estos registros, excepto el registro es, que utilizaremos al finalizar el módulo para una tarea muy concreta. Texto elaborado a partir de: Fundamentos de computadores I Montse Peiron Guàrdia, Antonio José Velasco González, Jordi Carrabina Bordoll, Fermín Sánchez Carracedo, Anna M. del Corral González, Enric Pastor Llorens, Pedro de Miguel Anasagasti Febrero 2002