DEV Community

Cover image for ESP32 Embedded Rust at the HAL: I2C Scanner
Omar Hiari
Omar Hiari

Posted on

ESP32 Embedded Rust at the HAL: I2C Scanner

This blog post is the eleventh of a multi-part series of posts where I explore various peripherals in the ESP32C3 using embedded Rust at the HAL level. 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 by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Introduction

I2C is a popular serial communication protocol in embedded systems. Another common name used is also Two-Wire Interface (TWI), since, well, it is. I2C is commonly found in sensors and interface boards as it offers a compact implementation with decent speeds reaching Mbps. Counter to UART, I2C allows multiple devices (up to 127) to tap into the same bus. As a result, it becomes often useful if there is a way to scan for devices that are connected.

In this post, I will be configuring and setting up I2C communication for the ESP32C3 to scan the bus for devices. The code would detect if there is a device connected to a particular address. The retrieved results will also be printed on the console.

📚 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 I2C communication basics.

💾 Software Setup

All the code presented in this post is available on the apollolabs ESP32C3 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.

Additionally, the full project (code and simulation) is available on Wokwi here.

🛠 Hardware Setup

Materials

🔌 Connections

Connections include the following:

  • Gpio2 wired to the SCL pin of all I2C devices.

  • Gpio3 wired to the SDA pin of all I2C devices.

  • Power and Gnd wired to all I2C devices on the bus.

👨‍🎨 Software Design

I2C communication involves a master device initiating communication with a slave device through a start condition, followed by sending the slave's address and a read/write bit. The slave responds with an acknowledgment, and data transfer occurs with subsequent acknowledgment or non-acknowledgment after each byte. Finally, the communication is concluded with a stop condition. Furthermore, the address field of an I2C frame is 7-bits wide which supports up to 127 devices on a connected single bus.

Note how after a slave address is sent, a slave device has to respond with an acknowledgment. If a slave does not respond, it means that it does not exist on the bus. As such, to create an I2C scanner the following steps need to be taken:

  1. Initialize address to 0.

  2. Send a read frame to address

  3. If ack received, record that device detected at address , otherwise, record that no device is connected.

  4. Increment address .

  5. If address< 127 Go back to step 2. Otherwise, terminate the application.

👨‍💻 Code Implementation

📥 Crate Imports

In this implementation the crates required are as follows:

  • The esp32c3_hal crate to import the ESP32C3 device hardware abstractions.

  • The esp_backtrace crate to define the panicking behavior.

  • The esp_println crate to provide println! implementation.

use esp32c3_hal::{clock::ClockControl, 
    i2c::I2C, 
    peripherals::Peripherals, 
    prelude::*, 
    IO,
};
use esp_backtrace as _;
use esp_println::println;
Enter fullscreen mode Exit fullscreen mode

🎛 Peripheral Configuration Code

1️⃣ Obtain a handle for the device peripherals: In embedded Rust, as part of the singleton design pattern, we first have to take the PAC-level device peripherals. This is done using the take() method. Here I create a device peripheral handler named dp as follows:

let peripherals = Peripherals::take();
Enter fullscreen mode Exit fullscreen mode

2️⃣ Instantiate and obtain handle to system clocks: The system clocks need to be configured as they are needed in setting up the I2C peripheral. To set up the system clocks we need to first make the SYSTEM struct compatible with the HAL using the split method (more insight on this here). After that, the system clock control information is passed to boot_defaults in ClockControl. ClockControl controls the enablement of peripheral clocks providing necessary methods to enable and reset specific peripherals. Finally, the freeze() method is applied to freeze the clock configuration. Note that freezing the clocks is a protection mechanism by the HAL to avoid the clock configuration changing during runtime.

    let system = peripherals.SYSTEM.split();
    let clocks = ClockControl::boot_defaults(system.clock_control).freeze();
Enter fullscreen mode Exit fullscreen mode

3️⃣ Instantiate and Create Handle for IO: When creating an instance for I2C, we'll be required to pass it instances of the SDA and SCL pins. As such, we'll need to create an IO struct instance. The IO struct instance provides a HAL-designed struct that gives us access to all gpio pins thus enabling us to create handles/instances for individual pins. We do this like prior posts, by calling the new() instance method on the IO struct as follows:

let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);
Enter fullscreen mode Exit fullscreen mode

Note how the new method requires passing two parameters; the GPIO and IO_MUX peripherals.

4️⃣ Configure and Obtain Handle for the I2C peripheral: To create an instance of an I2C peripheral we need to use the new instance method in i2c::I2c in the esp32c3-hal:

pub fn new<SDA, SCL>(
    i2c: impl Peripheral<P = T> + 'd,
    sda: impl Peripheral<P = SDA> + 'd,
    scl: impl Peripheral<P = SCL> + 'd,
    frequency: Rate<u32, 1, 1>,
    clocks: &Clocks<'_>
) -> I2C<'d, T>
where
    SDA: OutputPin + InputPin,
    SCL: OutputPin + InputPin,
Enter fullscreen mode Exit fullscreen mode

The i2c parameter expects and instance of an i2c peripheral, the sda and scl parameters expect instances of pins configured as input/output, frequency is the desired bus frequency, and clocks is an instance of the system clocks. As such, I create an instance for the pulse handle as follows:

let mut i2c0 = I2C::new(
    peripherals.I2C0,
    io.pins.gpio3,
    io.pins.gpio2,
    100u32.kHz(),
    &clocks,
);
Enter fullscreen mode Exit fullscreen mode

I've chosen I2C0 wth gpio3 for SDA, gpio2 for SCL, and 100kHz for the bus frequency.

Off to the application!

📱 Application Code

In the application, we are going to run a scan once for all possible addresses from 1 to 127. As such, a for loop can be utilized. In each iteration, we're first going to print the address being scanned followed by doing a i2c read. The I2C read method takes two parameters, a u8 address and a &[u8] buffer to store the read data. Also, in our case, we don't really care about any data received.

read returns a Result, in which case, if the Result is Ok that means that a device was found. Otherwise, if the Result is Err, it means that an ACK was not received and a device was not found. The following is the application code:

// Start Scan at Address 1 going up to 127
for addr in 1..=127 {
    println!("Scanning Address {}", addr as u8);

    // Scan Address
    let res = i2c0.read(addr as u8, &mut [0]);

    // Check and Print Result
    match res {
        Ok(_) => println!("Device Found at Address {}", addr as u8),
        Err(_) => println!("No Device Found"),
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, since the main returns a !, then an empty loop is required.

loop{}
Enter fullscreen mode Exit fullscreen mode

📱 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 apollolabs ESP32C3 git repo. Also the Wokwi project can be accessed here.

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

use esp32c3_hal::{clock::ClockControl, i2c::I2C, peripherals::Peripherals, prelude::*, IO};
use esp_backtrace as _;
use esp_println::println;

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take();
    let system = peripherals.SYSTEM.split();
    let clocks = ClockControl::boot_defaults(system.clock_control).freeze();

    // Obtain handle for GPIO
    let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);

    // Initialize and configure I2C0
    let mut i2c0 = I2C::new(
        peripherals.I2C0,
        io.pins.gpio3,
        io.pins.gpio2,
        100u32.kHz(),
        &clocks,
    );

    // This line is for Wokwi only so that the console output is formatted correctly
    esp_println::print!("\x1b[20h");

    // Start Scan at Address 1 going up to 127
    for addr in 1..=127 {
        println!("Scanning Address {}", addr as u8);

        // Scan Address
        let res = i2c0.read(addr as u8, &mut [0]);

        // Check and Print Result
        match res {
            Ok(_) => println!("Device Found at Address {}", addr as u8),
            Err(_) => println!("No Device Found"),
        }
    }

    // Loop Forever
    loop {}
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this post, a I2C scanner application was created leveraging the I2C peripheral for the ESP32C3. The I2C code was created at the HAL level using the Rust no-std esp32c3-hal. Have any questions/comments? Share your thoughts in the comments below 👇.

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

Subscribe Now to The Embedded Rustacean

Top comments (0)