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 (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

(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).