esp-hal icon indicating copy to clipboard operation
esp-hal copied to clipboard

RMT driver tracking issue

Open wisp3rwind opened this issue 4 months ago • 42 comments

Let's keep track of desirable refactorings and enhancements of the RMT driver. A bunch of these have come up in recent PRs, where it was decided to not address them immediately:

  • [x] Always type-erase the RMT channel number on Channel

    • mentioned in https://github.com/esp-rs/esp-hal/pull/3505#discussion_r2154655712
    • PR: https://github.com/esp-rs/esp-hal/pull/3980
  • [x] Move channel specification from cfg_if! branches to esp-metadata

    • came up in https://github.com/esp-rs/esp-hal/pull/3917#discussion_r2264607504
    • PR: https://github.com/esp-rs/esp-hal/pull/4131
    • PR: https://github.com/esp-rs/esp-hal/pull/4138
  • [ ] Consider redesigning the extended channel RAM feature to avoid global shared mutable state

    • cf. https://github.com/esp-rs/esp-hal/pull/3917#discussion_r2264610088
  • [x] Support wrapping rx/tx for all methods

    • PR (partial): https://github.com/esp-rs/esp-hal/pull/4126
    • PR: https://github.com/esp-rs/esp-hal/pull/4049
  • [ ] Support more general data types (iterator, an encoder type similar to IDF)

    • also remove From<PulseCode>/Into<PulseCode> support: https://github.com/esp-rs/esp-hal/pull/4126#pullrequestreview-3238091369
    • PR (partial): https://github.com/esp-rs/esp-hal/pull/4126
    • PR (partial): https://github.com/esp-rs/esp-hal/pull/4616
    • PR (partial): https://github.com/esp-rs/esp-hal/pull/4617
    • PR: https://github.com/esp-rs/esp-hal/pull/4604
    • @wisp3rwind currently working on this
  • [ ] Support multi-channel sync tx

  • [ ] Implement blocking methods for owned and borrowed channels and redesign how data is passed in

    • came up in https://github.com/esp-rs/esp-hal/pull/3716#discussion_r2179900769
    • related: https://github.com/esp-rs/esp-hal/issues/1749
    • PR (partial): https://github.com/esp-rs/esp-hal/pull/4174
    • @wisp3rwind currently working on this
  • [x] Properly tie Rmt, ChannelCreator, Channel lifetimes together.

    • PR: https://github.com/esp-rs/esp-hal/pull/4174
  • [ ] Fractional divider support:

    • https://github.com/esp-rs/esp-hal/issues/2119
  • [ ] Better reflect hardware capabilities for continuous tx in the API and add HIL tests:

    • came up in https://github.com/esp-rs/esp-hal/pull/4100#discussion_r2340858771
    • PR: https://github.com/esp-rs/esp-hal/pull/4260
    • PR: https://github.com/esp-rs/esp-hal/pull/4276
    • still missing: clarify/improve is_loopcount_interrupt_set()
  • [ ] Add an async continuous tx method

    • @wisp3rwind curently working on this
  • [x] Ensure that all fallible methods that take Channel by value return it on error

    • PR: https://github.com/esp-rs/esp-hal/pull/4302
  • [ ] Make clock source configurable

    • expose ClockSource enum, add corresponding argument to Rmt::new()

I've marked points that I'm currently working on/have some local prototype for.

wisp3rwind avatar Aug 13 '25 08:08 wisp3rwind

Does the point about lifetimes also mean fixing the current problem where you can’t configure the channel more than once, and if you start a transfer and it errors out during the initial configuration you lose safe access to the channel? Both of those could be fixed by just properly taking the parent structure by mutable reference on creation, which frees them up again after an error or a channel access ended.

kleinesfilmroellchen avatar Sep 22 '25 18:09 kleinesfilmroellchen

For reference, here’s an abomination I had to write to emulate a temporarily borrowed transmit channel.


/// Better interface for borrowing RMT channels temporarily and giving them back after use, able to be reconfigured.
struct BorrowedRmtChannel<'d, const CHANNEL: u8, Int>
where
	Int: ChannelInternal,
{
	// Should usually contain a value, but because of lower-level API problems we have to empty it out once in a while.
	channel: Option<Channel<Blocking, Int>>,
	config:  TxChannelConfig,
	pin:     AnyPin<'d>,
	creator: &'d mut ChannelCreator<Blocking, CHANNEL>,
}

impl<'d> BorrowedRmtChannel<'d, 0, ConstChannelAccess<Tx, 0>> {
	pub fn configure_tx(
		creator: &'d mut ChannelCreator<Blocking, 0>,
		pin: AnyPin<'d>,
		config: TxChannelConfig,
	) -> Result<Self, Error> {
		// SAFETY: We borrow mutably from the channel creator, ensuring that noone else can use it for our lifetime.
		//         The lifetime of the channel is tied to us.
		let actual_channel = unsafe { ChannelCreator::<Blocking, 0>::steal() };
		// SAFETY: Noone except us has a copy of the pin. We only use our second copy in case the channel gets dropped.
		let configured = actual_channel.configure_tx(unsafe { pin.clone_unchecked() }, config)?;
		Ok(Self { channel: Some(configured), config, creator, pin })
	}

	/// Transmit a certain number of loops of the given number.
	pub async fn transmit_loops(&mut self, data: &[u32], loops: u16) -> Result<(), Error> {
		debug_assert!(self.channel.is_some());
		let channel = self.channel.take().unwrap();
		let res = channel.transmit_continuously_with_loopcount(loops, data).map_err(|e| {
			// SAFETY: This is a borrow checker workaround. All of this code exits the function. The only problems would
			// happen if this code executed after the map_err below, which it never can.
			*self = Self::configure_tx(
				unsafe { mem::transmute(self.creator as *mut _) },
				unsafe { self.pin.clone_unchecked() },
				self.config,
			)
			.unwrap();
			e
		})?;
		while !res.is_loopcount_interrupt_set() {
			Timer::after(Duration::from_millis(1)).await;
		}
		let channel = res.stop_next().map_err(|(e, channel)| {
			self.channel = Some(channel);
			e
		})?;
		self.channel = Some(channel);
		Ok(())
	}
}

kleinesfilmroellchen avatar Sep 22 '25 18:09 kleinesfilmroellchen

You can do Rmt::new(peripherals.RMT.reborrow(), FREQ) and that way you can reclaim the peripheral singleton once you're done with Rmt. On the other hand, I think this is currently unsound, ChannelCreator needs to have a lifetime otherwise it's possible to drop Rmt while channels/channel creator structs are alive, which essentially lets you create any number of copies of the same channel...

bugadani avatar Sep 22 '25 19:09 bugadani

You can do Rmt::new(peripherals.RMT.reborrow(), FREQ) and that way you can reclaim the peripheral singleton once you're done with Rmt.

I need to have two different channels that do their own thing, so I’m not sure this will work, but it’s nice to know that the RMT peripheral can be reborrowed like the GPIOs (and others).

ChannelCreator needs to have a lifetime otherwise it's possible to drop Rmt while channels/channel creator structs are alive, which essentially lets you create any number of copies of the same channel...

Yeah, that’s true. Would be easy enough to add, but doesn’t that mean that you can do unsound things with the upstream API without unsafe since ChannelCreator is not my invention?

kleinesfilmroellchen avatar Sep 22 '25 19:09 kleinesfilmroellchen

but doesn’t that mean that you can do unsound things with the upstream API without unsafe since ChannelCreator is not my invention?

Absolutely, this is a problem that exists right now.

bugadani avatar Sep 22 '25 19:09 bugadani

Looking at it again, though, I don’t know what you’re exactly referring to in my code. Since I take ChannelCreator by mutable reference, as long as ChannelCreator depends on Rmt to be alive, things are fine. For me it would also just be adding another lifetime argument in the code above if that were to be changed. But either way, I’d rather want some API upstream that does what I’m hacking in here automatically, and I wanted to give an idea of what kind of API I need in my code. To improve this idea further, it’s probably useful to show the code that uses it as well:

async fn test_rmt(rmt_peripheral: peripherals::RMT<'_>, mut target: AnyPin<'_>) -> Result<(), esp_hal::rmt::Error> {
	// adjust clock to specific chip, here 80MHz on ESP32-C6
	let mut rmt = Rmt::new(rmt_peripheral, RMT_BASE_CLOCK)?;

	loop {
		// not important -- calculate the RMT config and pulse data based on some complex logic
		let config = rmt_data_for_steps(RMT_BASE_CLOCK, steps, time).unwrap();
		let target = target.reborrow();
		let mut channel = BorrowedRmtChannel::configure_tx(&mut rmt.channel0, target.into(), (&config).into())?;
		channel.transmit_loops(&config.pulses, config.loops).await?;
		Timer::after(Duration::from_millis(1000)).await;
	}
}

kleinesfilmroellchen avatar Sep 22 '25 19:09 kleinesfilmroellchen

Does the point about lifetimes also mean fixing the current problem where you can’t configure the channel more than once,

yes

and if you start a transfer and it errors out during the initial configuration you lose safe access to the channel?

yes

Both of those could be fixed by just properly taking the parent structure by mutable reference on creation, which frees them up again after an error or a channel access ended.

not quite, since it is desirable to have an API that takes the channel by value: https://github.com/esp-rs/esp-hal/pull/3716#discussion_r2179900769


You can do Rmt::new(peripherals.RMT.reborrow(), FREQ) and that way you can reclaim the peripheral singleton once you're done with Rmt.

That works right now due to the issue you point out below, but it's not a viable solution when using several channels independently.

On the other hand, I think this is currently unsound, ChannelCreator needs to have a lifetime otherwise it's possible to drop Rmt while channels/channel creator structs are alive, which essentially lets you create any number of copies of the same channel...

In fact what you suggest doesn't quite work out, but only by accident since https://github.com/esp-rs/esp-hal/pull/3453: It does compile, but due to tracking some global state it will error out when trying to configure the channel before the first instance is dropped.


Yeah, that’s true. Would be easy enough to add, but doesn’t that mean that you can do unsound things with the upstream API without unsafe since ChannelCreator is not my invention?

Currently, yes, but see the above comment on runtime errors.


The (probably) next PR I intend to submit after #4138 will address the above points. It should then be possible to do something along the lines of (note the .reborrow() on the ChannelCreator)

async fn test_rmt(rmt_peripheral: peripherals::RMT<'_>, mut target: AnyPin<'_>) -> Result<(), esp_hal::rmt::Error> {
    // adjust clock to specific chip, here 80MHz on ESP32-C6
    let mut rmt = Rmt::new(rmt_peripheral, RMT_BASE_CLOCK)?;

    loop {
        let config = rmt_data_for_steps(RMT_BASE_CLOCK, steps, time).unwrap();
        let channel = rmt.channel0
            .reborrow()
            .configure_tx(target.reborrow(), (&config).into())?;
        let transaction = channel
            .transmit_continuously_with_loopcount(&config.pulses, config.loops)
            .await?;
        while !transaction.is_loopcount_interrupt_set() {}
        Timer::after(Duration::from_millis(1000)).await;
    }
}

That PR (mostly ready locally) should enable most use cases that currently don't work, it might not be the most ergonomic for all of those yet.


As an aside; does submit_continuously_with_loopcount actually work for you? I've been writing a test, and concluded that it's actually mostly broken (esp32c6 being one of the devices where it's reasonably easy to fix, at least).

wisp3rwind avatar Sep 22 '25 21:09 wisp3rwind


		// SAFETY: We borrow mutably from the channel creator, ensuring that noone else can use it for our lifetime.
		//         The lifetime of the channel is tied to us.
		let actual_channel = unsafe { ChannelCreator::<Blocking, 0>::steal() };

This is enough for safety regarding channel duplication, but there's another problem with current RMT API here: If before the ChannelCreator::steal() all Channel, ChannelCreators and the Rmt have been dropped the peripheral clock will be stopped and re-enabled, which loses the clock configuration.

wisp3rwind avatar Sep 22 '25 21:09 wisp3rwind

@wisp3rwind thanks for the reply, and I’m happy that you’re planning to fix pretty much every problem I’m having with the current API. Your rewrite of my existing code looks very nice and would definitely be a workable solution for my and similar use-cases.

As an aside; does submit_continuously_with_loopcount actually work for you? I've been writing a test, and concluded that it's actually mostly broken (esp32c6 being one of the devices where it's reasonably easy to fix, at least).

I’m fairly certain it does. I don’t have access to an oscilloscope until the weekend, so I am not sure how well exactly it works for higher transmission speeds, but I know that the total transmission time seems to be (almost) correct (e.g. when I use a combination of clock dividers, pulse lengths and loop counts that come out to exactly 10s of transmission time, and hook up an LED to the RMT output, I can see that it lights up with the RMT “PWM” modulation for pretty much exactly 10s). This is true for a variety of combinations of loop counts, clock dividers, and number of pulses. It even works for extreme values, such as exactly two pulses over a 10s period with a super high divider and pulse length, where I can see and approximately time each pulse. So I don’t think there are any major issues with functionality apart from missing the fractional divider value, which you stated you want to get working eventually and which is not a necessary feature for my use case anyways.

kleinesfilmroellchen avatar Sep 23 '25 10:09 kleinesfilmroellchen

As a side note for the looping transmission mode: it would be cool if we found a way to integrate a waiter into the loop end interrupt so we can provide this API on async. My current implementation is pretty much a hack and relies on manual polling and embassy delays, which is not very portable nor performant.

kleinesfilmroellchen avatar Sep 23 '25 10:09 kleinesfilmroellchen

I actually found a bug (?) while testing this some more: If you set a loopcount of 1, the logic in TxChannelInternal::set_generate_repeat_interrupt means that a loop interrupt is never generated and the logic that waits for the interrupt never ends.

kleinesfilmroellchen avatar Sep 23 '25 12:09 kleinesfilmroellchen

I’m fairly certain it does. I don’t have access to an oscilloscope until the weekend, so I am not sure how well exactly it works for higher transmission speeds, but I know that the total transmission time seems to be (almost) correct (e.g. when I use a combination of clock dividers, pulse lengths and loop counts that come out to exactly 10s of transmission time, and hook up an LED to the RMT output, I can see that it lights up with the RMT “PWM” modulation for pretty much exactly 10s). This is true for a variety of combinations of loop counts, clock dividers, and number of pulses. It even works for extreme values, such as exactly two pulses over a 10s period with a super high divider and pulse length, where I can see and approximately time each pulse. So I don’t think there are any major issues with functionality apart from missing the fractional divider value, which you stated you want to get working eventually and which is not a necessary feature for my use case anyways.

One issue that I know of is that continuous transmission does not in fact stop automatically when you'd expect it to. esp32c6 does have the hardware capability to do so, however; I'll submit a fix.

I actually found a bug (?) while testing this some more: If you set a loopcount of 1, the logic in TxChannelInternal::set_generate_repeat_interrupt means that a loop interrupt is never generated and the logic that waits for the interrupt never ends.

Yeah, I have no idea why loopcount 1 is special-cased like that; maybe not all chips support it in the same way as other loop counts. It does not appear to require any special-casing for esp32c6; I'll add that fix.

As a side note for the looping transmission mode: it would be cool if we found a way to integrate a waiter into the loop end interrupt so we can provide this API on async. My current implementation is pretty much a hack and relies on manual polling and embassy delays, which is not very portable nor performant.

Added to the todo list; that should be easy enough to achieve.

wisp3rwind avatar Sep 23 '25 15:09 wisp3rwind

@kleinesfilmroellchen see https://github.com/esp-rs/esp-hal/pull/4174 for the work-in-progress PR that I mentioned. It's not done yet, but in particular for esp32c6, I'd expect everything to work.

wisp3rwind avatar Sep 23 '25 16:09 wisp3rwind

As an aside; does submit_continuously_with_loopcount actually work for you? I've been writing a test, and concluded that it's actually mostly broken (esp32c6 being one of the devices where it's reasonably easy to fix, at least).

So I think there’s a bug where it works once and then never again, but this may be related to how well my code is deconfiguring and reconfiguring the peripheral. I have a problem at the moment where there’s several situations in which the second submission will deadlock in this function.

kleinesfilmroellchen avatar Oct 29 '25 20:10 kleinesfilmroellchen

Apparently I am just holding it wrong. If you specify loopcount 0 (and/or empty data, or even both), weird things can happen. The high-level entry points like aforementioned transmit_continuously_with_loopcount should return an error in this case, as the hardware will behave weirdly. (What I saw was a transmission with loopcount 0 getting turned into an everlasting transmission, without any loop end interrupt ever firing. Or something else, it’s hard to sus out.)

kleinesfilmroellchen avatar Oct 29 '25 21:10 kleinesfilmroellchen

As an aside; does submit_continuously_with_loopcount actually work for you? I've been writing a test, and concluded that it's actually mostly broken (esp32c6 being one of the devices where it's reasonably easy to fix, at least).

So I think there’s a bug where it works once and then never again, but this may be related to how well my code is deconfiguring and reconfiguring the peripheral. I have a problem at the moment where there’s several situations in which the second submission will deadlock in this function.

Is that with or without https://github.com/esp-rs/esp-hal/pull/4260? If yes, with which loop mode argument? If not, it think the issue might be that tx actually never stops, even when using a finite loop count. I think there are still issues with continuous tx, but https://github.com/esp-rs/esp-hal/pull/4260 should help. (Note that the migration guide in that PR is a bit incorrect - a fix is pending in https://github.com/esp-rs/esp-hal/pull/4302/commits/13ef4390b9169d2d9cb54eeff373f54f0cb0ff1d

wisp3rwind avatar Oct 29 '25 21:10 wisp3rwind

Is that with or without #4260?

1.0.0-rc.0, so without. I am fairly certain I am not currently able to use the main branch on my whole project, as this duplicates some crates in the large dependency tree.

If not, it think the issue might be that tx actually never stops, even when using a finite loop count. I think there are still issues with continuous tx, but #4260 should help. (Note that the migration guide in that PR is a bit incorrect - a fix is pending in 13ef439

Yes, I think I am observing this occasionally, but so far fixing my own oversights like loop count 0 and empty transmission data has always made it work again.

As an aside, if it helps, my code that makes extensive use of strange RMT configurations (including lots of clock configuration fiddling) is open-source (and not terribly broken anymore): https://git.filmroellchen.eu/filmroellchen/penplot Maybe you can look at the code in https://git.filmroellchen.eu/filmroellchen/penplot/src/branch/main/penplot/cnc/stepping.rs and see how it behaves with any future changes. (For now, as I said, this is difficult for me, and I’m not seeing any major issues with my above-mentioned workaround hacks for the time being -- I’m really just here to suggest API improvements where they would help my use case.)

kleinesfilmroellchen avatar Oct 29 '25 23:10 kleinesfilmroellchen

One issue that I know of is that continuous transmission does not in fact stop automatically when you'd expect it to. esp32c6 does have the hardware capability to do so, however; I'll submit a fix.

This I just stumbled over; indeed the new code I see in main should fix that issue for me.

Excuse the dogshit oscilloscope, but in this case both channels should stop before the purple cursor in the center (at 9ms total transmit time):

Image

(Otherwise the RMT is being driven as expected, regarding pulse widths and frequencies.)

kleinesfilmroellchen avatar Oct 29 '25 23:10 kleinesfilmroellchen

One issue that I know of is that continuous transmission does not in fact stop automatically when you'd expect it to. esp32c6 does have the hardware capability to do so, however; I'll submit a fix.

This I just stumbled over; indeed the new code I see in main should fix that issue for me.

I think it's limited what you can achieve with the old driver implementation.

In any case, you get one extra repetition because the loopcount interrupt doesn't actually stop tx (even though c6 could do that, but it isn't enabled), only the stop_next call does. You could emulate this by using a loopcount - 1 in the first place. stop_next also does a blocking wait for it to actually stop, so you can't use it simultaneously for two channels, which might explain why one channel has one more pulse.

wisp3rwind avatar Oct 30 '25 08:10 wisp3rwind

I think what you'd really want here is continuous tx with the simultaneous tx feature (see the TRMs; "multi-channel sync tx" in the list above), which the driver doesn't support. What might also simplify things is regular tx with wrapping of the hardware tx buffer (supported) and iterator input, which I will soon (after a few other things) submit a PR for.

wisp3rwind avatar Oct 30 '25 08:10 wisp3rwind

Just noticed that the simultaneous TX feature exists – would definitely be what I need. It sounds like an entirely separate API (that takes ownership of both channels) would be suitable for this, maybe I can come up with something.

kleinesfilmroellchen avatar Oct 30 '25 11:10 kleinesfilmroellchen

In regards to the time delta between the channels due to my separate channel dispatch – from my testing the delay is always between 44 and 46 microseconds (7000-7500 CPU cycles), which is not at all significant for my use case. This is almost entirely the driver dispatch code as well; I take care to configure everything beforehand as much as possible to minimize this interval.

kleinesfilmroellchen avatar Oct 30 '25 12:10 kleinesfilmroellchen

In regards to the time delta between the channels due to my separate channel dispatch – from my testing the delay is always between 44 and 46 microseconds (7000-7500 CPU cycles), which is not at all significant for my use case. This is almost entirely the driver dispatch code as well; I take care to configure everything beforehand as much as possible to minimize this interval.

TBH, ~7000 cycles is still unexpectedly much. This sounds like the cache is cold and the driver only in flash. With a hot cache, or the place_rmt_driver_in_ram feature, it should be way faster.

EDIT: Actually, not sure that this makes sense: The cache should be hot when starting the second transmission in any case.

wisp3rwind avatar Oct 30 '25 13:10 wisp3rwind

I'm not putting the driver in RAM so this can be related.

kleinesfilmroellchen avatar Nov 03 '25 11:11 kleinesfilmroellchen

Updated my stuff to esp-hal 1.0 and the new RMT APIs. As far as I can tell, things are broken now, at least when you reborrow channels (as I obviously have to; at least in terms of API design my above hack is entirely obsoleted). The loop interrupt sometimes fires, sometimes it doesn’t, and it seems like transmissions unpredictably stop early sometimes (and also run at over 2x intended speed), at least when I start both channels. Early as in, within 1-2ms of starting. (And no loopcount interrupt ever fires, so the driver gets stuck.) Additionally, almost always the output pins are high at the end, even though every single pulse command has a “low” as its second level.

Here’s another bad scope shot of channel 1 transmitting correctly and completely, while channel 2 transmits way too fast and then breaks after three to five pulses.

Image

Since the configuration APIs are unchanged, I modified nothing there. I’m using LoopMode::Finite(...) (in this case, 60 on channel 0 and 80 on channel 1). I verified that channel structures are not being dropped prematurely by injecting some logging into the driver.

This also happens with just one channel, so it’s not related to the interaction between the two TX channels.

kleinesfilmroellchen avatar Nov 03 '25 17:11 kleinesfilmroellchen

The same thing (and unpredictable behavior) happens with InfiniteWithInterrupt - except then, if it actually continues to run, it doesn’t stop even when stop_next returns.

I’ll try to provide a minimum broken example when I get to it.

kleinesfilmroellchen avatar Nov 03 '25 17:11 kleinesfilmroellchen

Not sure that any of this is the issue, but:

  • there are .with_idle_output_level(Level::Low) and .with_idle_output(true) to actually enable that functionality
  • the special-casing of loopcount 1 has been removed from the driver, so your code should probably remove it, too

wisp3rwind avatar Nov 03 '25 19:11 wisp3rwind

* there are `.with_idle_output_level(Level::Low)` and `.with_idle_output(true)` to actually enable that functionality

The enable flag is new, thanks for the hint.

* the special-casing of loopcount 1 has been removed from the driver, so your code should probably remove it, too

Probably not related but will test. (all of the testing was with loopcount > 1)

kleinesfilmroellchen avatar Nov 03 '25 21:11 kleinesfilmroellchen

Pretty sure this is related to me choosing the maximum (or even “just” a large) RMT base clock divider. Take note of the RMT_BASE_CLOCK constant in this almost-minimal reproducer that produces glitched output over 50% of the time:

#![feature(
    impl_trait_in_assoc_type,
    never_type,
)]
#![no_std]
#![no_main]
#![deny(clippy::mem_forget)]

extern crate alloc;

use anyhow::Result;
use defmt::{debug, trace};
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{AnyPin, Level};
use esp_hal::interrupt::software::SoftwareInterruptControl;
use esp_hal::rmt::*;
use esp_hal::time::Rate;
use esp_hal::timer::systimer;
use esp_hal::{Blocking, peripherals};
use {esp_alloc as _, esp_backtrace as _, esp_println as _};

esp_bootloader_esp_idf::esp_app_desc!();

const HEAP_SIZE: usize = 1024;

const RMT_BASE_CLOCK: Rate = Rate::from_hz(80_000_000 / 256);

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    fallible_main(spawner).await.unwrap();
}

async fn fallible_main(_spawner: Spawner) -> Result<!> {
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals: peripherals::Peripherals = esp_hal::init(config);
    esp_alloc::heap_allocator!(size: HEAP_SIZE);
    let systimer = systimer::SystemTimer::new(peripherals.SYSTIMER);
    let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
    esp_rtos::start(systimer.alarm0, sw_int.software_interrupt0);

    let mut x_step_pin = AnyPin::from(peripherals.GPIO0);
    let mut rmt = Rmt::new(peripherals.RMT, RMT_BASE_CLOCK).unwrap();

    let tx_channel_config = TxChannelConfig::default()
        .with_clk_divider(1)
        .with_carrier_modulation(false)
        .with_idle_output_level(Level::Low)
        .with_idle_output(true);
    const X_RMT_LENGTH: u16 = 289;
    static X_PULSES: [u32; 2] = [
        PulseCode::new(Level::High, X_RMT_LENGTH, Level::Low, X_RMT_LENGTH).0,
        PulseCode::end_marker().0,
    ];

    loop {
        debug!("starting rmt transmission...");
        let x_step_pin = x_step_pin.reborrow();
        let x_channel = rmt
            .channel0
            .reborrow()
            .configure_tx(x_step_pin, tx_channel_config)
            .unwrap();

        // Timer::after_millis(200).await;
        transmit_loops(x_channel, &X_PULSES, 40).await.unwrap();
        // Timer::after_millis(10).await;
        debug!("... done.");
        Timer::after(Duration::from_secs(2)).await;
    }
}

#[unsafe(no_mangle)]
pub extern "Rust" fn _esp_println_timestamp() -> u64 {
    esp_hal::time::Instant::now()
        .duration_since_epoch()
        .as_micros()
}

fn transmit_loops<'ch>(
    channel: Channel<'ch, Blocking, Tx>,
    data: &[u32],
    loops: u16,
) -> impl Future<Output = Result<(), Error>> {
    // Finite and InfiniteWithInterrupts behave the same
    let maybe_res = channel.transmit_continuously(data, LoopMode::Finite(loops));
    trace!("transmission dispatched to hardware");
    async move {
        let res = maybe_res?;
        // does not change the behavior (!)
        // Timer::after_secs(10).await;

        // this code (with or without the if) deadlocks over 50% of the time
        // if loops > 1 {
        trace!("needs to wait for loopcount interrupt...");
        while !res.is_loopcount_interrupt_set() {
            Timer::after(Duration::from_micros(1)).await;
        }
        // }

        trace!("waiting for transmission stop...");
        let _ = res.stop_next().map_err(|(e, _)| e)?;
        trace!("transmission stopped.");
        Ok(())
    }
}

Cargo.toml:

[workspace]
resolver = "3"

[package]
name = "penplot"
edition = "2024"

[[bin]]
path = "penplot/main.rs"
name = "penplot"

[profile.release]
# Symbols are nice and they don't increase the size on Flash
debug = true
opt-level = 3
# maximum optimization with full LTO and minimal parallelism
lto = "fat"
codegen-units = 1
# more safety for the complex arithmetic
overflow-checks = true
debug-assertions = false
incremental = false

[profile.dev]
# Symbols are nice and they don't increase the size on Flash
debug = true
opt-level = "s"

[features]
default = ["esp32c6"]
esp32c6 = [
	"esp-hal/esp32c6",
	"esp-backtrace/esp32c6",
	"esp-println/esp32c6",
	"esp-rtos/esp32c6",
	"esp-bootloader-esp-idf/esp32c6",
]

[dependencies]
esp-bootloader-esp-idf = { version = "0.4", features = ["defmt"] }
esp-hal = { version = "=1.0.0", features = [
	"unstable",
	"defmt",
] }
esp-backtrace = { version = "0.18", features = ["panic-handler", "defmt"] }
esp-alloc = "*"
esp-rtos = { version = "0.2", features = [
	"defmt",
	"embassy",
	"esp-alloc",
] }
esp-println = { version = "0.16", default-features = false, features = [
	"defmt-espflash",
	"timestamp",
	"jtag-serial",
] }
defmt = { version = "*", features = ["alloc"] }

embedded-hal = "1"

embassy-time = { version = "*", features = ["defmt"] }
embassy-executor = { version = "*", features = ["nightly"] }
embassy-sync = { version = "*", features = ["defmt"] }

anyhow = { version = "1", default-features = false }

critical-section = "1"

Run with RUSTFLAGS="-Z stack-protector=all -C link-arg=-Tlinkall.x -C link-arg=-Tdefmt.x -C force-frame-pointers" DEFMT_LOG="debug,esp_rtos=info,penplot=trace" cargo run --package penplot on latest nightly, ESP32-C6. (Release builds behave the same.)

I will continue my investigation into the clock divider issue, but I hope you can find the bug better with this code at hand.

kleinesfilmroellchen avatar Nov 03 '25 22:11 kleinesfilmroellchen

So this seems to appear somewhere between an integer divider of 20 and 35 from 80MHz (from what I can tell this is always the clock source in use). I say somewhere because it’s hard to tease out, usually it triggers with higher loopcounts (and almost never with loopcount 1), it’s not always deterministic, and there is no specific transmission time (as I thought ~53ms to be at one point) where every divider falls over. I hope that’s helpful.

For example: divider 30 and loopcount 1000 falls over about 10% of the time. My original test case (divider 256 and loopcount 100-300 as above) falls over about 70% of the time.

kleinesfilmroellchen avatar Nov 03 '25 22:11 kleinesfilmroellchen