This blog post is the fourth of a multi-part series of posts where I explore various peripherals in the ESP32C3 using standard library embedded Rust and the esp-idf-hal. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.
Prior posts include (in order of publishing):
Introduction
Hardware timers while relatively simple circuits are really effective in several applications in embedded. Timer peripherals are effective in timing both software and hardware events. Timers also have features that allow the generation of hardware waveforms (Ex. PWM). In this post, I'll configure and setup an ESP timer peripheral to measure the width of the pulse for two different signals. The resulting pulse width value will be printed on the console.
The square waves used in this post will be generated using the Wokwi custom external block. In general, this type of measurement would be useful as it emulates the behavior of applications that include tachometers or anemometers. Tachometers and anemometers generate square wave signals that are proportional to the speed of rotation. With some calculations, and depending on the application, the provided code can be expanded to provide frequency and/or rpm values.
๐ Knowledge Pre-requisites
To understand the content of this post, you need the following:
- Basic knowledge of coding in Rust.
๐พ 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
-
Square Wave Generator/Pulse Generator: This can take many forms in real hardware though since I'm using Wokwi, there is the custom chip feature that allows me to generate square waves.
๐ Connections
๐ Note
All connection details are also shown in the Wokwi example.
Connections include the following:
Gpio0 wired to the top pin of the breakout custom chip.
Gpio1 wired to the bottom pin of the breakout custom chip.
๐จโ๐จ Software Design
The square wave signal is going to be fed into the ESP32 as an input. For the purpose of this post, the code will measure the widths of the pulses in each square wave. The custom chip is designed such that one wave generates pulses that are 10ms wide and another that are 25ms wide. To measure the pulse width, the algorithm in this post needs to determine the time elapsed between every positive edge and the negative edge that follows.
Following the configuration of the pins and the device, the algorithm will work as follows:
Assuming that the existing (a.k.a old
) level of the signal at the pin is High
:
Poll/read the
current
(new) level at the input pinIf a positive edge transition occurs then reset/start the timer and update the
old
pin value.If a negative edge transition occurs then capture the timer value and update the
old
pin value.Calculate and print the duration of the pulse.
Loop back to step 1
Identifying Edge Transitions
Note how in steps 2 and 3 we are checking for positive and negative edges. Programmatically, a transition is determined by checking if the pin current
level is different than the old
level. However, that still doesn't tell us if the transition is positive or negative. To check for positive edges, if the current
level is High
, this means that the transition is a positive edge transition. Otherwise, if it is Low
this means the transition is negative.
Let's now jump into implementing this algorithm.
๐จ Important Note
The custom chip block in Wokwi has its own internal code that generates the square waves/pulses. There is a tab in the project where one can look at the source code. However, how to create and code a custom block is not part of this post. For the interested in creating custom chips in Wokwi I recommend checking out the chips api documentation.
๐จโ๐ป Code Implementation
๐ฅ Crate Imports
In this implementation, one crate is required as follows:
- The
esp_idf_hal
crate to import the needed device hardware abstractions.
use esp_idf_hal::gpio::*;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::timer::config::Config;
use esp_idf_hal::timer::TimerDriver;
๐ Peripheral Configuration Code
Ahead of our application code, peripherals are configured through the following steps:
1๏ธโฃ Obtain a handle for the device peripherals: In embedded Rust, as part of the singleton design pattern, we first have to take
the device peripherals. This is done using the take()
method. Here I create a device peripheral handler named peripherals
as follows:
let peripherals = Peripherals::take().unwrap();
2๏ธโฃ Obtain handles for the input pins: As explained earlier, there are two signals that will be attached to input pins so that they can be measured. The pins are gpio0
and gpio1
that are given handles pin1
and pin2
respectively:
// Configure Pins that Will Read the Square Wave as Inputs
let pin1 = PinDriver::input(peripherals.pins.gpio0).unwrap();
let pin2 = PinDriver::input(peripherals.pins.gpio1).unwrap();
3๏ธโฃ Obtain a handle and configure the Timer peripheral: In order to configure I2C, there is a TimerDriver
abstraction in the esp-idf-hal that contains a new
method to create a Timer
instance. The new
method has the following signature:
pub fn new<TIMER: Timer>(
_timer: impl Peripheral<P = TIMER> + 'd,
config: &Config
) -> Result<Self, EspError>
as shown, the new
method has 2 parameters. The first timer
parameter is a TIMER
peripheral type. The second is a timer configuration type Config
. The ESP32C3 has two general timer peripherals named timer00
and timer10
. As such, we create an instance for two timers (one for each pin) timer00
and timer10
with handle names timer1
and timer2
as follows:
let config = Config::new();
let mut timer1 = TimerDriver::new(peripherals.timer00, &config).unwrap();
let mut timer2 = TimerDriver::new(peripherals.timer10, &config).unwrap();
Config
is a timer::config
type that provides configuration parameters for the Timer. Config
contains a method new
to create an instance with default parameters. Afterward, there are configuration parameters adjustable through various methods. A full list of those methods can be found in the documentation. For the purpose of this application, the default configuration should be fine.
That's it for configuration.
๐ฑ Application Code
Before entering the program loop, the following initialization steps need to take place:
1๏ธโฃ Declare and initialize variables that will track the pin levels:
Recall from the software design that we need to track both the current
and old
pin levels to determine the occurrence of transitions. For that, the pin methods return a Level
enum which the tracking variables have to have the same type. For each pin two variables were created, a current
and old
variable. Additionally, the old
variables need to be initialized with a known signal level. The starting state of old
being High
or Low
doesn't matter as it's an assumed state and will be adjusted accordingly:
let mut pin1_current_level: Level;
let mut pin1_old_level: Level = Level::Low;
let mut pin2_current_level: Level;
let mut pin2_old_level: Level = Level::High;
2๏ธโฃ Reset Timer Value and Enable Timer
For both timers their values can be reset to zero using the set_counter
method and then enabled using the enable
method:
timer1.set_counter(0_u64).unwrap();
timer2.set_counter(0_u64).unwrap();
timer1.enable(true).unwrap();
timer2.enable(true).unwrap();
3๏ธโฃ Declare and initialize variables that will store the timer values:
The timer counts captured on negative edges will need to be retained in a variable. The counter
method that captures the count returns a u64
. As such, two u64
variables are created count1
and count2
:
let mut count1: u64 = 0;
let mut count2: u64 = 0;
๐ The Application Loop
Following the software design steps:
Poll/read the
current
(new) level at the input pinThis is done using the
get_level
methods onpin1
andpin2
:// Get Level of pin 1 pin1_current_level = pin1.get_level(); // Get Level of pin 2 pin2_current_level = pin2.get_level();
If a positive edge transition occurs then reset/start the timer and update the
old
pin valueRecall, a transition is determined by checking if the pin
current
level is different than theold
level. Additionally, for positive edges, we check if thecurrent
level isHigh
. The counter is then reset using theset_counter
used earlier and the pincurrent_level
is copied over to theold_level
to keep track of the signal level. In the current and next steps, I'm showing the code for only one pin for brevity.// If pin 1 level changed from Low to High then reset count if (pin1_current_level != pin1_old_level) & (pin1_current_level == Level::High) { timer1.set_counter(0).unwrap(); pin1_old_level = pin1_current_level; }
If a negative edge transition occurs then capture the timer value and update the
old
pin valueThis step is more or less the same as the previous with two differences. First, is that we are checking for a negative edge transition using the
Low
variant. Second, we are capturing the timer level using thecounter
method.// If pin 1 level changed from High to Low then capture count if (pin1_current_level != pin1_old_level) & (pin1_current_level == Level::Low) { count1 = timer1.counter().unwrap(); pin1_old_level = pin1_current_level; }
Calculate and print the duration of the pulse
Using println
the pulse width duration is printed to the console. The count value is divided by 1000
to convert it to milliseconds since according to the documentation the timer clock frequency is 1 MHz.
println!("Sq Wave 1 Pulse Width is {}ms", count1 / 1000);
๐ง Enhancements & Optimizations
There are two areas where the presented code can be enhanced. First is the continuous printing. Note how the width is printed in every loop regardless of whether the counter value was updated or not. Second is the repetitive logic/code when an edge is detected. The first area can be enhanced such that the printing happens only when the counter value is updated in a negative edge transition. The second, on the other hand, can be enhanced by introducing an edge detection function (which can include the second area too). Thank you to one of our readers, Christian Foucher for suggesting these optimizations. Here is a suggested alternative implementation incorporating these enhancements:
use esp_idf_hal::gpio::*;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::timer::config::Config;
use esp_idf_hal::timer::TimerDriver;
fn main() {
esp_idf_sys::link_patches();
let peripherals = Peripherals::take().unwrap();
// Configure and Initialize Timer Drivers
let config = Config::new();
let mut timer1 = TimerDriver::new(peripherals.timer00, &config).unwrap();
let mut timer2 = TimerDriver::new(peripherals.timer10, &config).unwrap();
// Configure Pins that Will Read the Square Wave as Inputs
let pin1 = PinDriver::input(peripherals.pins.gpio0.downgrade_input()).unwrap();
let pin2 = PinDriver::input(peripherals.pins.gpio1.downgrade_input()).unwrap();
// Declare and Init Variables that will Track Pin level
let mut pin1_old_level: Level = Level::Low;
let mut pin2_old_level: Level = Level::High;
// Set Counter Start Value to Zero
timer1.set_counter(0_u64).unwrap();
timer2.set_counter(0_u64).unwrap();
// Enable Counter
timer1.enable(true).unwrap();
timer2.enable(true).unwrap();
loop {
pin1_old_level = measure_pin(&mut timer1, &pin1, 1, pin1_old_level);
pin2_old_level = measure_pin(&mut timer2, &pin2, 2, pin2_old_level);
}
}
fn measure_pin<T: Pin, MODE: esp_idf_hal::gpio::InputMode>(timer: &mut TimerDriver, pin: &PinDriver<T, MODE>, index: u8, pin_previous_level: Level) -> Level {
// Get Level of pin
let pin_current_level = pin.get_level();
if pin_current_level != pin_previous_level {
match pin_current_level {
// If pin level changed from Low to High then reset count
Level::High => timer.set_counter(0).unwrap(),
// If pin level changed from High to Low then capture count
Level::Low => {
let count: u64 = timer.counter().unwrap();
//**********integer version (no decimal digit)************
//println!("Sq Wave {} Pulse Width is {}ms", index, count);
//**********float version************
//**********float version************
//println!("Sq Wave {} Pulse Width is {:.1}ms", index, count as f32 / 1000f32);
//**********float version************
//**********remainder version********
let integer_part = count / 1000;
let decimal_part = (count % 1000) / 100;
// Calculate and Print Out the Pulse Width
// Clock Frequency is 1 MHz According to Code
println!("Sq Wave {} Pulse Width is {}.{}ms", index, integer_part, decimal_part);
//**********remainder version********
//*****rust_decimal crate version********
//let decimal_count = rust_decimal::Decimal::new(count as i64, 3); // decimal of 3 digits
//println!("Sq Wave {} Pulse Width is {:.1}ms", index, decimal_count);
//*****rust_decimal crate version********
}
}
}
pin_current_level
}
Note that there are options for printing in different formats. In the original code, only integer math was used.
๐ฑ 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.
use esp_idf_hal::gpio::*;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::timer::config::Config;
use esp_idf_hal::timer::TimerDriver;
fn main() {
esp_idf_sys::link_patches();
let peripherals = Peripherals::take().unwrap();
// Configure and Initialize Timer Drivers
let config = Config::new();
let mut timer1 = TimerDriver::new(peripherals.timer00, &config).unwrap();
let mut timer2 = TimerDriver::new(peripherals.timer10, &config).unwrap();
// Configure Pins that Will Read the Square Wave as Inputs
let pin1 = PinDriver::input(peripherals.pins.gpio0).unwrap();
let pin2 = PinDriver::input(peripherals.pins.gpio1).unwrap();
// Declare and Init Variables that will Track Pin level
let mut pin1_current_level: Level;
let mut pin1_old_level: Level = Level::Low;
let mut pin2_current_level: Level;
let mut pin2_old_level: Level = Level::High;
// Set Counter Start Value to Zero
timer1.set_counter(0_u64).unwrap();
timer2.set_counter(0_u64).unwrap();
// Enable Counter
timer1.enable(true).unwrap();
timer2.enable(true).unwrap();
// Declare and Init Variables that will Track Count Value
let mut count1: u64 = 0;
let mut count2: u64 = 0;
loop {
// Get Level of pin 1
pin1_current_level = pin1.get_level();
// // Get Level of pin 2
pin2_current_level = pin2.get_level();
// If pin 1 level changed from Low to High then reset count
if (pin1_current_level != pin1_old_level) & (pin1_current_level == Level::High) {
timer1.set_counter(0).unwrap();
pin1_old_level = pin1_current_level;
}
// If pin 1 level changed from High to Low then capture count
if (pin1_current_level != pin1_old_level) & (pin1_current_level == Level::Low) {
count1 = timer1.counter().unwrap();
pin1_old_level = pin1_current_level;
}
// If pin 2 level changed from Low to High then reset count
if (pin2_current_level != pin2_old_level) & (pin2_current_level == Level::High) {
timer2.set_counter(0).unwrap();
pin2_old_level = pin2_current_level;
}
// If pin 2 level changed from High to Low then capture count
if (pin2_current_level != pin2_old_level) & (pin2_current_level == Level::Low) {
count2 = timer2.counter().unwrap();
pin2_old_level = pin2_current_level;
}
// Calculate and Print Out the Pulse Width
// Clock Frequency is 1 MHz According to Code
println!("Sq Wave 1 Pulse Width is {}ms", count1 / 1000);
println!("Sq Wave 2 Pulse Width is {}ms", count2 / 1000);
}
}
Conclusion
In this post, a timer application measuring square wave pulse durations was created. The application leverages the Timer peripherals for the ESP32C3 microcontroller. The code was created using an embedded std
development environment supported by the esp-idf-hal
. Have any questions? Share your thoughts in the comments below ๐.