I2C Bus Datenanalyse mit PulseView am Beispiel des VL53L0X und des MPU-6050 Sensors

Hardware

Zum Testen habe ich einen ESP32 und einen VL53L0X / MPU6050 über die Default I2C GPIO Pins verbunden (D21 und D22).

I2C GPIO Pins des ESP32
VL53L0X Time-of-Flight-Entfernungssensor
MPU6050 Beschleunigungssensor und Gyroskop

Da ich mich hier aber nicht so sehr mit der Hardware beschäftigen möchte werde ich das mal nicht weiter ausführen. Details dazu findet man überall im Internet. Einfach mal nach ESP32 I2C und VL53L0X / MPU6050 suchen.

Software

Als nächstes Habe ich die Adafruit_VL53L0X Bibliothek in der Arduino-IDE installiert (Bibliotheken verwalten -> etc.) und das Adafruit VL43L0X Beispiel geladen (Beispiele -> Adafruit_VL53L0X) und den Sketch auf dem ESP32 installiert.

#include "Adafruit_VL53L0X.h"
Adafruit_VL53L0X lox = Adafruit_VL53L0X();
void setup() {
  Serial.begin(115200);
  // wait until serial port opens for native USB devices
  while (! Serial) {
    delay(1);
  }
  
  Serial.println("Adafruit VL53L0X test");
  if (!lox.begin()) {
    Serial.println(F("Failed to boot VL53L0X"));
    while(1);
  }
  // power 
  Serial.println(F("VL53L0X API Simple Ranging example\n\n")); 
}

void loop() {
  VL53L0X_RangingMeasurementData_t measure;
  Serial.print("Reading a measurement... ");
  lox.rangingTest(&measure, false); // pass in 'true' to get debug data printout!
  if (measure.RangeStatus != 4) {  // phase failures have incorrect data
    Serial.print("Distance (mm): "); Serial.println(measure.RangeMilliMeter);
  } else {
    Serial.println(" out of range ");
  }
  delay(100);
}

PS: Der Sensor hat eigentlich eine Reichweite von 2 Metern, die er aber nur erreicht, wenn man die folgende Zeile noch ergänzt.

lox.configSensor(Adafruit_VL53L0X::VL53L0X_SENSE_LONG_RANGE);

Die Konsole hat dann direkt angefangen, die gemessenen Distanzen auszugeben.

Reading a measurement... Distance (mm): 529
Reading a measurement... Distance (mm): 517
Reading a measurement... Distance (mm): 497
Reading a measurement... Distance (mm): 506
Reading a measurement... Distance (mm): 508

Wozu soll ich mich mit denn dem I2C Bus beschäftigen?

Alles ganz einfach und man hat innerhalb weniger Minuten einen funktionierenden Distanz-Sensor für wenige Euro realisiert.

Normalerweise gibt es keinen Grund, sich mit den Details der I2C Schnittstelle zu beschäftigen. Als „Maker“ inkludiert man einfach Adafruit_VL53L0X.h und erstelle eine Instanz der Klasse Adafruit_VL53L0X. Schon kann man der Methode rangingTest die Distanz auslesen. Das ist so unglaublich einfach, aber das geht nur so lange gut, bis man vor einem Problem steht und nicht weiß, woran es liegt.

Dann kann es sehr hilfreich sein, sich die Schnittstelle mal genauer anzuschauen.

Das Basis Wissen über die I2C Schnittstelle kann unter https://de.wikipedia.org/wiki/I2C nachgelesen werden.

Das Werkzeug

Wie sieht die Kommunikation zwischen dem ESP32 und dem VL43L0X auf dem I2C Bus wirklich aus? Um die Schnittstelle detailliert analysieren zu können, ist ein Logic-Analyzer oder ein vergleichbares Tool unverzichtbar.

USB Logic Analyzer 24MHz 8CH

Der Logic Analyzer den ich verwende ist von AZ-Delivery. Er kostet um die 11€ und ist sein Geld auf jeden Fall wert. Es gibt vergleichbare (identische) Kisten bei Aliexpress, Ebay und Co. die sogar noch ein paar Euro günstiger sind aber ich hatte keine Lust so lange zu warten und habe meinen hier in Deutschland bestellt.

AZ-Delivery hat auch eine sehr gute Einführung auf der Webseite, die man sich auf jeden Fall mal ansehen sollte.

https://www.az-delivery.de/blogs/azdelivery-blog-fur-arduino-und-raspberry-pi/logic-analyzer-teil-1-i2c-signale-sichtbar-machen

https://www.az-delivery.de/blogs/azdelivery-blog-fur-arduino-und-raspberry-pi/logic-analyzer-teil-2-i2c-signale-sichtbar-machen

Mit Sigrok PulseView kann man die Signale dann am PC anzeigen und weiterverarbeiten. Dank der vielen verfügbaren Decoder muss man sich nicht mal die Arbeit machen, die Pegel High/Low selbst in Bits und Bytes zu zerlegen.

Die Liste der verfügbaren Decoder ist sehr lang und ich habe bisher noch immer den Dekoder gefunden, den ich gesucht habe. Liste der verfügbaren Sigrok PulseView Decoder.  https://sigrok.org/wiki/Protocol_decoders

Der I2C Decoder (https://sigrok.org/wiki/Protocol_decoder:I2c) kann einfach an die beiden Leitungen (SDA, SCL) gehängt werden und schon geht es los.

Sigrok PulseView I2C Block

Zur Analyse zeichnet man ein paar Sekunden der Kommunikation auf und sieht sich dann die dekodierten Signale an. Am besten nimmt man sich noch das Datasheet dazu (https://www.st.com/resource/en/datasheet/vl53l0x.pdf) und vergleicht die Interface-Spezifikation mit den aufgezeichneten Daten.

Behind the Scenes

In meinem konkreten Beispiel ist ein Block zum Auslesen der Distanz ca. 40ms lang und enthält eine Unmenge an Daten.

Sigrok PulseView I2C Block

Vergrößert man das Bild etwas, kann man erkennen, dass es unterschiedliche Datenblöcke gibt. „Data read“ und „Data write“. Bei den „Data write“ Blöcken handelt es sich um die Daten, die der Master (Mikrokontroller) auf den Bus schreibt und bei den „Data read“ handelt es sich um die Daten vom Slave. (Sorry, aber die Schnittstelle ist von 1982 und damals hat sich noch niemand um rassistisch konnotierte Begriffe gekümmert)

Der erste Block in der Kommunikation zwischen VL53L0X und ESP32 hat folgenden Aubau:

Die vom Sensor empfangenen Daten werden in ein Register geschrieben. Jedes Datenbyte wird mit einem ACK quittiert. Die Daten werden dann in dem internen Register gespeichert, das durch den aktuellen Index adressiert ist.

Das ganze kann man jetzt Byte für Byte durchgehen und Block für Block untersuchen. Da das aber immer noch viel zu aufwändig ist, kann man die Daten einfach speichern und auf der Konsole damit weiter arbeiten.

Dazu kann man unter https://sigrok.org/wiki/Downloads#Binaries_and_distribution_packages das Programm sigrok-cli herunterladen. Das Programm steht unter der GNU GENERAL PUBLIC LICENSE und kann kostenlos verwendet werden.

Nach der Installation kann man eine Bash oder eine Powershell öffnen und das Kommando sigrok-cli aufrufen.

$ ./sigrok-cli.exe
Usage:
  sigrok-cli.exe [OPTION...]

Help Options:
  -h, --help                             Show help options

Application Options:
  -V, --version                          Show version
  -L, --list-supported                   List supported devices/modules/decoders
  --list-supported-wiki                  List supported decoders (MediaWiki)
  -l, --loglevel                         Set loglevel (5 is most verbose)
  -d, --driver                           The driver to use
  -c, --config                           Specify device configuration options
  -i, --input-file                       Load input from file
  -I, --input-format                     Input format
  -o, --output-file                      Save output to file
  -O, --output-format                    Output format
  -T, --transform-module                 Transform module
  -C, --channels                         Channels to use
  -g, --channel-group                    Channel groups
  -t, --triggers                         Trigger configuration
  -w, --wait-trigger                     Wait for trigger
  -P, --protocol-decoders                Protocol decoders to run
  -A, --protocol-decoder-annotations     Protocol decoder annotation(s) to show
  -M, --protocol-decoder-meta            Protocol decoder meta output to show
  -B, --protocol-decoder-binary          Protocol decoder binary output to show
  --protocol-decoder-samplenum           Show sample numbers in decoder output
  --protocol-decoder-jsontrace           Output in Google Trace Event format (JSON)
  --scan                                 Scan for devices
  -D, --dont-scan                        Don't auto-scan (use -d spec only)
  --show                                 Show device/format/decoder details
  --time                                 How long to sample (ms)
  --samples                              Number of samples to acquire
  --frames                               Number of frames to acquire
  --continuous                           Sample continuously
  --get                                  Get device options only
  --set                                  Set device options only
  --list-serial                          List available serial/HID/BT/BLE ports

Example use, typical options:
  -d <driver> --scan
  -d <driver> { --samples N | --frames N | --time T | --continuous }
  { -d <driver> | -I <format> | -O <format> | -P <decoder> } --show
  See the manpage or the wiki for more details.
  Note: --samples/--frames/--time/--continuous is required for acquisition.

Mit dem folgenden Befehl kann man nun die gespeicherten Daten (SCL=D1 SDA=D0) auf der Konsole anzeigen.

$ ./sigrok-cli -i data/vl53l0x-i2c-data.sr -P i2c:scl=D1:sda=D0:address_format=unshifted -A i2c=address-read:address-write:data-read:data-write

i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 80
i2c-1: Data write: 01
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: FF
i2c-1: Data write: 01
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 00
i2c-1: Data write: 00
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 91
i2c-1: Data write: 3C
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 00
i2c-1: Data write: 01
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: FF
i2c-1: Data write: 00
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 80
i2c-1: Data write: 00
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 00
i2c-1: Data write: 01
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 00
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 00
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 44
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 14
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 31
i2c-1: Data read: 05
i2c-1: Data read: B6
i2c-1: Data read: 04
i2c-1: Data read: 00
i2c-1: Data read: B0
i2c-1: Data read: 00
i2c-1: Data read: 58
i2c-1: Data read: 00
i2c-1: Data read: 20
i2c-1: Data read: 1F
i2c-1: Data read: FE
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: FF
i2c-1: Data write: 01
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: B6
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 09
i2c-1: Data read: C9
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: FF
i2c-1: Data write: 00
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 0B
i2c-1: Data write: 01
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 0B
i2c-1: Data write: 00
i2c-1: Write
i2c-1: Address write: 52
i2c-1: Data write: 13
i2c-1: Read
i2c-1: Address read: 53
i2c-1: Data read: 40

Dieser ganze Datenblock wird zwischen dem ESP32 und dem VL53L0X innerhalb von 40ms ausgetauscht und damit der Befehl rangingTest der Adafruit_VL53L0X Bibliothek ausgeführt. Zum Glück funktioniert die Schnittstelle und ich habe keinen Grund, die Daten Byte für Byte mit der Spezifikation abgleichen. (Und im Vergleich zur Initialisierungs-Routine ist dieser Datenblock noch verhältnismäßig klein)

Doch nicht alle I2C Schnittstellen sind so komplex. Z.B. die Schnittstelle des MPU-6050 ist wesentlich einfacher zu verstehen.

Bei dem MPU-6050 handelt es sich um einen 6-Achsen Sensor (3-Achsen-Gyroskop, 3-Achsen-Beschleunigung) der z.B. auch im „Arduino Programmable Open-Source Flight Controller and Mini Drone using SMT32 and MPU6050 for Drone Enthusiasts“ (https://circuitdigest.com/news/arduino-programmable-open-source-flight-controller-and-mini-drone-using-smt32-and-mpu6050-drone-enthusiasts) verbaut ist.

Schaut man sich die Daten auf der Schnittstelle an, die zum Auslesen der 6 Werte nötig sind, stellt man schnell fest, da ist nicht viel Overhead drin.

Exakt 2565 µs sind nötig, um einmal die Werte der Temperatur, Gyroskop und Beschleunigungssensoren auszulesen.

Ein sehr schönes Video in dem das Prinzip der Sensoren erklärt wird ist das folgende (Etwas Off-Topic aber egal)

Auf der Konsole sieht dass dann wie folgt aus:

$ ./sigrok-cli -i data/mpu-6050.sr -P i2c:scl=D1:sda=D0:address_format=shifted -A i2c=address-read:address-write:data-read:data-write

Schritt 1:
Adresse 0x38 des MPU6050 (ID 0x68) senden. 38 ist hier „base address for sensor data reads“. Es wird mit einer 14 Byte großen Sequenz geantwortet.

i2c-1: Write
i2c-1: Address write: 68
i2c-1: Data write: 3B

Schritt 2:
Schritt 2: Einlesen der 14 Bytes der Sensordaten. Die Daten sind wie folgt sortiert:

ACCEL_XOUT_H
ACCEL_XOUT_L
ACCEL_YOUT_H
ACCEL_YOUT_L
ACCEL_ZOUT_H
ACCEL_ZOUT_L
TEMP_OUT_H R
TEMP_OUT_L R
GYRO_XOUT_H
GYRO_XOUT_L
GYRO_YOUT_H
GYRO_YOUT_L
GYRO_ZOUT_H
GYRO_ZOUT_L
Quelle: https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Register-Map1.pdf

i2c-1: Read
i2c-1: Address read: 68
i2c-1: Data read: 08
i2c-1: Data read: 53
i2c-1: Data read: 00
i2c-1: Data read: 05
i2c-1: Data read: FE
i2c-1: Data read: 7F
i2c-1: Data read: ED
i2c-1: Data read: 07
i2c-1: Data read: 00
i2c-1: Data read: 0C
i2c-1: Data read: FF
i2c-1: Data read: EC
i2c-1: Data read: FF
i2c-1: Data read: F1

Schritt 3:
0x1C senden. Adresse 1C bedeutet: „Accelerometer specific configration register“

i2c-1: Write
i2c-1: Address write: 68
i2c-1: Data write: 1C

Schritt 4:
Daten der Adresse 0x1c werden zurückgegeben. 0x18 bedeutet hierbei:

i2c-1: Read
i2c-1: Address read: 68
i2c-1: Data read: 18

Das Gleiche wird nun noch einmal für das Gyrometer durchgeführt. Adresse 0x18 = „Gyro specfic configuration register“

i2c-1: Write
i2c-1: Address write: 68
i2c-1: Data write: 1B

i2c-1: Read
i2c-1: Address read: 68
i2c-1: Data read: 18

Nach ca. 2,5 Millisekunden sind alle Daten ausgelesen. Sowohl die Sensoren-Daten als auch die Skalierungsfaktoren (+/- 2g, +/- 4g, +/- 8g, +/- 16g und  +/- 250, +/- 500, +/- 1000 oder 2000°/s) der Konfiguration. Im Sourcecode der Adafruit_MPU6050 Bibliothek sieht dass dann wie folgt aus:

void Adafruit_MPU6050::_read(void) {
  // get raw readings
  Adafruit_BusIO_Register data_reg =
      Adafruit_BusIO_Register(i2c_dev, MPU6050_ACCEL_OUT, 14);

  uint8_t buffer[14];
  data_reg.read(buffer, 14);

  rawAccX = buffer[0] << 8 | buffer[1];
  rawAccY = buffer[2] << 8 | buffer[3];
  rawAccZ = buffer[4] << 8 | buffer[5];

  rawTemp = buffer[6] << 8 | buffer[7];

  rawGyroX = buffer[8] << 8 | buffer[9];
  rawGyroY = buffer[10] << 8 | buffer[11];
  rawGyroZ = buffer[12] << 8 | buffer[13];

  temperature = (rawTemp / 340.0) + 36.53;

  mpu6050_accel_range_t accel_range = getAccelerometerRange();

  float accel_scale = 1;
  if (accel_range == MPU6050_RANGE_16_G)
    accel_scale = 2048;
  if (accel_range == MPU6050_RANGE_8_G)
    accel_scale = 4096;
  if (accel_range == MPU6050_RANGE_4_G)
    accel_scale = 8192;
  if (accel_range == MPU6050_RANGE_2_G)
    accel_scale = 16384;

  // setup range dependant scaling
  accX = ((float)rawAccX) / accel_scale;
  accY = ((float)rawAccY) / accel_scale;
  accZ = ((float)rawAccZ) / accel_scale;

  mpu6050_gyro_range_t gyro_range = getGyroRange();

  float gyro_scale = 1;
  if (gyro_range == MPU6050_RANGE_250_DEG)
    gyro_scale = 131;
  if (gyro_range == MPU6050_RANGE_500_DEG)
    gyro_scale = 65.5;
  if (gyro_range == MPU6050_RANGE_1000_DEG)
    gyro_scale = 32.8;
  if (gyro_range == MPU6050_RANGE_2000_DEG)
    gyro_scale = 16.4;

  gyroX = ((float)rawGyroX) / gyro_scale;
  gyroY = ((float)rawGyroY) / gyro_scale;
  gyroZ = ((float)rawGyroZ) / gyro_scale;
}

Alles in allem ist es schon erstaunlich, wie viel Logik in einem 2 € Sensor steckt und wie viel Programmcode (z.B. https://github.com/adafruit/Adafruit_VL53L0X/ & https://github.com/adafruit/Adafruit_MPU6050) hier kostenlos zur Verfügung gestellt wird. Ohne dieses Engagement würde die Maker-Szene heute wohl ganz anders aussehen – Vielen Dank dafür

Fazit

Die I2C (Inter-Integrated Circuit) Schnittstelle ist heute in vielen digitalen ICs ein Standard. Es gibt zwar einige Limitierungen in Bezug auf Sicherheit, Geschwindigkeit und Stabilität in störanfälligen Umgebungen, aber er ist aus der IoT Welt eigentlich nicht mehr wegzudenken und hat sich seit 40 Jahren in Millionen (vermutlich sogar Milliarden) ICs  bewährt da er unglaublich flexibel einsetzbar ist und bis zu 128 Sensoren (oder andere ICs) mit nur 2 Leitungen (SCL und SDA) miteinander verbinden kann. Darüber hinaus kann man die Signale sehr einfach auslesen und analysieren. Ich bin ein I2C Bus Fanboy