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
Any I2C-based device (Ex. Adafruit DS1307 Real-Time Clock).
🔌 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:
Initialize
address
to 0.Send a read frame to
address
If ack received, record that device detected at
address
, otherwise, record that no device is connected.Increment
address
.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 provideprintln!
implementation.
use esp32c3_hal::{clock::ClockControl,
i2c::I2C,
peripherals::Peripherals,
prelude::*,
IO,
};
use esp_backtrace as _;
use esp_println::println;
🎛 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();
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();
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);
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,
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,
);
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"),
}
}
Finally, since the main
returns a !
, then an empty loop
is required.
loop{}
📱 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 {}
}
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 👇.
Top comments (0)