Formatted output

About this Post

For formatted output on the serial monitor or on displays, you can use the convenient functions sprintf() and snprintf(). This allows you to combine different data types in a character array, display results in tabular form, select number systems and much more.

This article is intended to provide an overview of the various possibilities of sprintf() or snprintf() and their siblings sprintf_P(), snprintf_P() and printf(). Some board packages do not support the formatting of decimal numbers using these functions. In these cases, you can take a detour via dtostrf().

The options for formatting output with Serial.print() and escape sequences are very limited. But to bring all formatting options together in one place, I will also go into this.

What you can expect in this article:

Formatted output with the Serial.print() options

If you pass a number to the function Serial.print() or Serial.println(), you can format it using a second parameter:

Serial.print(val, format);

For format you have the following options: 

  • You can define the number base for integers:
    • HEX: Base 16, hexadecimal system
    • DEC: Base 10, decimal system (standard),
    • OCT: Base 8, octal system
    • BIN: Base 2, binary system
  • For decimal numbers, you specify the number of decimal places (default: 2). The last digit is rounded.

Here is a small example sketch:

void setup() {
    Serial.begin(115200);
    // delay(1000);  //uncomment if your serial monitor is blank
    int a = 193;
    float f = PI; // PI = 3.1415926...
    Serial.println("Output of 193 (dec.):");
    Serial.print("Hexadecimal: "); Serial.println(a, HEX);
    Serial.print("Decimal    : "); Serial.println(a, DEC);
    Serial.print("Octal      : "); Serial.println(a, OCT);
    Serial.print("Binary     : "); Serial.println(a, BIN);
    Serial.println("\nOutput of Pi:"); // here, I already used an escape sequence
    Serial.print("1 decimal place : "); Serial.println(f, 1);
    Serial.print("2 decimal places: "); Serial.println(f, 2);
    Serial.print("3 decimal places: "); Serial.println(f, 3);
}

void loop() {}

And here is the output:

Formatted output with Serial.print()
Formatted output with Serial.print()

Saving SRAM with the F-Macro

Constant text, i.e. everything in quotation marks in the above sketch, is an SRAM eater. With the F macro, you ensure that the character string to be output is not stored in the SRAM but in the flash:

Serial.println(F("Output String"));

This has nothing to do with formatting, but it should be noted here for the sake of completeness because I will discuss a corresponding variant of the sprintf() function below.

Escape sequences

Escape sequences offer you further options for formatting the output of Serial.print() or outputting special characters. The use of escape sequences is not limited to Serial.print(), but can be used in all string objects or character arrays.

void setup() {
    Serial.begin(115200);
    // delay(1000);  //uncomment if your serial monitor is blank
    Serial.print("\\n outputs a line feed \n");
    Serial.println("\\r outputs a carriage return \r"); 
    Serial.println("\\xXX outputs the ASCII character of 0xXX, e.g. \\x41:  \x41");
    Serial.println("\\uXXXX outputs the unicode character of 0xXXXX, e.g. \\u21C4: \u21C4");
    Serial.println("\\t outputs a tab, e.g.: 12\t34 ");
    Serial.println("\\\" outputs \"");
    Serial.println("\\\' outputs \'");
    Serial.println("\\\\ outputs \\");
    Serial.print("Serial.println() of "); int numOfChars = Serial.println("12345");
    Serial.print("    ...prints "); Serial.print(numOfChars); Serial.println(" characters!");
}

void loop() {}

The output is:

Formatted output with escape sequences
Output of escape_sequences.ino

In the last line, you can see that Serial.println("...") generates two more characters than you might expect. This is due to the “hidden” carriage return and line feed. The function corresponds to Serial.print("...\r\n").

Formatted output with sprintf() Co

Overview of the functions

With the functions sprintf(), snprintf() printf() and snprintf_P() you have incomparably more powerful tools at your disposal.

sprintf()

sprintf() is defined as follows:

int sprintf ( char *buf, const char *format, ... );

Here, format contains the characters to be output and, if necessary, placeholders for variables with formatting instructions. These placeholders are also called format specifiers. They can be recognized by the preceding percent sign. The variables themselves are appended in the order in which their format specifiers appear, separated by commas:

sprintf(buf, "... format_specifier_1 ... format_specifier_2 ... format_specifier_3....", variable_1, variable_2, variable_3, ..,);

The result is saved in the character array buf and can be output via Serial.print().

snprintf() vs. sprintf()

The function sprintf() writes the character string defined in format to buf, regardless of whether you have reserved enough memory for it or not. If buf is too small, you simply write beyond it – with unforeseeable consequences (see below).  

The snprintf() function is safer. It differs from sprintf() by the concrete specification of the number of characters to be read in (here: “n”):

int snprintf ( char *buf, size_t n, const char * format, ... );

If you specify “n” with sizeof(buf), you are on the safe side. At worst, part of what you wanted to spend will lost, but at least you won’t have any problems due to unwanted overwriting of memory.

Even though I recommend snsprintf(), I will use sprintf() in the rest of the article to focus on the formatting options.

printf() / Serial.printf()

You may be wondering whether you can save yourself the detour via the character array buf. In fact, you can use the Serial.printf() function on some microcontroller boards (e.g. ESP32), but not, for example, on the classic AVR-based Arduinos.

int Serial.printf(const char * format, ....);

If the board of your choice supports printf(), then you can simply transfer everything from sprintf(). However, you should consider whether you really want to write your sketches board-specifically.

The printf() function (i. e. not: Serial.printf()) is generally available:

int printf (const char * format, ... );

However, you cannot simply generate an output on the serial monitor because its input and output is not defined. If you are interested in details and a workaround, look here and here.

Saving SRAM with sprintf_P() and snprintf_P()

With the variants sprintf_P() and snprintf_P() you can achieve that format is not read from the SRAM, but from flash:

int sprintf_P ( char *buf, const char* PSTR(format), ... );

int snprintf_P ( char *buf, size_t n, const char* PSTR(format), ... );

The PSTR macro is similar to the F macro. However, the latter is incompatible with sprintf_P() and snprintf_P(). If you want to know exactly: The F macro returns __FlashStringHelper*, but sprintf_P() and snprintf_P() expect const char* as a parameter. And this is exactly what the PSTR macro generates.

Using format specifiers

A format specifier consists of the percent sign, some optional sub-specifiers and the conversion specifier:

% [flags] [width] [.precision] [length] conversion specifier

The conversion specifier

You use the conversion specifier to define what you want to output the placeholders as. The conversion specifier must, of course, be compatible with the variable to be output. Here are the options:

Options for the conversion specifier for the formatted output
Options for the conversion specifier

Not all conversion specifiers are supported by all board packages. Users of AVR-based boards, e.g. Arduino UNO R3, classic Arduino Nano etc., will miss the options for decimal numbers (decimal numbers = fixed-point and floating-point numbers) the most. There is a detour via dtostrf(), which we will come to below.

The sub-specifiers

You have the following options for the flag:

Formatted output - flags for the format specifier
Flags for the format specifier

The width is the minimum width for the character string defined in the format specifier. If the character string is longer than defined in “width” or the same length, then “width” has no effect. If the character string is shorter than width, the difference is filled with spaces or zeros (see flags).

The effect of precision depends on the conversion specifier:

  • f, e, E, a, A: the precision specifies the number of decimal places.
  • g, G: the precision specifies the total number of digits before and after the decimal point.
  • d, i, o, u, x, X: the precision specifies the minimum width. Any spaces are filled with leading zeros.
  • s: the precision specifies the maximum number of characters to be output. Characters exceeding this are truncated.

The effect of the length also depends on the conversion specifier:

  • l (small “L”): turns d, i, o, u, x, X into a “long int” or an “unsigned long int”.
  • ll (small “Ls”): turns d, i, o, u, x, X into a “long long int” or an “unsigned long long int”.
  • L: turns f into a long double.

Example sketches

Basic example

The following sketch illustrates how to use the format specifiers in sprintf(), snprintf() and snprintf_P(). I will initially dispense with sub-specifiers:

void setup() {
    Serial.begin(115200);
    char buf[60]; // more than enough capacity
    int a = 42;
    char str[] = "universe";
    sprintf(buf, "%d is the meaning of life, %s %s", a, str, "and everything");
    Serial.println(buf);
    snprintf(buf, 26, "%d is the meaning of life, %s %s", a, str, "and everything");
    Serial.println(buf);
    snprintf(buf, sizeof(buf), "%d is the meaning of life, %s %s", a, str, "and everything");
    Serial.println(buf);
    snprintf_P(buf, sizeof(buf), PSTR("%d is the meaning of life, %s %s"), a, str, "and everything");
    Serial.println(buf);
}

void loop() {}

Here is the output:

Output of sprintf_snprintf_snprintf_P_basic.ino
Calculation of buf

When calculating the size of the “target string” (buf), it should be noted that the number of characters to be output is taken as the basis and not the size of the variable. Example: An integer uses 2 bytes on an AVR-based Arduino. However, how much memory the integer value requires in buf depends on the number of digits and possibly on the formatting parameters (additional signs, spaces, “0x” etc.).

Don’t forget to reserve one character for the null terminator.

Here you can see what can happen if you define a size for buf that is too small:

void setup() {
    Serial.begin(115200);
    char buf[5]; 
    char buf2[6];
    char str[] = "1234567890";
    sprintf(buf, "%s", str);
    Serial.print("buf : ");
    Serial.println(buf);
    sprintf(buf2, "%s", str);
    Serial.print("buf2: ");
    Serial.println(buf2);
    Serial.print("buf : ");
    Serial.println(buf);  
}

void loop() {}

The output, tested on an Arduino Nano, is:

Output of wrong_buf_size.ino on an Arduino Nano

As you can see, the error only becomes apparent after the string has been written to buf2 and its remainder, which does not fit into the reserved memory, “protrudes” into the memory reserved for buf

On an ESP32, the SRAM is organized differently. Here, buf is overwritten “coming from the other side”:

Output of wrong_buf_size.ino on an ESP32

Example: formatted output of integers

The next example sketch plays with some sub-specifiers for integers.

void setup() {
    Serial.begin(115200);
    char buf[50];
    int a = 193;
    sprintf(buf, "Default integer (signed)       : %i", a);
    Serial.println(buf);
    sprintf(buf, "Defined width, right-justify   : %5d%5d", a, a+a);
    Serial.println(buf);
    sprintf(buf, "Defined width, left-justify    : %-5d%-5d", a, a+a);
    Serial.println(buf);
    sprintf(buf, "Defined width, preceeding zeros: %05d", a);
    Serial.println(buf);
    sprintf(buf, "With sign                      : %+d", a);
    Serial.println(buf);
    sprintf(buf, "Hexadecimal, lowercase         : %x ", a);
    Serial.println(buf);
    sprintf(buf, "Hexadecimal, uppercase         : %X", a);
    Serial.println(buf);
    sprintf(buf, "Hexadecimal, with \"0X\"         : %#X", a);
    Serial.println(buf);
    sprintf(buf, "Octal                          : %o", a);
    Serial.println(buf);
    Serial.print("No binary option for sprintf   : "); Serial.println(a, BIN);
    sprintf(buf, "Long integer                   : %ld", 1234567890);
    Serial.println(buf);
    sprintf(buf, "%-31s%s%05d", "Alternative output", ": ", a);
    Serial.println(buf);
    sprintf(buf, "Address of buf                 : %p", buf);
    Serial.println(buf);
}

void loop() {}

 

Because I wanted to focus on the essentials, I have not used the full range of formatting options, for example to align the colons. However, to show how this can be done, I have made an exception for the formatting of the penultimate output line (“Alternative output”).  

Here is the output:

Ausgabe von sprintf_integers.ino

Example: formatted output of decimal numbers

As already mentioned, formatting of decimal numbers using sprintf() does not work on the AVR-based microcontroller boards. I have tried the following sketch on an ESP32 board:

void setup() {
    Serial.begin(115200);
    char buf[60];
    double a = PI; // PI = 3.1415926...
    sprintf(buf, "Default double                : %f", a);
    Serial.println(buf);
    sprintf(buf, "Specified length and precision: %6.2f", a);
    Serial.println(buf);
    sprintf(buf, "Scientific notation, lowercase: %e", a);
    Serial.println(buf);
    sprintf(buf, " \" , with length and precision: %10.3e", a);
    Serial.println(buf);
    sprintf(buf, "Scientific notation, uppercase: %E", a);
    Serial.println(buf);
    sprintf(buf, "Shortest, lowercase           : %g", a);
    Serial.println(buf);
    sprintf(buf, "Shortest, lowercase           : %g", 1000000 * a);
    Serial.println(buf);
    sprintf(buf, "Shortest, uppercase           : %G", a);
    Serial.println(buf);
    sprintf(buf, "Shortest, uppercase           : %G", 1000000 * a);
    Serial.println(buf);
    sprintf(buf, "Hex floating point, lowercase : %a", a);
    Serial.println(buf);
    sprintf(buf, "Hex floating point, uppercase : %A", a);
    Serial.println(buf);
}

void loop() {}

The output is:

Output of sprintf_floats.ino

You could also define Pi as a float data type in the example. You can then see the difference after the seventh digit.

Formatted output with dtostrf()

If formatting decimal numbers using sprintf() or %f does not work, you can use the dtostrf() function:

dtostrf(double f, int [-]width, int precision, char *buf)

The parameters are:

  • f: the number to be converted into a character array.
  • width: the minimum width.
    • an optional “-” forces the sign to be output.
  • precision: the decimal places.
  • buf: the output string.

When calculating the size of the “target string” (buf), you must take care that the number of characters to be output (including spaces) and the null terminator must fit.

For the output, it makes sense to combine the output of dtostrf() with sprintf().

Example sketch dtostrf()

Here are some examples of the use of dtostrf():

void setup() {
    Serial.begin(115200);
    char buf_1[10];
    char buf_2[40]; // 
    int i = 193;
    double a = PI; // PI = 3.1415926...
    
    dtostrf(a, 1, 2, buf_1);
    sprintf(buf_2, "Width > minimum width : %s", buf_1);
    Serial.println(buf_2);
    
    dtostrf(a, 7, 2, buf_1);
    sprintf(buf_2, "Right-justified:      : %s", buf_1);
    Serial.println(buf_2);

    dtostrf(a, -7, 2, buf_1);
    sprintf(buf_2, "Left-justified        : %s", buf_1);
    Serial.println(buf_2);

    dtostrf(i, 1, 0, buf_1);
    sprintf(buf_2, "Integer               : %s", buf_1);
    Serial.println(buf_2);

    dtostrf(i, 7, 2, buf_1);
    sprintf(buf_2, "\"Integer to double\"   : %s", buf_1);
    Serial.println(buf_2);   
}

void loop() {}

The output of the sketch is:

Output of dtostrf.ino

Example sketch for tabular output

Finally, here is an example of what a clear output of data records could look like:

struct dataset {
    char name[10];
    unsigned int age;
    unsigned int size;
    float weight;
};

void setup() {
    dataset mia {"Mia", 1, 75, 9.3};
    dataset michael {"Michael", 60, 185, 103.7};
    dataset kevin {"Kevin", 18, 183, 75.2};

    Serial.begin(115200);
    print_data(&mia);
    print_data(&michael);
    print_data(&kevin);
}

void print_data(dataset *set){
    char buf[40];
    char weightBuf[7]; // width 6 + null-terminator
    dtostrf(set->weight, 6, 1, weightBuf); 
    sprintf(buf, "| %-8s|%4d |%4d |%s |", set->name, set->age, set->size, weightBuf);
    Serial.println(buf);
    Serial.flush();
}

void loop() {}

As the data records are passed to print_data as pointers, the arrow operator must be used instead of the dot operator. Otherwise, the sketch should be self-explanatory.

Here is the output:

Output of formatted_table.ino

Leave a Reply

Your email address will not be published. Required fields are marked *