Table of Contents

Laborator facultativ: ARM assembly

Pe parcursul acestui semestru am invatat sa programam in limbajul de asamblare specific procesoarelor din familia x86. In acest laborator vom studia notiunile de baza ale programarii in limbajul de asamblare specific procesoarelor ARM.

De ce ARM?

Procesoarele ARM fac parte din categoria RISC. La nivel hardware, aceasta se traduce prin faptul ca necesita mai putine tranzistoare decat arhitecturile CISC, ceea ce le face superioare din punct de vedere al costului de productie, al consumului de energie si al caldurii disipate, sacrificand din puterea de calcul. Aceste caracteristici sunt dezirabile pentru dispozitive de mici dimensiuni, portabile, care se bazeaza pe baterie cum ar fi telefoanele mobile, laptop-uri, tablete, smartwatch-uri, routere, “device”-uri IoT etc. Practic, in ziua de azi interactionati mai des cu dispozitivele ce folosesc un procesor ARM decat cu cele bazate pe x86.

Descriere ARM

Registre

Spre deosebire de arhitectura x86, arhitectura ARM dispune de mai multe registre, numarul fiind variabil in functie de versiune. Cu toate acestea, manualul de referinta ARM specifica 30 de registre de 32 de biti. Primele 16 registre sunt accesibile in “user-mode”, in timp ce restul registrelor sunt disponibile in “privileged-mode”. In acest laborator vom lucra doar cu registrele disponibile in “user-mode”. Aceste 16 registre pot fi impartite in 2 categorii: de uz general si de uz special.

  1. R14/LR : link register. Atunci cand un apel de functie are loc, R14 este modificat astfel incat sa retina adresa urmatoarei instructiuni (adresa de retur)
  2. R15/PC : program counter. Acesta este eip-ul de pe arhitecturile x86, cu diferenta ca aici poate fi in mod direct folosit. Acesta este incrementat intotdeauna cu 4 octeti (instructiunile au dimensiune fixa).
  3. CPSR : current program status register. Desi acest registru este similar cu EFLAGS, sunt 2 biti ce sunt specifici arhitecturii ARM: Endianness si Thumb.

Desi pana la versiunea a 3-a, arhitecturile ARM erau Little-Endian, din acel moment au devenit bi-endian (adica endianness-ul este specificat de catre utilizator); bitul de endianess din CPSR este folosit pentru a specifica acest lucru.

Avand in vedere ca procesoarele ARM sunt folosite pe sisteme embedded unde, de regula, memoria este o resursa foarte limitata, sunt situatii in care dimensiunea programelor este foarte importanta. Astfel, procesoarele arm ofera un subset de instructiuni ce se reprezinta pe 16 biti doar. In felul acesta, daca dorim sa optimizam din punct de vedere al dimensiunii programului, putem sa folosim acest subset. Trecerea de la interpretarea ARM (instructiuni pe 32 de biti) la interpretarea Thumb (16 biti) se poate face in mod dinamic in cadrul unui program, oferind o flexibilitate foarte mare. Pentru ca procesorul sa stie in ce mod trebuie interpretate instructiunile, se foloseste bitul Thumb din CPSR.

In cadrul acestui laborator vom folosi doar date Little-Endian si setul de instructiuni ARM.

Setul de instructiuni

Instructiunile ARM au urmatoarea syntaxa generala: MNEMONIC{S}{condition} {Rd}, Operand1, Operand2, unde

Este important sa remarcam cateva lucruri:

  1. instructiunile ARM pot avea 3 operanzi : ADD R0, R1, R2
  2. majoritatea instructiunilor ARM permit executie conditionala : ADDNE R0, R1, R2 ; aceasta instructiune se executa doar daca “zero flag” este setat pe 0
  3. al doilea operand poate sa fie o instructiune : MOV R0, R1, LSL #1; R0 este operandul 1, iar “R1, LSL #1” este operandul 2 care reprezinta R1 shiftat logic la stanga cu 1 (valorile efective sunt scrise precedate de un #)
  4. o instructiune nu modifica niciodata flag-urile din CPSR daca nu este specificat suffixul s (exceptie facand instructiunile de comparatie cum ar fi cmp / tst al caror unic scop este sa modifica flag-urile)

Cele mai uzuale instructiuni:

Pentru setul complet de instructiuni si modul in care se utilizeaza vizitati acest link

Adresarea memoriei: LDR/STR

O diferenta majora intre procesoarele ARM si cele x86 este modul in care programatorul poate accesa memoria. Daca pe sistemele x86 putem scrie direct intr-o zona de memorie, pe sistemele ARM suntem obligati sa lucram doar cu registre pe care le putem folosi fie pentru a aduce informatii din memorie, fie pentru a le stoca.

Astfel, exista 4 moduri de adresare a memoriei:

LDR R0, [R1]    @ incarca in R0 ce se afla la adresa data de continutul registrului R1
STR R0, [R2]    @ stocheaza la adresa data de continutul registrului R2 valoarea registrului R0
STR R2, [R1, #2]         @ stocheaza valoarea din R2 la adresa data de R1+2
STR R2, [R1, R2]         @ stocheaza valoarea lui R2 la adresa data de R1 + R2
LDR R2, [R1, R2, LSL#2]  @ incarca in registrul R2 valoarea de la adresa R1 + R2<<2
STR R2, [R1, #2]!          @ stocheaza valoarea din R2 la adresa data de R1 + 2; R1 = R1 + 2
STR R2, [R1, R2]!          @ stocheaza valoarea lui R2 la adresa data de R1 + R2; R1 = R1 + R2
LDR R2, [R1, R2, LSL#2]!   @ incarca in registrul R2 valoarea de la adresa R1 + R2<<2; R1 = R1 + R2<<2
LDR R3, [R1], #4         @ incarca in R3 valoarea de la adresa de memorie data de R1; R1 = R1 + 4
STR R3, [R2], R4         @ stocheaza la adresa data de valoarea din R2 valoarea lui R3; R2 = R2 + R4
STR R3, [R2], R1, LSL#2  @ stocheaza la adresa data de valoarea lui R2 valoarea lui R3; R2 = R2 + R1<<2

Urmatoarele tipuri de date sunt disponibile pe sistemele ARM:

Registrele ARM nu dispun de subregistre, asa ca daca dorim sa incarcam/stocam din/in memorie un numar mai mic de 4 octeti, va trebui sa folosim sufixele corespunzatoare:

ldr = Load Word
ldrh = Load unsigned Half Word
ldrsh = Load signed Half Word
ldrb = Load unsigned Byte
ldrsb = Load signed Bytes

str = Store Word
strh = Store unsigned Half Word
strb = Store unsigned Byte

Este necesar sa specificam sufixul s cand incarcam numere cu semn, pentru ca procesorul sa stie sa ne faca in mod corect extensia de semn.

Stiva

Pe ARM, stiva functioneaza in acelasi mod ca si pe procesoarele x86. Diferenta, la nivel de instructiuni, este data de faptul ca instructiunile push si pop accepta un numar variabil de registre:

main:
     mov   r0, #2      /* set up r0 */
     mov   r1, #4      /* set up r1 */
     push  {r0, r1}    /* save r0 and r1 onto the stack */
     mov   r0, #3      /* overwrite r0 */
     mov   r1, #3      /* overwrite r1 */
     pop   {r0, r1}    /* restore r0 and r1 to their initial state */
     bx    lr          /* finish the program */

Apeluri de functii

Conventia de apel apel de functie pe procesoarele ARM este usor diferita de cea de pe procesoarele x86 pe 32 de biti. Astfel:

  1. primii 4 parametri se paseaza in registrele r0-r3; in cazul in care functia are mai mult de 4 parametri, restul se paseaza pe stiva
  2. adresa de retur va fi pusa in registrul lr atunci cand se foloseste instructiunea bl pentru a se apela functia
  3. valoarea de retur va fi pusa in registrele r0-r3, in functie de dimensiune (daca rezultatul este pe 32 de biti, r0 ne ajunge; daca rezultatul este pe 64 de biti ⇒ r0 si r1 etc.); in cazul in care rezultatul nu incape in r0-r3, functia va trebui sa aloce spatiu pentru rezultat si sa intoarca un pointer catre acesta in r0
  4. registrele r4-r11 sunt “callee saved”
  5. registrele r0-r3, r12, r14 sunt “caller saved”
  6. la finalul functiei, pc trebuie sa fie setat la adresa de retur a functiei (lr)

Pentru a intelege mai bine diferentele vom pleca de la exemplul de mai jos:

main:
	push   {r11, lr}    /* Start of the prologue. Saving Frame Pointer and LR onto the stack */
	add    r11, sp, #0  /* Setting up the bottom of the stack frame */
	mov    r0, #1       /* setting up local variables (a=1). This also serves as setting up the first parameter for the max function */
	mov    r1, #2       /* setting up local variables (b=2). This also serves as setting up the second parameter for the max function */
	bl     max          /* Calling/branching to function max */
	sub    sp, r11, #0  /* Start of the epilogue. Readjusting the Stack Pointer */
	pop    {r11, pc}    /* End of the epilogue. Restoring Frame pointer from the stack, jumping to previously saved LR via direct load into PC */

max:
	push   {r11}        /* Start of the prologue. Saving Frame Pointer onto the stack */
	add    r11, sp, #0  /* Setting up the bottom of the stack frame */
	cmp    r0, r1       /* Implementation of if(a<b) */
	movlt  r0, r1       /* if r0 was lower than r1, store r1 into r0 */
	add    sp, r11, #0  /* Start of the epilogue. Readjusting the Stack Pointer */
	pop    {r11}        /* restoring frame pointer */
	bx     lr           /* End of the epilogue. Jumping back to main via LR register */

Programul defineste 2 functii: main si max. Sa analizam intai apelul functiei max:

mov r0, #1
mov r1, #2
bl max

Se seteaza parametrii functiei (p1 = 1; p2 = 2) si apoi se foloseste bl (branch with link) pentru a se sari catre label-ul max; se foloseste instructiunea bl pentru a transmite procesorului ca adresa urmatoarei instructiuni sa fie pusa in registrul lr.

In cadrul functiei max putem distinge cele 3 zone specifice unei functii:

1. Prologul:

push {r11}               // echivalent cu 'push ebp'
add {r11}, esp, #0       // echivalent cu 'mov ebp, esp`

2. Corpul functiei, in care se calculeaza maximul si se pune rezultatul in registrul r0.

cmp    r0, r1
movlt  r0, r1

3. Epilogul:

add sp, r11, #0          // echivalent cu 'mov esp, ebp'
pop r11                  // echivalent cu 'pop ebp'
bx lr                    // jump catre adresa aflata in registrul lr

Daca ne uitam la prologul functiei main, observam ca se salveaza si registrul lr pe stiva. De ce?

Pentru mai multe informatii, accesati acest link.

Exercitii

În cadrul laboratoarelor vom folosi repository-ul de git al materiei IOCLA - https://github.com/systems-cs-pub-ro/iocla. Repository-ul este clonat pe desktop-ul mașinii virtuale. Pentru a îl actualiza, folosiți comanda git pull origin master din interiorul directorului în care se află repository-ul (~/Desktop/iocla). Recomandarea este să îl actualizați cât mai frecvent, înainte să începeți lucrul, pentru a vă asigura că aveți versiunea cea mai recentă. Dacă doriți să descărcați repository-ul în altă locație, folosiți comanda git clone https://github.com/systems-cs-pub-ro/iocla ${target}.Pentru mai multe informații despre folosirea utilitarului git, urmați ghidul de la Git Immersion.

În cadrul acestui laborator vom folosi fișierele din directorul laborator/old/arm.

[0.5p] 1. Instalare toolchain

Pana acum, pentru a putea rula un program scris in limbaj de asamblare eram nevoiti sa parcurgem 2 pasi:

  1. Compilarea sursei in limbaj de asamblare x86 pentru a obtine executabilul pentru platforma x86
  2. Rularea executabilului

Calculatoarele din laborator dispun de un procesor din familia Intel (x86). In situatia actuala nu putem rula/compila programe pentru arhitectura ARM. Pentru a depasi acest impas, suntem nevoiti sa emulam sistemul ARM, iar pentru asta avem nevoie de o masina virtuala. Pentru a simplifica procesul, vom folosi o masina virtuala doar pentru rularea executabilului; pentru compilare vom folosi un compilator din suita Linaro.

Inainte de a executa pasii de mai jos, verificati faptul ca pe masina voastra nu sunt deja instalate pachetele despre care vom discuta in continuare. Pentru asta, navigati in directorul 1-install-toolchain si rulati comanda make run. In cazul in care programul compileaza si ruleaza cu succes pachetele sunt deja instalate pe masina voastra, caz in care nu trebuie sa mai refaceti pasii de mai jos.

Asadar, avem nevoie de:

sudo bash install_toolchain.sh
apt-get install qemu

Dacă întâmpinați probleme la instalarea pachetelor (probabil în sala EG410), folosiți comenzile de mai jos pentru a actualiza repository-urile

sudo sed -i -e 's/hu.archive.ubuntu.com\|security.ubuntu.com/old-releases.ubuntu.com/g' /etc/apt/sources.list
sudo sed -i -e 's/archive.ubuntu.com\|security.ubuntu.com/old-releases.ubuntu.com/g' /etc/apt/sources.list
sudo apt update

Pentru a va asigura ca ati instalat in modul corespunzator toolchain-ul, rulati comanda:

make run

Dupa rularea programului ar trebui sa aveti afisat mesajul “Hello, World!”.

Dacă întâmpinați probleme la rularea comenzilor de compilare/asamblare, probabil nu a fost actualizată variabila de mediu PATH. Faceți manual actualizarea folosind

export PATH=/opt/gcc-armv8l/bin:$PATH

Inspectati fisierul Makefile. Identificati principalii pasi descrisi mai sus in obtinerea executabilului.

[3p] 2. Suma patratelor

Dacă aveți probleme la rularea exercițiilor 2+ de tipul “Illegal instruction”, este din cauza versiunii de QEMU veche de pe stații. Pentru a rezolva temporar acest lucru, executați următoarele comenzi în bash:

sudo wget -O "/usr/bin/qemu-arm" "http://elf.cs.pub.ro/asm/res/laboratoare/qemu-arm-static"
sudo chmod +x "/usr/bin/qemu-arm"

Verificați că aveți ultima versiune de QEMU:

$ qemu-arm --version
qemu-arm version 3.1.50 (v3.1.0-569-ge59dbbac03-dirty)
Copyright (c) 2003-2018 Fabrice Bellard and the QEMU Project developers

Pornind de la sursa din 2-mul-sum/mul.asm, implementati suma patratelor primelor N numere naturale. Urmariti comentariile din sursa. Pentru compilare si rulare, folositi fisierul Makefile.

[1p] 3. Adresarea memoriei

Navigati in directorul 3-mem-addr. Inspectati sursa mem.asm. Ce ar trebui sa faca programul? Ce afiseaza? De ce?

  1. Modificati sursa astfel incat rezultatul sa fie cel corect.
  2. Implementati afisarea tuturor elementelor din vectorul nums.
  3. Este numarul negativ afisat corect? De ce?

Nu folosiți registrele r2 și r3 pentru calculele voastre. Sunt modificate de funcția printf.

[1p] 4. Flags

Navigati in directorul 4-flags. Inspectati sursa flags.asm. Ce ar trebui sa faca programul? Ce face? De ce?

Pentru indicii, cititi urmatorul articol.

[5p] 5. Strings

Navigati in directorul 5-strings. In cadrul acestui exercitiu vom implementa o serie de functii utile in lucrul cu siruri:

  1. [1p] Implementati functia strlen. Urmariti comentariile marcate cu TODO 1.
  2. [2p] Implementati functia starts_with. Semnatura functiei este bool starts_with(char* s1, char* s2). Prin conventie, sirul mai mic este transmis al doilea. Functia va intoarce true in situatia in care sirul s1, incepe cu sirul s2, false altfel.
  3. [2p] Implementati functia substr. Semnatura functiei este void substr(char* s1, char* s2). Prin conventie, sirul mai mic este transmis al doilea. Functia va printa pozitiile tuturor aparitiilor sirului s2 in sirul s1.

Resurse utile