Wykład 2: Konstrukcja układów cyfrowych, nMigen

Data: 27.10.2020

Moduły

Układy cyfrowe składają się z modułów. Moduły to wydzielone obszary układu układające się w hierarchię — tworzymy większe moduły przez instancjonowanie podmodułów i opisanie logiki je łączącej. Układ cyfrowy ma ustalony „główny” moduł (top module), który reprezentuje cały układ.

Moduły mogą zawierać:

  • wejścia (sygnały wchodzące do modułu)

  • wyjścia (sygnały wychodzące z modułu)

  • sygnały wewnętrzne

  • instancje podmodułów

  • instancje prymitywów

  • logikę opisaną w języku HDL

Moduły w większości języków mogą być również parametryzowane.

Prymitywy są czymś podobnym do modułów — mają wejścia, wyjścia i parametry oraz są instancjonowane podobnie do modułów. Są jednak z punktu widzenia syntezy „czarną skrzynką” reprezentującą gotowy blok sprzętowy dostępny w danej technologii (rodzinie FPGA bądź bibliotece komórek ASIC). Większość prymitywów jest automatycznie wybierana przez proces syntezy, lecz niektóre trzeba zinstancjonować ręcznie (np. generatory zegarów).

Proces syntezy składa się z dwóch głównych etapów:

  • elaboracja: zaczynając od modułu głównego, syntezator rekurencyjnie znajduje wszystkie moduły składające się na układ i instancjonuje je z odpowiednimi parametrami, tworząc pełną sieć połączeń

  • właściwa synteza: cała logika opisana w języku wysokiego poziomu jest przetwarzana na prymitywy odpowiednie dla danej technologii

Prymitywy dostępne na Zynq można przejrzeć tutaj: https://www.xilinx.com/support/documentation/sw_manuals/xilinx2019_1/ug953-vivado-7series-libraries.pdf

Moduły w nMigen

Aby napisać moduł w nMigen, tworzymy klasę dziedziczącą z Elaboratable. Jeśli chcemy by nasz moduł był parametryzowany, dodajemy odpowiednie parametry do konstruktora. W konstruktorze tworzymy również sygnały które będą interfejsem modułu. W nMigen nie oznacza się specjalnie sygnałów wejścia/wyjścia — aby połączyć sygnały między modułami, wystarczy sięgnąć do odpowiedniego atrybutu podmodułu. Ta klasa powinna też mieć metodę elaborate, która stworzy właściwy moduł (instancję klasy Module) i wypełni go logiką (sygnałami wewnętrznymi, podmodułami, przypisaniami, itp).

Przykładowy moduł:

# A counter that counts down to 0.
class Counter(Elaboratable):
    # Width is the counter's width in bits.
    def __init__(self, width):
        self.width = width

        # Inputs.
        # Start trigger for the counter.
        self.start = Signal()
        # Start value of the counter.
        self.startval = Signal(width)
        # Counter enable — if 0, the counter will be paused.
        self.en = Signal(reset=True)

        # Outputs.
        # Set if the counter is done counting (the count is 0).
        self.done = Signal()

    def elaborate(self, platform):
        m = Module()

        val = Signal(self.width)

        with m.If(self.start):
            m.d.sync += val.eq(self.startval)
        with m.Elif(self.en & (val != 0)):
            m.d.sync += val.eq(val - 1)

        m.d.comb += self.done.eq(val == 0)

        return m

Aby dodać do modułu podmoduł również napisany w nMigenie, dodajemy go do m.submodules w elaborate, po czym możemy po prostu używać jego sygnałów w naszej logice:

my_ctr = m.submodules.my_ctr = Counter(width=4)
m.d.comb += my_ctr.start.eq(self.my_start_signal)

Jeśli chcemy dodać do modułu prymityw bądź podmoduł napisany w innym języku, używamy klasy Instance:

my_inst = m.submodules.my_inst = Instance("nazwa_modułu",
    p_nazwa_parametru=wartość_parametru,
    i_nazwa_wejścia=self.sygnał_wejściowy,
    o_nazwa_wyjścia=self.sygnał_wyjściowy,
)

W przypadku takich modułów trzeba ręcznie zdefiniować ich wejścia/wyjścia w konstruktorze, by nMigen znał ich interfejs.

Sygnały i przypisania

Sygnały stanowią odpowiednik zmiennych w językach programowania — są nazwanymi wartościami logicznymi (zmiennymi w czasie), których można użyć w wyrażeniach logicznych do wyliczenia wartości innych sygnałów.

Sygnały w nMigen tworzy się następująco:

# Jednobitowy sygnał.
a = Signal()

# 4-bitowy sygnał (reprezentujący wartość bez znaku w przypadku wyrażeń
# arytmetycznych), zakres 0..15
b = Signal(4)

# 4-bitowy sygnał, reprezentujący wartość ze znakiem, zakres -8..7
c = Signal(signed(4))

# Sygnał mający tyle bitów (3), ile potrzeba, by reprezentować liczby
# z zakresu 0..5
d = Signal(range(6))

class Color(enum.Enum):
    RED = 0
    GREEN = 1
    BLUE = 2

# Sygnał mający tyle bitów (2), ile potrzeba, by reprezentować wartości
# enumeracji Color.
e = Signal(Color)

# Sygnał o takim samym rozmiarze i znaku jak sygnał b.
f = Signal(b.shape())

# Sygnał mający 3 bity, o domyślnej wartości 2.
g = Signal(3, reset=2)

Przypisania

Wartość sygnałów ustala się przypisaniami:

# a jest równe 1 wtedy i tylko wtedy, gdy b jest równe 3
m.d.comb += a.eq(b == 3)

# b w każdym cyklu jest zwiększane o 1
m.d.sync += b.eq(b + 1)

Przypisania i sygnały należą do „domen” określających, kiedy są one przeliczane. Domyślnie istnieją dwie domeny:

  • comb: sygnał jest przeliczany ciągle (zawsze gdy zmieni się wartość prawej strony lub warunku przypisania). W przypadku bezwarunkowego przypisania oznacza to, że sygnał staje się efektywnie aliasem prawej strony przypisania. Jeśli żadne przypisanie sygnału nie jest aktywne, sygnał automatycznie przyjmuje swoją wartość reset (domyślnie 0).

  • sync: sygnał jest przeliczany za każdym razem, gdy wystąpi rosnące zbocze zegara. Wszystkie przypisania synchroniczne w domenie następują atomowo — wszystkie prawe strony przypisań są obliczane na podstawie wartości przed zboczem zegara. Na przykład poniższy kod spowoduje zamienienie miejscami wartości sygnałów x i y:

    m.d.sync += x.eq(y)
    m.d.sync += y.eq(x)
    

    Przed wystąpieniem pierwszego rosnącego zbocza zegara, sygnał ma swoją wartość reset (domyślnie 0). Jeśli w danym cyklu zegara żadne przypisanie dla danego sygnału nie było aktywne, sygnał zachowuje swoją wartość z poprzedniego cyklu.

Możliwe jest zdefiniowanie dodatkowych domen przez użytkownika (które zachowują się jak domena sync, ale z osobnym sygnałem zegarowym). Jest to jednak dość zaawansowany temat.

Warunki

Przypisania mogą być warunkowe (być aktywne tylko, jeśli dane wyrażenie jest niezerowe itp). W tym celu należy je umieścić w bloku m.If lub podobnym:

with m.If(a):
    m.d.sync += b.eq(0)
with m.Elif(b == 3):
    # 3+1 is a bad value, we don't like it.  Skip over it.
    m.d.sync += b.eq(5)
with m.Else():
    m.d.sync += b.eq(b + 1)

Dla danego sygnału, w kodzie mogą istnieć przypisania tylko z jednej domeny (nie wolno przypisywać tego samego sygnału z kilku domen).

W przypadku, gdy kilka przypisań do tego samego sygnału jest aktywne w danym momencie, ostatnie przypisanie wygrywa. W przypadku, gdy żadne przypisanie nie jest aktywne:

  • dla sygnałów przypisywanych z domeny comb (lub nigdzie nie przypisywanych), sygnał jest ustawiany na wartość domyślną (reset)

  • w przeciwnym przypadku (sygnał przypisywany z domeny synchronicznej), sygnał nie zmienia wartości.

Sygnały przypisywane z domeny comb syntezują się do układu obliczającego odpowiednie prawe strony i warunki oraz drzewa multiplekserów wybierającego aktywne przypisanie. Sygnały przypisywane z domen synchronicznych syntezują się do podobnego układu oraz zespołu przerzutników przechowujących obecny stan sygnału.

Switch

Analogicznie do konstrukcji m.If istnieje również konstrukcja Switch:

with m.Switch(operation):
    with m.Case(0):
        m.d.sync += o.eq(x + y)
    with m.Case(1):
        m.d.sync += o.eq(x - y)
    with m.Case(2):
        m.d.sync += o.eq(x | y)
    with m.Case(3):
        m.d.sync += o.eq(x & y)

Co więcej, oprócz w pełni zdefiniowanych stałych można używać wzorców bitowych z „wildcardami”:

with m.Switch(opcode):
    # Pasuje do 0, 1, 2, 3.
    with m.Case('0--'):
        m.d.sync += o.eq(x + opcode[0:2])
    with m.Case('100'):
        m.d.sync += o.eq(x + y)
    with m.Case('101'):
        m.d.sync += o.eq(x - y)
    with m.Case('110'):
        m.d.sync += o.eq(x | y)
    with m.Case('111'):
        m.d.sync += o.eq(x & y)

Wyrażenia i operatory

nMigen ma dość bogaty zbiór wyrażeń, które można skonstruować i używać w logice. Podobnie jak sygnały, wyrażenia mają rozmiar w bitach (który można sprawdzić przez len(wyrażenie)) i mogą być ze znakiem lub bez (wyrażenie.shape().signed).

Najprostszymi wyrażeniami są same sygnały oraz stałe. Stałe w nMigen można tworzyć jawnie (używając konstruktora Const) lub niejawnie (po prostu używając liczby bądź wartości enumeracji w wyrażeniu). Użycie Const pozwala nam wybrać szerokość stałej (w przeciwnym wypadku jest wybierana automatycznie):

# Stała o wartości 5, szerokości 3 bitów, bez znaku.
Const(5)

# Stała o wartości 5, szerokości 8 bitów, bez znaku.
Const(5, 8)

# Stała o wartości 5, szerokości 8 bitów, ze znakiem.
Const(5, signed(8))

# Stała o wartości -3, szerokości 3 bitów, ze znakiem
Const(-3)

# Stała o wartości 1, szerokości 2 bitów, bez znaku.
Const(5, 2)

# Stała o wartości 0, szerokości 2 bitów, bez znaku.
Const(Color.RED)

Wyrażenia można też tworzyć z innych wyrażeń używając operatorów. Operatory arytmetyczne w nMigen automatycznie dobierają szerokość wyniku tak, by mógł on reprezentować poprawny matematyczny wynik odpowiedniego obliczenia.

Dostępne operatory to:

  • a.as_signed(), a.as_unsigned(): rzutowanie surowych bitów wartości na wartość ze znakiem / bez znaku (zmienia właściwości wartości, nie zmienia bitów).

  • a[2:4]: wycina sygnał 2-bitowy bez znaku z bitów 2..3 wartości a.

  • Cat(a, b): skleja ze sobą bity dwóch wartości w większą wartość (a jest w niższych pozycjach, b w wyższych)

  • Repl(a, 3): równoważne Cat(a, a, a)

  • ~a, a & b, a | b, a ^ b: operacje bitowe NOT, AND, OR, XOR, operujące na parach bitów z a i b niezależnie i dające wynik takiego rozmiaru jak wejścia. Jeśli wartości a i b są różnych rozmiarów, krótsza jest rozszerzana do rozmiaru dłuższej.

  • a.all(), a.any(), a.xor(): operacje bitowe AND, OR, XOR operujące na wszystkich bitach wartości a, dające 1-bitowy wynik.

  • a.bool(): rzutowanie a na 1-bitową wartość logiczną. 1 jeśli a jest różne od 0. W zasadzie równoważne a.any().

  • Mux(a, b, c): operator wyboru. Jeśli a jest prawdą, zwraca b. W przeciwnym wypadku zwraca c. Rownoważne operatorowi ?: z języka C. Jeśli b i c mają różną długość, krótsza jest rozszerzana do dłuższej.

  • a + b, a - b, -a, a * b: operatory arytmetyczne. Wynik ma długość taką, by zawsze zmieścić wynik operacji.

  • a == b, a != b, a < b, a > b, a <= b, a >= b: operatory porównania. Zwracają 1-bitowy wynik.

  • a.bit_select(b, 3): jak a[b : b+3], ale działa również, gdy b nie jest stałą.

  • a.word_select(b, 3): jak a[b*3 : (b+1)*3], ale działa również gdy b nie jest stałą.

  • a << b, a >> b: operatory przesunięcia bitowego. Uwaga: podobnie jak przy operatorach arytmetycznych, wyrażenie ma taką długość, by wynik się zawsze zmieścił. W przypadku przesunięcia w lewo jest to wykładnicze od długości b. Należy zapewnić, że b tylko tyle bitów, ile jest konieczne.

Maszyny stanów

Ponieważ w układach cyfrowych nie można tak po prostu wyrazić pętli lub innych skomplikowanych pojęć programowania imperatywnego, bardzo często spotyka się maszyny stanów, które w zasadzie służą do ręcznej realizacji tych konceptów. nMigen ma natywne wsparcie dla maszyn stanów.

Rozważmy następujący kod w języku imperatywnym:

while (1) {
    int num = get_input();
    int sum = 0;
    while (num--)
        sum += get_input();
    output(sum);

W logice cyfrowej podobny schemat zrealizowalibyśmy za pomocą maszyny stanów w następujący sposób:

with m.FSM() as fsm:
    with m.State('START'):
        m.d.comb += input_rdy.eq(1)
        with m.If(input_vld):
            m.d.sync += sum.eq(0)
            m.d.sync += num.eq(input)
            with m.If(input != 0):
                m.next = 'ACCUMULATE'
            with m.Else():
                m.next = 'DONE'
    with m.State('ACCUMULATE'):
        m.d.comb += input_rdy.eq(1)
        with m.If(input_vld):
            m.d.sync += sum.eq(sum + input)
            m.d.sync += num.eq(num - 1)
            with m.If(num == 1):
                m.next = 'DONE'
    with m.State('DONE'):
        m.d.comb += output_vld.eq(1)
        m.d.comb += output.eq(sum)
        with m.If(m.output_rdy):
            m.next = 'START'