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

Im zweiten Teil unserer kleinen Serie schauten wir uns an, wie wir das ACURO-Protokoll mittels PIO-Zustandsmaschine unterstützen können. Diese produziert immer mittels 4 Takten einen Halbtakt des Taktsignals auf dem ACURO-Bus.

Definition der Zeitgruppen T1 bis T5

Vor den eigentlichen Datenbits haben wir zwei wesentliche Zeitabschnitte.

Laut BiSS-Spezifikation kann SL bei Beginn unserer Taktung zunächst auf LOW (nach Power On oder Reset) oder HIGH (nach einem vorherigen Paket) liegen. Der Beginn unseres Taktsignals signalisiert dem Drehgeber, dass wir ein Paket von ihm erwarten. Diese Anforderung quittiert er (ACK), indem er SL auf LOW zieht. Diesen Zeitabschnitt bezeichnen wir im Folgenden als T1:

ACURO BiSS-Modus Zeitanforderungen T1, Quelle https://www.hengstler.de/gfx/file/shop/encoder/AC58/Technical_Manual_SSI_BiSS_ACURO_en.pdf Seite 12, bearbeitet

Nachfolgend bereitet der Drehgeber das Paket vor. Er ermittelt seine Position, packt sie mit Warn- und Error-Bit zusammen und berechnet die CRC-Prüfsumme zur späteren Kontrolle der Paketübermittlung. Das braucht Zeit. Der Drehgeber ist beschäftigt (busy). Solange hält er SL auf LOW.

ACURO BiSS-Modus Zeitanforderungen T2, Quelle https://www.hengstler.de/gfx/file/shop/encoder/AC58/Technical_Manual_SSI_BiSS_ACURO_en.pdf Seite 12, bearbeitet

Ist das Paket sendebereit, so wird es synchronisiert zum Bustakt MA bei dessen nächster steigender Flanke versendet. Der Paketbeginn wird mittels eines Startbits mitgeteilt.

Wir haben damit zwei Zeitabschnitte vor unserem Start- und den Datenbits, die zu berücksichtigen sind, T1, bei dem SL sowohl LOW als auch HIGH sein kann, und T2, bei dem SL LOW gesetzt wird.

T2 enthält, wie zu sehen, mindestens eine fallende Flanke. Dazu kommt die Zeit busy. Im Falle des Hengstler ACURO AD34/1219AF.0NBC2 beträgt busy maximal 5,2 µs.

Damit ergibt sich für T2 eine Anzahl von Bits zwischen 1 und fMAtbusy,max \left\lceil f_{\mathrm{MA}} \cdot t_{\mathrm{busy,max}} \right\rceil. Das entspricht z.B. bei 100 kHz einem Bit, bei 400 kHz drei Bits, bei 1 MHz 6 Bits und bei 8 MHz bereits 42 Bits.

PIO-Zustandsmaschine, Quelle: https://pip-assets.raspberrypi.com/categories/1214-rp2350/documents/RP-008373-DS-2-rp2350-datasheet.pdf

Der RP2350 enthält drei identische PIO-Blöcke. Jeder Block besitzt einen Programmspeicher für 32 Instruktionen und 4 Zustandsmaschinen, welche mit diesem Programmspeicher arbeiten können. Jede der 4 Zustandsmaschinen hat zwei FIFOs, TX und RX, sowie frei konfigurierbaren Zugriff auf die GPIOs und kann interne IRQ-Flags setzen, die über die Interrupt-Maskierung auf eine der beiden PIO-Interruptleitungen geführt werden.

Damit ist es uns möglich, unser PIO-Programm als gemeinsamen BiSS-Treiber zu verwenden, bis zu 4 Treiber pro PIO-Block.

Der C++ Teil konfiguriert dabei MA- und SL-Pin für jede Zustandsmaschine, ihren Takt, die Fehlerzeiten für T1 und T2 sowie die Anzahl der Payload-Bits. Jede Zustandsmaschine kann damit einen anderen Sensor mit eigenen Pins, eigener Taktfrequenz, eigenen Timeoutwerten und eigener Payload-Länge ansprechen.

Wir haben so einen frei konfigurierbaren Treiber geschaffen, welcher seine Aufgabe abseits der CPU-Kerne des SoCs erfüllt. Durch die freie Konfiguration der Anzahl der Payload-Bits ist der Treiber sowohl für BiSS-B im normalen BiSS-Modus als auch für BiSS-C einsetzbar. Mehrere Zustandsmaschinen können weiter synchronisiert werden. Das ist wieder die Voraussetzung, Messwerte mehrerer Drehgeber zu faktisch identischen Zeitpunkten zu erhalten.

Unmittelbare Funktionen zum Einpacken unserer Parameter in die Wörter von TX- und Auspacken der Rückgabewerte samt möglicher Protokollfehler aus den Wörtern der RX-FIFOs legen wir in das PIO-File direkt ab. Auf diese Weise werden sie und PIO-Assemblercode in einem Source-File gemeinsam gehalten.

% c-sdk {
static inline uint32_t biss_read_make_command(
  uint32_t ack_samples,
  uint32_t start_samples,
  uint32_t payload_bits)
{
  return (
    (((payload_bits - 1u) & 0xffu) << 16) |
    (((start_samples - 1u) & 0xffu) << 8) |
    (((ack_samples - 1u) & 0xffu) << 0));
}

static inline uint32_t biss_read_result_ack_remaining(uint32_t timing_word)
{
  return (timing_word >> 8) & 0xffu;
}

static inline uint32_t biss_read_result_start_remaining(uint32_t timing_word)
{
  return timing_word & 0xffu;
}

static inline uint32_t biss_read_error_is_ack_timeout(uint32_t error_word)
{
  return error_word == 0u;
}

static inline uint32_t biss_read_error_is_start_timeout(uint32_t error_word)
{
  return
    (error_word & ~0x1ffu) == 0u &&
    (error_word & 1u) != 0u;
}

static inline uint32_t biss_read_error_ack_remaining(uint32_t error_word)
{
  return (error_word >> 1) & 0xffu;
}
%}

Ich selbst bevorzuge für Problemstellungen dieser Art C++ statt C. Auf Softwareseite wird die API zum Treiber so in Objekten gehalten und die Sprachfeatures von C++ erlauben Auslagerungen von Berechnungen in die Compilezeit ohne dabei Syntaxprüfung oder Typsicherheit zu beschneiden.

Der Treiber sollte dabei als Layermodell aufgebaut werden:

Dieser Aufbau ist typisch. Auf Hardware setzt ein generischer Bustreiber auf, dieser wieder ist die Grundlage spezialisierter Treiber, welche spezielle Protokolle, Geräte, Sensoren etc. über diesen Bus ansprechen.

Die Abstraktionsebene ändert sich dabei zunehmend vom hardwarenahen zum protokollbasierten Denken. Die aufeinander aufbauenden Schichten ermöglichen Wiederverwendbarkeit und konzentrieren jede Ebene auf eine klar abgegrenzte Problemstellung. Architektur ist die Basis sauberer Software.

In unserem Fall haben wir mit der PIO-Zustandsmaschine die Voraussetzung für einen allgemeingültigen BiSS-Treiber geschaffen.

Die Aufgaben des BiSS-Treibers sind

  • Taktsignal erzeugen
  • Paket oder Protokollfehler erkennen
  • Payload auslesen und übergeben oder Protokollfehler melden

Für seine Konfiguration benötigt er:

  • MA-Pin (Takt)
  • SL-Pin (Data)
  • Taktfrequenz
  • Timeout für ack
  • Timeout für busy
  • Anzahl Payload-Bits
  • Mindestpause zwischen den Paketen

Er ist „Dolmetscher“ zwischen Hardware und oberen Softwareebenen.

Darauf kommt ein spezialisierter Treiber für die Unterstützung verschiedener AD34-Typen. Seine Aufgabe besteht im

  • Auspacken der Datenbits für Single- und Multiturn, sowie von Warn- und Error-Bit
  • CRC-Prüfsumme des empfangenen Pakets berechnen und mit dessen CRC-Prüfsumme vergleichen

An dieser Stelle spaltet sich unsere Reihe auf. Um den Fokus auf dem Thema zeitlicher Determinismus zu halten, wird hier und folgend wenig auf die Gestaltung der Software eingegangen. Eine zweite Reihe knüpft nun an diesem Punkt an und zeigt saubere Architektur und Codegestaltung der Treiber als eigenständiges Thema.

Wieder zurück zum BiSS-Treiber. Diesen betrachten wir in dieser Reihe weiter unter dem Fokus zeitkritisch und deterministisch.

Das PIO-Programm wird von der Software durch das Befüllen des TX-FIFOs mit den Startparametern gestartet. Es selbst läuft als Zustandsmaschine strikt deterministisch Takt für Takt. Die Taktanzahl ist abhängig von:

  • einem eventuellen Fehlerfall
  • dem zu lesenden Paket

Oder anders: der Empfang ist zeitlich auf den Takt deterministisch, wenn ein Paket identisch eintrifft. Latenz und Jitter ergeben sich maßgeblich durch den Drehgeber als Sender.

Die Zustandsmaschine terminiert, nachdem sie das Timingwort und die gelesenen Payload-Wörter beziehungsweise im Fehlerfall ein einzelnes Fehlerwort in den RX-FIFO geschrieben hat. Mit irq 0 rel setzt die State Machine ein PIO-internes IRQ-Flag relativ zu ihrem eigenen Index.

    irq 0 rel                   side 1

Die Interrupt-Maskierung fasst die freigeschalteten internen Quellen auf der gemeinsamen Prozessor-Interruptleitung PIOx_IRQ_0 zusammen. Diese unterbricht den Kern, auf dem die zugehörige ISR registriert ist.

Da mehrere State Machines gleichzeitig oder kurz nacheinander ihre jeweiligen Flags setzen können, prüft die ISR die internen Flags und ordnet jedes Ergebnis der zuständigen Treiberinstanz zu:

  for (uint sm = 0; sm < maxStateMachines; ++sm)
  {
    BiSS* const instance = instances_[sm];
    if (!instance || !pio_interrupt_get(pio_, sm))
      continue;

    pio_interrupt_clear(pio_, sm);
    instance->handleResultFromIsr();
  }

Das Feld instances_[sm] wird bei der Konfiguration der State Machines gesetzt und ordnet deren Index der jeweiligen Treiberinstanz zu. Über das SM-spezifische IRQ-Flag prüft die ISR anschließend, welche State Machines ein Ergebnis gemeldet haben. Die ISR arbeitet damit als Demultiplexer. Die ISR ist also quasi ein Demultiplexer.

Diese Stelle zeigt wunderbar ein grundlegendes Problem. Nehmen wir an, vier Drehgeber in einem Messarm werden zur Positionsbestimmung genutzt. Damit die Positionsbestimmung aus den Winkelwerten stimmt, müssen alle Drehgeber zeitgleich abgefragt werden. Solche Fälle fanden im Entwurf von SSI von Anfang an Berücksichtigung.

Simultane Übertragung von Absolutgebern, Figur 4 aus Patent EP0171579

Nun erhalten wir vier Pakete faktisch gleichzeitig. Ihre Ergebnisquellen teilen sich jedoch eine gemeinsame Interruptleitung, deren ISR auf einem Kern abgearbeitet wird.

Damit kommen wir zu einer grundsätzlichen Problemklasse bei zeitkritischen Systemen. Bei diesem konkreten Beispiel ist nur erforderlich, zu wissen, die Messwerte kamen zeitgleich an. Ihre Verarbeitung kann anschließend nacheinander erfolgen, ohne aber ihren Bezug zueinander zu verlieren. Hier ist das Stichwort Zeitstempel. Und dieser wird in der ISR einmal erstellt, aber dann allen Paketen bei deren Abarbeitung mitgegeben.

  const uint64_t now_us = time_us_64();
	
  for (uint sm = 0; sm < maxStateMachines; ++sm)
  {
    BiSS* const instance = instances_[sm];
    if (!instance || !pio_interrupt_get(pio_, sm))
      continue;

    pio_interrupt_clear(pio_, sm);
	  instance->handleResultFromIsr(now_us);
  }

In diesem vereinfachten Beispiel wurde die Systemzeit nach Eintreffen der Pakete mitgegeben. Besser wäre ein Zeitstempel beim Start der Zustandsmaschine. Für die reine Zuordnung reicht auch eine Sequenznummer oder Gruppenkennung, z.B. ein hochzählender Zähler, wenn der absolute Zeitpunkt nicht bekannt sein muss.

An dieser Stelle wird wieder die Relevanz der Kürze von Funktionen sichtbar, welche eine ISR verarbeitet. Sie müssen äußerst kurz sein. In diesem Fall wächst die Latenz bei jeder Callback-Funktion weiter an. Dem zu begegnen ist das Thema des nächsten Teils unserer Serie.

Abschließend betrachten wir die gemessene End-to-End-Latenz vom softwareseitigen Start der Zustandsmaschine bis zum Eintritt in die empfangende ISR und untersuchen deren Streuung. Gemessen wurde vom Befüllen des TX-FIFOs mit dem Startkommando bis zum Eintritt in die durch PIO_IRQ0 ausgelöste ISR. Wir messen also die Kette

Softwarestart → PIO-Verarbeitung → Sensorantwort → Paketempfang → IRQ → ISR-Aufruf:

Zu sehen ist eine Streuung zwischen 51 und 54 µs.