Možná máte zájem naučit se programovat v asembleru, ale nevíte, kde začít. Slyšeli jste, že se v tomto jazyce píší bootsektory, ovladače zařízení, kompilátory, operační systémy, a tak zkoušíte něco takového napsat a pak zahlcujete odborná fóra dotazy, kde přiznáváte, že jste v asembleru začátečník a něco vám nefunguje. Než se pustíte do podobných specializovaných úkolů, je třeba získat základní orientaci a praxi v používání nástrojů jako je asembler, disasembler, prohlížeč či analyzátor souborů apod. To se nejlépe provede jejich častým používáním, nejprve na jednoduchých příkladech typu "Hello world", kalkulačka, kreslení ASCII grafikou, později i na méně triviálních aplikacích. Nemusíte se bát, že je psaní v asembleru oproti jiným jazykům pomalejší; samotným psaním na klávesnici strávíme mnohonásobně méně času, než přemýšlením nad logikou programu, a to platí ve všech jazycích.
Předpokládejme, že jste někde zahlédli pár asemblerových instrukcí, které byste chtěli vyzkoušet, ale teď nastává hlavní problém práce s novým programovacím jazykem: kam tyto instrukce napsat a jak zařídit, aby je váš počítač provedl. Zde se dozvíte jak.
Tento tutoriál je určen zájemcům o programování v asembleru pro osobní počítače architektury x64_32 (Intel, AMD) a budeme psát programy pro operační systémy MS Windows, Linux, DOS.
Předpokládejme, že máte aspoň základní znalosti angličtiny, umíte ovládat počítač, instalovat a spouštět programy z příkazového řádku, umíte editovat soubory prostým textovým editorem (nano, joe, Notepad apod.), znáte hexadecimální zápis čísel a umíte základní operace s hexadecimálními čísly, například umíte spočítat příklady typu
zpaměti, na papíře nebo aspoň s pomocí programátorské kalkulačky.
Z pohledu programátora aplikací se počítač skládá z
Nejmenší jednotkou informace je jeden bit, jehož fyzickou realizaci v CPU si můžeme představit
jako klopný obvod se dvěma stabilními stavy, který může být překlopen tak, aby na výstupu poskytoval napětí,
kterému jsme přiřadili hodnotu buď logická 1 nebo logická 0 (a nic mezi tím).
Klopný obvod si pamatuje svůj stav a může ho na požádání změnit (zapsat nebo přečíst).
Spojení více klopných obvodů tak, aby se jejich bity daly zapisovat/číst najednou, se říká registr.
Ten může být v osobních počítačích osmibitový, šestnáctibitový, třiadvacetibitový atd. až 512bitový.
Zápis obsahu registru pomocí nul a jedniček by byl nepřehledný, místo toho se obvykle obsah registru
zapisuje pomocí dvou hexadecimálních cifer na každý jeho bajt, tedy na 8 bitů.
Registry jsou umístěny na čipu CPU. Na rozdíl od pamětí jsou registry velmi rychlé, ale jejich počet a velikost jsou omezeny. Budeme pracovat hlavně s obecnými registry (General Purpose Registers, GPR). K registrům programátor přistupuje pomocí jejich jména (nikoli adresy), přičemž u většiny GPR lze kromě celého registru jmenovat také jejich podmnožiny. Dolní polovina 64bitového registru RAX se nazývá EAX, dolní čtvrtina AX, dolní osmina AL. Kompletní přehled registrů je v manuálu. Někde používáme označení rAX, rBX atd. Malé písmeno r zde vyjadřuje, že rAX může reprezentovat registry RAX, EAX nebo AX, v závislosti na aktuálním módu procesoru.
Obdobně jako RAX lze rozdělit i ostatní obecné registry RBX, RCX, RDX, RBP, RSI, RDI, RSP, R8..R15.
Obecné registry jsou v některých instrukcích specializovány nebo pevně přiřazeny, čemuž částečně odpovídá i mnemotechnika jejich názvu.
Ostatní registry, jako ST0..ST7 (matematický koprocesor), MM0..MM7 (multimediální), YMM0..YMM15 (SIMD registry) jsou ortogonální, tzn. nespecializované a vzájemně zaměnitelné.
Kromě registrů existují na čipu CPU klopné obvody zvané příznaky (flags), které se nastavují automaticky po určitých operacích, zejména aritmetických. Budou nás zajímat hlavně tyto:
Ostatní flagy, jako Parity, Auxilliary, Trap, Interrupt se v běžných aplikacích obvykle neužívají. Na příznaky můžeme nahlížet jako na samostatné klopné obvody s pamětí jednoho bitu. Pouze pro účely jejich zápisu na zásobník se sdružují do virtuálního registru, se kterým lze manipulovat instrukcemi PUSHF, POPF a obnovovat tak všechny flagy najednou.
K registrům lze započítat také ukazatel instrukcí rIP, který při vykonávání instrukce ukazuje za ni na adresu následující instrukce. Pouze u skokových instrukcí skoků se adresa v rIP nahrazuje cílovou adresou, na niž se má skákat.
Carry Flag je vyjímečný tím, že jej můžeme nastavovat na 1 instrukcí
STC, nulovat instrukcí
CLC nebo měnit jeho hodnotu na opačnou pomocí
CMC.
Podobně se ještě dají nastavovat a nulovat Direction Flag pomocí
STD a
CLD,
a Interrupt Flag pomocí
STI a
CLI.
Ostatní příznaky takto explicitně měnit nelze, ale například Zero Flag
můžeme nastavit na 1 vynulováním některého registru pomocí SUB reg, reg
.
CPU je spojen s pamětí pomocí datové a adresní sběrnice (soustava vodičů). Když CPU potřebuje něco načíst nebo zapsat, nastaví adresu na adresní sběrnici a zapsaná data přečte nebo zapíše na datovou sběrnici.
Obdobným způsobem funguje čtení a zápis do zařízení. K zařízením patří klávesnice, monitor, myš, síťová karta a další obdobné periférie. Na rozdíl od paměti se datovým kombinacím sloužícím k jejich výběru neříká adresa, ale port, např. klávesnice má pevně určený port 64h, tiskárna mívá port 378h apod. Přehled portů osobního počítače nalezneme např. v TechHelp.
Obecně se dá o fungování počítače z pohledu programátora v asembleru říci, že
Tou manipulací může být aritmetická nebo logická operace, změna bitů, nastavení na nějakou hodnotu apod. Kroky, které má procesor provést, jsou určeny pomocí strojových instrukcí. Ty mají proměnnou délku 1 až 15 bajtů a jsou uloženy v operační paměti, jedna za druhou. CPU je postupně z paměti načítá a provádí.
Každá instrukce má mnemotechnickou zkratku (určenou výrobcem procesoru), za kterou následují operandy, jež určují odkud a kam se má informace zapsat. Úlohou programu zvaného asembler je převést mnemotechnické zkratky a operandy na hexadecimální kód strojových instrukcí a uložit je do souboru tak, aby byly spustitelné pomocí operačního systému.
Typická instrukce má dva operandy – vstupní a výstupní – a v intelské syntaxi se zapisují v pořadí
instrukce výstup, vstup
.
Například instrukce ADD EAX,ECX
přikazuje procesoru, aby k obsahu registru EAX (výstupní)
přičetl obsah vstupního registru ECX. Obsah registru ECX zůstává nezměněn.
Obsah obecných registrů se přitom považuje za celé číslo s pevnou desetinnou čárkou.
Uvedený příklad pracuje s dvaatřicetibitovými registry EAX a ECX, ale stejný postup bychom uplatnili
i pro sčítání registrů s šířkou 8, 16, 32 nebo 64 bitů, například ADD AH,CL
,
ADD AH,CH
, ADD RAX,RCX
. Zlomkové registry, které nemají jméno, jako např.
horní polovina EAX, třetí osmina RCX ap. takto přímo sčítat nelze, ale mohli bychom pomocí
instrukcí rotace dočasně posunout obsah požadovaného zlomkového registru do pojmenované části, provést sčítání
pomocí ADD AL,CL
a pak případně opačnou rotací vrátit sečtený registr zpět.
Vedle intelské syntaxe ještě existuje syntaxe vyvinutá ve firmě AT&T, ve které je přehozen vstup a výstup. Tou se zde ale nebudeme zabývat, neboť téměř všechny asemblery i výrobci procesorů používají syntaxi Intel.
Při sledování výukových materiálů si nelze nevšimnout mnoha nekonzistencí v popisování dat:
Obsah registru ECX ve výše uvedeném příkladu ADD EAX,ECX
je zapsán hexadecimálně
jako 56 78 9A BC
a začíná tedy jeho nejvíce významným bajtem 56
.
To je zdánlivě v rozporu s ukládáním 32bitového slova do paměti počínaje nejméně významným bajtem,
avšak slovo v registru si představujeme jinak než slovo v paměti.
Pokud bychom registr ECX uložili do paměti třeba na adresu 0
(což by se provedlo instrukcí MOV [0],ECX
) a pak zobrazili obsah paměti debugerem
nebo podobným nástrojem, viděli bychom
Z toho je patrné, že záleží na tom, zda na obsah paměťového místa nahlížíme jako na vícebajtové slovo, nebo jako na sérii bajtů.
Osobní počítače běží v dnešní době prakticky výhradně v chráněném módu (protected mode). Operační systém v tomto módu chrání především sám sebe, aby uživatel nemohl z nepozornosti anebo záměrně narušit paměť systému nebo jiných uživatelů, kteří by mohli současně na počítači pracovat. Při pokusu o čtení nebo zápis do paměti, která nebyla uživateli přidělena, se přístup neprovede. Obdobně je omezen i přístup ke vstupně-výstupním portům. Instrukce pro přímý zápis a čtení portů IN a OUT jsou v chráněném režimu privilegovány a jejich používání si pro sebe vyhrazuje operační systém.
V počítačovém pravěku ještě osobní počítače (tehdy pouze 16bitové) běžely v reálném módu operačního systému DOS, kdy měl uživatel pro sebe celou operační paměť i všechny porty. Na dnešních PC je tento režim dostupný už jen pomocí emulátoru, jako je DOSBox, tedy oproti nativnímu prostředí Linuxu nebo Windows poněkud nepohodlně. Přesto se kupodivu šestnáctibitový režim preferuje v asemblerových kursech, snad z (mylného) přesvědčení, že 16bitový režim je pro začátečníky snazší než 32bitový nebo 64bitový.
Je li počítač přepnut do 16bitového reálného módu, ať už v rámci emulace, nebo nabootováním
počítače do DOSu, budeme mít k dispozici pouze 16bitové registry AX, BX, CX, DX, BP, SP, SI, DI
a při adresaci paměti musíme brát do úvahy segmentové registry CS, DS, SS, ES.
Paměť můžeme oslovovat buď přímým zápisem adresy, např. MOV AX,[1234h]
,
nebo adresu nejprve nahrát do registru a ten pak použít k adresaci:
V reálném módu můžeme k adresaci použít nanejvýš jeden bázový registr BX nebo BP
a nanejvýš jeden indexový registr SI nebo DI, např. MOV AX,[BX+SI+1234h]
.
Při použití BP bude jako implicitní segmentový registr použit SS (není-li výslovně předepsáno jinak),
jinak se použije defaultní datový registr DS.
K adrese vypočtené jako součet obsahu registrů BX, SI a přímé hodnoty 1234h se ještě přičítá obsah 16bitového segmentového registru vynásobený 16, než se tato lineární adresa použije k přístupu do paměti.
V chráněném 32bitovém módu je adresace paměti podstatně jednodušší.
K dispozici máme 32bitové registry EAX, EBX, ECX, EDX, EBP, ESP, ESI, EDI
a k adresaci paměti můžeme použít libovolnou kombinaci až dvou těchto registrů,
například MOV EAX,[ECX+EDX+12346789h]
. Je-li v adresaci použit ESP nebo EBP,
segmentovým registrem se stává namísto defaultního DS registr SS. To je ale obvykle jedno,
protože DS, SS i ES obsahují v chráněném módu tutéž adresu a segmentovými registry se
nemusíme vůbec zabývat. Je tedy vidět, že 32bitový režim je pro programátora mnohem snazší
než 16bitový.
Obdobná pravidla adresace platí i v 64bitovém módu, navíc zde můžeme kromě RAX, RBX, RCX, RDX, RBP, RSP, RSI, RDI využívat i další obecné registry R8..R15. Segmentové registry CS, DS, SS, ES se zde vůbec nepoužívají.
Datový typ určuje, jak máme na datovou položkou nahlížet - zda ji brát jako celočíselnou hodnotu, číslo s plovoucí desetinnou tečkou, textový řetězec, bitovou mapu či jinou strukturu. Jejich přehled je v manuálu. Na rozdíl od vyšších programovacích jazyků nejsou datové typy asemblerem nijak hlídány. Základní datový typ je určen šířkou datové položky (např. 8, 16, 32, 64 bitů), ale nic nám nebrání třeba 64bitové číslo s plovoucí desetinnou tečkou interpretovat jako binární číslo nebo jako řetězec znaků (a dobrat se samozřejmě chybného výsledku). Obsah položky (paměťového místa) můžeme zobrazovat jako text, převést na číslo ve formátu člověkem očekávaného, můžeme je přehrávat jako zvuk, zobrazovat jako fotografii, cokoli.
Co můžeme ve formátu zápisu strojových instrukcí mnemotechická_zkratka výstup, vstup používat jako výstup, vstup? Jsou čtyři možnosti.
Instrukcím se říká strojové, protože jsou zabudovány do stroje (procesoru). V asembleru ještě můžeme vidět pseudoinstrukce, které vypadají podobně, ale jsou to direktivy pro asembler, nikoli pro procesor.
Nejnovější procesory architektury x86-64 mohou mít přes dva tisíce různých strojových instrukcí. Dobrá zpráva je, že k běžnému programování vystačíme jen s pár desítkami, které si teď popíšeme.
Autoritativní popis každé instrukce je nejlépe hledat na webu výrobce CPU, např. Intel. Jedná se ale většinou o rozsáhlé materiály ve formátu PDF, se kterými se špatně pracuje a odkazuje, takže se asi budete raději dívat na jejich podobu převedenou do webového formátu HTML.
Asi nejuniverzálnější instrukcí je MOV, zkratka od MOVE, tedy přesuň, přestěhuj. Což je poněkud nešťastně zvolený název, lepší by bylo COPY, neboť se jedná o kopírování dat z jednoho místa na druhé, a ne o jejich přesouvání. Informace ve vstupním registru nebo paměťovém místu zůstává zachována. Ostatně v počítači ani není možnost informaci z registru nebo z paměti odstranit, vždy tam nějaká zůstane, přinejmenším samé nuly.
Kromě kopírování mezi 8,16,32,64bitovými registry a paměťovými místy je stejná mnemonika (MOV) využita i k přesouvání mezi obecnými (GPR) a dalšími registry, jako jsou segmentové, kontrolní, ladicí, MMX nebo SIMD.
Pomocí MOV z paměti do registru načítáme obsah paměťového místa (bajtu, slova, dvojslova) do registru,
což se vyjadřuje uzavřením paměťového místa do hranatých závorek: MOV ECX,[1234h]
,
MOV ECX,[EBX]
apod.
Při vynechání závorek bychom namísto obsahu uloženého v paměti načítali adresu paměťového místa.
MOV ECX,1234h
by tedy naplnilo registr ECX číslem 1234h.
MOV ECX,EBX
by pouze zkopírovalo obsah registru EBX do registru ECX.
Neuvědomění si rozdílu mezi adresou (offsetem) paměťového místa a jeho obsahem je častým zdrojem chyb.
Další velmi často používanou instrukcí je LEA.
Zatímco MOV registr, [memory]
načítal obsah paměti a
MOV registr, memory
načítal adresu paměti,
LEA registr, [memory]
načítá adresu, přestože je druhý operand vždy v hranatých závorkách.
Instrukce LEA je navíc o jeden bajt delší než MOV, proč ji tedy používat?
Například tam. kde nás zajímá výpočet adresy a nechceme znát její obsah (který ani nemusí v paměti existovat).
Ve 32bitovém a 64bitovém módu můžeme využít adresaci paměti se dvěma obecnými registry
(jeden je bázový a druhý indexový), přičemž indexový registr může být navíc škálován,
tedy interně vynásoben dvěma, čtyřmi nebo osmi.
To dovoluje používat k jednodušším výpočtům adresní aritmetiku:
LEA EDX,[ESI+ECX]
naplní EDX součtem obsahu registrů ESI a ECX.
LEA EAX,[8*EAX]
naplní EAX osminásobkem původního obsahu EAX.
LEA EBX,[EDX+4*EDX]
naplní EBX pětinásobkem obsahu EDX.
Další oblastí využití LEA je získávání adresy v 64bitovém režimu, kde na rozdíl od MOV používá relativní adresaci místo absolutní a umožňuje tak oslovovat paměť v rozsahu plusminus 2 GB od instrukce LEA.
K instrukcím kopírování dat ještě patří XCHG, tedy vzájemná výměna informací mezi dvěma registry nebo mezi registrem a paměťovým místem.
Je dobré si pamatovat, že MOV, LEA, XCHG nechávají příznaky (flags) beze změny.
Jedinou instrukcí MOV můžeme načíst z paměti do registru 1 až 8 bajtů, případně až 64 bajtů pomocí VMOV*.
Procesor přístupy do paměti sdružuje, takže když načítáme nebo zapisujeme jen jeden bajt, např. pomocí
MOV AL,[RBX]
, MOV [RBX],AL
, ve skutečnosti
CPU načte najednou třeba 16 sousedních bajtů, z nichž pak vybere ten jeden požadovaný
a ostatní bajty nechá v původním stavu.
Nejmenší granularita přístupu do paměti je jeden bajt, programátor se ale sdružováním v procesoru nemusí zabývat.
Co kdybychom potřebovali změnit jen část bajtu, třeba jen jeden bit? O to už se musí postarat programátor sám: načíst celý bajt, změnit v něm pouze požadovaný bit (a ostatní ponechat tak jak jsou) a pak ten bajt zapsat zpět do paměti. K manipulaci s jednotlivými bity můžeme použít specializované instrukce BTS (nastavení bitu na 1), BTR (nulování) nebo BTC (změna na opačný). Anebo můžeme použít bitově-logické (bitwise) operace OR k nastavování bitů, AND k nulování bitů nebo XOR k překlopení bitů do opačného stavu.
Aritmetické instrukce počítají s celými čísly v binárním vyjádření. Obsah osmibitového registru
může být v rozmezí 00h až FFh, tedy dekadicky 0 až 255. Takové interpretaci říkáme
čísla bez znaménka (unsigned).
Kromě 8bitového registru můžeme celá čísla uložit také do 16, 32 nebo 64bitového registru.
Pokud k osmibitovému registru obsahujícímu např. číslo 78h=120 přičteme jiný registr s obsahem 9Ah=154,
třeba instrukcí ADD AL,BL
, součet činí 112h=274.
To je o 12h víc než je kapacita osmibitového registru (FFh), do registru AL se proto uloží hodnota jen 12h
a procesor nastaví Carry Flag na 1 jako signalizaci přetečení. Tato jednička přetečená z AL tedy nepřechází
do vyššího registru AH, jako by tomu bylo při 16bitovém sčítání ADD AX,BX
.
Můžeme ji ale započíst k dalšímu součtu, pokud bychom jej namísto
ADD provedli instrukcí
ADC, (Add with Carry).
U ní se k součtu ještě navíc přičte jednotka, pokud byl Carry Flag nastaven: ADC AH,0
.
V praxi se to využívá pro sčítání a odčítání delších čísel, než odpovídá kapacitě GPR.
Následující příklad ilustruje sčítání dvou velkých čísel v 16bitových registrech, když 32bitové registry nebyly k dispozici, jako tomu bylo v DOSu. Mějme například sečíst v 16bitovém režimu dvě 32bitová čísla 89ABCDEFh a 55556666h. Čísla rozdělíme do dvojic registrů DX:AX a BX:CX. Dvojtečka mezi názvy registrů zde představuje spojení dvou 16bitových registrů do jednoho virtuálního 32bitového.
Výsledek ve dvojici registrů DX:AX je tedy DF013455h.
K dalším aritmetickým operacím vedle ADD a ADC patří ještě SUB a SBB (Subtrack with Borrow). SBB se liší od prostého odčítání (SUB) tím, že je-li nastaven Carry Flag, odečte navíc ještě jedničku.
Podobnou instrukcí jako SUB je CMP, která ale nic neodčítá (obsah registrů se nemění), pouze nastavuje příznaky podle výsledku hypotetického odčítání.
Logické instrukce OR, AND a XOR provádějí stejnojmenné logické operace s operandy šířky 8, 16, 32, 64 bitů systémem každý s každým, tedy nultý bit výstupního operandu s nultým bitem vstupního, první bit s prvním bitem, druhý s druhým atd.
Celá čísla v osmibitovém registru 0..255 jsme považovali za bezznaménková. To ale není jediná možná interpretace; můžeme vyhradit nejvýznamnější bit pro znaménko a považovat tak číslo za znaménkové (signed). Pak budou hodnoty 01h..7Fh odpovídat kladným číslům 1..127 a hodnoty FFh..80h záporným -1..-128, Nula zůstává nulou. Číselný rozsah se tedy u osmibitového registru změnil na -128..+127, u širších registrů bude samozřejmě ještě mnohem větší. Krása binární aritmetiky spočívá v tom, že znaménková i bezznaménková čísla sčítají a odčítají stejně, pomocí stejných instrukcí ADD a SUB. Těmto instrukcím nezáleží na tom, zda jsme jim předložili znaménková či bezznaménková čísla, výsledek aritmetické operace můžeme interpretovat tak i onak.
Pokud operujeme se znaménkovými čísly, přetečení (vybočení z povoleného číselného rozsahu) se namísto Carry Flag signalizuje pomocí Overflow Flag.
Instrukce NEG
převádí pozitivní binární číslo na jeho negativní hodnotu a naopak.
To se provede tak, že změní všechny bity na opačné a k takto změněnému výsledku přičte jedničku.
V osmibitovém registru instrukce NEG AL
změní hodnotu AL z 02h na FEh, z 01h na FFh,
z 00h na 00h, z FFh na 01h atd.
Obdobná instrukce je NOT,
ta se liší od NEG tím, že k invertovaným bitům žádnou jedničku nepřičítá, hodí se tedy spíše k logickým operacím.
Užitečné aritmetické operace jsou INC a DEC, které zvyšují a snižují obsah registru nebo paměťového místa o jednotku. U těchto dvou instrukcí se musíme zapamatovat, že mění aritmetické příznaky s výjmkou CF.
K aritmetickým instrukcím ještě patří násobení a dělení. U nich ale neplatí, že by s kladnými i zápornými čísly počítaly stejně. Pokud chceme násobit nebo dělit binární čísla se znaménkem, musíme je buď převést na kladná (pomocí NEG) a vypočtenou hodnotu pak případně opět převést na zápornou, anebo namísto MUL a DIV použít jejich znaménkové varianty IMUL a IDIV. U násobení a dělení neplatí, že bychom mohli použít kterékoli registry. Výsledek násobení dvou 64bitových čísel může pro uložení výsledku vyžadovat až 128 bitů, proto se pro uložení součinu používá dvojice registrů rDX:rAX, která je pevně dána. U 32bitového násobení se výsledek ukládá do dvojice EDX:EAX, u 16bitového do DX:AX, pouze u osmibitového násobení je výjimka a výsledek násobení AL vstupní osmibitovou hodnotou jde do registru AX (DX zůstává nezměněn). K přetečení nemůže už z principu dojít, avšak souběžné nastavení Carry Flag a Overflow Flag na jednotky signalizuje, že výsledek je velký a přetekl do horní z dvojice výstupních registrů (do DX, EDX nebo RDX).
Při celočíselném dělení se použije opačný postup: dělenec se umístí do dvojice registrů rDX:rAX,
dělitelem může být jiný registr nebo paměťové místo odpovídající šířky.
Zde však k přetečení dojít může, pokud bude dělitel menší než číslo v horní polovině vstupního
registrového páru (DX, EDX,RDX), výsledek by se nevešel do dolní poloviny (AX, EAX, RAX)
a nebyl by tedy definován (tomu se říká dělení nulou).
Architektura x86 neví, jaké číslo by v takovém případě měla uložit do výstupního registru a vyvolá tedy
programovou výjimku (přerušení), což může způsobit havarijní ukončení našeho programu.
Před dělením je tedy třeba horní polovinu vstupní dvojice registru vynulovat (v případě DIV) nebo
naopak nastavit na samé jedničky při dělení záporných čísel pomocí IDIV.
K tomu nejlépe poslouží u bezznaménkového dělení nulování rDX pomocí SUB rDX,rDX
a při znaménkovém dělení použití krátké instrukce
CWD, CDQ nebo CQO.
Zásobník je souvislá oblast rezervovaná z celkového množství operační paměti a prohlášená za zásobník. Pro adresaci v zásobníku se používá obecný registr rSP (stack pointer). Zásobník nejčastěji slouží k dočasnému uložení a následné obnově obsahu obecných registrů instrukcemi PUSH a POP. Při zavedení programu do paměti se operační systém postará, aby rezervoval dostatečné množství paměti pro zásobník a nastaví jeho ukazatel ESP nebo RSP na jeho počátek, což ale není nejnižší adresa, nýbrž naopak nejvyšší. Při ukládání na zásobník pomocí PUSH adresy postupně klesají a naopak při vybírání ze zásobníku pomocí POP adresy stoupají.
Předmětem PUSH mohou být obecné registry v šířce 16, 32 nebo 64 bitů, nebo paměťové proměnné o stejné šířce, dále segmentové registry a také přímá číselná hodnota, kterou procesor znaménkově rozšíří na šířku operandu. Procesor nejprve sníží ukazatel zásobníku rSP o 2, 4 nebo 8 bajtů a do takto vzniklého místa uloží operand. Registr rSP tedy adresuje právě uloženou položku.
Operace POP funguje obráceně: přesune obsah 2, 4 nebo 8 bajtů adresovaných registrem rSP do operandu a pak zvýší rSP o 2, 4 nebo 8 bajtů.
Používání registru rSP k adresaci zásobníku je implicitní; v instrukcích PUSH a POP se uvádí pouze vstupní nebo výstupní operand. Některé asemblery dovolují psát více než jeden operand do jedné instrukce, což je ale interně implementováno jako řada samostatných instrukcí PUSH nebo POP. Zápis více operandů slouží hlavně k úspoře řádků zdrojového programu.
Ukládání a obnova ze zásobníku probíhá z principu metodou LIFO, tedy Last In, First Out. Registr uložený na zásobník jako poslední pomocí PUSH se pak následnou instrukcí POP obnoví jako první; musíme je tedy obnovovat v opačném pořadí, než jsme je ukládali. Příklad:
V 16 a 32bitovém režimu můžeme namísto ukládání a následné obnově většího počtu registrů použít instrukce PUSHA a POPA, které najednou ukládají a obnovují všech 8 GPR v pořadí eAX, eCX, eDX, eBX, eSP, eBP, eSI, eDI. Ukládat všech osm registrů je sice mnohdy zbytečné a pomalejší, ale zase šetříme velikost kódu, neboť PUSHA i POPA zabírají jen jeden bajt. V 64bitovém režimu PUSHA není k dispozici a musíme tedy registry ukládat individuálně.
Pokud si z výuky programovacích jazyků pamatujete poučky o zákazu skoků a o škodlivosti příkazu GOTO, v asembleru na to můžete zapomenout. Všechny programové konstrukty jako IF, ELSE, WHILE, SWITCH, REPEAT UNTIL atd. se zde provádějí pomocí podmíněných skoků, kdy se nejprve vyhodnotí nějaká podmínka, např. instrukcemi CMP nebo TEST, a pak se provede (nebo neprovede) skok na jiné místo v kódu pomocí instrukce podmíněného skoku Jcc. Tento skok má řadu variant lišících se podmínkou cc v mnemotechnickém názvu instrukce. Například JA (Jump if Above) nejprve zkoumá, zda platí současně CF=0 a ZF=0 a skočí na cílovou adresu (návěstí) pouze pokud jsou obě tyto podmínky splněny, jinak instrukci ignoruje a pokračuje tou následující pod ní.
Pojmy Above, Below se používají, pokud jsme porovnávali čísla bez znaménka, např. dvě adresy pomocí CMP. Pojmy Greater, Less naopak po porovnávání znaménkových celých čísel.
Rozdílů mezi skoky short a near si nemusíme všímat, je starostí asembleru, aby použil ten správný.
K podmíněným skokům patří ještě instrukce LOOPcc, která nejprve sníží o 1 obsah registru rCX a pokud je rCX nenulový, provede skok na návěstí uvedené v operandu instrukce, jinak pokračuje pod instrukcí LOOP. Pokud by byl rCX nulový už před instrukcí LOOP, nejprve se sníží na hodnotu CX=65535 nebo dokonce ECX=4294967295, tedy nenulové, a smyčka se pak bude právě tolikrát opakovat. Což jsme asi nechtěli, proto se registr rCX před instrukcí LOOP testuje pomocí JCXZ nebo JECXZ a smyčka se v případě jeho nulovosti přeskočí:
Vedle podmíněných skoků můžeme také skákat na jiné místo programu nepodmíněně, tedy pokaždé když se v toku instrukcí objeví JMP. Tato instrukce nahradí registr rIP (který normálně obsahuje adresu příští instrukce) adresou, na kterou se skáče.
S nepodmíněnými skoky souvisí dvojice instrukcí
CALL a
RET.
Podobně jako JMP, také instrukce CALL nahrazuje rIP adresou skoku, ale navíc ještě předtím
uloží obsah rIP na zásobník, podobně jako bychom hypoteticky provedli neexistující operaci PUSH rIP
.
Instrukce RET provede hypotetickou operaci POP rIP
, což je ekvivalentní skoku
na návratovou adresu, která byla uložena na zásobníku instrukcí CALL.
Zásobníkové instrukce umožňují rozdělit tok aplikace na kratší podprogramy (procedury) a program tak přehledně strukturovat. Každý podprogram či programové makro můžeme považovat za černou skříňku, zdokumentovat její vstup, výstup a funkci, a pak na detaily její implementace zapomenout.
Strojové instrukci CALL se podobá
INT vyvolávající
softwarové přerušení (interrupt). Jako operand se zadává číslo 0..255, které v reálném módu udává
pořadové číslo dvojslova v tabulce přerušení (IDT) udávajícího adresu rutiny zpracovávající dané přerušení.
Například INT 21h
se podívá na adresu 21h*4 a dvě 16bitová slova z této adresy
zavede do registrů IP a CS. Na této adrese by měl být podprogram provádějící funkci očekávanou od INT 21h;
ten se pak ukončí instrukcí IRET.
Rozdíl mezi instrukcemi CALL/RET a INT/IRET je v tom, že INT před uložením návratové adresy na zásobník tam navíc
uloží i příznaky a IRET je pak obnoví zpět.
Následujících osm instrukcí posuvů a rotací umožňují manipulovat s obsahem 8, 16, 32, 64bitového registru nebo paměťového místa. Počet posuvů se zadává ve druhém oprandu jako bezprostřední číslo nebo jako obsah pevně určeného registru CL. O tento počet bitů se obsah posouvá či rotuje buď směrem vlevo, tedy od nejméně významného bitu LSb k nejvýznamnějšímu MSb, anebo naopak vpravo, tedy od MSb k LSb.
Při instrukcích rotace přes carry (RCL, RCR) se k bitům registru ještě přidává bit Carry Flag jakožto devátý (17., 33., 65.) bit.
Aritmetické instrukce SAL, SAR slouží k rychlému násobení a dělení znaménkových čísel pomocí mocnin dvou,
např. SAL AX,4
je ekvivalentní násobení obsahu AX šestnácti (24),
SAR EBX,3
vydělí obsah EBX osmi atd. Proto taky u aritmetického posuvu vpravo (SAR)
nejvyšší znaménkový bit MSb kopíruje při každém kroku svou původní hodnotu.
Logické posuvy (SHL, SHR) se hodí k logickým operacím. Instrukce SHL a SAL se chovají identicky. Dva příklady:
Jak víme, instrukce mohou přesouvat data mezi registry navzájem a mezi registry a pamětí, ale nikoli z jednoho místa paměti do druhého. To není tak zcela pravda, existuje instrukce MOVS, která to dělá za cenu obejití standardního způsobu zakódování adresy (ModRM+SIB). Místo toho očekává, že adresa vstupu bude uložena do registru rSI a adresa výstupu do rDI. Množství přenesených dat závisí na příponě instrukce, tedy MOVSB, MOVSW, MOVSD, MOVSQ pro přenesení jednoho bajtu, 16bitového slova (WORD), 32bitového slova (DWORD) nebo 64bitového slova (QWORD). Počet přenesených slov jednou instrukcí může být i větší, pokud použijeme opakovací prefix REP. Ten specifikuje, že počet opakování přenosu jednoho elementu se má opakovat tolikrát, jaký je obsah rCX, přičemž po každém přenosu se rCX zmenší o 1 a adresy v registrech rSI a rDI se změní o velikost přenášeného slova, tedy o 1, 2, 4 nebo 8 bajtů. To, zda se adresy rSI a rDI budou o velikost slova zvětšovat nebo snižovat, závisí na Direction Flag.
Dva příklady použití MOVS ve 32bitovém módu, kdy jako indexové registry rSI, rDI jsou použity ESI,EDI:
Instrukce REP MOVSB
v předchozím příkladu nejprve přenesla ECX=3 bajty
z adresy v ESI=02h na adresu EDI=07h.
V pokračování příkladu jsme změnili směr přenosu pomocí STD
doleva,
změnili šířku elementu z jednoho bajtu (MOVSB) na jedno 16bitové slovo (MOVSW)
a předepsali přenos pouze ECX=2 těchto elementů z adresy ESI=05h na adresu EDI=0Ah.
Ke změně obsahu obou adresovacích registrů rSI, rDI tedy dochází až po přenosu jednoho elementu.
Není-li před instrukcí MOVS zadán prefix REP, provede se přenos právě jednoho elementu
(a oba registry se zvětší nebo zmenší o velikost elementu), jinak se zkoumá obsah rCX
a dokud je nenulový, přenos elementu včetně změny rSI, rDI se opakuje a rCX se pak sníží o jednotku.
Obsah rCX v REP MOVS
tedy určuje počet přenášených elementů. Pokud bylo rCX=0,
REP MOVS
se neprovede ani jednou a obsah registrů rCX, rSI, rDI se nemění.
Zajímavější situace nastává, pokud se přenášená pole částečně překrývají. V následujícím příkladu se překrývá zdrojový řetězec (adresa ESI=02h, délka ECX=5) s cílovým (adresa EDI=07h, délka ECX=5). V každém z pěti kopírovacích kroků se nejprve načte a přenese jeden element (v našem případě bajt), a to i v případě, že tento vstupní element byl před chvilkou přenesen. Pokud je ESI<EDI a DF=0, anebo pokud je ESI>EDI a DF=1, řetězec se nekopíruje, ale pouze se jeho část mezi počátečními adresami obou registrů rozmnoží po celé délce výstupního řetězce. Aby došlo k posunutí zdrojového řetězce dopředu nebo dozadu, muselo by platit ESI<EDI a DF=1, anebo ESI>EDI a DF=0.
Další užitečnou řetězcovou instrukcí je STOS, která ukládá obsah registru AL, AX, EAX nebo RAX do paměti na adresu uloženou v registru rDI, a po uložení obsah rDI o velikost elementu zvětší nebo zmenší, což záleží na Direction Flag. Také před touto instrukcí se často používá prefix REP a umožňuje tak vynulovat velké úseky operační paměti.
Opačným způsobem než STOS funguje LODS, která načítá paměť z adresy dané registrem ESI do AL, AX, EAX nebo RAX, a po načtení zvětší nebo zmenší rSI. U této instrukce nemá smysl používat prefix REP, neboť načítané hodnoty by přepisovaly obsah rAX ještě dříve, než bychom jej stačili nějak zpracovat. LODSB se používá se spolupráci se STOSB, pokud chceme kopírovat řetězec bajt po bajtu a zároveň reagovat na kopírované znaky. Příklad jednoduché konverze nulou zakončeného řetězce z malých písmen a..z na velká A..Z:
Řetězcová instrukce SCAS slouží k vyhledání pozice hodnoty v registru AL, AX, EAX nebo RAX ve vstupním řetězci adresovaném pomocí rDI. Porovnání nastaví příznaky a pak zvětší nebo zmenší rDI o šířku porovnávaného registru (1, 2, 4 nebo 8) v závislosti na Direction Flag. Instrukce se používá s prefixem REPNE (opakovat, pokud se nerovná), počet opakování je přitom určen obsahem rCX. Provádění instrukce se ukončí buď nalezením znaku v řetězci (nastaví se Zero Flag jakožto příznak shody), anebo vyčerpáním délky řetězce v registru rCX. Pokud operace skončila kvůli vynulování rCX, můžeme ještě podle stavu příznaku ZF zjistit, zda byla hodnota nalezena v posledním elementu vstupního řetězce.
Poněkud menší smysl má skenování opakované s prefixem REPE (opakovat, dokud se rovná). To bychom využili, pokud registr EDI ukazuje například na řetězec samých nulových znaků a chceme zjistit pozici prvního nenulového:
Poslední užitečnou řetězcovou instrukcí je CMPS, která porovnává element v paměti (BYTE, WORD, DWORD nebo QWORD) adresovaný pomocí rSI s elementem stejné šířky adresovaným pomocí rDI. Nastaví aritmetické příznaky a pak zvětší nebo zmenší rSI a rDI o velikost elementu. Rovněž tuto instrukci můžeme opakovat pomocí prefixu REPE. Následující příklad ukazuje vyhledání nějakého slova nebo výrazu (Needle) v delším řetězci znaků (Haystack). Algoritmus nejprve vyhledá pomocí SCASB první znak Needle a pak pomocí CMPSB porovná následné znaky hledaného slova. Pokud tam nejsou, pokračuje v hledání prvního znaku a celý postup opakuje.
Linux i Windows vyžadují, aby Direction Flag byl před voláním jeho funkcí vynulován, a zároveň zaručuje, že při návratu je rovněž DF=0. Pokud bychom potřebovali používat řetězcovou instrukci s nastaveným Direction Flag, je třeba jej opět vynulovat pomocí CLD, nejlépe hned po jejím provedení. Pak ani nemusíme tento příznak nulovat na začátku uvedených příkladů.
Pamatujme, že řetězcové instrukce inkrementují registry až po provedení, takže např. REPNE SCASB
se po nalezení bajtu zastaví o jeden element později a nalezený bajt je tedy na adrese EDI-1.
Po seznámení se základními strojovými instrukcemi se konečně dostáváme k jejich vyzkoušení. Instrukce je třeba zapsat do zdrojového souboru jako prostý text v osmibitovém kódování (nikoli UTF-16) a bez interních příznaků uvozujících tučné či skloněné písmo, nadpisy apod. Soubor pak předložíme programu zvanému asembler, který jej převede do jiného souboru ve spustitelném tvaru. Tento tutorial doporučuje použít EuroAssembler, který je výhodný tím, že nemusíme specifikovat žádné parametry na příkazovém řádku, pouze název zdrojového souboru. Výstupem může být přímo spustitelný soubor pro DOS, Linux nebo Windows, není tedy potřeba linker. Nebudeme využívat ani žádné knihovny třetích stran, pouze API (Application Programming Interface) operačního systému.
Aby bylo programování zábavné, měl by každý program něco zajímavého vykonávat, přinejmenším něco vypsat na monitor. Obvykle se začíná vypsáním fráze "Hello, world!". Monitor, klávesnice, myš patří k zařízením (devices), která však jsou ve správě operačního systému a ten nám nedovolí použít instrukce IN a OUT k zápisu do zařízení. Možná by to šlo v reálném DOSu, ale beztak bychom se těžko potýkali s porty rozmanitých videoadaptérů, jaké se v době DOS používaly. K vypsání řetězce znaků na monitor budeme muset použít služby operačního systému.
Ukážeme si, jak vypsat "Hello, world!" v 16bitovém systému DOS, pak v 32bitovém Linuxu, 64bitovém Linuxu, 32bitovém MS Windows a 64bitovém MS Windows.
Služby DOS se vyvolávají strojovou instrukci INT 21h a jsou popsány např. v seznamu
DOS Fn Index
nebo INT 21h.
Po rozkliknutí seznamu funkcí INT 21h vidíme dva řádky zabývající se standardním výstupem, tedy zápisem na konzolu:
Int 21/AH=02h - DOS 1+ - WRITE CHARACTER TO STANDARD OUTPUT
a
Int 21/AH=09h - DOS 1+ - WRITE STRING TO STANDARD OUTPUT
.
Chceme vypisovat celý řetězec, ne jen jednotlivý znak (character), takže se podíváme na tu druhou funkci,
která má dva vstupní parametry:
AH = 09h
DS:DX -> '$'-terminated string
Vyvolání této funkce tedy vyžaduje následující instrukce:
Zbývá vyřešit maličkost: jak do dvojice DS:DX zapsat adresu řetězce "Hello, world!", prozatímně symbolizovanou slovem HelloWorldAddress?
V DOSu budeme preferovat programy ve spustitelném formátu COM. Ten se celý odehrává v rámci
jednoho bloku paměti o velikosti 64 KB, což pro naše drobné úlohy bohatě stačí
a má velikou výhodu, že všechny čtyři segmentové registry jsou již DOSem nastaveny
na potřebný obsah, takže se jimi nemusíme zabývat.
DOS při spouštění našeho programu jej zavede do paměti a před jeho začátek uloží datovou strukturu
o velikosti 256 bajtů zvanou PSP.
První instrukce našeho programu následuje hned za ní, tedy na adrese 256.
Program nemůže začínat na adrese 256 definicí řetězce "Hello, world!",
protože by procesor zkoušel znaky řetězce vykonávat jako programový kód a nejspíš by ohlásil chybu nebo zatuhnul.
Definici řetězce tedy odsuneme až na konec programu, asembleru je to jedno.
Po instrukci INT 21h
bychom měli náš program regulerně ukončit, jinak by sice vypsal pozdrav,
ale pak by se marně snažil vykonávat bajty řetězce jako strojové instrukce a nejspíš přitom zamrzl.
Prohlédnutím seznamu funkcí DOS 21h najdeme hned dvě obsahující text "TERMINATE":
Int 21/AH=00h - DOS 1+ - TERMINATE PROGRAM
a
Int 21/AH=4Ch - DOS 2+ - EXIT - TERMINATE WITH RETURN CODE
.
V programu formátu COM ale existuje ještě další a mnohem jednodušší způsob regulerního ukončení:
prostou instrukcí RET. Ta načte z zásobníku nulové slovo a tím způsobí, že se program vrací na adresu CS:0,
kde je začátek PSP a tam je uložena strojová instrukce INT 0x20
pro ukončení programu v DOS. Náš program tedy bude vypadat takto:
Textový řetězec byl v programu definován pomocí pseudoinstrukce
DB (definuj bajty) a zapsán v uvozovkách. Assembler použije adresu
následující za instrukcí RET, na ni uloží 13 bajtů řetězce a této adrese přiřadí symbolický název HelloWorldAddress.
Máme tedy zdrojový kód, zapíšeme ho do souboru nazvaného např. hello.asm
a uložíme.
Zkusíme jej přeložit do spustitelného tvaru tím, že zadáme v konzoli povel euroasm hello.asm
(uvozovky kolem jména souboru se mohou vynechat, pokud neobsahuje mezery):
Z výstupních zpráv generovaných EuroAssemblerem v průběhu překladu si povšimněme řádku
I0760 16bit TINY BIN file "hello.bin" created from source, size=29.
Program formátu COM by měl mít příponu .com
. To je tím, že jsme asembleru nesdělili,
že má překládat do formátu COM, takže použil defaultní příponu .bin
, která mimo jiné nevytváří PSP,
takže náš program by beztak nefungoval.
Jak říci EuroAssembleru, že má vygenerovat COM? K tomu slouží dvojice pseudoinstrukcí PROGRAM a ENDPROGRAM a operandy FORMAT=, WIDTH=, MODEL=, SUBSYSTEM= (a mnoho dalších). Musíme tedy náš program zabalit mezi PROGRAM a ENDPROGRAM. Jako povinné návěstí povelu PROGRAM uvedeme název programu (bez přípony). Nazvěme jej třeba HelloDos. Stejné jméno se uvádí také u ENDPROGRAM, ovšem nikoli v poli návěstí, ale jako operand, jak je obvyklé u blokových pseudoinstrukcí EuroAssembleru. Jméno programu nemusí nutně odpovídat názvu zdrojového souboru, jako je tomu u jiných asemblerů. Mohli bychom totiž v jednom souboru definovat více než jeden blok PROGRAM/ENDPROGRAM a tím vyrábět více různých spustitelných programů najednou. Ale to zatím nebudeme zkoušet.
Kromě názvu programu musíme v parametrech pseudoinstrukce PROGRAM specifikovat ještě formát a šířku výsledného souboru. EuroAssembler si ale šířku u formátu COM odvodí sám jako WIDTH=16, takže bychom tento operand mohli vynechat. Obdobně bychom mohli vynechat i operand ENTRY=256, neboť vstupní bod programu je u COM programů vždy pevně určen na této adrese.
Po jeho spuštění pomocí euroasm hello.asm
dostáváme:
Důležitá je poslední řádka, která informuje o errorlevel 0, tedy bez chyb.
Jinak bychom je nejprve museli ve zdrojovém textu najít a odstranit.
Podobně řádka I0660 16bit TINY COM file "HelloDos.com" created, size=29.
potvrzuje, že byl vygenerován program ve formátu COM a jakou má velikost.
Po téměř každé zprávě EuroAssembler ještě připojuje pozici ve zdrojovém souboru,
které se zpráva týká. Například "hello.asm"{7}
přiřazuje
zprávu k sedmému řádku zdrojového souboru, což je ENDPROGRAM HelloDos
.
Ještě si všimněme zprávy I0860 Listing file "hello.asm.lst" created, size=1039.
.
EuroAssembler bez ptaní vytváří soubor s listingem přeloženého souboru. Je to opět prostý textový soubor,
který si proto můžeme prohlédnout například Notepadem nebo podobným prohlížečem:
Vidíme, že listing obsahuje kopii zdrojového kódu odsunutou vpravo a do levé části se vložil sloupec ohraničený znaky | obsahující čtyřmístnou hexadecimální adresu ukončenou dvojtečkou, a po ní strojový kód instrukce. Na konci programu ještě přibyla mapa ukazující, jak byly jeho sekce slinkovány do výsledného souboru (**** ListMap) a seznam globálních symbolů (**** ListGlobals), v našem případě prázdný.
Formát listingu produkovaného EuroAssemblerem odpovídá platnému zdrojovému kódu, neboť levý sloupec ohraničený znaky | se jako strojová poznámka ignoruje. Pokud bychom tedy překlad znovu spustili povelem
euroasm hello.asm.lst
, opět bychom získali souborHelloDos.com.
Překlad programu skončil s errorlevel 0.
Můžeme tedy otevřít emulátor DOSu (DosBox),
přejít do adresáře, v němž máme HelloDos.com
a zkusit ho spustit zapsáním HelloDos
nebo HelloDos.com
. Nejspíš v okně DOSu uvidíte text Hello, wordl!
následovaný změtí nesmyslných znaků. To je způsobeno přehlédnutím detailu v popisu služby
DS:DX -> '$'-terminated string
- vypisovaný řetězec musí být zakončen znakem dolaru.
Zároveň s dolarem můžeme na konec řetězce připsat ještě dvojici Carriage Return a Line Feed, tedy 13 a 10,
které způsobí odřádkování:
HelloWorldAddress DB "Hello, world!",13,10,'$'
Po opravě řádku a novém přeložení by již mělo všechno fungovat podle očekávání.
Po zapnutí počítače CPU startuje v reálném módu a provádí program pevně "zadrátovaný" v paměti na základní desce. V té době ještě není z disku zaveden DOS ani jiný operační systém, ale funguje rozhraní BIOS, případně UEFI, které umí několik základních funkcí počítače: vypisovat na monitor znaky a řetězce, číst klávesnici, načíst z disku program pro zavedení OS. Tyto funkce se dají vyvolat instrukcemi INT, za zmínku stojí zejména INT 10h pro práci s videoadaptérem a tedy i monitorem. Úplný přehled funkcí vyvolávaných přerušením INT je na Interrupt Jump Table.
V předchozím příkladu bylo k vypsání řetězce použito rozhraní DOSu. Pokud bychom potřebovali zapisovat na obrazovku hned po zapnutí počítače ještě dříve, než je DOS nebo jiný operační systém zaveden, museli bychom použít rozhraní BIOS napevno uložené ve firmware základní desky. Takto se chová například program boot sektoru, který zavede 512 bajtů dlouhý obsah jednoho diskového sektoru do paměti a předá mu řízení. Zkusíme vypsat Hello, world! pomocí služby BIOS. Ty podobně jako služby DOSu zachovávají obsah všech registrů kromě těch, které vracejí výsledek. My použijeme službu TELETYPE OUTPUT, která očekává vypisovaný znak v AL, dále AH=0Eh jako identifikátor služby a BH=0 jako číslo interní stránky videoadaptéru.
EuroAssembler sice dovoluje generovat boot sektor přímo, volbou PROGRAM FORMAT=BOOT
,
avšak zavádění boot sektoru je komplikované a nepohodlné. Z praktických důvodů budeme opět generovat
osvědčený formát COM:
MOV DS,CS
, neboť taková instrukce není podporována.
MOV SI,HelloWorldAddress
MOV CX,HelloWorldSize
CLD ; Pro jistotu vynulujeme Direction Flag.
SUB BX,BX ; Použijeme základní (nultou) stránku videoadaptéru.
MOV AH,0Eh ; Číslo funkce BIOS INT 10h.
Next: LODSB ; Načtení jednoho znaku řetězce do AL, zvětšení SI.
INT 10h ; Vyvolání funkce BIOS pro vypsání znaku.
LOOP Next ; Skok pro další znak CX-krát.
JMP $ ; Program ukončíme nekonečným skákáním na sebe. Bez OS to jinak nejde.
HelloWorldAddress DB "Hello, world!",10
HelloWorldSize EQU $ - HelloWorldAddress
ENDPROGRAM HelloBioPo spuštění programu HelloBio.com v emulátoru DosBOX opět dostáváme očekávaný výstup Hello, world!. Vzhledem k zacyklení programu na konci ale pak budeme muset DosBOX havarijně ukončit.
Spustitelný formát pro Linux se v EuroAssembleru nazývá ELFX, generované programy
v tomto formátu dostávají souborovou příponu .x
, které se ale můžeme zbavit přejmenováním pomocí
mv HelloL32.x HelloL32
a mít tak spustitelný program bez přípony, jak je v Linuxu zvykem.
Kromě parametrů pseudoinstrukce PROGRAM FORMAT=ELFX a WIDTH=32 ještě musíme povinně určit vstupní bod programu,
tedy první prováděnou instrukci. Vstupní bod (ENTRY=) označíme symbolicky návěstím například
Start: nebo Main: apod.
Pokud programy tohoto tutoriálu spouštíme pod MS Windows, ke spuštění linuxové varianty bychom museli mít ve Windows nainstalován emulátor WSL.
Pro vypsání textu v Linuxu opět musíme chtě nechtě využít aplikační rozhraní jeho kernelu.
V případě 32bitového systému se funkce kernelu vyvolává instrukcí INT 80h
a její parametry se zadávají do registrů EBX, ECX, EDX, ESI, EDI, EBP, přičemž identifikátor
volané funkce se zadává v EAX. Naplníme samozřejmě pouze ty vstupní registry, které funkce jádra vyžaduje,
v případě funkce zápisu (sys_write má tři parametry) to jsou registry EBX, ECX, EDX.
Volání kernelu vrací výsledek funkce v registru EAX, ostatní registry se vracejí nezměněny.
Pro vypsání řetězce "Hello, world!" budeme tedy potřebovat funkci sys_write zapisující
na standardní výstup. Podle dokumentace
Linux Syscall Reference (32 bit)
má tato funkce identifikátor EAX=0x04. V prvním parametru zadávaném v registru EBX se určuje
fd, což je file descriptor neboli file handle. Pro standardní výstup je to číslo 1,
pro výstup chyb by to bylo 2. Do dalších registrů zadáme druhý a třetí parametr, což je adresa a velikost
vypisovaného řetězce. Velikost bychom mohli zadat číslem (v našem příkladě MOV EDX,13
),
rozumnější je ale zadat ji nepřímo, jako rozdíl adresy $ a adresy HelloWorldAddress.
Pokud bychom později prodloužili nebo zkrátili řetězec HelloWordAddress, jeho velikost se nastaví samočinně.
Symbol dolaru $ označuje aktuální adresu instrukce, v našem případě instrukce EQU.
Jelikož zadáváme délku explicitně, není třeba řetězec zakončovat nulovým znakem.
Zkusíme tedy zadat:
Program v souboru hello.asm
opět přeložíme pomocí euroasm hello.asm
.
Pokud vše prošlo dobře a EuroAssembler ohlásil
můžeme jej zkusit spustit v nativním Linuxu nebo ve WSL povelem ./HelloL32.x
.
V mém případě se na konzoli vypsalo:
Hello, world!Segmentation fault (core dumped)
.
Program tedy funguje, ale po vypsání řetězce zhavaroval s chybou Segmentation fault. Chyba byla v ukončení programu - instrukce RET v Linuxu nestačí, musíme opět použít funkci kernelu sys_exit s číslem 1. Jako první parametr jí zadáme nulu, ostatní parametry nejsou použity. A zároveň na konec řetězce přidáme znak Line Feed (10) pro odřádkování po skončení výpisu. Carriage Return se v Linuxu obvykle nepoužívá.
Po nahrazení RET instrukce pro ukončení programu již program pracuje podle očekávání.
Program pro 64bitový Linux vypadá obdobně, liší se parametr WIDTH=64,
který musíme v pseudoinstrukci PROGRAM zadávat, neboť její default je WIDTH=32.
Další věc, která odlišuje 32bitový a 64bitový Linux je volání jádra (funkcí kernelu)
nikoli pomocí INT 80h
, nýbrž instrukcí
SYSCALL.
Liší se také číselné identifikátory funkcí kernelu předávané v RAX
a pořadí registrů pro přenos parametrů: namísto EBX, ECX, EDX, ESI, EDI, EBP
je zadáváme v registrech RDI, RSI, RDX, R10, R8, R9. Jinak ale funkce zůstávají stejné.
Přehled volání jádra je např. v
Linux System Call Table for x86 64.
Při pokusu o přeložení dostáváme řadu varování
W2340 This instruction requires option "EUROASM CPU=X64".I těmito chybami by program fungoval, ale raději mu dáme požadovanou volbu
přidáním řádku EUROASM CPU=X64
před pseudoinstrukci
HelloL64 PROGRAM
,
aby zbytečně neupozorňoval, že používáme 64bitové registry.
Po novém přeložení už bychom měli dostat errorlevel 0 a můžeme náš 64bitový program
vyzkoušet:
Volání funkcí v MS Windows používá aplikační rozhraní Win32 definované v dynamicky linkovaných knihovnách. Většina základních funkcí je k dispozici v knihovně "kernel32.dll".
Budeme opět psát na standardní výstup. Oproti Linuxu zde ale nejsou pevně stanovené identifikátory souborů fd, jako byly 0 pro standardní vstup, 1 pro standardní výstup a 2 pro chybový výstup. Místo toho se nejprve musíme operačního systému zeptat, jaký identifikátor (file handle) je dnes používán pro standardní výstup. K tomu slouží funkce Win32 nazvaná GetStdHandle, které jako parametr zadáme identifikátor standardního výstupu, což je číslo -11. Hodnota vracená funkcí v registru EAX je pak již file handle použitelný ve funkci WriteFile.
Zbývá vyřešit, jak funkce Win32 vyvolat a jak jim předat parametry (adresu a velikost řetězce).
32bitový Windows používá volací konvenci standard call, kde se nejprve uloží parametry
na zásobník (PUSH) v pořadí od posledního k prvnímu, a pak se zavolá (CALL) importovaná funkce.
O odstranění uložených parametrů ze zásobníku se postaral programátor dané funkce.
Obvykle to dělá tak, že místo obyčejného návratu pomocí RET
použije RET n*4
, kde n
je počet parametrů na zásobníku.
Instrukce RET n*4
funguje jako obyčejný návrat z funkce (RET),
ale navíc pak zvětší ESP o n*4 bajty.
Jak zařídit, aby instrukce CALL poznala, že voláme dynamicky linkovanou funkci? Buď do programu začleníme importní knihovnu winapi.lib, anebo jméno funkce definujeme pseudoinstrukcí IMPORT. Tato pseudoinstrukce má parametr LIB=, kterým definujeme soubor (knihovnu), jež funkci obsahuje. Knihovna je v popisu každé funkce na webu Microsoftu definována v odstavci Requirements. Pokud je název knihovny "kernel32.dll", nemusíme ji parametrem LIB= uvádět. Zkusíme tedy napsat spustitelný program pro Windows, který bude mít formát PE (Portable Executable):
Přeložíme jej pomocí euroasm hello.asm
a výsledek by měl být
Po spuštění programu ve Windows povelem HelloW32.exe
nebo jen
HelloW32
bychom měli dostat pozdrav Hello, world!
.
MS Windows v 64bitovém režimu používá tytéž Win32 funkce a tytéž knihovny "kernel32.dll" jako 32bitový Windows, podstatně se ale liší volací konvence: namísto StdCall se používá FastCall. V této konvenci se pro první čtyři parametry používají RCX, RDX, R8, R9. Vyžaduje-li funkce více než čtyři parametry, ukládají se na zásobník opět v opačném pořadí (od posledního do pátého). Pak se na zásobníku rezervuje místo (tzv. shadow space) pro čtyři parametry předávané v registrech, a to i tehdy, má-li funkce méně než čtyři parametry. Před zavoláním externí funkce (před instrukcí CALL) musí být navíc zásobník (registr RSP) zaokrouhlen na celý násobek 16 bajtů. V případě, že jsou funkci předávány parametry typu plovoucí desetinné čárky, namísto příslušného RCX, RDX, R8, R9 se použije dolní polovina XMM0, XMM1, XMM2, XMM3. Volaná funkce po skončení neodstraňuje parametry ze zásobníku, o to se musí postarat volající.
Vybaveni těmito znalostmi se pokusíme vypsat řetězec v 64bitových Windows:
Jak je vidět zejména v posledním případě pro 64bitový Windows, vyvolání tak prosté funkce, jako je vypsání pozdravu, vyžaduje napsání poměrně velkého počtu strojových instrukcí. Pokusíme se s tím něco udělat. Klíčem k redukci programátorské práce je použití makroinstrukcí. Každá makroinstrukce neboli makro může nahradit celou řadu strojových instrukcí, přitom makro může akceptovat vstupní parametry a upravovat tak svou činnost podle potřeby programátora.
Syntaxe jazyka makroinstrukcí a jeho nuance jsou vlastnosti použitého asembleru,
prakticky každý asembler je řeší po svém. Zde se zaměříme na psaní makroinstrukcí v jazyce EuroAssembler.
Jeho aparát používá pro výrazy používané při psaní maker znak procenta %.
Pseudoinstrukce začínající procentem se týkají maker nebo pomocných proměnných asembleru.
Zatímco běžné paměťové proměnné zapsané pomocí pseudoinstrukce
D, například OrdinaryVar DD 1234h
definují místo v paměti nazvané OrdinaryVar a obsahující DWORD s hodnotou 1234h,
proměnná %OrdinaryVar
představuje něco zcela jiného: proměnnou samotného EuroAssembleru.
Její místo není v přeloženém programovém kódu nebo v jeho datech,
ale existuje jen během práce EuroAssembleru v jeho paměti.
Její obsah může být nastaven pseudoinstrukcí %SET a může jím být
jakýkoli text, aritmetický výraz, řetězec, číslo apod.
Kdykoli se pak ve zdrojovém textu objeví %OrdinaryVar, bude tímto textem nahrazeno.
Makroinstrukce jsou definovány dvojicí blokových pseudoinstrukcí %MACRO a %ENDMACRO. Identifikátor v poli návěstí %MACRO se stává názvem makroinstrukce. Strojové instrukce uvnitř bloku %MACRO/%ENDMACRO jsou tělem makra. Kdykoli ve zdrojovém kódu napíšeme název makroinstrukce, nahradí se všemi strojovými instrukcemi z jeho definice. Příklad makra pro 64bitový Linux:
Nadefinovali jsme makro nazvané WriteString se dvěma parametry: adresou řetězce a jeho délkou. Uvedení názvu makra v programu pak způsobí jeho rozvinutí do pěti strojových instrukcí, přičemž první a druhý parametr jsou k dispozici jako proměnné %1 a %2. Místo číselného označení proměnných parametrů (%1, %2) bychom také mohli použít formální názvy parametrů tak, že názvu parametru v definici makra (StringAddress, StringSize) předřadíme v těle makra znak procenta:
Makro můžeme dále vylepšit tak, že file descriptor pro standardní výstup předávaný v RDI nebudeme zadávat natvrdo jako číslo 1, ale zadáme jej jako parametr. A abychom nemuseli tento parametr uvádět v případě, že použijeme jeho obvyklou hodnotu 1, zadáme jej jako klíčový parametr, tedy s rovnítkem a s defaultní hodnotou 1:
Stejné makro nyní můžeme použít i pro psaní na chybový výstup místo standardního; stačí přidat parametr fd=2.
EuroAssembler dovoluje definovat paměťové proměnné pomocí literálů, tj. bezprostředně zadaných hodnot. Namísto definování řetězce pseudoinstrukcí DB a vymýšlení jeho symbolického jména jej definujeme až při jeho použití v instrukci:
Literál se definuje rovnítkem, za nímž následuje určení typu (BYTE, WORD, DWORD, QWORD nebo jen B, W, D, Q) a pak jeho hodnota. Výhodou literálu oproti symbolu je, že mu nemusíme vymýšlet název proměnné a hned vidíme jeho hodnotu v instrukci, kde byl použit.
S použitím makrojazyka a literálů nyní můžeme pro vypsání řetězce na standardní výstup napsat vlastní makra. Nazveme je třeba StdOutput. Tato práce již byla vykonána a makra jsou uvedena v knihovnách dodávaných spolu s EuroAssemblerem, konkrétně v makroknihovnách dosapi.htm pro DOS 16 bitů, linapi.htm pro Linux 32 bitů, linabi.htm pro Linux 64 bitů, winapi.htm pro Windows 32 bitů, winabi.htm pro Windows 64 bitů.
S použitím makra StdOutput a s použitím literálů se naše zkušební prográmky podstatně zjednoduší,
stačí includovat patřičnou knihovnu podle cílové platformy. Inkludování pomocí pseudoinstrukce
INCLUDE způsobí to, že jmenovaná knihovna
(což je jen další zdrojový soubor) nahradí řádek s povelem INCLUDE "knihovna.htm"
.
Knihovny s rozhraním API (16 a 32 bit) nebo ABI (64bit) obsahují definice maker StdOuput,
TerminateProgram a několik dalších.
Pro zjednodušení zapíšeme programy pro DOS, Linux i Windows do jednoho zdrojového souboru hello.asm
.
Vzhledem k tomu, že se makra pro zápis na standardní výstup a pro ukončení programu jmenují
ve všech knihovnách stejně (StdOutput a TerminateProgram), měli bychom před nadefinováním
dalšího programu říci EuroAssembleru pomocí %DROPMACRO,
aby zapomenul jejich definice z předchozí knihovny.
Po přeložení výše uvedeného souboru známým povelem euroasm hello.asm
bychom měli dostat pět programů s názvy HelloDos.com
, HelloL32.x
,
HelloL64.x
, HelloW32.exe
, HelloW64.exe
,
které hned můžeme s pomocí emulátorů (DosBox, WSL, wine) vyzkoušet.
Blok instrukcí mezi %MACRO a %ENDMACRO představuje definici makra. Samotná definice ještě nic zajímavého nedělá, pouze zabírá místo v zdrojovém kódu. Teprve až se pokusíme makroinstrukci použít v programu, dojde k její expanzi, tedy k nahrazení názvu makra instrukcemi z jeho těla (a možná taky k projevení chyb, pokud jsme nějakou při psaní makra spáchali).
Definici makra můžeme v EuroAssembleru napsat na začátku bloku PROGRAM/ENDPROGRAM, nebo před tímto blokem, případně i v separátním includovaném souboru (knihovně), avšak vždy předtím, než bude makro poprvé použito (expandováno). Makroinstrukce (a také proměnné EuroAssembleru začínající znakem procenta) totiž na rozdíl od běžných symbolů procházejí hranicemi bloku PROGRAM/ENDPROGRAM, jsou viditelné v celém zdrojovém kódu počínaje jejich definicí. V tom se liší od symbolů, které musí být v bloku PROGRAM/ENDPROGRAM unikátní, nesmějí se opakovat.
Makra a %proměnné tedy mohou být redefinovány i v rámci téhož zdrojového souboru.
Redefinování makra je ale poněkud neobvyklé, proto na ně EuroAssembler reaguje
varovnou zprávou W2512 Overwriting previously defined macro
.
Pokud tedy chceme makro přepsat jinou definicí makra se stejným názvem, je lepší ho
nejprve nechat zapomenout pomocí pseudoinstrukce %DROPMACRO.
Předat text uživateli našeho programu bylo snadné, v předchozích příkladech jsme použili službu operačního systému obvykle nazvanou write nebo podobně, možná zabalenou do makra StdOutput. Teď se podíváme na opačný případ, kdy chceme něco získat od uživatele. Jednou z možností je načtení argumentů z příkazového řádku, kterým jsme náš program spustili.
Pokud se náš program jmenuje například MyCalc
a v konsole jsme napsali MyCalc.exe 2 + 3
, operační systém nám poskytne
řetězec obsahující tutéž informaci MyCalc.exe 2 + 3.
Tedy název spuštěného programu (nultý argument) a pak přesnou kopii následujících znaků včetně mezer
či jiných znaků oddělujících argumenty. Kde je tento řetězec uložen?
V DOSu leží ve struktuře PSP počínaje bajtem 81h. Předchozí bajt na adrese 80h obsahuje délku řetězce.
Ve Windows dostaneme ukazatel na obdobný řetězec pomocí funkce API GetCommandLine. Pokud bychom chtěli mít každý argument zvlášť, musíme řetězec načítat např. instrukcí LODSB a reagovat na oddělovací znaky (této činnosti se říká parsing).
V Linuxu je to trochu jinak: řetězec už je rozparsován na jméno programu a na jeho mezerou oddělené argumenty,
přičemž všechny tyto položky jsou zakončeny nulovým znakem a ukazatele na ně jsou uloženy na zásobníku.
Schématicky je to znázorněno na obrázku u makra GetArg.
Adresy zásobníkových položek na tomto obrázku rostou směrem nahoru. Šířka každé položky je 8 bajtů v 64bitovém programu
nebo 4 bajty v 32bitovém programu. Instrukcí MOV RCX,[RSP]
na začátku programu bychom tedy
do RCX načetli celkový počet argumentů. Adresu prvního argumentu získáme pomocí
MOV RSI,[RSP+2*8]
, adresu druhého pomocí MOV RSI,[RSP+3*8]
atd.
Anebo použijeme již hotové makro GetArg které dodá jednotlivé argumenty již rozparsované
bez ohledu na operační systém. Stačí jen použít patřičnou makroknihovnu pro DOS, Linux nebo Windows v potřebné šířce
16, 32 nebo 64 bitů. Knihovny maker se načítají pseudoinstrukcí INCLUDE, které zadáme jako parametr jméno souboru
dosspi.htm
, linapi.htm
, linabi.htm
, winapi.htm
nebo winabi.htm
. Makro GetArg
se jmenuje ve všech těchto knihovnách stejně a pro požadovaný argument vrátí ukazatel na něj v rSI a jeho délku v rCX.
Případně vrátí CarryFlag, pokud dotyčný argument nebyl na příkazovém řádku dodán.
Zkusíme naprogramovat primitivní čtyřúkonovou kalkulačku. Po zadání dvou celých čísel rozdělených znaménkem aritmetické operace by naše kalkulačka měla vrátit správný výsledek. Přípustné operace budou určeny znaménkem +, -, *, / pro sčítání, odčítání, násobení, dělení.
Jako cílovou platformu vybereme 32bitové Windows, odpovídající knihovnou bude tedy winapi.htm
.
Zapíšeme-li na příkazový řádek CalcW32 3 + 4
, chceme dostat výsledek
7
. Soubor se zdrojovým kódem nazvěme třeba calc.asm
.Zapíšeme do něj:
Mělo by vás zarazit definování proměnných Arg1, Arg2, Arg3 uprostřed toku instrukcí.
To je špatná programátorská technika, která ale v EuroAssembleru funguje díky defaultně
povolenému parametru EUROASM AUTOSEGMENT=ON
. Autosegmentace rozlišuje,
zda byla na řádku uvedena strojová instrukce nebo definice dat a podle toho rozděluje
výstup přeložených instrukcí do samostatných sekcí pro programový kód, inicializovaná data
a neinicializovaná data.
Tyto sekce mají tradiční názvy [.text], [.data], [.bss]. Pokud bychom si prohlédli listing
calc.asm.lst
, automatickou změnu sekcí tam uvidíme.
Spoléhat na autosegmentaci ale u delších programů není příliš moudré, raději už při psaní
programu zařaďme datové položky Arg* až za programový kód.
Dále si povšimněme řádku nápovědy DB "Example: %^PROGRAM 3 + 4", 13, 10, 0
.
Výraz %^PROGRAM zde nepředstavuje uživatelsky definovanou proměnnou díky znaku
^ (caret) následujícím po znaku procenta.
Takovéto proměnné jsou systémové, jejich hodnota je nastavena samotným EuroAssemblerem,
v tomto konkrétním případě na jméno programu (CalcW32). Systémových proměnných
má EuroAssembler spoustu, každý parametr pseudoinstrukcí EUROASM a PROGRAM
má jednu, viz karta nápovědy.
Program se přeložil bez chyby, ale po spuštění vrací nesprávný výsledek:
Díky řádku StdOutput Arg1, Arg2, Arg3, Size=1 ; Kontrolní výpis argumentů.
jsme si nechali vypsat, s jakými argumenty byl CalcW32 spuštěn. To je dobrá taktika
pro hledání chyb (debugging). Vidíme, že všechny tři argumenty byly zadány správně.
Zkušenější programátor by v programu CalcW32 našel několik chyb. Například není určeno, kde má program pokračovat po vypsání nápovědy na řádku s návěstím Error. Chceme ho poslat na ukončení programu. Přidejme tedy k pseudoinstrukci TerminateProgram návěstí End:, kam necháme program skočit po vypsání nápovědy. Další chybou, která způsobuje nesprávný výsledek 3+4=g je nerozlišování binárního čísla a ASCII kódu jednotlivých číslic, které nám GetArg vrací. Při zadání číslice 3 jako argumentu makro GetArg vrací její ASCII kód, což je hexadecimálně 33. Obdobně namísto číslice 4 dostáváme 34 a protože jsme sčítali ASCII kódy, výsledkem je 33h + 34h = 67h, což je ASCII kód písmene g a nikoli číslice 7. Před použitím instrukce ADD pro sčítání musíme převést ASCII kódy na prostá binární čísla. To se provede snadno, odečtením 30h neboli '0' od ASCII kódu číslice. Po binárním sečtení pak převedeme součet přičtením 30h a tento ASCII kód již můžeme nechat vypsat jako výsledek pomocí StdOutput. Upravíme tedy program CalcW32 takto:
Program už funguje dobře, ovšem pouze pro sčítání jednociferných čísel a musíme dodržet mezery mezi argumenty. Při zadání bez mezer CalcW32 3+4 program vypisuje nápovědu, tzn. něco se mu nelíbí. Asi vzal celý řetězec 3+4 jako jeden argument a při pokusu o načtení neexistujícího druhého a třetího už GetArg vrací chybu. Budeme muset vyřešit vstup a výstup binárních čísel v ASCII (znakové) podobě pro delší čísla než pouze jednociferná. Operační systémy bohužel nenabízejí žádnou funkci převodu ASCII čísel na binární a zpět, vše musíme naprogramovat sami. Máme-li jako vstupní řetězec prvního čísla třeba znaky "123", očekáváme výsledek v binární podobě jako číslo 123, tedy hexadecimálně 7Bh v 8bitovém registru neboli 0000007Bh v 32bitovém. Jak převést řetězec "123" na binární číslo? Každý postupně načtený znak v rozsahu "0" až "9" nejprve převedeme na číslo v rozsahu 0 až 9 odečtením "0" neboli odečtením 30h. Pak jsou dva možné přístupy, jak takto získaná čísla postupně skládat do výsledku:
Druhá metoda vypadá jednodušeji, neboť nemusíme počítat narůstající váhy 1, 10, 100 atd.,
místo toho vystačíme s opakovaným násobením deseti.
Nazveme tento algoritmus ASCIItoInteger. Asi jej budeme používat častěji v různých programech,
proto jej zapouzdříme do procedury pomocí pseudoinstrukcí PROC a ENDPROC.
Tu pak budeme volat pomocí CALL ASCIItoInteger
kdykoli bude potřeba
převést číslo ze znaků ASCII na celočíselnou binární hodnotu.
Ještě zbývá doplnit proceduru popisem, co přesně dělá, jaké hodnoty očekává na vstupu a co poskytuje na výstupu.
Vyrobili jsme tak něco jako černou skříňku, u které již nemusíme přemýšlet
nad jejími instrukcemi, ale zabýváme se pouze jejím popisem jako celkem.
Později můžeme celou proceduru umístit do samostatného souboru a vytvořit tím knihovnu,
kterou pak zařadíme do všech programů vyžadujících převody řetězců na čísla.
Knihovnu můžeme nazvat třeba libcvt32.asm
.
V příkladu jednoduché kalkulačky ještě musíme vyřešit opačný převod binárního čísla na sérii ASCII znaků reprezentující toto číslo ve formátu, který můžeme tisknout pomocí StdOutput. Opět jsou zde dvě možnosti:
Druhý přístup je komplikovanější nutností udržovat dělitele 1_000_000_000, 100_000_000, 10_000_000 atd., použijeme první metodu. Opět to naprogramujeme jako samostatnou proceduru, neboť se jedná o často používanou funkci.
Vraťme se k našemu kalkulátoru. Nevýhodou načítání vstupu z příkazového řádku je použitelnost pouze pro jeden příklad; pro zadání dalšího výpočtu se musí program spustit znovu. Naučíme se proto číst vstupní znaky zadané z klávesnice, abychom mohli zadávat různé početní příklady opakovaně.
Přímé čtení klávesnice a myši pomocí instrukcí IN a OUT už v době USB periférií a chráněných operačních systémů není aktuální. Nejnižší úroveň práce s klávesnicí v DOSu a jeho emulacích poskytuje rozhraní INT 16h, konkrétně funkce CHECK FOR KEYSTROKE a pokud tato zjistí, že byla stisknuta klávesa, pak ještě GET KEYSTROKE, která vrací ASCII znak v AL.
Ve Windows, Linuxu, ale i v DOSu je vhodnější funkce pro čtení ze standardního vstupu, která je přesměrovatelná
a po řádcích vrací text zadaný z klávesnice. Do programu používajícícho
čtení standardního vstupu může být přesměrován obsah jiného textového souboru, například v DOSu takto:
type answers.txt | program.exe
.
Ke čtení použijeme v DOSU funkci READ FROM FILE OR DEVICE, v Linuxu sys_read, ve Windows ReadFile. Tyto funkce nevracejí ASCII znaky po každém stisku klávesy, ale implementují řádkový editor. To znamená, že se napsaný text dá přepisovat nebo mazat pomocí klávesy Backspace a do programu se vrátí až když stiskneme klávesu Enter. Vracený text bude v Linuxovém programu zakončen znakem Line Feed (0Ah), v DOSu a ve Windows dvojicí znaků Carriage Return a Line Feed (0Dh 0Ah). Kromě toho tyto funkce informují o počtu skutečně načtených znaků, včetně oněch zakončujících 0Dh,0Ah.
Zkusíme náš nepříliš funkční kalkulátor ve zdrojovém souboru calc.asm
vylepšit použitím
čtení ze standardního vstupu. Předpokládejme, že již máme textový soubor nazvaný libcvt32.asm
,
do něhož jsme uložili obě výše uvedené konverzní procedury ASCIItoInteger a IntegerToASCII.
Tento soubor vložíme do calc.asm
pomocí INCLUDE, takže se zdrojový kód kalkulátoru hezky zmenší.
Pro změnu napišme program pro 32bitový Linux:
Při pokusu o přeložení předchozího programu EuroAssembler hlásí tyto chyby:
K chybě došlo proto, že jsme v procedurách i v hlavním programu vícekrát použili návěstí nazvaná Next1, Next2, End. V každém programu může být určité návěstí použito pouze jednou. Měli bychom vymyslet unikátnější jména, ale existuje ještě lepší řešení, jak se zbavit duplicit: použít lokální jména symbolů. Lokální jména začínají tečkou a EuroAssembler si je pak ve skutečnosti pamatuje spojená se jménem procedury nebo programu, v němž bylo návestí definováno. Takže End v proceduře IntegerToASCII se po přejmenování na .End uloží jako IntegerToASCII.End a nebude pak kolidovat s Endem v programu CalcL32.
Jak je to vlastně s tečkami před názvem symbolu a s dvojtečkami za ním? Tečka na začátku označuje symbol jako lokální, ve skutečnosti je jméno symbolu modifikováno připojení názvu jmenného prostoru (programu, procedury, struktury) před lokální jméno. Viz také odstavec jmenného prostoru v manuálu.
Dvojtečka může, ale nemusí být připojena za název symbolu a zdůrazňuje tím, že to je symbol
a nikoli třeba jméno struktury, registru, instrukce. Na rozdíl od většiny jiných asemblerů
může být dvojtečka v €ASM připojena za jméno symbolu nejen při jeho definici v poli návěstí,
ale také kdykoli je symbol zmiňován, například MOV RSI, Symbol:
.
A pokud je dvojtečka dvojitá, zároveň tím označuje globální viditelnost symbolu,
nemusíme ji pak explicitně stanovovat pomocí GLOBAL Symbol
,
případně pomocí PUBLIC nebo EXTERN.
Po opravě symbolů Next1, Next2, End1, End2 v knihovně libcvt32.asm
na lokální
přidáním tečky před jejich jméno by už měl jít program přeložit bez chyb a můžeme jej vyzkoušet
s různými i delšími čísly a operacemi.
Vidíme, že načítání argumentů ze standardního vstupu funguje lépe než jejich odebírání
z příkazového řádku programu. Stejně ale bylo třeba program ./CalcL32.x
spouštět
po každém příkladu znova, neboť jsme zatím nenaprogramovali přechod na nové zadání
po každém úspěšném výpočtu. To snadno napravíme, stačí u návěstí End místo TerminateProgram
zařadit skok na začátek, tedy End: JMP Start:
.
Anebo ještě lépe: nahradíme všechny skoky na End: skokem na Start:.
Další změnou bude náhrada volání kernelu Linux makrem
StdInput z knihovny linapi.htm
,
která dělá v podstatě totéž a je snadno nahraditelné stejnojmenným makrem z knihoven pro jiné
operační systémy.
Poslední věc k opravě předchozího zdrojového kódu jsou redundance ve výpočtu aritmetických operací
Addition, Subtraction, Multiplication, Division: načtení prvního čísla
MOV EAX,[Arg1]
může být provedeno pouze jednou a pak použito pro všechny čtyři možné operace.
Instrukce
se opakují, takže u druhé, třetí a čtvrté aritmetické operace je můžeme nahradit skokem na tu první.
Zdrojový kód programu v souboru calc.asm
se těmito zásahy zkrátí:
Máme tedy fungující kalkulátor calc.asm
pro 32bitový Linux. Portace pro Windows 32 bitů je snadná:
místo CalcL32 PROGRAM FORMAT=ELFX, WIDTH=32, ENTRY=Start:
použijeme CalcW32 PROGRAM FORMAT=PE, WIDTH=32, ENTRY=Start:
a místo INCLUDE libcvt32.asm, linapi.htm
použijeme
INCLUDE libcvt32.asm, winapi.htm
. To je vše, ostatní instrukce se nemění
a makra StdOutput, StdInput a TerminateProgram mění pouze svoje tělo, ale nikoli název.
O něco málo složitější to bude s portací programu pro 64 bitů. Zatímco v 32bitovém módu
se ke práci s daty i s adresami používaly 32bitové registry, v 64bitovém módu se šířka
adres zvětšuje na 64 bitů, zatímco defaultní šířka dat zůstává 32 bitů (i když se dá také zvětšit na 64 bitů
použitím registrů začínajících na R).
Dále je třeba si pamatovat, že zápis do 32bitového registru zároveň nuluje horních 32 bitů odpovídajícího
64bitového registru. Tedy SUB EAX,EAX
nuluje nejen registr EAX, ale také horní (významnější)
polovinu registru RAX.
Naše knihovna pro konverzi celých čísel na ASCII a zpět bude po portaci na 64 bitů vypadat mírně odlišně.
Nazvěmě ji libcvt64.asm
:
Kód kalkulátoru pro 64bitový operační systém se změní jen nepatrně, neboť zůstaneme omezeni
na 32bitová čísla a práce s neadresními registry zůstane stejná.
Namísto 32bitových knihoven libcvt32.asm, linapi.htm, winapi.htm
budeme načítat knihovny libcvt64.asm, linabi.htm, winabi.htm.
Namísto MOV ESI,Buffer
bude lepší použít LEA RSI,[Buffer]
.
Ostatní instrukce mohou zůstat stejné jako ve 32bitové variantě:
Portace z 64bitového Linuxu na Windows je opět snadná: nahradíme knihovnu linabi.htm
knihovnou winabi.htm
, název a formát programu změníme
z CalcL64 PROGRAM FORMAT=ELFX
na CalcW64 PROGRAM FORMAT=PE
a to je vše.
Získáme tím kalkulátor pro 64bitový Windows.
Hledání a odstraňování chyb programu — ladění — je podstatnou náplní práce programátora, v asembleru to platí dvojnásob. Ukážeme si několik metod, jak chyby hledat.
V předchozích příkladech jsme pro kontrolu použili vypsání zadaných argumentů makrem StdOutput.
To je docela dobrá metoda jak se ujistit, že procesor probíhá těmi zákoutími našeho programu, kde ho očekáváme.
Na prvním místě dejme výpis StdOutput hned na začátek, kam ukazuje vstupní bod programu ENTRY=.
Tím se ujistíme, že jsme nechali náš program načíst správnou knihovnu a že výpis na standardní výstup
funguje. Text vypisovaný pomocí StdOutput nemusí být nijak sofistikovaný, stačí aby sdělil,
že program dospěl do určitého bodu. Například StdOutput ="Jsem na řádku 123",Eol=Yes
.
Na mnoha místech programu by bylo užitečné kromě informace, kde zrovna jsme, vidět také obsah určitého registru nebo paměťového místa. Mohli bychom sice obsah kteréhokoli registru převést do dekadického nebo hexadecimálního ASCII tvaru, uložit jej do nějaké dočasné paměťové proměnné a pak ji vypsat pomocí StdOutput, ale to je nepohodlné řešení náchylné k chybám. Lepší bude použít specializované makro, které dělá totéž pro všechny registry a nezanechává v laděném programu žádné stopy (kromě zvětšení jeho délky).
K ladění je k dispozici knihovna maclib/debug.htm a obsahuje jediné makro Debug, nezávislé na operačním systému a na šířce laděného programu (16, 32 nebo 64 bitů). Nezávislost na OS je vykoupena tím, že makro Debug informaci o registrech nevypisuje samo, ale posílá naformátovaný výpis do procedury s defaultním názvem DebugOutput volané z makra jako tzv. callback. Tuto proceduru musíme v laděném programu dočasně použít, naštěstí to není nic složitého. DebugOutput má za úkol vypsat na standardní výstup řetězec adresovaný registrem rSI v délce rCX bajtů. Můžeme zde k výpisu použít dobře známé makro StdOutput.
Příklady procedury DebugOutput pro různé šířky programu:
Postup pro ladění pomocí makra Debug:
EUROASM DEBUG=ON
na začátku programu.debug.htm.
Debug
,
program přeložíme, spustíme a pak pozorujeme, zda prochází těmi místy, kudy by měl.Výpis stavu počítače po každém použití makra Debug vypadá v 32bitovém programu nějak takto:
Makro na začátku vypsalo svou pozici ve zdrojovém kódu "filename.ext"{1234)
,
tedy název souboru a ve složených závorkách číslo řádku, a pak obsah všech GPR.
Vložení makra do programu jej nijak neovlivňuje, všechny registry i příznaky zůstávají zachovány.
Debug umí vypsat také obsah jednoho paměťového místa, jehož adresu a velikost specifikujeme
parametrem makra. Například Debug ESI, Size=32
vypíše kromě obsahu GPR také obsah paměti
na adrese ESI v délce 32 bajty. Parametr Size=
je v rozsahu 1..256, defaultní hodnota je 16.
U chráněných OS je třeba dbát, aby specifikovaná paměť skutečně existovala (byla programu alokována),
jinak může dojít k havarijnímu ukončení.
Mnohem pohodlnější přístup k ladění poskytují specializované ladicí aplikace — debuggery.
Pro Linux je k dispozici konzolová aplikace gdb,
případně její grafická nadstavba nemiver nebo ddd.
Nejvíce jsem si oblíbil tyto:
Pro DOS Turbo Debugger dodávaný firmou Borland
spolu s Turbo Assemblerem nebo Turbo Pascalem.
Pro 32bitový Windows OllyDbg,
pro 64bitový Windows x64dbg.
Na těchto celoobrazovkových aplikacích je sympatické, že jejich autoři od sebe opsali jednotné základní ovládání
pomocí funkčních kláves F4, F7, F8 apod. Nejčastěji budeme využívat základní obrazovku CPU,
která, rozdělena na čtyři části, zobrazuje strojové instrukce, obsah registrů, obsah paměti a zásobník.
Krokování programu (F7) s případným přeskakováním detailního procházení procedur (F8) umožňuje
po každém kroku sledovat, jak se změnily registry, paměť programu a zásobník.
Debuger ukazuje v levé horní čtvrtině okna CPU adresy a disasemblovaný strojový kód.
Adresy odpovídají tomu, jaké jim byly přiděleny linkerem, vidíme je jako hexadecimální čísla.
Bylo by užitečné vidět na jejich místě raději návěstí použité ve zdrojovém programu.
Toto propojení číselné adresy se symboly se ale bohužel neděje automaticky,
ani když je ve spustitelném souboru přítomna tabulka symbolů s jejich adresami.
Chceme-li znát adresy, jaké EuroAssembler přidělil symbolům, podívejme se do listingu.
Pokud jsme to ponechali povoleno volbou PROGRAM LISTGLOBALS=ON, na konci listingu calc.asm.lst
uvidíme u globálních symbolů jejich virutální adresu (VA=). Pokud bychom potřebovali znát adresu
dalších, neglobálních symbolů, musíme jim přidělit globální viditelnost.
To by šlo přidáním explicitní pseudoinstrukce GLOBAL Division, Error, Multiplication
atd.,
ale ještě jednodušší bude na konec názvu každého návěstí přidat dvě dvojtečky.
Po novém přeložení už v listingu jejich adresy uvidíme:
OllyDebugger pro 32bitový Windows dokonce uměl načítat adresy z tabulky symbolů v objektovém souboru COFF, pomocí klávesové zkratky Ctrl-O. My sice překládáme zdrojový kód přímo do spustitelného souboru, bez mezistupně v podobě COFF souboru, ale mohli bychom dočasně změnitPROGRAM FORMAT=PE
naPROGRAM FORMAT=COFF
a objektový formát COFF pak vygenerovat a použít pro načtení adres symbolů OllyDebuggerem.