In this video, we're going to be looking at the Ultra Low Power or ULP co-processor on
the ESP32.
This is a really interesting and powerful feature on the ESP32 that offers some very
exciting functionality.
It's worth having a watch of the previous deep sleep video - there's a link in the description
and it contains some really useful information.
So, why am I even looking at the ULP?
One of my latest projects is a DIY e-reader - for this, I want to use deep sleep to preserve
battery life.
The board I've got comes with a set of buttons and I've written the software so that it can
be used with just 3 buttons, up, down and select.
EXT1 sleep mode would be ideal for this - it can monitor multiple buttons and can tell
you which button was pushed - but, it can only do this if the buttons are active high.
Looking at the schematic for the board I have, the buttons all have external pull up resistors
- this means that they are active low and can't be used with EXT1 deep sleep mode.
With the ULP coprocessor, we have a lot more flexibility - we can write code that runs
on the ULP coprocessor waiting for one of our buttons to go low.
We can then wake the device up when that happens, and we can tell it which button was pushed.
It's pretty cool!
So what are going to cover in this video?
I'll describe what the ULP processor is.
We'll do a quick run-through of the instruction set that can be used to program the ULP.
I'll show how you use it in PlatformIO and the Arduino IDE.
I'll then run through some examples that will show you how to:
Read values from the GPIO pins
Wake up the main processor
Share data between the ULP coprocessor and the main processor
And then we'll write values to the GPIO pins - we'll write a blink sketch that runs entirely
on the ULP processor
There are some advanced use cases including reading from the ADC and I2C devices that
we'll cover in future videos.
We'll get started after this quick word about the channel sponsor PCBWay - They've been
sponsoring the channel for a while and we've built a few PCBs with them.
We'll be making some more PCBs in some future videos as I want to learn a bit more about
KiCad.
They also do 3D printing and CNC work.
Check out the link to PCBWay in the description.
So, what is this ULP coprocessor thing anyway?
The Espressif docs describe it as the following:
"The Ultra-Low Power coprocessor is a simple Finite State Machine which is designed to
perform measurements using the ADC, temperature sensor, and external I2C sensors, while the
main processors are in deep sleep mode."
Although they describe it as a simple finite state machine it's slightly easier to think
of it as a very basic low power processor that can run code even when the main processors
are asleep.
The ULP processor does not run all the time, you schedule it to run with a set time period
- it runs and then when the ULP program completes it stops running until the next scheduled
time.
To program the ULP we use some simple assembly language - don't let this put you off, it
really is very simple and not something to be afraid of.
If you're using Arduino then there is a very basic C-compiler that you can use as well.
The ULP coprocessor has 4 16 bit general purpose registers labelled R0, R1, R2 and R3.
It also has an extra 8-bit register called "STAGE_CNT" which can be used to implement
loops and has some special instructions.
It may help to imagine these registers as variables in a normal C program.
There are 26 instructions in total.
I've tried to group these into logical areas.
There are three instructions for getting values into our registers.
The move instruction will move a value from one register to another, or it will load an
immediate value into a register.
The STORE instruction will store the contents of a register into a memory address contained
in another register with an optional signed offset.
The LOAD instruction reads from the contents of a memory address in a register with an
optional signed offset.
We have two arithmetic instructions - ADD and SUB - as the names suggest these instructions
let you add and subtract values.
We can either add or subtract registers.
Or we can add and subtract numbers from registers.
We also have a set of bitwise operators - AND, OR, Logical shift left and logical shift right.
These all behave in the same way as the bitwise operators in C and C++.
We then have three jump commands - we have the absolute jump command.
This can jump to the address in a register or to an immediate address.
It also supports two conditions, EQ - which is true if the last ALU operation resulted
in a 0 or OV which will be true if the last ALU operation resulted in an overflow.
In this example, we set R0 to 1 and then AND it with the value 1, the result of this is
non zero so the first jump fails, the second jump has no condition so jumps.
This sample code is the equivalent of a C if-else construct.
Then we have a relative JUMPR command, this will jump based on the contents of the R0
register and a threshold.
And the last jump instruction is the JUMPS command, this will jump based on the contents
of the STAGE_CNT register and a threshold.
This leads us to the specific STAGE_CNT instructions.
We have STAGE_RST which will reset the stage count, STAGE_INC which will increment the
STAGE_CNT register and finally STAGE_DEC which will decrement the STAGE_CNT register.
You can use these instructions in conjunction with the JUMPS command to implement simple
for loops.
We then have the commands for controlling the ULP execution.
The HALT command will end the ULP program and the ULP processor will stop running until
it's woken up again by the timer.
The WAKE command wakes up the main processor - we'll talk about this in a bit with some
caveats on when the main processor can be woken.
The WAIT command delays for a number of cycles.
We then have a set of instructions for reading from peripherals - TSENS will read from the
internal ESP32 temperature sensor, ADC will read a value from the analogue to digital
converter and we have a couple of functions for reading from an I2C slave device.
We'll cover these instructions in future videos.
You can find more detailed documentation at this link - I've added this to the video description.
So how do we actually create a program for the ULP and run it?
Let's have a look at the process with PlatformIO first:
PlatformIO supports ULP programming, but only if you are targetting the ESP-IDF.
If you want to use Arduino code then you'll need to use the Arduino framework as a component.
Adding support for the ULP is pretty straightforward.
We need to create a folder and add a file containing our ULP code - this must have a
capital "S" as the extension.
Here I've added a very simple program that will wake up immediately.
We need to make the project aware of this file by modifying the CMakeLists.txt file
in the src folder.
The first line defines a name for our ULP app.
The second line tells the compiler which files contain our ULP code.
The third line tells the compiler which of our files will be using any variables exported
by the ULP code.
And the fourth line actually embeds the ULP binary blob in our project.
If we try and build now we'll get an error - we need to allocate space for our ULP program and enable the ULP processor.
We do this by running menu config.
In PlatformIO you do this by opening up the PlatformIO terminal and then running:
pio run -t menuconfig
We then need to go to Component config -> ESP32 specific and enable the Ultra Low Power ULP
Coprocessor.
By default, this will reserve 512 bytes of RTC memory for our code.
With that saved our project now builds successfully.
Our last job is to actually load the embedded ULP code into the coprocessor and run it.
In our main.cpp file we can now include an automatically generated header file ulp_main.h
- this contains any variables that have been exposed by our ULP program - we'll talk about
this in a bit.
We have two external constants that point to the start and end of our embedded ULP binary.
If we weren't woken by the ULP processor then we load up our ULP binary.
I've then set up the code to wait for a button press.
As soon as the button is pressed we go into deep sleep.
We enable ULP wakeup.
Set the period - in this case, I'm just setting the period to be around 5 seconds as my ULP
program wakes up the main processor immediately.
We then run our ULP program and go into deep sleep.
If we run this up on the device and then push the button, you can see that after about 5
seconds our device wakes up again and the wake cause is the ULP processor.
If you're using the Arduino IDE then there is a plugin that you can install a plugin
called ulptool - there are instructions on the README for this.
I won't run through the setup as it varies depending on the operating system you are
using.
The main difference between this and the PlatformIO/ESP-IDF approach is that the files must have a lowercase
"s" for the file extension.
So, how do we wake up the main processor from the ULP coprocessor - this is as simple as
issuing the WAKE instruction - however, there is a bit of boilerplate code that needs to
be run before this.
We need to make sure that the RTC controller is ready to wake up the main processor.
To check for this we need to monitor the ready for wakeup bit on the RTC control low power
status register.
The READ_RTC_FIELD is a helper macro that you can use to read a set of bits from the
register.
If the bit is low then we are not ready to wake the main processor.
We check for the low bit by anding the value in R0 with 1 - if the result of this is zero
then the EQ flag will be true and we'll try again.
If the result is not zero then the EQ flag will be false and we'll issue the WAKE instruction.
We make use of the stage counter register here so that we only try to wake up 10 times
before giving up.
This prevents our ULP program from getting stuck forever.
Reading from GPIO pins is pretty straightforward with a couple of gotchas for the unwary.
We need to set up the GPIO pins in our main program so that they can be used by the RTC
controller.
We covered this in more detail in the previous deep sleep video, but you do this using the
rtc_gpio commands.
In our ULP code, we use the READ_RTC_REG macro - we use this to read values from the RTC_GPIO_IN_REG.
If we look in the ESP32 technical manual we can see that this register contains the values
for the RTC GPIO pins in bits 14 up to bit 31.
The bit will be 1 if the GPIO is high and 0 if the GPIO is low.
These bits correspond to the RTC_GPIO0 in bit 14 up to RTC_GPIO17 in bit 31.
We can only read 16 bits at a time as our registers R0-R3 are all 16 bits.
The command shown on the screen will read the 16 bits from the register starting from
the bit position specified by RTC_GPIO_NEXT_S into register R0.
RTC_GPIO_NEXT_S is defined to be 14 so it starts reading at bit 14.
As we can only read 16 bits at a time if we want to read RTC GPIOS 16 and 17 we need to
adjust the bit position that we start reading from.
This will place the value for RTC GPIO 16 in bit 14 and RTC GPIO 17 in bit 15.
Armed with this knowledge we can now write a ULP program that will monitor the pins on
my e-paper board.
The pins I want to monitor are: GPIO_NUM_34, GPIO_NUM_35 and GPIO_NUM_39.
You'll recall from our previous video that there is a mapping from RTC pins to GPIO pins.
We can use this mapping to work out which bits we need to check for our GPIO pins.
GPIO_NUM_39 is RTC GPIO 3, GPIO_NUM_34 is RTC GPIO 4, GPIO_NUM_35 us RTC GPIO 5.
The code for checking if these bits are low is very similar to the code that checks if
we are ready to wake up.
We read in the current GPIO values from the register and then we and the value with the
bit that corresponds to the RTC pin we are interested in.
GPIO_NUM_34 is pin 4, if the result of this is zero then the pin must be low.
We do this for the other GPIO pins we are interested in.
GPIO_NUM_39 is pin 3 and GPIO_NUM_35 is pin 5.
This is all very interesting and we can now wake up the device when the user pushes the
buttons - but this is no different from the EXT1 mode that we used in the previous video
- we need some way of knowing which button actually woke us up.
We need to get data from the ULP coprocessor to the main processor.
This is actually surprisingly easy to do, in our ULP code we can mark labels as being
"global" doing this exposes them to our application code as global variables prefixed with the
value "ulp_".
We can read and write into these addresses and the values will be available in our main
code.
We can also put values into the variables in our main code and the values will be available
to the ULP.
With that done we now detect which button actually woke us in our application code.
In our ULP code we're writing the value we read from the GPIO register and then our application
code we're checking to see if the GPIO pins were low.
One thing to be aware of is that only the bottom 16 bits of the variable will contain
data as our ULP registers are all 16 bits.
You can check out my e-reader code to see a more detailed and flexible example.
So, how about writing to GPIO pins?
Is that possible - let's make a blink sketch that runs on the ULP.
We can see from the serial output that we've gone into deep sleep.
Looking at our code we don't have anything writing to the LED - everything is happening
on the ULP.
To set output values we use the RCT_GPIO_OUT_REG, GPIO2 which is where our built-in LED is connected
maps onto RTC012 which is bit 26 of the register.
There's a couple of extra hoops to jump through, in order to maintain the state of our output
when the ULP program is not running we need to set the hold bit of the pad.
So before we change the value we need to clear the hold bit, we then read the current value
of the pin.
We use the READ_RTC_REG macro and use the RTC_GPIO_OUT_DATA_S macro adding on 12 to
get the bit for the RTC GPIO 12.
If the bit is currently set then we clear it, if the bit is not set then we set it.
We then put the hold bit back onto the pad and finish our program.
In our application code, we've scheduled the ULP program to run every 500ms so it keeps
blinking until we reset the device.
Check out my previous video that explores deep sleep and wakeup using the built-in functionality
of the ESP32, you often don't need to use the ULP at all.
There's a whole bunch more really interesting applications that we can do using the ADC
and I2C peripherals and the ULP, we'll be covering these in a future video.
See you soon!