Lekce 2 - Assembler - Zásobník
V minulé lekci, Úvod do programování v Assembleru, jsme si řekli co to Assembler vlastně je, proč se jej učit, a dokonce si i ukázali jak vypadá.
V dnešním ASM tutoriálu se podíváme na to, co je to zásobník. Na první pohled se může zdát složitý, ale práce s ním je nesmírně jednoduchá. Avšak je tu pár věcí, na které si musíme dát pozor.
Zásobník
Zásobník (anglicky stack) je struktura pro uložení dat. Vlastně se jedná o vyhrazený úsek paměti, se kterým se pracuje pomocí instrukcí pro práci se zásobníkem. Jednotlivé hodnoty v něm jsou ukládané způsobem LIFO (Last In - First Out, tedy co se uloží poslední, to při čtení ze zásobníku získáme jako první), ale nesmíme zapomenout na strukturu označovanou jako fronta (anglicky queue). Ta je typu FIFO (First In - First Out, co se uloží první, jde jako první ven).
Zásobník můžeme u x86 procesorů používat v podstatě ke 3 účelům:
- jako programátor pro data, která si chceme uložit, např. kopii registrů
- některé instrukce používají zásobník pro uložení a obnovení adres,
např. pár instrukcí
CALL
aRET
- a také ho můžeme používat k předávání parametrů funkcím
Registry
Pro práci se zásobníkem se používají tři registry. Jedná se
segmentový registr SS
a dva offsetové registry SP
a
BP
.
Registrový pár SS:SP
Registry SS
a SP
tvoří registrový pár
ukazující na vrchol zásobníku. Zásobník si programátor nastaví sám,
podle toho, kde ho potřebuje mít. V tomto případě se jedná o zásobník,
kde se hodnota s každým uložením sníží. Na zásobník se
v případě x86 procesorů (pomocí instrukcí PUSH
a
POP
) ukládá slovo. r/imm16
značí, že jako druhý
operand se použije buď 16bitový registr, nebo tzv. okamžitá 16bitová
hodnota (konstanta).
Celý proces ukládání (instrukci PUSH
) bychom mohli rozložit
na tento kód:
dec sp mov word [ss:sp], r/imm16
Takový kód ale reálně fungovat nemůže, protože registr SP není možné v 16bitovém režimu přímo používat jako offsetový registr. Fungoval by tento kód:
dec sp mov bp, sp mov word [ss:bp], r/imm16
Registr BP
Registr BP
se používá jako všeobecný, ale setkáme
se s ním převážně při práci se zásobníkem. Nejčastěji jím budeme
nahrazovat registr SP
.
Druhým nejčastějším případem, kdy ho použijeme, jsou přerušení BIOSu. Tam představuje offsetovou část adresy na data, která si od BIOSu vyžádáme, např. ukazatel na font.
Instrukce pro práci se zásobníkem
Se zásobníkem pracujeme pomocí následujících instrukcí.
Instrukce PUSH
Instrukce PUSH
slouží k uložení další hodnoty na
zásobník. Má pouze jeden operand, kterým může být proměnná,
konstanta nebo registr.
Pracuje se s ní následovně:
mov ax, 0xffff
push ax
Do registru AX
přesouváme hodnotu 0xffff
a
pomocí instrukce PUSH
ukládáme tuto hodnotu z registru na
zásobník.
Instrukce POP
Když používáme instrukci PUSH
, měli bychom spolu s ní
používat i instrukci POP
. Ta totiž slouží k výběru
dat ze zásobníku. Vrátí naposledy přidanou hodnotu a zvýší
ukazatel. Má opět pouze jeden operand.
Samotné používání jedné či druhé instrukce může vést k přetečení nebo podtečení zásobníku.
Pracuje se s ní takto:
pop ax
Vybíráme hodnotu ze zásobníku a ukládáme ji do registru
AX
.
Výměna hodnot mezi registry a zásobníkem
Jak tyto dvě instrukce správně zkombinovat?
Instrukce PUSH
a POP
se nejčastěji používají,
když potřebujeme pracovat s nějakým registrem, ale nechceme jeho hodnotu
změnit trvale. Tím máme na mysli, že si jej půjčíme a pak do něj
vrátíme hodnotu, která v něm byla předtím.
Zde je ukázka toho, jak si do registru AX
uložíme hodnotu
0x1234
. Pak ji uložíme na zásobník pomocí PUSH
,
hodnotu v registru změníme a pak obnovíme původní hodnotu pomocí instrukce
POP
:
mov ax, 0x1234 push ax mov ax, 0xffff pop ax
Instrukce je také možné použít jako náhradu instrukce MOV
.
To se hodí např. při nastavování segmentových registrů:
mov ax, 0xdead
push ax
pop es
Někdy ale potřebujeme uložit více hodnot najednou, což si ukážeme hned jako další.
Instrukce PUSHA
Instrukce PUSHA
nám na zásobník uloží hodnoty
většiny registrů (AX
, CX
, DX
,
BX
, originální SP
, BP
, SI
a DI
). Možná sis již stihl vyvodit, že to A
navíc
znamená All - Vše. Nemá žádný operand:
mov ax, 0x0123 mov cx, 0x4567 mov dx, 0x89ab mov bx, 0xcdef pusha xor ax, ax xor cx, cx xor dx, dx xor bx, bx popa
Registry jsou opravdu ukládány v pořadí AX, CX, DX a BX.
Všem registrům jsme nastavili nějaké hodnoty, přepsali jsme je a opět obnovili.
Instrukce POPA
Určitě už ani nemusím říkat, co tato instrukce dělá. Zkrátka a
jednoduše nám vybere všechny hodnoty ze zásobníku a uloží je do
příslušných registrů (AX
, CX
, ...). Tyto
instrukce by se měly používat jako instrukční pár.
Jako příklad můžeme použít předchozí kód.
Instrukce PUSHF
Tato instrukce opět souvisí s ukládáním hodnot na zásobník, ale také s registrem EFLAGS, konkrétně se spodními 16 bity. Většina instrukcí ovlivňuje příznakový registr, ale i ten je někdy nutné zachovat. Proto existuje tato instrukce:
pushf
Instrukce POPF
A opět zde máme instrukci do páru s PUSHF
. Instrukce
POPF
zase uloženou hodnotu vybere, obnoví příznakový
registr:
popf
Přetečení a podtečení zásobníku
Nyní si povíme o chybách, které můžeme jako programátor u zásobníku udělat.
Podtečení zásobníku
Pokud bychom opakovaně používali instrukci POP
,
POPA
nebo POPF
, došlo by k podtečení zásobníku.
Při práci se zásobníkem se stále mění ukazatel SP
(Stack
Pointer). Pokud bychom hodnoty pouze vybírali, hodnota by se stále zvyšovala
a zásobník by podtekl. Vlastně bychom začali vybírat hodnoty, které už
jsou mimo jeho vyhrazenou oblast. V extrémním případě by ukazatel
SP
podtekl z hodnoty 0xfffe
na hodnotu
0x0000
a vybíraly by se hodnoty z této a vyšší adresy.
Přetečení zásobníku
Opak podtečení je přetečení. To můžeme vyvolat opakovaným
ukládáním na zásobník pomocí instrukce PUSH
,
PUSHA
nebo PUSHF
. Často stačí vyhradit pouze malý
prostor, nebo udělat chybu při uplatňování rekurze. Zde bychom hodnoty zase
ukládali přes rámec vyhrazeného prostoru. V nejhorším případě by
ukazatel SP
přetekl z hodnoty 0x0000
na hodnotu
0xfffe
a ukládalo by se sem a na nižší adresy.
Implementace zásobníku
Zásobník implementujeme tak, že vyhradíme určitou oblast v paměti a
nastavíme segment, kde se bude zásobník nacházet (k tomu slouží registr
SS
). Do registru SP
vložíme adresu, nejlépe konce
této oblasti. Někdy se doporučuje snížit adresu dna o jedna (podle vybrané
adresy), jelikož na zásobník se ukládá slovo a SP
se
snižuje/zvyšuje o sudou dvojku. Pokud by byla adresa lichá, mohlo by to
způsobit problém při ukládání na poslední volné místo (tam se však
při dobré implementaci nedostaneme).
Důležité je říct, že zásobník jde shora
dolů. Pokud bude dno zásobníku na adrese 0xfffe
,
ukazatel se bude s každým uložením sníží.
Takto si tedy nastavíme zásobník:
xor ax, ax mov ss, ax mov sp, 0xfffe
V kódu nejprve vynulujeme registr AX
a jeho hodnotu přesuneme
do registru SS
. Pak už jen přesuneme hodnotu 0xfffe
do SP
.
Možná vás napadlo, že by to šlo jinak? Jestli uvažujete nad těmito variantami, tak je to bohužel špatně:
xor ss, ss mov ss, 0x0000
Veškeré segmentové registry (např. DS
,
ES
a SS
) se nastavují pomocí jiného registru
(např. AX
, BX
), zásobníkem nebo hodnotou z
paměti.
Páry registrů
Nyní si pro představu ukážeme, jak jsou na zásobníku uloženy hodnoty registrů. Vždy se začínám vyšší registrem (xH) a po něm následuje nižší (xL).
Asi takto by to vypadalo:
Zásobník | Adresa ↓ |
---|---|
XX | 0xfffe |
XX | 0xfffd |
AH | 0xfffc |
AL | 0xfffb |
BH | 0xfffa |
BL | 0xfff9 |
CH | 0xfff8 |
CL | 0xfff7 |
Instrukce CALL
a
RET
To ale není vše. Se zásobníkem úzce souvisí i, již zmíněné,
instrukce CALL
, RET
a pro nás nová
RETF
.
Instrukce CALL
Všem určitě došlo, že pomocí této instrukce budeme „něco“ volat.
To „něco“ může být námi vytvořená funkce či adresa (např.
nahraného programu). Jak souvisí se zásobníkem? Instrukce se používá,
když počítáme s tím, že se vrátíme na pozici, kde jsme instrukci
CALL
použili, resp. na následující instrukci. Adresa
následující instrukce se totiž ukládá na zásobník:
call moje_funkce
inc ax
jmp $
moje_funkce:
mov ax, 0xfffe
ret
V kódu zavoláme funkci moje_funkce
, která nám do registru
AX
přesune hodnotu 0xfffe
. Z funkce se pomocí
instrukce RET
vrátíme zpět, přičteme jedna a zastavíme
provádění kódu.
JMP $
můžeme nahradit kombinací instrukcí
CLI
a HLT
. Toto si ještě vysvětlíme.
Instrukce RET
Instrukce CALL
a RET
opět tvoří pár. Jak už
jsme si mohli všimnout, instrukce RET
slouží k návratu na onu
adresu, odkud jsme funkci volali. Ač to může vypadat zvláštně, tato
instrukce může mít operand. Jedná se o 16bitovou konstantu, která funkci
řekne, kolik bajtů má vybrat ze zásobníku. Uplatnění nalezneme při
předávání parametrů funkci přes zásobník. O tom ale až později.
Možnosti zápisu:
ret ; Vyber ze zasobniku IP ret imm16 ; Vyber ze zasobniku IP a dalsich x bajtu
Instrukce RETF
Mezi instrukcemi RET
a RETF
je jeden zásadní
rozdíl. Abychom ho lépe pochopili, řekněme, že instrukce RET
se dá označit jako RETN
. RETN
znamená Return Near -
Blízký návrat. Oproti tomu RETF
znamená Return Far - Vzdálený
návrat.
Instrukce RET
(RETN
) obnoví pouze registr
IP
, ale RETF
obnoví IP
spolu s
CS
. To nám umožňuje opustit kód v segmentu, ve kterém se
nacházíme, a začít provádět např. nějaký program. Tuto instrukci budeme
používat při psaní dynamických knihoven a při návratu do OS z
programu.
Možná jste slyšeli i o instrukci IRET
, ale o té
si povíme jindy v souvislosti s přerušeními.
Teď ještě jak to všechno dát dohromady... Jako takovou ukázku si uveďme následující kód:
bits 16 org 0x7c00 main: cli xor ax, ax ;mov ds, ax ;mov es, ax mov ss, ax mov sp, 0x7c00 ;cld sti pusha mov al, 0x21 pridej_jedna: call vypis_znak inc al cmp al, 0xff jb pridej_jedna popa halt: cli hlt jmp halt vypis_znak: push ax push bx mov ah, 0x0e xor bh, bh int 0x10 pop bx pop ax ret
Výstup:
Tento kód je až směšně jednoduchý, že? Nejprve podotknu, že je
možné jej nabootovat - příklad si tedy můžeme vyzkoušet i na svém
vlastním počítači. Jako první sdělíme překladači direktivou
bits 16
, že chceme kód přeložit jako 16bitový. Direktivou
org 0x7c00
mu řekneme, ať ke všem použitým adresám přičte
0x7c00. Následovně zrušíme maskovatelná přerušení (nevztahuje se na
softwarově generovaná a NMI přerušení). Pak vynulujeme registr
AX
a nastavíme jím SS
(popřípadě i
DS
a ES
). Do registru SP
přesuneme
hodnotu 0x7c00, takže se začne ukládat na 0x7bfe a postupně se bude klesat.
Zásobník tak bude mít vyhrazený úsek od 0x00000500 po 0x00007bfe (něco
málo přes 30 KB). Jako další můžeme nastavit DF
na 0, čímž
bychom při operacích s řetězci pracovali odpředu - to je však nyní
nepodstatné, povolíme maskovatelná přerušení a přejdeme do hlavní
části programu. Uložíme „všechny“ registry na zásobním a
inicializujeme registr AL
. Hodnota 0x21 (33) slouží k výpisu
znaků od vykřičníku (!).
V cyklu jako první zavoláme funkci pro výpis znaku a počkáme na návrat
z této funkce. Následovně zvýšíme hodnotu v AL
o jedna a
zkontrolujeme, jestli je menší než 0xff (255). Pokud ano, cyklus
opakujeme.
Po vypsání všech požadovaných znaků obnovíme registry a přejdeme do
menšího „halt“ cyklu. Instrukce cli
zakáže maskovatelná
přerušení a hlt
bude na nějaké čekat. Protože jsou ale
zrušena, může se na první pohled zdát, že zde program zamrzne. Instrukce
cli
se však nevztahuje na NMI, což jsou vnější nemaskovatelná
přerušení. To znamená, že je zde stále šance, že by mohl program
pokračovat a něco by mohlo přerušení opět povolit. Proto nás v takovém
případě vrátí následující instrukce jmp
na instrukci
cli
.
Jako poslední v programu je funkce pro výpis. Jsem si jistý, že tuto funkci již rozebírat nemusíme.
V příští lekci, Hello world v ASM ve Windows, si vytvoříme první jednoduchý program, který
zobrazí text "Hello world"
.