Im ersten Teil unserer Reihe starteten wir mit einem einfachen Pulsgenerator auf Basis von Timer, ISR und PIO. Um aus didaktischen Gründen zwischen diesen Themen nicht allzu oft wechseln zu müssen, bleiben wir bei der PIO, in diesem Fall zur Einbindung eines Drehgebers.
Drehgeber und Stepper sind dabei mit einem Wellengetriebe 100:1 verbunden. Diese Anordnung hilft uns später als anschauliches Beispiel im Regelkreis, wir wollen eine bestimmte Position anfahren. Der Weg bis dahin ist aber noch weit, und dieser Teil dreht sich allein um die Einbindung des Drehgebers.
ACHTUNG: auch die Verwendung nur eines Drehgebers auf der getriebenen Achse des Wellengetriebes ist eine Vereinfachung aus didaktischen Gründen. Diese Vereinfachung ist für einfache Anwendungsfälle ausreichend. Sie verzichtet aber auf einen Sensor an der Motorachse, der die Rotorbewegung im Magnetfeld kontrolliert. Die Vorteile eines zweiten Drehgebers werden in einem separaten Beitrag zum Thema Motion Control beleuchtet.
Ein Drehgeber auf der getriebenen Achse hat seine Hauptfunktion im letzten Abschnitt einer Fahrt zu einer Zielposition. Er dient dort der Kontrolle zwischen vorgegebener und tatsächlicher Position. Vorher bei schneller Fahrt reichen primär die Steps.
Bedingt durch diese Aufgabe werden hier im Gegensatz zu Drehgebern auf der Achse des Steppers häufig Absolutgeber mit Bussen wie SSI, BiSS oder ganz einfach SPI gewählt.
Für unser Beispiel nutzen wir einen Hengstler ACURO AD34 mit 19 Bit Auflösung einer Umdrehung. Damit erreichen wir eine Auflösung von 0,0006866455078125 Grad, das sind rund 2,4719 Bogensekunden oder rund 0,76294 Milligon. Das mag im Alltag wenig erscheinen, im Vermessungswesen ist das bei vielen Einsatzzwecken sehr relevant.
Der Drehgeber nutzt das ACURO-Protokoll, einen Nachfolger von BiSS. Mehr Details sind in diesem Beitrag von mir zu lesen: https://thurow.de/projektbeispiel-vermessungsgeraet-teil-4/.
Sowohl IC-Haus als auch Hengstler haben hier gut durchdacht ihre Protokolle entwickelt, diese lassen sich mit wenig Aufwand sowohl in Soft- als auch Hardware abbilden. In Anlehnung an SPI generiert der Master ein Taktsignal, der Slave sendet seine Messwerte. ACURO ermöglicht sogar das Senden von Daten seitens des Masters durch Pulsweitencodierung, aber dazu später.
Der Master fordert durch den Start einer Sequenz von Taktsignalen vom Slave ein Paket an. Der Slave liest intern seine Drehposition aus, kann also nicht sofort mit dem Senden seines Pakets starten. Daher gibt es bis zum Paketstart zwei Wartezustände.
Der Slave bestätigt zunächst die Paketanforderung durch ACK = LOW. Abhängig vom vorherigen Zustand entsteht dabei eine fallende Flanke oder SL liegt bereits auf LOW:
Anschließend bereitet der Drehgeber intern das zu sendende Paket vor. In dieser Zeit bleibt seine Datenleitung auf low. Ist das Paket sendebereit, so wird es beginnend mit einem Startbit synchronisiert zum Taktsignal gesendet:
Die steigende Flanke des Startbits signalisiert also dem Master den Beginn des Pakets. Seine Länge ist vom Typ des Drehgebers abhängig.
Dieses Protokoll lässt sich wieder hervorragend durch eine Kombination von PIO und ISR unterstützen. In diesem Fall ist es Aufgabe der PIO, das Taktsignal zu generieren und das Paket einzulesen. Die ISR muss erst nach vollständigem Empfang des Paketes aktiv werden und die einzelnen Bits ihrer Bedeutung zuführen.
Wie bereits im Teil 1 erläutert ist das Besondere an unserer Zustandsmaschine, dass jeder Befehl genau einen Takt dauert und wir bei jedem Takt auch den Pegel von Pins vorgeben können. Auch den Takt der Zustandsmaschine geben wir vor.
Darauf aufbauend schreiben wir unseren „ACURO-Treiber“. Dieser muss zunächst ACK = LOW innerhalb des vorgesehenen Zeitfensters und anschließend das Startbit erkennen können, die folgenden Datenbits lesen und über RX-FIFO/IRQ an die Software übergeben.
Dabei ist der längste und damit entscheidende Abschnitt der Zustandsmaschine der Payload-Bit-Block, also das Einlesen der Datenbits:
payload_low:
nop side 0 [3]
payload_high:
nop side 1 [1]
payload_sample:
in pins, 1 side 1
jmp x-- payload_low side 1
Der Übergang von payload_low zu payload_high erzeugt eine steigende Flanke, wir lesen das Datenbit in etwa der Hälfte der Zeit des High-Pegels des Taktsignals, nach zwei Beruhigungszyklen im dritten HIGH-Zyklus. Und genau das macht der Abschnitt
;
nop side 1 [1]
in pins, 1 side 1
jmp x-- payload_low side 1
Der Befehl in pins, 1 liest das aktuelle Datenbit und schiebt es ins Input Shift Register, der Befehl jmp x-- payload_low dekrementiert den Counter x und prüft, ob alle Datenbits gelesen wurden (Anzahl der Datenbits – 1 wird anfangs in x gelegt). Das sind 2 Takte. Daher wird ihnen ein nop side 1 [1] vorangesetzt, ein auf 2 Takte verlängertes NOP.
Als Konsequenz dieses Abschnitts muss nun jeder Pegelabschnitt 4 Takte lang sein. Wir sehen das schon am vorangehenden low-Abschnitt:
payload_low:
nop side 0 [3]
Darauf aufbauend ist nun die Programmierung unserer Zustandsmaschine nicht mehr schwer. In jedem Schritt implementieren wir 4 Takte pro Pegel und setzen den Takt der Zustandsmaschine Faktor 8 zur Taktfrequenz (, da zwei Halbtakte zu 4 Taktzyklen der Zustandsmaschine), die wir für das Taktsignal erreichen wollen.
So arbeitet auch die ACK-Erkennung:
ack_low:
nop side 0 [3]
ack_high:
nop side 1 [1]
jmp pin ack_still_high side 1
in x, 8 side 1
...
ack_still_high:
jmp x-- ack_low side 1
Der Befehl nop side 0 [3] erzeugt wieder 4 Takte LOW-Pegel auf der Taktleitung, die Sequenzen je nach Verzweigung
nop side 1 [1]
jmp pin ack_still_high side 1
jmp x-- ack_low side 1
oder
nop side 1 [1]
jmp pin ack_still_high side 1
in x, 8 side 1
4 Takte High.
Zuletzt noch die Erkennung des Startbits als Ende von Busy:
busy_low:
nop side 0 [3]
busy_high:
nop side 1 [1]
busy_sample:
jmp pin start_found side 1
jmp y-- busy_low side 1
start_found:
in y, 8 side 1
Hier sorgen die Sequenzen je nach Verzweigung
nop side 1 [1]
jmp pin start_found side 1
in y, 8 side 1
oder
nop side 1 [1]
jmp pin start_found side 1
jmp y-- busy_low side 1
für 4 Takte HIGH-Pegel.
Am Ende müssen nur noch die gelesenen Datenbits über das RX-FIFO übergeben oder bei einem Protokollfehler ein entsprechender Fehlerstatus zurückgegeben werden.
Hier ist eine Besonderheit zu beachten. Wir geben auch Statusinformationen wie die Anzahl der vergangenen Bits bis zum ACK und bis zum Startbit indirekt über die Restwerte der Zähler zurück:
ack_high:
in x, 8 side 1
start_found:
in y, 8 side 1
payload_first_low:
push block side 0 [3]
payload_sample:
in pins, 1 side 1
...
push block side 1
result_ready:
irq 0 rel side 1
Der Speicher der PIO für Befehle ist nur 32 Wörter groß. Ist das Input Shift Register voll (32 Bit), so wird dessen Inhalt automatisch in den RX-FIFO geschoben.
Wenn unser Payload nun aber eine Länge hat, die keinem Vielfachen von 32 entspricht, so haben wir am Ende des Lesens unserer Paketbits noch gelesene Bits im Input Shift Register, die wir explizit in RX-FIFO schieben müssen. Diese Unterscheidung müsste per Modulo der Anzahl der Payload-Bits erfolgen, und würde weiter kostbaren PIO-Speicher reduzieren. Aus diesem Grund erzeugen wir immer ein abschließendes push block, was bei Payload-Längen von genau 32 oder 64 Bit ein zusätzliches leeres Word erzeugt.
Als Ergebnis wieder die Sicht des Oszilloskops, hier auf die Startsequenz eines ACURO-Pakets:

Code ist Wahrheit, daher zum Abschluss der komplette Code der Zustandsmaschine mit erklärenden Kommentaren (ACHTUNG: mit ISR ist im folgenden Code das Input Shift Register gemeint, nicht die Interrupt Service Routine):
; Generischer BiSS-Reader für eine Punkt-zu-Punkt-Verbindung
;
; MA: Side-set Clock-Ausgang
; SL: IN-Pin und JMP-Pin
;
; Für jede aktive MA-Periode gilt:
; LOW für 4 PIO-Zyklen
; HIGH für 4 PIO-Zyklen
;
; Daraus folgt:
; PIO-SM-Takt = 8 * MA-Takt
;
; Beispiel bei 8 MHz PIO-SM-Takt:
; 1 PIO-Zyklus = 125 ns
; LOW 4 Zyklen = 500 ns
; HIGH 4 Zyklen = 500 ns
; MA = 1 MHz
;
; SL wird jeweils im dritten HIGH-Zyklus ausgewertet. Der vierte HIGH-Zyklus
; wird durch die nachfolgende IN- oder JMP-Instruktion erzeugt.
;
; Ablauf eines Messkommandos:
; 1. Der Host schreibt ein gepacktes Kommandowort in den TX-FIFO.
; 2. Die State Machine sucht ACK = LOW.
; 3. Danach sucht sie das START-Bit = HIGH.
; 4. Das START-Bit selbst wird nicht in die Payload übernommen.
; 5. Anschließend liest sie 1..64 Payload-Bits ein.
; 6. Ein relativer IRQ meldet, dass das vollständige Ergebnis im RX-FIFO
; liegt.
;
; Vor dem ersten MA-Takt wird SL absichtlich nicht geprüft. SL darf nach Reset
; oder Power-on zunächst LOW liegen. Relevant sind ausschließlich die beiden
; Übergänge innerhalb des aktiven Bursts: ACK = LOW und danach START = HIGH.
;
; TX-FIFO pro Messung:
; word 0:
; Bits 7..0 : maximale Anzahl ACK-Samples minus 1
; Bits 15..8 : maximale Anzahl START-Samples minus 1
; Bits 23..16: Payload-Bitanzahl minus 1
; Bits 31..24: reserviert, müssen 0 sein
;
; Der OSR muss nach rechts schieben:
; sm_config_set_out_shift(&config, true, false, 32);
;
; Der ISR muss nach links schieben und nach 32 Bits automatisch pushen:
; sm_config_set_in_shift(&config, false, true, 32);
;
; RX-FIFO:
;
; Erfolg:
; 1..31 Payload-Bits:
; word 0: Timingwort
; word 1: Payload, rechtsbündig
;
; 32 Payload-Bits:
; word 0: Timingwort
; word 1: Payload
; word 2: 0, erzeugt durch den abschließenden PUSH nach dem Autopush
;
; 33..63 Payload-Bits:
; word 0: Timingwort
; word 1: erste 32 Payload-Bits
; word 2: restliche Payload-Bits, rechtsbündig
;
; 64 Payload-Bits:
; word 0: Timingwort
; word 1: erste 32 Payload-Bits
; word 2: zweite 32 Payload-Bits
; word 3: 0, erzeugt durch den abschließenden PUSH nach dem Autopush
;
; Damit ist die erwartete FIFO-Belegung bei Erfolg:
; 2 + floor(payloadBits / 32) Wörter
;
; Das zusätzliche Nullwort bei exakt 32 oder 64 Bits spart die bedingte
; Behandlung des letzten Payload-Worts im PIO-Programm. Der Host kennt die
; Payload-Länge und ignoriert dieses Wort.
;
; Timingwort bei Erfolg:
; Bits 15..8: verbleibender ACK-Zähler beim erkannten ACK
; Bits 7..0: verbleibender START-Zähler beim erkannten START-Bit
;
; Daraus berechnet der Host:
; bitsToAck = ackTimeoutSamples - ackRemaining
; bitsToStart = bitsToAck + startTimeoutSamples - startRemaining
;
; Fehler:
; Ein Fehler besteht immer aus genau einem RX-Wort.
;
; ACK-Timeout:
; errorWord = 0
;
; START-Timeout:
; errorWord = (ackRemaining << 1) | 1
;
; Auch bei einem START-Timeout kann der Host dadurch bitsToAck zurückgeben.
; bitsToStart bleibt 0, weil kein START-Bit erkannt wurde.
;
; IRQ-Flags sind relativ zur State Machine:
; SM0 -> IRQ-Flag 0
; SM1 -> IRQ-Flag 1
; SM2 -> IRQ-Flag 2
; SM3 -> IRQ-Flag 3
;
; Instruktionsspeicher:
; verwendet: 27 von 32 Instruktionen
; Reserve: 5 Instruktionen
.program biss_read
.side_set 1
.wrap_target
public wait_command:
; Ruhezustand: MA bleibt HIGH, bis ein Kommandowort eintrifft.
pull block side 1
; ACK- und START-Zähler laden. Der Payload-Zähler bleibt im OSR.
out x, 8 side 1
out y, 8 side 1
ack_low:
; LOW-Zyklen 1..4 der ACK-Suche.
nop side 0 [3]
ack_high:
; HIGH-Zyklen 1..2.
nop side 1 [1]
; HIGH-Zyklus 3: ACK = LOW suchen.
jmp pin ack_still_high side 1
; HIGH-Zyklus 4 bei erkanntem ACK: ACK-Restzähler im ISR merken.
in x, 8 side 1
first_busy_low:
; Erste LOW-Phase nach ACK und zugleich Payload-Zähler laden.
out x, 8 side 0 [3]
; HIGH-Zyklen 1..2 der ersten START-Suchperiode.
jmp busy_sample side 1 [1]
busy_low:
; LOW-Zyklen 1..4 der weiteren START-Suchperioden.
nop side 0 [3]
busy_high:
; HIGH-Zyklen 1..2.
nop side 1 [1]
busy_sample:
; HIGH-Zyklus 3: START = HIGH suchen.
jmp pin start_found side 1
; HIGH-Zyklus 4: weiteren Versuch starten oder Timeout erkennen.
; Beim Timeout läuft Y von 0 auf 0xffffffff über.
jmp y-- busy_low side 1
start_timeout:
; Im ISR steht bisher ackRemaining. Das niederwertigste Bit von
; Y = 0xffffffff ist 1:
; errorWord = (ackRemaining << 1) | 1
in y, 1 side 1
jmp emit_error side 1
ack_still_high:
; HIGH-Zyklus 4: weiteren ACK-Suchtakt starten oder Timeout erkennen.
jmp x-- ack_low side 1
ack_timeout:
; Der ISR wurde auf diesem Pfad noch nicht beschrieben und enthält 0.
emit_error:
; ACK-Timeout: 0
; START-Timeout: (ackRemaining << 1) | 1
push block side 1
jmp result_ready side 1
start_found:
; HIGH-Zyklus 4 der START-Periode. START nicht als Payload übernehmen,
; sondern START-Restzähler an den ACK-Restzähler anhängen.
in y, 8 side 1
payload_first_low:
; Erste Payload-LOW-Phase und zugleich Timingwort ausgeben.
push block side 0 [3]
; HIGH-Zyklen 1..2 der ersten Payload-Periode.
jmp payload_sample side 1 [1]
payload_low:
; LOW-Zyklen 1..4 der weiteren Payload-Perioden.
nop side 0 [3]
payload_high:
; HIGH-Zyklen 1..2.
nop side 1 [1]
payload_sample:
; HIGH-Zyklus 3: Payload-Bit einlesen.
in pins, 1 side 1
; HIGH-Zyklus 4: nächstes Bit oder Payloadende.
jmp x-- payload_low side 1
; Immer pushen:
; - partielle letzte Gruppe -> Nutzdatenwort
; - exakt 32/64 Bits -> zusätzliches Nullwort
push block side 1
result_ready:
; Das vollständige Ergebnis liegt im RX-FIFO.
irq 0 rel side 1
.wrap
% 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;
}
%}