Developing in C for the ATmega328P: struct

11 minute read

Where I demonstrate how to use the C Language data type struct and how to use it to simplify a program.

Introduction

As programs become more complicated, its desirable to group common elements into a “block”, then debug that block and have the block serve as a single debugged element. For example, a function can serve as a block of code which performs a set of commands commonly used together. Once debugged, the function can be called instead of the individual commands, simplifying debugging and code management.

In a similar fashion, a group of variables commonly used together, can be grouped together. The method to do this in C is called a struct. In other languages, this might be called a dictionary (Python), a class (C++) or a record (MySQL). A key aspect of a struct is that the elements don’t have to be of the same data type (unlike an array).

I’m going to use the blink code as a way to explore using a struct. At first, the struct might appear to be (and is), overkill and overly complicated. However, as I evolve the use of the struct along with a function, I believe I will demonstrate the utility of this approach.

Why Size Matters

With each variation of code, I will also discuss code size in number of lines, program space and RAM (data) requirements. Given how simple this program is, it might seem obsessive. I want to illustrate the importance of the three elements for the following reasons:

  • code size in lines: is a relative and simple measurement as to the complexity of the program and helps us to understand how maintainable the code will be as well as issues in debugging. The higher the number, the worse everything becomes.
  • program size in bytes: describes how much Flash will be required on the microcontroller. This tends to be the least important issue, as Flash capacity tend to be much larger than RAM availability.
  • data size in bytes: describes the minimum amount of RAM required for the program. This is the most critical issue for two reasons, first, as a finite limit, the program will fail if it exceeds the RAM on the microcontroller and second, as the amount of RAM consumed becomes near the RAM limit, it introduces run-time bugs as the stack might get corrupted. [Note: This doesn’t include RAM requirements for the stack as it isn’t readily discernible. For a little bit more information as to RAM and the stack see this.]

For more discussion as to why program and RAM size matter see Code vs. Cost

Yes, the approach in this version is overkill, however, the steps here will set us up for what we need. Programming is a lot easier on complex topics, if you approach it, step by step. I’ll start by using the blink without delay example in the examples/ folder and was originally from Adafruit. Review the Adafruit commentary on BlinkWithoutDelay if you don’t understand.

I will also add the size of the program, and it will be helpful to note the number of lines (total - comment lines - blank lines) each program requires as well. (Due to this latter point, I’ll keep inline comments to a minimum.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// blink without delay modified with variables
#include "pinMode.h"
#include "digitalWrite.h"
#include "sysclock.h"

uint8_t ledPin =  3;           // the number of the LED pin
uint8_t state =  LOW;          // the state (HIGH/LOW) the LED pin
uint16_t interval = 1000;      // interval at which to blink (milliseconds)
uint16_t previousMillis = 0;   // stores last time LED was updated

int main (void)
{
  init_sysclock_2 ();
  pinMode(ledPin, OUTPUT);      
    while(1)
    {
        uint16_t currentMillis = millis();
        if(currentMillis - previousMillis > interval) 
        {
	        previousMillis = currentMillis;   
	        state = !state;
	        digitalWrite(ledPin, state);
        }
    }
}

Code Size

# Code size: 22 lines
avr-size -C --mcu=atmega328p main.elf
AVR Memory Usage
----------------
Device: atmega328p

Program:    1224 bytes (3.7% Full)
(.text + .data + .bootloader)

Data:         24 bytes (1.2% Full)
(.data + .bss + .noinit)

This is our first attempt, so we’re unsure on what makes sense for our coding metrics. The code appears to be simple, adding another couple of LEDs wouldn’t be difficult, though a bit laborious.

Grouping the varibles into a struct

Grouping the variables into a struct, makes the program more complex. Not only do I need to declare the struct, I have to initialize the struct separately, adding more code. I view this as a required middle step and one which you will want to understand as much as possible.

Details as to the changes

  • Lines 6-12: declare a struct called blinker
  • Line 13: use declaration to create an instance called LED, all references to the variables in LED will be via dot notation as in LED.ledPin, LED.state and so on.
  • Lines 18-22: Use dot notation to assign values to the struct variables.
  • Lines 27-31: Use dot notation to use variables in the same manner as the simple variables as in LED.state as compared to previously using state
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// blink_w_1struct - blink 1 led without delay using a struct
#include "pinMode.h"
#include "digitalWrite.h"
#include "sysclock.h"

struct blinker
{
  uint8_t ledPin;           // the number of the LED pin
  uint8_t state;          // the state (HIGH/LOW) the LED pin
  uint16_t interval;      // interval at which to blink (milliseconds)
  uint16_t previousMillis;   // will store last time LED was updated
} ;
struct blinker LED;

int main (void)
{
  init_sysclock_2 ();
  LED.ledPin = 3;
  LED.state = LOW;
  LED.interval = 1000;
  LED.previousMillis = 0;
  pinMode(LED.ledPin, OUTPUT);      

    while(1)
    {
        uint16_t currentMillis = millis();
        if(currentMillis - LED.previousMillis > LED.interval) 
        {
            LED.previousMillis = currentMillis;   
            LED.state = !LED.state;
            digitalWrite(LED.ledPin, LED.state);
        }
    }
}

Code Size

# Code size: 31 lines
avr-size -C --mcu=atmega328p main.elf
AVR Memory Usage
----------------
Device: atmega328p

Program:    1216 bytes (3.8% Full)
(.text + .data + .bootloader)

Data:         31 bytes (1.2% Full)
(.data + .bss + .noinit)

Coding has become more complex, we haven’t reduced the labor required to add LEDs. The program and data space required went up, however, nothing extreme.

Double Your Pleasure

To add another LED, everything needs to be duplicated. For this step, I did the following, I changed all of the current references to LED0, then duplicated all lines with a LED0 in them. This creates two identical blinking LEDs, with almost twice the amount of code. Our code size has grown to 44 lines and is somewhat unwieldy, based on all of the duplicate code. The code and data size has only grown a little, which is nice.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// blink_w_2struct - blink 2 leds without delay using a struct
#include "pinMode.h"
#include "digitalWrite.h"
#include "sysclock.h"

struct blinker
{
  uint8_t ledPin;           // the number of the LED pin
  uint8_t state;          // the state (HIGH/LOW) the LED pin
  uint16_t interval;      // interval at which to blink (milliseconds)
  uint16_t previousMillis;   // will store last time LED was updated
} ;

struct blinker LED0;
struct blinker LED1;

int main (void)
{
  init_sysclock_2 ();
  LED0.ledPin = 3;
  LED0.state = LOW;
  LED0.interval = 1000;
  LED0.previousMillis = 0;
  pinMode(LED0.ledPin, OUTPUT);      

  LED1.ledPin = 5;
  LED1.state = LOW;
  LED1.interval = 500;
  LED1.previousMillis = 0;
  pinMode(LED1.ledPin, OUTPUT);      

    while(1)
    {
        uint16_t currentMillis = millis();
        if(currentMillis - LED0.previousMillis > LED0.interval) 
        {
            LED0.previousMillis = currentMillis;   
            LED0.state = !LED0.state;
            digitalWrite(LED0.ledPin, LED0.state);
        }

        if(currentMillis - LED1.previousMillis > LED1.interval) 
        {
            LED1.previousMillis = currentMillis;   
            LED1.state = !LED1.state;
            digitalWrite(LED1.ledPin, LED1.state);
        }
    }
}

Code Size

# Code size: 44 lines
avr-size -C --mcu=atmega328p main.elf
AVR Memory Usage
----------------
Device: atmega328p

Program:    1326 bytes (4.0% Full)
(.text + .data + .bootloader)

Data:         37 bytes (1.5% Full)
(.data + .bss + .noinit)

As we thought, adding another LED wasn’t difficult, however, we’ve doubled the number of lines, not an efficient program. Program size went up about 8% and RAM increased about 25%, not bad for doubling the capability of the program.

Move to an Array

In this step we’re going to add yet another LED, however, we’re going to begin to optimize. We’ll do the following:

  • Replace all of our LED0 references, to LEDS
  • Remove all statements with LED1 references
  • Add a line blinker LEDS[3]; for the 3rd LED
  • Add a 0-3 for loop in our while(1) loop
  • Add a constant via a #define for tracking the number of LEDs

This takes our individual struct’s LED0 and LED1 and turns them into 3 struct’s acting as an array, so we can use subscripts to manipulate them!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// blink without delay using a struct array
#include "pinMode.h"
#include "digitalWrite.h"
#include "sysclock.h"
#define N_LEDS 3
struct blinker
{
  uint8_t ledPin;           // the number of the LED pin
  uint8_t state;          // the state (HIGH/LOW) the LED pin
  uint16_t interval;      // interval at which to blink (milliseconds)
  uint16_t previousMillis;   // will store last time LED was updated
} ;

struct blinker LEDS[N_LEDS];

int main (void)
{
  // Set up a system tick of 1 millisec (1kHz)
  init_sysclock_2 ();
  LEDS[0].ledPin = 3;
  LEDS[0].state = LOW;
  LEDS[0].interval = 100;
  LEDS[0].previousMillis = 0;

  LEDS[1].ledPin = 5;
  LEDS[1].state = LOW;
  LEDS[1].interval = 500;
  LEDS[1].previousMillis = 0;

  LEDS[2].ledPin = 6;
  LEDS[2].state = LOW;
  LEDS[2].interval = 1000;
  LEDS[2].previousMillis = 0;

  pinMode(LEDS[0].ledPin, OUTPUT);      
  pinMode(LEDS[1].ledPin, OUTPUT);      
  pinMode(LEDS[2].ledPin, OUTPUT);      

    while(1)
    {
        uint16_t currentMillis = millis();
        for (uint8_t i = 0; i < N_LEDS; i++)
        {
            if(currentMillis - LEDS[i].previousMillis > LEDS[i].interval) 
            {
                LEDS[i].previousMillis = currentMillis;   
                LEDS[i].state = !LEDS[i].state;
                digitalWrite(LEDS[i].ledPin, LEDS[i].state);
            }
        }
    }
}

Code Size

# Code size: 45 lines
avr-size -C --mcu=atmega328p main.elf
AVR Memory Usage
----------------
Device: atmega328p

Program:    1342 bytes (4.2% Full)
(.text + .data + .bootloader)

Data:         43 bytes (1.8% Full)
(.data + .bss + .noinit)

Progress! Now its becoming very clear, how a struct can help simplify the code. Yes, there is a bit of setup to the struct, however, once setup, it scales quite nicely! When we look at code size, we’re up only one line of code for an additional LED. Program size only increased about 3% and RAM requirements by 20%.

Refactor

Once a program has been written and debugged, its helpful to look at it again to continue to simplify or refactor the code. Refactoring is beneficial as simplified code is easier to read, maintain and debug in the future. It looks like there’s quite a bit of duplication in lines 22-40. Let’s start there.

Instead of a linear approach, we can use a function to replace those lines with one set of lines called multiple times:

  • Lines 17-26: function init() which replaces the individual lines initializing each struct
  • Lines 32-34: calling init() and its a cleaner way to adjust the parameters
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// blink without delay using a struct array then add an init function
// the init function will simplify adding additional LEDs
#include "pinMode.h"
#include "digitalWrite.h"
#include "sysclock.h"

#define N_LEDS 3
struct blinker
{
  uint8_t ledPin;           // the number of the LED pin
  uint8_t state;            // the state (HIGH/LOW) the LED pin
  uint16_t interval;        // interval at which to blink (milliseconds)
  uint16_t previousMillis;  // will store last time LED was updated
} ;
struct blinker LEDS[N_LEDS];

void init(uint8_t index, uint8_t ledPin, uint8_t state, uint16_t interval,\
    uint16_t previousMillis)
{
    LEDS[index].ledPin = ledPin;
    LEDS[index].state = state;
    LEDS[index].interval = interval;
    LEDS[index].previousMillis = 0;
    pinMode(LEDS[index].ledPin, OUTPUT);
    return ;
}  

int main (void)
{
    init_sysclock_2 ();
    // Parameters: index, pin, state, interval, previous
    init(0, 3, LOW, 125, 0);
    init(1, 5, LOW, 250, 0);
    init(2, 6, LOW, 375, 0);

    while(1)
    {
        uint32_t currentMillis = millis();
        for (uint8_t i = 0; i < N_LEDS; i++)
        {
            if(currentMillis - LEDS[i].previousMillis > LEDS[i].interval) 
            {
                LEDS[i].previousMillis = currentMillis;   
                LEDS[i].state = !LEDS[i].state;
                digitalWrite(LEDS[i].ledPin, LEDS[i].state);
            }
        }
    }
}

Code Size

# Code size: 43 lines
avr-size -C --mcu=atmega328p main.elf
AVR Memory Usage
----------------
Device: atmega328p

Program:    1374 bytes (4.2% Full)
(.text + .data + .bootloader)

Data:         37 bytes (1.8% Full)
(.data + .bss + .noinit)

We’ve begun to reduce the lines of code! At this point, we have a readable and easily maintainable program. To add an additional LED, we simply need to increase N_LEDS and add a single line of code for the appropriate call to init(). I went through the exercise by adding 3 more LEDs. It took me less than a minute. The size of the program changed by 3 lines, one for each LED. And the new size statistics are:

Code Size

# 6 LEDS
# Code size: 46 lines
avr-size -C --mcu=atmega328p main.elf
AVR Memory Usage
----------------
Device: atmega328p

Program:    1416 bytes (4.3% Full)
(.text + .data + .bootloader)

Data:         55 bytes (2.7% Full)
(.data + .bss + .noinit)

We’ve made great progress. Adding capacity is a simple single-line operation along with incrementing the N_LEDS constant. Program storage increases about 14 bytes per additional LED and RAM storage is about 6 bytes. See Developing in C for the ATmega328P: Code vs. Cost for more discussion as to why this is important.

For one more iteration of refactoring, see this entry. This version is getting very close to a final product!

Comments powered by Talkyard.