Im Speicher abgebildete Register
Bei eingebetteten Systemen stößt man mit der reinen Ausführung von Standard-Rust-Code und dem Verschieben von Daten im Arbeitsspeicher (RAM) irgendwann an Grenzen. Wenn wir Informationen in das System einspeisen oder daraus ausgeben wollen – sei es das Blinken einer LED, das Erkennen eines Tastendrucks oder die Kommunikation mit einer externen Peripheriekomponente über einen Bus –, müssen wir uns mit der Welt der Peripherieeinheiten und deren „im Speicher abgebildeten Registern“ (memory-mapped registers) befassen.
Möglicherweise stellen Sie fest, dass der für den Zugriff auf die Peripherie Ihres Mikrocontrollers erforderliche Code bereits auf einer der folgenden Ebenen implementiert wurde:
- Mikroarchitektur-Crate – Diese Art von Crate verwaltet alle nützlichen Routinen, die dem Prozessorkern gemeinsam sind, den Ihr Mikrocontroller verwendet, sowie alle Peripheriegeräte, die allen Mikrocontrollern gemeinsam sind, die diesen bestimmten Prozessorkerntyp verwenden. Das cortex-m - Crate bietet Ihnen beispielsweise Funktionen zum Aktivieren und Deaktivieren von Interrupts, die für alle Cortex-M-basierten Mikrocontroller gleich sind. Außerdem erhalten Sie Zugriff auf die „SysTick“-Peripherie, die in allen Cortex-M-basierten Mikrocontrollern enthalten ist.
- Peripheral Access Crate (PAC) – Bei dieser Art von Crate handelt es sich um einen schlanken Adapter (Wrapper) für die verschiedenen Register, die für das spezifische Mikrocontroller-Modell definiert sind, das Sie verwenden. Zum Beispiel, tm4c123x für die Tiva-C-TM4C123-Serie von Texas Instruments oder stm32f30x für die STM32F30x-Serie von STMicroelectronics. Hierbei greifen Sie direkt auf die Register zu und befolgen dabei die Betriebshinweise für die jeweilige Peripherieeinheit, wie sie im technischen Referenzhandbuch Ihres Mikrocontrollers beschrieben sind.
- HAL-Crate – Diese Crates bieten eine benutzerfreundlichere API für einen bestimmten Prozessor, häufig durch die Implementierung gängiger Traits, die in embedded-hal definiert sind. So könnte ein solches Crate beispielsweise eine
Serial-Struktur bereitstellen, deren Konstruktor eine geeignete Kombination aus GPIO-Pins sowie eine Baudrate entgegennimmt und eine Funktion wiewrite_bytezum Senden von Daten anbietet. Weitere Informationen zu embedded-hal finden Sie im Kapitel über Portabilität. - Board-Crate – Diese Crates gehen noch einen Schritt weiter als HAL-Crates, indem sie verschiedene Peripheriekomponenten und GPIO-Pins passend für das jeweils verwendete Entwickler-Kit oder Board vorkonfigurieren – wie zum Beispiel stm32f3-discovery für das STM32F3DISCOVERY-Board.
Board Crate
Eine Board-Crate ist der ideale Ausgangspunkt für den Einstieg in Embedded Rust. Sie abstrahiert auf angenehme Weise die Hardware-Details, die Anfänger in diesem Bereich oft überfordern können, und erleichtert Standardaufgaben wie das Ein- oder Ausschalten einer LED. Der bereitgestellte Funktionsumfang variiert jedoch stark von Board zu Board. Da dieses Buch hardwareunabhängig bleiben soll, werden Board-Crates hier nicht behandelt.
Wenn Sie mit dem STM32F3DISCOVERY-Board experimentieren möchten, ist es sehr empfehlenswert, sich das stm32f3-discovery-Board-Crate anzusehen; dieses bietet Funktionen, um die LEDs des Boards blinken zu lassen sowie auf den Kompass, Bluetooth und mehr zuzugreifen. Das Discovery-Buch bietet eine hervorragende Einführung in die Verwendung eines Board-Crates.
Wenn Sie jedoch an einem System arbeiten, für das es noch kein dediziertes Board-Crate gibt, oder wenn Sie Funktionen benötigen, die von vorhandenen Crates nicht abgedeckt werden, lesen Sie weiter: Wir beginnen ganz unten, bei den Mikroarchitektur-Crates.
Mikroarchitektur-Crate
Betrachten wir die SysTick-Peripherie, die allen Mikrocontrollern auf Cortex-M-Basis gemeinsam ist. Im cortex-m-Crate finden wir eine recht hardwarenahe API, die sich folgendermaßen verwenden lässt:
#![no_std]
#![no_main]
use cortex_m::peripheral::{syst, Peripherals};
use cortex_m_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
let peripherals = Peripherals::take().unwrap();
let mut systick = peripherals.SYST;
systick.set_clock_source(syst::SystClkSource::Core);
systick.set_reload(1_000);
systick.clear_current();
systick.enable_counter();
while !systick.has_wrapped() {
// Loop
}
loop {}
}
Die Funktionen der SYST-Struktur entsprechen weitgehend der Funktionalität, die im ARM Technical Reference Manual für diese Peripherieeinheit definiert ist. Diese API bietet keine Möglichkeit, eine Verzögerung von „X Millisekunden“ direkt umzusetzen; wir müssen dies stattdessen selbst auf einfache Weise mittels einer while-Schleife implementieren. Beachten Sie, dass wir erst auf die SYST-Struktur zugreifen können, nachdem wir Peripherals::take() aufgerufen haben. Dabei handelt es sich um eine spezielle Routine, die sicherstellt, dass im gesamten Programm nur eine einzige SYST-Instanz existiert. Weitere Informationen dazu finden Sie im Abschnitt “Peripherien”.
Verwendung eines Peripheral Access Crate (PAC)
Bei der Entwicklung von Embedded-Software kommen wir nicht weit, wenn wir uns auf die grundlegenden Peripheriekomponenten beschränken, die in jedem Cortex-M enthalten sind. Irgendwann müssen wir Code schreiben, der speziell auf den verwendeten Mikrocontroller zugeschnitten ist. Gehen wir in diesem Beispiel davon aus, dass wir einen Texas Instruments TM4C123 verwenden – einen soliden 80-MHz-Cortex-M4 mit 256 KiB Flash-Speicher. Um diesen Chip nutzen zu können, binden wir das tm4c123x-Crate ein.
#![no_std]
#![no_main]
use panic_halt as _; // panic handler
use cortex_m_rt::entry;
use tm4c123x;
#[entry]
pub fn init() -> (Delay, Leds) {
let cp = cortex_m::Peripherals::take().unwrap();
let p = tm4c123x::Peripherals::take().unwrap();
let pwm = p.PWM0;
pwm.ctl.write(|w| w.globalsync0().clear_bit());
// Mode = 1 => Count up/down mode
pwm._2_ctl.write(|w| w.enable().set_bit().mode().set_bit());
pwm._2_gena.write(|w| w.actcmpau().zero().actcmpad().one());
// 528 cycles (264 up and down) = 4 loops per video line (2112 cycles)
pwm._2_load.write(|w| unsafe { w.load().bits(263) });
pwm._2_cmpa.write(|w| unsafe { w.compa().bits(64) });
pwm.enable.write(|w| w.pwm4en().set_bit());
}
Wir haben auf die PWM0-Peripherie auf genau dieselbe Weise zugegriffen wie zuvor auf die SYST-Peripherie, mit dem Unterschied, dass wir tm4c123x::Peripherals::take() aufgerufen haben. Da dieses Crate mithilfe von svd2rust automatisch generiert wurde, erwarten die Zugriffsfunktionen für unsere Registerfelder einen Closure anstelle eines numerischen Arguments. Auch wenn dies nach viel Code aussieht, kann der Rust-Compiler ihn nutzen, um eine Reihe von Prüfungen für uns durchzuführen und anschließend Maschinencode zu erzeugen, der handgeschriebenem Assemblercode sehr nahekommt! Wenn der automatisch generierte Code nicht feststellen kann, ob alle möglichen Argumente für eine bestimmte Zugriffsfunktion gültig sind (zum Beispiel, wenn die SVD das Register als 32-Bit-Register definiert, aber nicht angibt, ob bestimmte dieser 32-Bit-Werte eine besondere Bedeutung haben), wird die Funktion als unsafe markiert. Dies lässt sich im obigen Beispiel beim Setzen der Unterfelder load und compa mittels der Funktion bits() beobachten.
Lesen
Die Funktion read() gibt ein Objekt zurück, das schreibgeschützten Zugriff auf die verschiedenen Teilfelder dieses Registers gewährt – entsprechend der vom Hersteller für diesen Chip bereitgestellten SVD-Datei. Alle für den speziellen Rückgabetyp R dieses spezifischen Registers (innerhalb der jeweiligen Peripherieeinheit auf diesem Chip) verfügbaren Funktionen finden Sie in der tm4c123x-Dokumentation.
if pwm.ctl.read().globalsync0().is_set() {
// Tu etwas
}
Schreiben
Die Funktion write() erwartet einen Closure mit einem einzelnen Argument. Üblicherweise bezeichnen wir dieses als w. Dieses Argument gewährt Lese- und Schreibzugriff auf die verschiedenen Teilfelder innerhalb des Registers, wie sie in der SVD-Datei des Herstellers für diesen Chip definiert sind. Auch hier gilt: Alle für w verfügbaren Funktionen – spezifisch für dieses Register, diese Peripherieeinheit und diesen Chip – finden Sie in der tm4c123x-Dokumentation. Beachten Sie, dass alle Teilfelder, die wir nicht explizit setzen, automatisch auf einen Standardwert gesetzt werden; bereits vorhandene Inhalte des Registers gehen dabei verloren.
pwm.ctl.write(|w| w.globalsync0().clear_bit());Ändern
Wenn wir in diesem Register nur ein bestimmtes Teilfeld ändern und die übrigen Teilfelder unverändert lassen möchten, können wir die Funktion modify verwenden. Diese Funktion nimmt eine Closure mit zwei Argumenten entgegen – eines zum Lesen und eines zum Schreiben. Üblicherweise bezeichnen wir diese als r beziehungsweise w. Das Argument r dient dazu, den aktuellen Inhalt des Registers zu betrachten, während das Argument w genutzt werden kann, um den Registerinhalt zu ändern.
pwm.ctl.modify(|r, w| w.globalsync0().clear_bit());Die Funktion modify veranschaulicht hier eindrucksvoll die Leistungsfähigkeit von Closures. In C müssten wir den Wert zunächst in eine temporäre Variable einlesen, die entsprechenden Bits ändern und den Wert anschließend wieder zurückschreiben. Dies birgt ein erhebliches Fehlerpotenzial:
uint32_t temp = pwm0.ctl.read();
temp |= PWM0_CTL_GLOBALSYNC0;
pwm0.ctl.write(temp);
uint32_t temp2 = pwm0.enable.read();
temp2 |= PWM0_ENABLE_PWM4EN;
pwm0.enable.write(temp); // Uh oh! Wrong variable!
Verwendung eines HAL-Crates
Ein HAL-Crate für einen Chip funktioniert typischerweise, indem es ein spezifisches Trait für die vom PAC bereitgestellten Rohstrukturen implementiert. Oft definiert dieses Trait eine Funktion namens constrain() für einzelne Peripherieeinheiten oder split() für Komponenten wie GPIO-Ports mit mehreren Pins. Diese Funktion übernimmt die zugrundeliegende Rohstruktur der Peripherie und gibt ein neues Objekt mit einer höherwertigen API zurück. Diese API kann zudem sicherstellen, dass beispielsweise die new-Funktion für die serielle Schnittstelle eine Referenz auf eine Clock-Struktur verlangt; eine solche Struktur lässt sich nur durch den Aufruf der Funktion erzeugen, welche die PLLs konfiguriert und sämtliche Taktfrequenzen einstellt. Auf diese Weise ist es zur Kompilierzeit ausgeschlossen, ein Objekt für die serielle Schnittstelle zu erzeugen, ohne zuvor die Taktraten konfiguriert zu haben, oder dass das Objekt die Baudrate fehlerhaft in Taktzyklen umrechnet. Manche Crates definieren sogar spezielle Traits für die Zustände, die ein GPIO-Pin einnehmen kann; der Nutzer muss den Pin dann in den korrekten Zustand versetzen (etwa durch Auswahl des passenden Modus für alternative Funktionen), bevor er ihn an die Peripherieeinheit übergibt. Und das alles ohne Laufzeitkosten!
Schauen wir uns ein Beispiel an:
#![no_std]
#![no_main]
use panic_halt as _; // panic handler
use cortex_m_rt::entry;
use tm4c123x_hal as hal;
use tm4c123x_hal::prelude::*;
use tm4c123x_hal::serial::{NewlineMode, Serial};
use tm4c123x_hal::sysctl;
#[entry]
fn main() -> ! {
let p = hal::Peripherals::take().unwrap();
let cp = hal::CorePeripherals::take().unwrap();
// Kapseln Sie die SYSCTL-Struktur in einem Objekt mit einer API auf
// hoeherer Ebene.
let mut sc = p.SYSCTL.constrain();
// Waehlen Sie unsere Oszillationseinstellungen aus.
sc.clock_setup.oscillator = sysctl::Oscillator::Main(
sysctl::CrystalFrequency::_16mhz,
sysctl::SystemClock::UsePll(sysctl::PllOutputFrequency::_80_00mhz),
);
// Konfigurieren Sie die PLL mit diesen Einstellungen.
let clocks = sc.clock_setup.freeze();
// Kapseln Sie die `GPIO_PORTA`-Struktur in einem Objekt mit einer API der
// hoeheren Ebene.
// Beachten Sie, dass es `sc.power_control` nutzen muss, um die
// Stromversorgung der GPIO-Peripherie automatisch zu aktivieren.
let mut porta = p.GPIO_PORTA.split(&sc.power_control);
// Aktivieren Sie den UART.
let uart = Serial::uart0(
p.UART0,
// Der Sende-Pin
porta
.pa1
.into_af_push_pull::<hal::gpio::AF1>(&mut porta.control),
// Der Empfangs-Pin
porta
.pa0
.into_af_push_pull::<hal::gpio::AF1>(&mut porta.control),
// Kein RTS oder CTS erforderlich
(),
(),
// Die Baudrate
115200_u32.bps(),
// Ausgabeverarbeitung
NewlineMode::SwapLFtoCRLF,
// Wir benoetigen die Taktfrequenzen, um die Baudraten-Teiler zu
// berechnen.
&clocks,
// Wir benoetigen dies, um die UART-Peripherie zu aktivieren.
&sc.power_control,
);
loop {
writeln!(uart, "Hello, World!\r\n").unwrap();
}
}