Hardware máme, a co dál?
V předchozích dílech seriálu jsme navrhli a implementovali procesor MB5016, přidali jsme další komponenty a složili z nich počítač MB50. Celý počítač máme popsaný v jazyce VHDL. Když z něj necháme vývojové prostředí vygenerovat binární konfigurační stream a nahrajeme ho do FPGA, získáme kus hardwaru, který zatím nic nedělá. Počítač má prázdnou paměť, černou obrazovku, a CPU stojí na adrese 0×0000. Nastal čas, abychom se pokusili počítač „rozhýbat“.
Počítač MB50 má zabudované řídicí a ladicí rozhraní, připojené na sériový port. Přes něj je možné nahrávat programy do paměti a spustit vykonávání instrukcí v procesoru. Potřebujeme tedy sériový port připojit k nějakému hostitelskému počítači a z něj začít MB50 oživovat. Za tím účelem si naprogramujeme debugger.
Je sice možné psát software přímo v binárním strojovém kódu MB5016, ale to je dost nepohodlné. Na druhou stranu se nechceme pouštět ani do složitého vývoje překladače jazyka C nebo nějakého jiného vyššího programovacího jazyka. Místo toho zvolíme kompromis a naprogramujeme assembler.
Zdrojové kódy pro oba vývojové nástroje, debugger a assembler, jsou v repozitáři v adresáři mb50/mb50dev. Naprogramované jsou v C++23 a přeložit se dají jednoduše příkazem make. Debugger mb50dbg
je v souboru mb50dbg.cpp, assembler mb50as
je v mb50as.cpp a zdrojové kódy doplňuje společný hlavičkový soubor mb50common.hpp.
Při vývoji jsem používal Clang 19 na Ubuntu s volbami -fexperimental-library -stdlib=libc++
, ale kód by měl jít přeložit i jiným dostatečně moderním kompilátorem C++ i na jiných distribucích Linuxu, popř. i na jiných unixových systémech, jako je FreeBSD. Assembler používá pouze standardní knihovnu C++, proto by měl fungovat i na jiných operačních systémech. Debugger volá navíc několik funkcí z POSIXového API: tcgetattr()
, tcsetattr()
pro konfiguraci sériového portu a select()
pro současné čekání na data ze sériového portu a na stisk klávesy.
Debugger
Debugger běží na hostitelském počítači a přes sériový port komunikuje s ladicím rozhraním počítače MB50. To je implementované entitou cdi
v souboru cdi.vhd. Ladicí rozhraní je z jedné strany připojené na řadič sériového portu. Na opačné straně je napojené na řídicí signály CPU a řadiče paměti.
Komunikace po sériovém portu používá jednoduchý binární protokol. Komunikaci vždy zahajuje debugger. Pošle jeden bajt obsahující kód požadavku, po němž může následovat jeden nebo několik bajtů parametrů. Ladicí rozhraní vykoná požadovanou operaci a vrátí jednobajtový kód odpovědi, opět volitelně následovaný parametry. Činnost ladicího rozhraní je řízena konečným automatem.
Na sériovém portu se „optimisticky“ používá rychlost 115200 bitů/s bez parity a bez řízení toku dat (flow control). Předpokládá se, a dosavadní zkušenosti to potvrzují, že při přenosu nedochází k chybám a že obě strany stíhají číst příchozí data dostatečně rychle. Na straně MB50 pracuje konečný automat ladicího rozhraní s frekvencí hodinového kmitočtu 50 MHz a musí čekat maximálně než procesor dokončí aktuálně rozpracovanou instrukci, což trvá méně než mikrosekundu. Na straně hostitelského počítače očekáváme, že jeho operační systém (obvykle Linux) stíhá číst ze sériového portu bez ztráty dat.
Podporované akce prováděné prostřednictvím ladicího rozhraní zahrnují:
- Zjištění stavu procesoru: zda běží a jaká je aktuální hodnota čítače instrukcí (registru
pc
) - Přečtení hodnoty vybraného registru
- Změna hodnoty registru
- Přečtení hodnoty vybraného řídicího registru (CSR)
- Změna hodnoty řídicího registru
- Přečtení obsahu paměti. Adresa začátku a délka úseku se zadávají jako parametry požadavku.
- Zápis úseku paměti. Tato funkce se nejčastěji používá na nahrání programu, ale dá se využít i na modifikaci obsahu paměti při ladění.
- Vykonání jedné instrukce (krokování programu)
- Spuštění programu. Běžící program je zastaven když přijde požadavek na zastavení přes sériový port z debuggeru, nebo když se procesor sám zastaví vykonáním instrukce
brk
nebo v reakci na výjimku při zakázaném přerušení.
Debugger je jednoduchý interaktivní program s řádkovým rozhraním. Pracuje v nekonečném cyklu, kdy vždy přečte vstupní řádek od uživatele, interpretuje zadaný příkaz, vykoná ho buď lokálně nebo komunikací s ladicím rozhraním MB50, zobrazí výsledek a čeká na další vstup uživatele.
Příkazy na čtení a zápis registrů a paměti, krokování, spuštění a přerušení programu generují příslušné požadavky pro ladicí rozhraní. Při nahrávání programu debugger čte binární soubor obsahující strojový kód vygenerovaný assemblerem. Dále debugger podporuje příkazy na logování prováděných operací do souboru a naopak vykonávání příkazů přečtených ze souboru.
Assembler
Úkolem assembleru je přeložit zdrojový kód v assembleru (nebo hezky česky v jazyce symbolických adres) do binárního strojového kódu. Na vstupu dostává soubor se zdrojovým kódem s příponou .s
. Z něho generuje tři výstupní soubory.
Soubor s příponou .bin
obsahuje binární spustitelný strojový kód, který se dá pomocí debuggeru nahrát do paměti počítače MB50. V souboru .mif
je stejný strojový kód, ale v textovém formátu Memory Initialization File. Ten je možné přidat do projektu ve vývojovém prostředí Quartus Prime. Během syntézy se jeho obsah zahrne do konfiguračního streamu a při nahrání do FPGA se podle něj inicializuje obsah paměti. Tak lze mít v paměti program hned při zapnutí počítače, bez nutnosti nahrávat ho debuggerem.
Třetí výstupní soubor má příponu .out
. Obsahuje kopii zdrojového kódu s přidanými anotacemi obsahujícími expanze maker, symbolický a hexadecimální zápis vygenerovaných instrukcí a adresy, kde jsou instrukce uloženy v paměti. Tento soubor je určen primárně jako pomůcka pro programátora při ladění.
Pro ilustraci si ukážeme úryvek zdrojového kódu
char_loop: # Set black on white/yellow checkered pattern .set r2, .BG_WHITE | .FG_BLACK mv r10, r0 xor r10, r1
a odpovídající úsek vygenerovaného souboru .out
char_loop: # Set black on white/yellow checkered pattern .set r2, .BG_WHITE | .FG_BLACK ; MACRO /home/beran/devel/fpga/mb50/mb50sw/sys/macros.s:16 ldis REG, pc ; 0f06: ldis r2, r15 ; 0f06: $data_b 0x0c, 0x2f # 0x2f0c $data_w EXPR ; 0f08: $data_b 0x07, 0x00 # 0x0007 ; END_MACRO /home/beran/devel/fpga/mb50/mb50sw/app/demo1.s:56 mv r10, r0 ; 0f0a: mv r10, r0 ; 0f0a: $data_b 0x0e, 0xa0 # 0xa00e xor r10, r1 ; 0f0c: xor r10, r1 ; 0f0c: $data_b 0x1a, 0xa1 # 0xa11a
Assembler generuje přímo spustitelný program, neexistuje zde žádný linker, jenž by skládal samostatně přeložené moduly dohromady. Není ale potřeba psát vše do jednoho souboru. Pomocí direktivy $use
se dá výsledný program skládat z více zdrojových souborů.
Celý překlad probíhá v jednom průchodu zdrojovým kódem. Pokud se na nějakém místě odkazujeme na pojmenovanou konstantu nebo makro, musí být příslušné jméno definované před místem použití. Výjimkou jsou návěští (labels), která mohou být v kódu definovaná i za místem použití, protože jinak by nešly zapsat dopředné skoky.
Při překladu ze zdrojového textu do strojového kódu provádí assembler několik hlavních operací. Především podle tabulky instrukčních kódů převádí symbolický zápis instrukcí do binární podoby. Do výsledného binárního kódu vkládá data definovaná direktivami $data_b
a $data_w
, jejichž hodnoty se definují jako numerické nebo řetězcové literály, jména konstant definovaných v direktivách $const
, jména návěští (nahradí se příslušnou adresou), nebo kombinace těchto hodnot pomocí aritmetických operátorů. Assembler postupně buduje tabulku pro převod návěští na absolutní adresy. Naopak odkazy na návěští nahrazuje adresami, buď již při zpracování zdrojového kódu, nebo při druhém průchodu tabulkou návěští po přečtení celého vstupního souboru.
Důležitou funkcí je také definice a expanze maker. Direktivou $macro
se definuje makro. Následně je možné toto makro, volitelně s parametry, použít jinde v kódu. Použití makra je nahrazeno tělem makra, ohraničeným řádky $macro
a $end_macro
, v němž se jména parametrů makra nahradí hodnotami příslušných argumentů zadaných při použití makra. V těle mohou být použita další makra, expanze probíhá rekurzivně.
Makra se používají pro dva hlavní účely. Definují se pomocí nich pseudoinstrukce, tedy operace tvářící se jako instrukce, ale ve skutečnosti implementované pomocí jedné nebo více skutečných instrukcí. Příklady takových maker jsou nop
(žádná operace), definované jako instrukce mv r0, r0
, nebo set, REG, EXPR
(uložení hodnoty do registru), definované
$macro set, REG, EXPR ldis REG, pc $data_w EXPR $end_macro
Další oblastí využití maker je definice opakovaně použitelných úseků kódu, které z nějakého důvodu nechceme volat jako podprogramy, např. makra save_all
a restore_all
pro uložení všech registrů na zásobník na začátku podprogramu a pro obnovení registrů ze zásobníku před návratem z podprogramu.
Ukázky zdrojových souborů pro assembler je možné najít v adresáři mb50/mb50sw.
Obsah následujícího dílu
Příště si představíme systémovou knihovnu počítače MB50 a použijeme ji v několika ukázkových programech.
(Autorem obrázků je Martin Beran.)