IT rekvalifikace s garancí práce. Seniorní programátoři vydělávají až 160 000 Kč/měsíc a rekvalifikace je prvním krokem. Zjisti, jak na to!
Hledáme nové posily do ITnetwork týmu. Podívej se na volné pozice a přidej se do nejagilnější firmy na trhu - Více informací.

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 a RET
  • 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:

Výstup - Základy assembleru

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".


 

Předchozí článek
Úvod do programování v Assembleru
Všechny články v sekci
Základy assembleru
Přeskočit článek
(nedoporučujeme)
Hello world v ASM ve Windows
Článek pro vás napsal Jakub Verner
Avatar
Uživatelské hodnocení:
10 hlasů
Autor se věnuje programování v x86 Assembleru.
Aktivity