Bus I2C : machine.I2C, machine.I2CTarget et les périphériques esclaves¶
Référence interne
Cette page décrit le fonctionnement interne. Pour utiliser le composant (câblage, paramètres, exemples) : Périphériques I2C.
Cette page décrit comment est construit le bus I2C : le maître côté microcontrôleur (machine.I2C), la classe de base des périphériques esclaves (Internal.PartialI2cDevice), le contrat des scripts Python qui décrivent ces périphériques, et l'écran Grove LCD RGB. Le pourquoi (alternatives écartées, restrictions) est dans requirements.md, décision « Bus I2C électrique en drain ouvert ».
1. Un vrai bus électrique, en drain ouvert¶
Deux fils partagés, SDA (données) et SCL (horloge), plus la masse. Personne ne force jamais une ligne à l'état haut : chaque acteur (le microcontrôleur, chaque périphérique) ne sait que tirer la ligne à la masse ou la relâcher. Ce sont les résistances de tirage qui remontent une ligne relâchée. Conséquences directes, sans une ligne de code pour les obtenir :
- ET câblé : une ligne est basse dès qu'au moins un acteur la tire. C'est ainsi qu'un esclave acquitte (il tire SDA pendant que le maître la relâche), et qu'autant de périphériques qu'on veut se partagent les deux fils — Kirchhoff fait la résolution de bus.
- Sans tirage externe, rien ne marche :
I2C()active les tirages internes de 50 kΩ de SCL et SDA (pin_pull, comme le portrp2), mais face aux 10 pF d'entrée de chaque périphérique ils donnent des fronts montants de plusieurs microsecondes. SCL relâchée est encore basse un quart de période plus tard : le maître lèveOSError(ETIMEDOUT), le symptôme d'un montage réel où on a oublié les résistances (Examples.I2c.NoPullUp: 50 kΩ × 30 pF = 1,5 µs, contre 0,625 µs à 400 kHz).
| Côté | Tirer à la masse | Relâcher | Lire |
|---|---|---|---|
Microcontrôleur (MCU, aucune modification de MCU.mo) |
broche en sortie à l'état bas (pinIsOutputD = true, pinBoolOut = false) |
broche en entrée (interrupteur ouvert, haute impédance) | pinBoolIn |
Périphérique (Internal.PartialI2cDevice) |
conductance variable sdaOut.G = 1/ROut (SDA seulement) |
sdaOut.G = GOff |
VoltageSensor + seuil |
Les tirages sont portés par les périphériques : paramètre usePullUp (désactivé par défaut, RPullUp = 4,7 kΩ), composants conditionnels. Le Grove l'active, comme le module réel ; plusieurs paires se mettent en parallèle.
Chaque broche de périphérique porte aussi une capacité d'entrée CIn (10 pF). Elle n'est pas cosmétique : elle fait de SDA et SCL des états dynamiques, ce qui rompt la dépendance entre le when du microcontrôleur et ceux des esclaves (même rôle que CIn des périphériques série, cf. peripheriques-uart-externes.md). Elle fixe aussi le temps de montée : 4,7 kΩ × 10 pF = 47 ns, très en dessous du quart de période à 400 kHz (625 ns). Augmenter CIn ou RPullUp dégrade les fronts, comme sur un vrai bus trop chargé.
Pas de composant Ideal.* commutant côté périphérique (piège documenté de la LED embarquée, cf. requirements.md) : la sortie est une VariableConductor.
2. Le maître : une séquence cadencée par échéances¶
Resources/Include/pyruntime/pyruntime_i2c.c. Le maître n'attend aucun front : il impose l'horloge. Toute la séquence avance par échéances (nextWakeTime), un quart de période q = 1/(4·freq) à la fois :
bit : SCL basse ─[q]→ SDA positionnée ─[q]→ SCL relâchée ─[q]→ SDA lue (SCL vérifiée haute) ─[q]→ SCL basse
START : bus libre ? (SDA et SCL hautes) ─→ SDA basse ─[q]→ SCL basse
STOP : SDA basse (SCL basse) ─[q]→ SCL relâchée ─[q]→ SDA relâchée ─[q]→ résultat disponible
START répété : SDA relâchée (SCL basse) ─[q]→ SCL relâchée ─[q]→ START
Une seule action par appel de PyRuntime_sync, et la suivante toujours strictement dans le futur : Modelica doit d'abord laisser la ligne évoluer à travers les tirages, et time >= pre(nextWakeTime) ne se redéclenche que s'il repasse de faux à vrai.
Une transaction complète : [START adr+W, octets écrits] [START répété adr+R, octets lus] STOP, chaque segment facultatif. Le maître acquitte chaque octet lu sauf le dernier (NACK), comme le veut le protocole.
Appel bloquant. writeto, readfrom, readfrom_mem... ne rendent la main au script qu'à la fin réelle de la séquence, en temps simulé : la primitive i2c_xfer arme la transaction puis se gare (yield_to_modelica(1e300)). En fin de transaction, le moteur pose i2c_done_wake, qui rend ce réveil authentique (cf. cycle-de-vie.md, réveil authentique vs. pitstop) : un Timer ou une IRQ pendant l'attente ne fait pas revenir l'appel trop tôt.
Erreurs (valeurs errno de MicroPython) : adresse non acquittée → OSError(EIO) ; ligne restée basse (bus pas libre avant un START, ou SCL relâchée qui ne remonte pas) → OSError(ETIMEDOUT) ; appel I2C depuis un callback pendant une transaction → OSError(EBUSY).
Les broches du bus sont réservées (i2c_claimed, même motif que la réception UART) : leurs fronts ne réveillent pas le script et ne déclenchent pas d'IRQ GPIO.
3. L'esclave : un décodeur piloté par les fronts¶
Resources/Include/i2ctarget.h/.c (moteur cible partagé par les périphériques, I2cDeviceImpl.c + i2cdevice/, et par machine.I2CTarget du microcontrôleur, §5bis ; même idiome qu'uartcore). L'esclave n'a pas d'horloge : il suit celle du maître. Le when de PartialI2cDevice se déclenche à chaque franchissement de seuil de SCL ou de SDA ; I2cDevice_sync passe les niveaux à i2ct_sync, qui les compare à ceux du dernier appel. Ce que l'hôte fait des octets passe par des crochets (addr_match, write_byte, write_end, read_byte, read_end) : pour un périphérique, ce sont on_write()/on_read() et le journal.
| Événement | Interprétation |
|---|---|
| SDA descend pendant que SCL est haute | START (ou START répété) : clôture de la phase en cours, attente de l'adresse |
| SDA monte pendant que SCL est haute | STOP : clôture de la phase en cours |
| SCL monte | un bit est lu (adresse, octet écrit) ou l'acquittement du maître est lu (lecture) ; l'impulsion est comptée ici |
| SCL descend | l'esclave positionne SDA pour le bit suivant : ACK après le 8e bit, bit de donnée en lecture, ou relâchée |
Changer SDA juste après le front descendant de SCL garantit qu'un esclave ne fabrique jamais de faux START/STOP. Un esclave dont l'adresse ne correspond pas reste muet jusqu'au STOP ou au START suivant — c'est ce qui permet plusieurs périphériques sur le bus. Rappelée au même instant avec les mêmes niveaux (itérations d'événements), la fonction ne fait rien : il n'y a pas de nouveau front.
Piège rencontré : les impulsions étaient d'abord comptées au front descendant de SCL. Or le premier front descendant après un START n'est pas un coup d'horloge de donnée : l'esclave ne capturait que 7 bits, lisait 0x42 au lieu de 0x84, et n'acquittait jamais. Diagnostiqué en traçant SDA/SCL (CSV) et les états du décodeur.
4. Le contrat d'un script de périphérique¶
Le comportement d'un périphérique I2C est toujours décrit par un script Python — il n'y a pas de table de commandes. Le script ne voit que des transactions : ni bits, ni START/STOP, ni acquittements. Quatre fonctions, toutes facultatives :
def on_write(addr, data, t, v): # une phase d'écriture adressée vient de se clore (STOP ou START répété)
... # data : bytes reçus (jamais vide : une sonde de scan() n'appelle rien)
def on_read(addr, t, v): # le maître commence à lire
return b'...' # bytes, str, liste d'entiers ou entier ; sortis un par un,
# on_read rappelée si le maître en veut plus (0xFF si rien)
def outputs(): # relue après chaque gestionnaire -> connecteur valueOut
return (a, b)
def lines(): # relue après chaque gestionnaire -> line1/line2 (écrans)
return ('ligne 1', 'ligne 2')
addr est l'adresse utilisée par le maître : un composant peut en avoir plusieurs (paramètre addresses, chaîne "0x3E, 0x62" — Modelica n'a pas de littéraux hexadécimaux). t : temps simulé ; v : tuple des grandeurs du connecteur valueIn.
Le chargement (espace de noms propre à chaque instance, print() préfixé du nom du composant, arrêt propre de la simulation sur exception) est commun avec les périphériques série : Resources/Include/devscript.c. Deux instances du même script ont deux états indépendants ; les variables de module persistent d'un appel à l'autre.
Écrire un nouveau périphérique : copier Resources/Scripts/Device/i2c_generic.py (un banc de registres commenté), puis soit le désigner dans un Peripherals.I2cGenericDevice, soit en faire une classe (extends Internal.PartialI2cDevice(addresses = ..., scriptPath = ..., usePullUp = ...), sur le modèle de I2cEchoDevice).
5. Les périphériques fournis¶
| Composant | Adresse(s) | Script | Rôle |
|---|---|---|---|
I2cEchoDevice |
0x42 |
Device/i2c_echo.py |
Composant de test : relit au maître la dernière écriture. valueOut = (écritures, octets reçus, premier octet) |
I2cGenericDevice |
à régler | Device/i2c_generic.py |
Gabarit : banc de 16 registres à pointeur auto-incrémenté |
I2cGroveLcdRgb |
0x3E, 0x62 |
Device/grove_lcd_rgb.py |
Écran Grove - LCD RGB Backlight, tirages activés |
L'écran Grove porte deux circuits, d'où deux adresses pour un seul composant. Le script les émule d'après leurs fiches techniques, sans rien savoir du programme qui les pilote :
- JHD1313 (
0x3E, compatible HD44780) : chaque octet est précédé d'un octet de contrôle (bit 7Co: un autre octet de contrôle suit ; bit 6RS: commande ou caractère). Commandes : effacement, retour au début, mode d'entrée, écran allumé/éteint, décalage, configuration, position d'écriture (DDRAM 2 × 40, ligne 2 à0x40). Écran éteint à la mise sous tension. Un octet reçu pendant un effacement (1,52 ms) est ignoré, avec un avertissement dans le journal. - PCA9633 (
0x62) : registresMODE1/MODE2,PWM0–PWM3(bleu, vert, rouge), gradation de groupe,LEDOUT; pointeur de registre à auto-incrément ; oscillateur en veille à la mise sous tension.
Rendu : Internal.Lcd16x2RgbIcon, 32 cellules générées mécaniquement et un fond qui prend la couleur du rétroéclairage, animés pendant la relecture d'un résultat dans OMEdit.
Examples.I2c.GroveLcd exécute sans modification un driver MicroPython existant (Scripts/MCU/driver_grove_lcd_rgb.py, importé par le programme principal Scripts/MCU/i2c_grove_lcd_rgb.py, comme un module posé à côté de main.py sur la vraie carte), écrit pour la vraie carte : c'est la démonstration « jumeau numérique ». Le shim accepte pour cela I2C(scl=..., sda=..., freq=...) sans identifiant, en plus de la forme rp2 I2C(0, scl=..., sda=...).
5bis. La cible côté microcontrôleur : machine.I2CTarget¶
Resources/Include/pyruntime/pyruntime_i2ctarget.c, classe I2CTarget du shim. Un MCU peut être l'esclave d'un autre MCU (Examples.MultiMcu.I2c et I2cIrq). Le décodeur est le même que celui des périphériques (i2ctarget.c, §3) ; ce que le microcontrôleur fait des octets passe par les crochets du moteur, appelés sur le thread Modelica pendant PyRuntime_sync :
| Crochet | Mode mémoire (mem=) |
Sans mémoire |
|---|---|---|
addr_match |
remet à zéro le compteur d'octets d'adresse | — |
write_byte |
mem_addrsize/8 premiers octets → memaddr, puis écriture dans mem[memaddr++] (rebouclage) |
file rx (lue par readinto()), IRQ_WRITE_REQ |
write_end |
IRQ_END_WRITE, sauf si l'écriture n'a fait que choisir l'adresse |
IRQ_END_WRITE |
read_byte |
mem[memaddr++] |
file tx (remplie par write()) ; vide → I2CT_DEFER + IRQ_READ_REQ si un gestionnaire peut répondre, sinon 0xFF |
read_end |
IRQ_END_READ |
IRQ_END_READ |
Côté électrique, rien de neuf : les deux broches sont prises (i2c_claimed, ni réveil ni IRQ GPIO), SDA est tirée ou relâchée par i2c_drive comme pour le maître, SCL n'est jamais tenue (pas de clock stretching).
La mémoire est écrite sans le GIL. Le bytearray de mem= est exporté une fois (PyObject_GetBuffer, tampon tenu jusqu'à deinit() : sa taille ne peut plus changer), et le C y lit et écrit directement pendant PyRuntime_sync. C'est sûr parce que le worker est garé pendant toute la synchro (alternance stricte des tours) : aucun code Python du microcontrôleur ne peut toucher mem au même moment. Le mode mémoire continue donc de répondre après la fin du programme.
IRQ au même instant. Un événement dont le déclencheur est demandé pose i2ct.irq_pending. PyRuntime_sync ajoute cette condition au drain (i2ct_due) : le worker reçoit un pitstop au même instant simulé, run_due_callbacks remet les drapeaux (irq().flags()) et appelle le gestionnaire. Pour IRQ_READ_REQ, le moteur a différé l'octet (read_deferred, SDA relâchée) ; après le drain, i2ct_finish → i2ct_resume redemande l'octet (sans nouveau report : 0xFF si le gestionnaire n'a rien écrit) et fixe SDA avant que les sorties ne soient publiées. Le maître échantillonne SDA un quart de période plus tard : il voit le bon bit, sans clock stretching.
6. Ce que vérifient les scénarios¶
| Script | Modèle | Ce qui est vérifié |
|---|---|---|
verify_21_i2c_echo.mos |
Examples.I2c.Echo |
START conforme, ACK de l'adresse tenu par l'esclave, trame de 9 octets écrite puis relue, lecture de registre derrière un START répété |
verify_22_i2c_multi.mos |
Examples.I2c.MultiDevice |
Trois esclaves sur un bus, deux paires de tirages en parallèle, 400 kHz : scan() exact, pas de diaphonie, EIO sur une adresse absente |
verify_23_i2c_nopullup.mos |
Examples.I2c.NoPullUp |
Sans tirage externe : lignes au repos à 3,3 V (tirages internes), mais ETIMEDOUT, scan() vide, aucun esclave sollicité |
verify_24_i2c_grove_lcd.mos |
Examples.I2c.GroveLcd |
Driver du commerce tel quel : écran éteint puis « hello World », rétroéclairage rouge, vert, bleu |
verify_37_multi_i2c_mem.mos |
Examples.MultiMcu.I2c |
Cible MCU en mode mémoire : scan() = [0x42], registre 4 → LED de B, registres 0-1 relus (2,000 V) |
verify_38_multi_i2c_irq.mos |
Examples.MultiMcu.I2cIrq |
Cible MCU à gestionnaire : ID → b'MCU-B', puis CNT → 2 et 3 (IRQ_READ_REQ servi au même instant) |
Message « Chattering detected ... time >= pre(mcu.nextWakeTime) » dans le journal : information bénigne d'OpenModelica, qui signale 100 événements d'affilée dans un seul pas de sortie. Elle apparaît quand l'intervalle de sortie (Interval) est grand devant la période d'horloge du bus ; les exemples fournis choisissent un intervalle qui l'évite. Ce n'est pas une erreur : la séquence I2C est pilotée par événements, pas par le pas de sortie.
7. Restrictions et suites¶
Un seul bus maître et une seule cible (I2CTarget, adresse 7 bits, pas d'IRQ après la fin du programme) par microcontrôleur ; pas de clock stretching ni d'arbitrage multi-maître ; 1 kHz - 1 MHz ; 256 octets par transaction ; un esclave acquitte toujours ; au plus 4 adresses par composant. Détails dans requirements.md.