Embedded Rust BSPs with uFerris & Xiao: Buzzer Support with PWM

This is the fifth post in the µFerris & Xiao BSP series, where we build a Board Support Package from scratch, one peripheral at a time.
Series Past Posts
Introduction
In the last post, we added the first two components to the BSP; LED 1 and SW5. This post adds the buzzer. The buzzer is an audible transducer on GPIO4, and making it sound means feeding it a PWM square wave at around 2.7 kHz. For that, we will use the ESP LEDC peripheral, which generates PWM in hardware.
Let's get started.
📚 Knowledge Prerequisites
To understand the content of this post, you need the following:
Basic knowledge of coding in Rust.
Basic understanding of embedded systems development concepts.
Familiarity with
no_stddevelopment in Rust, preferably usingesp-hal.Familiarity with PWM concepts, mainly frequency and duty cycle.
Familiarity with the µFerris platform and flashing a XIAO module.
💾 Software Setup
All the code presented in this post is available in the µFerris & XIAO series repo. Every post in the series has its own self-contained Cargo project in a numbered folder. The code for this post lives in 05-buzzer-pwm/:
git clone https://github.com/theembeddedrustacean/learn-bsp-rs
cd learn-bsp-rs/05-buzzer-pwm
cargo run --release
The examples revolve around the ESP32-C3. If you want to follow along with a different device, please refer to the xiao-generate post for more detail.
🛠 Hardware Setup
The required hardware includes:
- µFerris Megalops Baseboard — the board this BSP is written for. Available from The Embedded Rustacean Store.
- Seeed Studio XIAO ESP32-C3 — the controller used throughout this phase of the series. Available from the SeeedStudio Store.
- USB-C cable for power, flashing, and serial output.
🔌 Connections
No connections are required. Every component the BSP will eventually drive is prewired; you only need to assemble the board. If you haven't assembled the board yet, this post walks through it.
The component added in this post is connected on the µFerris as follows:
| Device | XIAO Pin | Notes |
|---|---|---|
| Buzzer | GPIO4 | Magnetic transducer. Resonant around 2.7 kHz. |
The buzzer is loudest when driven at its resonance frequency. 2700 Hz is the figure the µFerris buzzer is specified at; this value will be a constant in the BSP and not a parameter the application passes in.
👨🎨 Software Design
This post builds on the BSP from the previous post, where we added LED and button support. This post assumes you understand that code. Recall the BSP code is composed of three parts; The Board struct, The Board Initialization Function, and The Board Control Functions. Each post adds to all three.
The Board Struct
Recall that the board struct represents the board, with one member per component. We add the buzzer as a third member. This is the form of the updated struct:
pub struct UFerris {
led1: Output<'static>,
sw_btn5: Input<'static>,
buzzer: Channel<'static, LowSpeed>,
}
Channel is an LEDC type from esp_hal::ledc::channel. LowSpeed marks which of the two LEDC speed modes it belongs to. The ESP32-C3 only implements low-speed channels, so LowSpeed is the only choice here.
Note what the struct does not hold. Setting up the buzzer produces three items; an Ledc driver, a Timer, and a Channel. We keep only the channel. The next section explains why.
The Board Initialization Function
The LEDC peripheral is organized in two layers:
A timer produces a frequency and a duty resolution.
A channel attaches to a timer, drives one output pin, and holds the duty value for that pin.
We configure the buzzer in four steps:
Create the
Ledcdriver from the LEDC peripheral.Configure a Timer.
Promote the configured timer to
'static.Configure the
Ledcchannel & attach the GPIO pin.
One thing to note is that a channel's configuration holds a reference to its timer, and that reference has to be 'static because the channel lives in the board struct for the rest of the program. A timer created as a local in uferris_init is dropped when the function returns, so the borrow checker rejects it.
The issue, however, is that we cannot declare the timer `static at compile time since we would not have initialized it yet. This means we need a mechanism to turn the timer into a `static after the program runs. static_cell::StaticCell solves this. It is a static slot that starts empty and is filled once at runtime. We create a new StaticCell as follows:
static BUZZER_TIMER: StaticCell<Timer<'static, LowSpeed>> = StaticCell::new();
then later in main after initializing buzzer_timer we would initialize the StaticCell as follows:
let buzzer_timer = BUZZER_TIMER.init(buzzer_timer);
What happens here is that init moves the value into the static and hands back a &'static mut to it.
The Board Control Functions
We add three functions:
| Function | Behaviour |
|---|---|
buzz_on(percent) |
Sounds the buzzer when called, at the given duty percentage. |
buzz_off() |
Silences the buzzer when called. |
buzz_on takes a duty as a percentage, not a frequency. The buzzer's resonance frequency is fixed. The BSP resolves the percentage against the timer's duty resolution, so the application never sees the raw duty range.
Note that 100% duty is not the loudest setting; it is silence. Holding the pin permanently high is DC, and the transducer only moves on transitions. The output is strongest at 50%.
Test Application Design
The test application steps are as follows:
Poll
sw5_pressed()in a loop.Mirror the result on LED 1 and the buzzer; both on while the button is held, both off when it is released.
Print the state over the serial port only when the state changes.
Wait 20 ms (allows button to settle from bouncing).
Go back to step 1.
👨💻 Code Implementation
1️⃣ Bring in the LEDC types. The GPIO imports from the last post stay. To them, we add the LEDC types, the SetDutyCycle trait, Rate to express the frequency, and StaticCell. Below is the code:
use embedded_hal::pwm::SetDutyCycle;
use esp_hal::{
gpio::{DriveMode, Input, InputConfig, Level, Output, OutputConfig},
ledc::{
LSGlobalClkSource, Ledc, LowSpeed,
channel::{self, Channel, ChannelIFace},
timer::{self, Timer, TimerIFace},
},
peripherals::Peripherals,
time::Rate,
};
use static_cell::StaticCell;
Note that ChannelIFace and TimerIFace are traits. They carry the configure methods, so the code will not compile without them in scope, even though neither name appears anywhere else.
2️⃣ Declare the static timer slot and the buzzer frequency. Both live at module level. Below is the code:
static BUZZER_TIMER: StaticCell<Timer<'static, LowSpeed>> = StaticCell::new();
/// Resonant frequency of the µFerris buzzer, in hertz.
const BUZZER_FREQ_HZ: u32 = 2700;
3️⃣ Populate the board struct. Add the buzzer Channel as a member alongside the two existing GPIO components:
pub struct UFerris {
led1: Output<'static>,
sw_btn5: Input<'static>,
buzzer: Channel<'static, LowSpeed>,
}
4️⃣ Populate the initialization method. The GPIO setup from the previous post is unchanged, and the buzzer setup follows it. Below is the code added to the init method:
// Instantiate the LEDC peripheral
let mut ledc = Ledc::new(peripherals.LEDC);
ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);
// Configure Timer 0
let mut buzzer_timer = ledc.timer::<LowSpeed>(timer::Number::Timer0);
buzzer_timer
.configure(timer::config::Config {
duty: timer::config::Duty::Duty14Bit,
clock_source: timer::LSClockSource::APBClk,
frequency: Rate::from_hz(BUZZER_FREQ_HZ),
})
.expect("buzzer timer configuration failed");
// Promote the timer to 'static
let buzzer_timer = BUZZER_TIMER.init(buzzer_timer);
// Configure the LEDC Channel and attach GPIO
let mut buzzer = ledc.channel(channel::Number::Channel0, peripherals.GPIO4);
buzzer
.configure(channel::config::Config {
timer: buzzer_timer,
duty_pct: 0,
drive_mode: DriveMode::PushPull,
})
.expect("buzzer channel configuration failed");
UFerris {
led1,
sw_btn5,
buzzer,
}
Note the shadowing on the timer.
BUZZER_TIMER.init(buzzer_timer)consumes the localTimerand returns a reference to where it now lives, so we reuse the name to keep the following code readable.
5️⃣ Add the control functions. These go in the same impl block as the LED and button functions. Below is the code:
/// Sound the buzzer at the given duty, as a percentage from 0 to 100.
pub fn buzz_on(&mut self, duty_percent: u8) {
let _ = self.buzzer.set_duty_cycle_percent(duty_percent);
}
/// Silence the buzzer.
pub fn buzz_off(&mut self) {
let _ = self.buzzer.set_duty_cycle_fully_off();
}
set_duty_cycle_percent comes from the SetDutyCycle trait and scales the percentage against the channel's maximum duty, which the timer's resolution sets. It returns a Result whose error type is Infallible for this channel, so we discard the result with let _ =.
Optionally, you can add a buzz_beep(ms) function that sounds the buzzer for a fixed time and returns. That is left for the reader as an exercise.
🏃♂️ Test Code & Run
The application asks for a duty as a percentage, so nothing in it changes if the BSP's duty resolution does:
#![no_std]
#![no_main]
use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::{UFerris, uferris_init};
esp_bootloader_esp_idf::esp_app_desc!();
#[main]
fn main() -> ! {
// Configure the device and hand the peripherals to the BSP
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
let mut uferris: UFerris = uferris_init(peripherals);
// Instantiate a delay provider
let delay = esp_hal::delay::Delay::new();
// Variable to keep track of the last state of the button.
let mut was_pressed = false;
loop {
let pressed = uferris.sw5_pressed();
// LED 1 and the buzzer both follow SW5
if pressed {
uferris.led1_on();
uferris.buzz_on(50);
} else {
uferris.led1_off();
uferris.buzz_off();
}
// Only announce a change
if pressed != was_pressed {
println!("SW5 {}", if pressed { "pressed" } else { "released" });
was_pressed = pressed;
}
// Read every 20 ms so a bouncing settles
delay.delay_millis(20);
}
}
There is no LEDC, no timer, and no duty resolution anywhere in that code. The application knows the board has a buzzer and that the buzzer takes a duty. Everything else stays in the BSP.
Flash it and open the monitor:
cargo run --release
The board beeps once on reset. After that, holding SW5 lights LED 1 and sounds the buzzer, and the terminal prints one line per transition:
SW5 pressed
SW5 released
If the boot beep sounds but pressing does nothing, look at the button. If nothing sounds at all, check that set_global_slow_clock is called before the timer is configured. A timer with no clock configures without complaint and produces no output.
✅ Conclusion
In this post, we added the buzzer to the µFerris BSP, and the board struct now holds a driver for it. The API the application sees did not get more complicated. It gained buzz_on and buzz_off, and it still has no idea the LEDC peripheral exists.
The next post adds the LDR, it will be the first component that hands data back rather than taking a command.
📱 Full Code
src/lib.rs
#![no_std]
use embedded_hal::pwm::SetDutyCycle;
use esp_hal::{
gpio::{DriveMode, Input, InputConfig, Level, Output, OutputConfig},
ledc::{
LSGlobalClkSource, Ledc, LowSpeed,
channel::{self, Channel, ChannelIFace},
timer::{self, Timer, TimerIFace},
},
peripherals::Peripherals,
time::Rate,
};
use static_cell::StaticCell;
/// The LEDC timer the buzzer channel reads its frequency from. The channel
static BUZZER_TIMER: StaticCell<Timer<'static, LowSpeed>> = StaticCell::new();
/// Resonant frequency of the µFerris buzzer, in hertz.
const BUZZER_FREQ_HZ: u32 = 2700;
/// A handle to the µFerris board and everything on it.
pub struct UFerris {
led1: Output<'static>,
sw_btn5: Input<'static>,
buzzer: Channel<'static, LowSpeed>,
}
/// Consume the raw chip peripherals and hand back a ready-to-use board.
pub fn uferris_init(peripherals: Peripherals) -> UFerris {
// Instantiate LED 1
let led1 = Output::new(peripherals.GPIO3, Level::Low, OutputConfig::default());
// Instantiate SW5
let sw_btn5 = Input::new(peripherals.GPIO5, InputConfig::default());
// Instantiate the LEDC peripheral
let mut ledc = Ledc::new(peripherals.LEDC);
ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);
// Configure the timer attached to the LEDC
let mut buzzer_timer = ledc.timer::<LowSpeed>(timer::Number::Timer0);
buzzer_timer
.configure(timer::config::Config {
duty: timer::config::Duty::Duty14Bit,
clock_source: timer::LSClockSource::APBClk,
frequency: Rate::from_hz(BUZZER_FREQ_HZ),
})
.expect("buzzer timer configuration failed");
// Promote the timer to 'static
let buzzer_timer = BUZZER_TIMER.init(buzzer_timer);
// Configure the LEDC Channel and attach pin.
let mut buzzer = ledc.channel(channel::Number::Channel0, peripherals.GPIO4);
buzzer
.configure(channel::config::Config {
timer: buzzer_timer,
duty_pct: 0,
drive_mode: DriveMode::PushPull,
})
.expect("buzzer channel configuration failed");
UFerris {
led1,
sw_btn5,
buzzer,
}
}
impl UFerris {
/// Turn LED 1 on.
pub fn led1_on(&mut self) {
self.led1.set_high();
}
/// Turn LED 1 off.
pub fn led1_off(&mut self) {
self.led1.set_low();
}
/// Read switch 5.
pub fn sw5_pressed(&mut self) -> bool {
self.sw_btn5.is_low()
}
/// Sound the buzzer at the given duty, as a percentage from 0 to 100.
pub fn buzz_on(&mut self, duty_percent: u8) {
let _ = self.buzzer.set_duty_cycle_percent(duty_percent);
}
/// Silence the buzzer.
pub fn buzz_off(&mut self) {
let _ = self.buzzer.set_duty_cycle_fully_off();
}
}
src/bin/main.rs
#![no_std]
#![no_main]
use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::{UFerris, uferris_init};
esp_bootloader_esp_idf::esp_app_desc!();
#[main]
fn main() -> ! {
// Configure the device and hand the peripherals to the BSP
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
let mut uferris: UFerris = uferris_init(peripherals);
// Instantiate a delay provider
let delay = esp_hal::delay::Delay::new();
// Variable to keep track of the last state of the button
let mut was_pressed = false;
loop {
let pressed = uferris.sw5_pressed();
// LED 1 and the buzzer both follow SW5 state
if pressed {
uferris.led1_on();
uferris.buzz_on(50);
} else {
uferris.led1_off();
uferris.buzz_off();
}
// Only announce a change, not every sample.
if pressed != was_pressed {
println!("SW5 {}", if pressed { "pressed" } else { "released" });
was_pressed = pressed;
}
// Sample every 20 ms so a bouncing settles between reads.
delay.delay_millis(20);
}
}





