Rise and Shine: Putting the nRF52840 to sleep, and waking it back up

Folkert
Systems software engineer
Rise and Shine: Putting the nRF52840 to sleep, and waking it back up
In embedded systems, energy efficiency is crucial for practical applications. Usually devices run on a battery, so the less energy you use, the longer the power supply will last. In this post we'll look at the basics of going to sleep and waking back up, and build a proof of concept using the nRF52840 development kit.

When it is expected that there are big pauses between activity, for instance a sensor providing new readings only every second or minute, it becomes attractive to enable the device's power saving mode: the device is put to sleep when we know the next activity will not happen for a while, and woken up when it needs to perform work again.

The idea is straightforward, but implementation proved less so. At Tweede Golf we use Rust for embedded development, but the relevant rust crates do not currently expose the functionality we need for this task in a convenient (and discoverable) way.

Going to sleep

The sleeping part at least is easy: we just need to flip a bit.

let peripherals = nrf52840_hal::pac::Peripherals::take().unwrap();
peripherals.POWER.systemoff.write(|w| w.systemoff().set_bit());

The rust ownership system makes this step more verbose than the C equivalent, but also provides stronger safety guarantees.

First we take ownership of the peripherals. The compiler makes sure there is only ever one owner of the peripherals. That is a best practice, especially when considering that execution can be interrupted at (almost) any moment. When the interrupt handler uses the same peripheral as user code, the result is undefined and the application may crash. Next, we set the systemoff register of the POWER peripheral.

When ran, the device will enter "System OFF" mode, the deepest power saving mode of the nRF52840. This will terminate all ongoing tasks, which has two consequences:

  • You may want to add some delay before sleeping, so ongoing tasks can finish
  • Sleeping is not immediate (it has to terminate other tasks first). It is common to see some delay or an infinite loop right after the code that enabled System OFF mode

Sensing trouble

Our next task is to wake up again when a sensor indicates there is work to be done. For this post we'll use one of the buttons of the nRF52840 development kit (DK) to simulate such input, but anything that can send a current to one of the device's pins will work.

A standard way to configure a button, and respond to it being pressed, looks roughly as follows:

let peripherals = nrf52840_hal::pac::Peripherals::take().unwrap();
let pins = nrf52840_hal::pac::p0::Parts::new(periph.P0);

// configure as default-high/active-low
// normally the voltage is high, except when pressed, then it is low.
// pin 12 corresponds to button 2 of the nRF52840DK
let button = pins.p0_12.into_pullup_input();

loop {
    if button.is_low() == Ok(true) {
        // the button is pressed
        // perform some action
    }
}

But because our device is asleep, no user code is executed, and we cannot perform the loop or the button.is_low() check. We need to generate a special signal to wake the device from its slumber. Luckily, the peripheral that controls the pins (and hence the buttons and leds of the DK), the GPIO peripheral, can send such a signal, the global DETECT signal.

Every pin has a SENSE field, that configures when a pin sends its DETECT signal. The DETECT signal can fire either when the pin switches from low to high, from high to low, or not at all. With default settings, the DETECT signal of all pins is combined into one global DETECT signal. The global signal can then be used by other peripherals. The POWER peripheral specifically will wake from Power OFF mode when the global DETECT signal is registered.

Breaking open the HAL

Unfortunately, the default setting for the SENSE field in the device's HAL (hardware abstraction library) is to disable it, therefore no DETECT signal is sent when we press the button, and our device does not wake up.

Let's look at what actually happens when we configure the button. The into_pullup_input function is implemented roughly as follows:

pub fn into_pullup_input(self) -> P0_12<Input<PullUp>> {
    unsafe { &(*nrf52840_hal::pac::P0::ptr()).pin_cnf[12] }.write(|w| {
        w.dir().input();
        w.input().connect();
        w.pull().pullup();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });

    P0_12 {
        _mode: PhantomData,
    }
}

(I've concretized the actual implementation, which is defined as a macro)

Indeed, the into_pullup_input disables the pin sending a DETECT signal. As mentioned, there is not currently a function that conveniently exposes the SENSE field. But we can create something close to this function ourselves. The pac (peripheral access crate) provides unsafe access to the peripherals. We can then configure pin P0_12 as a pullup with sense input like so:

let p0_register_block = nrf52840_hal::pac::P0::ptr();

unsafe { &(*p0_register_block).pin_cnf[12] }.write(|w| {
    w.dir().input();
    w.input().connect();
    w.pull().pullup();
    w.drive().s0s1();

    match sense_when_goes_to {
        Level::Low => w.sense().low(),
        Level::High => w.sense().high(),
    };

    w
});

However this approach is relatively unsafe, and hard to reuse. Nothing would stop us from configuring pin 12 a second time, which causes unexpected behavior.

Solution 2: fork the HAL

Alternatively, we can fork the hall and expose a safer and more convenient API. We add this function to the nrf-hal-common/src/gpio.rs file

    /// Set the pin's DETECT signal to high when the input goes to the given 
    /// level. With default configuration of the DETECTMODE register, 
    /// the DETECT signals of all pins are combined into one common DETECT signal. 
    /// This common signal can be used by other peripherals, for instance:
    ///
    /// - POWER: uses the DETECT signal to exit from System OFF mode.
    /// - GPIOTE: uses the DETECT signal to generate the PORT event.
    pub fn into_pullup_sense_input(
        self,
        sense_when_goes_to: Level
    ) -> $PXi<Input<PullUp>> {
        unsafe { &(*$PX::ptr()).pin_cnf[$i] }.write(|w| {
            w.dir().input();
            w.input().connect();
            w.pull().pullup();
            w.drive().s0s1();

            match sense_when_goes_to {
                Level::Low  => w.sense().low(),
                Level::High => w.sense().high(),
            };

            w
        });

        $PXi {
            _mode: PhantomData,
        }
    }

This function is inside a macro, which will implement the function for all pins. Also it takes ownership of the pin that is passed in, so configuring the same pin twice is not possible.

Rise and Shine

Lets put the sleeping and waking together in a small example with buttons and leds. We will use the first approach because it is self-contained. The full project can be found here.

#![no_main]
#![no_std]

use panic_halt as _;

use cortex_m_rt::entry;

use defmt_rtt as _; // global logger

use nrf52840_hal::gpio::{p0, Level};
use nrf52840_hal::prelude::{InputPin, OutputPin, _embedded_hal_blocking_delay_DelayMs};

#[entry]
fn main() -> ! {
    let peripherals = nrf52840_hal::pac::Peripherals::take().unwrap();
    let pins = p0::Parts::new(peripherals.P0);

    // the LED will turn on when the pin output level is low
    let mut led = pins.p0_13.degrade().into_push_pull_output(Level::Low);

    // pin 11 corresponds to button 1 on the nRF52840DK
    let button1 = pins.p0_11.into_pullup_input();

    let sense_when_goes_to = Level::Low;
    let p0_register_block = nrf52840_hal::pac::P0::ptr();
    unsafe { &(*p0_register_block).pin_cnf[12] }.write(|w| {
        w.dir().input();
        w.input().connect();
        w.pull().pullup();
        w.drive().s0s1();

        match sense_when_goes_to {
            Level::Low => w.sense().low(),
            Level::High => w.sense().high(),
        };

        w
    });

    // the solution that forks the hall would instead use
    // pins.p0_12.into_pullup_sense_input(Level::Low);

    let core = nrf52840_hal::pac::CorePeripherals::take().unwrap();
    let mut delay = nrf52840_hal::Delay::new(core.SYST);

    loop {
        // when button 1 is pressed
        if button1.is_low() == Ok(true) {
            // turn led off
            led.set_high().unwrap();

            // optionally, wait till all work is "done" before proceding
            // delay.delay_ms(100u16);

            peripherals
                .POWER
                .systemoff
                .write(|w| w.systemoff().set_bit());

            // systemoff takes some time, so we cannot break or return
            // That would cause undefined behavior
            // so we just loop for a while, until the system turns off
            loop {
                delay.delay_ms(1000u16);
            }
        }
    }
}

When button 1 is pressed, the device will enter System OFF mode. Upon pressing button 2, the system will wake back up and restart the program from the top.

Future work

Currently, all program state is lost. By default, a transition from System OFF to System On is like a system reset. That's of course rather inconvenient. In the future we want to look at storing and subsequently recovering program state in a practical way.

Conclusion

We have implemented a simple prototype of going to sleep and waking back up based on an input signal. In the coming weeks we'll revisit this subject to see how we can use low-power modes in our future production projects. Take a look at this post's full project on our github.

Sources

Stay up-to-date

Stay up-to-date with our work and blog posts?

Related articles

Last September, at the start of my internship at Tweede Golf, my tutors gave me a LoRa-E5 Dev Board. My task was to do something that would make it easier to write applications for this device in Rust. Here's what I did.

It's time for another technical blog post about async Rust on embedded. This time we're going to pitch Embassy/Rust against FreeRTOS/C on an STM32F446 microcontroller.

In our last post, we've seen that async can help reduce power consumption in embedded programs. The async machinery is much more fine-grained at switching to a different task than we reasonably could be. Embassy schedules the work intelligently, which means the work is completed faster and we race to sleep. Our application actually gets more readable because we programmers mostly don't need to worry about breaking up our functions into tasks and switching between them. Any await is a possible switching point.

Now, we want to actually start using async in our programs. Sadly there are currently some limitations. In this post, we'll look at the current workarounds, the tradeoffs, and how the limitations might be partially resolved in the near future.