Description of the problem

You called serializeJson() and expected to get the following output:

{"hello":"world"}

but instead, you got:

{"hello":"�"}

or any other kind of strange output.

Why does this happen?

Garbage in the output is the sign of dangling pointers, i.e., pointers to destructed variables.

This problem occurs when:

  1. The JsonDocument is constructed with variables that are destroyed before the call to serializeJson() (examples 1 and 2).
  2. A JsonArray, JsonObject, or JsonVariant refers to a destructed JsonDocument (example 3).

Example 1: destructed string value

The following program fills a JsonDocument with a temporary String:

// DON'T DO THAT!!!  💀
doc["address"] = address.toString().c_str();
serializeJson(doc, Serial);  // <- likely to produce garbage

The problem is that the call to address.toString() produce a temporary String that is destructed as soon as the line is executed.

By calling String::c_str(), the program gets a pointer to the temporary string and gives it to ArduinoJson. Since ArduinoJson sees a const char*, it doesn’t duplicate the string and simply saves the pointer.

The problem can be avoided by removing the call to String::c_str():

doc["address"] = address.toString(); // <- duplicates
obj.printTo(Serial);

Now, ArduinoJson sees a String and knows that it needs to make a copy of the string in the JsonDocument.

Example 2: destructed input string

The following function deserializes a JSON document using the “zero-copy” mode, but doesn’t keep the input in memory:

void loadConfig(JsonDocument& doc){
  File file = SPIFFS.open("config.json", "r");

  // DON'T DO THAT!!!  💀
  size_t size = file.size();
  std::unique_ptr<char[]> buf (new char[size]);
  file.readBytes(buf.get(), size);
  deserializeJson(doc, buf.get());

  file.close();
}

Indeed, when called with a char* (or a char[]), deserializeJson() uses the zero-copy mode.
In this mode, the JsonDocument stores pointers to bytes in the input.
The zero-copy mode is very efficient, but it requires that the input buffer has a longer lifetime than the JsonDocument.

To fix this function, just change the type of input to something that is read-only. In this particular case, it’s possible to pass the file directly:

void loadConfig(DynamicJsonBuffer& jsonBuffer){
  File file = SPIFFS.open("config.json", "r");
  deserializeJson(doc, file);
  file.close();
  return root;
}

Now, ArduinoJson will duplicates the relevant pieces of the input in the JsonDocument.

If unlike this example, your input is not a Stream but a plain old char*, you can force ArduinoJson to make a copy by casting the pointer to a const char*:

deserializeJson(doc, (const char*)input);

Example 3: destructed JsonDocument

The following program creates a JsonObject from a temporary JsonDocument.

// DON'T DO THAT!!!  💀
JsonObject createObject() {
  StaticJsonDocument<200> doc;
  JsonObject obj = doc.to<JsonObject>();
  obj["hello"] = "world";
  return obj;
}

The JsonObject returned by this function points to a destructed JsonDocument, and therefore is likely to produce garbage or crash the program.

The best way to fix this function is to pass the JsonDocument as an argument:

JsonObject createObject(JsonDocument& doc) {
  JsonObject obj = doc.to<JsonObject>();
  obj["hello"] = "world";
  return obj;
}

See also