Lekce 1 - Úvod do programování v Assembleru
Vítejte u kurzu, ve kterém se spolu ponoříme až do hloubek, kam se vůbec jako programátoři můžeme dostat. Obejdeme programovací jazyk i kompiler a zjistíme, jak funguje samotný stroj. Budeme posílat přímo instrukce procesoru, pochopíme architekturu x86, BIOS, jak se adresuje paměť a zavádí operační systém. Tato problematika souvisí také s reverse-engineeringem, crackováním softwaru a samozřejmě hackingem. Naučí vás používat nástroje jako disassemblery, cheat enginy a lépe pracovat s debuggery.
Kurz zahajme slavnou citací:
Don't learn to hack, hack to learn!
Předpoklady
Kurz předpokládá znalost alespoň základů fungování počítače po hardwarové stránce a zkušenost s libovolným vysokoúrovňovým programovacím jazykem (např. C nebo Java).
Proč se dnes učit Assembler?
Vždy mě zajímalo, jak věci v základu fungují a namísto toho, abych používal něco, co už existuje, jsem si raději udělal něco svého. Například máme doma rádio. A já, namísto toho, abych ho používal, začal jsem se stavbou vlastního AM rádia. A nebo většina z nás používá operační systém Windows... No a co jsem asi udělal... Ano, začal jsem pracovat na vlastním operačním systému v Assembleru.
Co se tu budeme učit je samozřejmě velmi speciální a úzké zaměření. Komerčně dnes tyto znalosti uplatníte ve sféře kyberbezpečnosti nebo při programování pro některá nízkoúrovňová embedded zařízení, případně pro high-performance programování. A nebo když se budete chtít pokusit donutit dělat nějakou cizí aplikaci to, co od ní chcete, případně si naprogramovat zavaděč operačního systému. To samozřejmě není tak snadné, ale někde se začít musí
Abychom pochopili, jak vůbec ASM funguje, podívejme se ve zkratce na historii programovacích jazyků.
Vývoj programovacích jazyků
1. generace jazyků - Strojový kód
Procesor počítače umí vykonávat jen omezené množství jednoduchých instrukcí, které jsou uloženy jako sekvence bitů, jsou to tedy čísla. Ta se mu obvykle zadávají v hexadecimální (šestnáctkové) soustavě. Instrukce jsou tak elementární, že umožňují pouze např. práci s registry procesoru (to je paměť v CPU) nebo skoky. Nelze např. jednoduše sečíst dvě čísla, musíme se na čísla dívat jako na adresy v paměti a takové sečtení čísel zabere několik instrukcí. Program sčítající dvě čísla by vypadal např. takto:
B853000000 BBFEFFFFFF 03C3 C3
Instrukce se procesoru předloží v binární podobě. Takovýto kód je samozřejmě extrémně nečitelný a závisí na instrukční sadě daného CPU. Každý počítačový program musí být nakonec do tohoto jazyka přeložen, aby mohl být na procesoru počítače spuštěn.
2. generace jazyků - Assembler
Assembler neboli JSA (Jazyk Symbolických
Adres) se objevil někdy v polovině 20. století. Konkrétně se jednalo o
jazyk druhé generace. Od jazyků první generace se lišil
tím, že namísto toho, abychom si museli pamatovat číselné kódy
instrukcí, jsme mohli psát jejich slovní kódy
(například: MOV
, CMP
, ADD
).
Díky Assembleru bylo psaní programů jednodušší a přehlednější, než kdybychom je psali v číslech. Další výhodou bylo, že adresy v programu se nemusely měnit přepisováním celého programu jako tomu bylo u první generace. Co se týče kódu samotného, nemusely se opět např. složitě vypočítávat adresy skoků. Kód tedy začal být vůbec lidsky čitelný, i když není o nic jednodušší, než původní strojový kód.
Stejný program by v ASM vypadal takto:
.code main: mov eax,83 mov ebx,-2 add eax,ebx ret end main
Vidíme, že je to poněkud lidštější, ale stále nezasvěcení lidé vůbec netuší, jak program funguje.
3. generace jazyků
Jazyky v třetí generaci konečně nabízí uživateli určitou abstrakci nad tím, jak program vidí počítač, zaměřují se na to, jak program vidí člověk. Jsou označovány jako tzv. vyšší programovací jazyky (anglicky High Level Languages, někdy zkráceně HLL). Naše čísla jsou vnímána již jako proměnné, zdrojový kód připomíná matematický zápis.
Jedním z prvních vysokoúrovňových programovacích jazyků byl jazyk C. I když jej předběhl Fortran nebo Pascal, tak to byl právě jazyk C, který dobyl svět. Opět ten samý programy by v jazyce C vypadal takto:
int main(void) { int a, b, c; a = 83; b = -2; c = a + b; return c; }
V kontrastu současných objektově orientovaných moderních jazyků je jazyk C často označován jako jazyk nízkoúrovňový. Jak to tedy je? Od příchodu jazyka C zkrátka uběhlo již několik dekád a záleží s čím jej porovnáváme. V našem kontextu s ASM je nazýván vysokoúrovňovým, v kontextu např. s jazykem C# .NET je naopak nízkoúrovňový.
Všichni asi tušíme, co program dělá, sečte čísla 83
a
-2
a výsledek uloží do proměnné c
. U všech
jazyků třetí generace je samozřejmě výhodou vysoká čitelnost.
Překlad vyšších programovacích jazyků
Tyto jazyky tedy mají svůj zdrojový kód v jazyce, kterému lidé dobře rozumí. Zdrojový kód se samozřejmě musí přeložit do binárního kódu, aby ho bylo možné na procesoru spustit. Tento překlad zajišťuje překladač (kompiler), který přeloží najednou celý program do strojového kódu. Některé kompilátory umějí kromě strojového kódu vygenerovat také kód assembleru.
Některé moderní programovací jazyky (např. C#, Java) se nepřekládají přímo do strojového kódu, ale používají binární mezikód. Ten se kompiluje do strojového kódu až na počítači uživatele (buď hned po instalaci aplikace nebo při jejím spuštění). To má výhodu v tom, že binární mezikód je nezávislý na procesoru. Vývojář aplikace tedy nemusí dávat ke stažení zvlášť verze pro x86, x64, nebo ARM. Nevýhodou je to, že uživatel musí mít nainstalovaný framework pro daný programovací jazyk.
Pokud budeme vědět, jak vypadají v ASM rutiny např. C překladače, budeme schopní pochopit, jak tyto programy fungují nebo je dokonce modifikovat, aniž bychom od nich měli zdrojový kód. Jedná se ale samozřejmě o velmi komplexní problematiku, protože sebemenší funkce vyústí ve velké množství ASM instrukcí a kompiler ve výsledné podobě ještě provádí četné optimalizace. Pojďme jim věnovat alespoň krátký odstavec.
Optimalizace překladače
Když bychom psali v čistém ASM, velmi pravděpodobně bude náš program pomalejší než ten samý program zkompilovaný např. z jazyka C. Jak je to možné? Kompiler totiž kód upraví tak, aby byl velmi rychlý a to často za cenu, že je pro člověka pak špatně čitelný. Určitě jste všichni někdy použili cyklus. Ten má nějakou řídící proměnnou, podmínku a zkrátka režii navíc. Proto se může někdy optimizer rozhodnout, že bude lepší cyklus nepřeložit, ale namísto toho kód jen několikrát zopakovat za sebou. Pokud vás problematika zajímá, doporučuji krásný článek Překladače pod pokličkou - optimalizace.
Specifika programování v Assembleru
A jak tedy takové programování v Assembleru vypadá?
Pokud si budeme programovat svůj vlastní operační systém, pak nemáme k
dispozici žádné předem vytvořené funkce. Nebudeme moct použít žádné
PrintString()
, ClearScreen()
, ani
SetCursorPosition()
. Úplně všechno si budeme muset vytvořit
sami.
Pokud bychom vytvářeli aplikaci pro historický operační systém MS-DOS,
pak máme k dispozici několik funkcí, které se volají přerušením
INT 21H
(k přerušením se v kurzu brzy dostaneme). Toto
přerušení je dostupné pouze v operačním systému MS-DOS, což znamená,
že ho (ani ostatní) nebudeme používat. My si vystačíme s přerušeními,
která nabízí BIOS.
Při programování aplikací pro moderní operační systémy (např. Windows nebo Linux) můžeme v Assembleru využívat všechna API konkrétního operačního systému. Například můžeme číst a zapisovat data do souborů, ovládat aplikaci klávesnicí a myší, zobrazit text na obrazovce. Naopak ale nesmíme používat přerušení BIOSu. Také nemůžeme používat všechny instrukce procesoru. Některé instrukce jsou totiž privilegované a jsou povoleny pouze pro jádro operačního systému a ovladače. V naší aplikaci nemůžeme přímo přistupovat k hardware, používat přerušení nebo přepínat režimy procesoru.
Ukázka kódu
Na závěr úvodního dílu si ukážeme a popíšeme zdrojový kód funkce
print_string
pro výpis textu jen pomocí rutin BIOSu. Metodu
budeme používat příště, kde si ji také vysvětlíme. Zde se na ni
můžete podívat jen proto, abyste věděli, jak programování v ASM vypadá a
do čeho se to vlastně pouštíme.
Funkce print_string
:
print_string: mov ah, 0x0e ; Funkce BIOSu pro teletype output xor bh, bh ; Pro výpis vybereme page 0 .print_char: lodsb ; Načteme hodnotu z DS:SI do AL or al, al ; Provedeme bitový součet, který nastaví příslušné příznaky jz short .return ; Pokud je nastaven příznak nuly (AL = NULL), skočíme na .return int 10h ; Vytiskneme znak jmp .print_char ; Opakujeme cyklus .return: ret ; Vrátíme se na místo, odkud jsme metodu zavolali (na další instrukci)
No a to je pro dnešek vše.
V příští lekci, Assembler - Zásobník, si povíme o tom, co je to zásobník, jak se dá používat, a na co si dát pozor, když s ním pracujeme.