Engine tachometer with shift light on an Arduino Uno
Engine pulses on an interrupt pin become rpm on a 16x2 LCD, timed by the period between pulses, with a ten-segment shift light that fills as the revs climb and flashes at 6500 rpm. The whole build, an Arduino Uno and 14 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Intermediate About 45 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $37. On Mokxi they're free.
What you'll learn
- Sensors
- Displays
- Lights
- Choosing and reading a resistor
- Difficulty: Intermediate
- Time: About 45 minutes
- Board: Arduino Uno R3
- Parts: 16
How it works
Engine pulses on an interrupt pin become rpm on a 16x2 LCD, timed by the period between pulses, with a ten-segment shift light that fills as the revs climb and flashes at 6500 rpm.
Arduino Uno R3 runs the sketch below. The circuit around it: Clock, Resistor, 1k Ω, LED bar graph, 10 segments, Resistor, 220 Ω, Character LCD with I2C backpack. pinMode sets BAR_PINS[i] as an output. Serial.begin opens the serial port at 115200 baud. millis reads the time since power-up, so the sketch can time things without stopping.
Parts list
16 parts, plus the jumper wires. They retail for about $37; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × ClockIn Logic Lab Kit.
- 1 × Resistor, 1k ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED bar graph, 10 segmentsIn Spaceship Panel Kit.
- 10 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × Character LCD with I2C backpackIn Claw Machine Kit, Vending Machine Kit, Rhythm Game Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| LED bar graph, 10 segments pin C10 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C9 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C8 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C7 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C6 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C5 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C4 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C3 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C2 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C1 | Arduino Uno R3 pin GND |
| Resistor, 220 Ω (10) pin 1 | Arduino Uno R3 pin 12 |
| Resistor, 220 Ω (9) pin 1 | Arduino Uno R3 pin 11 |
| Resistor, 220 Ω (8) pin 1 | Arduino Uno R3 pin 10 |
| Resistor, 220 Ω (7) pin 1 | Arduino Uno R3 pin 9 |
| Resistor, 220 Ω (6) pin 1 | Arduino Uno R3 pin 8 |
| Resistor, 220 Ω (5) pin 1 | Arduino Uno R3 pin 7 |
| Resistor, 220 Ω (4) pin 1 | Arduino Uno R3 pin 6 |
| Resistor, 220 Ω (3) pin 1 | Arduino Uno R3 pin 5 |
| Resistor, 220 Ω (2) pin 1 | Arduino Uno R3 pin 4 |
| Resistor, 220 Ω (1) pin 1 | Arduino Uno R3 pin 3 |
| Resistor, 1k Ω pin 1 | Arduino Uno R3 pin 2 |
| Character LCD with I2C backpack pin VCC | Arduino Uno R3 pin 5V |
| Character LCD with I2C backpack pin GND | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Resistor, 1k Ω pin 2 | Clock pin OUT |
| Resistor, 220 Ω (1) pin 2 | LED bar graph, 10 segments pin A1 |
| Resistor, 220 Ω (2) pin 2 | LED bar graph, 10 segments pin A2 |
| Resistor, 220 Ω (3) pin 2 | LED bar graph, 10 segments pin A3 |
| Resistor, 220 Ω (4) pin 2 | LED bar graph, 10 segments pin A4 |
| Resistor, 220 Ω (5) pin 2 | LED bar graph, 10 segments pin A5 |
| Resistor, 220 Ω (6) pin 2 | LED bar graph, 10 segments pin A6 |
| Resistor, 220 Ω (7) pin 2 | LED bar graph, 10 segments pin A7 |
| Resistor, 220 Ω (8) pin 2 | LED bar graph, 10 segments pin A8 |
| Resistor, 220 Ω (9) pin 2 | LED bar graph, 10 segments pin A9 |
| Resistor, 220 Ω (10) pin 2 | LED bar graph, 10 segments pin A10 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Engine tachometer with shift light: engine pulses on pin 2, the rpm on a
// 16x2 I2C LCD, and a ten-segment LED bar that fills as the revs climb and
// flashes at the shift point.
//
// 2 the conditioned engine pulse (INT0), 0 V / 5 V, through 1 k;
// its pull-up is on, so a loose wire reads no pulses
// 3 to 12 the bar graph's anodes A1 to A10, each through its own 220 ohm;
// the cathodes to GND
// LCD SDA -> A4 SCL -> A5 VCC -> 5 V GND -> GND
//
// SAFETY: this is a teaching simulation. Never connect a project like this to
// a running car's safety systems (brakes, airbags, steering, ECU wiring,
// anything that can strand or hurt someone). Try it on the bench with a bench
// supply or a 12 V battery, fused. The negative side of an ignition coil
// swings to a few hundred volts each spark, and a car's 12 V line carries
// load-dump spikes, so a real install needs an automotive-rated front end
// (a fuse, a clamp or TVS diode, and an optocoupler or a divider) that hands
// the Uno a clean 0 to 5 V pulse. A Hall sensor such as the A3144 on 5 V, with
// a pull-up on its open-collector output, gives that pulse directly.
//
// Pulses per revolution depend on what you trigger from:
// a magnet on the crank pulley and one Hall sensor 1 pulse a turn
// a 4-cylinder 4-stroke ignition (coil or tach out) 2 pulses a turn
// (a 4-stroke fires every cylinder once in two turns: cylinders / 2;
// a 6-cylinder gives 3, a V8 gives 4)
// This sketch is set for the 4-cylinder ignition: 800 rpm idle is 26.7 Hz,
// 6500 rpm is 216.7 Hz.
//
// Why time the period instead of counting pulses: at 800 rpm only about 6.7
// pulses arrive in a quarter second, so a counter would read 720 or 840 and
// jump between them. Timing the gap between two pulses with micros() (4 us
// steps) measures 37500 us to a hundredth of a percent, after one pulse. The
// rpm is then 60,000,000 us a minute / (period x pulses per rev).
//
// The sketch averages the last 8 periods to steady the reading (smoothing),
// throws away any gap shorter than 2 ms (spark ringing, a bounce: that would
// be 15000 rpm), and reads 0 when no pulse has come for half a second.
//
// Shift light: no bars under 2500 rpm, then one more bar each 400 rpm (bar 10
// at 6100 rpm). From 6500 rpm, the shift point, the whole bar flashes. From
// 7000 rpm, a typical redline for a small 4-cylinder, the LCD says OVER-REV.
//
// Press Run, then set the square-wave source's frequency (hz) in its
// properties: 26.7 Hz is an 800 rpm idle, 100 Hz is 3000 rpm, 216.7 Hz is
// 6500 rpm and 240 Hz is 7200 rpm. The serial monitor prints what the LCD shows.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
const int PULSE_PIN = 2; // INT0
const uint8_t BAR_PINS[10] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
const unsigned long PULSES_PER_REV = 2; // 4-cylinder 4-stroke ignition
const unsigned long MIN_PERIOD_US = 2000; // shorter is noise
const unsigned long STALL_US = 500000; // no pulse this long: engine off
const long LIGHT_START = 2500; // first bar
const long LIGHT_STEP = 400; // rpm per bar
const long SHIFT_RPM = 6500; // flash: change gear now
const long REDLINE = 7000; // over-rev warning
const uint8_t AVERAGE = 8; // periods averaged
LiquidCrystal_I2C lcd(0x27, 16, 2);
// Written by the interrupt, read by loop(): volatile, and copied with
// interrupts off so a pulse cannot change them halfway through the copy.
volatile unsigned long lastPulse = 0;
volatile bool seenPulse = false;
volatile unsigned long periods[AVERAGE];
volatile unsigned long periodSum = 0;
volatile uint8_t slot = 0;
volatile uint8_t filled = 0;
// Runs on every rising edge: keep it short, just the time stamp and the sum.
void onPulse() {
unsigned long now = micros();
unsigned long period = now - lastPulse;
if (seenPulse && period < MIN_PERIOD_US) return; // too soon: ignore it
lastPulse = now;
if (seenPulse) {
periodSum = periodSum - periods[slot] + period;
periods[slot] = period;
slot = (slot + 1) % AVERAGE;
if (filled < AVERAGE) filled++;
}
seenPulse = true;
}
// The rpm from the averaged period, rounded to the nearest whole rpm.
long readRpm() {
noInterrupts();
unsigned long sum = periodSum;
uint8_t n = filled;
unsigned long since = micros() - lastPulse;
if (seenPulse && since > STALL_US) {
// Stalled: forget the old periods, or the long gap would drag the
// average down for the first few pulses after a restart.
seenPulse = false;
filled = 0;
periodSum = 0;
for (uint8_t i = 0; i < AVERAGE; i++) periods[i] = 0;
}
bool stalled = !seenPulse;
interrupts();
if (n == 0 || stalled) return 0;
// average period = sum / n, so rpm = 60e6 * n / (sum * pulses per rev)
unsigned long divisor = sum * PULSES_PER_REV;
return (long)((60000000UL * n + divisor / 2) / divisor);
}
// How many bars to light for a reading.
int barsFor(long rpm) {
if (rpm < LIGHT_START) return 0;
long bars = 1 + (rpm - LIGHT_START) / LIGHT_STEP;
return bars > 10 ? 10 : (int)bars;
}
void showBars(int lit) {
for (int i = 0; i < 10; i++) digitalWrite(BAR_PINS[i], i < lit ? HIGH : LOW);
}
void setup() {
for (int i = 0; i < 10; i++) pinMode(BAR_PINS[i], OUTPUT);
pinMode(PULSE_PIN, INPUT_PULLUP); // held high, so a loose wire reads no pulses
attachInterrupt(digitalPinToInterrupt(PULSE_PIN), onPulse, RISING);
Serial.begin(115200);
Serial.println("Engine tachometer");
lcd.init();
lcd.backlight();
lcd.print("Tachometer");
}
unsigned long lastPrint = 0;
long lcdRpm = -1; // what the LCD shows now, so it is only rewritten on a change
int lcdState = -1;
// What line 2 says: 0 to 10 bars, or one of these.
const int ENGINE_OFF = -1;
const int SHIFT = 11;
const int OVER_REV = 12;
int stateFor(long shown) {
if (shown == 0) return ENGINE_OFF;
if (shown >= REDLINE) return OVER_REV;
if (shown >= SHIFT_RPM) return SHIFT;
return barsFor(shown);
}
void printState(Print &out, int state) {
if (state == ENGINE_OFF) {
out.print("engine off");
} else if (state == OVER_REV) {
out.print("OVER-REV!");
} else if (state == SHIFT) {
out.print("SHIFT UP!");
} else {
out.print("bars ");
out.print(state);
out.print("/10");
}
}
void loop() {
long rpm = readRpm();
// A real tach shows tens: the last digit only flickers.
long shown = (rpm + 5) / 10 * 10;
int state = stateFor(shown);
// The bar: fill, or flash the whole bar five times a second at the shift
// point. It goes by the rpm shown, so the light and the LCD always agree.
if (state == SHIFT || state == OVER_REV) {
showBars((millis() / 100) % 2 == 0 ? 10 : 0);
} else {
showBars(state < 0 ? 0 : state);
}
// An I2C LCD takes a few milliseconds a character, long enough to stall the
// flashing, so it is only rewritten when what it shows has changed.
if (shown != lcdRpm) {
lcdRpm = shown;
lcd.setCursor(0, 0);
lcd.print("RPM ");
lcd.print(shown);
lcd.print(" ");
}
if (state != lcdState) {
lcdState = state;
lcd.setCursor(0, 1);
printState(lcd, state);
lcd.print(" ");
}
if (millis() - lastPrint >= 250) {
lastPrint = millis();
Serial.print(shown);
Serial.print(" rpm ");
printState(Serial, state);
Serial.println();
}
}
Code walkthrough
The key functions of the sketch, and what each one does.
setup sketch.ino
Runs once when the board starts. It gets the pins and libraries ready.
void setup() {
for (int i = 0; i < 10; i++) pinMode(BAR_PINS[i], OUTPUT);
pinMode(PULSE_PIN, INPUT_PULLUP); // held high, so a loose wire reads no pulses
attachInterrupt(digitalPinToInterrupt(PULSE_PIN), onPulse, RISING);
Serial.begin(115200);
Serial.println("Engine tachometer");
lcd.init();
lcd.backlight();
lcd.print("Tachometer");
}loop sketch.ino
Runs over and over for as long as the board has power. This is where the project does its work.
void loop() {
long rpm = readRpm();
// A real tach shows tens: the last digit only flickers.
long shown = (rpm + 5) / 10 * 10;
int state = stateFor(shown);
// The bar: fill, or flash the whole bar five times a second at the shift
// point. It goes by the rpm shown, so the light and the LCD always agree.
if (state == SHIFT || state == OVER_REV) {
showBars((millis() / 100) % 2 == 0 ? 10 : 0);
} else {
showBars(state < 0 ? 0 : state);
}
// An I2C LCD takes a few milliseconds a character, long enough to stall the
// flashing, so it is only rewritten when what it shows has changed.
if (shown != lcdRpm) {
lcdRpm = shown;
lcd.setCursor(0, 0);
lcd.print("RPM ");
lcd.print(shown);
lcd.print(" ");
}
if (state != lcdState) {
lcdState = state;
lcd.setCursor(0, 1);
printState(lcd, state);
lcd.print(" ");
// ... 10 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The circuit does not behave as expected at the set frequency. Clock
Pick a frequency slow enough to watch, such as 1 Hz, when learning. Fast clocks look like steady signals. Make sure the clock output and your circuit share a ground. - A counter or flip-flop skips steps. Clock
Check that the clock edge matches the chip, rising or falling. Slow, clean edges are best. Do not connect a mechanical switch as a clock without debouncing it. - The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure. - A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground. - Segments are dim, uneven, or the chip is damaged. LED bar graph
Every segment is its own LED and needs its own current-limiting resistor, about 220 to 330 ohms at 5 V. One shared resistor makes brightness change with how many are lit. - The bar lights in reverse order or a segment never lights. LED bar graph
Check pin 1 and the orientation. Anode and cathode sides are on opposite rows. Some bars are numbered the other way around, so test with one segment at a time. - The screen stays blank or shows only a row of blocks. Character LCD with I2C backpack
The I2C address is usually 0x27 or 0x3F. Run an I2C scanner to find yours. Then turn the contrast trimmer on the backpack slowly until text appears. - The backlight is on but there is no text, or text is garbled. Character LCD with I2C backpack
Wire SDA and SCL to the right pins (A4 and A5 on an Uno), power it from 5 V, and call lcd.init() and the backlight call in setup.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.