Programma in linguaggio assembly Arm6 su un Raspberry Pi
Il linguaggio assembly offre approfondimenti speciali su come funzionano le macchine e su come possono essere programmate.
Il sito web di Arm pubblicizza l'architettura sottostante del processore come "la chiave di volta del più grande ecosistema informatico del mondo", il che è plausibile dato il numero di dispositivi portatili e incorporati con processori Arm. I processori Arm sono prevalenti nell'Internet delle cose (IoT), ma vengono utilizzati anche in macchine desktop, server e persino computer ad alte prestazioni, come Fugaku HPC. Ma perché guardare le macchine Arm attraverso la lente del linguaggio assembly?
Il linguaggio assembly è il linguaggio simbolico immediatamente sopra il codice macchina e offre quindi spunti speciali su come funzionano le macchine e su come possono essere programmate in modo efficiente. In questo articolo, spero di illustrare questo punto con l'architettura Arm6 utilizzando un computer mini-desktop Raspberry Pi 4 con Debian in esecuzione.
La famiglia di processori Arm6 supporta due set di istruzioni:
- Il set Arm, con istruzioni a 32 bit ovunque.
- Il set Thumb, con un mix di istruzioni a 16 e 32 bit.
Gli esempi in questo articolo utilizzano il set di istruzioni Arm. Il codice di assemblaggio del braccio è in minuscolo e, al contrario, il codice di pseudo-assemblaggio è in maiuscolo.
Macchine con magazzino di carico
La distinzione RISC/CISC si vede spesso quando si confrontano la famiglia Arm e la famiglia di processori Intel x86, entrambi prodotti commerciali concorrenti sul mercato. I termini RISC (computer a set di istruzioni ridotto) e CISC (computer a set di istruzioni complesse) risalgono alla metà degli anni '80. Anche allora i termini erano fuorvianti, in quanto sia i processori RISC (ad esempio MIPS) che CISC (ad esempio Intel) avevano circa 300 istruzioni nei loro set di istruzioni; oggi i conteggi delle istruzioni core nelle macchine Arm e Intel sono vicini, sebbene entrambi i tipi di macchine abbiano esteso i propri set di istruzioni. Una distinzione più netta tra macchine Arm e Intel si basa su una caratteristica architetturale diversa dal conteggio delle istruzioni.
Un'architettura del set di istruzioni (ISA) è un modello astratto di una macchina informatica. I processori Arm e Intel implementano ISA diversi: i processori Arm implementano un ISA con archivio di carico, mentre le loro controparti Intel implementano un ISA con memoria di registro. La differenza tra gli ISA può essere descritta come:
In una macchina load-store, solo due istruzioni spostano i dati tra una CPU e il sottosistema di memoria:
- Un'istruzione di caricamento copia i bit dalla memoria in un registro della CPU.
- Un'istruzione di memorizzazione copia i bit da un registro della CPU nella memoria.
Altre istruzioni, in particolare quelle per le operazioni logico-aritmetiche, utilizzano solo i registri della CPU come operandi di origine e destinazione. Ad esempio, ecco un codice pseudo-assembly su una macchina load-store per aggiungere due numeri originariamente in memoria, memorizzando la loro somma nuovamente in memoria (i commenti iniziano con ##
):
## R0 is a CPU register, RAM[32] is a memory location
LOAD R0, RAM[32] ## R0 = RAM[32]
LOAD R1, RAM[64] ## R1 = RAM[64]
ADD R2, R0, R1 ## R2 = R0 + R1
STORE R2, RAM[32] ## RAM[32] = R2
L'attività richiede quattro istruzioni: due LOAD, una ADD e una STORE.
Al contrario, una macchina con memoria a registri consente che gli operandi delle istruzioni aritmetico-logiche siano registri o posizioni di memoria, solitamente in qualsiasi combinazione. Ad esempio, ecco il codice pseudo-assembly su una macchina con memoria di registro per aggiungere due numeri in memoria:
ADD RAM[32], RAM[32], RAM[64] ## RAM[32] += RAM[64]
L'operazione può essere eseguita con una singola istruzione, anche se i bit da aggiungere devono ancora essere recuperati dalla memoria alla CPU e la somma deve quindi essere copiata nuovamente nella posizione di memoria RAM[32].
Qualsiasi ISA comporta dei compromessi. Come illustra l'esempio sopra, un ISA con archivio di carico ha ciò che gli architetti chiamano "bassa densità di istruzioni": per eseguire un'attività potrebbero essere necessarie relativamente molte istruzioni. Una macchina con memoria di registro ha un'elevata densità di istruzioni, il che è un vantaggio. Ci sono anche vantaggi nell'ISA del magazzino di carico.
La progettazione del magazzino di carico è uno sforzo per semplificare un'architettura. Ad esempio, consideriamo il caso in cui una macchina con memoria di registro ha un'istruzione con operandi misti:
COPY R2, RAM[64] ## R2 = RAM[64]
ADD RAM[32], RAM[32], R2 ## RAM[32] = RAM[32] + R2
L'esecuzione dell'istruzione ADD è complicata in quanto i tempi di accesso per i numeri da aggiungere differiscono, forse in modo significativo se l'operando di memoria si trova solo nella memoria principale anziché anche in una sua cache. Le macchine load-store evitano il problema dei tempi di accesso misti nelle operazioni logico-aritmetiche: tutti gli operandi, come i registri, hanno lo stesso tempo di accesso.
Inoltre, le architetture di caricamento del carico enfatizzano istruzioni di dimensione fissa (ad esempio, 32 bit ciascuna), formati limitati (ad esempio, uno, due o tre campi per istruzione) e relativamente poche modalità di indirizzamento. Questi vincoli di progettazione implicano che l'unità di controllo del processore (CU) e l'unità aritmetico-logica (ALU) possono essere semplificate: meno transistor e fili, meno energia richiesta e meno calore generato, e così via. Le macchine load-store sono progettate per essere architettonicamente sparse.
Il mio scopo non è quello di entrare nel dibattito sulle macchine load-store rispetto a quelle con memoria di registro, ma piuttosto creare un esempio di codice nell'architettura Arm6 load-store. Questo primo sguardo a load-store aiuta a spiegare il codice che segue. I due programmi (uno in C, uno in assembly Arm6) sono disponibili sul mio sito.
Il programma hstone in C
Tra i miei esempi di codici brevi preferiti c'è la funzione hailstone, che accetta un numero intero positivo come argomento. (Ho utilizzato questo esempio in un precedente articolo su WebAssembly.) Questa funzione è sufficientemente ricca da evidenziare importanti dettagli del linguaggio assembly. La funzione è definita come:
3N+1 if N is odd
hstone(N) =
N/2 if N is even
Ad esempio, hstone(12) vale 6, mentre hstone(11) vale 34. Se N è dispari, allora 3N+1 è pari; ma se N è pari, allora N/2 potrebbe essere pari (ad esempio, 4/2=2) o dispari (ad esempio, 6/2=3).
La funzione hstone può essere utilizzata in modo iterativo passando il valore restituito come argomento successivo. Il risultato è una sequenza di chicchi di grandine, come questa, che inizia con 24 come argomento originale, il valore restituito 12 come argomento successivo e così via:
24,12,6,3,10,5,16,8,4,2,1,4,2,1,...
Sono necessari 10 passi perché la sequenza converga a 1, a quel punto la sequenza di 4,2,1 si ripete indefinitamente: (3x1)+1 fa 4, che viene dimezzato per ottenere 2, che viene dimezzato per ottenere 1, e così SU. Per una spiegazione del motivo per cui "chicchi di grandine" sembra un nome appropriato per tali sequenze, vedere "Misteri matematici: sequenze di chicchi di grandine".
Nota che le potenze di 2 convergono rapidamente: 2N richiede solo N divisioni per 2 per raggiungere 1. Ad esempio, 32=25 ha una lunghezza di convergenza di 5 e 512=29 ha una lunghezza di convergenza pari a 9. Se la funzione hailstone restituisce una potenza di 2, la sequenza converge a 1. Di interesse qui è la lunghezza della sequenza dall'argomento iniziale alla prima occorrenza di 1.
La congettura di Collatz è che una sequenza di chicchi di grandine converge a 1 qualunque sia l'argomento iniziale N > 0. Non è stato trovato né un controesempio né una dimostrazione. La congettura, per quanto semplice da illustrare con un programma, rimane un problema profondamente impegnativo nella teoria dei numeri.
Di seguito è riportato il codice sorgente C per il programma hstoneC, che calcola la lunghezza della sequenza dei chicchi di grandine il cui valore iniziale viene fornito come input dell'utente. La versione in linguaggio assembly del programma (hstoneS) viene fornita dopo una panoramica delle nozioni di base di Arm6. Per chiarezza, i due programmi sono strutturalmente simili.
Ecco il codice sorgente C:
#include <stdio.h>
/* Compute steps from n to 1.
-- update an odd n to (3 * n) + 1
-- update an even n to (n / 2) */
unsigned hstone(unsigned n) {
unsigned len = 0; /* counter */
while (1) {
if (1 == n) break;
n = (0 == (n & 1)) ? n / 2 : (3 * n) + 1;
len++;
}
return len;
}
int main() {
printf("Integer > 0: ");
unsigned num;
scanf("%u", &num);
printf("Steps from %u to 1: %u\n", num, hstone(num));
return 0;
}
Quando il programma viene eseguito con un input pari a 9, l'output è:
Steps from 9 to 1: 19
Il programma hstoneC ha una struttura semplice. La funzione main
richiede all'utente un input N (un numero intero > 0) e quindi chiama la funzione hstone
con questo input come argomento. La funzione hstone
si ripete finché la sequenza da N raggiunge il primo 1, restituendo il numero di passaggi richiesti.
L'istruzione più complicata del programma riguarda l'operatore condizionale di C, che viene utilizzato per aggiornare N:
n = (0 == (n & 1)) ? n / 2 : (3 * n) + 1;
Questa è una forma concisa di un costrutto se-allora. Il test (0 == (n & 1))
controlla se la variabile C n
(che rappresenta N) è pari o dispari a seconda che l'AND bit a bit di N e 1 è zero: un valore intero è pari solo nel caso in cui il suo bit meno significativo (più a destra) sia zero. Se N è pari, N/2 diventa il nuovo valore; in caso contrario, 3N+1 diventa il nuovo valore. Allo stesso modo, la versione in linguaggio assembly del programma (hstoneS) evita un esplicito costrutto if-else nell'aggiornare la sua implementazione di N.
La mia macchina mini-desktop Arm6 include il set di strumenti GNU C, che può generare il codice corrispondente in linguaggio assembly. Con %
come prompt della riga di comando, il comando è:
% gcc -S hstoneC.c ## -S flag produces and saves assembly code
Questo produce il file hstoneC.s, che è composto da circa 120 righe di codice sorgente in linguaggio assembly, inclusa un'istruzione nop
("nessuna operazione"). L'assembly generato dal compilatore tende ad essere difficile da leggere e potrebbe presentare inefficienze come nop
. Una versione realizzata manualmente, come hstoneS.s
(sotto), può essere più semplice da seguire e anche significativamente più breve (ad esempio, hstoneS.s
ha circa 50 righe di codice ).
Nozioni di base sul linguaggio assembly
Arm6, come la maggior parte delle architetture moderne, è indirizzabile tramite byte: un indirizzo di memoria è di un byte, anche se l'elemento indirizzato (ad esempio, un'istruzione a 32 bit) è costituito da più byte. Le istruzioni vengono indirizzate in modo little-endian: l'indirizzo è del byte di ordine inferiore. Gli elementi dati vengono indirizzati in modo little-endian per impostazione predefinita, ma questo può essere modificato in big-endian in modo che l'indirizzo di un elemento dati multibyte punti al byte di ordine superiore. Per tradizione, il byte di ordine inferiore è rappresentato come quello più a destra e il byte di ordine superiore come quello più a sinistra:
high-order low-order
/ /
+----+----+----+----+
| b1 | b2 | b3 | b4 | ## 4 bytes = 32 bits
+----+----+----+----+
Gli indirizzi hanno una dimensione di 32 bit e gli elementi di dati sono disponibili in tre dimensioni standard:
- Un byte ha una dimensione di 8 bit.
- Una mezza parola ha una dimensione di 16 bit.
- Una parola ha una dimensione di 32 bit.
Sono supportati aggregati di byte, mezze parole e parole (ad esempio, array e strutture). I registri della CPU hanno una dimensione di 32 bit.
I linguaggi assembly, in generale, hanno tre caratteristiche chiave con una sintassi vicina e, a volte, identica:
Le direttive sia nell'assemblaggio Arm6 che in quello Intel iniziano con un punto. Ecco due esempi di Arm6, che funzionano anche su Intel:
.data .align 4
La prima direttiva indica che la sezione seguente contiene elementi di dati anziché codice. La direttiva
.align 4
specifica che gli elementi di dati dovrebbero essere disposti, in memoria, su limiti di 4 byte, cosa comune nelle architetture moderne. Come suggerisce il nome, una direttiva dà indicazioni al traduttore (l'"assemblatore") mentre svolge il suo lavoro.
Al contrario, questa direttiva indica un codice piuttosto che una sezione di dati:.text
Il termine "testo" è tradizionale e il suo significato, in questo contesto, è "sola lettura": durante l'esecuzione del programma, il codice è di sola lettura, mentre i dati possono essere letti e scritti.
Le etichette sia nel recente assemblaggio Arm che in quello Intel terminano con i due punti. Un'etichetta è un indirizzo di memoria per elementi di dati (ad esempio, variabili) o blocchi di codice (ad esempio, funzioni). I linguaggi assembly, in generale, fanno molto affidamento sugli indirizzi, il che significa che la manipolazione dei puntatori, in particolare il loro dereferenziamento per ottenere i valori a cui puntano, è in primo piano nella programmazione in linguaggio assembly. Ecco due etichette nel programma hstoneS:
collatz: /* label */ mov r0, #0 /* instruction */ loop_start: /* label */ ...
La prima etichetta segna l'inizio della funzione
collatz
, la cui prima istruzione copia il valore zero (#0
) nel registror0
. (Il codice operativomov
per "spostare" ricorre in tutti i linguaggi assembly ma in realtà significa "copia".) La seconda etichetta,loop_start:
, è l'indirizzo del ciclo che calcola il durata della sequenza dei chicchi di grandine. Il registror0
funge da contatore di sequenza.Le istruzioni, che gli editor sensibili all'assembly di solito indentano insieme alle direttive, specificano le operazioni da eseguire (ad esempio,
mov
) insieme agli operandi (in questo caso,r0
e #0). Ci sono istruzioni senza operandi e altre con diversi operandi.
L'istruzione mov
sopra non viola il principio del caricamento in memoria relativo all'accesso alla memoria. In generale, un'istruzione di caricamento (ldr
in Arm6) carica il contenuto della memoria in un registro. Al contrario, un'istruzione mov
può essere utilizzata per copiare un "valore immediato", come una costante intera, in un registro:
mov r0, #0 /* copy zero into r0 */
È inoltre possibile utilizzare un'istruzione mov
per copiare il contenuto di un registro in un altro:
mov r1, r0 /* r1 = r0 */
Il codice operativo di caricamento ldr
sarebbe inappropriato in entrambi i casi perché non è in gioco una posizione di memoria. Esempi di istruzioni Arm6 ldr
("carica registro") e str
("memorizza registro") sono in arrivo.
L'architettura Arm6 ha 16 registri CPU primari (ciascuno di 32 bit), un mix di usi generali e scopi speciali. La Tabella 1 fornisce un riepilogo, elencando funzionalità speciali e usi oltre lo Scratchpad:
Tabella 1. Registri della CPU primaria
- r0
Primo argomento della funzione di libreria, retval
- r1
2° argomento alla funzione di libreria
- r2
3° argomento alla funzione di libreria
- r3
4° argomento alla funzione di libreria
- r4
chiamato-salvato
- r5
chiamato-salvato
- r6
chiamato-salvato
- r7
chiamato salvato, chiamate di sistema
- r8
chiamato-salvato
- r9
chiamato-salvato
- r10
chiamato-salvato
- r11
puntatore al frame salvato dal chiamato
- r12
intra-procedura
- r13
puntatore dello stack
- r14
registro dei collegamenti
- r15
contatore di programma
In generale, i registri della CPU fungono da backup per lo stack, l'area della memoria principale che fornisce spazio di archiviazione riutilizzabile per gli argomenti passati alle funzioni e le variabili locali utilizzate nelle funzioni e in altri blocchi di codice (ad esempio, il corpo di un ciclo). Dato che i registri della CPU risiedono sullo stesso chip della CPU, il tempo di accesso è veloce. L'accesso allo stack è notevolmente più lento e i dettagli dipendono dalle particolarità di un sistema. I registri però sono scarsi. Nel caso di Arm6, ci sono solo 16 registri primari della CPU, e alcuni di questi hanno usi speciali oltre allo scratchpad.
I primi quattro registri, da r0
a r3
, sono usati per gli appunti ma anche per passare argomenti alle funzioni della libreria. Ad esempio, chiamare una funzione di libreria come printf
(utilizzata sia nei programmi hstoneC che hstoneS) richiede che gli argomenti attesi siano nei registri attesi. La funzione printf
accetta almeno un argomento (una stringa di formato) ma solitamente ne accetta anche altri (i valori da formattare). L'indirizzo della stringa di formato deve essere nel registro r0
affinché la chiamata abbia successo. Una funzione definita dal programmatore può ovviamente implementare la propria strategia di registro, ma l'utilizzo dei primi quattro registri per gli argomenti della funzione è comune nella programmazione Arm6.
Anche il registro r0
ha usi speciali. Ad esempio, tipicamente contiene il valore restituito da una funzione, come nella funzione collatz
del programma hstoneS. Se un programma chiama la funzione syscall
, che viene utilizzata per richiamare funzioni di sistema come read
e write
, registra r0
contiene l'identificatore intero della funzione di sistema da chiamare (ad esempio, la funzione write
ha 4 come identificatore). A questo proposito, il registro r0
ha uno scopo simile al registro r7
, che contiene tale identificatore quando la funzione svc
("chiamata supervisore") è usato al posto di syscall
.
I registri da r4
a r11
sono di uso generale e "chiamati salvati" (ovvero "non volatili" o "conservati dalla chiamata"). Consideriamo il caso in cui la funzione F1 chiama la funzione F2 utilizzando i registri per passare argomenti a F2. I registri da r0
a r3
sono "chiamante salvato" (noto anche come "volatile" o "call-clobbered") in quanto, ad esempio, la funzione chiamata F2 potrebbe chiamarne un'altra funzione F3 utilizzando gli stessi registri di F1, ma con nuovi valori al suo interno:
27 13 191 437
\ \ \ \
r0, r1 r0, r1
F1-------->F2-------->F3
Dopo che F1 chiama F2, il contenuto dei registri r0
e r1
viene modificato per la chiamata di F2 a F3. Di conseguenza, F1 non deve presumere che i suoi valori in r0
e r1
(27 e 13, rispettivamente) siano stati preservati; invece, questi valori sono stati sovrascritti, cancellati dai nuovi valori 191 e 437. Poiché i primi quattro registri non sono "chiamati salvati", la funzione chiamata F2 non è responsabile della conservazione e del successivo ripristino dei valori nei registri impostati da F1.
I registri salvati dal destinatario della chiamata attribuiscono la responsabilità a una funzione chiamata. Ad esempio, se F1 utilizzasse i registri salvati dal chiamato r4
e r5
nella chiamata a F2, allora F2 sarebbe responsabile del salvataggio del contenuto di questi registri (tipicamente nello stack ) e poi ripristinando i valori prima di ritornare in F1. Il codice di F2 potrebbe quindi iniziare e terminare come segue:
push {r4, r5} /* save r4 and r5 values on the stack */
... /* reuse r4 and r5 for some other task */
pop {r4, r5} /* restore r4 and r5 values */
L'operazione push
salva i valori in r4
e r5
nello stack. L'operazione pop
corrispondente recupera quindi questi valori dallo stack e li inserisce in r4
e r5
.
Altri registri nella Tabella 1 possono essere utilizzati come appunti, ma alcuni hanno anche un uso speciale. Come notato in precedenza, il registro r7
può essere utilizzato per effettuare chiamate di sistema (ad esempio, alla funzione write
), come mostrato in dettaglio in un esempio successivo. In un'istruzione svc
, l'identificatore intero per una particolare funzione di sistema deve essere nel registro r7
(ad esempio, 4 per identificare la funzione write
).
Il registro r11
ha l'alias fp
per "frame pointer", che punta all'inizio del frame di chiamata corrente. Quando una funzione ne chiama un'altra, la funzione chiamata ottiene la propria area dello stack (un call frame) da utilizzare come scratchpad. Un puntatore al frame, a differenza del puntatore allo stack descritto di seguito, in genere rimane fisso finché non ritorna una funzione chiamata.
Il registro r12
, noto anche come ip
("intra-procedure"), viene utilizzato dal linker dinamico. Tra una chiamata e l'altra alle funzioni di libreria collegate dinamicamente, tuttavia, un programma può utilizzare questo registro come blocco note.
Il registro r13
, che ha sp
("stack pointer") come alias, punta alla cima dello stack e viene aggiornato automaticamente tramite push
e le operazioni pop
. Il puntatore dello stack può essere utilizzato anche come indirizzo di base con un offset; ad esempio, sp - #4
punta 4 byte sotto dove punta sp
. Lo stack Arm6, come la sua controparte Intel, cresce dagli indirizzi alti a quelli bassi. (Alcuni autori di conseguenza descrivono il puntatore dello stack come puntato verso il basso anziché verso l'alto dello stack.)
Il registro r14
, con lr
come alias, funge da "registro di collegamento" che contiene un indirizzo di ritorno per una funzione. Tuttavia, una funzione chiamata può chiamarne un'altra con un'istruzione bl
("ramo con collegamento") o bx
("ramo con scambio"), intasando così il contenuto della funzione < codice>lr registro. Ad esempio, nel programma hstoneS, la funzione main
ne chiama altri quattro. Di conseguenza, la funzione main
salva il lr
del suo chiamante nello stack e successivamente ripristina questo valore. Il modello si verifica regolarmente nel linguaggio assembly Arm6:
push {lr} /* save caller's lr */
... /* call some functions */
pop {lr} /* restore caller's lr */
Il registro r15
è anche il pc
("contatore del programma"). Nella maggior parte delle architetture, il contatore del programma punta all'istruzione "successiva" da eseguire. Per ragioni storiche, pc
Arm6 punta a due istruzioni oltre quella attuale. Il pc
può essere manipolato direttamente (ad esempio, per chiamare una funzione), ma l'approccio consigliato è utilizzare istruzioni come bl
che manipolano il registro del collegamento.
Arm6 ha il consueto assortimento di istruzioni per l'aritmetica (ad esempio, addizione, sottrazione, moltiplicazione, divisione), logica (ad esempio, confronto, spostamento), controllo (ad esempio, ramo, uscita) e input/output (ad esempio, leggi, scrivi) . I risultati dei confronti e di altre operazioni vengono salvati nel registro speciale cpsr
("registro dello stato corrente del processore"). Ad esempio, questo registro registra se un'addizione ha causato un overflow o se due valori interi confrontati sono uguali.
Vale la pena ripetere che Arm6 ha esattamente due istruzioni di base per lo spostamento dei dati: ldr
per caricare il contenuto della memoria in un registro e str
per archiviare il contenuto del registro in memoria. Arm6 include variazioni delle istruzioni di base ldr
e str
, ma il modello di caricamento e memorizzazione dello spostamento dei dati tra i registri e la memoria rimane lo stesso.
Un esempio di codice dà vita a questi dettagli architettonici. La sezione successiva introduce il programma hailstone in linguaggio assembly.
Il programma hstone nell'assembly Arm6
La panoramica precedente dell'assembly Arm6 è sufficiente per introdurre l'esempio di codice completo per hstoneS. Per chiarezza, il programma in linguaggio assembly hstoneS ha essenzialmente la stessa struttura del programma C hstoneC: due funzioni, main
e collatz
, e per lo più esecuzione di codice lineare in ciascuna funzione. Il comportamento dei due programmi è lo stesso.
Ecco il codice sorgente per hstoneS:
.data /* data versus code */
.balign 4 /* alignment on 4-byte boundaries */
/* labels (addresses) for user input, formatters, etc. */
num: .int 0 /* 4-byte integer */
steps: .int 0 /* another for the result */
prompt: .asciz "Integer > 0: " /* zero-terminated ASCII string */
format: .asciz "%u" /* %u for "unsigned" */
report: .asciz "From %u to 1 takes %u steps.\n"
.text /* code: 'text' in the sense of 'read only' */
.global main /* program's entry point must be global */
.extern printf /* library function */
.extern scanf /* ditto */
collatz: /** collatz function **/
mov r0, #0 /* r0 is the step counter */
loop_start: /** collatz loop **/
cmp r1, #1 /* are we done? (num == 1?) */
beq collatz_end /* if so, return to main */
and r2, r1, #1 /* odd-even test for r1 (num) */
cmp r2, #0 /* even? */
moveq r1, r1, LSR #1 /* even: divide by 2 via a 1-bit right shift */
addne r1, r1, r1, LSL #1 /* odd: multiply by adding and 1-bit left shift */
addne r1, #1 /* odd: add the 1 for (3 * num) + 1 */
add r0, r0, #1 /* increment counter by 1 */
b loop_start /* loop again */
collatz_end:
bx lr /* return to caller (main) */
main:
push {lr} /* save link register to stack */
/* prompt for and read user input */
ldr r0, =prompt /* format string's address into r0 */
bl printf /* call printf, with r0 as only argument */
ldr r0, =format /* format string for scanf */
ldr r1, =num /* address of num into r1 */
bl scanf /* call scanf */
ldr r1, =num /* address of num into r1 */
ldr r1, [r1] /* value at the address into r1 */
bl collatz /* call collatz with r1 as the argument */
/* demo a store */
ldr r3, =steps /* load memory address into r3 */
str r0, [r3] /* store hailstone steps at mem[r3] */
/* setup report */
mov r2, r0 /* r0 holds hailstone steps: copy into r2 */
ldr r1, =num /* get user's input again */
ldr r1, [r1] /* dereference address to get value */
ldr r0, =report /* format string for report into r0 */
bl printf /* print report */
pop {lr} /* return to caller */
L'assembly Arm6 supporta la documentazione in stile C (la sintassi slash-star e star-slash usata qui) o commenti di una riga introdotti dal segno @. Il programma hstoneS, come la sua controparte C, ha due funzioni:
Il punto di ingresso del programma è la funzione
main
, identificata dall'etichettamain:
; questa etichetta indica dove si trova la prima istruzione della funzione. Nell'assembly Arm6, il punto di ingresso deve essere dichiarato globale:.global main
In C, il nome di una funzione è l'indirizzo del blocco di codice che costituisce il corpo della funzione, e una funzione C è
extern
(globale) per impostazione predefinita. Non sorprende quanto il C e il linguaggio assembly si somiglino; infatti, C è un linguaggio assembly portabile.La funzione
collatz
prevede un argomento, che è implementato dal registror1
per contenere l'input dell'utente di un valore intero senza segno (ad esempio, 9). Questa funzione aggiorna il registror1
finché non diventa uguale a 1, tenendo conto dei passaggi coinvolti con il registror0
, che quindi funge da valore di ritorno della funzione.
Un primo e interessante segmento di codice in main
riguarda la chiamata alla funzione di libreria scanf
, una funzione di input di alto livello che scansiona un valore dallo standard input (per impostazione predefinita, il tastiera) e converte questo valore nel tipo di dati desiderato, in questo caso un numero intero senza segno a 4 byte. Ecco il segmento di codice completo:
ldr r0, =format /* address of format string into r0 */
ldr r1, =num /* address of num into r1 */
bl scanf /* call scanf (bl = branch with link) */
ldr r1, =num /* address of num into r1 */
ldr r1, [r1] /* value at the address into r1 */
bl collatz /* call collatz with r1 as the argument */
Sono in gioco due etichette (indirizzi): format
e num
, entrambe definite nella sezione .data
nella parte superiore del programma :
num: .int 0
format: .asciz "%u"
L'etichetta num:
è l'indirizzo di memoria di un valore intero di 4 byte, inizializzato a zero; l'etichetta format:
punta a una stringa "%u"
con terminazione null (la "z" in "asciz" per zero), che specifica che l'input scansionato deve essere convertito in un numero intero senza segno. Di conseguenza, le prime due istruzioni nel segmento di codice caricano l'indirizzo della stringa di formato (=format
) nel registro r0
e l'indirizzo per il numero scansionato (= num
) nel registro r1
. Tieni presente che ogni etichetta ora inizia con un segno di uguale ("assegna indirizzo") e che i due punti vengono eliminati alla fine di ogni etichetta. La funzione di libreria scanf
può accettare un numero arbitrario di argomenti, ma il primo (che scanf
si aspetta nel registro r0
) dovrebbe essere l'"indirizzo" di una stringa di formato. In questo esempio, il secondo argomento di scanf
è l'"indirizzo" in cui salvare l'intero scansionato.
Le ultime tre istruzioni nel segmento di codice evidenziano importanti dettagli di assemblaggio. La prima istruzione ldr
carica l'indirizzo del numero intero basato sulla memoria (=num
) nel registro r1
. Tuttavia, la funzione collatz
prevede il valore memorizzato in questo indirizzo, non l'indirizzo stesso; quindi, l'indirizzo viene dereferenziato per ottenere il valore:
ldr r1, =num /* load address into r1 */
ldr r1, [r1] /* dereference to get value */
Le parentesi quadre specificano la memoria e r1
contiene un indirizzo di memoria. L'espressione [r1]
restituisce quindi il valore archiviato in memoria all'indirizzo r1
. L'esempio sottolinea che i registri possono contenere indirizzi e valori memorizzati negli indirizzi: il registro r1
contiene prima un indirizzo e poi il valore memorizzato in questo indirizzo.
Quando la funzione collatz
ritorna a main
, questa funzione esegue prima un'operazione di memorizzazione:
ldr r3, =steps /* steps is a memory address */
str r0, [r3] /* store r0 value at mem[r3] */
L'etichetta steps:
proviene dalla sezione .data
e il registro r0
contiene i passaggi calcolati nella funzione collatz
. L'istruzione str
salva quindi in memoria la lunghezza della sequenza dei chicchi di grandine. In un'istruzione ldr
, il primo operando (un registro) è il destinazione per il carico; ma in un'operazione str
, il primo operando (anche un registro) è la fonte per il negozio. In entrambi i casi, il secondo operando è una posizione di memoria.
Alcuni lavori aggiuntivi in main
impostano il rapporto finale:
mov r2, r0 /* save count in r2 */
ldr r1, =num /* recover user input */
ldr r1, [r1] /* dereference r1 */
ldr r0, =report /* r0 points to format string */
bl printf /* print report */
Nella funzione collatz
, il registro r0
tiene traccia di quanti passaggi sono necessari per raggiungere 1 dall'input dell'utente, ma la funzione di libreria printf
si aspetta il suo primo argomento (l'indirizzo di una stringa di formato) da memorizzare nel registro r0
. Il valore restituito nel registro r0
viene quindi copiato nel registro r2
con l'istruzione mov
. L'indirizzo della stringa di formato per printf
viene quindi memorizzato nel registro r0
.
L'argomento della funzione collatz
è l'input scansionato, che è memorizzato nel registro r1
; ma questo registro viene aggiornato nel ciclo collatz
a meno che il valore non sia 1 all'inizio. Di conseguenza, l'indirizzo num:
viene nuovamente copiato in r1
e quindi dereferenziato per ottenere l'input originale dell'utente. Questo valore diventa il secondo argomento di printf
, il valore iniziale della sequenza dei chicchi di grandine. Con questa impostazione, main
chiama printf
con l'istruzione bl
("ramo con collegamento").
All'inizio del ciclo collatz
, il programma controlla se la sequenza ha raggiunto un 1:
cmp r1, #1
beq collatz_end
Se il registro r1
ha 1 come valore, c'è un ramo (beq
per "ramo se uguale") alla fine della funzione collatz
, che significa un ritorno al chiamante main
con il registro r0
come valore di ritorno: il numero di passaggi nella sequenza dei chicchi di grandine.
La funzione collatz
introduce nuove funzionalità e codici operativi, che illustrano quanto possa essere efficiente il codice assembly. Il codice assembly, come il codice C, controlla la parità di N, con il registro r1
come N:
and r2, r1, #1 /* r2 = r1 & 1 */
cmp r2, #0 /* is the result even? */
Il risultato dell'operazione AND bit per bit sul registro r1
e 1 è memorizzato nel registro r2
. Se il bit meno significativo (più a destra) del registro r2
è 1, allora N (registro r1
) è dispari, altrimenti è pari. Il risultato di questo confronto (salvato nel registro speciale cpsr
) viene utilizzato automaticamente nelle istruzioni successive come moveq
("muovi se uguale") e addne
("aggiungi se diverso da uguale").
Il codice assembly, come il codice C, ora evita un costrutto if-else esplicito. Questo segmento di codice ha lo stesso effetto di un test if-else, ma il codice è più efficiente in quanto non è coinvolta alcuna ramificazione: il codice viene eseguito in modo lineare perché i test condizionali sono incorporati nei codici operativi dell'istruzione:
moveq r1, r1, LSR #1 /* right-shift 1 bit if even */
addne r1, r1, r1, LSL #1 /* left-shift 1 bit and add otherwise */
addne r1, #1 /* add 1 for the + 1 in N = 3N + 1 */
L'istruzione moveq
(eq
per "if equal") controlla il risultato del precedente test cmp
, che determina se il valore corrente del registro r1
(N) è pari o dispari. Se il valore nel registro r1
è pari, questo valore deve essere aggiornato alla sua metà, operazione eseguita mediante uno spostamento a destra di 1 bit (LSR #1
). In generale, spostare a destra un numero intero è più efficiente che dividerlo esplicitamente per due. Ad esempio, supponiamo che il registro r1
contenga attualmente 4, i cui quattro bit meno significativi sono:
...0100 ## 4 in binary
Spostandosi a destra di 1 bit si ottiene:
...0010 ## 2 in binary
LSR
sta per "spostamento logico a destra" e contrasta con ASR
per "spostamento aritmetico a destra". Uno spostamento aritmetico preserva il segno (bit più significativo pari a 1 per negativo e 0 per non negativo), mentre uno spostamento logico non lo è, ma i programmi antigrandine si occupano esclusivamente di senza segno (quindi, non- valori negativi). In uno spostamento logico, i bit spostati vengono sostituiti da zero.
Se il registro r1
contiene un valore con parità dispari, si verifica un codice lineare simile:
addne r1, r1, r1, LSL #1 /* r1 = r1 * 3 */
addne r1, #1 /* r1 = r1 + 1 */
Le due istruzioni addne
(ne
per "se non uguale") vengono eseguite solo se il precedente controllo di parità indica un valore dispari. La prima istruzione addne
esegue la moltiplicazione tramite uno spostamento a sinistra di 1 bit e un'addizione. In generale, lo spostamento e l'addizione sono più efficienti della moltiplicazione esplicita. Il secondo addne
poi aggiunge 1 al registro r1
in modo che l'aggiornamento vada da N a 3N+1.
Assemblare il programma hstoneS
Il codice sorgente assembly per il programma hstoneS deve essere tradotto ("assemblato") in un modulo oggetto binario, che viene quindi collegato alle librerie appropriate per diventare eseguibile. L'approccio più semplice è utilizzare il compilatore GNU C nello stesso modo in cui viene utilizzato per compilare un programma C come hstoneC:
% gcc -o hstoneS hstoneS.s
Questo comando esegue l'assemblaggio e il collegamento.
Un approccio leggermente più efficiente è utilizzare l'utilità as fornita con il set di strumenti GNU. Questo approccio separa l'assemblaggio e il collegamento. Ecco la fase di assemblaggio:
% as -o hstoneS.o hstoneS.s ## assemble
L'estensione .o
è tradizionale per i moduli oggetto. L'utilità di sistema ld potrebbe quindi essere utilizzata per il collegamento, ma un approccio più semplice ed altrettanto efficiente è tornare al comando gcc:
% gcc -o hstoneS hstoneS.o ## link
Questo approccio evidenzia ancora una volta che il compilatore C gestisce qualsiasi combinazione di C e assembly, siano essi file sorgente o moduli oggetto.
Concludendo con una chiamata di sistema esplicita
Entrambi i programmi antigrandine utilizzano le funzioni di input/output di alto livello scanf
e printf
. Queste funzioni sono di alto livello nel senso che si occupano di tipi formattati (in questo caso, interi senza segno) piuttosto che di byte grezzi. In un sistema embedded, tuttavia, queste funzioni potrebbero non essere disponibili; verrebbero invece utilizzate le funzioni di input/output di basso livello read
e write
, che alla fine implementano le loro controparti di alto livello. Queste due funzioni di sistema sono di basso livello in quanto funzionano con byte grezzi.
Nell'assembly Arm6, un programma chiama esplicitamente una funzione di sistema come write
in modo indiretto, invocando una delle funzioni sopra menzionate svc
o syscall
, Per esempio:
calls calls
program------->svc------->write
L'identificatore intero per una particolare funzione di sistema (ad esempio, 4 identifica write
) va nel registro appropriato (registra r7
per svc
e registra r0
per syscall
). I segmenti di codice sotto illustrano, prima con svc
e poi con syscall
.
I due segmenti di codice scrivono il saluto tradizionale sullo standard output, che è lo schermo per impostazione predefinita. L'output standard ha un descrittore di file, un valore intero non negativo che lo identifica. I tre descrittori predefiniti sono:
standard input: 0 (keyboard by default)
standard output: 1 (screen by default)
standard error: 2 (screen by default)
Ecco il segmento di codice per una chiamata di sistema di esempio con svc
:
msg: .asciz "Hello, world!\n" /* greeting */
...
mov r0, #1 /* 1 = standard output */
ldr r1, =msg /* address of bytes to write */
mov r2, #14 /* message length (in bytes) */
mov r7, #4 /* write has 4 as an id */
svc #0 /* system call to write */
La funzione write
accetta tre argomenti e, quando chiamata tramite svc
, gli argomenti della funzione vanno nei seguenti registri:
r0
contiene la destinazione dell'operazione di scrittura, in questo caso l'output standard (#1
).r1
ha l'indirizzo dei byte da scrivere (=msg
).r2
specifica quanti byte devono essere scritti (#14
).
Nel caso dell'istruzione svc
, il registro r7
identifica, con un intero non negativo (in questo caso, #4
), quale sistema funzione da chiamare. La chiamata svc
restituisce zero (il #0
) per segnalare il successo ma solitamente un valore negativo per segnalare un errore.
Le funzioni syscall
e svc
differiscono nei dettagli, ma utilizzarle per richiamare una funzione di sistema richiede gli stessi due passaggi:
- Specificare la funzione di sistema da chiamare (in
r7
persvc
, inr0
persyscall
). - Inserisci gli argomenti per la funzione di sistema nei registri appropriati, che differiscono tra le varianti
svc
esyscall
.
Ecco l'esempio syscall
di chiamata della funzione write
:
msg: .asciz "Hello, world!\n" /* greeting */
...
mov r1, #1 /* standard output */
ldr r2, =msg /* address of message */
mov r3, #14 /* byte count */
mov r0, #4 /* identifier for write */
syscall
Il C ha un wrapper sottile non solo per la funzione syscall
ma anche per le funzioni di sistema read
e write
. Il wrapper C per syscall
fornisce l'essenza ad alto livello:
syscall(SYS_write, /* 4 is the id for write */
STDOUT_FILENO, /* 1 is the standard output */
"Hello, world!\n", /* message */
14); /* byte length */
L'approccio diretto in C utilizza il wrapper per la funzione di sistema write
:
write(STDOUT_FILENO, "Hello, world!\n", 14);