Digital and Analog: Two Different Questions
Every sensor you connect answers one of two questions. A digital sensor answers yes or no: is the button pressed, is there a black line under me, has the beam been broken. An analog sensor answers how much: how bright, how warm, how far the knob is turned. Which kind you have decides which function you read it with, and mixing them up is a first-week rite of passage.
For digital sensors you use digitalRead(pin), which returns one of exactly two values, HIGH or LOW. For analog sensors you use analogRead(pin) on one of the A0–A5 pins, and it returns a whole number from 0 to 1023. That range is not arbitrary: the board's analog-to-digital converter measures the incoming voltage in 1024 steps, where 0 means 0 V and 1023 means the top of the measuring range, which on an Uno is its 5 V supply.
Now the asymmetry that catches almost everybody. analogRead() gives you a number from 0 to 1023, but analogWrite() — the function that sets a PWM output — takes a number from 0 to 255. They are different ranges because they are different mechanisms, not two halves of one feature. Feeding a raw analogRead result straight into analogWrite produces bizarre behaviour: anything above 255 wraps around, so turning a knob smoothly makes a motor speed up, snap back to nothing, speed up again, four times over. The fix is map(), which rescales one range onto another.
There is a second trap in the same area. The analog input pins can read a varying voltage; they cannot output one. Nothing on a standard Arduino Uno outputs a true varying voltage. When you want an LED at half brightness or a motor at half speed, what you actually use is PWM, which the next section explains properly.
digitalRead(pin)— returnsHIGHorLOW; use for buttons, line sensors, obstacle modulesanalogRead(A0)— returns 0 to 1023; use for potentiometers, light sensors, analog temperature sensorsdigitalWrite(pin, HIGH)— puts the pin fully on or fully offanalogWrite(pin, 0–255)— PWM on a~pin; used for brightness and motor speedmap(value, 0, 1023, 0, 255)— the standard way to convert a sensor range into an output range
map()only rescales, it does not clamp. If the input strays outside the range you declared, the result strays outside the output range too. Wrapping it inconstrain(value, 0, 255)guarantees a safe number reaches your motor.
Buttons, Floating Inputs and INPUT_PULLUP
A push button seems like the easiest possible sensor, and it is the one that produces the most baffling first bug. Wire a button between a pin and 5 V, set the pin as INPUT, and read it. Pressed, it reads HIGH — correct. Released, it reads... whatever it feels like. Sometimes HIGH, sometimes LOW, often flickering between them if you move your hand nearby.
The reason is that a released button connects the pin to nothing. An input pin is extremely sensitive and draws almost no current, so with nothing attached it is not at 0 V; it simply has no defined voltage at all. It picks up electrical noise from the room, from the mains wiring in the wall, even from your body acting as an antenna. This is called a floating input, and it is not a fault in your board — it is what an unconnected input pin does.
The cure is a resistor that gently ties the pin to a known voltage whenever the button is not doing so. A pull-up resistor connects the pin to 5 V, so the pin sits HIGH by default; pressing a button that connects the pin to ground pulls it firmly LOW. A pull-down resistor does the mirror image. Either works. The resistor is large enough that it does not fight the button when pressed, but small enough to overwhelm stray noise when it is not.
The good news is that you rarely need to add that resistor yourself. The Arduino chip has pull-up resistors built in, and pinMode(pin, INPUT_PULLUP) switches one on. Wire the button between the pin and GND, use INPUT_PULLUP, and you are done — no extra components, no floating. The one thing to remember is that the logic inverts: the pin reads HIGH when the button is not pressed and LOW when it is. Beginners often write the if the wrong way round the first time and conclude the button is broken.
// Button wired between pin 2 and GND - no external resistor needed.
const int buttonPin = 2;
const int ledPin = 13;
void setup() {
pinMode(buttonPin, INPUT_PULLUP); // internal pull-up switched on
pinMode(ledPin, OUTPUT);
Serial.begin(9600);
}
void loop() {
int reading = digitalRead(buttonPin);
// NOTE the inverted logic: LOW means pressed with a pull-up.
if (reading == LOW) {
digitalWrite(ledPin, HIGH);
Serial.println("pressed");
} else {
digitalWrite(ledPin, LOW);
}
} - If a button seems to work but is unreliable, check the wiring against the mode.
INPUT_PULLUPexpects the button to connect the pin to GND. Wiring it to 5 V instead leaves the pin permanently HIGH and it will never register a press. - The same floating problem affects any unused input pin you read by accident. If a sensor is disconnected, do not trust its readings — they are noise, not zeroes.
Switch Bounce: Why One Press Counts as Four
Write a sketch that counts button presses and you will meet the next surprise: one deliberate press sometimes adds three or five to the count. Your code is fine. The button is fine. The problem is mechanical.
A push button works by pressing two metal contacts together, and metal contacts are springy. In the few milliseconds while they meet, they touch, bounce apart, touch again, and settle — several times. To you it is one press. To a board sampling the pin thousands of times a second, it is a rapid burst of separate presses, all genuinely there. This is called switch bounce, and every mechanical switch does it, including the good ones.
The fix is debouncing: deciding to ignore any change that happens too soon after the last one. Since a human cannot press a button twice in a few milliseconds, anything arriving that fast must be bounce. In practice you record the time of the last accepted change and refuse to accept another until a short window has passed. A few tens of milliseconds is the usual choice — long enough to swallow the bounce, short enough that the button still feels instant.
Note what the code below is not doing. It does not call delay() to wait out the bounce. That would work for a demonstration, but it stops the entire robot for the duration, and on a moving robot that means the wheels keep turning while the board ignores its distance sensor. Debouncing with recorded times leaves the loop free to do everything else, which is the pattern the next section generalises.
const int buttonPin = 2;
int pressCount = 0;
int lastReading = HIGH; // pull-up: HIGH means not pressed
unsigned long lastChangeTime = 0;
const unsigned long debounceDelay = 50; // milliseconds to ignore bounce
void setup() {
pinMode(buttonPin, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
int reading = digitalRead(buttonPin);
if (reading != lastReading) {
// something changed - but is it real, or bounce?
if (millis() - lastChangeTime > debounceDelay) {
lastChangeTime = millis();
if (reading == LOW) { // a genuine new press
pressCount++;
Serial.print("presses: ");
Serial.println(pressCount);
}
}
lastReading = reading;
}
// the loop stays free - other sensors can be read here too
} - Debouncing is needed for mechanical contacts: push buttons, limit switches, reed switches, tilt switches. Solid-state sensors such as infrared modules do not bounce, though they can still flicker at the exact edge of detection for a different reason.
delay() Blocks Everything — Use millis() Instead
delay() is the first Arduino function everyone learns and the first one that has to be unlearned. It does not schedule anything; it simply stops. During delay(1000) the board sits there doing nothing at all for a full second. It will not read a sensor, will not notice a button, will not react to an obstacle. Nothing you have written elsewhere in the sketch runs.
For a blinking LED that is harmless. On a moving robot it is a real problem. Imagine an obstacle-avoiding car whose loop reads the distance sensor and then calls delay(500) to slow the readings down. That car is blind for half of every second — but its wheels never stop. At any reasonable speed, a wall can appear and be reached entirely inside one blind period. The robot then hits it and, worse, the sketch looks completely correct when you read it.
The alternative is to stop waiting and start checking the clock. millis() returns the number of milliseconds since the board powered up. Rather than freezing for a second, you record the time something last happened and, on every pass through loop(), ask whether enough time has gone by yet. If not, you do nothing and move on to the rest of the loop. The loop keeps spinning at full speed, so every sensor is read constantly, and several timed things can run at once at different rates.
Write the comparison as millis() - lastTime >= interval rather than millis() >= lastTime + interval, and store your times in unsigned long. Both details exist for the same reason: millis() eventually reaches the top of its range and wraps back to zero, and the subtraction form keeps working correctly across that wrap while the addition form does not. Storing a time in an int is much worse — it overflows after about half a minute and your robot starts misbehaving right when you have decided it works.
// Blink an LED every 500 ms AND print a sensor reading every 2 s,
// while the loop stays free to react instantly to a button.
const int ledPin = 13;
const int buttonPin = 2;
const int knobPin = A0;
unsigned long lastBlink = 0;
unsigned long lastReport = 0;
const unsigned long blinkInterval = 500;
const unsigned long reportInterval = 2000;
bool ledState = false;
void setup() {
pinMode(ledPin, OUTPUT);
pinMode(buttonPin, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
unsigned long now = millis();
// job 1: blink, without stopping anything else
if (now - lastBlink >= blinkInterval) {
lastBlink = now;
ledState = !ledState;
digitalWrite(ledPin, ledState ? HIGH : LOW);
}
// job 2: report, at its own slower rate
if (now - lastReport >= reportInterval) {
lastReport = now;
Serial.print("knob: ");
Serial.println(analogRead(knobPin));
}
// job 3: react to the button IMMEDIATELY - no delay is blocking us
if (digitalRead(buttonPin) == LOW) {
Serial.println("stop requested");
}
} delay()is not forbidden. It is fine insetup(), fine while testing a single component, and fine for the very short waits a sensor's datasheet requires, such as the microsecond-scale pulse an ultrasonic sensor needs. It becomes a problem the moment your robot has to watch the world while waiting.
Worked Example: the HC-SR04 Ultrasonic Sensor
The HC-SR04 is the distance sensor on most beginner robots, and reading it teaches a pattern you will meet again: send a trigger, then measure how long a response pin stays high.
It has four pins. VCC and GND are power. TRIG is an input to the sensor — you pulse it briefly to say "measure now". ECHO is an output from the sensor — it goes HIGH when the sound burst leaves and returns LOW when the echo arrives, so the length of time it stays HIGH is the round-trip travel time of the sound.
Converting that time to distance uses one physical fact: sound travels roughly 0.034 centimetres per microsecond in air. Multiply the duration by that figure and you get the distance the sound covered — but that is the round trip, out to the object and back, so divide by two. That is the whole of the arithmetic in the sketch below, and knowing where each number comes from means you can debug it instead of copying it.
The practical problems are worth knowing before they confuse you. If the sensor points at a smooth wall at a steep angle, the sound reflects away like light off a mirror and never returns, so pulseIn() waits and eventually gives up, returning 0 — which your arithmetic turns into a distance of 0 cm, meaning "something is touching me". A robot that suddenly panics in the middle of an open room is usually seeing this. Very close objects and very distant ones both read unreliably too, and soft surfaces such as curtains absorb the sound rather than reflecting it. Treat a reading of 0, or an implausibly large one, as "no valid measurement" rather than as a real distance.
const int trigPin = 9;
const int echoPin = 10;
void setup() {
pinMode(trigPin, OUTPUT);
pinMode(echoPin, INPUT);
Serial.begin(9600);
}
float readDistanceCm() {
// a clean 10-microsecond trigger pulse
digitalWrite(trigPin, LOW);
delayMicroseconds(2);
digitalWrite(trigPin, HIGH);
delayMicroseconds(10);
digitalWrite(trigPin, LOW);
// how long does ECHO stay HIGH? that is the round-trip time
long duration = pulseIn(echoPin, HIGH);
// sound covers about 0.034 cm per microsecond; halve for the return trip
return duration * 0.034 / 2;
}
void loop() {
float distance = readDistanceCm();
if (distance <= 0 || distance > 400) {
Serial.println("no valid reading"); // do NOT treat this as 0 cm
} else {
Serial.print("distance: ");
Serial.print(distance);
Serial.println(" cm");
}
delay(60); // give the previous echo time to fade before triggering again
} - Reading the sensor too rapidly lets the tail of the previous burst be mistaken for the next echo, producing readings that jump about. Leaving a short gap between measurements, as above, avoids it.
- Always print raw sensor readings to the Serial Monitor before you connect a sensor to any decision. Half of all robot bugs are a sensor reporting something other than what you assumed, and one minute of printing saves an evening of rewriting logic that was never wrong.
