Chapter 258: Porting Code Between ESP32 Variants

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the role of the Hardware Abstraction Layer (HAL) in ESP-IDF.
  • Use Kconfig and sdkconfig to manage hardware-specific features.
  • Write portable code that can be compiled for multiple ESP32 variants with minimal changes.
  • Leverage preprocessor directives to handle code sections specific to a certain chip.
  • Identify common porting challenges and apply systematic debugging techniques.
  • Confidently migrate an existing project from one ESP32 variant to another.

Introduction

In the previous chapter, we learned how to select the right ESP32 variant for a new project. But what happens when you need to migrate an existing project to a new chip? Perhaps you started with an ESP32 and now want to create a lower-cost version using an ESP32-C3, or you need the AI capabilities of the ESP32-S3 for a V2 product.

Writing code that is tightly coupled to one specific piece of hardware is a common pitfall in embedded development. Fortunately, the ESP-IDF framework is designed with portability in mind. It provides powerful abstraction layers and configuration systems that, when used correctly, allow a single codebase to support multiple hardware targets.

This chapter will teach you the art of writing portable, cross-variant code. We will explore the tools and techniques within ESP-IDF that make this possible, transforming porting from a daunting task into a structured, manageable process.

Theory

The key to portability within the ESP-IDF ecosystem lies in understanding and utilizing three core components: the Hardware Abstraction Layer (HAL), the Kconfig System, and SoC Capabilities Headers.

1. The Hardware Abstraction Layer (HAL)

An abstraction layer is a way of hiding the complex, low-level details of hardware implementation behind a simpler, standardized Application Programming Interface (API). The ESP-IDF includes a HAL that provides a consistent set of functions for interacting with peripherals like GPIO, I2C, SPI, and Timers, regardless of the underlying chip.

graph TD
    subgraph "Portability Stack"
        direction TB
        App_Code("<b>Your Application Code</b><br><i>e.g., my_app.c</i>")
        HAL("<b>ESP-IDF HAL / Drivers</b><br><i>e.g., gpio_driver.c, i2c_driver.c</i>")
        Hardware("<b>Hardware Variants</b>")
    end


    subgraph Physical Layer
        direction LR
        HW_ESP32("ESP32<br>Xtensa LX6")
        HW_S3("ESP32-S3<br>Xtensa LX7")
        HW_C3("ESP32-C3<br>RISC-V")
        Hardware --- HW_ESP32
        Hardware --- HW_S3
        Hardware --- HW_C3
    end

    App_Code -- "Calls high-level API<br>e.g., <i>gpio_set_level()</i>" --> HAL
    HAL -- "Translates API call to<br>hardware-specific register writes" --> Hardware
    
    style App_Code fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E40AF
    style HAL fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6
    style Hardware fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E
    style HW_ESP32 fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B
    style HW_S3 fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B
    style HW_C3 fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B

For example, when you call the function gpio_set_level(GPIO_NUM_4, 1), you are using the GPIO driver API. This function is part of the abstraction layer. Internally, ESP-IDF translates this high-level command into the specific register writes required to set GPIO 4 high on whichever chip you are compiling for. The registers and low-level procedures for an ESP32 (Xtensa) are different from an ESP32-C3 (RISC-V), but your application code doesn’t need to know that.

Analogy: Think of the HAL as a universal remote control for your home entertainment system. You press the “Volume Up” button. You don’t care if the remote sends an infrared signal to a Sony TV or a radio frequency signal to a Bose soundbar. The remote (the HAL) knows which specific command to send to the target device (the hardware). Your interaction is with the simple, abstract button.

By writing your code exclusively against these high-level driver APIs, you automatically gain a high degree of portability.

2. Kconfig and Component Configuration

As we’ve learned, not all peripherals are available on all variants. The ESP32 has a DAC, but the ESP32-C3 does not. The ESP32-S3 has USB OTG, but the original ESP32 does not. How does the build system manage this?

The answer is the Kconfig system. The menuconfig tool generates a sdkconfig file that contains thousands of configuration flags. The build system uses these flags to conditionally compile code. If you are compiling for an ESP32-C3, the CONFIG_SOC_DAC_SUPPORTED flag will not be set. Any code inside the ESP-IDF drivers that uses the DAC peripheral is wrapped in a conditional block like this:

C
#ifdef CONFIG_SOC_DAC_SUPPORTED
// DAC driver implementation code...
#endif

If the flag is not defined, the compiler never even sees the DAC code, and you will get a build error if your application tries to call a DAC function. This prevents you from accidentally using hardware that doesn’t exist on your target.

graph TD
    A(Start) --> B{Run 'idf.py menuconfig'};
    B --> C["User configures settings<br>in terminal UI (e.g., set GPIO number)"];
    C --> D(Save & Exit);
    D --> E["<b>sdkconfig</b> file<br>is created or updated<br>e.g., <i>CONFIG_BLINK_GPIO=5</i>"];
    E --> F{Run 'idf.py build'};
    F --> G["Build system parses <b>sdkconfig</b>"];
    G --> H["Preprocessor checks<br>#ifdef, #if, etc.<br>using CONFIG_ flags"];
    H --> I["Compiler includes/excludes<br>code sections"];
    I --> J(Final binary is linked<br>for the specific target);

    style A fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6
    style J fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46
    style B fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E
    style F fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E
    style C fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style D fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style E fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style G fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style I fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style H fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B

3. SoC Capabilities and Preprocessor Directives

The Kconfig system is great for enabling or disabling entire drivers, but what about more subtle differences? For instance, the number of ADC channels or the number of timer groups can vary.

For this, ESP-IDF provides a set of header files that define the specific capabilities of each SoC. The most important one is soc/soc_caps.h. This header file contains hundreds of #define macros that describe the hardware features of the target chip.

Capability Macro (#if) ESP32 ESP32-S3 ESP32-C3 ESP32-C6 Description
SOC_CPU_CORES_NUM > 1 ✔️ (2) ✔️ (2) ❌ (1) ❌ (1) Checks if the chip is dual-core.
SOC_DAC_SUPPORTED ✔️ Checks for a true Digital-to-Analog Converter.
SOC_TWAI_SUPPORTED ✔️ ✔️ Checks for the TWAI (CAN bus) peripheral.
SOC_USB_OTG_SUPPORTED ✔️ Checks for the native USB OTG peripheral.
SOC_WIFI_6_SUPPORTED ✔️ Checks for Wi-Fi 6 (802.11ax) capability.
SOC_BLE_5_SUPPORTED ✔️ ✔️ ✔️ Checks for Bluetooth Low Energy 5.0 support.

For example:

  • SOC_ADC_CHANNEL_NUM(unit): Defines the number of ADC channels for a given unit.
  • SOC_TWAI_SUPPORTED: Defined if the TWAI (CAN) controller is present.
  • SOC_CPU_CORES_NUM: Defines the number of CPU cores.

You can use these macros in your own application code with #if or #ifdef directives to write code that adapts to the hardware at compile time. This is the primary technique for managing variations that fall outside the scope of standard driver APIs.

Practical Example: The Universal GPIO Blinker

Let’s create a simple project that blinks an LED but is designed to be portable. We will define a “system status LED” but allow the GPIO number to be easily changed for different development boards. We will also include a feature that can only be compiled for dual-core chips.

Project Goal: Create a Blinker app that compiles for ESP32, ESP32-S3, and ESP32-C3. On dual-core systems (ESP32, ESP32-S3), it will print a message from Core 1.

Step 1: Project Setup in VS Code

  1. Create a new ESP-IDF project in VS Code.
  2. Open the main/CMakeLists.txt file and ensure it looks standard:idf_component_register(SRCS "main.c" INCLUDE_DIRS ".")

Step 2: Create a Kconfig file for our component

  1. In the main directory, create a new file named Kconfig.projbuild.
  2. Add a configuration option for our LED pin:menu "Project Configuration" config BLINK_GPIO int "Blink GPIO Number" default 5 help The GPIO number to use for the status LED. Check your development board's schematic. endmenu

This allows us to set the GPIO number from menuconfig without changing the code.

Step 3: Write the Portable C Code

Replace the contents of main/main.c with the following:

C
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "sdkconfig.h" // Important: includes Kconfig definitions
#include "soc/soc_caps.h" // Important: includes hardware capabilities

static const char *TAG = "UNIVERSAL_BLINKER";

// This function will only be compiled on multi-core chips
#if SOC_CPU_CORES_NUM > 1
void core1_task(void *pvParameters)
{
    while(1) {
        ESP_LOGI(TAG, "Hello from Core 1! This is a dual-core chip.");
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}
#endif

void app_main(void)
{
    ESP_LOGI(TAG, "Starting Universal Blinker");
    ESP_LOGI(TAG, "Target Chip: %s", CONFIG_IDF_TARGET);

    // Use the GPIO defined in Kconfig
    gpio_num_t blink_gpio = CONFIG_BLINK_GPIO;

    // Configure the GPIO
    gpio_reset_pin(blink_gpio);
    gpio_set_direction(blink_gpio, GPIO_MODE_OUTPUT);

    // Create a task on Core 1 if the chip supports it
#if SOC_CPU_CORES_NUM > 1
    ESP_LOGI(TAG, "This chip has %d cores. Creating task for Core 1.", SOC_CPU_CORES_NUM);
    xTaskCreatePinnedToCore(core1_task, "Core1Task", 2048, NULL, 5, NULL, 1);
#else
    ESP_LOGI(TAG, "This is a single-core chip. No secondary task will be created.");
#endif

    bool led_state = 0;
    while (1) {
        gpio_set_level(blink_gpio, led_state);
        led_state = !led_state;
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

Step 4: Build and Run for Different Targets

  1. Set Target to ESP32:
    • Click the ESP-IDF target name in the VS Code status bar (it might say esp32esp32c3, etc.).
    • Select Set Espressif device target.
    • Choose esp32.
    • VS Code will reconfigure the project.
    • (Optional) Run ESP-IDF: Launch SDK Configuration Editor and check that our “Blink GPIO Number” option is there.
    • Build, Flash, and Monitor. You should see the “Hello from Core 1!” messages.
  2. Set Target to ESP32-C3:
    • Click the target name in the status bar again.
    • Select Set Espressif device target.
    • Choose esp32c3.
    • Wait for reconfiguration.
    • Build, Flash, and Monitor. This time, you will see the “single-core chip” message, and the core1_task function will not be included in the compiled binary. The LED will still blink correctly.

You have successfully written and deployed a single codebase to two vastly different architectures (dual-core Xtensa and single-core RISC-V) with zero code changes.

Variant Notes

  • Timers: The General Purpose Timers (GPT) have a different structure. The original ESP32 has 4 timers organized in two groups. The ESP32-C3 has 2 timers in one group. The ESP32-S3 has 4 timers in two groups. Always use SOC_TIMER_GROUP_NUM to check the number of timer groups.
  • ADC/DAC: The ADC capabilities differ significantly. Use SOC_ADC_SUPPORTED and check the number of channels and supported attenuation levels. Remember that only the original ESP32 and ESP32-S2 have a true DAC (SOC_DAC_SUPPORTED).
  • TWAI (CAN Bus): The TWAI peripheral is not available on all variants. The ESP32, ESP32-S2, and ESP32-S3 have it. The C-series and H-series do not. Always guard TWAI code with #ifdef SOC_TWAI_SUPPORTED.
  • Pin Numbering: While a GPIO number like 5 might exist on all chips, its physical location on a module can change. Even more critically, some GPIOs have specific strapping functions on certain chips or cannot be used. Porting a project often requires creating a new “board definition” file that maps functional names (e.g., STATUS_LED_PIN) to the correct GPIO numbers for that specific hardware layout.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Hardcoding Pin Numbers Project works on one board but not another. Need to find and replace all instances of a GPIO number to change hardware. High risk of error. Solution: Abstract pin numbers into a board_definitions.h file or use Kconfig. For example, define #define LED_PIN 5 and use LED_PIN everywhere in your code.
Ignoring #if for variant features Code compiles for one target but fails on another with “undefined function” or “undefined member” errors for peripherals like DAC, TWAI, or USB. Solution: Proactively guard code for non-universal peripherals. Wrap hardware-specific calls in #if SOC_..._SUPPORTED blocks after checking soc/soc_caps.h.
Forgetting to include headers Build fails with “‘CONFIG_…’ undeclared” or “‘SOC_…’ undeclared” errors. Solution: Always include the required headers. Add #include "sdkconfig.h" for CONFIG_ macros and #include "soc/soc_caps.h" for SOC_ macros.
Implicitly assuming dual-core Application crashes or behaves unexpectedly on single-core chips (C-series, H-series) when trying to pin tasks to Core 1. Solution: Any code related to multi-core operations (e.g., xTaskCreatePinnedToCore with a core ID of 1) must be wrapped in an #if SOC_CPU_CORES_NUM > 1 block.
Build configuration caching issues After switching targets (e.g., from ESP32-S3 to ESP32-C3), the build fails with strange errors or seems to use old settings. Solution: Run the ESP-IDF: Full Clean command in VS Code (or idf.py fullclean in the terminal). This removes all build artifacts and forces a fresh reconfiguration for the new target.

Exercises

  1. Universal ADC Reader: Modify the “Universal Blinker” project. Add a new Kconfig option to enable an ADC reading feature. If enabled, the app should read from an ADC channel (also defined in Kconfig). The code must compile for both ESP32 (which has 2 ADC units) and ESP32-C3 (which has 1 ADC unit) without modification. It should print a warning if the user tries to configure an invalid ADC unit for the target.
  2. DAC Sine Wave (Conditional): Create a project that generates a sine wave using the DAC. The code must compile and run correctly on an ESP32-S2 but also compile without errors (and do nothing DAC-related) on an ESP32-C6.
  3. Port an Existing Example: Take one of the official ESP-IDF examples (e.g., peripherals/i2c/i2c_simple) designed for the ESP32. Your task is to get it to compile and run on an ESP32-C3. Document the steps you took. Did you need to change any code? Why or why not?
  4. Board Definition Header: Create a my_board.h header file for two different boards (e.g., a generic ESP32-WROVER-KIT and an ESP32-C3-DevKitM). This header should define LED_PINI2C_SDA_PIN, and I2C_SCL_PIN. Write a single main.c that includes my_board.h and blinks the LED and initializes I2C. By only changing which board’s definitions are active (e.g., with an #if defined(BOARD_A)), the code should work on both.
  5. Feature Detection: Write an application that acts as a “hardware profiler.” When it boots, it should use the soc_caps.h macros to print a report to the serial monitor detailing the capabilities of the specific chip it’s running on. It should report: CPU cores, presence of Bluetooth Classic, presence of Wi-Fi 6, presence of USB OTG, and the number of RMT channels.

Summary

  • Writing portable code is crucial for long-term project maintenance and flexibility.
  • The ESP-IDF HAL is the primary tool for portability, providing consistent APIs across all variants.
  • Use Kconfig to define configurable parameters like GPIO pins, making your code adaptable without modification.
  • Use soc/soc_caps.h and preprocessor directives (#if#ifdef) to handle hardware differences at compile time.
  • Always abstract hardware-specific values (like pin numbers) out of your main application logic.
  • A structured porting process involves: changing the target, resolving compile-time errors using SoC caps, and adjusting Kconfig values for the new hardware layout.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top