USB-Tutorial mit STM32: Unterschied zwischen den Versionen

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche
(Screenshot vom WinUSB Device im Gerätemanager hinzugefügt)
K (Hinweis zu C++17 hinzugefügt.)
Zeile 61: Zeile 61:


=== Beispielprojekt ===
=== Beispielprojekt ===
Das Ergebnis dieses Tutorials ist als Beispielprogramm über [https://github.com/Erlkoenig90/f1usb GitHub] verfügbar. Die drei hier gezeigten Varianten sind dort als einzelne Branches ausgeführt. Das Projekt ist als [https://www.eclipse.org/cdt/ eclipse-cdt] Projekt unter Nutzung des [https://gnu-mcu-eclipse.github.io/ GNU MCU Eclipse-Plugins] eingerichtet. Es funktioniert mit der GCC-Distribution [https://developer.arm.com/open-source/gnu-toolchain/gnu-rm GNU Arm Embedded Toolchain] in der Version 6.3.1 (Juni 2017). Das Projekt funktioniert mit dem [https://www.segger.com/products/debug-probes/j-link/ SEGGER J-Link], kann aber auch für andere Debugger angepasst werden (entsprechende Launch Configurations hinzufügen).
Das Ergebnis dieses Tutorials ist als Beispielprogramm über [https://github.com/Erlkoenig90/f1usb GitHub] verfügbar. Die drei hier gezeigten Varianten sind dort als einzelne Branches ausgeführt. Das Projekt ist als [https://www.eclipse.org/cdt/ eclipse-cdt] Projekt unter Nutzung des [https://gnu-mcu-eclipse.github.io/ GNU MCU Eclipse-Plugins] eingerichtet. Es benötigt einen Compiler mit C++17-Unterstützung, wie sie von der GCC-Distribution [https://developer.arm.com/open-source/gnu-toolchain/gnu-rm GNU Arm Embedded Toolchain] in der Version 6.3.1 (Juni 2017) geboten wird. Insbesondere die fold-expressions in dieser Sprachversion vereinfachen die Erstellung der USB-Deskriptoren, wie weiter unten zu sehen ist. Das Projekt funktioniert mit dem [https://www.segger.com/products/debug-probes/j-link/ SEGGER J-Link], kann aber 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.
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.



Version vom 24. November 2017, 00:16 Uhr

Dieser Artikel ist noch unvollständig und wird noch erweitert!

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.

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.

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.

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)
  • 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.

Beispielprojekt

Das Ergebnis dieses Tutorials ist als Beispielprogramm über GitHub verfügbar. Die drei hier gezeigten Varianten sind dort als einzelne Branches ausgeführt. Das Projekt ist als eclipse-cdt Projekt unter Nutzung des GNU MCU Eclipse-Plugins eingerichtet. Es benötigt einen Compiler mit C++17-Unterstützung, wie sie von der GCC-Distribution GNU Arm Embedded Toolchain in der Version 6.3.1 (Juni 2017) geboten wird. Insbesondere die fold-expressions in dieser Sprachversion vereinfachen die Erstellung der USB-Deskriptoren, wie weiter unten zu sehen ist. Das Projekt funktioniert mit dem SEGGER J-Link, kann aber 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 wird. 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:

const unsigned char deviceDescriptor [18] = {
	18,			// bLength
	1,			// bDescriptorType
	0x00, 0x02,	// bcdUSB
	0xFF,		// bDeviceClass
	0xFF,		// bDeviceSubClass
	0xFF,		// bDeviceProtocol
	64,			// bMaxPacketSize0
	0xAD, 0xDE, // idVendor
	0xEF, 0xBE, // idProduct
	0x00, 0x01, // bcdDevice
	0,			// iManufacturer
	0,			// iProduct
	0,			// iSerialNumber
	1			// bNumConfigurations
};

Dabei werden die 16bit-Zahlen in einzelne Bytes aufgeteilt, und das Byte mit den weniger signifikanten Stellen zuerst abgelegt (Little Endian). 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). Die bereits erwähnte Vendor ID und Product ID werden hier auf 0xDEAD bzw. 0xBEEF gesetzt. Stehen eigene ID's zur Verfügung, sollten die natürlich hier eingesetzt werden. Die drei Zahlen iManufacturer, iProduct und iSerialNumber geben Indices 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. Das Programm kann direkt ausgeführt werden.

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.

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.

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.

Virtueller COM-Port