Practical Introduction to C++ for Arduino

Overview

Disclaimer
  • This is not a complete C++ programming lecture but a small practical quick-start guide to help students to immediately read, modify, and debug the kind of C++ code that appears in Arduino tinyML labs.
  • We will use two programs throughout this lecture:
    • Blink: turn the built-in LED on and off.
    • Sensor stream: read accelerometer, gyroscope, and magnetometer data from the Nano 33 BLE Sense IMU.
  • Arduino sketches are written in C++, but the Arduino framework hides much of the startup code.
    • On a laptop, a program often starts, performs a task, and exits.
    • On an Arduino, a program usually starts, configures hardware once, and then runs forever.
  • Arduino sketches are organized around two functions:
    • setup() runs once.
    • loop() runs repeatedly.
Learning Objective
  • In this lecture, we will introduce C++ through Arduino code.
  • We will cover:
    • #include statements;
    • statements and semicolons;
    • setup() and loop();
    • variables and types;
    • constants;
    • if statements;
    • while loops;
    • functions;
    • objects and member functions;
    • references;
    • arrays;
    • struct values for organizing sensor data;
    • basic embedded C++ safety practices;
    • MISRA C++ standard in professional embedded systems.

Arduino Sketches

Blink
1
2
3
4
5
6
7
8
9
10
11
12
#include <Arduino.h>

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}
  • This file may have an .ino extension in the Arduino IDE, or it may be written as a .cpp file in PlatformIO.
  • Either way, the code is compiled as C++ code for the target board.
#include
  • An #include statement tells the compiler to bring in declarations from another file. For this sketch, Arduino.h gives us access to names such as:
    • pinMode
    • digitalWrite
    • delay
    • LED_BUILTIN
    • OUTPUT
    • HIGH
    • LOW
setup() and loop()
  • A traditional C++ program begins with main():
  • For Arduino, we write:
1
2
3
4
5
6
7
void setup() {
  // runs once
}

void loop() {
  // runs repeatedly
}
  • These two functions represent two major tasks in embedded systems:
    • Configure hardware
    • Execute tasks (read sensors, process data, write to serial connectors …) forever.
  • Conceptually, Arduino behaves like this:
1
2
3
4
5
6
7
8
9
int main() {
  initArduinoHardware();

  setup();

  while (true) {
    loop();
  }
}

A microcontroller program maintains a continuous with hardware:

    1. configure pins and sensors;
    1. check whether new data is available;
    1. read inputs;
    1. compute something small;
    1. update outputs;
    1. repeat. That is the setup() / loop() model.
setup()

setup() is for one-time configuration

1
2
3
void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}
loop()
  • loop() is for repeated behavior
1
2
3
4
5
6
void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}
  • API for digitalWrite
  • In Blink, loop() repeatedly turns the LED on and off by write (send) a HIGH voltage value and a LOW voltage value to the LED_BUILTIN pin, with a delay of 1000ms between sends.
Sensor
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
#include <Arduino.h>
#include <Arduino_BMI270_BMM150.h>

void setup() {
  Serial.begin(9600);

  while (!Serial) {
    ; // wait for serial monitor
  }

  if (!IMU.begin()) {
    Serial.println("Failed to initialize IMU!");
    while (1) {
      delay(1000);
    }
  }

  Serial.println("Serial ready. Initializing IMU...");
  Serial.println("IMU ready.");
  Serial.println("Ax Ay Az | Gx Gy Gz | Mx My Mz");
}

void loop() {
  float ax, ay, az;
  float gx, gy, gz;
  float mx, my, mz;

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(ax, ay, az);
  }

  if (IMU.gyroscopeAvailable()) {
    IMU.readGyroscope(gx, gy, gz);
  }

  if (IMU.magneticFieldAvailable()) {
    IMU.readMagneticField(mx, my, mz);
  }
 
  char line[160];

  snprintf(line,sizeof(line),
    "A:%.3f,%.3f,%.3f | G:%.3f,%.3f,%.3f | M:%.3f,%.3f,%.3f",
    ax, ay, az,
    gx, gy, gz,
    mx, my, mz
  );

  Serial.println(line);
  
  delay(200);
}

For the IMU sensor sketch, we include another library:

1
2
#include <Arduino.h>
#include <Arduino_BMI270_BMM150.h>

The second include gives us access to the IMU object and its sensor-reading functions.

setup()
  • starts serial communication;
  • waits for a serial monitor;
  • initializes the IMU sensor.
Variables and types
  • A variable gives a name to a value.
  • The sensor sketch declares variables as follows:
1
2
3
float ax, ay, az;
float gx, gy, gz;
float mx, my, mz;
  • In Arduino/C++, variables are associated with types:
  • Common types include:
Type Meaning Example use
int whole number pin number, counter
float decimal number acceleration, gyroscope, magnetometer reading
bool true/false whether data is available
char single character simple serial command
unsigned long large non-negative integer time from millis()
  • In Sensor, sensor readings are not usually integers, so float is appropriate.

loop()
  • declares sensor variables;
  • checks whether data is available;
  • reads acceleration, gyroscope, and magnetic-field values;
  • prints the values;
  • waits for 200ms and repeats.
if statements
  • The sensor sketch uses if statements.
  • This means: If acceleration data is available, then read those values into the variables ax, ay, and az.
1
2
3
if (IMU.accelerationAvailable()) {
  IMU.readAcceleration(ax, ay, az);
}
  • Similar patterns are for the gyroscope and magnetometer:
1
2
3
4
5
6
7
if (IMU.gyroscopeAvailable()) {
  IMU.readGyroscope(gx, gy, gz);
}

if (IMU.magneticFieldAvailable()) {
  IMU.readMagneticField(mx, my, mz);
}
while loop

The setup code uses a while loop:

1
2
3
while (!Serial) {
  ; // wait for serial monitor
}

This means while the serial connection is not ready, keep waiting.

function calls
1
readMagneticField(mx, my, mz)
  • Calling a function means to run a named segment of code, possibly with input values.
  • In this example:
    • readMagneticField is the function name.
    • mx, my, mz are the variables where readMagneticField write the contents of the magnetic sensors into.
    • readMagneticField API

More Functions

Problem Statement

Setup a pothole detector: If the sensor detects bounciness (vertical acceleration) that is greater than a certain value, change the LED light to RED. Otherwise, keep it as GREEN.

Pseudocode
1
2
3
4
5
6
7
8
9
10
11
12
void setup() {
  initialize LED with OUTPUT using LEDR, LEDG, and LEDB;
  initialize IMU;
  possibly initialize Serial for debugging purposes;
}

void loop() {
  capture accelerometer values into ax, ay, az;
  if az is greaer than a certain value (1 means stationary), change LED to RED;
  else change LED to GREEN; 
  delay
}
First version
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
#include <Arduino.h>
#include <Arduino_BMI270_BMM150.h>

void setup() {
  if (!IMU.begin()) {
    Serial.println("Failed to initialize IMU!");
    while (1) {
      delay(1000);
    }
  }

  pinMode(LEDR, OUTPUT);
  pinMode(LEDG, OUTPUT);
  pinMode(LEDB, OUTPUT);
}

void loop() {
  float ax, ay, az;

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(ax, ay, az);
  }

  if (az > 2) {
     // Red
    digitalWrite(LEDR, LOW);
    digitalWrite(LEDG, HIGH);
    digitalWrite(LEDB, HIGH);
  } else {
    // Green
    digitalWrite(LEDR, HIGH);
    digitalWrite(LEDG, LOW);
    digitalWrite(LEDB, HIGH);
  }
  
  delay(200);
}

Creating a function

Second version
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
#include <Arduino.h>
#include <Arduino_BMI270_BMM150.h>

void setred() {
  digitalWrite(LEDR, LOW);
  digitalWrite(LEDG, HIGH);
  digitalWrite(LEDB, HIGH);
}

void setgreen() {
  digitalWrite(LEDR, HIGH);
  digitalWrite(LEDG, LOW);
  digitalWrite(LEDB, HIGH);
}

void beginLED() {
  pinMode(LEDR, OUTPUT);
  pinMode(LEDG, OUTPUT);
  pinMode(LEDB, OUTPUT);
}

void setup() {
  IMU.begin();
  beginLED();
}

void loop() {
  float ax, ay, az;

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(ax, ay, az);
  }

  if (az > 1.5) {
     setred();
  } else {
     setgreen();
  }
  
  delay(200);
}

Function parameters

Third version
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
53
54
55
56
57
58
59
#include <Arduino.h>
#include <Arduino_BMI270_BMM150.h>

void setred() {
  digitalWrite(LEDR, LOW);
  digitalWrite(LEDG, HIGH);
  digitalWrite(LEDB, HIGH);
}

void setyellow() {
  digitalWrite(LEDR, LOW);
  digitalWrite(LEDG, LOW);
  digitalWrite(LEDB, HIGH);
}

void setgreen() {
  digitalWrite(LEDR, HIGH);
  digitalWrite(LEDG, LOW);
  digitalWrite(LEDB, HIGH);
}

void beginLED() {
  pinMode(LEDR, OUTPUT);
  pinMode(LEDG, OUTPUT);
  pinMode(LEDB, OUTPUT);
}

void emitColor(int color) {
    if (color == 0) {
        setgreen();
    } else if (color == 1) {
        setyellow();
    } else if (color == 2) {
        setred();
    }
}

void setup() {
  IMU.begin();
  beginLED();
}

void loop() {
  float ax, ay, az;

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(ax, ay, az);
  }

  if (az < 1.2) {
     emitColor(0);
  } else if (az > 1.2 && az < 1.8) {
     emitColor(1);
  } else {
     emitColor(2);
  }
  
  delay(200);
}

Function declarations

include/led_controller.h
1
2
3
4
5
void beginLED();
void emitColor(int color);
void setred();
void setyellow();
void setgreen();
src/led_controller.cpp
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
#include <Arduino.h>
#include "led_controller.h"

void setred() {
  digitalWrite(LEDR, LOW);
  digitalWrite(LEDG, HIGH);
  digitalWrite(LEDB, HIGH);
}

void setyellow() {
  digitalWrite(LEDR, LOW);
  digitalWrite(LEDG, LOW);
  digitalWrite(LEDB, HIGH);
}

void setgreen() {
  digitalWrite(LEDR, HIGH);
  digitalWrite(LEDG, LOW);
  digitalWrite(LEDB, HIGH);
}

void beginLED() {
  pinMode(LEDR, OUTPUT);
  pinMode(LEDG, OUTPUT);
  pinMode(LEDB, OUTPUT);
}

void emitColor(int color) {
    if (color == 0) {
        setgreen();
    } else if (color == 1) {
        setyellow();
    } else if (color == 2) {
        setred();
    }
}
src/main.cpp
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
#include <Arduino.h>
#include <Arduino_BMI270_BMM150.h>

#include "led_controller.h"

void setup() {
  IMU.begin();
  beginLED();
}

void loop() {
  float ax, ay, az;

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(ax, ay, az);
  }

  if (az < 1.2) {
     emitColor(0);
  } else if (az > 1.2 && az < 1.8) {
     emitColor(1);
  } else {
     emitColor(2);
  }
  
  delay(200);
}

Struct and Class

While modern C++ has added many new features that are not really C-like, historically, C++ began as C with Classes, and therefore, it inherited much of C’s syntax and builtin structures, including struct.

Struct

A struct groups related data together. Going back to the Sensors sketch, instead of keeping x, y, and z as separate variables, we can define a vector-like struct:

1
2
3
4
5
struct Vec3D {
  float x;
  float y;
  float z;
};

Then we can create 3 variables representing the three sensors, rather than the 9 variables.

1
2
3
Vec3D acceleration = {0.0F, 0.0F, 0.0F};
Vec3D gyroscope = {0.0F, 0.0F, 0.0F};
Vec3D magnetometer = {0.0F, 0.0F, 0.0F};

The fields of a a struct can be accessed using the dot operator:

1
2
3
Serial.print(acceleration.x);
Serial.print(acceleration.y);
Serial.println(acceleration.z);

We can group all sensor values into one sample:

1
2
3
4
5
6
7
8
9
10
11
struct Vec3D {
  float x;
  float y;
  float z;
};

struct ImuSample {
  Vec3 acceleration;
  Vec3 gyroscope;
  Vec3 magnetometer;
};

Then the loop can work with one ImuSample variable:

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
void loop() {
  ImuSample sample = {
      {0.0F, 0.0F, 0.0F},
      {0.0F, 0.0F, 0.0F},
      {0.0F, 0.0F, 0.0F}
  };

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(sample.acceleration.x,
                         sample.acceleration.y,
                         sample.acceleration.z);
  }

  if (IMU.gyroscopeAvailable()) {
    IMU.readGyroscope(sample.gyroscope.x,
                      sample.gyroscope.y,
                      sample.gyroscope.z);
  }

  if (IMU.magneticFieldAvailable()) {
    IMU.readMagneticField(sample.magnetometer.x,
                          sample.magnetometer.y,
                          sample.magnetometer.z);
  }

  delay(200);
}

This is longer at first, but the organization becomes valuable when programs grow.

Why structs matter for tinyML
  • A machine learning dataset is structured data.
  • For motion recognition, a single sample might include:
    • acceleration x/y/z;
    • gyroscope x/y/z;
    • timestamp;
    • label;
    • device ID;
    • sampling rate.
  • A struct is one of the first tools students can use to represent structured sensor data cleanly.

Class and Object

1
2
3
4
Serial.begin(9600);
Serial.println("IMU ready.");
IMU.begin();
IMU.readAcceleration(ax, ay, az);