Catto / Topic Map · Computing for Electronics I Year 4
Computing for Electronics I · 72 h · Topic 2 of 3

C and C++ development environments

The leap from “programming” to “programming hardware”. Pointers, structures and direct access to registers are the tools with which a program stops running on top of an abstraction and starts driving real pins, timers and converters.

Programming Microcontrollers Pointers Interrupts Embedded systems

01The toolchain

Programming a microcontroller has one central difference from programming a PC: the program is written and compiled on one machine and run on another. This is called cross-compilation.

main.ccodePreprocessorresolvesCompilergeneratesAssemblergeneratesLinkerjoinsmain.hexreadymain.ccodePreprocessorresolvesCompilergeneratesAssemblergeneratesLinkerjoinsmain.hexreadymain.c · source codeThe file the person writes: plain text with the instructions in C.Preprocessor · resolves #include and #definePastes in the contents of the headers and replaces the macros. It is still text.Compiler · generates assemblyTranslates to the target processor's instructions and applies the optimizations.Assembler · generates object codeConverts the assembly into machine code, still with the addresses unresolved.Linker · joins everything and places itCombines the objects with the libraries and decides where each thing lives in the chip's memory.main.hex · ready to flashThe file the programmer writes into the microcontroller's flash memory.Almost all of this happens with a single command, but it pays to know it: error messages say in which stage it failed,and a compiler error is not the same as a linker error.
Figure 1. The toolchain, animated: each stage lights up in its turn. The error messages say in which of them it failed, and a compiler error is not the same as a linker error.
ComponentWhat it does
ToolchainThe set of compiler + assembler + linker + utilities, specific to the target architecture (avr-gcc, arm-none-eabi-gcc, Microchip XC8).
IDEThe graphical environment that integrates editor, compiler, programmer and debugger: MPLAB X, Atmel Studio, STM32CubeIDE, PlatformIO, Arduino IDE.
Linker scriptDescribes the chip's memory map and decides at which address each thing goes.
Startup codeAn assembly routine that runs before main(): it sets up the stack, initializes variables and jumps to the program.
Programmer / flasherThe hardware that writes the .hex into flash: PICkit, USBasp, ST-Link, or a bootloader over USB.
DebuggerLets you halt the program on the real chip and inspect variables: ICD, JTAG, SWD.
The three memories of a microcontroller
  • Flash — stores the program. Non-volatile, large (tens of KB), written when programming.
  • RAM — stores the variables. Volatile and very scarce: 512 bytes to 8 KB in an 8-bit micro. It is the resource that runs out first.
  • EEPROM — a few non-volatile bytes, for configuration that must survive power-off.

When compiling, the environment reports how much flash and how much RAM the program uses. Always look at that number. A program that uses 95 % of the RAM will fail as soon as the stack grows a little.

02Pointers

A pointer is a variable that holds a memory address. It is the concept that is hardest to grasp and the one that pays off most: without pointers there is no hardware access, no pass by reference, and no data structures.

0x02000x02040x02080x020Cint x = 25;25xA place in memory is reserved for x, at address 0x0200, and the 25 is stored there.int *p = &x;25x0x0200pp is another variable, at 0x0204, and what it holds is the ADDRESS of x, not its value.*p = 40;40x0x0200pThe asterisk means 'the contents of the address p holds': x is modified without naming it.printf("%d", x);40x0x0200ppowerful and dangerous.x is 40 even though it was never touched directly. That is what makes pointers bothmemoryA pointer is a variable that holds an address. Nothing more than that, and everything else follows from it.
Figure 2. A variable and a pointer in memory, animated: you can see that p holds the address of x, and that writing to *p modifies x without naming it.
int  x = 42;
int *p;          // p is a pointer to int

p = &x;         // p takes the ADDRESS of x
printf("%d", *p);  // prints 42 — * accesses the CONTENTS
*p = 7;          // now x is 7, without naming x

What they are really for

Modifying the caller's variables
// does NOT work: receives copies
void mal(int a, int b) {
    int t = a; a = b; b = t;
}

// DOES work: receives addresses
void bien(int *a, int *b) {
    int t = *a; *a = *b; *b = t;
}
bien(&x, &y);
Returning more than one value
void leer_sensor(int *temp,
                 int *hum)
{
    *temp = medir_t();
    *hum  = medir_h();
}

int t, h;
leer_sensor(&t, &h);

A C function returns a single value; with pointers it can “return” as many as needed.

Pointer arithmetic

Adding 1 to a pointer does not add 1 to the address: it adds the size of the pointed-to type. That is the reason a pointer has a type.

int v[5] = {10, 20, 30, 40, 50};
int *p = v;        // the array name is ALREADY the address of the first element

*p        // 10  — equivalent to v[0]
*(p + 2)  // 30  — equivalent to v[2]; advanced 2 * sizeof(int) bytes
p[2]      // 30  — bracket notation IS pointer arithmetic

// Traverse an array with pointers
for (int *q = v; q < v + 5; q++) {
    printf("%d ", *q);
}
The three pointer mistakes
  • Uninitialized pointer. It contains garbage and writing through it corrupts memory at random. Every pointer gets initialized, even if only to NULL.
  • Dangling pointer. It points to a local variable of a function that has already returned. That memory has been reused and the contents are unpredictable. Never return the address of a local variable.
  • Dereferencing NULL. On a PC it causes an immediate crash; on a microcontroller it writes to address 0, which is often a critical register, and the chip resets.

03Structures, unions and typedef

A structure groups data of different types under a single name. It is what lets you treat “a sensor” or “a measurement” as a single thing.

typedef struct {
    uint8_t  id;
    int16_t  temperatura;   // in tenths of a degree
    uint16_t humedad;
    uint8_t  valido;
} Sensor;

Sensor s1;
s1.id = 3;
s1.temperatura = 235;      // 23.5 °C

Sensor red[8];             // an array of structures
red[0].humedad = 640;

Sensor *ps = &s1;
ps->id = 4;                // with a pointer, use -> instead of .
Union

All its fields share the same place in memory. It is used to view the same piece of data in two ways.

union {
    uint16_t palabra;
    uint8_t  byte[2];
} conv;

conv.palabra = 0xABCD;
// conv.byte[0] and [1] are
// 0xCD and 0xAB (or the reverse,
// depending on endianness)
Bit fields

They let you declare fields of an exact width in bits: ideal for configuration registers.

struct {
    uint8_t habilitado : 1;
    uint8_t modo       : 2;
    uint8_t prescaler  : 3;
    uint8_t reservado  : 2;
} ctrl;

ctrl.modo = 2;   // readable
Structure padding (alignment)

The compiler may insert empty bytes between fields to align them, so sizeof(Sensor) can be larger than the sum of its fields. This matters when the structure represents the exact format of a communication frame or of a file: in those cases you must use __attribute__((packed)) or serialize field by field.

04Hardware access and device drivers

Here everything above comes together. In a microcontroller, peripherals are controlled by writing and reading registers located at fixed memory addresses. A pointer to a specific address is the mechanism that makes it possible.

// A register is a pointer to a fixed address, marked volatile
#define PORTB  (*(volatile uint8_t *) 0x25)
#define DDRB   (*(volatile uint8_t *) 0x24)
#define PINB   (*(volatile uint8_t *) 0x23)

DDRB  |= (1 << 0);   // PB0 as output
PORTB |= (1 << 0);   // PB0 high

The three elements of that line are the ones you need to understand:

  • (volatile uint8_t *) 0x25 — converts the number 0x25 into a pointer to a byte at that address.
  • volatile — forbids the compiler from optimizing the accesses, because the value can change by hardware.
  • *(...) — dereferences, so that PORTB behaves like an ordinary variable.

What a device driver is

It is the software layer that translates between “what the program wants to do” and “the registers that have to be touched”. It isolates the rest of the program from the details of the chip.

// ---- led.h : the INTERFACE, the only thing the rest of the program sees ----
void led_init(void);
void led_on(uint8_t n);
void led_off(uint8_t n);
void led_toggle(uint8_t n);

// ---- led.c : the IMPLEMENTATION, specific to this micro ----
#include "led.h"
#include "registros.h"

void led_init(void)          { DDRB = 0xFF; PORTB = 0x00; }
void led_on(uint8_t n)     { PORTB |=  (1 << n); }
void led_off(uint8_t n)    { PORTB &= ~(1 << n); }
void led_toggle(uint8_t n) { PORTB ^=  (1 << n); }
Why it is worth separating things this way
  • The main program reads like the problem, not like the chip: led_on(3) instead of PORTB |= 8.
  • If you change microcontroller, you rewrite only the driver. The rest is untouched.
  • You can test the program logic on a PC with a fake driver that prints to the screen.
  • This is exactly what the Arduino library does: digitalWrite() is a driver on top of those same registers.

05Bootloaders

A bootloader is a small program that lives in a reserved area of the flash and runs first at power-up. Its function is to be able to program the rest of the flash without needing an external programmer.

Without bootloaderWith bootloader
A programmer is needed (PICkit, USBasp, ST-Link).A USB or serial cable is enough.
All the flash is available for the program.The bootloader takes between 0.5 and 4 KB.
Instant startup.Initial delay while it waits to see whether a new program is sent.
Needed to flash the bootloader the first time.Allows updating the firmware in the field, without opening the device.
The best-known case

An Arduino board comes with a bootloader flashed at the factory. When you press “Upload”, the IDE compiles with avr-gcc, generates the .hex and sends it over the serial port; the bootloader receives it and writes it into flash. That is why an Arduino board does not need a programmer, and why a “bare” micro bought loose does need one at least once, to flash the bootloader onto it.

06Real time and interrupts

A system is real-time when responding late is equivalent to responding wrongly. It does not mean “fast”: it means predictable. An airbag that fires 200 ms late has failed, even if the processor is extremely fast.

Polling (cyclic query)

The program asks over and over whether something happened.

while (1) {
  if (boton_apretado())
      accion();
  otras_tareas();
}

Simple and predictable, but it wastes time and the latency depends on how long the rest of the loop takes.

Interrupts

The hardware gives notice: it suspends the program, runs a routine and returns.

ISR(INT0_vect) {
    bandera = 1;   // short
}

while (1) {
  if (bandera) { ... }
}

Immediate response without wasting CPU, in exchange for a program that is harder to reason about.

The three rules of an interrupt routine
  1. Keep it very short. Set a flag, store a piece of data and exit. While the ISR runs, the rest of the system is stopped.
  2. No printf, no delays, no waiting. Everything slow goes in the main loop.
  3. Every variable shared with the main loop is declared volatile. Without that, the optimizer breaks the communication between the two.
Latency and priorities

Latency is the time between the event occurring and the ISR beginning to execute. It is made up of the time to finish the instruction in progress, the context save, and — most importantly — any period during which interrupts are disabled or another ISR is running. That is why critical sections must be as short as possible: every instruction with interrupts disabled worsens the latency of the whole system.

07Concurrency

As soon as there are interrupts, there are two flows of execution sharing variables. And that is where errors appear that do not exist in a sequential program.

The race condition
volatile uint16_t contador;   // 16 bits on an 8-bit micro

ISR(TIMER0_OVF_vect) { contador++; }

int main(void) {
    while (1) {
        uint16_t c = contador;   // ← DANGER
        ...
    }
}

An 8-bit micro needs two instructions to read a 16-bit variable. If the interrupt lands right in the middle — after reading the low byte and before the high one — the value obtained mixes the before and the after. With contador going from 0x00FF to 0x0100 you can read 0x01FF: a value that never existed.

The solution is the critical section: read with interrupts disabled, and re-enable them immediately.

uint8_t sreg = SREG;   // save the previous state
cli();                  // disable interrupts
uint16_t c = contador; // atomic read
SREG = sreg;            // restore (do NOT call sei() blindly)
ConceptWhat it is
Critical sectionA stretch of code that cannot be interrupted without corrupting data. It is protected by disabling interrupts, and must be as short as possible.
Atomic operationOne that completes with no possibility of interruption. On an 8-bit micro, reading or writing a uint8_t is atomic; a uint16_t is not.
Reentrant functionOne that can be called again before it finishes (from an ISR) without breaking. It uses no global or static variables.
Circular bufferThe standard structure for passing data from an ISR to the main loop: the ISR writes at one index and the loop reads from another, without stepping on each other.
Deadlock (mutual blocking)Two tasks waiting for each other. It appears in systems with an RTOS and semaphores.
When a real-time operating system comes in

As long as the problem fits into “configure + infinite loop + a few interrupts”, that is what is best: it is simple, predictable and consumes no resources. When there are many tasks with different timings (reading sensors every 10 ms, updating a display every 100 ms, handling a communication when it arrives), you move to an RTOS — FreeRTOS is the most widespread — which shares the processor among tasks with priorities. It is covered in depth in Year 7.

08In the lab

Lab 1 · Seeing memory for real

A PC program that declares several variables of different types and prints their address with %p and their size with sizeof. Verify that the addresses of an array are separated by exactly the size of the type, and that adding 1 to an int* advances 4 bytes while adding 1 to a char* advances 1. It is the most direct way to understand pointer arithmetic.

Lab 2 · Pass by value and by reference

Write two versions of a function intercambiar (“swap”), one with ordinary parameters and another with pointers. Check that the first one does nothing. Then write a function that receives an array and its length and returns through pointers the maximum, the minimum and the average.

Lab 3 · A simulated driver

Write led.h and led.c as in the example, but with a global variable unsigned char PORTB that simulates the port, and a function that prints its contents in binary. Then write a main that makes a “Knight Rider” chaser using only the driver functions. Compile the two files separately and link them: it is the first multi-file project of the course.

Lab 4 · Structures and data frames

Define a structure that represents a measurement (identifier, value, timestamp, checksum). Write functions to fill it in, compute the checksum and dump it into an array of bytes ready to transmit. Print the sizeof of the structure and compare it with the sum of its fields: if they differ, there is padding, and that is the reason to use packed.

09Common mistakes

MistakeConsequence
Pointer declared but not initializedA write to a random address. On a micro, a reset or erratic behavior.
Returning the address of a local variableDangling pointer: the memory has already been reused.
Confusing *p = 5 with p = 5The first writes the value; the second changes the address pointed to. The second is almost never what you want.
Using . with a pointer to a structureCompile error. With a pointer, use ->.
volatile missing on a register or on an ISR variableThe optimizer removes reads and the program hangs. It only shows up when compiling with optimization.
Long ISR, or one with delaysOther interrupts are lost and the system stops responding.
Reading a shared 16-bit variable without a critical sectionRace condition: impossible values, very hard to reproduce.
Enabling interrupts with sei() at the end of a critical sectionIt enables them even if they were previously disabled for another reason. You must save and restore the state.
Using malloc on a microcontrollerFragmentation of a tiny RAM and unpredictable failures. In embedded systems, static memory is used.

10Self-assessment

What is cross-compilation and why is it necessary in embedded systems?

It means compiling on one machine (the PC) so that the program runs on a different architecture (the microcontroller). It is necessary because the micro has no keyboard, screen or capacity to run a compiler.

If int *p is 0x1000 and p++ is executed, what is its value now?

It depends on the size of int: if it is 2 bytes, 0x1002; if it is 4 bytes, 0x1004. Pointer arithmetic advances in units of the pointed-to type, not in bytes.

Write a function that swaps two integers of the caller.
void intercambiar(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}
// call it like this:  intercambiar(&x, &y);
Why is a hardware register declared volatile?

Because its value can change for reasons outside the program (the hardware modifies it). Without volatile, the compiler may read it just once and reuse that copy, or eliminate writes that “serve no purpose” but actually drive a peripheral.

What concrete advantage does separating code into a driver have?

The rest of the program stops depending on the microcontroller. When you change chip, only the driver is rewritten; in addition the program can be tested on a PC with a simulated driver and it reads much better (led_on(3) instead of PORTB |= 8).

What is a bootloader and what advantage does it give?

A small program resident in flash that can program the rest of the flash over a serial or USB port. It avoids needing an external programmer and allows updating the firmware in the field. In exchange it takes up some flash and slows startup slightly.

List the three rules of an interrupt routine.

(1) Keep it as short as possible. (2) No delays, waits or slow functions. (3) Every variable shared with the main program declared volatile.

Explain a race condition using a 16-bit counter.

An 8-bit micro reads the variable in two instructions. If the interrupt that increments it lands between the two, you get a mixture of the old and the new value — for example 0x01FF when going from 0x00FF to 0x0100. It is solved by reading inside a critical section, with interrupts disabled.

Why is malloc avoided in embedded systems?

Because the RAM is only a few KB and repeated dynamic allocation fragments it: over time it can fail even though there is enough total free memory. In addition its execution time is not predictable, which is unacceptable in real time. Static memory sized at compile time is used instead.

Development of the topic “C and C++ development environments” of Computing for Electronics I (Year 4), based on the “Curriculum Proposal – Second Cycle of the Technical-Vocational Track, Secondary Education – Electronics,” Ministry of Education of the Province of Córdoba, DGETyFP. Back to the Topic Map · catto.ar