Multipleksację wyświetlacza LED już znamy. Była omawiana w 9 odcinku kursu. Polega na tym, że każda cyfra uaktywniana jest przy pomocy osobnej elektrody. Możliwe jest wyświetlanie tylko jednej cyfry jednocześnie, ale dzięki szybkiemu przełączaniu cyfr, ludzkie oko widzi wyświetlacz tak, jakby wszystkie cyfry świeciły się jednocześnie. Elektrody odpowiadających sobie segmentów (oznaczonych od A do G oraz segment kropki P) są ze sobą połączone. W tak zorganizowanym wyświetlaczu mamy 8 elektrod segmentów i tyle elektrod wspólnych, ile jest cyfr w wyświetlaczu. Wszystkie elektrody sterowane są stanem niskim lub wysokim i nie ma żadnych stanów pośrednich.
Jak działa multipleksowany wyświetlacz LCD?
Multipleksacja wyświetlacza LCD niestety jest dużo trudniejsza. Na rysunku 1 pokazano schemat połączeń wyświetlacza LCD-S401M16KR, który zastosowano na płytce MachXO2-Mega. Taki układ jest często stosowany w wyświetlaczach cyfrowych LCD wielu producentów, ale niestety nie jest standardem stosowanym przez wszystkich. Pomimo, że wyświetlacz ma cztery cyfry i cztery elektrody wspólne COM, to elektrody wspólne nie aktywują pojedynczych cyfr, a grupy segmentów we wszystkich cyfrach. Aby zaczernić segment, musimy uaktywnić elektrodę COM i jednocześnie uaktywnić odpowiadającą mu elektrodę SEG. Na przykład, aktywowanie elektrody COM3 sprawia, że możliwe staje się wyświetlenie segmentów A oraz F we wszystkich cyfrach, jeżeli aktywowane są także odpowiadające im elektrody SEG0...SEG7. Taki sposób sterowania sprawia, że moduł wyświetlacza multipleksowanego, jaki opracowaliśmy w 9 odcinku kursu, jest w tym przypadku zupełnie bezużyteczny.
W tym momencie musimy wprowadzić jeden z dwóch kluczowych parametrów wyświetlaczy LCD, jakie używają ich producenci – duty. Parametr ten mówi, przez jaki czas cyklu aktywna jest jedna elektroda wspólna (i jednocześnie ile jest elektrod wspólnych). Typowo stosuje się:
- 1 duty – brak multipleksacji, 1 elektroda wspólna sterująca wszystkimi segmentami,
- 1/2 duty – dwie elektrody wspólne,
- 1/3 duty – trzy elektrody wspólne,
- 1/4 duty – cztery elektrody wspólne,
- i tak dalej…
Sprawę dodatkowo komplikuje fakt, że elektrody większości wyświetlaczy LCD sterować trzeba nie tylko stanem wysokim czy niskim, ale także stanami pośrednimi. Tutaj pojawia się kolejny parametr, czyli bias, określający jakimi napięciami należy sterować piny wyświetlacza. Możliwe są następujące opcje (gdzie 1 oznacza napięcie zasilania):
- 1 bias – stosowane są tylko dwa stany: 0 oraz 1,
- 1/2 bias – stosowane są trzy stany: 0, 1/2 i 1,
- 1/3 bias – stosowane są cztery stany: 0, 1/3, 2/3 i 1,
- 1/4 bias – stosowanych jest pięć stanów: 0, 1/4, 1/2, 3/4, oraz 1,
- i tak dalej…
Wyświetlacz, jaki będziemy stosować ma 1/4 duty oraz 1/3 bias, zatem przy napięciu zasilania 3,3 V poszczególne stany będą mieć napięcia 0 V; 1,1 V; 2,2 V oraz 3,3 V. Dla wygody w dalszej części tekstu część ułamkową będziemy zaokrąglać.
Aby było jeszcze trudniej – między każdym segmentem a elektrodami wspólnymi napięcie średnie musi być równe zero. Jeżeli doprowadzimy do tego, że segmenty wyświetlacza będą zasilane napięciem stałym to po pewnym czasie dojdzie do uszkodzenia ciekłych kryształów. Zatem musimy w bardzo specyficzny sposób naprzemiennie odwracać sygnały sterujące wyświetlaczem w taki sposób, żeby napięcie chwilowe pomiędzy elektrodami COM i SEG było równe +3 V lub –3 V dla segmentów widocznych oraz +1 V lub –1 V dla segmentów niewidocznych.
Zobaczmy teraz jak wyglądają przebiegi napięć na elektrodach wspólnych COM naszego wyświetlacza, które pokazano na rysunku 2. Takie przebiegi muszą występować przez cały czas pracy niezależnie od tego, które segmenty wyświetlacza mają być zaczernione.
Cały cykl sterowania można podzielić na osiem części, które łącznie stanowią jedną ramkę. Każda z tych części trwa 5 milisekund. Części możemy podzielić na dwie grupy:
- W częściach od 1 do 4 – aktywna elektroda COM ma napięcie 3 V, a nieaktywne elektrody COM mają napięcia 1 V.
- W częściach od 5 do 8 – aktywna elektroda COM ma napięcie 0 V, a nieaktywne elektrody COM mają napięcie 2 V.
W ten sposób aktywna jest zawsze tylko jedna z czterech elektrod COM. Natychmiast po zmianie aktywnej elektrody COM, musimy aktywować odpowiednie elektrody SEG, aby zaczernić żądane segmenty.
Na rysunku 3 pokazano przykład sygnałów, które powodują zaczernienie segmentów C i D cyfry zerowej. Segment C zaczerniany jest, kiedy aktywna jest para COM1-SEG0, a segment D staje się widoczny po aktywacji pary COM0-SEG1. Dla poprawy czytelności pominięto nieistotne sygnały COM2, COM3 oraz SEG2…7.
Podobnie jak w przypadku sterowaniu elektrod COM, sterowanie elektrod SEG możemy podzielić na dwie grupy:
- W częściach od 1 do 4 – aktywna elektroda SEG ma napięcie 0 V, a nieaktywne elektrody SEG mają napięcia 2 V.
- W częściach od 5 do 8 – aktywna elektroda SEG ma napięcie 3 V, a nieaktywne elektrody SEG mają napięcie 1 V.
W tabeli 1 zestawiono powyższe informacje w sposób bardziej syntetyczny dla wszystkich możliwych kombinacji aktywnych/nieaktywnych elektrod COM/SEG. Tylko w przypadku, kiedy aktywne są jednocześnie COM i SEG to na wybranym segmencie występuje napięcie ±3 V, co powoduje zaczernienie segmentu, a we wszystkich innych przypadkach napięcie segmentu wynosi ±1 V i w rezultacie segment pozostaje niewidoczny.
Generowanie napięć sterujących
Zastanówmy się teraz, skąd wziąć napięcia o wartości 0, 1/3, 2/3 i 1 napięcia zasilającego? Opcje są dwie. Schemat pierwszego rozwiązania został pokazany na rysunku 4. Jest to rozwiązanie analogowe i polega na zastosowaniu dzielnika napięcia z trzech rezystorów o identycznej rezystancji, które tworzą napięcia o wartości 1/3 i 2/3 napięcia zasilającego. Stan 0 to oczywiście połączenie do masy, a 1 to połączenie z zasilaniem. Następnie przy pomocy multipleksera analogowego, wybierane jest jedno z czterech dostępnych napięć. Takich multiplekserów potrzebujemy 12, czyli tyle, ile pinów ma wyświetlacz.
Wyjścia multiplekserów wychodzą na piny układu scalonego, a one są połączone bezpośrednio z elektrodami wyświetlacza LCD.
Niestety w FPGA nie mamy do dyspozycji dzielników napięcia ani multiplekserów analogowych. Generowanie różnych napięć musimy zrealizować całkowicie cyfrowo. Rozwiązaniem jest generator PWM oraz filtry RC. Rozwiązanie pokazano na rysunku 5. Dwanaście sygnałów PWM, pochodzących z pinów FPGA, przechodzi przez dwanaście filtrów RC, które przekształcają je na sygnały analogowe.
Generatorom PWM przyjrzymy się bliżej w jednym z kolejnych odcinków kursu. Jedyne co musimy o nich wiedzieć to tylko to, że w naszym rozwiązaniu będą one generować sygnał prostokątny o wypełnieniu 33% lub 66%, co po przefiltrowaniu da napięcie około 1 V i 2 V.
Filtry RC można pominąć. Wtedy sygnały PWM zostaną wygładzone przez pasożytniczą pojemność segmentów wyświetlacza. Jednak takie rozwiązanie nie jest rekomendowane ze względu na generowanie dużo większych zakłóceń, przez co urządzenie może mieć problemy z certyfikacją EMC. Zaleca się umieścić filtry w pobliżu pinów układu FPGA, aby ścieżki sygnałów PWM były jak najkrótsze.
Na listingu 1 zaprezentowano kod modułu odpowiedzialnego za generowanie czterech napięć, jakie są wymagane do sterowania wyświetlaczem LCD, który będziemy używać. Postanowiłem zmodyfikować nieco konwencję pisania kodu, jaką stosowałem w poprzednich odcinkach ze względu na to, że nasze projekty stają się coraz bardziej skomplikowane.
// Plik lcd_pwm.v
`default_nettype none // 1
module LCD_PWM(
input wire Clock,
input wire Reset,
output wire Voltage0_o, // Wypełnienie 0%
output wire Voltage1_o, // Wypełnienie 33%
output wire Voltage2_o, // Wypełnienie 66%
output wire Voltage3_o // Wypełnienie 100%
);
// Bardzo prosta maszyna stanów
reg [1:0] State_r; // 2
always @(posedge Clock, negedge Reset) begin
if(!Reset)
State_r <= 2’b00;
else if(State_r == 2’b00) // 3
State_r <= 2’b01;
else if(State_r == 2’b01) // 4
State_r <= 2’b11;
else // 5
State_r <= 2’b00;
end
// Przypisanie wyjść
assign Voltage0_o = 1’b0; // 6
assign Voltage1_o = State_r[1]; // 7
assign Voltage2_o = State_r[0]; // 8
assign Voltage3_o = 1’b1; // 9
endmodule
`default_nettype wire // 10
Pierwsza zmiana polega na zastosowaniu instrukcji `default_nettype w linii 1. W języku Verilog nie ma potrzeby deklarowania zmiennych przed ich użyciem, tak jak to jest w C++ czy wielu innych językach. Może się zdarzyć, że popełniając literówkę w nazwie zmiennej, syntezator stwierdzi, że chcemy utworzyć zupełnie nową zmienną typu wire. W rezultacie kod będzie poprawny pod względem składni języka i zsyntezuje się, ale nie będzie działał prawidłowo. Rozwiązaniem tego problemu jest zastosowanie rozwiązania z linii 1. W momencie napotkania zmiennej, która nie została wcześniej zdefiniowana jako zmienna wire, reg, integer, czy inny typ to zostanie zgłoszony błąd.
Instrukcja ta ma zasięg globalny i dotyczy wszystkich plików projektu, więc mogłaby spowodować błąd w plikach, które napisane są „po staremu”. Z tego powodu w ostatniej linijce pliku przywracamy ustawienie, że domyślnie wybieranym typem dla niezdefiniowanych wcześniej zmiennych jest typ wire (linia 10).
Kolejna zmiana polega na dodawaniu oznaczenia do nazwy zmiennej w zależności od kierunku portu lub typu. Do wszystkich wyjść będziemy dodawać _o jak output, a do wejść będzie to _i jak input. W ten sposób będziemy mogli rozróżnić wejścia i wyjścia analizując instancję w module nadrzędnym.
Wyjątkiem są Clock i Reset, ponieważ one występują w prawie każdym module i ich przeznaczenie jest oczywiste. Ponadto, do nazw zmiennych typu reg dodamy _r, a do zmiennych wire dodawać będziemy _w.
W jaki sposób będziemy generować przebiegi o wypełnieniu 33% i 66%? Potrzebujemy dwubitowej zmiennej State_r (linia 2). W każdym takcie zegara będziemy tę zmienną modyfikować, by uzyskać takie przebiegi, jak pokazano na rysunku 6.
W bloku jedynym always tego modułu mamy proste drzewko decyzyjne, które na podstawie obecnego stanu zmiennej State_r ustawia kolejny stan. I tak, jeżeli obecna wartość tej zmiennej jest równa 00, to zmieniamy ją na 01 (linia 3). Jeżeli już mamy 01, to zmieniamy na 11 (linia 4), a w innym przypadku zmieniamy z powrotem na 00. W ten sposób uzyskujemy cykliczną, 3-cyklową pracę, gdzie State[1] przez dwa takty ma stan niski i przez jeden taki ma stan wysoki. Natomiast State[0] przez dwa takty jest w stanie wysokim, a tylko przez jeden – niskim.
Pozostaje już tylko przypisać odpowiednie sygnały do portów wyjściowych. W linii 6 przypisujemy wyjście, które ma dostarczać napięcie 0 V, czyli przywiązujemy je na stałe ze stanem niskim. Analogicznie postępujemy z wyjściem, które ma dostarczać 3 V – przypisujemy do niego stan wysoki (linia 9).
Do wyjścia, mającego dostarczać napięcie 1 V przywiązujemy rejestr State_r[1] (linia 7), a do wyjścia 2 V dajemy State_r[0] (linia 8).
Warto zwrócić uwagę, że sygnały generowane przez moduł z listingu 1 zmieniają się z każdym taktem zegara, który może mieć częstotliwość powyżej 100 MHz. Takie szybkie przełączanie pinów GPIO nie jest optymalne – może powodować dużo większe zużycie energii niż jest to potrzebne, a także może utrudniać zaliczenie testów EMC. Do tego tematu powrócimy w odcinku poświęconym generatorom PWM, a póki co pozostawmy tę małą niedoskonałość.
Sterownik wyświetlacza LCD
Przejdźmy teraz do modułu, który decyduje o tym, jakie napięcie ma zostać ustalone na poszczególnych elektrodach wyświetlacza na podstawie informacji o tym, jakie segmenty mają być widoczne. Moduł ten widać na listingu 2. Standardowo, zaczynamy od parametru, który określa częstotliwość sygnału zegarowego CLOCK_HZ (linia 1). Drugi parametr nazwany CHANGE_COM_US określa czas w mikrosekundach, po upływie którego przełączane są elektrody wspólne COM (linia 2). W naszym przykładzie ten czas to 5 ms. Ponieważ w cyklu sterowania elektrodami wspólnymi jest 8 stanów, gdzie każdy trwa 5 ms to całość zajmie 40 ms. Odwrotność tej liczby daje nam 25 Hz. Jest to częstotliwość odświeżania wyświetlacza.
// Plik lcd.v
`default_nettype none
module LCD #(
parameter CLOCK_HZ = 10_000_000, // 1
parameter CHANGE_COM_US = 5000 // 2
)(
input wire Clock,
input wire Reset,
input wire [7:0] Digit3_i, // PGFEDCBA // 3
input wire [7:0] Digit2_i,
input wire [7:0] Digit1_i,
input wire [7:0] Digit0_i,
output wire [3:0] ComPWM_o, // 4
output wire [7:0] SegPWM_o // 5
);
// Generator czterech napięć zasilających elektrody wyświetlacza
wire [3:0] Voltage_w; // 6
LCD_PWM LCD_PWM_inst( // 7
.Clock(Clock),
.Reset(Reset),
.Voltage0_o(Voltage_w[0]),
.Voltage1_o(Voltage_w[1]),
.Voltage2_o(Voltage_w[2]),
.Voltage3_o(Voltage_w[3])
);
// Generator sygnałów przełączających stan co określony czas
wire ChangeState_w; // 8
StrobeGenerator #( // 9
.CLOCK_HZ(CLOCK_HZ),
.PERIOD_US(CHANGE_COM_US)
) StrobeGenerator0(
.Clock(Clock),
.Reset(Reset),
.Strobe_o(ChangeState_w)
);
// Maszyna stanów // 10
reg [2:0] State_r /* synthesis syn_encoding = “safe, sequential” */;
localparam COM_0H = 3’d0;
localparam COM_1H = 3’d1;
localparam COM_2H = 3’d2;
localparam COM_3H = 3’d3;
localparam COM_0L = 3’d4;
localparam COM_1L = 3’d5;
localparam COM_2L = 3’d6;
localparam COM_3L = 3’d7;
// Zmiana stanu
always @(posedge Clock, negedge Reset) begin
if(!Reset)
State_r <= 0;
else if(ChangeState_w) // 11
State_r <= State_r + 1’b1; // 12
end
// Zmienne przechowujące aktualne napięcie elektrod wyświetlacza
reg [1:0] ComAnalog_r[0:3]; // 13
reg [1:0] SegAnalog_r[0:7]; // 14
// Ustalanie napięcia elektrod w zależności od stanu maszyny
// oraz informacji o tym, które segmenty mają być widoczne
always @(*) begin // 15
case(State_r) // 16
COM_0H: begin
ComAnalog_r[0] = 2’d3;
ComAnalog_r[1] = 2’d1;
ComAnalog_r[2] = 2’d1;
ComAnalog_r[3] = 2’d1;
SegAnalog_r[0] = Digit0_i[7] ? 2’d0 : 2’d2; // Dwukropek
SegAnalog_r[1] = Digit0_i[3] ? 2’d0 : 2’d2; // D0, D
SegAnalog_r[2] = Digit1_i[7] ? 2’d0 : 2’d2; // D1, P
SegAnalog_r[3] = Digit1_i[3] ? 2’d0 : 2’d2; // D1, D
SegAnalog_r[4] = Digit2_i[7] ? 2’d0 : 2’d2; // D2, P
SegAnalog_r[5] = Digit2_i[3] ? 2’d0 : 2’d2; // D2, D
SegAnalog_r[6] = Digit3_i[7] ? 2’d0 : 2’d2; // D3, P
SegAnalog_r[7] = Digit3_i[3] ? 2’d0 : 2’d2; // D3, D
end
COM_1H: begin
ComAnalog_r[0] = 2’d1;
ComAnalog_r[1] = 2’d3;
ComAnalog_r[2] = 2’d1;
ComAnalog_r[3] = 2’d1;
SegAnalog_r[0] = Digit0_i[2] ? 2’d0 : 2’d2; // D0, C
SegAnalog_r[1] = Digit0_i[4] ? 2’d0 : 2’d2; // D0, E
SegAnalog_r[2] = Digit1_i[2] ? 2’d0 : 2’d2; // D1, C
SegAnalog_r[3] = Digit1_i[4] ? 2’d0 : 2’d2; // D1, E
SegAnalog_r[4] = Digit2_i[2] ? 2’d0 : 2’d2; // D2, C
SegAnalog_r[5] = Digit2_i[4] ? 2’d0 : 2’d2; // D2, E
SegAnalog_r[6] = Digit3_i[2] ? 2’d0 : 2’d2; // D3, C
SegAnalog_r[7] = Digit3_i[4] ? 2’d0 : 2’d2; // D3, E
end
COM_2H: begin
ComAnalog_r[0] = 2’d1;
ComAnalog_r[1] = 2’d1;
ComAnalog_r[2] = 2’d3;
ComAnalog_r[3] = 2’d1;
SegAnalog_r[0] = Digit0_i[1] ? 2’d0 : 2’d2; // D0, B
SegAnalog_r[1] = Digit0_i[6] ? 2’d0 : 2’d2; // D0, G
SegAnalog_r[2] = Digit1_i[1] ? 2’d0 : 2’d2; // D1, B
SegAnalog_r[3] = Digit1_i[6] ? 2’d0 : 2’d2; // D1, G
SegAnalog_r[4] = Digit2_i[1] ? 2’d0 : 2’d2; // D2, B
SegAnalog_r[5] = Digit2_i[6] ? 2’d0 : 2’d2; // D2, G
SegAnalog_r[6] = Digit3_i[1] ? 2’d0 : 2’d2; // D3, B
SegAnalog_r[7] = Digit3_i[6] ? 2’d0 : 2’d2; // D3, G
end
COM_3H: begin
ComAnalog_r[0] = 2’d1;
ComAnalog_r[1] = 2’d1;
ComAnalog_r[2] = 2’d1;
ComAnalog_r[3] = 2’d3;
SegAnalog_r[0] = Digit0_i[0] ? 2’d0 : 2’d2; // D0, A
SegAnalog_r[1] = Digit0_i[5] ? 2’d0 : 2’d2; // D0, F
SegAnalog_r[2] = Digit1_i[0] ? 2’d0 : 2’d2; // D1, A
SegAnalog_r[3] = Digit1_i[5] ? 2’d0 : 2’d2; // D1, F
SegAnalog_r[4] = Digit2_i[0] ? 2’d0 : 2’d2; // D2, A
SegAnalog_r[5] = Digit2_i[5] ? 2’d0 : 2’d2; // D2, F
SegAnalog_r[6] = Digit3_i[0] ? 2’d0 : 2’d2; // D3, A
SegAnalog_r[7] = Digit3_i[5] ? 2’d0 : 2’d2; // D3, F
end
COM_0L: begin
ComAnalog_r[0] = 2’d0;
ComAnalog_r[1] = 2’d2;
ComAnalog_r[2] = 2’d2;
ComAnalog_r[3] = 2’d2;
SegAnalog_r[0] = Digit0_i[7] ? 2’d3 : 2’d1; // Colon
SegAnalog_r[1] = Digit0_i[3] ? 2’d3 : 2’d1; // D0, D
SegAnalog_r[2] = Digit1_i[7] ? 2’d3 : 2’d1; // D1, P
SegAnalog_r[3] = Digit1_i[3] ? 2’d3 : 2’d1; // D1, D
SegAnalog_r[4] = Digit2_i[7] ? 2’d3 : 2’d1; // D2, P
SegAnalog_r[5] = Digit2_i[3] ? 2’d3 : 2’d1; // D2, D
SegAnalog_r[6] = Digit3_i[7] ? 2’d3 : 2’d1; // D3, P
SegAnalog_r[7] = Digit3_i[3] ? 2’d3 : 2’d1; // D3, D
end
COM_1L: begin
ComAnalog_r[0] = 2’d2;
ComAnalog_r[1] = 2’d0;
ComAnalog_r[2] = 2’d2;
ComAnalog_r[3] = 2’d2;
SegAnalog_r[0] = Digit0_i[2] ? 2’d3 : 2’d1; // D0, C
SegAnalog_r[1] = Digit0_i[4] ? 2’d3 : 2’d1; // D0, E
SegAnalog_r[2] = Digit1_i[2] ? 2’d3 : 2’d1; // D1, C
SegAnalog_r[3] = Digit1_i[4] ? 2’d3 : 2’d1; // D1, E
SegAnalog_r[4] = Digit2_i[2] ? 2’d3 : 2’d1; // D2, C
SegAnalog_r[5] = Digit2_i[4] ? 2’d3 : 2’d1; // D2, E
SegAnalog_r[6] = Digit3_i[2] ? 2’d3 : 2’d1; // D3, C
SegAnalog_r[7] = Digit3_i[4] ? 2’d3 : 2’d1; // D3, E
end
COM_2L: begin
ComAnalog_r[0] = 2’d2;
ComAnalog_r[1] = 2’d2;
ComAnalog_r[2] = 2’d0;
ComAnalog_r[3] = 2’d2;
SegAnalog_r[0] = Digit0_i[1] ? 2’d3 : 2’d1; // D0, B
SegAnalog_r[1] = Digit0_i[6] ? 2’d3 : 2’d1; // D0, G
SegAnalog_r[2] = Digit1_i[1] ? 2’d3 : 2’d1; // D1, B
SegAnalog_r[3] = Digit1_i[6] ? 2’d3 : 2’d1; // D1, G
SegAnalog_r[4] = Digit2_i[1] ? 2’d3 : 2’d1; // D2, B
SegAnalog_r[5] = Digit2_i[6] ? 2’d3 : 2’d1; // D2, G
SegAnalog_r[6] = Digit3_i[1] ? 2’d3 : 2’d1; // D3, B
SegAnalog_r[7] = Digit3_i[6] ? 2’d3 : 2’d1; // D3, G
end
COM_3L: begin
ComAnalog_r[0] = 2’d2;
ComAnalog_r[1] = 2’d2;
ComAnalog_r[2] = 2’d2;
ComAnalog_r[3] = 2’d0;
SegAnalog_r[0] = Digit0_i[0] ? 2’d3 : 2’d1; // D0, A
SegAnalog_r[1] = Digit0_i[5] ? 2’d3 : 2’d1; // D0, F
SegAnalog_r[2] = Digit1_i[0] ? 2’d3 : 2’d1; // D1, A
SegAnalog_r[3] = Digit1_i[5] ? 2’d3 : 2’d1; // D1, F
SegAnalog_r[4] = Digit2_i[0] ? 2’d3 : 2’d1; // D2, A
SegAnalog_r[5] = Digit2_i[5] ? 2’d3 : 2’d1; // D2, F
SegAnalog_r[6] = Digit3_i[0] ? 2’d3 : 2’d1; // D3, A
SegAnalog_r[7] = Digit3_i[5] ? 2’d3 : 2’d1; // D3, F
end
endcase
end
// Przypisanie wyjść
assign ComPWM_o[0] = Voltage_w[ComAnalog_r[0]]; // 18
assign ComPWM_o[1] = Voltage_w[ComAnalog_r[1]];
assign ComPWM_o[2] = Voltage_w[ComAnalog_r[2]];
assign ComPWM_o[3] = Voltage_w[ComAnalog_r[3]];
assign SegPWM_o[0] = Voltage_w[SegAnalog_r[0]];
assign SegPWM_o[1] = Voltage_w[SegAnalog_r[1]];
assign SegPWM_o[2] = Voltage_w[SegAnalog_r[2]];
assign SegPWM_o[3] = Voltage_w[SegAnalog_r[3]];
assign SegPWM_o[4] = Voltage_w[SegAnalog_r[4]];
assign SegPWM_o[5] = Voltage_w[SegAnalog_r[5]];
assign SegPWM_o[6] = Voltage_w[SegAnalog_r[6]];
assign SegPWM_o[7] = Voltage_w[SegAnalog_r[7]];
endmodule
`default_nettype wire
W linii 3 i kolejnych, tworzymy cztery 8-bitowe wejścia, sterujące segmentami wszystkich cyfr. Cyfry ponumerowane są zgodnie z opisem na rysunku 1. Najmłodszy bit każdego z wejść DigitX_i[0] odpowiada za sterowanie segmentem A wyświetlacza, natomiast bit najstarszy DigitX_i[7] odpowiada za segment P, czyli przecinek lub dwukropek. Stan wysoki powodować będzie zaczernienie odpowiadającego segmentu, a stan niski sprawi, że segment będzie niewidoczny.
Linia 4 zawiera wyjście sygnałów PWM sterujących czterema elektrodami wspólnymi COM, a w linii 5 jest 8-bitowe wyjście kontrolujące elektrody SEG.
Następnie tworzymy instancję modułu, który omawialiśmy w poprzednich akapitach (linia 7). Sygnały PWM o wypełnieniu 0 %, 33 %, 66 % i 100 % są rozprowadzane do pozostałych elementów przy pomocy 4-bitowej zmiennej Voltage_w typu wire (linia 6). Zgrupowanie sygnałów PWM w zmienną 4-bitową będzie bardzo pomocne w dalszej części kodu – stosując wyrażenie Voltage_w[X], gdzie X to liczby od 0 do 3, będziemy mogli w bardzo łatwy sposób uzyskać sygnał generujący napięcie od 0 do 3 woltów. Będziemy wykorzystywać zależność, że indeks w nawiasach kwadratowych jest równy napięciu w woltach. Będzie to omówione w dalszej części tekstu.
W dalszej części kodu tworzymy instancję generatora sygnałów strobe, które służyć mają do przełączania maszyny stanów. W linii 9 stosujemy moduł StrobeGenerator, który znamy z poprzednich odcinków kursu i nie będziemy po raz kolejny omawiać jego działania. Sygnał zmiany stanu jest przekazywany za pośrednictwem zmiennej wire ChangeState_w (linia 8) – sygnał ten pozostaje w stanie niskim przez większość czasu, ale cyklicznie co 5 ms jest ustawiany w stan wysoki na jeden takt zegara, co ma spowodować przełączenie zmiennej sterującej maszyną stanów.
Maszynę stanów tworzymy w linii 10 i kolejnych. Zgodnie z rysunkiem 2 i 3 wyświetlacz ma osiem stanów – z tego powodu zmienna State_r, przechowująca stan maszyny, jest zmienną 3-bitową. W następnych liniach nazywamy wszystkie stany, poprzez tworzenie parametrów lokalnych, którym przypisane są 3-bitowe liczby od 0 do 7.
Zapis /* synthesis syn_encoding = "safe, sequential" */ jest czymś zupełnie nowym. Z punktu widzenia składni języka Verilog, jest to zupełnie zwyczajny komentarz, jednak dla syntezatora Lattice Synthesis Engine jest to polecenie mówiące, w jaki sposób ma zostać zrealizowana maszyna stanów. Parametr syn_encoding może przyjmować następujące wartości:
- sequential – stan maszyny zapisany jest binarnie, a stany ponumerowane są od zera; zmienna sterująca maszyną potrzebuje tyle bitów, ile potrzebne jest, by “zmieścić” numer ostatniego stanu. Takie kodowanie oszczędza zasoby, zwłaszcza przerzutniki, ale na ogół działa wolniej niż one-hot;
- one-hot – stan maszyny zapisany jest przy pomocy kodowania one-hot, tzn. zmienna przechowująca stan ma tyle bitów, ile możliwych jest stanów i tylko jeden z nich jest równy 1, a pozostałe bity są równe 0. Taki sposób przyspiesza działanie lecz jest bardziej zasobochłonny;
- gray – rozwiązanie pośrednie, można stosować tylko gdy maszyna ma nie więcej niż 4 stany;
- safe – może być dodane opcjonalnie do sequential i one hot; zostanie dodatkowo dodana logika resetująca maszynę stanów w przypadku, gdyby zmienna stanu miała wartość, która nie jest obsługiwana (np. dwa bity równe 1 przy kodowaniu one-hot).
Zachęcam by przetestować kod dla różnych ustawień maszyny stanów. Lattice Synthesis Engine automatycznie przerobi nasz kod i nie ma potrzeby ręcznie zmieniać definicji stanów. W tabeli 2 porównano wyniki, jakie są osiągane dla różnych ustawień.
W liniach 13 i 14 mamy coś nowego, czego jeszcze w tym kursie nie widzieliśmy – tablice, zwane także pamięciami. Pierwszy nawias kwadratowy informuje ile bitów ma pojedyncza zmienna tablicy, dokładnie w taki sam sposób, jak przy wielobitowych zmiennych reg i wire, które już znamy. Zatem w liniach 13 i 14 tworzymy tablice zmiennych 2-bitowych. Drugi nawias kwadratowy informuje o numerach indeksu pierwszego i ostatniego elementu tablicy. Tablica ComAnalog_r ma cztery elementy 2-bitowe, ponumerowane od 0 do 3, a tablica SegAnalog_r ma osiem elementów 2-bitowych, ponumerowanych od 0 do 7.
Jak zapewne się spodziewasz, w tych tablicach będziemy przechowywać informację o napięciu, jakie ma zostać dostarczone do odpowiednich elektrod COM i SEG. Zgrupowanie ich w tablice nie jest konieczne – równie dobrze moglibyśmy utworzyć dwanaście zwykłych 2-bitowych zmiennych typu reg, ale taki zabieg poprawia czytelność kodu i umożliwia jego parametryzację. W sytuacji, gdybyśmy pisali moduł obsługujący wyświetlacze o różnej liczbie elektrod COM/SEG to zastosowanie tablic byłoby zdecydowanym ułatwieniem.
Blok maszyny stanów znajdziemy w linii 15. Jest to blok logiki kombinacyjnej, reagujący na zmianę dowolnego sygnału występującego w bloku – poznajemy to po znaku gwiazdki za always @(*). Składa się on z dość rozbudowanej instrukcji case, która decyduje, co ma się wykonywać na podstawie aktualnego stanu zmiennej State_r. Dalszy kod podzielony jest na osiem części dla każdego ze stanów, jakie utworzyliśmy w linii 10.
Przeanalizujemy tylko stan COM_0H – wszystkie są zrealizowane bardzo podobnie i wynikają z konieczności wygenerowania przebiegów, jakie pokazano na rysunkach 2 i 3. Jest to stan, w którym elektroda COM0 ma mieć napięcie 3 V, a pozostałe COM-y mają mieć 1 V. Z tego powodu do ComAnalog_r[0] wpisujemy 2-bitową wartość 2’d3, a do pozostałych 2’d1;
Z ustaleniem napięć na elektrodach SEG sprawa jest bardziej skomplikowana, ponieważ zależy od poszczególnych bitów wejść DigitX_i. Dla każdego z pinów SegAnalog_r[X] musimy sprawdzić właściwy bit na odpowiednim wejściu danych (1 – segment widoczny, 0 – segment niewidoczny) i najlepiej jest to zrobić przy pomocy operatora warunkowego ?: który występuje również w językach C i C++. Jeżeli sprawdzany bit ma stan wysoki, to do zmiennej SegAnalog_r[X] wpisujemy 2’d0, a jeżeli niski to 2’d2. Analogicznie postępujemy we wszystkich pozostałych siedmiu stanach.
Przeskoczmy teraz do linii 18 i kolejnych. Przypisujemy tutaj wyjścia, które sterują pinami wyświetlacza. Poszczególne wyjścia ComPWM_o oraz SegPWM_o łączymy do jednego z czterech sygnałów Voltage_w. Robimy to przy pomocy operatora [ ]. Taka konstrukcja zostanie zsyntezowana jako multiplekser z czterema wejściami danych i 2-bitowym wejściem adresowym, tzn adres wybierający może być równy 0, 1, 2 lub 3.
Te liczby bezpośrednio odpowiadają napięciu, jakie zostanie wygenerowane po przefiltrowaniu sygnału PWM. Pobieramy je z tablic ComAnalog_r i SegAnalog_r, które zawierają 2-bitowe zmienne typu reg. W ten prosty sposób zmiana jednej zmiennej w tablicy spowoduje zmianę napięcia na wybranej elektrodzie wyświetlacza.
Zwróć uwagę, że indeksy w nawiasach kwadratowych we wszystkich przypisaniach SegPWM_o[x] oraz SegAnalog_r[x] są sobie równe. Gdyby zaszła potrzeba napisania sterownika, który obsługuje dowolną liczbę elektrod, można by wtedy ten kod łatwo sparametryzować. To samo dotyczy ComPWM[x] oraz ComAnalog_r[x].
Testbench sterownika
Czas, aby zrobić testbench, którym będziemy mogli symulować sterownik wyświetlacza LCD. Jego kod został pokazany na listingu 3. Aby zredukować czas trwania symulacji oraz ilość danych w pliku wynikowym, ustawimy częstotliwość symulowanego zegara na 1 MHz (linia 1), a czas trwania jednego stanu wyświetlacza ustawimy na 50 mikrosekund (linia 5).
// Plik lcd_tb.v
`timescale 1ns/1ns
`default_nettype none
module LCD_tb();
parameter CLOCK_HZ = 1_000_000; // 1
parameter HALF_PERIOD_NS = 1_000_000_000 / (2 * CLOCK_HZ);
// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#HALF_PERIOD_NS;
Clock = !Clock;
end
// Zmienne
reg Reset = 1’b0;
reg [7:0] Digit3_r = 8’b00000000; // 2
reg [7:0] Digit2_r = 8’b00000000;
reg [7:0] Digit1_r = 8’b00010000; // widoczny segment E
reg [7:0] Digit0_r = 8’b00000000;
wire [3:0] ComPWM_w;
wire [7:0] SegPWM_w;
// Sekwencja testowa
initial begin
$timeformat(-6, 3, “us”, 10);
$display(“===== START =====”);
$display(“CLOCK_HZ = %9d”, CLOCK_HZ);
#1 Reset = 1’b1;
$display(“ time C0 C1 C2 C3 S0 S1 S2 S3 S4 S5 S6 S7”); // 3
$monitor(“%t %d %d %d %d %d %d %d %d %d %d %d %d”,
$realtime,
DUT.ComAnalog_r[0],
DUT.ComAnalog_r[1],
DUT.ComAnalog_r[2],
DUT.ComAnalog_r[3],
DUT.SegAnalog_r[0],
DUT.SegAnalog_r[1],
DUT.SegAnalog_r[2],
DUT.SegAnalog_r[3],
DUT.SegAnalog_r[4],
DUT.SegAnalog_r[5],
DUT.SegAnalog_r[6],
DUT.SegAnalog_r[7],
);
repeat(8) begin // 4
@(posedge DUT.ChangeState_w);
end
#1 $display(“===== END =====”);
#1 $finish;
end
// Instancja testowanego modułu
LCD #(
.CLOCK_HZ(CLOCK_HZ),
.CHANGE_COM_US(50) // 5
) DUT(
.Clock(Clock),
.Reset(Reset),
.Digit3_i(Digit3_r),
.Digit2_i(Digit2_r),
.Digit1_i(Digit1_r),
.Digit0_i(Digit0_r),
.ComPWM_o(ComPWM_w),
.SegPWM_o(SegPWM_w)
);
// Eksport zmiennych
initial begin
$dumpfile(“lcd.vcd”);
$dumpvars(0, LCD_tb);
$dumpvars(2, DUT.ComAnalog_r[0]); // 6
$dumpvars(2, DUT.ComAnalog_r[1]);
$dumpvars(2, DUT.ComAnalog_r[2]);
$dumpvars(2, DUT.ComAnalog_r[3]);
$dumpvars(2, DUT.SegAnalog_r[0]);
$dumpvars(2, DUT.SegAnalog_r[1]);
$dumpvars(2, DUT.SegAnalog_r[2]);
$dumpvars(2, DUT.SegAnalog_r[3]);
$dumpvars(2, DUT.SegAnalog_r[4]);
$dumpvars(2, DUT.SegAnalog_r[5]);
$dumpvars(2, DUT.SegAnalog_r[6]);
$dumpvars(2, DUT.SegAnalog_r[7]);
end
endmodule
`default_nettype wire
W linii 2 mamy cztery 8-bitowe zmienne, które symulują dane wejściowe dla sterownika wyświetlacza. Każdy z bitów tych zmiennych odpowiedzialny jest za inny segment wyświetlacza. Celowo jeden z nich jest w stanie wysokim – dzięki temu będziemy mogli porównać przebiegi sygnałów dla segmentów widocznych i niewidocznych.
Napięcia na elektrodach wyświetlacza i ich zmiany w czasie wyświetlimy w formie tabelarycznej. W tym celu w linii 3 wyświetlamy nagłówek tabeli przy pomocy funkcji $display. Następnie mamy funkcję $monitor, która powoduje wyświetlenie tekstu, kiedy zmienia się jakakolwiek z obserwowanych zmiennych.
Obie funkcje działają podobnie do printf() z C++. W swoim pierwszym argumencie przyjmuje ciąg znaków, który ma być wyświetlany, a w miejsce %d mają być wstawione zmienne liczbowe, a w miejscu %t ma być wyświetlony czas. W kolejnych argumentach znajdują się zmienne, odpowiadające znakom % w ciągu.
W linii 4 umieszczono pętlę repeat, która wykona się osiem razy. Ta liczba jest nie bez powodu. Właśnie tyle stanów jest w całym cyklu pracy wyświetlacza. Wewnątrz pętli znajduje się tylko jedno polecenie – oczekiwanie na wystąpienie zbocza rosnącego sygnału ChangeState_w wewnątrz instancji DUT, czyli testowanego sterownika wyświetlacza. Stan wysoki na tym sygnale oznacza żądanie przejścia do kolejnego stanu. W taki sposób zapewniamy, że symulacja będzie trwała przez osiem stanów – niezależnie od częstotliwości sygnału zegarowego ani czasu trwania stanu, który jest ustalany parametrem CHANGE_COM_US.
Następnie mamy instancję sterownika wyświetlacza. Nie ma w niej nic nowego, co by wymagało komentarza.
Dalej znajduje się blok initial, w którym są tylko instrukcje związane z eksportem danych, powstałych w wyniku symulacji. Instrukcja $dumpvars(), której pierwszy argument jest zerem, powoduje zapisywanie wszystkich zmiennych z modułu wskazanego w drugim argumencie i wszystkich zmiennych w jego modułach podrzędnych. Ale to nie dotyczy tablic! Niestety musimy zażądać eksportu każdej zmiennej tablicy osobno, tak jak to przedstawiono w linii 6. W przypadku większych tablic można posłużyć się pętlą for.
Symulacja
Kod skryptu, uruchamiającego symulację w symulatorze Icarus Verilog pokazano na listingu 4. Wystarczy dodać ten skrypt do Lattice Diamond i uruchomić go.
@echo off
cd impl1
cd source
iverilog -o lcd.o lcd.v lcd_tb.v lcd_pwm.v strobe_generator.v
vvp lcd.o
del lcd.o
pause
W wyniku symulacji dostajemy plik lcd.vcd. Należy go otworzyć. Uruchomi się przeglądarka GTKWave. Omawialiśmy ją dokładnie w 12 odcinku kursu. Dodaj i skonfiguruj wszystkie sygnały tak, aby uzyskać efekt pokazany na rysunku 7. W razie potrzeby gotowy plik GTWK z kompletną analizą jest dostępny w plikach z kodem źródłowym.
Zmienna State_r prezentuje stan sterownika wyświetlacza. Dla poprawy czytelności wykresu warto zmienić wartości liczbowe od 0 do 7 na etykiety tekstowe, które jasno i czytelnie będą nam mówić, w jakim stanie znajduje się maszyna stanów. W tym celu musimy utworzyć dodatkowy plik z etykietami. Nazwijmy go lcd-state.gtkw. Treść tego pliku pokazano na listingu 5.
0 COM_0H
1 COM_1H
2 COM_2H
3 COM_3H
4 COM_0L
5 COM_1L
6 COM_2L
7 COM_3L
Aby wyświetlić etykiety stanów, zmienną State_r w okienku Signals klikamy prawym przyciskiem myszy, a następnie wybieramy opcje Data Format, następnie Translate Filter File i ostatecznie Enable and Select. Pojawi się okienko w którym klikamy przycisk Add filter to list i wskazujemy plik lcd-state.gtkw.
Ścieżka do pliku pojawi się w okienku. Należy ją kliknąć, aby była podświetlona (inaczej nie będzie działać!) po czym możemy kliknąć OK. Nic się nie zmieniło! Musimy jeszcze raz kliknąć prawym przyciskiem myszy zmienną State_r w okienku Signals, po czym wybieramy jeszcze raz Data Format i tym razem klikamy Enum (autorowi programu należą się szczere gratulacje za prosty i intuicyjny interfejs).
Zwróć uwagę na zmienną SegPWM_o[3]. Jest to stan na wyprowadzeniu FPGA, który steruje elektrodą SEG3 wyświetlacza poprzez filtr RC. W stanie COM1_H oraz COM1_L ten sygnał wygląda zupełnie inaczej niż wszystkie inne. To jest właśnie sygnał, który powoduje zaczernienie segmentu E cyfry 1. Wynika on z ustawienia piątego bitu zmiennej Digit1_r (listing 3, linia 2). Zgodnie z rysunkiem 1 segment E1 leży na skrzyżowaniu COM1 oraz SEG3. Spróbuj zmodyfikować testbench w taki sposób, by zaczernić inne segmenty. Przeprowadź ponowną symulację i załaduj ponownie dane do GTKWave używając przycisku Reload (pierwszy od prawej w górnym pasku narzędzi).
Moduł top
Pozostaje już tylko sporządzić moduł nadrzędny top. Jego kod widzimy na listingu 6. W module top umieścimy instancję generatora sygnału zegarowego, sterownika wyświetlacza oraz prosty licznik 16-bitowy tylko po to, by móc pokazywać na wyświetlaczu LCD jakieś zmieniające się liczby. Testowy licznik będzie inkrementowany co 0,1 sekundy przy pomocy modułu StrobeGenerator, który wielokrotnie wykorzystywaliśmy w innych aplikacjach. Licznik ma 16-bitów, więc na każdą cyfrę przypadają 4 bity, w których zapisana jest binarnie wartość szesnastkowa od 0 do F. Trzeba ją będzie przekształcić na kod segmentowy przy pomocy czterech dekoderów Decoder7seg, które opracowaliśmy w odcinku na temat wyświetlacza LED.
// Plik top.v
`default_nettype none
module top(
input wire Reset,
output wire [3:0] ComPWM_o, // 1
output wire [7:0] SegPWM_o // 2
);
// Generator sygnału zegarowego
parameter CLOCK_HZ = 14_000_000;
wire Clock;
OSCH #( // 3
.NOM_FREQ("14.00")
) OSCH_inst(
.STDBY(1’b0),
.OSC(Clock),
.SEDSTDBY()
);
// Generator impulsów inkrementujących testowy licznik
wire CountEnable_w; // 4
StrobeGenerator #(
.CLOCK_HZ(CLOCK_HZ),
.PERIOD_US(100_000) // 5
) CountEnable_m(
.Clock(Clock),
.Reset(Reset),
.Strobe_o(CountEnable_w)
);
// Testowy licznik
reg [15:0] Counter_r; // 6
always @(posedge Clock, negedge Reset) begin
if(!Reset)
Counter_r <= 0;
else if(CountEnable_w) // 7
Counter_r <= Counter_r + 1’b1;
end
// Dekoder wyświetlacza – cyfra 0
wire [6:0] Segments0_w;
Decoder7seg Decoder0( // 8
.Enable_i(1’b1),
.Data_i(Counter_r[3:0]),
.Segments_o(Segments0_w)
);
// Dekoder wyświetlacza – cyfra 1
wire [6:0] Segments1_w;
Decoder7seg Decoder1(
.Enable_i(1’b1),
.Data_i(Counter_r[7:4]),
.Segments_o(Segments1_w)
);
// Dekoder wyświetlacza – cyfra 2
wire [6:0] Segments2_w;
Decoder7seg Decoder2(
.Enable_i(1’b1),
.Data_i(Counter_r[11:8]),
.Segments_o(Segments2_w)
);
// Dekoder wyświetlacza – cyfra 3
wire [6:0] Segments3_w;
Decoder7seg Decoder3(
.Enable_i(1’b1),
.Data_i(Counter_r[15:12]),
.Segments_o(Segments3_w)
);
// Instancja sterownika wyświetlacza LCD
LCD #(
.CLOCK_HZ(CLOCK_HZ),
.CHANGE_COM_US(5000) // 9
) LCD_inst(
.Clock(Clock),
.Reset(Reset),
.Digit3_i({1’b0, Segments3_w}), // 10
.Digit2_i({1’b0, Segments2_w}),
.Digit1_i({1’b0, Segments1_w}),
.Digit0_i({1’b0, Segments0_w}),
.ComPWM_o(ComPWM_o),
.SegPWM_o(SegPWM_o)
);
endmodule
`default_nettype wire
Na liście portów modułu top mamy jedynie wejście resetujące oraz wyjścia elektrod COM (linia 1) i elektrod SEG (linia 2), które należy połączyć do filtrów RC, które przekształcają sygnał PWM na sygnał analogowy, wymagany przez wyświetlacz.
Używamy wbudowanego generatora sygnału zegarowego OSCH, pracującego z częstotliwością 14 MHz (linia 3). Był już omawiany w poprzednich odcinkach kursu, więc nie będziemy go tutaj omawiać jeszcze raz.
Następnie mamy instancję modułu StrobeGenerator, która została skonfigurowana tak, by dawać sygnał do inkrementacji testowego licznika co 100000 mikrosekund, czyli 0,1 sekundy (linia 5). Cyklicznie, co taki czas, generator będzie ustawiał swoje wyjście w stan wysoki na jeden takt zegara. Wyjście generatora jest połączone zmienną wire CountEnable_w (linia 4) z warunkiem if, który sprawdzany jest w linii 7 – kiedy warunek jest prawdziwy to następuje zwiększenie testowego licznika Counter_r o 1.
Dalej widzimy cztery instancje dekoderów, przekształcających 4-bitową liczbę binarną na 7-bitowy kod wyświetlacza 7-segmentowego. Na wejściu każdego z dekoderów mamy 4-bitowe „fragmenty” 16-bitowego licznika Counter_r, a ich wyjścia są podłączone do wejść danych sterownika wyświetlacza poprzez 7-bitowe zmienne SegmentsX_w typu wire.
W linii 10 i kilku kolejnych widzimy wejścia sterownika wyświetlacza. Jednak są to wejścia 8-bitowe, które przyjmują informacje o segmentach w kolejności PGFEDCBA. Segment P to kropka lub dwukropek. Dekodery 7-segmentowe dostarczają tylko informacji o segmentach od A do G. Z tego powodu przy pomocy operatora sklejania { } do danych pochodzących z dekoderów dokleiliśmy zero na początku, aby nie wyświetlać kropek ani dwukropków. Zmień te zera na jedynki, jeżeli chcesz zaczernić kropki albo dwukropek.
W linii 9 ustawiamy czas trwania każdego z ośmiu stanów wyświetlacza. W naszym przykładzie ten czas jest ustawiony na 5 ms. Spróbuj zmienić tę liczbę na dużo większą, aby dało się zaobserwować, w jaki sposób sterownik załącza kolejne segmenty.
Pozostaje już tylko zsyntezować, przypisać piny w Spreadsheet według numeracji, jaką pokazano na rysunku 5, wgrać do FPGA i zobaczyć jak działa wyświetlacz LCD.
W następnym odcinku nauczymy się generować dźwięki, a żeby nie było monotonnie – poznamy jak działają bloki pamięci EBR wbudowane w MachXO2, w których będą zapisane informacje o częstotliwościach dźwięków oraz jak długo mają trwać. Zbudujemy prosty odtwarzacz muzyczny o możliwościach kompozytora dzwonków z czasów Nokii 3310.
Dominik Bieczyński
leonow32@gmail.com