Procházet zdrojové kódy

Store static strings in a dedicated pool

Because a slot id is smaller than a pointer, this change will ultimately allow reducing the slot size.
Benoit Blanchon před 1 rokem
rodič
revize
cc077c1b63
36 změnil soubory, kde provedl 282 přidání a 91 odebrání
  1. 37 0
      CHANGELOG.md
  2. 1 1
      extras/tests/Deprecated/BasicJsonDocument.cpp
  3. 6 0
      extras/tests/Helpers/Allocators.hpp
  4. 1 0
      extras/tests/JsonArray/add.cpp
  5. 3 1
      extras/tests/JsonDeserializer/destination_types.cpp
  6. 5 3
      extras/tests/JsonDeserializer/filter.cpp
  7. 1 0
      extras/tests/JsonDocument/ElementProxy.cpp
  8. 7 2
      extras/tests/JsonDocument/MemberProxy.cpp
  9. 1 0
      extras/tests/JsonDocument/add.cpp
  10. 2 0
      extras/tests/JsonDocument/constructor.cpp
  11. 2 2
      extras/tests/JsonDocument/remove.cpp
  12. 3 1
      extras/tests/JsonDocument/set.cpp
  13. 15 4
      extras/tests/JsonDocument/shrinkToFit.cpp
  14. 1 2
      extras/tests/JsonDocument/subscript.cpp
  15. 5 17
      extras/tests/JsonObject/set.cpp
  16. 12 6
      extras/tests/JsonObject/subscript.cpp
  17. 4 2
      extras/tests/JsonVariant/copy.cpp
  18. 20 2
      extras/tests/JsonVariant/set.cpp
  19. 1 1
      extras/tests/JsonVariantConst/subscript.cpp
  20. 1 1
      extras/tests/Misc/StringAdapters.cpp
  21. 3 1
      extras/tests/MsgPackDeserializer/destination_types.cpp
  22. 1 0
      extras/tests/ResourceManager/CMakeLists.txt
  23. 4 4
      extras/tests/ResourceManager/StringBuffer.cpp
  24. 14 13
      extras/tests/ResourceManager/StringBuilder.cpp
  25. 47 0
      extras/tests/ResourceManager/saveStaticString.cpp
  26. 13 0
      src/ArduinoJson/Memory/MemoryPool.hpp
  27. 9 0
      src/ArduinoJson/Memory/MemoryPoolList.hpp
  28. 24 1
      src/ArduinoJson/Memory/ResourceManager.hpp
  29. 2 2
      src/ArduinoJson/Object/JsonPair.hpp
  30. 1 1
      src/ArduinoJson/Object/ObjectImpl.hpp
  31. 3 2
      src/ArduinoJson/Strings/Adapters/RamString.hpp
  32. 1 2
      src/ArduinoJson/Strings/JsonString.hpp
  33. 2 2
      src/ArduinoJson/Variant/ConverterImpl.hpp
  34. 0 3
      src/ArduinoJson/Variant/VariantContent.hpp
  35. 8 11
      src/ArduinoJson/Variant/VariantData.hpp
  36. 22 4
      src/ArduinoJson/Variant/VariantImpl.hpp

+ 37 - 0
CHANGELOG.md

@@ -1,6 +1,43 @@
 ArduinoJson: change log
 =======================
 
+HEAD
+----
+
+* Optimize storage of static strings
+
+> ### BREAKING CHANGES
+>
+> Static string cannot contain NUL characters anymore (they could since 7.3.0).
+> This is an extremely rare case, so you probably won't be affected.
+>
+> For example, the following code produces different output in 7.3 and 7.4:
+>
+> ```cpp
+> JsonDocument doc;
+> doc["a\0b"] = "c\0d";
+> serializeJson(doc, Serial);
+> // With Arduino 7.3 -> {"a\u0000b":"c\u0000d"}
+> // With Arduino 7.4 -> {"a":"c"}
+> ```
+>
+> `JsonString` contructor now only accepts two arguments, not three.
+> If your code uses `JsonString` to store a string as a pointer, you must remove the size argument.
+>
+> For example, if you have something like this:
+>
+> ```cpp
+> doc["key"] = JsonString(str.c_str(), str.size(), true);
+> ```
+>
+> You must replace with either:
+>
+> ```cpp
+> doc["key"] = JsonString(str.c_str(), true);  // store as pointer, cannot contain NUL characters
+> doc["key"] = JsonString(str.c_str(), str.size()); // store by copy, NUL characters allowed
+> doc["key"] = str; // same as previous line for supported string classes (`String`, `std::string`, etc.)
+> ```
+
 v7.4.2 (2025-06-20)
 ------
 

+ 1 - 1
extras/tests/Deprecated/BasicJsonDocument.cpp

@@ -54,7 +54,7 @@ TEST_CASE("BasicJsonDocument") {
     doc["hello"] = "world";
     auto copy = doc;
     REQUIRE(copy.as<std::string>() == "{\"hello\":\"world\"}");
-    REQUIRE(allocatorLog == "AA");
+    REQUIRE(allocatorLog == "AAAA");
   }
 
   SECTION("capacity") {

+ 6 - 0
extras/tests/Helpers/Allocators.hpp

@@ -275,6 +275,12 @@ inline size_t sizeofPool(
   return MemoryPool<VariantData>::slotsToBytes(n);
 }
 
+inline size_t sizeofStaticStringPool(
+    ArduinoJson::detail::SlotCount n = ARDUINOJSON_POOL_CAPACITY) {
+  using namespace ArduinoJson::detail;
+  return MemoryPool<const char*>::slotsToBytes(n);
+}
+
 inline size_t sizeofStringBuffer(size_t iteration = 1) {
   // returns 31, 63, 127, 255, etc.
   auto capacity = ArduinoJson::detail::StringBuilder::initialCapacity;

+ 1 - 0
extras/tests/JsonArray/add.cpp

@@ -56,6 +56,7 @@ TEST_CASE("JsonArray::add(T)") {
     REQUIRE(array[0].is<int>() == false);
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 

+ 3 - 1
extras/tests/JsonDeserializer/destination_types.cpp

@@ -104,6 +104,8 @@ TEST_CASE("deserializeJson(MemberProxy)") {
 
     REQUIRE(err == DeserializationError::Ok);
     REQUIRE(doc.as<std::string>() == "{\"hello\":\"world\",\"value\":[42]}");
-    REQUIRE(spy.log() == AllocatorLog{});
+    REQUIRE(spy.log() == AllocatorLog{
+                             Allocate(sizeofStaticStringPool()),
+                         });
   }
 }

+ 5 - 3
extras/tests/JsonDeserializer/filter.cpp

@@ -825,7 +825,9 @@ TEST_CASE("shrink filter") {
 
   deserializeJson(doc, "{}", DeserializationOption::Filter(filter));
 
-  REQUIRE(spy.log() == AllocatorLog{
-                           Reallocate(sizeofPool(), sizeofObject(1)),
-                       });
+  REQUIRE(spy.log() ==
+          AllocatorLog{
+              Reallocate(sizeofPool(), sizeofObject(1)),
+              Reallocate(sizeofStaticStringPool(), sizeofStaticStringPool(1)),
+          });
 }

+ 1 - 0
extras/tests/JsonDocument/ElementProxy.cpp

@@ -31,6 +31,7 @@ TEST_CASE("ElementProxy::add()") {
     REQUIRE(doc.as<std::string>() == "[[\"world\"]]");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 

+ 7 - 2
extras/tests/JsonDocument/MemberProxy.cpp

@@ -25,6 +25,7 @@ TEST_CASE("MemberProxy::add()") {
     REQUIRE(doc.as<std::string>() == "{\"hello\":[42]}");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 
@@ -34,6 +35,7 @@ TEST_CASE("MemberProxy::add()") {
     REQUIRE(doc.as<std::string>() == "{\"hello\":[\"world\"]}");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 
@@ -44,6 +46,7 @@ TEST_CASE("MemberProxy::add()") {
     REQUIRE(doc.as<std::string>() == "{\"hello\":[\"world\"]}");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                              Allocate(sizeofString("world")),
                          });
   }
@@ -55,8 +58,8 @@ TEST_CASE("MemberProxy::add()") {
     REQUIRE(doc.as<std::string>() == "{\"hello\":[\"world\"]}");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                              Allocate(sizeofString("world")),
-
                          });
   }
 
@@ -71,6 +74,7 @@ TEST_CASE("MemberProxy::add()") {
     REQUIRE(doc.as<std::string>() == "{\"hello\":[\"world\"]}");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                              Allocate(sizeofString("world")),
                          });
   }
@@ -399,7 +403,7 @@ TEST_CASE("MemberProxy under memory constraints") {
   }
 
   SECTION("value slot allocation fails") {
-    timebomb.setCountdown(1);
+    timebomb.setCountdown(2);
 
     // fill the pool entirely, but leave one slot for the key
     doc["foo"][ARDUINOJSON_POOL_CAPACITY - 4] = 1;
@@ -412,6 +416,7 @@ TEST_CASE("MemberProxy under memory constraints") {
     REQUIRE(doc.overflowed() == true);
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                              AllocateFail(sizeofPool()),
                          });
   }

+ 1 - 0
extras/tests/JsonDocument/add.cpp

@@ -32,6 +32,7 @@ TEST_CASE("JsonDocument::add(T)") {
     REQUIRE(doc.as<std::string>() == "[\"hello\"]");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 

+ 2 - 0
extras/tests/JsonDocument/constructor.cpp

@@ -64,6 +64,7 @@ TEST_CASE("JsonDocument constructor") {
     REQUIRE(doc2.as<std::string>() == "{\"hello\":\"world\"}");
     REQUIRE(spyingAllocator.log() == AllocatorLog{
                                          Allocate(sizeofPool()),
+                                         Allocate(sizeofStaticStringPool()),
                                      });
   }
 
@@ -87,6 +88,7 @@ TEST_CASE("JsonDocument constructor") {
     REQUIRE(doc2.as<std::string>() == "[\"hello\"]");
     REQUIRE(spyingAllocator.log() == AllocatorLog{
                                          Allocate(sizeofPool()),
+                                         Allocate(sizeofStaticStringPool()),
                                      });
   }
 

+ 2 - 2
extras/tests/JsonDocument/remove.cpp

@@ -22,10 +22,10 @@ TEST_CASE("JsonDocument::remove()") {
 
   SECTION("string literal") {
     doc["a"] = 1;
-    doc["a\0b"_s] = 2;
+    doc["x"] = 2;
     doc["b"] = 3;
 
-    doc.remove("a\0b");
+    doc.remove("x");
 
     REQUIRE(doc.as<std::string>() == "{\"a\":1,\"b\":3}");
   }

+ 3 - 1
extras/tests/JsonDocument/set.cpp

@@ -37,7 +37,9 @@ TEST_CASE("JsonDocument::set()") {
     doc.set("example");
 
     REQUIRE(doc.as<const char*>() == "example"_s);
-    REQUIRE(spy.log() == AllocatorLog{});
+    REQUIRE(spy.log() == AllocatorLog{
+                             Allocate(sizeofStaticStringPool()),
+                         });
   }
 
   SECTION("const char*") {

+ 15 - 4
extras/tests/JsonDocument/shrinkToFit.cpp

@@ -75,7 +75,11 @@ TEST_CASE("JsonDocument::shrinkToFit()") {
     doc.shrinkToFit();
 
     REQUIRE(doc.as<std::string>() == "hello");
-    REQUIRE(spyingAllocator.log() == AllocatorLog{});
+    REQUIRE(spyingAllocator.log() ==
+            AllocatorLog{
+                Allocate(sizeofStaticStringPool()),
+                Reallocate(sizeofStaticStringPool(), sizeofStaticStringPool(1)),
+            });
   }
 
   SECTION("owned string") {
@@ -110,7 +114,9 @@ TEST_CASE("JsonDocument::shrinkToFit()") {
     REQUIRE(spyingAllocator.log() ==
             AllocatorLog{
                 Allocate(sizeofPool()),
+                Allocate(sizeofStaticStringPool()),
                 Reallocate(sizeofPool(), sizeofObject(1)),
+                Reallocate(sizeofStaticStringPool(), sizeofStaticStringPool(1)),
             });
   }
 
@@ -137,7 +143,9 @@ TEST_CASE("JsonDocument::shrinkToFit()") {
     REQUIRE(spyingAllocator.log() ==
             AllocatorLog{
                 Allocate(sizeofPool()),
+                Allocate(sizeofStaticStringPool()),
                 Reallocate(sizeofPool(), sizeofArray(1)),
+                Reallocate(sizeofStaticStringPool(), sizeofStaticStringPool(1)),
             });
   }
 
@@ -164,20 +172,23 @@ TEST_CASE("JsonDocument::shrinkToFit()") {
     REQUIRE(spyingAllocator.log() ==
             AllocatorLog{
                 Allocate(sizeofPool()),
+                Allocate(sizeofStaticStringPool()),
                 Reallocate(sizeofPool(), sizeofObject(1)),
+                Reallocate(sizeofStaticStringPool(), sizeofStaticStringPool(2)),
             });
   }
 
   SECTION("owned string in object") {
-    doc["key"] = "abcdefg"_s;
+    doc["key1"_s] = "value"_s;
 
     doc.shrinkToFit();
 
-    REQUIRE(doc.as<std::string>() == "{\"key\":\"abcdefg\"}");
+    REQUIRE(doc.as<std::string>() == "{\"key1\":\"value\"}");
     REQUIRE(spyingAllocator.log() ==
             AllocatorLog{
                 Allocate(sizeofPool()),
-                Allocate(sizeofString("abcdefg")),
+                Allocate(sizeofString("key1")),
+                Allocate(sizeofString("value")),
                 Reallocate(sizeofPool(), sizeofPool(2)),
             });
   }

+ 1 - 2
extras/tests/JsonDocument/subscript.cpp

@@ -25,8 +25,6 @@ TEST_CASE("JsonDocument::operator[]") {
     SECTION("string literal") {
       REQUIRE(doc["abc"] == "ABC");
       REQUIRE(cdoc["abc"] == "ABC");
-      REQUIRE(doc["abc\0d"] == "ABCD");
-      REQUIRE(cdoc["abc\0d"] == "ABCD");
     }
 
     SECTION("std::string") {
@@ -114,6 +112,7 @@ TEST_CASE("JsonDocument::operator[] key storage") {
     REQUIRE(doc.as<std::string>() == "{\"hello\":0}");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 

+ 5 - 17
extras/tests/JsonObject/set.cpp

@@ -26,25 +26,12 @@ TEST_CASE("JsonObject::set()") {
     REQUIRE(obj2["hello"] == "world"_s);
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 
-  SECTION("copy local string value") {
-    obj1["hello"] = "world"_s;
-    spy.clearLog();
-
-    bool success = obj2.set(obj1);
-
-    REQUIRE(success == true);
-    REQUIRE(obj2["hello"] == "world"_s);
-    REQUIRE(spy.log() == AllocatorLog{
-                             Allocate(sizeofPool()),
-                             Allocate(sizeofString("world")),
-                         });
-  }
-
-  SECTION("copy local key") {
-    obj1["hello"_s] = "world";
+  SECTION("copy local string key and value") {
+    obj1["hello"_s] = "world"_s;
     spy.clearLog();
 
     bool success = obj2.set(obj1);
@@ -54,6 +41,7 @@ TEST_CASE("JsonObject::set()") {
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
                              Allocate(sizeofString("hello")),
+                             Allocate(sizeofString("world")),
                          });
   }
 
@@ -110,7 +98,7 @@ TEST_CASE("JsonObject::set()") {
   }
 
   SECTION("copy fails in the middle of an array") {
-    TimebombAllocator timebomb(1);
+    TimebombAllocator timebomb(2);
     JsonDocument doc3(&timebomb);
     JsonObject obj3 = doc3.to<JsonObject>();
 

+ 12 - 6
extras/tests/JsonObject/subscript.cpp

@@ -102,21 +102,25 @@ TEST_CASE("JsonObject::operator[]") {
     REQUIRE(42 == obj[key]);
   }
 
-  SECTION("should not duplicate const char*") {
+  SECTION("string literals") {
     obj["hello"] = "world";
-    REQUIRE(spy.log() == AllocatorLog{Allocate(sizeofPool())});
+    REQUIRE(spy.log() == AllocatorLog{
+                             Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
+                         });
   }
 
   SECTION("should duplicate char* value") {
     obj["hello"] = const_cast<char*>("world");
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                              Allocate(sizeofString("world")),
                          });
   }
 
   SECTION("should duplicate char* key") {
-    obj[const_cast<char*>("hello")] = "world";
+    obj[const_cast<char*>("hello")] = 42;
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
                              Allocate(sizeofString("hello")),
@@ -136,12 +140,13 @@ TEST_CASE("JsonObject::operator[]") {
     obj["hello"] = "world"_s;
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                              Allocate(sizeofString("world")),
                          });
   }
 
   SECTION("should duplicate std::string key") {
-    obj["hello"_s] = "world";
+    obj["hello"_s] = 42;
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
                              Allocate(sizeofString("hello")),
@@ -158,7 +163,7 @@ TEST_CASE("JsonObject::operator[]") {
   }
 
   SECTION("should duplicate a non-static JsonString key") {
-    obj[JsonString("hello", false)] = "world";
+    obj[JsonString("hello", false)] = 42;
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
                              Allocate(sizeofString("hello")),
@@ -166,9 +171,10 @@ TEST_CASE("JsonObject::operator[]") {
   }
 
   SECTION("should not duplicate a static JsonString key") {
-    obj[JsonString("hello", true)] = "world";
+    obj[JsonString("hello", true)] = 42;
     REQUIRE(spy.log() == AllocatorLog{
                              Allocate(sizeofPool()),
+                             Allocate(sizeofStaticStringPool()),
                          });
   }
 

+ 4 - 2
extras/tests/JsonVariant/copy.cpp

@@ -38,13 +38,15 @@ TEST_CASE("JsonVariant::set(JsonVariant)") {
     REQUIRE(var1.as<std::string>() == "{\"value\":[42]}");
   }
 
-  SECTION("stores const char* by reference") {
+  SECTION("stores string literals by pointer") {
     var1.set("hello!!");
     spyingAllocator.clearLog();
 
     var2.set(var1);
 
-    REQUIRE(spyingAllocator.log() == AllocatorLog{});
+    REQUIRE(spyingAllocator.log() == AllocatorLog{
+                                         Allocate(sizeofStaticStringPool()),
+                                     });
   }
 
   SECTION("stores char* by copy") {

+ 20 - 2
extras/tests/JsonVariant/set.cpp

@@ -23,7 +23,9 @@ TEST_CASE("JsonVariant::set() when there is enough memory") {
     REQUIRE(result == true);
     CHECK(variant ==
           "hello"_s);  // linked string cannot contain '\0' at the moment
-    CHECK(spy.log() == AllocatorLog{});
+    CHECK(spy.log() == AllocatorLog{
+                           Allocate(sizeofStaticStringPool()),
+                       });
   }
 
   SECTION("const char*") {
@@ -149,7 +151,9 @@ TEST_CASE("JsonVariant::set() when there is enough memory") {
 
     REQUIRE(result == true);
     REQUIRE(variant == "world");  // stores by pointer
-    REQUIRE(spy.log() == AllocatorLog{});
+    REQUIRE(spy.log() == AllocatorLog{
+                             Allocate(sizeofStaticStringPool()),
+                         });
   }
 
   SECTION("non-static JsonString") {
@@ -265,6 +269,20 @@ TEST_CASE("JsonVariant::set() with not enough memory") {
 
   JsonVariant v = doc.to<JsonVariant>();
 
+  SECTION("string literal") {
+    bool result = v.set("hello world");
+
+    REQUIRE(result == false);
+    REQUIRE(v.isNull());
+  }
+
+  SECTION("static JsonString") {
+    bool result = v.set(JsonString("hello world", true));
+
+    REQUIRE(result == false);
+    REQUIRE(v.isNull());
+  }
+
   SECTION("std::string") {
     bool result = v.set("hello world!!"_s);
 

+ 1 - 1
extras/tests/JsonVariantConst/subscript.cpp

@@ -57,7 +57,7 @@ TEST_CASE("JsonVariantConst::operator[]") {
     SECTION("string literal") {
       REQUIRE(var["ab"] == "AB"_s);
       REQUIRE(var["abc"] == "ABC"_s);
-      REQUIRE(var["abc\0d"] == "ABCD"_s);
+      REQUIRE(var["abc\0d"] == "ABC"_s);
       REQUIRE(var["def"].isNull());
       REQUIRE(var[0].isNull());
     }

+ 1 - 1
extras/tests/Misc/StringAdapters.cpp

@@ -21,7 +21,7 @@ TEST_CASE("adaptString()") {
     auto s = adaptString("bravo\0alpha");
 
     CHECK(s.isNull() == false);
-    CHECK(s.size() == 11);
+    CHECK(s.size() == 5);
     CHECK(s.isStatic() == true);
   }
 

+ 3 - 1
extras/tests/MsgPackDeserializer/destination_types.cpp

@@ -104,6 +104,8 @@ TEST_CASE("deserializeMsgPack(MemberProxy)") {
 
     REQUIRE(err == DeserializationError::Ok);
     REQUIRE(doc.as<std::string>() == "{\"hello\":\"world\",\"value\":[42]}");
-    REQUIRE(spy.log() == AllocatorLog{});
+    REQUIRE(spy.log() == AllocatorLog{
+                             Allocate(sizeofStaticStringPool()),
+                         });
   }
 }

+ 1 - 0
extras/tests/ResourceManager/CMakeLists.txt

@@ -5,6 +5,7 @@
 add_executable(ResourceManagerTests
 	allocVariant.cpp
 	clear.cpp
+	saveStaticString.cpp
 	saveString.cpp
 	shrinkToFit.cpp
 	size.cpp

+ 4 - 4
extras/tests/ResourceManager/StringBuffer.cpp

@@ -2,7 +2,7 @@
 // Copyright © 2014-2025, Benoit BLANCHON
 // MIT License
 
-#include <ArduinoJson/Memory/StringBuffer.hpp>
+#include <ArduinoJson.hpp>
 #include <catch.hpp>
 
 #include "Allocators.hpp"
@@ -22,7 +22,7 @@ TEST_CASE("StringBuffer") {
     sb.save(&variant);
 
     REQUIRE(variant.type() == VariantType::TinyString);
-    REQUIRE(variant.asString() == "hi!");
+    REQUIRE(variant.asString(&resources) == "hi!");
   }
 
   SECTION("Tiny string can't contain NUL") {
@@ -32,7 +32,7 @@ TEST_CASE("StringBuffer") {
 
     REQUIRE(variant.type() == VariantType::OwnedString);
 
-    auto str = variant.asString();
+    auto str = variant.asString(&resources);
     REQUIRE(str.size() == 3);
     REQUIRE(str.c_str()[0] == 'a');
     REQUIRE(str.c_str()[1] == 0);
@@ -45,6 +45,6 @@ TEST_CASE("StringBuffer") {
     sb.save(&variant);
 
     REQUIRE(variant.type() == VariantType::OwnedString);
-    REQUIRE(variant.asString() == "alfa");
+    REQUIRE(variant.asString(&resources) == "alfa");
   }
 }

+ 14 - 13
extras/tests/ResourceManager/StringBuilder.cpp

@@ -2,7 +2,7 @@
 // Copyright © 2014-2025, Benoit BLANCHON
 // MIT License
 
-#include <ArduinoJson/Memory/StringBuilder.hpp>
+#include <ArduinoJson.hpp>
 #include <catch.hpp>
 
 #include "Allocators.hpp"
@@ -46,7 +46,7 @@ TEST_CASE("StringBuilder") {
 
     REQUIRE(resources.overflowed() == false);
     REQUIRE(data.type() == VariantType::TinyString);
-    REQUIRE(data.asString() == "url");
+    REQUIRE(data.asString(&resources) == "url");
   }
 
   SECTION("Short string fits in first allocation") {
@@ -134,9 +134,10 @@ TEST_CASE("StringBuilder::save() deduplicates strings") {
     auto s2 = saveString(builder, "world");
     auto s3 = saveString(builder, "hello");
 
-    REQUIRE(s1.asString() == "hello");
-    REQUIRE(s2.asString() == "world");
-    REQUIRE(+s1.asString().c_str() == +s3.asString().c_str());  // same address
+    REQUIRE(s1.asString(&resources) == "hello");
+    REQUIRE(s2.asString(&resources) == "world");
+    REQUIRE(+s1.asString(&resources).c_str() ==
+            +s3.asString(&resources).c_str());  // same address
 
     REQUIRE(spy.log() ==
             AllocatorLog{
@@ -152,10 +153,10 @@ TEST_CASE("StringBuilder::save() deduplicates strings") {
     auto s1 = saveString(builder, "hello world");
     auto s2 = saveString(builder, "hello");
 
-    REQUIRE(s1.asString() == "hello world");
-    REQUIRE(s2.asString() == "hello");
-    REQUIRE(+s2.asString().c_str() !=
-            +s1.asString().c_str());  // different address
+    REQUIRE(s1.asString(&resources) == "hello world");
+    REQUIRE(s2.asString(&resources) == "hello");
+    REQUIRE(+s2.asString(&resources).c_str() !=
+            +s1.asString(&resources).c_str());  // different address
 
     REQUIRE(spy.log() ==
             AllocatorLog{
@@ -170,10 +171,10 @@ TEST_CASE("StringBuilder::save() deduplicates strings") {
     auto s1 = saveString(builder, "hello world");
     auto s2 = saveString(builder, "worl");
 
-    REQUIRE(s1.asString() == "hello world");
-    REQUIRE(s2.asString() == "worl");
-    REQUIRE(s2.asString().c_str() !=
-            s1.asString().c_str());  // different address
+    REQUIRE(s1.asString(&resources) == "hello world");
+    REQUIRE(s2.asString(&resources) == "worl");
+    REQUIRE(s2.asString(&resources).c_str() !=
+            s1.asString(&resources).c_str());  // different address
 
     REQUIRE(spy.log() ==
             AllocatorLog{

+ 47 - 0
extras/tests/ResourceManager/saveStaticString.cpp

@@ -0,0 +1,47 @@
+// ArduinoJson - https://arduinojson.org
+// Copyright © 2014-2025, Benoit BLANCHON
+// MIT License
+
+#include <ArduinoJson/Memory/ResourceManager.hpp>
+#include <ArduinoJson/Strings/StringAdapters.hpp>
+#include <catch.hpp>
+
+#include "Allocators.hpp"
+
+using namespace ArduinoJson::detail;
+
+TEST_CASE("ResourceManager::saveStaticString() deduplicates strings") {
+  SpyingAllocator spy;
+  ResourceManager resources(&spy);
+
+  auto str1 = "hello";
+  auto str2 = "world";
+
+  auto id1 = resources.saveStaticString(str1);
+  auto id2 = resources.saveStaticString(str2);
+  REQUIRE(id1 != id2);
+
+  auto id3 = resources.saveStaticString(str1);
+  REQUIRE(id1 == id3);
+
+  resources.shrinkToFit();
+  REQUIRE(spy.log() ==
+          AllocatorLog{
+              Allocate(sizeofStaticStringPool()),
+              Reallocate(sizeofStaticStringPool(), sizeofStaticStringPool(2)),
+          });
+  REQUIRE(resources.overflowed() == false);
+}
+
+TEST_CASE("ResourceManager::saveStaticString() when allocation fails") {
+  SpyingAllocator spy(FailingAllocator::instance());
+  ResourceManager resources(&spy);
+
+  auto slotId = resources.saveStaticString("hello");
+
+  REQUIRE(slotId == NULL_SLOT);
+  REQUIRE(resources.overflowed() == true);
+  REQUIRE(spy.log() == AllocatorLog{
+                           AllocateFail(sizeofStaticStringPool()),
+                       });
+}

+ 13 - 0
src/ArduinoJson/Memory/MemoryPool.hpp

@@ -34,6 +34,11 @@ class Slot {
     return ptr_;
   }
 
+  T& operator*() const {
+    ARDUINOJSON_ASSERT(ptr_ != nullptr);
+    return *ptr_;
+  }
+
   T* operator->() const {
     ARDUINOJSON_ASSERT(ptr_ != nullptr);
     return ptr_;
@@ -76,6 +81,14 @@ class MemoryPool {
     return slots_ + id;
   }
 
+  SlotId find(const T& value) const {
+    for (SlotId i = 0; i < usage_; i++) {
+      if (slots_[i] == value)
+        return i;
+    }
+    return NULL_SLOT;
+  }
+
   void clear() {
     usage_ = 0;
   }

+ 9 - 0
src/ArduinoJson/Memory/MemoryPoolList.hpp

@@ -114,6 +114,15 @@ class MemoryPoolList {
     return pools_[poolIndex].getSlot(indexInPool);
   }
 
+  SlotId find(const T& value) const {
+    for (PoolCount i = 0; i < count_; i++) {
+      SlotId id = pools_[i].find(value);
+      if (id != NULL_SLOT)
+        return SlotId(i * ARDUINOJSON_POOL_CAPACITY + id);
+    }
+    return NULL_SLOT;
+  }
+
   void clear(Allocator* allocator) {
     for (PoolCount i = 0; i < count_; i++)
       pools_[i].destroy(allocator);

+ 24 - 1
src/ArduinoJson/Memory/ResourceManager.hpp

@@ -34,6 +34,7 @@ class ResourceManager {
   ~ResourceManager() {
     stringPool_.clear(allocator_);
     variantPools_.clear(allocator_);
+    staticStringsPools_.clear(allocator_);
   }
 
   ResourceManager(const ResourceManager&) = delete;
@@ -42,6 +43,7 @@ class ResourceManager {
   friend void swap(ResourceManager& a, ResourceManager& b) {
     swap(a.stringPool_, b.stringPool_);
     swap(a.variantPools_, b.variantPools_);
+    swap(a.staticStringsPools_, b.staticStringsPools_);
     swap_(a.allocator_, b.allocator_);
     swap_(a.overflowed_, b.overflowed_);
   }
@@ -111,14 +113,34 @@ class ResourceManager {
     stringPool_.dereference(s, allocator_);
   }
 
+  SlotId saveStaticString(const char* s) {
+    auto existingSlotId = staticStringsPools_.find(s);
+    if (existingSlotId != NULL_SLOT)
+      return existingSlotId;
+
+    auto slot = staticStringsPools_.allocSlot(allocator_);
+    if (slot)
+      *slot = s;
+    else
+      overflowed_ = true;
+
+    return slot.id();
+  }
+
+  const char* getStaticString(SlotId id) const {
+    return *staticStringsPools_.getSlot(id);
+  }
+
   void clear() {
-    variantPools_.clear(allocator_);
     overflowed_ = false;
+    variantPools_.clear(allocator_);
     stringPool_.clear(allocator_);
+    staticStringsPools_.clear(allocator_);
   }
 
   void shrinkToFit() {
     variantPools_.shrinkToFit(allocator_);
+    staticStringsPools_.shrinkToFit(allocator_);
   }
 
  private:
@@ -126,6 +148,7 @@ class ResourceManager {
   bool overflowed_;
   StringPool stringPool_;
   MemoryPoolList<SlotData> variantPools_;
+  MemoryPoolList<const char*> staticStringsPools_;
 };
 
 ARDUINOJSON_END_PRIVATE_NAMESPACE

+ 2 - 2
src/ArduinoJson/Object/JsonPair.hpp

@@ -18,7 +18,7 @@ class JsonPair {
   JsonPair(detail::ObjectData::iterator iterator,
            detail::ResourceManager* resources) {
     if (!iterator.done()) {
-      key_ = iterator->asString();
+      key_ = iterator->asString(resources);
       iterator.next(resources);
       value_ = JsonVariant(iterator.data(), resources);
     }
@@ -46,7 +46,7 @@ class JsonPairConst {
   JsonPairConst(detail::ObjectData::iterator iterator,
                 const detail::ResourceManager* resources) {
     if (!iterator.done()) {
-      key_ = iterator->asString();
+      key_ = iterator->asString(resources);
       iterator.next(resources);
       value_ = JsonVariantConst(iterator.data(), resources);
     }

+ 1 - 1
src/ArduinoJson/Object/ObjectImpl.hpp

@@ -36,7 +36,7 @@ inline ObjectData::iterator ObjectData::findKey(
     return iterator();
   bool isKey = true;
   for (auto it = createIterator(resources); !it.done(); it.next(resources)) {
-    if (isKey && stringEquals(key, adaptString(it->asString())))
+    if (isKey && stringEquals(key, adaptString(it->asString(resources))))
       return it;
     isKey = !isKey;
   }

+ 3 - 2
src/ArduinoJson/Strings/Adapters/RamString.hpp

@@ -90,8 +90,9 @@ template <size_t N>
 struct StringAdapter<const char (&)[N]> {
   using AdaptedString = RamString;
 
-  static AdaptedString adapt(const char (&p)[N]) {
-    return RamString(p, N - 1, true);
+  static AdaptedString adapt(const char* p) {
+    ARDUINOJSON_ASSERT(p);
+    return RamString(p, ::strlen(p), true);
   }
 };
 

+ 1 - 2
src/ArduinoJson/Strings/JsonString.hpp

@@ -28,8 +28,7 @@ class JsonString {
             detail::enable_if_t<detail::is_integral<TSize>::value &&
                                     !detail::is_same<TSize, bool>::value,
                                 int> = 0>
-  JsonString(const char* data, TSize sz, bool isStatic = false)
-      : str_(data, size_t(sz), isStatic) {}
+  JsonString(const char* data, TSize sz) : str_(data, size_t(sz), false) {}
 
   // Returns a pointer to the characters.
   const char* c_str() const {

+ 2 - 2
src/ArduinoJson/Variant/ConverterImpl.hpp

@@ -160,7 +160,7 @@ struct Converter<const char*> : private detail::VariantAttorney {
 
   static const char* fromJson(JsonVariantConst src) {
     auto data = getData(src);
-    return data ? data->asString().c_str() : 0;
+    return data ? data->asString(getResourceManager(src)).c_str() : 0;
   }
 
   static bool checkJson(JsonVariantConst src) {
@@ -178,7 +178,7 @@ struct Converter<JsonString> : private detail::VariantAttorney {
 
   static JsonString fromJson(JsonVariantConst src) {
     auto data = getData(src);
-    return data ? data->asString() : JsonString();
+    return data ? data->asString(getResourceManager(src)) : JsonString();
   }
 
   static bool checkJson(JsonVariantConst src) {

+ 0 - 3
src/ArduinoJson/Variant/VariantContent.hpp

@@ -56,13 +56,10 @@ union VariantContent {
   bool asBoolean;
   uint32_t asUint32;
   int32_t asInt32;
-#if ARDUINOJSON_USE_EXTENSIONS
   SlotId asSlotId;
-#endif
   ArrayData asArray;
   ObjectData asObject;
   CollectionData asCollection;
-  const char* asLinkedString;
   struct StringNode* asOwnedString;
   char asTinyString[tinyStringMaxLength + 1];
 };

+ 8 - 11
src/ArduinoJson/Variant/VariantData.hpp

@@ -77,7 +77,7 @@ class VariantData {
         return visit.visit(JsonString(content_.asTinyString));
 
       case VariantType::LinkedString:
-        return visit.visit(JsonString(content_.asLinkedString, true));
+        return visit.visit(JsonString(asLinkedString(resources), true));
 
       case VariantType::OwnedString:
         return visit.visit(JsonString(content_.asOwnedString->data,
@@ -216,7 +216,7 @@ class VariantData {
         str = content_.asTinyString;
         break;
       case VariantType::LinkedString:
-        str = content_.asLinkedString;
+        str = asLinkedString(resources);
         break;
       case VariantType::OwnedString:
         str = content_.asOwnedString->data;
@@ -261,7 +261,7 @@ class VariantData {
         str = content_.asTinyString;
         break;
       case VariantType::LinkedString:
-        str = content_.asLinkedString;
+        str = asLinkedString(resources);
         break;
       case VariantType::OwnedString:
         str = content_.asOwnedString->data;
@@ -298,12 +298,14 @@ class VariantData {
     }
   }
 
-  JsonString asString() const {
+  const char* asLinkedString(const ResourceManager* resources) const;
+
+  JsonString asString(const ResourceManager* resources) const {
     switch (type_) {
       case VariantType::TinyString:
         return JsonString(content_.asTinyString);
       case VariantType::LinkedString:
-        return JsonString(content_.asLinkedString, true);
+        return JsonString(asLinkedString(resources), true);
       case VariantType::OwnedString:
         return JsonString(content_.asOwnedString->data,
                           content_.asOwnedString->length);
@@ -519,12 +521,7 @@ class VariantData {
     var->setString(value, resources);
   }
 
-  void setLinkedString(const char* s) {
-    ARDUINOJSON_ASSERT(type_ == VariantType::Null);  // must call clear() first
-    ARDUINOJSON_ASSERT(s);
-    type_ = VariantType::LinkedString;
-    content_.asLinkedString = s;
-  }
+  bool setLinkedString(const char* s, ResourceManager* resources);
 
   template <typename TAdaptedString>
   void setTinyString(const TAdaptedString& s) {

+ 22 - 4
src/ArduinoJson/Variant/VariantImpl.hpp

@@ -18,6 +18,20 @@ inline void VariantData::setRawString(SerializedValue<T> value,
     setRawString(dup);
 }
 
+inline bool VariantData::setLinkedString(const char* s,
+                                         ResourceManager* resources) {
+  ARDUINOJSON_ASSERT(type_ == VariantType::Null);  // must call clear() first
+  ARDUINOJSON_ASSERT(s);
+
+  auto slotId = resources->saveStaticString(s);
+  if (slotId == NULL_SLOT)
+    return false;
+
+  type_ = VariantType::LinkedString;
+  content_.asSlotId = slotId;
+  return true;
+}
+
 template <typename TAdaptedString>
 inline bool VariantData::setString(TAdaptedString value,
                                    ResourceManager* resources) {
@@ -26,10 +40,8 @@ inline bool VariantData::setString(TAdaptedString value,
   if (value.isNull())
     return false;
 
-  if (value.isStatic()) {
-    setLinkedString(value.data());
-    return true;
-  }
+  if (value.isStatic())
+    return setLinkedString(value.data(), resources);
 
   if (isTinyString(value, value.size())) {
     setTinyString(value);
@@ -70,6 +82,12 @@ inline const VariantExtension* VariantData::getExtension(
 }
 #endif
 
+inline const char* VariantData::asLinkedString(
+    const ResourceManager* resources) const {
+  ARDUINOJSON_ASSERT(type_ == VariantType::LinkedString);
+  return resources->getStaticString(content_.asSlotId);
+}
+
 template <typename T>
 enable_if_t<sizeof(T) == 8, bool> VariantData::setFloat(
     T value, ResourceManager* resources) {