Hardware erzeugen mit C++, Teil 9
16.09.2024, 00:00 Uhr
Nicht gerührt, aber gut geschüttelt
Hardware mit VHDL, Rechenalgorithmen mit C/C++ als Hardware und Steuerung mit C/C++ in einem RISC-Prozessor werden gemeinsam in einem einzigen Projekt eingesetzt.
Nach acht Artikeln haben wir endlich alles beisammen: Hardware mit VHDL, Rechenalgorithmen mit C/C++ als Hardware und Steuerung mit C/C++ im einem RISC-Prozessor. Dieser Artikel beschreibt nun ein Beispiel, in dem alle drei Verfahren in einem einzigen Projekt zusammenspielen. Dabei werden die unterschiedlichen Schnittstellen zwischen den Komponenten beleuchtet und schließlich zum Abschluss die Vorgehensweisen betrachtet.
Einführung
Die ersten Artikel dieser FPGA-Serie [1] [2] [3] [4] stellten die Hardware-Erzeugung mit der Programmiersprache VHDL vor. In den darauf folgenden Artikeln [5] [6] [7] wurden Komponenten in C/C++ mit der High-Level-Synthese erstellt. Schließlich zeigte ein weiterer Artikel, wie auf dem einfachen RISC-Prozessor MicroBlaze (der als Hardware im FPGA erzeugt wurde) normaler C/C++-Code ausgeführt werden kann [8]. Hierzu soll noch einmal erwähnt werden, dass es FPGA-Chips gibt, die bereits einen vollständigen und sehr leistungsfähigen RISC-Prozessor zusätzlich zu den normalen FPGA-Schalteinheiten enthalten, der im Prinzip genauso verwendet werden kann wie der MicroBlaze-Core.
Die in diesen Artikeln eingesetzte Software kann für einen privaten Gebrauch kostenlos heruntergeladen und installiert werden:
- Vivado ML (VHDL
- Vitis HLS (C/C++
- Vitis IDE (C/C++
Hier soll auch noch einmal der Unterschied zwischen den beiden C/C++-Varianten erläutert werden: Mit Vitis HLS kann man aus C/C++ Hardware erzeugen, die in der Regel in einem Block-Design von VHDL-Code aus aufgerufen wird. In diesen HLS-Komponenten werden häufig Rechenalgorithmen implementiert, zum Beispiel Fourier-Analyse, Fourier-Synthese, Finite Impulse Response Filter (FIR) oder Matrixberechnungen. Solche Algorithmen lassen sich sehr gut in C/C++ programmieren. Die oft benötigten mathematischen Funktionen (sin, cos, tan, exp, log, …) stehen in HLS-Komponenten ebenfalls zur Verfügung. Mithilfe der Template-Programmierung von C++ kann die Hardware dann explizit für spezifische Datentypen optimal erzeugt werden.
Mit Vitis IDE befindet man sich mit dem C/C++-Code in einer höheren Abstraktionsebene. Hier können zum Beispiel I/O-Funktionen (cout, printf und verwandte Funktionen) verwendet werden, um Daten zu formatieren. Auf der anderen Seite ist man mit C/C++ aber noch so nahe an der Hardware, dass man problemlos Bitmanipulationen oder logische Operationen ausführen kann.
In diesem Artikel sollen nun die drei Konzepte in einem Projekt gemeinsam benutzt werden. Zusätzlich sollen auch noch fertige IPs (IP steht für Intellectual Property, geistiges Eigentum), welche die Firma AMD-Xilinx mit der Vivado-Installation zur Verfügung stellt, genutzt werden. Es soll ein Spannungswert in den FPGA eingelesen werden (0 bis 1 Volt sind hier erlaubt) und zunächst mit einem Analog-Digital-Converter (ADC) in einen Digitalwert umgewandelt werden. In diesem Part spielt VHDL die wichtige Rolle. Um den ADC zu realisieren, benutzt man am besten den XADC-Wizard von Xilinx, der als fertige Komponente vorliegt. Die Messwerte sollen im Sekundentakt ermittelt werden, und es wird in einem HLS-IP ein gleitender Durchschnitt der jeweils zehn letzten Messwerte errechnet und als Ergebnis zur Verfügung gestellt. Es handelt sich hierbei um Integerwerte im Bereich von 0 bis 4095 (12 Bit). Der errechnete Wert wird über eine GPIO-Komponente (General Purpose Input Output) an einen MicroBlaze-RISC-Prozessor weitergeleitet. Dort wird der Integerwert in einen Fließkommawert im Bereich von 0,000 bis 0,999 umgerechnet, und danach wird der Binärwert mit sprintf in einen lesbaren Text umgewandelt und mit ein paar Bit-Schiebetricks in Daten konvertiert, die für eine Ausgabe auf der 7-Segment-Anzeige benötigt werden. Diese Daten werden wiederum mithilfe einer GPIO-Komponente in die Hardware übertragen und an die Anzeige weitergegeben.
Bild 1 zeigt die grundsätzliche Struktur des Projekts. Alle grauen Rechtecke repräsentieren fertige Xilinx-Komponenten. Der Zähler, der 7-Segment-Treiber und die HLS-Komponente sind Eigenentwicklungen.
Zusätzlich zu den in Bild 1 dargestellten Komponenten werden noch weitere einfache IPs benötigt (Clocking-Wizard, Constant, Hilfs-IPs für den MicroBlaze, Concat, Speicher, UART und weitere), die in der Vivado-Installation vorhanden sind und hier nicht dargestellt werden.
Das vollständige Beispiel
Eigentlich sollte man ein „größeres“ Projekt Schritt für Schritt erstellen und die Zwischenschritte immer wieder testen:
- Spannungsteiler mit Potentiometer „bauen“, Erzeugung einer Messspannung von 0 bis 1 V;
- XADC konfigurieren, Ausgabe auf 7-Segment-Anzeige (Hex);
- Zähler in VHDL programmieren, sekündliche Ausgabe auf 7-Segment-Anzeige (Hex);
- Ringpuffer programmieren, Ausgabe auf 7-Segment-Anzeige (Hex);
- MicroBlaze integrieren, GPIO, Formatierung programmieren, GPIO, Ausgabe auf 7-Segment-Anzeige, UART integrieren (Dezimal).
In dieser Demo hingegen soll die gesamte Schaltung in einem Schritt realisiert werden. Zunächst benötigen wir aber eine einfache Hardware, die eine Spannung von 0 bis 1 Volt zum Messen erzeugt. Hier könnte man natürlich ein normales regelbares Netzteil benutzen. Aber diese Netzteile können auch wesentlich höhere Spannungen (bis 30 Volt oder mehr) erzeugen, die dann leider den ADC im FPGA zerstören können, wenn man nicht aufpasst. Auf dem Basys3-Board stehen an den Pmod-Steckern jedoch mehrere Ausgänge mit einer Spannung von 3,3 Volt zur Verfügung (Bild 2).
Basys3 mit vier Pmod-Schnittstellen (JA, JB, JC, JXADC), mit 3,3 V und Ground (Bild 2)
Quelle: Autor
Da diese Spannung am FPGA immer noch viel zu groß ist, muss ein Spannungsteiler her. Es sollen verschiedene Spannungen im Bereich von 0 bis 1 Volt zum Messen eingestellt werden können. Darum benötigt man einen Spannungsteiler mit einem Potentiometer (10 kOhm) und einem Festwiderstand (22 kOhm) in Reihe. Diese Schaltung liefert dann bei einer Eingangsspannung von 3,3 Volt eine Ausgangsspannung von 0 bis etwa 1,03 Volt, wenn das Potentiometer entsprechend verstellt wird (Bild 3).
Der Spannungsteiler, Vcc = 3,3 V am Basys3, Pmod JA, Output = 0 ... 1 V (Bild 3)
Quelle: Autor
Die Berechnungsformel für den erforderlichen Spannungsteiler lautet:
Hierbei ist Uinput die Eingangsspannung von 3,3 V, R1 ist der eingestellte Widerstand am Potentiometer, R2 ist der Festwiderstand (hier: 22 kOhm) und Uoutput die resultierende Ausgangsspannung (0 bis ungefähr 1 V).
Die Verdrahtung des Spannungsteilers und des Basys3-FPGA-Boards zeigt Bild 4. Hier wurde ein Steckbrett (oder auch „Brassel-Board“) verwendet, um die Bauteile miteinander zu verbinden.
Der Hardware-Aufbau aus Potentiometer und Widerstand (Bild 4)
Quelle: Autor
Die Belegung für den Pmod-Stecker JXADC ist in Bild 5 besser von der Seite zu erkennen. Der Stecker besteht aus zwei Reihen mit jeweils sechs Anschlüssen. Ganz links (roter Draht) befindet sich der +3,3-V-Ausgang, rechts daneben (schwarzer Draht) ist der Ground-Anschluss, jeweils in der oberen und in der unteren Reihe. Ganz rechts wird das Messsignal in das FPGA-Board eingegeben (oben gelber und unten grüner Draht).
Benutzte Anschlüsse am Pmod -Stecker JXADC (Bild 5)
Quelle: Autor
Damit ist die erforderliche Hardware vollständig beschrieben, und wir können mit der Programmierung des Projekts beginnen. Wenn möglich, sollte man die Ausgangsspannung am Spannungsteiler mit einem Messinstrument überprüfen.
Der Analog-Digital-Konverter und der
Sekundenzähler
Im ersten Schritt wird mit dem XADC-Wizard, dem Clocking-Wizard und einem Zähler das Einlesen der Daten ermöglicht. Die beiden Wizards sind fertige Xilinx-IPs, dir direkt in ein Block-Design eingefügt und benutzt werden können. Sie müssen aber für den Gebrauch korrekt konfiguriert werden. So kann man etwa im XADC-Wizard auswählen, ob man fortlaufend neue Messungen durchführen oder auf ein Ereignis mit einer Messung reagieren möchte. Da mit einer Taktfrequenz von 100 MHz aus dem Clocking-Wizard gearbeitet wird, muss der Zähler bis 100 000 000 zählen, um einen Ein-Sekunden-Takt für die Messungen zu erhalten. Der Zähler wird in VHDL programmiert und dann ins Block-Design eingefügt.
Zunächst wird jedoch das Verzeichnis Demo08 im Hauptverzeichnis Vivado-Projects angelegt. Dieses Mal braucht man hier die drei Unterverzeichnisse VIVADO, HLS und VITIS. Außerdem muss im Verzeichnis für die HLS-IPs ein Unterverzeichnis mit dem Namen Demo08 generiert werden, um dort die HLS-Komponente so abzulegen, dass Vivado sie auch finden kann. Nun wird die Software Vivado gestartet und das Projekt Demo08 im gerade erstellten VIVADO-Verzeichnis erzeugt. Die Option Create Project Directory wird deaktiviert und das Basys3-FPGA-Board wird ausgewählt. Nun lässt sich mit dem Befehl Create Block Design ein neues Design mit dem Standardnamen design_1 anlegen.
Als Nächstes fügt man den Clocking-Wizard und den XADC-Wizard in das Block-Design ein (+-Schaltfläche in der Toolbar, Suchtexte Clock und XADC). Die Zähler-Komponente liegt im Verzeichnis WeitereDateien als fertige VHDL-Datei StdCounter32.vhd vor. Dieser Zähler kann für beliebige Zählendwerte (bis 4 000 000 000) mittels eines generischen Parameters verwendet werden. Die Datei StdCounter32.vhd wird in das Projekt eingefügt (Tab Sources, +-Schaltfläche, Befehl Add or create design sources, Next-Schaltfläche, Add Files-Schaltfläche, Verzeichnis WeitereDateien und darin die Datei StdCounter32.vhd auswählen, dann Finish-Schaltfläche). Danach wird die Zähler-Komponente in das Block-Design eingefügt (Tab Sources, rechte Maustaste auf StdCounter32.vhd, dann Befehl Add Module to Block Design). Im Block-Design befinden sich daraufhin drei IPs, die nun noch konfiguriert werden müssen.
Im Clocking-Wizard soll nur der Name des Ausgangssignals in clk_100MHz geändert werden (Doppelklick auf das IP, Tab Output Clocks, Port Name clk_out1 in clk_100MHz ändern, OK-Schaltfläche). Im StdCounter32 wird (nach dem Doppelklick) der generische Parameter N auf den Wert 100 000 000 gesetzt, sodass nun einmal pro Sekunde ein Signal erzeugt wird, das dann eine Messung mit dem Analog-Digital-Konverter auslöst. Da die Konfigurierung des XADC-Wizards etwas umfangreicher ist, wurden die Einstellungen auf den fünf Tab-Seiten des Dialogs in der Datei XADC-Config.pdf im Verzeichnis WeitereDateien in den Downloads zu diesem Artikel abgelegt. Nach dem Doppelklick werden im ersten Tab Basic folgende Optionen aktiviert: DRP (Dynamic Reconfiguration Port) und Event Mode. Weiter unten sollte nun die Option convstin eingeschaltet sein. An diesem Eingang wird das Ein-Sekunden-Signal angelegt, das eine Messung auslösen soll. Auf dem zweiten Tab ADC Setup muss nichts geändert werden. Im Tab Alarms werden alle Alarme ausgeschaltet, das heißt, alle Kontrollkästchen im Fenster werden deaktiviert. Im Tab Single Channel wird im Kombinationsfeld Select Channel die Eintragung VAUXP6 VAUXN6 ausgewählt. Dieser XADC-Eingang gehört zu den beiden Steckbuchsen Mess(1) und Mess(2) in Bild 5. Die Konfigurierung kann nun mit der OK-Schaltfläche abgeschlossen werden.
Jetzt können folgende Verbindungen zwischen den drei IPs hergestellt werden:
- clk_wiz_0: clk_100MHz mit StdCounter32_0: clk
- clk_wiz_0: clk_100MHz mit xadc_wiz_0: dclk_in
- StdCounter32_0: outSig mit xadc_wiz_0: convst_in
- xadc_wiz_0: eoc_out mit xadc_wiz_0: den_in
Die letzte Verbindung in der Liste erlaubt es, die nächste Messung zu starten, wenn die letzte Messung abgeschlossen ist. Allerdings wird hier die nächste Messung erst dann gestartet, wenn das Ein-Sekunden-Signal am Eingang convst_in des XADC-IP kurz auf logisch 1 gesetzt wird.
Nun können die erforderlichen externen Ports erstellt werden. Zunächst klickt man im XADC-IP mit der rechten Maustaste auf Vaux6 und ruft im Kontextmenü den Befehl Make External auf. Der neue Anschluss muss rechts im Fenster External Interface Properties mit dem neuen Namen Vaux6 versehen werden. Der Befehl Make External wird jetzt auch auf den reset-Eingang des Clocking-Wizards angewendet, wobei der neue Name reset anzugeben ist.
Abschließend wird nun der Eingang clk_in1 mit dem Systemtakt sys_clock verbunden. Diese Aktion wird ganz einfach durch Anklicken des Links Run Connection Automation oben im grünen Balken des Block-Designs aufgerufen. Im Dialog müssen im Auswahlbaum ganz links alle Zweige (All Automation, clk_wiz_0 und clk_in1) aktiviert sein, und im Eingabefeld Select Board Part Interface muss das Element sys_clock ausgewählt sein. In diesem Fall kann die Aktion mit der OK-Schaltfläche abgeschlossen werden.
Nun muss die Channel-Adresse für den Analog-Digital-Konverter erzeugt werden. Da der Kanal bei der Konfigurierung angegeben wurde (Vauxp6 und Vauxn6), können die fünf Bit des Ports channel_out[4:0] der XADC-Komponente mit zwei zusätzlichen Null-Bits vereinigt werden und dann als Adresswert am Port daddr_in[6:0] genutzt werden. Es müssen zwei fertige IPs in das Block-Design eingefügt werden: Constant und Concat. Das Element xlconstant_0 wird wird mit den Parametern Const Width = 2 und Const Val = 0 konfiguriert. Für xlconcat_0 müssen beide Ports auf manual umgestellt werden (Schieber im linken Bereich des Hauptfensters). Danach kann man für den Parameter In1 den Wert 5 und für In2 den Wert 2 eintragen. Die Komponente kann nun ein 5-Bit- und ein 2-Bit-Signal zu einem neuen Signal bestehend aus 7 Bit zusammensetzen.
Folgende Verbindungen werden nun erstellt:
- xlconstant_0: dout[1:0] mit xlconcat_0: ln1[1:0]
- xadc_wiz_0: channel_out[4:0] mit xlconcat: In0[4:0]
- xlconcat_0: dout[6:0] mit xadc_wiz_0: daddr_in[6:0]
Nach dem Regenerieren des Layouts (in der Toolbar) sollte das aktuelle Design wie in Bild 6 aussehen.
Der Ringpufferspeicher als HLS-IP
Im Pufferspeicher werden die Messdaten vom Typ unsigned short (16 bit) abgespeichert. Dieser Speicher bietet Platz für zehn Messwerte (ist aber per #define konfigurierbar), und jeder weitere Wert überschreibt dann den jeweils ältesten Wert. Beim Ablegen im Ringspeicher werden die Messdaten allerdings etwas korrigiert. Der Analog-Digital-Konverter liefert als Messergebnis eigentlich einen 12-Bit-Wert (von 0 bis 4095 dezimal), der aber als 16-Bit-Zahl aus dem XADC-IP zurückgegeben wird. Die niederwertigsten vier Bit können verworfen werden, was in C/C++ einfach mit einem vierfachen Shift-Befehl nach rechts erledigt wird. Im Speicher wird dann das 12-Bit breite korrigierte Messergebnis abgelegt. Indem nun einfach die zehn vorhandenen Messwerte im Pufferspeicher gemittelt werden, erhält man den gleitenden Durchschnitt in der aktuellen Messung. Das Ergebnis wird wieder als unsigned short aus dem HLS-IP zurückgegeben. Es ist jedoch noch nicht in einen korrekten Spannungswert umgerechnet worden, sondern liefert wieder einen 12-Bit-Ganzzahlwert.
Es wird nun ein neues Vitis-HLS-Projekt im bereits angelegten Verzeichnis ...\Demo08\HLS mit den drei Dateien Demo08_HLS.cpp, Demo08_HLS.h und Demo08_HLS_Test.cpp erzeugt. Der Code für diese Dateien wird in Listing 1 gezeigt. Wichtig ist an dieser Stelle noch die Eintragung für den FPGA-Chip des Basys3-Boards im Dialog Part Selection. Hier muss die Chip-Nummer xc7a35tcpg236-1 eingetragen werden. Danach kann das Projekt fertig erstellt und übersetzt werden:
Listing 1: Ringpuffer als HLS-IP für zehn Messwerte
// ************** Demo08_HLS.h *******************
#ifndef _DEMO08_HLS_H_
#define _DEMO08_HLS_H_
typedef unsigned short din_t;
typedef unsigned short dout_t;
// Top Level Function Declaration
dout_t demo08(din_t a);
#endif
// ************** Demo08_HLS.cpp *****************
#include "Demo08_HLS.h"
#define SIZE 10 // Groesse des Speichers
// Globale Variablen: bleiben ueber den
// Aufruf hinaus erhalten
din_t mem[SIZE]; // Ringspeicher
short next = 0; // Zeiger fuer den naechsten Wert
// *** Top Level Function
dout_t demo08(din_t data)
{
// Summenvariable fuer den Durchschnitt
// Um auch bei groesserer Zahlenmenge einen
// Overflow zu vermeiden: als int (32bit)
int iSum = 0;
// Abspeichern des neuen Messwerts
mem[next] = (din_t)(data >> 4);
// Zeiger auf den Ringspeicher erhoehen und
// ggf. wieder auf 0 setzen, wenn das Ende
// erreicht wurde
next++;
if(next >= SIZE)
{
next = 0;
}
// Gleitenden Durchschnitt berechnen
// mit Pipelining
loop1 : for(short i = 0; i < SIZE; i++)
{
#pragma HLS PIPELINE II=1
iSum += (int)mem[i];
}
iSum /= SIZE;
return (dout_t) iSum;
}
// ************** Demo08_HLS_Test.cpp *************
#include <stdio.h>
#include <math.h>
#include "Demo08_HLS.h"
int main(void)
{
// Deklarationen
din_t x1 = (din_t)1600; // Test: 1600/16=100
// 100/10=10
dout_t res1;
int ret_value;
// Berechnungen und ganz einfacher Test
// mit einem Messwert
res1 = demo08(x1);
printf("Result: %d\n", res1);
if(res1 != 10)
{
fprintf(stdout, "\nTest FAILED!\n");
ret_value = 1; // ERROR
}
else
{
fprintf(stdout, "\nTest PASSED!\n");
ret_value = 0; // OK
}
return ret_value;
}
#ifndef _DEMO08_HLS_H_
#define _DEMO08_HLS_H_
typedef unsigned short din_t;
typedef unsigned short dout_t;
// Top Level Function Declaration
dout_t demo08(din_t a);
#endif
// ************** Demo08_HLS.cpp *****************
#include "Demo08_HLS.h"
#define SIZE 10 // Groesse des Speichers
// Globale Variablen: bleiben ueber den
// Aufruf hinaus erhalten
din_t mem[SIZE]; // Ringspeicher
short next = 0; // Zeiger fuer den naechsten Wert
// *** Top Level Function
dout_t demo08(din_t data)
{
// Summenvariable fuer den Durchschnitt
// Um auch bei groesserer Zahlenmenge einen
// Overflow zu vermeiden: als int (32bit)
int iSum = 0;
// Abspeichern des neuen Messwerts
mem[next] = (din_t)(data >> 4);
// Zeiger auf den Ringspeicher erhoehen und
// ggf. wieder auf 0 setzen, wenn das Ende
// erreicht wurde
next++;
if(next >= SIZE)
{
next = 0;
}
// Gleitenden Durchschnitt berechnen
// mit Pipelining
loop1 : for(short i = 0; i < SIZE; i++)
{
#pragma HLS PIPELINE II=1
iSum += (int)mem[i];
}
iSum /= SIZE;
return (dout_t) iSum;
}
// ************** Demo08_HLS_Test.cpp *************
#include <stdio.h>
#include <math.h>
#include "Demo08_HLS.h"
int main(void)
{
// Deklarationen
din_t x1 = (din_t)1600; // Test: 1600/16=100
// 100/10=10
dout_t res1;
int ret_value;
// Berechnungen und ganz einfacher Test
// mit einem Messwert
res1 = demo08(x1);
printf("Result: %d\n", res1);
if(res1 != 10)
{
fprintf(stdout, "\nTest FAILED!\n");
ret_value = 1; // ERROR
}
else
{
fprintf(stdout, "\nTest PASSED!\n");
ret_value = 0; // OK
}
return ret_value;
}
- Run C Simulation
- Run C Synthesis
- Run Cosimulation (Option VHDL einstellen)
- Export RTL (Zielverzeichnis auswählen, Entwickler und Version eintragen)
- export.zip im Zielverzeichnis entpacken
Je nach Anzahl der zu puffernden Messwerte kann der Parameter SIZE in der C++-Datei Demo08_HLS.cpp angepasst werden (#define). Der restliche Code für den Ringpuffer ist selbsterklärend. Nach der fehlerfreien Erzeugung der HLS-Komponente kann das Programm Vitis HLS beendet werden.
Angemerkt sei noch, dass es in den installierten Vivado-IPs auch eine fertige Ringpuffer-Implementierung in VHDL gibt. Aus didaktischen Gründen wurde hier jedoch eine eigene HLS-Komponente entwickelt.
Das HLS-IP Demo08 wird nun mit der Vivado-Software in das Block-Design eingefügt. Daraufhin können folgende Verbindungen hergestellt werden:
- xadc_wiz_0: eoc_out mit demo08_0: ap_start (Interface ap_ctrl aufklappen)
- clk_wiz_0: clk_100MHz mit demo08_0: ap_clk
- xadc_wiz_0: do_out[15:0] mit demo_08_0: data[15:0]
Das bereits bekannte Signal eoc_out steht immer dann auf logisch 1, wenn ein fertiger Messwert zur Verfügung steht. Mit diesem Signal wird dann der Rechenprozess im HLS-IP Demo08 gestartet, um den neuen Messwert in den Ringpuffer aufzunehmen und danach den Mittelwert erneut zu berechnen. Außerdem wird das 100-MHz-Taktsignal am HLS-IP angeschlossen. Die aktuellen Messdaten werden aus dem Analog-Digital-Konverter (Port do_out[15:0]) in den Ringpuffer (Port data[15:0]) übertragen. Den Stand des Designs zu diesem Zeitpunkt zeigt Bild 7.
Der MicroBlaze-RISC-Prozessor
Im nächsten Schritt kommen nun der RISC-Prozessor und die dazugehörigen Komponenten dran. Nach dem Einfügen (+-Schaltfläche in der Toolbar, Suchtext: Micro) des MicroBlaze-IP (Achtung: nicht MicroBlaze MCS!) taucht im grünen Balken über dem Block-Design der Befehl Run Block Automation auf, der dann auch sofort angeklickt wird. Die Konfigurierung des Blocks bezieht sich in diesem Fall ganz klar auf das MicroBlaze-IP. Bild 8 zeigt den dazugehörigen Dialog. Da im Beispiel nur ein solches IP im Block-Design vorhanden ist, muss im Baum auf der linken Seite keine Änderung vorgenommen werden. Auf der rechten Seite im Bereich Options wird das Local Memory jedoch auf 128KB heraufgesetzt. Außerdem sollten man jetzt ganz unten prüfen, ob als Clock Connection der im Block-Design angelegte Taktgenerator clk_wiz_0 mit seinem 100-MHz-Ausgang auch für den RISC-Prozessor verwendet wird. Danach wird der Dialog mit der OK-Schaltfläche beendet. Die Ausführung des Befehls dauert einige Sekunden. Dabei werden auch noch weitere wichtige IPs in das Block-Design eingefügt (Speicher, Debug-Modul, …).
Block Automation für das MicroBlaze-IP (Bild 8)
Quelle: Autor
Nun muss noch eine ganz wichtige Verbindung zwischen dem MicroBlaze-IP und dem HLS-IP (Ringpuffer) hergestellt werden. Jedes Mal, wenn das HLS-IP einen neuen Messwert aus dem Analog-Digital-Konverter erhält, wird dieser abgespeichert und der neue, aktuelle Mittelwert wird berechnet. Wenn diese Rechenoperation beendet ist und das Ergebnis am Port ap_return[15:0] vorliegt, wird der Ausgang ap_done (im Interface ap_ctrl) des HLS-IPs auf logisch 1 gesetzt. Dieses Signal wird nun am Interrupt-Eingang (Interface INTERRUPT) des RISC-Prozessors angelegt, sodass dort immer nur dann gerechnet wird, wenn sich Eingangsdaten geändert haben. Dazu muss dann später im C-Code eine spezielle Funktion implementiert werden, die auf den Interrupt reagiert.
Außerdem kann das Reset-Signal der Processor System Reset-Komponente an das HLS-IP angeschlossen werden, die automatisch mit bei der Konfiguration des MicroBlaze mit eingefügt wurde. Es werden nun also folgende Verbindungen hergestellt:
- microblaze_0: Interrupt (Interface INTERRUPT) mit demo08_0: ap_done (Interface ap_ctrl)
- rst_clk_wiz_0_100M: peripheral_reset[0:0] mit demo08_0: ap_rst
Im Block-Design hat sich wieder eine Menge getan. Den aktuellen Stand mit dem RISC-Prozessor zeigt Bild 9.
Verbindung Hardware–MicroBlaze (Software)
Im nächsten Schritt soll die Hardware des Block-Designs so mit dem MicroBlaze-Prozessor verbunden werden, dass die übergebenen Daten in einem einfachen C-Programm verarbeitet werden können. Außerdem sollen die formatierten Daten wieder an die Hardware zurückgegeben werden, um dann schließlich auf dem 7-Segment-Display angezeigt zu werden.
Der Datentransport zwischen Hardware und Software (MicroBlaze) wird mithilfe von GPIO-IPs realisiert, die bei der Vivado-Installation mit installiert wurden. Diese GPIO-Komponenten können die Daten je nach Konfigurierung in beide Richtungen transportieren. Die Bitbreite der übermittelten Daten ist ebenfalls einstellbar.
Für das Beispiel benutzen wir zwei GPIO-IPs, die in das Block-Design eingefügt werden (+-Schaltfläche in der Toolbar, Suche nach GPIO oder AXI GPIO). Das IP axi_gpio_0 soll den berechneten Mittelwert aus dem HLS-IP in den MicroBlaze übertragen. Nach einem Doppelklick auf das IP wird zunächst auf den Tab IP Configuration umgeschaltet. Dort wird die Option All Inputs (oben bei GPIO-Einstellungen) ausgewählt. Der Datentransport läuft also „in“ den RISC-Prozessor hinein. Die Bitbreite beträgt 16 Bit, da das Ergebnis als unsigned short aus dem HLS-IP vorliegt. Weitere Eingaben sind nicht erforderlich.
Die zweite GPIO-Komponente axi_gpio_1 muss ebenfalls konfiguriert werden. Hier wird auf dem Tab IP Configuration aber die Option All Outputs aktiviert (die Daten kommen „aus“ dem MicroBlaze heraus) und die Bitbreite wiederum auf 16 Bit eingestellt, da die 7-Segment-Anzeige aus vier Ziffern besteht, die mit jeweils vier Bit angesteuert werden.
Die beiden AXI GPIO-Komponenten werden nun über ein spezielles IP mit dem Namen AXI Interconnect mit dem MicroBlaze-Prozessor verbunden. Dieses IP wird automatisch in das Block-Design eingefügt, wenn man den Befehl Run Connection Automation (im grünen Balken) ausführt. Im Dialog (Bild 10) werden die gezeigten Einstellungen eingegeben. Dazu aktiviert man nur unter dem Zweig axi_gpio_0 den Eintrag S_AXI. Es wird kein Häkchen bei der Eintragung GPIO gesetzt, denn diese Verbindung wird direkt im Block-Design konfiguriert. Beim Beenden des Dialogs mit der OK-Schaltfläche wird das AXI Interconnect-IP eingefügt und mit den erforderlichen Schnittstellen konfiguriert (hier: M00_AXI).
Konfiguration der GPIO-IPs mit Run Connection Automation (Bild 10)
Quelle: Autor
Um den Rahmen des Artikels nicht zu sprengen, wird die AXI-Schnittstelle nicht im Detail erläutert. Es geht letztendlich darum, die Daten der Hardwareseite so aufzuarbeiten, dass sie auf der Softwareseite (MicroBlaze- oder ARM-Prozessor) benutzt werden können.
Natürlich kann man das auch alles „von Hand“ konfigurieren, aber der Artikel soll ja eine „endliche“ Seitenzahl haben.
Bei einem zweiten Aufruf des Befehls Run Connection Automation kann man dann das Element S_AXI im Zweig axi_gpio_1 aktivieren. Ebenso gut hätte man allerdings auch beide Verbindungen gleichzeitig anlegen können.
Bevor nun die Verbindungen angelegt werden, wird noch das „neue“ 7-Segment-Modul in das Block-Design eingefügt. Diese Variante zeigt nur Dezimalzahlen (0 bis 9) sowie den Dezimalpunkt, und es kann, wenn erforderlich, an der Position ganz links ein Minuszeichen angezeigt werden. Die Datei SevenSeg_4digit_mod.vhd finde Sie im Verzeichnis WeitereDateien in den Downloads zu diesem Artikel. Diese Datei wird in das Projekt und danach in das Block-Design eingefügt (Vorgehensweise: siehe oben mit StdCounter32.vhd). Jetzt können weitere wichtige Verbindungen angelegt werden:
- demo08_0: ap_return[15:0] mit gpio_0: gpio_io_i[15:0] (Interface GPIO aufklappen)
- SevenSeg_4digit_0: data_in[15:0] mit gpio_1: gpio_io_o[15:0] (Interface GPIO aufklappen)
- SevenSeg_4digit_0: clk mit clk_wiz_0: clk_100MHz
- SevenSeg_4digit_0: reset mit rst_clk_wiz_0_100M: peripheral_reset[0:0]
Die erste und die zweite Verbindung sorgen für den Datentransport in den MicroBlaze-Prozessor und wieder hinaus in die 7-Segment-Anzeige. Außerdem werden zwei Ports im SevenSeg_4digit_0-Element als „extern“ deklariert. Dies wird wie üblich mit dem Befehl Make External für die Ports an[3:0] und sseg[7:0] durchgeführt; die Vorgehensweise wurde weiter oben bereits beschrieben. Als Namen für die beiden Ausgänge werden an und sseg im Fenster External Port Properties (links) eingestellt. Bild 11 zeigt das aktuelle Design.
UART hinzufügen und übersetzen
Nun ist noch eine letzte Komponente erforderlich: das AXI-UART-Lite-IP. Das IP wird benötigt, um den Code auf den MicroBlaze-Prozessor zu laden und bei Bedarf zu debuggen.
Es wird also aus der IP-Bibliothek von Vivado die Komponente Uartlite (oder: AXI Uartlite) in das Block-Design eingefügt. Da auch hier wieder die AXI-Schnittstelle benutzt wird, kann das IP ganz einfach mit dem Befehl Run Connection Automation in das Design eingefügt werden. Im Dialog müssen alle Zweige im Baum links aktiviert werden, sodass zum einen das IP über die AXI-Schnittstelle an den MicroBlaze und zum anderen an den USB-Anschluss des FPGA-Boards angeschlossen wird.
Mit einem Doppelklick wird das UART-IP konfiguriert. Im Tab IP Configuration wird die zu benutzende Baud-Rate eingestellt. In diesem Fall kann man 115 200 Baud benutzen. Die anderen Parameter bleiben unverändert.
Abschließend wird das Layout noch einmal regeneriert; das Ergebnis ist in Bild 12 gezeigt.
Nun sollte das finale Block-Design überprüft werden (in der Toolbar Validate Design oder Taste [F6]). Hier sollten keine Fehlermeldungen auftreten, ansonsten sind Verbindungen fehlerhaft eingerichtet oder fehlen im Design.
Die Contraints-Datei Demo08.xdc ist relativ einfach aufgebaut. Sie enthält die Anschlüsse für das 7-Segment-Display und den Anschluss des Reset-Signals an einen Taster. Die Datei kann aus dem Verzeichnis WeitereDateien in den Downloads zu diesem Artikel in das Projekt eingefügt werden (Tab Sources, +-Schaltfläche, Add or Create Constraints auswählen, Next anklicken, Add Files-Schaltfläche, Datei Demo08.xdc auswählen, Finish-Schaltfläche).
In nächsten Schritt wird der Wrapper für das gesamte Block-Design erzeugt, indem im Tab Sources das Design design_1 mit der rechten Maustaste angeklickt und der Befehl Create HDL Wrapper … ausgewählt wird. Um eventuell später durchzuführende Änderungen im Design zu erleichtern, sollte im Dialog die Option Let Vivado manage wrapper and auto-update eingeschaltet werden. Nach einer kurzen Wartezeit ist der Wrapper generiert und in das Projekt eingefügt worden. Nun kann der Bitstream generiert werden. Hierzu wird im Flow Manager (ganz links) der Befehl Generate Bitstream (unten) aufgerufen. Anschließend ist erneut etwas Geduld erforderlich.
Nach einigen Minuten ist die Ausgabedatei erstellt. Eventuelle Fehlermeldungen müssen natürlich abgearbeitet werden, bevor schließlich die XSA-Datei erzeugt wird, welche die gesamte Hardware der Demo in Binärform enthält. Hierzu wird im Hauptmenü von Vivado der Befehl File | Export | Export Hardware … aufgerufen. Der erste Dialog wird mit der Next-Schaltfläche übersprungen. Im zweiten Dialog muss die Option Include Bitstream aktiviert werden. Nach der Next-Schaltfläche werden im dritten Dialog der Name und das Zielverzeichnis der XSA-Datei angegeben. Bei Bedarf können die Standardvorgaben angepasst werden. In unserem Fall werden die Vorgaben jedoch unverändert übernommen. Die Aktion wird mit der Finish-Schaltfläche abgeschlossen.
Die Entwicklung der Hardwareseite ist damit fertiggestellt, und im Hauptmenü von Vivado kann der Befehl Tools | Launch Vitis IDE aufgerufen werden, um nun den C/C++-Code für den MicroBlaze-Prozessor zu erstellen.
Software für den RISC-Prozessor
Im Start-Dialog der Vitis-Software wird als Arbeitsumgebung das Unterverzeichnis VITIS im Hauptverzeichnis des Projekts Demo08 ausgewählt. Nun wird zunächst ein Platform-Projekt mit dem Befehl File | New | Platform Project … erstellt. Als Platform Project Name wird im ersten Dialog Demo08_platform eingesetzt. Im zweiten Dialog wird im Eingabefeld die XSA-Datei eingetragen, die mit Vivado erzeugt wurde. Diese Datei findet man im Verzeichnis VIVADO, sie trägt den Namen design_1_wrapper.xsa. Die Datei wird am besten über die Schaltfläche Browse ausgewählt. Die Aktion wird mit der Finish-Schaltfläche abgeschlossen. Das Platform-Projekt wird in der IDE erzeugt und kann sofort übersetzt werden (Im Explorer: rechte Maustaste auf Demo08_platform, Befehl Build Project aufrufen).
Im zweiten Schritt wird das Applikationsprojekt erstellt. Im Hauptmenü wird der Befehl File | New | Application Project … aufgerufen. Die ersten beiden Dialoge können mit der Next-Schaltfläche übersprungen werden. Im dritten Dialog wird der Application Project Name eingetragen, in diesem Fall ist das Demo08_application. Wieder wird zweimal auf die Next-Schaltfläche geklickt. Im letzten Dialog gilt es noch eine Vorlage für den Start-Code auszuwählen. In diesem Beispiel kann das einfache Template Hello World ausgewählt werden. Zum Abschluss wird die Finish-Schaltfläche angeklickt.
Im Zweig src des Projekts Demo08_application sollte die Datei Helloworld.c mit dem Rename-Befehl (rechte Maustaste) in main.c umbenannt werden. Der C-Code in der Datei main.c wird nun mit dem Code aus Listing 2 überschrieben.
Listing 2: Der C-Code für die Formatierung der Ausgabe
/*
* This application configures UART 16550 to baud rate
* 9600.
* PS7 UART (Zynq) is not initialized by this
* application, since bootrom/bsp configures it
* to baud rate 115200
*
* ------------------------------------------------
* | UART TYPE BAUD RATE |
* ------------------------------------------------
* uartns550 9600
* uartlite Configurable only in HW design
* ps7_uart 115200 (configured by bootrom/bsp)
*/
#include <stdio.h>
#include "platform.h"
#include "xil_printf.h"
#include <xil_exception.h>
#include <xgpio.h>
#include "xparameters.h"
#include <stdbool.h>
XGpio input, output;
bool bInit = false;
void formatData(void)
{
if(bInit)
{
// Daten einlesen vom HLS-IP
short data_in = XGpio_DiscreteRead(&input, 1);
// Konvertieren
float mess = (float) data_in / 4096.0f;
// Generierung 7-Seg-Output (4 Digits)
char str[10];
short data_out;
sprintf(str, "%6.3f", mess);
// Vorzeichen erzeugen
if(mess >= 0.0f)
{
data_out = (str[1] - 48) << 12;
}
else
{
data_out = 10 << 12;
}
data_out += (str[3] - 48) << 8;
data_out += (str[4] - 48) << 4;
data_out += (str[5] - 48);
// Ausgabe auf 7-Seg. via GPIO
XGpio_DiscreteWrite(&output, 1, data_out);
}
}
int main()
{
init_platform();
// Initialisierung
XGpio_Initialize(
&input, XPAR_AXI_GPIO_0_DEVICE_ID);
XGpio_Initialize(
&output, XPAR_AXI_GPIO_1_DEVICE_ID);
// Data Direction
XGpio_SetDataDirection(&input, 1, 1);
XGpio_SetDataDirection(&output, 1, 0);
// Interrupt Handling
microblaze_register_handler((XInterruptHandler)
formatData, (void*) 0);
microblaze_enable_interrupts();
bInit = true;
while(1)
{
// Endlosschleife
}
cleanup_platform();
return 0;
}
* This application configures UART 16550 to baud rate
* 9600.
* PS7 UART (Zynq) is not initialized by this
* application, since bootrom/bsp configures it
* to baud rate 115200
*
* ------------------------------------------------
* | UART TYPE BAUD RATE |
* ------------------------------------------------
* uartns550 9600
* uartlite Configurable only in HW design
* ps7_uart 115200 (configured by bootrom/bsp)
*/
#include <stdio.h>
#include "platform.h"
#include "xil_printf.h"
#include <xil_exception.h>
#include <xgpio.h>
#include "xparameters.h"
#include <stdbool.h>
XGpio input, output;
bool bInit = false;
void formatData(void)
{
if(bInit)
{
// Daten einlesen vom HLS-IP
short data_in = XGpio_DiscreteRead(&input, 1);
// Konvertieren
float mess = (float) data_in / 4096.0f;
// Generierung 7-Seg-Output (4 Digits)
char str[10];
short data_out;
sprintf(str, "%6.3f", mess);
// Vorzeichen erzeugen
if(mess >= 0.0f)
{
data_out = (str[1] - 48) << 12;
}
else
{
data_out = 10 << 12;
}
data_out += (str[3] - 48) << 8;
data_out += (str[4] - 48) << 4;
data_out += (str[5] - 48);
// Ausgabe auf 7-Seg. via GPIO
XGpio_DiscreteWrite(&output, 1, data_out);
}
}
int main()
{
init_platform();
// Initialisierung
XGpio_Initialize(
&input, XPAR_AXI_GPIO_0_DEVICE_ID);
XGpio_Initialize(
&output, XPAR_AXI_GPIO_1_DEVICE_ID);
// Data Direction
XGpio_SetDataDirection(&input, 1, 1);
XGpio_SetDataDirection(&output, 1, 0);
// Interrupt Handling
microblaze_register_handler((XInterruptHandler)
formatData, (void*) 0);
microblaze_enable_interrupts();
bInit = true;
while(1)
{
// Endlosschleife
}
cleanup_platform();
return 0;
}
Der Code beginnt mit den notwendigen Include-Dateien. Die meisten dieser Dateien sind schon in anderen Beispielen benutzt worden. Neu ist die Include-Datei xil_exception.h, die für die Benutzung des MicroBlaze-Interrupts benötigt wird. In der main-Funktion in Listing 2 wird zunächst der GPIO-Zugriff initialisiert (XGpio_Initialize und XGpio_SetDataDirection). Danach wird die Funktion formatData als Interrupt-Handler definiert (API-Funktion microblaze_register_handler). Die Funktion formatData wird jedes Mal aufgerufen, wenn ein Interrupt-Signal ausgelöst wird. Dieses Signal kommt immer dann aus dem HLS-IP, wenn ein neuer Mittelwert berechnet wurde. Daraufhin wird die Eingabe (GPIO: input) umgerechnet, formatiert und wieder ausgegeben (GPIO: output). Das Verarbeiten der Interrupts wird durch den Aufruf der Funktion microblaze_enable_interrupts aktiviert.
Danach folgt nur noch eine Endlosschleife, die dafür sorgt, dass die Anwendung nicht beendet wird und die angelegten Variablen und der Interrupt-Handler nicht aufgelöst werden.
Die Funktion formatData führt nach dem Einlesen des Messwerts die Konvertierung von einem vorzeichenlosen 12-Bit-Ganzzahlwert in eine Fließkommazahl (Spannung zwischen 0,0 und 1,0 Volt) aus. Danach wird die sprintf-Funktion verwendet, um einen Text-String für die Ausgabe zu erzeugen. Es wird erst auf eine negative Spannung geprüft und gegebenenfalls ein Minuszeichen gesetzt, und danach werden einige Bit-Schiebereien durchgeführt, um die 16-Bit breite Ausgangsvariable für die 7-Segment-Anzeige zu erzeugen, die dann via GPIO an die FPGA-Hardware zurückgegeben wird. Die Bit-Verschiebungen ermöglichen es dem 7-Segment-Treiber (in VHDL: SevenSeg_4digit_0), die richtigen Segmente für die einzelnen Ziffern einzuschalten.
Nachdem der C-Code vollständig eingegeben wurde, kann auch das Application-Projekt übersetzt werden (Im Explorer: rechte Maustaste auf Demo08_application, Befehl Build Project aufrufen).
Und nun der Test!
So, endlich geschafft! Im nächsten Schritt wird der Spannungsteiler an das FPGA-Board angeschlossen (siehe Bild 4 und Bild 5). Spätestens jetzt sollte auch das USB-Kabel zwischen Rechner und Board eingesteckt werden. Nun kann das gesamte Projekt getestet werden:
- Im Explorer: Rechte Maustaste auf Demo08_application, Befehl Debug As >> 1 Launch Hardware (Single Application Debug) aufrufen.
- Die Anwendung wird gestartet und bleibt auf der ersten ausführbaren Code-Zeile stehen. Anmerkung: In diesem Fall wird kein Vitis Serial Terminal benötigt, alle Ausgaben werden auf dem 7-Segment-Display angezeigt.
- Taste [F8] drücken oder in der Toolbar Resume wählen – die Anwendung läuft.
Nun kann man am Potentiometer verschiedene Einstellungen ausprobieren und die gemessene Spannung auf dem 7-Segment-Display beobachten. Die Spannungswerte sollten zwischen circa 0,0 und 1,0 Volt regelbar sein.
Beachten Sie, dass dabei ein gleitender Durchschnitt berechnet wird. Wenn das Potentiometer sehr schnell um einen großen Betrag gedreht wird, dauert es mit den hier gewählten Einstellungen zehn Sekunden, bis der endgültige und korrekte Spannungswert angezeigt wird (also ist nach jedem Verdrehen des Potentiometers etwas Geduld angesagt). Dieses „Einpegeln“ des Messwerts kann durch Änderungen in der HLS-Komponente (Ringpuffer) angepasst werden. So lässt sich durch eine Verkürzung des Messzyklus auf 0,5 Sekunden die „Einpegelzeit“ auf fünf Sekunden verkürzen. Eine Vergrößerung des Ringspeichers verlängert dagegen diese Zeit (Hinweis: Dieses „Verzögerungs-Feature“ ist in vielen realen Schaltungen natürlich oft nicht wünschenswert. Hier wird es nur aus didaktischen Gründen eingefügt).
Nach dem Test kann die Verbindung zum FPGA-Board mit Run | Disconnect unterbrochen werden, und das Board wird ausgeschaltet.
Zusammenfassung
Die neun Artikel dieser Serie über FPGA-Programmierung haben die verschiedenen Möglichkeiten der Erzeugung von Hardware (VHDL, HLS) und Software (C/C++, MicroBlaze) vorgestellt. Außerdem wurde die Zusammenarbeit der einzelnen Bereiche in verschiedenen Beispielen herausgearbeitet und es wurden unterschiedliche Performance-Optimierungen für HLS-Komponenten vorgestellt.
Die Artikel konnten nur die grundlegenden Features der Softwarekomponenten von AMD-Xilinx vorstellen. Wahrscheinlich könnte man noch Dutzende weitere Artikel schreiben und hätte immer noch nicht alle Möglichkeiten thematisiert. Außerdem gibt es natürlich auch von anderen Firmen ähnliche Software-Tools, die ebenfalls hervorragende Ergebnisse liefern. Die AMD-Xilinx-Plattform ist vor allem deshalb interessant, weil es mehrere FPGA-Boards von der Firma Digilent gibt, auf denen sofort und ohne großen Aufwand viele Beispiele ausprobiert werden können.
Es soll an dieser Stelle noch angemerkt werden, dass während der Erstellung der letzten fünf Artikel eine neue Vivado-Version (2023.1) freigegeben wurde. Die hier vorgestellten Demo-Programme können sowohl mit der Version 2022.2 als auch mit Version 2023.1 ausgeführt werden.◾
Dokumente
Artikel als PDF herunterladen
Downloads
Projektdateien herunterladen
Fußnoten
- Bernd Marquardt, Die flexible Hardware, dotnetpro 7/2021, Seite 128 ff.
- Bernd Marquardt, Der Simulant, dotnetpro 8/2021, Seite 130 ff.
- Bernd Marquardt, Hardware durch Software, dotnetpro 9/2021, Seite 127 ff.
- Bernd Marquardt, Stein auf Stein, dotnetpro 1/2022, Seite 120 ff.
- Bernd Marquardt, FPGAs programmieren mit C/C++, dotnetpro 9/2023, Seite 92 ff.
- Bernd Marquardt, Algorithmen per FPGA parallel abarbeiten, dotnetpro 1/2024, Seite 66 ff.
- Bernd Marquardt, HLS-IPs optimieren, dotnetpro 3/2024, Seite 20 ff.
- Bernd Marquardt, Einen RISC-Prozessor bauen, dotnetpro 9/2024, Seite 131 ff.