Aller au contenu principal

Les fonctions

Ce que ce chapitre apporte 7 points
  • Découper un programme en fonctions nommées d'après ce qu'elles font.
  • Déclarer une fonction avec des paramètres et une valeur de retour, puis l'appeler.
  • Distinguer une fonction qui agit sur la carte d'une fonction qui calcule une valeur, et écrire une fonction qui répond par oui ou non.
  • Prévoir l'effet d'un passage par valeur sur la variable de l'appelant, et le représenter en mémoire.
  • Passer un tableau et sa taille à une fonction.
  • Conserver une valeur d'un appel à l'autre avec static, sans variable globale.
  • Réécrire un programme recopié en une suite d'appels qui se lit comme sa description.

Un programme Arduino qui grossit devient vite illisible, parce que tout s'entasse dans loop(). Le découpage lui-même, et la question de savoir où couper, sont traités dans le chapitre d'algorithmique sur la modularité. Une fonction donne un nom à un morceau de traitement : allumerSeul(VERT, 3000) se lit, trois digitalWrite suivis d'un delay demandent à être déchiffrés. C'est la différence entre un programme qu'on relit et un programme qu'on réécrit. Ce chapitre montre aussi deux comportements du C qui surprennent : une fonction ne modifie pas les variables qu'on lui passe, et un tableau n'y arrive jamais entier.

Donner un nom à un traitement

Un feu tricolore écrit sans fonction répète la même séquence trois fois : éteindre les trois LEDs, en allumer une, attendre. Une fonction la décrit une fois.

typeRetour nomDeLaFonction(type parametre1, type parametre2) {
  // instructions
  return valeur;          // absent si le type de retour est void
}
Arduino
const int ROUGE = 9, ORANGE = 10, VERT = 11;
void allumerSeul(int broche, unsigned long duree) {
digitalWrite(ROUGE, LOW);
digitalWrite(ORANGE, LOW);
digitalWrite(VERT, LOW);
digitalWrite(broche, HIGH); // seule la LED demandée reste allumée
delay(duree);
}
void setup() {
pinMode(ROUGE, OUTPUT);
pinMode(ORANGE, OUTPUT);
pinMode(VERT, OUTPUT);
}
void loop() {
allumerSeul(VERT, 3000);
allumerSeul(ORANGE, 1000);
allumerSeul(ROUGE, 3000);
}

loop() se lit maintenant comme la description du feu. Et la fonction garantit une propriété que le code recopié ne garantissait pas : à aucun instant deux feux ne sont allumés ensemble.

Définitions

Les paramètres sont les variables déclarées dans l'en-tête de la fonction : broche et duree.

Les arguments sont les valeurs fournies à l'appel : VERT et 3000. Chaque argument est copié dans le paramètre correspondant, dans l'ordre.

Un appel suspend la fonction appelante. Quand loop() exécute allumerSeul(VERT, 3000), l'exécution saute au début de allumerSeul, en parcourt les instructions, puis revient à la ligne suivante de loop(). C'est pourquoi loop() n'avance vers l'orange qu'une fois les trois secondes de vert écoulées.

Pour revenir au bon endroit, la carte range l'adresse de retour et les variables de la fonction dans une zone de la mémoire vive appelée la pile, qui grandit à chaque appel et se vide à chaque retour. Le chapitre Les fonctions et la pile du parcours C explique ce que chaque appel y réserve, le cadre, et quand ce cadre est rendu. Sur l'Uno, elle partage les 2 Ko de mémoire vive avec toutes les variables : des appels trop imbriqués la font déborder sur les données, sans message d'erreur.

Où placer les fonctions
L'environnement Arduino ajoute automatiquement les déclarations manquantes, ce qui permet d'écrire une fonction après loop() et de l'appeler avant. En C++ ordinaire, une fonction doit être connue avant d'être appelée. Placer les fonctions avant setup() fonctionne dans les deux cas.

Agir ou calculer

Deux sortes de fonctions coexistent, et les distinguer rend un programme plus facile à corriger.

Une fonction qui agit modifie l'état de la carte : elle allume, attend, envoie un message. Elle ne rend rien, et son type de retour est void.

Une fonction qui calcule reçoit des valeurs et en rend une autre par return, sans rien toucher d'autre. Sa réponse ne dépend que de ses arguments.

Arduino
// Calcule : convertit une lecture du capteur de température LM35 en degrés Celsius.
float enCelsius(int lecture) {
float tension = lecture * (5.0 / 1023.0); // la lecture ramenée en volts
return tension * 100.0; // le LM35 délivre 10 mV par degré
}
void setup() {
Serial.begin(9600);
}
void loop() {
float temperature = enCelsius(analogRead(A0));
Serial.println(temperature, 1); // une décimale
delay(1000);
}
Pourquoi une fonction de calcul n'affiche rien
enCelsius pourrait afficher le résultat au lieu de le rendre. Elle deviendrait alors inutilisable dans un test de seuil, dans une moyenne, dans un envoi sur une autre liaison. Rendre la valeur laisse à l'appelant le choix de ce qu'il en fait. Pour une lecture de 62, elle rend 30,3.

Une fonction qui répond par oui ou non

Une question dont la réponse est vrai ou faux mérite une fonction de type bool. Son nom se lit alors comme une question, et l'appel se glisse directement dans un if.

Arduino
bool estDansPlage(float valeur, float minimum, float maximum) {
return valeur >= minimum && valeur <= maximum; // la comparaison est déjà un booléen
}
float enCelsius(int lecture) {
return lecture * (5.0 / 1023.0) * 100.0;
}
void setup() {
pinMode(8, OUTPUT);
}
void loop() {
float t = enCelsius(analogRead(A0));
if (estDansPlage(t, 18.0, 26.0)) {
digitalWrite(8, LOW); // voyant hors plage éteint
} else {
digitalWrite(8, HIGH); // trop froid ou trop chaud : voyant allumé
}
delay(1000);
}

return valeur >= minimum && valeur <= maximum; est l'écriture directe. Un débutant écrit souvent if (...) { return true; } else { return false; }, qui fait exactement la même chose en quatre lignes : la condition est déjà la réponse.

Vérification rapideon peut se reprendre

1.Quel type de retour prend une fonction qui allume une LED sans rien rendre ?

2.Que rend estDansPlage(30.0, 18.0, 26.0) ?

3.Pourquoi préférer qu'une fonction de conversion rende sa valeur plutôt que de l'afficher ?

Le passage par valeur

Définition

En C, un argument est copié dans le paramètre. La fonction travaille sur cette copie : modifier le paramètre ne modifie pas la variable de l'appelant.

Arduino
void doubler(int x) {
x = x * 2; // modifie la copie seulement
}
int doublee(int x) {
return x * 2; // rend le résultat
}
void setup() {
Serial.begin(9600);
int n = 21;
doubler(n);
Serial.println(n); // 21 : n n'a pas changé
n = doublee(n);
Serial.println(n); // 42 : c'est l'appelant qui range le résultat
}
void loop() {}

La mémoire rend la chose évidente. Pendant l'appel doubler(n), deux variables existent : n, qui appartient à setup(), et x, que l'appel vient de créer ailleurs en mémoire en y copiant 21. La multiplication agit sur x. À la fin de l'appel, x disparaît, et n n'a jamais été touché.

MÉMOIRE VIVE : 4 octets sur les 2 048 de l'Uno, un carré par octet0x8F80x8F90x8FA0x8FB21n, dans setup42x, dans doubler
Pendant l'appel doubler(n), juste après x = x * 2. Deux cases distinctes : n vaut toujours 21, seule la copie x vaut 42. Quand doubler se termine, la case de x est rendue, et le 42 avec elle.

Pour qu'une fonction change une valeur, elle la rend, et c'est l'appelant qui l'affecte. Cette règle a une exception, et elle concerne les tableaux.

Vérification rapideon peut se reprendre

Après void mettreAZero(int x) { x = 0; } et l'appel mettreAZero(n), que vaut n ?

Passer un tableau

Un tableau n'est pas copié quand on le passe à une fonction : celle-ci reçoit seulement l'adresse de son premier élément, c'est-à-dire un pointeur, au sens du parcours C. Deux conséquences en découlent.

La fonction peut modifier les éléments du tableau de l'appelant, puisqu'elle travaille sur la même mémoire. Et elle ne connaît pas la taille du tableau : sizeof y mesure l'adresse, pas le tableau. D'où la règle : la taille voyage toujours avec le tableau, en second paramètre.

Arduino
float moyenne(const int valeurs[], int n) { // const : la fonction s'interdit de modifier le tableau
long somme = 0;
for (int i = 0; i < n; i++) {
somme += valeurs[i];
}
return (float)somme / n;
}
int lectures[] = {512, 498, 530, 505};
const int NB = sizeof(lectures) / sizeof(lectures[0]); // calculé là où le tableau est déclaré
void setup() {
Serial.begin(9600);
Serial.println(moyenne(lectures, NB));
}
void loop() {}

La figure montre ce que reçoit moyenne lors de l'appel moyenne(lectures, NB). Le paramètre valeurs n'est pas une copie des quatre lectures : c'est une case de deux octets qui contient l'adresse de lectures[0], d'où la flèche. Le paramètre n est une copie ordinaire du nombre 4.

MÉMOIRE VIVE : 12 octets sur les 2 048 de l'Uno, un carré par octet0x1000x1010x1020x1030x1040x1050x1060x1070x1080x1090x10A0x10B512lectures[0]498lectures[1]530lectures[2]505lectures[3]0x100valeurs4n
Ce que reçoit moyenne(lectures, NB). Le tableau reste où il est ; valeurs contient seulement son adresse, 0x100, sur deux octets. C'est pourquoi sizeof(valeurs) vaut 2 dans la fonction, et pourquoi la taille doit arriver par n.
sizeof dans la fonction
Écrire sizeof(valeurs) / sizeof(valeurs[0]) à l'intérieur de moyenne compile sans avertissement. Sur l'Uno, une adresse occupe 2 octets, comme un int : le quotient vaut 1, et la fonction calcule la « moyenne » du seul premier élément, quelle que soit la taille du tableau.

Puisque la fonction reçoit l'adresse, elle peut aussi écrire dans le tableau de l'appelant. C'est la seule façon propre, sans passer par une variable globale, de faire modifier plusieurs valeurs par une fonction :

Arduino
void remettreAZero(int t[], int n) { // pas de const : la fonction a le droit d'écrire
for (int i = 0; i < n; i++) {
t[i] = 0;
}
}
int compteurs[] = {4, 7, 1};
void setup() {
Serial.begin(9600);
remettreAZero(compteurs, 3);
Serial.println(compteurs[1]); // 0 : le tableau de l'appelant a bien changé
}
void loop() {}

Le mot-clé const du paramètre de moyenne prend alors tout son sens : il annonce, et le compilateur le fait respecter, que la fonction se contente de lire.

Vérification rapideon peut se reprendre

1.Que reçoit une fonction à laquelle on passe un tableau ?

2.Une fonction reçoit int t[] et exécute t[0] = 5. Que devient le tableau de l'appelant ?

Garder une valeur d'un appel à l'autre

Une variable locale naît à chaque appel et disparaît à la fin : une fonction ne se souvient de rien. Pour compter le nombre de fois où elle a été appelée, la tentation est de déclarer une variable globale. Le mot-clé static offre mieux.

Arduino
int prochainNumero() {
static int compteur = 0; // initialisée une seule fois, au premier appel
compteur++;
return compteur;
}
void setup() {
Serial.begin(9600);
Serial.println(prochainNumero()); // 1
Serial.println(prochainNumero()); // 2
Serial.println(prochainNumero()); // 3
}
void loop() {}
Définition

Une variable locale static est initialisée une seule fois, au premier passage, et conserve sa valeur entre deux appels. Elle vit aussi longtemps qu'une variable globale, mais reste invisible hors de sa fonction.

Le compteur ne peut donc être modifié que par prochainNumero, et aucune autre partie du programme ne risque de le remettre à zéro par erreur. C'est la bonne place pour une valeur qui n'intéresse qu'une fonction : le dernier état d'un bouton, l'instant du dernier clignotement, un numéro de message.

Vérification rapideon peut se reprendre

Sans le mot static, que rendraient les trois appels à prochainNumero() ?

Réécrire un programme en fonctions

Un signal de détresse en morse, SOS, se compose de trois points, trois traits, trois points. Le code morse fixe tout en unités de temps : un point dure 1 unité, un trait 3, l'extinction entre deux signaux d'une même lettre 1, entre deux lettres 3, et entre deux messages 7.

200 ms par bit5 bauds34 bits, 6,8 s en tout
LED101010001110111011100010101000000001,6 s3,2 s4,8 s6,4 s
SOS en morse, avec une unité de 200 ms. Trois points, trois traits, trois points, puis sept unités de silence avant la répétition : 34 unités, soit 6,8 secondes par message.

Écrit d'un seul tenant, ce programme aligne dix-huit digitalWrite et autant de delay, et une erreur d'une unité ne se retrouve qu'en recomptant tout. Découpé en fonctions, il se lit comme la définition du morse :

Arduino
const int LED = 3;
const unsigned long UNITE = 200; // durée d'un point, en millisecondes
// Allume pendant la durée demandée, puis éteint pendant une unité.
void impulsion(unsigned long duree) {
digitalWrite(LED, HIGH);
delay(duree);
digitalWrite(LED, LOW);
delay(UNITE);
}
void point() { impulsion(UNITE); }
void trait() { impulsion(3 * UNITE); }
void lettreS() {
point(); point(); point();
delay(2 * UNITE); // 1 unité déjà faite + 2 = les 3 d'entre deux lettres
}
void lettreO() {
trait(); trait(); trait();
delay(2 * UNITE);
}
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
lettreS();
lettreO();
lettreS();
delay(4 * UNITE); // 3 unités déjà faites + 4 = les 7 d'entre deux messages
}

Trois bénéfices, que la version recopiée n'avait pas.

  • loop() dit ce que fait le programme : S, O, S, une pause. Le détail des durées est descendu d'un niveau.
  • Chaque règle du morse est écrite une seule fois. Changer UNITE à 100 accélère tout le message sans risque d'oublier un delay.
  • Chaque fonction se vérifie seule. point() produit « 1 0 » sur le chronogramme, lettreS() produit « 1 0 1 0 1 0 0 0 » : le chronogramme complet se retrouve en mettant bout à bout ces morceaux.
Nommer d'après l'intention, pas d'après les instructions
impulsion, point et lettreS disent ce qu'elles représentent ; une fonction nommée allumerAttendreEteindre décrirait ses instructions et n'apprendrait rien de plus que leur lecture. Un bon nom de fonction permet de lire l'appel sans ouvrir la fonction.

Mettre en pratique

Suivre les appels et les durées

  • 1.

    Combien de millisecondes dure un appel à trait() avec UNITE = 200, extinction comprise ?

  • 2.

    Combien de millisecondes dure un message SOS complet, pause finale comprise ?

  • 3.

    Avec UNITE = 120, combien de messages SOS complets sont émis en une minute ? Donner le nombre entier de messages terminés.

  • 4.

    Après trois appels à doubler(n), n valant 21 au départ, que vaut n ?

  • 5.

    Dans une fonction, sizeof(valeurs) vaut 2 et sizeof(valeurs[0]) vaut 2. Que vaut le quotient, que la fonction prendrait pour le nombre d'éléments ?

Pourquoi la fonction allumerSeul éteint-elle les trois LEDs avant d'en allumer une, plutôt que d'éteindre seulement la précédente ?

Parce qu'elle ne sait pas quelle LED était allumée avant son appel. En éteignant tout, elle garantit l'état final quel que soit l'état de départ : une seule LED allumée, celle qui est demandée.

Une version qui éteindrait « la précédente » dépendrait de l'ordre des appels dans loop(). Le jour où cet ordre change, deux feux se retrouvent allumés ensemble, et le défaut ne se voit que sur le montage.

Après int n = 21; doubler(n);, n vaut toujours 21. Comment corriger sans changer le rôle de la fonction ?

doubler reçoit une copie de n et ne modifie que cette copie. La correction consiste à lui faire rendre le résultat, et à l'affecter chez l'appelant :

Arduino
int doublee(int x) {
return x * 2;
}
void setup() {
int n = 21;
n = doublee(n); // n vaut 42
}
void loop() {}
Une fonction moyenne(int valeurs[]) calcule la taille avec sizeof(valeurs) / sizeof(valeurs[0]). Sur un tableau de quatre lectures, elle rend la première lecture. Pourquoi ?

Dans la fonction, valeurs n'est pas le tableau mais l'adresse de son premier élément. Sur l'Uno, une adresse occupe 2 octets, un int aussi : le quotient vaut 1, et la boucle ne traite qu'un élément.

La taille se calcule là où le tableau est déclaré, puis se passe en paramètre : moyenne(lectures, NB).

Le capteur LM35 renvoie une lecture de 62 sur A0. Que rend enCelsius(62) ?

La tension vaut 62×51023≈0,30362 \times \dfrac{5}{1023} \approx 0{,}303 V. Le LM35 délivrant 10 mV par degré, cela fait 30,3 °C.

Une fonction de calcul se vérifie ainsi, à la main sur une valeur connue, avant d'être branchée sur un capteur réel. C'est précisément ce que permet le fait qu'elle rende sa valeur au lieu de l'afficher.

Écrire une fonction bool estPair(int n), puis l'utiliser pour n'allumer une LED qu'un passage de loop() sur deux.
Arduino
bool estPair(int n) {
return n % 2 == 0; // le reste de la division par 2 est nul
}
void setup() {
pinMode(3, OUTPUT);
}
void loop() {
static int passage = 0; // compte les passages, sans variable globale
digitalWrite(3, estPair(passage) ? HIGH : LOW);
passage++;
delay(500);
}

Le compteur est static : il survit d'un passage de loop() au suivant, comme une globale, mais reste invisible du reste du programme. L'écriture condition ? HIGH : LOW choisit l'une des deux valeurs selon la condition.

Ajouter la lettre T (un seul trait) au programme morse, et émettre SOS puis T.

Une fonction de plus, sur le modèle des deux autres, et un appel de plus :

Arduino
const int LED = 3;
const unsigned long UNITE = 200;
void impulsion(unsigned long duree) {
digitalWrite(LED, HIGH);
delay(duree);
digitalWrite(LED, LOW);
delay(UNITE);
}
void point() { impulsion(UNITE); }
void trait() { impulsion(3 * UNITE); }
void lettreS() { point(); point(); point(); delay(2 * UNITE); }
void lettreO() { trait(); trait(); trait(); delay(2 * UNITE); }
void lettreT() { trait(); delay(2 * UNITE); }
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
lettreS();
lettreO();
lettreS();
lettreT();
delay(4 * UNITE);
}

Aucune ligne existante n'a changé. C'est le signe d'un bon découpage : ajouter un comportement revient à ajouter un morceau, pas à retoucher tout le reste.

Chapitre révisé en octobre 2026 · Une erreur dans ce chapitre ?

Dans la bibliothèque d'exercices