Bibliotheken und Klassen erstellen – Teil II

Über den Beitrag

In meinem letzten Beitrag habe ich die Grundlagen zum Erstellen von Bibliotheken und Klassen erklärt. In dieser Fortsetzung möchte ich anhand des Beschleunigungssensors und Gyroskops MPU6050 ganz konkret zeigen, wie ihr eine Arduino Bibliothek für ein zu steuerndes Bauteil schreibt. Natürlich hat jedes Bauteil individuelle Eigenschaften und entsprechend unterschiedlich sind die zu entwickelnden Bibliotheken. Auf bestimmte Fragen, Hürden und Stolpersteine werdet ihr aber unabhängig vom konkreten Bauteil häufig stoßen und diese versuche ich hier abzudecken. Insbesondere wenn ihr eigene Bibliotheken für Sensoren schreibt, dürftet ihr einiges aus diesem Beitrag übertragen können.

Über den MPU6050 habe ich einen ausführlichen, separaten Beitrag geschrieben (Link). Hier gehe ich nur auf die Aspekte ein, die für das Thema des Beitrages relevant sind.

Noch ein wichtiger Hinweis: Ich habe mich bei meinen Bibliotheken auf GitHub an vieles, das ich hier predige, selbst nicht gehalten!  Das eine oder andere unterliegt kontinuierlicher Verbesserung, bei einigen Punkten müsste ich aber die Abwärtskompatibilität aufgeben und das möchte ich nicht.

Vorbereitungen

Ihr braucht euch nicht unbedingt einen MPU6050 zuzulegen, um diesen Beitrag nachzuvollziehen. Aber es ist sicherlich spannender und lehrreicher, wenn ihr den hier vorgestellten Code ausprobieren und variieren könnt. MPU6050 Module gibt es für wenige Euro in Online-Shops.

Für den Beitrag habe ich die Bibliothek MPU6050_JFL geschrieben. Dabei steht „JFL“ für „just for learning“, denn ich möchte nicht den Eindruck erwecken, dass es sich dabei um eine vollständige Bibliothek handelt (obgleich sie natürlich funktioniert!).

Ihr ladet MPU6050_JFL, einschließlich der Beispiele, direkt hier von GitHub herunter. Wie die manuelle Installation funktioniert, habe ich im letzten Beitrag beschrieben. Über die Arduino Bibliotheksverwaltung findet ihr MPU6050_JFL nicht. Es wäre nicht angemessen, die Arduino Bibliothekssammlung um ein solches Trainingsobjekt zu erweitern.

Ein MPU6050 Modul - unser Anschauungsobjekt zum Schreiben von Bibliotheken
Ein MPU6050 Modul – unser Anschauungsobjekt

Der Anschluss an den Arduino oder einen anderen Mikrocontroller ist einfach. GND an GND, VCC an 5 Volt (die meisten Module haben einen Spannungsregler), SDA an SDA und SCL an SCL. Ob ihr noch Pull-Up Widerstände oder Level-Shifter braucht, hängt vom Modul ab. Über ADO steuert ihr die I2C-Adresse. Ist ADO unverbunden oder an GND, dann ist die Adresse 0x68. Zieht ihr ADO auf HIGH-Niveau, dann ist die Adresse 0x69. Diese Angaben gelten jedenfalls für die von mir genutzten Module.

Bibliotheken schreiben: Beispielschaltung MPU6050 am Arduino Uno
Minimalschaltung für den MPU6050 am Arduino Uno

Das Studium des Datenblattes

Am Anfang der Entwicklung einer Bibliothek für ein elektronisches Bauteil steht die Lektüre des Datenblattes. Datenblätter sind im Allgemeinen nicht nach didaktischen Gesichtspunkten verfasst und deshalb oft schwere Kost. Mit der Zeit bekommt man aber Übung darin, sie zu lesen. Nach meiner Erfahrung sollte man sich beim ersten Durchgang nicht lange an Abschnitten aufhalten, die man nicht versteht. Oft klären sich die Dinge im weiteren Verlauf oder beim zweiten Lesen.

Für den MPU6050 gibt es eine Produktspezifikation von 52 Seiten und eine Registerübersicht von noch einmal 46 Seiten. Die gute Nachricht ist, dass ihr diese Dokumente zum Verständnis dieses Beitrages nicht lesen müsst! Hineinschauen solltet ihr trotzdem einmal.

Die Register des MPU6050

Was sind Register?

Register sind besondere Speicherbereiche, in die ihr Daten schreiben könnt, oder ihr lest Daten von dort aus. Register haben einen Namen und eine interne Adresse. Bauteile, die über Register gesteuert werden, kann man sich wie ein Schaltpult aus dem letzten Jahrhundert vorstellen. In einigen Registern werden Funktionen oder Eigenschaften der Bauteile aktiviert oder deaktiviert. Sie sind die Schalter. In anderen Registern lassen sich Parameter regulieren. Damit sind es sozusagen die Drehknöpfe oder Schieber. Wieder andere Register enthalten Messwerte oder andere Information und stellen damit Anzeigen dar. Im Wesentlichen geht es in den Bibliotheken also darum, dieses Schaltpult zu beherrschen und aus den kryptischen Registeranweisungen verständliche Funktionen zu machen.

Berücksichtigte Register

Der MPU6050 besitzt über einhundert Register. Wir betrachten davon nur einen kleinen Teil:

Bibliotheken schreiben: Die in MPU6050_JFL berücksichtigten Register
Die in MPU6050_JFL berücksichtigten Register

In den Registern GYRO_CONFIG und ACCEL_CONFIG befinden sich die Bits FS_SEL (Full Scale Select), mit denen die Messbereiche für den Beschleunigungssensor und das Gyroskop eingestellt werden.

Die Beschleunigungs- und Rotationsmesswerte werden für die x-, y- und z-Achse getrennt ermittelt. Sie umfassen jeweils 16 Bit und werden deshalb auf je zwei Register aufgeteilt, z. B. ACCEL_XOUT_H und ACCEL_XOUT_L für die Beschleunigung der x-Achse. Da die Messwerte positiv oder negativ sein können, erstreckt sich der Bereich jeweils von -215 bis + 215 (genau genommen: -215 bis 215-1).

In den Messwerteregistern finden sich die Rohdaten. Sie müssen unter Berücksichtigung des aktuellen Messbereiches in Beschleunigungswerte in g oder Rotationswerte in dps (degree per second = Grad pro Sekunde) umgerechnet werden. Anders ausgedrückt: Wenn ihr einen Wertebereich von +/- 2 g eingestellt habt, bedeutet der Rohwert 32767 (= 215-1) eine Beschleunigung von + 2 g. Ist euer Messbereich +/- 4 g, so bedeutet derselbe Rohwert eine Beschleunigung von + 4 g.

\text{Beschleunigung [g]} = \frac{\text{Rohwert}\cdot \text{Messbereich\,\text[g]}}{32767}

Der MPU6050 besitzt zusätzlich einen Temperatursensor. Den Rohwert gibt er als 16 Bit Zahl aus. Die Formel für die Umrechnung in die Temperatur in Grad Celsius lautet (siehe Datenblatt):

T\;[°\text{C}] = \frac{\text{Rohwert}}{340}+36.53

Das Register POWER_MGMT_1 (Power Management 1) enthält zwei Bits, die wir in unserer Bibliothek berücksichtigen. Setzt ihr das Bit DEV_RESET (Device Reset), dann löst der MPU6050 einen Reset aus. Dabei werden alle Register auf die Standardeinstellungen zurückgesetzt. DEV_RESET wird im Zuge des Resets gelöscht. Ist das Bit SLEEP gesetzt, dann befindet sich der MPU6050 im Schlafmodus. Ihr weckt ihn, indem ihr das Bit löscht.

Zu guter Letzt betrachten wir noch das WHO_AM_I Register. Es ist unveränderbar und enthält den Wert 0x68. Damit überprüft ihr, ob es sich bei dem IC auf eurem Modul wirklich um einen MPU6050 handelt.

Erster Schritt: Rumpf und Kommunikation

Viele Wege führen nach Rom! Und so gibt es natürlich auch verschiedene Strategien, wie ihr Bibliotheken oder Klassen erstellt. Es gibt den „Schulweg“, bei dem ihr erst einmal ein Blatt Papier nehmt und in Kästchen die Methoden und Funktionen aufschreibt und plant. Ich persönlich bin dafür zu ungeduldig und versuche möglichst schnell zu einem ersten Ergebnis zu kommen, um mein Belohnungszentrum zu stimulieren!

Ein schönes erstes Erfolgserlebnis wäre zum Beispiel, das WHO_AM_I Register auslesen zu können. Da es sich bei dem MPU6050 um ein I2C Bauteil handelt, braucht ihr eine entsprechende Wire-Lesefunktion. Falls ihr euch mit den Wire-Funktionen nicht auskennt, schaut hier.

So könnte eine Minimal-Headerdatei aussehen:

#include "Arduino.h"
#include "Wire.h"

#ifndef MPU6050_JFL_H_
#define MPU6050_JFL_H_

constexpr uint8_t MPU6050_WHO_AM_I{0x75}; 
// alternative:
// #define MPU6050_WHO_AM_I 0x75

class MPU6050_JFL
{
    public:
        MPU6050_JFL(const uint8_t addr)  
        : i2cAddress{addr} {}
    
        uint8_t whoAmI();
           
    private:
        uint8_t i2cAddress;
        uint8_t readRegister(uint8_t reg);
};

#endif

Das wäre die Quelldatei dazu:

#include <MPU6050_JFL.h>

uint8_t MPU6050_JFL::whoAmI(){
    return readRegister(MPU6050_WHO_AM_I);
}

uint8_t MPU6050_JFL::readRegister(uint8_t reg){
    uint8_t data; 
    Wire.beginTransmission(i2cAddress);         
    Wire.write(reg);                    
    Wire.endTransmission(false);           
    Wire.requestFrom(i2cAddress, (uint8_t)1);  
    data = Wire.read();                      
    return data;                             
}

Und mit diesem Sketch würdet ihr dann das WHO_AM_I Register lesen:

#include <MPU6050_JFL.h>
#include <Wire.h>
#define I2C_ADDRESS 0x68

MPU6050_JFL myMPU = MPU6050_JFL(I2C_ADDRESS);

void setup() {
  Wire.begin();
  Serial.begin(9600);
  Serial.print("Who Am I: 0x");
  Serial.println(myMPU.whoAmI(),HEX);
}

void loop(){}

Für ein SPI Bauteil oder eines, das über UART kommuniziert, würde ich ähnlich vorgehen. Für I2C Bauteile könnt ihr die Lesefunktion readRegister() in vielen Fällen so übernehmen. Es gibt aber auch Ausnahmen. Deswegen schaut in das Datenblatt eures Bauteils.

Noch zwei Anmerkungen zu der Minimalbibliothek:

  1. Die I2C Adresse übergebe ich in der Regel dem Konstruktor und verwende sie in der Klasse als globale Variable. Globale Variablen sollte man so wenig wie möglich verwenden. Ihr könnt die I2C Adresse als globale Variable einsparen, indem ihr sie bei jedem Funktionsaufruf erneut übergebt. Das ist eine Frage von Bequemlichkeit und Komfort vs. Effektivität.
  2. Viele Autoren von Bibliotheken verwenden für Definitionen #define xxx yyy (früher habe ich das auch getan!). Sicherer und eindeutiger ist jedoch constexpr datatype xxx{yyy}.

Wenn ihr Vorkenntnisse in Bibliotheken und Klassen habt, dann sollte die Minimalbibliothek einfach nachvollziehbar sein. Bei Schwierigkeiten schaut vielleicht noch einmal in meinen letzten Beitrag.

Und wie geht es weiter? Im nächsten Schritt habe ich eine Schreibfunktion für Register (writeRegister()) eingefügt und getestet. Und von diesem „Brückenkopf“ aus ließ sich das Bauteil dann schrittweise „erobern“. Aber anstatt jeden einzelnen Evolutionsschritt durchzugehen, machen wir einen Sprung zum Resultat.

Die Bibliothek bzw. Klasse MPU6050_JFL – Überblick

Die Bibliothek MPU6050_JFL besteht aus einer gleichnamigen Headerdatei („.h“) und einer Quelldatei („.cpp“). Sie enthält nur eine einzige Klasse, die ebenfalls denselben Namen trägt. Ich zeige die Bibliotheksdateien hier in voller Länge. Die Besprechung folgt in den nächsten Kapiteln.

Dies ist die Headerdatei:

#ifndef MPU6050_JFL_H_
#define MPU6050_JFL_H_

enum accel_range{
    MPU6050_ACCEL_RANGE_2G = 0,
    MPU6050_ACCEL_RANGE_4G,
    MPU6050_ACCEL_RANGE_8G,
    MPU6050_ACCEL_RANGE_16G
};

enum class GyroRange : uint8_t{
    MPU6050_250DPS  = 0x00,
    MPU6050_500DPS  = 0x08,
    MPU6050_1000DPS = 0x10,
    MPU6050_2000DPS = 0x18
};

struct xyzFloat {
    float x;
    float y;
    float z;
};

class MPU6050_JFL
{
    public:
        static constexpr uint8_t MPU6050_GYRO_CONFIG       {0x1B};
        static constexpr uint8_t MPU6050_ACCEL_CONFIG      {0x1C};
        static constexpr uint8_t MPU6050_ACCEL_XOUT_H      {0x3B};
        static constexpr uint8_t MPU6050_ACCEL_XOUT_L      {0x3C};
        static constexpr uint8_t MPU6050_ACCEL_YOUT_H      {0x3D};
        static constexpr uint8_t MPU6050_ACCEL_YOUT_L      {0x3E};
        static constexpr uint8_t MPU6050_ACCEL_ZOUT_H      {0x3F};
        static constexpr uint8_t MPU6050_ACCEL_ZOUT_L      {0x40};
        static constexpr uint8_t MPU6050_TEMP_OUT_H        {0x41};
        static constexpr uint8_t MPU6050_TEMP_OUT_L        {0x42};
        static constexpr uint8_t MPU6050_GYRO_XOUT_H       {0x43};
        static constexpr uint8_t MPU6050_GYRO_XOUT_L       {0x44};
        static constexpr uint8_t MPU6050_GYRO_YOUT_H       {0x45};
        static constexpr uint8_t MPU6050_GYRO_YOUT_L       {0x46};
        static constexpr uint8_t MPU6050_GYRO_ZOUT_H       {0x47};
        static constexpr uint8_t MPU6050_GYRO_ZOUT_L       {0x48};
        static constexpr uint8_t MPU6050_PWR_MGMT_1        {0x6B}; // Device defaults to the SLEEP mode
        static constexpr uint8_t MPU6050_WHO_AM_I          {0x75}; // Should return 0x68
        static constexpr uint8_t MPU6050_ACCEL_RANGE_MASK  {0x18}; // = 0b00011000
        static constexpr uint8_t MPU6050_GYRO_RANGE_MASK   {0x18}; // = 0b00011000
        static constexpr uint8_t MPU6050_DEVICE_RESET      {0x80};
        static constexpr uint8_t MPU6050_DEVICE_SLEEP      {0x40};
        
        MPU6050_JFL(const uint8_t addr = 0x68)
        : i2cAddress{addr} {/* empty */}   
            
        bool init();
        bool reset();
        void sleep(bool sl);
        uint8_t whoAmI();
        void setAccelRange(accel_range range);
        uint8_t getAccelRange();
        void setGyroRange(GyroRange range);
        float getOnlyTemperature();
        xyzFloat getAccelerationData();
        void getGyroscopeData(xyzFloat *gyro);
        void update();
        xyzFloat getGyroscopeDataFromAllRawData();
            
    protected:
        uint8_t i2cAddress;
        float accelRangeFactor;
        float gyroRangeFactor;
        uint8_t allRawData[14];
        uint8_t writeRegister(uint8_t reg, uint8_t regValue);
        uint8_t readRegister(uint8_t reg);
        uint16_t read2Registers(uint8_t reg);
        void readMultipleRegisters(uint8_t reg, uint8_t count, uint8_t *buf); 
};

#endif

 

Und hier die Quelldatei:

#include <MPU6050_JFL.h>

bool MPU6050_JFL::init(){
    //Wire.setWireTimeout();
    accelRangeFactor = 1.0;
    gyroRangeFactor = 1.0; 
    bool connected = !reset();
    delay(100); // MPU6050 needs 100 ms to reset
    sleep(false); // disable sleep
    delay(100); // give the device some time to wake up!
    return connected;
}

bool MPU6050_JFL::reset(){
    return writeRegister(MPU6050_PWR_MGMT_1, MPU6050_DEVICE_RESET);
}

void MPU6050_JFL::sleep(bool sl){
    uint8_t regVal = readRegister(MPU6050_PWR_MGMT_1);
    if(sl){
        regVal |= MPU6050_DEVICE_SLEEP;
    }
    else{
        regVal &= ~MPU6050_DEVICE_SLEEP;
    }
    writeRegister(MPU6050_PWR_MGMT_1, regVal);
}   

uint8_t MPU6050_JFL::whoAmI(){
    return readRegister(MPU6050_WHO_AM_I);
}

void MPU6050_JFL::setAccelRange(accel_range range){
    uint8_t regVal = readRegister(MPU6050_ACCEL_CONFIG);
    switch(range){
        case MPU6050_ACCEL_RANGE_2G:  accelRangeFactor = 2.0;  break;
        case MPU6050_ACCEL_RANGE_4G:  accelRangeFactor = 4.0;  break;
        case MPU6050_ACCEL_RANGE_8G:  accelRangeFactor = 8.0;  break;
        case MPU6050_ACCEL_RANGE_16G: accelRangeFactor = 16.0;  break;
    }
    regVal &= ~MPU6050_ACCEL_RANGE_MASK;
    regVal |= range<<3;
    writeRegister(MPU6050_ACCEL_CONFIG, regVal);
}

void MPU6050_JFL::setGyroRange(GyroRange range){
    uint8_t regVal = readRegister(MPU6050_ACCEL_CONFIG);
    switch(range){
        case GyroRange::MPU6050_250DPS:  gyroRangeFactor = 250.0;  break;
        case GyroRange::MPU6050_500DPS:  gyroRangeFactor = 500.0;  break;
        case GyroRange::MPU6050_1000DPS: gyroRangeFactor = 1000.0;  break;
        case GyroRange::MPU6050_2000DPS: gyroRangeFactor = 2000.0;  break;  
    }
    regVal &= ~MPU6050_GYRO_RANGE_MASK;
    regVal |= static_cast<uint8_t>(range);
    writeRegister(MPU6050_GYRO_CONFIG, regVal);
}

uint8_t MPU6050_JFL::getAccelRange(){
    uint8_t regVal = readRegister(MPU6050_ACCEL_CONFIG);
    regVal = (regVal >> 3) & 0x03;
    return regVal;
}

float MPU6050_JFL::getOnlyTemperature(){
    uint16_t rawTemp = read2Registers(MPU6050_TEMP_OUT_H);
    float temp = (static_cast<int16_t>(rawTemp))/340.0 + 36.53; // see RegMap page 30;
    return temp;
}

xyzFloat MPU6050_JFL::getAccelerationData(){
    uint8_t rawData[6]; 
    xyzFloat accel;
    readMultipleRegisters(MPU6050_ACCEL_XOUT_H, 6, rawData);
    accel.x = (static_cast<int16_t>((rawData[0] << 8) | rawData[1]))/32768.0 * accelRangeFactor;
    accel.y = (static_cast<int16_t>((rawData[2] << 8) | rawData[3]))/32768.0 * accelRangeFactor;
    accel.z = (static_cast<int16_t>((rawData[4] << 8) | rawData[5]))/32768.0 * accelRangeFactor;
    return accel;
}   

void MPU6050_JFL::getGyroscopeData(xyzFloat *gyro){
    uint8_t rawData[6]; 
    readMultipleRegisters(MPU6050_GYRO_XOUT_H, 6, rawData);
    gyro->x = (static_cast<int16_t>((rawData[0] << 8) | rawData[1]))/32768.0 * gyroRangeFactor;
    gyro->y = (static_cast<int16_t>((rawData[2] << 8) | rawData[3]))/32768.0 * gyroRangeFactor;
    gyro->z = (static_cast<int16_t>((rawData[4] << 8) | rawData[5]))/32768.0 * gyroRangeFactor;
}  

void MPU6050_JFL::update(){
    readMultipleRegisters(MPU6050_ACCEL_XOUT_H, 14, allRawData);
}

xyzFloat MPU6050_JFL::getGyroscopeDataFromAllRawData(){
    xyzFloat gyro;
    readMultipleRegisters(MPU6050_GYRO_XOUT_H, 6, allRawData);
    gyro.x = ((static_cast<int16_t>(allRawData[8] << 8) | allRawData[9]))/32768.0 * gyroRangeFactor;
    gyro.y = ((static_cast<int16_t>(allRawData[10] << 8) | allRawData[11]))/32768.0 * gyroRangeFactor;
    gyro.z = ((static_cast<int16_t>(allRawData[12] << 8) | allRawData[13]))/32768.0 * gyroRangeFactor;
    return gyro;
}   

uint8_t MPU6050_JFL::writeRegister(uint8_t reg, uint8_t regValue){
    Wire.beginTransmission(i2cAddress);
    Wire.write(reg);
    Wire.write(regValue);

    return Wire.endTransmission();
}

uint8_t MPU6050_JFL::readRegister(uint8_t reg){
    uint8_t data; 
    Wire.beginTransmission(i2cAddress);         
    Wire.write(reg);                    
    Wire.endTransmission(false);           
    Wire.requestFrom(i2cAddress, static_cast<uint8_t>(1));  
    data = Wire.read();                      
    return data;                             
}

uint16_t MPU6050_JFL::read2Registers(uint8_t reg){
    uint8_t MSB = 0, LSB = 0; // (Most / Least Significant Byte)
    uint16_t regValue = 0;
    Wire.beginTransmission(i2cAddress);         
    Wire.write(reg);                    
    Wire.endTransmission(false);           
    Wire.requestFrom(i2cAddress, static_cast<uint8_t>(2));  
    MSB = Wire.read(); 
    LSB = Wire.read();
    
    regValue = (MSB<<8) + LSB;
    return regValue;                             
}

void MPU6050_JFL::readMultipleRegisters(uint8_t reg, uint8_t count, uint8_t *buf){
    Wire.beginTransmission(i2cAddress);   
    Wire.write(reg);            
    Wire.endTransmission(false);       
    uint8_t i = 0;
    Wire.requestFrom(i2cAddress, count); 
    while (Wire.available()) {
        buf[i] = Wire.read();
        i++;
    }       
}

 

Definition der Register und Registerwerte

Für die Bezeichnung der Register und einiger Registerwerte verwende ich constexpr – Ausdrücke. Da die Definition innerhalb der Klasse erfolgt, muss das Schlüsselwort static hinzugefügt werden, also beispielsweise: static constexpr uint8_t MPU6050_GYRO_CONFIG {0x1B};.

Die Definition innerhalb der Klasse verringert die Gefahr von Namenskollisionen. Außerhalb der Klasse sind die Bezeichner nicht bekannt. Wenn ihr sie trotzdem außerhalb der Klasse verwenden wollt, dann müsst ihr ihnen den Klassennamen voranstellen, getrennt durch den Bereichsauflösungsoperator ::, also z. B. MPU6050_JFL::MPU6050_GYRO_CONFIG.

Eine andere Möglichkeit, Namenskollisionen zu vermeiden, ist die Verwendung eines namenlosen Namespace. Dazu platziert ihr die constexpr Definitionen in der Quelldatei (ohne static) und dort innerhalb einer Namespace Umgebung:  namespace{....}. Dann könnt ihr die Bezeichner allerdings nicht außerhalb der Quelldatei verwenden. Das könnte bei Vererbungen nerven – aber ich merke gerade, dass ich mich in Details verliere…

Gleichwohl schützen euch diese Maßnahmen nicht davor, dass Andere vielleicht dieselben Namen in #define – oder constexpr – Ausdrücken ohne solche Vorkehrungen in Bibliotheken verwenden, die ihr einbinden wollt. Ihr solltet also immer noch individuelle Namen verwenden und nicht so etwas wie beispielsweise „CONFIG_REGISTER“.

Konstruktor

Zum Konstruktor gibt es nicht viel zu sagen. Als Parameter empfängt er die I2C Adresse. Wenn bei der Objektinitialisierung keine Adresse übergeben wird, dann greift die Voreinstellung 0x68.

Initialisierung

Die init() Funktion schauen wir uns im Detail an:

bool MPU6050_JFL::init(){
    //Wire.setWireTimeout();
    accelRangeFactor = 1.0;
    gyroRangeFactor = 1.0; 
    bool connected = !reset();
    delay(100); // MPU6050 needs 100 ms to reset
    sleep(false); // disable sleep
    delay(100); // give the device some time to wake up!
    return connected;
}

In vielen Bibliotheken sorgt eine init() oder begin() Funktion für einen definierten Ausgangszustand. Außerdem wird die Funktion oft genutzt, um festzustellen, ob das Bauteil funktionstüchtig ist. So auch hier. Wenn das MPU6050 Modul ansprechbar ist, wird connected als true zurückgegeben.

Wire.setWireTimeout() stellt sicher, dass der Sketch bei einer fehlerhaften I2C Kommunikation nicht hängen bleibt. Wenn ihr die Funktion auskommentiert lasst, kann es dazu kommen (muss aber nicht), dass init() im Fehlerfall nicht zu Ende ausgeführt wird und ihr deshalb auch keine Rückmeldung bekommt.

Zu den Variablen accelRangeFactor und gyrRangeFactor komme ich später.

Die reset() Funktion

Die reset() Funktion überschreibt das Register MPU6050_PWR_MGMT_1 mit dem Wert 0x80 (= 0b10000000). Sie setzt also das DEV_RESET Bit.

bool MPU6050_JFL::reset(){
    return writeRegister(MPU6050_PWR_MGMT_1, MPU6050_DEVICE_RESET);
}

Die Funktion writeRegister() liefert den Rückgabewert von Wire.endTransmission() zurück (Zeile 113). Bei Erfolg ist das eine 0 (false). Im Fehlerfall wird ein Fehlercode zurückgegeben, also ein Wert ungleich 0 (true). Der Rückgabewert wird bis zu init() zurückgereicht und dort durch bool connected = !reset() invertiert. Es wäre unlogisch, wenn init() im Erfolgsfall false zurückgeben würde.

Wenn das Bauteil, das ihr ansteuern wollt, eine Resetfunktion hat, solltet ihr sie im Rahmen der Initialisierung für eure Bibliotheken auch nutzen. Gibt es sie nicht, dann rate ich die Register „manuell“ auf Ausgangswerte bringen. Sonst kann folgendes passieren: Ihr spielt mit eurem Code und ladet eine neue Version auf euren Mikrocontroller. Dieser wird beim Upload zwar resettet, euer Bauteil aber nicht, sofern es nicht zwischenzeitlich vom Strom getrennt wurde. Damit haben die Register noch ihre Werte aus dem letzten Programmdurchlauf. Dieser undefinierte Zustand kann zu unvorhersehbaren Fehlern führen.

Nach dem Reset braucht der MPU6050 bis zu 100 Millisekunden, um wieder einsatzbereit zu sein. Dieser Wert steht im Datenblatt.

Die sleep() Funktion

Ein Reset setzt alle Register des MPU6050 auf null, mit Ausnahme der Register PWR_MGMT_1 und natürlich WHO_AM_I. Der Standardwert von PWR_MGMT_1 ist 0x40, d.h. das Sleep-Bit ist gesetzt. Mit anderen Worten: nach einem Reset schläft der MPU6050 und muss erst geweckt werden. Dazu müssen wir das Sleep-Bit mit einer geeigneten Funktion löschen. Die Funktion soll es aber auch ermöglichen, das Sleep-Bit später wieder zu setzen, um den MPU6050 jederzeit in den Schlaf schicken zu können. Überdies soll die Funktion die anderen Bits des Registers PWR_MGMT_1 nicht beeinflussen. 

Diese Anforderungen erfüllt sleep():

void MPU6050_JFL::sleep(bool sl){
    uint8_t regVal = readRegister(MPU6050_PWR_MGMT_1);
    if(sl){
        regVal |= MPU6050_DEVICE_SLEEP;
    }
    else{
        regVal &= ~MPU6050_DEVICE_SLEEP;
    }
    writeRegister(MPU6050_PWR_MGMT_1, regVal);
}   

In sleep() wird zunächst der Inhalt des Registers PWR_MGMT_1 gelesen. Danach wird, wenn sleep() mit dem Parameter true aufgerufen wurde, der folgende Teil der if-Konstruktion ausgeführt:

regVal |= MPU6050_DEVICE_SLEEP;
// entspricht / equals:
// regVal = regVal | 0b01000000;

Bei false greift:

regVal &= ~MPU6050_DEVICE_SLEEP;
// entspricht / equals:
// regVal = regVal & 0b10111111;

In beiden Fällen wirkt der Eingriff selektiv auf das Sleep-Bit. Der modifizierte Registerwert wird dann zurückgeschrieben. Wenn ihr mehr zu solchen Binäroperationen wissen wollt, dann lest am besten meinen Beitrag zu dem Thema.

Ihr könntet alternativ zwei getrennte Funktionen für das Schlafen und Wecken einrichten und damit auf eine Parameterübergabe verzichten – Geschmackssache.

Einstellen des Messbereiches

Für die Beschleunigung und die Gyroskopwerte gibt es jeweils 4 Messbereiche, die durch die FS_SEL-Bits (3 und 4) der zuständigen Konfigurationsregister nach dem folgenden Schema eingestellt werden:

Bibliotheken schreiben: Einstellung der Messbereiche des MPU6050
Einstellung der Messbereiche des MPU6050

Für die Benennung der Messbereiche des Beschleunigungssensors verwende ich Enum-Aufzählungen, die ich in der Headerdatei definiert habe:

enum accel_range{
    MPU6050_ACCEL_RANGE_2G = 0,
    MPU6050_ACCEL_RANGE_4G,
    MPU6050_ACCEL_RANGE_8G,
    MPU6050_ACCEL_RANGE_16G
};

Die Messbereiche werden an die zuständigen Funktionen übergeben, also beispielsweise setAccelRange(MPU6050_ACCEL_RANGE_2G). Natürlich wäre es auch möglich, einfach 2, 4, 8 oder 16 zu übergeben. So eine Vorgehensweise ist pragmatisch, macht eure Bibliotheken in der Bedienung aber fehleranfällig. Ihr könntet beispielsweise eine 42 übergeben, ohne dass der Compiler einen Fehler meldet. Bei einem sperrigen Ausdruck wie MPU6050_ACCEL_RANGE_2G besteht hingegen zwar die Gefahr, dass man sich verschreibt, aber über Syntax-Highlighting lässt sich das Risiko minimieren. Außerdem beschwert sich der Compiler, wenn ihr einen nicht definierten Ausdruck übergeben wollt.  

Besser als die einfachen Enum-Aufzählungen sind Enum-Klassen. Sie sind „typensicherer“, da unbeabsichtigte Typenumwandlungen verhindert werden. Außerdem verringern sie die Gefahr von Namenskollisionen. Um den Unterschied zu zeigen, habe ich die Gyroskop-Messbereiche als Enum-Klasse definiert:

enum class GyroRange : uint8_t{
    MPU6050_250DPS  = 0x00,
    MPU6050_500DPS  = 0x08,
    MPU6050_1000DPS = 0x10,
    MPU6050_2000DPS = 0x18
};

Um auf die Elemente der Enum-Klasse zuzugreifen, müsst ihr ihnen den Klassennamen voranstellen, getrennt durch den Bereichsauflösungsoperator ::, also beispielsweise: GyroRange::MPU6050_250DPS.

Messbereich für den Beschleunigungssensor

Schauen wir uns die Funktion setAccelRange() im Detail an:

void MPU6050_JFL::setAccelRange(accel_range range){
    uint8_t regVal = readRegister(MPU6050_ACCEL_CONFIG);
    switch(range){
        case MPU6050_ACCEL_RANGE_2G:  accelRangeFactor = 2.0;  break;
        case MPU6050_ACCEL_RANGE_4G:  accelRangeFactor = 4.0;  break;
        case MPU6050_ACCEL_RANGE_8G:  accelRangeFactor = 8.0;  break;
        case MPU6050_ACCEL_RANGE_16G: accelRangeFactor = 16.0;  break;
    }
    regVal &= ~MPU6050_ACCEL_RANGE_MASK;
    regVal |= range<<3;
    writeRegister(MPU6050_ACCEL_CONFIG, regVal);
}

Ich hatte die accelRange Werte so definiert, dass sie den FS_SEL Bits 3 und 4 entsprechen. D.h. der Wert für 2 g ist 0, für 4 g ist er 1, für 8 g ist er 2 und für 16 g ist er 3. Im Gegensatz zur Manipulation des Sleep-Bits haben wir es hier mit zwei Bits zu tun. Deshalb müssen wir die Vorgehensweise etwas ändern. Wir lesen wieder den Inhalt des relevanten Registers (hier: ACCEL_CONFIG) aus, löschen dann aber zunächst die FS_SEL Bits:

regVal &= ~MPU6050_ACCEL_RANGE_MASK;
//entspricht / equals:
//regVal = regVal & 0b11100111;

Danach setzen wir die übergebenen Bits mithilfe des logischen ORs (also |), müssen sie allerdings noch um 3 Bits nach links verschieben:

regVal |= range<<3;

In setAccelRange() wird auch noch accelRangeFactor festgelegt. Diesen Wert brauchen wir bei der Berechnung der Beschleunigungswerte aus den Rohdaten. Ich habe mir erlaubt, die Variable global zu definieren. Alternativ könntet ihr vor jeder Messwertabfrage den Messbereich mit getAccelRange() abfragen und so auf diese globale Variable verzichten. Das allerdings geht auf Kosten der Geschwindigkeit.

Um den Messbereich mit getAccelRange() abzufragen, lesen wir das ACCEL_CONFIG Register aus, verschieben den Wert um drei Stellen nach rechts und eliminieren alles, was ggf. noch links von den FS_SEL Bits steht:

regVal = (regVal >> 3) & 0x03;

Messbereich für das Gyroskop

Für die Definition der Gyroskop-Messbereiche habe ich außer der Verwendung einer Enum-Klasse eine weitere Änderung vorgenommen. Und zwar entsprechen die Elemente von gyro_range  nicht den FS_SEL Bits, sondern berücksichtigen auch die Position im GYRO_CONFIG Register. Anders ausgedrückt: Sie sind um drei Bits nach links verschoben, sodass ihr euch diesen Vorgang später sparen könnt. Welche Variante ihr vorzieht, ist Geschmackssache.

Auf eine getGyroRange() Funktion habe ich verzichtet. Wer ein wenig üben möchte, könnte sie ergänzen. 

Auslesen der Rohdaten und Berechnung der Messwerte

Wenn ein Sensor mehrere Messdaten ausgibt, dann gibt es unterschiedliche Ansätze, wie ihr diese anfordert, die Rohdaten auslest und die daraus ermittelten Messwerte zurückgebt.

Rohwerte einzeln auslesen

Die Rohdaten der Messwerte des MPU6050 sind auf je zwei Register verteilt. Am naheliegendsten ist es deshalb, eine zu Funktion implementieren, die die zwei Register ausliest, daraus den Rohwert zusammensetzt und an die anfordernde Funktion zurückgibt. Und genau das tut die Funktion read2Registers():

uint16_t MPU6050_JFL::read2Registers(uint8_t reg){
    uint8_t MSB = 0, LSB = 0; // (Most / Least Significant Byte)
    uint16_t regValue = 0;
    Wire.beginTransmission(i2cAddress);         
    Wire.write(reg);                    
    Wire.endTransmission(false);           
    Wire.requestFrom(i2cAddress, static_cast<uint8_t>(2));  
    MSB = Wire.read(); 
    LSB = Wire.read();
    
    regValue = (MSB<<8) + LSB;
    return regValue;                             
}

Hier seht ihr am Beispiel der Funktion getOnlyTemperature(), wie ihr read2Registers() verwendet:

float MPU6050_JFL::getOnlyTemperature(){
    uint16_t rawTemp = read2Registers(MPU6050_TEMP_OUT_H);
    float temp = (static_cast<int16_t>(rawTemp))/340.0 + 36.53; // see RegMap page 30;
    return temp;
}

Zu beachten ist, dass der Rohwert als uint16_t Zahl ausgelesen und dann in eine int16_t Zahl umgewandelt wird.

Mehrere Rohwerte in einem Rutsch auslesen (Burst Read)

So wie eben beschrieben könntet ihr auch mit den x-, y- und z-Werten für Beschleunigung und Rotation verfahren, nämlich einzeln. Allerdings ist das nicht besonders effektiv, da ihr jeden Lesevorgang erst wieder neu mit dem MPU6050 „verhandeln“ müsst. Mehrere Werte auf einmal auszulesen, ist schneller.

Um die auslesende Funktion flexibel zu gestalten, übergeben wir ihr die Anzahl der auszulesenden Register. Und um Speicher zu sparen, verwenden wir Zeiger:

void MPU6050_JFL::readMultipleRegisters(uint8_t reg, uint8_t count, uint8_t *buf){
    Wire.beginTransmission(i2cAddress);   
    Wire.write(reg);            
    Wire.endTransmission(false);       
    uint8_t i = 0;
    Wire.requestFrom(i2cAddress, count); 
    while (Wire.available()) {
        buf[i] = Wire.read();
        i++;
    }       
}

Die Funktion hat keinen Rückgabewert, da wir mit dem Original arbeiten, das als uint8_t *buf übergeben wurde.

Es stellt sich dann noch die Frage, wie die Messwerte an den aufrufenden Sketch zurückgegeben werden. Eine Möglichkeit wäre natürlich, ein Array zu verwenden. In meinen Bibliotheken für Beschleunigungssensoren und Gyroskope habe ich mich entschieden, eine Struktur namens xyzFloat zu definieren, die aus den drei Float-Werten x, y und z. bestehen:

struct xyzFloat {
    float x;
    float y;
    float z;
};

So sieht dann die Funktion für die Abfrage der Beschleunigungswerte aus:

xyzFloat MPU6050_JFL::getAccelerationData(){
    uint8_t rawData[6]; 
    xyzFloat accel;
    readMultipleRegisters(MPU6050_ACCEL_XOUT_H, 6, rawData);
    accel.x = (static_cast<int16_t>((rawData[0] << 8) | rawData[1]))/32768.0 * accelRangeFactor;
    accel.y = (static_cast<int16_t>((rawData[2] << 8) | rawData[3]))/32768.0 * accelRangeFactor;
    accel.z = (static_cast<int16_t>((rawData[4] << 8) | rawData[5]))/32768.0 * accelRangeFactor;
    return accel;
}   

Es werden die drei Beschleunigungswerte, sprich sechs Einzelwerte abgefragt und in dem Array rawData gespeichert. Aus Rohwertepaaren berechnet die Funktion die drei Beschleunigungswerte und speichert sie im Rückgabewert accel, einem xyzFloat

Alternativ übergibt der aufrufende Sketch das Wertetripel als Zeiger. Um zu zeigen, wie das geht, habe ich diese Vorgehensweise für die Gyroskopwerte implementiert.

void MPU6050_JFL::getGyroscopeData(xyzFloat *gyro){
    uint8_t rawData[6]; 
    readMultipleRegisters(MPU6050_GYRO_XOUT_H, 6, rawData);
    gyro->x = (static_cast<int16_t>((rawData[0] << 8) | rawData[1]))/32768.0 * gyroRangeFactor;
    gyro->y = (static_cast<int16_t>((rawData[2] << 8) | rawData[3]))/32768.0 * gyroRangeFactor;
    gyro->z = (static_cast<int16_t>((rawData[4] << 8) | rawData[5]))/32768.0 * gyroRangeFactor;
}  

Damit können wir uns die lokale Variable gyro sparen und die Funktion hat keinen Rückgabewert.

Die übergebene Variable *gyro ist ein Zeiger. Der Zeiger selbst ist kein xyzFloat, sondern verweist lediglich darauf. Der Zeiger hat auch kein Element x, y oder z. Deswegen muss anstelle des Punktoperators der Pfeiloperator verwendet werden, also gyro->x. Alternativ geht auch (*gyro).x, das ist aber unüblich.

Eine weitere Möglichkeit wäre, gyro als Referenz zu übergeben, also getGyroscopeData(xyzFloat &gyro){...}. Dann könntet ihr euch die Pfeiloperatoren sparen. Ich persönlich finde die Zeigermethode in diesem Fall besser, weil deutlicher heraussticht, dass hier mit dem Original und keiner lokalen Kopie gearbeitet wird. Aus meiner Sicht ist das Geschmackssache.

Beispielsketch

Nach den ganzen Erklärungen ist es Zeit, sich die Bibliothek MPU6050 einmal anhand eines Beispielsketches in Aktion anzuschauen:

#include <MPU6050_JFL.h>
#include <Wire.h>
#define I2C_ADDRESS 0x68

MPU6050_JFL myMPU = MPU6050_JFL(I2C_ADDRESS);
// alternative: 
//MPU6050_JFL myMPU = MPU6050_JFL();

void setup() {
  Wire.begin();
  Serial.begin(9600);
  if(myMPU.init()){
    Serial.println("MPU6050 connected");
  }
  else{
    Serial.println("MPU6050 not connected");
    while(1); 
  }
  Serial.print("Who Am I: 0x");
  Serial.println(myMPU.whoAmI(),HEX);
  myMPU.setAccelRange(MPU6050_ACCEL_RANGE_2G);
  myMPU.setGyroRange(GyroRange::MPU6050_500DPS);
  Serial.print("Acceleration Range (0-3): ");
  Serial.println(myMPU.getAccelRange());
  Serial.print("Temperature [°C]: ");
  Serial.println(myMPU.getOnlyTemperature());
  Serial.println();
}

void loop(){
  xyzFloat acc = myMPU.getAccelerationData();
  xyzFloat gyr = {0.0, 0.0, 0.0};
  myMPU.getGyroscopeData(&gyr);
  Serial.print("acc_x: ");
  Serial.print(acc.x); Serial.print('\t');
  Serial.print("acc_y: ");
  Serial.print(acc.y); Serial.print('\t');
  Serial.print("acc_z: ");
  Serial.println(acc.z);
  
  Serial.print("gyr_x: ");
  Serial.print(gyr.x); Serial.print('\t');
  Serial.print("gyr_y: ");
  Serial.print(gyr.y); Serial.print('\t');
  Serial.print("gyr_z: ");
  Serial.println(gyr.z);
  Serial. println();

  delay(2000); 
} 

 

Ihr seht hier die Unterschiede beim Aufruf von getAccelerationData() und getGyroscopeDateData(). Ansonsten sollte der Sketch keine Erklärungen benötigen. Hier noch die Ausgabe:

Bibliotheken schreiben am Beispiel MPU6050 - Ausgabe example_1.ino
Ausgabe example_1.ino

Alle Rohwerte auf einmal auslesen

Bisher haben wir die Beschleunigungs-, Gyroskop- und Temperaturwerte getrennt ausgelesen. Wenn ihr alle diese Werte braucht und es auf eine hohe Geschwindigkeit ankommt, dann könnt ihr natürlich auch alle Rohdaten auf einmal auslesen. Dazu habe ich eine globale Variable uint8_t allRawData[14] eingeführt, die durch die Funktion update() aktualisiert wird:

void MPU6050_JFL::update(){
    readMultipleRegisters(MPU6050_ACCEL_XOUT_H, 14, allRawData);
}

Aus allRawData bedienen sich dann die Funktionen zur Berechnung der Messwerte. Ich habe das beispielhaft nur für die Gyroskopwerte implementiert:

xyzFloat MPU6050_JFL::getGyroscopeDataFromAllRawData(){
    xyzFloat gyro;
    readMultipleRegisters(MPU6050_GYRO_XOUT_H, 6, allRawData);
    gyro.x = ((static_cast<int16_t>(allRawData[8] << 8) | allRawData[9]))/32768.0 * gyroRangeFactor;
    gyro.y = ((static_cast<int16_t>(allRawData[10] << 8) | allRawData[11]))/32768.0 * gyroRangeFactor;
    gyro.z = ((static_cast<int16_t>(allRawData[12] << 8) | allRawData[13]))/32768.0 * gyroRangeFactor;
    return gyro;
}   

Im zugehörigen Sketch example_2.ino muss vor jeder Abfrage der Messwerte eine Aktualisierung mittels update() erfolgen.

#include <MPU6050_JFL.h>
#define I2C_ADDRESS 0x68

MPU6050_JFL myMPU = MPU6050_JFL(I2C_ADDRESS);

void setup() {
  Serial.begin(9600);
  if(myMPU.init()){
    Serial.println("MPU6050 connected");
  }
  else{
    Serial.println("MPU6050 not connected");
    while(1); 
  }
  myMPU.setGyroRange(GyroRange::MPU6050_500DPS);
}

void loop(){
  myMPU.update();
  xyzFloat gyr = myMPU.getGyroscopeDataFromAllRawData();
 
  Serial.print("gyr_x: ");
  Serial.print(gyr.x); Serial.print('\t');
  Serial.print("gyr_y: ");
  Serial.print(gyr.y); Serial.print('\t');
  Serial.print("gyr_z: ");
  Serial.println(gyr.z);

  delay(2000); 
} 

Eine weitere Option, um die Messwerte zu „verpacken“ wäre ein einziges großes struct-Objekt:

struct MPU6050_result {
    float acc_x;
    float acc_y;
    float acc_z;
    float temp;
    float gyr_x;
    float gyr.y;
    float gyr.z;
};

Weitere MPU6050 Bibliotheken

Es gibt noch mehr Möglichkeiten, die Messwerte auszulesen und zu verarbeiten. Wenn ihr Lust habt, dann schaut euch mal weitere Bibliotheken für den MPU6050 auf GitHub an, zum Beispiel MPU6050 von Kris Winer. Am Beispielsketch MPU6050BasicExample.ino seht ihr, dass die Bibliothek nur die Rohwerte zurückgibt und die eigentliche Berechnung in den Sketch ausgelagert wird. Dadurch ist die Bibliothek sehr schlank und verwendet keine globalen Variablen, aber dafür ist die Bedienung etwas weniger komfortabel.

Falls Ihr wissen wollt, wie ihr Winkel aus den Beschleunigungswerten berechnet, dann empfehle ich einen Blick in die Bibliothek Arduino-MPU6050 von Korneliusz Jarzębski.

Ein Beispiel für eine MPU6050 Bibliothek, die es erlaubt, Wire-Objekte zu übergeben, ist MPU6050_tockn. Das hat den Vorteil, dass Ihr beispielsweise die beiden I2C-Schnittstellen des ESP32 nutzen könnt (siehe auch hier). In meinen Bibliotheken für I2C- und SPI-basierte Bauteile erlaube ich grundsätzlich die Übergabe von Wire- und SPI-Objekten.

Fazit

Ich hoffe, dass dieser Beitrag hilfreich war und euch motiviert, eigene Bibliotheken zu schreiben. Ihr dürftet gesehen haben, dass man keine exakte Anleitung dafür erstellen kann, da es selbst für ein und dasselbe Bauteil sehr unterschiedliche Herangehensweisen gibt. Probiert es aus und schaut in andere Bibliotheken – so lernt man aus meiner Sicht am schnellsten.

Vielleicht wollt ihr auch die MPU6050_JFL Bibliothek weiter ausbauen? Dann solltet ihr natürlich erst einmal einen Schritt zurückgehen und euch bei den vielen verschiedenen Optionen, die ich aufgezeigt habe, jeweils die auswählen, die euch am besten gefällt.

2 thoughts on “Bibliotheken und Klassen erstellen – Teil II

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert