Usted está aquí: Inicio Ver Cursos Archivados Arquitectura de Ordenadores lecturas html sub.html

sub.html

Acciones de Documento
  • Marcadores (bookmarks)
  • Exportación de LTI
Autor: Abelardo Pardo

Construcciones de alto nivel

Las aplicaciones que se ejecutan en un ordenador están generalmente programadas en alguno de los denominados lenguajes de alto nivel que los compiladores traducen a ejecutables que contienen secuencias de instrucciones máquina del procesador. Para facilitar el desarrollo de estas aplicaciones se necesitan mecanismos adicionales tanto a nivel de procesador como de sistema operativo. Por ejemplo, la posibilidad de fragmentar el código en múltiples ficheros, gestión del acceso de símbolos en otros ficheros, etc.

En este capítulo se estudian los mecanismos que facilitan la traducción de programas en lenguajes de alto nivel a programas en ensamblador. Además de la fragmentación de código en múltiples ficheros, se estudia en detalle el procedimiento para la invocación, paso de parámetros y ejecución de subrutinas, y la traducción de estructuras de control presentes en lenguajes de programación a lenguaje ensamblador.

8.1. Desarrollo de aplicaciones en múltiples ficheros

La funcionalidad que ofrece un procesador está basada en sus instrucciones máquina, órdenes muy simples para manipular datos. Pero para programar operaciones más complejas se precisa un lenguaje que sea más intuitivo y que abstraiga o esconda los detalles de la arquitectura del procesador. A este tipo de lenguajes se les denomina “lenguajes de alto nivel” por contraposición al lenguaje ensamblador cuya estructura y construcciones están directamente relacionadas con la arquitectura del procesador que lo ejecuta. La traducción de las operaciones en un lenguaje de alto nivel a secuencias de instrucciones máquina se lleva a cabo por el compilador.

Las principales limitaciones que se derivan del uso del lenguaje ensamblador son:

  • Las aplicaciones que contengan manejo de datos u operaciones complejas requieren secuencias de instrucciones extremadamente largas, y por tanto, es muy fácil que se introduzcan errores.

  • El lenguaje ensamblador carece de tipos de datos. A pesar de que existen directivas para definir datos, su efecto no es más que almacenar una secuencia de bytes en memoria. El procesador accede a estos datos como una secuencia de bytes sin información de tamaño ni de su tipo.

  • Las subrutinas ofrecen un mecanismo básico para ejecutar porciones de código de forma repetida y con diferentes datos, pero no se realiza ningún tipo de comprobación de su correcta invocación.

Los lenguajes de alto nivel solventan estas limitaciones ofreciendo un conjunto de mecanismos para definir y manipular datos. Cada lenguaje tiene su propia sintaxis, o forma de escribir las órdenes, y su semántica, o cómo esas órdenes son traducidas a secuencias de instrucciones máquina.

Al conjunto de reglas para definir y manipular los tipos de datos en un lenguaje de alto nivel se le denomina el “sistema de tipos de datos”. Aquellos lenguajes que estructuran sus datos en base a objetos que se crean a partir de una definición genérica denominada “clase” se les conoce como “lenguajes orientados a objeto”. Java, Smaltalk y C++ son algunos de los múltiples lenguajes con esta característica.

El proceso de compilación de los programas escritos en lenguajes de alto nivel es similar al de traducción de lenguaje ensamblador a lenguaje máquina. Dado un conjunto de ficheros escritos en el lenguaje de entrada, se produce un ejecutable que contiene la traducción de todos ellos a instrucciones máquina y definiciones de datos. La figura 8.1 muestra el procedimiento por el que dado un conjunto de ficheros en lenguaje de alto nivel, el compilador obtiene un fichero ejecutable.

Figura 8.1. Compilación de un programa escrito en un lenguaje de alto nivel

Compilación de un programa escrito en un lenguaje de alto nivel

El lenguaje de programación Java merece una mención especial, pues no sigue el patrón de traducción que se muestra en la figura 8.1. Los programas se escriben en múltiples ficheros que contienen la definición de clases con sus campos y métodos. El proceso de compilación no produce directamente un ejecutable sino un fichero con formato “class” o “bytecode”. Este formato no corresponde con instrucciones máquina del procesador sino con instrucciones de lo que se conoce como “máquina virtual de java” o JVM (Java Virtual Machine).

La traducción a código de la JVM se realiza para garantizar la “portabilidad” de un programa, es decir, que el fichero generado se pueda ejecutar sin modificaciones en cualquier procesador. La máquina virtual lee el código escrito en formato class y lo traduce a instrucciones máquina del procesador sobre el que se ejecuta. Esta traducción se hace en el momento en el que se ejecuta un programa. Por este motivo se dice que Java es un lenguaje parcialmente compilado y parcialmente interpretado. La compilación traduce el código inicial a formato bytecode que a su vez es interpretado en tiempo de ejecución por la máquina virtual.

Mediante la presencia de esta máquina virtual, se garantiza la compatibilidad de los programas Java en cualquier procesador. Para ello es preciso crear una máquina virtual diferente para cada uno de los procesadores existentes en el mercado. Una vez implementada esta máquina virtual, todos los programas escritos en Java son ejecutables en esa plataforma.

Existen otro tipo de lenguajes de alto nivel que no precisan de un paso previo de compilación para obtener un ejecutable, sino que se ejecutan directamente a través de un programa auxiliar denominado “intérprete” cuyo cometido es similar al compilador sólo que su tarea la hace justo en el instante que el programa debe ser ejecutado, y no como paso previo. A estos lenguajes de alto nivel se les denomina “interpretados” pues el proceso que se lleva a cabo en el momento de la ejecución es una interpretación del código fuente y su traducción instantánea a código máquina. Perl, Python y TCL son algunos ejemplos de lenguajes de programación interpretados.

8.2. Programas en múltiples ficheros

La generación de un programa a partir de un conjunto de ficheros con código fuente procesándolos se realiza en dos pasos. En el primero se traduce cada fichero por separado a código máquina. En el segundo paso denominado de “enlazado” (ver sección 1.5.2) se combinan las porciones de código generadas en el paso anterior y se crea el fichero ejecutable. Para ello se precisan dos mecanismos:

  • Política de acceso a los símbolos definidos en cada uno de los ficheros

  • Ejecución parcial de un fragmento de código en un fichero diferente al que se está ejecutando y su retorno al mismo punto una vez terminado.

Cada fichero ensamblador contiene un conjunto de etiquetas que representan diferentes posiciones de memoria. Para que un programa pueda ser fragmentado debe ser posible referirse a un símbolo definido en otro fichero. Por ejemplo, una instrucción de salto debe poder especificar como destino un punto en el código de otro fichero.

Pero si una aplicación consta de múltiples ficheros cada uno de ellos con un número muy elevado de etiquetas definidas, tareas como la ampliación de un programa se vuelven muy difíciles. Si los símbolos definidos en los ficheros son todos ellos globales, no se puede utilizar un nombre para una variable o una posición en el código que esté presente en otro fichero.

Para solucionar este problema se adopta la política de gestión opuesta para el ámbito de los símbolos. Todo símbolo definido en un fichero tiene como ámbito de validez únicamente el propio fichero a no ser que se especifique lo contrario con la directiva de ensamblador .global. El ensamblador permite definir una etiqueta que coincide con el nombre de otra definida como global. En este caso, el símbolo local toma precedencia y por tanto el global no es accesible. De esta forma, cuando se escribe código ensamblador en un fichero, en principio se puede utilizar cualquier nombre para una etiqueta.

En el primer paso de la traducción, todo símbolo que no esta definido en el fichero que se procesa se considera externo, y por tanto su posición es desconocida. Es en el paso de entrelazado en el que los símbolos son todos conocidos y se pueden traducir a sus correspondientes valores. El compilador incluye en cada fichero obtenido en el primer paso dos conjuntos de símbolos: el primero corresponde con las etiquetas definidas en la zona de datos o de código que han sido declaradas globales, mientras que el segundo contiene aquellos que se utilizan pero cuya definición no se ha encontrado en el fichero. La figura 8.2 muestra un ejemplo de programa escrito en dos ficheros en los que se producen referencias a símbolos externos.

Figura 8.2. Referencia a símbolos en dos ficheros

Referencia a símbolos en dos ficheros

En la fase de entrelazado, para cada fichero, el compilador busca los símbolos externos en la lista de símbolos globales del resto de ficheros. En el caso de que un símbolo externo no esté definido en ninguno de ellos se muestra un mensaje de error. Si dos ficheros definen el mismo símbolo como global también se muestra un mensaje de error. En ambos casos no se produce fichero ejecutable.

Además de los símbolos contenidos en cada uno de los ficheros, en la fase de entrelazado el compilador dispone de código auxiliar en ficheros denominados “bibliotecas” en los que se incluyen rutinas para realizar tareas comunes de cualquier programa como lectura/escritura de datos a través de diferentes dispositivos (teclado, pantalla, ficheros, etc).

Otro aspecto que debe solventar el compilador para generar un ejecutable a partir de múltiples ficheros fuente es el de la “reubicación de código”. Al traducir el código ensamblador contenido en cada fichero, el código binario resultante se almacena a partir de la posición cero de memoria, pues no se sabe la posición exacta que ocupará a la hora de ejecutar. Pero cuando el código está en múltiples ficheros, en la fase de entrelazado sólo el código de uno de ellos puede estar en la posición inicial, el resto debe ser reubicado. La figura 8.3 muestra un ejemplo en el que el ejecutable se obtiene a partir de tres ficheros. El código de dos de ellos debe ser reubicado.

Figura 8.3. Reubicación de símbolos en la fase de entrelazado

Reubicación de símbolos en la fase de entrelazado

La reubicación de código consiste en que toda instrucción que contenga en su codificación el valor de una dirección de memoria (por ejemplo, las que utilizan el modo de direccionamiento absoluto) deben ser modificadas para referirse a la posición de memoria tras la reubicación. El compilador recorre de nuevo las instrucciones máquina generadas y suma a toda dirección de memoria un factor de reubicación que corresponde con la dirección utilizada al comienzo del fichero.

Considérese la instrucción call metodo1 que invoca a la subrutina método definida en otro fichero. En la primera fase de compilación esta instrucción se traduce por el código 0xE8FCFFFFFF. Los cuatro últimos bytes denotan la dirección de memoria representada por la etiqueta método. En el ejecutable obtenido, el código de esta instrucción pasa a ser 0xE8FA000000 que conserva el primer byte que corresponde con el código de operación pero cambia los cuatro últimos bytes que codifican la dirección de memoria.

8.3. Traducción de construcciones de alto nivel a ensamblador

Las construcciones que ofrecen los lenguajes de alto nivel como Java para escribir programas distan mucho de la funcionalidad ofrecida por el lenguaje máquina del procesador. Por ejemplo, en Java se permite ejecutar una porción de código de forma iterativa mediante las construcciones for o while hasta que una condición se deje de cumplir. El compilador es el encargado de producir el código ensamblador tal que su ejecución sea equivalente a la especificada en el lenguaje Java.

A continuación se muestra cómo la funcionalidad ofrecida por el procesador es suficiente para traducir estas construcciones a secuencias de instrucciones ensamblador con idéntico significado.

8.3.1. Traducción de un if/then/else

La figura 8.4 muestra las tres partes de las que consta un bloque if/then/else. La palabra reservada if va seguida de una expresión booleana entre paréntesis. A continuación entre llaves una primera porción de código que puede ir seguida opcionalmente de una segunda porción también entre llaves y con el prefijo else.

Figura 8.4. Estructura de un if/then/else

  if (expresión booleana) {
    Bloque A

  } else {
    Bloque B
  }

Lo más importante para traducir un bloque a ensamblador es saber su significado o semántica. La semántica del bloque if/then/else es que se evalua la expresión booleana y si el resultado es verdadero se ejecuta el bloque A de código y se ignora el bloque B, y si es falsa, se ignora el bloque A y se ejecuta el bloque B.

El elemento clave para traducir esta construcción a ensamblador es la instrucción de salto condicional. Este tipo de instrucciones permiten saltar a un destino si una condición es cierta o seguir la secuencia de ejecución en caso de que sea falsa. Lo único que se necesita es traducir la expresión booleana de alto nivel a una condición que pueda ser comprobada por una de las instrucciones de salto condicional ofrecida por el procesador. Supóngase que la expresión es falsa si el resultado de la evaluación es cero y cierta en caso contrario. Además, tras ejecutar las instrucciones de evaluación, el resultado se almacena en %eax. En la figura 8.5 se muestra la estructura genérica en lenguaje ensamblador resultante de traducir un if/then/else en este supuesto.

Figura 8.5. Traducción de un if/then/else a ensamblador

        ...                     # Evaluar la expresión booleana
        ...                     # Resultado en %eax
        cmp $0, %eax
        je bloqueb
        ...                     # Traducción del bloque A
        ...                     # Fin del bloque A
        jmp finifthenelse
bloqueb:                        # Traducción del bloque B
        ...
        ...                     # Fin del bloque B
finifthenelse:
        ...                     # Resto del programa

Tras la evaluación de la condición, el resultado previamente almacenado en %eax se compara, y si es igual a cero se ejecuta el salto que evita la ejecución del bloque A. En el caso de un if/then/else sin el bloque B, el salto sería a la etiqueta finifthenelse.

En un bloque genérico de este tipo no es preciso asumir que el resultado de la condición está almacenado en %eax. El ejemplo 8.1 muestra la traducción de un if/then/else con una condición booleana con múltiples operaciones. Se asume que las variables x, i y j son de tipo entero y están almacenadas en memoria con etiquetas con el mismo nombre.

Ejemplo 8.1. Traducción de un if/then/else a ensamblador

Código de alto nivel Código ensamblador
if ((x <= 3) && (i == (j + 1))) {
  Bloque A 
} else {
  Bloque B
}
    cmpl $3, x         # Comparar si x <= 3
    jg bloqueB         # Si falso ir a bloque B
    mov j, %eax        # Obtener j + 1
    inc %eax           
    cmp %eax, i        # Comparar i == (j + 1)
    jne bloqueB        # Si falso ir  a bloque B
    ...                # Traducción del bloque A
    ...             
    jmp finifthenelse  # Evitar bloque B
bloqueB: 
    ...                # Traducción del bloque B
    ...
finifthenelse:         # Final de traducción

La primer expresión de la conjunción se traduce en una única instrucción cmp. Si esta comparación no es cierta, al tratarse de una conjunción, se debe ejecutar el bloque B sin necesidad de seguir evaluando. Esto se consigue con el salto jg que contiene la condición contraria a la del código (x <= 3).

Si la primera parte de la conjunción evalúa a cierto, se pasa a evaluar la segunda. Primero se obtiene el valor de j, se copia en un registro y se incrementa, pues el código de alto nivel no modifica el valor almacenado en memoria. A continuación se compara con la variable i y de nuevo, mediante un salto condicional, se ejecuta el bloque pertinente.

8.3.2. Traducción de un switch

A menudo en programación es preciso realizar una operación y ejecutar diferentes bloques de código dependiendo del valor obtenido. La construcción switch mostrada en la figura 8.6 ofrece exactamente esta funcionalidad

Figura 8.6. Estructura de un switch

  switch (expresión) {
    case valor A:
      Bloque A

      break;             // Opcional
    case valor B:
      Bloque B
      break;             // Opcional
      ...
    default:
      Bloque por defecto // Opcional
  }

La semántica de esta construcción establece que primero se evalúa la condición y a continuación se compara el resultado con los valores de cada bloque precedido por la palabra clave case. Esta comparación se realiza en el mismo orden en el que se definen en el código y si alguna de estas comparaciones es cierta, se pasa a ejecutar el código restante en el bloque (incluyendo el resto de casos). Si ninguna de las comparaciones es cierta se ejecuta (si está presente) el caso con etiqueta default. La palabra clave break se puede utilizar para transferir el control a la instrucción que sigue al bloque switch.

La estructura del código ensamblador para implementar esta construcción debe comenzar por el cálculo del valor de la expresión. A continuación se compara con los valores de los casos siguiendo el orden en el que aparecen en el código. Si una comparación tiene éxito, se ejecuta el bloque de código que le sigue. Si se encuentra la orden break se debe saltar al final del bloque. En el caso de que ninguna comparación tenga éxito, se debe pasar a ejecutar el bloque default. Supóngase que la evaluación de la expresión es un valor que se almacena en el registro %eax. En la figura 8.7 se muestra la estructura genérica en lenguaje ensamblador resultante de traducir un switch en este supuesto.

Figura 8.7. Traducción de un switch a ensamblador

        ...                     # Evaluar la expresión
        ...                     # Resultado en %eax
        cmp $valorA, %eax       # Caso A
        je bloquea
        cmp $valorB, %eax       # Caso B
        je bloqueb
        ...
        jmp default
bloquea:
        ...                     # Traducción del bloque A
        ...                     # 
        jmp finswitch           # Si bloque A tiene break
bloqueb:
        ...                  
        ...
        jmp finswitch           # Si bloque B tiene break
        ...                  
        ...
default: 
        ...                     # Caso por defecto                  
        ...
finswitch:
        ...                     # Resto del programa

En este esquema se asume que tras obtener el valor de la expresión, éste se mantiene en el registro %eax. La presencia de la línea break corresponde directamente con la instrucción jmp finswitch. El ejemplo 8.2 muestra la traducción de un switch. Se asume que las variables son todas de tipo entero y están almacenadas en memoria con etiquetas con el mismo nombre.

Ejemplo 8.2. Traducción de un switch a ensamblador

Código de alto nivel Código ensamblador
  switch (x + i + 3 + j) {
    case 12:
      Bloque A
      break;
    case 14:
    case 16:
      Bloque B
    case 18:
      Bloque C
      break;
    default:
      Bloque D
  }
        mov x, %eax             # Evaluar la expresión
        add i, %eax
        add $3, %eax
        add j, %eax
        cmp $12, %eax           # Caso 12
        je bloquea
        cmp $14, %eax           # Caso 14
        je bloqueb
        cmp $16, %eax           # Caso 16
        je bloqueb
        cmp $18, %eax           # Caso 18
        je bloquec
        jmp default
bloquea:
        ...                     # Traducción del bloque A
        ...                     # 
        jmp finswitch           # Bloque A tiene break
bloqueb:
        ...                     # Traducción del bloque B
        ...
bloquec:
        ...                     # Traducción del bloque C
        ...
        jmp finswitch           # Bloque C tiene break
default: 
        ...                     # Bloque D
        ...
finswitch:
        ...                     # Resto del programa

La condición del ejemplo es una suma de cuatro operandos, con lo que las instrucciones ensamblador correspondientes obtienen los operandos y acumulan su suma en %eax. A continuación se comparan los sucesivos casos. Si alguna de ellas tiene éxito se pasa a ejecutar el correspondiente bloque de código. Si todas ellas fallan, se ejecuta el salto incondicional al bloque default. En cada uno de los bloques, si está presente la palabra reservada break, ésta se traduce en un salto incondicional al final del bloque.

8.3.3. Traducción de un bucle while

Una de las construcciones más comunes en lenguajes de alto nivel para ejecutar código de forma iterativa es el bucle while. La figura 8.8 muestra su estructura. La palabra reservada while da paso a una expresión booleana entre paréntesis que se evalúa y en caso de ser cierta pasa a ejecutar el bloque del código interno tras cuyo final se vuelve de nuevo a evaluar la condición.

Figura 8.8. Estructura de un bucle while

  while (expresión booleana) {
    Código interno
  }

En este bloque es importante tener en cuenta que la expresión booleana se evalúa al menos una vez y se continúa evaluando hasta que sea falsa. Supóngase que la evaluación de la expresión es cero en caso de ser falsa y diferente de cero si es cierta y el valor resultante se almacena en %eax. En la figura 8.9 se muestra la estructura genérica en lenguaje ensamblador resultante de traducir un bucle while en este supuesto.

Figura 8.9. Traducción de un bucle while a ensamblador

eval:   ...            # Evaluar la expresión booleana
        ...            # Resultado en %eax
        cmp $0, %eax
        je finwhile
        ...            # Traducción del código interno
        ...
        jmp eval
finwhile:
        ...            # Resto del programa

Tras evaluar la condición se ejecuta una instrucción que salta al final del bloque si es falsa. En caso de ser cierta se ejecuta el bloque de código y tras él un salto incondicional a la primera instrucción con la que comenzó la evaluación de la condición. El destino de este salto no puede ser la instrucción de comparación porque es muy posible que las variables que intervienen en la condición hayan sido modificadas por lo que la evaluación se debe hacer a partir de estos valores. El ejemplo 8.3 muestra la traducción de un bucle while con una de estas condiciones. Se asume que las variables x, i y j son de tipo entero y están almacenadas en memoria con etiquetas con el mismo nombre.

Ejemplo 8.3. Traducción de un bucle while a ensamblador

Código de alto nivel Código ensamblador
while ((x == i) || (y < x)) {
  Código interno 
}
eval:                 # Comienza evaluación
    mov x, %eax
    cmp i, %eax       # Comparar si x == i
    je codigointerno  # Si cierto ejecutar código
    cmp y, %eax
    jle finwhile      # Si falso ir al final
codigointerno:
    ...               # Código interno
    ...             
    jmp eval          # Evaluar de nuevo
finwhile:             # Final de traducción

En este caso la condición del bucle es una disyunción con lo que si una de las condiciones es cierta, se puede ejecutar el código interno del bucle sin evaluar la segunda. Por este motivo se utiliza el salto condicional je tras la primera comparación. En caso de éxito se pasa a ejecutar directamente el bloque de código interno al bucle. Si la primera condición es falsa se evalúa la segunda. El correspondiente salto condicional en este caso tiene una condición inversa a la incluida en el código, pues si ésta es falsa, se debe transferir el control al final del bucle.

8.3.4. Traducción de un bucle for

El bucle for, aunque con semántica similar al anterior, tiene una estructura más compleja tal y como se muestra en la figura 8.10.

Figura 8.10. Estructura de un bucle for

  for (Bloque A; expresión booleana; Bloque B) {
    Código interno

  }

El bloque A se ejecuta una única vez antes del bucle, a continuación se evalúa la expresión booleana. En caso de ser cierta se pasa a ejecutar el código interno del bucle, y si no, se termina la ejecución del bucle. El bloque B se ejecuta a continuación del código interno y justo antes de saltar de nuevo a la evaluación de la expresión booleana. Supóngase que la evaluación de la expresión booleana es cero en caso de ser falsa y diferente de cero si es cierta y el valor resultante se almacena en %eax. En la figura 8.11 se muestra la estructura genérica en lenguaje ensamblador resultante de traducir un bucle for en este supuesto.

Figura 8.11. Traducción de un bucle for a ensamblador

        ...            # Traducción del bloque A
        ...
eval:   ...            # Evaluar la expresión booleana
        ...            # Resultado en %eax
        cmp $0, %eax
        je finfor
        ...            # Traducción del código interno
        ...
        ...            # Traducción del bloque B
        ...
        jmp eval
finfor:
        ...            # Resto del programa

Las primeras instrucciones corresponden a la traducción del bloque A seguidas de las que evalúan la condición. Se necesita la etiqueta eval como destino del salto incondicional al final del bloque B. A continuación se comprueba el resultado de la comparación, y si es falso se salta al final del bucle. En caso contrario se ejecuta el código interno que finaliza con las instrucciones del bloque B y un salto incondicional para que se evalúe de nuevo la condición. El ejemplo 8.4 muestra la traducción de un bucle for con una de estas condiciones. Se asume que las variables i y j son de tipo entero y están almacenadas en memoria con etiquetas con el mismo nombre.

Ejemplo 8.4. Traducción de un bucle for a ensamblador

Código de alto nivel Código ensamblador
for (i = 0; i <= --j; i++) {
  Código interno 
}
    movl $0, i  # Bloque A
eval:           # Expresión booleana
    mov i, %eax
    decl j
    cmp j, %eax # Comparar si x <= --j
    jg finfor   # Si falso ir al final
codigointerno:
    ...         # Código interno
    ...         
    incl i      # Bloque B
    jmp eval    # Evaluar de nuevo
finfor:         # Final de traducción

La traducción del bloque A es una única instrucción que almacena un cero en memoria. La expresión booleana incluye el decremento de la variable j antes de ser utilizada por la comparación. La traducción del bloque B también requiere una única instrucción para incrementar el valor de la variable i.

8.4. Ejecución de subrutinas

En la sección anterior se ha mostrado cómo se obtiene una traducción automática de un programa arbitrariamente complejo. El compilador primero traduce un bloque a su estructura genérica, luego traduce los bloques internos, y una vez terminado, pasa al bloque siguiente. La estructura global de un programa es una combinación de bloques para los cuales existe una traducción sistemática. Esta y la codificación de los datos son las dos principales tareas de un compilador para obtener un ejecutable.

El mecanismo que merece un estudio aparte es el de llamada a subrutinas. El desarrollo de programas modulares se basa en la posibilidad de ejecutar un bloque de código múltiples veces con diferentes valores de un conjunto de variables denominadas “parámetros” que produce un resultado. Este mecanismo, con diferentes matices, es lo que se denomina como procedimientos, funciones, subprogramas o métodos y están presentes en prácticamente todos los lenguajes de programación de alto nivel.

En el contexto del lenguaje ensamblador se define una subrutina como una porción de código que realiza una operación en base a un conjunto de valores dados como parámetros de forma independiente al resto del programa y que puede ser invocado desde cualquier lugar del código, incluso desde dentro de ella misma.

La ejecución de subrutinas tiene las siguientes ventajas:

  • Evita código redundante. Durante el diseño de un programa suelen existir ciertos cálculos que deben realizarse en diferentes lugares del código. La alternativa a replicar las instrucciones es encapsularlas en una subrutina e invocar esta cada vez que sea necesario lo cual se traduce en código más compacto.

  • Facilita la descomposición de tareas. La descomposición de tareas complejas en secuencias de subtareas más simples facilita enormemente el desarrollo de programas. Esta técnica se suele aplicar de forma sucesiva en lo que se denomina “diseño descendente” de programas. Cada subtarea se implementa como una rutina.

  • Facilita el encapsulado de código. El agrupar una operación y sus datos en una subrutina y comunicarse con el resto de un programa a través de sus parámetros y resultados, hace que si en algún momento se cambia su implementación interna, el resto del programa no requiera cambio alguno.

Además de estas ventajas, el encapsulado de código también facilita la reutilización de su funcionalidad en más de un programa mediante el uso de “bibliotecas”. Una biblioteca de funciones es un conjunto de subrutinas que realizan cálculos muy comunes en la ejecución de programas y que pueden ser utilizados por éstos. Java es un ejemplo de lenguaje que dispone de bibliotecas de clases que en su interior ofrecen multitud de métodos.

La desventaja de las subrutinas es que es necesario establecer un protocolo que defina dónde y cómo se realiza esta transferencia de datos para la que se requieren múltiples instrucciones máquina adicionales.

8.4.1. Las instrucciones de llamada y retorno de una subrutina

En ensamblador la llamada a una subrutina se realiza mediante la instrucción CALL cuyo único operando es la dirección de memoria, generalmente una etiqueta, en la que comienza su código. Tras ejecutar esta instrucción el procesador continua ejecutando la primera instrucción de la subrutina hasta que encuentra la instrucción RET que no tiene operandos y transfiere la ejecución a la instrucción siguiente al CALL que inició el proceso. La figura 8.12 ilustra esta secuencia.

Figura 8.12. Llamada y retorno de una subrutina

Llamada y retorno de una subrutina

La instrucción CALL tiene una funcionalidad similar a un salto incondicional, su único operando denota la siguiente instrucción a ejecutar. La instrucción RET no tiene operandos explícitos pero su efecto, el retorno a la siguiente instrucción tras la llamada, requiere la utilización de operandos implícitos.

Pero la dirección a la que debe retornar el procesador no puede ser un valor fijo para la instrucción RET puesto que depende del lugar desde donde ha sido invocada la subrutina. Considérese, por ejemplo, una subrutina que se invoca desde dos lugares diferentes de un programa. La instrucción RET con la que se termina su ejecución es idéntica en ambos casos pero su dirección de retorno no. Otra característica de las subrutinas es que su invocación se puede hacer de forma anidada, es decir, que desde una subrutina se invoca a otra y desde ésta a su vez a otra, hasta una profundidad arbitraria. La figura 8.13 muestra un ejemplo de invocación anidada de subrutinas y se puede comprobar como la subrutina B es invocada desde diferentes lugares del código.

Figura 8.13. Invocación anidada de subrutinas

Invocación anidada de subrutinas

La instrucción RET de la subrutina B retorna la ejecución a la subrutina A en su primera ejecución (denotada por la flecha número 3) y al programa principal en su segunda ejecución (denotada por la flecha número 6). Esto hace suponer, por tanto, que la dirección de retorno no puede ser decidida cuando se ejecuta esta instrucción sino en un momento anterior.

El instante en el que se sabe dónde ha de retomarse la ejecución tras una subrutina es precisamente en el momento de su invocación. Cuando el procesador está ejecutando la instrucción CALL obtiene la dirección de retorno como la de la instrucción siguiente en la secuencia.

Por tanto, el procesador, además de modificar la secuencia, al ejecutar la instrucción CALL debe guardar la dirección de retorno en un lugar prefijado del cual será obtenido por la instrucción RET. Pero, durante la ejecución de un programa es preciso almacenar múltiples direcciones de retorno de forma simultanea.

El que las subrutinas se puedan invocar de forma anidada hace que la utilización de los registros de propósito general para almacenar la dirección de retorno no sea factible. La alternativa es almacenarlas en memoria, pero la instrucción RET debe tener acceso a su operando implícito siempre en el mismo lugar. Además, esta zona de memoria debe poder almacenar un número arbitrario de direcciones de retorno, pues la invocación de subrutinas se puede anidar hasta niveles arbitrarios de profundidad.

Por tanto, se necesita un área de memoria que pueda almacenar tantas direcciones de retorno como subrutinas están siendo invocadas de forma anidada en un momento de la ejecución de un programa. La propiedad que tienen estas direcciones es que se almacenan por la instrucción CALL en un cierto orden, y son utilizadas por la instrucción RET en el orden inverso.

La estructura especialmente concebida para este propósito es la pila. En ella se almacena la dirección de retorno mientras se ejecuta el cuerpo de una subrutina. En caso de invocaciones anidadas, las direcciones de retorno pertinentes se guardan en la pila y están disponibles para la instrucción RET en el orden preciso.

La instrucción CALL, por tanto, realiza dos tareas: pasa a ejecutar la instrucción en la dirección dada como operando y almacena en la cima de la pila la dirección de la instrucción siguiente (al igual que lo haría una instrucción PUSH) que será la instrucción de retorno. Por su parte, la instrucción RET obtiene el dato de la cima de la pila (igual que lo haría la instrucción POP) y ejecuta un salto incondicional al lugar que indica. Ambas instrucciones, por tanto, modifican el contador de programa.

Del funcionamiento de estas instrucciones se concluye que la cima de la pila justo antes de la ejecución de la primera instrucción de una subrutina contiene la dirección de retorno, y por tanto, antes de ejecutar la instrucción RET debe apuntar exactamente a la misma posición. Aunque esta condición es esencial para que el retorno de la subrutina se haga al lugar correcto, los procesadores no realizan comprobación alguna de que así se produce. Por lo tanto, es responsabilidad del programador en ensamblador el manipular la pila en una subrutina de forma que la cima de la pila al comienzo de la ejecución sea exactamente la misma que justo antes de ejecutar la última instrucción.

Durante la ejecución de la subrutina se pueden hacer las operaciones necesarias sobre la pila siempre y cuando se conserve la dirección de retorno. Esta es la explicación de por qué en la sección 6.4 se estipuló la regla de que la pila al comienzo y final de un programa debe ser la misma. El programa en ensamblador que comienza a ejecutar a partir de la etiqueta main también es una subrutina que invoca el sistema operativo, y por lo tanto se debe garantizar que la cima es idéntica al comienzo y al final del programa pues contiene la dirección de retorno.

8.4.2. Paso de parámetros y devolución de resultados

En general una subrutina consiste en una porción de código que realiza una operación con un conjunto de valores proporcionados por el programa que la invoca denominados parámetros, y que devuelve un resultado. Los parámetros son copias de ciertos valores que se ponen a disposición de la subrutina y que tras acabar su ejecución se descartan. El resultado, en cambio, es un valor que la subrutina calcula y copia en un lugar para que el programa invocador lo utilice. La figura 8.14 ilustra la manipulación de parámetros y resultado.

Figura 8.14. Parámetros y resultado de una subrutina

Parámetros y resultado de una subrutina

Se necesita establecer las reglas que estipulen cómo y dónde deposita el programa que invoca una subrutina estos valores y cómo y dónde se deposita el resultado. En adelante, a la porción de código que realiza la llamada a la subrutina se le denominará “programa llamador” mientras que al código de la subrutina se le denominará “programa llamado”. Las llamadas a subrutinas se puede hacer de forma “anidada”, es decir, un programa llamado invoca a su vez a otra subrutina con lo que pasa a comportarse como programa llamador.

8.4.2.1. Paso de parámetros a través de registro

El paso de parámetros a través de registro consiste en que el programa llamador y el llamado asumen que los parámetros se almacenan en ciertos registros específicos. Antes de la instrucción de llamada el programa llamador deposita los valores pertinentes en estos registros y la subrutina comienza a procesarlos directamente.

En general, dada una rutina que recibe n parámetros y devuelve m resultados, se necesita definir en qué registro deposita el programa llamador la copia de cada uno de los n parámetros, y en qué registro deposita la subrutina la copia del resultado obtenido. El ejemplo 8.5 muestra las instrucciones necesarias en el caso de una subrutina que recibe como parámetros dos enteros a través de los registros %eax y %ebx y devuelve el resultado a través del registro %ecx.

Ejemplo 8.5. Paso de parámetros a través de registros

Programa llamador Programa llamado
    mov param1, %eax
    mov param2, %ebx
    call subrutina
    mov %ecx, resultado
subrutina:
    push %...     # Salvar registros utilizados
    push %...     # excepto %eax, %ebx y %ecx

    ...           # Realizar cálculos 

    mov ..., %ecx # Poner resultado en %ecx

    pop %...      # Restaurar registros
    pop %...
    ret

Al utilizar los registros %eax y %ebx para pasar los parámetros la subrutina no salva su contenido pues dispone de esos valores como si fuesen suyos. El registro %ecx, al contener el resultado, tampoco se debe salvar ni restaurar.

El principal inconveniente que tiene este esquema es el número limitado de registros de propósito general. En los lenguajes de alto nivel no hay límite en el número de parámetros que puede tener una función o método en su definición, y por tanto, si este número es muy alto, el procesador puede no tener registros suficientes.

A pesar de esta limitación, en el caso de subrutinas con muy pocos parámetros y que devuelve un único resultado, este mecanismo es muy eficiente pues el procesador no precisa almacenar datos en memoria. Los sistemas operativos suele utilizar esta técnica para invocaciones de subrutinas internas de estas características.

8.4.2.2. Paso de parámetros a través de memoria

El paso de parámetros a través de memoria consiste en definir una zona de memoria conocida tanto para el programa llamador como para el llamado y en ella se copia el valor de los parámetros y el del resultado para su intercambio. La ventaja de esta técnica radica en que permite tener un número arbitrario de parámetros, pues tan sólo se requiere una zona más grande de memoria.

En general, para una subrutina que recibe n parámetros y devuelve m resultados se define una zona de memoria cuyo tamaño es la suma de los tamaños de todos ellos así como el orden en el que estarán almacenados. El ejemplo 8.6 muestra la instrucciones necesarias para el caso de una subrutina que precisa tres parámetros de tamaño 32 bits y devuelve dos resultados de tamaño 8 bits. Se asume que la zona de memoria está definida a partir de la etiqueta params.

Ejemplo 8.6. Paso de parámetros a través de memoria

Programa llamador Programa llamado
    mov params, %eax
    mov v1, %ebx
    mov %ebx, (%eax)
    mov v2, %ebx
    mov %ebx, 4(%eax)
    mov v3, %ebx
    mov %ebx, 8(%eax)
    call subrutina
    mov 12(%eax), %ah
    mov 13(%eax), %ah
subrutina:
    push %...         # Salvar registros utilizados
    push %...     

    mov params, %ebx  # Acceso a los parámetros
    mov (%ebx), ...
    mov 4(%ebx), ...
    mov 8(%ebx), ...
    ...               # Realizar cálculos 

    mov %dh, 12(%ebx) # Poner resultado
    mov %dl, 13(%ebx)

    pop %...          # Restaurar registros
    pop %...
    ret

El principal inconveniente de esta técnica es que necesita tener estas zonas de memoria previamente definidas. Además, en el caso de invocación anidada de subrutinas, se necesitan múltiples espacios de parámetros y resultados pues mientras la ejecución de una subrutina no termina, éstos siguen teniendo validez.

El incluir esta definición junto con el código de una subrutina parecería una solución idónea, pues al escribir sus instrucciones se sabe el número y tamaño de parámetros y resultados. Pero existen subrutinas denominadas “recursivas” que se caracterizan por contener una invocación a ellas mismas con un conjunto de parámetros diferente.

La conclusión es que se precisan tantas zonas para almacenar parámetros y devolver resultados como invocaciones pendientes de terminar en cada momento de la ejecución. Pero este requisito de vigencia es idéntico al que tiene la dirección de retorno de una subrutina. Es más, la dirección de retorno se puede considerar un valor más que el programa llamador pasa al llamado para que éste lo utilice. En esta observación se basa la siguiente técnica de paso de parámetros.

8.4.2.3. Paso de parámetros a través de la pila

El paso de parámetros a través de la pila tiene múltiples ventajas. En primer lugar, tanto parámetros como resultados se pueden considerar resultados temporales que tienen validez en un período muy concreto de la ejecución de un programa por lo que la pila favorece su manipulación. Además, dada una secuencia de llamadas a subrutinas, el orden de creación y destrucción de estos parámetros es el inverso tal y como permiten las instrucciones de gestión de la pila.

En general, para una subrutina que recibe n parámetro y devuelve m resultados el programa llamador reserva espacio en la cima de la pila para almacenar estos datos justo antes de ejecutar la instrucción CALL y lo elimina justo a continuación.

Pero en la subrutina es necesario un mecanismo eficiente para acceder a la zona de parámetros y resultados. Al estar ubicada en la pila lo más intuitivo es utilizar el registro %esp que apunta a la cima y el modo de direccionamiento base + desplazamiento mediante la utilización de los desplazamientos pertinentes. Pero el inconveniente de este método es que la cima de la pila puede fluctuar a lo largo de la ejecución de la subrutina y por tanto los desplazamientos a utilizar varían.

Para que el acceso a los parámetros no dependa de la posición de la cima de la pila y se realice con desplazamientos constantes a lo largo de la ejecución de la subrutina, las primeras instrucciones almacenan una copia del puntero de pila en otro registro (generalmente %ebp) y al fijar su valor, los accesos a la zona de parámetros y resultados se realizan con desplazamientos constantes. Pero para preservar el valor de los registros, antes de crear este duplicado es preciso guardar en la pila una copia de este registro. El ejemplo 8.7 muestra las instrucciones necesarias para el caso de una subrutina que precisa tres parámetros de tamaño 32 bits y devuelve un resultado de 8 bits.

Ejemplo 8.7. Paso de parámetros a través de la pila

Programa llamador Programa llamado
    sub $4, %esp 
    push v3
    push v2
    push v1
    call subrutina
    add $12, %esp
    pop resultado
subrutina:
    push %ebp         # Guardar registro %ebp
    mov %esp, %ebp    # Apuntar a punto fijo en pila
    push %...         # Salvar registros utilizados
    push %...     

    mov 8(%ebp), ...  # Acceso a los parámetros
    mov 12(%ebp), ...
    mov 16(%ebp), ...
    ...               # Realizar cálculos 

    mov ..., 20(%ebx) # Poner resultado

    pop %...          # Restaurar registros
    pop %...
    mov %ebp, %esp    # Restaurar %esp y %ebp
    pop %ebp
    ret

La primera instrucción del programa llamador modifica el puntero de la pila para reservar espacio donde almacenar el resultado. Al ser una posición de memoria sobre la que se escribirá el resultado no es preciso escribir ningún valor inicial, de ahí que no se utilice la instrucción push.

A continuación se depositan en la pila los valores de los parámetros. El orden en que se almacenan debe ser conocido por el programa llamador y el llamado. Tras la ejecución de la subrutina se eliminan de la pila los parámetros, que al desempeñar ningún papel, basta con corregir el valor de la cima dejando la pila preparada para obtener el valor del resultado.

Por su parte, el programa llamado guarda la copia del registro %ebp para justo a continuación copiar el valor de %esp y por tanto fija su valor a la cima de la pila. A partir de este instante, cualquier dato que se ponga en la pila no afecta el valor de %ebp y el desplazamiento para acceder a los parámetros es respectivamente de 8, 12 y 16 pues en la posición debajo de la cima se encuentra la dirección de retorno. Para depositar el resultado se utiliza el desplazamiento 20. Tras terminar el cálculo del resultado se procede a deshacer la estructura de datos creada en la pila en orden inverso. Primero se descargan de la pila los registros salvados y a continuación se restaura el valor del registro %ebp dejando en la cima la dirección de retorno que necesita la instrucción ret.

A la porción de memoria en la pila que contiene el espacio para la devolución de resultados, los parámetros, la dirección de retorno, la copia de %ebp se le denomina el “bloque de activación”. Al registro %ebp que ofrece un punto fijo de referencia a los datos se le denomina el “puntero” al bloque de activación.

8.4.2.4. Almacenamiento de variables locales a una subrutina

Además de la capacidad de definir y ejecutar subrutinas, los lenguajes de programación de alto nivel permiten la definición de variables locales. El ámbito de validez se reduce al instante en que se está ejecutando el código de la subrutina. De nuevo se precisa un mecanismo que gestione de forma eficiente estas variables. El ejemplo 8.8 muestra la definición de un método en Java en el que las variables i, str y p son de ámbito local.

Ejemplo 8.8. Definición de variables locales a un método

int traducir(Representante r, int radix) {
    int i;      // Variables locales
    String str;
    Punto p;

    i = 0;
    str = new String(...);
    punto = ...
    ...                    
    return i;
}

El ámbito de estas variables no impide que el valor de alguna de ellas sea devuelto como resultado tal y como muestra el método del ejemplo. La última línea copia el valor de la variable local en el lugar en el que se devuelve el resultado, y por tanto está disponible para el programa llamador.

El ámbito de estas variables es idéntico al de los parámetros y al de la dirección de retorno, por lo que para almacenar estas variables se pueden utilizar cualquiera de las tres técnicas descritas anteriormente: en registros, en posiciones arbitrarias de memoria y en la pila.

El almacenamiento en la pila se hace en el bloque de activación justo a continuación de haber establecido el registro %ebp como puntero al bloque de activación. De esta forma, como el número de variables locales es siempre el mismo, utilizando desplazamientos con valores negativos y el registro base %ebp se accede a ellas desde cualquier punto de la subrutina.

8.5. Gestión del bloque de activación

De las técnicas descritas para la invocación de subrutinas, la que crea el bloque de activación en la pila es la más utilizada por los lenguajes de alto nivel. En la tabla 8.1 se muestran los pasos a seguir por el programa llamador y el llamado para crear y destruir el bloque de activación.

Tabla 8.1. Pasos para la gestión del bloque de activación

Programa llamador Programa llamado
  1. Reserva espacio en la pila para almacenar los resultados.

  2. Carga los parámetros en orden en la pila.

  3. Ejecuta la instrucción de llamada a subrutina.

  4. Descarga los parámetros de la pila.

  5. Obtiene el resultado de la pila.

  1. Salva el valor de %ebp para utilizar como puntero al bloque de activación.

  2. Copia el valor de %esp en %ebp.

  3. Reserva espacio en la pila para variables locales.

  4. Salva los registros que se utilizan en la subrutina.

  5. Ejecuta el código de la subrutina.

  6. Deposita el resultado en el espacio reservado a tal efecto.

  7. Restaura el valor de los registros salvados.

  8. Iguala la cima de la pila al puntero al bloque de activación.

  9. Restaura el valor del registro %ebp.

  10. Ejecuta la instrucción de retorno de subrutina.


Tras restaurar el valor de los registros utilizados por la subrutina, el estado de la pila es tal que en la cima se encuentra el espacio para las variables locales y a continuación la copia del valor anterior de %ebp. Como el propio %ebp apunta a esa misma posición, la forma más fácil de restaurar la cima de la pila al valor correcto es asignándole a %esp el valor de %ebp. De esta forma no es preciso tener en cuenta el tamaño de la zona reservada para las variables locales. Esta técnica funciona incluso en el caso de que una subrutina no tenga variables locales.

8.6. Ejemplo de evolución del bloque de activación

Considérese un programa que invoca a la subrutina cuenta que dada la dirección de un string terminado en cero y un carácter, devuelve el número de veces que el carácter aparece en el string como entero. La pila que recibe la subrutina tiene, en orden creciente de desplazamiento desde el puntero al bloque de activación, la dirección de retorno (siempre está presente como primer valor), la dirección del string, el carácter a comprobar como byte menos significativo del operando en la pila y el espacio para el resultado. El fragmento de código para invocar a esta subrutina se muestra en el ejemplo 8.9. Se asume que la letra a buscar está almacenada en la etiqueta letra y el string en la etiqueta mensaje.

Ejemplo 8.9. Invocación de la rutina cuenta

        ...
        sub $4, %esp  # Espacio para el resultado
        push letra    # Parámetros en el orden correcto
        push mensaje
        call cuenta   # Invocación de la subrutina
        add $8, %esp  # Descarga del espacio para parámetros
        pop %eax      # Resultado en %eax
        ...

La instrucción push letra tiene por operando una etiqueta que apunta a un dato de tamaño byte. Como los operandos de la pila son de 4 bytes, en ella se depositan la letra y los tres siguientes bytes. Esto no tiene importancia porque la subrutina únicamente accede al byte de menos peso tal y como se ha especificado en su definición. La figura 8.15 muestra la evolución de la pila desde el punto de vista del programa llamador.

Figura 8.15. Evolución de la pila desde el punto de vista del programa llamador

Evolución de la pila desde el punto de vista del programa llamador

El código de la subrutina cuenta se muestra en el ejemplo 8.10.

Ejemplo 8.10. Código de la rutina cuenta

cuenta:  push %ebp               # Salvar %ebp
         mov %esp, %ebp          # Crear puntero a bloque de activación

         sub $4, %esp            # Espacio para variable local: contador

         push %eax               # Salvar registros utilizados
         push %ebx
         push %ecx

         movl $0, -4(%ebp)       # Contador = 0
         mov 8(%ebp), %eax       # Dirección base del string
         mov 12(%ebp), %cl       # Letra a comparar en %cl
         mov $0, %ebx            # Registro índice

bucle:   cmpb $0, (%eax, %ebx)   # Detectar final de string
         je res

         cmpb %cl, (%eax, %ebx)  # Compara letra dada con letra en string
         jne incr                # Si iguales incrementar contador
         incl -4(%ebp)

incr:    inc %ebx                # Incrementar registro índice
         jmp bucle

res:     mov -4(%ebp), %eax      # Mover contador a resultado
         mov %eax, 16(%ebp)

         pop %ecx                # Restaurar registros
         pop %ebx
         pop %eax

         mov %ebp, %esp          # Eliminar variables locales
         pop %ebp                # Restaurar %ebp
         ret

La subrutina almacena el número de veces que aparece la letra en el string dado como una variable local en la pila. Tras finalizar el bucle, su valor se transfiere al lugar en el que lo espera el programa llamador. La figura 8.16 muestra la evolución de la pila durante la ejecución de la subrutina.

Figura 8.16. Evolución de la pila durante la llamada a cuenta

Evolución de la pila durante la llamada a cuenta

Reutilizar Curso
Descargar este curso