RISC-V - budujemy własny mikrokontroler (1)

RISC-V - budujemy własny mikrokontroler (1)

Otwarte oprogramowanie jest spotykane w wielu dziedzinach od kilkudziesięciu lat. W wielu przypadkach wypierało swoich płatnych konkurentów. Zadaniem, jakie przed sobą stawiają członkowie projektu RISC-V, jest przeprowadzenie podobnej rewolucji w dziedzinie procesorów.

ISA

RISC-V nie jest gotowym procesorem. Chcąc być precyzyjnym należy stwierdzić, że jest to ISA - (instruction set architecture - model programowy procesora). Można powiedzieć, że jest to warstwa, która umożliwia komunikację pomiędzy światem oprogramowania (kompilatorami, asemblerami), a światem sprzętu (twórcami układów scalonych). Składa się na nią między innymi lista dostępnych dla programisty zasobów (takich jak rejestry, czy jednostki arytmetyczno logiczne) oraz rozkazów pozwalających na sterowanie nimi.

Obecnie dwa najbardziej popularne ISA należą do firm Intel oraz ARM. Są one jednak komercyjne. Nie możemy stworzyć własnej implementacji bez wykupienia odpowiedniej licencji. Celem rozpoczętego w 2015 roku na uniwersytecie Berkley projektu RISC-V jest stworzenie otwartego standardu [1]. Obecnie do fundacji należy ponad 200 firm i instytucji w tym między innymi Google czy Nvidia, a od listopada 2018 współpracuje ona z Linux Foundation. Dzięki temu dostępne są już kompilatory, symulatory oraz coraz więcej oprogramowania przystosowanego do pracy z tą platformą [2].

Własny otwarty procesor na pierwszy rzut oka wydaje się czymś bardzo abstrakcyjnym. Choć niektórzy hobbyści pokazali [3], że układ scalony nie jest czymś, czego nie da się stworzyć we własnym garażu* to jednak wydaje się że budowa czegoś tak złożonego jak procesor jeszcze na długo pozostanie domeną jedynie wielkich korporacji. Na szczęście mamy jednak układy FPGA. Pozwalają one na stworzenie własnego sprzętu zdefiniowanego przez wgrany wsad. Rdzeń mikrokontrolera uruchomiony w taki sposób często nosi nazwę soft core - miękki rdzeń, co można rozumieć jako rdzeń zdefiniowany programowo.

Wersje RISC-V ISA

Dokumentacja RISC-V składa się z trzech dokumentów [4]:

  • User-Level ISA Specification - specifikacja ISA poziomu-użytkownika,
  • Privileged ISA Specification - specyfikacja ISA przywilejów,
  • Debug Specification - specifikacja debugowania.

Pierwszy dokument omawia zestaw podstawowych instrukcji i elementów rdzenia. Wyróżnia cztery podstawowe zestawy instrukcji: RV32I, RV32E, RV64I, RV128I. Jak łatwo się domyślić numer odnosi się do liczby bitów składających się na rejestry mikrokontrolera. Litera I pochodzi od integer (liczba całkowita) ponieważ operują one jedynie na liczbach całkowitych. Wersja RV32E to uproszczona wersja przeznaczona dla niewielkich systemów wbudowanych (embedded system). Większą część dokumentacji stanowią zestawy opcjonalnych instrukcji. Wśród nich znajdziemy między innymi: M - mnożenie i dzielenie liczb całkowitych, F - instrukcje zmienno przecinkowe, czy V - instrukcje wektorowe. Specyfikacja zostawia także wolne rozkazy, które twórcy procesorów mogą zagospodarować zgodnie ze swoją fantazją.

Drugi dokument opisuje elementy procesora związane z zarządzaniem poziomów uprzywilejowana. Są one niezbędne, jeżeli chcemy uruchomić na procesorze współczesny system operacyjny. Definiuje ona między innymi obsługę przerwań, czy zarządzanie dostępem do pamięci fizycznej.

Celem trzeciego dokumentu jest stworzenie standardu umożliwiającego debugowanie procesorów RISC-V. W momencie pisania tego artykułu jest on dostępny dopiero w wersji 0.13.2.

Rejestry i instrukcje

Aby lepiej zapoznać się z architekturą RISC-V postanowiłem przygotować prostą implementację w języku SystemVerilog. Bazując na materiałach z kursu [6], a zwłaszcza na proponowanej liście instrukcji [7]: zdecydowałem się na pominięcie będących częścią RV32I instrukcji barier oraz wywołań systemowych. Moim celem jest stworzenie prostego mikrokontrolera, który pozwoli rozpocząć własną przygodę z tą architekturą.

W procesorze RISC-V wszystkie operacje wykonywane są na rejestrach, do i z których możemy przenosić dane z pamięci. W wersji RV32I mamy do dyspozycji 32 rejestru uniwersalnego przeznaczenia oznaczone x0-x31. Rejestr x0 jest zawsze równy 0, a zapisywane do niego dane są tracone. Pozornie może się to wydawać marnotrawstwem, lecz jak zobaczymy za chwilę pozwala to na sprytną implementację wielu pseudo instrukcji. Pozostałe rejestry można wykorzystywać dowolnie, lecz jak pokazano w tabeli 1, przyjęto pewne konwencje do czego powinny być one użyte. Wprowadzono także dodatkowe oznaczenia, które są rozumiane przez assemblery. Dodatkowym rejestrem jest 32 bitowy pc-wskaźnik programu (program counter).

Wszystkie wykorzystane przez nas instrukcje mają długość 32 bitów. Korzystają one z jednego z sześciu formatów instrukcji przedstawionych na rysunku 1. Celem projektantów było stworzenie jak najprostszych do dekodowania instrukcji. W każdym z formatów numery rejestrów oraz kody funkcji zajmują zawsze te same bity. Pozostałe bity są przeznaczone dla stałych natychmiastowych (immediate). Każda z nich jest liczbą ze znakiem, który zawsze znajduje się na najstarszym 31 bicie instrukcji.

Rodzaj instrukcji określa wartość opcode. Dla instrukcji podstawowych dwa najmłodsze bity (opcode[1:0]) są zawsze równe b11. Wartości implementowanych instrukcji przedstawia tabela 2.

Instrukcje operujące na rejestrach i stałych

Chyba najprostszą rodziną jest OP. Składa się ona z 10 instrukcji realizujących działania na dwóch rejestrach rs1 i rs2 i zapisują wynik do rejestru rd. Sposób ich kodowania pokazuje rysunek 2. Jak widzimy są one typu r. Wspierane są dwa działania arytmetyczne: dodawanie (ADD) i odejmowanie (SUB). Instrukcje mnożenia i dzielenia są dostępne w rozszerzeniu M. W bazowej wersji muszą zostać zrealizowane programowo za pomocą instrukcji arytmetycznych oraz przesunięć. Tych ostatnich dysponujemy trzema rodzajami: przesunięcia logiczne w lewo (SLL) i prawo (SRL) oraz przesunięcie arytmetyczne w prawo (SRA). To ostatnie zachowuje znak rejestru. Do dyspozycji mamy typowe operacje logiczne: koniunkcję (AND), alternatywę (OR) oraz alternatywę wykluczającą (XOR). Nietypowe są dwie instrukcje, które wpisują do rejestru rd wartość 1, gdy rs1 jest mniejsze od rs2. SLT porównuje liczby zakładając, że są one zakodowane w reprezentacji ze znakiem, a SLTU bez znaku.

Instrukcje z rodziny OP-IMM są bliźniaczo podobne. Lecz zamiast pobierać drugi operand z rejestru wykorzystują 12 bitową stałą bezpośrednią ze znakiem. Pozwalają więc zakodować liczby całkowite z zakresu od 2047 do -2048. Składa się ona z 9 instrukcji odpowiadających wszystkim operacją z rodziny OP z wyjątkiem odejmowania. Nie jest ono potrzebne, ponieważ asembler może zamienić je na dodawanie stałej ujemnej. Kodowanie instrukcji jest przedstawione na rysunku 3.

Jak widzimy nazwy mnemoników różnią się dodaniem litery I. W większości wykorzystują one format I. Wyjątkiem są instrukcje przesunięć bitowych. Ponieważ przesunięcie o więcej niż 31 jest pozbawione sensu, więc rozróżnienie pomiędzy arytmetycznym i logicznym jest zakodowane w niewykorzystanej części, tak jakby było tam umieszczone func7 w instrukcjach r.

Dostępne są także dwie instrukcje pozwalające na ładowanie do rejestrów długich stałych bezpośrednich. Pokazano je na rysunku 4. Wykorzystują one format U, który przeznacza na nią 20 najstarszych bitów. Pierwsza z nich to LUI, która po prostu wpisuje wartość z pola imm do podanego rejestru. W połączeniu z wcześniej poznaną instrukcją ADDI pozwala na załadowanie dowolnej 32-bitowej stałej. Druga AUIPC działa dość podobnie, ale do rejestru rd zapisuje sumę stałej i aktualnej wartości licznika PC. W połączeniu z instrukcją skoku JALR pozwala wpisać do rejestru PC dowolną 32-bitową stałą liczbową.

Znamy już instrukcje pozwalające wykonywać operacje na stałych i rejestrach. Jest jednak kilka szczególnych przypadków ich wykorzystania, które są na tyle użyteczne, że doczekały się swoich własnych nazw. Niektóre z nich (na przykład nop) mogą być zrealizowane na różne sposoby. Aby ułatwić tworzenie programów oraz w celu ujednoznacznienia kodu asemblera niektóre z nich zostały zdefiniowane. Są to tak zwane pseudo instrukcje, które przez Assembler są zamieniane na jedną, albo kombinację kilku rozkazów procesora. Przyjrzyjmy się części z nich.

Pierwszą której się przyglądniemy jest trochę niedoceniana, lecz bardzo przydatna instrukcja nop (no operation - brak funkcji). Ustalono, że zawsze będzie ona realizowana jako:

Symulator

Na razie było dość dużo teorii. Dla równowagi trzeba więc trochę poeksperymentować. Poza tym samodzielne tłumaczenie mnemoników na wartości binarne byłoby bardzo nużące. Dlatego do assemblacji oraz testowania programów wykorzystamy internetowy symulator [8]. Działa w całości w przeglądarce, więc można go pobrać i używać offline. Gdy wejdziemy pod adres [8] zobaczymy stronę podobną do tej z rysunku 5. Program piszemy w zakładce Editor. Możemy używać zarówno instrukcji, jak i pseudo instrukcji. Gdy przejdziemy do zakładki Simulator zostaną one zamienione na odpowiednie kombinację rozkazów, co pokazuje rysunek 6. Za pomocą przycisku Step - krok, możemy wykonać kolejne rozkazy. Aktualny stan rejestrów widzimy w zakładce Register, a pamięci w Memory. Po kliknięciu przycisku Dump - zrzucić w polu Console output - wyjście konsoli pojawi się wynikowy kod w postaci liczbowej, który wykorzystamy później do zaprogramowania pamięci w naszym mikrokontrolerze.

Zanim zaczniemy się zastanawiać jak zaimplementować działający rdzeń, warto chwilę samemu poeksperymentować z symulatorem, aby lepiej zapoznać się z efektami wykonania różnych instrukcji. Na listingu 1 jest pokazany przykładowy kod, który demonstruje poznane przed chwilą operację na rejestrach i stałych. Można go także znaleźć w repozytorium [9] w pliku code/regop.mem. Po pobraniu repozytorium należy przejść do tagu v1_0. Warto go samemu wkleić do symulatora i za pomocą przycisku Step przejść go krok po kroku przyglądając się jak wpłynie on na stan rejestrów.

Składnia została pokolorowana za pomocą modułu dla środowiska Visual Studio Code, który można pobrać spod adresu [15]. W pierwszych dwóch liniach znajdziemy przykład wykorzystania pary instrukcji luiaddi pozwalających wczytać dowolną 32 bitową stałą. Następnie zaprezentowane są przykładowe operacje. Szczególnie warto przyjrzeć się różnicy pomiędzy działaniem instrukcji sltisltiu. Stała -1 jest po prostu ciągiem „samych jedynek”. Pierwsza instrukcja interpretuje je jako liczbę ze znakiem, czyli -1, przez co porównanie 0<=-1 jest fałszem. Druga natomiast ten sam ciąg bitów rozumie jako liczbę bez znaku: 4294967295 (232-1). A ponieważ 0<=4294967295, więc do rejestru x5 zostanie wpisana liczba 1 symbolizująca prawdę.

Na samym końcu znajduje się nieskończona, pusta pętla. Zapobiega ona przed wyjściem mikrokontrolera poza kod programu i interpretowaniem śmieci z pamięci jako rozkazów. Zrealizowana jest za pomocą instrukcji skoku.

Skoki

Na rysunku 7 możemy zobaczyć dwie instrukcje, które pozwalają na realizację skoków. Pierwsza z nich pozwala dodać do rejestru PC 20 bitową liczbę ze znakiem. Ponieważ w architekturze RISC-V adresy rozkazów muszą być wyrównane co najmniej do 2 bajtów (a w prezentowanej uproszczonej implementacji do 4) nie musimy kodować najmłodszego bitu, gdyż będzie on zawsze równy 0. Dzięki temu możliwy jest skok o wartość od 524286 do -524288. Jeżeli jest to zbyt mały zakres jak dla potrzeb naszego programu możemy za pomocą rozkazu AUIPC przygotować w dowolnym rejestrze sumę aktualnej wartości rejestru PC i starszych 20 bitów nowego adresu, a następnie za pomocą instrukcji JALR ustawić młodszych 12 bitów i dokonać skoku pod dowolny adres w 32 bitowej przestrzeni adresowej.

Obie z poznanych instrukcji zapisują w rejestrze rd adres następnej instrukcji, która byłaby wykonana, gdyby skok nie został wykonany, czyli wartość PC+4. Jeżeli adres powrotu nie jest potrzebny można po prostu jako rd ustawić rejestr x0. Aby lepiej zrozumieć ich działanie przyglądnijmy się dwóm programom realizującą nieskończoną pętlę inkrementującą wartość w rejestru x5. Oba rozpoczynają się od jego wyzerowania. Kod z listingu 2. realizuję pętlę z wykorzystaniem instrukcji jal. Zamiast samemu obliczać adres skoku użyto etykiety loop. Gdy uruchomimy kod w symulatorze zauważymy, że instrukcja została rozwinięta do postaci:

Pozostałe pseudo instrukcje skoków warunkowych zebrane są w tabeli 4. Pierwsze dwie realizują skok pod stałą (j) oraz pod wartość z rejestru (jr), ale w przeciwieństwie do standardowych instrukcji nie zapamiętują adresu powrotu. Korzystają one z faktu, że zapis do rejestru x0 nie ma żadnego efektu. W ten sposób poznaliśmy tajemnicę ostatniej linijki przykładu z listingu 1.

Trzy ostatnie instrukcje ułatwiają nam tworzenie podprogramów poprzez założenie, że adres powrotu jest zawsze zapisywany do rejestru x1, który z tego powodu posiada alternatywną nazwę ra (return address - adres powrotu). Instrukcje jaljalr pozwalają na wywołanie, a ret na powrót z procedury.

Skoki warunkowe

Znamy już instrukcje, które pozwalają na przeprowadzanie operacji na danych oraz przemieszczanie się w różne miejsca programu. Niestety nasz rdzeń nie potrafi jeszcze podjąć żadnej decyzji. Potrzebna jest do tego kolejna grupa sześciu instrukcji skoków warunkowych BRANCH. Są pokazane na rysunku 8, każda z nich jest zakodowana w formacie SB. Pozwalają przejść w inne miejsce programu, ale tylko wtedy gdy spełniony jest konkretny warunek.

W przeciwieństwie do innych popularnych ISA takich jak MIPS czy ARM, decyzja o skoku nie jest wykonywana na podstawie wartości wcześniej ustalonych flag. Każda instrukcja skoku przyjmuje dwa rejestry źródłowe i sprawdza czy spełniają one warunek. Dostępnych jest sześć możliwości: równe, różne oraz mniejsze niż i większe bądź równe w wariantach z oraz bez znaku. Instrukcje mniejszy lub równy oraz większy niż nie są potrzebne. Assembler może po prostu zamienić kolejność rejestrów i użyć dwóch poprzednich operacji. Tak jak przy skokach bezwarunkowych nie kodujemy najmłodszego bitu adresu docelowego. Pozwala to na rozszerzenie zakresu skoku od 4094 do -4096.

Krótki przykład ich użycia przedstawia listing 4. Można go także znaleźć w repozytorium w pliku code/branch.S. Realizuje pętlę sumującą liczby od 1 do 2. W linii 2 do rejestru x1 wpisujemy liczbę 2, która określi liczbę iteracji pętli, a w linii 3 zerujemy rejestr x2, do którego wpisujemy wynik. Następnie od linii 5, w której znajduje się etykieta loop rozpoczynamy pętle. Najpierw dodajemy do x2 wartość z x1, a następnie dekrementujemy x1. Najważniejsza część kodu to linia 8, w której następuje porównanie wartości x1 z 0 (dla przypomnienia do rejestru x0 zawsze wpisane jest 0). Jeżeli wartości są różne, nastąpi skok pod etykietę loop. Gdy uruchomimy kod w symulatorze przekonamy się, że została ona zmieniona na liczbę -8, co oznacza, że cofniemy się o dwie instrukcje. Po drugiej iteracji wartość x1 będzie równa 0 i skok nie nastąpi, ale przejdziemy do instrukcji z linii 10, która za pomocą znanej nam już pseudo instrukcji powróci na początek programu.

Istnieją także pseudo instrukcje oparte na rozkazach skoków warunkowych. Są one zebrane w tabeli 5. Pierwsze sześć realizuje sprawdzenia względem 0, korzystając ze standardowych instrukcji i rejestru x0. W programie przykładowym moglibyśmy więc zastąpić instrukcję bne bardziej czytelną formą bnez. Ostatnie cztery pozwalają zwiększyć czytelność programu poprzez wykorzystanie porównań większy niż oraz mniejszy bądź równy. Assembler za nas zmieni kolejność operandów i wywoła dostępne w ISA instrukcje porównań.

Podsumowanie

Mam nadzieję, że udało mi się zainteresować czytelników tematem tworzenia własnych mikrokontrolerów. W kolejnym artykule zostanie omówiona tematyka dostępu do pamięci oraz zajmiemy się budową rdzenia naszego procesora.

Rafał Kozik
rafkozik@gmail.com

 

*) Dodajmy, że jest to garaż wyposażony w sprzęt, w tym urzadzenia technologiczne (piec do dyfuzji, napylarka), wartości milionów dolarów [3]

 

Bibliografia

[1] http://bit.ly/2MFFapx, dostęp na dzień 29.04.2019
[2] http://bit.ly/2YP0EGY, dostęp na dzień 29.04.2019
[3] Zeloof S., First IC:) http://bit.ly/33dwwEx, dostęp na dzień 29.04.2019
[4] http://bit.ly/2MHjQQL
[5] Computer Structures, MIT 6.004, Spring 2019 http://bit.ly/33dx4dL
[6] MIT 6.004 RISC-V ISA Reference Card, http://bit.ly/2TcOS43
[7] The RISC-V Instruction Set Manual, Volume I: User-Level ISA, Document Version2.2, Editors Andrew Waerman and Krste Asanovic, RISC-V Foundation, May 2017
[8] http://bit.ly/31jY2OV
[9] http://bit.ly/2yDgVjP
[10] Quartus Prime Lite Edition, https://intel.ly/2MC6TYp
[11] Ataki na procesory - PortSmash, TLBleed, Foreshadow, http://bit.ly/2M307eL
[12] Patterson D., Hennessy J., Computer Organization and Design: The Hardware Software Interface (RISC-V Edition), Morgan Kaufmann, 1st ed, 2017
[13] MAXimator Altera MAX10 FPGA Evaluation Board, http://bit.ly/2YJneNp
[14] Great Ideas in Computer Architecture (Machine Structures), CS61C Berkeley with Nicholas Weaver, Spring 2019 http://bit.ly/2P3cjhR
[15] Moduł dla Visual Studio Code http://bit.ly/2YP0Jug

Artykuł ukazał się w
Elektronika Praktyczna
wrzesień 2019

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik marzec 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio marzec - kwiecień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje marzec 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna marzec 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich kwiecień 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów