The ESP32 microcontroller, renowned for its versatility and performance, features a unique Ultra-Low-Power (ULP) coprocessor that enables efficient low-power sensing and processing tasks. Debugging ULP applications, however, has often been perceived as challenging due to its specialized architecture and limited debugging capabilities. In this article, we’ll demystify ESP32 ULP coprocessor debugging, and show insights and techniques that we use to effectively debug ULP applications.
The ULP coprocessor is a dedicated processor integrated into the ESP32 SoC, designed to execute tasks at ultra-low power consumption levels. It operates independently of the main CPU core and is optimized for low-power sensing and control applications.
While the ULP coprocessor offers significant power-saving benefits, it comes with limitations in terms of debugging and program execution. Unlike the main CPU core, the ULP coprocessor lacks sophisticated debugging features and has limited access to system resources. The developer has no direct control over the ULP. The coprocessor has access to the RTC slow SRAM memory for data and instructions, which is shared with the main processor. That means, that to program the ULP, we need to program the ESP main processor, to place instructions into the RTC memory and start the ULP.
There are several debugging Techniques for ESP32 ULP coprocessors, in this article, we will focus on the ESP32S3 chip, but the concept is similar on all ESP chips. The ESP32S3 has two coprocessors, a ULP-FSM which can be programmed with Assembly instructions or Assembly macros, and a ULP-RISC-V, which can be programmed in C. They can not be used simultaneously. In the examples, we use the ULP-FSM and Assembly language.
One of the simplest methods to debug ULP code is to utilize GPIO pins and LED indicators to signal specific events or states within the ULP program. Toggle the state of GPIO pins from the ULP assembly code to indicate program execution milestones or trigger external events. In more time-dependent applications you can use an oscilloscope to measure the time between GPIO changes or to check which GPIO was set first precisely. This is a very useful and fast method, which can be used in code like a breakpoint. You can also create a macro to set a pin high or low.
.set GPIO_TEST, 14 // GPIO_TEST is gpio port 14/rtc port 14 .macro set_TEST WRITE_RTC_REG(RTC_GPIO_OUT_W1TS_REG,RTC_GPIO_OUT_DATA_W1TS_S+GPIO_TEST, 1, 1) .endm .macro clear_TEST WRITE_RTC_REG(RTC_GPIO_OUT_W1TC_REG,RTC_GPIO_OUT_DATA_W1TC_S+GPIO_TEST, 1, 1) .endm
The second debug method relies on using the main ESP processor to read and print through the UART interface variables used by the ULP. Printf debugging is a simple yet effective technique for monitoring ULP program execution. You can create a loop code in the ESP that will print all ULP variables, and you can create additional debug variables on the ULP site. Then in code, you change the debug variables according to the present state. For this method, in most cases, we have to slow down the ULP runtime and add some delays to have enough time to print the variable and not miss any changes between the prints.
If the runtime of the application is relevant, we can create a debug array, and fill it with a debug variable every loop until the array is full. Then we can print the whole array and see all the changes that were made to the variable even if the ULP code is much faster than the UART print.
Here is an example of a test_array, which can hold up to 512 elements;
.bss .global variable variable: //debug variable .long 0 .global test_array test_array: .space 2048 .data .global test_array_index test_array_index: .long 512
We fill the array using such functions. First, we need to check if the array isn’t full:
save_element: move r3, test_array_index //index ld r0, r3, 0 //load current index jumpr next_function, 0, eq // skip saving if array is full jump save_element1
now we can save the variables:
save_element1: sub r0, r0, 1 //decrement the array index st r0, r3, 0 //store the decremented array index move r3, variable //move to r3 pointer to variable to store move r2, test_array //move to r2 pointer to array add r2, r2, r0 //move to the array index, where we want to store ld r0, r3, 0 //load the value of the variable st r0, r2, 0 //store the variable into the array
We can also clean the array when it’s full:
move r1, test_array_index //Check if array_index is already 0, which means ld r0, r1, 0 //that the array is full, if is 0, jump to jumpr reset_array, 0, eq //reset_array reset_array: move r0, 512 //In this function we reset the index to 512 move r1, test_array_index st r0, r1, 0 jump clear_loop clear_loop: //Here we store 0 into each element of the array move r2, test_array add r2, r2, r0 move r1, 0 st r1, r2, 0 sub r0, r0, 1 jumpr next_function, 0, eq jump clear_loop
To print the array in the main processor code we can use an automatically created by the ESP-IDF in the build process header file called “ulp_main.h”. It creates definitions for all variables created by the ULP with the prefix “ulp_”. That means that after including the header file, we can access an ulp variable directly using its name. For example, the “variable” can be accessed by using “ulp_variable”. Our array can be printed with:
printf("array_index %ld \n", ulp_test_array_index); for(size_t i = 0; i < 512; i ++) { printf("%lx, ",(uint32_t)(&ulp_test_array)[i]); } printf("\n");
Debugging ESP32 ULP coprocessor applications requires a combination of creativity, ingenuity, and technical expertise. By employing the techniques and tools discussed in this article, developers can overcome the challenges associated with ULP debugging and harness the full potential of ESP32’s ultra-low-power capabilities. Embrace the opportunity to innovate and optimize ULP applications, paving the way for energy-efficient IoT solutions and intelligent sensing applications in diverse domains.