Kurs FPGA Lattice (20). 14-segmentowy wyświetlacz LCD

Kurs FPGA Lattice (20). 14-segmentowy wyświetlacz LCD

Wyświetlacze 14-segmentowe umożliwiają pokazanie wszystkich cyfr, liter, nawiasów, znaków interpunkcyjnych i wielu innych. Produkowane są w technologiach LED oraz LCD. W tym odcinku kursu opracujemy prostą aplikację, która będzie odbierać znaki ASCII poprzez interfejs UART, aby pokazywać je na 14-segmentowym wyświetlaczu LCD.

Pierwszy raz z technologią wyświetlaczy LCD spotkaliśmy się w 13 odcinku kursu (EP 2023/11) – opracowaliśmy w nim moduł obsługujący 4-cyfrowy wyświetlacz 7-segmentowy. Opisywaliśmy wówczas dokładnie podstawy funkcjonowania takiego komponentu, a w szczególności sposób multipleksacji wyświetlacza, który ma cztery wyprowadzenia wspólne COM, a wszystkie jego elektrody steruje się czterema poziomami napięć. Gorąco zachęcam do odświeżenia informacji o podstawach multipleksacji LCD przed przeczytaniem niniejszego odcinka, ponieważ przebiega ona zupełnie inaczej niż w przypadku wyświetlaczy z diodami LED.

W naszym projekcie zastosujemy płytkę Segment14, opisaną w tym samym numerze „Elektroniki Praktycznej”. Współpracuje ona z płytką deweloperską MachXO2 Mega, używaną w poprzednich odcinkach kursu i zaprezentowaną w EP 2023/09. Można ją nabyć w sklepie AVT.

Na rysunku 1 zestawiono oznaczenia wszystkich segmentów w wyświetlaczu 14-segmentowym. Stanowią one standard przyjęty przez większość producentów, ale można spotkać różne nomenklatury w zakresie segmentu przecinka (punktu dziesiętnego) – czasem oznaczany jest literami DP od angielskiego decimal point, a innym razem po prostu P.

Rysunek 1. Oznaczenia segmentów wyświetlacza 14-segmentowego

Zaprezentowane powyżej ułożenie segmentów umożliwia wyświetlanie znaków widocznych na rysunku 2. Choć wyświetlacz 14-segmentowy jest „wstecznie kompatybilny” z panelem 7-segmentowym, to można użyć dodatkowych segmentów, aby cyfry 1, 3 i 7 upodobnić do naturalnego kształtu tych znaków. Istnieje także możliwość wyświetlania niektórych małych liter, znaków interpunkcyjnych i wybranych symboli matematycznych.

Rysunek 2. Znaki możliwe do wyświetlenia

Na płytce testowej zastosowano wyświetlacz VIM-828-DP13.2-RC-S-LV firmy Varitronix – o bardzo klasycznej topologii, przystosowany do pokazywania 8 znaków, bez żadnych dodatkowych ikonek i innych bajerów. Komponent ten nie ma wbudowanego sterownika, więc musimy opracować go sami. Działa on w trybie:

  • 1/4 duty, co oznacza, że w danej chwili możliwe jest zaczernienie 1/4 segmentów, a więc sam wyświetlacz ma cztery elektrody wspólne COM,
  • 1/3 bias, co znaczy, że napięcie doprowadzone do elektrod stopniuje się co 1/3 maksymalnej wartości napięcia zasilającego, a dokładniej są to napięcia równe 0 V, 1 V, 2 V oraz 3 V (części ułamkowe zaokrągliłem).

Zobaczmy na rysunku 3, jak wygląda schemat wewnętrzny tego wyświetlacza.

Rysunek 3. Schemat wewnętrzny wyświetlacza VIM-828

Model VIM-828 ma 36 elektrod, z czego 4 to elektrody wspólne COM, a 32 – elektrody sterujące segmentami. Multipleksacja wyświetlaczy LCD, w przeciwieństwie do wyświetlaczy LED, jest niestety dość mocno skomplikowana i wymaga zastanowienia się przez dłuższą chwilę, jak w ogóle opanować obsługę takiego ekranu.

Dla ułatwienia elektrody COM oraz odpowiadające im segmenty zaznaczono kolorami:

  • Niebieski – COM0,
  • Zielony – COM1,
  • Żółty – COM2,
  • Czerwony – COM3.

W każdej chwili aktywna jest tylko jedna z czterech elektrod COM, a to umożliwia zaczernienie tylko segmentów zaznaczonych kolorem odpowiadającym aktywnej elektrodzie wspólnej. Zaczernione zostaną jedynie te segmenty, których elektrody są aktywne, na przykład: aby włączyć segment A znaku nr 7, musimy jednocześnie aktywować elektrody COM0 oraz 7ABCP. Wszystkie pozostałe segmenty pozostaną niewidoczne.

Poprzez pojęcie „aktywny” rozumiemy stan, w którym do elektrody doprowadzamy odpowiednie napięcie. Różnica napięć między aktywną parą elektrody COM i elektrody segmentów wynosi ±3 V, a między nieaktywnymi elektrodami przyjmuje wartość ±1 V. Zjawisko to zostało dokładniej opisane w 13 odcinku kursu – wraz z ukazaniem dokładnych przebiegów napięć na elektrodach wyświetlacza.

Preprocesor

Poznamy nową funkcjonalność języka Verilog, z której jeszcze nie korzystaliśmy. Jest nią preprocesor (choć bardziej prawidłowo powinniśmy używać nazwy „dyrektywy kompilatora”). Programiści języka C i C++ polubią tę opcję, ponieważ preprocesor w Verilogu działa bardzo podobnie.

Poniżej wypisano najczęściej stosowane dyrektywy preprocesora:

  • `define ZMIENNA – zdefiniowanie zmiennej bez wartości,
  • `define ZMIENNA WARTOŚĆ – zdefiniowanie zmiennej i przypisanie do niej jakiejś wartości,
  • `undef ZMIENNA – kasowanie definicji zmiennej,
  • `include „plik.v” – dołączenie pliku.

Zwróć uwagę, że wszystkie dyrektywy poprzedzone zostały znakiem ukośnego apostrofu, w przeciwieństwie do C i C++, w których stosuje się znak #.

Istotną różnicą względem C i C++ jest to, że w celu odczytania wartości zdefiniowanej zmiennej musimy jej nazwę poprzedzić ukośnym apostrofem. Takie zmienne możemy wtedy stosować w kodzie tak samo, jak stałe określone parametrami.

Istnieje możliwość tworzenia instrukcji warunkowych. Przykłady takich operacji zaprezentowano na listingu 1. Na marginesie dodam, że tym, co w języku Verilog zawsze mnie dziwiło, jest brak najprostszej instrukcji warunkowej `if. Można sprawdzać tylko, czy jakaś zmienna została lub nie została zdefiniowania, natomiast nie można w żaden sposób sprawdzać jej wartości.

// Zdefiniowanie zmiennej A
`define A

// Sprawdzanie, czy zmienna jest zdefiniowana
`ifdef A
// Ten blok zostanie wykonany
`else
// Ten blok zostanie pominięty
`endif

// Sprawdzanie czy zmienna nie jest zdefiniowania
`ifndef A
// Ten blok zostanie pominięty
`else
// Ten blok zostanie wykonany
`endif

Listing 1. Przykłady instrukcji warunkowych

Jak już wiemy, wyświetlacz VIM-828 ma 36 pinów, a każdy ze znaków ma 14 segmentów plus kropkę. Oczywiście można by napisać sterownik tego wyświetlacza i posługiwać się w kodzie numerami wyprowadzeń oraz numerycznymi pozycjami segmentów w taki sposób, jak zrobiliśmy to, pisząc kod 4-cyfrowego wyświetlacza 7-segmentowego. Przy tak prostym wyświetlaczu było to jeszcze akceptowalnie wygodne, ale przy bardziej złożonym lepiej będzie przypisać wszystkim pinom jakieś etykiety tekstowe, aby uniknąć pomyłki. Użyjemy w tym celu definicji preprocesora.

Na potrzeby definicji utworzymy osobny plik o nazwie vim828_defines.v, którego kod pokazano na listingu 2. Zaletę tego rozwiązania stanowi fakt, że definicje – w przeciwieństwie do parametrów – są globalne i można je stosować we wszystkich plikach projektu.

// Plik vim828_defines.v
`ifndef VIM828_DEFINES_V // 1
`define VIM828_DEFINES_V // 2

// Numery pinów wyświetlacza i ich etykiety
`define COM0 19 // 3
`define COM1 36
`define COM2 18
`define COM3 1

`define SEG0_ABCP 20
`define SEG0__FED 16
`define SEG0_IJKN 17
`define SEG0_HGLM 21

`define SEG1_ABCP 22
`define SEG1__FED 14
`define SEG1_IJKN 15
`define SEG1_HGLM 23

`define SEG2_ABCP 24
`define SEG2__FED 12
`define SEG2_IJKN 13
`define SEG2_HGLM 25

`define SEG3_ABCP 26
`define SEG3__FED 10
`define SEG3_IJKN 11
`define SEG3_HGLM 27

`define SEG4_ABCP 28
`define SEG4__FED 8
`define SEG4_IJKN 9
`define SEG4_HGLM 29

`define SEG5_ABCP 30
`define SEG5__FED 6
`define SEG5_IJKN 7
`define SEG5_HGLM 31

`define SEG6_ABCP 32
`define SEG6__FED 4
`define SEG6_IJKN 5
`define SEG6_HGLM 33

`define SEG7_ABCP 34
`define SEG7__FED 2
`define SEG7_IJKN 3
`define SEG7_HGLM 35

// Numery segmentów
`define BIT_A 0 // 4
`define BIT_B 1
`define BIT_C 2
`define BIT_D 3
`define BIT_E 4
`define BIT_F 5
`define BIT_G 6
`define BIT_H 7
`define BIT_I 8
`define BIT_J 9
`define BIT_K 10
`define BIT_L 11
`define BIT_M 12
`define BIT_N 13
`define BIT_P 14

// Napięcie aktywnych i nieaktywnych
// elektrod COM i SEG
`define COM_H_ACTIVE 2’d3 // 5
`define COM_H_PASSIVE 2’d1
`define SEG_H_ACTIVE 2’d0
`define SEG_H_PASSIVE 2’d2

`define COM_L_ACTIVE 2’d0
`define COM_L_PASSSIVE 2’d2
`define SEG_L_ACTIVE 2’d3
`define SEG_L_PASSIVE 2’d1

`endif // VIM828_DEFINES_V // 6

Listing 2. Kod pliku vim828_defines.v

Podobnie jak w C i C++, definicje nie mogą się powtarzać. Do takiej nieprawidłowej sytuacji mogłoby dojść wtedy, gdy plik z definicjami byłby włączany za pomocą dyrektywy `include przez dwa lub większą liczbę modułów. Aby uniknąć takiego błędu, należy zastosować mechanizm include guard, który działa identycznie jak w C.

Plik rozpoczynamy sprawdzeniem, czy nie została zdefiniowana zmienna VIM828_DEFINES_V (linia 1). Zmienna celowo przypomina nazwę pliku. Jeżeli okaże się, że jest zdefiniowana, bo plik został już wcześniej włączony do syntezy, to natychmiast przechodzimy do instrukcji `endif, znajdującej się na końcu pliku (linia 6). W takiej sytuacji cała treść pliku ulega zignorowaniu, ponieważ został on przetworzony już wcześniej.

Natomiast jeżeli zmienna nie jest zdefiniowana, czyli warunek logiczny pozostaje spełniony, wówczas definiujemy ją w linii 2. Nie przypisujemy żadnej wartości – po prostu definiujemy pustą zmienną, a następnie analizujemy cały plik.

Na początek określmy piny wyświetlacza oraz ich numery, zaczynając od linii 3. Informacje te pochodzą z datasheetu wyświetlacza. Dalej, w linii 4, mamy numerację segmentów, a w linii 5 – napięcia (w woltach), jakie należy doprowadzić do poszczególnych elektrod wyświetlacza, by je uaktywnić lub nie.

Moduł VIM828

Przejdźmy teraz do omówienia głównego modułu wyświetlacza, którego kod pokazano na listingu 3. Moduł rozpoczynamy od parametrów. CLOCK_HZ to oczywiście częstotliwość sygnału zegarowego, doprowadzonego do wejścia Clock. Drugi parametr CHANGE_COM_US (linia 1) określa, jak długo ma trwać odstęp czasowy pomiędzy przełączeniami elektrod wspólnych COM. Niestety producent zapomniał o podaniu wspomnianego parametru w dokumentacji (?!) i trzeba było go wyznaczyć eksperymentalnie. Czas ten nie może być zbyt długi, ponieważ wtedy widać przełączanie segmentów. Nie może być też zbyt krótki, bo zużycie energii istotnie rośnie. Podczas eksperymentów stwierdziłem, że wartość rzędu 1000 μs daje optymalny wynik.

// Plik vim828.v

`default_nettype none
module VIM828 #(
parameter CLOCK_HZ = 10_000_000,
parameter CHANGE_COM_US = 1000 // 1
)(
input wire Clock,
input wire Reset,

// Znak pierwszy z prawej ma numer 0
// Znak pierwszy z lewej ma numer 7
// Kolejność segmentów = NMLKJIHGFEDCBA
input wire [13:0] Segments7_i, // 2
input wire [13:0] Segments6_i,
input wire [13:0] Segments5_i,
input wire [13:0] Segments4_i,
input wire [13:0] Segments3_i,
input wire [13:0] Segments2_i,
input wire [13:0] Segments1_i,
input wire [13:0] Segments0_i,

input wire [ 7:0] DecimalPoints_i, // 3

// Wyjście do pinów wyświetlacza
// Każde z wyjść musi mieć filtr RC, aby wygładzić PWM
output wire [36:1] Pin_o // 4
);

// Join together segments data with decimal points
wire [14:0] Bitmap7 = {DecimalPoints_i[7], Segments7_i}; // 5
wire [14:0] Bitmap6 = {DecimalPoints_i[6], Segments6_i};
wire [14:0] Bitmap5 = {DecimalPoints_i[5], Segments5_i};
wire [14:0] Bitmap4 = {DecimalPoints_i[4], Segments4_i};
wire [14:0] Bitmap3 = {DecimalPoints_i[3], Segments3_i};
wire [14:0] Bitmap2 = {DecimalPoints_i[2], Segments2_i};
wire [14:0] Bitmap1 = {DecimalPoints_i[1], Segments1_i};
wire [14:0] Bitmap0 = {DecimalPoints_i[0], Segments0_i};

// Generator PWM by wygenerować napięcia
// równe 0, 1/3, 2/3 i 1 napięcia zasilającego
wire [3:0] Voltage; // 6
VIM828_PWM VIM828_PWM_inst( // 7
.Clock(Clock),
.Reset(Reset),
.Voltage0_o(Voltage[0]), // 0V
.Voltage1_o(Voltage[1]), // 1V
.Voltage2_o(Voltage[2]), // 2V
.Voltage3_o(Voltage[3]) // 3V
);

// Generator sygnałów przełączających stan maszyny
wire ChangeState; // 8

StrobeGenerator #( // 9
.CLOCK_HZ(CLOCK_HZ),
.PERIOD_US(CHANGE_COM_US)
) StrobeGenerator_inst(
.Clock(Clock),
.Reset(Reset),
.Enable_i(1’b1),
.Strobe_o(ChangeState) // 10
);

// Maszyna stanów
reg [2:0] State /* synthesis syn_encoding = "sequential" */; // 11
localparam [2:0] COM_0H = 3’d0;
localparam [2:0] COM_1H = 3’d1;
localparam [2:0] COM_2H = 3’d2;
localparam [2:0] COM_3H = 3’d3;
localparam [2:0] COM_0L = 3’d4;
localparam [2:0] COM_1L = 3’d5;
localparam [2:0] COM_2L = 3’d6;
localparam [2:0] COM_3L = 3’d7;

// Zmiana stanu maszyny
always @(posedge Clock, negedge Reset) begin // 12
if(!Reset)
State <= 0;
else if(ChangeState) // 13
State <= State + 1’b1; // 14
end

// A matrix of 36 elements that are 2-bit variables
reg [1:0] PinVoltage[36:1]; // 15

// Part H
// – Active COM: 3
// – Inactive COM: 1
// – Active SEG: 0
// – Inactive SEG: 2
// Part L
// – Active COM: 0
// – Inactive COM: 2
// – Active SEG: 3
// – Inactive SEG: 1

// Logika wyświetlacza
always @(*) begin // 16
case(State)
COM_0H: begin
PinVoltage[`COM0] = `COM_H_ACTIVE; // 17
PinVoltage[`COM1] = `COM_H_PASSIVE;
PinVoltage[`COM2] = `COM_H_PASSIVE;
PinVoltage[`COM3] = `COM_H_PASSIVE;

PinVoltage[`SEG0_ABCP] = Bitmap0[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE; // 18
PinVoltage[`SEG0__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG0_IJKN] = Bitmap0[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG0_HGLM] = Bitmap0[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG1_ABCP] = Bitmap1[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG1__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG1_IJKN] = Bitmap1[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG1_HGLM] = Bitmap1[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG2_ABCP] = Bitmap2[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG2__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG2_IJKN] = Bitmap2[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG2_HGLM] = Bitmap2[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG3_ABCP] = Bitmap3[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG3__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG3_IJKN] = Bitmap3[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG3_HGLM] = Bitmap3[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG4_ABCP] = Bitmap4[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG4__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG4_IJKN] = Bitmap4[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG4_HGLM] = Bitmap4[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG5_ABCP] = Bitmap5[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG5__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG5_IJKN] = Bitmap5[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG5_HGLM] = Bitmap5[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG6_ABCP] = Bitmap6[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG6__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG6_IJKN] = Bitmap6[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG6_HGLM] = Bitmap6[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;

PinVoltage[`SEG7_ABCP] = Bitmap7[`BIT_A] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG7__FED] = `SEG_H_PASSIVE;
PinVoltage[`SEG7_IJKN] = Bitmap7[`BIT_I] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
PinVoltage[`SEG7_HGLM] = Bitmap7[`BIT_H] ? `SEG_H_ACTIVE : `SEG_H_PASSIVE;
end

// (...kompletny plik znajduje się w materialach dodatkowych na ep.com.pl...)



endcase
end

// Przypisanie wyjść
generate // 19
genvar i; // 20
for(i=1; i<=36; i=i+1) begin // 21
assign Pin_o[i] = Voltage[PinVoltage[i]]; // 22
end
endgenerate

endmodule
`default_nettype wire

Listing 3. Kod pliku vim828.v

Następnie widzimy osiem 14-bitowych wejść SegmentsX_i, które mają sterować segmentami wyświetlacza (linia 2). Cyfra X oznacza numer znaku, gdzie 0 to znak pierwszy z prawej, a 7 – pierwszy z lewej. Wejścia te zorganizowane są w taki sposób, że najmłodszy bit steruje segmentem A. Kolejne bity sterują segmentami B, C, i tak dalej, aż do najstarszego bitu, sterującego segmentem N. Jedynka na wejściu powoduje zaczernienie segmentu, odpowiadającego danemu bitowi, natomiast zero sprawia, że segment staje się niewidoczny.

W linii 3 mamy 8-bitowe wejście DecimalPoints_i. Jak można się spodziewać, steruje ono przecinkami. Każdy bit tego wejścia odpowiada za jeden z ośmiu punktów dziesiętnych.

Moduł ma tylko jedno 36-bitowe wyjście Pin_o (linia 4). Zwróć uwagę, że bity tego wyjścia ponumerowane są od 1 do 36. Zwykle numerujemy je od zera, jednak nie zawsze stanowi to regułę. W tym przypadku chcemy podłączyć wyświetlacz do FPGA, a w dokumentacji jego piny ponumerowane są od 1. Aby nie wprowadzać zbędnego zamieszania, oznaczymy zatem wyjścia modułu tak, jak zostały one nazwane w dokumentacji wyświetlacza.

Osobne wejścia segmentów znaków oraz punktów dziesiętnych mają na celu ułatwienie komunikacji modułu wyświetlacza z innymi elementami systemu, a w szczególności z dekoderem znaków 14-segmentowych. W kolejnym odcinku opracujemy moduł, który będzie przekształcał 8-bitowe kody ASCII na kod wyświetlacza 14-segmentowego. Przecinki będzie można wówczas podświetlać niezależnie od tego, jakie znaki ASCII trafią na wyświetlacz.

Jednak wewnątrz modułu sterownika wyświetlacza wygodniej będzie mieć wszystkie segmenty i przecinki zgrupowane. Przecinek stanowi de facto piętnasty segment. Z tego powodu w linii 5 i kolejnych tworzymy osiem 15-bitowych zmiennych wire BitmapX, które powstają ze sklejenia ze sobą odpowiadających sobie wejść przecinków i segmentów. Zaletę tego rozwiązania docenimy, analizując główny blok always opisywanego modułu.

W dalszej części projektu musimy utworzyć generator czterech napięć, sterujących elektrodami wyświetlacza. Zastosujemy identyczne rozwiązanie, jak w 13 odcinku kursu: w linii 7 tworzymy instancję modułu VIM828_PWM, którego zadanie polega na wygenerowaniu czterech sygnałów PWM o współczynnikach wypełnienia 0%, 33%, 66% i 100%. Takie sygnały – po przejściu przez filtry RC – dadzą napięcia 0 V, 1 V, 2 V oraz 3 V. Aby ułatwić sobie dostęp do nich, utworzymy 4-bitową zmienną Voltage typu wire (linia 6). Skorzystamy z następującej zależności: numer indeksu wskazanego bitu tej zmiennej, podawany w nawiasach kwadratowych Voltage[X], jest taki sam, jak napięcie w woltach po przefiltrowaniu.

Kodu modułu VIM828_PWM nie będziemy analizować, ponieważ okazuje się on bardzo prosty (odsyłam do odcinka 13), a jego kod widnieje na listingu 4.

// Plik vim828_pwm.v

`default_nettype none
module VIM828_PWM(
input wire Clock,
input wire Reset,
output wire Voltage0_o, // Współczynnik wypełnienia 0%
output wire Voltage1_o, // Współczynnik wypełnienia 33%
output wire Voltage2_o, // Współczynnik wypełnienia 66%
output wire Voltage3_o // Współczynnik wypełnienia 100%
);

// Prosta maszyna stanów
reg [1:0] State /* synthesis syn_state_machine = 1 */;

always @(posedge Clock, negedge Reset) begin
if(!Reset)
State <= 2’b00;
else if(State == 2’b00)
State <= 2’b01;
else if(State == 2’b01)
State <= 2’b11;
else
State <= 2’b00;
end

// Przypisanie wyjść
assign Voltage0_o = 1’b0;
assign Voltage1_o = State[1];
assign Voltage2_o = State[0];
assign Voltage3_o = 1’b1;

endmodule

`default_nettype wire

Listing. 4. Kod pliku vim828_pwm.v

Wyświetlacz ma cztery elektrody COM, więc jego pracę trzeba podzielić na osiem stanów, następujących kolejno po sobie (zobacz rysunki 2 i 3 z odcinka 13 kursu w EP 2023/11). Każdy z tych stanów ma trwać przez czas określony w parametrze CHANGE_COM_US (linia 1). Do cyklicznego generowania sygnałów przełączających zastosujemy dobrze znany moduł StrobeGenerator, którego instancję tworzymy w linii 9. Wyjście Strobe_o łączymy ze zmienną ChangeState typu wire, utworzoną w linii 8 – będzie ona używana przez logikę maszyny stanów.

Rejestr maszyny stanów State tworzymy w linii 11, a poniżej definiujemy wszystkie osiem możliwych stanów.

W linii 12 rozpoczynamy pierwszy blok always. Jest to prosty blok sekwencyjny, którego jedyny cel to sprawdzanie, czy zmienna ChangeState została ustawiona w stan wysoki (linia 13). Jeżeli tak, to inkrementujemy licznik stanu maszyny (linia 14).

W linii 15 tworzymy tablicę PinVoltage, która przechowywać będzie napięcie, jakie należy dostarczyć do każdego pinu wyświetlacza. Tablica składa się z 36 elementów, ponumerowanych od 36 do 1 (kolejność numeracji nie ma znaczenia, może być odwrotna), w sposób odpowiadający oznaczeniom elektrod wyświetlacza. Każdy element ma tylko dwa bity, w których może przechowywać liczbę od 0 do 3, określającą napięcie w woltach doprowadzone do każdej elektrody.

W linii 16 rozpoczynamy wielki blok always, czyli blok logiki kombinacyjnej, niezależnej od zegara i resetu, którego jedynym celem jest określenie napięć na elektrodach wyświetlacza na podstawie danych wejściowych i zapisanie wyników w tablicy PinVoltage. Koncepcja działania tego kodu jest identyczna jak w odcinku 13, lecz jej realizacja okaże się inna.

Blok always zawiera instrukcję case, opisującą komplet ośmiu możliwych stanów maszyny. Wszystkie linie kodu są w miarę podobne. Najpierw przypisujemy napięcia do elektrod wspólnych COM, a potem do elektrod segmentów.

Weźmy pod lupę linię 17. To, do którego elementu tablicy zamierzamy wpisać dane, określamy w nawiasach kwadratowych za pomocą definicji `COM0, pod którą kryje się liczba 19, czyli numer pinu wyświetlacza o nazwie COM0. Do tego elementu wpisujemy wartość definicji `COM_H_ACTIVE, czyli liczbę 3. Taka operacja spowoduje doprowadzenie napięcia 3 V do pinu 19 wyświetlacza.

Przejdźmy do linii 18. Kod podzielony został na fragmenty zgrupowane po cztery linie, ponieważ każdym znakiem sterują cztery elektrody segmentów. Etykiety pinów segmentów nazwano w taki sposób, aby dało się łatwo rozszyfrować, który segment w danej chwili jest konfigurowany. Wyjaśnienie oznaczeń zaprezentowano na rysunku 4.

Rysunek 4. Wyjaśnienie oznaczeń używanych w kodzie

W ten sposób wypełniamy tablicę PinVoltage, w której określamy napięcie wszystkich 36 elektrod wyświetlacza.

Pozostaje już tylko przypisać wyjścia modułu. Moglibyśmy zrobić to podobnie, jak w odcinku z wyświetlaczem 7-segmentowym, czyli napisać 36 podobnych przypisań w sposób ukazany poniżej:

assign Pin_o[i] = Voltage[PinVoltage[i]];

gdzie w miejsce i wstawiamy liczby od 1 do 36. Oczywistym pomysłem, ułatwiającym pisanie kodu, byłoby zastosowanie pętli for (linia 21). Jednak nie możemy tego uczynić tak po prostu, jakbyśmy pisali kod w C++. Wewnątrz pętli for tworzymy nowe przypisania, zatem samą pętlę musimy umieścić wewnątrz bloku generate (linia 19). Iterator pętli for, wykonującej się wewnątrz bloku generate, musi być zmienną typu genvar. Iterator pętli deklarujemy w linii 20.

Testbench modułu VIM828

Zgodnie z naszym zwyczajem, przed wygenerowaniem bitstreamu i wgraniem go do FPGA, przetestujemy nasz nowy moduł w symulatorze. Podczas symulacji będziemy chcieli zaczernić segment L znaku numer 6, a wszystkie pozostałe segmenty mają być niewidoczne. Kod testbencha pokazano na listingu 5.

// Kod pliku

`timescale 1ns/1ns
`default_nettype none

module VIM828_tb();

parameter CLOCK_HZ = 1_000_000;
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;

// Instancja testowanego modułu
VIM828 #( // 1
.CLOCK_HZ(CLOCK_HZ),
.CHANGE_COM_US(50) // 2
) DUT(
.Clock(Clock),
.Reset(Reset),
//NMLKJIHGFEDCBA
.Segments7_i(14’b00000000000000),
.Segments6_i(14’b00100000000000), // Widoczny segment L
.Segments5_i(14’b00000000000000),
.Segments4_i(14’b00000000000000),
.Segments3_i(14’b00000000000000),
.Segments2_i(14’b00000000000000),
.Segments1_i(14’b00000000000000),
.Segments0_i(14’b00000000000000),
.DecimalPoints_i(8’b00000000),
.Pin_o()
);

// Eksport wyników symulacji do pliku
initial begin
$dumpfile("vim828.vcd");
$dumpvars(0, VIM828_tb);
end

// Sekwencja testowa
initial begin
$timeformat(-6, 3, "us", 10);
$display("===== START =====");
$display("CLOCK_HZ = %9d", CLOCK_HZ);

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

// Czekaj poprzez wszystkie 8 stanów
repeat(8) begin // 3
@(posedge DUT.ChangeState);
end

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

endmodule
`default_nettype wire

Listing 5. Kod pliku vim828_tb.v

Testbench jest banalnie prosty – w gruncie rzeczy ogranicza się tylko do utworzenia instancji testowanego modułu, puszczenia jej w ruch i czekania przez jakiś czas.

Instancję modułu VIM828 tworzymy w linii 1. Na potrzeby symulacji przyspieszamy działanie modułu, ustawiając czas przełączania stanów na 50 μs za pomocą parametru CHANGE_COM_US (linia 2). Oczywiście moglibyśmy zasymulować wyświetlacz z rzeczywistym czasem przełączania 1000 μs, ale wtedy symulacja trwałaby dłużej, a plik wynikowy zajmowałby dużo więcej miejsca, co byłoby zupełnie niepotrzebne.

Kilka linii dalej ustalamy, które segmenty mają być widoczne. W naszym przykładzie chcemy zaczernić tylko jeden segment szóstego znaku, więc do wejścia Segments6_i podajemy ciąg zer z tylko jedną jedynką na właściwej pozycji. Oczywiście możemy testować dowolną kombinację widocznych i niewidocznych segmentów.

Maszyna stanów wyświetlacza ma osiem możliwych stanów. Aby zasymulować całą ramkę, musimy osiem razy poczekać na sygnał zmiany stanu. Rozwiązanie to widzimy w linii 3.

Uruchamiamy symulację skryptem, którego kod zaprezentowano na listingu 6 – lub ręcznie wpisujemy te polecenia w konsoli. W celu poprawienia czytelności wprowadziłem znaki ^, które sprawiają, że kolejne linie traktuje się jako jedną. Okazuje się to czytelniejsze niż wypisywanie wszystkich plików w ciągu rozdzielanym spacjami.

@echo off
iverilog -o vim828.o  ^
vim828_defines.v ^
vim828.v ^
vim828_tb.v ^
vim828_pwm.v ^
strobe_generator.v
vvp vim828.o
del vim828.o

Listing 6. Kod pliku vim828.bat

Zwróć uwagę, że plik vim828_defines.v koniecznie musimy umieścić przed vim828.v. Pliki podlegają przetwarzaniu w takiej kolejności, w jakiej wymienione zostaną w skrypcie. Definicje mają zasięg globalny i dostępne są we wszystkich plikach – od momentu, w którym zostaną zdefiniowane! Z tego powodu kolejność plików ma znaczenie.

Otwórz plik wynikowy vim828.vcd w przeglądarce GTKWave i skonfiguruj ją tak, by uzyskać efekt widoczny na rysunku 5.

Rysunek 5. Efekt symulacji modułu wyświetlacza

Widzimy 36 przebiegów PWM, które po przejściu przez filtry RC dadzą napięcie 0, 1, 2 lub 3 V na każdym pinie wyświetlacza.

W celu poprawienia czytelności kolorem pomarańczowym zaznaczyłem przebiegi na pinach 1, 18, 19 i 36 – są to elektrody wspólne COM. Przebiegi na wszystkich pinach segmentów okazują się identyczne, ponieważ wszystkie – z wyjątkiem segmentu L znaku numer 6 – zostały wygaszone. Fragment przebiegów odpowiedzialny za sterowanie tym segmentem zaznaczyłem żółtymi elipsami.

Zweryfikujmy to z rysunkiem 3, pokazującym wewnętrzny schemat połączeń segmentów wyświetlacza. Wszystkie segmenty L sterowane są przez elektrodę COM2 (kolor żółty). Segment L szóstego znaku sterowany jest pinem 6HGLM (o numerze 33). Spójrz teraz na rysunek 6. Sygnał Pin_o[33] wyraźnie zmienia swój stan, kiedy aktywna jest elektroda COM2 – a dokładniej mówiąc, sygnał ten stanowi negację COM2. W takiej sytuacji segment sterowany przez parę elektrod COM2 i 6HGLM zostanie zaczerniony i będzie widoczny.

Rysunek 6. Konfiguracja pinów

Moduł top

Czas na ćwiczenia praktyczne na fizycznej macierzy FPGA. Aplikacja będzie bardzo prosta, a jej jedyne zadanie polegać będzie na wyświetlaniu statycznego napisu KURS.FPGA na wyświetlaczu. Kod modułu top pokazano na listingu 7. Znajduje się w nim tylko instancja generatora sygnału zegarowego oraz modułu wyświetlacza. Na wejścia SegmentsX_i sterownika wyświetlacza podajemy „na sztywno” bitmapy odpowiadające żądanym literom. Spróbuj wyświetlić jakiś inny napis.

// Plik top.v
`default_nettype none

module top(
input wire Reset,
output wire [36:1] PinLCD_o
);

// Generator sygnału zegarowego
parameter CLOCK_HZ = 14_000_000;
wire Clock;
OSCH #(
.NOM_FREQ("14.00")
) OSCH_inst(
.STDBY(1’b0),
.OSC(Clock),
.SEDSTDBY()
);

// Instancja sterownika wyświetlacza LCD
VIM828 #(
.CLOCK_HZ(CLOCK_HZ),
.CHANGE_COM_US(1000)
) VIM828_inst(
.Clock(Clock),
.Reset(Reset),
// NMLKJIHGFEDCBA
.Segments7_i(14’b10001001110000), // K
.Segments6_i(14’b00000000111110), // U
.Segments5_i(14’b10010001110011), // R
.Segments4_i(14’b00010001101101), // S
.Segments3_i(14’b00000001110001), // F
.Segments2_i(14’b00010001110011), // P
.Segments1_i(14’b00010000111101), // G
.Segments0_i(14’b00010001110111), // A
.DecimalPoints_i(8’b00010000),
.Pin_o(PinLCD_o)
);

endmodule

`default_nettype wire

Listing 7. Kod pliku top.v

Uruchom syntezę, a następnie otwórz Spreadsheet i skonfiguruj piny w taki sposób, jak pokazano na rysunku 6.

Generujemy bitstream, wgrywamy do FPGA i naszym oczom ukazuje się napis widoczny na zdjęciu tytułowym niniejszego odcinka kursu. Wyświetlanie statycznego napisu jest jednak nudne, dlatego w następnym odcinku użyjemy odbiornika UART, opracowanego w 19 odcinku cyklu, aby sterować wyświetlaczem przez terminal w komputerze.

Dominik Bieczyński
leonow32@gmail.com

Artykuł ukazał się w
Elektronika Praktyczna
czerwiec 2024
DO POBRANIA
Materiały dodatkowe

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik październik 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio wrzesień - październik 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje wrzesień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna październik 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich październik 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów