Introduction to 64 Bit Intel Assembly Language Programming for Linux | Cerca per titolo, autore, parola chiave | ||||||||
Introduction to 64 Bit Intel Assembly Language Programming for Linux by Ray Seyfarthm, October 27, 2011. In tempi in cui la tendenza nel mondo della programmazione è l'uso di linguaggi object-oriented, di alto livello, che utilizzano, a loro volta, un ulteriore interprete per tradurre il byte-code, il tutto per creare programmi in tempo record, sembra che preoccuparsi di come viene utilizzata la memoria o dei cicli della CPU sia un retaggio di un tempo ormai passato. In un contesto come questo, perchè mai qualcuno potrebbe desiderare di imparare la programmazione assembly? La programmazione in Assembly language implica la presenza di alcune tra le peggiori calamità temute dai programmatori. Innanzitutto, il linguaggio assembly è l'icona della non portabilità del codice. Ogni CPU ha il suo proprio linguaggio assembly. Molte CPU hanno più di un linguaggio assembly. Prendiamo la famiglia delle Intel CPU, con la loro collezione di AMD CPU, simili una all'altra. Le ultime versioni di questi chip possono operare in modalità 16 bit, 32 bit, 64 bit. Naturalmente, per ciascuna di queste modalità c'è un differente linguaggio assembly. In più, il sistema operativo impone ulteriori differenziazioni. L'interfaccia alle system call dei sistemi operativi x86-64 Linux è differente da quella utilizzata dai sistemi Microsoft Windows. Nel linguaggio assembly, la portabilità è praticamente impossibile da ottenere. Un altro terribile problema con la programmazione in assembly language è l'affidabilità. Nei linguaggi moderni di programmazione, quali Java, il programmatore è protetto da molti possibili problemi, quali, per esempio, gli errori di puntamento (pointer error). I puntatori esistono in Java, ma il programmatore può anche ignorarli beatamente. In linguaggio assembly, invece, ogni accesso ad una variabile fa uso dei puntatori. E ancora: la sintassi dei linguaggi di programmazione di alto livello ricorda la sintassi usata in matematica, mentre il linguaggio assembly è fatto da sequenze di istruzioni macchina che non denotano alcuna relazione con il problema da risolvere. Il linguaggio Assembly è ritenuto da tutti molto più lungo da scrivere, rispetto ai linguaggi di programmazione di alto livello. Uno può anche, con il tempo, ridurre i tempi di scrittura, ma anche il più esperto programmatore impiegherà almeno il doppio del tempo che impiegherebbe con un linguaggio di programmazione di alto livello. La domanda che sorge spontanea è: perchè usare il linguaggio Assembly? Una delle cose che si dicono è che il linguaggio assembly è molto più efficiente dei linguaggi di alto livello. Un programmatore esperto in assembly language può scrivere codice che usa meno tempo CPU e meno memoria di quelli usati da un programma prodotto da un compilatore. C'è da dire che i moderni compilatori C e C++ eseguono ottimizzazioni del codice eccellenti: un programmatore inesperto di assembly non sarebbe comunque in grado di competere con un buon compilatore. I programmatori di compilatori conoscono molto bene l'architettura di una CPU. Resta il fatto che un bravo programmatore assembly può ottenere risultati molto buoni. Un indiscutibile vantaggio del linguaggio assembly è che con esso si possono fare cose che non si possono fare con i linguaggi di alto livello, quali la gestione degli interrupt hardware e la gestione del memory mapping. Queste operazioni sono essenziali per un sistema operativo, anche se non necessarie per la programmazione di applicazioni. Ma il motivo fondamentale per cui è meglio imparare l'assembly language è che con esso si capisce come funziona la CPU, cioè il processore. E questo può essere utilissimo quando si programma ad alto livello. Capire la traduzione dal limguaggio di alto livello al linguaggio macchina è fondamentale per comprendere il comportamento dei bug. Senza la conoscenza di assembly, un linguaggio di programmazione è fondamentalmente un concetto matematico che risponde a leggi matematiche. Ma, al di sotto di questa esteriorità matematica, il computer esegue una sequenza di istruzioni macchina, ciascuna delle quali ha i suoi limiti e potrebbe rispondere in modo inaspettato. Un computer è una macchina che elabora bit. Un bit è una unità di memorizzazione che può assumere due soli valori: 0 e 1. Noi usiamo i computer per elaborare informazioni, ma tutte le informazioni sono rappresentate da bit. Un insieme di bit può rappresentare caratteri, numeri o una qualsiasi altra informazione. Gli uomini interpretano questi bit come informazione, ma il computer maneggia semplicemente dei bit. I computer di oggi accedono alla memoria in blocchi da 8 bit. Ciascuno di questi blocchi è chiamato byte. La memoria RAM è un array di byte, ciascuno dei quali (byte) ha un suo proprio indirizzo. L'indirizzo del primo byte di memoria è 0, mentre l'indirizzo dell'ultimo byte dipende dall'hardware e dal software in uso. Un byte può essere interpretato come numero binario. Il numero binario 01010101 è il numero decimale 85. Se questo numero binario, invece, fosse interpretato come istruzione macchina, il computer copierebbe il valore presente nel registro RBP nel run-time stack. Ma, il numero 85 può anche essere interpretato come la lettera maiuscola "U". Oppure, potrebbe essere una parte di un numero più grande. La stessa lettera "U" potrebbe essere solo una parte di una stringa testuale memorizzata in memoria. È solo un problema di interpretazione. Un programma in esecuzione occupa un certo numero di celle di memoria (indirizzi di memoria) con le istruzioni del programma. I 12 byte seguenti costituiscono un semplicissimo programma che fa solo una cosa: esce, con codice di stato 5. Dalla sinistra troviamo: il numero del byte, l'indirizzo di memoria (in notazione esadecimale), il valore contenuto in quell'indirizzo (in notazione esadecimale e decimale), vale a dire l'istruzione: 01 4000b0 b8 (184) 02 4000b1 01 (1) 03 4000b2 00 (0) 04 4000b3 00 (0) 05 4000b4 00 (0) mov eax, 1 ; EXIT SYS_CALL 0xb8 0x01 0x00 0x00 0x00 06 4000b5 bb (187) 07 4000b6 05 (5) 08 4000b7 00 (0) 09 4000b8 00 (0) 10 4000b9 00 (0) mov ebx, 5 0xbb 0x05 0x00 0x00 0x00 11 4000ba cd (205) 12 4000bb 80 (128) int 0x80 0xcd 0x80 La sequenza degli indirizzi di memoria danno un indizio su come il sistema operativo mappa un programma in memoria. Le pagine di memoria iniziano con un indirizzo di memoria con le ultime tre cifre esadecimali uguale a zero: come si può vedere il primo degli indirizzi di memoria del programma di 12 byte è molto vicino all'inizio di una pagina di memoria. Questo programma di esempio, inoltre, ci mostra che ogni tipo di computer (CPU e processore) conosce una serie di istruzioni che è in grado di capire ed eseguire. Queste istruzioni vengono memorizzate in memoria, prelevate, interpretate ed eseguite in fase di esecuzione del programma. La sequenza di byte che compongono il programma (come i 12 byte del programma di esempio) viene chiamata programma in linguaggio macchina. Com'è facilmente intuibile, usare il linguaggio macchina può rivelarsi davvero complicato: per ogni istruzione, occorre inserire l'esatta sequenza di byte, per ogni dato occorre conoscere l'indirizzo di memoria in cui è stato memorizzato. Accadde così che i primissimi computer venivano programmati in linguaggio macchina, ma che in seguito i programmatori iniziarono a cercare una modalità di programmazione più semplice. Il primo passo fu di usare parole, quali MOV, per indicare una particolare istruzione. Ma l'altra grande rivoluzione fu quella di inserire i nomi simbolici per rappresentare gli indirizzi di memoria, sia dei dati, sia delle istruzioni, così da evitare al programmatore la necessità di calcolarli. Negli anno 50 (1950), i programmatori svilupparono i linguaggi simbolici assembly, che, nel giro di pochi anni, sostituirono i linguaggi macchina, eliminando un sacco di noioso lavoro. I linguaggi macchina vengono considerati i linguaggi di prima generazione, mentre i linguaggi assembly vengono considerati i linguaggi di seconda generazione. I linguaggi assembly resistettero anche alla nascita dei linguaggi di terza generazione, Fortran e Cobol, alla fine degli anni 50. In particulare, i sistemi operativi erano scritti praticamente al 100% in assembly language, almeno fino all'arrivo di C e del sistema operativo UNIX, scritto in C. Ecco come i 12 byte del codice di esempio diventano in assembly language: segment .text global _start _start: mov eax , 1 mov ebx , 5 int Ox80 Lo statement: segment .text non è un'istruzione macchina, bensì un'istruzione per lo stesso assembler e dice a quest'ultimo che i dati e le istruzioni seguenti sono da piazzare nel segmento o sezione di memoria chiamato text. In Linux, questa è la sezione di memoria in cui vengono allocate le istruzioni di un programma. Lo statement: global _start è un'altra istruzione riservata all'assembler, una direttiva assembler o pseudo opcode (pseudo-op), che lo informa dell'esistenza dell'etichetta successiva: _start in modo che possa essere passata al linker in fase di linking del programma. La funzione _start è il vero "entry point" di un programma Linux. Quando il sistema operativo esegue un programma, trasferisce il controllo a questa funzione. Un programma C ha la funzione main chiamata indirettamnte attraverso una funzione: _start presente nella libreria C. La riga che inizia con: _start è un'etichetta. Visto che fino a quel punto, non è stato scritto alcun codice, l'etichetta corrisponde alla locazione di memoria 0 del segmento text del programma. Le restanti tre righe sono tre istruzioni (symbolic opcode) eseguibili. L'ultima istruzione genera un interrupt software, con numero Ox80, che Linux usa per gestire le 32 bit system call. Il codice funziona sia sui sistemi a 32 bit, sia su quelli a 64 bit. Ora che il programma è scritto, usiamo il compilatore per poterlo eseguire: yasm -f elf64 -g dwarf2 -1 exit.lst exit.asm dove: exit.asm è il nome del file assembly da noi scritto, l'opzione: -1 exit.lst chiede a YASM di generare un file contenente il listato del codice generato, in esadecimale, l'opzione: -g dwarf2 seleziona il formato di debugging dwarf2, essenziale per poter eseguire il debug del programma, l'opzione: -f elf64 seleziona un formato a 64 bit dell'output, compatibile sia con Linux sia con GCC. L'assembler YASM è modellato sull'assembler NASM e produce un object code compatibile sia con il debugger GDB, sia con il debugger DDD, mentre NASM non produce un codice accettabile per il debug. Il comando YASM produce un object file chiamato: exit.o che contiene le istruzioni generate e i dati in una forma pronta ad essere linkata (unita) con altro codice macchina contenuto in altri object file o librerie. Nel caso di un programma assembly, contenente la funzione: _start il linking deve essere fatto con il comando LD: ld -o exit exit.o Se il programma assembly, invece, definisse una funzione: main il linking dovrà esser fatto con il comando GCC: gcc -o exit exit.o In questo caso, GCC incorporerà la sua versione di _start e invocherà main da _start. Per eseguire il programma, al quale abbiamo dato il nome exit, eseguiamo: ./exit Appena eseguito il programma, eseguiamo al prompt il comando: echo $? per vedere il codice di stato del programma. Un codice diverso da zero indica un errore, solitamente, ma nel nostro caso il codice di stato sarà 5, visto che lo abbiamo impostato noi stessi! Memory Ora vedremo come un computer esegue la mappatura della memoria per assicurare a ciascun processo uno spazio protetto degli indirizzi di memoria e come Linux gestisce la memoria per ciascun processo. La memoria di un computer può essere vista come un array di byte. Ciascun byte di memoria ha un suo indirizzo. Il primo byte di memoria è all'indirizzo 0, il secondo byte è all'indirizzo 1, e così via, fino all'ultimo byte della memoria. Nelle moderne CPU esistono dei registri di mappatura hardware che vengono usati per assegnare ad ogni processo uno spazio protetto degli indirizzi. Più utenti possono eseguire un programma ciascuno e ciascuno di questi programmi può vedersi assegnare uno spazio degli indirizzi a partire dall'indirizzo Ox4004c8. Questi processi si vedono assegnare gli stessi indirizzi logici, ma, in realtà, stanno usando uno spazio di memoria con differenti indirizzi fisici. I registri di mappatura hardware, su una x86-64 CPU, possono mappare pagine di memoria (aree di memoria) di due differenti dimensioni: 4096 byte e 2 megabyte. Linux usa pagine di 2 MB per il kernel e pagine di 4 KB per molti altri usi. In alcune CPU recenti, c'è il supporto anche per pagine da 1 GB. Il sistema della memoria deve tradurre i bit più significativi (upper bit) di un indirizzo di memoria, per convertire un indirizzo logico di un processo nel suo indirizzo fisico. Consideriamo solo le pagine da 4 KB. Per la conversione servono il numero della pagina e l'indirizzo della pagina. Supponiamo di avere un indirizzo logico Ox4000002220. Poichè 4096 = 212, l'offset all'interno della pagina è rappresentato dai 12 byte a destra (Ox220). Il numero della pagina è rappresentato dai bit restanti (Ox4000002). Uno o più registri hardware traducono questo numero di pagina virtuale in un numero di pagina fisica di memoria, per esempio Ox780000000. Combinando numero di pagina fisico e offset, avremo l'indirizzo fisico richiesto: Ox780000220. In Linux, la memoria per un processo è suddivisa in quattro regioni logiche: text, data, heap, stack. Lo stack è mappato verso l'indirizzo più alto di un processo, che in Linux è: Ox7fffffffffff 11111111111111111111111111111111111111111111111 (binary) 140.737.488.355.327 (decimal) Il massimo numero di bit utilizzabili per un indirizzo logico è 48. Questo indirizzo è di 47 bit, tutti impostati a 1. La decisione di usare solo 47 bit fu presa perché gli indirizzi canonici sono da estendere fino al bit 63. In figura 3.1 possiamo vedere la disposizione dei vari segmenti di memoria. A livello più basso, troviamo il text segment, che parte dall'indirizzo 0. L'indirizzo più basso in un processo in un processore x86-64 è: Ox400000 Il segmento text è un segmento che non potrà crescere, in fase di esecuzione, visto che contiene le istruzioni del programma. Il segmento data segue immediatamente il segmento text. Dopo questi due segmenti, troviamo il segmento heap ed il segmento stack. Il segmento data contiene i dati inizializzati e, subito dop, il segmento bss (block started data by symbol), che contiene i dati non inizializzati, che vengono memorizzati con una sequenza di bit a zero. Lo heap è una regione di memoria dinamicamente ridimensionabile, usata per allocare memoria per un processo, attraverso le funzioni C e C++: malloc new In x86-64 Linux, questa regione può crescere davvero molto, avendo come limite la somma di memoria fisica e spazio swap. L'ultimo segmento di un processo è lo stack (pila), che Linux limita solitamente a 16 megabyte. Non una quantità eccessiva, ma, a meno che il programmatore non vi debba memorizzare un array enorme, più che sufficiente per tenere traccia delle chiamate di funzione, dei parametri, delle variabili locali, degli indirizzi di ritorno. Se il top dello stack è all'indirizzo: Ox7fffffffffff e le dimensioni dello stack sono di 16 megabyte, si ha che l'indirizzo di memoria valido più basso dello stack è: Ox7fffff000000 Lo stack cresce automaticamente quando necessario, in risposta ad un page fault. Il sistema operativo sa in quale intervallo d indirizzi si trova l'indirizzo che ha generato il page fault (Ox7fffff000000 / Ox7fffffffffff), perché è il solo usato per lo stack: quindi, alloca una nuova pagina di memoria (4096 byte) al processo. Questo semplice layout della memoria non è estremamente accurato, perché è possibile che, dopo che il programma è stato caricato in memoria, vengano mappati in memoria alcuni object file, con il risultato che il kernel utilizzi alcune regioni dello heap per memorizzare istruzioni e dati. Queste regioni vengono usate anche per mappare regioni condivise in un processo. Memory example Prendiamo il nostro piccolo esempio precedente di codice, fatto solo di tre istruzioni. Per esaminare la memoria utilizzata da un processo, in Linux si può eseguire il comando: cat /proc/999/maps dove 999 è l'ID del processo che ci interessa. Questa è la memoria virtuale del nostro processo: #address perms offset device inode pathname 00400000-00401000 r-xp 00000000 08:03 7100605 /home/xxx/yyyy/zzz/ex3 7ffff7ffa000-7ffff7ffd000 r--p 00000000 00:00 0 [vvar] 7ffff7ffd000-7ffff7fff000 r-xp 00000000 00:00 0 [vdso] 7ffffffde000-7ffffffff000 rwxp 00000000 00:00 0 [stack] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall] Un'area di memoria è caratterizzata da un indirizzo di inizio, un indirizzo di fine, dalla lunghezza e dai permessi.
|
|||||||||
Introduction to 64 Bit Intel Assembly Language Programming for Linux | Disclaimer: questo è un link a contenuti ospitati su server esterni. |