Oggi la programmazione in assembler non ha più quel fascino di una volta. Il linguaggio più diffuso è senza dubbio ormai il C. Utilizzare un linguaggio d’alto livello come il C fornisce dei vantaggi, per esempio in termini di portabilità. Può capitare, a volte, di dover scrivere porzioni di codice in assembler per interfacciare la propria applicazione verso dispositivi fisici o, anche, per aumentare le prestazioni del nostro sistema in termini di velocità e occupazione di memoria. Oltre a dare un’introduzione all’assembler daremo ampi stralci al suo modello di programmazione.
Introduzione
La specifica architetturale del PowerPc è stata rilasciata nel 1993 a 64-bit con un subset a 32-bit. La maggior parte dei PowerPC disponibili, ad eccezione di alcuni modelli, sono basati a 32-bit. Il processore occupa un rilevante segmento di mercato grazie alle sue buone prestazioni, bassi consumi e dissipazioni. Esistono proposte basate sul core PowerPc con delle periferiche integrate: ethernet controller o I/O dedicati. Il PowerPc ha 32 registri a 32 o 64 bit (GPR) e vari altri registri dedicati: PC (program counter), LR (link register) o CR (condition register), uno dei registri più interessanti è il PVR (PowerPc Version Register). Questo registro è utile quando vogliamo conoscere la versione e il modello del processore in uso, di conseguenza è possibile definire diverse opzioni di inizializzazione del nostro ambiente di esecuzione. La tabella 1 mostra un esempio del contenuto di un PVR.
L’architettura originale del PowerPc definisce una famiglia di processori che offrono diverse prestazioni differenti sia in termini di costo che di possibilità tecniche. L’architettura è definita in quattro libri che definiscono quattro differenti livelli di architettura. Dalle famiglie 600/700 che sono state pensate e realizzate per applicazioni generali e ad ampio spettro, a quelle della famiglia 400 rivolta verso il mercato embedded.
PowerPC EABI (Embedded Application Binary Interface)
L’interfaccia EABI è stata sviluppata dai produttori per rispondere alle esigenze d’intero perabilitià per le applicazioni embedded basate su PowerPc. Questa interfaccia è un’ottimizzazione della interfaccia SVR4 Application Binary Interface. L’obiettivo dell’ottimizzazione nel mondo embedded è quello di minimizzare l’uso della memoria e, nello stesso tempo, mantenere le prestazioni. Una Application Binary Interface è un insieme di convenzioni per permettere al software l’interoperabilità del codice. Conseguentemente, il PowerPc non ha un hardware stack pointer. In questo modo, chi sviluppa il codice deve definire il ruolo di ogni GPR (General Purpose Register). Questo comporta anche un allineamento con i tools utilizzati per lo sviluppo ed il testing del codice: compilatori e debugger. La differenza tra ABI ed EABI sta nella dif fer ente risposta al segmento di mercato. L‘interfaccia EABI si rivolge al mondo embedded ed è un’estensione dello standard Unix Sistema V Release 4 (SVR4) ABI per PowerPc. La tabella 2 mostra i GPR utilizzati secondo la convenzione EABI.
I simboli _SDA_BASE e _SDA2_BASE sono definiti a link time e questi specificano la zona della small data areas. Un programma deve caricare questi valori nei registri, rispettivamente, GPR13 e GPR2 prima di utilizzare i dati del programma. Prima di eseguire il programma, la parte di startup del codice deve anche definire lo stack pointer in GPR1. Questo puntatore deve avere un allineamento a 8 byte. Questo lavoro viene svolto, di solito, con la routine _eabi(), l’utilizzatore non chiama direttamente questa funzione, ma il compilatore deve inserire una chiamata alla funzione prima di eseguire la funzione main(). La maggioranza degli ambienti forniscono una funzione _eabi() automaticamente collegata, nel processo di linking, con il programma utente.
L’interfaccia EABI è stata sviluppata dai produttori per rispondere alle esigenze d’interoperabilitià per le applicazioni embedded basate su PowerPc. Questa interfaccia è un’ottimizzazione della interfaccia SVR4 Application Binary Interface. L’obiettivo dell’ottimizzazione nel mondo embedded è quello di minimizzare l’uso della memoria e, nello stesso tempo, mantenere le prestazioni.
Quando usare l’assembler
Non sempre è utile adoperare linguaggi ad alto livello. Può capitare, infatti, di interagire con i registri della CPU. Per esempio, il linguaggio C consente di applicare le operazioni di decremento e incremento sui registri, ma se esiste la necessità di memorizzare da qualche parte un registro particolare, come lo stack pointer, l’operazione non è più consona ad un linguaggio come il C. Infatti, quando si assegna lo stack ad un registro, di solito si utilizza l’assembler in quanto risulta più consono per queste operazioni. Questa è una delle ragioni per cui la sezione di startup di un’applicazione è, di solito, scritta in assembler. Inoltre, può anche capitare di gestire le eccezioni, in questo caso occorre mettere da parte lo stato dei registri per poi, al termine della gestione dell’eccezione, di riprendere il loro valore allo stato precedente dell’eccezione stessa. La scrittura in assembler di porzioni del nostro programma può essere necessario per diverse ragioni, per esempio se vogliamo aumentare il livello d’ottimizzazione del nostro lavoro può essere necessario ricorrere a questa scelta, ma, in ogni caso, questa opzione è la nostra ultima possibilità da ponderare attentamente. In ogni modo è opportuno tenere presente:
➤ Profile
Fate il profile del vostro codice prima di iniziare qualsiasi lavoro di ottimizzazione. Cercate di ottenere, da questa attività, lo stato di codifica della vostra realizzazione con i vari colli di bottiglia. Solo al termine di questo lavoro, e individuati i criteri oggettivi su sui intervenire, si inizia il lavoro di ottimizzazione del codice. In questo caso a volte può essere necessario addirittura riscrivere porzioni di codice direttamente in assembler.
➤ Ottimizzazione degli algoritmi
In questo contesto, a volte può essere necessario scegliere diverse strutture dati da implementare in assembler. Oppure, quando utilizziamo strutture iterative su una lista circolare può essere utile scegliere tabelle hash o alberi binari.In ogni caso, quando esiste questa esigenza è opportuno tenere presente queste considerazioni:
1) il codice scritto in C è estremamente portabile ed è compreso da un ampio spettro di programmatori, viceversa, il codice scritto in assembler è difficile da comprendere e non è per nulla portabile;
2) il codice C è, relativamente, molto più facile da debuggare;
3) di contro, il codice assembler ci permette di realizzare funzioni dove il requisito prestazionale gioca un peso rilevante. Il compilatore della nostra cross-factory è lo strumento migliore per ricavare indicazioni utili della stesura del nostro codice. Prima di iniziare a riscrivere le porzioni di codice incriminato, proviamo a utilizzare lo switch –O3 e le direttive __inline__. Infatti, il compilatore conosce perfettamente il flusso dello scheduling dell’architettura hardware disponibile e di conseguenza può regolare perfettamente l’attività della pipeline. Per osservare come il nostro cross compilatore genera codice assembler a volte risulta utile selezionare gli appositi switch in fase di compilazione. Per esempio, utilizzando il compilatore gcc con l’opzione:
gcc –O3 –S file_name.c
si genera un file assembler secondo il formato previsto dall’assemblatore gas (GNU assembler). Il file generato ha una estensione nome_file.s. Il listato 1 fornisce un breve esempio di traslazione da un file C a uno assembler listato 2.
Include <stdio.h> Void main (void) {printf(“CIAO”); }
Listato 1 |
.data # inizia la sezione dati Messaggio: .string “CIAO\n” Len =.-msg .text # termina la sezione dati e inizia la sezione codice _start: li 0,4 # syscall (sys_write) li 3,1 # first argument: file descriptor (stdout) # second argument: pointer to message to write lis 4,string@ha # load top 16 bits of &msg addi 4,4,string@ # load bottom 16 bits li 5,len # third argument: message length sc # chiama una system call, in questo modo # accediamo al kernel and exit li 0,1 # il numero della syscall (sys_exit) li 3,1 # primo argomento (exit code) sc # chiama il kernel
Listato 2 |
L’assembler del PowerPC richiede un registro di destinazione per tutti le operazioni basate su registro (si ricorda che questo microprocessore è basato su architettura RISC). Il registro di destinazione è sempre il primo della lista degli argomenti espressa nella linea assembler. Con il PowerPc sotto Linux, le chiamate di sistema (system call) sono fatte utilizzando l’istruzione syscall (sc) con il numero della chiamata nel registro gpr0 e gli argomenti in gpr3. La chiamata di sistema, l’ordine degli argomenti e il numero degli stessi possono essere differenti a seconda del sistema operativo ospite (per esempio NetBSD piuttosto che Mac OS). Questa diversità è una delle ragioni per cui i programmatori tipicamente utilizzando le chiamate attraverso la libreria libc, infatti, questa libreria gestisce gli specifici dettagli del sistema operativo. I registri del PowerPc hanno un numero non un nome. In questo modo, il registro 3 è identificato come gpr3, mentre il registro 3 in virgola mobile come fgpr3. Vediamo delle considerazioni su alcune particolari istruzioni:
ISTRUZIONI IMMEDIATE
Con l’opcode li (load immediate) si vuole intendere che la costante conosciuta al momento della compilazione è posta in un registro come espresso dall’opcode. addi è l’istruzione addizione con operando immediato, addi 3,3,1 il registro 3 è incrementato di 1 e messo in 3. Viceversa, con add 3,3,1 invece si incrementa il registro 3 con il contenuto del registro 1 e si pone il risultato in 3. Da questo si deduce che se le istruzioni finiscono con la lettera i, allora queste sono istruzioni immediate.
Mnemonici
li non è una istruzione, ma è un m-nemonico. Non è una istruzione assembler, ma, per poter essere accettato dal microprocessore, dovrà essere tradotto in una istruzione assembler al momento della generazione del codice oggetto. Utilizzando il comando GNU objdump –d si ottiene la stampa, a video o su file, dei mnemonici del PowerPc in luogo delle istruzioni presenti nel file originale.
Puntatori
Vediamo dal listato 1 che il messaggio da visualizzare è indirizzato dall’etichetta string. Il PowerPc utilizza istruzioni a 32 bit di lunghezza fissa (ia32 utilizza istruzioni variabili), di conseguenza le istruzioni che utilizzano istruzioni a 32 bit sono posizionati su interi a 32. Questo intero è diviso in una serie di campi con differenti significati: opcode (6 bits), registro sorgente (5 bits), registro destinazione (5 bits) e valore immediato (16 bits). Il numero dei bit di ciascun campo variano dal tipo di istruzioni, ma la cosa importante da sapere è che questi bit occupano spazio nell’istruzione. Con l’istruzione addi, per esempio, i primi tre campi sono inseriti direttamente nell’istruzione, mentre i rimanenti 16 bits del valore immediato sono semplicemente aggiunti. La conseguenza di questo è che possono essere utilizzati solo 16 bits per indicare valori immediati, cioè non è possibile caricare un valore di 32 bit in un qualsiasi registro. Per fare questo è necessario utilizzare due istruzioni, nel primo è caricato la prima parte dei 16 bits e nel secondo la rimanente parte. Dal punto di vista implementativo è necessario utilizzare la notazione:
@ha per high
@l per low
Quindi, dal listato 1:
lis 4,string@ha # load top 16 bits of &msg addi 4,4,string@l # load bottom 16 bits
Alcune considerazioni a livello di registro
Vediamo a questo punto alcune considerazioni da tenere presenti per gestire correttamente i registri del PowerPc. Iniziamo con il time base register per poi affrontare altri registri che sono specifici per determinate attività.
Time Base
L’architettura PowerPc definisce un registro a 64-bit utilizzato come time base e tutte le implementazioni hanno un registro a 64 eccetto il 403GA che ne utilizza uno a 56 bit. Questo registro contiene un valore che è incrementato in base ad una frequenza selezionata dipendente dall’implementazione. La frequenza è incrementata, nella famiglia 600, ogni ¼ del bus clock rate. La famiglia 400 incrementa questo time base ogni execution unit clock o da un clock esterno fornito dall’implementatore. Le istruzioni per scrivere verso questo registro non dipendono dall’implementazione, in questo modo le scritture al registro con un 32 bit PowerPC dovrà lavorare correttamente anche su un 64 bit. Il modo di lettura di questo registro dipendono, invece, dall’implementazione. Per la famiglia 600, 401x2 e 405 il registro è letto mediante l’uso di diversi SPR (sono registri del PowerPc): questi registri sono utilizzati per scrivere i registri a 32 bit TBU e TBL. Il livello di privilegio definito come user consente la lettura utilizzando le istruzioni mftb per leggere il registro TBL e mftbu per TBU. Il livello supervisor consente di scrivere i registri utilizzando mttbl e mttbu per scrivere, rispettivamente, nel registro TBL e TBU. L’architettura descritta in book E non dispone dell’istruzione mftb. Il core del modello 440 aderisce all’architettura proposta dal manuale e utilizza l’accesso del 403.
Altri specifici registri
Il valore del registro PVR è unico per ogni processore, ciascuna implementazione ha un proprio valore. Vediamo in questo breve elenco quali sono i registri specifici della famiglia 600.
SPRG0-SPRG3
Solo i processori della famiglia 600 hanno questi registri. Il processore SPRG0 è un registro riservato che contiene il puntatore allo stack pointer utilizzato dal primo livello del gestore delle eccezioni. Il registro SPRG1 è utilizzato come registro di backup di un GPR ed è utilizzato come stack pointer per contenere altri registri mediante la copia del contenuto di SPRG0 verso il registro GPR, anche in questo caso SPRG1 è utilizzato al primo livello del gestore di eccezioni. La norma EABI specifica che il registro GPR1 è utilizzato come stack pointer ed è sempre, in ogni caso, valido.
HID0
Questo registro è chiamato Hardware Implementation Dependent 0 ed è utilizzato per abilitare e determinare alcuni parametri del processore, quali il bus parity control, il power management, il controllo della cache e altro. Il significato di questo registro cambia in base al processore selezionato.
HID1
Questo registro è utilizzato per configurare la PLL. L’accesso a questo registro è consentito utilizzando i registri mtspr e MFSPR.
EAR
Questo registro (External Access Register) è un registro opzionale nell’OEA del PowerPc. L’EAR è utilizzato per controllare gli accessi verso il mondo esterno. I componenti della famiglia 400 non dispongono di un registro equivalente.
PIR
Anche questo è un registro opzionale ed è utilizzato dal sistema operativo per assegnare un identificatore al processore. Questo è anche utilizzato quando il processore comunica con il mondo degli I/O.
L’uso dello standard EABI porta indubbi vantaggi nelle applicazioni. Per prima cosa è possibile, in questo modo, integrare differenti componenti software in maniera indipendente dal compilatore utilizzato e svincolarsi dall'obbligo di supportare differenti toolchain.
Indipendentemente dal processore o microcontrollore utilizzato, esistono delle parti di codice che devono essere implementate (necessariamente o preferibilmente, a seconda dei casi) in assembler. Queste includono parti del kernel o dello schedulatore, routine di risposta all’interrupt veloci, codice ottimizzato manualmente per l’esecuzione di funzioni matematiche o per l’accesso alle risorse hardware e altro ancora. In ogni caso l’assembler è importante per eseguire il debugging e per capire come il compilatore ha generato il codice. Con livelli di ottimizzazione del codice particolarmente elevati può essere comunque molto difficile interpretare il flusso del programma guardando soltanto il codice assembler.
Ho lavorato su assembler PowerPC quando ho giocato a iniettare del codice della PS3, che ha appunto un processore Cell, multicore, ognuno con architettura PowerPC. Essendo la PS3 blindata, e’ necessario per farlo installare prima uno dei tool scritto dai grandi hacker in rete, che tramite un exploit hardware su USB, avevano permesso di prendere il controllo del processore, superando il sistema operativo nativo della Sony.
A quel punto si puo’ cross-compilare su qualsiasi host (io lo facevo da Ubuntu) avendo come target il Cell, e poi caricare delle proprie prove come un normale gioco. E’ cmq molto delicato, perche’ si puo’ rischiare di “brickare” la macchina, rendendola inutilizzabile. La Sony aveva cmq patchato l’exploit in piu’ versioni di firmware, per cui e’ necessario avere una PS3 con fw al massimo 3.41 per poterlo exploitare.
A me piace molto l’architettura PowerPC. Peccato che poi Apple l’abbia abbandonata per l’Intel.