Comme nous l'avons vu précédemment, le microcontrôleur est candencé à une certaine fréquence, qui dépend très souvent d'un élément hardware externe, le quartz. Cette fréquence est appelée fréquence d'horloge.
Par exemple, notre ATmega328P est cadencé à 16MHz
.
Souvenez vous de la définition -DF_CPU=16000000UL
dans le fichier Makefile
. C'est cette dernière
qui permet au programme C de savoir à quelle fréquence le microcontrôleur est cadencé.
Un timer est un module hardware qui permet de compter le nombre de cycles d'horloge. Il est incrémenté à une certaine période \(T\), et réinitialisé lorsqu'il dépasse un certain plafond \(P\) (on parle d'overflow).
La valeur du timer peut être lue et utilisée directement par le code. Par exemple, le registre TCNT0
contient la valeur du timer TIMER0
de l'ATmega328P.
La période d'un timer peut se régler à l'aide d'un registre, appelé prescaler. Ce dernier permet de diviser la fréquence d'horloge par un certain nombre \(N\), on peut alors calculer la période \(T\) à l'aide de la formule:
$$T = \frac{N}{f_{CPU}}$$Où \(f_{CPU}\) est la fréquence d'horloge du microcontrôleur.
En général, le prescaler \(N\) est une valeur que l'on peut choisir parmis une liste de valeurs prédéfinies.
Par exemple, la liste des valeurs possibles pour le prescaler du TIMER0 de l'ATmega328P est:
1
, 8
, 64
, 256
ou 1024
.
Page 87 de la datasheet
En fonction du mode d'utilisation du timer, le plafond peut être sa valeur maximale (par exemple 0xFF
pour
un timer 8 bits), ou une valeur arbitraire fixée par un registre.
Page 86 de la datasheet
Par exemple, le TIMER0
de l'ATmega peut être réglé en mode CTC (Clear Timer on Compare Match), où le plafond
est fixé par le registre OCR0A
.
La fréquence à laquelle le timer overflow (c'est à dire repasse à zéro) est alors:
$$f = \frac{f_{CPU}}{N \times (P + 1)}$$Où \(f_{CPU}\) est la fréquence d'horloge du microcontrôleur, \(N\) est le prescaler, et \(P\) le plafond.
Exemple
Si un microcontrôleur est candencé à 20Mhz
, qu'on définit le prescaler à 256
et le plafond à 124
,
on obtiendra alors une fréquence de 20Mhz / (256 * (124 + 1)) = 625Hz
,
soit une période de 1.6ms
.
Dans le TD1, nous avons fait briller une LED plus ou moins fort en jouant sur le ratio \(t_1\) entre le temps où la LED est allumée et le temps \(t_0\) où elle est éteinte.
On nomme ce type de signal modulation en longueur d'impulsion (ou PWM pour Pulse Width Modulation).
Le ratio suivant:
$$r = \frac{t_1}{t_0 + t_1}$$S'appelle le rapport cyclique.
Si la fréquence du signal est suffisament élevée, ce type de signal est équivalent à une tension moyenne proportionnelle au rapport cyclique.
Produire un un signal de ce type est possible à l'aide de délai (en "software"), mais en monopolisant le microcontrôleur. En pratique, il est possible d'implémenter la même chose en utilisant des timers (en "hardware")
En ajoutant un autre registre de comparaison, il est possible de produire ce signal en utilisant un timer en mode PWM:
Ici:
Il est ainsi possible de régler le rapport cyclique en faisant varier OCR0A
de 0
à 255
(si il s'agit d'un timer 8 bits avec le plafond maximum)
Les timers permettent de déclencher des interruptions à chaque overflow (c'est à dire à chaque fois que le timer revient à zéro), ainsi qu'à chaque fois que le timer atteint la valeur du registre de comparaison.
En jouant sur le prescaler et sur le plafond, il est alors possible d'obtenir des interruptions à des fréquences arbitraires et régulières.
Comme vous l'avez remarqué, nous programmons actuellement sans système d'exploitation.
En particulier, il n'y a pas de concept de tâche (ou thread), et le programme s'exécute de manière séquentielle.
Question: Comment faire plusieurs choses "en même temps" ?
La solution la plus simple est de pratiquer un ordonnancement coopératif. C'est à dire que le programme est découpé en plusieurs tâches, et que chacune de ces tâches est exécutée à tour de rôle.
Par exemple, le code suivant qui fait clignoter une LED:
DDRB |= (1 << PB5);
while (1) {
PORTB |= (1 << PB5);
_delay_ms(1000);
PORTB &= ~(1 << PB5);
_delay_ms(1000);
}
Est bloquant à cause des appels à _delay_ms
.
Supposons que l'on dispose d'une méthode uint32_t system_clock()
qui utilise un timer et nous retourne
le temps écoulé depuis le démarrage du microcontrôleur en millisecondes.
On pourrait alors ré-écrire le code de la manière suivante:
uint32_t last_change = 0;
uint8_t led_state = 0;
void blink_init() {
DDRB |= (1 << PB5);
}
void blink_tick() {
if (system_clock() - last_change >= 1000) {
last_change = system_clock();
led_state = !led_state;
if (led_state) {
PORTB |= (1 << PB5);
} else {
PORTB &= ~(1 << PB5);
}
}
}
Ainsi, la fonction main
ressemblerait alors à:
int main() {
blink_init();
// Initialisation d'autre tâches
while (1) {
blink_tick();
// Exécution d'autre tâches
}
}
Cette méthode repose sur le fait que
les fonctions *_tick
ne sont pas bloquantes, elles ne font que vérifier si il est temps de faire
quelque chose et rendent la main immédiatement.
L'ordonnancement coopératif est une approche simple et efficace pour réaliser plusieurs tâches en même temps sans système d'exploitation.
Une autre approche consiste à interrompre les tâches afin de donner du temps de calcul à d'autres tâches.
Une manière d'implémenter cela est d'utiliser un timer pour déclencher une interruption à une fréquence donnée, et d'écrire du code (un peu technique) qui permet de changer le contexte de l'exécution (état des registres, de la pile, etc.) lors de l'interruption.
Au retour de l'interruption, une autre tâche peut alors être exécutée.
Ce type de mécanisme est appelé ordonnancement préemptif. Il est plus complexe à mettre en oeuvre, et la meilleure solution est d'utiliser un système d'exploitation, dont une des principale valeur ajoutée est préciement de gérer ce type de mécanisme.