image/svg+xml

Ensamblador para el ARM Cortex-A53

Instrucciones, mnemónicos y directivas

Es importante notar la diferencia entre instrucciones, mnemónicos y directivas, que son términos utilizados al escribir código ensamblador.

Las instrucciones son las que se interpretan por la computadora en el proceso de ejecución. Son las que en conjunto forman la arquitectura de set de instrucciones o ISA por sus siglas en inglés.

Los mnemónicos son palabras asociadas a una instrucción para que sea más simple para el humano interpretar y escribir código. A continuación se muestra una tabla que muestra las equivalencias entre algunas instrucciones y los mnemónicos.

Instrucción Mnemónico Descripción
e0810002 add r0, r1, r2 r0 <- r1 + r2
e08f3003 add r3, pc, r3 r3 <- pc + r3
e2844001 add r4, r4, #1 r4 <- r4 + 1
e1a08001 mov r8, r1 r8 <- r1

Las directivas son interpretadas por el ensamblador y no por la máquina en tiempo de ejecución. Son instrucciones que no aparecerán en el archivo objeto final, pero que sirven para facilitar la tarea del programador y dar instrucciones al ensamblador. También se conocen como pseudoinstrucciones. Estas varían de ensamblador a ensamblador.

Considerando el siguiente programa escrito para el ensamblador gcc de la GNU:

.global main

.equ NUM, 42

main:
        mov r0, #NUM
        bx lr

Existen dos directivas, la primera es .global main que le dice al ensamblador que existe en el archivo una etiqueta main que será utilizada por el linker, después para integrar el archivo objeto.

La siguiente es la directiva .equ que permite definir macros que al ser procesadas por el ensamblador, se intercambiaran por el valor definido. Esto sirve para definir “variables” que hagan que el código sea más legible.

Al final la línea mov r0, #NUM será interpretada por el ensamblador como mov r0, #42.

Memoria

Las operaciones realizadas en ARM tienen que ser con registros, no se puede hacer directamente en memoria. Para eso se requiere cargar los datos desde memoria, procesarlos y luego almacenarlos en memoria.

Ahora bien, para cargar desde memoria se requiere conocer la dirección de memoria primero. Esta, al igual que los datos, debe ser cargada primero en registros para poder operar con la memoria de dicha dirección.

Secciones

Además, la memoria del procesador, está dividida por secciones puesto que se trata de una arquitectura RISC, existe una sección para el código y otra para los datos por ejemplo. Para utilizar una u otra es necesario utilizar las directivas del ensamblador .text y .data para el código y los datos respectivamente.

.text

    /* Código */
    
.data

    /* Datos */

Variables

Para definir variables, se pueden utilizar las directivas .balign y .word. La primera sirve para indicar al ensamblador que alinee la memoria para tener un arreglo de $n$ número de bytes, para esto rellena la memoria con bytes hasta llegar a un espacio de memoria que cumpla con los requerimientos.

Luego .word indica que el dato del argumento debe ser almacenado en un espacio de palabra, en este caso de 32 bits o 4 bytes.

.data

.balign 4
var1: .word 3

Aquí, .balign alinea hasta tener un espacio de 4 bytes. A partir de esa dirección con el label var1, .word escribe el valor de un entero con valor 3 sobre un espacio de 32 bits.

En realidad .balign sólo es necesaria cuando se trabaja con espacios de memoria diferentes al tamaño de palabra (32 bits).

Carga y almacenado

Para cargar datos se utilizan las instrucciones ld<x> y para almacenar, las st<x>. Tomando por ejemplo las instrucciones para cargar y almacenar en registros:

ldr ra,[rb] @ ra <- *rb
str ra,[rb] @ *rb <- ra

La primera instrucción ldr, carga el valor encontrado en la dirección de memoria que está almacenada rb y lo almacena en ra. Es decir [rb] índica el contenido de la dirección a la que apunta rb, rb es un puntero.

En el caso de str, se almacena el contenido de ra en la dirección a la que apunta rb.

[] es un modo de direccionamiento, existen 3 más y se pueden consultar aquí.

¿Cómo se cargan direcciónes de memoria a un registro?

Ya se vió como cargar contenidos de una dirección de memoria a un registro, pero ¿Cómo cargamos una dirección de memoria en sí?

Los labels o etiquetas del ensamblador representan direcciones de memoria, entonces var1 representa la dirección de memoria donde se puso el valor entero 3. Sin embargo, no es posible utilizar esa dirección directamente puesto que se encuentra en una sección diferente a la del código.

Sólo se pueden utilizar direcciones directamente desde la misma sección, esto por la arquitectura RISC del procesador. Así que para facilitar eso, se deja el trabajo al ensamblador de la siguiente forma:

.data
var1: .word 3   @ Contenido de la dirección var1: 3
var2: .word 10  @ Contenido de la dirección var2: 10

.text
.global main

main:
    ldr r0, addr_var1
    ldr r1, addr_var2

addr_var1 : .word var1 @ Contenido de la dirección addr_var1: dirección var1
addr_var2 : .word var2 @ Contenido de la dirección addr_var2: dirección var2

Las etiquetas addr_var1 y addr_var2 serían las direcciones de memoria donde se almacenan las direcciones de memoria de var1 y var2. Cómo addr_var1 y addr_var2 están en la sección .text son accesibles para el código y su contenido se utiliza para cargar a r0 y r1 con las direcciones de memoria var1 y var2. Esto lo hace el ensamblador en la etapa de enlazado, cuando ya sabe las direcciones exactas de memoria para cada etiqueta.

Saltos

Los saltos ocurren cuando una instrucción modifica al contador de programa r15 o pc. Existen saltos condicionales e incondicionales.

Saltos incondicionales

.text
.global main
main:
    @ código...
    b end
    @ código que no se va a ejecutar...
end:
    bx lr

El salto incondicional ocurre con la instrucción b (branch), que salta a la dirección especificada en el argumento.

Saltos condicionales

Los saltos condicionales ocurren cuando se cumplen ciertas condiciones que tienen que ver con el current program status register cpsr, que es el registro donde se tiene el estado del programa, mismo que es cambiado por ciertas instrucciones y cuenta con las siguientes banderas:

No todas las instrucciones tienen modifican al cpsr. Una explicación del complemento a 2 y el acarreo se vió en el curso de microcontroladores en esta nota.

Los saltos en a32 se realizan con las instrucciones bXX que se listan en la siguiente tabla:

Instrucción Descripción (inglés) Condiciones
beq Equal Z = 1
bne Equal Z = 1
GE Greater or equal than, in two’s complement V = N
LT Lower than, in two’s complement V != N
GT Greater than, in two’s complement Z = 0 & N = V
LE Lower or equal than, in two’s complement Z = 1 || (Z = 0 & N = V)
MI Minus/negative N = 1
PL Plus/positive or zero N = 0
VS Overflow set V = 1
VC Overflow clear V = 0
HI Higher C = 1 & Z = 0
LS Lower or same C = 0 || Z = 1
CS/HS Carry set/higher or same C = 1
CC/LO Carry clear/lower C = 0

Las condiciones serán evaluadas independientemente de qué instrucción se llamó después, pero la instrucción más común que se utiliza antes el cmp ra, rb, que hace r1 - r2, sin almacenar el resultado, pero actualizando las banderas del cpsr.

Para implementar estructuras if/else, while, for, etc. con los saltos condicionales, en este artículo dice como.

La información obtenida aquí proviene de los artículos ARM assembler in Raspberry Pi.