Jak pisać programy w języku asembler pod Linuksem?

Część 1 - Podstawy, czyli czym to się je

Wyobraźcie sobie, jakby to było móc programować maszynę bezpośrednio - rozmawiać z procesorem bez pośrednictwa struktur wysokiego poziomu, na przykład takich jak spotykamy w języku C. Bezpośrednie operowanie na procesorze umożliwia przecież pełną kontrolę jego działań! Bez zbędnych instrukcji i innych śmieci spowalniających nasze programy.

Czy już czujecie chęć pisania najkrótszych i najszybszych programów na świecie?
Programów, których czasem w ogóle NIE MOŻNA napisać w innych językach? Brzmi wspaniale, prawda?

Tylko pomyślcie o tym, co powiedzieliby znajomi, gdybyście się im pochwalili. Widzicie już te ich zdumione miny?

Miła perspektywa, prawda? No, ale dość już gadania. Zabierajmy się do rzeczy!

Zacznijmy od krótkiego wprowadzenia:


Niedziesiętne systemy liczenia.


  1. Dwójkowy (binarny)

    Najprostszy dla komputera, gdzie coś jest albo włączone, albo wyłączone. System ten operuje na liczbach zwanych bitami (bit = binary digit = cyfra dwójkowa). Bit przyjmuje jedną z dwóch wartości: 0 lub 1.
    Na bajt składa się 8 bitów. Jednym bajtem można przedstawić więc 2^8=256 możliwości.

    Przeliczenie liczby zapisanej w systemie dwójkowym na dziesiętny jest proste. Podobnie jak w systemie dziesiętnym, każdą cyfrę mnożymy przez odpowiednią potęgę podstawy (podstawa wynosi 2 w systemie dwójkowym, 10 w systemie dziesiętnym).

    Oto przykład (daszek ^ oznacza potęgowanie):
    1010 1001 dwójkowo =
    1*(2^7) + 0*(2^6) + 1*(2^5) + 0*(2^4) + 1*(2^3) + 0*(2^2) + 0*(2^1) + 1*(2^0) =
    128 + 32 + 8 + 1 =
    169 dziesiętnie (lub dec, od decimal).

    Działanie odwrotne też nie jest trudne: naszą liczbę dzielimy ciągle (do chwili uzyskania ilorazu równego 0) przez 2, po czym zapisujemy reszty z dzielenia wspak:


    (przeskocz konwersję liczby dziesiętnej na dwójkową)
    	169	|
    	 84	| 1
    	 42	| 0
    	 21	| 0
    	 10	| 1
    	  5	| 0
    	  2	| 1
    	  1	| 0
    	  0	| 1

    Wspak dostajemy: 1010 1001, czyli wyjściową liczbę.

  2. Szesnastkowy (heksadecymalny, w skrócie hex)

    Jako że system dwójkowy ma mniej cyfr niż dziesiętny, do przedstawienia względnie małych liczb trzeba użyć dużo zer i jedynek. Jako że bajt ma 8 bitów, podzielono go na dwie równe, czterobitowe części. Teraz bajt można już reprezentować dwoma znakami, a nie ośmioma. Na każdy taki znak składa się 2^4=16 możliwości. Stąd wzięła się nazwa szesnastkowy.
    Powstał jednak problem: cyfr jest tylko 10, a trzeba mieć 16. Co zrobić?
    Postanowiono liczbom 10-15 przyporządkować odpowiednio znaki A-F.
    Na przykład
    Liczba 255 dziesiętnie = 1111 1111 binarnie = FF szesnastkowo (1111 bin = 15 dec = F hex)
    Liczba 150 dziesiętnie = 1001 0110 binarnie = 96 szesnastkowo.

    Należy zauważyć ścisły związek między systemem dwójkowym i szesnastkowym: 1 cyfra szesnastkowa to 4 bity, co umożliwia błyskawiczne przeliczanie między obydwoma systemami: wystarczy tłumaczyć po 4 bity (1 cyfrę hex) na raz i zrobione.

    Przeliczenie liczby zapisanej w systemie szesnastkowym na dziesiętny jest równie proste, jak tłumaczenie z dwójkowego na dziesiętny. Każdą cyfrę mnożymy przez odpowiednią potęgę podstawy (podstawa wynosi 16 w systemie szesnastkowym).

    Oto przykład:
    10A szesnastkowo =
    1*16^2 + 0*16^1 + A*16^0 =
    256 + 0 + 10 =
    266 dziesiętnie.

    Działanie odwrotne też nie jest trudne: naszą liczbę dzielimy ciągle (do chwili uzyskania ilorazu równego 0) przez 16, po czym zapisujemy reszty z dzielenia wspak:


    (przeskocz konwersję liczby dziesiętnej na szesnastkową)
    	 266	|
    	 16	| 10
    	  1	| 0
    	  0	| 1
    Wspak dostajemy kolejno: 1, 0, 10, czyli 10A, czyli wyjściową liczbę.

    Podczas pisania programów, liczby w systemie szesnastkowym oznacza się przez dodanie na końcu litery h (lub z przodu 0x), a liczby w systemie dwójkowym - przez dodanie litery b na końcu.
    Tak więc, 101 oznacza dziesiętną liczbę o wartości 101, 101b oznacza liczbę 101 w systemie dwójkowym (czyli 5 w systemie dziesiętnym), a 101h lub 0x101 oznacza liczbę 101 w systemie szesnastkowym (czyli 257 dziesiętnie).



Język asembler i rejestry procesora

Co to jest asembler?
Asembler jest to język programowania, należący do języków niskiego poziomu. Znaczy to tyle, że jednej komendzie asemblera odpowiada dokładnie jeden rozkaz procesora. Asembler operuje na rejestrach procesora.

A co to jest rejestr procesora?
Rejestr procesora to zespół układów elektronicznych, mogący przechowywać informacje (taka własna pamięć wewnętrzna procesora).
Zaraz podam Wam podstawowe rejestry, na których będziemy operować. Wiem, że ich liczba może przerazić, ale od razu mówię, abyście NIE uczyli się tego wszystkiego na pamięć! Najlepiej zrobicie, czytając poniższą listę tylko 2 razy, a potem wracali do niej, gdy jakikolwiek rejestr pojawi się w programach, które będę później prezentował w ramach tego kursu.
Oto lista interesujących nas rejestrów:

  1. ogólnego użytku:


  2. rejestry segmentowe (wszystkie 16-bitowe) - tych najlepiej nie dotykać w Linuksie:


  3. rejestr stanu procesora: FLAGI (16-bitowe), E-FLAGI (32-bitowe) lub R-FLAGI (64-bitowe).
    Służą one przede wszystkim do badania wyniku ostatniego przekształcenia (na przykład czy nie wystąpiło przepełnienie, czy wynik jest zerem, itp.). Najważniejsze flagi to CF (carry flag - flaga przeniesienia), OF (overflow flag - flaga przepełnienia), SF (sign flag - flaga znaku), ZF (zero flag - flaga zera), IF (interrupt flag - flaga przerwań), PF (parity flag - flaga parzystości), DF (direction flag - flaga kierunku).

Użycie litery R przed symbolem rejestru, na przykład RCX, oznacza rejestr 64-bitowy, dostępny tylko na procesorach 64-bitowych.

Użycie litery E przed symbolem rejestru, na przykład EAX, oznacza rejestr 32-bitowy, dostępny tylko na procesorach rodziny 80386 lub lepszych. Nie dotyczy to rejestru ES.

Napisy

RAX = EAX+starsze 32 bity; EAX=AX+ starsze 16 bitów; AX=AH+AL

oznaczają:


(przeskocz rozwinięcie rejestru RAX)
				RAX (64 bity)
				     |                 EAX (32b)
    00000000000000000000000000000000 | 0000000000000000 | 00000000 | 00000000
       		   32b	     	     |	      16b       |        AX(16b)
                       		     | 		        |  AH(8b)  |   AL(8b)

Napisy

RSI = ESI+starsze 32 bity; ESI = SI + starsze 16 bitów; SI = SIL+starsze 8 bitów

oznaczają:


(przeskocz rozwinięcie rejestru RSI)
				RSI (64 bity)
				     |               ESI (32b)
    00000000000000000000000000000000 | 0000000000000000 | 00000000 | 00000000
       		   32b		     |	     16b        |        SI(16b)
                       		     |		        |   8b     |  SIL(8b)

Jedna ważna uwaga - między nazwami rejestrów może pojawić się dwukropek w dwóch różnych znaczeniach:

Na razie nie musicie się przejmować tymi dwukropkami. Mówię to tylko dlatego, żebyście nie byli zaskoczeni, gdyż w przyszłości się pojawią.

Programista może odnosić się bezpośrednio do wszystkich wymienionych rejestrów, z wyjątkiem *IP oraz flag procesora (z wyjątkami).

Jak widać po ich rozmiarach, do rejestrów 8-bitowych można wpisać liczbę z przedziału 0-255 (lub od -128 do 127, gdy najwyższy, siódmy bit służy nam jako bit oznaczający znak liczby), w 16-bitowych zmieszczą się liczby 0-65535 (od -32768 do 32767), a w 32-bitowych - liczby od 0 do 4.294.967.295 (od -2.147.483.648 do 2.147.483.647).

Dobrym, choć trudnym w odbiorze źródłem informacji są: Intel Architecture Software Developer's Manual (IASDM) dostępny ZA DARMO ze stron Intela oraz DARMOWE podręczniki AMD64 Architecture Programmer's Manual firmy AMD



Pisanie i kompilowanie (asemblowanie) swoich programów

Jak pisać programy w asemblerze?
Należy zaopatrzyć się w:

Wtedy wystarczy napisać w edytorze tekstu plik zawierający komendy procesora (o tym później), zapisać go z rozszerzeniem .asm lub .s (GNU as), po czym użyć kompilatora, aby przetworzyć program na kod rozumiany przez procesor.

Jakiego kompilatora użyć?
Istnieje wiele kompilatorów języka asembler pod Linuksa. Do najpopularniejszych należą Netwide Asembler Project (NASM), Flat Asembler (FASM), High-Level Asembler (HLA) i Gnu As.

Można je ściągnąć z internetu:


(przeskocz adresy stron kompilatorów)

Po skompilowaniu pliku z kodem źródłowym należy użyć programu łączącego - będziemy używać standardowego LD (tego, którego używają inne kompilatory), gdyż również powinien się znajdować w każdej dystrybucji Linuksa.

Mamy więc już wszystko, co potrzeba. Zaczynamy pisać. Będę tutaj używał składni NASMa i FASMa (gdyż kompilują one programy w składni Intela, która jest bardziej przejrzysta, mimo iż może się wydawać odwrotna).
Najpierw programy na systemy 32-bitowe:


(przeskocz pierwszy 32-bitowy program w składni NASM)
	; wersja NASM na system 32-bitowy

	section	.text			; początek sekcji kodu.
	global _start		; linker ld chce mieć ten symbol globalny

	_start:				; punkt startu programu

		mov	eax, 4		; numer funkcji systemowej:
					; sys_write - zapisz do pliku
		mov	ebx, 1		; numer pliku, do którego piszemy.
					; 1 = standardowe wyjście = ekran
		mov	ecx, tekst	; ECX = adres (offset) tekstu
		mov	edx, dlugosc	; EDX = długość tekstu
		int	80h		; wywołujemy funkcję systemową
		mov	eax, 1		; numer funkcji systemowej
					; (sys_exit - wyjdź z programu)
		int	80h		; wywołujemy funkcję systemową

	section .data			; początek sekcji danych.

	tekst	db	"Czesc", 0ah	; nasz napis, który wyświetlimy
	dlugosc	equ	$ - tekst	; długość napisu

Teraz wersja dla FASMa:


(przeskocz pierwszy 32-bitowy program w składni FASM)
	; wersja FASM na system 32-bitowy

	format ELF executable		; typ pliku
	entry _start			; punkt startu programu

	segment	readable executable	; początek sekcji kodu

	_start:				; punkt startu programu

		mov	eax, 4		; numer funkcji systemowej:
					; sys_write - zapisz do pliku
		mov	ebx, 1		; numer pliku, do którego piszemy.
					; 1 = standardowe wyjście = ekran
		mov	ecx, tekst	; ECX = adres (offset) tekstu
		mov	edx, [dlugosc]	; EDX = długość tekstu
		int	80h		; wywołujemy funkcję systemową
		mov	eax, 1		; numer funkcji systemowej
					; (sys_exit - wyjdź z programu)
		int	80h		; wywołujemy funkcję systemową

	segment readable writeable	; początek sekcji danych.
	tekst	db	"Czesc", 0ah	; nasz napis, który wyświetlimy
	dlugosc	dd	$ - tekst	; długość napisu

Teraz program 64-bitowy (x86-64) dla NASMa:


(przeskocz pierwszy 64-bitowy program w składni NASM)
	; wersja NASM na system 64-bitowy (x86-64)

	section	.text			; początek sekcji kodu.
	global _start		; linker ld chce mieć ten symbol globalny

	_start:				; punkt startu programu

		mov	rax, 1		; numer funkcji systemowej:
					; sys_write - zapisz do pliku
		mov	rdi, 1		; numer pliku, do którego piszemy.
					; 1 = standardowe wyjście = ekran
		mov	rsi, tekst	; RSI = adres (offset) tekstu
		mov	rdx, dlugosc	; RDX = długość tekstu
		syscall			; wywołujemy funkcję systemową
		mov	rax, 60		; numer funkcji systemowej
					; (sys_exit - wyjdź z programu)
		syscall			; wywołujemy funkcję systemową

	section .data			; początek sekcji danych.

	tekst	db	"Czesc", 0ah	; nasz napis, który wyświetlimy
	dlugosc	equ	$ - tekst	; długość napisu

I w końcu program 64-bitowy dla FASMa:


(przeskocz pierwszy 64-bitowy program w składni FASM)
	; wersja FASM na system 64-bitowy  (x86-64)

	format ELF64 executable		; typ pliku
	entry _start			; punkt startu programu

	segment	readable executable	; początek sekcji kodu

	_start:				; punkt startu programu
		mov	rax, 1		; numer funkcji systemowej:
					; sys_write - zapisz do pliku
		mov	rdi, 1		; numer pliku, do którego piszemy.
					; 1 = standardowe wyjście = ekran
		mov	rsi, tekst	; RSI = adres (offset) tekstu
		mov	rdx, [dlugosc]	; RDX = długość tekstu
		syscall			; wywołujemy funkcję systemową
		mov	rax, 60		; numer funkcji systemowej
					; (sys_exit - wyjdź z programu)
		syscall			; wywołujemy funkcję systemową

	segment readable writeable	; początek sekcji danych.
	tekst	db	"Czesc", 0ah	; nasz napis, który wyświetlimy
	dlugosc	dq	$ - tekst	; długość napisu w trybie 64-bitowym

Bez paniki! Teraz omówimy dokładnie, co każda linia robi.

Programik nazywamy hello.asm i kompilujemy poleceniem (FASM):

	fasm hello.asm hello

lub, dla NASMa:

	nasm -f elf hello.asm
	ld -o hello hello.o

lub, dla NASMa na systemie 64-bitowym:

	nasm -f elf64 hello.asm
	ld -o hello hello.o

Wyjaśnienie opcji:

Na systemach 64-bitowych można też uruchamiać programy 32-bitowe, ale w takim przypadku trzeba je odpowiednio skompilować i zlinkować. Dla FASMa komenda się nie zmienia, gdyż wszystkie informacje są wewnątrz pliku. Zmienia się za to kompilacja na NASMa:

	nasm -f elf hello.asm
	ld -melf_i386 -o hello hello.o

Kompilacja, nawet programu w asemblerze (zwana czasem asemblacją), ma kilka etapów:

Jeśli do programu nie dołączamy innych już skompilowanych plików ani bibliotek, to niektóre kompilatory nie wymagają osobnego linkera i mogą same sobie poradzić z wygenerowaniem programu wyjściowego. Widać to na przykład w wywołaniach FASMa powyżej.

Jeśli linker LD wyświetla ostrzeżenie o wykonywalnym stosie (np. missing .note.GNU-stack section implies executable stack), wystarczy do kodu programu dopisać na końcu

	section .note.GNU-stack noalloc noexec nowrite progbits
po czym ponownie skompilować i zlinkować.

Teraz uruchamiamy ./hello i cieszymy się swoim dziełem.
W dalszej części kursu będę przedstawiał programy często tylko 32-bitowe i często tylko dla jednego kompilatora. Ta część będzie służyć Wam pomocą, jeśli chcielibyście pisać programy pod systemy 64-bitowe lub pod inny kompilator.

Miłego eksperymentowania.

Na świecie jest 10 rodzajów ludzi:
ci, którzy rozumieją liczby binarne i ci, którzy nie.


Informacja dla użytkowników *BSD (klawisz dostępu 3)
Kolejna część kursu (klawisz dostępu 4)
Spis treści off-line (klawisz dostępu 1)
Spis treści on-line (klawisz dostępu 2)
Ułatwienia dla niepełnosprawnych (klawisz dostępu 0)



Ćwiczenia


  1. Poeksperymentujcie sobie, wstawiając różne znaki do napisu. Na przykład, znaki o kodach ASCII 10 (Line Feed), 13 (Carriage Return), 7 (Bell). Na przykład:
    	info	db	"Czesc.", 00, 01, 02, 07, 10, 13, 10, 13