Wykład 5: Komunikacja ze światem zewnętrznym — UART

Data: 17.11.2020

Sygnały asynchroniczne

Załóżmy, że chcemy w naszym układzie synchronicznym obserwować stan sygnału zewnętrznego, który nie jest zsynchronizowany z naszym zegarem. Okazuje się, że jest to problematyczne.

Działanie przerzutników, metastabilność

Mowiliśmy, że przerzutnik zapisuje stan wejścia w momencie rosnącego zbocza zegara. Jest to jednak duże uproszczenie — w prawdziwym świecie wykonanie idealnego przerzutnika jest niemożliwe:

  1. Prawdziwe przerzutniki nie obserwują stanu wejścia w jednym punkcie czasu, tylko w pewnym przedziale, w którym wejście nie powinno się zmieniać. Przedział ten zdefiniowany jest dwoma parametrami:

    • setup time: czas przed zboczem zegarowym od którego wejście powinno być stabilne

    • hold time: czas po zboczu zegarowym do którego wejście powinno być stabilne

    Aby przerzutnik poprawnie zapisał stan wejścia w danym cyklu, wejście nie może zmieniać stanu w przedziale (zbocze - setup, zbocze + hold).

    Zdarzają się (dość często) przerzutniki o zerowym bądź ujemnym czasie hold — pozwala to zagwarantować, że dane wysyłane synchronicznie z zegarem zostaną poprawnie zarejestrowane. Możliwe jest też wyprodukowanie przerzutników z zerowym bądź ujemnym czasem setup, choć rzadko się to zdarza. Nie istnieją jednak (i nie mogą istnieć) przerzutniki, w których oba czasy są zerowe bądź ujemne.

  2. Prawdziwe sygnały danych nie zmieniają stanu z 0 na 1 (i na odwrót) natychmiast — w rzeczywistości, każdy sygnał jest analogowy i podczas tranzycji sygnał przyjmie każdą wartość pośrednią.

W przypadku naruszenia czasów hold/setup, bądź w przypadku podania na wejściu wartości pośredniej (pomiędzy 0 a 1), przerzutnik zapisze w sobie stan pośredni, nie będący ani zerem ani jedynką. Taki stan nie jest stabilny — prędzej czy później nastąpi jego „rozpad” albo do 0 albo do 1. Potrafi jednak trwać całkiem długo (tym dłużej, im bliżej stanu 0.5 jest zapisany stan). Z tego powodu taki stan nazywa się stanem metastabilnym, a zjawisko występowania takich stanów nazywa się metastabilnością.

Okazuje się, że nie istnieje żaden limit czasu na istnienie stanu metastabilnego — może on teoretycznie trwać dowolnie długo. Zachowuje się jednak jak rozpad promieniotwórczy — można określić jego czas półtrwania, a prawdopodobieństwo zostania w stanie metastabilnym spada wykładniczo z czasem (i bardzo szybko staje się pomijalnie małe).

Obsługa sygnałów asynchronicznych w praktyce — wejście

Aby poradzić sobie ze zjawiskiem metastabilności i nie pozwolić na przedostanie się stanów metastabilnych do naszego układu, należy na każdym asynchronicznym wejściu zastosować tzw. synchronizator — układ, który „zatrzyma” stan wejściowy odpowiednio długo, by ewentualne stany metastabilne zdążyły się rozpaść. Synchronizator składa się po prostu z kilku przerzutników połączonych szeregowo:

# UWAGA: nie używać; w prawdziwym kodzie należy użyć synchronizatora z biblioteki
# Synchronizator z 3 przerzutników.
stage1 = Signal()
stage2 = Signal()
sync_input = Signal()
m.d.sync += [
    stage1.eq(async_input), # async_input jest sygnałem zewnętrznym
    stage2.eq(stage1),
    sync_input.eq(stage2),
]
# W dalszej części układu używamy sync_input.

Idea działania synchronizatora jest prosta: stan jest trzymany w każdym przerzutniku przez cały okres zegara, co prawie na pewno wystarczy, by ewentualny stan metastabilny zdążył się rozpaść zanim dotrze do ostatniego przerzutnika (którego wyjście jest już używane przez logikę synchroniczną).

Minimalny (i najczęściej stosowany) rozmiar synchronizatora to 2 przerzutniki. Gdy potrzebujemy dodatkowej pewności, stosuje się synchronizatory z 3-ma przerzutnikami. W praktyce to wystarcza, by prawdopodobieństwo wystąpienia metastabilności na wyjściu synchronizatora było mniejsze niż inne powody potencjalnego błędnego działania układu (promieniowanie kosmiczne itp).

W nMigen nie należy pisać synchronizatora samemu — zamiast tego, używamy gotowej implementacji z biblioteki:

from nmigen.lib.cdc import FFSynchronizer

# ...

sync_input = Signal()
m.submodules.my_input_synchronizer = FFSynchronizer(async_input, sync_input,
    # opcjonalne parametry i ich wartości domyślne
    o_domain='sync',        # możemy zmienić, gdy chcemy synchronizować do innej domeny niż sync
    reset=0,                # wartość początkowa przerzutników w synchronizatorze
    stages=2,               # liczba przerzutników w synchronizatorze
)

Dzięki użyciu tej wersji, nMigen automatycznie dostosuje wygenerowany kod do docelowej platformy, emitując odpowiednie atrybuty np. wyłączające optymalizację dla tych przerzutników (która popsułaby działanie synchronizatora) czy wyłączające wejście pierwszego przerzutnika z analizy czasowej.

Należy zauważyć, że synchronizatory z natury opóźniają sygnał wejściowy o co najmniej dwa cykle — jest to główny powód, dla którego przechodzenie między domenami zegarowymi wprowadza duże opóźnienia. Niestety, nie da się tego uniknąć w przypadku asynchronicznych zegarów.

Obsługa sygnałów asynchronicznych w praktyce — wyjście

Gdy wyprowadzamy z naszego układu wyjście, które będzie obserwowane przez asynchroniczny układ (pracujący w innej domenie zegarowej), również należy uważać. Tym razem jedynym problemem są potencjalne glitche w kombinacyjnej części naszego układu. Aby ich uniknąć, wystarczy zapewnić, że każde asynchroniczne wyjście naszego układu jest bezpośrednim wyjściem przerzutnika (czyli, w przypadku nMigen, nie jest przypisywane z domeny comb).

Port szeregowy, czyli UART

RS-232, popularnie zwany portem szeregowym, jest jednym z najstarszych standardów komunikacji, które wciąż są w użyciu.

Standard ten oryginalnie powstał w roku 1960 w celu łączenia dalekopisów z komputerami mainframe za pośrednictwem modemów i sieci telefonicznej. Od tego czasu był wielokrotnie przystosowywany do najróżniejszych celów:

  • łączenie komputerów PC ze sobą (za pośrednictwem modemów bądź bezpośrednio)

  • podłączenie do komputerów myszy, drukarek, bądź innych urządzeń peryferyjnych

  • podłączenie terminali do urządzeń bez wyjścia graficznego, jak serwery czy routery

  • podłączenie interfejsów do debugowania i konfiguracji do rozmaitych urządzeń

Oryginalny standard RS-232 wykorzystywał złącze DB-25 (później zamiast niego zazwyczaj DB-9) i transmitował dane cyfrowo używając napięć +12V i -12V. Miał 8 sygnałów danych:

  • RxD (receive data): dane szeregowe z modemu do komputera

  • TxD (transmit data): dane szeregowe z komputera do modemu

  • RTS (request to send): kontrola przepływu z komputera do modemu

  • CTS (clear to send): kontrola przepływu z modemu do komputera

  • DTR (data terminal ready): sygnał gotowości z komputera do modemu

  • DSR (data set ready): sygnał gotowości z modemu do komputera

  • DCD (data carrier detect): sygnał gotowości połączenia telefonicznego, z modemu do komputera

  • RI (ring indicator): przychodzące połączenie telefoniczne, z modemu do komputera

Pierwsze dwa z tych sygnałów transmitują dane protokołem szeregowym, podczas gdy pozostałe są prostymi stanami logicznymi. Układ który potrafi odbierać i wysyłać dane szeregowe w tym formacie nazywa się UART (universal asynchronous receiver/transmitter).

Większość z tych sygnałów jest niepotrzebna gdy używamy portu szeregowego do innych zastosowań niż podłączenie modemu — w praktyce zazwyczaj używa się tylko sygnałów RxD i TxD, czasem dodając jeszcze RTS i CTS do kontroli przepływu.

We współczesnych czasach, fizyczny port szeregowy (ze złączem DB-9) w komputerach to rzadkość — jeśli takiego potrzebujemy, zazwyczaj musimy kupić konwerter na USB, które zazwyczaj są złej lub bardzo złej jakości. Często zdarzają się jednak układy, które mają interfejs logicznie taki sam jak RS-232, ale używający innych poziomów logicznych (zazwyczaj 0V i 5V lub 0V i 3.3V) i innych złączy (bądź bezpośrednio połączone ze sobą na płytce). Na wielu płytkach z FPGA możemy spotkać układ konwertujący z USB na UART (np. FTDI z rodziny FT232*), podłączony do FPGA interfejsem 3.3V, umożliwiający komunikację z komputerem.

Na płytce używanej na zajęciach (Pynq-Z2) mamy układ FT2232HL, wystawiający jednocześnie interfejsy UART i JTAG przez USB. Ten UART jest jednak połączony na płytce nie do FPGA, a do UARTa procesora ARM zawartego w Zynq. Na zajęciach możemy jednak używać interfejsu szeregowego w FPGA przez podłąćzenie go do drugiego UARTa procesora ARM przez wewnętrzny interfejs EMIO, nawiązując w ten sposób komunikację między procesorem ARM a naszym układem w FPGA.

Protokół szeregowy

RS-232 jest interfejsem asynchronicznym — nie zakłada wspólnego sygnału zegarowego między nadawcą a odbiorcą. Zakłada jednak uzgodnione wcześniej parametry po obu stronach oraz w miarę (±5%) dopasowaną szybkość transmisji i odbioru.

Parametry protokołu szeregowego są następujące:

  • szybkość transmisji, w bitach na sekundę; najczęściej używane wartości to 9600 (często spotykany default), 57600 (maksymalna szybkość jaką da się przesłać przez linię telefoniczną), 115200 (maksymalna szybkość na starych UARTach).

  • liczba bitów danych w bajcie: spotyka się od 5 do 9 bitów, najczęściej jest to 8

  • typ bitu parzystości:

    • N: none, brak (najczęściej używany)

    • E: even, bit parzystości ustawiony tak, by razem z bitami danych było parzyście wiele jedynek

    • O: odd, bit parzystości ustawiony tak, by razem z bitami danych było nieparzyście wiele jedynek

    • M: mark, bit parzystości zawsze równy 1

    • S: space, bit parzystości zawsze równy 0

  • ilość bitów stopu: 1, 1.5, bądź 2 bity (najczęściej jest to 1)

Protokół działa następująco:

  • gdy nic nie jest przesyłane, linia przesyłu ma stan logiczny 1

  • gdy nadawca chce przesłać bajt, wysyła następujące bity (przesył każdego z nich trwa (1 / bity na sekundę) sekund):

    • 1 (jeden) bit startu: ma zawsze stan logiczny 0

    • bity danych, od najniższego bitu

    • bit parzystości, jeśli jest w użyciu

    • bit lub bity stopu: mają zawsze stan logiczny 1

  • po przesłaniu bajtu, nadawca może natychmiast rozpocząć transmisję kolejnego bajtu, bądź wrócić do stanu nieaktywnego przez dalsze utrzymywanie linii w stanie logicznym 1

W tym protokole bit startu służy za synchronizację — gdy odbiorca zobaczy, że wcześniej nieaktywna linia zmieniła stan logiczny z 1 na 0, rozpoczyna odbiór danych i kalibruje swój zegar odbiorczy tak, by każdy bit próbkować w środku przedziału w którym powinien być przesłany ((idx + 0.5) * czas trwania bitu od początku bitu startu). Bit (lub bity) stopu służą natomiast za padding między bajtami (by odbiorca miał czas wrócić do stanu nieaktywnego i zauważyć kolejną zmianę z 1 na 0 sygnalizującą kolejny bit startu).

O ile implementacja nadawcy jest dość oczywista i nie zawiera wiele pola na twórczość, implementacja odbiorcy to bardziej ciekawa kwestia — można napisać układy odbiorcze różniące się funkcjami wykrywania i obsługi błędów protokołu czy wykrywania niedopasowanych parametrów transmisji.