Chaîne de compilation

Dans cette partie, nous donnerons les détails d'une chaîne de compilation embarquée, toujours avec l'exemple de l'ATmega328P.

Langage C

Tout d'abord, nous allons détailler certaines particularités du langage C qui nous seront utiles.

Nous supposons que vous savez déjà programmer en C, et que vous connaissez les bases du langage.

Optimisations

Le compilateur GCC permet d'optimiser le code généré pour le rendre plus efficace, ou plus léger. Dans le cas de l'embarqué, il est primoordial de réduire la taille du code généré, car la mémoire est très limitée.

L'option de compilation -Os permet d'optimiser drastiquement le code pour la taille, il est impératif de l'utiliser.

Par exemple, le code C suivant:

#include <stdio.h>

int main(void)
{
    int sum = 0;
    for (int i = 0; i < 10000; i++) {
        sum += i;
    }
    printf("sum = %d\n", sum);
}

Compilé à l'aide de gcc -Os -S main.c (sur x86_64), produit le code assembleur suivant:

movl    $49995000, %edx
movl    $1, %edi
xorl    %eax, %eax
call    __printf_chk@PLT

Les variables i et sum n'existent pas dans le code assembleur, car elles ont été optimisées par le compilateur. Le résultat du calcul (49995000) a été directement inséré dans le code assembleur.

stdint.h

La taille des types de base du C (int, long, short, char) dépend de l'architecture cible, et peut donc varier d'une machine à l'autre.

Le fichier d'en-tête stdint.h permet de définir des types de taille fixe, ce qui est très utile pour l'embarqué.

#include <stdint.h>

int main(void)
{
    uint8_t a = 0; // 8 bits non signé
    uint16_t b = 0; // 16 bits non signé
    int32_t c = 0; // 32 bits signé
    uint64_t d = 0; // 64 bits non signé
}

Volatile

Et si nous ne voulons pas que le compilateur optimise certaines variables ? Par exemple, dans
le cas d'un registre matériel, il faut s'assurer que l'écriture dans le registre se fasse bien. De manière schématique, imaginons le code suivant:

int main()
{
    int PORTB = 0;
    for (int i=0; i<10000; i++) {
        PORTB = 0;
        PORTB = 1;
    }
}

Nous voudrons que la variable PORTB soit bien mise à 0 puis à 1 à chaque itération, mais elle risque d'être optimisée par le compilateur. Pour éviter cela, il faut utiliser le mot-clé volatile:

// Pas d'optimisation, force les écritures et lectures
volatile int PORTB = 0;

Afin d'écrire dans un registre matériel, on pourra utiliser la syntaxe suivante:

#define PORTB  (*((volatile uint8_t *) 0x05))

Ici:

  • uint8_t permet de préciser que nous considérerons ce type comme une variable de 8 bits,
  • 0x05 est l'adresse mémoire du registre PORTB,
  • Le mot clé volatile permet de forcer les lectures/écritures dans le registre,
  • L'arithmétique des pointeurs permet de caster l'adresse mémoire en un pointeur vers un uint8_t, puis de déréférencer ce pointeur pour obtenir une variable de type uint8_t.

Il est donc possible de créer des définitions pour les registres matériels, afin de les manipuler à l'aide de leur nom.

Bonne nouvelle: ce travail a déjà été fait par le constructeur qui fournit des en-têtes (avr/io.h). Nous y reviendrons plus tard.

Opérations bit à bit

Rappels

La représentation d'un octet sous la forme d'une suite de 0 et de 1 est appelée représentation binaire (en base 2). Sa représentation avec des chiffres alant de 0 à F est appelée représentation hexadécimale (en base 16).

En C, il est possible d'écrire le même nombre en décimal, binaire ou hexadécimal à l'aide des préfixes 0b et 0x:

int a = 42; // Décimal
int b = 0b101010; // Binaire
int c = 0x2A; // Hexadécimal

Introduction

On peut donc écrire dans des registres tels que DDRB ou PORTB, comme s'il s'agissait de variables. Cependant, il est souvent nécessaire de manipuler des bits individuellement.

Par exemple, comment mettre à 1 le bit 5 du registre PORTB ?

Datasheet de l'ATmega328P, Page 72

Liste des opérateurs bit à bit

OpérateurOpérationDescription
&ET logiqueMet à 1 si les deux bits sont à 1
|OU logiqueMet à 1 si au moins un bit est à 1
^OU exclusifMet à 1 si un seul bit est à 1
~NON logiqueInverse les bits
<<Décalage à gaucheDécalle les bits à gauche de n positions
>>Décalage à droiteDécalle les bits à droite de n positions

Les opérateurs |= et &= permettent de combiner une opération bit à bit avec une affectation. Par exemple, A |= B est équivalent à A = A | B.

Masques

Afin de modifier des bits individuellement, nous allons utiliser des masques. Des masques sont des valeurs binaires qui permettent de sélectionner des bits particuliers. Ils sont ensuite combinés avec des opérations bit à bit.

Par exemple, pour mettre à 1 le bit 5 du registre PORTB, nous pouvons utiliser un "OU logique" avec le masque 0b00100000.

Pour obtenir ce masque, nous pouvons utiliser un "décalage à gauche" de 5 bits de la valeur 1.

// Met le 5ème bit à 1 dans PORTB (sans toucher aux autres)
PORTB |= (1 << 5);

Autre exemple, pour mettre à 0 le bit 5 du registre PORTB, nous pouvons utiliser un "ET logique" avec le masque 0b11011111.

Pour obtenir ce masque, nous pouvons utiliser un "NON logique" du masque précédent.

// Met le 5ème bit à 0 dans PORTB (sans toucher aux autres)
PORTB &= ~(1 << 5);

Remarque: La représentation héxadécimale est souvent pratique pour écrire des masques, car 0b11111111 peut être écrit 0xFF.

Par exemple, un masque qui ne garderait que l'octet de poids fort :

uint16_t mask = 0b1111111100000000;

S'écrirait en hexadécimal :

uint16_t mask = 0xff00;

Octets de poids faible et fort

Lors de la transmission de données, il arrive parfois qu'une valeur soit séparée en plusieurs octets.

Par exemple :

uint16_t a = 0x1234;

Est un entier non signé de 16 bits, composée de l'octet de poids fort (Most Significant Byte, MSB) 0x12 et de l'octet de poids faible (Least Significant Byte, LSB) 0x34.

Exercices

Maintenant, à vous d'écrire chacune des opérations ci-dessous en une ligne de C:

  • Mettre à 1 le bit 3 du registre PORTB et à 0 les autres bits,
  • Mettre à 1 les bits 3 et 5 du registre PORTB,
  • Mettre à 0 les bits 3 et 5 du registre PORTB,
  • Mettre tous les bits de PORTB à 0 sauf le bit 3,
  • Tester (if) si le bit 3 du registre PORTB est à 1,
  • Inverser le bit 3 du registre PORTB.
  • Décomposer l'entier uint16_t i en uint8_t a et uint8_t b, respectivement les octets de poids fort et faible (une ligne pour a et une ligne pour b).
  • Combiner a et b pour obtenir i,
  • Construire j directement à partir de i, en inversant les octets de poids fort et faible.

Programmation

Compilation du firmware

Nous avons désormais notre premier programme C permettant d'allumer la LED de la carte Arduino:

#include <avr/io.h>

int main(void)
{
    // Configure le bit 5 du registre DDRB en sortie
    DDRB |= (1 << 5);
    PORTB |= (1 << 5);

    // Boucle infinie
    while (1);
}

L'ATmega328P utilise une architecture AVR, et non x86. Le code assembleur généré par gcc ne seraient donc pas compréhensible par l'ATmega328P.

Nous avons donc besoin d'un compilateur qui s'exécute lui-même sur notre machine, et qui génère du code assembleur pour l'ATmega328P. C'est ce qu'on appelle un compilateur croisé. En l'occurance, nous utiliserons avr-gcc.

Afin de compiler à l'aide de avr-gcc, nous devons préciser l'architecture exacte que nous ciblons:

avr-gcc -mmcu=atmega328p -Os -o main.elf main.c

Cette ligne de commande produira un fichier de type ELF (Executable and Linkable Format), qui contient le code assembleur généré par avr-gcc (avec d'autres informations).

Pour extraire le code binaire uniquement, nous pouvons utiliser la commande avr-objcopy:

avr-objcopy -O binary main.elf main.bin

Nous disposons maintenant du fichier main.bin, qui contient le code binaire à flasher sur l'ATmega328P.

Chargement du firmware

Afin de programmer un microcontrôleur, nous avons besoin de discuter avec un programme nommé bootloader. Ce programme est déjà présent sur l'ATmega328P, et permet de charger un nouveau firmware via le port série. Nous utiliserons l'outil avrdude pour communiquer avec le bootloader.

avrdude -p atmega328p -c arduino -P /dev/ttyACM0 -U flash:w:main.bin

Ici:

  • -p précise le type de microcontrôleur,
  • -c précise le type de programmateur (ici, le bootloader Arduino),
  • -P précise le port série à utiliser (sous Windows, il s'agit d'un port COM),
  • -U précise l'opération à effectuer (ici, écrire le fichier main.bin dans la mémoire flash).

🥳

Après l'exécution des commandes ci-dessus, la LED de la carte devrait s'allumer!

Cadence du processeur

Essayons maintenant de faire clignoter la LED. Une solution naïve serait d'utiliser une boucle pour "occuper" le processeur pendant un certain temps. C'est précisément ce que fait la fonction _delay_ms de la bibliothèque util/delay.h.

En ajoutant ce délai, un message d'erreur apparait:

In file included from main.c:2:0:
/usr/lib/avr/include/util/delay.h:92:3: warning: 
# warning "F_CPU not defined for <util/delay.h>" [-Wcpp]

La raison: pour fonctionner, la fonction _delay_ms "occupe" le processeur pendant un certain temps, et ce temps dépend de la fréquence du processeur.

La fréquence du processeur dépend d'un oscillateur, qui est connecté à l'ATmega328P. S'agissant d'un composant externe, il est possible de le changer pour modifier la fréquence du processeur.

Sur l'Arduino Uno, un oscillateur de 16 MHz est présent.

Pour préciser la fréquence du processeur, nous devons utiliser la macro F_CPU:

avr-gcc -mmcu=atmega328p -DF_CPU=16000000 -Os -o main.elf main.c

Dans la partie suivant,
nous décortiquerons un firmware simple afin de comprendre l'intégralité du code