A client asked me to write a logging library for a project using the Arduino SDK. Since the library will be open-source, I‘m running an experiment. I will document the design, development, and feedback cycles on this project. I hope to expose readers to a real development process, feedback on a design, and how to adjust based on that feedback.
In a previous article I created a library for the Arduino that supports the printf-family of functions. We’ll leverage the same printf library to build an initial version of our logging library.
Table of Contents:
- Requirements
- Schedule
- Selecting Initial Requirements
- Brainstorming the Library
- Building the Library
- Creating Examples
- Documentation
- Putting it All Together
- Further Reading
Requirements
I like to have a list of requirements to work with before starting a project. The requirements guide me during the brainstorming and implementation process. They also serve as a checklist when writing tests and planning development.
My client shared the requirements and feature requests over 5–6 emails. I’ve organized them and binned them into feature areas:
- Supports standard format strings (like
printf) - Supports multiple output streams, selectable by the user. Examples may include:
- SD Card
- Serial port
- Buffer in memory
- Satellite radio
- Displays a timestamp in log messages
- Initially to be milliseconds from boot
- Ideally user-specifiable, with a long-term goal to support an RTC with HH:MM:SS format
- Provide the ability to add method name + file + line number to the log statement
- This should be user-configurable
- Support automatic rotation of log files
- Files are configurable both in size and number
- Rotate log files when full
- Provide an option to rotate log files at program boot
- Use logging levels that match the Python logger
- Log output has a tag for the level, e.g.
<error>
- Log output has a tag for the level, e.g.
- Log levels are filterable at compile-time and run-time
- Logging code should be usable in a unit test framework without changes to the code under test
- Nice if it’s run-time configurable, or link, or last choice compile-time Macros with
#define
- Nice if it’s run-time configurable, or link, or last choice compile-time Macros with
- Avoid the use of C++ streams
- Option to echo log statements over the
Serialport (or otherPrintclass configured for useprintf()) - Option for filtering message routing based on log level
- For example, a user can send
CRITICALmessages on a priority channel, such as a satellite radio - Output plugins with common interface, and various implementations: text file,
printf, binary file, etc.
That’s quite a laundry list of features!
Schedule
To complicate matters further, the client would like to use the logging library within a two-week period. (This series will run longer than that.)
We can’t wait until we complete the full set of requirements to release the library and get feedback, so we’ll use an Agile approach. Our goal is to select a viable and minimal set of features to release immediately. Then we can react to feedback and add new features incrementally.
Selecting Initial Requirements
The goal is to build a library that can handle basic logging. We want the customer to check the interface and make sure it is acceptable. From there, we will add new features based on priority.
Here are the initial requirements I’ll satisfy in this development session:
- Python-esque logging levels
- Add an indicator to the log output showing the log level for each message
- Compile-time log level filtering
- Run-time log level filtering
- Optional echo to the serial console (using the
printflibrary) - User-configurable logging output, with an initial implementation that uses a circular buffer in memory
- Avoid the use of C++ streams
Just to note: I will ignore multi-threading concerns. I’ll also defer time-stamping to a later release, since I don’t want to figure out how to structure a configurable clock input source yet.
Brainstorming the Library
So far, we have a relatively straightforward set of requirements. Before I start work, I like to brainstorm my initial approach.
First, we need to remember that this library must be usable on an Arduino. I will design the library with C++11 capabilities in mind. We likely cannot rely on the STL or standard library functionality.
The biggest unknown for me is how to arrange the logging code. To enable user-configuration, we need some kind of abstraction. Our logging library will provide a core set of functionality, such as filtering by level and echo to console. Other functionality, such as output destination and formatting, will be modifiable by the user.
The general architectural pattern that applies here is the Strategy Pattern, which defers some steps of an operation to a user-selectable class or method. We can implement the pattern using virtual methods and inheritance, using templates and CRTP, or with function pointers supplied to our logging class during construction.
Since the library is destined for Arduino, I will shy away from heavily templated code, although it might be the ideal implementation approach for a non-Arduino embedded environment. Function pointers work well, but I’m not yet sure what I want my interfaces to look like, so I hesitate to define them. That leaves us with virtual methods.
Virtual methods give us nice user configurability with low overhead. We can create basic interfaces for the class, and under the hood the user can route output any way they choose. Here’s a visual representation of my initial thoughts using UML:
For compile-time log-level filtering, we will use macros. Using macros also allows us to access the file, line, and function information, since the preprocessor provides those. In C++20 we can access that information without the preprocessor, but we are stuck behind the times.
Avoiding the use of C++ streams will be easy, since we’re planning on using printf-style format strings.
Other features that come to mind (which my client did not ask for): completely disabling logging, resetting the contents of a log buffer (optional), and the ability to control when we flush the buffer contents to the output source (optional).
Lucky for me, I already have some existing C++17 logging code that I can strip down to meet these requirements. We’ll use that as our starting point and grow the code from there.
Building the Library
Now that we have a rough idea for the organization, we can begin putting together the initial library.
Project Skeleton
The first steps are easy: create a GitHub repository, set up a license, and clone to the system. Ill name the library arduino-logger (at least for now).
I like set up the project structure and infrastructure first. I use code formatting tools, will plan to add unit tests, and need a build system for the tests.
To begin, I install formatting tools as a submodule under the tools/ directory:
mkdir tools
cd tools/
git submodule add git@github.com:embeddedartistry/format.git
Cloning into '/Users/pjohnston/src/ea/arduino-logger/tools/format'...
remote: Enumerating objects: 63, done.
Receiving objects: 100% (63/63), 10.42 KiB | 5.21 MiB/s, done.
Resolving deltas: 100% (26/26), done.
remote: Total 63 (delta 0), reused 0 (delta 0), pack-reused 63
cd format/
./setup.sh
Parent git repository found 2 levels up: deploying style clang_format.
I will be writing tests on my host machine, so I’ll need a non-Arduino build system. I use meson and ninja.
I also install my meson build system helper scripts as a submodule, although we may not need them:
git submodule add https://github.com/embeddedartistry/meson-buildsystem build
Cloning into '/Users/pjohnston/src/ea/arduino-logger/build'...
I also imported an existing Makefile shim that works with the meson build system and many of the tools I use. I like to just type make and have everything compile with a single command (like the good old days), make format to auto-format all of my code, and make test to run my unit tests.
I will use the Catch2 unit testing framework for this project. I created a test folder and put the Catch 2.9.2 header in that location:
ls test/
catch.hpp
I might as well create the skeletons for our test files. We’ll need one called catch_main.cpp, which defines the main function for our build:
#define CATCH_CONFIG_MAIN // This tells Catch to provide a main() - only do this in one cpp file
#include <catch.hpp>
Then we’ll create a home for our tests, with a single basic test case. The test file will also include our library header, so the build will fail if the file cannot be found.
#include <catch.hpp>
#include <ArduinoLogger.h>
TEST_CASE("EmptyTestCase", "[ArduinoLogger]")
{
// Force a failure so we can see everything working
REQUIRE(true == false);
}
Our test folder now looks like this:
ls test
ArduinoLoggerTests.cpp
catch.hpp
catch_main.cpp
My build system will generate a folder called buildresults to store the outputs, so we need to add an entry to the .gitignore file:
# Generated files
buildresults/**
Now we can create basic source file skeletons to get our build system up and running. Arduino lets us put source files for libraries in the repository top-level, or in a folder called src/. We’ll put the files in src/.
I start by creating an initial ArduinoLogger.h file:
#ifndef ARDUINO_LOGGER_H_
#define ARDUINO_LOGGER_H_
#endif //ARDUINO_LOGGER_H_
And ArduinoLogger.cpp file:
#include "ArduinoLogger.h"
We’ll also create an examples/ folder to showcase some demo programs, even though we don’t have any yet.
Initial Build Setup
Before we write any code, we want to make sure we can compile the source files, test files, and run a unit test application.
First, we need to define our test build:
test_includes = [
include_directories('test'),
include_directories('src'),
]
logging_tests = executable('arduino_logger_tests',
[
files('src/ArduinoLogger.cpp'),
files('test/ArduinoLoggerTests.cpp'),
files('test/catch_main.cpp'),
],
include_directories: test_includes,
)
And we’ll register it with the meson test system:
test('ArduinoLogger_tests',
logging_tests)
We can run make and we should see our build succeed:
make
[0/1] Regenerating build files.
[ ... truncated output]
[2/2] Linking target arduino_logger_tests.
When we run the tests, we should see a failure:
make test
1/1 ArduinoLogger_tests FAIL 0.02 s (exit status 1)
[... truncated output]
make: *** [test] Error 1
We can run the actual test program for more detailed output:
/buildresults/arduino_logger_tests
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
arduino_logger_tests is a Catch v2.9.2 host application.
Run with -? for options
------------------------------------------------------------------
EmptyTestCase
------------------------------------------------------------------
../test/ArduinoLoggerTests.cpp:5 ...............................................................................
../test/ArduinoLoggerTests.cpp:8: FAILED:
REQUIRE( true == false )
===========================================
test cases: 1 | 1 failed assertions: 1 | 1 failed
Great, now we know the build system functions, it properly sees the includes, and our test fails. We can kill the failing test statement in EmptyTestCase and everything will pass.
make test
1/1 ArduinoLogger_tests OK 0.02 s
Ok: 1
Expected Fail: 0
Fail: 0
Unexpected Pass: 0
Skipped: 0
Timeout: 0
Now we’re ready to begin development. Since I’m importing an existing library, what follows will explain the code I am using. In future articles, I will journal the changes in response to feedback and new features. You’ll see the live evolution of the library using this code as a starting point.
Defining Log Levels
We want our levels to match the Python levels. We’ll use different numbers than Python, however.
/// Logging is disabled
#define LOG_LEVEL_OFF 0
/// Indicates the system is unusable, or an error that is unrecoverable
#define LOG_LEVEL_CRITICAL 1
/// Indicates an error condition
#define LOG_LEVEL_ERROR 2
/// Indicates a warning condition
#define LOG_LEVEL_WARNING 3
/// Informational messages
#define LOG_LEVEL_INFO 4
/// Debug-level messages
#define LOG_LEVEL_DEBUG 5
/// The maximum log level that can be set
#define LOG_LEVEL_MAX LOG_LEVEL_DEBUG
/// The number of possible log levels
#define LOG_LEVEL_COUNT (LOG_LEVEL_MAX + 1)
We can define log level prefix strings:
#define LOG_LEVEL_CRITICAL_PREFIX "!"
#define LOG_LEVEL_ERROR_PREFIX "E"
#define LOG_LEVEL_WARNING_PREFIX "W"
#define LOG_LEVEL_INFO_PREFIX "I"
#define LOG_LEVEL_DEBUG_PREFIX "D"
#define LOG_LEVEL_INTERRUPT_PREFIX "int"
We can create long versions of the log level names:
#ifndef LOG_LEVEL_NAMES
/// Users can override these default names with a compiler definition
#define LOG_LEVEL_NAMES \
{ \
"off", "critical", "error", "warning", "info", "debug", \
}
#endif
And short versions of the log level names names:
#ifndef LOG_LEVEL_SHORT_NAMES
/// Users can override these default short names with a compiler definition
#define LOG_LEVEL_SHORT_NAMES \
{ \
"O", LOG_LEVEL_CRITICAL_PREFIX, LOG_LEVEL_ERROR_PREFIX, LOG_LEVEL_WARNING_PREFIX, \
LOG_LEVEL_INFO_PREFIX, LOG_LEVEL_DEBUG_PREFIX, \
}
#endif
Note that we allow users to supply their own definitions if they don’t like the default ones. All a user needs to do is provide an equivalent compile-time definition (still supplied as an array initializer list).
Compile-time Options
We can provide the option for a compile-time log level setting. By default, we’ll allow all log statements to remain in the binary:
#ifndef LOG_LEVEL
/** Default maximum log level.
*
* This is the maximum log level that will be compiled in.
* To set a custom log level, define the LOG_LEVEL before including this header
* (e.g., as a compiler definition)
*/
#define LOG_LEVEL LOG_LEVEL_DEBUG
#endif
We can provide options to control whether logging is enabled by default, and whether logging should echo to console/serial/printf:
#ifndef LOG_EN_DEFAULT
/// Whether the logging module is enabled automatically on boot.
#define LOG_EN_DEFAULT true
#endif
#ifndef LOG_ECHO_EN_DEFAULT
/// Indicates that log statements should be echoed to the console
/// If true, log statements will be echoed.
/// If false, log statements will only go to the log.
#define LOG_ECHO_EN_DEFAULT false
#endif
Building up the C++ Library
We’ve got some preprocessor definitions, now let’s get our C++ classes going. I’m importing some existing and already tested logging code, so we will bypass test-first for now.
We’ll want a real enum for use with the APIs:
enum log_level_e
{
off = LOG_LEVEL_OFF,
critical = LOG_LEVEL_CRITICAL,
error = LOG_LEVEL_ERROR,
warn = LOG_LEVEL_WARNING,
info = LOG_LEVEL_INFO,
debug = LOG_LEVEL_DEBUG,
};
constexpr log_level_e LOG_LEVEL_LIMIT() {
return static_cast<log_level_e>(LOG_LEVEL);
}
We can also create some helpful functions to translate from a log level to a string:
class logNames
{
public:
// These are instantiated in a .cpp file, FYI
constexpr static const char* level_short_names[LOG_LEVEL_COUNT] =
LOG_LEVEL_SHORT_NAMES;
constexpr static const char* level_string_names[LOG_LEVEL_COUNT] =
LOG_LEVEL_NAMES;
};
constexpr const char* LOG_LEVEL_TO_C_STRING(log_level_e level)
{
constexpr const char* level_string_names[] = LOG_LEVEL_NAMES;
return level_string_names[level];
}
constexpr const char* LOG_LEVEL_TO_SHORT_C_STRING(log_level_e level)
{
constexpr const char* level_short_names[] = LOG_LEVEL_SHORT_NAMES;
return level_short_names[level];
}
We’ll create a base class (LoggerBase) with the interface we want all loggers to have. We can create multiple logging implementations by deriving from the base class. We’ll start with the API draft we created in the brainstorming session.
We’ll need some private data members that track the state of the logger. I will make these variables private for now, although we may promote them to protected later if there is a good reason for the derived classes to use them.
protected:
/// Indicates whether logging is currently enabled
bool enabled_ = LOG_EN_DEFAULT;
/// The current log level.
/// Levels greater than the current setting will be filtered out.
log_level_e level_ = LOG_LEVEL_LIMIT();
/// Console echoing.
/// If true, log statements will be printed to the console through printf().
bool echo_ = LOG_ECHO_EN_DEFAULT;
Next, we can start defining the public interfaces we outlined in our API brainstorming.
The size, capacity, flush, and clear functions depend on the implementation strategy, so they will be pure virtual. Derived classes must supply the implementation.
public:
virtual size_t size() const noexcept = 0;
virtual size_t capacity() const noexcept = 0;
// Flush to the output source, if necessary. Can be no-op.
virtual void flush() noexcept = 0;
// Clear the buffer contents without flushing. Can be no-op
virtual void clear() noexcept = 0;
We can add functions to set and get the various settings:
bool enabled() const noexcept
{
return enabled_;
}
bool echo() const noexcept
{
return echo_;
}
bool echo(bool en) noexcept
{
echo_ = en;
return echo_;
}
log_level_e level() const noexcept
{
return level_;
}
log_level_e level(log_level_e l) noexcept
{
if(l <= LOG_LEVEL_LIMIT())
{
level_ = l;
}
return level_;
}
We’ll also configure the constructor and destructor. These are marked protected because we don’t want this base class to be directly instantiated.
protected:
/// Default constructor
LoggerBase() = default;
/// Default destructor
virtual ~LoggerBase() = default;
Logging Implementation
Now for the real meat: logging data. Recall that we want to enforce general logging interfaces and behavior, while allowing users to specify the actual output destination (and eventually, format).
This is where we need to leverage the Arduino printf library Since the library code comes from an existing implementation that I created, I will explain it rather than derive it from scratch.
The mpaland/printf library provides a function called fctprintf. This function allows us to specify an output function for a single character (similar to putchar), an instance pointer, a format string, and our arguments.
// use output function (instead of buffer) for streamlike interface
int fctprintf(void (*out)(char character, void* arg), void* arg, const char* format, ...);
We will leverage this function to build a customizable logger.
First, we will create a protected pure virtual member function. Derived classes must implement this function to handle log buffer output.
/** Log buffer putc function
*
* This function adds a character to the underlying log buffer.
*
* This function is used with the fctprintf() interface to output to the log buffer.
* This enables the framework to reuse the same print formatting for both logging and printf().
*
* Derived classes must implement this function.
*
* @param c The character to insert into the log buffer.
*/
virtual void log_putc(char c) = 0;
This is C++, and to access a member function we need access to our this pointer. The printf library is implemented in C, so this pointers are a foreign concept. We can use the void * arg of the fctprintf function to supply the this pointer, but we need some way to cast the pointer and call the function.
To do that, we’ll create a static “bounce” function that handles the translation for us. All this function does is take the character and void * argument, cast to the proper type, and forward the character to log_putc.
static void log_putc_bounce(char c, void* this_ptr)
{
reinterpret_cast<LoggerBase*>(this_ptr)->log_putc(c);
}
Now that we have our building blocks, we can assemble the log() interface.
We need a function that takes in a log level, a format string, and a variable number of arguments. Rather than using C’s variadic arguments, we’re going to use a C++ variadic template.
template<typename... Args>
void log(log_level_e l, const char* fmt, const Args&... args) noexcept
The implementation is straightforward. To enable run-time filtering, we need to check whether logging is currently enabled and whether the requested log level is valid:
if(enabled_ && l <= level_)
{
// Do Stuff
}
If so, we need to add to the log.
First, we’ll print the prefix. Right now this is fixed, but eventually we can refactor this into another virtual function. We provide the log level as an enum, so we’ll use our constexpr conversion function to supply the string. Notice that fctprintf uses the static bounce function with the current class’s this pointer.
// Add our prefix
fctprintf(&LoggerBase::log_putc_bounce, this, "<%s> ",
LOG_LEVEL_TO_SHORT_C_STRING(l));
Next we will print the log statement in the same way. Since we’re using a variadic template, we can skip the va_list boilerplate and supply the arguments using args...:
// Send the primary log statement
fctprintf(&LoggerBase::log_putc_bounce, this, fmt, args...);
Finally, to support echo-to-console behavior, we also need to call printf with the equivalent statements above:
if(echo_)
{
printf("<%s> ", LOG_LEVEL_TO_SHORT_C_STRING(l));
// cppcheck-suppress wrongPrintfScanfArgNum
printf("%s", fmt, args...);
}
Now we have a minimal logger we can customize:
template<typename... Args>
void log(log_level_e l, const char* fmt, const Args&... args) noexcept
{
if(enabled_ && l <= level_)
{
// Add our prefix
fctprintf(&LoggerBase::log_putc_bounce, this, "<%s> ",
LOG_LEVEL_TO_SHORT_C_STRING(l));
// Send the primary log statement
fctprintf(&LoggerBase::log_putc_bounce, this, fmt, args...);
if(echo_)
{
printf("<%s> ", LOG_LEVEL_TO_SHORT_C_STRING(l));
// cppcheck-suppress wrongPrintfScanfArgNum
printf("%s", fmt, args...);
}
}
}
Circular Buffer
We can’t use our base class directly, so we need to create an initial logging implementation.
The simplest logger that I know of is one that stores the information in a circular buffer in RAM. If the log fills up, we overwrite the oldest information with the newest information.
My initial testing is on the Teensy[ Set a reminder to update this with a link when it’s published], which gives me more flexibility with using the STL. I will leverage a single-header library I love called ring-span-lite. This library provides a circular buffer interface to an existing buffer.
#include "ArduinoLogger.h"
#include "internal/ring_span.hpp"
When we define our class, we need it to inherit from the LoggerBase class. I want the user to specify a size for the log buffer as a template parameter so we can avoid dynamic memory allocations.
template<size_t TBufferSize = (1 * 1024)>
class CircularLogBufferLogger final : public LoggerBase
Constructors and destructors are simple:
CircularLogBufferLogger() : LoggerBase() {}
~CircularLogBufferLogger() noexcept = default;
We will need private data members to represent our static log storage. First, we have a raw char buffer for our storage. We then create a ring_span representation for the buffer.
private:
char buffer_[TBufferSize] = {0};
stdext::ring_span<char> log_buffer_{buffer_, buffer_ + TBufferSize};
The crucial piece: our log_putc implementation. For the circular buffer, we just need to add the new value to the back of the queue.
void log_putc(char c) noexcept final
{
log_buffer_.push_back(c);
}
Finally, we can populate our public virtual interfaces. When we call flush(), we pop a character from the buffer and print to serial. When we call clear(), we just pop all entries. More optimal implementations are possible, but we’re going for simple and deliverable right now.
size_t size() const noexcept final
{
return log_buffer_.size();
}
size_t capacity() const noexcept final
{
return log_buffer_.capacity();
}
void flush() noexcept final
{
while(!log_buffer_.empty())
{
_putchar(log_buffer_.pop_front());
}
}
void clear() noexcept final
{
while(!log_buffer_.empty())
{
log_buffer_.pop_front();
}
}
Now we have a real logging implementation we can test!
Enforcing Static Instances
I know that I want to enable macros to work so I can get compile-time removal of unwanted log statements. This will require a common logging instance, since the user can call the macros from any source file.
We’ll create a wrapper class:
template<class TLogger>
class PlatformLogger_t
{
public:
PlatformLogger_t() = default;
~PlatformLogger_t() = default;
static TLogger& inst()
{
static TLogger logger_;
return logger_;
}
}
To use the singleton model, we need to supply a common alias definition a user-defined header, which we will call platform_logger.h. The user selects the logging strategy they want and places the class name within the template brackets.
#include <CircularBufferLogger.h>
using PlatformLogger =
PlatformLogger_t<CircularLogBufferLogger<1024>>;
Then, our macros can use the common PlatformLogger.inst() for logging.
Log Macros
Our last piece of the implementation puzzle is defining logging macros that accomplish a few goals:
- Support compile-time filtering of log statements
- Enable future support for preprocessor tracing information
- Hide the details of the singleton interface
#if LOG_LEVEL >= LOG_LEVEL_CRITICAL
#ifndef logcritical
#define logcritical(...) PlatformLogger::inst().log(log_level_e::critical, __VA_ARGS__)
#endif
#else
#define logcritical(...)
#endif
#if LOG_LEVEL >= LOG_LEVEL_ERROR
#ifndef logerror
#define logerror(...) PlatformLogger::inst().log(log_level_e::error, __VA_ARGS__)
#endif
#else
#define logerror(...)
#endif
#if LOG_LEVEL >= LOG_LEVEL_WARNING
#ifndef logwarning
#define logwarning(...) PlatformLogger::inst().log(log_level_e::warning, __VA_ARGS__)
#endif
#else
#define logwarning(...)
#endif
#if LOG_LEVEL >= LOG_LEVEL_INFO
#ifndef loginfo
#define loginfo(...) PlatformLogger::inst().log(log_level_e::info, __VA_ARGS__)
#endif
#else
#define loginfo(...)
#endif
#if LOG_LEVEL >= LOG_LEVEL_DEBUG
#ifndef logdebug
#define logdebug(...) PlatformLogger::inst().log(log_level_e::debug, __VA_ARGS__)
#endif
#else
#define logdebug(...)
#endif
We can also create helpful shims that help us avoid the inst() method:
#define logflush() PlatformLogger::inst().flush();
#define loglevel(lvl) PlatformLogger::inst().level(lvl);
#define logecho(echo) PlatformLogger::inst().echo(lvl);
#define logclear() PlatformLogger::inst().clear();
Now we have a basic interface in place with a single logging strategy. This is good enough to share with the client for feedback.
Tests
Now, in reality I copied existing files, refactored the names, stripped out unneeded features, and simplified the interface. I then created some basic checks just to make sure everything compiles and roughly works.
We will use this Arduino library in a real program alongside arduino-printf. But for test code, we need to satisfy the dependency without requiring the full Arduino environment. So I copied the printf.c and printf.h files from the library into the test/ directory. I then edited printf.h to remove the #define statements like this one, to make sure I could use my OS’s printf in the tests:
#define printf printf_
int printf_(const char* format, ...);
I like to make an initial test list to work through. For the CircularBuffer, I wanted to check:
- Compiles on host (done)
- Links on host (done)
- Create a circular log buffer of a given size, check default parameters
- Change log level limit, observe that it changes
- Check that log statements are added to the queue and size increases
- Check that a prefix is added to the log statement
- Flush the buffer and verify that the output matches the expectation
- Set different log levels, run-time filtering works
- Check that short strings match the expectation for log level
- Check that long strings match the expectation for log level
Thanks to the printf library, I can define a _putchar function to test the log buffer contents without reaching into the class details:
std::string log_buffer_output;
void _putchar(char character)
{
log_buffer_output += character;
}
I won’t bore you with a full test code. You can see the initial set of tests here. There’s a lot of work still to be done with testing… I’m just trying to get the first pass out the door. And again, I have confidence in this code because I know the origin – although that doesn’t make it bulletproof!
Creating Examples
Onto the real fun – testing the code on real hardware.
Recall that we need to define a platform_logger.h file which defines the proper singleton instance for use with the macros. We’ll create one that uses a 1 kB circular buffer:
#ifndef PLATFORM_LOGGER_HPP_
#define PLATFORM_LOGGER_HPP_
#include <CircularBufferLogger.h>
using PlatformLogger =
PlatformLogger_t<CircularLogBufferLogger<1024>>;
#endif
In our example sketch, we include that header:
#include "platform_logger.h"
We want to echo over Serial using printf, so we need to initialize the Serial class in setup(). We’ll also add a log statement.
void setup() {
Serial.begin(115200);
logdebug("This line is added to the log buffer from setup\n");
}
The loop() function will add to the log buffer, and every 10 iterations it will print out the buffer contents:
static int iterations = 0;
void loop() {
// put your main code here, to run repeatedly:
loginfo("Loop iteration %d\n", iterations);
iterations++;
if((iterations % 10) == 0)
{
printf("Log buffer contents:\n");
logflush();
}
delay(1000);
}
It works! (I did run tests after all…)
Log buffer contents:
<D> This line is added to the log buffer from setup
<I> Loop iteration 0
<I> Loop iteration 1
<I> Loop iteration 2
<I> Loop iteration 3
<I> Loop iteration 4
<I> Loop iteration 5
<I> Loop iteration 6
<I> Loop iteration 7
<I> Loop iteration 8
<I> Loop iteration 9
I created a second example which uses the same structure, but shows compile-time filtering of log statements. The difference between the examples is in the platform_logger.h header:
// This will change the compile-time log level to compile-out logdebug messages
#define LOG_LEVEL LOG_LEVEL_INFO
#include <CircularBufferLogger.h>
using PlatformLogger =
PlatformLogger_t<CircularLogBufferLogger<1024>>;
Documentation
Everything works, and I’ve done some source-level commenting along the way. But we don’t have a useful library if developers don’t know how to use it, so we need to create a README.
We’ve previously written about writing a great README and shared a README template.
For this library and the intended audience, we will simplify things a bit. Here’s what we want to cover:
- Does this library have any dependencies?
- How should a user incorporate the library into a project?
- How do you interact with the library?
- What customization options are there?
- What other requirements should the user know about?
- How can we run the tests?
From this list of questions, I know what I need to highlight:
- The dependency on arduino-printf
- How to work with the library using the macros
- How to select a logger implementation
- The provided logging implementations
- How to configure the library at compile-time
- How to configure the library at run-time
- How to compile and run the tests.
I also want to add some notes about the different examples that are available and what I expect each example to demonstrate.
We’ll also want to add documentation for creating your own logging strategy, but we’ll wait until after the client reviews the interface. You can see the full README contents in the repository.
Putting it All Together
We have a basic interface in place with a single logging strategy. We have examples, basic documentation, and tests in place. Before I invest any more time into the library, I want to send it to my client to make sure the current direction is acceptable. The next article in the series will cover the review feedback and show how I address that. We’ll also tackle one new feature.
I created a tag to track this point in the project, so you can go look at the code as it is described in this article.
git tag -m "Tagging 0.1.0 - first pass, released to Kelley for review." 0.1.0
{master} arduino-logger$ git push --tags
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 203 bytes | 203.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0)
To github.com:embeddedartistry/arduino-logger.git
* [new tag] 0.1.0 -> 0.1.0
One thing that is different between the article and source: I moved the library files into the src/ folder after writing this article. I updated the article, but for 0.1.0 the library files still reside in the top-level.
In the next article, you’ll see the client’s feedback and how I addressed it.
Further Reading
- Building a Flexible Logging Library for Arduino, Part 2
- Building an Arduino
printfLibrary - Arduino printf Library
- Comparing Variadic Functions: C vs C++
- Using A C++ Object’s Member Function with C-style Callbacks
- Arduino Libraries
- Arduino Library Template
- Python 3 Logging
- thijse/Arduino-Log
- greiman/SdFat
- mpaland/printf
