Module 08: Ultrasonic Distance Sensing
Level: 🟢 Beginner Board: Arduino Uno + HC-SR04 Prerequisites: Module 07 Estimated time: 45–60 minutes Goal: Read distance from an ultrasonic sensor and halt the robot before it hits an obstacle.
What You'll Learn
By the end of this module you will understand how ultrasonic time-of-flight ranging works, know how to wire the HC-SR04 and use pulseIn() to measure echo duration, and have a robot that drives forward and stops reliably before reaching a wall.
The robot has been blind until now; it executes a fixed program without knowing what's in front of it. This module adds its first sense: a distance measurement accurate to within a few millimetres, 20 times per second. You'll understand how ultrasonic ranging works, wire the HC-SR04, write a clean readDistance() function, and build a robot that stops before hitting a wall.
8.1 How ultrasonic distance sensing works
The HC-SR04 measures distance the same way a bat does: it fires a short burst of sound at 40 kHz (well above human hearing) and waits for the echo. The time between the outgoing burst and the returning echo tells you how far away the reflecting surface is. Sound travels at approximately 343 metres per second at room temperature (20 °C). Knowing speed and measuring time gives distance. The signal makes a round trip (out to the obstacle and back), so the formula halves the result:
// Speed of sound ≈ 343 m/s = 0.0343 cm/µs
// duration = round-trip travel time in microseconds
distance_cm = duration * 0.0343 / 2.0
// Integer shorthand — 1 cm round trip ≈ 58.3 µs:
distance_cm = duration / 58
The sensor doesn't know distance. It knows time. Your code converts time into distance.
<svg viewBox="0 0 760 220" width="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="aU8" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M2 2L8 5L2 8" fill="none" stroke="#1a1a18" stroke-width="1.5" stroke-linecap="round"/></marker>
<marker id="aG8" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M2 2L8 5L2 8" fill="none" stroke="#1a6b4a" stroke-width="1.5" stroke-linecap="round"/></marker>
</defs>
<!-- Timeline axis -->
<line x1="40" y1="110" x2="720" y2="110" stroke="#c4bfb0" stroke-width="1.5"/>
<text x="750" y="114" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">t →</text>
<!-- TRIG signal -->
<text x="30" y="68" text-anchor="end" font-family="IBM Plex Mono,monospace" font-size="10" fill="#c8420a">TRIG</text>
<!-- LOW baseline -->
<line x1="40" y1="74" x2="120" y2="74" stroke="#c4bfb0" stroke-width="1.5"/>
<!-- HIGH pulse (10µs) -->
<line x1="120" y1="74" x2="120" y2="52" stroke="#c8420a" stroke-width="1.5"/>
<line x1="120" y1="52" x2="160" y2="52" stroke="#c8420a" stroke-width="2"/>
<line x1="160" y1="52" x2="160" y2="74" stroke="#c8420a" stroke-width="1.5"/>
<line x1="160" y1="74" x2="720" y2="74" stroke="#c4bfb0" stroke-width="1.5"/>
<text x="140" y="46" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">10 µs</text>
<text x="90" y="82" font-family="Manrope,sans-serif" font-size="9" fill="#888">LOW</text>
<text x="125" y="65" font-family="Manrope,sans-serif" font-size="9" fill="#c8420a">HIGH</text>
<!-- ECHO signal -->
<text x="30" y="150" text-anchor="end" font-family="IBM Plex Mono,monospace" font-size="10" fill="#1a6b4a">ECHO</text>
<!-- LOW before burst -->
<line x1="40" y1="154" x2="200" y2="154" stroke="#c4bfb0" stroke-width="1.5"/>
<!-- HIGH: burst fires, waiting for echo -->
<line x1="200" y1="154" x2="200" y2="132" stroke="#1a6b4a" stroke-width="1.5"/>
<line x1="200" y1="132" x2="520" y2="132" stroke="#1a6b4a" stroke-width="2"/>
<line x1="520" y1="132" x2="520" y2="154" stroke="#1a6b4a" stroke-width="1.5"/>
<line x1="520" y1="154" x2="720" y2="154" stroke="#c4bfb0" stroke-width="1.5"/>
<!-- Duration annotation -->
<line x1="200" y1="122" x2="520" y2="122" stroke="#1a6b4a" stroke-width="1" stroke-dasharray="3 2"/>
<line x1="200" y1="118" x2="200" y2="126" stroke="#1a6b4a" stroke-width="1.5"/>
<line x1="520" y1="118" x2="520" y2="126" stroke="#1a6b4a" stroke-width="1.5"/>
<text x="360" y="116" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" fill="#1a6b4a">duration T (µs)</text>
<text x="360" y="170" text-anchor="middle" font-family="Manrope,sans-serif" font-size="10" fill="#3d3c38">→ distance = T / 58 cm</text>
<!-- Sound path annotation -->
<text x="220" y="105" font-family="Manrope,sans-serif" font-size="9" fill="#6b6a65">sound out →</text>
<text x="390" y="105" font-family="Manrope,sans-serif" font-size="9" fill="#6b6a65">bounces →</text>
<text x="490" y="105" font-family="Manrope,sans-serif" font-size="9" fill="#6b6a65">echo back</text>
<!-- pulseIn annotation -->
<rect x="550" y="126" width="160" height="36" rx="3" fill="#1a1a18"/>
<text x="630" y="140" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#a8e8c8">pulseIn(ECHO_PIN,</text>
<text x="630" y="154" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#a8e8c8">HIGH) measures T</text>
</svg>
Fig 8.1: TRIG and ECHO signal timing. A 10 µs HIGH pulse on TRIG fires the sound burst. ECHO goes HIGH when the burst leaves and stays HIGH until the reflected pulse returns. pulseIn() captures that duration in microseconds. Dividing by 58 converts it to centimetres.
The 40 kHz frequency is inaudible to humans (hearing tops out around 20 kHz). Dogs can detect up to about 65 kHz, so they'll notice the sensor operating; it's harmless, but worth knowing if you're working around animals.
The burst duration (8 cycles at 40 kHz ≈ 200 µs of actual sound) creates a minimum usable range of about 2 cm. Closer than that, the echo overlaps the outgoing burst and the reading becomes unreliable.
8.2 The HC-SR04 sensor
The HC-SR04 is a self-contained ranging module. It handles the 40 kHz transmit pulse, receive amplification, and echo detection internally. From the Arduino's perspective the interaction is simple: pulse TRIG for 10 µs, then measure how long ECHO stays HIGH.
<svg viewBox="0 0 760 260" width="100%" xmlns="http://www.w3.org/2000/svg">
<!-- HC-SR04 front face -->
<rect x="60" y="60" width="200" height="140" rx="6" fill="#edeae0" stroke="#c4bfb0" stroke-width="2"/>
<text x="160" y="86" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="11" letter-spacing="1" fill="#3d3c38">HC-SR04</text>
<!-- Two transducer eyes -->
<circle cx="110" cy="140" r="30" fill="#1a1a18" stroke="#444" stroke-width="2"/>
<circle cx="110" cy="140" r="20" fill="#333"/>
<circle cx="110" cy="140" r="10" fill="#555"/>
<text x="110" y="188" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" fill="#6b6a65">T</text>
<text x="108" y="200" text-anchor="middle" font-family="Manrope,sans-serif" font-size="9" fill="#888">Transmit</text>
<circle cx="210" cy="140" r="30" fill="#1a1a18" stroke="#444" stroke-width="2"/>
<circle cx="210" cy="140" r="20" fill="#333"/>
<circle cx="210" cy="140" r="10" fill="#555"/>
<text x="210" y="188" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" fill="#6b6a65">R</text>
<text x="210" y="200" text-anchor="middle" font-family="Manrope,sans-serif" font-size="9" fill="#888">Receive</text>
<!-- Pins across the bottom -->
<line x1="80" y1="200" x2="80" y2="220" stroke="#c8420a" stroke-width="2"/>
<circle cx="80" cy="222" r="4" fill="#c8420a"/>
<text x="80" y="240" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">VCC</text>
<line x1="120" y1="200" x2="120" y2="220" stroke="#c8420a" stroke-width="2"/>
<circle cx="120" cy="222" r="4" fill="#c8420a"/>
<text x="120" y="240" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">TRIG</text>
<line x1="160" y1="200" x2="160" y2="220" stroke="#1a6b4a" stroke-width="2"/>
<circle cx="160" cy="222" r="4" fill="#1a6b4a"/>
<text x="160" y="240" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1a6b4a">ECHO</text>
<line x1="200" y1="200" x2="200" y2="220" stroke="#444" stroke-width="2"/>
<circle cx="200" cy="222" r="4" fill="#444"/>
<text x="200" y="240" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">GND</text>
<text x="160" y="100" text-anchor="middle" font-family="Manrope,sans-serif" font-size="9" fill="#888">TOP-DOWN VIEW — beam cone</text>
<!-- Beam cone (right side) -->
<text x="470" y="50" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" letter-spacing="1" fill="#3d3c38">BEAM CONE</text>
<!-- Robot icon -->
<rect x="390" y="108" width="40" height="28" rx="3" fill="#edeae0" stroke="#c4bfb0" stroke-width="1.5"/>
<text x="410" y="126" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#3d3c38">robot</text>
<!-- Cone lines -->
<line x1="430" y1="122" x2="600" y2="90" stroke="#1f4d8c" stroke-width="1" stroke-dasharray="5 3" opacity=".7"/>
<line x1="430" y1="122" x2="600" y2="154" stroke="#1f4d8c" stroke-width="1" stroke-dasharray="5 3" opacity=".7"/>
<!-- Filled cone area -->
<polygon points="430,122 600,90 600,154" fill="#1f4d8c" opacity=".08"/>
<!-- Arc at 50cm -->
<path d="M490 96 Q510 122 490 148" fill="none" stroke="#1f4d8c" stroke-width="1" stroke-dasharray="3 2" opacity=".5"/>
<text x="496" y="122" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1f4d8c">50 cm</text>
<!-- Arc at 150cm edge of view -->
<path d="M576 95 Q594 122 576 149" fill="none" stroke="#1f4d8c" stroke-width="1" opacity=".3"/>
<text x="610" y="100" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">max</text>
<text x="610" y="112" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">~400cm</text>
<!-- Angle labels -->
<text x="448" y="92" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1f4d8c">~15°</text>
<text x="448" y="158" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1f4d8c">~15°</text>
<!-- Spec box -->
<rect x="390" y="170" width="210" height="48" rx="3" fill="#1a1a18"/>
<text x="400" y="185" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8c4bc">Range 2 – 400 cm · ±3 mm · 40 kHz</text>
<text x="400" y="200" font-family="Manrope,sans-serif" font-size="9" fill="#888">Dead zone: <2 cm · Max rate: ~40 readings/sec</text>
</svg>
Fig 8.2: HC-SR04 front face showing the two transducer eyes (T = transmitter, R = receiver) and the four pin positions. The beam spreads at roughly ±15°. Objects within the cone at up to 400 cm will reflect. Objects outside the cone, or very close to its edge, may not return a usable echo.
Pin reference
| Pin | Direction | Logic level | Description |
|---|---|---|---|
| VCC | Power in | 5 V | Connect to Arduino 5 V. Draws ~15 mA during a measurement cycle. |
| GND | Ground | 0 V | Must share a common ground with the Arduino. |
| TRIG | Input (from Arduino) | 5 V digital | A HIGH pulse of ≥10 µs triggers one distance measurement. |
| ECHO | Output (to Arduino) | 5 V digital | Goes HIGH when the burst has been fired and the sensor is listening; stays HIGH until the echo returns. Pulse width equals round-trip travel time in microseconds. |
ECHO is 5 V; no level shifter needed. In Module 7, the HC-05's RX pin required a voltage divider because it couldn't tolerate 5 V input. The HC-SR04 is different: the Arduino's digital input pins are 5 V tolerant and receive the ECHO signal directly. One fewer component, but understanding the reason matters. Always check a new module's voltage tolerance before wiring. The question to ask: "Does this pin output or receive 3.3 V signals only, or is it 5 V tolerant?" The datasheet answers it.
8.3 Wiring the HC-SR04
The sensor needs four connections. Pin selection for TRIG and ECHO follows the same avoidance logic as previous modules: skip D0/D1 (USB serial), D2/D3 (Bluetooth from Module 7), and D5–D10 (motor driver). D13's built-in LED creates a pull-down that makes it unreliable as a digital input; avoid it for sensor signal pins. That leaves D4, D11, and D12 as clean digital options. Use D11 for TRIG and D12 for ECHO.
<svg viewBox="0 0 760 280" width="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="aW8" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M2 2L8 5L2 8" fill="none" stroke="#1a1a18" stroke-width="1.5" stroke-linecap="round"/></marker>
</defs>
<!-- ARDUINO UNO -->
<rect x="40" y="40" width="160" height="200" rx="5" fill="#1a6b4a" stroke="#0e4030" stroke-width="2"/>
<text x="120" y="64" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="11" fill="#a8e8c8">ARDUINO</text>
<text x="120" y="80" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#60b080">Uno</text>
<!-- Pin labels on right side -->
<circle cx="200" cy="110" r="5" fill="#c8420a" stroke="#7a2000" stroke-width="1"/>
<text x="210" y="114" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">D11</text>
<circle cx="200" cy="130" r="5" fill="#1a6b4a" stroke="#0e4030" stroke-width="1"/>
<text x="210" y="134" font-family="IBM Plex Mono,monospace" font-size="9" fill="#a8e8c8">D12</text>
<circle cx="200" cy="160" r="5" fill="#c8420a" stroke="#7a2000" stroke-width="1"/>
<text x="210" y="164" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">5V</text>
<circle cx="200" cy="180" r="5" fill="#444" stroke="#222" stroke-width="1"/>
<text x="210" y="184" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">GND</text>
<!-- HC-SR04 -->
<rect x="500" y="80" width="180" height="120" rx="5" fill="#edeae0" stroke="#c4bfb0" stroke-width="2"/>
<text x="590" y="104" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="11" fill="#3d3c38">HC-SR04</text>
<!-- Transducer circles -->
<circle cx="555" cy="148" r="22" fill="#1a1a18" stroke="#444" stroke-width="1.5"/>
<circle cx="555" cy="148" r="12" fill="#333"/>
<circle cx="625" cy="148" r="22" fill="#1a1a18" stroke="#444" stroke-width="1.5"/>
<circle cx="625" cy="148" r="12" fill="#333"/>
<!-- Pins on left side of HC-SR04 -->
<circle cx="500" cy="100" r="4" fill="#c8420a"/>
<text x="494" y="104" text-anchor="end" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">VCC</text>
<circle cx="500" cy="118" r="4" fill="#c8420a"/>
<text x="494" y="122" text-anchor="end" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">TRIG</text>
<circle cx="500" cy="136" r="4" fill="#1a6b4a"/>
<text x="494" y="140" text-anchor="end" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1a6b4a">ECHO</text>
<circle cx="500" cy="154" r="4" fill="#444"/>
<text x="494" y="158" text-anchor="end" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">GND</text>
<!-- Wires -->
<!-- 5V to VCC -->
<line x1="205" y1="160" x2="360" y2="160" stroke="#c8420a" stroke-width="1.5"/>
<line x1="360" y1="160" x2="360" y2="100" stroke="#c8420a" stroke-width="1.5"/>
<line x1="360" y1="100" x2="496" y2="100" stroke="#c8420a" stroke-width="1.5"/>
<text x="290" y="156" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">5V (red)</text>
<!-- GND to GND -->
<line x1="205" y1="180" x2="380" y2="180" stroke="#444" stroke-width="1.5"/>
<line x1="380" y1="180" x2="380" y2="154" stroke="#444" stroke-width="1.5"/>
<line x1="380" y1="154" x2="496" y2="154" stroke="#444" stroke-width="1.5"/>
<text x="290" y="192" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#888">GND (black)</text>
<!-- D11 to TRIG -->
<line x1="205" y1="110" x2="340" y2="110" stroke="#c8b400" stroke-width="1.5"/>
<line x1="340" y1="110" x2="340" y2="118" stroke="#c8b400" stroke-width="1.5"/>
<line x1="340" y1="118" x2="496" y2="118" stroke="#c8b400" stroke-width="1.5"/>
<text x="310" y="106" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8b400">TRIG (D11)</text>
<!-- D12 to ECHO -->
<line x1="205" y1="130" x2="320" y2="130" stroke="#1a6b4a" stroke-width="1.5"/>
<line x1="320" y1="130" x2="320" y2="136" stroke="#1a6b4a" stroke-width="1.5"/>
<line x1="320" y1="136" x2="496" y2="136" stroke="#1a6b4a" stroke-width="1.5"/>
<text x="310" y="146" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1a6b4a">ECHO (D12)</text>
<!-- Wire key -->
<rect x="40" y="256" width="680" height="18" rx="2" fill="#1a1a18"/>
<line x1="50" y1="265" x2="80" y2="265" stroke="#c8420a" stroke-width="1.5"/>
<text x="84" y="269" font-family="Manrope,sans-serif" font-size="9" fill="#888">5V</text>
<line x1="120" y1="265" x2="150" y2="265" stroke="#444" stroke-width="1.5"/>
<text x="154" y="269" font-family="Manrope,sans-serif" font-size="9" fill="#888">GND</text>
<line x1="190" y1="265" x2="220" y2="265" stroke="#c8b400" stroke-width="1.5"/>
<text x="224" y="269" font-family="Manrope,sans-serif" font-size="9" fill="#888">TRIG signal (D11→sensor)</text>
<line x1="400" y1="265" x2="430" y2="265" stroke="#1a6b4a" stroke-width="1.5"/>
<text x="434" y="269" font-family="Manrope,sans-serif" font-size="9" fill="#888">ECHO signal (sensor→D12)</text>
</svg>
Fig 8.3: HC-SR04 wiring. Four connections: VCC to 5V (red), GND to GND (black), TRIG to D11 (yellow), ECHO to D12 (green). No voltage divider required; the Arduino's digital inputs accept the 5V ECHO signal directly.
Sensor mounting
Mount the HC-SR04 at the front of the chassis, centered left-to-right, facing forward. Aim for a height of 10–15 cm, which corresponds to the mid-height of typical indoor obstacles (chair legs, door thresholds, cardboard boxes). A sensor aimed too high clears low obstacles; one aimed at the floor triggers on carpet texture and uneven surfaces.
Secure the sensor firmly. Foam tape, a small cardboard bracket, or two craft sticks hot-glued into an L-shape all work. If your chassis kit includes an L-bracket, use that. The sensor must point horizontally, not angled up or down. A sensor that tilts downward under driving vibration will detect the floor and stop the robot for no apparent reason; this is the most common HC-SR04 frustration for beginners.
Route the four HC-SR04 wires above the chassis surface, grouped alongside the existing motor harness. Use a rubber band or zip tie to keep them bundled and away from the wheel paths. A wire that droops below the chassis will catch in a drive wheel and disconnect mid-run.
The HC-SR04 reflects poorly off soft, angled, or sound-absorbing surfaces such as foam cushions, curtains, or objects at a steep angle to the beam axis. Hard, flat surfaces perpendicular to the beam (walls, flat boxes, chair legs) give strong, consistent returns. Keep this in mind when selecting a test environment for the lab.
8.4 Reading distance in code
Arduino's pulseIn() function does exactly what the timing diagram showed: it waits for a specified pin to go HIGH, starts a microsecond timer, waits for the pin to go LOW, and returns the elapsed time. Three lines control the full measurement cycle.
// 1. Ensure TRIG starts LOW (clears any residual state)
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
// 2. Fire the trigger pulse — minimum 10 µs HIGH
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
// 3. Measure how long ECHO stays HIGH — timeout at 30 ms
long duration = pulseIn(ECHO_PIN, HIGH, 30000);
The timeout parameter in pulseIn() is critical. Without it, an absent echo (open space, object out of range) blocks the program indefinitely. 30 000 µs = 30 ms covers a 5-metre round trip, well beyond the sensor's 4-metre spec. When pulseIn times out it returns 0. Map that to 400 (the sensor's maximum range) rather than dividing zero by 58, which would give an incorrect "obstacle at 0 cm" reading.
const int TRIG_PIN = 11;
const int ECHO_PIN = 12;
const int STOP_DISTANCE_CM = 20; // stop this many cm from an obstacle
long readDistance() { // long: covers full pulseIn range (0–65535 µs) without overflow
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
long duration = pulseIn(ECHO_PIN, HIGH, 30000);
if (duration == 0) return 400; // timeout → open space
return duration / 58;
}
Test the sensor alone first
Before wiring the reading into motor control, confirm the sensor works in isolation. This is the subsystem-first principle from Module 7: test one component before integrating.
void setup() {
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
Serial.begin(9600);
}
void loop() {
long dist = readDistance();
Serial.print("Distance: ");
Serial.print(dist);
Serial.println(" cm");
delay(100); // 10 readings/second — easy to watch in the monitor
}
Upload this, open Serial Monitor at 9600 baud, and move your hand toward and away from the sensor. Readings should track smoothly. If you see all 400s, TRIG is disconnected or GND is missing. If you see erratic jumps between real values and 400, the ECHO wire is intermittent; press it firmly into the breadboard.
Why readings vary slightly: Even with a stationary target, consecutive readings typically vary ±1–3 cm. The main cause is small aim variations between successive pulses; the burst isn't a laser and consecutive pulses diverge slightly. Temperature is a secondary factor: the /58 constant assumes 20 °C; a 10 °C change shifts readings by about 1.7%. For obstacle avoidance at robot scale this variation is irrelevant. For precise distance measurement, take three rapid readings and use the median value, a two-line improvement that eliminates single-sample glitches.
8.5 First autonomous behavior: obstacle stop
Combining readDistance() with the motor functions from Module 4 gives the robot its first reactive behavior: it drives forward until it detects an obstacle, then stops. This is the simplest possible form of the sensing-actuation loop, the architectural backbone of all autonomous robotics.
Sense → Decide → Act. Every autonomous behavior, from a simple obstacle stop to a Mars rover path planner, reduces to this loop.
<svg viewBox="0 0 760 200" width="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="aL8" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 2L8 5L2 8" fill="none" stroke="#1a1a18" stroke-width="1.5" stroke-linecap="round"/></marker>
<marker id="aR8" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 2L8 5L2 8" fill="none" stroke="#c8420a" stroke-width="1.5" stroke-linecap="round"/></marker>
<marker id="aG8b" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 2L8 5L2 8" fill="none" stroke="#1a6b4a" stroke-width="1.5" stroke-linecap="round"/></marker>
</defs>
<!-- loop() label -->
<text x="30" y="30" font-family="IBM Plex Mono,monospace" font-size="10" fill="#888">loop() repeating forever</text>
<!-- readDistance() box -->
<rect x="30" y="50" width="150" height="50" rx="4" fill="#daeaf5" stroke="#1f4d8c" stroke-width="1.5"/>
<text x="105" y="72" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" fill="#1f4d8c">readDistance()</text>
<text x="105" y="88" text-anchor="middle" font-family="Manrope,sans-serif" font-size="10" fill="#2c4a6e">SENSE</text>
<!-- Arrow to decision -->
<line x1="180" y1="75" x2="240" y2="75" stroke="#1a1a18" stroke-width="1.5" marker-end="url(#aL8)"/>
<text x="210" y="68" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#6b6a65">dist</text>
<!-- Decision diamond -->
<polygon points="290,45 360,75 290,105 220,75" fill="#f5eed8" stroke="#c8aa60" stroke-width="1.5"/>
<text x="290" y="71" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#8a6800">dist <</text>
<text x="290" y="85" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="9" fill="#8a6800">STOP_CM?</text>
<!-- YES branch → stopMotors -->
<line x1="290" y1="105" x2="290" y2="150" stroke="#c8420a" stroke-width="1.5" marker-end="url(#aR8)"/>
<text x="300" y="132" font-family="IBM Plex Mono,monospace" font-size="9" fill="#c8420a">YES</text>
<rect x="220" y="152" width="140" height="36" rx="4" fill="#fde8e8" stroke="#c8420a" stroke-width="1.5"/>
<text x="290" y="168" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" fill="#c8420a">stopMotors()</text>
<text x="290" y="182" text-anchor="middle" font-family="Manrope,sans-serif" font-size="9" fill="#a03010">ACT</text>
<!-- NO branch → driveForward -->
<line x1="360" y1="75" x2="430" y2="75" stroke="#1a6b4a" stroke-width="1.5" marker-end="url(#aG8b)"/>
<text x="395" y="68" font-family="IBM Plex Mono,monospace" font-size="9" fill="#1a6b4a">NO</text>
<rect x="430" y="52" width="150" height="46" rx="4" fill="#d8f0e5" stroke="#1a6b4a" stroke-width="1.5"/>
<text x="505" y="72" text-anchor="middle" font-family="IBM Plex Mono,monospace" font-size="10" fill="#1a6b4a">driveForward()</text>
<text x="505" y="88" text-anchor="middle" font-family="Manrope,sans-serif" font-size="9" fill="#0e3d28">ACT</text>
<!-- Loop back arrow -->
<path d="M505 98 Q505 170 105 170 Q105 105 105 100" fill="none" stroke="#888" stroke-width="1" stroke-dasharray="4 3" marker-end="url(#aL8)"/>
</svg>
Fig 8.4: The sensing-actuation loop. Every pass through loop() reads the sensor, makes one binary decision, and commands the motors accordingly. More complex behaviors add states and additional sensors; the loop structure stays the same.
Complete obstacle-stop sketch
// ── Pin constants ─────────────────────────────────────────────────────────────
const int ENA = 5; const int ENB = 6;
const int IN1 = 7; const int IN2 = 8;
const int IN3 = 9; const int IN4 = 10;
const int TRIG_PIN = 11;
const int ECHO_PIN = 12;
// ── Tuning constants ──────────────────────────────────────────────────────────
const int RTRIM = 0; // from Module 5 calibration — update with your value
const int CRUISE = 180; // forward drive speed (0–255)
const int STOP_DISTANCE_CM = 20; // stop this many cm from an obstacle
// ── Motor functions ───────────────────────────────────────────────────────────
void stopMotors() {
analogWrite(ENA, 0);
analogWrite(ENB, 0);
}
void driveForward(int speed) {
digitalWrite(IN1, HIGH); digitalWrite(IN2, LOW);
digitalWrite(IN3, HIGH); digitalWrite(IN4, LOW);
analogWrite(ENA, speed);
analogWrite(ENB, speed - RTRIM);
}
// ── Distance sensor ───────────────────────────────────────────────────────────
long readDistance() { // long: covers full pulseIn range (0–65535 µs) without overflow
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
long dur = pulseIn(ECHO_PIN, HIGH, 30000);
if (dur == 0) return 400; // timeout → open space sentinel
return dur / 58;
}
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
pinMode(ENA, OUTPUT); pinMode(ENB, OUTPUT);
pinMode(IN1, OUTPUT); pinMode(IN2, OUTPUT);
pinMode(IN3, OUTPUT); pinMode(IN4, OUTPUT);
stopMotors(); // safe state on power-up
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
Serial.begin(9600);
}
// ── Main loop ─────────────────────────────────────────────────────────────────
void loop() {
long dist = readDistance();
Serial.print("Distance: ");
Serial.print(dist);
Serial.println(" cm");
if (dist < STOP_DISTANCE_CM) {
stopMotors();
} else {
driveForward(CRUISE);
}
delay(50); // 20 readings/sec — sensor needs ~20 ms recovery between pulses
}
The delay(50) serves two purposes: it gives the HC-SR04 time to reset between readings (faster than ~20 ms risks picking up echoes from the previous pulse as "ghost" readings), and it caps the loop at 20 iterations per second, fast enough for obstacle avoidance at walking speed and slow enough that Serial Monitor output is readable.
Tuning STOP_DISTANCE_CM
The right stopping distance depends on your robot's speed. At CRUISE=180, a typical chassis takes 5–10 cm to coast to a stop after motors cut. STOP_DISTANCE_CM=20 provides a comfortable 10–15 cm safety margin beyond that. If the robot still makes contact with the obstacle, increase STOP_DISTANCE_CM by 5 and re-test. If it stops too early to be useful, reduce by 5. The minimum safe value is roughly (coasting distance + 5 cm). Reducing CRUISE to 120 shortens the coasting distance and lets you use a tighter stopping threshold.
Advanced path: median filtering for noisy readings. A single reading can be wrong: floor reflections, electrical noise, or a narrow object at the beam edge produce occasional bad samples. A two-line improvement: take three readings in rapid succession and return the median (sort three values, return the middle one). This eliminates single-sample glitches without adding measurable latency, since each
readDistance()call takes less than 30 ms. In Module 10, the state-machine architecture provides a cleaner solution: require the reading to exceed the threshold for N consecutive passes before changing state. That approach handles sustained sensor noise, not just individual bad samples, and it teaches a pattern that scales to any sensor type.
Hardware sanity check before you continue. Upload the sensor-only test sketch from section 8.4 and open Serial Monitor at 9600 baud. Point the sensor at the ceiling. You should read approximately 200 cm (adjust for ceiling height). If you read 400 on every line, the TRIG or ECHO wire is disconnected; re-check both. If you read 0 or erratic values, press each jumper firmly into the breadboard and re-test. Do not proceed to the motor integration until the sensor gives smooth, sensible readings.
Lab 08: Build an obstacle-stopping robot
Wire the HC-SR04, verify it works alone in Serial Monitor, then integrate with the motor sketch. The goal: the robot drives toward a wall and stops before touching it, repeatably, every run.
What you need: Completed robot from Module 07 · HC-SR04 module · jumper wires (4) · foam tape or cardboard for sensor mount · rubber band or zip tie for wire management
Disconnect all power. Mount the HC-SR04 at the front of the chassis, centered and forward-facing at approximately mid-chassis height. Secure it with foam tape or a cardboard bracket so it won't tilt under vibration.
Wire the sensor: VCC to Arduino 5V, GND to GND, TRIG to D11, ECHO to D12. Check all four connections before powering on.
Upload the sensor-only test sketch (read and print only, no motor code). Open Serial Monitor at 9600 baud. Move your hand toward the sensor from about 1 metre. Verify readings decrease smoothly as your hand approaches. Hold your hand at 30 cm; the reading should stabilize near 28–32 cm. Erratic jumps usually mean a loose ECHO wire.
Test the dead zone: slowly bring your hand to within 2 cm. Note the distance at which readings become unreliable; don't set
STOP_DISTANCE_CMbelow that value plus 5 cm.Sensor mount check: press the sensor bracket firmly and hold for 10 seconds. Gently tug the sensor body; it shouldn't flex or shift. Verify the sensor face aims horizontally forward, not downward. A sensor that tilts toward the floor will detect it and stop the robot for no apparent reason.
Before the first autonomous run: place the robot on the floor with at least 80 cm of clear space ahead. Keep hands and fingers away from the drive wheels and clear of the sensor beam path. Have your finger on the power switch; if the robot doesn't stop before the wall, cut power immediately. This is its first autonomous run.
Upload the complete obstacle-stop sketch with
STOP_DISTANCE_CM = 20. Place the robot on the floor facing a flat wall, about 60 cm away. Power on. The robot should drive forward and stop before the wall. If it makes contact, increaseSTOP_DISTANCE_CMby 5. If it stops too far away, reduce by 5. Tune in increments of 5.Move the robot to different starting distances (30 cm, 80 cm, 120 cm) and re-run. The stop position should be consistent within ±3 cm across all runs.
Extra: aim the sensor at different surfaces such as a cloth cushion, a narrow chair leg, or a glass door. Note which surfaces give reliable readings and which produce 400 (no echo). This is real-world sensor characterization, and the results will guide where you mount it in later modules.
Module milestone: Phase 3 begins. The robot drives toward a wall and stops before touching it, consistent to within ±3 cm of
STOP_DISTANCE_CM, across multiple starting positions. Sensor readings print correctly in Serial Monitor. The HC-SR04 is characterized for your chassis. Ready for Module 9: Non-blocking Timing withmillis().
Self-Check: Module 08
- I can explain time-of-flight ranging: the sensor measures the round-trip duration of a sound pulse and divides by 58 to get centimetres.
- I know the HC-SR04's usable range (2–400 cm) and why the 2 cm dead zone exists.
- My
readDistance()function includes apulseIn()timeout and returns 400 (not 0) when no echo is detected. - I verified the sensor alone in Serial Monitor before integrating it with the motor code.
- I understand why D13 is avoided for sensor signal pins (built-in LED pull-down).
- The robot stops before the wall consistently across at least three different starting distances.
- I have tuned
STOP_DISTANCE_CMto account for my robot's coasting distance at CRUISE speed. - I have characterized the sensor against at least one hard surface and one soft/angled surface and noted the difference.
Key Terms Glossary
| Term | Definition |
|---|---|
| time of flight | A ranging method that measures how long a signal takes to travel to a target and back. Distance = (speed × time) / 2. Used by ultrasonic sensors, LiDAR, and radar. |
| ultrasonic | Sound above 20 kHz, above human hearing range. The HC-SR04 uses 40 kHz. Higher frequencies produce shorter wavelengths, improving ranging resolution but reducing penetration distance. |
pulseIn() |
Arduino function that measures the duration of a HIGH (or LOW) pulse on a pin, in microseconds. The third parameter is a timeout; if the pulse doesn't arrive within that time, returns 0. |
| sensor dead zone | The minimum distance at which a sensor returns valid readings. HC-SR04 dead zone is ~2 cm; closer than this, the outgoing burst and its echo overlap, producing meaningless output. |
| sensing-actuation loop | The core pattern of reactive robotics: read a sensor, make a decision, command actuators, repeat. Loop rate determines the robot's responsiveness to its environment. |
| beam angle | The angular spread of the sensor's detection cone. The HC-SR04 detects within about ±15° of its pointing direction. Objects outside this cone return too little energy to trigger a reading. |
timeout (pulseIn) |
Maximum microseconds pulseIn() waits for a pulse before giving up. Without it, an absent echo blocks the entire program. Set to 30 000 µs for this module; covers a 5 m round trip. |
| sentinel value | A special return value that signals an exceptional condition rather than a real measurement. readDistance() returns 400 on timeout: not a measurement, but a safe fallback that downstream logic can use directly. |
Previous: ← Module 07: Remote Control via Bluetooth · Next: Module 09: Non-blocking Timing with millis() →