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

Część 4 - Pierwsze programy, czyli przełamywanie pierwszych lodów

Znamy już rejestry, trochę instrukcji i zasad. No ale teoria jest niczym bez praktyki. Dlatego w tej części przedstawię kilka względnie prostych programów, które powinny rozbudzić wyobraźnię tworzenia.

Ten program spyta się użytkownika o imię i przywita się z nim:


(przeskocz program pytający o imię)
	; Program witający się z użytkownikiem po imieniu
	;
	; Autor: Bogdan D.
	; kontakt: bogdandr (at) op (dot) pl
	;
	; kompilacja:
	; nasm -f elf czesc.asm
	; ld -s -o czesc czesc.o
	;
	; kompilacja FASM:
	; fasm czesc.asm czesc

	; dla FASMa:
	;format ELF executable
	;entry _start

	;segment readable executable		; początek sekcji kodu

	; dla NASMa:
	section .text		; początek sekcji kodu
	global _start	; _start będzie symbolem globalnym,
			; od którego zacznie się wykonywanie programu

	_start:
		mov	eax, 4		; zapis do pliku
		mov	ebx, 1		; na ekran
		mov	ecx, jak_masz	; napis do wyświetlenia: pytanie
		mov	edx, jak_masz_dl	; długość napisu
		int	80h		; wyświetlamy

		mov	eax, 3		; czytanie z pliku
		mov	ebx, 0		; z klawiatury
		mov	ecx, imie	; dokąd czytać?
		mov	edx, imie_dl	; ile bajtów czytać?
		int	80h		; wczytujemy


		mov	eax, 4		; zapis do pliku
		mov	ebx, 1		; na ekran
		mov	ecx, czesc	; napis do wyświetlenia: "cześć"
		mov	edx, czesc_dl	; długość napisu
		int	80h		; wyświetlamy

		mov	eax, 4		; zapis do pliku
		mov	ebx, 1		; na ekran
		mov	ecx, imie	; napis do wyświetlenia: imię
		mov	edx, imie_dl	; długość napisu
		int	80h		; wyświetlamy

		mov	eax, 1
		xor	ebx, ebx
		int	80h

	; dla FASMa:
	;segment readable writeable		; początek sekcji danych

	section .data		; początek sekcji danych

	jak_masz	db	"Jak masz na imie? "
	; FASM: znak równości zamiast EQU
	jak_masz_dl	equ	$ - jak_masz

	; rezerwuj 20 bajtów o wartości początkowej zero, na imię
	imie:		times 20 db 0
	; FASM: znak równości zamiast EQU
	imie_dl		equ	$ - imie

	czesc		db	"Czesc "
	; FASM: znak równości zamiast EQU
	czesc_dl	equ	$ - czesc

Następny program wypisuje na ekranie rejestr flag w postaci dwójkowej.


(przeskocz program wypisujący flagi)
; Program wypisujący flagi w postaci dwójkowej
;
; Autor: Bogdan D.
; kontakt: bogdandr (at) op (dot) pl
;
; kompilacja:
; nasm -f elf flagi.asm
; ld -s -o flagi flagi.o
;
; kompilacja FASM:
; fasm flagi.asm flagi


;format ELF executable		; dla FASMa
; entry _start			; dla FASMa

; segment readable executable	; dla FASMa

section .text		; tu zaczyna się segment kodu,
			; nie jest to potrzebne

global _start		; nazwa punktu rozpoczęcia programu.
			; FASM: usunąć tę linijkę

;CPU 386		; będziemy tu używać rejestrów 32-bitowych.
			; Nie jest to potrzebne, gdyż
			; NASM domyślnie włącza wszystkie
			; możliwe instrukcje.

_start:				; etykieta początku programu

	pushfd			; 32 bity flag idą na stos

	pop	esi		; flagi ze stosu do ESI

	mov	eax, "0"
	mov	ebx, nasze_flagi; EBX = adres bufora dla wartości flag
	xor	edi, edi	; EDI = 0

	mov	cx, 32		; tyle bitów i tyle razy trzeba
				; przejść przez pętlę

petla:				; etykieta oznaczająca początek pętli.

	and	al, "0"	; upewniamy się, że AL zawiera tylko
				; 30h="0", co zaraz się
				; może zmienić. Dokładniej,
				; czyścimy bity 0-3,
				; z których bit 0 może się zaraz zmienić

	shl	esi, 1		; Przesuwamy bity w ESI o 1 w lewo.
				; 31 bit ESI idzie do CF

	adc	al, 0		; ADC - add with carry. Do AL dodaj
				; 0 + wartość CF.
				; jeśli CF (czyli 31 bit ESI) = 1,
				; to AL := AL+1,
				; inaczej AL bez zmian

	mov	[ebx+edi], al	; zapisz AL w buforze
	add	edi, 1

	loop	petla		; przejdź na początek pętli,
				; jeśli nie skończyliśmy

	mov	eax, 4		; funkcja zapisywania do pliku/na ekran
	mov	ebx, 1		; 1 = ekran
	mov	ecx, nasze_flagi
	mov	edx, 32		; długość tekstu
	int	80h		; wypisz na ekran

        mov     byte [nasze_flagi],0ah
        mov     eax, 4          ; funkcja zapisywania do pliku/na ekran
        mov     ebx, 1          ; 1 = ekran
        mov     ecx, nasze_flagi
        mov     edx, 1          ; długość tekstu
        int     80h             ; wypisz na ekran przejście do nowej linii

	mov	eax, 1
	int	80h		; wyjście z programu

; FASM: segment readable writeable
section .data			; dane już nie mogą być w sekcji kodu, gdyż
				; w Linuksie sekcja kodu programu jest
				; chroniona przed zapisem

nasze_flagi:	times	32	db "0"		; "0" = 30h

Kompilujemy go następująco (wszystkie programy będziemy tak kompilować, chyba że powiem inaczej):

	nasm -f elf flagi.asm
	ld -s -o flagi flagi.o

lub:

	fasm flagi.asm flagi

Nie ma w tym programie wielkiej filozofii. Nie powinno być trudno go zrozumieć.


Teraz krótki programik, którego jedynym celem jest wyświetlenie na ekranie cyfr od 0 do 9, każda w osobnej linii:


(przeskocz program wypisujący cyfry)
; Program wypisuje na ekranie cyfry od 0 do 9
;
; kompilacja NASM:
; nasm -f elf cyfry.asm
; ld -s -o cyfry cyfry.o


section .text

global _start

; definiujemy stałe (NASM):

%define		lf	10		; Line Feed
%define	stdout	1	; standardowe urządzenie wyjścia (zwykle ekran)
%define		sys_write 4		; funkcja pisania do pliku

;
; kompilacja FASM:
; fasm cyfry.asm cyfry
;
; format ELF executable		; dla FASMa
; entry _start
; segment readable executable

; definiujemy stałe (FASM):
; lf = 10
; stdout = 1
; sys_write = 4



_start:

	mov	eax, 0			; pierwsza wypisywana cyfra

wyswietlaj:
	call	_pisz_ld		; uruchom procedurę wyświetlania
					; liczby będącej w EAX
	call	_nwln			; uruchom procedurę, która
					; przechodzi do nowej linii
	add	eax, 1			; zwiększamy cyfrę
	cmp	eax, 10			; sprawdzamy, czy ciągle EAX < 10
	jb	wyswietlaj		; jeśli EAX < 10, to
					; wyświetlamy cyfrę

	mov	eax, 1			; funkcja wyjścia z programu
	xor	ebx, ebx		; kod wyjścia = 0
	int	80h			; wychodzimy



; następujące procedury (wyświetlanie liczby i przechodzenie
; do nowego wiersza) nie są aż tak istotne, aby omawiać je
; szczegółowo, gdyż w przyszłości będziemy używać tych samych
; procedur, ale z biblioteki, a te wstawiłem tutaj dla
; uproszczenia kompilacji programu.

; Ogólny schemat działania tej procedury wygląda tak:
; weźmy liczbę EAX=12345. Robimy tak:
; 1. dzielimy EAX przez 10. reszta = EDX = DL = 5.
; Zapisz do bufora. EAX = 1234 (iloraz)
; 2. dzielimy EAX przez 10. reszta = DL = 4.
; Zapisz do bufora. EAX=123 (iloraz)
; 3. dzielimy EAX przez 10. reszta = DL = 3.
; Zapisz do bufora. EAX=12
; 4. dziel EAX przez 10. DL = 2. zapisz. iloraz = EAX = 1
; 5. dziel EAX przez 10. DL = 1. zapisz. iloraz = EAX = 0.
; Przerywamy pętlę.
; Teraz w buforze są znaki: "54321". Wystarczy wypisać wspak
; i oryginalna liczba pojawia się na ekranie.

_pisz_ld:

; pisz32e
; we: EAX=liczba bez znaku do wypisania

	pushfd
	push	ecx
	push	edx
	push	eax
	push	esi

	xor	esi, esi
	mov	ecx, 10

._pisz_ld_petla:
	xor	edx, edx
	div	ecx

	or	dl, "0"
	mov	[_pisz_bufor+esi], dl	; do bufora idą reszty z
					; dzielenia przez 10,
	inc	esi			; czyli cyfry wspak

	test	eax, eax
	jnz	._pisz_ld_petla

._pisz_ld_wypis:
	mov	al, [_pisz_bufor+esi-1]	; wypisujemy reszty wspak
	call	_pisz_z

	dec	esi
	jnz	._pisz_ld_wypis

	pop	esi
	pop	eax
	pop	edx
	pop	ecx
	popfd

	ret


_pisz_z:

; pisz_z
; we: AL=znak do wypisania

	push	eax
	push	ebx
	push	ecx
	push	edx

	mov	[_pisz_bufor+39], al

	mov	eax, sys_write		; funkcja zapisu do pliku
	mov	ebx, stdout		; kierujemy na
					; standardowe wyjście
	lea	ecx, [_pisz_bufor+39]
	mov	edx, 1
	int	80h

	pop	edx
	pop	ecx
	pop	ebx
	pop	eax

	ret


_nwln:

;wyświetla znak końca linii (Linux)

	push	eax

	mov	al, lf
	call	_pisz_z

	pop	eax
	ret


section .data
; FASM: segment readable writeable

_pisz_bufor:	times	40	db 0	; miejsce na 40 cyferek

Następny twór nie jest wolnostojącym programem, ale pewną procedurą. Pobiera ona informacje z rejestru AL i wypisuje, co trzeba. Oto ona:


(przeskocz procedurę _pisz_ch)
; FASM: segment readable executable
section .text

_pisz_ch:

;we: AL=cyfra heksadecymalna do wypisania 0...15
; CF=1 jeśli błąd

	push eax		; zachowaj modyfikowane rejestry: AX, Flagi
	pushfd

	cmp al,9		; Sprawdzamy dane wejściowe :
				; AL jest w 0-9 czy w 10-15?
	ja _ch_hex		; AL < 9. Skok do "_ch_hex"
	or al,"0"		; 0 < AL < 9. Or ustawia 2 bity,
				; czyniąc z AL liczbę z
				; przedziału 30h - 39h, czyli od "0" do "9".
				; Można było napisać
				; "add al,30h", ale zdecydowałem się
				; na "or", bo jest
				; szybsze a efekt ten sam.

	jmp short _ch_pz	; AL już poprawione. Skacz do miejsca,
				; gdzie wypisujemy znak.

_ch_hex:			; AL > 9. Może będzie to cyfra hex,
				; może nie.
	cmp al,15		; AL > 15?
	ja _blad_ch		; jeśli tak, to mamy błąd
	add al,"A"-10		; Duży skok myślowy. Ale wystarczy to
				; rozbić na 2 kroki i
				; wszystko staje się jasne. Najpierw
				; odejmujemy 10 od AL.
				; Zamiast liczby od 10 do 15 mamy już
				; liczbę od 0 do 5. Teraz tę liczbę
				; dodajemy do "A", czyli kodu ASCII litery
				; A, otrzymując znak od "A" do "F"

_ch_pz:				; miejsce wypisywania znaków.
	mov	[znak], al

	mov	eax, 4		; funkcja wypisywania
	mov	ebx, 1		; ekran
	mov	ecx, znak
	mov	edx, 1
	int	80h


	popfd			; zdejmij ze stosu flagi
	clc			; CF := 0 dla zaznaczenia braku błędu
				; (patrz opis procedury)
	jmp short _ch_ok	; skok do wyjścia

_blad_ch:			; sekcja obsługi błędu (AL > 15)
	popfd			; zdejmij ze stosu flagi
	stc			; CF := 1 na znak błędu

_ch_ok:				; miejsce wyjścia z procedury
	pop eax			; zdejmij modyfikowane rejestry

	ret			; return, powrót

; FASM: segment readable writeable
section .data

znak	db	0

To chyba nie było zbyt trudne, co?
Szczegóły dotyczące pisania procedur (i bibliotek) znajdują się w moim artykule o pisaniu bibliotek.

Teraz pokażę pewien program, który wybrałem ze względu na dużą liczbę różnych instrukcji i sztuczek. Niestety, nie jest on krótki, ale wspólnie spróbujemy przez niego przejść.


(przeskocz program zliczający liczby pierwsze)
; Program liczy liczby pierwsze w przedziałach
;   2-10, 2-100, 2-1000,... 2-100.000
;
; Autor: Bogdan D.
; kontakt: bogdandr (at) op (dot) pl
;
; kompilacja:
;
; nasm -f elf ile_pier.asm
; ld -s -o ile_pier ile_pier.o
;
; kompilacja FASM:
; fasm ile_pier.asm ile_pier


; format ELF executable		; tylko dla FASMa
; entry _start

; FASM: segment readable executable
section .text

global _start			; FASM: usunąć

_start:				; początek...

	xor ebx,ebx		; EBX = liczba, którą sprawdzamy,
				; czy jest pierwsza. Zaczniemy od 3.
				; Poniżej jest 3 razy "inc"
				; (zwiększ o 1). Najpierw
				; EBX = 0, bo "xor rej,rej" zeruje dany
				; rejestr

	xor edi,edi		; EDI = bieżący licznik liczb pierwszych

	xor ecx,ecx		; ECX = stary licznik liczb
				; (z poprzedniego przedziału)
				; Chwilowo, oczywiście 0.

	inc ebx			; EBX = 1
	mov esi,10		; ESI = bieżący koniec przedziału.
	inc edi			; EDI=1. uwzględniamy dwójkę, która
				; jest liczbą pierwsza
	inc ebx			; EBX=2, pierwsza liczba będzie = 3

petla:				; pętla przedziału

	cmp ebx,esi		; czy koniec przedziału?
				; (ebx=liczba, esi=koniec przedziału)
	jae pisz		; EBX >= ESI - idź do sekcji
				; wypisywania wyników

	mov ebp,2		; EBP - liczby, przez które
				; będziemy dzielić.
				; pierwszy dzielnik = 2

	inc ebx			; zwiększamy liczbę. EBX=3. Będzie
				; to pierwsza sprawdzana

spr:				; pętla sprawdzania pojedynczej liczby

	mov eax,ebx		; EAX = sprawdzana liczba
	xor edx,edx		; EDX = 0
	div ebp			; EAX = EAX/EBP (EDX było=0),
				; EDX=reszta z dzielenia

	or edx,edx		; instrukcja OR tak jak wiele innych,
				; ustawi flagę zera ZF na 1, gdy jej
				; wynik był zerem. W tym przypadku
				; pytamy: czy EDX jest zerem?

	jz petla		; jeżeli dzieli się bez reszty
				; (reszta=EDX=0),
				; to nie jest liczbą pierwszą
				; i należy zwiększyć liczbę
				; sprawdzaną (inc ebx)

	inc ebp			; zwiększamy dzielnik

	cmp ebp,ebx		; dzielniki aż do liczby
	jb spr			; liczba > dzielnik - sprawdzaj
				; dalej tę liczbę. Wiem, że
				; można było sprawdzać tylko do
				; SQRT(liczba) lub LICZBA/2, ale
				; wydłużyłoby to program i brakowało
				; mi już rejestrów...

juz:				; przerobiliśmy wszystkie dzielniki,
				; zawsze wychodziła reszta
				; więc liczba badana jest pierwsza

	inc edi			; zwiększamy licznik liczb znalezionych
	jmp petla		; sprawdzaj kolejną liczbę aż
				; do końca przedziału

				; sekcja wypisywania informacji

pisz:

	push ebx		; zachowujemy modyfikowane
				; a ważne rejestry
	push ecx

	mov	eax, 4
	mov	ebx, 1
	mov	ecx, przedzial
	mov	edx, dlugosc_przedzial
	int	80h		; wypisujemy informację o przedziale

	mov	eax,esi		; EAX=ESI=koniec przedziału
	call	_pisz_ld	; wypisz ten koniec (EAX)

	mov	eax, 4
	mov	ebx, 1
	mov	ecx, dwuk
	mov	edx, 1
	int	80h		; wypisujemy dwukropek

	pop ecx

	add ecx,edi		; dodajemy poprzednią liczbę
				; znalezionych liczb pierwszych
	mov eax,ecx		; EAX = liczba liczb pierwszych
				; od 2 do końca bieżącego przedziału

	call _pisz_ld		; wypisujemy tę liczbę.

	pop ebx

	cmp esi,100000		; 10^5
	jb dalej		; ESI > 100.000? Tak - koniec,
				; bo dalej liczy zbyt długo
koniec:
	mov	eax, 4
	mov	ebx, 1
	mov	ecx, przedzial
	mov	edx, 1
	int	80h		; wypisujemy znak nowej linii


	xor	ebx, ebx	; kod wyjścia = 0
	mov	eax, 1
	int	80h		; wyjście z programu

dalej:

	mov eax,esi		; EAX=ESI
	shl eax,3		; EAX = EAX*8
	shl esi,1		; ESI=ESI*2
	add esi,eax		; ESI=ESI*2+EAX*8=ESI*2+ESI*8=ESI*10.
				; Znacznie szybciej niż MUL

	xor edi,edi		; bieżący licznik liczb

	jmp petla		; robimy od początku...

_pisz_ld:

;we: EAX=liczba bez znaku do wypisania

	push ebx
	push ecx		; zachowujemy modyfikowane rejestry
	push edx
	push eax
	push esi

	xor esi,esi		; SI=0. Będzie wskaźnikiem w
				; powyższy bufor.

	mov ecx,10		; będziemy dzielić przez 10,
				; aby uzyskiwać kolejne cyfry
				; Reszty z dzielenia pójdą do
				; bufora, potem będą wypisane
				; wspak, bo pierwsza reszta
				; jest przecież cyfrą jedności
_pisz_ld_petla:
	xor edx,edx		; EDX=0

	div ecx			; EAX = EAX/ECX, EDX = reszta,
				; która mieści się w DL, bo to
				; jest tylko 1 cyfra dziesiętna.

	or  dl, "0"

	mov [_pisz_bufor+esi],dl	; Cyfra do bufora.

	inc esi			; Zwiększ numer komórki w buforze,
				; do której będziemy teraz pisać

	or eax,eax		; EAX = 0 ?

	jnz _pisz_ld_petla	; Jeśli nie (JNZ), to skok do
				; początku pętli

_pisz_ld_wypis:

	mov	eax, 4
	mov	ebx, 1
	lea	ecx, [_pisz_bufor+esi-1]
	mov	edx, 1
	int	80h

	dec esi			; zmniejsz wskaźnik do bufora.

	jnz _pisz_ld_wypis	; Jeśli ten wskaźnik (ESI) nie
				; jest zerem, wypisuj dalej

	pop esi			; odzyskaj zachowane rejestry
	pop eax
	pop edx
	pop ecx
	pop ebx

	ret                     ; powrót z procedury

; FASM: segment readable writeable
 section .data

 _pisz_bufor: times 20 db 0       ; miejsce na cyfry dla procedury

 przedzial       db      10,"Przedzial 2-"

; FASM:  dlugosc_przedzial       =     $ - przedzial
 dlugosc_przedzial       equ     $ - przedzial

 dwuk            db      ":"

Kilka uwag o tym programie:

Wiem, że ten program nie jest doskonały. Ale taki już po prostu napisałem...
Nie martwcie się, jeśli czegoś od razu nie zrozumiecie. Naprawdę, z czasem samo przyjdzie. Ja też przecież nie umiałem wszystkiego od razu.

Inny program do liczb pierwszych znajdziecie tu: prime.txt.

Następnym razem coś o ułamkach i koprocesorze.

Podstawowe prawo logiki:
Jeżeli wiesz, że nic nie wiesz, to nic nie wiesz.
Jeżeli wiesz, że nic nie wiesz, to coś wiesz.
Więc nie wiesz, że nic nie wiesz.


Poprzednia część kursu (Alt+3)
Kolejna część kursu (Alt+4)
Spis treści off-line (Alt+1)
Spis treści on-line (Alt+2)
Ułatwienia dla niepełnosprawnych (Alt+0)



Ćwiczenia

(można korzystać z zamieszczonych tu procedur)
  1. Napisz program, który na ekranie wyświetli liczby od 90 do 100.

  2. Napisz program sprawdzający, czy dana liczba (umieścisz ją w kodzie, nie musi być wczytywana znikąd) jest liczbą pierwszą.

  3. Napisz program wypisujący dzielniki danej liczby (liczba też w kodzie).