Efficient logging using compile-time strings
April 29, 2025
Let’s explore compile-time string processing to mimic some of the functionality of the defmt
crate, but using C++.
tl;dr
int main() {
log("hello {}"_sc, "world");
}
Compiles down to a binary which does not contain the string "hello {}"
, but an integer number which it logs instead. The receiving end knows the id -> string
mapping and can display a readable log.
The advantage: Smaller binary, less data to log, faster execution.
The downside: Each program version has its own log reader.
How it’s done:
- Use the string as a template argument of an
extern
-ally defined function of typestring -> int
- Parse the compiled object file for the template type
- Generate code for the missing
string -> int
function and compile - Link everything. LTO and ELF stripping remove all traces of the string
Introduction
I watched a C++ talk on YouTube Link to YouTube by Luke Valenty of Intel, which I found fascinating. In it he described how he used strings as compile-time constants, and was able to remove them entirely from the compiled binary.
The idea had never crossed my mind, and I was surprised to find the idea in a Rust crate, defmt
Link to crate .
So this is me figuring out how to implement something like this myself.
1. Compile-time strings
We can expand a string into its individual chars and use that as template arguments This makes use of the N3599 proposal, which gcc and clang implement as a GNU extension. :
// ConstexprStr.hpp
#pragma once
template<char... Chars>
struct ConstexprStr {};
template<typename T, T... Chars>
constexpr ConstexprStr<Chars...> operator""_cs() {
return {};
}
// main.cpp
#include "ConstexprStr.h"
int main() {
constexpr auto str = "hello world"_cs;
}
The type of str
is:
ConstexprStr<'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'>
Easy.
2. IDs for compile-time strings
Introduce a yet unimplemented function catalog
which maps a string to an integer. We are very good at foreshadowing and put it into ConstexprStr.h
:
// ConstexprStr.h
#pragma once
template<typename T>
int catalog();
template<char... Chars>
struct ConstexprStr {};
template<typename T, T... Chars>
constexpr ConstexprStr<Chars...> operator""_cs() {
return {};
}
// main.cpp
#include <stdio.h>
#include "ConstexprStr.h"
template<typename FormatStr>
void log(const FormatStr& format_str) {
printf("%d\n", catalog<std::decay_t<FormatStr>>());
}
int main() {
log("hello world"_cs);
}
At this point, I compiled my main.cpp
into an object file:
$ g++ main.cpp -c -o main.o
$ nm -uC main.o
U printf
U __stack_chk_fail
U int catalog<ConstexprStr<(char)104, (char)101, (char)108, (char)108, (char)111, (char)32, (char)119, (char)111, (char)114, (char)108, (char)100> >()
This is cool! The string is right there for us to pick! On to the next step:
3. Extract all catalog
calls from object files
The crafty software developer will spot a chance to automate - let’s do this manually though. The catalog
call needs an implementation, otherwise the program won’t compile:
// strings.cpp
#include "ConstexprStr.h"
using str1 = ConstexprStr<'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'>;
template<> int catalog<str1>() { return 1; }
Of course this can be built:
$ g++ strings.cpp -c -o strings.o
4. Linking everything
Finally, bind everything together:
$ g++ main.o strings.o && ./a.out
1
There it is!
But does the binary contain "hello world"
?
$ strings a.out | grep "hello"
$
No. Rejoice. However, it may still contain the name of the catalog
function.
It can be stripped from the binary, luckily. That leaves us with a binary with
no bits that could identify the string. Cool stuff.
Going further
Armed with a proper build system this can be made useable. It might feel alien at first, but I can imagine using something like this in production. Of course there’s still so much missing.
One thing I was playing with was adding parameters:
int main() {
log("Hello, {}!"_cs, "World");
log("Name: {}, Age: {}, Score: {}"_cs, "John", 42, 3.14f);
log("Pi is approximately {}"_cs, 3.14159f);
log("The value is {}"_cs, 42);
log("Tool {} is running with value {}"_cs, "hammer", 100);
return 0;
}
Every build would also generate a log reader, so that I could run:
$ ./myapp | ./logreader
Hello, World!
Name: John, Age: 42, Score: 3.14
Pi is approximately 3.14159
The value is 42
Tool hammer is running with value 100
I invented a custom protocol for it, and the difference in total logged bytes was 129 bytes vs. 80 bytes. So 62%.