Zeitlicher Determinismus durch Timer, ISRs und Hardware im SoC – Teil 1

Wir kennen es aus der Biologie: entspannt sitzt man auf einem Stuhl, liest ein Buch und denkt über seinen Inhalt nach. Plötzlich will ein Glas auf dem Tisch umfallen, reflexartig greift man zu und rettet das Glas. Möglich macht dies die Arbeitsteilung im Körper, darunter auch zwischen Groß- und Kleinhirn.

Ebenso haben wir diese Arbeitsteilung in der Computertechnik, in jedem Arbeitsrechner, Server oder Embedded-System. In diesem Beitrag zeige ich das Prinzip speziell an einem SoC, dem RP2350 (Raspberry Pi Pico 2), denn dieser besitzt im Gegensatz zu vielen anderen SoC-Familien eine programmierbare Zustandsmaschine PIO (Programmable Input Output).

Diese ist ein sehr eleganter Kompromiss zwischen dem viel flexibleren FPGA, mit allerdings auch wesentlich komplexerer Programmierung, und „fest verdrahteten“ Timern, Bustreibern oder Lösungen wie PWM, RMT, PCNT etc. Die praktischen Auswirkungen sehen wir später konkret.


ACHTUNG: Manche Entscheidungsträger ordnen Raspberry-Pi-Produkte reflexhaft als „Bastlerlösungen“ ein, obwohl Raspberry Pi längst parallel zum Maker-Markt ernstzunehmend den Industriesektor bedient.

Dazu gehören Compute Modules, Mikrocontroller, umfangreiche technische Dokumentation, Compliance-Unterlagen und teils sehr lange Produktionszusagen. Gerade die große Community ist ein zusätzlicher Qualitäts-, Erfahrungs- und Supportfaktor: Fehler werden schneller sichtbar, Lösungen verbreiten sich schneller, und die Plattform ist in der Praxis breiter erprobt als viele vermeintlich „professionellere“ Nischenlösungen.


Als praktisches Beispiel wird ein Stepper bewegt. Für jeden Schritt oder Teilschritt muss dazu ein kurzer Steuerpuls an einen Treiber geschickt werden. Wir starten langsam mit dem bekannten Heartbeat (Herzschlag), wir generieren einen gleichmäßigen Takt. Der Rotor eines Steppers wartet dabei nicht in seiner Rotation, bis der SoC mal „gerade wieder Zeit“ hat. Das Signal muss im regelmäßigen Raster kommen.

Für solche Aufgaben dienen Interrupt-Service-Routinen (ISR). Ein Hardware-Timer löst einen Interrupt aus, und dieser wird zeitnah abgearbeitet. Wichtig ist dabei, was man hier unter zeitnah versteht. Muss dieses Zeitnah in einem garantierten Zeitfenster stattfinden, wie in unserem Fall, dann kommen Echtzeitbetriebssysteme zum Einsatz (RTOS).

Echtzeitbetriebssysteme leben von drei Eigenschaften:

  • Präemptives Multitasking
  • Vorhersagbares Zeitverhalten der Systemaufrufe
  • Vorhersagbare Reaktionszeiten auf Ereignisse

Und hier betrifft der letzte Punkt uns selbst. Wenn mir eine ISR schaffen, muss ihre Reaktionszeit vorhersagbar sein, und sehr kurz. Denn während ihrer Ausführung blockiert sie andere ISRs gleicher oder niedrigerer Priorität.

Zunächst lassen wir eine ISR noch sehr gemächlich mit nur 10 kHz aufrufen, also alle 100 µs.

/**
 * @brief Hardware-Timer-Callback für die 100-us-Telemetrie.
 *
 * Dieser Callback läuft im IRQ-Kontext des Hardware-Alarms.
 */
static void TelemetryTimerIrq(uint alarm_num)
{
  /*
   * Messen, was uns interessiert:
   * Wann kam die ISR tatsächlich zur Ausführung?
   */
  const uint64_t now_us = time_us_64();
		
  /*
   * Den nächsten Alarmzeitpunkt sofort wieder relativ zum geplanten Zeitpunkt
   * setzen, nicht relativ zu "jetzt".
   *
   * Dadurch messen wir Jitter/Latenz der ISR, aber der Timer driftet nicht mit
   * der Callback-Laufzeit weg.
   */
  g_next_alarm_time = delayed_by_us(g_next_alarm_time, kTelemetryPeriodUs);

  /*
   * Rückgabewert auswerten:
   *
   * true bedeutet, dass der neue Zielzeitpunkt schon verpasst wurde. Für diese
   * Minimalmessung ignorieren wir das noch nicht, sondern setzen dann den
   * nächsten Alarm relativ zu jetzt neu, damit der Timer nicht dauerhaft in der
   * Vergangenheit hängt.
   */
  if (hardware_alarm_set_target(alarm_num, g_next_alarm_time))
    g_next_alarm_time = make_timeout_time_us(kTelemetryPeriodUs);

  SetTelemetry(now_us);
}

bool StartTelemetryTimer100us()
{
	g_alarm_num = hardware_alarm_claim_unused(false);
	if (g_alarm_num < 0)
	{
		g_pcap = nullptr;
		return false;
	}

	const uint alarm_num = static_cast<uint>(g_alarm_num);

	hardware_alarm_set_callback(alarm_num, TelemetryTimerIrq);

	/*
	 * Das ist der theoretische Raster-Nullpunkt.
	 *
	 * Er liegt absichtlich etwas in der Zukunft, damit genug Zeit bleibt,
	 * den Referenzwert zu senden und danach den ersten Alarm scharf zu schalten.
	 */
	const absolute_time_t base_time = make_timeout_time_us(kTelemetryStartLeadUs);
	const uint64_t base_us = to_us_since_boot(base_time);

	/*
	 * ZUERST den Raster-Nullpunkt als erstes Sample senden.
	 *
	 * Dieses Sample ist kein ISR-Aufruf, sondern die Referenz für die spätere
	 * Auswertung:
	 *
	 *   theoretical_time_us[0] = base_us
	 */
	if (!SetTelemetry(base_us))
	{
		hardware_alarm_set_callback(alarm_num, nullptr);
		hardware_alarm_unclaim(alarm_num);

		g_alarm_num = -1;
		g_pcap = nullptr;

		return false;
	}

	/*
	 * Der erste echte Timer-IRQ kommt eine Periode NACH dem Raster-Nullpunkt.
	 *
	 * Damit gilt:
	 *
	 *   theoretical_time_us[0] = base_us
	 *   theoretical_time_us[1] = base_us + 100
	 *   theoretical_time_us[2] = base_us + 200
	 */
	g_next_alarm_time = delayed_by_us(base_time, kTelemetryPeriodUs);

	if (hardware_alarm_set_target(alarm_num, g_next_alarm_time))
	{
		hardware_alarm_set_callback(alarm_num, nullptr);
		hardware_alarm_unclaim(alarm_num);

		g_alarm_num = -1;
		g_pcap = nullptr;

		return false;
	}

	return true;
}

Und wir betrachten hier Latenz und Jitter des Aufrufs:

Bei einem Testlauf mit 10000 Aufrufen wurden 9765 Samples sofort, also einer Latenz von 0 µs aufgerufen. Abgesehen vom jeweils ersten Timer-IRQ lagen die größten beobachteten Abweichungen bei 5 µs. Der erste IRQ bildete reproduzierbar einen Sonderfall und lag in den Testläufen bei genau 17 µs, unabhängig von verschiedenen getesteten Parametern.

Eine belastbare Ursache für die Verteilung konnte ich bisher nicht erkennen. Auffällig sind jedoch einzelne zeitliche Häufungen.


ACHTUNG: Aus Erfahrung führe ich immer solche Tests am Anfang durch. Leider zeigte sich, egal ob Hardware oder Software, traue keiner Datenblattangabe, die Du nicht selbst geprüft hast.

Das gilt auch für die Zukunft. Sensoren oder Aktoren, die bei der Auswahl noch zuverlässig arbeiteten, können morgen fehlerhaft geliefert werden. Eine zukünftige Softwareversion kann sich plötzlich anders verhalten.

Gerade die Testumgebungen, welche in den Frühphasen der Entwicklung zur Evaluierung geschaffen werden, können in den meisten Fällen mit wenigen Änderungen später zur Kontrolle im Wareneingang dienen. Es lohnt sich daher, diese von Anfang an sauber aufzusetzen, zu dokumentieren etc.


ACHTUNG: Gerade am Anfang eines solchen Projektes lohnt es sich, zuerst Telemetrie aufzubauen. In diesem Fall wurde der Aufrufzeitpunkt mithilfe einer Hardwareuhr auf dem RP2350 mit 1 µs-Raster erfasst und als gebündelte Zeitstempel paketweise per USB versendet. Andere Beiträge von mir gehen genau auf diese Methoden der Telemetrie näher ein.


Nun wollen wir das Taktsignal für den Treiber generieren. Für ein solches Taktsignal schalten wir einen GPIO-Pin kurz high und dann wieder low. In diesem Fall sind 2 µs für den Treiber und den Weg zu ihm völlig ausreichend.

Die steigende Flanke in der ISR ist einfach:

gpio_put(pin, true);

Aber wie nun die 2 µs abwarten bis zur fallenden Flanke?

Warten innerhalb der ISR wäre keine gute Lösung. Sie blockiert die ganze Zeit den Kern. Das sind bei 150 MHz Taktfrequenz 300 Takte für nichts! Dazu kommt der Ärger des Jitters / der Latenz für andere ISRs in dieser Zeit.

Man könnte nun einen zweiten Interrupt generieren. Aber es geht eleganter. Und damit kommen wir zur PIO (Programmable Input Output) des SoCs.

Wir haben es hier mit einer Zustandsmaschine zu tun, welche genau einen Befehl pro Takt ausführt. Den Takt können wir konfigurieren. Und bei jedem Befehl können wir den Level von vorher konfigurierten Pins vorgeben.

Folgend ist der Assembler-Code für die Zustandsmaschine zu sehen. Sie arbeitet quasi in einer Endlosschleife zwischen .wrap_target und .wrap.

.program step_pulse

; Ein beliebiger 32-Bit-Wert im TX-FIFO löst genau einen STEP-Puls aus.
; Die State Machine läuft mit 1 MHz:
;   1 PIO-Takt = 1 µs
;
; Ergebnis:
;   STEP high: 2 µs
;   STEP low:  mindestens 2 µs

.wrap_target
    pull block
    set pins, 1 [1]
    set pins, 0
.wrap

Die Kerne des SoCs sind mit der Zustandsmaschine über zwei FIFOs verbunden, jeder 4×32 Bit groß. Standardmäßig sind sie als ein TX- und ein RX-FIFO konfiguriert, lassen sich aber auch als ein TX- oder RX-FIFO mit 8 Einträgen umkonfigurieren. In unserem Fall dient der TX-FIFO rein zur zeitlichen Synchronisation zwischen ISR und Zustandsmaschine.

Der Befehl pull block blockiert unsere Zustandsmaschine, bis der Eingangs-FIFO mit einem Wert, in unserem Fall beliebig, gefüllt wird. Das ist ein Takt. Und durch den vorherigen Durchlauf ist hier unser gesetzter Pin noch low.

Mit set pins, 1 [1] ändert sich das, die Zustandsmaschine setzt die vorher konfigurierten Pins, in unerem Fall ein Pin, auf high. Das wäre wieder ein Takt, den wir mit [1] um einen Takt verlängern. Somit bleibt unser Pin zwei Takte auf high. Mit set pins, 0 setzt die Zustandsmaschine am Ende den Pin wieder auf low und der Kreislauf ist geschlossen. Dabei bilden set pins, 0 und pull block zusammen mindestens 2 Takte, es ist somit garantiert, dass unser Pin auch mindestens 2 µs low bleibt.

Die Zustandsmaschine erledigt nun gleich zwei Dinge für uns: sie schaltet den Pin high und nach 2 µs wieder low. Jetzt müssen wir sie nur noch konfigurieren. Und hier wird die PIO noch interessanter: das gleiche Programm kann auf 4 Zustandsmaschinen mit jeweils anderer Konfiguration gleichzeitig laufen. Im folgenden die Einbindung ausschnittsweise:

	// Alle Stepper Instanzen laufen auf dem selben PIO Programm.
	if (!programOffset_)
		programOffset_ = pio_add_program(pio_, &step_pulse_program);

	sm_ = pio_claim_unused_sm(pio_, true);
	
	// Ausgang vor der Übergabe an die PIO definiert auf LOW setzen.
	gpio_init(pins_.step);
	gpio_put(pins_.step, false);
	gpio_set_dir(pins_.step, GPIO_OUT);

	// GPIO-Multiplexer auf PIO umschalten.
	pio_gpio_init(pio_, pins_.step);

	// Der GPIO wird von dieser State Machine als Ausgang verwendet.
	pio_sm_set_consecutive_pindirs(pio_, sm_, pins_.step, 1, true);

	pio_sm_config config = step_pulse_program_get_default_config(programOffset_);

	// "set pins, ..." wirkt auf genau diesen GPIO.
	sm_config_set_set_pins(&config, pins_.step, 1);

	// 1 MHz PIO-Takt: 1 Instruktionszyklus entspricht 1 µs.
	const float clockDivider = static_cast<float>(clock_get_hz(clk_sys)) / 1'000'000.0f;

	sm_config_set_clkdiv(&config, clockDivider);

	pio_sm_init(pio_, sm_, programOffset_, &config);

	// Sicherheitshalber den Ausgang nochmals explizit auf LOW setzen.
	pio_sm_set_pins_with_mask(pio_, sm_, 0u, 1u << pins_.step);

	pio_sm_set_enabled(pio_, sm_, true);

Nach ihrer Konfiguration muss unsere ISR sie nur noch auslösen, indem sie ein DWord, in diesem Fall 1, in den FIFO schiebt.


ACHTUNG: Eine ISR darf niemals blockieren. Und daher darf an dieser Stelle keine wartende FIFO-Operation verwendet werden. Entweder ist durch die Auslegung garantiert, dass der TX-FIFO nie voll wird, oder der Füllstand wird vorher geprüft und ein Fehlerpfad behandelt.


pio_sm_put(pio_, sm_, 1u);

Damit ist die Pulsbreite nicht mehr Aufgabe der ISR. Die ISR bestimmt nur noch den Zeitpunkt des Pulses; die PIO erzeugt dessen Form deterministisch in Hardware-Nähe. Genau diese Trennung ist der zentrale Architekturgewinn: Der Prozessorkern bleibt frei, während die zeitkritische Signalform von einer spezialisierten Zustandsmaschine erzeugt wird.

Voraussetzung ist natürlich, dass die State Machine schneller abarbeitet, als die ISR neue Pulse anstößt. In unserem Fall sind das 100 µs Raster mit jeweils einem 2 µs Signal. Und das Oszilloskop weist das saubere Signalraster nach:

In den folgenden Beiträgen werden wir uns ansehen, wie wir auf unterschiedliche Weise Daten zwischen ISRs und Hauptprogramm austauschen, zeitabhängige Fehler durch Tracen finden und mithilfe der PIO zeitkritische Busprotokolle unterstützen.