USB-Tutorial mit STM32

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Die USB-Schnittstelle ist mittlerweile im Consumer-Bereich allgegenwärtig, während aber im Hobby- und auch Industriebereich noch die serielle Schnittstelle (RS232/UART) sehr verbreitet ist. Der Grund dafür dürfte hauptsächlich in der komplizierteren Implementierung von USB liegen, dafür ist USB aber insbesondere für den Anwender deutlich einfacher einzusetzen - ein gut umgesetztes USB-Gerät kann nach dem Anschließen ohne jegliche Konfiguration oder Installation direkt genutzt werden. Da mittlerweile viele direkt USB-fähige Mikrocontroller auch für den Hobby-Entwickler verfügbar sind, ist es an der Zeit sich von der seriellen Schnittstelle zu verabschieden.

Steuerung von LED's vom PC aus via USB und Mikrocontroller, z.B. zur Anzeige der CPU-Last

Dafür soll in diesem Artikel ein Tutorial zur Entwicklung eines eigenen einfachen USB-Geräts gezeigt werden, um dies auch für einfache Hobby-Projekte zugänglich zu machen. Als Mikrocontroller wird der STM32F103RB genutzt, welcher native Unterstützung für USB FullSpeed Devices bietet. Um das Verständnis für die Hardware zu fördern und die komplexe und eher undurchsichtige USB-Bibliothek des Herstellers selbst zu vermeiden, erfolgt der Hardware-Zugriff direkt über die Peripherie-Register. An externem Code wird lediglich die Header-Datei mit den Registerdefinitionen, sowie der obligatorische Startup-Code und Linkerscript verwendet. Somit richtet sich dieses Tutorial an Leser, die USB nutzen und dabei auch verstehen möchten, was genau in der Software passiert. Für komplexe USB-Geräte oder eine schnelle Implementierung sei auf die im STM32CubeF1 enthaltene Bibliothek verwiesen. Es wird Grundlagenwissen über die Programmierung der STM32-Controller und über die C++-Programmierung vorausgesetzt.

Zunächst wird ein "USB Hello-World" entwickelt, welches dem PC die Steuerung von LEDs ermöglicht sowie in der Art eines "Loopbacks" empfangene Daten byteweise umdreht und zurücksendet. Dieses Device gehört keiner Standard-Klasse an, sondern wird von einer eigenen Anwendung gesteuert. Es wird eine Möglichkeit gezeigt, wie dies auch unter aktuellen Windows-Versionen ohne manuelle Treiber-Installation oder sonstige Konfiguration gelingt. Als zweites Beispiel erfolgt die Implementierung eines 3-fach-Adapters von USB auf die serielle Schnittstelle (VCP, Virtual COM Port) auf Basis der Standard-Klasse CDC-ACM, was somit ebenfalls ohne Treiber-Installation funktioniert. Als weiteres einfaches Beispiel wird eine Firmware gezeigt, welche vom Host einen Strom von 500mA anfordert und die Freigabe über einen Pin signalisiert, was zur Versorgung eigener Projekte per USB genutzt werden kann.

Ein Artikel von Niklas Gürtler. Feedback und Fragen können im zugehörigen Thread abgegeben werden.

Einleitung

USB-Grundlagen

Im Folgenden werden knapp die relevanten Basics der USB-Schnittstelle aufgezählt:

  • USB wurde ursprünglich für die Verbindung von PCs mit diversen Peripheriegeräten entwickelt. Es gibt mehrere Versionen (1.0, 1.1, 2.0, 3.0, 3.1) die insbesondere unterschiedliche Geschwindigkeiten ermöglichen. Im Folgenden wird nur auf USB 2.0 im FullSpeed Modus eingegangen, was Geschwindigkeiten bis 12 MBit/Sec ermöglicht. Dies ist noch relativ einfach auf Mikrocontrollern umzusetzen.
  • USB-Geräte sind immer entweder ein "Host" oder ein "Device". "USB On-the-Go" (OTG)-Geräte können zwischen den beiden Rollen umschalten. Die (logische) Kommunikation erfolgt immer zwischen einem Host und einem Device; Hosts und Devices können untereinander jeweils nicht kommunizieren. An einem Host können aber mehrere Devices angeschlossen werden. Die meisten PCs haben mehrere USB-Host-Controller (allein schon um die unterschiedlichen Standards zu unterstützten), die wiederum meist jeweils mehrere USB-Ports versorgen.
  • In der USB-Spezifikation fest vorgesehen sind USB-Hubs, mit denen mehrere Devices an einem Anschluss des Hosts betrieben werden können. Mit USB-Hubs kann eine Baumstruktur aufgebaut werden, an deren Wurzel der Host steht, deren Blätter die Devices sind und die inneren Knoten die Hubs. Es sind keine Kreise möglich. Es handelt sich also um eine Stern-Topologie mit mehreren Ebenen (max. 5). Jeder Host kann max. 127 Geräte nutzen.
  • Jedes Device "sieht" nur den Host - Hubs und andere Devices sind transparent bzw. "unsichtbar". Daher wird im Folgenden von einer direkten Kommunikation Host<->Device ausgegangen.
  • Es sind diverse Standard-Klassen mit vorgegebenen Protokollen definiert, denen Geräte entsprechen können um mit denen bei Betriebssystemen mitgelieferten Standard-Treibern zu funktionieren. Geräte können sich aber auch als "herstellerspezifisch" anmelden und funktionieren dann nur mit eigenen Treibern.
  • Jedes USB-Gerät wird über zwei fest einprogrammierte 16bit-Zahlen, die Vendor ID (VID) und Product ID (PID) identifiziert. Zur Vermeidung von Überschneidungen wird die VID vom USB Implementers Forum verwaltet und vergeben; eine Eigene zu erhalten kostet derzeit einmalig 5000$ oder 4000$ jährlich. Dies ist für Hobby-Entwickler wenig realistisch, weshalb bei solchen Projekten oft die vorhandene VID eines Herstellers "geborgt" oder eine Fantasie-Zahl wie z.B. "0xDEAD" genutzt wird, die vermutlich nie vergeben wird. Dann sollte man den Nutzern des Geräts auf jeden Fall ermöglichen, die VID zu ändern, falls Kollisionen auftreten. Eine andere Möglichkeit bietet pid.codes. Die PID wird vom Hersteller nach Belieben vergeben.
  • Jegliche Kommunikation wird vom Host aus gesteuert. Der Host sendet Daten an Geräte und fragt Daten ab; ein Gerät kann niemals selbstständig eine Kommunikation beginnen.
  • Bis USB 2.0 wird nur eine Datenleitung verwendet, welche aber doppelt ausgeführt ist (differentielle Übertragung) und bidirektional genutzt wird.
  • Jedes Gerät hat 1-16 sogenannte "Endpoints", diese sind mit Ports bei TCP zu vergleichen. Die Kommunikation läuft im Wesentlichen so ab, dass Datenpakete an Endpoints geschickt bzw. von Endpoints abgefragt werden. Die Richtung Host->Device heißt dabei "OUT" und Device->Host heißt "IN". Dies ist aus Sicht der Geräteentwicklung etwas verwirrend, sollte aber konsistent beibehalten werden. Die beiden Richtungen eines jeden Endpoints können laut USB-Spezifikation individuell konfiguriert werden, aber beim hier genutzten STM32F103 gehören sie eng zusammen. Alle Geräte müssen einen Endpoint 0 haben, über den die Kommunikation zum Erkennen und Konfigurieren des Geräts abläuft.
  • Endpoints können verschiedene Typen haben:
    • Bulk Endpoints sind für die Übertragung größerer Datenmengen mit garantierter Konsistenz ohne Timing-Anforderung; ähnlich einem TCP-Socket. Wird typischerweise bei Speichermedien oder virtuellen COM-Ports genutzt.
    • Control Endpoints sind Bulk-Endpoints sehr ähnlich und definieren ein auf der Paketübertragung aufbauendes Frage-Antwort-Protokoll. Der Endpoint 0 ist immer ein Control Endpoint.
    • Interrupt Endpoints sind für spontane unregelmäßige geringe Datenmengen mit garantierter Latenz. Wird z.B. für HID-Geräte genutzt (Mäuse, Tastaturen u.a.).
    • Isochronous Endpoints sind für geringe Datenmengen mit fixer Timing-Anforderung ohne garantierte Konsistenz, beispielsweise für Audio/Video-Anwendungen.
  • Geräte erhalten bei der Verbindung mit dem Host eine neue Adresse im Bereich 1-127. Bei der initialen Kommunikation ist die Adresse noch 0.
  • Der Host kann ein Device zurücksetzen ("reset"). Dies geschieht durch ein Ausbleiben der regelmäßig gesendeten "SOF"-Pakete. Das geschieht insbesondere beim erstmaligen Anschließen des Geräts.
  • Zur Kommunikation mit dem Device ist auf Host-Seite ein Treiber nötig. Bei den Standard-Klassen sind diese bei den Betriebssystemen mitgeliefert. Die Treiberentwicklung ist ein großer Aufwand, insbesondere unter Windows ist die erforderliche Signierung eine Hürde. Stattdessen kann von Anwendungen aus auch direkt auf die Geräte zugegriffen werden:
    • Der Linux-Kernel stellt dafür via udev die Geräte-Dateien in /dev/bus/usb zur Verfügung, auf die von Anwendungen zugegriffen werden kann
    • Unter Windows kann für ein Gerät der "WinUSB"-Treiber geladen werden, über dessen API Anwendungen mit Geräten kommunizieren können
    • Für beide Varianten bietet libusb einen Wrapper, welche den plattformunabhängigen einfachen Zugriff auf Geräte ermöglicht. Dies wird auch im Beispiel gezeigt.
    • Es kann so aber nur eine Anwendung gleichzeitig auf das Gerät zugreifen; diese kann notfalls die Zugriffe weiterleiten.
    • Für Generic-HID-Geräte sind Treiber ab Windows 98SE bei der Standardinstallation dabei. Eine Windows-Applikation kann ohne Installation eines (eigenen) Treibers (per VID/PID) über den HID-Treiber auf das Gerät lesend und schreibend zugreifen. Einschränkung: Weniger als 64000 Byte/Sekunde möglich (Maximalgröße eines HID-Reports mal Abfragerate), keine Bulk-Endpoints.

Vergleich mit serieller Schnittstelle

Vorteile USB

  • Höhere Geschwindigkeit (hier: 12MBit/Sec)
  • Einfache Nutzung für Endanwender ("Plug and Play"), keine Konfiguration von Baudrate/Portnummer nötig
  • Anwendungen können anhand VID/PID direkt das korrekte Gerät finden, es muss nicht wie bei der seriellen Schnittstelle der richtige Port ausgewählt werden
  • Stromversorgung der Geräte möglich
  • Je nach Controller geringerer Hardware-Aufwand als RS-232 (wg. Pegelwandler)
  • Standard-Treiber für typische Anwendungen im Betriebssystem verfügbar
  • Das USB-Protokoll teilt den Datenstrom explizit in Pakete ein, im Gerät ist direkt klar wo ein Paket anfängt und wo es endet, während man bei der seriellen Schnittstelle ein Protokoll benötigt, um Paketanfänge zu erkennen (z.B. an Pausen)
  • USB enthält eine Flusskontrolle, es können in beide Richtungen nur Daten gesendet werden, wenn der Empfänger bereit ist. Somit können keine Puffer-Überläufe auftreten.
  • USB erkennt automatisch das Verbinden/Trennen der Gegenstelle, es ist keine manuelle Erkennung per "Ping" o.ä. nötig

Vorteile Serielle Schnittstelle

  • Deutlich einfacher in der Implementierung
  • Praktisch jeder Controller bietet UART-Module zur Unterstützung der seriellen Schnittstelle
  • Controller können auch problemlos untereinander direkt kommunizieren (USB-Host Implementierung ist aufwendig)
  • Quarzloser Betrieb möglich (Die meisten, aber nicht alle, Mikrocontroller benötigen einen Quarz für USB).
  • Gar keine Treiber-Installation nötig
  • Keine VID/PID nötig
  • Kommunikation reißt nicht ab, wenn Controller längere Zeit nicht antwortet, während USB kurze Timeouts bei der Enumeration hat (Anhalten des Controllers z.B. zum Debuggen während der Enumeration führt zu sofortiger Abmeldung des Geräts)

Hardware & Beschaltung

Wie die Controller selbst unterscheiden sich auch die USB-Peripheriemodule. Die STM32 sind mit drei verschiedenen USB-Peripherien verfügbar:

  • Das einfach nur USB genannte Modul der kleineren Controller unterstützt nur den Device-Modus und nur FullSpeed.
  • Das OTG_FS-Modul unterstützt OTG und kann somit auch als Host agieren und ist komplizierter zu programmieren.
  • Das OTG_HS-Modul unterstützt zusätzlich USB High Speed (480 MBit/Sec).

Hier wird ein Controller mit der einfachsten Variante gewählt, der STM32F103RB. Dieser ist beispielsweise auf dem Olimexino-STM32 zu finden, im Folgenden wird dieses als Grundlage für die Beispiele genutzt. Der auf den günstigen Blue Pill-Boards zu findende STM32F103C8 kann ebenfalls genutzt werden - dazu müssen im Linkerscript die Speichergröße und im Code die genutzten Pins angepasst werden.

USB-Beschaltung des Olimexino-STM32

Bei der Entwicklung eigener Platinen kann der Schaltplan das Olimexino als Inspiration genutzt werden. Die Pins PA11 und PA12 des Controllers müssen mit D- bzw. D+ der USB-Buchse verbunden werden.

Die einzig erforderliche zusätzliche Komponente ist der 1,5kΩ-Widerstand von der USB-Datenleitung D+ nach +3,0V - +3,6V. An diesem Widerstand erkennt der Host das angeschlossene Gerät. Wird der Widerstand schaltbar ausgeführt, z.B. über einen PNP-Transistor, kann das Gerät die Verbindung trennen, aber noch eingeschaltet bleiben. Das ist zum Testen praktisch - startet man eine neue Debugging Session, wird vor dem Start des Hauptprogramms der Widerstand zunächst abgeschaltet, sodass der Host das zuvor ggf. fehlerhaft erkannte Gerät vergisst, und dann das neu gestartete Programm erneut ansteuert sobald der Widerstand aktiviert ist.

Bei der Verwendung von USB ist außerdem ein Quarz Pflicht, damit die Frequenz exakt gehalten werden kann.

Hinweis zum "Blue Bill" Board

Beim Blue Pill-Board ist der o.g. Pull-Up Widerstand am USB D+ Pin (PA12 am STM32F103) fix verdrahtet und nicht abschaltbar; die entsprechenden Code-Zeilen (s.u.) müssen dann entfernt werden. Einige neuere Controller haben diesen Widerstand auch schaltbar integriert.

Wenn man sich die externe Beschaltung wie oben gezeigt sparen will, bietet sich folgende Lösung an:

  • Pin PA12 als Open Drain Output konfigurieren.
  • Pin per Software auf "0" setzen.
  • Kurz warten.
  • Pin wieder als Floating Input konfigurieren.

Das sorgt dafür, dass der externe 1,5kΩ Pull-Up Widerstand kurzzeitig über den PA12 Pin nach GND gezogen wird und der USB Host die Bus Enumeration neu startet. Die dann über den Pin fließenden I = U / R = max. 3.6V / 1.5kΩ = 2.5 mA stellen lt. Datenblatt für den µC kein Problem dar.

Wichtig: Die Sequenz muss ausgeführt werden, bevor der USB Transceiver aktiviert wird, d.h. vor der weiter unten gezeigen "connect()" Funktion. Der Grund ist, dass der Pin intern automatisch auf den USB Core verbunden wird, sobald dieser aktiviert wird (vgl. STM32F1 CPU Reference Manual RM0008 Rev 20, Seite 168, Sect. 9.1.11, Table 29).

    // Pin PA12 als Open Drain Output konfigurieren
    GPIOA->CRH &= ~(0b11'11 << 16);
    GPIOA->CRH |=  (0b01'10 << 16);

    // Setze PA12 als "low"
    GPIOA->ODR &= ~(0b1 << 12);

    // Kurz warten...
    delay();

    // Pin PA12 wieder als Floating Input konfigurieren.
    GPIOA->CRH &= ~(0b11'11 << 16);

Beispielprojekt

Das Ergebnis dieses Tutorials ist als Beispielprogramm über GitHub verfügbar. Die vier hier gezeigten Varianten sind dort als einzelne Branches ausgeführt. Der Code ist nicht als fertig einzusetzende Bibliothek konzipiert (davon gibt es bereits einige), sondern dient der Illustration bei der Durcharbeitung des Tutorials. Im Repository sind Projektdateien für die folgenden Entwicklungsumgebungen enthalten:

Der Beispielcode benötigt einen Compiler mit C++11-Unterstützung, was von den aktuellen Versionen der genannten Umgebungen geboten wird. Das SW4STM32-Projekt ist für die Nutzung mit dem ST-Link konfiguriert, die anderen für den SEGGER J-Link. Das kann aber jeweils auch für andere Debugger angepasst werden (entsprechende Launch Configurations hinzufügen). Das Projekt kann als Ausgangspunkt für eigene Modifikationen genutzt werden, oder als leere Vorlage durch Löschen der Dateien im "src"-Ordner und Neuschreiben des Codes nach diesem Tutorial. Auf GitHub sind auch die Kompilate als Binärdateien zum direkten Flashen verfügbar.

Debugging

Zur Fehlersuche ist dringend die Verwendung eines Debuggers empfohlen, wie z.B. des ST-Link oder des SEGGER J-Link. Breakpoints sind aber mit Vorsicht zu genießen, denn wenn bei der Enumerierung eines USB-Geräts dieses angehalten wird und nicht mehr auf Anfragen vom Host reagiert, der Host das Gerät als offline ansieht und die Verbindung trennt. Ist das Gerät aber erst einmal vollständig erkannt, dürfen durchaus Pausen eingelegt werden - ggf. anstehende Paket-Transfers müssen dann eben warten. Die Verwendung von Linux zum Testen ist sinnvoll, weil der Linux-Kernel relativ hilfreiche Fehlermeldungen bei sich falsch verhaltenden Geräten ausgibt. Diese sind im Kernel-Log zu finden und bspw. über den "dmesg"-Befehl abzurufen. Zusätzlich ist Wireshark's Fähigkeit, USB-Traffic anzuzeigen, sehr hilfreich. Da dabei auch die Daten aller anderen angeschlossenen Geräte mit angezeigt werden, ist es sinnvoll, zum Testen ein Notebook zu nutzen, an dem nur das eigene Gerät hängt. Bei einem Desktop-PC kann man herausfinden, welche USB-Ports zu welchem USB-Host-Controller gehören, und einen Controller ausschließlich für das eigene Gerät reservieren.

Literatur

Dieses Tutorial ist im Endeffekt eine übersichtlichere Zusammenstellung vorhandener Informationen. Viele weitere Details finden sich in den entsprechenden Dokumenten:

Hello-World per USB

Als erstes wird ein einfaches Testprogramm ohne großen Nutzen geschrieben, welches die Grundfunktionalität verdeutlicht. Die erste Herausforderung besteht darin, dass sich das Gerät korrekt am Host anmelden soll - ist das geschafft, können eigene Funktionen implementiert werden. Wir starten zunächst mit einem leeren Projekt, in dem die grundlegende Umgebung bereits eingerichtet ist (Linker-Script, Startup-Code) und der Haupt-Takt auf 72 MHz konfiguriert ist (Quarz und PLL eingeschaltet). Im Beispielprojekt kann hier der Branch "minimal" genutzt werden.

Aktivierung der Peripherie

Das Einschalten des USB-Peripheriemoduls ist noch recht einfach. Zunächst müssen die Peripherietakte und der Pin für den 1,5kΩ-Widerstand konfiguriert werden. Die USB-Pins sind im Port A, aber der muss nicht aktiviert werden, USB funktioniert auch so.

void init () {
	// Aktiviere USB-Takt
	RCC->APB1ENR |= RCC_APB1ENR_USBEN_Msk;
	RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
	// Konfiguriere Pin für 1.5kOhm-Widerstand, und schalte Pin auf high, s.d. Widerstand aus ist
	GPIOC->CRH = 0x44474444;
	GPIOC->BSRR = GPIO_BSRR_BS12;
	// Schalte USB Interrupt ein
	NVIC_EnableIRQ (USB_LP_CAN1_RX0_IRQn);
}

Um dann tatsächlich eine Verbindung zu initiieren, muss laut Controller-Manual eine bestimmte Sequenz beachtet werden. Das standardmäßig aktive Bit "PDWN" im "CNTR" Register wird ausgeschaltet, so dass der Transceiver aktiviert wird. Danach müssen wir 1µs warten (tSTARTUP). Das wird hier mit einer einfachen Schleife realisiert, welche mindestens 72 Takte braucht. Dann kann auch das "FRES" -Bit abgeschaltet werden - danach ist die Peripherie sofort bereit. Es müssen lediglich noch die Interrupts konfiguriert werden. Wir aktivieren nur die Interrupts "CTR" (Transfer abgeschlossen) und "RESET" (Host setzt Gerät zurück - passiert normalerweise beim Verbinden). Beide Interrupt-Quellen lösen im Interrupt-Controller den selben Interrupt aus, den wir auch aktivieren. Zuletzt signalisieren wir dem Host durch Einschalten des 1,5kΩ-Widerstands, dass ein Gerät vorhanden ist:

static void delay () {
	for (int i = 0; i < 72; ++i) __NOP ();
}
void connect () {
	// Schalte Transceiver ein, lasse Logik aus
	USB->CNTR = USB_CNTR_FRES;
	// Warte auf Hochfahren der analogen Schaltung (tSTARTUP)
	delay ();
	// Schalte USB ein, aktiviere Interrupts
	USB->CNTR = USB_CNTR_CTRM | USB_CNTR_RESETM;
	// Lösche alle Interrupts außer Reset-Interrupt
	USB->ISTR = USB_ISTR_RESET_Msk;
	NVIC_ClearPendingIRQ (USB_LP_CAN1_RX0_IRQn);
	// Schalte 1.5kOhm-Widerstand ein, s.d. Host das Gerät erkennt.
	GPIOC->BSRR = GPIO_BSRR_BR12;
}

Die beiden gezeigten Funktionen rufen wir von der main() aus auf, und starten dann eine Endlosschleifen. Der Linux-Kernel erkennt das Vorhandensein des Geräts, und beschwert sich prompt darüber, dass das Gerät nicht auf Anfragen antwortet, wie im Syslog zu erkennen ist:

[20661.625605] usb 2-2: new full-speed USB device number 17 using xhci_hcd
[20661.737623] usb 2-2: device descriptor read/64, error -71

Hier beginnt der schwierige Teil. Wir müssen die Anfragen empfangen und verarbeiten. Doch dazu ist einiges an Vorarbeit nötig.

Endpoints & Puffer

Die USB-Peripherie des genutzten Controllers kann einzelne Pakete senden und empfangen, und benachrichtigt die Software per Interrupt über dessen Vervollständigung. Um die Daten den einzelnen Endpoints zuzuordnen, besitzt die Peripherie 8 Endpoint-Puffer. Jeder dieser Puffer wird auf eine Endpoint-Adresse (0-15) eingestellt, und kann dann Daten für diesen Endpoint senden und empfangen. Die Endpoint-Puffer sind von 0-7 durchnummeriert, aber diese Nummer ist nur für die Software des Controllers relevant, und entspricht nicht notwendigerweise der Nummer des Endpoints (0-15) auf dem Bus, die auch für den Host sichtbar ist. So könnte z.B. Endpoint-Puffer 0 dem Endpoint 7 zugeordnert werden und Endpoint-Puffer 5 dem Endpoint 0. Der Typ eines Endpoints wird pro Endpoint-Puffer eingestellt, und ein Endpoint-Puffer bearbeitet immer beide Richtungen (IN und OUT), somit müssen beide Richtungen vom gleichen Typ sein. Dies ist eine Einschränkung des STM32F103 - laut USB Spezifikation können die beiden Richtungen auch unterschiedlichen Typs sein. Außerdem können eben nur 8 Endpoints genutzt werden und nicht das von der Spezifikation vorgegebene Maximum von 16.

Jeder Endpoint-Puffer der Nummer i besteht aus zwei Elementen: Eintrag i der Buffer Descriptor Table und das Register EPiR. Diese werden im Folgenden beschrieben.

Der USB-Pufferspeicher

Für den Zugriff auf die empfangenen bzw. zu sendenden Daten wird aber nicht wie bei anderen Peripheriemodulen DMA eingesetzt, sondern die Peripherie hat ihren eigenen Pufferspeicher. Dieser ist 512 Byte groß und wird auch für das CAN-Modul genutzt - daher können USB und CAN nicht gleichzeitig verwendet werden. Auf diesen Puffer können wir per Software direkt zugreifen, die Hardware simuliert hier einen Dual-Port-RAM. Die Struktur des Puffers ist nicht vorgegeben - wir müssen der Hardware mitteilen, was wo gespeichert werden soll.

Schematische Darstellung der Struktur des USB-Pufferspeichers

Der Zugriff auf den Puffer von der Software-Seite ist etwas unintuitiv: Die Hardware sieht den Puffer als eine Folge von 512 Bytes, beginnend ab der Adresse 0. Der Prozessorkern und damit die Software sieht den Puffer als Folge von 256 16bit-Worten, zwischen denen jeweils eine 16bit-Lücke ist, an der nichts gespeichert werden kann. Somit erscheint der Puffer der Software als 1024 Bytes groß, wobei die Hälfte Lücken sind. Aus Software-Sicht beginnt der Puffer ab Adresse 0x40006000. Im Bild ist dies grafisch dargestellt. Beim Schreiben bzw. Lesen von Paket-Daten im Pufferspeicher muss dies berücksichtigt werden.

Die Einteilung des Pufferspeichers in Bereiche für die einzelnen Pakete muss durch die Software vorgenommen werden. Da man sich hier schnell verrechnen kann, überlassen wir diese Aufgabe einem Programm, das genau darauf optimiert ist: Dem Linker. Dazu legen wir im Linker-Script (STM32F103RB.ld) innerhalb des "MEMORY"-Blocks einen weiteren Speicherbereich für den USB-Pufferspeicher an:

MEMORY {
	FLASH		: ORIGIN = 0x8000000,	LENGTH = 128K
	SRAM		: ORIGIN = 0x20000000,	LENGTH =  20K
	USBBUF		: ORIGIN = 0x40006000,	LENGTH = 1024
}

Dazu geben wir Größe und Adresse aus Sicht des Prozessors (daher 1024 Bytes) an. Dann übernehmen wir alle Daten in der Eingabe-Section ".usbbuf" in diesen Speicher, indem wir folgendes innerhalb der "SECTIONS"-Anweisung ablegen:

	.UsbBuffer (NOLOAD) : {
		UsbBufBegin = .;
		*(.usbbuf)
		*(.usbbuf*)
	} > USBBUF

Das NOLOAD sorgt dafür, dass dort abgelegte Daten nicht automatisch initialisiert werden. Jetzt können wir im Code globale Variablen anlegen und speziell markieren, sodass Compiler & Linker sie in diesem Speicherbereich ablegen, d.h. ihnen eine Adresse im gewünschten Bereich zuweisen, die beim Zugriff aus dem Code heraus angewendet wird. Da dies etwas umständlich ist, definieren wir dafür ein Makro. Das geschieht funktioniert dann so:

#define USB_MEM __attribute__((section(".usbbuf")))
uint16_t myBufferData USB_MEM;

Zugriffe auf myBufferData werden dann auf den USB-Pufferspeicher umgeleitet. Um ganze Pakete ablegen zu können, möchten wir Arrays verwenden, bei denen flexibel die Größe geändert werden kann. Dabei muss aber für den Software-Zugriff die Lücke nach jedem 16bit-Wort beachtet werden. Daher definieren wir eine Klasse "UsbMem":

class UsbMem {
	public:
		uint16_t data;
	private:
		char padding [2];
};

Sie ist 4 Bytes groß, aber nur die ersten 2 Bytes sind als 16bit-Wort zugänglich. Legen wir davon jetzt ein Array im Pufferspeicher an, können wir da unsere Paketdaten hineinschreiben. Dabei ist es aber etwas lästig, dass die Größe jetzt in 16bit-Wörtern statt wie üblich in Bytes angegeben werden muss. Daher definieren wir eine template-Klasse namens "UsbAlloc", welcher die gewünschte Größe in Bytes übergeben wird, und die dann ein Array der korrekten Größe enthält. Wie wir später sehen werden, unterliegt die Größe weiteren Einschränkungen, damit der Puffer genutzt werden kann. Diese überprüfen wir mit static_assert, um das Einstellen einer ungültigen Größe zu vermeiden. Den []-Operator überladen wir, um den Zugriff zu vereinfachen:

template <size_t N>
struct UsbAlloc {
	static_assert (((N <= 62) && (N%2 == 0)) || ((N <= 512) && (N % 32 == 0)), "Invalid reception buffer size requested");
	static constexpr size_t size = N;

	/// Das eigentliche Daten-Array
	UsbMem data [N/2];
	/// Bietet Zugriff auf 16bit-Word "i".
	usb_always_inline uint16_t& operator [] (size_t i) {
		return data [i].data;
	}
};

Wenn wir jetzt der Peripherie eine Adresse im Pufferspeicher mitteilen möchten, müssen wir die Adresse aus Peripherie-Sicht angeben. Wenn wir die Adresse einer solchen Variablen per &-Operator abfragen, erhalten wir aber die Adresse aus Prozessor-Sicht. Um diese umzurechnen, definieren wir uns eine Hilfsfunktion:

template <typename T>
usb_always_inline uint16_t mapAddr (T* addr) {
	// Die Anfangsadresse wird im Linkerscript definiert und hier referenziert.
	extern char UsbBufBegin;
	// Ziehe Adresse von Anfangsadresse ab und teile durch 2 um Lücken herauszurechnen.
	return static_cast<uint16_t> ((reinterpret_cast<uintptr_t> (addr) - reinterpret_cast<uintptr_t> (&UsbBufBegin)) / 2);
}

Wird ein Zeiger auf eine Variable beliebigen Typs im Pufferspeicher übergeben, subtrahiert die Funktion von dieser die Anfangsadresse des Pufferspeichers, teilt das Ergebnis durch 2 um die Lücken herauszurechnen, und gibt das Resultat als 16bit-Integer zurück.

Die Buffer Descriptor Table

Damit die Peripherie weiß, wo die Daten eines bestimmten Endpoints abgelegt sind, müssen ihr die Adressen und die Größe der selbst angelegten Arrays im Pufferspeicher mitgeteilt werden. Dies geschieht über die Puffer Descriptor Table, welche für jeden der 8 Endpoint-Puffer vier 16bit-Werte speichert:

  • Die Transmission buffer address speichert die Startadresse des aktuell zu sendenden Pakets im Pufferspeicher.
  • Die Transmission byte count gibt die Anzahl an Bytes im zu sendenden Paket an.
  • Die Reception buffer address gibt die Startaddresse im Pufferspeicher an, an der das aktuell zu empfangende Paket abgelegt werden soll.
  • Die Reception byte count gibt in einem komprimierten Format die Anzahl zu empfangender Bytes sowie die Anzahl tatsächlich empfangener Bytes an. Die Anzahl zu empfangender Bytes muss eine der folgenden Bedingungen erfüllen:
    • Sie muss <= 62 und gerade sein
    • Sie muss <= 512 und ein Vielfaches von 32 sein.

Die Buffer Descriptor Table befindet sich selbst auch im USB-Pufferspeicher. Damit die Peripherie weiß wo, muss die Adresse im Register USB->BTABLE angegeben werden. In der Tabelle im Pufferspeicher sind die 4 16bit-Werte wie erläutert mit 16bit-Lücke hintereinander abgelegt, 8 mal hintereinander (einmal pro Endpoint-Puffer). Um diese Struktur in Software abzubilden, wird ein struct definiert und davon ein acht-elementiges Array angelegt:

struct EP_BufDesc {
	// Anfang des Sendepuffers
	uint16_t txBufferAddr;
	uint16_t Padding1;
	// Größe des Sendepuffers in Bytes
	uint16_t txBufferCount;
	uint16_t Padding2;
	// Anfang des Empfangspuffers
	uint16_t rxBufferAddr;
	uint16_t Padding3;
	// Größe und Füllstand des Empfangspuffers; Spezialformat siehe Reference Manual
	uint16_t rxBufferCount;
	uint16_t Padding4;
};
alignas(8) EP_BufDesc BufDescTable [8] USB_MEM;

Die vier "Padding"-Variablen sorgen dafür, dass das Layout dem des Pufferspeichers mit Lücken entspricht. Per "alignas" wird sichergestellt, dass die Adresse des Arrays ein Vielfaches von 8 ist, was von der Peripherie gefordert wird. Über BufDescTable[i] kann somit auf die vier Werte von Endpoint-Puffer i zugegriffen werden.

Die EPnR-Register

Zu jedem Endpoint-Puffer gehört eines der acht EPnR-Register (EP0R, EP1R, ..., EP7R). Darüber können verschiedene Dinge konfiguriert werden:

  • Zustand des Puffers - ob Senden/Empfangen aktiviert ist, und wenn nicht welcher Fehler dem Host signalisiert wird
  • Typ des Endpoints (Bulk, Control, Isochronous, Interrupt). Gilt für beide Richtungen.
  • Nummer des Endpoints aus Host-Sicht (0-15).

Es kann abgelesen werden, ob ein Datenpaket komplett empfangen/gesendet wurde und ob ein "Setup"-Paket empfangen wurde. Außerdem kann für die Übertragung zwischen DATA0/DATA1 umgeschaltet werden - dazu später mehr. Im Header von ST sind die EPnR-Register nur einzeln definiert (EP0R, EP1R, ...). Wenn der Index des gewünschten Registers erst zur Laufzeit bekannt ist, kann man nicht direkt auf das Register zugreifen. Daher definieren wir uns ein Array das an die richtige Stelle im Adressraum gemappt wird, um darüber komfortabel auf die Register per Index zugreifen zu können. Zwischen den einzelnen Registern ist je eine 16bit-Lücke, obwohl sich diese nicht im USB-Pufferspeicher befinden. Indem die zuvor definierte Klasse "UsbMem" im Array wiederverwendet wird, wird die Lücke beim Zugriff übersprungen:

static __IO UsbMem (&EPnR) [numEP] = *reinterpret_cast<__IO UsbMem (*)[numEP]> (USB_BASE);

Somit entspricht z.B. EPnR[3].data eben EP3R. Die EPnR-Register haben noch eine Eigenart: Die verschiedenen Bits werden auf unterschiedliche Art geschrieben. Einige Bits bleiben beim Schreiben von 0 unverändert, und schalten beim Schreiben von 1 um ("Toggle"). Andere bleiben beim Schreiben von 1 unverändert, und werden beim Schreiben von 0 auf 0 gesetzt. Der Rest verhält sich normal (nimmt den geschriebenen Wert direkt an). Um Schreibzugriffe zu vereinfachen, definieren wir uns eine Funktion setEPnR, welcher man die gewünschten Endwerte der Bits sowie die überhaupt zu schreibenden Bits als Bitmaske übergibt, und die dann automatisch die richtige Schreiboperation durchführt:

void setEPnR (uint8_t EP, uint16_t mask, uint16_t data, uint16_t old) {
	// Diese Bits werden beim Schreiben von 0 gelöscht und bleiben bei 1 unverändert.
	constexpr uint16_t rc_w0 = USB_EP_CTR_RX_Msk | USB_EP_CTR_TX_Msk;
	// Diese Bits werden beim Schreiben von 1 umgeschaltet, und bleiben bei 0 unverändert.
	constexpr uint16_t toggle = USB_EP_DTOG_RX_Msk | USB_EPRX_STAT_Msk | USB_EP_DTOG_TX_Msk | USB_EPTX_STAT_Msk;
	// Diese Bits verhalten sich "normal", d.h. der geschriebene Wert wird direkt übernommen.
	constexpr uint16_t rw = USB_EP_T_FIELD_Msk | USB_EP_KIND_Msk | USB_EPADDR_FIELD;

	// Prüfe zu löschende Bits
	uint16_t wr0 = static_cast<uint16_t> (rc_w0 & (~mask | data));
	// Bei Bits mit Umschalte-Verhalten muss der alte Zustand beachtet und per XOR verarbeitet werden
	uint16_t wr1 = (mask & toggle) & (old ^ data);
	// Bei "normalen" Bits wird der alte Zustand beibehalten oder auf Wunsch überschrieben.
	uint16_t wr2 = rw & ((old & ~mask) | data);

	// Kombiniere alle drei Schreibmethoden.
	EPnR[EP].data = static_cast<uint16_t> (wr0 | wr1 | wr2);
}

Ohne eine solche Funktion sind eine Menge Fehler beim Schreiben des Gesamtprogramms vorprogrammiert.

Der USB-Interrupt

Ein Großteil der USB-Ansteuerung wird im von der USB-Peripherie ausgelösten Interrupt geschehen. Dort wird das USB->ISTR Register abgefragt, um zu prüfen welches Ereignis aufgetreten ist. Die Behandlung wird in eine Schleife verpackt, um während der Verarbeitung aufgetretene weitere Ereignisse mit abzufangen:

/// Globaler Interrupt für die USB-Peripherie.
extern "C" void USB_LP_CAN1_RX0_IRQHandler () {
	uint16_t ISTR;
	// Nur diese Interrupts werden verarbeitet
	const uint16_t interrupts = USB_ISTR_RESET | USB_ISTR_CTR;
	// Bearbeite in einer Schleife so lange aufgetretene Ereignisse, bis die USB Peripherie keine weiteren signalisiert.
	while (((ISTR = USB->ISTR) & interrupts) != 0) {
		// Ein "RESET" tritt auf beim Ausbleiben von Paketen vom Host. Dies ist zu Beginn jeder Verbindung der Fall,
		// bis der Host das Device erkennt.
		if (ISTR & USB_ISTR_RESET) {
			// Lösche Interrupt
			USB->ISTR = USB_ISTR_PMAOVR | USB_ISTR_ERR | USB_ISTR_WKUP | USB_ISTR_SUSP | USB_ISTR_SOF | USB_ISTR_ESOF;
			// ...
		}
		// CTR signalisiert die Beendigung eines korrekten Transfers
		if (ISTR & USB_ISTR_CTR) {
			// ...
		}
	}
}

Der Reset-Interupt wird durch schreiben des "USB_ISTR_RESET"-Bits im ISTR-Registers auf 0 zurückgesetzt. Indem die anderen Bits auf 1 geschrieben werden, bleibt ihr aktueller Zustand erhalten. Der Transfer-Interrupt wird später über die EPnR-Register quittiert.

Initial-Konfiguration beim Reset

Beim vom Host ausgelösten USB-Reset vergisst die USB-Peripherie alle vorherigen Einstellungen. Daher müssen wir auf den Reset-Interrupt reagieren und dort die Endpoint-Puffer neu konfigurieren. Da dies auch beim erstmaligen Verbinden geschieht, brauchen wir woanders keine weitere Initialisierung. Da der Host beim Verbinden ein Gerät in einem definierten Zustand erwartet, sollten wir in diesem Interrupt auch alle weitere Funktionalität des Programms zurücksetzen. Als erstes legen wir uns eine globale Variable EP0_BUF an zum Verarbeiten der Pakete auf Endpoint 0:

alignas(4) static UsbAlloc<64> EP0_BUF	USB_MEM;

In die oben gezeigte USB-ISR fügen wir im Abschnitt für den Reset den folgenden Code ein:

// Bringe Hard-und Software in definierten Ausgangszustand
// Mache der Peripherie die Buffer Descriptor Table bekannt
USB->BTABLE = mapAddr (BufDescTable);
// Neu verbundene Geräte haben Adresse 0. Speichere diese im USB-Register.
USB->DADDR = USB_DADDR_EF | (0 << USB_DADDR_ADD_Pos);
// Anfangsadresse des Empfangspuffers einstellen
BufDescTable [0].rxBufferAddr = mapAddr (EP0_BUF.data);
// Größe des Empfangspuffers einstellen
BufDescTable [0].rxBufferCount = 0x8400;
// Endpoint 0 konfigurieren
setEPnR (0, USB_EPRX_STAT_Msk | USB_EP_T_FIELD_Msk | USB_EPADDR_FIELD | USB_EP_KIND | USB_EPTX_STAT_Msk,
	static_cast<uint16_t> (USB_EP_RX_VALID | USB_EP_TX_NAK | USB_EP_CONTROL));

Hier wird zunächst der USB-Peripherie die Adresse der Buffer Descriptor Table im Pufferspeicher bekannt gemacht. Danach wird in der Peripherie die Adresse 0 eingestellt. Dies ist nötig, falls das Gerät zwischenzeitlich nicht verbunden war aber die Peripherie von einer vorherigen Verbindung noch eine Adresse eingestellt hat. Außerdem wird das "USB_DADDR_EF"-Bit gesetzt, um das Peripherie-Modul endgültig einzuschalten. Dann wird die Anfangsadresse unseres Empfangspuffers EP0_BUF in eine Adresse in Peripherie-Sicht umgerechnet und in die Buffer Descriptor Table eingetragen. Dann wird die Größe von 64 Bytes (das Maximum für Endpoint 0) über ein spezielles Format eingestellt (dazu später mehr). Schließlich wird der Endpoint-Puffer konfiguriert und eingeschaltet: Es wird der Typ auf "Control Endpoint" gesetzt, die Adresse auf 0 gestellt, das Senden abgeschaltet, und das Empfangen aktiviert. In den zweiten if-Block der ISR setzen wir einen Breakpoint (z.B. manuell mit "__BKPT();"). Wenn alles funktioniert, wird dieses Programm nach dem Einschalten das erste Paket vom Host empfangen.

Unser erstes USB-Paket

Anzeige des ersten Pakets vom Host beim Debugging

Im Debugger können wir dann im Empfangsinterrupt das angekommene Paket analysieren, indem wir das Array EP0_BUF.data anzeigen lassen (siehe Bild). Die ersten beiden Bytes sind also 0x80 und 0x06, darauf folgen die 16bit-Werte 0x100, 0 und 0x40. Dies ist einer der Standard-Requests, den der Host sendet, um das Gerät zu identifizieren. Die genau Bedeutung der Bytes ist in der USB 2.0 Spezifikation auf S. 248 erläutert. Die ersten zwei Bytes verraten den Typ der Anfrage - es handelt sich um "GET_DESCRIPTOR" - dies verwendet der Host, um die Eigenschaften des Geräts abzufragen. Da wir hier noch keine Antwort senden, beschwert sich der Linux Kernel weiterhin über das Fehlverhalten des Geräts. Um aber überhaupt etwas zum Zurücksenden zu haben, müssen wir noch mehr Vorarbeit leisten. Andere Betriebssysteme senden ggf. zuerst andere Anfragen.

Transfer-Interrupts

Bevor wir Daten zurücksenden können, müssen wir die Interrupt-Behandlung noch so umbauen, dass korrekt zwischen Senden/Empfangen unterschieden wird. Dazu fragen wir das "DIR"-Bit im ISTR-Register ab. Ist es 1, wurde etwas empfangen, bei 0 nicht. In beiden Fällen wurde ggf. etwas abgesendet. Im EP_ID-Feld des ISTR finden wir die Nummer des betreffenden Endpoint-Puffers. Zunächst behandeln wir nur Puffer Nr. 0. Im EP0R-Register finden wir dann weitere Informationen dazu, was passiert ist: Das CTR_RX-Bit gibt an, ob ein empfangender Transfer abgeschlossen wurde, und CTR_TX ist für sendende Transfers. Nach dem Abfragen dieser beiden Bits müssen wir sie auf 0 setzen, aber so dass zwischen Abfragen und Setzen ggf. auftretende weitere Ereignisse nicht versehentlich mit gelöscht werden: Dazu maskieren wir den aktuellen Inhalt von EP0R via "und" mit "USB_EP0R_CTR_RX_Msk | USB_EP0R_CTR_TX_Msk", um die aktuell gesetzten Bits zu erhalten. Das übergeben wir an setEPnR, um nur diese Bits auf 0 zu setzen und zwischenzeitlich auf 1 geänderte Bits zu zu belassen. Bei empfangenden Transfers auf dem Endpoint 0 müssen wir noch die Paketdaten auseinandernehmen und speichern sie in leichter zu verarbeitende Variablen. Insgesamt könnte das dann so aussehen:

// CTR signalisiert die Beendigung eines korrekten Transfers
if (ISTR & USB_ISTR_CTR) {
	// Richtung des letzten Transfers. false bedeutet "IN" transfer (Device->Host), true bedeutet "IN" oder "OUT"
	bool dir = ISTR & USB_ISTR_DIR; // 0=TX, 1=RX/TX
	// Die Nummer des EPnR-Registers, welches zu diesem Transfer gehört.
	uint8_t EP = (ISTR & USB_ISTR_EP_ID) >> USB_ISTR_EP_ID_Pos;
	// Wir benutzen vorerst nur EP 0
	if (EP != 0) error ();
	
	// Frage Zustand dieses Endpoints ab
	uint16_t s = EPnR [0].data;
	
	// Lösche im EPnR-Register die RX/TX-Flags, falls sie gesetzt sind. Falls die Hardware zwischen Abfragen und Löschen
	// eines der Bits setzt, wird dies nicht gelöscht und im nächsten Schleifendurchlauf behandelt.
	setEPnR (EP, s & (USB_EP0R_CTR_RX_Msk | USB_EP0R_CTR_TX_Msk), 0, s);

	if (dir && (s & USB_EP0R_CTR_RX_Msk)) {
		// Paket empfangen...
		// Extrahiere die Parameter der Anfrage
		uint8_t bmRequestType = static_cast<uint8_t> (EP0_BUF [0] & 0xFF);
		uint8_t bRequest = static_cast<uint8_t> ((EP0_BUF [0] >> 8) & 0xFF);
		uint16_t wValue = EP0_BUF [1];
		uint16_t wIndex = EP0_BUF [2];
		uint16_t wLength = EP0_BUF [3];
	}
	if (s & USB_EP0R_CTR_TX_Msk) {
		// Paket gesendet...
	}
}

Jetzt können wir korrekt auf Empfangs- und Sende-Ereignisse reagieren. Das nutzen wir jetzt, um den vom Host angefragten Deskriptor zurück zu senden.

Der USB Device-Deskriptor

Ein Deskriptor ist ein binär kodierter Datenblock, der Informationen über ein Gerät enthält. Jedes USB-Device enthält typischerweise mehrere davon, die als Read-Only-Daten abgespeichert sind und vom Host abgerufen werden können. Bei manchen Devices, wie z.B. den FT232 USB-Serial-Adaptern, können über herstellereigene Tools die Deskriptoren nachträglich geändert werden. Das Format der Deskriptoren ist von der USB-Spezifikation oder anderen darauf aufbauenden Spezifikationen jeweils vorgegeben. Das Zustammenstellen der Daten ist ein notwendiges Übel, denn es ermöglicht erst die Flexibilität und Plug-and-Play-Eigenschaft des USB.

Das erste Byte eines jeden Deskriptors gibt dessen Länge in Bytes an. Das zweite Byte definiert den Typ des Deskriptors. Die entsprechenden Werte für Standard-Deskriptoren sind in der USB 2.0 Spezifikation auf S. 251 definiert. Die restlichen Einträge sind ab S. 262 definiert. Wir fangen mit dem Device Descriptor an indem wir ihn zunächst als simples char-Array definieren:

#define W(x) (x)&0xFF,(x)>>8
const unsigned char deviceDescriptor [18] = {
	18,			// bLength
	1,			// bDescriptorType: Device
	W(0x0200),	// bcdUSB
	0xFF,		// bDeviceClass: Vendor-specific
	0xFF,		// bDeviceSubClass: ignored
	0xFF,		// bDeviceProtocol: ignored
	64,			// bMaxPacketSize0: allowed: 8,16,32,64
	W(0xDEAD),	// idVendor
	W(0xBEEF),	// idProduct
	W(0x0100),	// bcdDevice
	0,			// iManufacturer: No string descriptor
	0,			// iProduct: No string descriptor
	0,			// iSerialNumber: No string descriptor
	1			// bNumConfigurations (must be at least 1)
};

Dabei werden die 16bit-Zahlen in einzelne Bytes aufgeteilt, und das Byte mit den weniger signifikanten Stellen zuerst abgelegt (Little Endian). Die Lösung mit dem #define W() funktioniert auch in C ohne ++, ist flexibel, hat kein Alignment-Problem und verbessert die Lesbarkeit - die Bytes muss man trotzdem genau abzählen. Die USB-Version wird als 2.0 angegeben. Geräte-Klasse und -Protokoll werden auf 0xFF gesetzt, d.h. es wird keine Standard-Klasse genutzt und ein kein Standard-Treiber vom Host geladen. Als Paketgröße für Endpoint 0 wählen wir das zulässige Maximum von 64 (dazu später mehr). Interessanterweise ist dieses Byte bei Offset 7. Denn die minimale zulässige USB-Paketlänge beträgt 8 Bytes. Der Host fragt nämlich als allererstes vom deviceDescriptor die ersten 8 Bytes ab, um danach mit der wahren FIFO-Größe arbeiten zu dürfen. Die bereits erwähnte Vendor ID (Hersteller- oder Verkäufer-ID) und Product ID werden hier auf 0xDEAD bzw. 0xBEEF (= „totes Rindfleisch“, normalerweise ein 32-Bit-Füllwort für ungenutzten Speicher) gesetzt. Stehen eigene ID's zur Verfügung, sollten die hier eingesetzt werden. Die drei Zahlen iManufacturer, iProduct und iSerialNumber geben Indizes weiterer Deskriptoren an, die textuell die jeweilige Angabe enthalten zur Anzeige im Host-Betriebssystem; durch 0 signalisieren wir das Fehlen solcher Texte. Jedes Gerät muss mindestens eine Konfiguration haben - da wir auch nicht mehr brauchen, geben wir bei bNumConfigurations 1 an.

Diesen Datenblock müssen wir jetzt auf die Anfrage zurücksenden. Dazu werten wir die Parameter der Anfrage aus, um festzustellen, dass überhaupt dieser Deskriptor gemeint war. Zudem gibt der Host an, wie viele Bytes des Deskriptors gesendet werden sollen - dies ist für solche mit variabler Länge nötig. Wir müssen also als Länge das Minimum von tatsächlicher und gewünschter Länge nutzen. Dann kopieren wir jeweils 2 Bytes des Deskriptors in ein 16bit-Wort des Pufferspeichers, und ein ggf. einzelnes übrig bleibendes Byte. Schlussendlich können wir das Absenden aktivieren, indem wir im EPnR-Register die STAT_TX-Bits auf "VALID" setzen. Das könnte etwa so aussehen:

if (bmRequestType == 0x80 && bRequest == 6) {
	// GET_DESCRIPTOR
	uint8_t type = static_cast<uint8_t> ((wValue >> 8) & 0xFF);
	uint8_t index = static_cast<uint8_t> (wValue & 0xFF);
	if (type == 1 && index == 0 && wIndex == 0) {
		// Wie viele Bytes müssen gesendet werden?
		uint16_t length = std::min (wLength, static_cast<uint16_t> (sizeof (deviceDescriptor)));
		// Kopiere Deskriptor-Daten in Pufferspeicher
		for (uint_fast16_t i = 0; i < length / 2; ++i) {
			EP0_BUF [i] =	static_cast<uint16_t> ( deviceDescriptor [2*i]
						|	(uint16_t { deviceDescriptor [2*i+1]} << 8));
		}
		if (length % 2)
			EP0_BUF [length/2] = deviceDescriptor [length-1];

		// Konfiguriere Sendepuffer in Buffer Descriptor Table
		BufDescTable [0].txBufferAddr = mapAddr(EP0_BUF.data);
		BufDescTable [0].txBufferCount = length;

		// Aktiviere Senden
		setEPnR (0, USB_EPTX_STAT_Msk, USB_EP_TX_VALID);
	}
}

Beim Empfang des Pakets wird der Empfang automatisch abgeschaltet, weil ja kein weiterer Speicher zur Verfügung steht. Daher müssen wir nach Absenden unserer Antwort den Empfang erneut aktivieren:

if (s & USB_EP0R_CTR_TX_Msk) {
	// Wir haben etwas gesendet. Reaktiviere Empfangspuffer
	BufDescTable [0].rxBufferAddr = mapAddr(EP0_BUF.data);
	BufDescTable [0].rxBufferCount = 0x8400;

	setEPnR (0, USB_EPRX_STAT_Msk, USB_EP_RX_VALID);
}

Zu Beachten ist hier: Der Endpoint 0 arbeitet "half-duplex", d.h. es werden nie gleichzeitig Daten gesendet und empfangen (obwohl das für andere Endpoints möglich ist). Daher können wir den Puffer EP0_BUF sowohl zum Senden als auch zum Empfangen nutzen und so etwas Speicher sparen.

Anzeige des Device-Deskriptors in Wireshark

Wird das Gerät jetzt angeschlossen, erhalten wir vom Kernel eine andere Fehlermeldung als zuvor:

[ 4561.749898] usb 2-2: new full-speed USB device number 78 using xhci_hcd
[ 4564.696371] usb 2-2: Device not responding to setup address.
[ 4564.898054] usb 2-2: Device not responding to setup address.
[ 4565.102040] usb 2-2: device not accepting address 78, error -71

Das ist noch nicht das richtige Erfolgserlebnis. Daher benutzen wir Wireshark, um die übertragenen Daten zu betrachten. Die Kommunikation mit unserem Gerät befindet sich zwischen einer Reihe an anderen Paketen, die nur für den USB-Hub gedacht sind. In der "GET_DESCRIPTOR Response" kann der Deskriptor binär in Hex-Form und aufgeschlüsselt nach einzelnen Feldern angezeigt werden. Das ist auch später bei komplexeren Deskriptoren sehr hilfreich.

Kapselung von Transfers

Die ISR besteht jetzt schon aus ziemlichem Spaghetti-Code. Bevor das bei der Implementierung weiterer Funktionalität noch viel schlimmer wird, sollten wir etwas dagegen tun. Ein probates Mittel zur Strukturierung bietet die objektorientierte Programmierung. Zunächst verpacken wir die globalen Funktionen zur Konfiguration der USB-Peripherie in eine Klasse namens "USBPhys":

class USBPhys {
	public:
		constexpr USBPhys (std::array<EPBuffer*, 8> epBuffers) : m_epBuffers (epBuffers) {}

		void init ();
		void connect ();
		void disconnect ();

		void irq ();
	private:
		/// Merkt die Zeiger auf die einzelnen EP-Puffer.
		const std::array<EPBuffer*, 8> m_epBuffers;
};
USBPhys usbPhys ( ... );
extern "C" void USB_LP_CAN1_RX0_IRQHandler () {
	usbPhys.irq ();
}

Die Funktionen init und connect sind die aus den vorherigen Kapiteln. Zur Vollständigkeit wird noch das Komplement "disconnect" hinzugefügt, welches die USB-Peripherie sowie den 1,5kΩ-Widerstand abschaltet. Die Interrupt-Behandlung verschieben wir in die Funktion "irq", welche dann von der eigentlichen ISR aufgerufen wird. Den Zugriff auf die einzelnen Endpoint Puffer kapseln wir in eine separate Klasse "EPBuffer". Von dieser können bis zu 8 Instanzen angelegt und Zeiger darauf an die USBPhys-Klasse übergeben werden:

enum class EP_TYPE : uint8_t { BULK = 0, CONTROL = 1, ISOCHRONOUS = 2, INTERRUPT = 3 };
class EPBuffer {
	friend class USBPhys;
	public:
		constexpr EPBuffer (uint8_t iBuffer, uint8_t addr, EP_TYPE type, UsbMem* rxBuffer, size_t rxBufLength, UsbMem* txBuffer, size_t txBufLength)
			: m_rxBuffer (rxBuffer), m_txBuffer (txBuffer), m_rxBufLength (rxBufLength), m_txBufLength (txBufLength),
			  m_iBuffer (iBuffer), m_address (addr), m_type (type) {}

		void transmitPacket (const uint8_t* data, size_t length);
		void transmitStall ();
		void receivePacket (size_t length);
		void getReceivedData (uint8_t* buffer, size_t length);
	protected:
		virtual void onReset ();
		/**
		 * Wird von USBPhys aufgerufen, wenn Daten auf diesem Puffer empfangen wurden.
		 * "setup" gibt an, ob es ein SETUP-Transfer war, und rxBytes die Anzahl
		 * empfangener Bytes.
		 */
		virtual void onReceive (bool setup, size_t rxBytes) = 0;
		/// Wird von USBPhys aufgerufen, wenn Daten aus diesem Puffer abgesendet wurden.
		virtual void onTransmit () = 0;
	private:
		/// Speichert Empfangs-bzw. Sendepuffer.
		UsbMem* const m_rxBuffer, *const m_txBuffer;
		/// Speichert Länge der beiden Puffer.
		const size_t m_rxBufLength, m_txBufLength;
		/// Speichert Index des Puffers, d.h. Nummer des EPnR-Registers und des BufDescTable-Eintrags.
		const uint8_t m_iBuffer;
		/// Speichert Bus-Adresse der diesem Puffer zugewiesenen Endpoints.
		const uint8_t m_address;
		/// Speichert den Typ der Endpoints.
		const EP_TYPE m_type;
};

Der Klasse werden zunächst im Konstruktor eine Reihe von fixen Einstellungen übergeben. Dies ist der Index des Endpoint Puffers (d.h. Nummer des EPnR-Registers), die Nummer des Endpoints auf dem Bus ("EA" Feld im EPnR), der Typ des Endpoints als enum, sowie Zeiger auf Sende-und Empfangspuffer im USB-Pufferspeicher und deren Größe. Für half-duplex-Endpoints werden wir hier zweimal den gleichen Puffer übergeben. Diese Informationen werden alle in konstanten Member-Variablen gespeichert. Die USBPhys-Klasse wird dann in ihrer "irq"-Funktion über das Zeiger-Array Callbacks in den einzelnen EPBuffer-Instanzen aufrufen:

  • onReset wird bei einem USB-Reset aufgerufen und reinitialisiert den Endpoint-Puffer über das EPnR-Register unter Nutzung der in den Member-Variablen abgelegten Informationen.
  • onReceive wird bei einem empfangenden USB-Transfer aufgerufen unter Übergabe der Anzahl an empfangenden Bytes
  • onTransmit wird nach Abschluss eines sendenden USB-Transfers aufgerufen.

Die letzten beiden Funktionen sind rein virtuell und müssen von einer ableitenden Klasse überschrieben werden. Die weiteren Funktionen sind:

  • transmitPacket kopiert die übergebenen Daten im "normalen" Speicher in den USB-Pufferspeicher und sendet sie ab. Hierhin wird die Schleife von eben zum Zusammenfügen zu 16bit-Worten verschoben. Es wird außerdem eine Fallunterscheidung eingebaut, um leere Datenpakete senden zu können. Das wird später gebraucht.
  • receivePacket bereitet den Empfang von Daten vor. Dazu wird wie eben im USB-Reset-Interrupt die Buffer Descriptor Table vorbereitet und der Empfang im EPnR-Register aktiviert. Es kann auch der Empfang leerer Datenpakete angefordert werden - in diesem Fall wird die Peripherie so konfiguriert, dass sie bei nichtleeren Paketen einen Fehler zurücksendet. Auch das wird später gebraucht.
  • getReceivedData kopiert empfangene Daten aus dem USB-Pufferspeicher in normalen Speicher.
  • transmitStall konfiguriert den Endpoint-Puffer so, dass wenn der Host das nächste Mal versucht Daten abzuholen ("IN"), das Gerät mit "STALL" antwortet. Dies signalisiert bei Control Endpoints einen temporären und bei anderen Endpoints einen dauerhaften Fehler. Das werden wir später noch brauchen

In receivePacket muss noch beachtet werden, dass die Puffergröße auf spezielle Art kodiert werden muss - entweder als Vielfache von 2, oder als Vielfache von 32, was über ein separates Bit angegeben wird. Die Funktion rechnet die übergebene Anzahl an Bytes in die entsprechende Darstellung um. Die beiden gezeigten Klassen bieten eine einfache Hardware-Abstraktion: Darauf aufbauender Code muss sich nicht mit den Eigenheiten des USB-Pufferspeichers und den sonstigen USB-Registern befassen, sondern kann relativ komfortable Funktionen dafür nutzen.

Das Protokoll von Control Endpoints

Schematische Darstellung von Control-Transfers

Die bis jetzt implementierte Behandlung von Control Transfers (Abfrage des Device Descriptor) könnte jetzt direkt auf Basis der soeben erstellten EPBuffer klasse umgebaut werden. Zuvor macht es aber Sinn, sich das hier eigentlich implementierte Protokoll genauer anzusehen, um die Behandlung von Control Transfers direkt vernünftig zu strukturieren.

Ein Protokoll-Transfer beginnt immer mit der Setup-Stage, welche aus einem einzelnen "Setup"-Paket vom Host zum Device besteht. Setup-Pakete unterschieden sich von normalen "Data OUT" Paketen nur durch die Befehlsnummer. Auf dem Controller können wir Setup-Pakete am "SETUP"-Bit des EPnR-Registers erkennen. Dieses Bit wird von USBPhys ausgelesen und als "setup"-Parameter an EPBuffer::onReceive übergeben. Das Setup-Paket enthält Informationen über die gewünschte Operation, die wir ja auch bereits ausgewertet haben. Je nach Operation schließt sich an die Setup-Stage eine Data-Stage oder direkt eine Status-Stage an.

Die Data-Stage besteht aus einer Folge von Paketen die alle in die gleiche Richtung gehen und eine beliebig große Datenmenge übertragen können. Die gewünschte Richtung geht aus der Art der Anfrage hervor. Auf die Data-Stage folgt die Status-Stage.

Die Richtung der Status-Stage hängt von der Vorgeschichte ab: Kam vorher "IN" Data-Stage, besteht die Status-Stage immer aus einem leeren Paket welches der Host an das Device sendet um den ganzen Transfer abzuschließen. Nach einer "OUT" Data-Stage oder wenn es keine Data-Stage gab, signalisiert das Device in der Status-Stage Erfolg oder Misserfolg der gewünschten Operation. Ersteres wird durch Absenden eines leeren Datenpakets angezeigt, letzteres durch Senden eines "STALL"-Tokens. Dies kann durch einen entsprechenden Wert für die STAT_TX-Bits im EPnR-Register erreicht werden.

Für unser einfaches "Hello World"-Device brauchen wir nur den Fall ohne Data-Stage und nur die "IN"-Data-Stage. Zudem reicht es, in der Data-Stage nur ein einzelnes Paket senden zu können, da die hier gesendeten Daten in die maximale Paketgröße von 64 passen. Später werden wir das ausbauen.

Die bis jetzt benötigte rudimentäre Umsetzung von Control Transfers verpacken wir in eine extra Klasse, welche von EPBuffer ableitet und eigene Callbacks für die einzelnen Stages bietet. Sie ist noch sehr einfach, kann dafür aber später leicht erweitert werden.

class ControlEP : public EPBuffer {
	public:
		constexpr ControlEP (...)
			: EPBuffer (...), m_sendStatus (false) {}

		void dataInStage (const uint8_t* data, size_t length);
		void statusStage (bool success);
	protected:
		/// Wird aufgerufen, nachdem vom Host ein "Setup" Paket empfangen wurde. Sollte dataInStage oder statusStage aufrufen.
		virtual void onSetupStage () = 0;
		/**
		 * Wird aufgerufen, wenn in der Data Stage alle Daten an den Host gesendet wurden.
		 * Da bei "In" transfers kein Erfolg signalisiert wird, sollte hier
		 * statusStage NICHT aufgerufen werden. Kann daher leer gelassen werden.
		 */
		virtual void onDataIn () = 0;
		/**
		 * Wird aufgerufen, wenn ein leeres Datenpaket zur Signalisierung des Erfolgs
		 * abgesendet wurde (bei Out-Transfers oder solchen ohne Data Stage - in=false),
		 * oder ein leeres Paket Empfangen wurde (bei In-Transfers - in=true).
		 */
		virtual void onStatusStage (bool in) = 0;

		virtual void onReset () override;
		virtual void onReceive (bool setup, size_t rxBytes) override final;
		virtual void onTransmit () override final;
	private:
		void receiveControlPacket ();

		bool m_sendStatus;
};

Die Klasse überschreibt die Callbacks von EPBuffer um auf die verschiedenen Ereignisse zu reagieren. Die Funktionen dataInStage und statusStage initiieren die jeweilige Stage. Die Klasse ruft die Callbacks onDataIn und onStatusStage auf wenn die entsprechenden Stages abgeschlossen wurden. Aufgrund der rudimentären Unterstützung der Data-Stage brauchen wir nur ein einziges Bit an Zustands-Information: "m_sendStatus" ist "false" während der Data-In-Stage, sodass bei onTransmit die Status-Stage begonnen wird. Während der Status-Stage ist m_sendStatus dann "true", sodass bei onTransmit wieder die nächste Setup-Stage vorbereitet werden kann. Das Empfangen von Daten wird jeweils durch receiveControlPacket vorbereitet, was EPBuffer::receivePacket aufruft. In onReceive können Setup-Pakete direkt zum Nutzer der Klasse weitergeleitet werden - bei normalen Paketen wird angenommen es handele sich um die Bestätigung in der Status-Stage nach einer Data-In-Stage, weshalb onStatusStage aufgerufen wird.

Adresszuweisung

Von ControlEP leiten wir eine weitere Klasse DefaultControlEP ab, welche speziell für Endpoint 0 ist und die von der USB Spezifikation definierten Anfragen verarbeitet. Die Klasse überschreibt onSetupStage, wohin wir jetzt die Verarbeitung der GET_DESCRIPTOR-Anfrage verschieben. Außerdem können wir dort jetzt weitere Anfragen des Hosts verarbeiten. Die nächste Anfrage, die der Linux-Kernel sendet, ist SET_ADDRESS (bmRequestType = 0, bRequest = 5). Hier wird dem Device eine Adresse im Bereich 1-127 zugeordnet. Diese müssen wir an die USB-Peripherie im Register USB->DADDR ablegen, damit die Peripherie auf Anfragen an die neue Adresse korrekt reagiert. Das darf aber nicht sofort beim Empfangen des Kommandos geschehen, sondern erst nachdem wir die Bestätigung in der Status-Stage (leeres Paket) abgesendet haben. Nach dem Empfang des Kommandos speichern wir also nur die empfangene Adresse in einer Member-Variablen und beginnen die Status-Stage:

if (bmRequestType == 0 && bRequest == 5) {
	// Merke Adresse; diese wird erst nach Absenden der Bestätigung gesetzt
	m_setAddress = static_cast<uint8_t> (m_wValue & 0x7F);
	// Sende Bestätigung
	statusStage (true);
}

Jetzt müssen wir noch die onStatusStage-Funktion überschreiben, um die Adresse nach dem Absenden tatsächlich zu übernehmen. Den eigentlichen Registerzugriff kapseln wir in USBPhys, damit DefaultControlEP hardwareunabhängig bleibt:

void DefaultControlEP::onStatusStage (bool in) {
	// Haben wir gerade die Bestätigung für SET_ADDRESS gesendet?
	if (m_setAddress && !in) {
		// Jetzt erst die Adresse übernehmen (von Spezifikation vorgegeben)
		m_phys.setAddress (m_setAddress);
		// Aber nur diesmal
		m_setAddress = 0;
	}
}
void USBPhys::setAddress (uint8_t addr) {
	USB->DADDR = static_cast<uint16_t> (USB_DADDR_EF | addr);
}

Das "EF"-Bit muss wieder mitgeschrieben werden, um die Peripherie eingeschaltet zu lassen. Die Unterscheidung der einzelnen Anfragen in onSetupStage implementieren wir mit einer langen if - else - if ... Kette. Bei nicht implementierten Anfragen landen wir also im letzten else-Zweig, hier sollten wir mit statusStage (false); dem Host einen Fehler signalisieren.

Starten wir dieses Programm, sieht die Meldung des Kernels schon ganz anders aus:

[13292.875740] usb 2-2: new full-speed USB device number 30 using xhci_hcd
[13293.004359] usb 2-2: unable to read config index 0 descriptor/start: -32
[13293.004366] usb 2-2: chopping to 0 config(s)
[13293.004371] usb 2-2: New USB device found, idVendor=dead, idProduct=beef
[13293.004375] usb 2-2: New USB device strings: Mfr=0, Product=0, SerialNumber=0
[13293.004511] usb 2-2: no configuration chosen from 0 choices

Im Debugger stellen wir fest, dass der Kernel nach der Adresszuweisung (mehrfach) den Device_Qualifier-Deskriptor abfragt. Das darf er, weil das Gerät im Device Deskriptor als USB 2.0 (statt 1.1)-kompatibel markiert wurde und somit auf diese Anfrage korrekt reagieren muss. Dieser Deskriptor ist nur für High-Speed-Geräte relevant - ein USB 2.0-kompatibles Full Speed-Gerät muss hier einfach ein "STALL" zurücksenden, was wir über "statusStage (false);" erreichen. Danach wird der Configuration Descriptor angefragt. Dieser ist auch für unser Gerät erforderlich.

Weitere Standard-Deskriptoren

Neben dem Device Deskriptor sind noch weitere Deskriptoren nötig, um das Gerät dem Host komplett bekannt zu machen. Dafür sind verschiedene Typen von Deskriptoren definiert (Device, Configuration, Interface, Endpoint, String ...). Von manchen Deskriptoren kann es Verschiedene geben, die über einen Index durchnummeriert werden. Zur vollständigen Identifizierung eines Deskriptors sind also Typ und Index nötig. Für Detail-Informationen über die einzelnen Einträge der Deskriptoren sei wieder auf die USB 2.0 Spezifikation ab S. 261 verwiesen.

Kodierungsfunktionen

Alle Deskriptoren wie zuvor als "char"-Array zu kodieren ist relativ umständlich - insbesondere das Kodieren von Zahlen mit mehr als 8 Bit in einzelne Bytes sowie das Zusammenrechnen der Größe von kombinierten Deskriptoren ist fehleranfällig. Das manuelle Anlegen von UTF-16-Stringdeskriptoren ist wie weiter unten erläutert völlig unpraktikabel. Daher befinden sich im Beispielprojekt die Dateien "usb_desc_helper.hh" und "encode.hh", welche einige Hilfskonstrukte enthalten, die das Erstellen der Deskriptoren vereinfachen. Die Definition des zuvor gezeigten Device-Deskriptors sieht damit so aus:

static constexpr auto deviceDescriptor = EncodeDescriptors::USB20::device (
			0x200,		// bcdUSB
			0xFF,		// bDeviceClass
			0xFF,		// bDeviceSubClass
			0xFF,		// bDeviceProtocol
			64,			// bMaxPacketSize0
			0xDEAD,		// idVendor		TODO - anpassen
			0xBEEF,		// idProduct	TODO - anpassen
			0x0100,		// bcdDevice
			0,			// iManufacturer, entspricht dem Index des strManufacturer-Deskriptors
			0,			// iProduct, entspricht dem Index des strProduct-Deskriptors
			0,			// iSerialNumber, entspricht dem Index des strSerial-Deskriptors
			1			// bNumConfigurations
		);

Die Parameter der EncodeDescriptors::USB20::device sind bereits mit den richtigen Typen definiert, d.h. bcdUSB, idVendor, idProduct und bcdDevice sind uint16_t. Größe und Typ des Deskriptors müssen nicht angegeben werden, weil diese ohnehin immer gleich sind. Die Funktion gibt ein std::array<uint8_t,18> zurück, welches später simpel byteweise in den USB-Pufferspeicher kopiert werden kann. Durch Markierung mit "constexpr" wird der Compiler dieses Array während des Kompilier-Vorgangs berechnen und das Ergebnis als Konstante in den Flash legen - so wird kein RAM oder Rechenzeit zum Zusammenstellen dieser Daten benötigt. Diese Funktionen nutzen intern eine relativ komplexe Logik auf Basis von Metaprogrammierung. Daher wird die Funktionsweise hier nicht genauer erläutert. Für den Fall, dass diese Konstruktion nicht genutzt werden kann, sind im Beispielcode die Deskriptoren in Kommentaren als simple Arrays hinterlegt.

Konfiguration, Interface & Endpoint

Wie bereits erwähnt müssen wir einen Configuration Deskriptor erstellen. Aber was ist überhaupt eine Konfiguration? Die USB Spezifikation verlangt, dass jedes Gerät mindestens eine Konfiguration besitzt, und der Host das Gerät anweisen kann, zwischen verschiedenen Konfigurationen umzuschalten. Es ist immer genau eine Konfiguration aktiv. Konfigurationen repräsentieren also eine Art "Betriebsmodus". Unser einfaches Hello-World-Programm wird nur eine einzelne Konfiguration anbieten.

Jede Konfiguration muss mindestens ein Interface bieten. Alle Interfaces einer Konfiguration sind gleichzeitig aktiv, aber für jedes Interface kann es sogenannte Alternative Settings geben, zwischen denen umgeschaltet werden kann ähnlich wie zwischen verschiedenen Konfigurationen. Ein Interface bietet Zugriff auf eine Funktion oder einen funktionalen Aspekt eines Geräts; Multifunktionsgeräte nutzen daher mehrere davon. Für unser Programm reicht ein einzelnes Interface ohne Alternate Settings aus.

Jedes Interface kann 0 oder mehr Endpoints haben, über welche die Funktionalität dieses Interfaces genutzt werden kann. Der Default Control Endpoint 0 gehört automatisch zu allen Interfaces.

Pro Konfiguration muss das Gerät einen Configuration Descriptor haben. Jeder davon setzt sich aus einer Folge mehrerer einzelner Blöcke zusammen:

  • Zuerst kommt der eigentliche Configuration Descriptor als "Header"
  • Für jedes Interface folgt ein Interface Descriptor
  • Nach jedem Interface Descriptor folgen 0 oder mehr Endpoint Descriptors, einer pro Endpoint und pro Richtung (IN/OUT).

Die interessanteste Angabe im Configuration Descriptor ist der Strom, den das Gerät benötigt und den der Host zur Verfügung stellen muss. Dieser wird in 2mA-Schritten angegeben (0-500mA). Im Interface Descriptor wird u.a. die Klasse und Protokoll des Interface definiert. Diese ist von der Geräteklasse abhängig, und gibt bei Geräten mit mehreren Interfaces an, welches Interface welche Funktion bietet. Im Endpoint Descriptor werden Typ und Adresse definiert und die maximale Paketgröße. Diese muss konsistent mit dem Programm sein: Wenn das Programm versucht ein größeres Paket zu senden, oder (temporär) nur kleinere Pakete akzeptiert, schlägt die Kommunikation komplett fehl. In der Adresse wird jeweils die Richtung im höchsten Bit mit angegeben.

Die einzelnen Deskriptoren für Interfaces und Endpoints werden über die Funktionen EncodeDescriptors::USB20::interface und EncodeDescriptors::USB20::endpoint angelegt. Deren Rückgabewerte werden dann an EncodeDescriptors::USB20::configuration übergeben, welches sie zusammen mit den Daten für den Configuration Descriptor zu einem großen Datenblock zusammenfügt. Wir definieren uns also einen Configuration Descriptor, einen Interface Descriptor und zwei Endpoint Descriptors für beide Richtungen eines Bulk-Endpoints mit Adresse 1, mit welchem wir später unsere eigene Funktionalität umsetzen werden:

static constexpr auto confDescriptor = EncodeDescriptors::USB20::configuration (
			1,			// bNumInterfaces
			1,			// bConfigurationValue
			0,			// iConfiguration
			0x80,		// bmAttributes
			250,		// bMaxPower (500mA)

			EncodeDescriptors::USB20::interface (
				0,		// bInterfaceNumber
				0,		// bAlternateSetting
				2,		// bNumEndpoints
				0xFF,	// bInterfaceClass
				0xFF,	// bInterfaceSubClass
				0xFF,	// bInterfaceProtocol
				0		// iInterface
			),
			EncodeDescriptors::USB20::endpoint (
				1,		// bEndpointAddress
				2,		// bmAttributes
				64,		// wMaxPacketSize
				10		// bInterval
			),
			EncodeDescriptors::USB20::endpoint (
				0x81,	// bEndpointAddress
				2,		// bmAttributes
				64,		// wMaxPacketSize
				10		// bInterval
		)
);

Deskriptor-Tabelle

Um diesen Deskriptor abfragen zu können, könnten wir nun in die Verarbeitung der GET_DESCRIPTOR-Anfrage eine Fallunterscheidung einbauen, die beim richtigen Befehl die neuen Daten zurücksendet. Das wird aber bei steigender Zahl an Deskriptoren umständlich und schlecht wartbar. Daher erstellen wir eine einfache Klasse "Descriptor", welche einen Zeiger auf die (konstanten) Deskriptor-Daten enthält, sowie Größe, Typ (als enum) und Index:

enum class D_TYPE : uint8_t {	DEVICE = 1, CONFIGURATION = 2, STRING = 3, INTERFACE = 4, ENDPOINT = 5, DEVICE_QUALIFIER = 6, OTHER_SPEED_CONFIGURATION = 7, INTERFACE_POWER = 8 };
struct Descriptor {
	template <size_t N>
	constexpr Descriptor (const std::array<Util::EncChar, N>& data_, D_TYPE type_, uint8_t index_) : data (& data_[0]), length (N), type (type_), index (index_) {}

	const Util::EncChar* data;
	uint8_t length;
	D_TYPE type;
	uint8_t index;
};

Durch Ausführung des Konstruktors als template kann direkt ein std::array übergeben werden und die Größe wird automatisch übernommen. Davon können wir jetzt eine Tabelle anlegen, in der alle Deskriptoren aufgelistet sind, sowie eine Funktion um diese zu durchsuchen anhand des gewünschten Typ und Index:

static constexpr Descriptor descriptors [] = { { deviceDescriptor, D_TYPE::DEVICE, 0 },
									{ confDescriptor, D_TYPE::CONFIGURATION, 0 } };
const Descriptor* getUsbDescriptor (D_TYPE type, uint8_t index) {
	// Durchsuche Deskriptor-Tabelle
	for (auto& d : descriptors) {
		if (d.type == type && d.index == index)
			return &d;
	}
	return nullptr;
}

Bei der GET_DESCRIPTOR-Anfrage wird im Parameter "wValue" im oberen Byte der gewünschte Typ, und im unteren der gewünschte Index abgegeben (meistens 0). In wIndex wird die Sprache angegeben. Wenn nur eine Sprache unterstützt wird, kann man dies ignorieren. Anhand dieser Daten kann dann der Deskriptor abgefragt und zurückgesendet werden:

if (m_bmRequestType == 0x80 && m_bRequest == 6) {
	// Deskriptor-Typ aus Anfrage extrahieren
	D_TYPE descType = static_cast<D_TYPE> (m_wValue >> 8);
	// Deskriptor-Index aus Anfrage extrahieren
	uint8_t descIndex = static_cast<uint8_t> (m_wValue & 0xFF);

	// Durchsuche Deskriptor-Tabelle
	const Descriptor* d = getUsbDescriptor (descType, descIndex);
	if (!d) {
		// Kein passender Deskriptor - sende Fehler
		statusStage (false);
	} else {
		// Sende nur max. so viel wie gefordert. Falls Deskriptor länger ist, wird der Host eine erneute Anfrage des
		// ganzen Deskriptors stellen, dessen Gesamtlänge immer ganz zu Beginn steht und somit nach der 1. Anfrage
		// bekannt ist.
		uint16_t length = std::min<uint16_t> (m_wLength, d->length);

		// Sende Deskriptor
		dataInStage (d->data, length);
	}
}

Wird das um den Configuration Descriptor aufgerüstete Programm gestartet, erhalten wir die folgende Ausgabe von Linux:

[ 2156.256928] usb 2-2: new full-speed USB device number 13 using xhci_hcd
[ 2156.385534] usb 2-2: New USB device found, idVendor=dead, idProduct=beef
[ 2156.385538] usb 2-2: New USB device strings: Mfr=0, Product=0, SerialNumber=0
[ 2156.386382] usb 2-2: can't set config #1, error -32

Es gibt keine Beschwerde über den Configuration Descriptor mehr; stattdessen fehlen uns aber noch die Behandlung weiterer Anfragen.

String-Deskriptoren

Wo wir gerade mit Deskriptoren beschäftigt sind, können wir noch ein paar String-Deskriptoren hinzufügen. Wie der Name andeutet, enthalten diese einen menschenlesbaren Text der zur Anzeige im Host-Betriebssystem gedacht ist und keine technische Bedeutung hat. String-Deskriptoren werden über einen Index identifiziert, und jeder davon kann in mehrfacher Ausfertigung existieren für verschiedene Sprachen. Im String mit Nr. 0 wird statt eines Textes die "Language Table" angegeben, eine Aufzählung der unterstützten Sprachen. Diese besteht aus einer Folge von 16bit-Integern, welche die unterstützten Sprach-Codes angeben. Wir werden nur Deutsch einbauen:

static constexpr auto langTable = EncodeDescriptors::USB20::languageTable (0x0407 /* German (Standard) */);

Die eigentlichen Strings werden in Unicode via UTF-16 kodiert, d.h. jedes Zeichen besteht aus einem oder mehr 16bit-Werten, wobei das niederwertige Byte jeweils zuerst kommt (Little Endian). Somit können Zeichen in (fast) allen Sprachen der Welt angegeben und vermischt werden. Die Kodierung stellt uns vor ein unerwartetes Problem: Es wäre sinnvoll, die Strings direkt im geforderten UTF-16 LE-Format im Flash abzulegen und dann 1:1 abzuschicken, andererseits möchten wir die Texte gerne als String-Literale im Code angeben, damit sie leicht les-und änderbar sind. Leider werden Quelltext-Dateien meistens als UTF-8 kodiert, und eine Umwandlung der Quelltextdatei nach UTF-16 ist kaum praktikabel weil dann auch alle Header-Dateien (inkl. der System-Header!) mit konvertiert werden müssten. Seit C++11 ist es aber möglich, explizit UTF-16-Stringliterale anzugeben, indem vor die öffnenden Anführungsstriche einfach ein kleines(!) "u" vorangestellt wird (großes "U" ist für UTF-32). Der Typ des Literals ist dann "const char16_t []", d.h. der Compiler hat bereits die Konvertierung vom Quellcode-Format (welches auch immer eingestellt ist) in 16bit-Worte vorgenommen. Diese müssen wir jetzt noch in Einzel-Bytes der richtigen Reihenfolge (Little Endian) umwandeln. Dafür ist in der "encode.hh" die Funktion Util::encodeString definiert, welche aus einem String-Literal ein std::array<uint8_t, ...> macht:

static constexpr auto myString = Util::encode(u"Der Gerät");

Im Flash landet dann die UTF-16-Kodierung. Ein alternatives Vorgehen besteht in der manuellen Berechnung der einzelnen Werte der Bytes und Definition des Strings als Array, was aber sehr umständlich ist. Etwas vereinfachen kann man sich das indem man die Texte in einem Text-Editor in einer Datei als UTF-16 speichert und sich das Ergebnis im Hex-Editor ansieht. Im Beispiel-Code von ST werden die String-Literale klassisch im ASCII-Format angegeben, und dann vor dem Senden nach UTF-16 konvertiert, indem nach jedem Byte ein 0-Byte eingefügt wird. Dabei ist aber die Nutzung von Nicht-ASCII-Zeichen (z.B. Umlaute) nicht möglich und es wird zusätzliche Rechenleistung benötigt.

Für einen echten String-Deskriptor muss dem Text noch die Länge (in Bytes) sowie der Deskriptor-Typ (0x3 für Strings) vorangestellt werden. Das erledigt "EncodeDescriptors::USB20::string". Die vollständige Definition unserer String-Deskriptoren und die Auflistung in der Tabelle sieht dann z.B. so aus:

static constexpr auto strManufacturer = EncodeDescriptors::USB20::string (u"ACME Corp.");
static constexpr auto strProduct = EncodeDescriptors::USB20::string (u"Fluxkompensator");
static constexpr auto strSerial = EncodeDescriptors::USB20::string (u"42-1337-47/11");
static constexpr auto langTable = EncodeDescriptors::USB20::languageTable (0x0407 /* German (Standard) */);
static constexpr Descriptor descriptors [] = { ...
									{ strManufacturer, D_TYPE::STRING, 1 },
									{ strProduct, D_TYPE::STRING, 2 },
									{ strSerial, D_TYPE::STRING, 3 }};

Die String-Deskriptoren haben jetzt also die Indices 1,2,3. Diese können wir jetzt im Device Descriptor für die Werte iManufacturer, iProduct und iSerialNumber angeben. Dadurch erkennt Linux unser Gerät jetzt mit Namen:

[ 5089.189084] usb 2-2: new full-speed USB device number 16 using xhci_hcd
[ 5089.318259] usb 2-2: New USB device found, idVendor=dead, idProduct=beef
[ 5089.318263] usb 2-2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[ 5089.318266] usb 2-2: Product: Fluxkompensator
[ 5089.318268] usb 2-2: Manufacturer: ACME Corp.
[ 5089.318270] usb 2-2: SerialNumber: 42-1337-47/11
[ 5089.319066] usb 2-2: can't set config #1, error -32

Restliche Standard-Requests

Das Betriebssystem akzeptiert unser Gerät immer noch nicht vollständig. Wir müssen noch auf einige weitere Standard-Requests reagieren. Wir fangen mit SET_CONFIGURATION an: Dieser Request schaltet zwischen den verschiedenen Konfigurationen ("Betriebsmodi") um. Da wir davon nur eine haben, muss hier nichts getan werden. Es gibt aber einen wichtigen "Nebeneffekt": Nach dem Umschalten müssen alle Bulk- und Interrupt-Endpoints auf "DATA0" gesetzt werden: Auf diesen Endpoints werden Datenblöcke abwechselnd mit den DATA0/DATA1 Befehlen übertragen, damit der Empfänger erkennen kann, ob ein Paket ein Korrekturversuch eines zuvor fehlerhaft übertragenen Pakets war, oder ob es das nächste Datenpaket ist. Auf Control Endpoints wird bei Setup-Paketen immer mit DATA0 begonnen, und Isochronous-Endpoints haben keine Fehlerkorrektur, weshalb dieser Mechanismus hier nicht genutzt wird. Nach SET_CONFIGURATION wird auf Bulk/Interrupt-Endpoints immer mit DATA0 weiter gemacht. Die Umschaltung erfolgt über die EPnR-Register, indem die DTOG_RX und DTOG_TX-Bits auf 0 gesetzt werden (es handelt sich um Bits mit "Toggle"-Charakteristik, d.h. es muss der aktuelle Wert zurückgeschrieben werden um 0 zu erhalten). Dazu schreiben wir uns eine Funktion, welche bei SET_CONFIGURATION aufgerufen wird, alle EPnR-Register iteriert, ihren Typ auf Bulk/Interrupt prüft und die genannten Bits auf 0 setzt, sofern die entsprechende Richtung aktiviert ist:

void USBPhys::resetDataToggle () {
	for (uint_fast8_t iEP = 0; iEP < 8; ++iEP) {
		uint16_t s = EPnR [iEP].data;
		// Prüfe Typ des Endpoints (Bulk/Interrupt)
		if ((s & USB_EP_T_FIELD_Msk) == USB_EP_BULK || (s & USB_EP_T_FIELD_Msk) == USB_EP_INTERRUPT) {
			// Prüfe ob Senden/Empfangen aktiviert. Diese Information ließe sich auch über m_epBuffers
			// gewinnen, aber so ist es einfacher.
			bool rx = (s & USB_EPRX_STAT) != USB_EP_RX_DIS;
			bool tx = (s & USB_EPTX_STAT) != USB_EP_TX_DIS;
			if (rx && tx)
				// Setze beide Richtungen zurück
				setEPnR (static_cast<uint8_t> (iEP), USB_EP_DTOG_RX_Msk | USB_EP_DTOG_TX_Msk, 0, s);
			else if (rx)
				// Nur Empfangen zurücksetzen
				setEPnR (static_cast<uint8_t> (iEP), USB_EP_DTOG_RX_Msk, 0, s);
			else if (tx)
				// Nur Senden zurücksetzen
				setEPnR (static_cast<uint8_t> (iEP), USB_EP_DTOG_TX_Msk, 0, s);
		}
	}
}

Das war aber immer noch nicht alles: Es muss noch auf die Anfragen CLEAR_FEATURE, SET_FEATURE, GET_STATUS, GET_INTERFACE, SET_INTERFACE reagiert werden. Diese sind für Spezial-Funktionen, die für unser Gerät nicht relevant sind. Daher können wir hier jeweils eine "Dummy-Antwort" bzw. Fehler ("STALL") zurücksenden, die dem Host die Funktionalität vortäuschen. Auf die eher langweiligen Details wird hier nicht eingegangen und auf den Beispiel-Code verwiesen.

Vollständige Enumeration

Nach all der Vorarbeit haben wir endlich ein Gerät, das vollständig ohne Fehler erkannt wird und genau keine Funktion bietet:

[ 6607.039063] usb 2-2: new full-speed USB device number 20 using xhci_hcd
[ 6607.168365] usb 2-2: New USB device found, idVendor=dead, idProduct=beef
[ 6607.168369] usb 2-2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[ 6607.168372] usb 2-2: Product: Fluxkompensator
[ 6607.168374] usb 2-2: Manufacturer: ACME Corp.
[ 6607.168375] usb 2-2: SerialNumber: 42-1337-47/11

Das Hinzufügen sinnvoller Funktionalität auf Basis der erstellten Strukturen ist jetzt aber nicht mehr viel Aufwand.

Eigene Requests

Die einfachste Möglichkeit, eigene Funktionen hinzuzufügen, ist das Reagieren auf Requests auf dem Default Control Endpoint 0, genau wie auf die Standard-Anfragen reagiert wird. Damit die eigenen Anfragen sich nicht mit den Standard-Anfragen überschneiden, sollten wir in bmRequestType die Bits 6 und 5 auf auf 1 bzw. 0 setzen, was einen Vendor-spezifischen Request markiert. Das höchste Bit sollte die Richtung angeben (0 = Host->Device, 1 = Device->Host). bRequest, wValue, wIndex und wLength können beliebig vergeben werden. Das nutzen wir, um 2 LED's auf dem Olimexino zu setzen und den aktuellen Zustand abfragen zu können:

if (bmRequestType == 0xC0 && bRequest == 2) {
	// LED Status abfragen und 1-Byte-Datenpaket zusammensetzen
	uint8_t data = static_cast<uint8_t> (LED1.getOutput () | (uint8_t { LED2.getOutput () } << 1));
	// Absenden
	dataInStage (&data, 1);
} else if (bmRequestType == 0x40 && bRequest == 1) {
	// Bits aus wValue an die Pins übertragen
	LED1.set (wValue & 1);
	LED2.set (wValue & 2);
	statusStage (true);
}

LED1 und LED2 sind Instanzen der "Pin"-Klasse welche recht simpel ist und deren Funktionen genau das tun wonach sie aussehen. Details können im Beispielcode nachgesehen werden.

Eigener Bulk-Endpoint

Mit Requests auf dem Default Control Endpoint können wir die mögliche Datenrate von USB noch nicht ausnutzen. Daher nutzen wir den zuvor im Interface Descriptor angelegten Endpoint 1, um größere Datenmengen übertragen zu können. Als simplen Test implementieren wir ein "Loopback" bei dem wir alle empfangenen Datenpakete direkt wieder zurücksenden, nachdem wir jedes Byte einmal umgedreht haben. Dazu erstellen wir eine neue Klasse namens "MirrorEP" und leiten von "EPBuffer" ab. Davon legen wir eine globale Instanz an und übergeben einen Pointer darauf an die USBPhys-Instanz:

class MirrorEP : public EPBuffer {
	public:
		constexpr MirrorEP (UsbMem* epBuffer, size_t length) : EPBuffer (1, 1, EP_TYPE::BULK, epBuffer, length, epBuffer, length), m_buffer {} {}
	protected:
		virtual void onReceive (bool setup, size_t rxBytes);
		virtual void onTransmit ();
		virtual void onReset ();
	private:
		uint8_t m_buffer [64];
};
void MirrorEP::onReset () {
	EPBuffer::onReset ();
	// Bereite Datenempfang vor
	receivePacket (std::min<size_t> (getRxBufLength(), sizeof (m_buffer)));
}
void MirrorEP::onReceive (bool, size_t rxBytes) {
	// Frage empfangene Daten ab
	size_t count = std::min<size_t> (sizeof (m_buffer), rxBytes);
	getReceivedData (m_buffer, count);

	// Drehe jedes Byte um
	for (size_t i = 0; i < count; ++i) {
		m_buffer [i] = static_cast<uint8_t> (__RBIT(m_buffer [i]) >> 24);
	}

	// Sende Ergebnis zurück
	transmitPacket (m_buffer, count);
}
void MirrorEP::onTransmit () {
	// Nach erfolgreichem Senden, mache erneut bereit zum Empfangen
	receivePacket (std::min<size_t> (getRxBufLength(), sizeof (m_buffer)));
}
alignas(4) static UsbAlloc<64> EP0_BUF	USB_MEM;
alignas(4) static UsbAlloc<64> EP1_BUF	USB_MEM;
/// Der Default Control Endpoint 0 ist Pflicht für alle USB-Geräte.
static DefaultControlEP controlEP (usbPhys, 0, EP0_BUF.data, EP0_BUF.size);
/// Lege Endpoint zum Umdrehen der Daten an
MirrorEP mirrorEP (EP1_BUF.data, EP1_BUF.size);
/// Zentrale Instanz für USB-Zugriff. Übergebe 2 Endpoints.
USBPhys usbPhys (std::array<EPBuffer*, 8> {{ &controlEP, &mirrorEP, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr }});

Dies ist relativ viel Code für so eine einfache Operation, aber die Nutzung der abstrakten EPBuffer-Klasse ermöglicht die Implementation unseres Loopbacks ohne die Low-Level USB-Routinen anpassen zu müssen. Die so hinzugefügte Funktionalität ändert das Verhalten des Hosts nicht - wir brauchen jetzt eine Gegenstelle, welche die entsprechenden Daten sendet um die Funktionen zu testen.

Eigene Anwendung für PC-Seite

Wie bereits erwähnt, wollen wir die PC-Seite über libusb implementieren, was uns ermöglicht, aus einer gewöhnlichen Anwendung direkt auf das Gerät zuzugreifen, ohne selbst einen Treiber implementieren zu müssen. Dies dürfte die einfachste Möglichkeit sein, mit eigenen USB-Geräten zu kommunizieren. Das Beispielprogramm dafür ist ebenfalls auf GitHub zu finden. Dies ist mit CMake implementiert und kann für Linux und Windows kompiliert werden. Der eigentliche Code ist plattformunabhängig - ein #include "libusb.h" reicht um libusb einzubinden. Es ist empfehlenswert, zunächst das Beispielprogramm wie auf der GitHub-Seite beschrieben in Betrieb zu nehmen, bevor mit einem eigenen Projekt begonnen wird. Dort sind auch fertig kompilierte Programmdateien verfügbar.

Einbinden von LibUsb

Im Folgenden wird kurz beschrieben, wie libusb in eigene Projekte integriert werden kann.

Linux

Zunächst müssen die Header-und Binärdateien von libusb installiert werden. Unter Ubuntu geschieht das über das Paket libusb-1.0-0-dev. Eigener Sourcecode auf Basis von libusb kann dann so kompiliert und gelinkt werden:

$ g++ main.cc -c -o main.o `pkg-config --cflags libusb-1.0`
$ g++ main.o -o usbclient `pkg-config --libs libusb-1.0`

Das Beispielprojekt generiert über CMake ein Makefile, welches genau so funktioniert. Wenn man das so erstellte Programm als "root"-User ausführt, funktioniert es direkt.

Windows / Visual Studio

Für Windows gibt es auf der libusb-Website fertig kompilierte Bibliotheken-Dateien zum Herunterladen. Diese können in eigene Visual Studio-Projekte eingebunden werden. Die Bibliothek liegt in Form von DLL-Dateien vor, welche dann mit der eigenen Anwendung ausgeliefert werden müssen. Alternativ kann auch die "statische" Version genutzt werden, deren Inhalt dann mit in die erstellte .exe-Datei eingebunden wird, sodass diese ohne zusätzliche Dateien funktioniert. Da die libusb unter der LGPL-Lizenz steht, ist diese Option nur für Open Source-Programme erlaubt. Die statischen Bibliotheksdateien funktionieren auch nur mit älteren Visual Studio-Versionen. Es ist aber relativ einfach, mit einer aktuellen VS-Version libusb selbst zu kompilieren, um statische Bibliotheken zu erhalten, die dann (nur) mit dieser VS-Version funktionieren. Im "usbclient"-Projekt sind solche vorkompilierte Bibliotheksdateien für Visual Studio 15 2017 mitgeliefert, für 32/64-Bit als statische und dynamische (DLL) Bibliothek. Legt man ein eigenes VS-Projekt an, muss man die Pfade zur gewünschten Variante der Bibliothek sowie zu den Header-Dateien einstellen. Im Beispielprojekt ist das bereits erledigt.

Konfiguration der PC-Seite

Windows: Laden von WinUSB

Anzeige des WinUSB-Device im Gerätemanager

Unter Windows können libusb-Programme nicht ohne Weiteres direkt auf die Hardware zugreifen. Dazu ist es erst nötig, einen Treiber für das Gerät zu installieren/laden, welcher libusb den Zugriff ermöglicht. Es gibt verschiedene Projekte, die derartige Treiber zur Verfügung stellen, auf die mit libusb aus dem eigenen Programm zugegriffen werden kann:

Im Folgenden wird nur auf WinUSB eingegangen. Dies hat den großen Vorteil, dass es bereits auf aktuellen Windows-Versionen vorinstalliert ist. Man muss Windows "nur" noch dazu bringen, den WinUSB-Treiber für das eigene Gerät zu laden. Dies kann man manuell im Geräte-Manager tun (umständlich), oder komfortabler über das Programm Zadig, welches übrigens auch die anderen aufgelisteten Treiber unterstützt. Leider sind dort nicht immer die aktuellen Versionen mitgeliefert. Die "traditionelle" Variante besteht darin, eine eigene .inf und .cat -Datei anzulegen, welche Windows anweist, für ein bestimmtes Gerät den gewünschten Treiber zu laden. Allerdings muss letztere Datei korrekt signiert sein, und die Installation von nicht/selbst-signierten Dateien wird mir neueren Windows-Versionen zunehmend umständlicher und ist unter Windows 10 gänzlich unpraktikabel.

Seit Windows 8 gibt es noch eine andere Möglichkeit: Man kann das Gerät durch Hinzufügen spezieller von Microsoft definierter Deskriptoren als sogenanntes WinUSB Device markieren. Windows lädt dafür vollautomatisch ohne jede Nutzer-Interaktion den WinUSB-Treiber, und man kann sofort die libusb-Anwendung nutzen. Unter anderen Betriebssystemen hat das keine Auswirkung, hier funktioniert das Gerät wie zuvor. Hier findet sich eine gute Erläuterung der benötigten Deskriptoren. Daher wird hier nur kurz gezeigt, wie diese Daten anzulegen sind.

Es wird ein zusätzlicher String-Deskriptor benötigt sowie der sogenannte Compat Id Descriptor, für den in der usb_desc_helper.hh eine Funktion vorbereitet ist:

static constexpr auto strOsStringDesc = EncodeDescriptors::USB20::string(u"MSFT100\u0003");
static constexpr auto compatIdDescriptor = EncodeDescriptors::MS_OS_Desc::compatId (
			0x100,					// bcdVersion
			4,						// wIndex
			EncodeDescriptors::MS_OS_Desc::compatIdFunction (
				0,									// bFirstInterfaceNumber
				Util::encodeString ("WINUSB\0\0"),	// compatibleID
				std::array<Util::EncChar, 8> {}				// subCompatibleID
			)
);
static constexpr Descriptor descriptors [] = { [...]
									{ strOsStringDesc, D_TYPE::STRING, 0xEE },
									{ compatIdDescriptor, D_TYPE::OS_DESCRIPTOR, 0 }
};
void DefaultControlEP::onSetupStage () {
	[...]
	if (	// Eine Standard-USB-Anfrage eines Deskriptors
			(bmRequestType == 0x80 && bRequest == ST_REQ::GET_DESCRIPTOR)
			// Oder eine Microsoft-spezifische Abfrage eines OS String Deskriptors
	||		(bmRequestType == 0xC0 && bRequest == ST_REQ::GET_OS_STRING_DESC)
	) {
		// Bei Standard-Anfragen ist der Typ des Deskriptors vorgegeben, ansonsten immer den OS String Deskriptor nehmen
		D_TYPE descType = bmRequestType == 0xC0 ? D_TYPE::OS_DESCRIPTOR : static_cast<D_TYPE> (wValue >> 8);
		// Es gibt nur 1 OS String Deskriptor; bei anderen nutze den gewünschten Index
		uint8_t descIndex = bmRequestType == 0xC0 ? 0 : static_cast<uint8_t> (wValue & 0xFF);

		// Durchsuche Deskriptor-Tabelle
		const Descriptor* d = getUsbDescriptor (descType, descIndex);
		[...]

Der String-Deskriptor wird über die ID 0xEE abgefragt. Windows sendet dann einen Befehl mit bmRequestType = 0xC0 und mit bRequest gleich der "Vendor"-Nummer, welche im String-Deskriptor definiert wurde. Im Beispiel ist sie "3". Auf diesen Request muss das Programm mit dem Compat Id Descriptor antworten. Im Beispielprogramm wird das erreicht indem diese Anfrage genau wie GET_DESCRIPTOR behandelt wird, und der Deskriptor in das globale "descriptors"-Array einsortiert wird unter Nutzung eines eigenen Deskriptor-Typs.

Linux: Setzen der udev-Regeln

Unter Linux haben normale User erstmal keine Berechtigung um auf beliebige USB-Geräte zuzugreifen - das würde im Falle von Datenträgern auch die komplette Datei-Rechteverwaltung außer Gefecht setzen. Wir können manuell die richtige Datei unter /dev/bus/usb ausfindig machen und die Berechtigungen anpassen, was aber nach jeder Verbindung des Geräts passieren muss und ziemlich lästig ist. Stattdessen kann man "udev", dem Programm welches die Gerätedateien unter /dev überhaupt erst anlegt, mitteilen wie es die Berechtigung unseres eigenes Geräts anpassen soll. Dazu legen wir z.B. unter /etc/udev/rules.d/99-deadbeef.rules (Name an Gerätetyp anpassen) eine Textdatei mit folgendem Inhalt an:

SUBSYSTEM=="usb", ATTR{idVendor}=="dead", ATTR{idProduct}=="beef", MODE="0666"

Die beiden Zahlen müssen dabei der VID/PID des Geräts entsprechen. Über "0666" geben wir oktal die gewünschte Berechtigung an, ähnlich wie bei "chmod". 0666 bedeutet dabei, dass jeder User Schreib-und Lesezugriff erhält. Alternativ könnte die Gerätedatei auch nur einer Benutzergruppe zugänglich gemacht werden, sodass nur bestimmte User - die in der Gruppe - Zugriff erhalten. Siehe dazu die Dokumentation für die udev-Regeldateien. Die Änderung wird erst nach einem Neustart wirksam, oder nach Ausführung dieses Befehls:

sudo udevadm control -R

Die Datei kann dem Benutzer zusammen mit dem Programm ausgeliefert werden, und z.B. mit in das .deb Paket verpackt werden.

Das libusb-API

Nachdem jetzt alles vorbereitet ist, können wir ein eigenes Programm mit libusb für den Zugriff auf unser USB-Gerät schreiben. Dazu werden im Folgenden kurz die Grundlagen von libusb gezeigt.

Ressourcen-Verwaltung

libusb hat ein C-API, was die Fehlerbehandlung etwas lästig macht. Wir wollen nicht gleich einen ganzen C++-Wrapper dafür implementieren, aber die Ressourcenverwaltung und Fehlerbehandlung um "RAII" und Exceptions aufrüsten, um den Code übersichtlich zu halten. Viele der Funktionen von libusb geben einen Integer-Typ zurück (aber nicht alle den gleichen), bei dem ein negativer Wert einen Fehler anzeigt. Daher schreiben wir uns eine Funktion "lu_err", welcher dieser Rückgabewert sowie eine Fehlermeldung als String übergeben wird. Ist der Wert >= 0, wird er einfach direkt zurückgegeben. Ist er negativ, wird eine Exception mit der Fehlermeldung ausgelöst, wobei auch die textuelle Beschreibung des Fehlers von libusb abgefragt wird:

template <typename Ret>
Ret lu_err (Ret r, std::string errmsg) {
	if (r < 0)
		// Frage Fehlerbeschreibung der Libusb ab und baue Fehlermeldung zusammen
		throw std::runtime_error (errmsg + libusb_error_name (static_cast<int> (r)) + " - " + libusb_strerror (static_cast<libusb_error> (r)));
	return r;
}

Beim Auslösen einer Exception wird bekanntlich zum im Aufrufstack nächsten "catch"-Block gesprungen. Wenn wir zwischenzeitlich Ressourcen angefragt haben (z.B. Device-Handler), werden diese dann nicht mehr freigegeben. Es werden aber die Destruktoren aller Objekte aufgerufen, welche in den nun verlassenen Scopes (Funktionen, if-Blöcke, Schleifen) bis dahin angelegt wurden. Dies nutzen wir mithilfe der std::unique_ptr-Klasse aus, indem wir Pointer auf automatisch zu löschende Ressourcen an diese übergeben und einen eigenen "Deleter" angeben, um die entsprechende libusb-Funktion zur Freigabe aufzurufen. Am Beispiel des libusb_context, der für die ganze Nutzungsdauer der libusb existieren muss, sieht das dann so aus:

/// Ein Dummy-Struct zur Freigabe des libusb context. Kann als "Deleter" in std::unique_ptr genutzt werden.
struct ExitLibusb {
	void operator () (libusb_context* ctx) {
		libusb_exit (ctx);
	}
};
/// Ein libusb_context welcher in diesem unique_ptr verpackt wird, wird automatisch korrekt freigegeben.
using CtxPtr = std::unique_ptr<libusb_context, ExitLibusb>;
// Initialisiere libusb
libusb_context* ctx;
lu_err (libusb_init (&ctx), "Initialisierung von libusb fehlgeschlagen: ");
// Verpacke libusb Kontext in unique_ptr für automatische Freigabe
CtxPtr ctxPtr (ctx);

Solange ctxPtr existiert, kann der Kontext genutzt werden. Wenn die diesen Code umschließende Funktion verlassen wird, wird der Kontext automatisch freigegeben. Analog verfahren wir mit Device-Listen und Device-Handles.

Gerät finden

Nachdem wir so den Kontext initialisiert haben, müssen wir unser angeschlossenes Gerät ausfindig machen. Hierbei macht sich ein Vorteil von USB bemerkbar: Das richtige Gerät lässt sich über die VID/PID identifizieren, der Benutzer muss keine Port-Nummer angeben. Über die Funktion libusb_get_device_list fragen wir eine Liste aller angeschlossener Geräte ab. Hier nutzen wir wieder std::unique_ptr, um die Liste beim Verlassen wieder freizugeben. Mit libusb_get_device_descriptor können wir dann die Informationen jedes Geräts abfragen - den Device Descriptor, den wir selbst zuvor angelegt haben. Durch Vergleich der darin enthaltenen VID/PID prüfen wir, ob es sich um das gesuchte Gerät handelt. Das könnte so aussehen:

// Die Liste der angeschlossenen Geräte
libusb_device **list_raw;
// Frage Liste ab, libusb_get_device_list allokiert Speicher
ssize_t cnt = lu_err(libusb_get_device_list (nullptr, &list_raw), "Liste angeschlossener Geräte konnte nicht abgefragt werden: ");

// Verpacke Liste in unique_ptr für automatische Freigabe
DevListPtr list (list_raw);

// Iteriere gefundene Geräte
ssize_t iFound = -1;
std::cout << "Angeschlossene Geräte:\n";
for (ssize_t i = 0; i < cnt; i++) {
	// Das Gerät
	libusb_device *device = list [i];
	libusb_device_descriptor deviceDescriptor;
	// Frage Device Descriptor ab
	lu_err (libusb_get_device_descriptor (device, &deviceDescriptor), "Konnte Geräte-Deskriptor nicht abfragen: ");

	// Prüfe auf gewünschte VID+PID
	if (iFound == -1 && deviceDescriptor.idVendor == 0xDEAD && deviceDescriptor.idProduct == 0xBEEF) {
		// Merke Index
		iFound = i;
	}
}
if (iFound == -1)
	throw std::runtime_error ("Kein passendes USB-Gerät gefunden.");

Gerät öffnen

Wenn wir ein passendes libusb_device gefunden haben, müssen wir es öffnen und das erste Interface in Anspruch nehmen:

// Öffne Device
libusb_device_handle *handle = nullptr;
lu_err (libusb_open (list [iFound], &handle), "Konnte Gerät nicht öffnen: ");

// Verpacke Handle in unique_ptr für automatische Freigabe
DevPtr devPtr (handle);

// Beanspruche das Interface für diese Anwendung (sendet nichts auf dem Bus)
lu_err (libusb_claim_interface (handle, 0), "Konnte Interface nicht öffnen: ");

Abfrage der String-Deskriptoren

Als nächstes wollen wir die String-Deskriptoren anzeigen. Dazu nutzen wir libusb_get_string_descriptor_ascii, um die Strings nach ASCII zu konvertieren, denn die plattformübergreifende Konsolen-Ausgabe von UTF-16-Strings ist kompliziert. Bei der Entwicklung von GUI-Applikationen mit z.B. dem Gtk+ können wir den UTF-16-String abfragen, nach UTF-8 konvertieren und direkt anzeigen. Für die Funktion brauchen wir einen eigenen Puffer und den Index des String-Deskriptors, den wir dem Device-Deskriptor entnehmen:

// libusb erwartet einen String-Puffer. 256 Bytes ist die Maximal-Länge bei String-Deskriptoren, und wir brauchen noch ein Zeichen mehr zum Terminieren
unsigned char strBuffer [257];
int len;
if (foundDeviceDescriptor.iManufacturer != 0) {
	// Frage Deskriptor ab
	len = lu_err (libusb_get_string_descriptor_ascii (handle, foundDeviceDescriptor.iManufacturer, strBuffer, sizeof (strBuffer)-1), "Konnte Hersteller-String nicht abfragen: ");
	// Setze terminierendes 0-Byte
	strBuffer [len] = 0;
	std::cout << "Manufacturer: " << strBuffer << std::endl;
}

Analog verfahren wir mit dem Product- und dem Serial-String. Letzterer kann genutzt werden, um verschiedene Exemplare des gleichen Gerätetyps zu unterscheiden, und dem Nutzer eines eigenen Programms die Möglichkeit zu geben, ein bestimmtes Gerät auszuwählen, wenn mehrere davon am PC angeschlossen sind. Dies wird z.B. in der J-Link-Software so gemacht.

Control-Transfers

Jetzt wollen wir unser Gerät etwas sinnvolles tun lassen, und die LED's vom PC aus steuern. Dazu müssen wir einen Control-Transfer mit der selbst definierten Anfrage senden. Dazu kodieren wir den Wunschzustand in einem Integer, welchen wir in wValue an das Gerät senden werden, indem wir libusb_control_transfer aufrufen:

// Prüfe Kommandozeilenargumente
bool LED1 = args[1] == "1";
bool LED2 = args[2] == "1";
// Baue Paket zusammen
uint8_t ledData = static_cast<uint8_t> (uint8_t{ LED1 }  | (uint8_t{ LED2 } << 1));
// Sende Anfrage, nutze Paket für wValue
lu_err (libusb_control_transfer (handle, 0x40, 1, ledData, 0, nullptr, 0, 0), "Konnte LED-Zustand nicht setzen: ");

Die Gegenrichtung zum Abfragen des aktuellen Zustands geht ebenfalls über libusb_control_transfer:

// Frage aktuellen Zustand ab, empfange dazu ein 1-Byte-Paket
uint8_t ledData;
lu_err (libusb_control_transfer (handle, 0xC0, 2, 0, 0, &ledData, 1, 0), "Konnte LED-Zustand nicht abfragen: ");

// Extrahiere Daten aus Paket und gebe sie aus
std::cout << "LED1: " << int {ledData & 1} << std::endl << "LED2: " << int {(ledData & 2) >> 1} << std::endl;

Zum Schluss wollen wir noch die Übertragung größerer Datenmengen testen, indem wir ein aus zufälligen Bytes bestehendes Datenpaket an den Bulk Endpoint 1 senden, die Antwort empfangen, und prüfen ob jedes Byte wie gewünscht gedreht wurde. Die Datenübertragung läuft dabei über libusb_control_transfer:

// Sende Datenblock
int sent;
lu_err (libusb_bulk_transfer (handle, 1, txBuffer, sizeof (txBuffer), &sent, 0), "OUT Transfer fehlgeschlagen: ");

// Empfange antwort
lu_err (libusb_bulk_transfer (handle, 0x81, rxBuffer, sizeof (rxBuffer), &sent, 0), "IN Transfer fehlgeschlagen: ");

Zu beachten ist, dass wir den Endpoint 1 als "half-duplex" implementiert haben, d.h. dass erst wieder Daten vom Gerät empfangen werden können, nachdem wir das zurückgesendete Paket vom PC aus abgefragt haben. Ein wiederholtes Senden von Daten an das Gerät ohne zwischenzeitliches Abfragen der Antwort bewirkt eine Blockade der Kommunikation.

Ein vollständiger Lauf des Beispielprogramms sieht dann (gekürzt) etwa so aus:

$ ./usbclient 0 1
Angeschlossene Geräte:
2:8:5 04f2:b336
2:5:9 04ca:300b
2:2:10 dead:beef
2:4:6 046d:0a1f
[...]
Manufacturer: ACME Corp.
Product: Fluxkompensator
Serial: 42-1337-47/11
LED1: 1
LED2: 0
Sende Daten     : de, c8, 1d, a0, 7b, 33, 65, f0 [...]
Empfangene Daten: 7b, 13, b8, 05, de, cc, a6, 0f [...]
Daten stimmen überein: true

Das war's! Wir haben ein eigenes simples USB-Gerät implementiert und vom PC aus plattformunabhängig angesteuert. Auf dieser Basis können wir jetzt kompliziertere Geräte mit sinnvollerer Funktion entwickeln.

Vollständige Umsetzung von Control Endpoints

Unser USB-Hello-World enthält nur eine rudimentäre Umsetzung des Protokolls für Control Endpoints. Damit können außer dem Setup-Block keine Daten vom Host empfangen werden, und es können nur Daten gesendet werden, die in ein Paket passen, was maximal 64 Byte groß sein darf. Insbesondere für die Standard-Geräteklassen werden aber oft wesentlich längere Deskriptoren benötigt, die wir so nicht senden können. Daher soll die Unterstützung jetzt ausgebaut werden, indem die ControlEP-Klasse entsprechend erweitert wird. Da wir von Anfang an das Protokoll in dieser Klasse gekapselt haben, müssen wir keine anderen Programmteile modifizieren.

In der Klasse implementieren wir jetzt einen endlichen Automaten, der sich merkt, an welcher Stelle im Protokoll er gerade ist. Dazu definieren wir ein enum, was die einzelnen Zustände angibt:

enum class CTRL_STATE : uint8_t { SETUP, DATA_OUT, DATA_IN, DATA_IN_LAST, STATUS_IN, STATUS_OUT };
Zustandsdiagramm für Control Transfers

Die Bedeutung der einzelnen Zustände ist:

  • Bei Anfangszustand SETUP wird auf den Empfang eines Setup-Pakets gewartet (Setup-Stage). Von hier wird je nach Anfrage entweder nach DATA_OUT, DATA_IN, DATA_IN_LAST oder STATUS_OUT gewechselt, oder im Fehlerfall in SETUP verblieben.
  • Im Zustand DATA_OUT wird auf den Empfang eines Datenpakets der Data-Stage gewartet. Wenn der Host ein Paket sendet, dessen Länge geringer als die maximale Paketgröße (bMaxPacketSize0 im Device Descriptor) ist oder ein leeres Paket (nötig wenn die Gesamt-Länge ein Vielfaches der max. Paketgröße ist) bedeutet dies, dass keine weiteren Daten mehr vorliegen. Dann wird von hier nach STATUS_OUT gewechselt. Im Fehlerfall geht es direkt zurück nach SETUP.
  • Im Zustand DATA_IN wird auf das vollständige Absenden eines Datenpakets in der Data-Stage gewartet. Wenn das Ende des Datenblocks erreicht wird, wird dies signalisiert, indem ein unvollständiges oder leeres Datenpaket (analog zum Empfang) gesendet wird. In diesem Fall wird zu DATA_IN_LAST gewechselt.
  • Im Zustand DATA_IN_LAST wird auf das Absenden des letzten (unvollständigen bzw. leeren) Datenpakets gewartet. Danach wird zu STATUS_IN gewechselt.
  • Im Zustand STATUS_IN wird auf das Empfangen eines leeren Pakets vom Host gewartet, was den Abschluss der Transaktion signalisiert. Danach wird zurück zu SETUP gewechselt.
  • Im Zustand STATUS_OUT wird auf das Absenden eines leeren Pakets zum Signalisieren des Erfolgs gewartet. Danach wird zu SETUP gewechselt.

Wenn im Fehlerfall zu SETUP zurückgegangen wird, wird der Endpoint auf "STALL" konfiguriert, sodass der Host den Fehler erkennt. Der Empfang des nächsten Setup-Pakets setzt diesen Zustand automatisch in der Peripherie zurück. In einer neuen Member-Variable m_state, die in ControlEP das alte m_sendStatus ersetzt, speichern wir über das enum den aktuellen Zustand dieses Endpoints.

Um "OUT" Data-Stages zu unterstützen, brauchen wir in ControlEP eine neue Funktion dataOutStage, welche diese initiiert. Dazu gehört noch ein neuer Callback onDataOut, der aufgerufen wird, wenn der Datenblock empfangen wurde. Um längere Datenblöcke übertragen zu können, brauchen wir einige neue Member-Variablen:

/// Datenpuffer für zu sendende/empfangende Daten.
uint8_t* m_data;
/// Gesamtzahl zu übertragender Bytes
size_t m_count;
/// Verbleibende Anzahl zu übertragender Bytes
size_t m_remaining;
/// Aktueller Zustand des Zustandsautomaten
CTRL_STATE m_state;

Darin speichern wir einen Zeiger auf die als nächstes zu übertragenen Daten, sowie die Gesamtmenge und die verbleibende Menge an Bytes. Eigentlich brauchen wir zwei Zeiger - einen vom Typ "const uint8_t*" zum Senden, und einen vom Typ "uint8_t*" zum Empfangen. Ersterer kann nicht zum Empfangen genutzt werden weil er nicht schreibbar ist, und letzterer nicht zum Senden weil dataInStage einen "const"-Pointer erhält. Da aber immer nur einer davon gleichzeitig gebraucht wird, würden wir so Speicher verschwenden. Stattdessen nutzen wir nur einen "uint8_t*"-Pointer, und nutzen in dataInStage einen "const_cast", um das "const" loszuwerden. Das ist zwar unelegant, aber nicht verboten da wir ja beim Senden nie auf m_data schreiben, obwohl es dann nicht "const" ist.

Jetzt müssen wir in den Callbacks onTransmit, onReceive und onReset die einzelnen Schritte durchführen.

  • In onReset wird der ganze Automat zurückgesetzt.
  • Das Verhalten von onReceive ändert sich je nach Zustand
    • Beim Empfang von Setup-Paketen wird immer onSetupStage aufgerufen
    • Sonst wird im Zustand STATUS_IN ein leeres Paket verarbeitet und onStatusStage aufgerufen
    • Im Zustand DATA_OUT wird werden die empfangenen Daten nach m_data gespeichert, und wenn das Ende des Transfers erkannt wurde, onDataOut aufgerufen.
  • Ähnliches gilt für onTransmit:
    • Im Zustand DATA_IN wird berechnet, ob als nächstes ein volles, unvollständiges oder leeres Paket gesendet werden muss und entsprechend entschieden, ob danach wieder DATA_IN oder DATA_IN_LAST folgt.
    • Im Zustand DATA_IN_LAST wird nach STATUS_IN gewechselt, der Empfang eines leeren Pakets als Bestätigung erwartet und onDataIn aufgerufen.
    • Im Zustand STATUS_OUT wird wieder nach SETUP gewechselt, und onStatusStage aufgerufen.

In dataInStage ist eine ähnliche Fallunterscheidung nötig, um zu entscheiden, was für ein Paket gesendet wird und ob DATA_IN oder DATA_IN_LAST betreten wird. dataOutStage ist relativ simpel und bereitet nur den Empfang vor. statusStage muss nur an die geänderte Zustandsdarstellung angepasst werden. Weitere Änderungen des restlichen Codes sind nicht notwendig. So können insbesondere mit dataInStage auch direkt längere Datenblöcke gesendet, und damit größere Deskriptoren abgerufen werden. Das wird im nächsten Kapitel benötigt.

Virtueller COM-Port

Testen des VCP mithilfe eines Arduinos

Als komplexeres Beispiel soll nun ein VCP, d.h. ein Adapter von USB auf den Serial-Port, implementiert werden. Dafür soll die Standard-USB-Klasse CDC ("communications device class") genutzt werden. Diese definiert eine ganze Reihe verschiedener Kommunikations-Geräte, darunter die Unterkategorie "PSTN" für alles, was mit dem analogen Telefonnetz verbunden wird (Fax, Telefon, Modem). Darin steht die Unterklasse ACM ("abstract control model") für klassische Analog-Modems zur Verfügung, bei der die Daten (insb. AT-Kommandos) durch USB durchgeleitet werden. CDC-ACM ist also dafür gedacht, als USB-Serial-Adapter speziell für Analog-Modems zu fungieren. Die Betriebssysteme legen für CDC-ACM-Geräte einen COM-Port an, auf den dann per Einwahlsoftware zugegriffen werden kann, wie COM1 unter Windows und /dev/ttyACM0 unter Linux. Tatsächlich kann man aber auch mit beliebiger anderer Software darauf zugreifen und auch beliebige andere Geräte an solch einen Adapter anschließen. Daher wird diese Klasse hauptsächlich als allgemeiner USB-Serial-Adapter genutzt, weil die Betriebssysteme bereits Treiber dafür mitliefern und die Haupt-Anwendung der Analog-Modems ohnehin obsolet ist. Daher soll auch im folgenden Beispiel ein CDC-ACM-Gerät implementiert werden, welches die Daten auf die UART-Peripherie des Controllers umleitet. Alternativ könnte man die Daten auch direkt in Software für etwas anderes verarbeiten, und somit den Zugriff auf das Gerät per Terminal-Software erlauben - hierbei verliert man aber einige der Vorteile von USB. Im Beispielprojekt erfolgt die Implementierung des VCP im branch vcp.

Deskriptoren für CDC

Weil die CDC-Klasse so viele verschiedene Gerätearten unterstützt, muss das konkrete Gerät relativ kompliziert deklariert werden. Als erstes muss im Device Deskriptor die Klasse auf "2" gesetzt werden, und die SubClass und Protocol beide auf 0. Es wird immer noch nur 1 Konfiguration gebraucht, aber darin müssen für jeden Port 2 Interfaces angelegt werden, also in Summe 6. Daher wird bNumInterfaces auf 6 gesetzt. Das erste Interface ist jeweils ein "Communications Class Interface", und erhält die Werte bInterfaceClass=2, bInterfaceSubClass=2 und bInterfaceProtocol=1. Das definiert dieses Interface als CDC-ACM mit AT-Kommandos. Über den einzigen Endpoint von diesem Interface, auch management element genannt, werden nur Meta-Informationen übertragen - im Fall von ACM sind das u.a. der Zustand der Flusskontroll-Leitungen DSR und DCD. Das werden wir aber der Einfachheit halber nicht implementieren, aber wir müssen dennoch das Interface mit seinem IN-Endpoint vom Typ "Interrupt" im Deskriptor anlegen. Das zweite Interface ist das "Data Class Interface" mit den Werten bInterfaceClass=0xA, bInterfaceSubClass=0 und bInterfaceProtocol=0. Dieses enthält einen bidirektionalen Bulk-Endpoint, über welchen die eigentlichen Daten für den seriellen Port übertragen werden. Nach dem Deskriptor für das Communications Class Interface müssen wir noch weitere CDC-spezifische Deskriptoren hinzufügen:

  • Als erstes folgt ein "Header Functional Descriptor", dessen einzige Information außer Größe und Typ die Version der CDC-Spezifikation ist.
  • Es wird "Union Interface Functional Descriptor" benötigt, welcher die beiden Interfaces zu einer funktionalen Einheit verbindet. Dort wird die Nummer unseres Communications Class Interface als "Control Interface" angegeben, und die des Data Class Interface als Subordinate Interface.
  • Im "Abstract Control Management Functional Descriptor" wird in einem Bitfeld die Fähigkeiten des Geräts angegeben. Dort werden wir Bit 1 setzen, um anzuzeigen dass das Gerät die Befehle Set_Line_Coding, Set_Control_Line_State und Get_Line_Coding unterstützt. Das wird gebraucht, um die Baudrate am Host einstellen zu können.
  • Im "Call Management Functional Descriptor" geben wir an, dass das Gerät kein Call Management unterstützt - es ist schließlich kein echtes Modem vorhanden.

Für die Details der Deskriptoren sei auf die CDC-Spezifikation verwiesen. In der usb_desc_helper.hh sind auch für die CDC-Deskriptoren Funktionen angelegt. Weil die Deskriptoren für die drei Ports bis auf die Endpoint-und Interfacenummern identisch sind, wird eine zusätzliche Funktion definiert, welche alles zusammenfasst, was nach dem Configuration Descriptor pro Port folgt. Damit sieht die Definition von Device- und Configuration-Deskriptor so aus:

/**
 * iInterface gibt den Index des Management-Interface an (für Notifications), das Daten
 * Interface wird dann als iInterface+1 angenommen. iMgmtEP und iDataEP geben die Adressen der jeweiligen
 * Endpoints an.
 */
static constexpr std::array<Util::EncChar, 58> vcpDescriptor (uint8_t iInterface, uint8_t iMgmtEP, uint8_t iDataEP) {
	return Util::concatArrays<Util::EncChar> (
		EncodeDescriptors::USB20::interface (
			static_cast<uint8_t> (iInterface),					// bInterfaceNumber
				0,												// bAlternateSetting
				1,												// bNumEndpoints
				0x2,											// bInterfaceClass		Communications Interface
				0x2,											// bInterfaceSubClass	Abstract Control Model
				0x1,											// bInterfaceProtocol	AT
				0												// iInterface
		),

			EncodeDescriptors::CDC::classSpecific (
				0x10,	// bcdCDC
				EncodeDescriptors::CDC::unionInterface(
					static_cast<uint8_t> (iInterface),			// bControlInterface
					static_cast<uint8_t> (iInterface + 1)		// bSubordinateInterfaces
				),
				EncodeDescriptors::CDC::ACM(
						2										// bmCapabilities	Unterstützung für Set_Line_Coding, Set_Control_Line_State, Get_Line_Coding,
				),
				EncodeDescriptors::CDC::callManagement(
						0,										// bmCapabilities	Kein Call Management
						static_cast<uint8_t> (iInterface + 1)	// bDataInterface
				)
			),

			// Notification endpoint - wird im Beispiel nicht genutzt
			EncodeDescriptors::USB20::endpoint (
				0x80 | iMgmtEP,									// bEndpointAddress		IN
				3,												// bmAttributes			Interrupt
				8,												// wMaxPacketSize
				255												// bInterval
			),

		// Data Interface (Payload transfer)
		EncodeDescriptors::USB20::interface (
			static_cast<uint8_t> (iInterface+1),				// bInterfaceNumber
			0,													// bAlternateSetting
			2,													// bNumEndpoints
			0xA,												// bInterfaceClass		Data Interface
			0x0,												// bInterfaceSubClass	Unused
			0x0,												// bInterfaceProtocol	No class specific protocol
			0													// iInterface
		),
			// Endpoints für eigentliche Nutzdaten

			EncodeDescriptors::USB20::endpoint (
				iDataEP,										// bEndpointAddress			OUT
				2,												// bmAttributes				Bulk
				dataEpMaxPacketSize,							// wMaxPacketSize
				10												// bInterval
			),
			EncodeDescriptors::USB20::endpoint (
				0x80 | iDataEP,									// bEndpointAddress			IN
				2,												// bmAttributes				Bulk
				dataEpMaxPacketSize,							// wMaxPacketSize
				10												// bInterval
			)
	);
}

// Die hier im Device Deskriptor angegebene Device Class markiert das Gerät als Composite Device.
static constexpr auto deviceDescriptor = EncodeDescriptors::USB20::device (
			0x200,		// bcdUSB
			0x02,		// bDeviceClass		Communication Device Class
			0x00,		// bDeviceSubClass	Unused
			0x00,		// bDeviceProtocol	Unused
			64,			// bMaxPacketSize0
			0xDEAD,		// idVendor		TODO - anpassen
			0xBEEF,		// idProduct	TODO - anpassen
			0x0100,		// bcdDevice
			1,			// iManufacturer, entspricht dem Index des strManufacturer-Deskriptors
			2,			// iProduct, entspricht dem Index des strProduct-Deskriptors
			3,			// iSerialNumber, entspricht dem Index des strSerial-Deskriptors
			1			// bNumConfigurations
		);

static constexpr auto confDescriptor = EncodeDescriptors::USB20::configuration (
			6,			// bNumInterfaces
			1,			// bConfigurationValue
			0,			// iConfiguration
			0x80,		// bmAttributes
			250,		// bMaxPower (500mA)

			// Füge die Deskriptoren für die VCPs hinzu
			vcpDescriptor (0, 1, 2),
			vcpDescriptor (2, 3, 4),
			vcpDescriptor (4, 5, 6)
);

Wird das so markierte Gerät angeschlossen, zeigt der Linux-Kernel folgende Meldung:

[39541.301191] usb 2-2: new full-speed USB device number 24 using xhci_hcd
[39541.431269] usb 2-2: New USB device found, idVendor=dead, idProduct=beef
[39541.431275] usb 2-2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[39541.431277] usb 2-2: Product: Fluxkompensator
[39541.431279] usb 2-2: Manufacturer: ACME Corp.
[39541.431281] usb 2-2: SerialNumber: 42-1337-47/11
[39541.432081] cdc_acm 2-2:1.0: ttyACM0: USB ACM device
[39541.432508] cdc_acm 2-2:1.2: ttyACM1: USB ACM device
[39541.432968] cdc_acm 2-2:1.4: ttyACM2: USB ACM device
Der 3x-VCP im Geräte-Manager

Entsprechend werden die Dateien /dev/ttyACM0, /dev/ttyACM1, /dev/ttyACM2 angelegt. Die sind natürlich noch nicht benutzbar, weil wir noch nichts auf den entsprechenden Endpoints übertragen.

Unter Windows funktioniert das Gerät so leider noch nicht. Der Windows-CDC-Treiber kann nicht direkt mit Geräten umgehen, die mehrere Ports haben. Stattdessen ist es nötig, das Gerät als Verbundgerät ("Composite Device") zu definieren, dann wird der Treiber 3x (einmal pro Port) geladen und wir erhalten 3 COM-Ports. Dazu setzen wir im device descriptor bDeviceClass=0xEF, bDeviceSubClass=0x02, bDeviceProtocol=0x01. Für diese neue Klasse müssen wir dann im Configuration Descriptor pro Port je einen "Interface Association Descriptor" anlegen. Dieser definiert je eine Funktion des Verbundgeräts. Dort geben wir an, welche Interfaces zu dieser Funktion gehören, indem wir den Index des ersten Interfaces (0/2/4) angeben sowie die Anzahl zugehöriger Interfaces (2). Außerdem müssen dort die Class, SubClass und Protocol des ersten Interfaces mit übernommen werden, also 2, 2 und 1. Optional kann noch ein String-Deskriptor angegeben werden. Mithilfe einer entsprechenden Funktion in der usb_desc_helper.hh sieht das dann so aus:

EncodeDescriptors::IAD::interfaceAssociation (
	iInterface,		// bFirstInterface
	2,				// bInterfaceCount
	0x02,			// bFunctionClass
	2,				// bFunctionSubClass
	1,				// bFunctionProtocol
	4				// iFunction
)

Das so definierte Gerät wird dann sowohl von Windows als auch Linux korrekt als 3-Fach-Serial-Adapter erkannt. Als nächstes müssen wir die Endpoint der Data Class Interfaces mit Leben füllen.

Implementierung der Datenübertragung

Um alle Aspekte eines der drei VCPs zu kapseln, implementieren wir eine Klasse namens "VCP". Diese wird den Zugriff auf den USART mittels DMA steuern, die Baudrate & Frame-Format konfigurieren und Flusskontroll-Leitungen setzen. Da USART und USB unterschiedliche Datenraten haben, wird für jede Richtung ein Puffer benötigt. Hier wird ein einfacher Doppelpuffer genutzt, der aber z.B. auf einen "richtigen" Ringpuffer ausgebaut werden könnte.

Für den Zugriff auf den Bulk-Endpoint für die Nutzdaten wird die VCP-Klasse von der zuvor implementierten EPBuffer-Klasse ableiten und die drei Callbacks onReset, onReceive, onTransmit überschreiben. Der Konstruktor wird mit den entsprechenden Daten (Endpoint-Adresse, -Typ, Zeiger auf Puffer) aufgerufen. Der Management-Endpoint sollte zwar initialisiert werden, aber die Callbacks werden nicht gebraucht. Zu dessen Implementierung wird eine von EPBuffer abgeleitete Klasse VCP_MgmtEP erstellt, welche die Callbacks leer implementiert und dem Konstruktur die nötige Konfiguration übergibt. Die Puffer-Zeiger können "nullptr" sein, da nie receivePacket/transmitPacket aufgerufen wird. Vom Typ dieser Klasser erhält VCP eine Member-Variable. Damit ist VCP nun in der Lage Daten per USB zu senden/empfangen.

Die VCP-Klasse enthält zwei Doppelpuffer-Instanzen, eine pro Richtung. Via DMA wird darin direkt gelesen/geschrieben, so wie Platz ist bzw. Daten verfügbar sind. Ist die empfangende Seite des Doppelpuffers voll, werden die Puffer getauscht, sodass die empfangenen Daten weitergesendet werden, und wieder Platz zum Empfangen ist. Diese Konstruktion hat ein Problem bei niedrigen Datenraten: Dann kann es sehr lange dauern, bis der Puffer voll ist und die Daten weitergeleitet werden. Wenn ein serielles Protokoll mit kurzen Datenpaketen genutzt wird, bei dem auf eine Antwort gewartet wird, bleibt die Kommunikation sogar ganz stecken. Daher wird auf der USB->USART Seite der Puffer nach dem Empfang eines Pakets sofort getauscht, und auf der USART->USB Seite nach Empfang eines IDLE-Frames, d.h. wenn für (mindestens) die Dauer eines Frames die Leitung auf 'High' war. Dies hat den Nachteil, dass zu Beginn einer schnellen Kommunikation die Effizienz sinkt.

Auf eine genauere Erläuterung der USART-Ansteuerung wird hier verzichtet, weil dies nicht mehr viel mit USB zu tun hat. Einzig interessant ist noch die Einstellung der Schnittstellenparameter. Dazu sendet der Host die Anfrage "SET_LINE_CODING" mit den Parametern im Datenblock auf dem Default Control Endpoint 0. Über GET_LINE_CODING können die Parameter wieder abgefragt werden. In diesem Datenblock wird die gewünschte Baudrate als 32bit-Integer angegeben, aus welchem dann der Prescaler für die USART-Peripherie berechnet werden muss. Im Reference Manual ist die Berechnung des USART-Prescalers unnötig kompliziert dargestellt. Einfacher ist es so: Das USART-Modul wird mit dem Perpherietakt f_PCLK betrieben - APB2 beim USART1, und APB1 bei USART2 und USART3. Im Beispiel sind beim APB2 72 MHz, und 36 MHz beim APB1 eingestellt. Der Prescaler P ergibt sich dann bei gewünschter Baudrate B_w durch: P = f_PCLK / B_w. Die normale Integer-Division in C++ ist allerdings grundsätzlich abrundend, es wäre aber wünschenswert richtig zu runden, um immer die nächstmögliche und nicht die nächste kleinere Baudrate zu erwischen. Dies kann erreicht werden, indem man "P = (f_PCLK + (B_w / 2)) / B_w;" rechnet. Mit dem USART lässt sich aber nicht jede beliebige Baudrate erreichen, sondern nur solche, bei der die tatsächliche über den Prescaler erreichte Baudrate "B_i = f_PCLK / P " um maximal 3% von der gewünschten "B_w" abweicht. Wir wollen also prüfen, ob die angeforderte Baudrate tatsächlich erreichbar ist, d.h. ob "|B_i - B_w| / B_w <= 0.03" ist. Um das nicht mit floating-Point-Zahlen rechnen zu müssen (der Cortex-M3 hat keine FPU), wird das nach "|B_i - f_PCLK|*100/3 <= B_i" umgestellt. Da im Code nur mit vorzeichenlosen Integern gerechnet wird, muss der Betrag der Differenz mit einer Fallunterscheidung berechnet werden. So können wir letzendlich jede Baudrate einstellen, die vom Controller unterstützt wird, wozu alle üblichen Standard-Baudraten gehören. Bei nicht unterstützten wird ein Fehler an den PC zurückgesendet und die Baudrate nicht übernommen.

Übrig bleibt lediglich noch das Setzen der Flusskontroll-Leitungen DTR und RTS über den Request SET_CONTROL_LINE_STATE. Das funktioniert genauso wie das Setzen der LED's im Hello-World-Beispiel.

In Summe erhalten wir so einen einfachen aber funktionsfähigen 3-Fach-USB-Serial-Adapter, der ohne Treiber funktioniert und viele Baudraten unterstützt.

Stromversorgung per USB

Da nun die Grundlagen der USB-Ansteuerung geklärt sind, kann ein weiteres einfaches Beispiel gezeigt werden: Häufig stellt sich die Frage, wie eigene Projekte über USB versorgt werden können. Standardmäßig erlaubt USB angeschlossenen Geräten, nur maximal 100mA Strom aufzunehmen. Benötigt ein Gerät unerlaubt mehr, kann der Host es abschalten oder sogar Schaden nehmen. Ein Gerät kann per Software bis zu 500mA anfordern, indem es diesen Strom im Configuration Descriptor einträgt. Nur wenn der PC diese Konfiguration per "SET_CONFIGURATION" aktiviert, darf das Gerät den Strom aufnehmen. Der PC kann die Freigabe auch wieder zurücknehmen, indem er "SET_CONFIGURATION" mit "0" als Parameter sendet. Das explizite Einschalten ist wichtig, um auch bei der Nutzung von bus powered Hubs eine Überlast der root ports zu vermeiden. Das Verhalten der verschiedenen Host-Implementation variiert allerdings stark; viele Hosts nehmen eine zu hohe Stromaufnahme nicht übel.

Auf Basis der bereits implementierten Funktionen kann sehr einfach eine Firmware erstellt werden, welche die maximalen 500mA anfordert und nur bei Freigabe einen Pin einschaltet, mit dem dann der eigene Verbraucher ein/aus geschaltet werden kann. Damit Windows das Gerät ohne Treiberinstallation akzeptiert, wird es wieder als WinUSB Device implementiert, welches aber keine PC-Software benötigt und keine weitere Kommunikation unterstützt. Es wird lediglich enumeriert und befindet sich dann im "Leerlauf". Es reagiert auf die SET_CONFIGURATION-Befehle mit Ein/Ausschalten eines GPIO's. Im Beispielcode wird hier PA5 genutzt, welcher auf dem Olimexino die LED1 schaltet und auf den Arduino-Pin D13 geführt ist. Pin und Stromanforderung können in der "src/main.hh" angepasst werden. Im Beispielcode ist dies im usbpower-Branch implementiert. Es ist auch ein fertiges Image zum direkten Flashen verfügbar.