# Výpočetní stroj V této kapitole si představíme základní výpočetní model, který pak budeme používat celý semestr. Realizovat jej bude výpočetní stroj ‹tiny›, který je sice velmi jednoduchý (minimální implementace v jazyce Python má přibližně 200 řádků kódu), ale umožní nám spouštět libovolné programy napsané v jazyce C.¹ ¹ Přesto, že v tomto předmětu budeme používat pouze podmnožinu jazyka C, není to na úkor obecnosti: tato podmnožina je dostatečně silná na to, aby do ní bylo možné přepsat libovolný program napsaný v plném ISO C99, a to včetně standardní knihovny. Také by bylo možné přímo jazyk ISO C99 překládat do strojového kódu stroje ‹tiny›. ## Model výpočtu Abychom se mohli o výpočtech přesně vyjadřovat (a také přesně uvažovat), musíme si zavést vhodný «model» výpočtu – efektivně matematický aparát, který nám umožní výpočet uchopit jako objekt. Standardní metodou, jak výpočet popsat, je jako sled «stavů» (konfigurací) nějakého «abstraktního stroje» a přechodů – «akcí» – mezi nimi. ### Motivace │ • zjednodušený model počítače │ • bez virtualizace, souběžnosti │ • 16bitové registry a adresy │ • jednoduchá instrukční sada Protože tento předmět je zaměřený spíše prakticky (a pragmaticky), chceme aby náš abstraktní výpočetní stroj co nejpřesněji odpovídal skutečným počítačům. Zároveň se ale nechceme takříkajíc „utopit v detailech“ – proto bude náš konkrétní výpočetní model určitým kompromisem. Většinu modelu a terminologie si půjčíme ze světa skutečných počítačů, v několika ohledech si ale situaci zjednodušíme: 1. nebudeme se zabývat virtualizací a souběžností – náš „počítač“ bude najednou vykonávat jediný sekvenční program, 2. omezíme se na výpočty se 16bitovými hodnotami a na 16bitovou adresaci – to nám umožní mít lepší přehled o obsahu paměti, a zároveň nám velmi usnadní čtení adres a čísel obecně, 3. instrukční sada ‹tiny› je oproti těm reálným velmi malá (obsahuje mnohem méně operací), jednodušší (jednotlivé operace toho dělají méně najednou) a pravidelnější. ### Stav │ • hodnoty uložené v │ ◦ registrech │ ◦ paměti │ • konečný počet možností Jak jsme zmínili výše, důležitým aspektem výpočtu jsou «stavy» výpočetního stroje. Stav stroje ‹tiny› má tři složky:¹ 1. obecné registry, 2. programový čítač, 3. paměť. Přesněji řečeno, stavem myslíme konkrétní hodnoty „uložené“ v těchto složkách. Pro ilustraci si na chvíli stroj ještě zmenšíme (na velikost, která už není pro výpočty prakticky použitelná, ale která se nám vejde na stránku). Přisoudíme mu pouze 2 obecné registry (prozatím je nazveme ‹r₁› a ‹r₂›), programový čítač ‹pc› a 12 bajtů (slabik) paměti. Stavy takovéhoto vskutku mikroskopického výpočetního stroje vypadají jako řádky této tabulky: │ ‹pc› │ ‹r₁› │ ‹r₂› │ paměť │ ├──────│──────│──────│─────────────────────────────────────│ │ 0000 │ 0000 │ 0000 │ 00 00 00 00 00 00 00 00 00 00 00 00 │ │ 0004 │ 0007 │ 03ff │ de ad de ad de ad de ad de ad de ad │ │ 0000 │ 1007 │ 7fff │ 00 00 00 00 00 00 00 de ad 00 co de │ Samozřejmě zatím nevíme, jaký mají takovéto stavy význam – tím se budeme zabývat za chvíli. Můžeme udělat ale jiné zajímavé pozorování – totiž kolik různých stavů takový výpočetní stroj má. Protože máme k dispozici pevný počet bitů, dva stavy se od sebe budou lišit v případě, že se liší alespoň v jednom bitu. To zejména znamená, že různých stavů bude «konečný počet»; velmi jednoduchá kombinatorická úvaha nás pak dovede k výsledku, že různých stavů je ⟦2¹⁴⁴⟧ – i pro takto malinký stroj se jedná o velmi úctyhodný počet. „Skutečný“ stroj ‹tiny› (ten, který budeme používat, resp. programovat) má 16bitový programový čítač, 16 16bitových registrů a 64 KiB paměti, tzn. jeho stav obsahuje 65570 bajtů resp. 524560 bitů informace. Různých stavů tedy existuje ⟦2⁵²⁴⁵⁶⁰⟧ což je přibližně ⟦10¹⁵⁷⁹⁰⁸⟧. Pro srovnání, celkový počet baryonů (hlavně protonů a neutronů) ve vesmíru se odhaduje na ⟦10⁸⁰⟧. ¹ Viz také sekce B.2 sbírky. ### Akce │ • počáteční stav = program │ • akce = přechod mezi stavy │ • stav přesně udává akci │ ◦ determinismus │ ◦ 1 program = 1 výpočet Velmi důležitou vlastností takto definovaného stroje je, že libovolný stav «přesně určuje» celý následující výpočet – jinými slovy, náš výpočetní model je «deterministický». Chování tohoto typu stroje je velmi jednoduché – je řízen sadou pravidel, podle které určíme jak z daného stavu odvodíme ten následovný. Výpočet budeme obvykle začínat ve stavu, kdy jsou všechny registry nulové, ale obsah paměti nikoliv – počáteční obsah paměti budeme nazývat «programem». S každým programem se tak váže právě jeden výpočet.¹ ¹ To může vypadat na pohled velmi nerealisticky – všichni zřejmě máme zkušenost, kdy spuštění téhož programu (v neformálním smyslu) vede k různým výsledkům. To je způsobeno jednak tím, že reálné počítače nejsou deterministické (reagují na vnější události) a také tím, že v reálných situacích často nemáme počáteční stav zcela pod kontrolou a to co se nám jeví jako tentýž stav je ve skutečnosti mnoho různých, ale těžko odlišitelných, stavů. ### Speciální stavy │ • výpočet může být konečný │ • rozlišení důvodu ukončení │ ◦ konec programu │ ◦ chybná instrukce │ ◦ selhání tvrzení Výpočet může mít jeden ze dvou základních tvarů: 1. konečný výpočet, tvořen konečnou posloupností po dvou různých stavů, je obvyklá a žádoucí situace; může mít dvě varianty: a. úspěšný výpočet který byl ukončen instrukcí ‹halt›, přitom «výsledek» výpočtu jsme obvykle schopni odečíst z posledního stavu, b. neúspěšný výpočet, který byl ukončen chybou (viz také níže), 2. nekonečný výpočet, ve kterém se nějaká posloupnost stavů nekonečněkrát opakuje – je složen z konečného prefixu (kde se stavy neopakují) a nekonečné smyčky, celkově má tvar tzv. lasa. Chyby, které mohou výpočet ukončit mohou být různých typů: • nepodařilo se dekódovat instrukci, tzn. na adrese uložené v registru ‹pc› nezačíná platné kódování žádné instrukce, • při vykonání instrukce došlo k fatální chybě (typickým příkladem je zde dělení nulou, případně neplatný přístup do paměti), • selhalo «tvrzení» (angl. «assertion») realizované instrukcí ‹asrt› – došlo k porušení programátorem určené vstupní nebo výstupní podmínky nebo invariantu, • byla detekována sémanticky chybná operace, obvykle podle anotací vložených překladačem – z pohledu stroje by výpočet mohl bez problémů pokračovat, ale pravděpodobně by vedl k nesprávnému výsledku – situace, která se podobá na selhané tvrzení z předchozího bodu. ## Instrukční sada V dalším se budeme zabývat akcemi, které má stroj k dispozici. Abychom mohli popsat jejich «efekt», musíme si ale nejprve upřesnit jak vypadají jednotlivé složky stavu a jaký mají význam. ### Registry │ • 16 obecných registrů │ ◦ ‹rv›, ‹l1› … ‹l7›, ‹t1› … ‹t6›, ‹bp›, ‹sp› │ ◦ výpočetně zcela rovnocenné │ ◦ ‹sp› má navíc speciální využití │ ◦ jména jinak pouze pro lidi │ • programový čítač ‹pc› Nyní se na jednotlivé složky stavu podíváme blíže. Stroj ‹tiny› má 16 „obecných“ a jeden „speciální“ registr. Obecné registry mají mnemonické názvy, které ale na výpočet nemají žádný vliv. Naznačují pouze typické použití (programy, které vzniknou překladem z jazyka C se budou tohoto konvenčního použití držet): • ‹rv› od «return value» je registr určený pro předávání návratové hodnoty z podprogramu (více si o podprogramech povíme ve třetí kapitole), • ‹l1› až ‹l7› jsou registry pro lokální proměnné a pro předávání parametrů podprogramům (jak později uvidíme, mezi formálním parametrem a lokální proměnnou není v jazyce C příliš velký rozdíl), • ‹t1› až ‹t6› slouží pro dočasné hodnot – mezivýsledky, např. při výpočtu složených výrazů – při výpočtu ‹a + b + c› budeme potřebovat dočasně někam uložit výsledek ‹a + b›, • ‹bp› a ‹sp› opět souvisí s podprogramy, konkrétněji se správou zásobníku. Krom těchto 16 obecných registrů, které mohou být operandy libovolné aritmetické instrukce (a zároveň také jejich cílovým registrem), má stroj ‹tiny› ještě speciální registr • ‹pc› (program counter, programový čítač), který obsahuje adresu ze které bude načtena, dekódována a provedena další instrukce. ### Paměť │ • 64 KiB (65536 jednoslabičných buněk) │ • adresace 16bitovým číslem │ • každá adresa je platná │ • program začíná adresou 0 Stroj ‹tiny› disponuje 64 KiB paměti. Tím se myslí, že tato paměť je složená z ⟦2¹⁶⟧ buněk, přitom každá buňka je schopna uchovávat číslo od 0 do 255. Tyto buňky jsou navíc očíslované (mají adresy). S paměťovými buňkami lze pracovat použitím instrukcí ‹ld› a ‹st› (více o nich ve čtvrté kapitole). Se kterou buňkou si přejeme pracovat určuje «hodnota uložená v registru», tzn. adresa zejména může být výsledkem nějakého výpočtu. Všimněte si, že se jedná o zcela zásadní rozdíl oproti registrům – se kterými registry daná instrukce pracuje je její «pevnou součástí». ### Sémantika │ • popis za pomoci mikroinstrukcí │ • efekt instrukce na stav │ • instrukce určena stavem │ ◦ 4 bajty od adresy dané ‹pc› Krom «syntaxe» – toho, jak instrukce zapisujeme, potažmo kódujeme, nás bude zajímat jejich «sémantika» – význam. Co instrukce znamená budeme zejména popisovat tím, jaký má efekt na stav. Stav stroje jednoznačně určuje, jaká instrukce bude spuštěna – je to ta, které kódování je uloženo na adrese ‹pc› (každá instrukce je kódovaná do 4 bajtů, tzn. ve skutečnosti je uložena na adresách ‹pc›, ‹pc + 1›, ‹pc + 2› a ‹pc + 3›). Řada instrukcí bude mít podobné efekty. Zejména každá instrukce, která není instrukcí řízení toku, zvýší registr ‹pc› o 4, čím způsobí, že jako další bude spuštěna instrukce na nejbližší vyšší adrese. ### Operandy │ • registrové operandy │ ◦ 0–2 vstupní registry │ ◦ 0 nebo 1 výstupní registr │ • přímý operand │ ◦ hodnota je součást instrukce Podobně obsahují téměř všechny instrukce nějaké «operandy», které určují, s jakými daty bude operace pracovat. Typicky budou operandy «identifikátory registrů». Počet a forma operandů se pro různé operace bude lišit, ale ve všech případech spadají do těchto mezí: • součástí instrukce můžou být až 2 «vstupní registry» – operandy, které určí, ze kterých registrů se mají načíst vstupní hodnoty, • instrukce může mít nejvýše jeden «výstupní registr» – operand, který určí, do kterého registru bude zapsán výsledek, • instrukce může obsahovat nejvýše jeden «přímý operand» – 16bitové slovo, které je přímo součástí instrukce, a které se použije jako jedna ze vstupních hodnot (která to bude závisí od operace). ### Kódování │ • dvě slova (2⋅16 = 32b) │ • horní slovo │ ◦ kód operace │ ◦ výstupní registr │ ◦ vstupní registr 1 │ • spodní slovo │ ◦ vstupní registr 2 «nebo» │ ◦ přímý operand Instrukce stroje ‹tiny› mají velice jednoduché a pravidelné kódování – je to zejména proto, abychom byli v případě potřeby schopni instrukce dekódovat (alespoň přibližně) i „ručně“, z číselného výpisu obsahu paměti. Také se tím značně zjednodušuje implementace dekodéru (který máte k dispozici jako ukázku ve sbírce). Instrukce jsou kódovány do dvou 16bitových slov, přitom: 1. horní slovo obsahuje: a. 8bitový kód operace v horní slabice, b. dva registrové operandy ve spodní slabice – výstupní registr v horní půlslabice a první vstupní registr v té spodní, 2. spodní slovo kóduje zbývající vstupní operand: ◦ druhý vstupní registr (v nejvyšší půlslabice), nebo ◦ přímý operand (využije celé spodní slovo). Jak se dekódují operandy je určeno kódem operace, který je proto potřeba dekódovat jako první. ### Kopírování hodnot │ • 1× vstup + 1× výstup │ • nastaví hodnotu registru │ • ‹copy reg₁ → reg₂› │ • ‹put imm → reg₁› Nejzákladnější operací, kterou můžeme v programu potřebovat, je nastavení registru, a to buď na předem známou konstantu, nebo na hodnotu aktuálně uloženou v některém jiném registru. K tomuto účelu můžeme použít operace ‹copy› (kopie mezi registry) a ‹put› (nastavení registru na konstantu). ### Binární operace │ • aritmetika, srovnání │ • bitové operace (logické, posuvy) │ • 2× vstup + 1× výstup │ • vstup/výstup se mohou překrýt Binárních operací je k dispozici celá řada – odpovídají běžným aritmetickým a bitovým operacím, které z velké části již znáte. Patří sem:¹ • aritmetika: sčítání ‹add›, odečítání ‹sub›, násobení ‹mul›, dělení (‹sdiv›, ‹udiv›, ‹smod›, ‹umod› – rozdíly mezi verzemi ‹s___› a ‹u___› vysvětlíme později), • srovnání – výsledkem je hodnota 0 nebo 1: rovnost ‹eq›, nerovnost ‹ne›, znaménkové porovnání ‹slt›, ‹sgt›, ‹sle›, ‹sge›, neznaménkové porovnání ‹ult›, ‹ugt›, ‹ule›, ‹uge›, • bitové operace: logické ‹and›, ‹or›, ‹xor› a posuvy ‹shl›, ‹shr›, ‹sar› (aritmetický). ¹ Mnohem podrobněji je význam jednotlivých operací popsán ve sbírce. ### Operace řízení toku │ • efekt na hodnotu ‹pc› │ • nepodmíněný přímý skok ‹jmp› │ • podmíněný přímý skok ‹jz›, ‹jnz› │ • volání podprogramu později Do registru ‹pc› není možné běžnými instrukcemi přímo zapisovat ani z něj číst; efekt ‹jmp addr› je velmi podobný hypotetické instrukci ‹put addr → pc›, ale protože je tento efekt velmi odlišný od všech ostatních použití operace ‹put›, má svůj vlastní zápis.¹ Naproti tomu operace ‹jz› a ‹jnz› se na žádnou další známou instrukci nepodobají – jsou totiž «podmíněné» – jejich efekt se bude lišit podle hodnoty operandu. Uvažme instrukci ‹jz reg, addr› – tato instrukce se bude chovat jako ‹jmp addr› v případě, že je v registru ‹reg› uložena nula. V případě opačném bude výpočet pokračovat následující instrukcí. Jinými slovy: • ‹jz reg, addr› nastaví ‹pc› na hodnotu přímého operandu ‹addr›, je-li v registru ‹reg› uložena nula, • jinak zvýší hodnotu ‹pc› o 4. Operace ‹jnz› je pak analogická, liší se pouze inverzí podmínky (skok se provede je-li registr nenulový). ¹ Tento přístup – striktně oddělovat instrukce řízení toku – je zcela běžný i u reálných procesorů. ### Řízení stroje │ • zastavení ‹halt› │ ◦ bez podmínky │ ◦ indikuje úspěch │ • tvrzení ‹asrt› │ ◦ podmíněno vstupním registrem │ ◦ nula = neúspěch → chyba Jak jsme již zmiňovali dříve, za výstup programu budeme brát pouze formu jeho ukončení: • výpočet může být úspěšně dokončen, což program signalizuje provedením instrukce ‹halt›, která nemá žádné operandy, • výpočet může skončit chybou – tuto lze signalizovat instrukcí ‹asrt reg›, přitom chyba nastane pouze v případě, že v registru ‹reg› je uložena nula (v opačném případě výpočet pokračuje další instrukcí, tzn. registr ‹pc› se zvýší o 4) – jedná se o analogii tvrzení ‹assert› jak ho znáte z jazyka Python, • výpočet může skončit jinou chybou (špatná instrukce, atp.) nebo se může „zacyklit“ – neskončí nikdy (v praxi budeme vždy délku výpočtu nějak uměle omezovat,¹ „nikdy“ je totiž velmi dlouhá doba). ¹ Jak již bylo zmíněno dříve, stroj ‹tiny› může v principu zacyklení spolehlivě detekovat (je to díky tomu, že konfigurace stroje má pevnou velikost – existuje tak pouze konečně mnoho různých konfigurací). V praxi může být takový cyklus velmi dlouhý a tedy těžce odhalitelný. ## Řízení toku V poslední části první kapitoly si ukážeme, jak ve strojovém kódu zapsat standardní konstrukce řízení toku z vyšších programovacích jazyků – podmíněný příkaz a cyklus. Protože jediný vyšší programovací jazyk, který v tuto chvíli známe, je Python, budeme jej prozatím využívat jako pseudokód. V pozdějších kapitolách pak budeme používat jazyk C. ### Konstrukce strojového kódu │ • rekurzivní algoritmus │ • postupujeme po struktuře programu │ ◦ blok složíme z příkazů │ ◦ výraz složíme z podvýrazů │ • zatím pouze intuitivně Jak již bylo zmíněno v kapitole B, překladem rozumíme zobrazení, kterého vstupem je nějaký program ⟦P⟧, zatímco výstupem je nový program ⟦Q⟧ (typicky v jiném jazyce), přitom ale platí, že výpočet ⟦P⟧ končí úspěchem «právě když» výpočet ⟦Q⟧ končí úspěchem. Zobrazení je přitom definováno pouze pro «platné» vstupní programy. Nyní jsme připraveni provést první náčrt toho, jak toto zobrazení «spočítáme» (jinými slovy, jak funguje překladač). Z předchozího studia¹ víte, že «výrazy» (zejména aritmetické, ale i libovolné jiné) můžeme reprezentovat pomocí stromů (ve smyslu datové struktury). Stejný přístup lze použít i pro příkazy a další prvky programovacích jazyků. Jinak řečeno, zdrojové jazyky překladu mají typicky «rekurzivní strukturu», kterou lze zachytit (abstraktním) «syntaktickým stromem». Tento strom (angl. AST, z «abstract syntax tree») je tak přesnou reprezentací «vstupního programu» a budeme jej považovat za startovní bod překladu.² Jednoduchý překladač sestavuje strojový kód «rekurzivně», podle struktury vstupního stromu. To zejména znamená, že překlad nějakého uzlu obdržíme vhodnou kombinací překladů jeho potomků. V této kapitole si ukážeme pouze překlad uzlů, které odpovídají příkazům řízení toku – ‹if› a ‹while› – abychom získali představu, jaký je vztah mezi vstupním programem (tím, který typicky píšeme) a strukturou výstupního strojového kódu. Kompletní algoritmus překladu jazyka C³ vybudujeme postupně (naleznete jej vždy ve sbírce, ve skriptech a přednášce vyzvedneme jen ty nejzajímavější části). ¹ Např. příklady z kapitoly 9 sbírky předmětu IB111, nebo mnohem podrobněji přednášky 11 a 12 téhož. ² Skutečné překladače tento strom konstruují ze vstupního textu. Touto částí překladu – syntaktickou analýzou – se zde zabývat nebudeme. Předpokládejme, že naším vstupem je již hotový abstraktní syntaktický strom. ³ Resp. jeho podmnožiny, kterou budeme v tomto předmětu používat. ### Podmíněný příkaz │ • nejjednodušší forma: ‹if b: stmt₁› │ ◦ realizace jedním podmíněným skokem │ ◦ není-li ‹b› splněno, přeskoč tělo │ • rozšíření: ‹else› větev │ ◦ podmíněný + nepodmíněný skok │ ◦ na konci „then“ přeskočíme za blok ‹else› Použité instrukce: • ‹jmp›, ‹jz›, ‹jnz› • podmíněný skok realizuje «rozhodování» ### Nekonečný cyklus │ • jak zapsat ‹while True›? │ • realizace jedním skokem │ ◦ nepodmíněným │ ◦ na nižší adresu │ • časem na něj opět narazíme ### Cyklus ‹while› │ • nekonečný cyklus + podmíněný ‹break› │ • ‹break› je jeden podmíněný skok │ ◦ pro ‹while› na začátku těla │ ◦ skok za konec těla │ • jednodušší: ‹do›–‹while›