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
- Form the Coil: Cut a 15cm diameter circle from stiff cardboard. Cut a slit to the center to anchor your wire start.
- 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.
- 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.
- 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.
- 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
- 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). - 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.
- 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 use0x3Dinstead of0x3C.
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.






