Built-in project · Arduino Uno R3

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.

The circuit runs here once the page has loaded.

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.

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 pinConnects to
LED bar graph, 10 segments pin C10Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C9Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C8Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C7Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C6Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C5Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C4Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C3Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C2Arduino Uno R3 pin GND
LED bar graph, 10 segments pin C1Arduino Uno R3 pin GND
Resistor, 220 Ω (10) pin 1Arduino Uno R3 pin 12
Resistor, 220 Ω (9) pin 1Arduino Uno R3 pin 11
Resistor, 220 Ω (8) pin 1Arduino Uno R3 pin 10
Resistor, 220 Ω (7) pin 1Arduino Uno R3 pin 9
Resistor, 220 Ω (6) pin 1Arduino Uno R3 pin 8
Resistor, 220 Ω (5) pin 1Arduino Uno R3 pin 7
Resistor, 220 Ω (4) pin 1Arduino Uno R3 pin 6
Resistor, 220 Ω (3) pin 1Arduino Uno R3 pin 5
Resistor, 220 Ω (2) pin 1Arduino Uno R3 pin 4
Resistor, 220 Ω (1) pin 1Arduino Uno R3 pin 3
Resistor, 1k Ω pin 1Arduino Uno R3 pin 2
Character LCD with I2C backpack pin VCCArduino Uno R3 pin 5V
Character LCD with I2C backpack pin GNDArduino Uno R3 pin GND
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Resistor, 1k Ω pin 2Clock pin OUT
Resistor, 220 Ω (1) pin 2LED bar graph, 10 segments pin A1
Resistor, 220 Ω (2) pin 2LED bar graph, 10 segments pin A2
Resistor, 220 Ω (3) pin 2LED bar graph, 10 segments pin A3
Resistor, 220 Ω (4) pin 2LED bar graph, 10 segments pin A4
Resistor, 220 Ω (5) pin 2LED bar graph, 10 segments pin A5
Resistor, 220 Ω (6) pin 2LED bar graph, 10 segments pin A6
Resistor, 220 Ω (7) pin 2LED bar graph, 10 segments pin A7
Resistor, 220 Ω (8) pin 2LED bar graph, 10 segments pin A8
Resistor, 220 Ω (9) pin 2LED bar graph, 10 segments pin A9
Resistor, 220 Ω (10) pin 2LED 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.

sketch.ino · Arduino Uno R3
// 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 editor

Troubleshooting

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.