Design-Verträge
Im letzten Kapitel haben wir eine Schnittstelle geschrieben, die keine Design-Verträge (Design Contracts) durchsetzte. Werfen wir noch einmal einen Blick auf unser fiktives GPIO-Konfigurationsregister:
| Name | Bit-Nummer | Wert | Bedeutung | Hinweise |
|---|---|---|---|---|
| enable | 0 | 0 | disabled | Deaktiviert den GPIO |
| 1 | enabled | Aktiviert den GPIO | ||
| direction | 1 | 0 | input | Legt die Richtung auf „Eingang“ fest. |
| 1 | output | Legt die Richtung auf „Ausgang” fest. | ||
| input_mode | 2..3 | 00 | hi-z | Setzt den Eingang auf hochohmig. |
| 01 | pull-low | Der Eingangspin wird auf Low-Pegel gezogen. | ||
| 10 | pull-high | Der Eingangspin wird auf High-Pegel gezogen | ||
| 11 | n/a | Ungültiger Zustand. Nicht setzen. | ||
| output_mode | 4 | 0 | set-low | Der Ausgangspin wird auf Low-Pegel gesteuert. |
| 1 | set-high | Der Ausgangspin wird auf High-Pegel gesteuert. | ||
| input_status | 5 | x | in-val | 0, wenn der Eingang < 1,5 V ist; 1, wenn der Eingang ≥ 1,5 V ist. |
Wenn wir stattdessen den Zustand überprüfen würden, bevor wir die zugrundeliegende Hardware nutzen, und dabei unsere Entwurfsverträge zur Laufzeit durchsetzen würden, könnten wir stattdessen Code schreiben, der wie folgt aussieht:
/// GPIO-Schnittstelle
struct GpioConfig {
/// Von svd2rust generierte GPIO-Konfigurationsstruktur
periph: GPIO_CONFIG,
}
impl GpioConfig {
pub fn set_enable(&mut self, is_enabled: bool) {
self.periph.modify(|_r, w| {
w.enable().set_bit(is_enabled)
});
}
pub fn set_direction(&mut self, is_output: bool) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// Muss aktiviert sein, um die Richtung festzulegen.
return Err(());
}
self.periph.modify(|r, w| {
w.direction().set_bit(is_output)
});
Ok(())
}
pub fn set_input_mode(&mut self, variant: InputMode) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// Muss aktiviert sein, um den Eingabemodus festzulegen.
return Err(());
}
if self.periph.read().direction().bit_is_set() {
// Die Richtung muss input sein.
return Err(());
}
self.periph.modify(|_r, w| {
w.input_mode().variant(variant)
});
Ok(())
}
pub fn set_output_status(&mut self, is_high: bool) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// Muss aktiviert sein, um den Ausgangsstatus festzulegen.
return Err(());
}
if self.periph.read().direction().bit_is_clear() {
// Die Richtung muss output sein.
return Err(());
}
self.periph.modify(|_r, w| {
w.output_mode.set_bit(is_high)
});
Ok(())
}
pub fn get_input_status(&self) -> Result<bool, ()> {
if self.periph.read().enable().bit_is_clear() {
// Muss aktiviert sein, um den Status abzurufen.
return Err(());
}
if self.periph.read().direction().bit_is_set() {
// Die Richtung muss input sein.
return Err(());
}
Ok(self.periph.read().input_status().bit_is_set())
}
}
Da wir die Hardware-Beschränkungen durchsetzen müssen, führen wir letztlich zahlreiche Laufzeitüberprüfungen durch – was Zeit und Ressourcen verschwendet –, und der Code ist für Entwickler deutlich weniger angenehm zu handhaben.
Typzustände
Was aber, wenn wir stattdessen das Typsystem von Rust nutzen würden, um die Regeln für Zustandsübergänge durchzusetzen? Betrachten wir dieses Beispiel:
/// GPIO-Schnittstelle
struct GpioConfig<ENABLED, DIRECTION, MODE> {
/// Von svd2rust generierte GPIO-Konfigurationsstruktur
periph: GPIO_CONFIG,
enabled: ENABLED,
direction: DIRECTION,
mode: MODE,
}
// Typzustaende fuer MODE in GpioConfig
struct Disabled;
struct Enabled;
struct Output;
struct Input;
struct PulledLow;
struct PulledHigh;
struct HighZ;
struct DontCare;
/// Diese Funktionen koennen an jedem GPIO-Pin verwendet werden.
impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
pub fn into_disabled(self) -> GpioConfig<Disabled, DontCare, DontCare> {
self.periph.modify(|_r, w| w.enable.disabled());
GpioConfig {
periph: self.periph,
enabled: Disabled,
direction: DontCare,
mode: DontCare,
}
}
pub fn into_enabled_input(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| {
w.enable.enabled()
.direction.input()
.input_mode.high_z()
});
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
pub fn into_enabled_output(self) -> GpioConfig<Enabled, Output, DontCare> {
self.periph.modify(|_r, w| {
w.enable.enabled()
.direction.output()
.input_mode.set_high()
});
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Output,
mode: DontCare,
}
}
}
/// Diese Funktion kann fuer einen Ausgangspin verwendet werden.
impl GpioConfig<Enabled, Output, DontCare> {
pub fn set_bit(&mut self, set_high: bool) {
self.periph.modify(|_r, w| w.output_mode.set_bit(set_high));
}
}
/// Diese Methoden koennen auf jedem aktivierten Eingangs-GPIO verwendet werden.
impl<IN_MODE> GpioConfig<Enabled, Input, IN_MODE> {
pub fn bit_is_set(&self) -> bool {
self.periph.read().input_status.bit_is_set()
}
pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| w.input_mode().high_z());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
pub fn into_input_pull_down(self) -> GpioConfig<Enabled, Input, PulledLow> {
self.periph.modify(|_r, w| w.input_mode().pull_low());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: PulledLow,
}
}
pub fn into_input_pull_up(self) -> GpioConfig<Enabled, Input, PulledHigh> {
self.periph.modify(|_r, w| w.input_mode().pull_high());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: PulledHigh,
}
}
}
Schauen wir uns nun an, wie der Code aussehen würde, der dies verwendet:
/*
* Beispiel 1: Nicht konfiguriert als High-Z-Eingang
*/
let pin: GpioConfig<Disabled, _, _> = get_gpio();
// Das ist nicht moeglich, die PIN ist nicht aktiviert!
// pin.into_input_pull_down();
// Schalten Sie den Pin nun von „unkonfiguriert“ auf einen hochohmigen Eingang
// (High-Z-Eingang) um.
let input_pin = pin.into_enabled_input();
// Vom Pin lesen
let pin_state = input_pin.bit_is_set();
// Das ist nicht moeglich; Eingangs-Pins verfuegen nicht über diese
// Schnittstelle!
// input_pin.set_bit(true);
/*
* Beispiel 2: Hochohmiger Eingang (High-Z) an auf Low-Pegel gezogenen Eingang
*/
let pulled_low = input_pin.into_input_pull_down();
let pin_state = pulled_low.bit_is_set();
/*
* Beispiel 3: Eingang auf Low gezogen, Ausgang auf High gesetzt
*/
let output_pin = pulled_low.into_enabled_output();
output_pin.set_bit(true);
// Das geht nicht, Ausgangspins verfuegen nicht über diese Schnittstelle!
// output_pin.into_input_pull_down();
Dies ist zweifellos eine praktische Möglichkeit, den Zustand des Pins zu speichern, aber warum sollte man es auf diese Weise tun? Warum ist das besser, als den Zustand als enum innerhalb unserer GpioConfig-Struktur zu speichern?
Funktionale Sicherheit zur Kompilierzeit
Da wir unsere Designvorgaben vollständig zur Kompilierzeit durchsetzen, entstehen keinerlei Laufzeitkosten. Es ist unmöglich, einen Ausgabemodus einzustellen, solange sich ein Pin im Eingabemodus befindet. Stattdessen muss man die Zustände durchlaufen, indem man den Pin zunächst in einen Ausgangspin umwandelt und anschließend den Ausgabemodus festlegt. Dadurch entfällt der Laufzeitaufwand für die Überprüfung des aktuellen Zustands vor der Ausführung einer Funktion.
Da diese Zustände zudem durch das Typsystem erzwungen werden, sind Fehler bei der Verwendung dieser Schnittstelle ausgeschlossen. Versucht der Nutzer einen unzulässigen Zustandsübergang, lässt sich der Code nicht kompilieren!