6.8.1. Think twice before using enumerations in C

It is generally considered a good practice to define named integer constants as enumerations (enum) if they are related to each other. i.e.: they can be grouped under a common name, bringing more meaning, more “semantic intention”:

enum Planet {
  MERCURY = 0xa1,
  VENUS = 0xb2,
  EARTH = 0xc3,
  MARS = 0xd4
};

as opposed to preprocessor’s #define that cannot group a set of macro constants under a common name:

#define MERCURY (0xa1)
#define VENUS (0xb2)
#define EARTH (0xc3)
#define MARS (0xd4)

Most of the time and for the vast majority of usual C code, this practice should be applied. Enumerations also have the advantage of being true C symbols from the compiler and debugger point of view.

However, there are a few situations where manipulating integer constants as enumerations should not be considered, or at least should be taken really carefully. These situations happen when enumerations are manipulated as l-values on specific architectures.

6.8.1.1. The C specification

This is an excerpt from the C specification about enumerations (in http://www.open-std.org/JTC1/SC22/WG14/www/docs/n1256.pdf - 6.7.2.2 Enumeration specifiers):

“The expression that defines the value of an enumeration constant shall be an integer constant expression that has a value representable as an int.”

As every C programmer knows, the size of an int in C is left unspecified by the standard. This means that the size of a enumeration constant value is platform/architecture/compiler dependant. Enumerations are not of a guaranteed size by the C specification, and there is no standard way to do so.

6.8.1.2. A consequence on binary interfacing

The first consequence of the C specification is that enum constants don’t have a fixed size across different architectures, as the size of an int varies between architectures/compilers. This is quite a well-know fact amongst C programmers.

Defining constants as enum should be avoided when binary interaction/interfacing is needed accross different architectures (e.g. over networks).

6.8.1.3. An argument against specific uses of enumerations

Let’s now introduce a sneaky pitfall, that happens under specific conditions. Lazuli RTOS is targeting embedded systems. It is written in ANSI C and aims to be easily portable across different architectures.

We take here the example of the AVR architecture, which is a target for the Lazuli RTOS.

AVR is an 8-bit CPU, but on which many compilers (such as AVR-GCC) define the type int to be 16-bit long. This means that enumerations will be 16-bit long as well.

Here is our general rule:

Great care must be taken when using enumerations on architectures where the size of the machine word is narrower than the size of an int.

Although this situation is usually not a problem (we’ve used 64-bit variables on 32-bit machines for years), it can become one in certain contexts, and can even lead to synchronization problems as we will see. And it’s a situation that happens quite often in embedded C development, as we are often dealing with tiny machines. It is much more difficult to see these problems and their consequences when using enumerations rather than integers, because we often rely on standard header <stdint.h> when declaring integer variables. We then have a convenient way to master the size of integer variables right from their declaration. Unfortunately no equivalent exists for enumerations, and its not easy to spot their size at a glance when reviewing the source code.

Using enumeration variables on a platform where the size of the machine word is narrower than the size of an int has 3 main consequences, that are in fact linked together:

  • An impact on performance
  • An impact on memory usage and the total size of the resulting binary
  • Non atomicity of memory accesses

Now let’s see why.

We consider the following piece of C code (the constant values have been chosen so we can find them easily within the disassembly):

enum Planet {
  MERCURY = 0xa1,
  VENUS = 0xb2,
  EARTH = 0xc3,
  MARS = 0xd4
};

volatile enum Planet planet;

void
example(void)
{
  planet = EARTH;
}

Notice that all these constants are only 8-bit long.

Let’s compile this short example for the AVR architecture and observe the assembly generated by the compiler.

 [root@0ca45bbdd5b1 best_practices]# avr-gcc -c -O3 enum_example.c \
 > && avr-objdump -d enum_example.o

 enum_example.o:     file format elf32-avr


 Disassembly of section .text:

 00000000 <example>:
    0:   83 ec           ldi r24, 0xC3   ; 195
    2:   90 e0           ldi r25, 0x00   ; 0
    4:   90 93 00 00     sts 0x0000, r25 ; 0x800000 <__SREG__+0x7fffc1>
    8:   80 93 00 00     sts 0x0000, r24 ; 0x800000 <__SREG__+0x7fffc1>
    c:   08 95           ret

First, the value of the constant EARTH (0xc3) is loaded in 2 registers r24 and r25 (ldi, LoaD-Immediate). Notice here that one register (r25) is loaded with zero, as it is the high byte of 0xc3 encoded as a 16-bit word. Then this value is stored in memory, the corresponding lines are highlighted. You can also notice that, even with compiler optimizations switched on (-O3), the actual writing in memory is done in 2 operations (sts, Store-To-Sram), one byte at a time. This is coherent, as we are storing a 16-bit value in memory using an 8-bit CPU.

Now let’s do the same thing, but by replacing the enum for a smaller integer type that fits the architecture word size, and using the C preprocessor to define our named constants:

typedef unsigned char planet_t;

#define MERCURY ((planet_t)0xa1)
#define VENUS ((planet_t)0xb2)
#define EARTH ((planet_t)0xc3)
#define MARS ((planet_t)0xd4)

volatile planet_t planet;

void
example(void)
{
  planet = EARTH;
}

Let’s examine the disassembly:

 [root@0ca45bbdd5b1 best_practices]# avr-gcc -c -O3 enum_example_with_cpp.c \
 > && avr-objdump -d enum_example_with_cpp.o

 enum_example_with_cpp.o:     file format elf32-avr


 Disassembly of section .text:

 00000000 <example>:
    0:   83 ec           ldi r24, 0xC3   ; 195
    2:   80 93 00 00     sts 0x0000, r24 ; 0x800000 <__SREG__+0x7fffc1>
    6:   08 95           ret

The actual writing in memory is now performed in one operation. Needless to precise that this writing operation is atomic on the AVR.

We can now address our three concerns from before:

First, about performance. This is a strong concern if you are writing code that must be fast in those very architecture-specific cases (which is the case of many embedded projects, such as Lazuli RTOS, or cross-architecture projects). If the values of your enum can all fit within a machine word, then you should consider using #define with a machine word size instead of enum. In the Lazuli kernel, all enumerations definitions used in the scheduler have been replaced by typedef and #define to a smaller type.

Then, about the memory usage and total size of binary. It is obvious when comparing the two disassemblies above that using enumerations where a smaller type can be used leads to a bigger memory usage. This is true for ROM (program memory), as well as RAM usage (global variables, stack variables, etc.). The difference may only be one byte, but the AVR ATmega328P only has 2048 bytes of RAM, so one byte is important. Obviously, this can be applied only if the values of your enum can all fit within a machine word.

And last but not least, about the atomicity of memory accesses. Looking at the first disassembly above, a context switch or an interrupt handler can occur during writing in memory, between the two sts. If another thread of execution tries to access the value while the writing thread is suspended between the two sts of its writing operation, then the reader thread will possibly read a corrupted value. This can have catastrophic consequences. This means that in cases where the size of the machine word is narrower than the size of an int, enumerations must not be used to pass signals or messages between different threads of execution. Otherwise synchronization issues can occur.

Replacing enumerations by typedef and #define to a type whose size fits the machine word solves this problem.

In those cases, you can use the type u_read_write_atomic_t that is defined in <Lazuli/common.h>. The type u_read_write_atomic_t is an unsigned integer having a size that guarantees that reading/writing from/to memory is atomic accross all architectures. This is the equivalent of the C standard sig_atomic_t defined in <signal.h>. In some situations, using this type can also avoid using an explicit lock.

This type is used in the Lazuli kernel any time a user task needs to pass a message to the scheduler/kernel.

6.8.1.4. A note on -fshort-enums

Some compilers support an option to produce “short enumerations”, such as GCC’s -fshort-enums. This option is not used in Lazuli, as it compiler-specific. Lazuli is written in standard C89, so it must compile and run correctly with any compiler. No assumptions about the compiler, no surprises. The code is not supposed to work correctly only under the condition of being compiled with a specific compiler (especially for atomic reads and writes).

Some other problems can occur when using -fshort-enum, but it is not worth speaking about them here, as we care about not being compiler-specific. You can read more about the pitfalls of using -fshort-enums here: https://interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags#-fshort-enum