DEV Community

Cover image for Embedded Rust & Embassy: Analog Sensing with ADCs
Omar Hiari
Omar Hiari

Posted on • Edited on

Embedded Rust & Embassy: Analog Sensing with ADCs

This blog post is the fourth of a multi-part series of posts Rust embassy. This post is going to explore reading Analog values using the embassy HAL. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.

If you find this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Introduction

Apart from a few unclarities here and there, working with embassy thus far has been a joy. What I've been doing thus far is rewriting past posts in embassy to compare. Given the lesser amount of needed code, I feel that embassy is on a path to becoming the framework of choice for teaching embedded Rust. Not only because of less verbosity but also because the function interfaces are more readable. However, at least from an stm32 context, there is still some work that needs to be done at least on the documentation side to make things more accessible.

In this post, I will recreate the analog sensor reading application I created with the stm32f4xx-hal. The post will be self-contained so there is no need to refer back to the past post unless one is interested in comparing. We'll see that setting up a simple ADC reading is fairly straightforward in embassy, but there are a few things that one needs to be aware of.

📚 Knowledge Pre-requisites

To understand the content of this post, you need the following:

  • Basic knowledge of coding in Rust.

  • Familiarity with the basic template for creating embedded applications in Rust.

  • Familiarity with UART communication basics.

  • Familiarity with the working principles of NTC Thermistors. This page is a good resource.

💾 Software Setup

All the code presented in this post in addition to instructions for the environment and toolchain setup is available on the apollolabsdev Nucleo-F401RE git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.

In addition to the above, you would need to install some sort of serial communication terminal on your host PC. Some recommendations include:

For Windows:

For Mac and Linux:

Apart from Serial Studio, some detailed instructions for the different operating systems are available in the Discovery Book.

For me, Serial Studio comes highly recommended. I personally came across Serial Studio recently and found it to be awesome for two main reasons. First is that you can skip many of those instructions for other tools, especially in Mac and Linux systems. Second, if you are you want to graph data over UART, it has a really nice and easy-to-configure setup. It's also open-source and free to use.

🛠 Hardware Setup

👔 Materials

Nucleo

Base Shield

Temp Sensor

🚨 Important Note:

I used the Grove modular system for connection ease. It is a more elegant approach and less prone to mistakes. To directly wire the NTC temperature sensor to the board, one would need to build a circuit similar to the one shown in this schematic.

🔌 Connections

  • Temperature sensor signal pin connected to pin PA0 (Grove Connector A0).

  • The UART Tx line that connects to the PC through the onboard USB bridge is via pin PA2 on the microcontroller. This is a hardwired pin, meaning you cannot use any other for this setup. Unless you are using a different board other than the Nucleo-F401RE, you have to check the relevant documentation (reference manual or datasheet) to determine the number of the pin.

🔬 Circuit Analysis

The temperature sensor used has a single-pin interface called "signal" that provides a voltage output. The temperature sensor is also a negative temperature coefficient (NTC) sensor. This means the resistance of the sensor increases as the temperature increases. The following figure shows the schematic of the temperature sensor circuit for the grove module utilized.

NTC Schematic

It is shown that the NCP18WF104F03RC NTC Thermistor is connected in a voltage divider configuration with a 100k resistor. The Op-Amp only acts as a voltage follower (or buffer). As such, the voltage at the positive terminal of the op-amp V+V_{+} is equal to the voltage on the signal terminal and expressed as:

V+=VccR1R1+RNTC V_{\text{+}} = V_{cc} * \frac{R_{1}}{R_{1} + R_{\text{NTC}}}

Where R1=100kΩR_1 = 100k\Omega and the resistance value of RNTCR_{\text{NTC}} is the one that needs to be calculated to obtain the temperature. This means that later in the code, I would need to retrieve back the value of RNTCR_{\text{NTC}} from the V+V_{\text{+}} value that is being read by the ADC. With some algebraic manipulation we can move all the known variables to the right hand side of the equation to reach the following expression:

RNTC=(VccV+1)R1 R_{\text{NTC}} = \left( \frac{ V_{cc} }{ V_{\text{+}} } -1 \right) * R_{1}

After extracting the value of RNTCR_{\text{NTC}} , I would need to determine the temperature. Following the equations in the datasheet, I leverage the Steinhart-Hart NTC equation that is presented as follows:

β=ln(RNTCR0)(1T1T0) \beta = \frac{ln(\frac{R_{\text{NTC}}}{R_0})}{(\frac{1}{T}-\frac{1}{T_0})}

where β\beta is a constant and equal to 4275 for our NTC as stated by the datasheet and TT is the temperature we are measuring. T0T_0 and R0R_0 refer to the ambient temperature (typically 25 Celcius) and resistance at ambient temperature, respectively. For the Grove module used, again from the datasheet, the value of the resistance at 25 Celcius ( T0T_0 ) is equal to 100kΩ100k\Omega ( R0R_0 ). With more algebraic manipulation we solve for TT to get:

T=11βln(RNTCR0)+1T0 T = \frac{1}{\frac{1}{\beta} * ln(\frac{R_{\text{NTC}}}{R_0}) +\frac{1}{T_0}}

👨‍🎨 Software Design

Now that we know the equations from the prior section, an algorithm needs to be developed and is quite straightforward in this case. After configuring the device (including ADC and UART peripherals), the algorithmic steps are as follows:

  1. Kick off the ADC and obtain a reading/sample.

  2. Calculate the temperature in Celcius.

  3. Send the temperature value over UART.

  4. Go back to step 1.

👨‍💻 Code Implementation

📥 Crate Imports

In this implementation, the following crates are required:

  • The cortex_m_rt crate for startup code and minimal runtime for Cortex-M microcontrollers.

  • The libm::log crate that is a math crate that will allow me to calculate the natural logarithm.

  • The heapless::String crate to create a fixed capacity String.

  • The core::fmt crate will allow us to use the writeln! macro for print formatting.

  • The panic_halt crate to define the panicking behavior to halt on panic.

  • The embassy_stm32 crate to import the embassy STM32 series microcontroller device hardware abstractions. The needed abstractions are imported accordingly.

  • The embassy_time crate to import timekeeping capabilities.

use core::fmt::Write;
use heapless::String;
use libm::log;
use cortex_m_rt::entry;
use embassy_stm32::adc::Adc;
use embassy_stm32::dma::NoDma;
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::Delay;
use panic_halt as _;
Enter fullscreen mode Exit fullscreen mode

🎛 Peripheral Configuration Code

ADC Peripheral Configuration

1️⃣ Initialize MCU and obtain a handle for the device peripherals: A device peripheral handler p is created:

let p = embassy_stm32::init(Default::default());
Enter fullscreen mode Exit fullscreen mode

2️⃣ Configure ADC and obtain handle: ADCs in microcontrollers typically have many configuration options. At the time of writing this post, the implementation and documentation of ADC at the embassy HAL level are a bit limited. Things that I've noticed missing from an ADC embassy HAL perspective include the following:

  • There isn't any interrupt or async support.

  • DMA support seems to be missing as well.

  • There is unclarity in some documentation aspects (for example is delay parameter described in new method).

  • Not all device configuration options are adjustable.

  • To find out the default configuration of the ADC peripheral one needs to navigate the source.

The ADC peripheral configuration is actually quite simple. The driver struct contains only a few methods. In the documentation, there is a new method as part of the Adc struct abstraction to configure an ADC peripheral so that we can obtain a handle. new has the following signature:

pub fn new(
    _peri: impl Peripheral<P = T> + 'd,
    delay: &mut impl DelayUs<u32>
) -> Self
Enter fullscreen mode Exit fullscreen mode

new takes two parameters where peri expects an argument passing in an ADC peripheral instance and delay that expects a Delay abstraction. Unfortunately, the documentation doesn't really explain the point behind the delay parameter. For now, it does not seem to serve any useful purpose. Following the above, an adc handle is created as follows:

let mut delay = Delay;
let mut adc = Adc::new(p.ADC1, &mut delay);
Enter fullscreen mode Exit fullscreen mode

Two questions remain here though, first, where is the pin that we will be reading from defined? and second, what is the configuration?

Regarding the pin, it turns out that it will be passed as a parameter into the read method later. The read method is the one that triggers a conversion.

Regarding the configuration, one needs to navigate the source code of the documentation. From what I've found, the default VrefV_{\textit{ref}} is 3.3V, the default resolution is 12 bits, and the default sample time setting is 3 clock cycles. It seems also, although not clear in the documentation, that in the current state the ADC supports only one-shot conversion. That would probably make sense anyway since DMA is still not supported either. For understanding in detail what each configuration parameter means, detail is provided in the stm32f401re reference manual.

UART Peripheral Configuration

1️⃣ Configure UART and obtain handle: On the Nucleo-F401RE board pinout, the Tx line pin PA2 connects to the USART2 peripheral in the microcontroller device. Similar to what was done in the embassy UART post, an instance of USART2 is attached to the usart handle as follows:

let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());
Enter fullscreen mode Exit fullscreen mode

Also, similar to before a String type msg handle is created to store the formatted text that will be transmitted over UART:

let mut msg: String<64> = String::new();
Enter fullscreen mode Exit fullscreen mode

This concludes the configuration aspect of the code.

📱Application Code

Following the design described earlier, before entering the loop, I first need to set up a couple of static values that I will be using in the conversion calculations. This includes keying in the constant values for β\beta and R0R_0 as follows:

    static R0: f64 = 100000.0;
    static B: f64 = 4275.0; // B value of the thermistor
Enter fullscreen mode Exit fullscreen mode

After entering the program loop, as the software design stated earlier, the first thing I need to do is kick off the ADC to obtain a sample. In the documentation, I found a read method with the following signature:

pub fn read<P>(&mut self, pin: &mut P) -> u16
where
    P: AdcPin<T>,
    P: Pin,
Enter fullscreen mode Exit fullscreen mode

As shown, we need to pass a reference to pin which will be the actual pin instance that will connect to the sensor output.

Then an ADC sample is obtained as follows.

 let sample = adc.read(&mut p.PA0);
Enter fullscreen mode Exit fullscreen mode

Next, I convert the sample value to a temperature by implementing the earlier derived equations as follows:

let mut r: f64 = 4094.0 / sample as f64 - 1.0;
r = R0 * r;
let temperature = (1.0 / (log(r / R0) / B + 1.0 / 298.15)) - 273.15;
Enter fullscreen mode Exit fullscreen mode

A few things to note here; first I don't convert the collected sample to value to a voltage as in the first calculation the voltage calculation is a ratio. This means I keep the sample in LSBs and use the equivalent LSB value for VccV_{cc} . To plug in VccV_{cc} I simply calculate the maximum possible LSB value (upper reference) that can be generated by the ADC. This is why I needed to know the resolution, which was 12 because Vcc=212LSBsV_{cc} = 2^{12} LSBs . Second, recall from the convert signature that sample is a u16, so I had to use as f64 to cast it as an f64 for the calculation. Third, log is the natural logarithm and obtained from the libm library that I imported earlier. Fourth, and last, the temperature is calculated in Kelvins, the 273.15 is what converts it to Celcius.

Finally, now that the temperature is available, similar to the embassy UART example, a message is prepared and sent over UART as follows:

// Format Message
core::writeln!(&mut msg, "Temperature {:02} Celcius\r", temperature).unwrap();

// Transmit Message
usart.blocking_write(msg.as_bytes()).unwrap();

// Clear String for next message
msg.clear();
Enter fullscreen mode Exit fullscreen mode

This is it!

📀 Full Application Code

Here is the full code for the implementation described in this post. You can additionally find the full project and others available on the apollolabsdev Nucleo-F401RE git repo.

#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use core::fmt::Write;

use heapless::String;

use libm::log;

use cortex_m_rt::entry;
use embassy_stm32::adc::Adc;
use embassy_stm32::dma::NoDma;
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::Delay;
use panic_halt as _;

#[entry]
fn main() -> ! {
    // Initialize and create handle for devicer peripherals
    let mut p = embassy_stm32::init(Default::default());

    // ADC Configuration
    let mut delay = Delay;
    // Create Handler for adc peripheral (PA0 is connected to ADC1)
    let mut adc = Adc::new(p.ADC1, &mut delay);

    //Configure UART
    let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());

    // Create empty String for message
    let mut msg: String<64> = String::new();

    static R0: f64 = 100000.0;
    static B: f64 = 4275.0; // B value of the thermistor

    // Algorithm
    // 1) Get adc reading
    // 2) Convert to temperature
    // 3) Send over Serial
    // 4) Go Back to step 1

    // Application Loop
    loop {
        // Get ADC reading
        let sample = adc.read(&mut p.PA0);

        //Convert to temperature
        let mut r: f64 = 4094.0 / sample as f64 - 1.0;
        r = R0 * r;
        let temperature = (1.0 / (log(r / R0) / B + 1.0 / 298.15)) - 273.15;

        // Format Message
        core::writeln!(&mut msg, "Temperature {:02} Celcius\r", temperature).unwrap();

        // Transmit Message
        usart.blocking_write(msg.as_bytes()).unwrap();

        // Clear String for next message
        msg.clear();
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this post, an analog temperature measurement application was created leveraging the ADC peripheral using Rust on the Nucleo-F401RE development board. The resulting measurement is also sent over to a host PC over a UART connection. All code was created leveraging the embassy framework for STM32. As things stand right now, the STM32 embassy HAL can only provide a simple implementation of ADCs and is behind on several features. At a minimum, interrupts do not seem to be supported yet. Have any questions? Share your thoughts in the comments below 👇.

If you found this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Top comments (0)