How can I ensure that disabled logging calls are optimized out at compile-time, even on debug builds?

I want to write a C++ logging class for an embedded device.
What is very important, that log instructions that are not “activated” are optimized out by the preprocessor !

Until now, I did something like that:

#define LOGLEVEL 3

#if LOGLEVEL > 3
#define LOG_DEBUG(...) Serial.printf(__VA_ARGS__)
#else
#define LOG_DEBUG()
#endif

#if LOGLEVEL > 2
#define LOG_INFO(...) Serial.printf(__VA_ARGS__)
#else
#define LOG_INFO()
#endif

LOG_DEBUG("var a is: &d", a); // will be completely optimized out
LOG_INFO("var b is: %f", b);

Now I want to be a bit more sophisticated with a more tunable output:

#define LOGLEVEL 5
#define LOGAREAS LOGAREA_THIS | LOGAREAS_THAT

#define LOGAREA_THIS 0x01
#define LOGAREA_THAT 0x02
#define LOGAREA_FOO 0x04
#define LOGAREA_BAR 0x08


#if LOGLEVEL > 4
#define LOG_TRACE(x, ...) if(x & LOGAREAS) Serial.printf(__VA_ARGS__)
#else
#define LOG_TRACE()
#endif

LOG_TRACE(LOGAREA_THIS, "var b is: %f", b);
LOG_TRACE(LOGAREA_FOO | LOGAREA_BAR , "var a is: &d", a); // only displayed if LOGAREA_FOO or LOGAREA_BAR is selected

problem is with that code, that unselected messages still are in the cpp code and with no optimization (for debuging) still find their way into my binary…

Is there a way with a similar usability that allows me to do what I want where the unused messages are removed completely?

There is no need for macros here.

constexpr auto LOGAREA_THIS = 0x01;
constexpr auto LOGAREA_THAT = 0x02;
constexpr auto LOGAREA_FOO = 0x04;
constexpr auto LOGAREA_BAR = 0x08;

constexpr auto LOGLEVEL = 5;
constexpr auto LOGAREAS = LOGAREA_THIS | LOGAREAS_THAT;

template<auto x, typename... Args>
constexpr void LOG_TRACE(Args&&... args) {
    if constexpr((LOGLEVEL > 4) && (x & LOGAREAS))
        Serial.printf(std::forward<Args>(args)...);
}

//...

LOG_TRACE<LOGAREA_THIS>("var b is: %f", b);
LOG_TRACE<LOGAREA_FOO | LOGAREA_BAR>("var a is: &d", a);

It may be possible that the compiler doesn’t inline the call to LOG_TRACE in some cases, e.g. if in -O0 builds and if LOG_TRACE is called multiple times with the same log flags and argument types. In that case it would help to add some compiler-specific [[gnu:always_inline]] or __forceinline type attribute to LOG_TRACE.

If you change your syntax to something like this:

LOG_TRACE(LOGAREA_FOO, LOGAREA_BAR)("var a is: %d", a);

(Changing your bitwise | into a comma and having a second pair of brackets), you can make things like LOG_TRACE(0, 0, 0, 0) (all zeroes) expand into a macro that ignores its arguments and if there are any ones it expands into a macro that calls the log function with its arguments.

This means instead of a bitmask, all of your LOGAREA_* macros are either zero or one.

With Boost.Preprocessor, you can build a macro that does this:

#include <boost/preprocessor.hpp>
#define ANY_OF_OP(S, STATE, X) BOOST_PP_BITOR(STATE, X)
#define ANY_OF(...) BOOST_PP_SEQ_FOLD_LEFT(ANY_OF_OP, 0, BOOST_PP_VARIADIC_TO_SEQ(__VA_ARGS__))

#define LOGGER(...) static_cast<void>(std::printf(__VA_ARGS__))
#define NOLOGGER(...) static_cast<void>(0)
#define LOG(MINIMUM_LEVEL, ...) BOOST_PP_IF(BOOST_PP_BITAND(BOOST_PP_GREATER_EQUAL(LOGLEVEL, MINIMUM_LEVEL), ANY_OF(__VA_ARGS__)), LOGGER, NOLOGGER)

#define LOG_TRACE(...) LOG(5, __VA_ARGS__)
#define LOG_DEBUG(...) LOG(4, __VA_ARGS__)
#define LOG_INFO(...) LOG(3, __VA_ARGS__)


// Configuration
#define LOGLEVEL 5
#define LOGAREA_THIS 1
#define LOGAREA_THAT 1
#define LOGAREA_FOO 0
#define LOGAREA_BAR 0
// Example usage
int a;
float b;

int main() {
    LOG_TRACE(LOGAREA_THIS)("var b is: %f", b);
    LOG_TRACE(LOGAREA_FOO, LOGAREA_BAR)("var a is: %d", a);
    // With above configuration, this expands to
    static_cast<void>(Serial.printf("var b is: %f", b));
    static_cast<void>(0);
}

With a bit of grunt work this can be done without Boost.Preprocessor too: https://godbolt.org/z/bG6Mdxves

Leave a Comment