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:

  1. Use the string as a template argument of an extern-ally defined function of type string -> int
  2. Parse the compiled object file for the template type
  3. Generate code for the missing string -> int function and compile
  4. 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%.

Efficient logging using compile-time strings - April 29, 2025 - Arne Elster