Chapter 283: Code Size Optimization Techniques
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the critical importance of firmware binary size in embedded systems.
- Use ESP-IDF tools like
idf.py size
to analyze the size of your application. - Systematically configure ESP-IDF components via
menuconfig
to remove unused features. - Apply compiler optimizations specifically aimed at reducing code size, such as LTO.
- Manage logging levels and other features that contribute to binary bloat.
- Implement code-level practices that result in smaller firmware.
- Recognize how flash size constraints differ across ESP32 variants.
Introduction
In the world of desktop or mobile development, application size is a concern, but storage is measured in gigabytes. In the embedded world, storage is measured in megabytes, and every kilobyte is precious. A large firmware binary can increase product cost by requiring a bigger flash chip and, more critically, can make Over-the-Air (OTA) updates impossible.
OTA updates typically require two “app” partitions in flash memory: one for the currently running application and one to store the new version being downloaded. If your application binary is larger than a single OTA partition, you simply cannot perform an update. This chapter is dedicated to the essential skill of slimming down your firmware. We will move beyond performance optimization and focus entirely on techniques to make your application as lean as possible, from high-level component configuration down to compiler-level tricks.
Theory
1. Why Firmware Size is Critical
- OTA Updates: As mentioned, the most common partition scheme includes two OTA partitions. The application binary must be small enough to fit into one of them. A 4MB flash chip might only allocate 1.5MB for each application.
- Cost: Flash memory has a direct impact on the bill of materials (BOM). A smaller firmware footprint might allow you to select a cheaper, lower-capacity flash chip for your product.
- Data Storage: Every byte used by your application is a byte that cannot be used for something else, like a SPIFFS/FAT filesystem for storing assets, logs, or configuration files.
- Flash Speed: Larger flash chips can sometimes be slightly slower than their smaller counterparts, though this is a minor factor compared to the others.
2. Analyzing Your Application’s Size
Before you can reduce the size, you must measure it. ESP-IDF provides excellent tools for this.
graph TD A[Start: Build Project<br><br><b>idf.py build</b>] --> B{Check Total Size<br><br><b>idf.py size</b>}; B --> C{Is size acceptable?}; C -- Yes --> D[End: Done]; C -- No --> E[Analyze Components<br><br><b>idf.py size-components</b>]; E --> F{Identify Top 3-5<br>Largest Components<br><i>e.g., freertos, wifi, bt</i>}; F --> G[Go to <b>menuconfig</b><br>to disable unused<br>features in those components]; G --> A; %% Styling classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef endNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; class A,G startNode; class B,E,F processNode; class C decisionNode; class D endNode;
idf.py size
This command provides a top-level summary of your compiled application’s memory usage. It shows the size of different memory segments in the final .elf
file.
.text
: The “text” segment contains the executable machine code. This is the primary target for our size optimization efforts..data
: This segment contains initialized global and static variables. These values are copied from flash to DRAM at startup..rodata
: The “read-only data” segment, containing constants and string literals. It resides in flash..bss
: Contains uninitialized global and static variables. The linker only reserves space for this in DRAM; it doesn’t take up space in the binary on flash.
Segment | Contents | Location | Contributes to Flash Binary Size? |
---|---|---|---|
.text | Executable machine code (your functions). | Flash | ✔ Yes (Primary Target) |
.rodata | Read-only data, such as const variables and string literals. | Flash | ✔ Yes (Secondary Target) |
.data | Initialized global and static variables. | Copied from Flash to DRAM at boot. | ✔ Yes (but usually smaller than .text) |
.bss | Uninitialized global and static variables. | DRAM (zero-initialized at boot). | ✘ No. The linker just reserves space in RAM. |
Running idf.py size
produces output like this:
Total sizes:
DRAM .data size: 13588 bytes
DRAM .bss size: 27888 bytes
Used statics in DRAM: 41476 bytes ( 12% of 327680 bytes)
...
Flash .text size: 194835 bytes
Flash .rodata size: 53860 bytes
Total image size: 262283 bytes (.bin may be padded larger)
Our main goal is to reduce the Flash .text
and Flash .rodata
sizes.
idf.py size-components
This command gives a much more detailed, per-component breakdown of your firmware’s size, sorted from largest to smallest. It is the most valuable tool for identifying which parts of your application are consuming the most flash.
Total sizes:
...
Per-component contributions to ELF file:
(sorted from largest to smallest)
Component | ROM Data | ROM Text | DRAM .data | DRAM .bss | Flash .text | Flash .rodata
----------------|----------|----------|------------|-----------|-------------|---------------
freertos | 204 | 10619 | 540 | 2904 | 65103 | 2560
main | 0 | 0 | 12 | 32 | 35421 | 320
wifi | 72 | 5323 | 356 | 224 | 21011 | 4824
...
This immediately tells you where to focus your optimization efforts.
3. Strategic Configuration with menuconfig
The single most effective way to reduce code size is to tell ESP-IDF not to include code you don’t need. This is all done through idf.py menuconfig
.
- Compiler Optimization: As covered in Chapter 281, set the optimization level to
Optimize for size (-Os)
.Component config
—>Compiler options
—>Optimization Level
—>Optimize for size (-Os)
.
Optimization | menuconfig Path | Estimated Size Saving |
---|---|---|
Disable Bluetooth | Component config -> Bluetooth | Very High (~80-100 KB) |
Enable LTO | Component config -> Compiler options | High (~10-30 KB) |
Set Compiler to -Os | Component config -> Compiler options | Medium-High (~5-20 KB) |
Reduce Log Verbosity | Component config -> Log output | Medium (depends on code) |
Enable Nano Libc | Component config -> Newlib | Low-Medium (~1-5 KB) |
Disable Ethernet | Component config -> Ethernet | Low (if not used) |
- Disable Major Components:
- Bluetooth: If you are not using Bluetooth (Classic or LE), disable it completely. This is a huge saving.
Component config
—>Bluetooth
—> UncheckBluetooth
.
- Ethernet: If your device is Wi-Fi only, disable the Ethernet drivers.
Component config
—>Ethernet
—> UncheckSupport for external Ethernet PHYs
.
- Bluetooth: If you are not using Bluetooth (Classic or LE), disable it completely. This is a huge saving.
- Fine-Tune a “Minimal” libc: The standard C library can be large. ESP-IDF allows you to use a “nano” version that omits less common features (like full C99 format string support in
printf
).Component config
—>Newlib
—> EnableEnable nano-formatted newlib features
.
- Reduce Logging: Verbose logging adds many string literals to your
.rodata
section. For production builds, set the global logging level toError
orWarn
. You can still override this for specific components if needed.Component config
—>Log output
—>Default log verbosity
—>Error
.
- Disable Unused Features: Scrutinize the configuration for components you are using. For example, in the Wi-Fi component, you can disable features like WPA3, WPS, or Enterprise Authentication if your application doesn’t require them.
4. Advanced Compiler Technique: Link-Time Optimization (LTO)
LTO is a powerful optimization that the compiler performs at the final linking stage. Normally, the compiler optimizes one source file (.c
file) at a time. With LTO, the compiler looks at the entire program at once. This allows it to perform optimizations across file boundaries, such as inlining functions from one file into another and removing unused functions that might have otherwise been included. This can result in significant size savings.
graph LR subgraph "Standard Compilation" direction LR A1[file_a.c] --> B1(Compiler) A2[file_b.c] --> B1 B1 --> C1[file_a.o] B1 --> C2[file_b.o] C1 --> D1(Linker) C2 --> D1(Linker) D1 --> E1[Firmware.elf] style B1 fill:#DBEAFE,stroke:#2563EB style D1 fill:#DBEAFE,stroke:#2563EB end subgraph "Link-Time Optimization (LTO)" direction LR F1[file_a.c] --> G1(Compiler) F2[file_b.c] --> G1 G1 --> H1["file_a.o <br><i>(Intermediate Rep.)</i>"] G1 --> H2["file_b.o <br><i>(Intermediate Rep.)</i>"] H1 --> I1(Linker +<br><b>Global Optimizer</b>) H2 --> I1 I1 --> J1[Smaller Firmware.elf] style G1 fill:#DBEAFE,stroke:#2563EB style I1 fill:#D1FAE5,stroke:#059669,stroke-width:2px end
- How to Enable LTO:
idf.py menuconfig
Component config
—>Compiler options
—>Enable Link-Time Optimization (LTO)
.
Warning: LTO can significantly increase compilation time and memory usage during the build process. It is typically enabled only for final production builds.
Practical Example: Shrinking “Hello World”
Let’s see these techniques in action by taking the standard hello_world
example and making it as small as possible.
1. Establish a Baseline
- Create a new
hello_world
project:idf.py create-project -p . size_test
- Navigate into the
size_test
directory. - Build it with default settings:
idf.py build
. - Analyze the size:
idf.py size
.
You will get a baseline size. For an ESP32 with default settings, the total image size might be around ~260KB. Running idf.py size-components
will show that freertos
, wifi
, mbedtls
, and bt
are among the largest components.
2. Apply Optimizations
Now, let’s make the following changes using idf.py menuconfig
:
- Set Optimization Level:
Component config
->Compiler options
->Optimization Level
->Optimize for size (-Os)
. - Disable Bluetooth:
Component config
->Bluetooth
-> UncheckBluetooth
. - Set Logging to Error:
Component config
->Log output
->Default log verbosity
->Error
. - Enable Nano Libc:
Component config
->Newlib
-> EnableEnable nano-formatted newlib features
. - Enable LTO:
Component config
->Compiler options
-> EnableEnable Link-Time Optimization (LTO)
. - Save and exit
menuconfig
.
3. Rebuild and Observe
- Clean the project to ensure all changes are applied:
idf.py fullclean
. - Build again:
idf.py build
. - Analyze the new size:
idf.py size
.
After these changes, the total image size will be dramatically smaller, likely around ~150KB. You have just saved over 100KB of flash space without changing a single line of application code! This is often enough to solve OTA space issues.
Variant Notes
- Default Flash Size: Low-power variants often come with smaller flash chips by default. An ESP32-C3 dev kit might have only 2MB or 4MB of flash, making size optimization a necessity from the very beginning. High-performance variants like the ESP32-S3 are often paired with 8MB or 16MB of flash, giving you more breathing room.
- ROM Functions: Newer ESP32 variants (S2, S3, C3, etc.) have more extensive functionality baked into the chip’s ROM. This includes parts of the bootloader, FreeRTOS, and even Wi-Fi libraries. This is a significant advantage, as it means less code needs to be included in your application binary, leading to smaller firmware sizes out-of-the-box compared to the original ESP32 for the same functionality.
- Target-Specific Features: Chips like the ESP32-C6 and H2 include an IEEE 802.15.4 radio for protocols like Zigbee and Thread. If your application does not use these, disabling the
openthread
component inmenuconfig
will yield substantial size savings.
Variant | Typical Default Flash | Extensive ROM Functions? | Size Optimization Priority |
---|---|---|---|
ESP32 | 4 MB | ✘ No (Original) | Medium |
ESP32-S2 | 4 MB | ✔ Yes | Medium |
ESP32-S3 | 8 MB / 16 MB | ✔ Yes | Low (more space available) |
ESP32-C3 | 2 MB / 4 MB | ✔ Yes | High (less space available) |
ESP32-C6 | 4 MB | ✔ Yes | High (less space available) |
Common Mistakes & Troubleshooting Tips
Mistake / Issue | Symptom(s) | Troubleshooting / Solution |
---|---|---|
Linker error: “does not fit into partition” | The project fails at the final linking stage with an error message about the app partition being too small. | Your binary is too large for the OTA partition. Solution: Apply more size reduction techniques from this chapter. Use idf.py size-components to find what else can be removed or optimized. |
“Undefined reference to…” error after disabling a component. | The project fails to compile/link with errors about missing functions. | You disabled a component that your code (or another component) depends on. Solution: Re-enable the component mentioned in the error. Test your application thoroughly after making major changes in menuconfig. |
Size reduction changes don’t seem to apply. | You disable Bluetooth, but the binary size barely changes after rebuilding. | The build was not cleaned properly, and old object files are being reused. Solution: Run idf.py fullclean before running idf.py build to ensure all files are recompiled with the new settings. |
Application crashes with “Guru Meditation Error” at runtime. | The app builds and flashes, but crashes when a certain feature is used. | A feature was disabled in menuconfig that the code still tries to call. E.g., calling a Wi-Fi function after disabling the Wi-Fi component. Solution: Use #if CONFIG_… preprocessor guards in your code to avoid calling functions that may have been compiled out. |
Exercises
- Optimize the HTTP Server: Take the
protocols/http_server/simple
example from ESP-IDF. Follow the steps from the practical example in this chapter to reduce its size as much as possible. Document the starting size, the final size, and the percentage reduction you achieved. - Find the Bloat: Choose one of your own projects (or another example). Run
idf.py size-components
. Identify the top 3 largest components. For each one, go intomenuconfig
and see if there are any sub-features you can disable without breaking the application. - Investigate C++ Impact: Create a simple C++ “hello world” example using the ESP-IDF template (
idf.py create-project --lang cpp ...
). Compare its default size to the C version. Now, enable C++ exceptions (menuconfig
->Component config
->C++
->Enable C++ exceptions
). Rebuild and note the significant size increase. - Custom Partition Table: Read the ESP-IDF documentation on Partition Tables. Create a custom
partitions.csv
file that defines a smaller OTA partition size (e.g., 1.2MB). Try to compile your optimized HTTP server from exercise 1. Does it still fit? What error does the build system give if it doesn’t?
Summary
- Binary size is critical for OTA updates, cost, and available data storage.
- Always start by analyzing your firmware with
idf.py size
and, more importantly,idf.py size-components
. - The most effective size reduction tool is
menuconfig
. Systematically disable unused components (Bluetooth
) and features. - Set the compiler optimization level to
-Os
(Optimize for size) for production builds. - Reduce logging verbosity to minimize the number of strings stored in flash.
- For maximum savings, enable Link-Time Optimization (LTO), but be aware of longer compile times.
- Writing size-conscious code involves avoiding large static buffers and being mindful of language features that can increase binary size.
Further Reading
- ESP-IDF Documentation: Minimizing Binary Size: The definitive guide from Espressif.
- ESP-IDF Documentation: Link-Time Optimization:
- ESP-IDF Documentation: Partition Tables: