You deploy a sensor node—perhaps a temperature monitor pushing JSON to an MQTT broker. It runs flawlessly on your workbench for an hour. You deploy it to the field. 24 hours later, the device hangs, reboots unexpectedly, or stops transmitting. You hit the physical reset button, and it works perfectly again... for another 24 hours.
This is not a hardware fault. It is almost certainly a memory management issue caused by the misuse of the standard Arduino String class. While convenient, the String object is a dangerous abstraction in embedded environments with limited SRAM (like the ATmega328P's 2KB).
The Root Cause: Heap Fragmentation
To understand why your device crashes, you must understand how memory is mapped on an AVR microcontroller. The SRAM is divided primarily into two dynamic regions:
- The Stack: Grows downward. Stores local variables, function parameters, and return addresses.
- The Heap: Grows upward. Stores dynamic memory allocated via
malloc()or thenewoperator.
The String class relies heavily on the Heap. Every time you concatenate a string (e.g., myStr += " value";), the microcontroller must:
- Allocate a new block of memory large enough for the combined string.
- Copy the old data and the new data into this block.
- Free the old block.
The Swiss Cheese Effect
In a loop running thousands of times, this constant allocation and deallocation creates Heap Fragmentation.
Imagine you have 100 bytes of free Heap. You allocate two 20-byte objects. You free the first one. You have 100 bytes of free space total, but your heap has a 20-byte "hole" at the start.
If you subsequently try to allocate a String of 30 bytes, the allocation will fail—even though you technically have enough total free RAM—because there is no contiguous block large enough to hold it.
When malloc fails on an Arduino:
- The function returns
NULL. - Most Arduino libraries do not check for
NULLpointers. - The program attempts to write to memory address 0.
- Crash.
The Solution: Deterministic Memory Management
The fix is to stop asking the runtime to find memory for you dynamically and instead tell the compiler exactly how much memory you need upfront. We replace dynamic String objects with Fixed-Size Character Arrays (C-Strings).
The Anti-Pattern (Do Not Do This)
This code looks innocent but is a fragmentation bomb. It reallocates the json object multiple times per loop iteration.
// BAD: Causes Heap Fragmentation
String createJSON(float temp, float humidity) {
String json = "{";
json += "\"temp\":";
json += String(temp);
json += ", \"hum\":";
json += String(humidity);
json += "}";
return json; // Returns a copy, causing more allocation
}
void loop() {
float t = readTemp();
float h = readHum();
// Each call churns the Heap
String payload = createJSON(t, h);
Serial.println(payload);
delay(1000);
}
The Fix: snprintf and Stack Allocation
We solve this using snprintf (String N-Print Formatted). This function formats a C-string into a pre-allocated buffer.
- Pre-allocate: We define a
chararray with a fixed size. - Format safely: We use
snprintfto ensure we never write more bytes than the buffer holds (preventing buffer overflows). - Zero Allocation: Because the buffer is a local variable, it lives on the Stack. When the function exits, the Stack pointer simply moves back. No Heap searching, no holes, no fragmentation.
// GOOD: Deterministic Memory Usage
#include <cstdio> // Standard C IO library
// Define a maximum buffer size based on your expected data
const size_t JSON_BUFFER_SIZE = 64;
void sendTelemetry(float temp, float humidity) {
// 1. Allocate buffer on the STACK.
// This memory is automatically reclaimed when the function returns.
char buffer[JSON_BUFFER_SIZE];
// 2. Format the string.
// %d = int, %s = string, %.2f = float with 2 decimal places
// Note: Standard Arduino AVR usually doesn't support %f in snprintf by default.
// For AVR (Uno/Nano), we often use dtostrf, but for ESP32/ARM, %f works.
// AVR Workaround for floats:
char t_str[8];
char h_str[8];
dtostrf(temp, 4, 2, t_str); // width 4, precision 2
dtostrf(humidity, 4, 2, h_str);
int bytesWritten = snprintf(
buffer,
JSON_BUFFER_SIZE,
"{\"temp\":%s, \"hum\":%s}",
t_str,
h_str
);
// 3. Error Checking
// snprintf returns the number of characters that WOULD have been written
if (bytesWritten >= JSON_BUFFER_SIZE) {
Serial.println(F("Error: Buffer too small for JSON payload"));
return;
}
// 4. Use the buffer
Serial.println(buffer);
}
void loop() {
float t = 24.55;
float h = 60.00;
sendTelemetry(t, h);
delay(1000);
}
Note: If you are using an ESP32 or Teensy (ARM architecture), you can skip dtostrf and use %.2f directly inside snprintf.
ESP32 / Modern C++ Implementation
If you are running on more powerful hardware (ESP32, STM32) where standard library support is complete, the code becomes even cleaner:
// ESP32 / ARM Version
void sendTelemetryModern(float temp, float humidity) {
char buffer[64];
// Direct float formatting supported
snprintf(buffer, sizeof(buffer), "{\"temp\":%.2f, \"hum\":%.2f}", temp, humidity);
Serial.println(buffer);
}
Why This Fix Works
1. Stack vs. Heap
In the fixed version, char buffer[64] is a local variable. The compiler knows exactly 64 bytes are required. When sendTelemetry is called, the stack pointer decrements by 64 bytes. When the function returns, the stack pointer increments by 64 bytes. This is O(1) complexity and purely deterministic.
2. No Memory Leaks
Since we are not manually calling malloc or new, we do not need to call free or delete. The scope of the variable dictates its lifecycle.
3. Buffer Safety
The n in snprintf stands for size. By passing sizeof(buffer), we guarantee that even if the data is massive, we will simply truncate the string rather than overwriting adjacent memory addresses (a common security vulnerability and crash cause known as Buffer Overflow).
Conclusion
The String object is useful for quick prototyping or infrequent operations. However, for the main loop of a long-running embedded system, it is technically unsuitable.
By transitioning to fixed-size char arrays and snprintf, you move your memory load from the fragmented Heap to the managed Stack. This change transforms your device from a prototype that crashes daily into a professional-grade embedded system that can run for years.