În cadrul laboratorului de Arhitectura Calculatoarelor vom studia un limbaj de descriere a hardware-ului (eng. Hardware Description Language - HDL) numit Verilog. Îl vom folosi pe tot parcursul laboratorului pentru a implementa noțiuni legate de arhitectura calculatoarelor. Limbajele de descriere a hardware-ului sunt folosite în industrie pentru proiectarea și implementarea circuitelor digitale. Cele mai folosite limbaje de descriere a hardware-ului sunt Verilog și VHDL.
Deși din punct de vedere sintactic se aseamănă foarte mult cu un limbaj de programare de uz general (C/C++/Java), trebuie ținut cont că instrucțiunile nu se execută secvențial, ca pe un procesor. Ținta unui cod scris în Verilog este implementarea sa pe un FPGA sau dezvoltarea unui ASIC (Application Specific Integrated Circuit).
Un limbaj de descriere hardware conține o serie de abstractizări sau moduri de a genera, prin intermediul codului, porți logice. În comparație cu a proiecta “de mână” circuitele integrate, tocmai aceste abstractizări sunt cele care au permis electronicii digitale să se dezvolte în ritm rapid, odată cu progresul tehnologiei de fabricație. Cu ajutorul lor putem descrie relativ ușor structuri complexe, divizându-le în componentele lor comune și de bază.
Însă apare întrebarea naturală: Ce aș putea face cu un FPGA și nu aș putea face cu un procesor? Pe scurt, există trei răspunsuri:
Întrucât au fost puse în discuție atât proiectarea prin porți logice a unui circuit cât și descrierea lui la un nivel mai abstract, putem clasifica alternative de descriere a unui circuit:
Circuitele logice combinaționale aplică funcții logice pe intrări pentru a obține ieșirile. Valorile de ieșire depind astfel doar de valorile curente de intrare, iar când starea unei intrări se schimbă, se reflectă imediat asupra ieșiri.
Diagrama bloc pentru un circuit combinațional cu n intrări și m ieșiri
Logica combinațională poate fi reprezentată prin:
Spre deosebire de circuitele logice combinaționale, cele secvențiale (eng: sequential logic) nu mai depind exclusiv de valoarea curentă a intrărilor, ci și de stările anterioare ale circuitului. Logica secvențială poate fi de două tipuri: sincronă și asincronă.
Schema bloc a unui circuit secvențial sincron
În primul caz, cel cu care vom lucra și la laborator, este folosit un semnal de ceas care comandă elementul/elementele de memorare, acestea schimbându-și starea doar la impulsurile de ceas. În al doilea caz, ieșirile se modifică atunci când se modifică și intrările, neexistând un semnal de ceas pentru elementele de memorare. Circuitele secvențiale asincrone sunt mai greu de proiectat, pot apărea probleme de sincronizare și sunt folosite mai rar. În continuare ne vom referi doar la circuitele secvențiale sincrone.
Pentru mai multe detalii și exemple privind circuitele combinaționale și secvențiale, studiați Laboratorul 0.
În laboratorul curent ne vom concentra asupra asimilării acestui nou limbaj, începând cu descrierea structurală a unui circuit combinațional.
Atunci când proiectăm un circuit digital folosind un HDL, începem prin a face o descriere textuală a circuitului, adică scriem cod. Acesta este compilat, iar în urma procesului va rezulta un model al circuitului care poate fi apoi rulat într-un simulator cu scopul de a verifica funcționalitatea descrierii. O alternativă la simulare este folosirea unui utilitar de sintetizare, care preia codul HDL și generează fișiere de configurare pentru FPGA.
Însă proiectarea circuitelor poate deveni complexă. Datorită acestui motiv, se preferă proiectarea de tip top-down, o modalitate de partiționare sistematică și repetată a unui sistem complex în unități funcționale mai simple, a căror implementare poate fi făcută mai facil. O partiționare și organizare la nivel înalt a unui sistem reprezintă arhitectura acestuia. Unitățile funcționale individuale ce rezultă în urma partiționării sunt mai ușor de proiectat și de testat decât întregul sistem. Strategia divide-et-impera a proiectării top-down ne permite proiectarea de circuite care conțin milioane de porți.
Modulul este unitatea de bază a limbajului Verilog, element ce încapsulează inferfața și comportamentul unui circuit. Modelul black-box este cel mai apropiat de definiția unui modul, întrucât se cunosc elementele de legătură: intrările și ieșirile din modul precum și funcționalitatea precisă a modulului, cu un accent mai redus asupra detaliilor de implementare și a modului în care acesta funcționează.
Pentru declararea unui modul, se folosesc cuvintele cheie module
și endmodule
. Pe lângă aceste cuvinte cheie, declarația unui modul mai conține:
module my_beautiful_module ( output out, input [3:0] a, input b); /* descrierea funcționalității */ endmodule
Pentru implementarea modului avem la dispoziție câteva elemente, care sunt descrise în ceea ce urmează.
Element ce stă la baza descrierii structurale a circuitelor, primitiva este o funcție asociată unei porți logice de bază. Verilog are o suită de primitive predefinite:
Fiecare primitivă are porturi, prin care este conectată în exterior. Primitivele predefinite oferă posibilitatea conectării mai multor intrări (ex. or, and, xor etc.) sau mai multor ieșiri (ex. buf, not). Folosirea unei primitive se face prin instanțierea sa cu lista de semnale care vor fi conectate la porturile ei. Pentru primitivele predefinite porturile de ieșire sunt declarate înaintea porturilor de intrare.
Tabelul de mai jos oferă câteva exemple de instanțiere a unor porți în Verilog. Pentru primitivele predefinite numele instanței este opțional.
Schema | Cod |
---|---|
or(out, a, b, c); // sau or o1(out, a, b, c); |
|
not(out, in); // sau not my_not(out, in); |
|
nand (z, x, y); // sau nand n(z, x, y); |
Exemple de instanțiere a primitivelor
Un singur modul sau o singură primitivă nu poate îndeplini singură funcția cerută. Astfel, apare necesitatea interconectării modulelor sau a primitivelor. Specificarea semnalelor dintr-o diagramă se face prin wires, care se declară prin cuvântul cheie wire
.
wire y1, y2; xor(out, y1, y2); and(y1, in1, in2); nand(y2, in3, in4, in5); |
În exemplul anterior y1 și y2 sunt semnale de câte 1 bit care leagă ieșirile porților and (y1) și nand (y2) la intrările porții xor.
Pentru a declara semnale pe mai mulți biți se pot folosi vectori precum în declarațiile următoare: m reprezintă un semnal de 8 biți, iar n reprezintă un semnal de 5 biți. Bitul cel mai semnificativ (eng. most significant bit - MSB) este situat întotdeauna în stânga, iar bitul cel mai puțin semnificativ (eng. least significant bit - LSB) în dreapta.
În mod implicit semnalele care nu sunt declarate sunt considerate ca fiind de tip wire și având 1 bit (ex. in1, in2, … din codul de mai sus). Putem accesa individual biții dintr-un wire sau putem accesa un grup consecutiv de biți specificând intervalul (ex. m[0], m[3:1], m[7:2]).
wire[7:0] m; // 8 biti, MSB este bitul 7, LSB bitul 0 wire[0:4] n; // 5 biti, MSB este bitul 0, LSB bitul 4 wire[7:0] a [9:0]; // array multidimensional cu 10 elemente de 8 biti
Deși partiționarea circuitelor în cadrul unei arhitecturii duce la simplificarea implementării unui modul, implementarea acestuia la nivel de porți logice este rareori folosită, întrucât aceasta devine complicată și dificil de înțeles.
Primul pas este reprezentat de ușurarea modalității de scriere a unei funcții logice. Pentru aceasta, Verilog oferă o instrucțiune numită atribuire continuă. Aceasta folosește cuvântul cheie assign
și “atribuie” unei variabile de tip wire
, valoarea expresiei aflată în partea dreaptă a semnului egal. Atribuirea are loc la fiecare moment de timp, deci orice schimbare a valorii expresiei din partea dreaptă se va propaga imediat.
În partea stângă a unei atribuiri continue se poate afla orice variabilă declarată de tip wire sau orice ieșire a modulului care nu are altă declarație (ex. reg). Expresiile din partea dreaptă pot fi formate din orice variabile sau porturi de intrare și de ieșire și orice operatori suportați de Verilog.
Bazându-ne pe circuitul descris în figura de mai jos, acesta se poate scrie sub formă de atribuiri continue în următoarea formă:
wire y1, y2; xor(out, y1, y2); and(y1, in1, in2); nand(y2, in3, in4, in5); |
module my_beautiful_module ( output out, input i1,i2,i3,i4,i5); assign y1 = i1 & i2; assign y2 = ~(i3 & i4 & i5); assign out = y1 ^ y2; endmodule
sau, mai concis:
module my_beautiful_module ( output out, input i1,i2,i3,i4,i5); assign out = (i1 & i2) ^ (~(i3 & i4 & i5)); endmodule
Se poate observa că o atribuire continuă este mult mai ușor de scris, de înțeles și de modificat decât o descriere echivalentă bazată pe instanțierea de primitive. Circuitul descris de o atribuire continuă poate fi însă relativ ușor sintetizat ca o serie de porți logice care implementează expresia dorită, unii operatori având o corespondență directă cu o poartă logică.
Pentru specificarea valorilor întregi este folosită următoarea sintaxă:
[size]['radix] constant_value
8'b1; //binar, pe 8 biti, echivalent cu 1 sau 8'b00000001 8'b1010_0111; //binar, echivalent cu 167 sau 8'b10100111 4'b10; //binar, pe 4 biti, echivalent cu 2 sau 4'b0010 etc. 126; //scriere in decimal 16'habcd; //scriere in hexazecimal
Descrierea comportamentală la nivelul fluxului de date, descrisă anterior, presupune în continuare cunoașterea schemei hardware la nivelul porților logice sau, măcar, expresia logică. Deși reprezintă o variantă mai simplă decât utilizarea primitivelor, nu este cea mai facilă.
Pentru a ușura implementarea, Verilog pune la dispoziție mai multe tipuri de operatori. Unii dintre aceștia sunt cunoscuți din limbajele de programare precum C, C++, Java, și au aceeași funcționalitate. Alții sunt specifici limbajului Verilog și sunt folosiți în special pentru a descrie ușor circuite logice. Cu ajutorul acestora putem simplifica implementarea, apelând la construcții folosind limbajul de nivel înalt.
Tabelul de mai jos conține operatorii suportați de Verilog, împreună cu nivelul lor de precedență.
Simbol | Funcție | Precedență |
---|---|---|
! ~ + - (unari) | Complement, Semn | 1 |
** | Ridicare la putere | 2 |
* / % | Înmulțire, Împărțire, Modulo | 3 |
+ - (binari) | Adunare, Scădere | 4 |
<< >> <<< >>> | Shiftare | 5 |
< <= > >= == != | Relaționali | 6 |
& ~& ^ ~^ ^~ | ~| | Reducere | 7 |
&& || | Logici | 8 |
?: | Condițional | 9 |
{,} | Concatenare |
În continuare sunt prezentați operatorii mai neobișnuiți suportați de Verilog:
wire signed[7:0] a, x, y; assign x = a >>> 1; // dacă bitul de semn al lui a este 0 bitul nou //introdus este 0 // dacă bitul de semn al lui a este 1 bitul nou // introdus este 1 assign y = a <<< 1; // bitul nou introdus este tot timpul 0, //asemănător cu operatorul <<
wire[7:0] a; wire x, y, z; assign x = &a; // realizeaza AND între toți biții lui a assign y = ~&a; // realizează NAND între toți biții lui a assign z = ~^a; // realizeaza XNOR între toți biții lui a, // echivalent cu ^~
wire[3:0] a, b; wire[9:0] x; // biții 9:6 din x vor fi egali cu biții 3:0 ai lui b // biții 5:4 din x vor fi egali cu 01 // biții 3:2 din x vor fi egali cu biții 2:1 ai lui a // biții 1:0 din x vor fi egali cu 00 assign x = {b, 2'b01, a[2:1], 2'b00};
Cuvântul rezervat parameter
este o construcție de limbaj în verilog care permite unui modul să fie reutilizat cu specificații diferite. Spre exemplu, un sumator poate fi parametrizat să accepte o valoare pentru numărul de biți care poate să fie configurată diferit de la o simulare la alta. Comportamentul lor este similar cu cel al argumentelor unor funcții în alte limbaje de programare cunoscute. Folosind parameter
este declarată o valoare constantă, prin urmare este ilegală modificarea valorii acesteia în timpul simulării. De asemenea, este ilegal ca un alt tip de dată să aibă același nume ca unul dintre parametri.
parameter MSB = 7; // MSB este un parametru cu valoarea constantă 7 parameter [7:0] number = 2’b11; // o valoare de 2 biți este convertită // într-o valoare de 8 biți
O variabilă de tip parametru este vizibilă local, în modulul ce a fost declarată.
Instanțierea modulelor a fost folosită și în laboratorul anterior pentru a invoca logica implementată într-un alt modul. În acel context, era necesar să cunoaștem dimensiunea semnalelor din interfață pentru a le potrivi cu variabilele conectate la instanță. În cazul în care un modul are dimensiunile porturilor parametrizate, acesta poate fi instanțiat cu valori particulare ale parametrilor (diferite de cele predefinite). Să considerăm ca exemplu un modul de mai jos:
module my_beautiful_module (out, a, b); output [7:0] out; input [3:0] a; input [4:0] b; …// some logic endmodule
Pentru a instanția acest modul, vom avea nevoie de 3 variabile de 8, 4, respectiv 5 fire pe care le vom conecta astfel:
My_beautiful_module inst1(out, a, b);
Pe de altă parte, având modulul:
module my_beautiful_parameterized_module(out, a, b); parameter a_width = 4; parameter b_width = 5; parameter out_width = 8; output [out_width-1:0] out; input [a_width-1:0] a; input [b_width-1:0] b; …// some logic endmodule
Îi putem utiliza logica fără a depinde de o dimensiune predefinită a semnalelor din interfață
wire [4:0] out1; wire [4:0] out2; wire [2:0] a; wire [1:0] b; my_beautiful_parameterized_module #(.a_width(3), .b_width(2), .out_width(5)) inst2(out, a, b); // Sau, menținându-se ordinea parametrilor, doar prin specificarea noilor // dimensiuni: my_beautiful_parameterized_module #(3, 2, 5) inst3(out, a, b);
Pentru testarea unui modul folosind simulatorul se creează module speciale de test, în care, printre altele, se vor atribui valori intrărilor. Simularea permite detecția rapidă a erorilor de implementare și corectarea acestora.
Pentru a creea un modul de test și a-l simula puteți urma tutorialul de simulare aici, iar această secțiune va prezenta câteva din construcțiile de limbaj pe care le puteți folosi într-un astfel de modul.
Blocurile initial descriu un comportament executat o singură dată la începerea/activarea simulării și sunt folosite pentru inițializări și în module de test. Instrucțiunile sale trebuie încadrate între cuvintele cheie begin și end și sunt executate secvențial.
initial begin a = 0; b = 1; #10; // delay 10 unități de timp de simulare a = 1; b = 0; end
Blocurile initial
nu sunt sintetizabile, fiind folosite doar în simulări.
Folosind operatorul # se poate specifica o durată de timp între apariția instrucțiunii și momentul executării acesteia. Aceasta este utilă pentru a separa temporal diversele atribuiri ale intrărilor. Durata de timp este reprezentată prin unități de timp de simulare. De exemplu, dacă simularea folosește un timescale în nanosecunde, #n va reprezenta n nanosecunde.
Atât în modulele de test cât și în modulele testate se pot folosi construcții pentru afișare în interiorul blocurilor initial și always. Una dintre aceste instrucțiuni este display
:
$display(arguments);
Argumentele acestei comenzi sunt similare cu cele ale funcției printf din C, ca în exemplul de mai jos, iar specificația completă o puteți găsi aici. $display adaugă o linie nouă, iar dacă nu se dorește acest lucru se poate folosi comanda $write.
a = 1; b = 4; $display("suma=%d", a+b);