Chapter 287: Production Firmware Preparation

Chapter Objectives

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

  • Understand the critical differences between a development and a production firmware build.
  • Configure project settings for a release build using menuconfig.
  • Effectively manage logging levels and disable assertions to optimize performance and stability.
  • Select the appropriate compiler optimization level for size or performance.
  • Set and retrieve a production-ready application version for firmware tracking.
  • Generate and locate the final, consolidated binary file for factory flashing.

Introduction

Throughout this book, our focus has been on development. We use extensive logging to see what our code is doing, we use debug-friendly compiler settings to step through code, and we allow assertions to halt the device on critical errors. This environment is perfect for building and debugging, but it is entirely unsuitable for a product that will be shipped to customers.

Production firmware must be robust, reliable, efficient, and secure. It cannot simply stop if an unexpected input occurs, nor should it print verbose logs that slow down performance and potentially reveal internal workings. The process of converting a development-stage application into a production-ready one is known as “hardening.”

This chapter covers the essential steps for hardening your ESP-IDF application. We will transition from a debug configuration to a release configuration, focusing on the key settings that enhance performance, reduce firmware size, and increase the overall stability of your device in the field. This is the final and most crucial software step before your product is ready for manufacturing and deployment.

Theory

The Two Faces of Firmware: Debug vs. Production

A firmware build can have one of two personalities: one for the developer’s workbench and one for the outside world.

Feature Debug Build (Development) Production Build (Release)
Primary Goal Visibility & Ease of Debugging Reliability, Performance, & Efficiency
Logging Level Verbose (Info, Debug, Verbose) Minimal (Warning, Error)
Compiler Optimization -Og (Debug-friendly) -O2 (Performance) or -Os (Size)
Assertions Enabled (halts on error) Disabled (handles errors gracefully)
Binary Size Larger Smaller / Optimized
Performance Slower due to logging and lack of optimization Faster and more efficient
Use Case During active development and bug fixing Final firmware for customer devices
  • Debug Build: Optimized for visibility and ease of debugging. It includes extensive logging, checks for logical errors (assertions), and minimal code optimization so the compiled code closely matches the source code. This is the default configuration in ESP-IDF.
  • Production (Release) Build: Optimized for performance, size, and reliability. Logs are suppressed, assertions are disabled, and the compiler aggressively optimizes the code. This is the version you ship to customers.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%%
graph TD
    subgraph "Development Phase"
        direction LR
        A(Start: Project Code) --> B{Write and<br>Debug Code}
        B --> C["Run idf.py build<br>(Debug Config)"]
        C --> D((Debug))
        D -- "Fix Bugs" --> B
        C -- "Test & Iterate" --> B
    end

    subgraph "Production Hardening"
        P(Ready for Release) --> Q{Run idf.py menuconfig}
        Q --> R(Set Log Level to Warning/Error)
        Q --> S(Set Optimization to -O2 / -Os)
        Q --> T(Set Application Version)
        Q --> U(Disable Debug Features)
    end

    subgraph "Final Build & Deployment"
        V[Run idf.py clean] --> W["Run idf.py build<br>(Production Config)"]
        W --> X(Generate project.bin)
        X --> Y((Factory Flash))
    end

    A --> P
    T --> V
    R --> V
    S --> V
    U --> V

    classDef start fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6
    classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E
    classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B
    classDef endo fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46

    class A,P start
    class C,Q,R,S,T,U,V,W,X process
    class B decision
    class Y,D endo

Key Areas of Production Configuration

1. Logging Levels

As we’ve seen, the ESP_LOGx macros are invaluable for development. However, every call to ESP_LOGIESP_LOGD, or ESP_LOGV consumes CPU cycles to format the string and clock cycles to transmit it over UART. In a time-sensitive loop, this can significantly impact performance. In production, you should only log unrecoverable errors.

ESP-IDF defines the following log levels, in order of severity:

Level Macro Description Typical Use Case
Error (E) ESP_LOGE Critical, unrecoverable errors. The system may be unstable. Production: Essential. Logs fatal issues.
Warning (W) ESP_LOGW Potentially harmful situations that are not system-critical. Production: Recommended. Notes unexpected but handled events.
Info (I) ESP_LOGI High-level informational messages showing program flow. Development: Default level. Good for general progress tracking.
Debug (D) ESP_LOGD Detailed information for debugging specific functions. Development: Used for focused debugging of a module.
Verbose (V) ESP_LOGV Granular state information, most detailed level. Development: Used for deep-dive debugging of complex logic.

For production, the log level should typically be set to Warning or Error.

2. Compiler Optimizations

The C compiler is a powerful tool that can dramatically restructure your code to make it faster or smaller. These transformations are controlled by optimization flags.

Flag Focus Impact on Performance Impact on Size Best For
-Og Debugging Experience Lowest Largest Development (Default)
-O2 Execution Speed Highest Can be larger than -Os Production (CPU-intensive tasks)
-Os Binary Size High (often close to -O2) Smallest Production (Flash-constrained devices, faster OTA)
  • Debug (-Og): The default ESP-IDF setting. It applies minimal optimization but ensures the best possible debugging experience. The structure of the compiled code closely mirrors your source code, making breakpoints and variable inspection reliable.
  • Performance (-O2): A high level of optimization focused on execution speed. The compiler may unroll loops, inline functions, and reorder instructions. This often comes at the cost of a slightly larger binary size.
  • Size (-Os): Optimizes for the smallest possible binary size. This is crucial for devices with limited flash or for reducing OTA update transfer times. It provides good performance, but -O2 is generally faster.

Disabling optimizations is a development-only convenience. All production code should be compiled with either size or performance optimization enabled.

3. Assertions

An assertion, implemented via the assert() or ESP_GOTO_ON_FALSE macros, is a check that validates a condition that should always be true. For example, assert(pointer != NULL);. During development, if this condition is false, the program halts immediately, pointing you to the source of a critical bug.

In a production environment, halting is unacceptable. A deployed device must be resilient. If an unexpected condition occurs, the device should handle it gracefully—perhaps by resetting or entering a safe mode—but it should not simply stop working. Disabling assertions removes these hard stops. The compiler also completely removes the check from the code, resulting in a small performance gain.

4. Application Versioning

Every production firmware image must have a version. Versioning is essential for:

  • Tracking: Knowing exactly which version of the firmware is running on a customer’s device.
  • Updates: The OTA (Over-the-Air) update mechanism relies on version information to decide whether to download and apply a new firmware image.
  • Bug Reports: When a user reports an issue, the version number is the first piece of information you need for diagnosis.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%%
sequenceDiagram
    actor User as Device in Field
    participant Server as Update Server
    participant Bootloader as ESP32 Bootloader

    User->>Server: Check for new firmware<br>(Current Version: "v1.0.0")
    Server-->>User: Latest version is "v1.1.0"

    alt New Version Available
        User->>User: Download new firmware (v1.1.0.bin)
        User->>Bootloader: Signal to update on next boot
        User->>User: Reboot system
    else No Update Needed
        Server-->>User: You are up to date.
        User->>User: Sleep / Continue operation
    end

    Bootloader->>Bootloader: Validate new firmware image<br>(Check signature & integrity)
    Bootloader->>Bootloader: Read version from new image ("v1.1.0")
    Bootloader->>Bootloader: Compare with old version ("v1.0.0")
    Bootloader->>User: Activate new firmware partition
    User->>Server: Report successful update<br>(New Version: "v1.1.0")

ESP-IDF provides a standardized way to embed the application version directly into the firmware image via menuconfig. This version can be read programmatically at runtime.

5. The Consolidated Binary

When you build an ESP-IDF project, the build directory contains many files, including the application .elf file (which contains debug symbols), partition tables, and bootloader binaries. For a developer, this is fine.

For a factory, this is cumbersome and error-prone. The manufacturing process requires a single, monolithic binary file that contains the bootloader, partition table, and all application code. This file can be written to the flash memory of a new device in one simple step. The ESP-IDF build system can generate this consolidated project-name.bin file for you.

Practical Examples

Let’s walk through the process of configuring a standard project for production using the system configuration menu. We will use the blinker project from the previous chapter as our base.

1. Open the Configuration Menu

In your VS Code terminal, navigate to your project directory and run:

Bash
idf.py menuconfig

2. Set Compiler Optimization Level

This is one of the most impactful settings for production firmware.

  1. Navigate to Component config —> Compiler options —>.
  2. Select Optimization Level.
  3. Change the value from the default Debug (-Og) to Optimize for performance (-O2) or Optimize for size (-Os). For most applications, -O2 is a good choice unless you are severely constrained by flash size.
Plaintext
--- Compiler options
[*] Enable C++ exceptions
(s) Default C++ standard
(gnu++17) C++ language standard
(auto) Link-time optimization
(O2) Optimization Level --->
[ ] Enable LTO-based assertions
[ ] Enable stack smashing protection

Tip: Optimize for size (-Os) is often the best balance for IoT devices, as it significantly reduces the firmware size (which makes OTA updates faster and cheaper) while still providing excellent performance.

3. Adjust Logging Levels

Next, we will reduce the “chattiness” of our firmware.

  1. Go back to the top level, then navigate to Component config —> Log output —>.
  2. Select Default log verbosity.
  3. Change the value from Info to Warning or Error. This will be the default log level for all components that don’t specify their own.
Plaintext
--- Log output
[ ] Use colors in log output
(3) Timestamps source
(Error) Default log verbosity --->
[ ] Compile-time log verbosity control
(Info) Maximum log verbosity
[ ] Defer logging when flash operations are running
[ ] Allow using ESP_LOG_EARLY macros

Warning: It is generally better to set the Default log verbosity than to set the Maximum log verbosity. The latter completely removes any logging calls above that level from the compiled code, which can make it impossible to debug a device in the field even with a special command.

4. Set the Application Version

Now, let’s assign a formal version to our application.

  1. Go back to the top level, then navigate to Application Manager —>.
  2. Select Application version.
  3. Enter your production version string, for example, v1.0.0.
Plaintext
--- Application Manager
[*] Get the project version from git describe
(v1.0.0) Application version

5. Save and Exit

Press S to save your new configuration, then Q to quit menuconfig. The changes are saved in the sdkconfig file.

6. Accessing the Version in Code (Optional but Recommended)

It’s good practice to log the firmware version once upon startup. This confirms the correct version is running. Modify your main.c to include this check.

main/main.c

C
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_app_format.h" // Required for app description
#include "blinker.h"

#define BLINK_GPIO GPIO_NUM_2 
static const char *TAG = "MAIN";

void app_main(void)
{
    // Get the application description
    const esp_app_desc_t *app_desc = esp_app_get_description();
    ESP_LOGW(TAG, "Running app: %s, version: %s", app_desc->project_name, app_desc->version);

    // --- Rest of your app_main ---
    ESP_LOGI(TAG, "This INFO log will not be visible with default log level set to Warning.");
    
    esp_err_t ret = blinker_init(BLINK_GPIO);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "Blinker init failed: %s", esp_err_to_name(ret));
        return;
    }

    ret = blinker_start(2);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "Blinker start failed: %s", esp_err_to_name(ret));
    }

    ESP_LOGW(TAG, "app_main finished. Blinker is running.");
}

Notice we use ESP_LOGW to print the version. If we had set the default log level to Error, this message would not appear.

7. Build and Locate the Production Binary

  1. Clean your project. This is crucial to ensure no old object files from the debug build are left over.idf.py clean
  2. Build the project.idf.py build
  3. Observe the output. At the end of the build process, the log will show the location of the binaries. Look for the consolidated binary.... Project build complete. To flash, run this command: .../esp-idf/components/esptool_py/esptool/esptool.py -p (PORT) -b 460800 --before default_reset --after hard_reset --chip esp32 write_flash --flash_mode dio --flash_freq 40m --flash_size 2MB 0x8000 build/partition_table/partition-table.bin 0x1000 build/bootloader/bootloader.bin 0x10000 build/my_blinker_project.bin

The file you need for production is build/my_blinker_project.bin. This single file is what you provide to the factory programming station.

Variant Notes

The process described in this chapter—using menuconfig to adjust compiler settings, log levels, and versioning—is entirely a function of the ESP-IDF software framework. Therefore, the steps are 100% identical across all ESP32 variants, including ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, and ESP32-H2.

While the final binary will be different for each chip architecture (e.g., Xtensa for ESP32 vs. RISC-V for ESP32-C3), the method you use to prepare that binary for production remains the same. This is a powerful feature of the ESP-IDF, providing a consistent development and deployment experience across a wide range of hardware.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Forgetting to clean build Unpredictable behavior, crashes, mix of old and new code, features not updating as expected. Always run idf.py clean before building the final production binary to ensure a fresh build.
Using software delays Delays (e.g., for loops) don’t work or have inconsistent timing. The device runs faster than expected. The compiler (-O2/-Os) optimizes away “empty” loops. Use FreeRTOS delays like vTaskDelay(pdMS_TO_TICKS(1000)) for non-blocking delays.
Shipping the wrong file Factory reports flashing errors. The provided file is a .elf or other intermediate file. Provide the single, consolidated binary file from the build/ directory (e.g., my_project.bin). This is the only file needed for flashing.
Hardcoding version strings OTA updates fail. The device doesn’t report its version correctly through standard tools. Never use #define for versions. Always set the version in menuconfig under Application Manager to integrate with the ESP-IDF build and OTA system.
Disabling all logs A device fails in the field, and there is no information available to diagnose the problem remotely or from recovered units. Set default log level to Error or Warning, not “No output”. This provides a critical diagnostic lifeline without significant performance cost.
Assertions left enabled Device halts and reboots unexpectedly when a recoverable error occurs (e.g., receiving malformed data). Ensure assertions are disabled in the production build (this is the default when optimization is enabled). Add graceful error handling (e.g., if/else checks) instead of relying on assert().

Exercises

  1. Profile the Size Difference: Take a completed project (like the blinker project).a. Configure it with default debug settings (-Og, Info logging).b. Run idf.py clean and idf.py build. Note the size of the final .bin file reported at the end.c. Reconfigure for production (-Os, Warning logging).d. Run idf.py clean and idf.py build. Note the new binary size. Calculate the percentage of size reduction.
  2. Create a Production KConfig: In your project’s main directory, create a file named Kconfig.projbuild. Add a configuration option that allows you to enable or disable a specific feature. For example, a boolean CONFIG_ENABLE_ADVANCED_DIAGNOSTICS. In main.c, wrap a block of code that prints detailed system statistics (e.g., heap size) inside an #ifdef CONFIG_ENABLE_ADVANCED_DIAGNOSTICS. This demonstrates how to create features that can be fully compiled out for a production build by simply unchecking a box in menuconfig.
  3. Implement a Version Check: Write a function that is called from app_main. This function should use esp_app_get_description() to get the version. Using sscanf or another string parsing method, extract the major version number (the 1 from v1.0.0). If the major version is 0, log a warning: “This is a development build, not for production.” This simulates a safeguard to prevent accidental deployment of test firmware.

Summary

  • Production firmware must be configured differently from development firmware to be small, fast, and reliable.
  • Key production settings are configured in idf.py menuconfig.
  • Compiler Optimization: Change from Debug (-Og) to Optimize for performance (-O2) or Optimize for size (-Os).
  • Log Verbosity: Reduce the Default log verbosity to Warning or Error to minimize performance impact.
  • Application Version: Always set a semantic version (e.g., v1.2.3) in the Application Manager for tracking and OTA updates.
  • Build Process: Always run idf.py clean before building the final production image with idf.py build.
  • Final Artifact: The single, consolidated file (e.g., my_project.bin) found in the build directory is the correct binary to use for factory flashing.

Further Reading

Leave a Comment

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

Scroll to Top