Électronique et domotique libre - partie 5 : Programmation, Premiers signes de vie

De Wiki Techno-Innov
Révision datée du 2 septembre 2020 à 18:52 par Nathael (discussion | contributions) (Page créée avec « {{DISPLAYTITLE:Électronique et domotique libre - partie 5 : Programmation, Premiers signes de vie}} <div style="float:left; margin-right: 2.5em;">__TOC__</div> Image:D... »)
(diff) ← Version précédente | Voir la version actuelle (diff) | Version suivante → (diff)
Aller à la navigation Aller à la recherche

Programmation du module !

Nous avons vu dans le quatrième article comment fabriquer le module GPIO-Démo (ou toute version modifiée), nous allons désormais nous atteler à lui donner vie.

Note : La programmation sera faite en C, bien que d'autres langages puissent être utilisés, à la seule condition qu'il existe un compilateur qui puisse générer du code binaire pour ARM à partir de ce langage.

par Nathael Pajani

Paru dans le numéro 14 du magazine Open Silicium.


Les documentations techniques

La première étape lorsqu'on veut écrire du code pour un système embarqué qui utilise un micro-contrôleur, c'est d'en trouver la documentation technique.

L'accès aux documentations techniques est à mon avis un des critères principaux dans le choix des composants pour un projet, aussi bien pour les concepteurs que pour les utilisateurs.

Si vous avez réalisé la conception du circuit, vous aurez normalement déjà eu besoin de ces documentations, dans le cas contraire, c'est le moment de les récupérer. Pour le module GPIO-Démo, il y a plusieurs documentations techniques nécessaires pour utiliser la totalité des fonctionnalités du module. La première et la plus importante est celle du micro-contrôleur [7], mais vous aurez aussi besoin de celle du module, disponible sur le site de Techno-Innov [4], et de celles de l'EEPROM I2C, et du capteur de température I2C.

Encart : Pour ce qui est du bridge USB-UART, la documentation n'est pas nécessaire pour la programmation du micro-contrôleur et du module, mais elle le devient si vous voulez modifier la façon dont le composant s'identifie lorsque vous le connectez, par exemple pour pouvoir lui donner un nom spécifique ou simplifier l'identification. Cela ne sera cependant pas le sujet de cet article.

Pour récupérer les documentations techniques, vous avez le choix entre le site du fabriquant et celui du distributeur chez qui vous avec acheté les composants. Pour la majorité des composants j'utilise le site du distributeur (possible uniquement si il fourni les bonnes documentations), mais pour les micro-contrôleurs je consulte le site du fabriquant car il fourni aussi les "errata" (notes d'informations contenant les corrections sur la documentation) et le plus souvent des notes d'application qui indiquent comment utiliser le composant pour telle ou telle application (avec parfois des exemples de code).

Outils de programmation

La deuxième étape ne concerne toujours pas le code que vous voulez écrire.

En effet, pour écrire du code vous n'avez pas besoin de la "cible" (target en anglais) qui est le matériel sur lequel vous allez exécuter la version compilée du code, mais uniquement d'un poste de développement, couramment appelé hôte (host en anglais).

Cependant, la cible en question n'a que faire des fichiers sources qui se trouvent sur votre poste de développement, et il vous faudra un certain nombre d'outils pour passer des fichiers source (quelque soit le langage) au code exécutable par votre carte électronique.

C'est aussi un point très important à mon sens dans le choix d'un système embarqué ou d'un micro-contrôleur : quels sont les outils dont j'aurais besoin pour passer de mon code source à un système fonctionnant avec ?

Dans le cas des micro-contrôleurs de la gamme LPC de NXP il existe de nombreuses solutions pour passer de l'un à l'autre, mais ce qui est intéressant à mon sens c'est qu'il est possible de le faire avec un minimum de matériel et de logiciel : une liaison série "TTL 3.3V", une chaîne de compilation croisée et un utilitaire pour "uploader" le code binaire.

Matériel : un port USB (et le PC qui va avec)

Pour ce qui est de la liaison série "TTL 3.3V", il existe plein d'adaptateurs USB-UART fonctionnant en 3.3V, et pour le cas du module GPIO-Démo cet adaptateur est intégré sur le module, un port USB suffit donc.

L'accès à cette liaison série se fera via un fichier spécial dont le nom sera devrait ressembler à "/dev/ttyUSB0", le "USB0" pouvant différer selon votre système ("USB1", USB2", ... ou même "ACM0" avec d'autres adaptateurs).

Compilation : GNU

Pour ce qui est de la chaîne de compilation croisée, les micro-contrôleurs LPC de NXP utilisent des cœurs ARM Cortex-M*, très bien supportés par gcc, bien connu de tous les lecteurs de ce magazine (du moins je le pense), et parfaitement adapté à la cross-compilation.

Dit comme ça, c'est simple ... mais en fait, pas tant que ça. Cette problématique pourrait faire l'objet d'un (petit ?) article, je ne l'inclurai donc pas ici, je vous donne juste quelques pistes et suppose que vous saurez trouver les informations sur le net. De mon côté je dois les intégrer à la documentation technique du module GPIO-Démo, mais ce n'est pas encore fait à l'heure où j'écris ces lignes :( [4].

Si vous n'avez pas déjà une chaîne de cross-compilation installée pour ARM vous avez globalement trois solutions : Debian/EmDebian (celle que j'utilise), Launchpad, et Crosstools-ng.

Le projet EmDebian fournit des paquets debian pour différentes chaînes de cross-compilation (ARM parmi tant d'autres), qui sont entrain d'être intégrées à Debian. Cela devrait simplifier les problèmes de dépôts et de dépendances dont souffrait les dépôts EmDebian, même si seule la version "arm-none-eabi" (qui nous suffit, nous n'avons pas besoin de libC) est actuellement intégrée dans SID sans problèmes de dépendances (j'ai bien dit "devrait").

Le site de Launchpad fourni aussi des versions binaires de la chaîne de cross-compilation GCC ARM.

La solution CrossTools-ng : recompilation de sa propre chaîne de compilation croisée : la solution du dernier recours, si votre distribution ne fournit pas de solution packagée et que les versions binaires non signées ne vous conviennent pas.

Programmation : lpctools (ou autre)

Enfin, pour ce qui est de l'utilitaire permettant de charger (uploader) le code binaire sur le micro-contrôleur (flasher le micro-contrôleur), je n'avais pas trouvé d'outil libre permettant de réaliser cette étape au moment où j'ai étudié la possibilité d'utiliser les micro-contrôleurs de NXP, mais le protocole série permettant de réaliser cette opération était documenté dans la documentation technique des micro-contrôleurs, il ne devait donc pas y avoir de problème de ce côté là.

La seule solution "gratuite" que j'avais trouvé à l'époque ne fonctionnait que sur un seul système (pas le miens), ne fournissait pas ses sources, et interdisait une utilisation commerciale sans payer une licence, hors je voulais justement en faire une utilisation commerciale.

J'ai donc pris quelques heures de mon temps pour coder un utilitaire permettant de programmer les micro-contrôleurs LPC, et placé le tout sous licence GPL v3 [5]. Le tout est désormais disponible sur le site de Techno-Innov, et est intégré depuis peu à la distribution Debian GNU/Linux (Sid et Jessie à l'heure de l'écriture de ces lignes).

J'ai entre-temps découvert d'autres projets permettant de programmer des micro-contrôleurs LPC, mais je ne les ai pas testés (nxpprog - licence MIT, mxli - GPLv3, pyLPCTools - GPLv2) et constaté qu'un autre paquet Debian fournit les outils permetant de programmer les micro-contrôleurs LPC de NXP : lpc21isp (qui dispose d'ailleurs d'une interface graphique : GLPC, qui elle n'est pas dans les dépôts). Voir liens [6] en fin d'article.

Makefile - particularités pour la compilation croisée

Une dernière petite étape avant d'écrire notre code, bien que l'on se rapproche de la programmation, puisqu'il s'agit d'un élément essentiel de tout projet : la création du Makefile.

Dans le cas de la compilation pour une cible comme le module GPIO-Démo le Makefile doit inclure quelques éléments supplémentaires que l'on ne retrouve habituellement pas dans un Makefile classique.

Le premier élément concerne la définition du compilateur à utiliser. Nous utiliserons la variable "CROSS_COMPILE" à laquelle nous affecterons une valeur par défaut correspondant au préfixe du compilateur. Si vous voulez utiliser un compilateur correctement installé sur le système, il suffit d'utiliser le préfixe, sinon, il faudra ajouter devant la totalité du chemin donnant accès au compilateur.

Par exemple pour le compilateur EmDebian :

CROSS_COMPILE ?= arm-linux-gnueabi-

ou avec un chemin complet pour un autre compilateur :

CROSS_COMPILE ?= /usr/local/mon/compilateur/bin/arm-none-eabi-

Le "?=" permet de ne modifier la variable que si elle n'existe pas, notamment si elle n'est pas déjà présente dans la ligne de commande.

Cette variable est ensuite utilisée pour modifier la variable "CC" utilisée par "make" pour compiler les fichiers de code source C. Si vous utilisez un autre langage, modifiez la variable correspondante.

CC = $(CROSS_COMPILE)gcc

Il est aussi possible d'en profiter pour spécifier une version du compilateur installé :

CC = $(CROSS_COMPILE)gcc-4.7

Nous allons en profiter pour définir la variable "LD" qui correspond à l'éditeur de liens (linker en anglais) :

LD = $(CROSS_COMPILE)ld

Il nous faut ensuite définir la cible pour laquelle nous voulons compiler. Dans notre cas il s'agit d'un cœur ARM Cortex-M0, qui utilise le jeu d'instructions "thumb" (instructions sur 16bits permettant d'obtenir un code plus compact), nous allons donc utiliser les options -mcpu et -mthumb de gcc pour lui indiquer notre besoin :

CPU = cortex-m0
CFLAGS = -Wall -mthumb -mcpu=$(CPU)

En plus de cela, nous allons demander au compilateur de ne pas reconnaître les fonctions pour lesquelles il a une définition interne (builtin) tout simplement parce que la majorité de ces fonctions dépendent d'un environnement très différent de celui de notre micro-contrôleur, soit par la présence d'une bibliothèque C, d'un système d'exploitation respectant la norme POSIX, ou d'une unité de calcul flottant. Rien de tout ceci n'est vrai dans notre cas, et il est donc préférable que le compilateur nous informe si nous tentons d'utiliser ces fonctions sans l'avoir explicitement demandé en utilisant le préfixe "__builtin__".

Nous demanderons aussi au compilateur de bien placer les données et les fonctions dans leurs sections respectives. Malgré ce que dit la documentation de GCC, cela permet (au moins dans notre cas) de produire un binaire plus petit. Cela se fait à l'aide des directives d'optimisation "-ffunction-sections" et "-fdata-sections".

Nous ajouterons ces directives dans une variable dédiée :

FOPTS = -fno-builtin -ffunction-sections -fdata-sections

Nous obtenons donc les lignes suivantes à ajouter à votre Makefile :

CROSS_COMPILE ?= arm-linux-gnueabi-
CC = $(CROSS_COMPILE)gcc
LD = $(CROSS_COMPILE)ld
CPU = cortex-m0
FOPTS = -fno-builtin -ffunction-sections -fdata-sections
CFLAGS = -Wall -Wextra -mthumb -mcpu=$(CPU) $(FOPTS)

Notre Makefile ressemblerait alors à ceci :

NAME = mod_gpio
CROSS_COMPILE ?= arm-linux-gnueabi-
CC = $(CROSS_COMPILE)gcc
LD = $(CROSS_COMPILE)ld
CPU = cortex-m0
FOPTS = -fno-builtin -ffunction-sections -fdata-sections
CFLAGS = -Wall -Wextra -mthumb -mcpu=$(CPU) $(FOPTS)

.PHONY: all
all: $(NAME)

SRC = $(wildcard *.c)
OBJS = $(SRC:.c=.o)

$(NAME): $(OBJS)
	$(LD) $^ -o $@

Ce Makefile est encore incomplet et ne permet pas d'obtenir le binaire pour notre micro-contrôleur, mais nous ajouterons les parties manquantes plus tard.

En attendant, ce Makefile nous permettra de compiler le code que nous allons écrire, passons donc aux parties intéressantes, et le reste du Makefile sera bien plus simple à comprendre.

Commençons simple

Pour ne pas trop compliquer les choses dès le début, nous allons tenter de faire clignoter la led bicolore présente sur le module GPIO-Démo.

Notre squelette de code ressemblera à s'y méprendre à ce que l'on pourrait avoir pour un programme plus commun :

/*
 * Notre exemple simple pour Open Silicium
 */
void system_init(void)
{
	/* System init ? */
}

int main(void)
{
	/* Micro-controller init */
	system_init();
	
	while (1) {
		/* Change the led state */
		/* Wait some time */
	}
	return 0;
}

Si vous compilez ce petit morceau de code avec notre Makefile, vous obtiendrez un binaire qui globalement ne fait rien, mais a déjà une taille de 5.7 Ko. C'est gros pour ne rien faire, d'autant que la mémoire Flash de notre micro-contrôleur ne fait que 32 Ko.

Si on regarde plus loin, par exemple avec l'utilitaire file, on obtient quelques informations supplémentaires :

$ file mod_gpio
mod_gpio: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked (uses shared libs), [....], not stripped

Arghhh, notre binaire est un exécutable dynamique au format ELF, qui utilise des bibliothèques partagées !

Nous l'avons déjà évoqué, notre micro-contrôleur n'a pas de libC ni de système d'exploitation, notre binaire ne peut donc pas être dynamique (lié dynamiquement à des bibliothèques) ! Il nous faut donc faire l'édition de lien en "static", en apportant quelques modification à notre Makefile. Nous allons introduire une variable "LDFLAGS", que l'on ajoutera à la règle de compilation utilisée pour l'édition de liens.

LDFLAGS = -static
$(NAME): $(OBJS)
	$(LD) $^ $(LDFLAGS) -o $@

Recompilons notre projet ... 608 Ko !!! Re-Argh !

Visiblement quelqu'un ajoute du code dont nous n'avons pas besoin. Il faut du moins l'espérer, sinon il ne sera jamais possible de faire rentrer un programme dans la flash de notre micro-contrôleur, qui fait toujours 32Ko.

L'option "static" demande à l'éditeur de lien de créer un binaire statique, mais résultat il nous ajoute tout ce qui est utile de la libC pour un exécutable ... pour un système Linux. D'ailleurs, file nous dit bien que notre exécutable est au format ELF.

Pour commencer nous allons demander à l'éditeur de liens (ld) de ne pas inclure les éléments de démarrage dans notre binaire, car ils sont inutiles sur notre micro-contrôleur. Pour cela nous ajoutons l'option "-nostartfiles" à nos directives pour l'édition de liens.

Et hop, une recompilation plus tard, notre binaire ne fait plus que 1 Ko :) Mais pour file, ce binaire est toujours au format ELF, et gcc nous informe qu'il n'a pas trouvé de symbole d'entrée "_start" et qu'il utilise une adresse par défaut (nous l'avons cherché, cela fait partie de ce que nous avons demandé à l'éditeur de liens en ajoutant l'option "-nostartfiles").

En effet, le "main" de nos programmes en C n'est que le point de départ de l'exécution de notre code, mais pas celui du programme. Il est précédé par un certain nombre de routines (fonctions) d'initialisation, dont le point d'entrée est la routine "_start", qui appellera le main "classique".

Mais notre but n'est pas de créer un exécutable pour un système Linux, nous devons créer un binaire pour notre micro-contrôleur. Il est désormais temps de nous plonger dans la documentation du micro-contrôleur pour comprendre comment il démarre, pour pouvoir adapter notre code au démarrage du micro-contrôleur et fournir l'équivalent de cette routine "_start".

Bootstrap

Table des vecteurs d'interruption

Pour avoir des informations sur le démarrage du micro-contrôleur LPC1224 dans sa documentation technique [7] il y a deux chapitres intéressants.

Nous pouvons nous référer au chapitre 4 (LPC122x System control) qui inclut une section sur le reset du micro-contrôleur (4.6 : Reset), dans laquelle on apprend qu'une fois que la condition de reset disparait et que les procédures internes se sont exécutées, le processeur commence l'exécution à l'adresse contenue dans le vecteur "Reset" du "boot block".

Nous avons ainsi quelques informations, mais qu'est-ce que le vecteur "Reset" et le "boot block" ? Cela nous mène au deuxième chapitre intéressant, le chapitre 25 sur le cœur ARM Cortex-M0. On y trouve une section sur le processeur (25.3 : Processor) avec une sous section concernant les exeptions (25.3.3 : Exception model).

Dans les informations sur les exceptions (25.3.3.2), et plus particulièrement l'exception "Reset", il est indiqué qu'après un "Reset" l'exécution reprend à l'adresse indiquée dans le vecteur "Reset" dans la table des vecteurs d'interruption.

Bien, cela confirme ce qui se trouvait dans le chapitre 4. Mais cette fois, nous avons une sous-section "25.3.3.4 Vector table", qui décrit cette fameuse table des vecteurs d'exception, précisant qu'elle contient l'addresse de toutes les routines de gestion des exceptions (les fameux "vecteurs" d'exception, dont le vecteur "Reset").

Remarquez au passage que les interruptions sont gérées de la même façon, l'addresse d'entrée des routines de gestion des interruptions se trouve dans cette même table.

Et en dessous de cette table, nous avons une autre information très intéressante : "The vector table is fixed at address 0x00000000".

Si l'on se réfère à la table 65 de la section "25.3.2 Memory model", et à la figure 2 de la section "2.3 Memory allocation", on découvre que cette adresse est en fait le début de la mémoire flash du micro-contrôleur, qui devra contenir notre code binaire.

Reste à créer cette table, et à la placer au bon endroit dans le code binaire compilé...

Les "handlers" et les "dummy handlers"

Nous allons commencer par la partie code. Ce code peut être placé dans le même fichier que celui ou ce trouve notre fonction main, ou dans un autre fichier, cela n'a pas d'importance. Si vous vous référez au code du module GPIO-Démo disponible dans les dépôts git de Techno-Innov, vous trouverez ce code dans le fichier "core/bootstrap.c".

/* Cortex M0 core interrupt handlers */
void Reset_Handler(void);
void NMI_Handler(void) __attribute__ ((weak, alias ("Dummy_Handler")));
void HardFault_Handler(void) __attribute__ ((weak, alias ("Dummy_Handler")));

void Dummy_Handler(void);

void *vector_table[] = {
	0,
	Reset_Handler,
	NMI_Handler,
	HardFault_Handler,
	0,
	/* [......] */
};

void Dummy_Handler(void) {
    while (1);
}

void Reset_Handler(void) {
	/* Our program entry ! */
}

Voici un petit morceau de code relativement court qui définit notre table de vecteur ainsi que les routines de gestion correspondantes.

Avant d'aller plus loin, faisons un petit point sur certains éléments : les attributs utilisés pour la déclaration de deux de nos routines de gestion des exceptions : "__attribute__ ((weak, alias ("Dummy_Handler")))"

Ces attributs permettent de définir un alias "faible" pour une fonction, ce qui veut dire que si le compilateur ne trouve pas de définition pour cette fonction, il pourra utiliser son alias, que nous avons défini plus bas. Au contraire, si dans un autre fichier (ou celui-ci) vous définissez l'une de ces fonctions, c'est votre définition qui sera utilisée.

Note : je n'ai pas mis la totalité de la table des vecteurs, cela n'a pas d'intérêt pour les explications, et notre programme fonctionnera sans, mais il faudrait la compléter pour un vrai programme, en faisant attention à l'ordre, à partir des informations de la figure 68 déjà évoquée, et de la table 4 du chapitre 3 concernant le gestionnaire d'interruptions (vous noterez la typo, il s'agit des numéros d'IRQ et non pas des numéros des vecteurs d'exception).

Appeler notre main()

Ce petit bout de code compile donc, puisque tout est défini, mais nous avons toujours l'avertissement concernant le symbole d'entrée "_start" qui n'a pas été trouvé. Nous ne sommes pas plus avancé.

Tout d'abord, nous avons déjà indiqué que le point d'entrée de notre code est la fonction main() (qui pourrait avoir n'importe quel nom), mais pour notre micro-controlleur la fonction d'entrée du programme est la routine de gestion de l'exception "Reset".

La solution pour faire le lien est toute simple : il suffit d'appeler la fonction main() depuis la routine de gestion de l'exception "Reset".

int main(void);
void Reset_Handler(void) {
	/* Our program entry ! */
	main();
}

Tisser des liens, c'est important

Cela ne résout cependant aucun de nos problèmes, nous allons donc devoir donner plus d'indications à l'éditeur de liens, en créant un script pour l'édition de lien (lpc_link_lpc1224.ld). Nous donnerons le nom de ce script à l'éditeur de liens en utilisant l'otion "-Tlpc_link_lpc1224.ld".

Ce script d'édition de lien ("linker script" en anglais) est en fait un fichier qui décrit la mémoire de notre micro-contrôleur, ainsi que l'organisation que devra respecter notre binaire.

Nous allons donc commencer, toujours à partir des mêmes informations en provenance de la documentation technique, par définir quelle est la mémoire disponible, aussi bien la mémoire "vive" (SRAM) que la mémoire "morte" (FLASH).

MEMORY
{
	sram (rwx) : ORIGIN = 0x10000000, LENGTH = 4k
	flash (rx) : ORIGIN = 0x00000000, LENGTH = 32k
}

Nous définissons ainsi l'adresse et la taille (et les droits d'accès) de la sram et de la flash, qui deviennent deux blocs de mémoire disponibles pour l'éditeur de liens, qui pourra donc y placer du code et des données.

Il nous faut maintenant expliquer à l'éditeur de liens comment organiser le code et les données dans ces blocs de mémoire, et tout particulièrement notre tableau de vecteurs d'interruptions

Le code d'un programme est composé de "sections", dans lesquelles le compilateur place chaque fonction et chaque variable en fonction de différents critères, que l'on peut contrôler soit à partir de directives de compilation, soit d'attributs ajoutés dans notre code C

Nous allons donc modifier notre tableau de vecteurs d'interruptions pour lui ajouter un attribut "section" qui forcera l'éditeur de liens à le placer dans la section que nous avons défini.

void *vector_table[] __attribute__ ((section(".vectors"))) = {

Et en parallèle, nous allons définir dans notre script quelles sont les sections que nous voulons trouver dans notre binaire, et dans quel ordre. Commençons simple (si si, je vous assure), nous compliquerons plus tard :

SECTIONS {
	. = ORIGIN(flash);
	.text :
	{
		KEEP(*(.vectors))
		*(.text*)
		*(.rodata*)
	} >flash
	.data :
	{
		*(.data*)
		*(.bss*)
	} >sram
}

Pour commencer, nous définissons l'adresse courante (.) comme étant l'origine de du bloc de mémoire "flash" (le bloc de mémoire défini précédemment et auquel nous avons donné ce nom, bien qu'un autre nom aurait pu faire l'affaire). Ceci est important pour que l'éditeur de liens puisse définir les adresses des fonctions pour l'exécution de notre code. À partir de ce point nous définissons une section "text", dans laquelle nous allons demander à l'éditeur de lien de mettre plusieurs éléments, à commencer par notre fameuse table de vecteurs, en lui interdisant d'en changer la position ! Après quoi, nous lui demandons de placer l'ensemble du code, que le compilateur a placé dans des section ".text.*", puis les données en lecture seule (Read-Only data = rodata), et de placer le tout dans le bloc de mémoire "flash".

À la suite, nous créons une section "data", dans laquelle nous lui demandons de placer les données initialisées à des valeurs non nulles (data), puis les données initialement nulles (bss [8]), et de placer cette section en RAM.

Ne reste plus qu'une information à donner à l'éditeur de liens (pour l'instant): le point d'entrée de notre programme. Ceci est relativement simple et explicite :

ENTRY(Reset_Handler)

Dans notre Makefile la variable LDFLAGS devient :

LDFLAGS = -static -nostartfiles -Tlpc_link_lpc1224.ld

Une compilation plus tard, sans avertissements cette fois, nous avons un binaire ... beaucoup trop gros : notre binaire fait maintenant plus de 66 Ko !

Soyons un peu curieux et allons donc voir ce qu'il contient, mais pas à la main, rassurez vous, utilisons un utilitaire fait exprès pour cela : objdump. attention, il faut bien entendu utiliser la version "ARM" de objdump, donc avec le préfixe "CROSS_COMPILE", soit dans mon cas arm-linux-gnueabi-objdump, avec l'option --disassemble-all (-D) :

arm-linux-gnueabi-objdump -D mod_gpio > dump

Et là, surprise, la version "désassemblée" de notre binaire est plus petite que la version binaire (le fichier "dump" fait 2.2 Ko).

Regardons tout de même son contenu. La première ligne nous informe que le format du fichier est "elf32-littlearm" ... pas grand chose à voir avec ce que nous voulions, un "simple" binaire (Notre micro-contrôleur n'a toujours pas appris à lire les exécutables au format ELF). Premier problème.

Quelqu'un est venu glisser une section ".note.gnu.build-id" avant notre section ".text", que l'éditeur de liens aimerait placer en début de RAM ("10000000 <_sram_base>"), ce qui n'a pas d'intérêt pour nous.

S'en suit tout de même notre section ".text", qui commence bien par notre table (enfin ! on nous écoute un peu !), suivie de nos quelques maigres fonctions. Notez les adresses des fonctions dans la table des vecteurs, qui sont toutes impaires. Ceci indique que notre processeur exécutera ces fonctions en mode "thumb" qui est bien le mode que nous avions demandé (-mthumb).

Et encore une fois, notre section ".text" n'est pas suivie de nos données, mais d'autres sections que nous n'avions pas demandées ! Certes, nous n'avons pas de variables, et l'éditeur de lien n'ayant rien à mettre dans notre section ".data", il ne l'a pas créée, mais au final nous sommes relativement loin de ce que nous voulions.

Nous allons donc demander à l'éditeur de liens de générer un petit peu moins de choses en ajoutant l'option -Wl,--build-id=none à nos directives de compilation (variable LDFLAGS de notre Makefile), ce qui nous donne un binaire moitié plus petit mais toujours trop gros.

Un binaire vraiment binaire

Mais nous ne pouvons pas en demander bien plus à l'éditeur de liens, dont le travail est de créer des exécutables au format ELF. Pour créer d'autres types de binaire, nous devrons utiliser l'utilitaire objcopy (toujours la version ARM, donc en fait arm-linux-gnueabi-objcopy). Le travail de cet utilitaire est de créer des binaires à partir des exécutables ELF, en ne conservant que les sections qui nous intéressent (du moins, c'est l'usage que nous en ferons).

Nous allons demander à cet utilitaire de nous générer une image binaire à partir du résultat de notre compilation en ajoutant dans notre Makefile une cible "$(NAME).bin" qui deviendra notre cible par défaut et dépendra de la génération du fichier au format ELF :

all: $(NAME).bin

$(NAME).bin: $(NAME)
	$(CROSS_COMPILE)objcopy -O binary $^ $@

Et notre objectif est enfin atteint, avec un binaire de ... 36 octets !
Rappelez vous, je n'ai mis que 5 entrées dans ma table de vecteurs, soit 20 octets, suivis de quelques fonctions vides. Tout va bien.

Un programme un peu plus utile

Nous allons désormais pouvoir nous occuper d'allumer nos Leds, en quelque sorte le "Hello world" de l'électronique.

Entrées et sorties

Du point de vue de notre micro-contrôleur, allumer ou éteindre une Led revient à changer l'état de la sortie correspondante.

Les entrées/sorties du microcontrôleur peuvent avoir plusieurs fonctions, mais hormis quelques exceptions elles sont par défaut configurées en entrées/sorties (GPIO (general Purpose Input Output) en anglais) avec la résistance de pull-up interne activée. Pour simplifier, nous laisserons donc cette étape de la configuration de côté pour l'instant, et reviendrons dessus lorsque nous attaquerons la programmation d'interfaces plus complexes.

Note : Les exceptions sont les GPIO 13, 25 et 26 du port 0 (respectivement Reset, SWDIO et SWDCLK), qui permettent le reset et le debug, ainsi que quatre GPIO sur lesquelles la fonction "0" est réservée : les GPIO 30 et 31 du port 0 et les GPIO 0 et 1 du port 1.

Les registres

Pour changer l'état d'une de ces entrées/sorties, il faut commencer par la configurer en sortie, puis définir sont état.

Avant d'aller plus loin, il est important de comprendre que le processeur de notre micro-contrôleur ne voit que de la mémoire autour de lui. Il n'a pas accès directement aux signaux électriques. Pour accéder aux fonctions spéciales comme l'état électrique d'une sortie le processeur doit donc modifier des données dans une zone mémoire spécifique, dédiée à cette sortie, que l'on appelle un registre.

Notre micro-contrôleur dispose d'un grand nombre de registres qui ont tous une taille de 32 bits, même si souvent certains bits ne sont pas utilisés (ils sont alors "réservés"). Ces registres particuliers, qui nous donnent accès aux fonctions spéciales du micro-contrôleur, comme la configuration et l'état des entrées sorties, sont accessibles chacun à une adresse fixe dans l'espace d'adressage du micro-contrôleur.

Nous avons déjà eu besoin d'informations sur cet espace d'adressage lorsque nous avons cherché des informations sur le démarrage du micro-contrôleur, il se trouve représenté sur la figure 2 dans la section "2.3 Memory allocation" du chapitre 2 (LPC122x Memory map).

L'adresse qui nous intéresse dépend du port sur lequel les leds sont connectées. La led bicolore étant connectée sur le port 1 ce sont les registres présents à l'adresse 0x50010000 que nous devrons utiliser. Cette adresse est aussi rappelée avec la description des registres dans le chapitre 8 (LPC122x General Purpose I/O (GPIO))

À partir de cette adresse se trouvent une série de registres permettant de contrôler les entrées/sorties du port 1. La modification du contenu de la mémoire accessible ainsi modifiera donc le comportement des entrées/sorties correspondantes ou leur état lorsqu'elles sont configurées en sortie.

Très (trop) souvent, pour accéder à ces registres, les programmeurs utilisent des définitions selon le principe suivant:

#define LPC_AHB_BASE  (0x50000000UL)
#define LPC_GPIO_1_BASE  (LPC_AHB_BASE + 0x10000)
#define PORT1_MASK  (LPC_GPIO_1_BASE + 0x00)
#define PORT1_PIN   (LPC_GPIO_1_BASE + 0x04)
#define PORT1_OUT   (LPC_GPIO_1_BASE + 0x08)
/* [.....] */

Personnellement je trouve cette façon de faire très inefficace, et peu lisible (voire complètement illisible), et je préfère l'utilisation de structures. Cela me semble beaucoup plus lisible, plus adapté pour accéder à de la mémoire qui est structurée, et apporte aussi un très net avantage lorsqu'il existe plusieurs ensembles de registres utilisant la même organisation, par exemple quand il y a plusieurs liaisons séries sur le micro-contrôleur, ou dans le cas qui nous intéresse pour l'instant, plusieurs ports d'entrées/sorties. La définition se passe alors ainsi :

#define LPC_AHB_BASE  (0x50000000UL)
#define LPC_GPIO_0_BASE  (LPC_AHB_BASE + 0x00000)
#define LPC_GPIO_1_BASE  (LPC_AHB_BASE + 0x10000)
/* General Purpose Input/Output (GPIO) */
struct lpc_gpio
{
   volatile uint32_t mask;       /* 0x00 : Pin mask, affects data, out, set, clear and invert */
   volatile uint32_t in;         /* 0x04 : Port data Register (R/-) */
   volatile uint32_t out;        /* 0x08 : Port output Register (R/W) */
   volatile uint32_t set;        /* 0x0C : Port output set Register (-/W) */
   volatile uint32_t clear;      /* 0x10 : Port output clear Register (-/W) */
   volatile uint32_t toggle;     /* 0x14 : Port output invert Register (-/W) */
   uint32_t reserved[2];
   volatile uint32_t data_dir;   /* 0x20 : Data direction Register (R/W) */
   /* [.....] */
};
#define LPC_GPIO_0      ((struct lpc_gpio *) LPC_GPIO_0_BASE)
#define LPC_GPIO_1      ((struct lpc_gpio *) LPC_GPIO_1_BASE)

À noter, l'utilisation du mot clé "volatile" qui interdira au compilateur d'optimiser les accès à ces registres en gardant des copies intermédiaires, forçant la lecture ou l'écriture immédiate.

Cette notation nous donne aussi l'information de la taille des données présentes à ces adresses, dans ce cas là, des valeurs sur 32 bits.

Pour l'utilisation dans le code, c'est ensuite très simple, il suffit de déclarer un pointeur vers cette structure, puis d'accéder au champ qui nous intéresse :

struct lpc_gpio* gpio1 = LPC_GPIO_1;
gpio1->out = 0;

Allumer les leds

Reste à déterminer quels sont les champs qui nous intéressent et quelles valeurs nous devons écrire dedans. Pour configurer les pins reliées à nos Leds en sortie nous devons modifier le registre "DIR" (appelé "data_dir" dans la structure), et mettre les bits 4 et 5 à "1" puisque chaque bit de ce registre permet de configurer une des pins du port correspondant, le bit 0 pour la pin 0, le bit 1 pour la pin 1, et ainsi de suite (attention, ici les numéros de pins ne sont pas les numéros des pattes du composant).

Nous pouvons donc modifier notre main() pour obtenir le code suivant qui réalise cette opération de façon lisible, et positionne la Led verte à l'état "allumée", sans modifier l'état des autres sorties du port 1 :

/* The status LED is on GPIO Port 1, pin 4 (PIO1_4) and Port 1, pin 5 (PIO1_5) */
#define LED_RED    5
#define LED_GREEN  4
int main(void)
{
   struct lpc_gpio* gpio1 = LPC_GPIO_1;
   /* Micro-controller init */
   system_init();
   /* Configure the Status Led pins */
   gpio1->data_dir |= (1 << LED_GREEN) | (1 << LED_RED);
   /* Turn Green Led ON */
   gpio1->set = (1 << LED_GREEN);
   gpio1->clear = (1 << LED_RED);
   while (1) {
       /* Change the led state */
       /* Wait some time */
   }
}

[Encart : Décalages de bits] :

La notation "<<" correspond à l'opérateur logique "décalage de bits vers la gauche" (">>" pour le décalage à droite). Voir l'article wikipedia sur le sujet, en Anglais, la version Française étant catastrophiquement incomplète [9].

Mettre le code sur le micro-contrôleur : lpctools

Je sais que vous êtes de plus en plus impatients et que vous voulez voir le résultat de tout ceci en vrai, nous allons donc passer à la mise en flash sur le micro-contrôleur pour tester tout cela.

Je ne traiterais ici que le cas de la version 48 broches du micro-contrôleur LPC1224 (package LQFP48). A vous d'adapter si vous utilisez un autre micro-contrôleur, le principe étant très similaire pour tous les LPC de NXP. Pour les autres gammes de micro-contrôleurs, référez vous à leurs docs techniques et aux informations sur Internet.

Connexion

Si vous avez mis l'adaptateur USB-UART sur votre module comme proposé dans les articles précédents, l'étape de connexion au PC est simple, connectez votre module sur un port USB, directement ou via un câble en fonction du connecteur USB que vous avez choisi.

Sinon, il vous faudra un adaptateur USB-UART (souvent vendus comme adaptateurs USB-série) fonctionnant en 3.3V, et relier les signaux Rx et Tx aux signaux TXD0 et RXD0 correspondant à l'UART 0 du micro-contrôleur LPC1224 qui se trouvent sur le port 0, pins 1 et 2 (broches 16 et 17 du composant). Il est alors préférable d'avoir rendu ces signaux accesssibles sur un connecteur de votre choix, sans quoi il vous faudra souder des fils directement sur les pattes du micro-contrôleur ... possible, mais pas conseillé du tout).

Mode ISP

Il faut ensuite mettre le micro-contrôleur en mode programmation (ISP : "In System Programming" en anglais). Le chapitre 20 (LPC122x Flash ISP/IAP) indique qu'il faut maintenir la pin 12 du port 0 (broche 27) à l'état bas (0V) pendant au moins 3ms après avoir relaché le signal "Reset" (pin 13 du port 0, broche 28). La suite du chapitre décrit le protocole de programmation, uniquement utile si vous voulez créer votre propre utilitaire pour programmer le micro-contrôleur, ou si vous voulez réaliser des opérations très spécifiques.

Puisque nous avons placé des boutons poussoir reliant ces signaux à la masse, avec des résistance de "pull-up", cette opération est très simple : il faut appuyer sur les deux boutons en même temps, puis relacher le bouton reset avant de relacher le bouton ISP. Lorsque tout c'est bien passé, la led bicolore doit avoir deux petits points lumineux à peine visibles dans l'obscurité (un rouge et un vert).

Dialogue avec le micro-contrôleur

La suite se passe sur le PC. Le paquet lpctools contient deux binaires : lpcisp et lpcprog. Le premier donne accès aux opérations élémentaires du protocole de programmation, et ne nous sera pas utile. Le second permet de programmer le micro-contrôleur en utilisant une unique commande, bien que d'autres commandes permettent d'effectuer quelques opérations intéressantes.

Nous allons d'ailleurs commencer par une de ces autres opération : demander les identifiants de notre micro-contrôleur avec la commande "id". La syntaxe des commandes lpcprog est simple, il suffit de lui passer un nom de device, que l'on passe comme argument de l'option "-d", une commande (argument de l'option "-c"), et si besoin le nom du fichier à utiliser. Dans l'exemple suivant le module GPIO-Démo est identifié sur le poste de développement comme périphérique "ttyUSB0", nous utiliserons donc le device "/dev/ttyUSB0".

$ lpcprog -d /dev/ttyUSB0 -c id
Part ID 0x3640c02b found on line 26
Part ID is 0x3640c02b
UID: 0x2c0cf5f5 - 0x4b32430e - 0x02333834 - 0x4d7c501a
Boot code version is 6.1

La première ligne nous indique que lpcprog a reconnu le micro-contrôleur et qu'il sera donc possible de le programmer. (Si ce n'est pas le cas, vous devrez compléter le fichier de description des micro-contrôleurs). Ensuite, lpcprog nous donne les informations propres au micro-contrôleur, à savoir son identifiant unique et la version du "boot code" qui se trouve en rom sur le micro-contrôleur.

Si vous avez un affichage similaire, tout va bien. Sinon, vérifiez la connexion et que le micro-contrôleur est bien en mode ISP.

La programmation se fait ensuite très simplement avec la commande "flash", en ajoutant le nom du fichier binaire à envoyer.

$ lpcprog -d /dev/ttyUSB0 -c flash mod_gpio.bin
Part ID 0x3640c02b found on line 26
Flash now all blank.
Checksum check OK
Flash size : 32768, trying to flash 1 blocks of 1024 bytes : 1024
Writing started, 1 blocks of 1024 bytes ...

lpcprog se charge d'effacer la flash, de générer la somme de contrôle de l'entête et de la placer au bon endroit dans le binaire, et d'envoyer le tout au micro-contrôleur pour mise en flash.

Pour tester, il suffit d'appuyer sur le bouton Reset, et de constater qu'il ne se passe rien, ce qui n'est pas ce que nous attendions.

Il y a plusieurs problèmes.

Votre code s'il vous plait !

Le premier vient de la génération de la somme de contrôle, ou plutôt de sa position dans notre binaire. Tous les LPC de NXP utilisent (à ce jour de ce que j'ai pu voir) le même mécanisme pour déterminer si la flash contient une image valide avant d'essayer d'exécuter son contenu : la vérification du checksum des 8 premiers vecteurs d'interruption, qui doit être nulle.

Pour que ceci soit possible, conformément à la documentation technique des micro-contrôleurs, lpcprog calcule le complément à 2 des 7 premiers vecteurs, et le place dans le huitième.

Sauf que notre tableau ne fait pour l'instant que 5 "cases" et que la huitième case contenait en fait notre code exécutable, qui a été écrasé.

La première modification est donc de remplir au moins 8 cases de ce tableau, au pire avec des valeurs nulles, sinon, avec des valeurs correspondant à ce qui est attendu dans la documentation : les adresses des routines de gestion, ou au moins celle de notre routine "de remplacement" (Dummy_Handler()) lorsque la documentation indique que l'emplacement est utilisé (table 363 et figure 68 pour les exceptions, et table 4 pour les interruption).

void *vector_table[] __attribute__ ((section(".vectors"))) = {
   0, /* 0 */
   Reset_Handler,
   NMI_Handler,
   HardFault_Handler,
   0,
   0, /* 5 */
   0,
   /* Entry 7 (8th entry) must contain the 2’s complement of the check-sum
      of table entries 0 through 6. This causes the checksum of the first 8
      table entries to be 0 */
   (void *)0xDEADBEEF, /* Actually, this is done using an external tool. */
   0,
   /* [.....] voir le code du module GPIO-Demo pour la suite */
};

Cependant ceci n'est pas suffisant (vous pouvez désormais le tester très simplement, je vous laisse faire).

Un peu de place pour la pile

Le dernier point un petit peu particulier est encore sur cette fameuse table des vecteurs, et concerne la première entrée. Si vous regardez attentivement la figure 68, le vecteur "Reset" n'est que la deuxième entrée de la table. La première est la valeur initiale du pointeur de la pile (Stack pointer), qui correspond en fait à son sommet puisque cette pile est remplie en faisant décroître les adresses (cette information se trouve dans le chapitre 25, mais cette fois je vous laisse chercher un petit peu).

Nous avons initialisé cette valeur à 0, ce qui forcément pose un soucis.

Pour obtenir la bonne valeur, nous pourrions coder en dur l'adresse du haut de notre sram, mais ce n'est pas propre. Pour que tout soit fait correctement et automatiquement, nous allons modifier le script de l'édition de liens, et demander à ld de remplir cette adresse à notre place, en fonction de l'adresse de la sram et de sa taille.

Nous allons donc créer des variables dans notre script, juste après la définition de la mémoire disponible, et chose très importante, ces variables seront des variables externes utilisables dans notre code C !

_sram_size = LENGTH(sram);
_sram_base = ORIGIN(sram);
_end_stack = (_sram_base + _sram_size);

Nous appelons le bas de la pile (la "fin" de la pile) _end_stack, et plaçons cette fin de pile tout en haut de la mémoire vive.

Et pour l'utilisation dans notre code C, tout simplement :

extern unsigned int _end_stack;
void *vector_table[] __attribute__ ((section(".vectors"))) = {
   &_end_stack, /* Initial SP value */ /* 0 */
   Reset_Handler,
...

Si vous compilez votre code avec ces modifications, vous obtenez enfin un binaire qui allume la Led !

Conclusion

Nous avons enfin notre "Hello World !" version électronique, mais la route est encore longue.

Vous pourrez voir dans le prochain article que cette première étape n'était qu'une étape, et qu'il reste encore plusieurs étapes importantes avant de pouvoir vraiment commencer à écrire du code "fonctionnel", comme la gestion du "watchdog", la configuration de l'horloge interne, l'initialisation de la mémoire, et l'écriture des drivers pour chaque bloc fonctionnel (liaison série, I2C, SPI, ADC, ....).

Rendez-vous donc pour le second article dédié à la programmation de notre micro-contrôleur.

Merci pour votre lecture attentive !

Les fichiers créés

Vous trouverez sur notre serveur le Makefile, le fichier C main.c, et le script de lien lpc_link_lpc1224.ld

Liens - Bibliographie