Tutorial · Einsteiger · 22 Min. Lesezeit

Hindernisvermeidung: Fühlen, Scannen, Entscheiden, Drehen

Entscheidungsschleife hinter einem Hindernisvermeidungsroboter: Stoppschwelle, Servo-Scan und eine Zustandsmaschine, die ins Freie dreht, ohne zu verklemmen.

Sobald ein Roboter eine saubere Distanz lesen und nach der offenen Richtung scannen kann, fehlt noch ein Baustein: die Logik, die entscheidet, was zu tun ist. Das ist das Gehirn eines hindernisvermeidenden Roboters — eine kurze Schleife, die vorwärts fährt, merkt, wenn etwas zu nah ist, sich umschaut und zum freien Raum dreht. Es ist einfacher, als es klingt, und richtig gemacht geht es vor allem darum, nicht stecken zu bleiben.

Reaktiv, nicht geplant

Dieser Roboter hat keine Karte. Er reagiert auf das, was gerade vor ihm ist: fühlen, entscheiden, handeln, wiederholen. Das ist reaktive Navigation, und sie ist als erster Ansatz richtig — billig, robust und genug, um durch ein vollgestelltes Zimmer zu fahren, ohne etwas zu treffen. Das ist nicht dasselbe wie Pfadplanung, bei der ein Roboter anhand einer Karte vorab eine Route wählt (das ist A* auf einem Belegungsraster-Terrain). Reaktive Vermeidung ist das Fundament; Planung kommt später.

Die Stoppschwelle

Alles hängt an einer Zahl: Wie nah ist zu nah? Wähle eine Distanz, bei der der Roboter anhält und neu überlegt. Zu klein, und er streift Hindernisse, bevor er reagieren kann; zu groß, und er hält vor allem an und kriecht nervös herum. Der richtige Wert hängt von der Geschwindigkeit ab — ein schnellerer Roboter muss früher reagieren, weil er mehr Boden bedeckt, bevor er stoppen kann. Für einen kleinen Roboter in gemütlichem Tempo sind 15–25 cm ein sinnvoller Start.

Die Zustandsmaschine

Ein Distanzsensor vermeidet die meisten Hindernisse; ein Bump-Schalter fängt die, die er nicht sehen kann — und jeder Roboter, der unbeaufsichtigt läuft, will beides.

Die sauberste Art, das Verhalten auszudrücken, ist eine endliche Zustandsmaschine: wenige benannte Zustände mit klaren Regeln für die Übergänge. Sie ersetzt ein Gewirr verschachtelter ifs durch etwas, das man lesen und debuggen kann. (Zum allgemeinen Muster siehe endliche Zustandsmaschinen für Roboterverhalten.)

Zustandsdiagramm mit vier Zuständen: DRIVE (vorwärts fahren), SCAN (Servo schwenken), TURN (zur Öffnung drehen) und REVERSE (zurücksetzen). Pfeile zeigen von DRIVE nach SCAN bei „Distanz unter Stoppschwelle“, von SCAN nach TURN bei „offene Seite gefunden“, von TURN zurück nach DRIVE bei „frei, weiterfahren“, plus ein REVERSE-Zweig aus SCAN bei „alle Seiten blockiert“, der zum erneuten Scannen zurückführt.
Vier Zustände und die Bedingungen dazwischen: fahren, bis etwas nah ist, scannen, zur Öffnung drehen und nur zurücksetzen, wenn jede Richtung blockiert ist. SVG herunterladen
enum State { DRIVE, SCAN, TURN, REVERSE };
State state = DRIVE;
const int STOP_CM = 20;

void loop() {
  switch (state) {
    case DRIVE:
      driveForward();
      if (medianCm() < STOP_CM) { stopMotors(); state = SCAN; }
      break;

    case SCAN: {
      int a = bestAngle();            // servo sweep from the scan tutorial
      long open = distanceAt(a);
      if (open < STOP_CM)      state = REVERSE;   // nowhere to go
      else { turnToward(a); state = TURN; }
      break;
    }

    case TURN:
      if (turnComplete()) state = DRIVE;          // opening cleared, resume
      break;

    case REVERSE:
      backUp(400);                                // ms
      state = SCAN;                               // then look again
      break;
  }
}

Die Drehrichtung wählen

Der Scan hat die schwere Arbeit schon erledigt: Er liefert die offenste Peilung. Drehe dorthin. Der Aufruf turnToward(angle) bildet „links offen“ auf einen Linkspivot ab und „rechts offen“ auf einen Rechtspivot, per Differenzialantrieb — ein Rad vorwärts, das andere zurück, um ungefähr auf der Stelle zu drehen.

Schwingen und Sackgassen vermeiden

Zwei Fehlermodi fangen naive Roboter, und beide haben einfache Fixes:

  • Flattern zwischen links und rechts an einer Ecke, wo beide Seiten ähnlich aussehen. Füge eine Mindestdrehdauer hinzu, damit der Roboter die Drehung durchzieht, statt bei jedem Durchlauf neu zu entscheiden.
  • Einkeilen in einer Sackgasse, wo jede Richtung blockiert ist. Dafür ist der Zustand REVERSE da: zurücksetzen, dann erneut scannen. Ein kleiner Feststeckzähler — nach mehreren Scans ohne Fortschritt kräftiger zurücksetzen — sprengt die schlimmsten Fallen.

Abstimmung: Geschwindigkeit gegen Reaktionsdistanz

Zwei Knöpfe entscheiden, ob sich der Roboter ruhig oder panisch anfühlt: Fahrgeschwindigkeit und Stoppschwelle. Erhöhst du die Geschwindigkeit, musst du die Schwelle erhöhen, damit der Bremsweg sicher bleibt — mit v·t für Latenz und v²/2a fürs Bremsen, weshalb doppelte Geschwindigkeit mehr als die doppelte Schwelle verlangt. Am schnellsten findest du ein gutes Paar dort, wo ein Fehler nichts kostet — im Hindernisvermeidungs-Simulator — und überträgst die Werte erst dann auf die Hardware.

Hysterese: zwei Schwellen, nicht eine

Die obige Zustandsmaschine nutzt ein einziges STOP_CM, und das ist das Erste, was zu fixen ist. Ein Roboter, der nahe an einer Schwelle schwebt, springt mehrfach pro Sekunde zwischen DRIVE und SCAN, weil winzige Messänderungen den Vergleich kippen. Er stottert statt sich festzulegen.

const int STOP_CM   = 20;   // stop when closer than this
const int RESUME_CM = 30;   // do not drive again until clearer than this

Die Lücke dazwischen ist Hysterese, und sie zwingt das Verhalten zur Festlegung. Dasselbe Muster fixt fast jede schwellengetriebene Schwingung im Robotercode — nicht nur diese: Kantendetektor, Batterie-warnung, Docking-Anfahrt.

Eine nützliche Regel für die Lückengröße: Mache sie größer als dein Messrauschen plus die Strecke, die der Roboter in einem Durchlauf fährt. Für einen Roboter mit 0,3 m/s und 60 ms Sensorzyklus sind das 18 mm Fahrt plus ein paar Zentimeter Ultraschallstreuung — eine 10-cm-Lücke ist also großzügig, eine 3-cm-Lücke flattert noch.

Sich auf eine Drehung festlegen

Die zweite Schwingung sitzt auf Entscheidungsebene: Der Roboter dreht links, der Scan sieht nun anders aus, er dreht rechts und tänzelt auf der Stelle. Zwei Mechanismen fixen das:

unsigned long turnStartedAt = 0;
const unsigned long MIN_TURN_MS = 250;    // commit for at least this long

case TURN:
  if (millis() - turnStartedAt < MIN_TURN_MS) break;   // no re-deciding yet
  if (medianCm() > RESUME_CM) { stopTurn(); state = DRIVE; }
  break;

Eine Mindestdrehdauer hindert den Roboter daran, neu zu entscheiden, bevor die Drehung etwas geändert hat, und geprüft wird gegen die Weiterfahr-Schwelle, nicht gegen die Stoppschwelle.

Erkennen, dass du feststeckst

REVERSE behandelt eine Sackgasse voraus. Den härteren Fall behandelt es nicht: ein Roboter, der sich technisch bewegt, scannt und dreht und trotzdem nirgendwo hinkommt — eine konkave Ecke oder ein Stuhl, den er ständig umkreist.

uint8_t scanCount = 0;
unsigned long lastProgressAt = 0;

// Called whenever the robot successfully drives forward for a while.
void notedProgress() { scanCount = 0; lastProgressAt = millis(); }

void escalate() {
  scanCount++;
  if (scanCount >= 3) {
    backUp(800);                    // back further
    pivot(random(120, 240));        // and turn a large, RANDOM amount
    scanCount = 0;
  } else if (millis() - lastProgressAt > 15000) {
    backUp(1200);
    pivot(180);                     // full about-turn
    lastProgressAt = millis();
  }
}

Zwei Entwurfsideen sind hier nennenswert.

Eskalation. Die erste Antwort auf ein Hindernis ist eine kleine Drehung; die dritte Antwort auf dieselbe Lage ist eine große. Eine feste Antwort, wiederholt, hält einen Roboter in der Ecke.

Zufall. Ein deterministischer Roboter in einer symmetrischen Falle trifft jedes Mal dieselbe Wahl und schleift endlos. Eine Zufallskomponente in der Fluchtdrehung bricht die Symmetrie — und sie ist der Grund, warum die frühesten Saugroboter zufälliges Abprallen statt einer cleveren Regel nutzten: Es ist ehrlich robust gegen Fallen, die du nicht vorhergesehen hast.

Die Bump-Schalter in jedem Zustand lesen

Die Zustandsmaschine, wie geschrieben, prüft nur Distanz, und jeder Distanzsensor hat Oberflächen, die er nicht sieht. Ein Roboter ohne Berührungssensorik schiebt einen Vorhang, bis die Motoren blockieren.

Berührung gehört über die Zustandsmaschine, nicht in einen Zustand:

void loop() {
  updateBumpers();                       // debounced, from the bump-switch tutorial

  // Contact overrides whatever the robot thought it was doing.
  if (bumpLeft || bumpRight) {
    stopMotors();
    backUp(300);
    pivot(bumpLeft ? +60 : -60);         // turn AWAY from the side that hit
    state = SCAN;
    return;
  }

  switch (state) { /* ... as before ... */ }
}

Beachte das Vorzeichen: Drehe weg von dem Schalter, der ausgelöst hat. Genau darum verdienen zwei Schalter ihren Platz gegenüber einem — ein einzelner Mittelschalter sagt dir, dass du etwas getroffen hast, und überlässt die Drehrichtung dem Zufall.

Geschwindigkeit und Schwelle, als Tabelle

Die Stoppschwelle ist keine Konstante; sie ist eine Funktion der Geschwindigkeit, weil der Bremsweg mit dem Quadrat der Geschwindigkeit wächst. Für einen Roboter mit 80 ms Fühl-und-Entscheidungs-Latenz, Bremsen mit 1,0 m/s², 90 mm Radius und 50 mm Reserve:

Geschwindigkeit Latenzanteil Bremsanteil Gesamtschwelle
0,15 m/s 12 mm 11 mm 16 cm
0,30 m/s 24 mm 45 mm 21 cm
0,45 m/s 36 mm 101 mm 28 cm
0,60 m/s 48 mm 180 mm 37 cm
0,90 m/s 72 mm 405 mm 62 cm

Doppelte Geschwindigkeit von 0,3 auf 0,6 m/s verdoppelt die Schwelle nicht — aber sie wächst um 76 %, und bei 0,9 m/s muss der Roboter auf über einen halben Meter reagieren. Darum trifft ein Roboter, der im Schritttempo zuverlässig ausweicht, plötzlich Dinge, wenn du ihn schneller machst — und darum ist der Fix eine geschwindigkeitsabhängige Schwelle statt eines großen Festwerts, der den Roboter überall ängstlich macht.

int stopThresholdCm(float speedMps) {
  const float latency = speedMps * 0.08f;             // 80 ms
  const float braking = (speedMps * speedMps) / 2.0f; // a = 1.0 m/s^2
  return (int)((latency + braking + 0.09f + 0.05f) * 100.0f);
}

Den Zustand zu drucken ist das Debugging-Werkzeug

Die einzelne nützlichste Zeile, die du hinzufügen kannst:

if (state != lastReportedState) {
  Serial.print(millis()); Serial.print(F(" -> "));
  Serial.println(STATE_NAMES[state]);
  lastReportedState = state;
}

Drucken bei Wechsel statt bei jedem Durchlauf hält die Ausgabe lesbar und verwandelt einen Roboter, der „sich manchmal komisch verhält“, in ein lesbares Protokoll. Fast jeder Bug in diesem Verhalten ist als Zustandsfolge sichtbar, die keinen Sinn ergibt — ein TURN, das nie endet, ein SCAN, das vierzigmal pro Sekunde feuert, ein REVERSE, das sofort wieder eintritt.

Wenn du „was tut der Roboter gerade“ nicht aus einer einzigen Variable beantworten kannst, macht die Zustandsmaschine ihren Job nicht.

Fehlersuche

Symptom Wahrscheinliche Ursache Fix
Stottert an Hindernissen Eine Schwelle, keine Hysterese Bei 20 cm stoppen, ab 30 cm weiterfahren
Tänzelt links-rechts auf der Stelle Neuentscheidung mitten in der Drehung Mindestdrehdauer
Schleift ewig in derselben Ecke Deterministische Flucht Eskalieren, plus Zufallsanteil
Fährt in Vorhänge und Kissen Schall wird absorbiert — Physik Bump-Schalter, über der Zustandsmaschine behandelt
Fährt rückwärts gegen Dinge Keine Hecksensorik Heck-Bump-Schalter oder Rücksetzzeit begrenzen
Weicht langsam gut aus, trifft schnell Dinge Feste Schwelle, quadratisches Bremsen Geschwindigkeitsabhängige Schwelle
Friert in zufälligen Abständen ein Blockierende Sensorlesungen Nicht-blockierendes Pingen und Scannen
Setzt sofort nach dem Zurücksetzen wieder zurück REVERSE erneut betreten, bevor der Scan aktualisiert war Nach dem Zurücksetzen einen frischen Scan erzwingen
Dreht zu der Wand, die er gerade traf Dreht zum auslösenden Bumper hin Drehe weg von ihm
Kann nicht sagen, was er tut Nichts druckt den Zustand Bei Zustandswechsel drucken

Den Graphen erkunden

Teil dieser Bauprojekte

Projekte und Lernpfade, die dieses Tutorial enthalten.

Weiterführende Literatur

Quellen