An Arduino metal detector built on the Beat Frequency Oscillator (BFO) or single-coil LC resonance principle relies on a deceptively simple physical interaction: when a metallic object enters the magnetic field of a search coil, eddy currents induced in the metal alter the coil's inductance. This shifts the resonant frequency of the oscillator circuit. By measuring this microsecond-level frequency shift with a microcontroller, you can detect coins, nails, or foil buried in dry soil or hidden behind drywall.

Many hobbyist guides incorrectly attempt to use a 555 timer in a standard astable configuration with an inductor. A standard 555 astable relies on RC (resistor-capacitor) timing; forcing an inductor into that topology results in erratic triggering and flyback voltage spikes that can destroy the IC. The correct, robust approach is a Colpitts LC oscillator built with a single NPN transistor, feeding a clean square wave into the Arduino's digital interrupt or pulseIn pin.

The Physics and Component Selection

The sensitivity of your detector is dictated almost entirely by the search coil's geometry and the oscillator's base frequency. According to Colpitts oscillator theory, the resonant frequency is determined by the inductance of the coil (L) and the equivalent series capacitance of the voltage divider capacitors (C1 and C2). For a DIY build, targeting a base frequency between 10 kHz and 20 kHz provides the best balance: it is high enough to react to small metallic masses, but low enough that the Arduino's pulseIn() function can measure the period without requiring complex hardware timer interrupts.

Search Coil Specifications and Expected Frequencies

The table below provides empirical data for hand-wound, single-layer air-core coils using 26 AWG enameled copper wire (magnet wire). Inductance is calculated using Wheeler's approximation, and the base frequency assumes a Colpitts tank circuit using two 100nF capacitors (yielding a 50nF equivalent capacitance).

Coil Diameter Turns (N) Wire Gauge Calc. Inductance (L) Expected Base Freq Best Use Case
10 cm (4") 50 26 AWG 1.15 mH 20.9 kHz High sensitivity, small targets (jewelry, coins)
15 cm (6") 70 26 AWG 2.75 mH 13.5 kHz General purpose, good depth-to-discrimination ratio
20 cm (8") 90 26 AWG 5.40 mH 9.6 kHz Deep seeking, larger objects (pipes, caches)
25 cm (10") 110 26 AWG 8.90 mH 7.5 kHz Maximum depth, heavy mineralization soil

Exact Parts List

  • Microcontroller: Arduino Nano v3 (ATmega328P, 16MHz crystal variant). Do not use the Nano Every or 33 IoT for this specific code without modifying the timer registers.
  • Transistor: 2N3904 NPN Bipolar Junction Transistor (TO-92 package).
  • Display: 0.96" SSD1306 I2C OLED (128x64, address 0x3C).
  • Capacitors: 2x 100nF (104) ceramic capacitors (C1, C2), 1x 10µF electrolytic (power decoupling).
  • Resistors: 10kΩ (bias), 4.7kΩ (emitter), 1kΩ (base protection), 100Ω (piezo current limiting).
  • Audio: 5V active piezo buzzer.
  • Coil Wire: ~10 meters of 26 AWG enameled copper wire.

Pin Mapping and Coil Winding Procedure

Winding the coil is where most builds fail. The enamel insulation on magnet wire is invisible to the naked eye but acts as a perfect dielectric. If you do not strip it, your circuit will be an open loop.

Pin Mapping Table

Arduino Nano Pin Function Connected To
D2 Frequency Input Colpitts Oscillator Output (via 1kΩ resistor)
D8 Audio Output Piezo Buzzer Positive (via 100Ω resistor)
A4 (SDA) I2C Data SSD1306 OLED SDA
A5 (SCL) I2C Clock SSD1306 OLED SCL
5V Power Oscillator VCC, OLED VCC
GND Ground Common Ground Plane

Step-by-Step Winding and Assembly

  1. Form the Coil: Cut a 15cm diameter circle from stiff cardboard. Cut a slit to the center to anchor your wire start.
  2. Wind the Turns: Wrap exactly 70 turns of 26 AWG wire tightly. Keep the turns adjacent (single layer). Do not overlap them, as overlapping increases parasitic capacitance and lowers the Q-factor.
  3. Secure and Strip: Wrap the coil in electrical tape. Critical: Use sandpaper or a lighter to burn off the enamel coating from the last 2cm of both wire ends. Scrape until the copper shines brightly. Tin the ends with solder immediately to prevent oxidation.
  4. Build the Oscillator: Wire the Colpitts circuit on a breadboard. The coil connects between the transistor's collector and the midpoint of the two 100nF capacitors. The other ends of the capacitors go to the emitter and ground, respectively.
  5. Couple to Arduino: Route the oscillator output through a 1kΩ current-limiting resistor into Digital Pin 2. This protects the ATmega328P input clamp diodes from voltage spikes.

Complete Arduino Code with Error Handling

This code targets the Arduino Nano v3 (ATmega328P). It uses the pulseIn() function to measure the high-time of the oscillator's square wave. Because pulseIn() is blocking, we use a strict timeout to prevent the microcontroller from hanging if the oscillator stops. We also implement a rolling average to smooth out microsecond jitter inherent in LC circuits.

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// --- PIN DEFINITIONS ---
#define PIN_OSCILLATOR 2
#define PIN_PIEZO      8

// --- OLED CONFIGURATION ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET   -1
#define SCREEN_ADDRESS 0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --- DETECTOR VARIABLES ---
const int NUM_READINGS = 10;
unsigned long readings[NUM_READINGS];
int readIndex = 0;
unsigned long total = 0;
float baseFreq = 0;
float currentFreq = 0;

void setup() {
  Serial.begin(115200);
  pinMode(PIN_OSCILLATOR, INPUT);
  pinMode(PIN_PIEZO, OUTPUT);
  
  // Initialize OLED with I2C error handling
  if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("ERR: OLED_NAK - Check I2C wiring or address"));
    // Blink LED to indicate fatal I2C failure
    pinMode(LED_BUILTIN, OUTPUT);
    while(true) { digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN)); delay(100); }
  }
  
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0,0);
  display.println("Calibrating...");
  display.display();
  
  // Calibrate base frequency (keep metal away during boot!)
  delay(500);
  baseFreq = getAverageFrequency();
  if (baseFreq == 0) {
    displayError("ERR: FREQ_ZERO");
    while(1); // Halt execution
  }
  
  display.clearDisplay();
  display.setCursor(0,0);
  display.print("Base: "); display.print(baseFreq, 1); display.println(" Hz");
  display.display();
  delay(1000);
}

void loop() {
  currentFreq = getAverageFrequency();
  
  if (currentFreq == 0) {
    displayError("ERR: FREQ_ZERO");
    tone(PIN_PIEZO, 100, 200); // Low error beep
    delay(500);
    return;
  }
  
  float delta = baseFreq - currentFreq; // Metal causes frequency to drop
  
  display.clearDisplay();
  display.setCursor(0,0);
  display.print("F: "); display.print(currentFreq, 1); display.println(" Hz");
  display.print("Delta: "); display.print(delta, 1); display.println(" Hz");
  
  // Trigger logic: if frequency drops by more than 15 Hz, sound alarm
  if (delta > 15.0) {
    int pitch = map((int)delta, 15, 200, 800, 2500);
    pitch = constrain(pitch, 800, 2500);
    tone(PIN_PIEZO, pitch, 50);
    display.println(">>> TARGET <<<");
  } else {
    noTone(PIN_PIEZO);
  }
  display.display();
}

float getAverageFrequency() {
  total = total - readings[readIndex];
  // Measure half-period in microseconds, 100ms timeout
  unsigned long duration = pulseIn(PIN_OSCILLATOR, HIGH, 100000); 
  
  if (duration == 0) {
    readings[readIndex] = 0; // Timeout or open circuit
  } else {
    unsigned long period = duration * 2; // Full period
    unsigned long freq = 1000000UL / period;
    readings[readIndex] = freq;
  }
  
  total = total + readings[readIndex];
  readIndex = (readIndex + 1) % NUM_READINGS;
  
  if (total == 0) return 0.0; // Prevent divide by zero
  return (float)total / NUM_READINGS;
}

void displayError(const char* errMsg) {
  Serial.println(errMsg);
  display.clearDisplay();
  display.setCursor(0, 20);
  display.setTextSize(1);
  display.println(errMsg);
  display.println("Check coil continuity");
  display.display();
}

Debugging: First Three Checks and Error Strings

LC oscillators are notoriously finicky on breadboards due to parasitic capacitance and loose jumper wires. When your detector fails to trigger or the serial monitor spits out an error, follow this ranked diagnostic path.

The First Three Things to Check

  1. Enamel Stripping (The #1 Failure): If you get ERR: FREQ_ZERO, your coil is an open circuit. 90% of the time, the builder forgot to sand the enamel off the wire ends, or the solder joint is a "cold joint" sitting on top of the insulation. Scrape the wire again, tin it, and verify continuity with a multimeter (should read 1-3 ohms).
  2. Transistor Pinout Orientation: The 2N3904 has a specific pinout: Emitter, Base, Collector (E-B-C) when looking at the flat side. If inserted backward, the oscillator will not start, or the transistor will overheat and fail.
  3. I2C Pull-ups and Address: If the OLED stays blank and the onboard LED blinks rapidly, the Arduino is hanging on Wire.h. Cheap SSD1306 clones sometimes lack internal I2C pull-up resistors. Add 4.7kΩ pull-up resistors between SDA/SCL and 5V. Also, verify the address; some clones use 0x3D instead of 0x3C.

Exact Error Strings and Ranked Causes

Serial / OLED Error String Meaning Ranked Causes & Fixes
ERR: FREQ_ZERO pulseIn() timed out (100ms). No square wave reaching Pin D2. 1. Coil enamel not stripped.
2. Breadboard jumper disconnected.
3. Transistor dead or backward.
4. Coil shorted to ground.
ERR: OLED_NAK I2C bus did not acknowledge the display address. 1. Wrong I2C address (try 0x3D).
2. SDA/SCL swapped.
3. Missing I2C pull-up resistors.
Delta: -XXX.X Hz Frequency is increasing instead of dropping. 1. You are detecting a ferrite/ferrous metal that increases inductance in a specific geometry, or your baseline calibration was done with metal already near the coil.

Extending and Simplifying the Build

Depending on your application, the baseline BFO build might be over-engineered or under-featured. Here is how to adapt the hardware and firmware.

How to Simplify (The "Beachcomber" Minimalist Build)

If you want a lightweight, handheld unit for finding lost keys in the grass, strip out the I2C OLED and the Adafruit_GFX libraries. Connect the piezo directly to Pin D8. Replace the visual feedback with a pure audio tone mapping. This reduces the code footprint by 80%, eliminates I2C bus capacitance issues, and allows you to power the entire circuit from a 9V battery for weeks using the ATmega328P's sleep modes between pulseIn reads.

How to Extend (True BFO and Discrimination)

To upgrade from a single-coil frequency-shift detector to a true Beat Frequency Oscillator (BFO), you need a second, identical "reference" coil housed in the control box, shielded from the search area. Both coils drive separate Colpitts oscillators. You feed both frequencies into the Arduino and calculate the difference (the "beat" frequency). When the search coil approaches metal, the beat frequency shifts into the audible range (e.g., from 0 Hz to 400 Hz). This architecture, detailed in advanced oscillator design guides, provides vastly superior stability against temperature drift and allows for basic ferrous/non-ferrous discrimination by analyzing the phase shift of the beat note.

For the software side, you can extend the current code by logging the delta values to an SD card module via SPI, allowing you to map out underground pipe routes by reviewing the frequency dip signatures later on a PC.