Kurs FPGA Lattice (26). Slave SPI

Kurs FPGA Lattice (26). Slave SPI

Interfejs SPI, obok UART oraz I²C, należy do najczęściej stosowanych łączy komunikacyjnych między mikrokontrolerami i innymi układami scalonymi, takimi jak wyświetlacze, pamięci, czujniki itp. W tym odcinku zobaczymy, jak wykonać moduł pełniący funkcję Slave SPI. Celem naszego ćwiczenia będzie zademonstrowanie komunikacji CPU-FPGA, przy czym CPU będzie odgrywać rolę mastera, wydającego polecenia układowi FPGA za pośrednictwem interfejsu SPI.

Trochę teorii

SPI wprowadza podział na urządzenia typu master oraz slave. Master rozpoczyna i kończy transmisję oraz generuje sygnał zegarowy. Slave musi odpowiadać na polecenia mastera, ale sam nie może zainicjować transmisji. Przy użyciu SPI można połączyć jednego mastera z – teoretycznie – nieskończoną liczbą układów slave. Zaletą SPI jest duża szybkość transmisji – bez najmniejszego problemu uzyskamy przepustowość rzędu 10 Mbit/s, nawet w przypadku prostych i tanich mikrokontrolerów. Współczesne wyświetlacze LCD potrafią odbierać dane przez SPI z prędkością nawet 80 MHz.

SPI korzysta z czterech linii sygnałowych:

  • SCK (Serial Clock) – sygnał zegarowy generowany przez mastera i odbierany przez wszystkie układy slave.
  • MOSI (Master Output, Slave Input) – wyjście danych z układu master, połączone z wejściami wszystkich układów slave. Wszystkie slave’y mają zatem możliwość odbierania danych wysyłanych przez mastera, niezależnie od tego, który slave został przez niego wybrany za pomocą aktywnego sygnału CS.
  • MISO (Master Input, Slave Output) – wyjście danych z układu slave, połączone z wejściem mastera. Jeżeli jest więcej niż jeden slave, to ich wyjścia połączone są ze sobą. Aby uniknąć wzajemnego zakłócania się wyjść (konfliktu na szynie danych), wyposaża się je w bufor trójstanowy, który odcina wyjście, kiedy slave nic nie nadaje. Przy takim rozwiązaniu możliwe jest, aby tylko jeden slave miał aktywne wyjście MISO, podczas gdy wszystkie pozostałe slave’y muszą ustawić te wyjścia w stan wysokiej impedancji.
  • CS (Chip Select) – wyjście z układu master doprowadzone do wejścia wybierającego układu slave. Jeżeli na magistrali SPI mamy więcej układów slave, potrzebujemy po jednej osobnej linii CS na każdego slave’a. Kiedy linia CS pozostaje w stanie wysokim, slave jest nieaktywny. Aby uaktywnić slave’a, master ustawia na tej linii stan niski i utrzymuje go przez cały czas transmisji. Ustawienie CS w stan wysoki oznacza zakończenie komunikacji i dezaktywowanie układu slave. Czasami na wspomnianą linię dodaje się rezystory pull-up, aby mieć pewność, że slave nie uaktywni się przez przypadek, kiedy master nie działa (np. kiedy program procesora nie zdążył się jeszcze uruchomić).

Istnieją cztery tryby pracy interfejsu SPI, ponumerowane od 0 do 3 – zaprezentowano je w tabeli 1. Można wybrać polaryzację oraz fazę sygnału zegarowego. Omówimy skrótowo tryb pracy 0.

Zobaczmy rysunek 1, pokazujący cztery linie sygnałowe szyny SPI. Kiedy interfejs pozostaje nieaktywny, wówczas linia CS jest w stanie wysokim, linia zegarowa w stanie niskim, a MISO i MOSI znajdują się w stanie wysokiej impedancji (w niektórych implementacjach MOSI może mieć taki stan, jaki miał ostatni transmitowany wcześniej bit).

Rysunek 1. Przebiegi sygnałów na magistrali SPI pracującej w trybie 0

Bity przesyłane są w kolejności od najstarszego do najmłodszego, zatem na liniach MISO i MOSI pojawiają się najstarsze bity z bajtów przesyłanych pomiędzy masterem a slave'em.

Kiedy sygnał zegarowy SCK przechodzi ze stanu niskiego na wysoki, wówczas master i slave odczytują stany swoich wejść danych i zapisują je w swoich rejestrach przesuwnych, w których przechowywane są kolejne bity odbieranych bajtów. W momencie wystąpienia zbocza opadającego sygnału SCK master i slave aktualizują stany linii MISO oraz MOSI.

Można powiedzieć, że transmisja bajtu danych przez SPI składa się z ośmiu cykli zegarowych, przy czym zmiana stanu linii MISO i MOSI następuje na początku każdego cyklu, a odczytanie stanu tych linii – w połowie cyklu.

Po przesłaniu ośmiu bitów możliwe jest przesłanie kolejnych danych, a jeżeli nie ma więcej bajtów do transmisji – linia CS przechodzi w stan wysoki, co sygnalizuje zakończenie transmisji.

Moduł SlaveSPI

Kod zaprezentowany na listingu 1 umożliwi realizację urządzenia slave SPI, pracującego tylko w trybie 0. Pozwoliłem sobie na takie uproszczenie z dwóch powodów. Po pierwsze, tryb 0 jest najczęściej używany w różnego rodzaju pamięciach, wyświetlaczach oraz czujnikach. Po drugie, nasz układ FPGA będzie połączony z jakimś procesorem, odgrywającym rolę mastera (na płytce User Interface Board jest to popularny ESP32). Zmiana trybu pracy w mikrokontrolerze pozostaje tylko kwestią ustawiania jednego czy dwóch rejestrów. Zatem jest to dużo prostsze niż przygotowanie kodu w Verilogu, który byłby konfigurowalny za pomocą parametrów.

// Plik slave_spi.v

`default_nettype none

module SlaveSPI(
input wire Clock,
input wire Reset,

input wire CS_i, // Chip select, aktywny stan niski
input wire SCK_i, // Zegar generowany przez mastera
input wire MOSI_i, // Master Output, Slave Input
output wire MISO_o, // Master Input, Slave Output

input wire [7:0] DataToSend_i, // Bajt odpowiedzi do wysłania przez MISO
output reg [7:0] DataReceived_o, // Bajt odebrany z MOSI
output reg TransactionDone_o, // Stan wysoki po odebraniu bajtu
output wire TransmissionStart_o, // Stan wysoki po przejściu CS w stan niski
output wire TransmissionEnd_o // Stan wysoki po przejściu CS w stan wysoki
);

// Synchronizacja wejść CS, SCK i MOSI z domeną zegarową
wire SyncCS; // 1
wire SyncSCK;
wire SyncMOSI;

Synchronizer #( // 2
.WIDTH(3)
) Synchronizer_inst(
.Clock(Clock),
.Reset(Reset),
.Async_i({CS_i, SCK_i, MOSI_i}), // 3
.Sync_o({SyncCS, SyncSCK, SyncMOSI}) // 4
);

// Rozpoznawanie zdarzeń
wire TransmissionInProgress = !SyncCS; // 5
wire InputSampleRequest; // 6
wire OutputShiftRequest; // 7

EdgeDetector EdgeDetectorCS( // 8
.Clock(Clock),
.Reset(Reset),
.Signal_i(SyncCS), // 9
.RisingEdge_o(TransmissionEnd_o), // 10
.FallingEdge_o(TransmissionStart_o) // 11
);

EdgeDetector EdgeDetectorSCK( // 12
.Clock(Clock),
.Reset(Reset),
.Signal_i(SyncSCK), // 13
.RisingEdge_o(InputSampleRequest), // 14
.FallingEdge_o(OutputShiftRequest) // 15
);

// Odbiornik
reg [2:0] BitCounter; // 16

always @(posedge Clock, negedge Reset) begin // 17
if(!Reset) begin
BitCounter <= 0;
DataReceived_o <= 0;
end

else if(TransmissionStart_o) begin // 18
BitCounter <= 0;
end

else if(TransmissionInProgress && InputSampleRequest) begin // 19
BitCounter <= BitCounter + 1’b1;
DataReceived_o <= {DataReceived_o[6:0], SyncMOSI}; // 20
end
end

// Nadajnik
reg [7:0] DataToSend; // 21

always @(posedge Clock, negedge Reset) begin // 22
if(!Reset) begin
DataToSend <= 0;
end

else if(TransmissionStart_o ||
(OutputShiftRequest && BitCounter == 3’d0)) begin
DataToSend <= DataToSend_i; // 23
end

else if(OutputShiftRequest) begin // 24
DataToSend <= DataToSend << 1;
end
end

// Wyjście nadajnika
assign MISO_o = TransmissionInProgress ? DataToSend[7] : 1’bZ; // 25

// Wykrywanie końca transmisji bajtu
always @(posedge Clock, negedge Reset) begin // 26
if(!Reset)
TransactionDone_o <= 0;
else if(InputSampleRequest && BitCounter == 3’d7) // 27
TransactionDone_o <= 1;
else
TransactionDone_o <= 0; // 28
end

endmodule

`default_nettype wire

Listing. 1. Kod pliku slave_spi.v

Omówmy listę portów modułu SlaveSPI:

  • Clock – wejście zegara taktującego FPGA.
  • Reset – wejście resetujące, aktywne w stanie niskim.
  • CS_i, SCK_i, MOSI_i – wejścia interfejsu SPI,
  • sterowane przez układ master. Trzeba pamiętać, że sygnały na tych wejściach nie są zsynchronizowane z domeną zegarową w FPGA.
  • MISO_o – wyjście danych ze slave’a do mastera.
  • DataToSend_i[7:0] – bajt danych, który ma zostać wysłany do mastera poprzez linię MISO.
  • DataReceived_o[7:0] – bajt danych, który został odebrany od mastera poprzez linię MOSI.
  • TransactionDone_o – wyjście informujące, że bajt danych został odebrany i można go odczytać z wyjścia DataReceived_o.
  • TransmissionStart_o – wyjście informujące o rozpoczęciu transmisji (wykrycie zbocza opadającego na CS).
  • TransmissionEnd_o – wyjście informujące o zakończeniu transmisji (wykrycie zbocza rosnącego na CS).

Na trzech ostatnich wyjściach pojawiać się będą sygnały strobe, czyli szpilki stanu wysokiego o długości jednego taktu zegarowego.

Zanim zaczniemy omawiać logikę nadajnika SPI, musimy zastanowić się nad problemem synchronizacji. Master i slave na ogół mają swoje własne układy zegarowe, nierzadko pracujące z zupełnie innymi częstotliwościami. Musimy w jakiś sposób zsynchronizować sygnały łączące te dwa układy, aby uniknąć problemu metastabilności. Istnieje kilka rozwiązań, które możemy zastosować w tym celu:

  1. Najprostszym rozwiązaniem jest synchronizacja wejść CS, SCK i MOSI z domeną zegarową FPGA w taki sposób, jak to omawialiśmy w odcinku kursu poświęconemu metastabilności (odcinek 11, EP 09/2023). Wadę tego rozwiązania stanowi fakt, że częstotliwość sygnału na wejściu SCK musi być mniejsza od połowy częstotliwości zegara FPGA.
  2. Inną opcją jest zastosowanie zegara SPI jako zegara taktującego logikę w FPGA. W tym przypadku konieczne jest, aby linia SCK była doprowadzona do wejścia PCLKTxx układu FPGA. Wtedy nie musimy nic synchronizować, bo cały układ FPGA jest „naturalnie” zsynchronizowany z masterem, jednak wadą tego rozwiązania jest to, że układ FPGA zupełnie nie działa, jeżeli master nie prowadzi komunikacji.
  3. Rozwiązaniem pośrednim między dwoma powyższymi jest zastosowanie dwóch oddzielnych domen zegarowych. Pierwsza obejmowałaby całą logikę w FPGA z wyjątkiem modułu SPI, a druga zawierałaby tylko moduł SPI i byłaby taktowana zegarem SCK z mastera. Należałoby wtedy zsynchronizować jedynie sygnały komunikujące się między tymi dwiema domenami. Takie rozwiązanie pozwoliłoby na osiągnięcie dużej prędkości transmisji, lecz problem synchronizacji byłby bardziej skomplikowany i zasobochłonny niż w pierwszym przypadku.

W tym odcinku kursu zastosujemy rozwiązanie pierwsze. W przypadku zegara FPGA o częstotliwości 25 MHz umożliwia to komunikację z częstotliwością 10 MHz. W linii 1 oraz kilku kolejnych tworzymy zmienne typu wire dla sygnałów CS, SCK oraz MOSI po przejściu przez synchronizator. Sygnały te będą używane przez dalsze operacje.

W linii 2 tworzymy instancję modułu synchronizatora. Za pomocą parametru WIDTH określamy, że wejście i wyjście mają być 3-bitowe. Do wejścia sygnałów asynchronicznych doprowadzamy sygnały CS_i, SCK_i, MOSI_i sklejone ze sobą za pomocą operatora konkatenacji (linia 3). Wyjście sygnałów synchronicznych zrealizowane jest w bardzo podobny sposób (linia 4).

Następnie musimy zająć się wykrywaniem specyficznych zdarzeń. Sygnały, które omówimy w trzech następnych akapitach, będą służyły do sterowania logiką nadajnika oraz odbiornika SPI.

W linii 5 tworzymy zmienną wire TransmissionInProgress, której stan wysoki informować będzie, że właśnie trwa transmisja na magistrali. Zmienna ta jest w gruncie rzeczy zanegowanym sygnałem CS po synchronizacji z domeną zegarową FPGA.

Przejdźmy teraz do linii 8. Tworzymy tam pierwszą z dwóch instancji detektorów zboczy. Instancja ta analizuje sygnał CS (oczywiście zsynchronizowany), który doprowadzony jest do wejścia Signal_i (linia 9). Zbocze opadające oznacza rozpoczęcie transmisji, co powoduje wystąpienie stanu wysokiego o długości jednego cyklu zegarowego na wyjściu TransmissionStart_o (linia 11). Natomiast zbocze rosnące oznacza zakończenie transmisji, co będzie sygnalizowane za pośrednictwem wyjścia TransmissionEnd_o (linia 10).

W linii 12 tworzymy drugą instancję wykrywacza zboczy – będzie ona analizować sygnał zegarowy SCK (linia 13). Zgodnie z teorią zaprezentowaną na początku artykułu, zbocze rosnące sygnału SCK ma powodować odczytanie linii MISO/MOSI. Zatem wykrycie takiego zbocza ustawi stan wysoki przez jeden takt zegarowy na zmiennej InputSampleRequest typu wire (linia 14), która została zadeklarowana wcześniej w linii 6. Analogicznie, zbocze opadające zegara SCK ma spowodować zmianę stanu MISO/MOSI. Zatem wykrycie tego zbocza ustawi stan wysoki na pojedynczy takt zegara na zmiennej OutputShiftRequest typu wire (linia 15), która została zadeklarowana w linii 7.

Następnie możemy sporządzić trzy bloki always: odbiornik, nadajnik oraz blok odpowiedzialny za wykrywanie końca transmisji całego bajtu. Ponadto musimy utworzyć 3-bitowy licznik BitCounter, aby móc odliczać osiem transmitowanych bitów, co czynimy w linii 16.

W linii 17 rozpoczynamy pierwszy blok always, odpowiedzialny za logikę odbiornika, obsługującą wejście MOSI. W chwili wykrycia rozpoczęcia transmisji (linia 18) następuje wyzerowanie licznika BitCounter. Jeżeli podczas trwającej transmisji pojawi się sygnał, że należy próbkować wejście MOSI (linia 19), to inkrementujemy licznik BitCounter i jednocześnie rejestr DataReceived_o przesuwamy w lewo, a w miejsce najmłodszego bitu wstawiamy wartość odczytaną z wejścia MOSI. Robimy to za pomocą operatora konkatenacji, którym sklejamy dotychczasową wartość bitów [6:0] rejestru DataReceived_o z wartością zmiennej SyncMOSI (linia 20).

Można zapytać, dlaczego w linii 19 sprawdzamy dwa warunki TransmissionInProgress && InputSampleRequest, a nie tylko InputSampleRequest? Otóż InputSampleRequest zależy wyłącznie od wejścia zegarowego SCK, które jest wspólne dla wszystkich urządzeń slave. Kiedy master nawiązuje komunikację z jakimś innym slave'em, wówczas na zmiennej InputSampleRequest również występują szpilki stanu wysokiego. W takiej sytuacji nie chcemy wykonywać żadnych czynności. Dlatego musimy także sprawdzić zmienną TransmissionInProgress, odzwierciedlającą stan wejścia CS, które jest oddzielne dla każdego urządzenia slave.

Możemy teraz przejść do omówienia logiki nadajnika. W linii 21 tworzymy 8-bitowy rejestr DataToSend, którego celem jest przechowywanie bajtu aktualnie transmitowanego poprzez wyjście MISO. Do tego rejestru kopiowany jest stan wejścia DataToSend_i (linia 23), kiedy spełniony zostaje jeden z dwóch warunków:

  • Pojawił się sygnał TransmissionStart_o, informujący o rozpoczęciu transmisji,
  • Jednocześnie pojawiło się żądanie przesłania kolejnego bitu i licznik BitCounter wskazuje zero – to znaczy, że slave przesłał już jeden bajt, a master chce otrzymać kolejny.

Natomiast – kiedy te warunki nie są spełnione – sprawdzamy, czy pojawił się stan wysoki w zmiennej OutputShiftRequest: jeżeli tak, to przesuwamy rejestr DataToSend o jeden bit w lewo (linia 24).

Pin wyjściowy MOSI_o powinien być wyposażony w bufor trójstanowy, aby uniemożliwić sytuację, w której linia MOSI jest wysterowana przez dwa slave’y jednocześnie. W tym celu posłużymy się instrukcją assign oraz operatorem warunkowym :? – występującym również w C i C++. Jeżeli transmisja trwa, wyjście MISO_o łączymy z siódmym bitem rejestru DataToSend. Natomiast jeżeli w danej chwili nie ma aktywnej transmisji, wyjście MISO_o ustawiane jest w stan wysokiej impedancji, reprezentowany symbolem 1’bZ (linia 25). Należy zwrócić uwagę, że zasoby logiczne wewnątrz FPGA nie mogą w żaden sposób ustawić stanu wysokiej impedancji. Może to zrobić tylko driver I/O, który bezpośrednio steruje pinem układu scalonego. Dlatego zaleca się, aby sterowanie trójstanowe umieszczać jedynie w module top lub w modułach podrzędnych – pod warunkiem że wyjście trójstanowe modułu podrzędnego poprowadzone jest prosto do wyjścia modułu top.

Pozostaje trzeci blok always (linia 25), którego celem jest ustawienie wyjścia TransactionDone_o w stan wysoki na jeden takt zegarowy, jeżeli przetransmitowanych zostanie osiem bitów. Zadanie to okazuje się bardzo proste do realizacji. Wystarczy sprawdzić tylko, czy w stanie wysokim jest sygnał żądający odczytania wejścia MOSI (InputSampleRequest) oraz czy w tej samej chwili licznik BitCounter równy jest 7 (linia 27). Jeżeli tak, to ustawiamy TransactionDone_o w stan wysoki, a w każdym innym przypadku ustawiamy tę zmienną w stan niski (linia 28).

Testbench modułu SlaveSPI

Przed opracowaniem modułu top i wgraniem bitstreamu do FPGA musimy najpierw przetestować go w symulatorze. Testujemy moduł slave, zatem testbench musi symulować układ master i generować sygnały CS, SCK oraz MOSI. Przejdźmy do omówienia kodu pokazanego na listingu 2.

// Plik slave_spi_tb.v

`timescale 1ns/1ns
`default_nettype none

module SlaveSPI_tb();

// Configuration
parameter CLOCK_HZ = 1_000_000; // 1
parameter SCK_DELAY = 7894; // 2

// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#(1_000_000_000.0 / (2 * CLOCK_HZ));
Clock = !Clock;
end

// Zmienne
reg Reset = 0;
reg CS = 1;
reg SCK = 0;
reg MOSI = 0;
reg [7:0] ResponseData = 0; // 3

// Eksport wyników symulacji
initial begin
$dumpfile(”slave_spi.vcd”);
$dumpvars(0, SlaveSPI_tb);
end

// Instancja testowanego modułu
SlaveSPI DUT( // 4
.Clock(Clock),
.Reset(Reset),
.CS_i(CS), // Chip select, aktywny stan niski
.SCK_i(SCK), // Serial clock
.MOSI_i(MOSI), // Master Out, Slave In
.MISO_o(), // Master In, Slave Out
.DataToSend_i(ResponseData), // Bajt do wysłania przez MISO
.DataReceived_o(), // Bajt odebrany z MOSI
.TransactionDone_o(),
.TransmissionStart_o(),
.TransmissionEnd_o()
);

// Komunikat po odebraniu bajtu danych
always @(posedge DUT.TransactionDone_o) begin // 5
$display(”%t Received: %H %b”,
$realtime,
DUT.DataReceived_o,
DUT.DataReceived_o
);
end

// Task wysyłający bajt danych z mastera do slave’a
task TransmitSPI(input [7:0] Data); // 6
integer i; // 7
begin
$display(”%t Transmitting: %H %b”, // 8
$realtime,
Data,
Data
);
for(i=7; i>=0; i=i-1) begin // 9
SCK = 0;
MOSI = Data[i];
#SCK_DELAY;
SCK = 1;
#SCK_DELAY;
end
end
endtask

// Sekwencja testowa
initial begin
$timeformat(-6, 3, ”us”, 10);
$display(”===== START =====”);
$display(”SCK freq is %f Hz”, 1_000_000_000.0 / (2 * SCK_DELAY));

@(posedge Clock);
Reset <= 1’b1;

repeat(5) #SCK_DELAY;

// Wyślij 1 bajt do testowanego slave’a
CS = 0; // 10
ResponseData = 8’h01;
TransmitSPI(8’h80);
CS = 1;
#SCK_DELAY;

// Wyślij 1 bajt do innego slave’a
ResponseData = 8’h02; // 11
TransmitSPI(8’h40);
#SCK_DELAY;

// Wyślij 2 bajty do testowanego slave’a
CS = 0; // 12
ResponseData = 8’h04;
TransmitSPI(8’h20);
#SCK_DELAY;
TransmitSPI(8’h10);
CS = 1;

repeat(5) #SCK_DELAY;

$display(”====== END ======”);
$finish;
end

endmodule

Listing 2. Kod pliku slave_spi_tb.v

Częstotliwość zegara w testowanym module ustawiamy na 1 MHz (linia 1). Poniżej, w linii 2, mamy parametr określający czas pomiędzy zboczami sygnału zegarowego SCK, mierzony w jednostkach symulacji, tzn. konkretnie w nanosekundach (bo tak zostało to ustawione instrukcją `timescale). Wartość 7894 wydaje się trochę dziwna. Użyłem jej celowo, aby zasymulować działanie mastera w innej domenie zegarowej niż domena testowanego modułu.

Następnie deklarujemy zmienne typu reg, którymi sterować będzie kod testbencha. Ich znaczenie jest oczywiste, z wyjątkiem 8-bitowej zmiennej ResponseData (linia 3). Jest to odpowiedź modułu slave, jaką ma on przesłać do mastera poprzez linię MISO.

W linii 4 tworzymy instancję testowanego modułu. Myślę, że nie wymaga ona komentarza.

Jeśli chcemy wyświetlać na konsoli odebrane bajty, musimy w tym celu napisać prosty blok always. W linii 5 tworzymy taki blok, który reagować będzie na zbocze rosnące sygnału TransactionDone_o z instancji testowanego modułu. Sygnał ten przyjmuje stan wysoki, kiedy moduł odbierze bajt danych z SPI i jest on gotowy do odczytania na wyjściu DataReceived_o. Wewnątrz bloku always mamy tylko prostą instrukcję $display, której celem jest wyświetlanie aktualnego czasu symulacji oraz wartości odebranego bajtu w formacie szesnastkowym i binarnym.

W linii 6 tworzymy task TransmitSPI – jego rolą będzie symulowanie mastera poprzez odpowiednie sterowanie liniami SCK oraz MOSI, które de facto są zmiennymi zdefiniowanymi wcześniej. Ma on jedno 8-bitowe wejście Data i poprzez nie przekazywać będziemy bajt danych do wysłania linią MOSI. Task ma pętlę for, zatem iterator pętli musimy zadeklarować wcześniej (uwaga dla programistów przyzwyczajonych do C++). Iterator pętli i (typu integer) tworzymy w linii 7, przed blokiem begin-end zawierającym ciało tasku.

Wewnątrz tasku mamy instrukcję $display (linia 8), wyświetlającą na konsoli czas oraz wartość wysyłanego bajtu – podobny zapis pojawił się dwa akapity wcześniej. Dalej mamy pętlę for (linia 9), na początku której ustawiamy iterator i na wartość 7, a następnie zmniejszamy go aż do zera. Pamiętaj, że bity przesyłamy w kolejności od najstarszego do najmłodszego. Wewnątrz pętli znajdują się instrukcje sterujące liniami SCK i MOSI, a także opóźnienie SCK_DELAY, którego wartość została ustawiona w linii 2.

Przechodzimy do sekwencji testowej. W linii 10 rozpoczynamy wysłanie bajtu 8’h80 z mastera do slave’a, a slave ma odpowiedzieć bajtem 8’h01. W linii 11 przesyłamy bajt 8’h40 do jakiegoś innego slave’a, którego nie ma w symulacji. Zwróć uwagę, że w tym przypadku nie ustawiamy linii CS w stan niski.

Badany moduł ma zignorować tę transmisję. W linii 12 wysyłamy dwa bajty do testowanego slave’a. Celem tego testu jest sprawdzenie, czy moduł może odebrać więcej niż jeden bajt w pojedynczej transmisji.

Aby uruchomić symulację, potrzebujemy skryptu, którego kod pokazany został na listingu 3.

@echo off
iverilog -o slave_spi.o ^
slave_spi.v ^
slave_spi_tb.v ^
edge_detector.v ^
synchronizer.v
vvp slave_spi.o
del slave_spi.o

Listing 3. Kod pliku slave_spi.bat

Wynik symulacji widać na listingu 4 oraz na rysunku 2.

VCD info: dumpfile slave_spi.vcd opened for output.
===== START =====
SCK freq is 63339.244996 Hz
40.470us Transmitting: 80 10000000
161.000us Received: 80 10000000
174.668us Transmitting: 40 01000000
308.866us Transmitting: 20 00100000
430.000us Received: 20 00100000
443.064us Transmitting: 10 00010000
564.000us Received: 10 00010000
====== END ======
slave_spi_tb.v:101: $finish called at 608838 (1ns)

Listing 4. Wynik widoczny na konsoli symulatora
Rysunek 2. Przebiegi uzyskane w wyniku symulacji

Moduł top

Moduł top ma pełnić funkcję prostego demonstratora interfejsu SPI. Jego celem będzie pokazanie na wyświetlaczu 7-segmentowym czterech ostatnich bajtów, odebranych od mastera. Ponadto moduł ma odsyłać do mastera poprzednio odebrany bajt. Przeanalizujmy kod widoczny na listingu 5.

// Plik top.v

`default_nettype none

module top #(
parameter CLOCK_HZ = 25_000_000
)(
input wire Clock, // Pin 20
input wire Reset, // Pin 17
input wire CS, // Pin 27
input wire SCK, // Pin 31
input wire MOSI, // Pin 49
output wire MISO, // Pin 32
output wire [7:0] Cathodes_o, // Pin 40 41 42 43 45 47 51 25
output wire [7:0] Segments_o // Pin 39 38 37 36 35 34 30 29
);

// Cztery ostatnio odebrane bajty
reg [7:0] Byte0 = 0; // 1
reg [7:0] Byte1 = 0;
reg [7:0] Byte2 = 0;
reg [7:0] Byte3 = 0;

wire [7:0] DataReceived; // 2
wire ReceivedEvent; // 3

SlaveSPI SlaveSPI_inst( // 4
.Clock(Clock),
.Reset(Reset),
.CS_i(CS),
.SCK_i(SCK),
.MOSI_i(MOSI),
.MISO_o(MISO),
.DataToSend_i(Byte0), // 5
.DataReceived_o(DataReceived), // 6
.TransactionDone_o(ReceivedEvent), // 7
.TransmissionStart_o(),
.TransmissionEnd_o()
);

// Kiedy zostanie odebrany nowy bajt, umieść go na pozycji zerowej
// a dotychczasowe bajty przesuń na kolejną pozycję
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Byte0 <= 0;
Byte1 <= 0;
Byte2 <= 0;
Byte3 <= 0;
end

else if(ReceivedEvent) begin // 8
Byte0 <= DataReceived;
Byte1 <= Byte0;
Byte2 <= Byte1;
Byte3 <= Byte2;
end
end

// Instancja wyświetlacza
DisplayMultiplex #( // 9
.CLOCK_HZ(CLOCK_HZ),
.SWITCH_PERIOD_US(1000),
.DIGITS(8)
) DisplayMultiplex_inst(
.Clock(Clock),
.Reset(Reset),
.Data_i({Byte3, Byte2, Byte1, Byte0}), // 10
.DecimalPoints_i(8’b01010101),
.Cathodes_o(Cathodes_o),
.Segments_o(Segments_o),
.SwitchCathode_o()
);

endmodule

`default_nettype wire

Listing 5. Kod pliku top.v

Na początku deklarujemy kilka zmiennych. W linii 1 i kolejnych mamy cztery 8-bitowe rejestry, w których przechowywane będą cztery ostatnio odebrane bajty. W linii 2 deklarujemy 8-bitową zmienną DataReceived typu wire – jej zadaniem jest przekazywanie danych z wyjścia modułu SlaveSPI do logiki zapisującej odebrane bajty. Analogicznie, w linii 3 zmienna ReceivedEvent typu wire przekazuje sygnał, że bajt odebrany przez moduł SlaveSPI jest gotowy do odczytu.

Instancję modułu obsługującego SPI tworzymy w linii 4. Moduł ma wysyłać ostatnio odebrany bajt, zatem jego wejście DataToSend_i połączone jest z rejestrem Byte0 (linia 5). Wyjście danych oraz wyjście informujące o odebraniu bajtu podłączone są do dwóch zmiennych typu wire, omówionych w poprzednim akapicie (linie 6 i 7). Wyjście informujące o rozpoczęciu i zakończeniu transmisji pozostawiany niepołączone do niczego.

Następnie mamy prosty blok always, którego celem jest obsługa pamięci ostatnich bajtów. W linii 8 sprawdzamy, czy sygnał ReceivedEvent jest w stanie wysokim. Jeżeli tak, to do Byte0 wpisujemy wartość z DataReceived, które połączone jest z wyjściem DataReceived_o modułu SlaveSPI. Natomiast do kolejnych Byte 3/2/1 wpisujemy dotychczasowe wartości z Byte 2/1/0.

Pozostaje już tylko utworzyć dobrze znaną instancję wyświetlacza multipleksowanego LED, z którego korzystaliśmy w kursie już wiele razy (linia 9). Do wejścia danych wyświetlacza doprowadzamy 32-bitową wartość powstałą w wyniku sklejenia ze sobą wszystkich czterech poprzednio odebranych bajtów (linia 10).

Przeprowadź syntezę, otwórz Spreadsheet i skonfiguruj piny tak, jak to pokazano na rysunku 3, a następnie wygeneruj bitstream i wgraj go do FPGA.

Rysunek 3. Konfiguracja pinów w Spreadsheet

Aplikacja testowa na ESP32

W celu demonstracji działania modułu SlaveSPI użyjemy płytek MachXO2 Mega, User Interface Board oraz ESP32-DevKitC – ta ostatnia będzie pracować w roli mastera. Wybrałem ESP32 ze względu na popularność i łatwość programowania w MicroPythonie. Jeżeli Czytelnik nie dysponuje tą płytką, może zastosować dowolny mikrokontroler i podłączyć go kabelkami do złącza J5 zgodnie z opisem na płytce.

Omówimy teraz w telegraficznym skrócie, jak zainstalować MicroPythona na ESP32. Do pisania programów będziemy używać środowiska Thonny, które jest bardzo proste w obsłudze i dobrze automatyzuje komunikację z ESP32. Program Thonny potrafi sam zainstalować w pamięci ESP32 właściwy firmware, obsługujący MicroPythona. Pobierz instalator spod adresu [3], a następnie zainstaluj program. Instalacja jest banalna i nie wymaga komentarza.

Podłącz płytkę ESP32-DevKitC do komputera poprzez USB i uruchom program Thonny. Musimy skonfigurować program i wybrać, z jakim procesorem chcemy pracować (środowisko obsługuje między innymi także ESP8266, Raspberry Pi Pico, BBC micro:bit oraz „zwykłego” Pythona na komputery osobiste).

Z górnego paska menu wybierz Narzędzia, a następnie Opcje, po czym kliknij zakładkę Interpreter. W polu wyboru interpretera wybierz MicroPython (ESP32) i poniżej ustaw właściwy port COM, przez który komunikuje się płytka ESP32 (uwaga – płytka programatora USB-JTAG do FPGA również może działać jako wirtualny port COM). Kliknij pole Zainstaluj lub zaktualizuj MicroPython na dole okna. Otworzy się nowe okienko z różnymi parametrami. Skonfiguruj wszystkie opcje tak, jak zaprezentowano to na rysunku 4, po czym kliknij przycisk Zainstaluj. Wgrywanie firmware zajmuje kilkadziesiąt sekund.

Rysunek 4. Parametry instalacji MicroPythona w ESP32

Tym, co wgraliśmy do ESP32, jest interpreter języka Python. Działa bardzo podobnie do tego, który możemy zainstalować na zwykłym komputerze. Możemy wydawać mu polecenia ręcznie w konsoli, a także uruchamiać różne skrypty, zapisane w plikach na komputerze lub w systemie plików na ESP32.

MicroPython ma domyślnie zainstalowane różne przydatne biblioteki do obsługi peryferiów ESP32, łączenia z Wi-Fi czy Bluetooth oraz wiele innych przydatnych możliwości, a jeżeli czegoś brakuje – możemy rozszerzać jego funkcjonalność, wgrywając dodatkowe biblioteki. Zachęcam do samodzielnego przejrzenia strony projektu [4], gdzie opisano, jak stosować ten język do obsługi ESP32.

Jednak w kursie języka Verilog nie będę rozwodził się nad możliwościami Pythona, bo jest to temat na osobny artykuł. Postaram się natomiast zaprezentować absolutne minimum, aby tylko przetestować w praktyce kody, które wgrywamy do FPGA. Nie będę używał żadnych zaawansowanych „bajerów” programistycznych, lecz ograniczę się do podstawowych, łatwych do opanowania instrukcji, aby kurs był zrozumiały dla osób, które jeszcze nie miały styczności z Pythonem.

Wróćmy do programu Thonny. Po zainstalowaniu interpretera w ESP32 powinniśmy ujrzeć widok podobny do tego, jaki pokazano na rysunku 5.

Rysunek 5. Edytor Thonny

W centralnej części ekranu znajduje się edytor kodu. Poniżej jest konsola, w której możemy wpisywać polecenia. W tym miejscu będziemy także widzieć komunikaty wyświetlane przez programy. Po lewej stronie na górze znajdują się pliki zapisane na komputerze, a w lewym dolnym rogu – pliki przechowywane w pamięci ESP32. Okienka po prawej stronie, w których pokazane są zmienne, ich wartości, lista funkcji, itp., domyślnie są wyłączone. Można je włączyć w menu Podgląd.

Omówmy teraz prosty kod demonstracyjny, widoczny na listingu 6.

from machine import Pin, SPI # 1
cs = Pin(5, Pin.OUT) # 2
spi = SPI(2, baudrate=1_000_000, polarity=0, phase=0, # 3
sck=Pin(18), mosi=Pin(23), miso=Pin(19))
print(spi) # 4

write_buffer = bytearray([0x01, 0x03, 0x07, 0xFF]) # 5
read_buffer = bytearray(4) # 6

cs(0) # 7
spi.write_readinto(write_buffer, read_buffer) # 8
cs(1) # 9

for byte in read_buffer: # 10
print(f”{byte:02X}”, end=” ”) # 11

# Można też tak
# cs(0)
# spi.write(b’\x01’)
# spi.write(b’\x03’)
# spi.write(b’\x07’)
# spi.write(b’\xFF’)
# cs(1)

Listing 6. Kod pliku spi_demo.py

Jego zadaniem jest przesłanie czterech bajtów z ESP32 do FPGA, a następnie wyświetlenie na konsoli czterech bajtów, które FPGA przesłało do ESP32. Ponieważ jest to kurs języka Verilog, a nie Python, omówimy kod linia po linii, lecz bardzo skrótowo:

  1. Importujemy potrzebne moduły, tzn. obsługę pinów GPIO oraz SPI.
  2. Inicjalizujemy pin CS, ponieważ SPI w MicroPythonie kontroluje jedynie piny MISO, MOSI i SCK, a linię CS musimy wysterować sami.
  3. Inicjalizujemy interfejs SPI, podając do niego różne informacje konfiguracyjne. Między innymi są to: częstotliwość zegara oraz informacja, które piny ESP32 mają pełnić funkcję MISO, MOSI i SCK.
  4. Wyświetlamy parametry utworzonego wcześniej interfejsu. Przy ustawianiu wysokich częstotliwości zegara SCK program może obniżyć częstotliwość do wartości możliwej do osiągnięcia.
  5. Tworzymy bufor, który zostanie wysłany do FPGA. Zapisujemy w nim cztery bajty.
  6. Tworzymy bufor o długości 4 bajtów, w którym zapisana będzie odpowiedź otrzymana od FPGA.
  7. Ustawiamy pin CS w stan niski, co aktywuje transmitter slave w FPGA.
  8. Przeprowadzamy transmisję, wysyłamy write_buffer, a otrzymane dane zapisujemy do read_buffer.
  9. Ustawiamy pin CS w stan wysoki, aby zakończyć transmisję.
  10. Rozpoczynamy pętlę, która dla każdego bajtu z bufora odczytowego…
  11. …ma wyświetlić ten bajt w formacie szesnastkowym.

Aby uruchomić kod, wystarczy nacisnąć F5 na klawiaturze. Na konsoli zobaczymy komunikaty, jak na listingu 7, a na płytce User Interface Board powinniśmy zobaczyć przesłane bajty – tak jak to pokazano na fotografii tytułowej.

MPY: soft reboot
SPI(id=2, baudrate=1000000, polarity=0, phase=0, bits=8, firstbit=0, sck=18, mosi=23, miso=19)
00 01 03 07

Listing. 7 Efekt działania programu testowego

Na rysunku 6 widzimy także przebiegi sygnałów, zapisane za pomocą oscyloskopu. Zwróć uwagę na powolne opadanie linii MISO po zakończonej transmisji. Jest to efekt przełączenia się pinu MISO układu slave w stan wysokiej impedancji. Wtedy ta linia nie jest przez nic wysterowana i powoli rozładowuje się jej pojemność pasożytnicza.

Rysunek 6. Zapis z oscyloskopu

Interfejs SPI opracowany w tym odcinku będziemy jeszcze wielokrotnie stosować. W następnym odcinku poznamy podstawy działania VGA, a w kolejnym wykonamy replikę popularnego i niedrogiego sterownika wyświetlacza OLED typu SSD1309, która – jak zapewne się domyślasz – sterowana będzie z ESP32 przez SPI, a obraz będzie wyświetlany na monitorze z interfejsem VGA, zamiast matrycy OLED.

Zobacz więcej:

Dominik Bieczyński
leonow32@gmail.com

Artykuł ukazał się w
Elektronika Praktyczna
grudzień 2024
DO POBRANIA
Materiały dodatkowe
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik marzec 2025

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio marzec - kwiecień 2025

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje marzec 2025

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna marzec 2025

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich kwiecień 2025

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów