Setup
Setup
1. Rust
Go to https://www.rust-lang.org/tools/install
💡 Windows users: prefer to install Rust natively, rather than using WSL.
2. Toolchain
To compile Rust code for out ESP32-C6 microcontroller, we need to install the appropriate toolchain.
rustup toolchain install stable --component rust-src --target riscv32imac-unknown-none-elf
3. Tools
Additionally, we will want tools to help us generate projects and program the firmware.
cargo install cargo-espflash espflash esp-generate
Hardware information
Board pinout
Documents
Links
Hello World
Create the project
Let's create our first embedded Rust project. For this, we will be using esp-generate
tool installed earlier.
To begin with, run the following command:
esp-generate --headless --chip=esp32c6 -o log -o unstable-hal hello_world
This will create a new project in hello_world
directory. Go ahead and open it with: code hello_world
.
💡 You can run
esp-generate --chip=esp32c6
to open the tool in interactive mode and explore the available options.
Run
With the ESP board plugged into your computer, you should be able to run the firmware with:
cargo run
You may have to select the appropriate device. If this is the case, the tool will prompt for it.
If everything goes well, your device will boot and start printing the "hello world" message:
INFO - Hello world!
INFO - Hello world!
INFO - Hello world!
INFO - Hello world!
INFO - Hello world!
Experiment
Experiment with this example a little bit.
- Change the message to print something else.
- Change the log level (e.g. to
warn
). - Change the printing frequency by adjusting the sleep duration.
Bonus
The way the delay is currently written is a bit verbose.
We can simplify the code by using Delay
from the esp_hal
crate.
- Add the following line next to the existing
use
declarations:#![allow(unused)] fn main() { use esp_hal::delay::Delay; }
- Create a delay variable:
#![allow(unused)] fn main() { let delay = Delay::new(); }
- Replace the two lines using
delay_start
with the following one line:#![allow(unused)] fn main() { delay.delay_millis(1000); }
Blinky
We have created a typical "hello world" application, but in embedded systems, the equivalent of "hello world" is blinky: simply blinking an LED.
We will continue working in the hello_world
project.
Change the program
In order to blink an LED, we need to define an output pin and make a couple of other changes.
Firstly, let's rename the _peripherals
variable to peripherals
.
💡 In Rust, a variable can be prefixed with an underscore (
_
) to indicate that it is unused. Since we'll be using it, let's remove the leading underscore.
Next, we will define our LED:
#![allow(unused)] fn main() { let mut led = Output::new( peripherals.GPIO19, Level::Low, OutputConfig::default().with_drive_mode(DriveMode::OpenDrain), ); }
The code above will not compile as is, since the compiler won't know where Output
and other types are coming from. To fix that, import the required types like so:
#![allow(unused)] fn main() { use esp_hal::gpio::{DriveMode, Level, Output, OutputConfig}; }
💡 Whenever a you have a missing declaration error, you can use
rust-analyzer
to help you fix it.Place the cursor on the missing type name and press
Ctrl
+.
on the keyboard. In the context menu, selectImport <Type>
orQualify <Type>
."Import" will add the required
use
declaration, while "Qualify" will prefix the module path in-place.
Finally, add the following line to the body of the loop
to toggle the LED every cycle:
#![allow(unused)] fn main() { led.toggle(); }
Experiment
Output
has other methods than toggle
. Try using set_high
and set_low
methods to change the LED blinking pattern.
Working with inputs
We have worked through an example of driving a GPIO output. Now it's time for you to try and extend the program to read the GPIO input state.
- Use the
esp_hal
crate documentation to create and initialize a GPIOInput
. - The button is connected to
GPIO9
. - Write a program that turns the LED on only while the button is pressed.
Solution
- Add new imports:
#![allow(unused)] fn main() { use esp_hal::gpio::{Input, InputConfig}; }
- Declare the button:
#![allow(unused)] fn main() { let button = Input::new(peripherals.GPIO9, InputConfig::default()); }
- Modify the loop code:
#![allow(unused)] fn main() { if button.is_high() { led.set_high(); } else { led.set_low(); } }
Explanation
You may be tempted to think that high
means ON and low
means off, however, the logic of both the button and the LED
is inverted.
Button has a pull-up resistor and connects the pin to ground when pressed.
The LED has its anode connected to the 3.3 V supply and the cathode is connected to the open-drain output. When the output is set to low, it is connected to ground, allowing the current to flow and thereby turning the LED on.
Interrupts
In the last exercise, we learnt how to read to read a GPIO pin state. However, it's not always desirable to poll the pin state in software. This is where interrupts come it. We can ask hardware to notify us whenever the pin state changes.
Background
Because interrupts can happen at any time during the execution of our program, we can think of them as if they're running on a separate thread. As such, when working with interrupts, we apply the same principles as when working in a multi-threaded environment.
Additionally, because the interrupt routine is called by hardware, there is no way to pass parameters or return values from the interrupt handler.
One common way to pass data between the application and interrupt contexts is using global state. This is mostly straightforward in C, as you can just read and write any global variable at any time. Whether you like it or not, this "feature" opens up questions about what happens when the variable is modified while some other code is reading it?
Consider this code:
volatile int counter = 0;
int main() {
while (true) {
app_task();
++counter; // Same as: counter = counter + 1
}
}
void timer_interrupt() {
interrupt_task(counter);
counter = 0;
}
In Rust, however, using a mutable global state is explicitly unsafe
. While it's possible to bypass
the compiler rules, the language strongly encourages developers to write correct code.
Atomics & Mutexes
When working with primitive types we can use one of the types provided in core::sync::atomic
.
Atomic types are the most fundamental way to ensure exclusive access -- these opperations are provided by the CPU.
For more complex types, we have to resort to higher level synchronization primitives, such as Mutexes and
other types of locks. Note that Mutex
is only available in the std
library, as is requires operating system support.
No worries though, for embedded systems we can use Mutex provided by the critical_section
crate.