Ultrasonic Modul mit I2C-Interface

Am Schülerforschungszentrum phænovum in Lörrach experimentieren wir mit autonomen Modell-Fahrzeugen, also Fahrzeuge, die weitgehend selbstständig ihren Weg finden. Dieses Thema ist ein wunderschönes Experimentierfeld für Algorithmen.

Hier zeige ich ein Modul mit 5 Abstandssensoren, das die Experimente mit den autonomen Fahrzeugen vereinfachen und unterstützen soll. Die Verwaltung der Sensoren wird von einem separaten Prozessor übernommen und das Ergebnis der Messungen über eine einfache Schnittstelle bereit gestellt. Firmware, Schaltplan, Platinen-Design und Arduino-Bibliothek stehen auf GitHub zur Verfügung.

Die Idee

Die wichtigste Anforderung an ein autonomes Fahrzeug ist, dass es Kollisionen vermeidet. Dazu muss die Umgebung aktiv nach Hindernissen abgesucht werden. Eine einfache Methode bieten die Ultraschall-Sensoren HC-SR04. Sie sind preisgünstig und einigermaßen einfach auszulesen. Die Sensoren werden mit einem kurzen Trigger-Signal aktiviert und antworten darauf mit einem Echo-Impuls. Die Länge des Echos entspricht der Laufzeit des Ultraschall-Signals und ist ein Maß für die Entfernung zum nächsten Hindernis. Zur Auswertung der Antwort muss ein Mikrocontroller (z.B. ein Arduino) die Zeit des Impulses exakt in Mikrosekunden messen.

Die Signalfolge am HC-SR04 mit dem Oszilloskop: Die blaue Kurv zeigt das Trigger-Signal und die gelbe Kurve das Echo. Die Länge des Echo-Impulses ist ein Maß für den Abstand zum Hindernis.

Diese eigentlich einfache Aufgabe kann kompliziert werden, wenn mehrere Sensoren ins Spiel kommen und der Arduino gleichzeitig noch andere Aufgaben hat, z.B. die Motoren drehen, weitere Sensoren auslesen, die Fahrtrichtung bestimmen und mehr. An dieser Stelle soll das hier gezeigte “Ultrasonic Modul” helfen. Es kombiniert 5 Sensoren auf einem Board. Ein ATmega328 ist dazu abgestellt, sich ausschließlich um die Sensoren zu kümmern, wodurch der Hauptprozessor auf dem Fahrzeug entlastet wird. Die Messergebnisse werden über eine I2C-Schnittstelle zur Verfügung gestellt. In der Arduino-Welt ist diese Schnittstelle als Wire bekannt und kann einfach ausgelesen werden.

Die Schaltung

Der Schaltplan ist vergleichsweise simpel. Die 5 Ultraschall-Module (nummeriert von 0 bis 4) werden mit 5V versorgt und liegen mit ihren Trigger- und Echo-Pins direkt an den Ports des ATmega. Der Prozessor wird über einen Low Drop-Spannungsregler mit 3.3V versorgt, so dass das I2C-Interface kompatibel mit dem Spannungslevel von Raspberry Pi oder ESP32 und gleichzeitig 5V tolerant ist. Da der Prozessor mit dem internen Taktgenerator (8 MHz) arbeitet, ist kein Quarz notwendig. Schließlich gibt es noch eine Leuchtdiode, die von der Arbeit des Prozessors berichtet. Die Leitungen für das I2C-Interface werden an eine Steckerleiste herausgeführt. Die Schaltung enthält Pull Up-Widerstände für SDA und SCL, die bei Bedarf bestückt werden können. Alles wird mit 100 nF Blockkondensatoren versehen. Das ist dann auch schon alles.

Die Schaltung ist überschaubar. Der Stecker für den ISP-Adapter (Mitte rechts) ist optional.

Die Platine

Um den Aufbau zu erleichtern, habe ich eine Platine mit KiCad entworfen. Die Sensoren sitzen direkt auf der Platine und zeigen nach links, links vorne, vorne, rechts vorne und rechts. Ob die Winkel der seitlichen Sensoren von 90 und 45 Grad wirklich geeignet sind, wird sich erweisen. Möglicherweise sind kleinere Winkel sinnvoll. Z.B. wären 30 und 60 Grad denkbar.

Auf der Platine ist ein Lötbrücke, mit der die I2C-Adresse verändert werden kann. So können zwei solcher Module an einem Arduino betrieben werden.

Platine im 3D-Viewer. Vorne rechts befindet sich der 6-polige Stecker für den ISP-Programmier-Adapter, so dass der ATmega direkt in der Schaltung programmiert werden kann. Wenn kein Bedarf dafür besteht, kann der Stecker einfach unbestückt bleiben
Stückliste

Software

Die Software wurde mit dem Atmel Studio Version 7 entwickelt und wird weitgehend über Interrupts gesteuert. Folgende Interrupts kommen zum Einsatz:

  • Timer 0 (ein 8-Bit Timer) produziert einen regelmäßigen Interrupt, typischerweise mit einem 40 ms Intervall. Das Programm verwendet den Interrupt als Erinnerung (via job_flag), dass es Zeit ist, den nächsten Sensor zu aktivieren und dort ein Trigger-Signal auszulösen.
  • Die Echo-Signale der Sensoren lösen Pin-Change-Interrupts aus. Die entsprechende Interrupt-Routine übernimmt die Messung der Laufzeit des Echo-Signals.
  • I2C-Interrupts werden von der Schnittstelle ausgelöst und bedeuten, dass der Host Daten benötigt. Mehr dazu weiter unten.
  • Schließlich gibt es noch Timer 1 (ein 16-Bit Timer), der zwar keinen Interrupt auslöst, aber mit einem Vorteiler (Prescaler) von 8 die Zeit in Mikrosekunden misst.

Ein Herzstück des Programms ist der Pin-Change-Interrupt, der vom Echo-Impuls der Sensoren ausgelöst wird. Zur Steuerung der Auswertung gibt es eine Variable echo_flag, die den Status des zugehörigen Sensors festhält. Wenn das Hauptprogramm einen Trigger setzt, wird echo_flag für den entsprechenden Sensor auf den Wert 1 gesetzt. So hat die Interrupt-Routine die Information, dass von diesem Sensor ein Echo-Impuls zu erwarten ist. Der Pin-Change-Interrupt wird sowohl von steigenden als auch von fallenden Flanken aktiviert. In der Interrupt-Routine muss untersucht werden, (1) welcher Sensor den Interrupt ausgelöst hat und (2) ob es sich um eine ansteigende oder eine fallende Flanke handelt. Zu (1) wird die beschrieben Variable echo_flag ausgewertet. Für (2) wird der aktuelle Wert des Ports abgefragt. Ist dieser high, dann war es eine ansteigende Flanke, und der aktuelle Wert des Timer 1 wird als Startzeit abgelegt. Außerdem wird der Wert des echo_flag auf 2 erhöht. Damit bekommt die Interrupt-Routine die Information, dass für diesen Sensor eine fallende Flanke zu erwarten ist. Ist das echo_flag also 2 und zeigt der zugehörige Port den Pegel low, dann war es offensichtlich eine fallende Flanke. Jetzt wird die Laufzeit aus der Differenz des aktuellen Timer-Werts und der Startzeit errechnet. Dabei muss ein möglicher Überlauf des Timers berücksichtigt werden. Der zugehörige Source-Code sieht so aus:

if ((echo_flag[3] == 1 ) && (PINC & (1 << HC3_ECHO)) > 0) {  			// echo 3 is active
		start_time_3 = TCNT1;
		echo_flag[3] = 2;
		PORTB |= (1 << CTRL_LED);
	} else if ((echo_flag[3] == 2 ) && (PINC & (1 << HC3_ECHO)) == 0) {		// echo finished
		if (start_time_3 > TCNT1)
			median[3] = 65536 - start_time_3 + TCNT1;
		else
			median[3] = TCNT1 - start_time_3;
		echo_flag[3] = 0;
		PORTB &= ~(1 << CTRL_LED);		

Die vollständige Software besteht aus 3 Dateien.

  • main.c enthält das Hauptprogramm mit der Verwaltung der Sensoren
  • TWI_slave.c und TWI_slave.h enthalten die Logik für das I2C-Slave-Interface. Ich verwende den offiziell von Microchip publizierten Source-Code (siehe AVR311), der den I2C-Slave-Mode auf dem ATmega bereit stellt.

Alle Dateien müssen im Atmel-Studio in das Projekt eingebunden werden, damit die Kompilierung klappt. Der kommentierte Source-Code mit weiteren Details befindet sich auf GitHub.

Gelegentlich bekomme ich Fragen zur Programmierung der ATmega und ATiny-Chips: Der ATmega arbeitet hier ohne die Arduino-Umgebung und hat deshalb auch kein USB-Interface an Board. Die Software muss mit einem der üblichen Programmier-Adapter auf den Prozessor kopiert werden. Ich arbeite seit vielen Jahren mit einem Atmel AVRISP mkII. Aber es gibt auch andere. Mehr dazu unter AVR In System Programmer – Mikrocontroller.net.

Die Platine trägt einen Stecker für den AVRISP Programmierer, so dass der ATmega direkt in der Schaltung “geflasht” werden kann. Das ist sehr nützlich, wenn die Software weiter entwickelt werden soll.

I2C Interface

Das Modul ist als I2C-Slave unter der Adresse 0x5A (Lötbrücke an Port B2 offen) oder 0x5B (Port B2 über Brücke auf Masse gelegt) erreichbar.

Der Host (z.B. ein Arduino) muss das Modul ansprechen, um eine Antwort zu bekommen. Das geschieht, indem ein Byte gesendet wird. Wir nennen es “Register”. Als Antwort auf Register-Werte zwischen 0 und 4 liefert das Modul die aktuelle gemessene Entfernung des entsprechenden Sensors in Millimetern. Das ist ein 16-Bit-Wert. Die Sensoren sind im Uhrzeigersinn von links durchlaufend nummeriert. Der Sensor vorne in der Mitte hat also die Nummer 2.

Darüber hinaus gibt es drei Register, die die Arbeitsweise des Moduls beeinflussen. Das Register ist der untere Nibble. Die eigentlichen Daten werden als oberes Nibble übertragen :

  • set_status (unteres Nibble = 10) kann die Entfernungsmessungen anhalten (oberes Nibble == 0) oder wieder starten (oberes Nibble > 0).
  • set_cycle_time (unteres Nibble = 11) setzt die Zeit (oberes Nibble mit dem Wert 1 bis 15) zwischen zwei aufeinanderfolgenden Messungen in 10 ms. Der Default-Wert ist 4 (entspricht 40 ms). Kleinere Werte ergeben schnellere Abfolgen, können aber möglicherwiese dazu führen, dass überlappende Echo-Signale eintreffen, was zu Fehlmessungen führt. Hier ist Raum zum Experimentieren.
  • set_mode (unteres Nibble = 12) definiert die Anzahl der Sensoren und die Sequenz (wir nennen es “Mode”), mit der sie nacheinander abgefragt werden. Das obere Nibble kann folgende Werte annehmen:
  • Mode 0: Sensor 0 -> Sensor 2 -> Sensor 4 -> Sensor 1 -> Sensor 3
  • Mode 1: Sensor 0 -> Sensor 2 -> Sensor 4
  • Mode 2: Sensor 1 -> Sensor 3
  • Mode 3: Sensor 2

Die Werte für Cycle Time und Mode werden im EEPROM abgelegt und bleiben nach dem Abschalten der Spannungsversorgung erhalten.

Einsatz mit Arduino

Das Modul benötigt 4 Anschlussleitungen zum Host: GND, SDA, SCL und Vcc (+5V). Die Spannungsversorgung geschieht über GND und Vcc. Diese Pins sind auf der Platine doppelt ausgeführt, da man in einem Projekt nie genug solcher Anschlüsse haben kann.

Die I2C-Schnittstelle mit den beiden Leitungen SCL (Takt) und SDA (daten) muss mit den entsprechenden Pins am Arduino verbunden werden. Z.B. beim Arduino Uno oder Nano sind es A5 für SCL und A4 für SDA.

Es gibt eine kleine Arduino-Bibliothek, die alle Funktionen des Moduls zur Verfügung stellt.

		uint16_t get_distance(uint8_t sens);
		uint8_t get_mode(void);
		uint8_t get_cycle_time(void);
		void set_status(uint8_t status);
		void set_cycle_time(uint8_t cycle_time);
		void set_mode(uint8_t mode);

Ein einfaches Beispiel für ein Anwendungs-Programm ist hier gezeigt:

/* The sketch reads the sensors and shows the result via the serial interface
 *  SLW - Feb-2021
 */

#include <UltrasonicModule.h>
UltrasonicModule usm(0x5B);

//------------------------------------------------------------------------
void setup() {
  Wire.begin();
  Wire.setClock(400000);    // set Wire speed to fast mode
  Serial.begin(115200);  
  delay(250);               // allow for a short start up period 
}

//------------------------------------------------------------------------
void loop() {
  for (int sens = 0; sens < 5; ++sens) {
    Serial.print(usm.get_distance(sens));
    Serial.print("  ");
    delay(100);
  }
  Serial.println();
}

Fazit

Testlauf an einem Arduino Nano, der die Messdaten auf einem LCD-Display (ebenfalls per I2C) anzeigt.

Das Ultrasonic Modul macht den Einsatz von mehreren Ultraschall-Abstands-Sensoren so einfach wie möglich. Das Modul lässt sich gut in Arduino-Systeme integrieren, arbeitet aber auch mit einem Raspberry Pi zusammen. Ob es tatsächlich für das autonome Steuern von Fahrzeugen hilfreich ist, wird sich noch erweisen müssen. Mehr davon zu gegebener Zeit …

Update vom17. März 2021

In der ersten Version der Software hatte sich ein Bug versteckt, der dazu führte, dass das Modul nach kurzer Zeit keine sinnvollen Daten mehr lieferte. Das I2C-Slave-Interface blieb am “Busy-Flag” hängen. Der Fehler ist jetzt behoben. Inzwischen gibt es auch ein Fahrzeug, dass mit dem Modul seine Runden dreht und auch bei längeren Touren zuverlässige Daten bekommt.

Prototyp-Fahrzeug mit dem Ultrasonic-Modul

Ressourcen

GitHub – smlaage/Ultrasonic-I2C-Module: A module comprising 5 ultrasonic distance sensors and I2C interface

Erste Schritte mit dem Prototyp auf Lochrasterplatte …

Schreibe einen Kommentar

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