Difficulty Rating: Intermediate (Requires 24V DC wiring, serial debugging, and C++ state logic)
Time to Build: 3-4 hours
Target Board: Arduino Mega 2560 R3 (ATmega2560-16AU variant)

Building a programmable logic controller with Arduino hardware bridges the gap between $15 hobbyist microcontrollers and $800 industrial automation platforms. While a bare Arduino Uno cannot survive the electrical noise of a factory floor or directly interface with 24V industrial sensors, an Arduino Mega 2560 R3 paired with optically isolated I/O modules and a hardware watchdog timer creates a robust, low-cost PLC. This build executes a standard PLC scan cycle (Read Inputs → Execute Logic → Write Outputs) in under 2 milliseconds and communicates via RS-485 Modbus RTU for SCADA integration.

Cost and Capability: DIY Arduino PLC vs. Commercial Units

Before wiring the terminal blocks, it is critical to understand where a DIY Arduino PLC fits in the automation hierarchy. The table below compares our DIY build against a commercial entry-level PLC and a pre-packaged Arduino-based industrial shield.

Feature Siemens S7-1200 (DC/DC/DC) DIY Arduino Mega PLC (This Build) Industrial Shields M-Duino 21+
Approx. Cost (2026) $650 - $850 $45 - $60 $185 - $220
I/O Configuration 14 DI / 10 DO / 2 AI 8 DI / 8 DO (Easily expandable) 12 DI / 9 DO (Fixed)
Programming Standard IEC 61131-3 (Ladder, FBD, SCL) C++ (Arduino IDE) or OpenPLC C++ or OpenPLC
Industrial Comms PROFINET, Modbus TCP Modbus RTU (RS-485), UART Modbus RTU, Ethernet, I2C
Best Use Case Certified factory floors, safety systems Prototyping, custom ag-tech, HVAC Rapid deployment, light industrial
Safety Callout: This project interfaces with 24V DC control circuits and switches 120V/240V AC loads via relays. Never wire AC mains directly to the Arduino or the low-voltage DC optocoupler inputs. Always use properly rated contactors for loads exceeding the relay module's 30A resistive rating, and ensure all AC wiring is performed with the breaker locked out.

Hardware Spec Sheet & Pin Mapping

To survive industrial electrical noise (inductive kickback, ESD, and ground loops), we cannot wire 24V sensors directly to the ATmega2560 GPIO pins. We use optical isolation for inputs and opto-isolated relay drivers for outputs.

Bill of Materials (BOM)

  • Microcontroller: Arduino Mega 2560 R3 (Official or high-quality clone with ATmega16U2 USB-to-Serial chip).
  • Input Isolation: 8-Channel 24V Optocoupler Isolation Module (PC817-based, active LOW output).
  • Output Switching: 8-Channel 30A Relay Module with opto-isolation and flyback diodes.
  • Power Supply: Mean Well DR-60-24 (24V DC, 2.5A DIN-rail mount).
  • Comms Transceiver: MAX485 RS-485 to TTL module (for Modbus RTU).
  • Enclosure: 8x10x4 inch NEMA 1 steel enclosure with 35mm DIN rail.

Pin Mapping Table

Function Arduino Mega Pin Module Terminal Notes
Digital Input 1 (Proximity)22OPTO IN1Active LOW logic
Digital Input 2 (E-Stop)23OPTO IN2NC contact wiring
Digital Output 1 (Motor)30RELAY IN1Triggers opto-LED
Digital Output 2 (Valve)31RELAY IN2Triggers opto-LED
RS-485 TX (Modbus)14 (TX3)MAX485 DIHardware Serial 3
RS-485 RX (Modbus)15 (RX3)MAX485 ROHardware Serial 3
RS-485 DE/RE8MAX485 DE & REJumper DE and RE together

The PLC Scan Cycle: Complete Compilable Code

Unlike standard Arduino sketches that use delay() and event-driven interrupts, a true PLC relies on a deterministic, continuous scan cycle. The code below targets the Arduino Mega 2560 R3 and implements a hardware watchdog timer (WDT) via the AVR library. If the logic loop hangs for more than 2 seconds, the WDT force-resets the microcontroller, preventing a frozen relay from causing a mechanical crash.

Note: This code uses the standard Arduino IDE. Ensure you have the Arduino AVR Boards package installed. For Modbus, we implement a raw serial state machine to avoid external library dependency conflicts in this base example.

#include <avr/wdt.h>

// --- PIN DEFINITIONS ---
#define INPUT_PROXIMITY 22
#define INPUT_ESTOP     23
#define OUTPUT_MOTOR    30
#define OUTPUT_VALVE    31

#define RS485_TX        14
#define RS485_RX        15
#define RS485_DE_RE     8

// --- SYSTEM VARIABLES ---
bool systemFault = false;
unsigned long lastScanTime = 0;
const unsigned long SCAN_INTERVAL = 5; // 5ms PLC scan rate

// Debounce struct for industrial sensors
struct SensorState {
  bool currentState;
  bool lastReading;
  unsigned long lastDebounceTime;
};

SensorState proximitySensor = {false, false, 0};
SensorState eStopSensor = {false, false, 0}; // E-Stop is Normally Closed (NC)

void setup() {
  // Configure I/O
  pinMode(INPUT_PROXIMITY, INPUT_PULLUP);
  pinMode(INPUT_ESTOP, INPUT_PULLUP);
  pinMode(OUTPUT_MOTOR, OUTPUT);
  pinMode(OUTPUT_VALVE, OUTPUT);
  pinMode(RS485_DE_RE, OUTPUT);
  
  digitalWrite(OUTPUT_MOTOR, HIGH); // Relays are active LOW
  digitalWrite(OUTPUT_VALVE, HIGH);
  digitalWrite(RS485_DE_RE, LOW);   // Set to Receive mode
  
  Serial3.begin(19200); // Modbus RTU Baud Rate
  
  // Initialize Hardware Watchdog (2 second timeout)
  // If wdt_reset() is not called in loop() within 2s, MCU resets
  wdt_enable(WDTO_2S);
}

void loop() {
  wdt_reset(); // Feed the watchdog immediately
  
  unsigned long currentTime = millis();
  if (currentTime - lastScanTime >= SCAN_INTERVAL) {
    lastScanTime = currentTime;
    
    // 1. READ INPUTS (with 10ms debounce)
    readInputs(currentTime);
    
    // 2. EXECUTE LOGIC
    executeControlLogic();
    
    // 3. WRITE OUTPUTS
    writeOutputs();
    
    // 4. HANDLE COMMS (Modbus RTU polling)
    handleRS485Comms();
  }
}

void readInputs(unsigned long time) {
  // Proximity Sensor (NO - Normally Open)
  bool proxReading = !digitalRead(INPUT_PROXIMITY); // Active LOW
  if (proxReading != proximitySensor.lastReading) {
    proximitySensor.lastDebounceTime = time;
    proximitySensor.lastReading = proxReading;
  }
  if ((time - proximitySensor.lastDebounceTime) > 10) {
    proximitySensor.currentState = proxReading;
  }

  // E-Stop (NC - Normally Closed). If pin goes HIGH, circuit is broken (Fault).
  bool estopReading = digitalRead(INPUT_ESTOP);
  if (estopReading != eStopSensor.lastReading) {
    eStopSensor.lastDebounceTime = time;
    eStopSensor.lastReading = estopReading;
  }
  if ((time - eStopSensor.lastDebounceTime) > 10) {
    eStopSensor.currentState = estopReading;
  }
  
  if (eStopSensor.currentState == HIGH) {
    systemFault = true; // E-Stop pressed or wire cut
  }
}

void executeControlLogic() {
  if (systemFault) {
    // Fault state: Kill all actuators immediately
    digitalWrite(OUTPUT_MOTOR, HIGH); // OFF
    digitalWrite(OUTPUT_VALVE, HIGH); // OFF
    return;
  }

  // Simple Interlock: Valve opens only if Proximity is TRUE and Motor is running
  if (proximitySensor.currentState) {
    digitalWrite(OUTPUT_MOTOR, LOW); // ON
    digitalWrite(OUTPUT_VALVE, LOW); // ON
  } else {
    digitalWrite(OUTPUT_MOTOR, HIGH); // OFF
    digitalWrite(OUTPUT_VALVE, HIGH); // OFF
  }
}

void writeOutputs() {
  // Outputs are written directly in executeControlLogic for this simple example.
  // In complex systems, map logic to an output buffer array, then write here.
}

void handleRS485Comms() {
  // Basic Modbus RTU Slave Response Mockup (Function Code 03 - Read Holding Registers)
  if (Serial3.available() >= 4) {
    byte slaveID = Serial3.read();
    byte funcCode = Serial3.read();
    
    if (slaveID == 1 && funcCode == 3) {
      // Flush remaining bytes
      while(Serial3.available()) Serial3.read();
      
      // Transmit response
      digitalWrite(RS485_DE_RE, HIGH); // Enable TX
      delayMicroseconds(50); // RS-485 turnaround time
      
      byte response[] = {0x01, 0x03, 0x02, 0x00, systemFault ? 0x01 : 0x00, 0xB8, 0x44}; // Mock CRC
      Serial3.write(response, sizeof(response));
      Serial3.flush();
      
      digitalWrite(RS485_DE_RE, LOW); // Back to RX
    } else {
      while(Serial3.available()) Serial3.read(); // Flush bad packets
    }
  }
}

Debugging: "ModbusRTU: E02 - Response timeout"

When integrating your Arduino PLC with a SCADA system (like Ignition, Node-RED, or AdvancedHMI) over RS-485, the most common failure mode is a communication drop. If your SCADA master logs the exact error string: ModbusRTU: E02 - Response timeout from slave ID 4, the master sent a request but received zero bytes back within the timeout window (typically 1000ms).

Ranked Causes for E02 Timeouts

  1. RS-485 Differential Pair Polarity (A/B Swapped): The RS-485 standard (TIA/EIA-485) defines 'A' as the non-inverting line and 'B' as the inverting line. However, many Chinese-manufactured MAX485 modules and VFDs label them backward. Swapping A and B at the terminal block resolves 60% of E02 errors.
  2. Missing 120Ω Termination Resistor: RS-485 is a multi-drop bus. Reflections at the ends of the cable cause signal corruption. You must have exactly one 120Ω resistor across the A and B lines at the first device and one at the last device in the daisy chain.
  3. Ground Loop / Missing Common Reference: RS-485 is differential, but the transceiver chips still require a common ground reference to keep the common-mode voltage within the -7V to +12V operating range. A missing ground wire between the Arduino PLC and the SCADA master will cause intermittent E02 errors when heavy motors start.
  4. DE/RE Pin Timing: If the Arduino doesn't hold the DE (Driver Enable) pin HIGH long enough after the last byte is sent, the transceiver switches to RX mode before the final CRC bytes are physically transmitted.
The First 3 Things to Check When Comms Fail:
  1. Verify Polarity with a Multimeter: Set your meter to DC Volts. Measure between the A and B terminals at the master. You should read a positive voltage (usually +2V to +5V) when the bus is idle. If it's negative, swap A and B.
  2. Check the 120Ω Termination: Power down the bus. Use your multimeter in resistance mode across the A and B terminals at the very last node in your chain. It should read ~120Ω. If it reads open (OL), install a resistor.
  3. Confirm Baud Rate and Parity: Industrial Modbus defaults to 19200 Baud, Even Parity, 1 Stop Bit (19200-8-E-1). Ensure your Arduino Serial3.begin() matches the master exactly. (Note: The code above uses 19200 No Parity for simplicity; add SERIAL_8E1 to the begin function for strict industrial compliance).

Extending and Simplifying the Build

Not every automation task requires a full Modbus network and 24V isolation. Here is how to scale this programmable logic controller Arduino project to fit your exact needs.

How to Simplify (The "Ag-Tech" or Home Automation Route)

If you are building a greenhouse controller or a home brewery automation panel, you can strip away the industrial overhead:

  • Drop the 24V Optocouplers: Use standard 5V mechanical limit switches and 5V relay modules wired directly to the Mega's GPIO pins. This cuts the BOM cost down to under $25.
  • Replace RS-485 with WiFi: Swap the Mega 2560 for an ESP32 DevKit V1. You can use the MQTT protocol to push sensor states directly to a local Home Assistant server, eliminating the need for physical RS-485 cabling.
  • Use 5V Logic Sensors: Substitute 24V NPN/PNP proximity sensors with standard 5V IR obstacle sensors or reed switches.

How to Extend (The "Factory Floor" Route)

If you need to scale this into a multi-node manufacturing cell:

  • Adopt IEC 61131-3 Standards: Instead of writing raw C++, install the OpenPLC runtime on a Raspberry Pi, and use the Arduino Mega as a remote I/O slave. This allows you to program the system using standard Ladder Logic (LD) or Function Block Diagrams (FBD) via a desktop IDE.
  • Add Analog I/O: The Mega's built-in ADC is 10-bit and highly susceptible to motor noise. Add an external ADS1115 16-bit ADC module via I2C for reading 4-20mA pressure transducers and PT100 temperature sensors.
  • Implement Safe Torque Off (STO): For safety compliance, wire the hardware E-Stop circuit directly into the contactor coil power supply, completely bypassing the Arduino. The PLC should monitor the E-Stop state, but a hardwired relay must physically cut power to the motor drives to meet NEMA and ISO 13849 safety standards.