Design Contracts

In our last chapter, we wrote an interface that didn't enforce design contracts. Let's take another look at our imaginary GPIO configuration register:

NameBit Number(s)ValueMeaningNotes
enable00disabledDisables the GPIO
1enabledEnables the GPIO
direction10inputSets the direction to Input
1outputSets the direction to Output
input_mode2..300hi-zSets the input as high resistance
01pull-lowInput pin is pulled low
10pull-highInput pin is pulled high
11n/aInvalid state. Do not set
output_mode40set-lowOutput pin is driven low
1set-highOutput pin is driven high
input_status5xin-val0 if input is < 1.5v, 1 if input >= 1.5v

If we instead checked the state before making use of the underlying hardware, enforcing our design contracts at runtime, we might write code that looks like this instead:

  1. /// GPIO interface
  2. struct GpioConfig {
  3. /// GPIO Configuration structure generated by svd2rust
  4. periph: GPIO_CONFIG,
  5. }
  6. impl GpioConfig {
  7. pub fn set_enable(&mut self, is_enabled: bool) {
  8. self.periph.modify(|_r, w| {
  9. w.enable().set_bit(is_enabled)
  10. });
  11. }
  12. pub fn set_direction(&mut self, is_output: bool) -> Result<(), ()> {
  13. if self.periph.read().enable().bit_is_clear() {
  14. // Must be enabled to set direction
  15. return Err(());
  16. }
  17. self.periph.modify(|r, w| {
  18. w.direction().set_bit(is_output)
  19. });
  20. Ok(())
  21. }
  22. pub fn set_input_mode(&mut self, variant: InputMode) -> Result<(), ()> {
  23. if self.periph.read().enable().bit_is_clear() {
  24. // Must be enabled to set input mode
  25. return Err(());
  26. }
  27. if self.periph.read().direction().bit_is_set() {
  28. // Direction must be input
  29. return Err(());
  30. }
  31. self.periph.modify(|_r, w| {
  32. w.input_mode().variant(variant)
  33. });
  34. Ok(())
  35. }
  36. pub fn set_output_status(&mut self, is_high: bool) -> Result<(), ()> {
  37. if self.periph.read().enable().bit_is_clear() {
  38. // Must be enabled to set output status
  39. return Err(());
  40. }
  41. if self.periph.read().direction().bit_is_clear() {
  42. // Direction must be output
  43. return Err(());
  44. }
  45. self.periph.modify(|_r, w| {
  46. w.output_mode.set_bit(is_high)
  47. });
  48. Ok(())
  49. }
  50. pub fn get_input_status(&self) -> Result<bool, ()> {
  51. if self.periph.read().enable().bit_is_clear() {
  52. // Must be enabled to get status
  53. return Err(());
  54. }
  55. if self.periph.read().direction().bit_is_set() {
  56. // Direction must be input
  57. return Err(());
  58. }
  59. Ok(self.periph.read().input_status().bit_is_set())
  60. }
  61. }

Because we need to enforce the restrictions on the hardware, we end up doing a lot of runtime checking which wastes time and resources, and this code will be much less pleasant for the developer to use.

Type States

But what if instead, we used Rust's type system to enforce the state transition rules? Take this example:

  1. /// GPIO interface
  2. struct GpioConfig<ENABLED, DIRECTION, MODE> {
  3. /// GPIO Configuration structure generated by svd2rust
  4. periph: GPIO_CONFIG,
  5. enabled: ENABLED,
  6. direction: DIRECTION,
  7. mode: MODE,
  8. }
  9. // Type states for MODE in GpioConfig
  10. struct Disabled;
  11. struct Enabled;
  12. struct Output;
  13. struct Input;
  14. struct PulledLow;
  15. struct PulledHigh;
  16. struct HighZ;
  17. struct DontCare;
  18. /// These functions may be used on any GPIO Pin
  19. impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
  20. pub fn into_disabled(self) -> GpioConfig<Disabled, DontCare, DontCare> {
  21. self.periph.modify(|_r, w| w.enable.disabled());
  22. GpioConfig {
  23. periph: self.periph,
  24. enabled: Disabled,
  25. direction: DontCare,
  26. mode: DontCare,
  27. }
  28. }
  29. pub fn into_enabled_input(self) -> GpioConfig<Enabled, Input, HighZ> {
  30. self.periph.modify(|_r, w| {
  31. w.enable.enabled()
  32. .direction.input()
  33. .input_mode.high_z()
  34. });
  35. GpioConfig {
  36. periph: self.periph,
  37. enabled: Enabled,
  38. direction: Input,
  39. mode: HighZ,
  40. }
  41. }
  42. pub fn into_enabled_output(self) -> GpioConfig<Enabled, Output, DontCare> {
  43. self.periph.modify(|_r, w| {
  44. w.enable.enabled()
  45. .direction.output()
  46. .input_mode.set_high()
  47. });
  48. GpioConfig {
  49. periph: self.periph,
  50. enabled: Enabled,
  51. direction: Output,
  52. mode: DontCare,
  53. }
  54. }
  55. }
  56. /// This function may be used on an Output Pin
  57. impl GpioConfig<Enabled, Output, DontCare> {
  58. pub fn set_bit(&mut self, set_high: bool) {
  59. self.periph.modify(|_r, w| w.output_mode.set_bit(set_high));
  60. }
  61. }
  62. /// These methods may be used on any enabled input GPIO
  63. impl<IN_MODE> GpioConfig<Enabled, Input, IN_MODE> {
  64. pub fn bit_is_set(&self) -> bool {
  65. self.periph.read().input_status.bit_is_set()
  66. }
  67. pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
  68. self.periph.modify(|_r, w| w.input_mode().high_z());
  69. GpioConfig {
  70. periph: self.periph,
  71. enabled: Enabled,
  72. direction: Input,
  73. mode: HighZ,
  74. }
  75. }
  76. pub fn into_input_pull_down(self) -> GpioConfig<Enabled, Input, PulledLow> {
  77. self.periph.modify(|_r, w| w.input_mode().pull_low());
  78. GpioConfig {
  79. periph: self.periph,
  80. enabled: Enabled,
  81. direction: Input,
  82. mode: PulledLow,
  83. }
  84. }
  85. pub fn into_input_pull_up(self) -> GpioConfig<Enabled, Input, PulledHigh> {
  86. self.periph.modify(|_r, w| w.input_mode().pull_high());
  87. GpioConfig {
  88. periph: self.periph,
  89. enabled: Enabled,
  90. direction: Input,
  91. mode: PulledHigh,
  92. }
  93. }
  94. }

Now let's see what the code using this would look like:

  1. /*
  2. * Example 1: Unconfigured to High-Z input
  3. */
  4. let pin: GpioConfig<Disabled, _, _> = get_gpio();
  5. // Can't do this, pin isn't enabled!
  6. // pin.into_input_pull_down();
  7. // Now turn the pin from unconfigured to a high-z input
  8. let input_pin = pin.into_enabled_input();
  9. // Read from the pin
  10. let pin_state = input_pin.bit_is_set();
  11. // Can't do this, input pins don't have this interface!
  12. // input_pin.set_bit(true);
  13. /*
  14. * Example 2: High-Z input to Pulled Low input
  15. */
  16. let pulled_low = input_pin.into_input_pull_down();
  17. let pin_state = pulled_low.bit_is_set();
  18. /*
  19. * Example 3: Pulled Low input to Output, set high
  20. */
  21. let output_pin = pulled_low.into_enabled_output();
  22. output_pin.set_bit(false);
  23. // Can't do this, output pins don't have this interface!
  24. // output_pin.into_input_pull_down();

This is definitely a convenient way to store the state of the pin, but why do it this way? Why is this better than storing the state as an enum inside of our GpioConfig structure?

Compile Time Functional Safety

Because we are enforcing our design constraints entirely at compile time, this incurs no runtime cost. It is impossible to set an output mode when you have a pin in an input mode. Instead, you must walk through the states by converting it to an output pin, and then setting the output mode. Because of this, there is no runtime penalty due to checking the current state before executing a function.

Also, because these states are enforced by the type system, there is no longer room for errors by consumers of this interface. If they try to perform an illegal state transition, the code will not compile!