Learning Goals 5 min
- Display a value that changes over time (e.g. a counter) without ghosting — the classic LCD beginner trap.
- Pick the right tool for each refresh:
lcd.clear()(heavy, slow, full wipe), overwriting with spaces (selective, fast), or printing fixed-width values (no overwrite needed). - Position the cursor precisely with
setCursor(col, row)to build a static template once and update only the changing cells.
Warm-Up 10 min
Yesterday's sketches all printed static text in setup(). The moment you want the screen to show a live value — a count, a temperature, a distance — you hit the LCD's biggest gotcha: it doesn't auto-clear stale characters. Print "100", then a moment later print "9" in the same place, and you see 900.
Predict the bug
Sketch in your head: count up from 0 once per second, print on row 1, no clears. What does the row look like at second 12?
Reveal
After printing 10, then 11, the row shows 11. So far so good. After printing 12: still 12. Now what if at second 5 the count had reached 100 (because you sped it up)? You'd see 100. If the next count goes to 0 (a reset), print(0) writes "0" in column 0 → the row reads 000 for one second. Welcome to ghosting.
New Concept · Three ways to refresh 25 min
Option 1 — lcd.clear() on every update
Wipes the entire screen. Simple to think about. The problem: it's slow (~1.5 ms on the HD44780) and visible (you see a quick blank between updates). Acceptable for low-rate updates (once per second or slower); flickers badly at 10+ Hz.
void loop() {
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Count: ");
lcd.print(count);
count++;
delay(1000);
}Option 2 — overwrite with spaces
Print spaces over the area you want to clear, then write the new value. Fast and flicker-free, but you have to know how wide the field is. The L01-12-style way: write a helper.
void writeField(int col, int row, int width, int value) {
lcd.setCursor(col, row);
for (int i = 0; i < width; i++) lcd.print(' '); // wipe
lcd.setCursor(col, row);
lcd.print(value); // write new
}Three lines of work, but only touches a slice of the screen. The static text around it is undisturbed.
Option 3 — print fixed-width values
Best option when you control the value's width. Print numbers with leading spaces or zeroes so they always take exactly N columns. Then re-printing at the same position naturally overwrites the previous value cleanly.
// Print an integer in exactly 4 columns, right-aligned with spaces
void print4(int value) {
if (value < 10) lcd.print(" ");
else if (value < 100) lcd.print(" ");
else if (value < 1000) lcd.print(" ");
lcd.print(value);
}Tomorrow's lesson formalises this with the sprintf trick. Today, the manual version is fine.
The template-once pattern
The cleanest way to build a dynamic display is to write the static parts once (in setup()) and update only the dynamic cells (in loop()):
void setup() {
lcd.begin(16, 2);
lcd.setCursor(0, 0); lcd.print("Temp: "); // _____
lcd.setCursor(13, 0); lcd.print("\xDF" "C"); // °C
lcd.setCursor(0, 1); lcd.print("Status:");
}Now the screen permanently shows the labels Temp:, °C, Status:. The loop() only updates the value cells:
void loop() {
// refresh just the temperature digits
lcd.setCursor(6, 0);
lcd.print(" "); // wipe 5 chars
lcd.setCursor(6, 0);
lcd.print(temperature, 1); // e.g. "27.3"
// refresh just the status label
lcd.setCursor(8, 1);
lcd.print(" "); // wipe 8 chars
lcd.setCursor(8, 1);
lcd.print(statusLabel);
delay(500);
}"\xDF" is the degree symbol on most HD44780 character ROMs (decimal 223). The trailing "C" in a separate string is a C++ quirk — adjacent string literals concatenate, but writing "\xDFC" would be ambiguous (the compiler might parse \xDFC as one hex escape). Splitting them removes the ambiguity.
When to pick which
| Situation | Best refresh method |
|---|---|
| Update rate < 1 Hz, screen redesigns often | lcd.clear() + rewrite all |
| Update rate 1–10 Hz, value changes width | Overwrite with spaces |
| Update rate > 1 Hz, fixed-width values | Template-once + re-print fixed-width |
| Update rate > 30 Hz (animation) | Use a graphic display, not an HD44780 |
Worked Example · Live counter with template 20 min
Step 1 — wiring
Same LCD setup as yesterday. No new components.
Step 2 — the sketch (bare version)
Save as live-counter.ino:
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
int count = 0;
unsigned long lastTick = 0;
void setup() {
lcd.begin(16, 2);
// template: static labels, written once
lcd.setCursor(0, 0); lcd.print("Counter:");
lcd.setCursor(0, 1); lcd.print("Up since boot");
}
// Print an int right-aligned in 4 columns at (col,row)
void printInt4(int col, int row, int value) {
lcd.setCursor(col, row);
if (value < 10) lcd.print(" ");
else if (value < 100) lcd.print(" ");
else if (value < 1000) lcd.print(" ");
lcd.print(value);
}
void loop() {
if (millis() - lastTick >= 1000) {
lastTick = millis();
count++;
printInt4(12, 0, count); // 4-column field on row 0, column 12-15
}
}Step 3 — upload, watch the counter tick
The screen should show:
Counter: 0 Up since boot
The 0 sits at column 15 (right-aligned in a 4-wide field starting at column 12). Each second it increments. Watch carefully through 9 → 10 → 99 → 100: you should see no ghosting. The leading spaces always overwrite the previous digit.
Step 4 — try without the leading spaces
Temporarily replace the printInt4 body with just lcd.print(value). Re-upload. Watch the screen reach 9 → 10. Without the space-padding you'll see briefly something like 10 ... wait, this is fine when going UP. Now reset and let it run again; the bug only bites when a value shrinks. Set count = 999 in setup, then on each tick do count -= 100. Watch the screen go 999 → 899 → 799 → 99 with a leftover digit creating "199" → "099". Now you've seen ghosting in the wild.
Step 5 — restore fixed-width, observe quiet refresh
Put the printInt4 back. The same shrinking-count sequence now displays 999 → 899 → 799 → 99 cleanly with the leading space wiping the old hundreds digit. That's the fixed-width payoff.
Try It Yourself 20 min
Goal: Add a label secs after the counter (e.g. Counter: 123 secs... wait, the label would be at column 17 which doesn't exist on a 16-col LCD). Redesign the layout: shorten the static label to Up: and add sec after the number. Aim for the row to look like Up: 123 sec.
Hint
Lay it out on paper first. Cols 0–2: Up:. Cols 7–10: 4-wide number field. Cols 12–14: sec. That fits in 15 cols with room to spare. Set static text in setup, dynamic in loop with printInt4(7, 0, count).
Goal: Add a second dynamic field on row 1 that shows the count divided by 10 (a sub-counter). Use the same fixed-width helper. Both fields should refresh cleanly when they grow or shrink.
Hint
Same template + dynamic-field pattern, just twice:
printInt4(7, 0, count);
printInt4(7, 1, count / 10);The display now shows both at once and updates them together — but with no ghosting on either.
Goal: Show the elapsed time as mm:ss (e.g. 02:34). Tricky because the minutes part might be 1 or 2 digits, and seconds need a leading zero (e.g. 7 seconds → 07, not 7).
Hint
void printMMSS(int col, int row, int totalSeconds) {
int m = totalSeconds / 60;
int s = totalSeconds % 60;
lcd.setCursor(col, row);
if (m < 10) lcd.print('0');
lcd.print(m);
lcd.print(':');
if (s < 10) lcd.print('0');
lcd.print(s);
}5 characters wide, always. Looks like a clock. Next lesson's sprintf will collapse this into one line.
Mini-Challenge · Two-field debug display 15 min
Build a generic two-value debug display you can drop into any sketch. Spec:
- Row 0: a 8-char label followed by a 7-char value field (right-aligned).
- Row 1: same format, second variable.
- Helper function:
void setLabel(int row, const char* text)writes the label once. - Helper function:
void setValue(int row, long v)updates the value cleanly. - Demo: wire the potentiometer from L01-40 to A0 and a tilt switch from L01-39 to D2. Show the pot reading on row 0 (label "POT:") and the tilt count on row 1 (label "TILT:").
It's done when:
- Turning the pot updates the row 0 number smoothly with no ghosting.
- Tilting the switch increments the row 1 count.
- The labels never get accidentally overwritten by a value field.
- If you reset the Arduino, both rows redraw cleanly.
Reveal one valid sketch
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const int POT = A0;
const int TILT = 2;
long tiltCount = 0;
bool lastTilt = false;
unsigned long lastRefresh = 0;
void setLabel(int row, const char* text) {
lcd.setCursor(0, row);
for (int i = 0; i < 8; i++) lcd.print(i < (int)strlen(text) ? text[i] : ' ');
}
void setValue(int row, long v) {
char buf[8];
// 7-char right-aligned
for (int i = 0; i < 7; i++) buf[i] = ' ';
buf[7] = '\0';
// convert v to string in tail of buf
String s = String(v);
int n = s.length();
if (n > 7) n = 7;
for (int i = 0; i < n; i++) buf[7 - n + i] = s[i];
lcd.setCursor(8, row);
lcd.print(buf);
}
void setup() {
pinMode(TILT, INPUT_PULLUP);
lcd.begin(16, 2);
setLabel(0, "POT:");
setLabel(1, "TILT:");
}
void loop() {
// poll tilt switch for state changes (active LOW with pullup)
bool t = (digitalRead(TILT) == LOW);
if (t && !lastTilt) tiltCount++;
lastTilt = t;
// refresh display every 200 ms
if (millis() - lastRefresh >= 200) {
lastRefresh = millis();
setValue(0, analogRead(POT));
setValue(1, tiltCount);
}
}The setValue helper builds a fixed 7-char buffer with spaces and writes it in one lcd.print(buf) — fewer I²C/parallel transactions than printing space-by-space. The setLabel pads the label to 8 chars so a shorter label always clears any leftover from a longer previous one. Both helpers prevent ghosting structurally.
Recap 5 min
The HD44780 doesn't auto-clear when you print over it — old characters "ghost" through if the new text is shorter. Three fixes, ordered from heaviest to lightest: lcd.clear() (full wipe, flickers), overwrite with spaces (selective, fast), and print fixed-width values (no wipe needed, fastest). The template-once pattern — static text in setup(), value cells refreshed in loop() — is the cornerstone of every clean LCD project. Tomorrow we add custom characters; the day after we use the LCD to display real sensor values.
lcd.clear()- Wipes the entire screen and resets the cursor to (0, 0). Slow (~1.5 ms) and visibly flickers on rapid updates. Use sparingly.
setCursor(col, row)- Moves the cursor to a specific position. Column 0–15, row 0–1 on a 16×2. Always set before printing a value into a known slot.
- Ghosting
- The bug where old characters remain visible after a shorter new value is printed. Fixed by clear, space-overwrite, or fixed-width formatting.
- Fixed-width formatting
- Always printing a value in exactly N columns (right-aligned with leading spaces) so a re-print at the same position naturally overwrites the previous value.
- Template-once pattern
- Print all static labels once in
setup(); update only the changing cells inloop(). Fast, flicker-free, and the loop code only contains the parts that actually change. - Degree symbol (
\xDF) - The ° character on most HD44780 ROMs, at byte value 223 (= 0xDF). Used for temperature displays.
- Helper function for refresh
- A small function (
printInt4,setValue) that hides the "cursor + spaces + value" pattern behind a single call. Same principle asreadDistanceCmfor sensors.
Homework 5 min
Refactor a sensor sketch to use the LCD. Take any one of your earlier sensor sketches — DHT11 logger, soil moisture probe, distance display — and rebuild it so the readings show on the LCD instead of (or alongside) the Serial Monitor.
Requirements:
- Use the template-once pattern: static labels in
setup(), dynamic values inloop(). - Use fixed-width formatting (your own helper or just spaces) so the screen never ghosts.
- Refresh at a sensible rate for the sensor (DHT11 → every 2 s, distance sensor → every 200 ms, soil → every 1 s).
- If the value is "invalid" (NaN for DHT, -1 for distance), display
--.-or?in the value slot.
Bring back next class:
- A photo of the LCD showing live readings.
- Your
hw-l02-29.inosketch. - A short note: did the LCD make the project feel more like a "real thing" than the Serial Monitor did? Why or why not?