MS1 – prosty soft procesor na FPGA

Każdy kiedyś myślał o zbudowaniu własnego procesora, no może nie każdy, ale akurat ja jestem w tej niewielkiej grupie osób. Niemal od samego początku mojego zainteresowania elektroniką najbardziej fascynowały mnie jej cyfrowe zagadnienia i choć programowanie było znacznie bardziej efektowne, to najwięcej przyjemności czerpałem z projektowania obwodów logicznych. W przeszłości budowałem różne konstrukcje, które można by nazwać prostymi procesorami lub mikrokontrolerami zależnie od zastosowanych rozwiązań. Sprzęty te bazowały na podstawowych układach logicznych i choć wówczas robiły wrażenie, to z czasem ich rozbudowa stawała się co raz trudniejsza i bardziej kosztowna, zwłaszcza dla kogoś, kto dopiero co zaczął szkołę średnią. Chcąc poznawać kolejne tajniki techniki cyfrowej, należało przejść krok dalej i tym sposobem zainteresowałem się tematem układów programowalnych. Tak też powstały kolejne konstrukcje oparte jednak nie na wielu osobnych chipach a na pojedynczym kawałku krzemu zamkniętym w układzie FPGA. Jednak o nich, jak i o tych starszych, logicznych konstrukcjach opowiem przy okazji innego materiału, ponieważ przedmiotem rozważań tego tekstu będzie jeden z moich ostatnio przygotowanych projektów.

Przeszukując internet pod kątem frazy „prosty procesor FPGA”, znajdziemy kilka ciekawy projektów, przede wszystkim opisanych w języku angielskim, z którymi jest według mnie jeden problem – żadna z tych konstrukcji nie jest prosta. Projekty te dedykowane są raczej entuzjastom i znawcom układów FPGA i choć z perspektywy nieco bardziej zaawansowanego użytkownika projekt może nie być zbyt skomplikowany, to dla początkujących będą one czarną magią. Według mnie wielu autorów opisujących proste jednostki centralne mylnie zakłada, że czytający doskonale orientuje się w meandrach języka VHDL lub Verilog i dodatkowo zna doskonale zasady działania i projektowania tego typu konstrukcji. W ten sposób otrzymujemy zazwyczaj ciekawe projekty, której jednak dla osoby początkującej będą kompletnie niezrozumiałe.

Dlatego też w tym artykule chciałbym przedstawić wam jeden z moich ostatnich projektów, czyli bardzo prosty soft procesor zaimplementowany na układzie FPGA, który opisałem nazwą kodową MS1. Postaram się przedstawić go w jak najprostszy sposób, tak aby uniknąć efektu, który opisałem wyżej i który był motywacją do zajęcia się tym tematem. Z założenia projekt ma być naprawdę prosty i przed jego rozpoczęciem wyszczególniłem sobie kilka celów:

  • Konstrukcja (kod) procesora powinien być jak najprostszy, najlepiej mieszczący się tylko w jednym pliku.
  • Bazowanie na architekturze harwardzkiej, gdzie pamięć danych i programu są od siebie rozdzielone, co jest koncepcją prostszą w implementacji i zrozumieniu.
  • Liczba obsługiwanych rozkazów zredukowana do minimum, nawet kosztem potencjalnej funkcjonalności. Projekt ma przede wszystkim opisywać pewną koncepcję działania tego typu konstrukcji.
  • Możliwość fizycznej implementacji na realnym chipie, nie tylko działanie w symulatorze.
  • Opcja komunikacji ze światem zewnętrznym w najprostszej formie równoległego wejścia i wyjścia danych.
  • Budowa na tyle otwarta i uniwersalna, aby potencjalna rozbudowa projektu nie była zbyt trudna.

Są to oczywiście dość ogólne założenia, które jednak dość konkretnie definiują projekt MS1. Można je streścić w trzech punktach – prostota, uniwersalność i fizyczna implementacja.

W kolejnych rozdziałach opisze kolejno ogólną budowę i charakterystykę procesora, obsługiwane polecenia oraz jego działanie, krok po kroku. Następnie przedstawię wam jego kod w języku VHDL wraz z wytłumaczeniem. Natomiast w ostatniej sekcji przyjrzymy się jego działaniu zarówno w symulatorze, jak i realnym środowisku.

Choć z założenia projekt ma być jak najbardziej przystępny dla początkujących, to nie będę skupiać się na całkowitych podstawach, bo kierując się tą logiką, musiałbym zacząć od opisu czym jest prąd elektryczny, a to wydłużyłoby znacznie cały temat. Przydatne jest oczywiście podstawowa wiedza – czym są układy FPGA, ale dla nieznających tematu, postaram się to wyjaśniać w kilku słowach. Tak jak wspomniałem, zdarzało mi się w przeszłości budować jednostki centralne no pojedynczych układach logicznych. Wraz ze wzrostem zaawansowania całej konstrukcji, rosła też liczba użytych chipów, co zwiększało też prawdopodobieństwo potencjalnych problemów. Układ FPGA był w tym przypadku cudownym panaceum. Złożony z uniwersalnych bloków chip pozwala budować dowolne konstrukcje logiczne na pojedynczym kawałku krzemu, korzystając z języka opisu sprzętu, bez potrzeby angażowania wielu pojedynczych układów scalonych. Tak w prosty sposób można scharakteryzować chipy programowalne – jako uniwersalne układy scalone, których działanie zależy od użytkownika. Nie są to jednak mikrokontrolery, wykonujące zapisany w pamięci program. FPGA działa na kodzie będącym opisem sprzętu – czyli słownym opisie struktur logicznych przekształcanych dalej w wewnętrzną konfigurację układu. W praktyce oznacza to, że zamiast pisać kolejne instrukcje „co robić”, jak w przypadku programowania mikrokontrolerów, opisujemy „czym jest” projekt implementowany na FPGA.

Architektura i budowa MS1

Schemat blokowy MS1.

Tak jak każdy procesor budowę MS1 możemy opisać schematem blokowym. Jest to pewna graficzna forma, dzięki której łatwiej będzie nam zrozumieć działanie systemu jako całości, choć nie oddaje ona w pełni fizycznej implementacji CPU na układzie programowalnym. Procesor składa się z trzech rodzajów bloków funkcyjnych, są to rejestry oznaczone kolorem niebieskim, moduły pamięci zielone oraz pozostałe bloki logiczne w kolorze szarym. Jest to jednostka 8 bitowa, co oznacza, że dane mają długość właśnie 8 bitów i procesor pracuje na liczbach bez znaku w zakresie od zera do 255. Magistrale adresowe mają szerokość 4 bitów, co pozwala zaadresować maksymalnie 16 adresów.

Zgodnie z zasadami architektury harwardzkiej pamięć podzielona została na dwa odrębne moduły – ROM, w której przechowywany jest wykonywany programu oraz RAM z danymi, na których pracuje CPU. Oba bloki mają identyczną pojemność 128B, w konfiguracji 16x8b. Pamięć programu adresowana jest z poziomu PC (program counter), czyli specjalnego licznika zwiększającego swoją wartość po wykonaniu rozkazu. 8 bitowe wyjście danych ROM podzielone jest na dwie części – operand i opcode, każda z nich ma 4 bity. Operand jest sygnałem przekazywanym do pamięci RAM pełniącym rolę adresu, co pozwala odwoływać się do zapisanych w niej danych. Opcode będący kodem rozkazu, który ma wykonać procesor, przesyłany jest do bloku Logic, w którym dzieje się cała magia. Instrukcja jest tam dekodowana i na jej podstawie wysterowywane są inne bloki procesora, tak aby wykonać odpowiednią instrukcję.

MS1 oparty jest tylko na czterech rejestrach. IN i OUT służą do komunikacji ze światem zewnętrznym, do każdego z nich podłączone jest osiem fizycznych wyprowadzeń układu FPGA. Dzięki temu możliwy jest odczyt danych poprzez rejestr IN oraz wysterowanie przykładowo ośmiu diod LED podłączonych do OUT, które reprezentować będą przetwarzane przez CPU dane. Głównym rejestrem MS1 jest A, może on przechowywać informacje pochodzące z pamięci RAM, rejestru IN lub jednostki ALU. Linia prowadząca do rejestru B celowo oznaczona została przerywaną linią, ponieważ jego wejście połączone jest tylko z wyjście danych pamięci RAM i tylko ona może być źródłem informacji dla tego rejestru.

Rejestry A i B tworzą wspólnie parę, która przechowuje liczby, na których wykonywane są operacje arytmetyczne. Są nimi dodawanie i odejmowanie fizycznie realizowane w ALU, czyli jednostce arytmetyczno-logicznej. Jak można zauważyć, MS1 wykonuje tylko rozkazy arytmetyczne i bardziej pasowałaby tu nazwa AU, jednostka arytmetyczna, ale zdecydowałem się nie wprowadzać zbędnego bałaganu w nazewnictwie, bo powszechną praktyką jest określanie tego typu bloków jako ALU, nawet jeśli są one bardzo proste.

Instrukcje MS1

Opcode

Operand

Instruction

Description

0000

XXXX

NOP

Don’t do anything

0001

ADDR

LD A

Enter the value from the address into register A

0010

ADDR

LD B

Enter the value from the address into register B

0011

ADDR

STR A

Enter the value from register A into memory

0100

XXXX

ADD

Addition (A+B) result in A

0101

XXXX

SUB

Subtraction (A-B) result in A

1000

XXXX

OUT

Enter the value from A into the OUT register

1001

XXXX

IN

Enter the value from IN into register A

1111

XXXX

HLT

Stop the CPU

Procesor MS1 obsługuje dziewięć rozkazów, które zgrupować można w trzy grupy – przesłania, operacje i rozkazy specjalne. Każda instrukcja składa się z ośmiu bitów, czterech opcode i czterech operand, zapisanych w pamięci ROM. Opcode jest unikalną wartością przypisaną do konkretnego rozkazu, natomiast operand pełni rolę czterobitowego adresu przy rozkazach wymagających tego parametru. W tabeli są to miejsca opisane jako ADDR, jednak we większości przypadków wartość zapisana w tym miejscu nie ma znaczenia dla wykonywanej instrukcji, są to miejsca opisane jako XXXX.

MS1 obsługuje pięć rozkazów przesłań danych. LD A i LD B wpisują dane do odpowiednich rejestrów z pamięci RAM, konkretny adres pamięci określany jest przez operand. Instrukcja STR A ma odwrotne działanie i zapisuje dane z rejestru A do pamięci RAM pod adres określony przez operand. OUT i IN to instrukcje pozwalające jednostce centralnej komunikować się ze światem zewnętrznym. Dzięki nim można zapisać dane do rejestru OUT, którego wyjście wyprowadzone jest na zewnątrz lub rejestru IN, którego to wejście podłączone jest do zewnętrznych wyprowadzeń. Oba rozkazy odnoszą się też do rejestru A, z którego lub, do którego zapisywane są dane.

Instrukcjami arytmetycznymi, które obsługuje CPU jest dodawanie i odejmowanie. Odnoszą się one do wartości zapisanych w rejestrach A i B, gdzie wartość z tego pierwszego jest zawsze bardziej znacząca. Oznacza to, że do niej dodawana jest wartość lub od niej jest ona odejmowana. Po wykonaniu operacji wynik zostaje wpisany do rejestru A.

Procesor wspiera też dwa rozkazy specjalne NOP i HLT. Pierwszy z nich jest pustą instrukcją, która jest wykonywana, ale nie powoduje żadnych zmian. Druga instrukcja, czyli HLT to polecenie zatrzymujące procesor, gdy ten napotka w pamięci ROM wartość 1111, przestanie pobierać kolejne rozkazy z pamięci. Wyjście z tego stanu możliwe jest tylko poprzez zewnętrzny reset lub odpięcie zasilania.

Jak działa MS1?

Sieć działania MS1.

Przejdźmy teraz do ogólnej zasady działania procesora MS1, dzięki niej łatwiej będzie opisać kod, którym zajmiemy się w kolejnym rozdziale. Zasadniczo działanie moglibyśmy opisać za pomocą czterech stanów – Fetch, Decode, Execute i WriteBack. Występują one jeden po drugi i w każdym procesor wykonuje określone funkcje, tak aby zrealizować zadany rozkaz. Jednak tego typu opis, choć prawdziwy nie wyczerpuje tematu nawet w niewielkim stopniu. Dlatego też przygotowałem krótki algorytm, który opisuje działanie MS1 nieco bardziej szczegółowo.

W pierwszym kroku po uruchomieniu układ sprawdza stan sygnału reset. Tak jak wspomniałem przy okazji analizy listy rozkazów, ten jest zewnętrzny, co oznacza, że do jednego z wyprowadzeń układu FPGA podłączony jest przycisk realizujący tę funkcję. Jeśli sygnał reset jest w stanie zero (dział on w logice ujemnej) procesor nie będzie realizować zapisanego w pamięci programu, a wszelkie jego wewnętrzne rejestry zostaną przywrócone do stanu domyślnego. Dopiero gdy reset wejdzie w stan jeden, przykładowo po puszczeniu przycisku procesor zacznie działać. W tym momencie sprawdzony zostanie kolejny warunek, który możemy nazwać nadrzędnym. MS1 podobnie jak jemu podobne konstrukcje jest projektem synchroniczny. Oznacza to, że do działania potrzebuje odpowiedniego sygnału zegarowego, wyznaczającego kolejne kroki w jego funkcjonowaniu. Sygnał zegarowy podobnie jak reset jest zewnętrzny i pochodzi z kwarcowego generatora. Celowym zabiegiem było niewspominanie wcześniej o częstotliwości taktowania MS1, bo ta może być dowolna, zależna od platformy sprzętowej, na której projekt zostanie uruchomiony, w moim przypadku jest to 50MHz. Jeśli reset nie jest aktywny, CPU czeka na narastające zbocze zegara i gdy to się pojawi, wykonuje pojedynczy krok. W ten sposób opisać możemy ogólne działanie MS1, określając niejako czy ten wykonuje program, czy też jest zapętlony w obsłudze funkcji resetu.

Przejdźmy teraz do kwestii wykonywania programu i wspomnianych wcześniej stanów procesora. Pierwszym z nich, które wykona CPU, będzie Fetch. Jest to pierwszy krok, w którym z pamięci ROM pobierany jest rozkaz. Miejsce, z którego zostanie on pobrany, definiowane jest przez wartość licznika programu PC. Po pierwszym uruchomieniu będzie to adres zerowy.

Gdy rozkaz został pobrany, przechodzimy do jego zdekodowania – Decode. W tym kroku zostaje on podzielony na Opcode i Operand, które z perspektywy tego, co ma zrobić procesor są dość ważne. Ich znaczenie opisałem przy okazji przedstawienia listy rozkazów.

Po przygotowaniu wartości Opcode i Operand MS1 przechodzi do wykonania rozkazu – Execute. To, co dokładnie się stanie definiują wspomniane wartości, przyjrzymy się temu dokładniej w dalszej części przy okazji analizy kodu, ale w tym miejscu można wspomnieć, że większość rozkazów polega na odpowiednim przesłaniu danych lub wywołaniu funkcji arytmetycznej. Wyjątkiem będzie tutaj instrukcja HLT, która wyróżniona została w sieci działania MS1. Procesor sprawdza, czy Opcode ma wartość 1111, jeśli tak jest ponownie wykonany zostanie krok Execute. Innymi słowy, procesor w ten sposób wejdzie w nieskończoną pętlę, która z perspektywy użytkownika, będzie wyglądać jak zatrzymanie jego działania. Wyjście z tego stanu możliwe będzie jedynie poprzez aktywowanie resetu lub odpięcie zasilania. Warto wspomnieć, że po wykonaniu rozkazu następuje zwiększenie licznika rozkazów PC, co jest przygotowaniem do wykonania kolejnej instrukcji zapisanej w ROM.

Jeśli CPU wykonuje rozkaz inny niż HLT, przejść możemy do kolejnego sprawdzenia Opcode, jeśli ten ma wartość inną niż 1111, wywołujemy stan – WriteBack. W przeciwnym razie wracamy do stanu Fetch, co jednak nigdy nie powinno się wydarzyć, ponieważ, procesor powinien wejść wcześniej w nieskończoną pętlę, funkcjonalność ta jest tylko dodatkowym zabezpieczeniem.

W stanie WriteBack procesor sprawdza, czy wykonywany był rozkaz związany z jednostką arytmetyczno-logiczną ALU. Jeśli jest to prawdą, wynik z wyjścia tego modułu zostaje wpisany do rejestru A, gdzie umieszczane są wszystkie wyniki operacji arytmetycznych. Niezależnie czy proces ten zostanie zrealizowany, jest to moment, w którym MS1 kończy wykonywanie pojedynczej instrukcji. Wracamy wówczas do stanu Fetch, gdzie pobrane zostanie kolejne polecenie spod zwiększonego o jeden w trakcie stanu Execute adresu licznika programu.

W ten sposób można opisać pokrótce działanie MS1, które na pewno rozjaśni się nieco bardziej przy okazji dokładnej analizy opisu sprzętu.

Kod procesora

Zgodnie z założeniami sprzętowy opis procesora MS1 jest dość krótki i mieści się w jednym pliku. Całość zajmuje około 100 linii poleceń w języku VHDL, około, ponieważ liczba ta może być nieco większa w sytuacji, gdy procesor realizować będzie bardziej złożony program zapisany w pamięci ROM. W rzeczywistości opis mógłby być jeszcze krótszy niż 100 linii, ale wiązałoby się to z pogorszeniem jego czytelności. Kod jako całość możecie znaleźć na moim profilu GitHub, zachęcam do zapoznania się z nim, ponieważ pozwoli to wam spojrzeć na niego nieco szerzej.

library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.NUMERIC_STD.ALL;

Projekt zaczyna się dość standardowo, można powiedzieć typowo dla konstrukcji opartych na FPGA. Mamy tutaj trzy linie importujące biblioteki niezbędne do procy z sygnałami logicznymi i typami numerycznymi.

entity cpu is
    Port (
        clk     : in  STD_LOGIC;
        rst     : in  STD_LOGIC;
        input_port : in  STD_LOGIC_VECTOR(7 downto 0);
        output_port : out STD_LOGIC_VECTOR(7 downto 0)
    );
end cpu;

Kolejno znalazł się opis portów, jakimi dysponuje MS1. Są to: wejście sygnału zegarowego – clk, wejście sygnału reset – rst, port wejściowy dla danych – input_port oraz wyjście danych – output_port, co warto zauważyć, oba porty są 8 bitowe.

architecture Behavioral of cpu is

    type state_type is (Fetch, Decode, Execute, WriteBack);
    signal state : state_type := Fetch;

    signal pc        : unsigned(3 downto 0) := (others => '0');
    signal instr     : STD_LOGIC_VECTOR(7 downto 0);
    signal opcode    : STD_LOGIC_VECTOR(3 downto 0);
    signal operand   : STD_LOGIC_VECTOR(3 downto 0);

    signal regA      : STD_LOGIC_VECTOR(7 downto 0) := (others => '0');
    signal regB      : STD_LOGIC_VECTOR(7 downto 0) := (others => '0');
    signal alu_result: STD_LOGIC_VECTOR(7 downto 0);

W pierwszej części bloku architecture tworzymy typ enumeracyjny state_type, który będzie mógł przyjąć jeden z czterech stanów – Fetch, Decode, Execute i WriteBack. Następnie deklarujemy sygnał state, którego typ to właśnie state_typ. Innymi słowy, w ten sposób opisana została maszyna stanów, która określa, co w danej chwili robić będzie procesor. Wartość state będzie zmieniać się w czasie działania MS1, ale domyślnie po uruchomieniu stanem aktualnym będzie Fetch, definiuje to zapis „:=Fetch;”.

Następnie zadeklarowane zostały rejestry i sygnały pomocnicze. Sygnał pc pełni rolę licznika rozkazów, jest to sygnał bez znaku o czterobitowej długości, którego bazową wartością po starcie procesora jest zero.

Wektory instr, opcode i operand związane są z pamięcią ROM, pierwszy z nich przechowywać będzie pobraną z pamięci wartość, która w kolejnym kroku zostanie przypisana do sygnałów opcode i operand. Jak widać, instr ma długość 8 bitów, które to później dzielone są na dwie równe części. 

Dalej umieszczane zostały rejestry A i B, o których znaczeniu wspominałem przy okazji opisu architektury, ich domyślną wartością jest zero. Alu_result to sygnał powiązany z jednostką arytmetyczno-logiczną, będzie on przechowywać wynik wygenerowany przez ALU, w czasie, gdy ten nie zostanie wpisany jeszcze do rejestru A.

    type memory_type is array (0 to 15) of STD_LOGIC_VECTOR(7 downto 0);
    signal ROM : memory_type := (
        0 => "00010000", -- LD A, 0
        1 => "00100001", -- LD B, 1
        2 => "01000000", -- ADD
        3 => "00100010", -- LD B, 2
        4 => "01000000", -- ADD
        5 => "10000000", -- OUT
        6 => "11110000", --HLT
        others => (others => '0')
    );
    signal RAM : memory_type := (
        0 => "00000010", -- 2
        1 => "00000001", -- 1
        2 => "00000001", -- 1
        others => (others => '0'));

Pamięci ROM i RAM zdefiniowane zostały jako szesnastu elementowe tablice memeory_type, w których zapisane są 8 bitowe wektory logiczne. Jeden, jak i drugi moduł wypełniony został przykładowymi danymi. W pamięci ROM umieściłem prosty program, który doda dwie liczby zapisane w RAM, a następnie do wyniku doda kolejną liczbę. Po tych operacjach wynik zostanie wysłany na port OUT a procesor zatrzymany. Na tym przykładzie dość dobrze widać strukturę danych, które są rozkazami, cztery najstarsze bity to opcode określający funkcję, jaką ma wykonać MS1. Kolejne bity mogą być adresem dla pamięci RAM, tak też jest przy okazji rozkazów LD, pierwszy z nich odwołuje się do adresu zerowego w RAM, gdzie zapisana jest liczba dwa, drugi do adresu pierwszego, zapisana jest tam jedynka, natomiast trzecie polecenie LD korzysta z drugiej komórki, gdzie również zapisane zostało jeden.

    process(clk, rst)
    begin
        if rst = '0' then
            pc <= (others => '0');
            instr <= (others => '0');
            opcode <= (others => '0');
            operand <= (others => '0');
            regA <= (others => '0');
            regB <= (others => '0');
            alu_result <= (others => '0');
            output_port <= (others => '0');
            state <= Fetch;

Pracę CPU opisuje synchroniczny blok proces zależny od sygnałów clk i rst, to one są tutaj kluczowe, czy też nadrzędne jak wspomniałem już wcześniej. Pierwszym warunkiem, który sprawdza MS1 jest stan sygnału reset, gdy ten jest aktywny, czyli jego wartość jest zerem, następuje wyzerowanie wszystkich sygnałów i rejestrów, a maszyna stanu przyjmuje wartość Fetch.

        elsif rising_edge(clk) then
            case state is
                when Fetch =>
                    instr <= ROM(to_integer(pc));
                    state <= Decode;
                when Decode =>
                    opcode <= instr(7 downto 4);
                    operand <= instr(3 downto 0);
                    state <= Execute;

Jeśli sygnał rst nie jest aktywny, procesor sprawdza, czy pojawiło się narastające zbocze sygnału zegarowego, które definiuje kolejne takty CPU. Gdy takowe wystąpi, przechodzimy do wykonania rozkazu. Całość bazuje na funkcji case zależnej od state, czyli maszyny stanów. Zależnie od wartości wykona się inny fragment kodu. Jeśli aktualnie aktywny będzie stan Fetch, procesor przypisze do sygnału instr, wartość zapisaną w ROM, spod adresu określonego przez pc i jednocześnie przypisze nowy stan do maszyny stanów – Decode, taka, aby przy kolejnym takcie CPU przekształcił instr na opcode i operand. To też dzieje się w kolejnym fragmencie kodu wraz z następną zmianą state, której przypisana zostaje wartość Execute.

                when Execute =>
                    case opcode is
                        when "0000" => -- NOP
                            null;
                        when "0001" => -- LD A
                            regA <= RAM(to_integer(unsigned(operand)));
                        when "0010" => -- LD B
                            regB <= RAM(to_integer(unsigned(operand)));
                        when "0011" => -- STR A
                            RAM(to_integer(unsigned(operand))) <= regA;
                        when "0100" => -- ADD
                            alu_result <= std_logic_vector(unsigned(regA) + unsigned(regB));
                        when "0101" => -- SUB
                            alu_result <= std_logic_vector(unsigned(regA) - unsigned(regB));
                        when "1000" => -- OUT
                            output_port <= regA;
                        when "1001" => -- IN
                            regA <= input_port;
                        when "1111" => -- HLT
                            state <= Execute;
                        when others =>
                            null;
                    end case;
                    if opcode /= "1111" then
                        state <= WriteBack;
                    end if;

We fragmencie kodu odpowiedzialnym za wykonanie rozkazu dzieje się zdecydowanie najwięcej. Jest tutaj kolejna funkcja case zależna od wartości opcode. Jak widać, wszystkie rozkazy opisane zostały w podobny sposób, dlatego przeanalizujemy je jeden po drugim.

Instrukcja NOP nie zmienia niczego, stąd też wziął się zapis „null;”.  Ładowanie danych do rejestrów A i B działa na tej samej zasadzie, czyli przypisania do regA i regB sygnału z RAM określonego przez adres przechowywany przez sygnał operand. Zapis danych do pamięci RAM również wygląda podobnie, z tym że tym razem do sygnału RAM określnego przez operand przypisana zostaje wartość z regA. Kolejnymi są funkcje arytmetyczne przypisujące do alu_result wyniki dodawania lub odejmowania. Rozkazy OUT i IN to też nic innego jak tylko przesłania danych z regA do output_port lub z input_port do regA.

Ostatnią obsługiwaną funkcją jest HLT, która jak już wspominałem, powoduje zapętlenie się procesora. Uaktywnia ona ponownie stan Execute, dzięki czemu przy kolejnym takcie zegara, ta zostanie od razu wykonana i ponownie napotka rozkaz HLT, ponieważ stany sygnałów i rejestrów nie zostaną zmodyfikowane. W ten sposób MS1 wejdzie w nieskończoną pętle, cały czas wykonując rozkaz HLT. Warto też wspomnieć o zapisie „when others =>”, jak widać, gdy w pamięci znajdzie się wartość, która nie odpowiada, żadnemu z rozkazów procesor potraktuje ją jako pustą instrukcję NOP.

Poza funkcją case znalazł się jeszcze niewielki warunek sprawdzający, czy wykonywane wcześniej polecenie było inne niż „1111”, innymi słowy, czy był to rozkaz inny niż HLT. Jeśli tak, maszyna stanów przyjmie wartość WriteBack, dzięki czemu procesor będzie mógł przejść do kolejnego kroku.

                when WriteBack =>
                    if opcode = "0100" or opcode = "0101" then
                        regA <= alu_result;
                    end if;
                    pc <= pc + 1;
                    state <= Fetch;
            end case;
        end if;
    end process;

end Behavioral;

WriteBack jest ostatnim stanem procesora, przy jego wykonaniu sprawdzany jest opcode, a dokładniej czy jego wartością było „0100” lub „0101”, co oznacza, że wykonywany był któryś z rozkazów arytmetycznych. Jeśli jest to prawdą, do rejestru A przypisana zostaje wartość spod sygnału alu_result, tak aby przy wykonywaniu kolejnej instrukcji wynik umieszczony był właśnie w tym miejscu.

Poza tym ostatni stan procesora powoduje też zwiększenie o jeden licznika rozkazów pc, dzięki czemu przy kolejnym przebiegu pobrany zostanie następny w kolejce rozkaz. Dodatkowo maszyna stanów ustawiona zostaje na Fetch i cała procedura wykonuje się ponownie.

Implementacja MS1

Obwód testowy MS1.

Jednym z założeń projektu MS1 była jego fizyczna implementacja. Do tego celu wykorzystałem jedną z moich płytek, dokładniej niewielką konstrukcję produkcji Qmtech. Moduł wyposażony jest w chip FPGA Xilinx z rodziny Artix-7 – XC7A35T. Na płytce znaleźć można też układ pamięci SDRAM (niewykorzystywany), trzy przyciski, z których jeden zaadaptowałem, tak aby pełnił funkcję resetu, trzy diody LED oraz kwarcowy generator sygnału o częstotliwości 50MHz, który służy jako sygnał clk procesora. Poza tym do zewnętrznych wyprowadzeń podłączyłem osiem diod LED (output_port) oraz ośmiopozycyjny przełącznik DipSwitch (input_port).

Wykorzystanie zasobów dla XC7A35T.

Projekt przygotowałem w programie Vivado, jego skompresowaną formę możecie znaleźć na moim profilu GitHub. MS1 nie jest zbyt wymagającą konstrukcją i można ją zaimplementować na praktycznie każdym układzie FPGA. Do działania potrzebnych jest 41 podstawowych bloków logicznych LUT, 8 bloków LUTRAM do implementacji pamięci, 50 rejestrów FF, 18 pinów I/O i jeden bufor zegarowy.

Analiza przykładowego programu

Przejdźmy teraz do analizy przykładowego programu, który może wykonać MS1. Skorzystamy w nim z portów wejścia/wyjścia, pamięci RAM oraz funkcji dodawania. Poniżej umieszczam fragment kodu z odpowiednio skonfigurowaną pamięcią ROM i RAM.

    signal ROM : memory_type := (
        0 => "10010000", -- IN
        1 => "00100001", -- LD B, ADDR:1
        2 => "01000000", -- ADD
        3 => "10000000", -- OUT
        4 => "11110000", -- HLT        
        others => (others => '0')
    );
    signal RAM : memory_type := (
        0 => "00000000", -- 0
        1 => "00011010", -- 26
        2 => "00000000", -- 0
        others => (others => '0'));

Jak widać, program składa się z pięciu poleceń. Na początku odczytujemy wartość z portu wejściowego i zapisujemy ją w rejestrze A. Kolejnym krokiem jest pobranie z pamięci RAM wartości zapisanej pod pierwszym adresem i umieszczenie jej w rejestrze B. Tak przygotowane dane dodajemy, a wynik trafia do rejestru A, skąd zostaje skopiowany i przekazany na port wyjściowy. Po tych operacjach procesor zostaje zatrzymany poleceniem HLT.

W pamięci RAM pod adresem pierwszym zapisana została liczba 26 i to do niej program będzie dodawać dowolną wartość pobraną z rejestru IN.

Efekt działania MS1.

Na załączonym obrazku możecie zobaczyć działanie programu w praktyce. Za pomocą przełącznika DipSwitch ustawiłem wartość 35 (00100011), która to jest pobierana przez procesor. Po dodaniu do niej liczby 26 z pamięci uzyskujemy 61, przekazane na port wyjściowy, dzięki czemu możemy zobaczyć binarną reprezentację tej wartości, czyli 00111101.

Symulacja programu, wszystkie widoczne cyfry są w postaci heksadecymalnej.

Poza rzeczywistym uruchomieniem przeprowadziłem też symulację programu, która całkiem dobrze obrazuje co dzieje się z procesorem przy każdym kolejnym takcie zegara, co ważne wszystkie widoczne na symulacji wartości mają postać heksadecymalną. CPU zaczyna działać w momencie zmiany wartości sygnału reset na jeden. Wówczas pobrana zostaje pierwsza instrukcja 0x90, zostaje ona podzielona na opcode i operand, i efektem jej działania jest pojawienie się w rejestrze A wartości z wejścia, czyli 0x23 (35). Następnie obsłużona zostaje kolejna instrukcja, jak można zauważyć, operand zmienia swą wartość na adres, który przekazywany jest do pamięci RAM. Po jej wykonaniu w rejestrze B pojawia się 0x1A, czyli 26. W następnym kroku wartości są dodawane, co również widać na przebiegu, ponieważ w rejestrze A pojawia się wynik tej operacji – 0x3D (61). Wartość tą trzeba jeszcze przesłać na rejestr wyjściowy i to dzieje się w ostatnim kroku. Na samym końcu można jeszcze zauważyć wejście procesora w permanentny stan Execute, czyli realizację rozkazu HLT.

Co dalej?

Możliwości procesora MS1 są dość niewielkie, jest to koszt jego bardzo prostej budowy, ale taki był właśnie zamysł tej konstrukcji. Ma ona na celu przybliżyć koncepcję realizacji jednostek centralnych tym wszystkim, którzy być może dopiero zaczynają swoją przygodę z układami FPGA. Również dla tych bardziej zaawansowanych użytkowników może być to dobry fundament do stworzenia własnego, bardziej rozbudowanego projektu.

Osobiście mam zamiar kontynuować ten projekt, aby pokazać właśnie proces jego rozbudowy. Pierwsze co trzeba zrobić to oczywiście rozbudowa pamięci, poszerzenie możliwości ALU, dodanie instrukcji skoków warunkowych i bezwarunkowych oraz programowe sterowanie częstotliwością taktowania wraz z możliwością pracy krokowej, co znacznie poprawi proces debugowania. Procesor o takich możliwościach będzie już znacznie bardziej „używalny”, bo mimo wszystko MS1, jest bardziej czymś pokazowym aniżeli użytecznym.

Na koniec zachęcam do własnych eksperymentów, opinii i pomysłów, którymi możecie się podzielić.

Na mojej stronie nie znajdziesz zwyczajnych, jak i automatycznie generowanych przez Google Ads reklam, innymi słowy nie mam żadnych profitów z prowadzenia tego serwisu. Ale jeśli chcesz wesprzeć moją pracę, to możesz postawić mi kawę. Dzięki😊

Chcesz być na bieżąco?
Dołącz do newslettera

Otrzymywać będziesz powiadomienia o nowych artykułach oraz informacje o projektach, nad którymi pracuję.

Przewiń do góry