Wykład 3: RAM w układach cyfrowych

Data: 03.11.2020

Czym jest RAM

RAM jest ogólną nazwą na pamięć o dowolnym dostępie — typ pamięci, w której możemy we w miarę podobnym czasie dostać się do dowolnej komórki (czyli ją odczytać lub zapisać). Jest to tak naprawdę niezbyt dobra definicja (można by się kłócić, że DRAM tak naprawdę nie pasuje do tej definicji).

RAMem będziemy określać tablicę bitów, która ma:

  • szerokość: ile bitów ma jednostka pamięci, którą czytamy/piszemy

  • głębokość: ile różnych adresów możemy wybrać do odczytu/zapisu

  • rozmiar: szerokość × głębokość

  • porty odczytu: zbiory sygnałów, których używamy do odczytu zawartości RAMu. Pamięć może mieć jeden lub więcej niezależnych portów odczytu.

  • porty zapisu: zbiory sygnałów, których używamy do zapisu zawartości RAMu.

  • porty odczytu i zapisu (robiące jedno i/lub drugie pod tym samym adresem).

RAM jest bardzo często przydatnym elementem w logice cyfrowej. W zależności od potrzeb stosuje się różne rozmiary i technologie RAMu. Te najważniejsze to:

  1. Najprostszym rodzajem RAMu jest po prostu zespół przerzutników połączonych przez multipleksery (do odczytu) i dekodery adresu zapisu. Szybko staje się to jednak nieefektywne (szczególnie w przypadku logiki programowalnej).

  2. SRAM (pamięć statyczna): tablica bitów zrealizowanych jako tzw. zatrzaski (po 6 tranzystorów każdy w najczęstszej realizacji), zorganizowana fizycznie w wiersze i kolumny. Pamięć ta sama podtrzymuje swój stan dopóki ma zasilanie i może w dowolnym momencie przeczytać lub zapisać dowolny bit.

    Pamięć SRAM może być osadzona w układzie logicznym (jeśli jest mała), bądź umieszczona w osobnym układzie scalonym (jeśli jest duża). W przypadku układów ASIC, możemy zaprojektować pamięć o dość dowolnych parametrach. W przypadku układów FPGA, mamy dostępne gotowe bloki pamięci o z góry ustalonych rozmiarach, zazwyczaj podzelonych na kilka rodzajów:

    • LUT RAM albo distributed RAM: małe (rzędu dziesiątek bądź setek bitów), są nieco przerobionymi LUTami do których dorobiona została funkcjonalność zapisu. Zazwyczaj mają jeden port synchroniczny port zapisu i jeden lub więcej asynchroniczny port odczytu. Jeśli nie są akurat używane jako pamięć, mogą służyć za zwykłe LUTy.

    • block RAM: średniego rozmiaru (rzędu dziesiątek kilobitów). Dedykowany blok. Zazwyczaj mają dwa niezależne synchroniczne porty odczytu+zapisu.

    • (czasem w niektórych FPGA) ultra RAM: jak block RAM, ale większe (rzędu setek kilobitów).

    Układy FPGA (w przeciwieństwie do ASIC) zazwyczaj dają nam możliwość wyboru stanu początkowego pamięci (w szczególności możemy nie używać żadnych portów zapisu i traktować pamięć jako ROM).

    Jeżeli bloki pamięci dostępne w FPGA są dla nas za małe, można połączyć wiele z nich w jedną pamięć.

  3. DRAM (pamięć dynamiczna): tablica bitów zrealizowanych w najtańszy możliwy sposób: każdy bit to jeden tranzystor i jeden kondensator. Również fizycznie zrealizowana w wiersze i kolumny. Ze względu na sposób realizacji, pamięć ta cały czas powoli zapomina swój stan, nawet gdy jest zasilana — gwarantowany czas przechowywania informacji to tylko 64ms. Co więcej, dostęp jest bardzo skomplikowany — trzeba najpierw przeczytać cały wiersz pamięci do specjalnego bufora (co zajmuje trochę czasu) przed przeczytaniem/odczytaniem danej komórki, a następnie odłożyć go z powrotem (co również zajmuje czas). Użycie takiej pamięci wymaga skomplikowanego kontrolera pamięci, który będzie pilnował wykonania wszystkich czynności w odpowiedniej sekwencji i z odpowiednimi odstępami czasowymi, a także zapewniał odświeżanie pamięci — regularne czytanie i zapisywanie z powrotem każdego wiersza pamięci, by utrzymać jej stan.

    Ponieważ efektywna konstrukcja pamięci DRAM wymaga trochę innego procesu niż efektywna konstrukcja logiki (i ze względu na jej rozmiar), pamięć DRAM jest zawsze osobnym układem. Kontroler pamięci znajduje się jednak razem z logiką. W przypadku FPGA, kontroler pamięci realizuje się zazwyczaj używając programowalnej logiki (wyjątkiem są FPGA z wbudowanym kontrolerem pamięci, w tym te z wbudowanym procesorem jak Zynq).

RAM osadzony w układach cyfrowych

Aby użyć osadzonego RAMu w FPGA, możemy:

  1. Przeczytać dokumentację FPGA, wybrać odpowiedni rodzaj RAMu i zinstancjonować ręcznie dany prymityw, bądź

  2. Opisać wymiary i funkcjonalność pożądanego RAMu w języku opisu sprzętu i zdać się na automatyczny wybór przez narzędzia syntezy.

Automatyczny wybór jest oczywiście prostszy, lecz często ma ograniczone możliwości — prymitywy dostępne w FPGA miewają wiele funkcji ciężkich do opisania w języku sprzętu i ich użycie w ten sposób będzie niemożliwe (np. funkcje ECC czy hardware FIFO).

Opis RAMu w nMigen

Aby opisać swój RAM w nMigen, najpierw tworzymy obiekt typu Memory z odpowiednimi wymiarami i danymi początkowymi, a następnie wywołujemy jego metody read_port i write_port, by stworzyć jego porty, i dodajemy je jako podmoduły naszego modułu:

m = Module()

...

# Pamięć o 16 komórkach, każda komórka ma 8 bitów.  Dane początkowe
# to 0x12, 0x33, 0x44, 0, 0, ..., 0.
mem = Memory(width=8, depth=16, init=[0x12, 0x33, 0x44])

# Tworzymy synchroniczny port odczytu.
rdport = m.submodules.rdport = mem.read_port()
raddr = Signal(4)
rdata = Signal(8)
m.d.comb += [
    # podłączamy adres z którego ma czytać (gdzieś powinno być przypisanie do raddr)
    rdport.addr.eq(raddr),
    # podłączamy dane
    rdata.eq(rdport.data),
    # [opcjonalnie] podłączamy sygnał enable — gdy ten sygnał będzie równy 0,
    # port nie będzie pracował (wartość rdata się nie zmieni).  Jeśli go nie
    # podłączymy, domyślnie będzie to zawsze 1.
    rdport.en.eq(cośtam == coś),
]

# Tworzymy synchroniczny port zapisu.
wrport = m.submodules.wrport = mem.write_port()
waddr = Signal(4)
wdata = Signal(8)
wen = Signal()
m.d.comb += [
    wrport.addr.eq(waddr),
    wrport.data.eq(wdata),
    # Gdy ten sygnał będzie równy 1 na zboczu zegara, nastąpi zapis danych
    # wdata pod adres waddr.  Gdy będzie równy 0, nic się nie stanie.
    wrport.en.eq(wen),
]

Jeśli zamiast tego chcielibyśmy mieć asynchroniczny port odczytu:

# Tworzymy asynchroniczny port odczytu.
rdport = m.submodules.rdport = mem.read_port(domain='comb')
raddr = Signal(4)
rdata = Signal(8)
m.d.comb += [
    # podłączamy adres z którego ma czytać (gdzieś powinno być przypisanie do raddr)
    rdport.addr.eq(raddr),
    # podłączamy dane
    rdata.eq(rdport.data),
    # w asynchronicznych portach odczytu nie wolno mieć sygnału en — nie miałoby to sensu
]

Czasem przydaje się port zapisu z osobnymi sygnałami enable dla różnych grup bitów danych (w szczególności, osobno dla każdego bajtu w słowie):

mem = Memory(width=32, depth=0x1000)

# Tworzymy synchroniczny port zapisu z osobną kontrolą zapisu każdego bajtu.
wrport = m.submodules.wrport = mem.write_port(granularity=8)
waddr = Signal(12)
wdata = Signal(32)
wen = Signal(4)
m.d.comb += [
    wrport.addr.eq(waddr),
    wrport.data.eq(wdata),
    # bit 0 wen mówi, czy zapisać bity 0-7 wdata
    # bit 1 wen mówi, czy zapisać bity 8-15 wdata
    # bit 2 wen mówi, czy zapisać bity 16-23 wdata
    # bit 3 wen mówi, czy zapisać bity 24-31 wdata
    wrport.en.eq(wen),
]

W nMigen nie możemy bezpośrednio opisać portu odczytu+zapisu — zamiast tego, tworzymy osobne porty odczytu i zapisu współdzielące sygnał adresu. Narzędzia syntezy scalą je w jeden port, jeśli będzie to potrzebne do realizacji RAMu.

Realizacja RAMów w FPGA

Należy pamiętać, że nasz opis RAMu musi być realizowalny w docelowym układzie FPGA. Synteza spróbuje wielu transformacji by móc zrealizować nasz RAM, lecz nie zawsze będzie to możliwe:

  1. Szerokość RAMu zostanie dopasowana do szerokości sprzętowej. Jeśli nasz sprzęt ma tylko szerokość 8, a prosimy o RAM o szerokości 5, zostanie on odpowiednio rozszerzony (marnując ⅜ dostępnych bitów). Jeśli prosimy o RAM o szerokości 24, syntezator wykorzysta 3 równoległe bloki sprzętu by poskładać nasz RAM.

  2. Głębokość RAMu również zostanie dopasowana, marnując RAM jeśli nasz RAM jest mniejszy od bloku, bądź wykorzystując wiele bloków (i odrobinę dodatkowej logiki sterującej) gdy nasz RAM jest większy.

  3. Jeśli nasze porty nie dają się zapakować do portów bloku sprzętowego przez nadmiar portów odczytu, synteza zduplikuje cały RAM dla każdego portu odczytu (bądź grupy portów odczytu). Jeśli nie da się zapakować z innych powodów (nadmiar portów zapisu, brak możliwości poscalania portów odczytu z portami zapisu, bądź brak wymaganej funkcjonalności portów), RAMu w ogóle nie da się zrealizować.

Zaleca się przestrzegać następujących reguł:

  1. Używamy maksymalnie jednego portu zapisu.

  2. Jeżeli pamięć jest mała: staramy się minimalizować liczbę portów odczytu.

  3. Jeżeli pamięć jest duża (kilka kilobitów i więcej): minimalizujemy liczbę portów odczytu, używamy tylko synchronicznych portów odczytu.

Do czego używany jest RAM

Przykładowe użycia RAMu:

  1. Plik rejestrów procesora

  2. Kolejka przychodzących danych / zleceń / pakietów …

  3. Kod / pamięć operacyjna mikrokontrolera

  4. Pamięć cache

  5. Tabele stałych (np. stablicowane wartości sinusa)

  6. Tablica aktualnie przetwarzanych danych (część mnożonej macierzy, itp)