Programming The ESP32 In C - Direct To GPIO
Written by Harry Fairhead   
Wednesday, 15 October 2025

Using direct access to registers you can do almost anything you want to with GPIO lines. This is an extract from Harry Fairhead's book on programming the ESP32 using C and the Espressif IDF.

Programming The ESP32 In C
Using The Espressif IDF

By Harry Fairhead

espC360

Available as a softback, hardback and kindle from Amazon

Contents

       Preface

  1. The ESP32 – Before We Begin
  2. Getting Started
  3. Getting Started With The GPIO 
  4. Simple Output
     
    Extract: Phased Pulses ***NEW!
  5. Some Electronics
  6. Simple Input
  7. Advanced Input – Interrupts
  8. Pulse Width Modulation
         Extract: PWM First Example
  9. The Motor Control PWM
         Extact: MCPWM First Example 
  10. Controlling Motors And Servos
  11. Getting Started With The SPI Bus
  12. Using Analog Sensors
  13. Using The I2C Bus
  14. One-Wire Protocols
         
    Extract: The S3's RGB LED 
  15. The Serial Port
  16. Using WiFi
          Extract:Socket Web Client
  17. Direct To The Hardware
  18. Free RTOS 

<ASIN:1871962919>

<ASIN:187196282X>

Direct To The Hardware

The SDK provides functions to let you access most of the hardware features of the ESP32 by way of memory-mapped registers. They are very simple wrappers around the basic mechanism of working with the hardware. You might think that bypassing the SDK and doing the job directly via hardware access would be attractive by virtue of being more efficient – it isn’t. The SDK is such a light wrapper over the hardware that there is very little point in trying to gain the few fractions of a microsecond that direct access provides. The obvious reason for knowing how to use memory-mapped registers is for situations where the SDK doesn’t provide a function that does what you want. Perhaps a better reason is just to know how things work!

In this chapter we take a look at how the ESP32 presents its hardware for you to use and how to access it via basic software.

Registers

Some processors have special ways of connecting devices, but the ESP32’s processor uses the more common memory-mapping approach. In this, each external device is represented by a set of memory locations or “registers” that control it. Each bit in the register controls some aspect of the way the device behaves. Groups of bits also can be interpreted as short integers which set operating values or modes.

How do you access a register? Simply by storing the values in it or by assigning its value to a variable. This is nothing new in C. The big difference is that you now have to refer not to a memory location provided by the system, but to a fixed address provided by the documentation. You still use a pointer, but one that is initialized by a constant or literal.

The only difficult part is in working out the address you need to use and the value that sets or resets the bits you need to modify. For example, if you look in the documentation for the ESP32 you will find that the GPIO registers start at address 0x3FF44000. However, if you look up the starting address for the ESP32 S3, you will find that they start at 0x60004000. You cannot assume that all versions of the ESP32 have the same memory map, but you can assume that the main the registers mostly work in the same way. The registers are defined by the offset from their starting address or an absolute address.

For the ESP32, the start of the table of GPIO registers is: 

Name

Description

Address

Access

GPIO_OUT_REG

GPIO 0-31 output register

0x3FF44004

R/W

GPIO_OUT_W1TS_REG

GPIO 0-31 output register_W1TS

0x3FF44008

WO

GPIO_OUT_W1TC_REG

GPIO 0-31 output register_W1TC

0x3FF4400C

WO

 

This gives an offset of 0x4, 0x8 and 0xC for each register. This is also true for the ESP32 S3, but the offsets are relative to 0x60004000 giving addresses of 0x60004004, 0x60004008 and 0x6000400C respectively.

The three registers control the GPIOs in output mode. How the GPIO line gets into output mode is a matter of using other registers described later in the table, but if we assume that the GPIO line is fully configured in output mode then these three registers control the state of GPIO0 to GPIO31. There are three similar registers for GPIO32 to GPIO39.

The big problem in making use of this information is that the “Description” part of the table is cryptic and often incomplete. You almost have to know what sorts of things the register is used for before it makes any sense. The first register is simple – if you write a 1 to bit n then GPIOn will be set active, usually high voltage, and if you write a 0 to bit n then the line is deactivated, usually low voltage. The other two registers are slightly more difficult to understand due to the use of W1TS and W1TC – which stand for Write One To Set and Write One To Clear. Once you know this, it is obvious that the first register is a bit-set register and the second a bit-clear register. That is, if you write a 1 to bit n using the W1TS register then GPIOn will be set active, but if you write it using the W1TC register, GPIOn will be deactivated.

You might wonder why we need three registers to control the GPIO lines? It is true that you don’t need anything beyond the first, but the other two make things easier. By writing a bit pattern to GPIO_OUT_REG you set or reset all of the GPIO lines depending on whether there is a 1 or a 0 at bit n. If you only want to change a subset of lines, then you have to read the current state of the lines, notice whether GPIO_OUT_REG has read or write access, and then modify just the bits corresponding to the lines you want to change. This isn’t difficult, but you can avoid having to do this by using GPIO_OUT_W1TS_REG with a bit pattern that sets just the lines that correspond to a 1 or GPIO_OUT_W1TC_REG which resets the same lines.

This becomes easier to understand after an example.

Blinky Revisited

Now we can re-write Blinky yet again, but this time using direct access to the GPIO registers.

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "driver/gpio.h"
void app_main(void)
{
    uint32_t* GPIObase = (uint32_t*)0x60004000;//EP32 S3
    //uint32_t* GPIObase = (uint32_t*)0x3FF44000; //ESP32
    gpio_reset_pin(2);
    gpio_set_direction(2, GPIO_MODE_OUTPUT);
    uint32_t* GPIOSet = GPIObase + 8 / 4; 
    uint32_t* GPIOClear = GPIObase + 0xC / 4; 
    uint32_t mask = 1 << 2;
    while (true) {
        *GPIOSet = mask;
        vTaskDelay(1000 / portTICK_PERIOD_MS);
        *GPIOClear = mask;
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

This program uses the standard function to set the GPIO line to output. If you think that this is cheating, it is an exercise in setting the line correctly using the GPIO control register.

The GPIOBase address has to be set correctly for the processor we are running the program on. Notice that as we are using pointers to uint32_t pointer arithmetic works in multiples of 4 hence to move the address on by 4 we just add 1 and in general to set an offset of x we add x/4.

To toggle GPIO2 we make use of the set and clear registers and a mask that has bit 2 set to 1. Notice that 1<<n is a bit pattern with bit n set to 1. Alternatively you could use:

mask = 0x02

Once we have the mask, the loop simply stores it in the set and clear register alternately. Notice that as only bit 2 is a 1 this only changes the state of GPIO2. 

This raises the question of how fast is this direct manipulation of the GPIO line’s state?

Changing the loop to read:

    while (true) {
        *GPIOSet = mask;
        *GPIOClear = mask;
    }

reveals that the fastest pulses are 60ns, i.e. 8.3MHz. This is about six times faster than using the SDK and more than 30 times faster than using MicroPython.

GPIO_REG.h

The need to adjust addresses depending on which version of the ESP32 is being used is a nuisance, but it can be avoided. The header file gpio_reg.h contains definitions for many of the hardware registers adjusted for the device in use. For example:

#define GPIO_OUT_W1TS_REG   
#define GPIO_OUT_W1TC_REG      

give the correct address for the registers accounting for the target device. This allows you to write code that works on any device. For example, the Blinky program can be written:

#include <stdio.h>
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "soc/gpio_reg.h"
void app_main(void)
{
    gpio_reset_pin(2);
    gpio_set_direction(2, GPIO_MODE_OUTPUT);
    uint32_t mask = 1 << 2;
    while (true) {
        *(int32_t*)GPIO_OUT_W1TS_REG = mask;
        vTaskDelay(1000 / portTICK_PERIOD_MS);
        *(int32_t*)GPIO_OUT_W1TC_REG = mask;
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

This should work on any model of ESP32 without changes, but it would have to be recompiled with an appropriate target set. The only downside of this approach is that gpio_reg.h is still under development and hence may change.

Example 1 - Simultaneous Setting of GPIO Lines

The SDK only provides functions to change single GPIO lines at a time, but it is very easy to create functions that set multiple lines at a time. A set function simply writes the mask to the GPIO_OUT_W1TS_REG register:

void gpio_setgroup(uint32_t mask) {
    *(int32_t*)GPIO_OUT_W1TS_REG = mask;
}

A clear function is just as easy and writes to the GPIO_OUT_W1TC_REG register:

void gpio_cleargroup(uint32_t mask) {
    *(int32_t*)GPIO_OUT_W1TC_REG = mask;
}

As before, only the set bits in the mask are affected.

While these two functions operate on single GPIO lines, you often want to select a set of bits and set or clear them in one operation, for example, when you want to change two or more GPIO lines in phase, i.e. all high or all low, you can use:

gpio_setgroup(0x3)
gpio_cleargroup(0x3)

This operates on the bottom two bits and so toggles GPIO0 and GPIO1, with both turning on and off at exactly the same time.

Now consider how to set GPIO0 high when GPIO1 is low. What we need is a function that will set any group of GPIO lines to 0 or 1 at the same time:

void gpio_value_mask(int32_t value, int32_t mask) {
   *(int32_t*)GPIO_OUT_REG  = 
(*(int32_t*)GPIO_OUT_REG & ~mask) | (value & mask); }

The mask gives the GPIO lines that need to be changed, i.e. it determines the group and the value gives the state they are to be set to. For example, if mask is 0111 and value is 0100 and the low four bits of the register are 1010 then reg & ~mask is 1000, value & mask is 0100 and finally reg | value is 1100. You can see that bits 0 to 3 have been set to 100 and bit 4 has been unchanged. 

The trick to working out how to do this is to construct one mask to set the bits that need to be set and another to unset the bits that need to be unset. If a bit is to be set, it needs a 1 in the mask and a 1 in the data and the mask to set bits is:

setmask = mask & data

If a bit is to be unset it needs a 1 in the mask and 0 in the data, so the mask to reset bits is:

resetmask = mask & ~data

Applying both to the value gives the required result:

(value | setmask) & ~(resetmask) =
             (value | (mask & data)) & ~(mask & ~ data)

which, after simplification, is:

value & ~mask | mask & data

Using this it is easy to create a function to do the job.

As demonstrated in Chapter 4, the value, mask function can be used to set GPIO lines simultaneously:

#include <stdio.h>
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "soc/gpio_reg.h"
void gpio_value_mask(int32_t value, int32_t mask) {
    *(int32_t*)GPIO_OUT_REG  =
(*(int32_t*)GPIO_OUT_REG & ~mask) | (value & mask); } void app_main(void) { gpio_reset_pin(2); gpio_set_direction(2, GPIO_MODE_OUTPUT); gpio_reset_pin(4); gpio_set_direction(4, GPIO_MODE_OUTPUT); uint32_t mask = (1 << 2) | (1 << 4); uint32_t value1=(1 << 2); uint32_t value2=(1 << 4); while (true) { gpio_value_mask(value1,mask); gpio_value_mask(value2,mask); } }


As we are changing the same pins each time, we only need a single mask. The value, however, changes each time. If you run this program you will see an almost perfect pair of out-of-phase 188ns pulses

pulsephase2

In chapter but not in this extract

  • Example II – PWM LEDC Rollover 
  • Keeping Time
  • Sleep
  • Wake Using ULP
  • Wake Using EXT0 and EXT1
  • Wake Using TouchPads
  • Watchdog Timer
  • Flash Memory
  • Creating Partitions – Adding FAT
  • Non-Volatile Storage
  • The FAT File System
  • External SD
  • Digging Deeper

Summary

  • All of the ESP32’s peripherals, including the GPIO lines, are controlled by registers. These are special memory locations that you write and read to configure and use the hardware.

  • Exactly where the registers are positioned in the address space is usually given in the documentation as a base address used for all of the similar registers and an offset that has to be added to the base to get the address of a particular register.

  • With knowledge of how things work, you can add functions that are missing from the ESP-IDF.

  • You can also use features of peripherals that are not supported, such as changing GPIO lines at the same time or detecting when the PWM timer wraps.

  • There is a Real Time Clock, RTC, that you can set using SNTP.

  • If you want to use the ESP32 with a battery source then you need to work with power-saving modes.

  • Low-power modes are implemented as part of the RTC. Some GPIO lines have low-power counterparts the RTC GPIO.

  • Light sleep is easy to work with because it saves the current state of the system and you can restart your program from where it entered light sleep.

  • Deep sleep saves more power, but the CPU is switched off and the system loses track of its state. The entire system is restarted when it wakes up and your program has to restore its state.

  • The system can be woken up either by a set time, a change in RTC GPIO lines or a touch input.

  • The watchdog timer can be used to make your program reliable.

  • You can work with the ESP32’s internal flash memory as partitions. You can install file systems onto partitions and then work with files.

  • The NVS file system allows you to save key value pairs to the internal flash memory.

  • If you add an external SD card reader, you can work with an SD card using the same techniques as used for the internal flash memory.

 

Programming The ESP32 In C
Using The Espressif IDF

By Harry Fairhead

espC360

Available as a softback, hardback and kindle from Amazon

Contents

       Preface

  1. The ESP32 – Before We Begin
  2. Getting Started
  3. Getting Started With The GPIO 
  4. Simple Output
     
    Extract: Phased Pulses ***NEW!
  5. Some Electronics
  6. Simple Input
  7. Advanced Input – Interrupts
  8. Pulse Width Modulation
         Extract: PWM First Example
  9. The Motor Control PWM
         Extact: MCPWM First Example 
  10. Controlling Motors And Servos
  11. Getting Started With The SPI Bus
  12. Using Analog Sensors
  13. Using The I2C Bus
  14. One-Wire Protocols
         
    Extract: The S3's RGB LED 
  15. The Serial Port
  16. Using WiFi
          Extract:Socket Web Client
  17. Direct To The Hardware
  18. Free RTOS 

<ASIN:1871962919>

<ASIN:187196282X>

pico book

 

Comments




or email your comment to: comments@i-programmer.info

To be informed about new articles on I Programmer, sign up for our weekly newsletter, subscribe to the RSS feed and follow us on Twitter, Facebook or Linkedin.

Banner

Last Updated ( Wednesday, 15 October 2025 )