2015-04-11 unter besonderer Berücksichtigung des Arduinos bzw. der ATmega und ATTiny-Serie, der Verständlichkeit für Anfänger und einer Tangente über size_t

Der I2C-Bus, Ih-Quadrat-Zeh, Ei-square-Ceh oder auch TWI (Two Wire Interface) ist ein kleiner Steuerbus. Ausgedacht seinerzeit von Philips zur Kommunikation zwischen zentralen Steuereinheiten mit den umgebenden Schnittstellen- und Nebeneinheiten auf einer Platine. Dazu wurde ein vergleichsweise langsames und einfaches Zweidrahtbussystem entwickelt, welches den sonst notwendigen Leitungsaufwand erheblich reduziert und damit die Baugruppenverbindungen übersichtlicher und einfacher gestaltet.

I2C ist nicht wirklich zur schnellen Übertragung von großen Datenmengen über lange Strecken gedacht. Weder groß noch schnell noch weit. Mittlerweile geht das zwar auch, aber damit steigt der Entwicklungsaufwand entsprechend an.
NXP (die haben Philips gekauft) gibt ein Durcheinander von 0,5 Metern bis 100 Metern an, (ich nehme mal an,) dass berücksichtigt die jeweilige Beschaltung und die dabei erreichbaren Geschwindigkeiten (100 kHz, 400 kHz, 3.4 MHz).

Letztendlich ist es eine Frage der Leitungskapazität, die Pull-Up-Widerstände müssen die Leitung wieder rechtzeitig hochziehen können. Mit verdrillten Leitungen oder ggf. mit geschirmten Leitungen (Übersprechen von SCL nach SDA) sollten 20 bis 30 Meter kein Problem sein. Und mit Bus-Puffern wie dem P82B96 kann man problemlos längere Distanzen überbrücken, mit noch mehr Aufwand wird man sicher auch richtig lange Leitungslängen hin bekommen. Ob für sowas I2C die erste Wahl ist? Nicht umsonst gibt es RS-422, RS-485…

Aus Sicht der obigen Microcontroller sowieso eher uninteressant.

Basiskenntnisse oder Was man wissen sollte

  • es gibt einen (oder mehrere, Achtung, Königsklasse!) Master
  • es gibt 112 Slaves (7-Bit-Adressraum minus 16 Adressen für anderen Krams)
  • der Master kontrolliert die Kommunikation (kein Slave kann "einfach so" Daten verschicken)
  • es gibt keine Sicherungsschichten
  • es gibt eine Datenleitung (SDA) und eine Taktleitung (SCL)

Das mit den Sicherungsschichten sollte man im Hinterkopf behalten. In der Praxis muss damit gerechnet werden, dass die Datenübertragung mit Fehlern erfolgt, Datensätze fehlen, Bits kippen oder Daten unvollständig übertragen werden. Konsequenz: Robuste Programmierung, die fehlertolerant an die Sache rangeht. Ggf. Prüfbytes. Weniger als gegeben annehmen und mehr überprüfen. Für Atomkraftwerke ungeeignet.

Aus Arduino-Sicht kommt hinzu:

  • der Master und die Slaves können maximal 32 Bytes verschicken

Schauen wir uns I2C im Arduino mit Beispielen an

Dazu brauchen wir folgende Zutaten:

  • zwei funktionierende, programmierbare Arduino Uno, Micro, Nano etc. mit gleicher Arbeitsspannung (also entweder 5V- oder 3.3V-Typen)
  • ein paar Drähte
  • zwei Widerstände zur Bus-Terminierung (1k..10k)

Der eine Arduino heißt Master, in ihn werden die Master-Beispiele gebrannt. Der andere heißt, na? Richtig. Slave und bekommt die Slave-Beispiele. Daher müssen die zwei seriellen Schnittstellen in der IDE immer richtig eingestellt werden! Fiese Fehlerfalle! Ggf. mit einem Zettel die Schnittstellen an den Arduino kleben.

Mit den Widerständen beginnt das elektrische Durcheinander: Werte zwischen 1k und 10k sind möglich. Letztendlich sind niedrigere Werte tendentiell günstiger, erhöhen aber die Leistungsaufnahme und das Rauschen in die Umgebung. Kurze Leitungen sind weniger problematisch als lange Leitungen (wobei "lang" eine Beschönigung ist: bei einem Meter ist möglicherweise bei fliegendem Aufbau und ungünstiger Kabelführung bereits Schluss). Daten- und Taktleitung können sich gegenseitig beeinflussen…

Die Verdrahtung

Die Verdrahtung zwischen den Arduinos ist übersichtlich:

  • die beiden GND-Leitungen der Arduinos verbinden
  • die beiden A5- (SCL) und A4- (SDA) Leitungen verbinden
  • irgendwo die Widerstände jeweils an die A5- bzw. A4-Leitung und die positive Versorgungsspannung legen (Pull-Up Widerstände bzw. Terminierung)

Erstes Beispiel

Ein Master schaltet im Sekundentakt die Leuchtdiode D13 beim zweiten Arduino, dem Slave, um. Dazu sendet der Master einfach ein beliebiges Byte im Sekundentakt an den Slave. Wenn beim Slave ein Byte ankommt dreht er den Status der Leitung um.

Unser Master erhält keine Busadresse (deshalb ist er der Master). In allen Beispielen wird der Slave die Busadresse 42 haben.

(Hinweis: die Beispiele sind "aus dem Ärmel" programmiert. Wer Übersetzungsfehler findet, immer her damit! lembke@gmail.com)

———Master——————

Select
#include <Wire.h>

void setup()
{
  Wire.begin(); 
}

void loop()
{
  Wire.beginTransmission(42); 
  Wire.write(0);             
  boolean allesgut = Wire.endTransmission();    
  delay(1000);
}

———Master——————

Wie man sieht ist der Master sehr übersichtlich. Im Setup() den Bus initialisieren und in der Loop() jeweils die Übertragung beginnen, senden, beenden, warten, wiederholen.

Man kann nicht parallel zu mehreren Slaves senden. Also für jeden Slave eine .beginTransmission-.endTransmission-Sektion. Die tatsächliche Übertragung findet erst mit dem .endTransmission-Aufruf statt, die Daten werden in einem 32 Byte großen Puffer gespeichert. Über den Rückgabewert von .endTransmission kann kontrolliert werden, ob der Slave irgendwie die Daten angenommen hat. Es ist keine Kontrolle, ob alle Daten fehlerfrei angekommen sind.

Beim Slave sieht es auf den ersten Blick etwas umständlicher aus: es werden ereignisorientierte Methoden, ähm, Funktionen implementiert. Das ist a.) modern und b.) klingt viel schlimmer, als es ist.

———Slave——————

Select
#include <Wire.h>

void setup()
{
  pinMode(13,OUTPUT);
  
  Wire.begin(42); 
  Wire.onReceive(receiveEvent);
}

void loop()
{
  //nix. 
}

void receiveEvent(int anzahl)
{
  while(Wire.available()){
    Wire.read();
  }	

  digitalWrite(13,! digitalRead(13));
}

———Slave——————

Der geniale Pfiff bei der ereignisorientierten Programmierung ist, dass wir uns nicht mehr um den Aufruf der Empfangsroutine kümmern müssen. Die Wire-Umgebung ruft, sobald Daten für uns vorliegen, die Empfangsroutine selbsttätig auf. Das ist mit den Interrupts (attachInterrupt) vergleichbar.

Gut, gucken wir im Detail: Im Setup wird die Methode, ähm, Funktion dem .onReceive-Ereignis zugewiesen, weiter unten wird die Funktion dann deklariert. Wichtig ist, dass der Datentyp des Übergabeparameters passt: hier int. Das kann bei andere Wire-Routinen für andere Prozessoren (z. B. bei TinyWire für Attinys) anders sein.

Wenn Daten ankommen, dann ruft die Wire-Bibliothek einfach die receiveEvent()-Funktion auf, in der die Daten gelesen werden können. Oft wird die übergebene Anzahl ignoriert und einfach so lange gelesen, bis keine Daten mehr verfügbar sind.

Da in diesem Beispiel mit den Daten nix getan wird, werden sie einfach nur gelesen und weggeworfen und entsprechend die LED geschaltet.

Wie geht es weiter?

Mit entsprechendem Aufwand können nun in der receiveEvent()-Funktion die ganze Aufgaben erledigt werden. Bytes lesen, Kommando und Daten rekonstruieren, Aktionen ausführen.

Da lauert das nächste Problem: in den Ereignisroutinen sollen/dürfen keine komplexen Funktionen ausgeführt werden. Komplex ist dabei etwas wischi-waschi. Nichts, was Interrupts benötigt (Seriel.print()!). Nichts, was längere Zeit benötigt. Also knapper Code und die Ausführungszeit kurz halten.

Ideal wäre, wenn man nur entsprechende Stati setzt und die loop() im nächsten Durchgang die Arbeit erledigt.

Unser Slave sähe besser so aus:

———Slave——————

Select
#include <Wire.h>

void setup()
{
  pinMode(13,OUTPUT);
  
  Wire.begin(42); 
  Wire.onReceive(receiveEvent);
}

boolean umschalten = false;

void loop()
{
  if (umschalten) {
    digitalWrite(13,! digitalRead(13));
	umschalten=false;
  }
}

void receiveEvent(int anzahl)
{
  while(Wire.available()){
    Wire.read();
  }	

  umschalten=true;
}

———Slave——————

Ein weiteres, einfaches Sendebeispiel

Unser Client steuert auf geheime Art und Weise die Helligkeit von 10 Leuchtmitteln. Die Leuchten sind durchgezählt 1..10, die Helligkeit geht von 0..1712. Hingucken ergibt: ich muss drei Byte senden: lampennummer und low+high Byte der Helligkeit.

Der Master ist einfach:

———Master——————

Select
#include <Wire.h>

void setup()
{
  Wire.begin(); 
}

void setzelampe(byte nummer; int helligkeit) {
  Wire.beginTransmission(42); 
  Wire.write(nummer);
  Wire.write(helligkeit & 0xff);
  Wire.write(helligkeit >> 8);
  boolean allesgut = Wire.endTransmission();    
}

void loop()
{
  setzelampe(random(1,10),random(0,1712));
  delay(1000);
}

———Master——————

In setzlampe() findet die Arbeit statt: byteweises Senden der Daten. Erst die Lampennummer und dann das niederwertige Byte gefolgt vom höherwertigen Byte.

Hier lauert die nächste Falle: gibt es .write() wirklich nur in der .write(byte)-Variante? Es könnte auch eine überladene, also zweite Variante von .write() geben: .write(int). Und der Übersetzer erkennt am Datentyp der Parameter, welche er nimmt. Würde Byte reingestopft, nähme er die Byte-Variante, würde Int reingestopft, dann die Int-Variante (Und Wire sende die beiden Bytes nacheinander in welcher Reihenfolge?).

Also in der konkret verwendeten Bibliothek nachgucken, wie und wie oft .write() definiert ist. Dazu gucken wir in die Header-Datei, die wir in libraries\Wire\Wire.h finden:

    virtual size_t write(uint8_t);
    virtual size_t write(const uint8_t *, size_t);
	....
    inline size_t write(unsigned long n) { return write((uint8_t)n); }
    inline size_t write(long n) { return write((uint8_t)n); }
    inline size_t write(unsigned int n) { return write((uint8_t)n); }
    inline size_t write(int n) { return write((uint8_t)n); }

Pfff. Was macht das denn? Also. Oben stehen die echten .write()-Methoden. Erkennt man am virtual. Das virtual sagt eigentlich, dass diese Methode beim Vererben dynamisch überschrieben werden kann und ist ganz großer Objektvodoo. Nicht drüber nachdenken. Als ahnungsloser Draufgucker kann man sich merken: die virtuellen Methoden sind oft die Basismethoden.

Anschließend war jemand so freundlich und hat vier Mal write definiert und implementiert (Da! Ein Anweisungsblock! {….}), so dass es mit den vier dort aufgezählten Datentypen unsigned long, long, unsigned int und int aufgerufen werden kann (ohne das es Mecker vom Übersetzer gibt). Und in der Implementation wird die obige .write()-Methode aufgerufen.

Aber halt Stopp! Aufgerufen mit einer Typenwandlung nach uint8_t. uint8_t? U wie unsigned, also Vorzeichenlos. int wie Integer, also ein Ganzzahlentyp. 8 wie acht Bit. Und _t, weil es eine Typendeklaration ist und die C-Leute das dann so machen. Man hätte auch "byte" hinschreiben können, aber das uint8_t ist prozessornäher immer 8 Bit (siehe die size_t-Tangente), also nimmt man das.

Was heißt das nun? Egal womit wir .write() aufrufen, es wird immer nur das niederwertigste Byte übertragen.

Hätte ich keine Lust, über solche Dinge nachzudenken oder in den Quellcode zu gucken, dann wäre

  Wire.write((byte)(helligkeit &amp; 0xff));
  Wire.write((byte)(helligkeit &gt;&gt; 8));

hilfreich gewesen. Da wird erst das Ergebnis ausgerechnet und dann zwangsweise in ein Byte gewandet und somit vom Übersetzer (quasi zwangsweise) immer die byte-grosse Variante ausgewählt.

Aufpasser haben gemerkt, dass noch eine .write()-Deklaration vorhanden ist, die anders aussieht. Dazu später.

Und dem Leser ist das mit den Funktionen und Methoden aufgefallen. Methoden sind Funktionen in Objekten. Sprachlicher Zucker, aber wichtig. Weil wenn ich von Methoden schreibe, dann weiss der Leser, dass da ein Objekt dranhängt. Und bei Funktionen eben nicht.

Nach diesem langen Text wissen wir nun, dass unser setzelampe() wahrscheinlich korrekt ist. helligkeit ist ein Int, das "& 0xff" (und 0xff) setzt die höheren Bits auf Null, die Übersetzer-Objekt-Magie macht daraus ein Byte, es bleibt das untere Byte. In der nächsten Zeile verschiebt das ">>8" den Inhalt um 8 Bit nach rechts, den Rest macht die Übersetzer-Magie wie vorher.

Kommen wir nun zum Slave.

———Slave——————

Select
#include <Wire.h>

void setup()
{
  pinMode(13,OUTPUT);
  
  Wire.begin(42); 
  Wire.onReceive(receiveEvent);
}

void lampenhardwaresteuerung(const byte nummer, const int helligkeit) {
  // hier fehlt die geheimnisvolle Hardwaresteuerung 
}

boolean schalten = false;
byte lampennummer = 0;
int helligkeit = 0;

void loop()
{
  if (schalten) {
    lampenhardwaresteuerung(lampennummer,helligkeit);
	schalten=false;
  }
}

void receiveEvent(int anzahl)
{
  if (anzahl!=3) {              // mehr oder weniger als 3 Bytes?
    while(Wire.available()){
      Wire.read();
    }	
    return;                     // schnell raus hier...
  }
  
  if (Wire.available()){
    lampennummer=Wire.read();
 
    if (Wire.available()){
      helligkeit=Wire.read();

	  if (Wire.available()){
        helligkeit=helligkeit+(Wire.read()<<8);
        schalten=true;
      }	
    }	
  }	
}

———Slave——————

Hier verwende ich wieder das Konzept aus dem letzten Beispiel: in receiveEvent() werden nur die Daten gelesen und ein Status gesetzt. Das eigentliche Umschalten findet in loop() statt.

Betrachten wir das Lesen der Daten: in der Reihenfolge, wie es reingesteckt wird muss es auch wieder gelesen werden: erst die Lampennummer, dann das untere Byte der Helligkeit und dann das um 8 Bit nach links geschobene Byte der Helligkeit.

Und receiveEvent() ist ziemlich umständlich, weil ich wirklich sicherstellen will, dass exakt drei Byte verfügbar sind. Erst gucken, ob was da ist, dann lesen. Statt .available() könnte man auch mit .read() erst in ein Int lesen, ein negativer Wert signalisiert einen Fehler (erst lesen dann kontrollieren). Macht den Quelltext nicht wirklich eleganter.

Robuste Programmierung ist das Ziel. Darum auch der Umstand am Anfang: Wenn mir schon eine andere Anzahl als meine erwarteten drei Bytes angekündigt werden, dann lese ich sie, ignoriere sie und den ganzen Rest.

Das könnte man, wenn man weiss, wie die Wire-Bibliothek intern aufgebaut ist, anders machen. Vielleicht mit weniger Aufwand ohne zu lesen. Damit verliert man allerdings seine Unabhängigkeit: wenn die Wire-Bibliothek sich ändert, intern umstrukturiert wird, die Hardware sich anders verhält, dann stimmen die Annahmen nicht mehr.

Daher lieber etwas umständlicher, in der Hoffnung, das es universeller ist.

Ich bin mir nicht sicher, dass Datenblöcke (Master sendet 32 Byte) immer in einem Block ankommen (Slave sieht 32 Byte im Stück). Also lieber kontrollieren, wenn man sich als Slave in unbekannte Busse einbinden will.

Und wenn der Slave was senden will?

Bisher war es so, dass der Master sendet und der Slave schweigend empfängt. Das ist oft so (Steuerung, z. B. LCD oder Schalter), aber natürlich nicht immer. Uhren-ICs liefern die Uhrzeit zurück, Radio-ICs allerlei Daten, Luftdruck-ICs… sie finden sicher selbst noch einige Beispiele.

Der grundsätzliche Ablauf ist dabei ganz einfach: der Master sendet erst wie in obigen Beispielen einen Datensatz, in dem steht, welche Daten er benötigt. Anschließend fordert der Master vom Slave die entsprechene Datenmenge an.

Also:

  Wire.beginTransmission(42); 
  Wire.write(0);             
  boolean allesgut = Wire.endTransmission();    
  byte antwort = Wire.requestFrom(42,10);

Hier wird erst ein Byte gesendet und dann werden 10 Byte als Anwort angefordert. Und die tatsächlich erhaltene Menge wird gespeichert. Weil: man weiss ja nie, wie viel tatsächlich ankommt.

Wenn der Slave etwas zurück senden soll, kommt oft noch ein zweites Problem hinzu: der Slave muss die Daten erst einmal ermitteln, bevor er sie senden kann. Dazu ein einfaches Beispiel: Der Master ruft bei einem Luftdruck-Slave den Luftdruck ab. Würde er sofort eine Antwort erwarten, dann müsste das Luftdruck-IC ständig den Luftdruck messen. Das ist (unabhängig von diesem Beispiel) oftmals technisch gar nicht möglich und auch gar nicht gewünscht. Zum Beispiel um Energie zu sparen: nur messen, wenn die Messdaten auch gebraucht werden. IC wacht auf, initialisiert den Messprozess, misst, schläft nach Datenabruf wieder ein.

Das ergibt dann gerne solchen Mastercode:

  Wire.beginTransmission(42); 
  Wire.write(0);             
  boolean allesgut = Wire.endTransmission();    
  delay(1000); // Warten, bis der Slave fertig ist
  byte antwort = Wire.requestFrom(42,10);

Insgesamt macht dieser Umstand den Programmablauf im Master nur wenig komplexer. Aber delay() ist nicht wirklich elegant und gilt es zu vermeiden…

Der Slave sendet!

Bauen wir ein entsprechendes Beispiel: der Master fordert den Druck in einer von 7 Gasflaschen an. Jede Gasflasche hat einen Schlauch zu einem gemeinsamen Drucksensor, Ventile steuern das ganze, wir ignorieren die Interna, es soll nur eine schicke Begründung sein.

Der Master soll alle 10 Minuten messen. Und er weiss, dass die Messergebnisse nach spätestens 10 Sekunden vorliegen (weil das steht im Datenblatt des Drucksensors). Der Slave sendet ein Byte zurück, Druck in ganzen Bar oder so.

———Master——————

Select
#include <Wire.h>

void setup()
{
  Serial.begin(57600);
  Wire.begin(); 
}

unsigned long messungsticker = 0;   // alle 10 Minuten eine Messung
unsigned long messticker = 0;       // 10 Sekunden für die Messung
boolean messunglaeuft = false;
byte gasflasche = 0;

void loop()
{
  // Messkommando schicken und Warteschleife steuern
  if ( (!messunglaeuft) && (millis()-messungsticker>600000L) ) {
    Wire.beginTransmission(42); 
	gasflasche=random(1,7);
    Wire.write(gasflasche);
    boolean allesgut = Wire.endTransmission();    
	
	messunglaeuft=true;
	messungsticker=millis();
	messticker=millis();
  }
  
  // Messzeit beendet, Datenübertragung initialisieren
  if ( (messunglaeuft) && (millis()-messticker>10000) ) {
    Wire.requestFrom(42,1);
    messunglaeuft=false;
  }

  // wenn Daten ankommen....  
   while ( Wire.available() )
   { 
     byte druck = Wire.read();
	 
	 // In ganzen Sätzen antworten....
	 Serial.print(F("In Flasche "));
	 Serial.print(gasflasche);
	 Serial.print(F(" besteht ein Druck von "));
	 Serial.print(druck);
	 Serial.print(F(" bar."));
	 Serial.println();
   } 
}

———Master——————

Doch ganz übersichtlich, oder? Drei Blöcke mit jeweils ihren spezifischen Aufgaben: Messung anfordern, Daten anfordern und Ergebnisse ausgeben. Gewartet wird über eine Differenzermittlung mit dem Millisekundenzähler. Die verwendete Methode ist übrigens überlaufsicher.

Richtig schick’ wäre es, wenn man es in eine Funktion verlagern würde, damit die loop() übersichtlich bleibt. Die Arbeit überlasse ich gerne ihnen. Ein Zweizeiler.

Nun zum Slave:

———Slave——————

Select
#include <Wire.h>

void setup()
{
  Wire.begin(42); 
  Wire.onReceive(receiveEvent);
  Wire.onRequest(requestEvent); 
}

byte messergebnis;

void totalkompliziertemessroutine(const byte flasche) {
  // ....
  messergebnis=random(100);
}

boolean messen = false;
byte flasche = 0;

void loop()
{
  if (messen) {
    totalkompliziertemessroutine(flasche);
	messen=false;
  }
}

void requestEvent(void) {
  Wire.write(messergebnis);
}

void receiveEvent(int anzahl)
{
  while (Wire.available()){
    flasche=Wire.read();
  }	

  messen=true;
}

———Slave——————

Das ist fast identisch zum zweiten Slave-Beispiel. Neu ist nur die requestEvent()-Funktion. Die wird, ganz ereignisgesteuert, aufgerufen, wenn der Master die Daten vom Slave haben will. Erst schickt der Master dem Slave die Flaschennummer. Der Slave merkt sich das und startet die Messung. Etwas später fordert der Master die Daten an und im Slave springt die Wire-Bibliothek in die requestEvent()-Funktion. Ich muss nur noch dafür sorgen, dass die richtigen Daten übertragen werden.

Auffällig hier: Der requestEvent() sieht nicht, wie viel Bytes angefordert wurden. Der Slave muss also über den vorangegangenen Befehl wissen, wie viel er liefern soll. In der Praxis ist es anders herum formuliert: der Master sagt dem Slave, was er will und fordert dann n Bytes an, weil er weiss, dass der Slave diese Anzahl liefern wird.

Und: in requestEvent() darf nur ein einziger .write()-Aufruf stehen. Was blöd ist, wenn mehr als ein Byte verschicken werden soll. Doch da war vorhin noch diese andere .write()-Methode in der Header-Datei:

    virtual size_t write(const uint8_t *, size_t);

Was sagt uns das? Zum noch besseren Verständnis zusätzlich die Methode aus der Implementation aus libraries\Wire\Wire.cpp:

    size_t TwoWire::write(const uint8_t *data, size_t quantity)

Aha, da sind dann auch die Variablennamen. Rein geht ein uint8_t. Nein, Moment, ein uint8_t *, also nicht die Daten selbst sondern ein Zeiger auf die Daten. Call by Reference (statt wie so oft Call by Value). Und ein Parameter quantity vom Typ size_t. size_t ist wieder ganz viel Sprachzucker, kurz gesagt, man hätte auch unsigned int/unsigned long/byte oder sowas hinschreiben können, tut man aber nicht, weil das da oben richtiger ist (siehe Tangente zu size_t).

Das, was da oben steht ist ein Standard. Nämlich ein Zeiger auf ein Stück Speicher und die Länge dieses Speichers. Viele Funktionen sehen fast identisch aus: Speicherbereich mit Werten füllen:

  void * memset ( void * ptr, int value, size_t num );

Oder Speicher von da nach dort kopieren:

  void * memcpy ( void * destination, const void * source, size_t num );  

Man bemerke die nachlässige Leerstellenzahl zwischen den Sternen. Einer muss mindestens sein, wo er ist ist egal.

Und das Const? Na, das sagt nur, dass die Variable bzw. der Zeiger in der Funktion nicht verändert wird. Hat den Vorteil, dass der Übersetzer möglicherweise besseren (schneller, kleiner) Code erzeugen kann. Immer eine gute Idee!

Und wie jetzt weiter?

Wie machen wir aus diesen Kenntnissen nun eine Lösung? Ganz einfach: wenn mehr als ein Byte übertragen werden soll, muss der Slave die Daten in ein Feld/Array ablegen und in der requestEvent()-Funktion dann das Feld und dessen Füllstand übergeben.

Am Beispiel unserer Gasflaschen wollen wir das ganze mal angucken. Statt einzeln die Werte abzurufen will ich mit einem einzigen Abruf alle 7 Werte zurückbekommen. Ich habe mir überlegt, die "0" zu senden, wenn das so ist. Warum sehen Sie im darauf folgenden Beispiel.

———Master——————

Select
#include <Wire.h>

void setup()
{
  Serial.begin(57600);
  Wire.begin(); 
}

unsigned long messungsticker = 0;   // alle 10 Minuten eine Messung
unsigned long messticker = 0;       // 10 Sekunden für die Messung
boolean messunglaeuft = false;
uint8_t ergebnis[7];

void loop()
{
  // Messkommando schicken und Warteschleife steuern
  if ( (!messunglaeuft) && (millis()-messungsticker>600000L) ) {
    Wire.beginTransmission(42); 
    Wire.write(0);
    boolean allesgut = Wire.endTransmission();    
	
	messunglaeuft=true;
	messungsticker=millis();
	messticker=millis();
  }
  
  // Messzeit beendet, Datenübertragung initialisieren
  if ( (messunglaeuft) && (millis()-messticker>10000) ) {
    Wire.requestFrom(42,7);
    messunglaeuft=false;
  }

  // wenn Daten ankommen....  
  int index = 0;
  while ( Wire.available() )
  { 
    ergebnis[index]= Wire.read();
    index = index + 1;
	 
    // oder die Hardcore-Variante: ergebnis[index++]= Wire.read();
	// Was aber a.) schlechter lesbar ist und b.) vom Übersetzer sowieso gebaut wird.
  } 
  
  for (;index>0;index=index - 1) {
	 // In ganzen Sätzen antworten....
	 Serial.print(F("In Flasche "));
	 Serial.print(index);
	 Serial.print(F(" besteht ein Druck von "));
	 Serial.print(ergebnis[index]);
	 Serial.print(F(" bar."));
	 Serial.println();
  }
}

———Master——————

Der Slave ist deutlich übersichtlicher:

———Slave——————

Select
#include <Wire.h>

void setup()
{
  Wire.begin(42); 
  Wire.onReceive(receiveEvent);
  Wire.onRequest(requestEvent); 
}

uint8_t messergebnisse[7];

void totalkompliziertemessroutine(void) {
  // ....
  for (int i=0;i<7;i++) {
    messergebnisse[i]=random(100);
  }	
}

boolean messen = false;

void loop()
{
  if (messen) {
    totalkompliziertemessroutine();
	messen=false;
  }
}

void requestEvent(void) {
  Wire.write(messergebnisse,7);
}

void receiveEvent(int anzahl)
{
  while (Wire.available()){
    Wire.read();
  }	

  messen=true;
}

———Slave——————

Neu ist: die Messroutine totalkompliziertemessroutine() legt die Ergebnisse nun in einem Feld ab. Und in der requestEvent()-Funktion wird dieses Feld und die Größe des Inhalts geschrieben.

Übersichtlich, oder? Ja!

Für das letzte Beispiel soll der Slave nun wieder erweitert werden: er soll auf das gesendete Kommando gucken: bei 0 die Werte aller Flaschen, bei 1..7 nur den Wert der jeweiligen Flasche.

———Slave——————

Select
#include <Wire.h>

void setup()
{
  Wire.begin(42); 
  Wire.onReceive(receiveEvent);
  Wire.onRequest(requestEvent); 
}

uint8_t messergebnisse[7];
byte flasche;

void totalkompliziertemessroutine(void) {
  // ....
  if (flasche==0) {
    for (int i=0;i<7;i++) {
      messergebnisse[i]=random(100);
    }	
   } else {
    messergebnisse[0]=random(100);
  }	
}

boolean messen = false;

void loop()
{
  if (messen) {
    totalkompliziertemessroutine();
	messen=false;
  }
}

void requestEvent(void) {
  if (flasche==0) {
    Wire.write(messergebnisse,7);
   } else {
    Wire.write(messergebnisse[0]);
  }   
}

void receiveEvent(int anzahl)
{
  while (Wire.available()){
    flasche = Wire.read();
  }	

  messen=true;
}

———Slave——————

Was passiert hier? Je nach Kommando (wird in "flasche" abgelegt) schreibt die Messroutine 7 Werte in das Feld. Oder nur einen Wert in das erste Feldelement.

Und im requestEvent() muss nur noch koordiniert werden, ob das ganze Feld oder der erste Feldelement gesendet wird.

So sieht die Lösung üblicherweise in einem Slave aus, der Daten zurückliefern soll. Die Routinen zur Ermittlung der Daten legen sie in einem Feld nebst Füllmenge ab. requestEvent() hat dann eine leichte Aufgabe.

  uint8_t buffer[32];
  byte bufferinhalt;
  void requestEvent(void) {
    Wire.write(buffer,bufferinhalt);
  }   

Das ist wirklich die einfachste Sache in einem I2C-Slave. Anstrengender ist es, die Daten byteweise abzulegen und im Master wieder zusammenzubasteln.

Und wie ist es mit dem Master? Siehe vorige beiden Beispiele. Funktionieren beide an unserem letzten Slave.

Damit haben wir fast das Ende dieses Textes erreicht.

Tangente: size_t

Warum

   size_t TwoWire::write(const uint8_t *data, size_t quantity)

und nicht

  unsigned int TwoWire::write(const uint8_t *data, unsigned int quantity)

Großes Ziel, als wehende Fahne vor uns her getragen, ist es immer, Quellcode zu schreiben, der möglichst ohne Änderungen auf allen Prozessorentypen funktioniert. Wegen Fortschritt und so. Der Übersetzer kennt den Prozessortyp und bastelt dann den richigen Kram, der Softwareentwickler muss sich nicht auch noch um diesen Kleinkram kümmern.

Jedoch gibt das den Datentyp int. Und das Chaos. Denn int ist nicht wirklich definiert. Je nach Übersetzer und Hardwareplattform kann es anders aussehen: auf 8-Bit Prozessoren ist ein int oft 16 Bit groß, auf 16-Bit Prozessoren 32 Bit, möglicherweise auch 64 Bit. Im Arduino liefert sizeof(int) eine 2, in meinem Visual Studio (Windows) liefert es 4.

Ergebnis: Erst einmal viel Durcheinander.

Und int geht nicht, weil unbekannt ist, wie groß die Speicherobjekte in einem unbekannten Zielsystem sein können. Wer sagt denn, dass mein 8-Bit-Prozesser mit 16 Bit int nicht 524288 Byte große Speicherblöcke (19 Bit) besitzen kann? Wäre es so, dann könnte ich diese Objekte mit meiner int-Variable nicht vollständig beschreiben.

Und daher gibt es size_t. (Oder eben auch das prozessornahe uint8_t.) Das ist immer so groß, dass damit das Größte im Speicher mögliche Objekt abgezählt werden kann. Wenn ich also in einer Variable die Größe eines Speicherbereichs ablegen will, nehme ich size_t und ich weiß, es passt. Ohne denken.

Und jeder Leser weiß, wenn er size_t liest: "Ach, da meint einer die Größe eines Speicherobjekts."