The hard coded, #define, const or variable question – custom protocols between Nextion and Arduino

Our long-time occupation on which we learn and grow at the same time, the Nextion Mega IO project will, as we have seen in my last blog, see another extension (CAN BUS) soon, which requires our custom protocol to be still more quick and flexible as its is already.

That was for me the moment to review what had already been done and especially, how it was done. While the number of supported data types doesn’t stop growing, we’ve seen in the past that adding these to our custom protocol was pretty straightforward, since everything had been well thought and designed from the beginning. But when I look back at our code on both sides, Arduino and Nextion, I find now that although it’s currently working surprisingly well, it is sometimes difficult to understand a few weeks later, what was done and how it was done. Time to make our code more self explaining! But as always, let’s take a little time to think and to study before going into “medias res” as the Romans said.

What am I talking about?

I’m talking about the numerous encodings, the network address, the data type, the data channel, and the data itself, which are all stuffed into the 2 or 3 bytes of our read or write commands. With everything we have seen, it’s basically easy to use bit mask and shift operations to assemble or to extract the different information of our 16bit, 24bit, or perhaps later 32bit word. It’s just that after a few weeks, memory is no longer what it was, and although I had put comments into the code, I sometimes wonder why I did things like

switch(datatype) {
  case 0x07:  // huh? What stood 0x07 still for ?
  ...

In short, it would simply be easier to have named elements instead of raw numbers, like

#define NexIOdataType_servo 0x07
switch(datatype) {
  case NexIOdataType_servo: // Ah! That is clear, even after years
  ...

That makes our code better readable and understandable, and also easier to maintain as we’ll see below.

Embedded programming purists might probably shake their heads, now, because naming a thing can eat up some of the precious memory. But that is not always the case. Let’s have a look at the different options:

Hard coded numbers

As seen above, readability of the code is not optimal. In case of a change, for example 0x07 needed to become 0x87, we’d have to look at all occurrences of 0x07 in our code, check if it’s really a 0x07 which needs to be modified (there might be others in different contexts) and replace each by 0x87. Thus, maintenance of the code is not easy, either, and rather bug prone. On the other side, after compiling, everything is in its place without wasting CPU cycles to look whatever up. No extra RAM is used.

  • Readability —
  • Maintainability —
  • Runtime behavior +++
  • RAM usage —
  • Type safety —

The famous #define

If you see a #define in some code, you’ll have to understand that this is NOT code! It’s called a preprocessor directive and it is addressed at the compiler. The famous line

#define NexIOdataType_servo 0x07

tells the compiler to pre-process the code before compiling. In this case, all occurrences of NexIOdataType_servo will be automatically replaced by 0x07 before the code is compiled into machine code. That means that after the preprocessor did its work silently and automatically, the compiler will see the hard coded number everywhere with all the advantages which come with that. Nevertheless, our code is much better readable AND maintainable! Because if we need to replace 0x07 by 0x87, we just modify one line:

#define NexIOdataType_servo 0x87

At the next compiler run, the preprocessor will do its work again: this time, all occurrences of NexIOdataType_servo will be automatically replaced by 0x87 before the code is compiled into machine code. And we are done:

  • Readability +++
  • Maintainability +++
  • Runtime behavior +++
  • RAM usage —
  • Type safety —

Thus, in most cases, the #define is fine for embedded programming. Most cases??? Why not all??? The keyword is type safety. That means that we are literally defining numeric values without an associated data type, for example byteintunsigned long, etc. As stated above, this isn’t a problem in most cases, the compiler will do what is needed to handle assignments and comparisons. But in rare cases, you’ll see compiler errors like “can not assign (…) to (…). Implicit conversion of (…)”. And these are a nightmare to find and to fix!

Constants

A constant is a variable which can not be modified at runtime. Since it is immutable, the compiler will most times be smart and place it in the ROM to save on the precious RAM.

const uint8_t NexIOdataType_servo = 0x07;

Since this is also a global definition, the advantages for readability and maintainability persist. And we have a clearly defined data type. And since it is a variable, although immutable, it has its defined and fixed address somewhere in the RAM or the ROM. But that means that each time, the code needs that constant, it has to be loaded from the RAM or the even slower ROM into a CPU register. Thus, at runtime, we risk a slightly slower code execution, but that’s rather an academic problem, with modern CPUs, you’ll not notice the difference.

  • Readability +++
  • Maintainability +++
  • Runtime behavior ???
  • RAM usage —
  • Type safety +++

A collection of constants: The ENUM

Enum is short for “Enumeration”. Look at

enum NexIOType {SET, GPIO, PWM, A_IN, SERVO = 7};

Here, with a single line, we can easily define 5 constants. The compiler will automatically attribute integer values to the elements, thus type safety is given. If there is nothing indicated, it will start at 0. Thus, SET = 0, GPIO = 1, PWM = 2, A_IN = 3, and SERVO would be 4 if we hadn’t overridden it by stating SERVO = 7. If there was another element behind SERVO, it’d automatically take 8. Even though  an enum is a data type for itself, the compiler will automatically cast the values to the underlying data type (integer) when it comes to assignments and comparisons. Thus, if you have a group of constants, enum is the preferred choice. The strong and weak points are the same as for constants.

  • Readability +++
  • Maintainability +++
  • Runtime behavior ???
  • RAM usage —
  • Type safety +++

The luxury variant: Class enums

C++ developpers, and among them, a small group of encapsulation fans who want to optimize type safety still more, can also state that each enum they declare is an explicit data type or class for itself. One can even define exactly the underlying data type. But the so-called class enums are a relatively recent C++ feature which is not (yet) fully supported in the C++ versions which come for example with the Arduino core. Thus, it was nice to mention it, but actually, there is almost no benefit for us.

And that’s all for today. In my next blog, we’ll se how to apply this knowledge to optimise both sides, Arduino and Nextion, of the Nextion MEGA IO project.

Last, but not least

You have any questions, comments, critics, or suggestions? Just send me an email to thierry (at) itead (dot) cc! 🙂

And, by the way, if you like and you find useful what I write, and you are about to order Nextion stuff with Itead, please do so by clicking THIS REFERRAL LINK! To you, it won’t forcibly make a change for your order but on some products, you may even get a 10% discount using the coupon code THIERRYFRSONOFF. In ever case, it will pay me perhaps the one or the other beer or coffee. And that will motivate me to write still more interesting blogs 😉