When you are debugging a complex digital logic board or testing an analog sensor input stage, relying on a software-only circuit simulator with Arduino code isn't always enough. Software simulators like Wokwi or Proteus are fantastic for verifying logic, but they cannot replicate the parasitic capacitance, ground bounce, and impedance mismatches of real breadboards and PCBs. To bridge this gap, we build a Hardware-in-the-Loop (HIL) circuit simulator. By using a microcontroller to generate precise, adjustable analog and digital waveforms, you can inject real-world signals into your Device Under Test (DUT) and observe the physical response on an oscilloscope or logic analyzer.
This guide walks you through building a benchtop HIL signal generator using an Arduino Nano and a 12-bit I2C DAC. It targets the exact board variant, provides the complete firmware, and details the specific failure modes you will encounter on the bench.
Project Spec Sheet & Hardware Overview
| Parameter | Specification |
|---|---|
| Target Board Variant | Arduino Nano v3 (ATmega328P, 5V logic, 16MHz crystal) |
| Analog Output Resolution | 12-bit (4096 steps) via MCP4725 I2C DAC |
| Digital Logic Outputs | 2x 5V CMOS (Direct GPIO), capable of 20mA sink/source |
| Max Analog Update Rate | ~800 Hz (constrained by I2C bus speed at 400kHz) |
| User Interface | KY-040 Rotary Encoder + 128x64 SSD1306 I2C OLED |
| Difficulty Rating | Intermediate (Requires I2C debugging and basic C++ state machines) |
Bill of Materials (Exact Variants)
Sourcing the exact variants matters here. Generic clones often ship with different I2C pull-up resistor configurations or alternate OLED controller chips that will break the firmware below.
- Microcontroller: Arduino Nano v3 (ATmega328P). Do not use the Nano 33 IoT or Nano Every for this specific build; the firmware relies on the AVR Wire.h implementation and 5V logic levels.
- DAC Module: Adafruit 935 MCP4725 Breakout Board (or a high-quality clone with 4.7kΩ I2C pull-ups already populated). Default I2C address must be
0x62. - Display: 128x64 I2C OLED with SSD1306 driver. Address
0x3C. Avoid SH1106 variants unless you change the library. - Input: KY-040 Rotary Encoder module with breakout board (includes hardware debounce capacitors).
- Passives: 2x 4.7kΩ pull-up resistors (if your DAC/OLED clone boards lack them), 100nF decoupling capacitors for the power rails.
Pin Mapping & Assembly Steps
Wire the components according to this mapping. Keep I2C traces (A4/A5) as short as possible on the breadboard to minimize bus capacitance, which can corrupt the DAC updates at 400kHz.
| Component | Module Pin | Arduino Nano Pin | Notes |
|---|---|---|---|
| OLED Display | SDA / SCL | A4 / A5 | Shared I2C bus |
| MCP4725 DAC | SDA / SCL | A4 / A5 | Shared I2C bus |
| MCP4725 DAC | VDD / GND | 5V / GND | Requires 5V for full 0-5V swing |
| KY-040 Encoder | CLK / DT | D2 / D3 | Use interrupt-capable pins |
| KY-040 Encoder | SW / + / GND | D4 / 5V / GND | Internal pull-up enabled in code |
| Logic Sim Out 1 | - | D6 | Digital square wave output |
| Logic Sim Out 2 | - | D7 | Digital pulse/PWM output |
Complete Compilable Firmware (Arduino Nano v3)
This firmware targets the ATmega328P. It initializes the I2C bus, verifies the presence of both the OLED and the DAC, and runs a simple state machine to output either a triangle wave on the analog pin or a variable-frequency clock on the digital pins. You will need the Adafruit_MCP4725 and Adafruit_SSD1306 libraries installed via the Library Manager.
#include <Wire.h>
#include <Adafruit_MCP4725.h>
#include <Adafruit_SSD1306.h>
// --- Pin Definitions ---
#define ENC_CLK 2
#define ENC_DT 3
#define ENC_SW 4
#define LOGIC_OUT_1 6
#define LOGIC_OUT_2 7
// --- I2C Addresses ---
#define DAC_ADDRESS 0x62
#define OLED_ADDRESS 0x3C
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
// --- Hardware Objects ---
Adafruit_MCP4725 dac;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// --- State Variables ---
volatile int encoderCount = 0;
int lastCount = 0;
int dacValue = 2048; // Midpoint of 12-bit (4096)
bool logicState = false;
unsigned long lastToggle = 0;
void setup() {
Serial.begin(115200);
// Initialize I2C with 400kHz fast mode
Wire.begin();
Wire.setClock(400000);
// Initialize OLED with error handling
if(!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
Serial.println(F("SSD1306 allocation failed"));
// Blink onboard LED to indicate fatal hardware fault
pinMode(13, OUTPUT);
while(1) { digitalWrite(13, !digitalRead(13)); delay(100); }
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
// Initialize DAC with error handling
if (!dac.begin(DAC_ADDRESS)) {
Serial.println(F("Failed to find MCP4725 chip"));
display.setCursor(0, 20);
display.println("DAC I2C ERROR!");
display.display();
while(1); // Halt execution
}
// Initialize Encoder and Logic Pins
pinMode(ENC_CLK, INPUT_PULLUP);
pinMode(ENC_DT, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
pinMode(LOGIC_OUT_1, OUTPUT);
pinMode(LOGIC_OUT_2, OUTPUT);
// Attach interrupt for encoder
attachInterrupt(digitalPinToInterrupt(ENC_CLK), readEncoder, FALLING);
display.setCursor(0,0);
display.println("HIL Simulator Ready");
display.display();
delay(1000);
}
void loop() {
// Update DAC based on encoder (Triangle wave generator)
if (encoderCount != lastCount) {
dacValue += (encoderCount - lastCount) * 16; // Step size
dacValue = constrain(dacValue, 0, 4095);
dac.setVoltage(dacValue, false);
lastCount = encoderCount;
display.clearDisplay();
display.setCursor(0,0);
display.print("DAC: ");
display.print(map(dacValue, 0, 4095, 0, 5000));
display.println(" mV");
display.display();
}
// Generate 1kHz Digital Clock on Logic Pin 1
if (millis() - lastToggle >= 1) { // 1ms = 500Hz period (1kHz square)
logicState = !logicState;
digitalWrite(LOGIC_OUT_1, logicState);
lastToggle = millis();
}
}
// Interrupt Service Routine for Encoder
void readEncoder() {
if (digitalRead(ENC_DT) != digitalRead(ENC_CLK)) {
encoderCount++;
} else {
encoderCount--;
}
}
Debugging: First Three Things to Check When It Fails
Hardware-in-the-loop builds rarely work perfectly on the first power-up. If the system halts, check these three specific failure modes in order.
1. Exact Error: SSD1306 allocation failed
Ranked Causes:
- Wrong I2C Address: Many cheap OLEDs ship with address
0x3Dinstead of0x3C. Run an I2C scanner sketch to verify. If it is 0x3D, changeOLED_ADDRESSin the code. - SRAM Exhaustion: The SSD1306 library requires a 1024-byte buffer. If you have a massive array in your global scope, the ATmega328P's 2KB SRAM will fail to allocate the display buffer. Move large lookup tables to PROGMEM.
- Missing Pull-ups: If using a bare OLED module without a breakout board, the I2C lines lack pull-up resistors. Solder 4.7kΩ resistors between SDA/SCL and VCC.
2. Exact Error: Failed to find MCP4725 chip
Ranked Causes:
- Address Pin Tied High: Some MCP4725 breakouts have the ADDR pin tied to VCC, shifting the address to
0x63. Check the silkscreen on the PCB. If it says 0x63, updateDAC_ADDRESS. - I2C Bus Lockup: If the Nano was reset while the DAC was mid-transmission, the DAC might be holding the SDA line low. Power cycle the entire breadboard (unplug USB and 5V rail) to clear the bus state.
3. Symptom: Encoder Count Drifting or Skipping
Ranked Causes:
- Interrupt Bounce: The KY-040 mechanical contacts bounce. The ISR handles direction, but extreme bounce can cause missed edges. Add 100nF ceramic capacitors between CLK/GND and DT/GND physically on the breadboard.
- ISR Execution Time: Never put
Serial.print()or I2C writes inside an Interrupt Service Routine. The code above strictly updates a volatile integer, keeping the ISR under 5 microseconds.
Scaling: How to Extend or Simplify the Build
To Simplify: If you only need digital logic simulation and don't care about analog sensor spoofing, drop the MCP4725 DAC and the OLED. Use the Arduino Nano's internal 10-bit ADC and analogWrite() (PWM) with a simple RC low-pass filter (1kΩ resistor + 100nF capacitor) to create a crude 0-5V analog output. This reduces the BOM cost to under $8 and eliminates I2C debugging entirely.
To Extend: If you need to simulate high-current loads (like a fuel injector or a 12V relay coil), the Nano's GPIO pins cannot source the required current. Buffer the LOGIC_OUT_1 pin through an optocoupler (like the PC817) or a logic-level N-channel MOSFET (like the IRLZ44N). Ensure you add a flyback diode (1N4007) across any inductive load you simulate, or the back-EMF will punch through the MOSFET and fry your Arduino's ground reference.
Frequently Asked Questions
Can I use a free online circuit simulator with Arduino code instead of building this?
Yes, for pure logic verification. Platforms like Wokwi allow you to simulate Arduino code alongside virtual logic gates, LEDs, and displays. However, software simulators assume ideal wires with zero resistance and infinite bandwidth. They will not reveal if your physical PCB layout has ground loops, if your I2C bus is failing due to parasitic capacitance, or if your power supply is browning out under load. Use Wokwi for code logic; use this HIL build for physical electrical validation.
How do I simulate high-current loads with this Arduino circuit simulator?
The ATmega328P GPIO pins are strictly limited to 20mA continuous current per pin, with a 200mA total package limit. To simulate a load that draws 2A (like a DC motor or solenoid), you must use the Arduino to drive the gate of a power MOSFET. Connect the Nano's digital output to the gate of an IRLZ44N (logic-level MOSFET), tie the source to ground, and place your load between the 12V supply and the MOSFET drain. The Arduino acts as the signal brain, while the MOSFET handles the heavy current.
Why does my simulated PWM signal show ringing on the oscilloscope?
If you probe the digital outputs and see high-frequency oscillation (ringing) on the rising and falling edges, you are witnessing impedance mismatch and parasitic inductance. Breadboards introduce roughly 2-5pF of capacitance per contact point and act as tiny inductors. When the Nano's GPIO switches states in nanoseconds, the fast edge rate (dV/dt) excites this parasitic LC tank circuit. To fix this, solder a small series resistor (22Ω to 47Ω) directly to the Nano's output pin. This forms an RC snubber with the breadboard capacitance, critically damping the signal and cleaning up the edges for your downstream logic gates.






