Kapitel 4. Eingänge, Ausgänge und Timer
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Eingänge, Ausgänge und Timer bilden die Grundlage für fast alles, was eingebettete Systeme tun. Sogar die Kommunikationsmethoden, um mit anderen Komponenten zu kommunizieren, bestehen aus diesen Komponenten (siehe Bit-Bang-Treiber in Kapitel 7). In diesem Kapitel werde ich ein Beispiel für die Produktentwicklung durchgehen. Die Ziele werden sich ständig ändern, damit es sich wie im richtigen Leben anfühlt. So können wir auch herausfinden, wie man von einem einfachen System mit einem blinkenden Licht zum Entprellen von Tasten und Dimmen von LEDs kommt. Auf dem Weg dorthin werden wir viel über Timer erfahren und darüber, dass sie viel mehr können als nur die Zeit zu messen.
Bevor wir jedoch mit der Weltherrschaft, äh, ich meine Produktentwicklung, beginnen, musst du ein wenig über Register wissen, die Schnittstelle zwischen deiner Software und dem Prozessor.
Umgang mit Registern
Um etwas mit einer E/A-Leitung zu tun, müssen wir mit dem entsprechenden Register sprechen. Wie in "Dein Prozessor ist eine Sprache" beschrieben , kannst du dir die Register als eine API für die Hardware vorstellen. Wie im Benutzerhandbuch des Chips beschrieben, gibt es verschiedene Arten von Registern, um den Prozessor zu konfigurieren und Peripheriegeräte zu steuern. Sie sind im Speicher abgelegt, so dass du an eine bestimmte Adresse schreiben kannst, um ein bestimmtes Register zu ändern. Oft bedeutet jedes Bit in einem Register etwas Bestimmtes, was bedeutet, dass wir anfangen müssen, Binärzahlen zu verwenden, um die Werte bestimmter Bits zu betrachten.
Binäre und hexadezimale Mathematik
Wenn du dich mit den Grundlagen der binären und hexadezimalen (hex) Mathematik vertraut machst, wird dir deine Karriere in eingebetteten Systemen viel mehr Spaß machen. Wenn du nur eine oder zwei Stellen ändern musst, ist es gut, einzelne Bits zu verschieben. Wenn du aber die gesamte Variable ändern musst, ist Hexadezimal sehr praktisch, denn jede Ziffer in Hexadezimal entspricht einem Nibble (vier Bits) in Binär (siehe Tabelle 4-1). (Ja, natürlich ist ein Nibble die Hälfte eines Bytes. Embedded-Leute mögen ihre Wortspiele.)
Binär | Hex | Dezimal | Erinnere dich an diese Nummer |
---|---|---|---|
0000 |
0 |
0 |
Das ist ganz einfach. |
0001 |
1 |
1 |
Das ist (1 << 0) . |
0010 |
2 |
2 |
Das ist (1 << 1) . Shifting ist dasselbe wie Multiplizieren mit 2shiftValue. |
0011 |
3 |
3 |
Beachte, dass dies im Binärsystem einfach die Summe von eins und zwei ist. |
0100 |
4 |
4 |
(1 << 2) ist eine 1, die um zwei Nullen nach links verschoben ist. |
0101 |
5 |
5 |
Das ist eine interessante Zahl, weil jedes zweite Bit gesetzt ist. |
0110 |
6 |
6 |
Siehst du, wie das aussieht, als könntest du die drei um eins nach links verschieben? Das könnte man als ((1 << 2)|(1 << 1)) , oder ((1 << 2) + (1 << 1)) , oder, am häufigsten, als (3 << 1) zusammensetzen. |
0111 |
7 |
7 |
Schau dir das Muster der binären Bits an. Sie wiederholen sich sehr häufig. Lerne das Muster und du wirst in der Lage sein, diese Tabelle zu erstellen, wenn du sie brauchst. |
1000 |
8 |
8 |
(1 << 3) . Siehst du, wie die Verschiebung und die Anzahl der Nullen zusammenhängen? Wenn nicht, schau dir die binäre Darstellung von 2 und 4 an. |
1001 |
9 |
9 |
Wir sind dabei, über die normalen Dezimalzahlen hinauszugehen. Weil es im Hexadezimalsystem mehr Ziffern gibt, leihen wir uns einige aus dem Alphabet. In der Zwischenzeit ist 9 einfach 8 + 1. |
1010 |
A |
10 |
Dies ist eine weitere spezielle Zahl, bei der jedes zweite Bit gesetzt ist. |
1011 |
B |
11 |
Siehst du, wie das letzte Bit von 0 auf 1 hin und her wechselt? Es steht für gerade und ungerade. |
1100 |
C |
12 |
Beachte, dass C einfach eine Kombination aus 8 und 4 in Binärform ist. Also ist es natürlich gleich 12. |
1101 |
D |
13 |
Das zweite Bit von rechts geht mit der halben Geschwindigkeit des ersten Bits von 0 auf 1 hin und her: 0, dann 0, dann 1, dann 1, dann wiederholen. |
1110 |
E |
14 |
Das dritte Bit geht auch hin und her, aber mit der halben Geschwindigkeit des zweiten Bits. |
1111 |
F |
15 |
Alle Bits sind gesetzt. Das ist ein wichtiger Punkt, an den du dich erinnern musst. |
Beachte, dass du mit vier Bits (einer Hex-Ziffer) 16 Zahlen darstellen kannst, aber nicht die Zahl 16. Viele Dinge in eingebetteten Systemen sind nullbasiert, darunter auch Adressen, sodass sie sich gut mit binären oder hexadezimalen Zahlen darstellen lassen.
Ein Byte besteht aus zwei Nibbles, von denen das linke um vier Leerzeichen nach oben (links) verschoben ist. 0x80
ist also (0x8 << 4)
.
Ein 16-Bit-Wort setzt sich aus zwei Bytes zusammen. Wir erhalten es, indem wir das höchstwertige Byte um 8 Bits nach oben verschieben und es dann zum unteren Byte addieren:
0x1234 = (0x12 << 8) + (0x34)
Ein 32-Bit-Wort ist in Hexadezimal 8 Zeichen lang, aber 10 Zeichen in Dezimal. Diese dichte Darstellung kann für Debug-Ausdrucke nützlich sein, aber der eigentliche Grund, warum wir Hexadezimal verwenden, ist die Vereinfachung der Binärdarstellung.
Tipp
Da der Speicher in der Regel in Hexadezimal angegeben wird, werden einige Werte verwendet, um Anomalien im Speicher zu erkennen. Erwarte vor allem 0xDEADBEEF
oder 0xDEADC0DE
als Indikator (sie sind in Hexadezimal viel leichter zu merken als in Dezimal) und verwende sie auch. Zwei weitere wichtige Bytes sind 0xAA
und 0x55
. Da sich die Bits in diesen Zahlen abwechseln, sind sie auf einem Oszilloskop leicht zu erkennen und eignen sich gut zum Testen, wenn du viele Veränderungen in deinen Werten sehen willst.
Bitweise Operationen
Wenn du mit Registern arbeitest, musst du über Dinge auf Bitebene nachdenken. Oft schaltest du bestimmte Bits ein und aus. Wenn du noch nie mit bitweisen Operationen gearbeitet hast, ist es jetzt an der Zeit, es zu lernen. Der logische Operator &&
bedeutet AND, !
bedeutet NOT und ||
bedeutet OR. Diese Operatoren basieren auf der Idee, dass alles, was Null ist, falsch ist und alles, was nicht Null ist, wahr ist.
Bitweise Operationen funktionieren auf einer Bit-für-Bit-Basis (siehe Tabelle 4-2). Wenn bei der bitweisen Verknüpfung AND
an den beiden Eingängen ein Bit gesetzt ist, gilt dies auch für die Ausgabe. Die anderen Bits in der Ausgabe sind Null. Bei der ODER-Verknüpfung ist ein Bit an einem der beiden Eingänge gesetzt, dann ist es auch am Ausgang gesetzt.
Testen, Setzen, Löschen und Umschalten
Hexadezimal- und bitweise Operationen sind nur eine Vorstufe dazu, dass du mit Registern interagieren kannst. Wenn du wissen willst, ob ein Bit gesetzt ist, musst du eine bitweise UND-Verknüpfung mit dem Register vornehmen:
test = register & bit; test = register & (1 << 3); // check 3rd bit in the register test = register & 0x08; // same, different syntax
Beachte, dass die Variable test
entweder gleich Null (das Bit ist im Register nicht gesetzt) oder 0x08
(das Bit ist gesetzt) ist. Sie ist nicht wahr oder falsch, obwohl du sie in normalen Konditionalen verwenden kannst, da diese nur auf Null oder Nicht-Null prüfen.
Wenn du willst, dass ein Bit in einem Register setzt, musst du es mit dem Register ODER verknüpfen:
register = register | bit; register = register | (1 << 3); // turn on the 3rd bit in the register register |= 0x08; // same, different syntax
Das Löschen eines Bits ist etwas verwirrender, weil du die anderen Bits im Register unverändert lassen willst. Die typische Strategie ist, das zu löschende Bit mit dem bitweisen NOT (~) zu invertieren. Dann wird der invertierte Wert mit dem Register UND-verknüpft. Auf diese Weise bleiben die anderen Bits unverändert:
register = register & ~bit; register = register & ~(1 << 3); // turn off the 3rd bit in the register register &= ~0x08; // same, different syntax
Wenn du ein Bit umschalten willst, kannst du seinen Wert prüfen und ihn dann je nach Bedarf setzen oder löschen. Zum Beispiel:
test = register & bit; if (test) { // bit is set, need to clear it register = register & ~bit; } else { // bit is not set, need to set it register = register | bit; }
Es gibt aber noch eine andere Möglichkeit, ein Bit umzuschalten: mit der XOR-Operation:
register = register ^ bit; register = register ^ (1 << 3);
Wenn in dem Register das Bit 0x08
gesetzt ist, erkennt XOR zwei Einsen und gibt für dieses Bit eine Null aus. Wenn das Bit im Register nicht gesetzt war, sieht XOR eine Eins und eine Null und gibt für dieses Bit eine Eins aus. Die anderen Bits im Register werden nicht verändert, weil nur das eine Bit im zweiten Eingang gesetzt ist und die anderen null sind.
Genug des Überblicks. Du musst diese Operationen kennen, um Register verwenden zu können. Wenn du dich damit nicht auskennst, findest du am Ende des Kapitels Ressourcen, mit denen du mehr lernen oder üben kannst.
Einen Ausgang umschalten
Das Marketing ist mit einer Produktidee zu dir gekommen. Wenn du hinter die Kulissen blickst, merkst du, dass sie nur ein Licht brauchen, um zu blinken.
Die meisten Prozessoren haben Pins, deren digitale Zustände von der Software gelesen (Eingang) oder gesetzt (Ausgang) werden können. Diese werden als E/A-Pins, General-Purpose I/O (GPIO), Digital I/O (DIO) und gelegentlich auch als General I/O (GIO) bezeichnet. Der grundlegende Anwendungsfall ist normalerweise einfach, zumindest wenn eine LED angeschlossen ist:
-
Initialisiere den Pin als Ausgang (als I/O-Pin kann er Eingang oder Ausgang sein).
-
Setze den Pin hoch, wenn die LED leuchten soll. Setze den Pin auf low, wenn die LED aus sein soll. (Die LED kann zwar auch so angeschlossen werden, dass sie leuchtet, wenn der Pin low ist, aber in diesem Beispiel wird die invertierte Logik vermieden).
In diesem Kapitel gebe ich dir Beispiele aus drei verschiedenen Benutzerhandbüchern, damit du eine Vorstellung davon bekommst, was du in der Dokumentation deines Prozessors erwarten kannst. Das ATtiny AVR Mikrocontroller-Handbuch von Microchip beschreibt einen 8-Bit-Mikrocontroller mit zahlreichen Peripheriegeräten. Das Benutzerhandbuch des MSP430x2xx von TI beschreibt einen 16-Bit-RISC-Prozessor, der besonders stromsparend ist. Das STM32F103xx Referenzhandbuch von STMicroelectronics beschreibt einen 32-Bit Arm Cortex Mikrocontroller. Du brauchst diese Dokumente nicht, um weiterzumachen, aber ich dachte, du möchtest vielleicht wissen, auf welchen Prozessoren die Beispiele beruhen.
Einstellen des Pins als Ausgang
Um auf die Anfrage des Marketings zum Umschalten einer LED zurückzukommen: Die meisten I/O-Pins können entweder Eingänge oder Ausgänge sein. Das erste Register, das du setzen musst, steuert die Richtung des Pins, damit er ein Ausgang ist. Bestimme zunächst, welchen Pin du ändern willst. Um den Pin zu ändern, musst du den Namen des Pins kennen (z. B. "I/O-Pin 2"), nicht seine Nummer auf dem Prozessor (z. B. Pin 12). Die Namen stehen in Schaltplänen oft im Inneren des Prozessors, während die Pin-Nummer auf der Außenseite des Gehäuses steht (wie in Abbildung 4-2 gezeigt).
Die Pins können mehrere Nummern in ihrem Namen haben, die einen Port (oder eine Bank) und einen Pin in diesem Port bezeichnen. (Die Ports können auch Buchstaben anstelle von Zahlen sein.) In der Abbildung ist die LED an den Prozessor-Pin 10 angeschlossen, der "SCLK/IO1_2" heißt. Diesen Pin teilen sich der SPI-Port (zur Erinnerung: das ist eine Kommunikationsmethode, die wir in Kapitel 7 besprechen werden) und das I/O-Subsystem (IO1_2). Im Benutzerhandbuch erfährst du, ob der Pin standardmäßig ein I/O- oder ein SPI-Pin ist (und wie du zwischen beiden umschalten kannst). Möglicherweise wird ein weiteres Register benötigt, um den Zweck des Pins anzugeben. Die meisten Hersteller sind gut darin, auf die Pin-Belegung zu verweisen, aber wenn der Pin von mehreren Peripheriegeräten gemeinsam genutzt wird, musst du vielleicht im Abschnitt über Peripheriegeräte nachsehen, um unerwünschte Funktionen zu deaktivieren. In unserem Beispiel sagen wir, dass der Pin standardmäßig ein I/O ist.
Im I/O-Subsystem ist es der zweite Pin (2) der ersten Bank (1). Daran müssen wir uns erinnern und sicherstellen, dass der Pin als I/O-Pin und nicht als SPI-Pin verwendet wird. Im Benutzerhandbuch deines Prozessors findest du weitere Informationen. Suche nach einem Abschnitt mit einem Namen wie "E/A-Konfiguration", "Einführung in digitale E/A" oder "E/A-Ports". Wenn du den Namen nicht finden kannst, halte nach dem Wort "Richtung" Ausschau, mit dem du beschreibst, ob der Pin ein Eingang oder ein Ausgang sein soll.
Sobald du das Register im Handbuch gefunden hast, kannst du feststellen, ob du ein Bit im Richtungsregister setzen oder löschen musst. In den meisten Fällen musst du das Bit setzen, damit der Pin ein Ausgang ist. Du könntest die Adresse ermitteln und das Ergebnis hardcodieren:
*((int*)0x0070C1) |= (1 << 2);
Aber bitte tu das nicht.
Der Hersteller des Prozessors oder Compilers stellt fast immer eine Header-Datei oder eine Hardware-Abstraktionsschicht(HAL, die eine Header-Datei enthält) zur Verfügung, die die Speicherabbildung des Chips verbirgt, damit du Register als globale Variablen behandeln kannst. Wenn der Hersteller dir keine Header-Datei zur Verfügung gestellt hat, solltest du selbst eine erstellen, damit dein Code wie eine der folgenden Zeilen aussieht:
- STM32F103 Prozessor
-
GPIOA->CRL |= 1 << 2; // set IOA_2 to be an output
- MSP430 Prozessor
-
P1DIR |= BIT2; // set IO1_2 to be an output
- ATtiny Prozessor
-
DDRB |= 0x4; // set IOB_2 to be an output
Beachte, dass die Registernamen für jeden Prozessor unterschiedlich sind, aber die Wirkung des Codes in jeder Zeile die gleiche ist. Jeder Prozessor hat unterschiedliche Möglichkeiten, das zweite Bit im Byte (oder Wort) zu setzen.
In jedem dieser Beispiele liest der Code den aktuellen Registerwert, ändert ihn und schreibt dann das Ergebnis zurück in das Register. Dieser Lese-Änderungs-Schreib-Zyklus muss in atomaren Abschnitten erfolgen, d. h. er muss ohne Unterbrechungen oder andere Verarbeitungen zwischen den Schritten ausgeführt werden. Wenn du den Wert liest, ihn änderst und dann etwas anderes tust, bevor du das Register schreibst, besteht die Gefahr, dass sich das Register geändert hat und der Wert, den du schreibst, nicht mehr aktuell ist. Die Registeränderung wird das beabsichtigte Bit ändern, kann aber auch unbeabsichtigte Folgen haben.
Einschalten der LED
Der nächste Schritt besteht darin, die LED einzuschalten. Auch hier müssen wir das entsprechende Register im Benutzerhandbuch finden:
- STM32F103 Prozessor
-
GPIOA->ODR |= (1 << 2); // IOA_2 high
- MSP430 Prozessor
-
P1OUT |= BIT2; // IO1_2 high
- ATtiny Prozessor
-
PORTB |= 0x4; // IOB_2 high
Die Header-Datei der GPIO-Hardwareabstraktionsschicht, die vom Prozessorhersteller zur Verfügung gestellt wird, zeigt, wie die Rohadressen durch einige Programmierhilfen maskiert werden. Beim STM32F103x6 wird auf die E/A-Register über eine Struktur mit einer Adresse zugegriffen (ich habe die Datei umstrukturiert und vereinfacht):
typedef struct { __IO uint32_t CRL; // Port configuration (low) __IO uint32_t CRH; // Port configuration (high) __IO uint32_t IDR; // Input data register __IO uint32_t ODR; // Output data register __IO uint32_t BSRR; // Bit set/reset register __IO uint32_t BRR; // Bit reset register __IO uint32_t LCKR; // Port configuration lock } GPIO_TypeDef; #define PERIPH_BASE 0x40000000UL #define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL) #define GPIOA_BASE (APB2PERIPH_BASE + 0x00000800UL) #define GPIOA ((GPIO_TypeDef *)GPIOA_BASE)
Die Header-Datei beschreibt viele Register, die wir uns nicht angesehen haben. Diese sind auch im Benutzerhandbuch zu finden, mit vielen weiteren Erklärungen. Ich ziehe es vor, auf die Register über die Struktur zuzugreifen, weil sie verwandte Funktionen zusammenfasst und es dir oft ermöglicht, mit den Ports austauschbar zu arbeiten.
Wenn wir die LED eingeschaltet haben, müssen wir sie wieder ausschalten. Dazu musst du die gleichen Bits löschen, wie in "Testen, Setzen, Löschen und Umschalten" gezeigt :
- STM32F103 Prozessor
-
GPIOA->ODR &= ~(1 << 2); // IO1_2 low
- MSP430 Prozessor
-
P1OUT &= ~(BIT2); // IO1_2 low
- ATtiny Prozessor
-
PORTB &= ~0x4; // IOB_2 low
Blinken der LED
Um unser Programm zu beenden, müssen wir es nur noch zusammensetzen. Der Pseudocode dafür lautet wie folgt:
main: initialize the direction of the I/O pin to be an output loop: set the LED on do nothing for some period of time set the LED off do nothing for the same period of time repeat loop
Wenn du sie für deinen Prozessor programmiert hast, solltest du sie kompilieren, laden und testen. Vielleicht möchtest du die Verzögerungsschleife so verändern, dass die LED ungefähr richtig aussieht. Dazu sind wahrscheinlich mehrere zehntausend Prozessorzyklen nötig, sonst blinkt die LED schneller, als du sie wahrnehmen kannst.
Fehlersuche
Wenn du ein Debugging-System wie JTAG eingerichtet hast, ist es wahrscheinlich ganz einfach herauszufinden, warum deine LED nicht leuchtet. Andernfalls musst du vielleicht den Prozess der Eliminierung anwenden.
Überprüfe zuerst deine Berechnungen. Auch wenn du dich mit Hexadezimalzahlen und Bitverschiebungen auskennst, ist ein Tippfehler immer möglich. Meiner Erfahrung nach sind Tippfehler die am schwersten zu findenden Fehler, oft schwerer als Fehler im Speicher. Überprüfe, ob du den richtigen Pin auf dem Schaltplan verwendest. Und stelle sicher, dass die Platine mit Strom versorgt wird. (Du denkst vielleicht, dass dieser Ratschlag witzig ist, aber du wärst überrascht, wie oft das eine Rolle spielt!)
Als Nächstes überprüfe, ob ein Pin von verschiedenen Peripheriegeräten gemeinsam genutzt wird. Wir haben zwar gesagt, dass der Pin standardmäßig ein E/A-Anschluss ist, aber wenn du Probleme hast, überprüfe, ob es eine alternative Funktion gibt, die im Konfigurationsregister eingestellt werden kann.
Solange du das Handbuch geöffnet hast, überprüfe, ob der Pin richtig konfiguriert ist. Wenn die LED nicht reagiert, musst du das Kapitel über Peripheriegeräte im Benutzerhandbuch lesen. Prozessoren sind unterschiedlich, also überprüfe, ob der Pin nicht zusätzlich konfiguriert werden muss (z. B. ein Stromkontrollausgang) oder ob eine Funktion standardmäßig aktiviert ist (mache den GPIO nicht versehentlich zu einem Interrupt).
Die meisten Prozessoren sind standardmäßig mit E/A ausgestattet, weil das für ihre Benutzer (uns!) die einfachste Möglichkeit ist, zu überprüfen, ob der Prozessor richtig angeschlossen ist. Vor allem bei stromsparenden Prozessoren solltest du jedoch alle ungenutzten Subsysteme ausschalten, um den Stromverbrauch zu senken. (Und andere Spezialprozessoren haben vielleicht andere Standardfunktionen.) Im Benutzerhandbuch erfährst du mehr über die Standardkonfiguration und wie du sie ändern kannst. Es gibt oft andere Register, die ein Bit setzen (oder löschen) müssen, damit ein Pin als E/A fungiert.
Manchmal müssen die Uhren konfiguriert werden, damit das E/A-Subsystem richtig funktioniert (oder damit eine Verzögerungsfunktion langsam genug läuft, damit du das Umschalten sehen kannst). Wenn du immer noch Probleme hast, sieh dir die Beispiele des Anbieters an und finde heraus, ob es Unterschiede gibt.
Stelle als Nächstes sicher, dass das System deinen Code ausführt. Hast du eine andere Möglichkeit, um zu überprüfen, ob es sich bei dem Code, der ausgeführt wird, um den Code handelt, den du kompiliert hast? Wenn du eine serielle Debug-Schnittstelle hast, versuche, die Revision zu erhöhen, um zu überprüfen, ob der Code geladen wird. Wenn das nicht funktioniert, stelle sicher, dass dein Build-System den richtigen Prozessor für das Ziel verwendet.
Mach den Code so einfach wie möglich, um sicher zu sein, dass der Prozessor die Funktion ausführt, die die LEDs bedient. Eliminiere alle unkritischen Initialisierungen von Peripheriegeräten, falls das System verzögert wird, weil es auf ein nicht vorhandenes externes Gerät wartet. Schalte Interrupts und Asserts aus und stelle sicher, dass der Watchdog ausgeschaltet ist (siehe "Watchdog").
Bei vielen Mikrocontrollern können die Pins mehr Strom aufnehmen, als sie abgeben (können). Daher ist es nicht ungewöhnlich, dass der Pin mit der Kathode und nicht mit der Anode der LED verbunden ist. In diesen Fällen schaltest du eine LED ein, indem du eine Null statt einer Eins schreibst. Das nennt man invertierte Logik. Überprüfe deinen Schaltplan, um zu sehen, ob das der Fall ist.
Wenn der Ausgang immer noch nicht funktioniert, überlege, ob es ein Problem mit der Hardware gibt. Überprüfe zunächst, ob die LED mit dem Chip-Pin verbunden ist, von dem du glaubst, dass er es ist. Wenn möglich, führe deine Software auf mehreren Boards aus, um einen Fehler bei der Montage auszuschließen. Auch bei der Hardware kann es sich um etwas Einfaches handeln (LEDs rückwärts einzubauen ist ziemlich einfach). Es kann ein Designproblem sein, z. B. dass der Prozessor-Pin nicht genug Strom liefern kann, um die LED anzusteuern; das Datenblatt (oder das Benutzerhandbuch) kann dir das vielleicht verraten. Es könnte ein Problem auf der Platine sein, z. B. ein defektes Bauteil oder eine Verbindung. Da die meisten Prozessoren eine hohe Dichte an Pins haben, ist es sehr einfach, Pins kurzzuschließen. Frag nach Hilfe oder hol dein Multimeter (oder Oszilloskop) heraus.
Die Hardware von der Aktion trennen
Dem Marketing hat dein erster Prototyp gut gefallen, auch wenn er vielleicht noch ein bisschen nachgebessert werden muss. Das System wurde von einer Prototyp-Platine auf eine Leiterplatte übertragen. Dabei änderte sich irgendwie die Pin-Nummer (zu IO1_3). Beide Systeme müssen funktionsfähig sein.
Es ist trivial, den Code für dieses Projekt zu korrigieren, aber für ein größeres System können die Pins durcheinander gewürfelt werden, um Platz für eine neue Funktion zu schaffen. Schauen wir uns an, wie wir die Änderungen einfacher machen können.
Board-spezifische Header-Datei
Wenn du eine Board-spezifische Header-Datei verwendest, musst du den Pin nicht fest kodieren. Wenn du eine Header-Datei hast, musst du den Wert nur dort ändern, anstatt ihn in deinem Code überall zu ändern, wo er referenziert wird. Die Header-Datei könnte wie folgt aussehen:
#define LED_SET_DIRECTION (P1DIR) #define LED_REGISTER (P1OUT) #define LED_BIT (1 << 3)
Die Codezeilen zur Konfiguration und zum Blinken der LED können prozessorunabhängig sein:
LED_SET_DIRECTION |= LED_BIT; // set the I/O to be output LED_REGISTER |= LED_BIT; // turn the LED on LED_REGISTER &= ~LED_BIT; // turn the LED off
Das könnte ein bisschen unhandlich werden, wenn du viele E/A-Leitungen hast oder die anderen Register brauchst. Es wäre schön, wenn du nur den Port (1) und die Position im Port (3) angeben könntest und der Code das herausfinden würde. Der Code könnte zwar komplexer sein, aber er würde wahrscheinlich Zeit (und Fehler) sparen. In diesem Fall würde die Header-Datei so aussehen:
// ioMapping_v2.h #define LED_PORT 1 #define LED_PIN 3
Wenn wir neu kompilieren wollen, um verschiedene Builds für verschiedene Boards zu verwenden, können wir drei Header-Dateien verwenden. Die erste ist die alte Board-Pinbelegung(ioMapping_v1.h). Als Nächstes erstellen wir eine für die neue Pinbelegung(ioMapping_v2.h). Wir könnten die Datei, die wir brauchen, in unsere Haupt- .c-Datei einbinden, aber das macht das Ziel zunichte, diesen Code weniger zu verändern. Wenn wir die Hauptdatei immer eine generische ioMapping.h einbinden, können wir die Versionen in der Hauptdatei wechseln, indem wir die richtige Header-Datei einbinden:
// ioMapping.h #if COMPILING_FOR_V1 #include "ioMapping_v1.h" #elif COMPILING_FOR_V2 #include "ioMapping_v2.h" #else #error "No I/O map selected for the board. What is your target?" #endif /* COMPILING_FOR_*/
Die Verwendung einer plattenspezifischen Header-Datei macht deinen Entwicklungsprozess robuster gegenüber zukünftigen Hardwareänderungen. Indem du die Board-spezifischen Informationen von der Funktionalität des Systems trennst, schaffst du eine lockere und flexible Codebasis.
Tipp
Die E/A-Zuordnung in Excel zu speichern, ist eine gängige Methode, um sicherzustellen, dass die Hardware- und Softwareentwickler sich über die Pin-Definitionen einig sind. Mit ein wenig kreativem Skripting kannst du deine versionsspezifische E/A-Karte aus einer CSV-Datei generieren, um sicherzustellen, dass die Pinbezeichnungen mit denen auf dem Schaltplan übereinstimmen.
I/O-Handling Code
Anstatt direkt in die Register im Code zu schreiben, müssen wir die verschiedenen Ports auf generische Weise behandeln. Bisher müssen wir den Pin als Ausgang initialisieren, den Pin auf high setzen, damit die LED leuchtet, und den Pin auf low setzen, damit die LED aus ist. Seltsamerweise haben wir selbst für eine so einfache Schnittstelle eine Vielzahl von Möglichkeiten, dies zu tun.
In der Implementierung konfiguriert die Initialisierungsfunktion den Pin als Ausgang (und setzt ihn ggf. als I/O-Pin anstelle eines Peripheriegeräts). Bei mehreren Pins könnte man geneigt sein, alle Initialisierungen zusammenzufassen, aber das würde die Modularität des Systems zerstören.
Obwohl der Code etwas mehr Platz benötigt, ist es besser, wenn jedes Subsystem die E/As initialisiert, die es benötigt. Wenn du dann ein Modul entfernst oder wiederverwendest, hast du alles, was du brauchst, in einem Bereich. Es gibt jedoch eine Situation, in der du die Schnittstellen nicht in Subsysteme aufteilen solltest: die I/O-Mapping-Header-Datei, in der alle Pins zusammengefasst sind, um die Kommunikation mit der Hardware zu vereinfachen.
Wenn wir mit der Schnittstelle des I/O-Subsystems weitermachen, könnte das Setzen eines Pins auf High und Low mit einer einzigen Funktion erfolgen: IOWrite(port, pin, high/low)
. Alternativ kann diese Funktion auch in zwei Funktionen aufgeteilt werden: IOSet(port, pin)
und IOClear(port, pin)
. Beide Methoden funktionieren. Stell dir vor, wie unsere Hauptfunktion in beiden Fällen aussehen wird.
Das Ziel ist es, die LED zum Umschalten zu bringen. Wenn wir IOWrite
verwenden, können wir eine Variable haben, die zwischen hoch und niedrig umschaltet. Im Fall von IOSet
und IOClear
müssten wir diese Variable speichern und in der Hauptschleife überprüfen, welche Funktion aufgerufen werden soll. Alternativ könnten wir IOSet
und IOClear
in einer anderen Funktion namens IOToggle
verstecken. Da wir mit unserer Hardware keine besonderen Einschränkungen haben, brauchen wir den Code in diesem Beispiel nicht zu optimieren. Zur Veranschaulichung solltest du dir jedoch die Optionen ansehen, die wir uns mit diesen potenziellen Schnittstellen geben.
Die Option IOWrite
führt alles in einer Funktion aus, sodass sie weniger Codeplatz benötigt. Allerdings hat sie mehr Parameter und benötigt daher mehr Stack-Speicherplatz, der aus dem RAM stammt. Außerdem muss sie eine Zustandsvariable behalten (ebenfalls RAM).
Mit der Option IOSet
/IOClear
/IOToggle
gibt es mehr Funktionen (mehr Codeplatz), aber weniger Parameter und möglicherweise keine benötigten Variablen (weniger RAM). Beachte, dass die Toggle-Funktion in Bezug auf die Prozessorzyklen nicht teurer ist als die Funktionen set und clear.
Diese Art der Bewertung erfordert, dass du die Schnittstelle in einer anderen Dimension betrachtest. In Kapitel 11 erfährst du mehr darüber, wie du für jeden Bereich optimieren kannst. In der Prototyping-Phase ist es noch zu früh, um den Code zu optimieren, aber es ist nie zu früh, um zu überlegen, wie der Code so gestaltet werden kann, dass er später optimiert werden kann.
Hauptschleife
Durch die Änderungen in den vorherigen Abschnitten wurde der Code für die E/A-Verarbeitung in ein eigenes Modul ausgelagert, obwohl sich die Grundlagen der Hauptschleife nicht ändern. Die Implementierung könnte wie folgt aussehen:
void main(void){ IOSetDir(LED_PORT, LED_PIN, OUTPUT); while (1) { // spin forever IOToggle(LED_PORT, LED_PIN); DelayMs(DELAY_TIME); } }
Die Hauptfunktion ist nicht mehr direkt vom Prozessor abhängig. Durch diese Entkopplung kann der Code mit größerer Wahrscheinlichkeit in anderen Projekten wiederverwendet werden. In (a) von Abbildung 4-3 ist die ursprüngliche Version der Softwarearchitektur dargestellt, deren einzige Abhängigkeit der Prozessor HAL ist. Die Mitte, beschriftet mit (b), ist unsere aktuelle Version. Sie ist komplizierter, aber die Trennung der Bereiche ist deutlicher. Beachte, dass die Header-Dateien an der Seite stehen, um zu zeigen, dass sie in die Abhängigkeiten einfließen.
Unsere nächste Umstrukturierung wird eine noch flexiblere und wiederverwendbare Architektur schaffen, wie in (c) dargestellt.
Muster der Fassade
Wie du dir vorstellen kannst, wird unsere E/A-Schnittstelle mit der Erweiterung der Produktfunktionen immer komplexer werden. (Zurzeit haben wir nur einen Ausgangspin, es kann also nicht wirklich einfacher werden.) Langfristig wollen wir die Details der einzelnen Subsysteme verbergen. Es gibt ein Standard-Software-Designmuster namens Fassade, das eine vereinfachte Schnittstelle zu einem Teil des Codes bietet. Das Ziel des Fassadenmusters ist es, die Nutzung einer Softwarebibliothek zu vereinfachen. In Anlehnung an die Metapher, die ich in diesem Buch verwendet habe, nämlich dass die Schnittstelle zum Prozessor der Arbeit mit einer Softwarebibliothek ähnelt, ist es sinnvoll, das Fassadenmuster zu verwenden, um einige Details des Prozessors und der Hardware zu verbergen.
In "Designing for Change" haben wir das Adaptermuster kennengelernt, das eine allgemeinere Version des Fassadenmusters ist. Während das Adaptermuster als Übersetzung zwischen zwei Ebenen fungiert, tut die Fassade dies, indem sie die darunter liegende Ebene vereinfacht. Wenn du als Dolmetscher zwischen Wissenschaftlern und Außerirdischen fungieren würdest, könnte man dich bitten, "x = y + 2, wobei y = 1" zu übersetzen. Wenn du ein Adaptermuster wärst, würdest du dieselbe Information ohne Änderungen wiedergeben. Wenn du ein Fassadenmuster wärst, würdest du wahrscheinlich "x = 3" sagen, weil das einfacher ist und die Details für die Verwendung der Information nicht entscheidend sind.
Das Verstecken von Details in einem Subsystem ist ein wichtiger Teil eines guten Designs. Es macht den Code lesbarer und einfacher zu testen. Außerdem ist der aufrufende Code nicht von den Interna des Subsystems abhängig, so dass der zugrunde liegende Code geändert werden kann, während die Fassade intakt bleibt.
Eine Fassade für unsere blinkende LED würde die Idee der I/O-Pins vor dem aufrufenden Code verbergen, indem sie ein LED-Subsystem erstellt, wie in Abbildung 4-3 rechts dargestellt. Da der Benutzer so wenig über das LED-Subsystem wissen muss, könnte die Fassade mit nur zwei Funktionen implementiert werden:
LEDInit()
-
Ruft die I/O-Initialisierungsfunktion für den LED-Pin auf (ersetzt
IOSetDir(…)
) LEDBlink()
-
Blinkt die LED (ersetzt
IOToggle(…)
)
Durch das Hinzufügen einer Fassade lässt sich dein Code leichter erweitern und ändern, wenn sich die Anforderungen unweigerlich ändern.
Die Eingabe in I/O
Das Marketing möchte die Art und Weise ändern, wie das System als Reaktion auf eine Taste blinkt. Wenn die Taste gedrückt gehalten wird, soll das System nicht mehr blinken.
Unser Schaltplan ist nicht viel komplexer, wenn wir einen Schalter hinzufügen (siehe Abbildung 4-4). Beachte, dass der Taster IO2_2 (I/O-Port 2, Pin 2) verwendet, der im Schaltplan mit S1 (Schalter 1) bezeichnet wird. Das Symbol für einen Schalter ergibt einen gewissen Sinn: Wenn du ihn drückst, leitet er über den angegebenen Bereich. Wenn du hier den Schalter drückst, wird der Pin mit Masse verbunden.
Viele I/O-Pins von Prozessoren haben interne Pull-up-Widerstände. Wenn ein Pin ein Ausgang ist, bewirken die Pull-ups nichts. Wenn der Pin jedoch ein Eingang ist, verleiht der Pull-up ihm einen gleichbleibenden Wert (1), auch wenn nichts angeschlossen ist. Das Vorhandensein und die Stärke des Pull-Ups können konfiguriert werden, aber das hängt von deinem Prozessor (und möglicherweise von dem jeweiligen Pin) ab. Die meisten Prozessoren haben sogar die Option, interne Pull-Downs an einem Pin zuzulassen. In diesem Fall hätte unser Schalter auch mit der Stromversorgung statt mit der Masse verbunden werden können.
Tipp
Eingänge mit internen Pull-Ups verbrauchen ein bisschen Strom. Wenn dein System also ein paar Mikroampere sparen muss, kannst du die nicht benötigten Pull-Ups (oder Pull-Downs) deaktivieren.
Das Benutzerhandbuch deines Prozessors enthält eine Beschreibung der Pin-Optionen. Die grundlegenden Schritte zur Einrichtung sind die folgenden:
-
Füge den Pin zur I/O Map Header-Datei hinzu.
-
Konfiguriere ihn als Eingang. Vergewissere dich, dass er nicht Teil eines anderen Peripheriegeräts ist.
-
Konfiguriere explizit einen Pull-up (falls nötig).
Sobald du deinen E/A-Pin als Eingang eingerichtet hast, musst du eine Funktion hinzufügen, um ihn zu verwenden - eine, die den Status des Pins als hoch (true) oder niedrig (false) zurückgeben kann:
boolean IOGet(uint8_t port, uint8_t pin);
Wenn die Taste gedrückt wird, wird sie mit Masse verbunden. Dieses Signal ist aktiv niedrig, das heißt, wenn die Taste aktiv gedrückt wird, ist das Signal niedrig.
Um die Details des Systems zu verbergen, wollen wir ein Button-Subsystem erstellen, das unser E/A-Modul verwenden kann. Oben auf die E/A-Funktion können wir eine weitere Fassade setzen, so dass das Button-Subsystem eine einfache Schnittstelle hat.
Die I/O-Funktion gibt den Pegel des Pins zurück. Wir wollen aber wissen, ob der Benutzer eine Aktion durchgeführt hat. Anstatt dass die Schnittstelle der Taste den Pegel zurückgibt, kannst du das Signal umkehren, um festzustellen, ob die Taste gerade gedrückt ist. Die Schnittstelle könnte so aussehen:
void ButtonInit()
-
Ruft die E/A-Initialisierungsfunktion für die Schaltfläche auf
boolean ButtonPressed()
-
Gibt true zurück, wenn die Taste unten ist
Wie in Abbildung 4-5 zu sehen ist, verwenden sowohl das LED- als auch das Tasten-Subsystem das I/O-Subsystem und die I/O-Map-Header-Datei. Dies ist ein einfaches Beispiel dafür, wie die Modularisierung, die wir in diesem Kapitel vorgenommen haben, eine Wiederverwendung ermöglicht.
Auf einer höheren Ebene gibt es einige Möglichkeiten, die Hauptfunktion zu implementieren. Hier ist eine Möglichkeit:
main: initialize LED initialize button loop: if button pressed, turn LED off else toggle LED do nothing for a period of time repeat
Mit diesem Code geht die LED nicht sofort aus, sondern wartet, bis die Verzögerung abgelaufen ist. Der Benutzer kann eine gewisse Verzögerung zwischen dem Drücken der Taste und dem Erlöschen der LED feststellen.
Tipp
Ein System, das nicht in weniger als einer Viertelsekunde (250 ms) auf einen Tastendruck reagiert, wirkt träge und ist schwer zu bedienen. Eine Reaktionszeit von 100 ms ist viel besser, aber für ungeduldige Menschen immer noch spürbar. Eine Reaktionszeit von unter 50 ms fühlt sich sehr flott an.
Um die Reaktionszeit zu verkürzen, könnten wir ständig überprüfen, ob der Knopf gedrückt wurde:
loop: if button pressed, turn LED off else if enough time has passed, toggle LED clear how much time has passed repeat
Beide Methoden überprüfen die Schaltfläche, um festzustellen, ob sie gedrückt ist. Diese kontinuierliche Abfrage wird Polling genannt und ist im Code leicht nachzuvollziehen. Wenn die LED jedoch so schnell wie möglich ausgeschaltet werden muss, möchtest du vielleicht, dass die Taste den normalen Ablauf unterbricht.
Warte! Dieses Wort(unterbrechen) ist ein sehr wichtiges Wort. Ich bin sicher, du hast es schon einmal gehört und wir werden bald (und in Kapitel 5) noch viel mehr ins Detail gehen. Schau dir vorher an, wie einfach die Hauptschleife sein kann, wenn du einen Interrupt verwendest, um den Tastendruck zu erfassen und zu verarbeiten:
loop: if button not pressed, toggle LED do nothing for a period of time repeat
Der Interrupt-Code (auch ISR oder Interrupt Service Routine genannt) ruft die Funktion zum Ausschalten der LED auf. Dadurch sind die Subsysteme der Taste und der LED jedoch voneinander abhängig und auf eine nicht offensichtliche Weise miteinander gekoppelt. Es gibt Fälle, in denen du das tun musst, damit ein eingebettetes System schnell genug ist, um ein Ereignis zu verarbeiten.
In Kapitel 5 wird genauer beschrieben, wie und wann man Unterbrechungen verwendet. In diesem Kapitel werden wir sie nur auf einer hohen Ebene betrachten.
Kurzer Tastendruck
Anstatt die LED mit der Taste anzuhalten, möchte das Marketing verschiedene Blinkraten testen, indem es die Taste antippt. Bei jedem Tastendruck sollte das System die Länge der Verzögerung um 50 % verringern (bis sie fast bei Null angelangt ist, um dann wieder auf die anfängliche Verzögerung zurückzugehen).
In der vorherigen Aufgabe musstest du nur überprüfen, ob der Knopf gedrückt wurde. Diesmal musst du sowohl wissen, wann der Knopf gedrückt wird, als auch, wann er losgelassen wird. Im Idealfall sollte der Schalter wie der obere Teil von Abbildung 4-6 aussehen. Wenn das der Fall wäre, könnte das System die steigende Kante des Signals erkennen und dann eine Aktion ausführen.
Unterbrechung beim Drücken einer Taste
Dies könnte ein weiterer Bereich sein, in dem ein Interrupt helfen kann, die Benutzereingabe abzufangen, damit die Hauptschleife den I/O-Pin nicht so schnell abfragen muss. Die Hauptschleife wird überschaubar, wenn sie eine globale Variable verwendet, um von den Tastendrücken zu erfahren:
interrupt when the user presses the button: set global button pressed = true loop: if global button pressed, set the delay period (reset or decrease it) set global button pressed = false turn LED off if enough time has passed, toggle LED clear how much time has passed repeat
Die Eingangspins vieler Prozessoren können so konfiguriert werden, dass sie unterbrechen, wenn das Signal am Pin einen bestimmten Pegel hat (hoch oder niedrig) oder sich verändert hat (steigende oder fallende Kante). Wenn das Tastensignal so aussieht wie in dem idealen Tastensignal oben in Abbildung 4-6, wo würdest du unterbrechen wollen? Eine Unterbrechung, wenn das Signal niedrig ist, kann zu mehrfachen Aktivierungen führen, wenn der Benutzer die Taste gedrückt hält. Ich bevorzuge eine Unterbrechung an der steigenden Kante, damit beim Drücken der Taste nichts passiert, bis der Benutzer sie loslässt.
Warnung
Um eine globale Variable in dieser Situation genau zu überprüfen, brauchst du das Schlüsselwort volatile
C, das du vielleicht noch nie gebraucht hast, wenn du Software in C und C++ entwickelst. Dieses Schlüsselwort teilt dem Compiler mit, dass sich der Wert der Variablen oder des Objekts unerwartet ändern kann und niemals herausoptimiert werden sollte. Alle Register und alle globalen Variablen, die von Interrupts und normalem Code gemeinsam genutzt werden, sollten als flüchtig markiert werden. Wenn dein Code ohne Optimierungen gut funktioniert und bei eingeschalteten Optimierungen fehlschlägt, überprüfe, ob die entsprechenden globalen Variablen und Register als flüchtig markiert sind.
Konfigurieren der Unterbrechung
Das Konfigurieren eines Pins zum Auslösen eines Interrupts ist normalerweise getrennt von der Konfiguration des Pins als Eingang. Obwohl beide als Initialisierungsschritte zählen, wollen wir die Interrupt-Konfiguration von unserer bestehenden Initialisierungsfunktion getrennt halten. Auf diese Weise kannst du die Komplexität der Interrupt-Konfiguration für die Pins aufheben, die sie benötigen (mehr dazu in Kapitel 5).
Die Konfiguration eines Pins zur Unterbrechung des Prozessors fügt unserem I/O-Subsystem drei weitere Funktionen hinzu:
IOConfigureInterrupt(port, pin, trigger type, trigger state)
-
Konfiguriert einen Pin als Interrupt. Der Interrupt wird ausgelöst, wenn er einen bestimmten Triggertyp erkennt, z. B. eine Kante oder einen Pegel. Bei einem Flankentrigger kann der Interrupt bei der steigenden oder fallenden Kante ausgelöst werden. Bei einem Pegel-Trigger kann der Interrupt ausgelöst werden, wenn der Pegel hoch oder niedrig ist. Einige Systeme stellen auch einen Parameter für einen Callback bereit, also eine Funktion, die aufgerufen wird, wenn der Interrupt eintritt; andere Systeme programmieren den Callback auf einen bestimmten Funktionsnamen fest und du musst deinen Code dort einfügen.
IOInterruptEnable(port, pin)
-
Aktiviert den mit einem Pin verbundenen Interrupt.
IOInterruptDisable(port, pin)
-
Deaktiviert den mit einem Pin verbundenen Interrupt.
Wenn die Interrupts nicht pro Pin sind (sie könnten pro Bank sein), kann der Prozessor einen generischen I/O-Interrupt für jede Bank haben. In diesem Fall muss die ISR herausfinden, welcher Pin den Interrupt verursacht hat. Das hängt von deinem Prozessor ab. Wenn jeder E/A-Pin seinen eigenen Interrupt haben kann, können die Module lockerer gekoppelt werden.
Entprellende Schalter
Viele Tasten liefern nicht das saubere Signal, das im idealen Tastensignal im oberen Teil von Abbildung 4-6 dargestellt ist. Stattdessen sehen sie eher aus wie die mit "Bouncy digital button signal" beschrifteten. Wenn du bei diesem Signal unterbrichst, könnte dein System Prozessorzyklen verschwenden, indem es bei den Störungen am Anfang und Ende des Tastendrucks unterbricht. Das Prellen des Schalters kann auf einen mechanischen oder elektrischen Effekt zurückzuführen sein.
Abbildung 4-6 zeigt auch eine analoge Ansicht dessen, was passieren könnte, wenn ein Knopf gedrückt wird und nur langsam in Kraft tritt. (Was wirklich passiert, kann viel komplizierter sein, je nachdem, ob das Prellen hauptsächlich auf eine mechanische oder elektrische Ursache zurückzuführen ist.) Beachte, dass es Teile des analogen Signals gibt, bei denen das Signal weder hoch noch niedrig ist, sondern irgendwo dazwischen liegt. Da es sich bei deiner E/A-Leitung um ein digitales Signal handelt, kann es diesen unbestimmten Wert nicht darstellen und kann sich schlecht verhalten. Ein digitales Signal mit vielen Kanten würde dazu führen, dass der Code glaubt, dass pro Benutzeraktion mehrere Tasten gedrückt wurden. Das Ergebnis wäre inkonsistent und frustrierend für den Benutzer. Schlimmer noch: Eine Unterbrechung bei einem solchen Signal kann zu einer Instabilität des Prozessors führen.
Die Entprellung ist die Technik, die verwendet wird, um die störenden Kanten zu beseitigen. Es kann sowohl mit Hardware als auch mit Software gemacht werden, aber wir konzentrieren uns auf die Software. In Jack Ganssles ausgezeichnetem Webartikel über Entprellung (unter "Weitere Informationen") findest du Lösungen für Hardware.
Hinweis
Viele moderne Schalter haben eine sehr kurze Zeitspanne der Unsicherheit. Auch für Schalter gibt es Datenblätter; sieh in deinem nach, was der Hersteller empfiehlt. Der Versuch, empirisch festzustellen, ob du eine Entprellung brauchst, ist möglicherweise nicht ausreichend, da sich verschiedene Schalterchargen unterschiedlich verhalten können.
Du solltest trotzdem nach der steigenden Kante des Signals suchen, an der der Benutzer die Taste loslässt. Um den Müll in der Nähe der steigenden und fallenden Kanten zu vermeiden, musst du nach einem relativ langen Zeitraum mit gleichbleibendem Signal suchen. Wie lange das ist, hängt von deinem Schalter ab und davon, wie schnell du auf den Benutzer reagieren willst.
Um den Schalter zu entprellen, nimmst du mehrere Messungen (sogenannte Samples) des Pins in regelmäßigen Abständen vor, und zwar mehrmals schneller, als du reagieren möchtest. Wenn mehrere aufeinanderfolgende, konsistente Abtastungen stattgefunden haben, informierst du den Rest des Systems darüber, dass sich der Schalter geändert hat. Siehe Abbildung 4-6. Das Ziel ist es, so viel abzulesen, dass die unsicheren Logikpegel nicht zu einem falschen Tastendruck führen.
Du brauchst drei Variablen:
-
Der aktuelle Rohwert der E/A-Leitung
-
Ein Zähler, um festzustellen, wie lange der Rohwert konsistent ist
-
Der Wert der entprellten Schaltfläche, der vom Rest des Codes verwendet wird
Wie lange die Entprellung dauert (und wie lange dein System braucht, um auf einen Benutzer zu reagieren), hängt davon ab, wie hoch der Zähler erhöht werden muss, bevor die Variable der entprellten Schaltfläche in den aktuellen Rohzustand geändert wird. Der Zähler sollte so eingestellt werden, dass die Entprellung innerhalb einer für den Schalter angemessenen Zeit erfolgt.
Wenn es in deinem Produkt keine Angaben dazu gibt, überlege, wie schnell die Tasten auf einer Tastatur gedrückt werden. Wenn eine fortgeschrittene Schreibkraft 120 Wörter pro Minute tippen kann und dabei von durchschnittlich fünf Zeichen pro Wort ausgeht, drückt sie die Tasten (Knöpfe) etwa 10 Mal pro Sekunde. Wenn du davon ausgehst, dass eine Taste die Hälfte der Zeit gedrückt ist, musst du davon ausgehen, dass die Taste etwa 50 ms lang gedrückt ist. (Wenn du wirklich eine Tastatur herstellst, brauchst du wahrscheinlich eine engere Toleranz, weil es schnellere Tipper gibt).
Für unser System hat der mythische Schalter ein imaginäres Datenblatt, das besagt, dass der Schalter beim Drücken oder Loslassen nicht länger als 12,5 ms klingelt. Wenn das Ziel ist, auf einen Knopf zu reagieren, der 50 ms oder länger gedrückt wird, können wir eine Abtastung von 10 ms (100 Hz) durchführen und nach fünf aufeinanderfolgenden Abtastungen suchen.
Fünf aufeinanderfolgende Abtastungen sind ziemlich konservativ. Vielleicht möchtest du die Häufigkeit der Pegelabfrage des Pins so anpassen, dass du nur drei aufeinanderfolgende Abtastungen brauchst, um anzuzeigen, dass sich der Zustand der Taste geändert hat.
Tipp
Wäge bei deiner Entprellungsmethode ab: Bedenke die Kosten, die entstehen, wenn du dich irrst (Ärger oder Katastrophe?), und die Kosten, die entstehen, wenn du langsamer auf den Nutzer reagierst.
Bei der bisherigen Methode zur Behandlung des Tastendrucks durch einen fallenden Kanten-Interrupt war der Zustand der Taste nicht so interessant wie die Änderung des Zustands. Deshalb fügen wir eine vierte Variable hinzu, um die Hauptschleife zu vereinfachen:
read button: if raw reading is the same as debounced button value, reset the counter else decrement the counter if the counter is zero, set debounced button value to raw reading set changed to true reset the counter main loop: if time to read button, read button if button changed and button is no longer pressed, set button changed to false set the delay period (reset or halve it) if time to toggle the LED, toggle LED repeat
Beachte, dass dies die Grundform des Codes ist. Es gibt viele Optionen, die von den Anforderungen deines Systems abhängen können. Willst du zum Beispiel, dass es schnell auf einen Tastendruck reagiert, aber langsam auf das Loslassen? Es gibt keinen Grund, warum der Entprellungszähler symmetrisch sein muss.
Im vorangegangenen Pseudocode fragt die Hauptschleife die Taste erneut ab, anstatt Interrupts zu verwenden. Viele Prozessoren haben jedoch Timer, die für Unterbrechungen konfiguriert werden können. Um die Hauptfunktion zu vereinfachen, könnte das Abfragen der Taste in einem Timer erfolgen. Auch das Umschalten der LED könnte in einem Timer erfolgen. Mehr über Timer in Kürze, aber zuerst hat das Marketing eine andere Anfrage.
Laufzeitunsicherheit
Das Marketing hat eine Reihe von LEDs zum Ausprobieren. Die LEDs sind an verschiedenen Pins befestigt. Benutze die Taste, um durch die Möglichkeiten zu blättern.
Wir haben den Tastendruck verarbeitet, aber das LED-Subsystem kennt nur den Ausgang an Pin 1_2 auf dem v1-Board oder 1_3 auf dem v2-Board. Wenn du alle LEDs als Ausgänge initialisiert hast, kannst du eine bedingte Anweisung (oder einen Schalter) in deine Hauptschleife einfügen:
if count button presses == 0, toggle blue LED if count button presses == 1, toggle red LED if count button presses == 2, toggle yellow LED
Um dies zu implementieren, brauchst du entweder drei verschiedene LED-Subsysteme oder (was wahrscheinlicher ist) deine LED-Umschaltfunktion muss einen Parameter akzeptieren. Ersteres bedeutet eine Menge kopierten Code (was fast immer schlecht ist), während letzteres bedeutet, dass die LED-Funktion jedes Mal, wenn sie die LED umschaltet, die Farbe dem I/O-Pin zuordnen muss (was Prozessorzyklen verbraucht).
Flexiblerer Code
Unser Ziel ist es, eine Methode zu erstellen, um eine bestimmte Option aus einer Liste von mehreren möglichen Objekten zu verwenden. Anstatt die Auswahl jedes Mal zu treffen (in der Hauptschleife oder in der LED-Funktion), kannst du die gewünschte LED auswählen, wenn die Taste gedrückt wird. Dann ist es für die LED-Umschaltfunktion egal, welche LED sie umschaltet:
main loop: if time to read button, read button if button changed and button is no longer pressed, set button changed to false change LED variable if time to toggle the LED, toggle LED repeat
Indem wir eine Zustandsvariable hinzufügen, verwenden wir ein wenig RAM, um ein paar Prozessorzyklen zu sparen. Zustandsvariablen machen ein System oft unübersichtlich, vor allem, wenn der change LED variable
Abschnitt des Codes von toggle LED
getrennt ist. Den Code zu entwirren, um zu zeigen, wie eine Zustandsvariable die Option steuert, kann für jemanden, der versucht, einen Fehler zu beheben, mühsam sein (Auskommentieren hilft!). Die Zustandsvariable vereinfacht die Funktion LED toggle
beträchtlich, daher gibt es Fälle, in denen eine Zustandsvariable die Komplikationen, die sie verursacht, wert ist.
Dependency Injection
Wir können jedoch über eine Zustandsvariable hinausgehen und etwas noch Flexibleres verwenden. Wir haben bereits gesehen, dass die Abstraktion der I/O-Pins von der Platine uns davor bewahrt, den Code neu schreiben zu müssen, wenn sich die Platine ändert. Wir können die Abstraktion auch nutzen, um mit dynamischen Änderungen umzugehen (z. B. welche LED verwendet werden soll). Hierfür verwenden wir eine Technik namens Dependency Injection.
Vorher haben wir den I/O-Pin im LED-Code versteckt (und damit eine Hierarchie von Funktionen geschaffen, die nur von den unteren Ebenen abhängen). Mit Dependency Injection entfernen wir diese Abhängigkeit, indem wir einen I/O-Handler als Parameter an den LED-Initialisierungscode übergeben. Der I/O-Handler weiß, welcher Pin geändert werden muss und wie er zu ändern ist, aber der LED-Code weiß nur, wie er den I/O-Handler aufrufen muss. Siehe Abbildung 4-7.
Ein oft verwendetes Beispiel zur Veranschaulichung der Abhängigkeitsinjektion bezieht sich auf Motoren und Autos. Das Auto, das Endprodukt, ist auf einen Motor angewiesen, um sich fortzubewegen. Das Auto und der Motor werden vom Hersteller hergestellt. Das Auto kann sich zwar nicht aussuchen, welchen Motor es einbauen soll, aber der Hersteller kann jede der Abhängigkeitsoptionen einbauen, die das Auto nutzen kann, um sich fortzubewegen (z. B. den 800-PS-Motor oder den 20-PS-Motor).
Um auf das LED-Beispiel zurückzukommen: Der LED-Code ist wie das Auto und hängt von einem I/O-Pin ab, damit er funktioniert, genauso wie das Auto vom Motor abhängig ist. Der LED-Code kann jedoch so generisch gestaltet werden, dass er nicht von einem bestimmten I/O-Pin abhängig ist. Dies ermöglicht es der Hauptfunktion (unserem Hersteller), einen den Umständen entsprechenden I/O-Pin zu installieren, anstatt die Abhängigkeit zur Kompilierungszeit fest zu codieren. Diese Technik ermöglicht es dir, die Funktionsweise des Systems zur Laufzeit zu gestalten.
In C++ oder anderen objektorientierten Sprachen übergeben wir der LED bei jedem Tastendruck ein neues I/O-Pin-Handler-Objekt, um die Abhängigkeit zu injizieren. Das LED-Modul würde nie erfahren, welchen Pin es verändert oder wie es das tut. Die Variablen, die dies verbergen sollen, werden bei der Initialisierung gesetzt (aber erinnere dich daran, dass es sich dabei um Variablen handelt, die RAM verbrauchen und den Code unübersichtlich machen).
Dependency Injection ist eine sehr mächtige Technik, vor allem wenn dein LED-Modul etwas viel Komplizierteres tun soll, zum Beispiel Morsezeichen ausgeben. Wenn du deinen I/O-Pin-Handler übergibst, kannst du die LED-Ausgaberoutine für jeden Prozessor wiederverwenden. Außerdem könnte dein I/O-Pin-Handler beim Testen jeden Aufruf des LED-Moduls ausdrucken, anstatt den Ausgangspin zu ändern (oder zusätzlich dazu).
Das Beispiel des Automotors verdeutlicht jedoch eines der Hauptprobleme von Dependency Injection: die Komplexität. Es funktioniert gut, wenn du nur den Motor ändern musst. Aber sobald du die Räder, die Lenksäule, die Sitzbezüge, das Getriebe, das Armaturenbrett und die Karosserie mit einbeziehst, wird das Automodul ziemlich kompliziert und hat kaum noch einen eigenen Nutzen.
Das Ziel von Dependency Injection ist es, Flexibilität zu ermöglichen. Dies steht im Widerspruch zum Ziel des Fassadenmusters, das die Komplexität reduziert. In einem eingebetteten System benötigt die Dependency Injection mehr Arbeitsspeicher und ein paar zusätzliche Prozessorzyklen. Das Facade-Muster wird fast immer mehr Codeplatz beanspruchen. Du musst die Bedürfnisse und Ressourcen deines Systems berücksichtigen, um ein vernünftiges Gleichgewicht zu finden.
Einen Timer verwenden
Die Verwendung der Taste zum Ändern der Blinkgeschwindigkeit war hilfreich, aber das Marketing hat ein Problem in der Unsicherheit der Blinkrate gefunden. Anstatt die Geschwindigkeit der LED zu halbieren, möchte das Marketing die Taste verwenden, um durch eine Reihe von präzisen Blinkgeschwindigkeiten zu wechseln: 6,5 Mal pro Sekunde (Hz), 8,5 Hz und 10 Hz.
Diese Anforderung scheint einfach zu sein, aber es ist das erste Mal, dass wir etwas mit Zeitpräzision machen müssen. Zuvor konnte das System die Tasten bedienen und die LED generell umschalten, wenn es günstig war. Jetzt muss das System die LED in Echtzeit steuern. Wie nah du an "präzise" herankommst, hängt von den Parametern deines Systems ab, vor allem von der Genauigkeit und Präzision der Eingangsuhr deines Prozessors. Wir fangen damit an, einen Timer auf dem Prozessor zu verwenden, um ihn präziser zu machen, als er vorher war, und sehen dann, ob das Marketing das akzeptieren kann.
Timer-Stücke
Im Prinzip ist ein Timer ein einfacher Zähler, der die Zeit misst, indem er eine bestimmte Anzahl von Ticks aufaddiert. Je deterministischer die Hauptuhr ist, desto präziser kann der Timer sein. Timer arbeiten unabhängig von der Ausführung der Software und agieren im Hintergrund, ohne den Code zu verlangsamen. Technisch gesehen finden sie in den Siliziumgattern statt, die Teil des Mikrocontrollers sind.
Um die Frequenz des Timers einzustellen, musst du den Takteingang bestimmen. Das kann der Takt deines Prozessors sein (auch Systemtakt oder Haupttakt genannt) oder ein anderer Takt von einem anderen Subsystem (viele Prozessoren haben zum Beispiel einen Peripherietakt).
Der ATtiny45 hat zum Beispiel einen maximalen Prozessortakt von 4 MHz. Wir wollen, dass die LED mit 10 Hz blinkt, aber das bedeutet, dass wir mit 20 Hz unterbrechen müssen (unterbrechen, um sie einzuschalten, und wieder unterbrechen, um sie auszuschalten). Das heißt, wir brauchen eine Teilung von 200.000. Der ATtiny45 ist ein 8-Bit-Prozessor; er hat zwei 8-Bit-Timer und einen 16-Bit-Timer. Keiner der beiden Timer kann so hoch zählen (siehe die Seitenleiste "Systemstatistik"). Die Chipdesigner haben dieses Problem jedoch erkannt und uns ein weiteres Hilfsmittel an die Hand gegeben: das Prescaler-Register, das den Takt unterteilt, damit der Zähler langsamer hochzählt.
Warnung
Viele Timer basieren auf Nullen statt auf Einsen. Wenn du also einen Vorteiler haben willst, der durch 2 dividiert, schreibst du eine 1 in das Vorteiler-Register. Die ganze Sache mit den Timern ist schon kompliziert genug, ohne dass du sie mit den obigen Berechnungen erklären musst. Schau im Handbuch deines Prozessors nach, welche Timer-Register nullbasiert sind.
Die Wirkung des Vorteilerregisters ist in Abbildung 4-8 zu sehen. Der Systemtakt schaltet regelmäßig um. Bei einem Vorteilerwert von zwei schaltet der Vorteiler-Takt (der Eingang zu unserem Timer-Subsystem) mit der halben Systemtaktgeschwindigkeit um. Der Timer zählt hoch. Der Prozessor merkt, wenn der Timer mit dem Vergleichsregister übereinstimmt (im Diagramm auf 3 gesetzt). Wenn der Timer übereinstimmt, kann er entweder weiter hochzählen oder sich zurücksetzen, je nach Prozessor und Konfigurationseinstellungen.
Bevor wir auf den Timer des ATtiny45 zurückkommen, solltest du wissen, dass die Register, die für den Betrieb eines Timers benötigt werden, im Allgemeinen aus den folgenden bestehen:
- Timer-Zähler
-
Hier wird der sich ändernde Wert des Timers gespeichert (die Anzahl der Ticks seit dem letzten Zurücksetzen des Timers).
- Vergleichsregister (oder Capture Compare Register oder Match Register)
-
Wenn der Timer-Zähler gleich diesem Register ist, wird eine Aktion ausgeführt. Für jeden Timer kann es mehr als ein Vergleichsregister geben.
- Aktionsregister (oder Auto-Reload-Register)
-
Dieses Register legt eine Aktion fest, die ausgeführt wird, wenn der Zeitzähler und das Vergleichsregister gleich sind. (Bei einigen Zeitgebern sind diese Aktionen auch verfügbar, wenn der Zeitgeber überläuft, d.h. wenn das Vergleichsregister auf den Maximalwert des Zeitgeberzählers gesetzt wird). Es gibt vier Arten von möglichen Aktionen, die konfiguriert werden können (eine oder mehrere können auftreten):
-
Unterbrechen
-
Stoppen oder weiterzählen
-
Ladet den Zähler neu
-
Einen Ausgangspin auf High, Low, Toggle oder keine Änderung setzen
-
- Taktkonfigurationsregister (optional)
-
Dieses Register teilt dem Subsystem mit, welche Taktquelle es verwenden soll, obwohl die Standardeinstellung die Systemuhr sein kann. Einige Prozessoren haben Timer, die es sogar erlauben, einen Takt an einen Eingangspin anzuhängen. Du kannst oft wählen, ob du aufwärts oder abwärts zählen willst. In diesen Beispielen verwende ich das Aufwärtszählen, aber das Verfahren ist beim Abwärtszählen ähnlich.
- Prescaler-Register
-
Wie in Abbildung 4-8 zu sehen ist, wird dadurch der schnell eingehende Takt reduziert, so dass er langsamer läuft und Zeitgeber für langsamere Ereignisse entstehen können.
- Kontrollregister
-
Dadurch wird der Timer so eingestellt, dass er zu zählen beginnt, sobald er konfiguriert wurde. Oft gibt es im Steuerregister auch eine Möglichkeit, den Timer zurückzusetzen.
- Unterbrechungsregister (kann mehrfach vorhanden sein)
-
Wenn du Timer-Interrupts hast, musst du das entsprechende Interrupt-Register verwenden, um jeden Timer-Interrupt zu aktivieren, zu löschen und seinen Status zu überprüfen.
Das Einrichten eines Timers ist prozessorspezifisch; das Benutzerhandbuch führt dich in der Regel durch das Einrichten der einzelnen Register. Im Benutzerhandbuch deines Prozessors werden die Register möglicherweise etwas anders benannt. Ein guter Weg, um zu verstehen, wie man einen Timer benutzt, ist ein Blick in den Beispielcode eines Chip-Herstellers: Er enthält normalerweise einige Beispiele, die Timer auf verschiedene Arten konfigurieren. Du solltest den Beispielcode Zeile für Zeile durchgehen und dabei das Datenblatt vor dir haben, um sicherzustellen, dass du verstehst, wie der Timer konfiguriert ist.
Hinweis
Anstelle eines Vergleichsregisters kann dein Prozessor dir erlauben, die Timer-Aktionen nur dann auszulösen, wenn der Timer überläuft. Dabei handelt es sich um einen impliziten Vergleichswert von zwei bis zur Anzahl der Bits im Timerregister minus 1 (z. B. für einen 8-Bit-Timer: (28) - 1 = 255). Wenn du den Vorteiler einstellst, kannst du die meisten Timerwerte ohne große Fehler erreichen.
Die Rechnung machen
Ich zeige dir jetzt die Mathematik, die du brauchst, um einen Timer zu konfigurieren. Überfliege einfach den Abschnitt "Pulsweitenmodulation" und komm zurück, wenn du die Mathematik brauchst. Außerdem findest du im GitHub-Repository dieses Buches einige Rechner, mit denen du die Mathematik durchgehen kannst, ohne meiner Algebra hier zu folgen.
Zeitgeber sind dafür gemacht, mit physikalischen Zeitskalen umzugehen, also musst du eine Reihe von Registern mit einer tatsächlichen Zeit in Beziehung setzen. Erinnere dich daran, dass die Frequenz (z.B. 10 Hz) umgekehrt proportional zur Periode (z.B. 0,1 Sekunden) ist.
Die Grundgleichung für die Beziehung zwischen der Interruptfrequenz, dem Takteingang, dem Vorteiler und dem Vergleichsregister lautet:
interruptFrequency = clockIn / (prescaler * compare)
Dies ist ein Optimierungsproblem. Du kennst die clockIn
und das Ziel, interruptFrequency
. Du musst den Vorteiler und die Vergleichsregister anpassen, bis die Interruptfrequenz nahe genug am Ziel liegt. Wenn es keine anderen Einschränkungen gäbe, wäre dieses Problem leicht zu lösen (aber das ist nicht immer so einfach).
Mit dem 8-Bit-Timer des ATtiny45, dem 4-MHz-Systemtakt und der Zielfrequenz von 20 Hz können wir die Randbedingungen exportieren, die wir zur Lösung der Gleichung benötigen:
-
Das sind ganzzahlige Werte, also müssen der Vorteiler und das Vergleichsregister ganze Zahlen sein. Diese Einschränkung gilt für jeden Prozessor.
-
Das Vergleichsregister muss zwischen 0 und 255 liegen (weil das Timer-Register acht Bits groß ist).
-
Der Vorteil des ATtiny45 liegt bei 10 Bits, so dass der maximale Vorteiler 1.023 beträgt. (Die Größe deines Vorteilers kann anders sein.)
Der Vorteil dieses Subsystems liegt darin, dass er nicht mit anderen Peripheriegeräten geteilt wird, so dass wir uns (noch) keine Gedanken über diese mögliche Einschränkung machen müssen.
Es gibt mehrere Heuristiken, um einen Vorteiler und ein Vergleichsregister zu finden, die die benötigte Interruptfrequenz liefern (siehe Abbildung 4-9).
Warnung
Ich habe zwei Matheprofessoren gefragt, wie man dieses Problem allgemein lösen kann. Die Antworten, die ich zurückbekommen habe, waren interessant. Am interessantesten war es zu erfahren, dass dieses Problem aus zwei Gründen NP-komplett ist: Es geht um ganze Zahlen und es ist ein nichtlineares Problem mit zwei Variablen. Danke, Professor Ross und Professor Patton!
Wir können den minimalen Vorteiler bestimmen, indem wir die Gleichung umstellen und das Vergleichsregister auf seinen maximalen Wert setzen:
prescaler = clockIn / (compare * interruptFrequency) = 4 MHz / (255 * 20 Hz)
Leider ist der resultierende Vorteilerwert eine Fließkommazahl (784,31). Wenn du aufrundest (785), erhältst du eine Interrupt-Frequenz von 19,98 Hz, was ein Fehler von weniger als einem Zehntelprozent ist.
Wenn du den Vorteiler (784) abrundest, liegt die Interrupt-Frequenz über dem Zielwert und du kannst vielleicht das Vergleichsregister verringern, um den Timer in die richtige Richtung zu bringen. Mit Prescaler = 784 und Compare = 255 liegt der Fehler bei 0,04 %. Das Marketing hat jedoch nach einer hohen Genauigkeit gefragt, und es gibt einige Methoden, um einen besseren Vorteil zu finden.
Beachte zunächst, dass das Produkt aus Vorteiler und Vergleichsregister gleich dem Eingangstakt geteilt durch die Zielfrequenz sein muss:
prescaler * compare = clockIn / interruptFrequency = 4 MHz/20 Hz = 200,000
Das ist eine schöne, runde Zahl, die sich leicht in 1.000 (Vorteiler) und 200 (Vergleichsregister) aufteilen lässt. Das ist die beste und einfachste Lösung zur Optimierung der prescaler
und compare
: Bestimme die Faktoren von (clockIn/interruptFrequency
) und ordne sie in prescaler
und compare
ein. Dazu muss (clockIn/interruptFrequency
) jedoch eine ganze Zahl sein und die Faktoren müssen sich leicht in die für die Register zulässigen Größen aufteilen lassen. Es ist nicht immer möglich, diese Methode anzuwenden.
Mehr Mathe: Schwierige Zielfrequenz
Auf dem Weg zu einer weiteren Blinkfrequenz, die vom Marketing gefordert wird (8,5 Hz oder eine Interrupt-Frequenz von 17 Hz), ist das neue Ziel:
prescaler * compare = clockIn / interruptFrequency = 4 MHz/17 Hz = 235294.1
Für diese Fließkommazahl gibt es keine einfache Faktorisierung. Wir können überprüfen, ob ein Ergebnis möglich ist, indem wir den minimalen Vorteiler berechnen (das haben wir bereits getan, indem wir das Vergleichsregister auf seinen maximalen Wert gesetzt haben). Das Ergebnis (923) wird in unser 10-Bit-Register passen. Wir können den prozentualen Fehler wie folgt berechnen:
error = 100 * (goal interrupt frequency - actual) / goal
Mit dem minimalen Vorteiler erhalten wir einen Fehler von 0,03 %. Das ist ziemlich genau, aber wir könnten noch näher dran sein.
Setze den Vorteiler auf seinen Maximalwert und sieh dir an, welche Möglichkeiten du hast. In diesem Fall führt ein Vorteiler von 1.023 zu einem Vergleichswert von 230 und einem Fehler von weniger als 0,02 %, was schon etwas besser ist. Aber können wir den Fehler noch weiter verringern?
Bei größeren Timern kannst du eine binäre Suche nach einem guten Wert durchführen, indem du mit dem minimalen Vorteiler beginnst. Verdopple ihn und schaue dir dann die Werte des Vorteilers an, die +/- 1 sind, um ein Vergleichsregister zu finden, das der ganzen Zahl am nächsten kommt. Wenn die resultierende Frequenz nicht nahe genug ist, wiederhole die Verdopplung des geänderten Vorteilers. Leider können wir in unserem Beispiel den Vorteil des doppelten Vorteils nicht nutzen, ohne die 10-Bit-Zahl zu überschreiten.
Eine andere Möglichkeit, die Lösung zu finden, besteht darin, ein Skript oder ein Programm (z. B. MATLAB oder Excel) zu verwenden und die Optionen mit roher Gewalt auszuprobieren, wie im folgenden Verfahren gezeigt. Beginne damit, den minimalen und maximalen Vorteilerwert zu ermitteln (indem du das Vergleichsregister auf 1 setzt). Schränke die Minimal- und Maximalwerte so ein, dass sie ganze Zahlen sind und in die richtige Anzahl von Bits passen. Berechne dann für jede ganze Zahl in diesem Bereich das Vergleichsregister für die Zielunterbrechungsfrequenz. Runde das Vergleichsregister auf die nächste ganze Zahl und berechne die tatsächliche Interrupt-Frequenz. Diese Methode führte zu einem Vorteiler von 997, einem Vergleichsregister von 236 und einem winzigen Fehler von 0,0009%. Eine Brute-Force-Lösung wie diese liefert den kleinsten Fehler, benötigt aber wahrscheinlich die meiste Entwicklungszeit. Lege fest, mit welchem Fehler du leben kannst, und gehe zu anderen Dingen über, wenn du dieses Ziel erreicht hast.
Brute-Force-Methode, um den niedrigsten Registerwert für die Fehlerunterbrechungsfrequenz zu finden:
-
Berechne den minimalen und maximalen Vorteilerwert:
-
Berechne für jeden Vorteilerwert von 922 bis 1.023 einen Vergleichswert:
Verwende den berechneten Vergleichswert, um die Unterbrechungshäufigkeit mit diesen Werten zu bestimmen:
-
Finde den Vorteiler und vergleiche die Register mit dem geringsten Fehler.
Lange Wartezeit zwischen Timer-Ticks
Brute Force funktioniert gut für 17 Hz, aber wenn du die Zielleistung von 13 Hz (das neue 2 × 6,5 Hz-Ziel des Marketings) erreichst, ist der minimale Vorteiler, den du berechnen kannst, mehr als 10 Bit. Der Timer passt nicht in den 8-Bit-Timer. Dies wird im Flussdiagramm(Abbildung 4-9) als Ausnahme dargestellt. Die einfachste Lösung ist, einen größeren Timer zu verwenden, wenn du kannst. Der 16-Bit-Timer des ATtiny45 kann dieses Problem entschärfen, weil sein maximaler Vergleichswert 65.535 statt der 8-Bit 255 beträgt, sodass wir einen kleineren Vorteiler verwenden können.
Wenn kein größerer Timer verfügbar ist, besteht eine andere Lösung darin, die E/A-Leitung vom Timer zu trennen und einen Interrupt aufzurufen, wenn der Timer abläuft. Der Interrupt kann eine Variable inkrementieren und eine Aktion ausführen, wenn die Variable groß genug ist. Um zum Beispiel auf 13 Hz zu kommen, könnten wir einen 26-Hz-Timer verwenden und die LED jedes Mal umschalten, wenn der Interrupt aufgerufen wird. Diese Methode ist weniger präzise, weil es zu Verzögerungen durch andere Interrupts kommen kann.
Einen Timer verwenden
Sobald du deine Einstellungen festgelegt hast, ist der schwierige Teil vorbei, aber es gibt noch ein paar Dinge zu tun:
-
Entferne den Code in der Hauptfunktion, um die LED umzuschalten. Jetzt braucht die Hauptschleife nur noch eine Reihe von Vorteilsgeber- und Vergleichsregistern, die durchlaufen werden, wenn die Taste gedrückt wird.
-
Schreibe den Interrupt-Handler, der die LED umschaltet.
-
Konfiguriere den Pin. Einige Prozessoren verbinden jeden Timer mit jedem Ausgang, während andere es nur mit speziellen Einstellungen zulassen, dass ein Timer einen Pin verändert. Für Prozessoren, die keinen Timer auf dem Pin unterstützen, musst du einen Interrupt-Handler in den Code einbauen, der nur den Pin umschaltet, der dich interessiert.
Pulsweitenmodulation verwenden
Die Marktforschung hat gezeigt, dass potenzielle Kunden sich an der Helligkeit der LED stören und sagen, dass sie von der laserähnlichen Intensität geblendet werden. Das Marketing möchte verschiedene Helligkeitseinstellungen (100%, 80%, 70% und 50%) ausprobieren und mit der Taste zwischen den Optionen wechseln.
Diese Aufgabe gibt uns eine gute Gelegenheit die Pulsweitenmodulation (PWM) zu erforschen, die bestimmt, wie lange ein Pin hoch oder niedrig bleibt, bevor er umschaltet. PWMs arbeiten kontinuierlich und schalten ein Peripheriegerät nach einem regelmäßigen Zeitplan ein und wieder aus. Der Zyklus ist normalerweise sehr schnell, in der Größenordnung von Millisekunden (oder Hunderten von Hz).
PWM-Signale treiben oft Motoren und LEDs an (obwohl Motoren etwas mehr Hardware-Unterstützung benötigen). Mit PWM kann der Prozessor steuern, wie viel Strom die Hardware erhält. Mit etwas preiswerter Elektronik kann der Ausgang eines PWM-Pins geglättet werden, um das durchschnittliche Signal zu erhalten. Für LEDs ist jedoch keine zusätzliche Elektronik notwendig. Die Helligkeit hängt von der Zeit ab, die die LED in jedem Zyklus leuchtet.
Ein Timer ist eine Reihe von Impulsen, die alle gleich sind. Das Timer-Signal ist also zu 50 % hoch und zu 50 % niedrig (das nennt man 50 % Tastverhältnis). Bei der PWM ändern sich die Impulsbreiten je nach Situation. Eine PWM kann also ein anderes Verhältnis haben. Eine PWM mit einem Tastverhältnis von 100% ist immer eingeschaltet, wie ein High-Pegel an einem Ausgangspin. Ein Tastverhältnis von 0 % entspricht einem Pin, der auf einen niedrigen Pegel gebracht (oder gezogen) wurde. Das Tastverhältnis stellt den Durchschnittswert des Signals dar, wie die gestrichelte Linie in Abbildung 4-10 zeigt.
Mit dem Timer aus "Einen Timer benutzen" könnten wir eine PWM mit einem Interrupt implementieren. Für unser 20-Hz-LED-Beispiel hatten wir ein Vergleichsregister von 200, sodass der Timer-Interrupt alle 200 Ticks etwas tun würde (die LED umschalten). Wenn wir wollen, dass die LED mit einem 20-Hz-Timer 80 % der Zeit leuchtet (statt wie bisher 50 %), können wir zwischen zwei Interrupts hin- und herschalten, die das Vergleichsregister bei jedem Durchlauf setzen:
-
Timer-Unterbrechung 1
-
Schalte die LED ein.
-
Setze das Vergleichsregister auf 160 (80% von 200).
-
Setze den Timer zurück.
-
-
Timer-Unterbrechung 2
-
Schalte die LED aus.
-
Setze das Vergleichsregister auf 40 (20% von 200).
-
Setze den Timer zurück.
-
Mit einem 20-Hz-Timer würde das wahrscheinlich wie eine sehr schnelle Reihe von Blitzen aussehen, anstatt einer schwachen LED. Das Problem ist, dass der 20-Hz-Timer zu langsam ist. Je mehr du die Frequenz erhöhst, desto düsterer wird die LED aussehen, anstatt zu blinken. Eine schnellere Frequenz bedeutet aber auch mehr Unterbrechungen.
Es gibt eine Möglichkeit, diesen Vorgang im Prozessor auszuführen. Im vorigen Abschnitt haben wir die konfigurierbaren Aktionen beschrieben, z. B. ob der Zähler zurückgesetzt werden soll und wie der Pin gesetzt werden soll. Viele Timer haben mehrere Vergleichsregister und erlauben unterschiedliche Aktionen für jedes einzelne. So kann ein PWM-Ausgang mit zwei Vergleichsregistern eingestellt werden, eines zur Steuerung der Schaltfrequenz und eines zur Steuerung des Tastverhältnisses.
Der untere Teil von Abbildung 4-10 zeigt zum Beispiel einen Timer, der hochzählt und zurückgesetzt wird. Dies stellt die Schaltfrequenz dar, die durch ein Vergleichsregister festgelegt wird. Wir nennen dieses Vergleichsregister A und setzen es auf 100. Wenn dieser Wert erreicht ist, wird der Timer zurückgesetzt und die LED leuchtet auf. Das Tastverhältnis wird mit einem anderen Register (Vergleichsregister B, das auf 80 eingestellt ist) festgelegt, das die LED ausschaltet, aber den Timer weiterzählen lässt.
Welche Pins als PWM-Ausgänge fungieren können, hängt von deinem Prozessor ab, wobei es sich oft um eine Teilmenge der Pins handelt, die als Timer-Ausgänge fungieren können. Der PWM-Abschnitt des Benutzerhandbuchs deines Prozessors kann vom Timer-Abschnitt getrennt sein. Außerdem gibt es verschiedene PWM-Controller-Konfigurationen, oft für bestimmte Anwendungen (Motoren sind oft wählerisch, welche Art von PWM sie benötigen).
Sobald die PWM für unsere LED eingerichtet ist, muss der Code nur noch das Tastverhältnis ändern, wenn die Taste gedrückt wird. Wie bei den Timern steuert die Hauptfunktion die LED nicht direkt.
Obwohl das Dimmen der LED das ist, was das Marketing verlangt hat, gibt es noch andere nette Anwendungen, die du ausprobieren kannst. Um einen Schnarch-Effekt zu erzielen, bei dem die LED ein- und ausgeblendet wird, musst du das Tastverhältnis schrittweise ändern. Wenn du dreifarbige LEDs hast, kannst du die PWM-Steuerung nutzen, um die drei LED-Farben unterschiedlich einzustellen und so eine ganze Palette von Möglichkeiten zu schaffen.
Versand des Produkts
Das Marketing hat die perfekte LED (blau), Blinkfrequenz (8 Hz) und Helligkeit (100%) gefunden und ist bereit, das Produkt zu versenden, sobald du die Parameter eingestellt hast.
Es scheint alles so einfach zu sein, nur die Parameter zu setzen und den Code zu versenden. Aber wie sieht der Code jetzt aus? Wurde der Timer-Code in den PWM-Code umgewandelt, oder gibt es den Timer-Interrupt noch? Bei einer Helligkeit von 100 % wird der PWM-Code nicht mehr benötigt. Der Tastencode kann sogar wegfallen. Die Möglichkeit, eine LED zur Laufzeit auszuwählen, wird nicht mehr benötigt. Das alte Platinenlayout kann in Zukunft vergessen werden. Bevor wir den Code ausliefern und die Entwicklung einfrieren, sollten wir versuchen, das Spaghetti in etwas weniger Verworrenes zu verwandeln.
Ein Produkt beginnt als Idee und braucht oft ein paar Iterationen, um sich zu verfestigen. Ingenieurinnen und Ingenieure haben oft eine gute Vorstellung davon, wie etwas funktionieren wird. Nicht jeder hat so viel Glück, deshalb kann ein guter Prototyp viel dazu beitragen, das Ziel zu definieren.
Wenn du jedoch nicht benötigten Code beibehältst, wird die Codebasis unübersichtlich (siehe Abbildung 4-11, linke Seite). Unbenutzter Code (oder schlimmer noch, auskommentierter Code) ist frustrierend für die nächste Person, die nicht weiß, warum etwas entfernt wurde. Vermeide das so weit wie möglich. Vertraue stattdessen darauf, dass deine Versionskontrolle alten Code wiederherstellen kann. Scheue dich nicht, eine Entwicklungsversion intern zu markieren, zu verzweigen oder zu veröffentlichen. Das wird dir helfen, die entfernten Funktionen wiederzufinden, nachdem du den Code für den Versand beschnitten hast.
In diesem Beispiel lassen sich viele Dinge leicht entfernen, weil sie einfach nicht gebraucht werden. Eine Sache, die schwieriger zu entscheiden ist, ist die Dependency Injection. Sie erhöht die Flexibilität für zukünftige Änderungen, was ein guter Grund dafür ist, sie beizubehalten. Wenn du jedoch einem bestimmten I/O-Pin einen bestimmten Timer zuweisen musst, wird die Konfiguration des Systems prozessorabhängiger und starrer. Der Preis für die erzwungene Flexibilität kann hoch sein, wenn du versuchst, eine Datei zu erstellen, die alle Eventualitäten abdeckt. In diesem Fall habe ich überlegt und die Kosten für eine Datei, die ich niemandem zeigen möchte, gegen den Vorteil abgewogen, dass die Gefahr von Fehlern im I/O-Subsystem verringert wird. Ich habe mich dafür entschieden, lesbarere Dateien zu erstellen, auch wenn das ein paar anfängliche Fehler bedeutet, aber ich respektiere beide Optionen.
Auf der rechten Seite von Abbildung 4-11 ist die Codebasis reduziert und es werden nur die Module verwendet, die benötigt werden. Die E/A-Zuordnungs-Headerdatei wird beibehalten, auch mit Definitionen für das alte Board, weil die Kosten dafür gering sind (sie befindet sich in einer separaten Datei, um die Wartung zu erleichtern, und erfordert keine zusätzlichen Prozessorzyklen). Ingenieure für eingebettete Systeme neigen dazu, die älteste Hardware zu verwenden (als karmische Rache für die Zeit zu Beginn des Projekts, als wir die neueste Hardware hatten). Möglicherweise brauchst du die alte Header-Datei für zukünftige Entwicklungen. So ziemlich alles, was nicht kritisch ist, kann in die Versionskontrolle gehen und dann aus dem Projekt entfernt werden.
Es tut weh, wenn man sieht, dass die Mühe umsonst war. Aber das hast du nicht! All der andere Code war notwendig, um die Prototypen zu erstellen, die für die Herstellung des Produkts benötigt wurden. Der Code hat dem Marketing geholfen, und du hast beim Schreiben und Testen viel gelernt. Das Beste an der Entwicklung des Prototypen-Codes ist, dass der endgültige Code sauber aussehen kann , weil du die Möglichkeiten ausgelotet hast.
Du musst den Spagat zwischen der Flexibilität, den gesamten Code drin zu lassen, und der Wartungsfreundlichkeit einer leicht verständlichen Codebasis schaffen. Nachdem du ihn einmal geschrieben hast, vertraue darauf, dass dein zukünftiges Ich ihn wieder schreiben kann (wenn du musst).
Weitere Lektüre
-
A Guide to Debouncing auf der Website von Jack Ganssle bietet Mechanismen, Experimente aus der Praxis, andere Methoden für die Implementierung deines Entprellungscodes und einige ausgezeichnete Methoden, um es in der Hardware zu machen und deine Prozessorzyklen für etwas Interessanteres zu sparen. Seine gesamte Website ist es wert, dass du dir etwas Zeit nimmst.
-
STM32F101xx, STM32F102xx, STM32F103xx, STM32F105xx, und STM32F107xx Referenzhandbuch (RM0008), Rev 21, Februar 2021
-
MSP430F2xx, MSP430G2xx Familie Benutzerhandbuch (SLAU144K), August 2022
-
Atmel Benutzerhandbuch: 8-Bit Mikrocontroller mit 2/4/8K Bytes In-System Programmable Flash (ATtiny25/V, ATtiny45/V, ATtiny85/V), Rev. 2586Q-AVR-08/2013.
-
Atmel Anwendungshinweis: AVR 130: Einrichtung und Verwendung der AVR-Timer
-
Wenn bitweise Operationen für dich neu sind, gibt es einige Spiele, die dir ein intuitives Gefühl dafür vermitteln, wie sie funktionieren. Turing Complete ist mein derzeitiger Favorit. Im GitHub-Repository dieses Buches findest du weitere Vorschläge.
Get Herstellung eingebetteter Systeme, 2. Auflage now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.