Having multiple Arduino boards communicate with each other, can be done in several ways. Depending on the data content, accuracy and direction the data needs to be sent, one protocol may be better than the other.
Different protocols
There are a few ways you can exchange data between Arduino boards
- One-to-one wire
- Digital I/O
- Analogue I/O
- PWM
- Bus protocols
- RS232
- SPI
- I2C
- Other
- Own protocol
- Basically, anything you can image
Data exchange via one-to-one wire
As the title may suggest, one way to exchange data between two (or more) Arduino boards, is by connecting them together through one or more wires, with each wire containing one type of data.
Digital I/O
Probably the simplest way to communicate, and the most limited, is by connecting 1 digital I/O pin between (two) Arduinos. The Arduino with the I/O pin set as OUTPUT will be the sender. The Arduino with the I/O pin set as INPUT will be the receiver. Only 1 bit of data can be send this way (TRUE or FALSE) and only in 1 direction. Using a second I/O pin, a 2 way communication can be achieved.
A practical example of this, is a “digital heartbeat”. This basically is a toggling signal (generated on device Node 0), that is sent to a second device (Node 1), or even multiple devices. The receiving device (Node 1) will then monitor this signal, and if the signal stops toggling, it will know that the sending device (Node 0) is not working anymore (no heartbeat). Adding a second line, the opposite direction can be monitored as well.

#define nodeAddress 1 // Set to 0 or 1 unsigned long lastLocalBeat = 0; unsigned long lastRemoteBeat = 0; int pinSendBeat; int pinReceiveBeat; bool lastBeat; int beating = 10; /**************************************************************** SETUP ****************************************************************/ void setup() { Serial.begin(115200); while (!Serial) { ; // Wait for serial } // Node 0 if (nodeAddress == 0) { pinSendBeat = 3; pinReceiveBeat = 4; } // Node 1 if (nodeAddress == 1) { pinSendBeat = 4; pinReceiveBeat = 3; } pinMode(pinSendBeat, OUTPUT); // Send the heartbeat pinMode(pinReceiveBeat, INPUT_PULLUP); // Listen for heartbeat } /**************************************************************** LOOP ****************************************************************/ void loop() { // Heartbeat every 1s if (lastLocalBeat + 1000 < millis()) { digitalWrite(pinSendBeat, not(digitalRead(pinSendBeat))); lastLocalBeat = millis(); } // Listen every 1/2s if (lastRemoteBeat + 500 < millis()) { bool currentBeat = digitalRead(pinReceiveBeat); if (currentBeat != lastBeat) { Serial.println("Still beating ..."); beating = 10; } else { Serial.println("..."); beating--; } if (beating <= 0) { Serial.println("No heartbeat detected !"); } lastBeat = currentBeat; lastRemoteBeat = millis(); } } // END

Pros | Cons |
Very simple | Only 1 bit of data per line |
Works on all boards | Data in 1 direction only (per line) |
Accurate | No sync (no clock) |
Analogue I/O
Just as with digital I/O, analogue I/O would only work in 1 direction (per line), and in order to work with a 2 way communication, a seconds line is required.
The advantage to digital I/O is that this way 10 bits of information can be send, by analogue values 0 to 1023 (0000 0011 1111 1111).
Note that some Arduino boards have a 12 bit ADC (0 to 4095).

// Sender analogWrite(A0, value); // MKR, Zero and Due only // Receiver analogRead(A0); // Any analogue pin
The downside here is that many Arduino boards don’t have an analogue output pin.
Another way of using analogue values is to treat them as 10 booleans,
or 12 booleans if using an Arduino board with 12 bit ADC. However, slight differences (4.52V vs 4.51V) will corrupt the data, so basically, this is pretty useless.
// Read analog value from pin A0 int analogValue = analogRead(A0); // Put each bit in a boolean bool arrayBooleans[10]; for (b=0; b<10; b++) { arrayBooleans[b] = bool(analogValue >> b & 1); }
Pros | Cons |
Simple | Only Arduino Zero, Due and MKR series have analogue output pins |
More bits available per line | Data in 1 direction only (per line) |
Not usable for high accuracy | |
No sync (no clock) |
PWM
Like the analogue I/O, PWM (Pulse Width Modulation) signals can hold multiple values, but in stead of 0-1023 with the analogue 10 bit pins, PWM signals (on Arduino boards) can only vary from 0 to 255, representing the 0% to 100% duty cycle.

Writing and reading PWM with an Arduino is not that ideal.
For writing a PWM, the function analogWrite(<pin>, <value>) can be used, with <value> being the value 0-255 representing a duty cycle of 0-100%
For reading, function pulseIn() can be used, however the return value wil be the time in µs. Both HIGH and LOW time of the pulse must be known, to calculate the duty cycle.
duty_cycle = high_time / (high_time + low_time) * 100
#define nodeAddress 0 // Set to 0 or 1 int pinSendPWM; int pinReceivePWM; unsigned long lastWrite; unsigned long lastRead; /**************************************************************** SETUP ****************************************************************/ void setup() { Serial.begin(115200); while (!Serial) { ; // Wait for serial } // Node 0 if (nodeAddress == 0) { pinSendPWM = 3; pinReceivePWM = 4; } // Node 1 if (nodeAddress == 1) { pinSendPWM = 3; pinReceivePWM = 4; } pinMode(pinSendPWM, OUTPUT); // Generate PWM signal (use PWM pins !) pinMode(pinReceivePWM, INPUT_PULLUP); // Read PWM signal lastWrite = millis(); lastRead = millis(); } /**************************************************************** LOOP ****************************************************************/ void loop() { // Write every 10ms if (lastWrite + 10 < millis()) { // Send PWM random duty cycle (20..80%) writePWM( int(random(20,80)) ); lastWrite = millis(); } // Read every 1s if (lastRead + 1000 < millis()) { // Check the incoming PWM Serial.println("Reading duty cycle of " + String(readPWM()) + "%"); lastRead = millis(); } } // Write PWM to pin void writePWM(int DutyCycle) { byte sendPWM; sendPWM = byte( float(DutyCycle) / 100 * 255 ); // Calculate pin value analogWrite(pinSendPWM, sendPWM); // Write to pin } // Read PWM from pin int readPWM() { int PWM_High_Time = pulseIn(pinReceivePWM, HIGH); // Read HIGH time (µs) int PWM_Low_Time = pulseIn(pinReceivePWM, LOW); // Read LOW time (µs) return int(float(PWM_High_Time) / (PWM_High_Time + PWM_Low_Time) * 100); // Calculate Duty Cycle (%) } // END
Looking at the code, you can image this is far from an ideal solution.

Pros | Cons |
Works on all boards | More complex |
Analogue values | Data in 1 direction only (per line) |
Not usable for high accuracy | |
No sync (no clock) |
Bus protocols
Much more interesting are the bus protocols. Generally, they allow big chunks of data to be sent between devices, at (relatively) high speeds, in any form possible (depending on the protocol used). The guarantee much more accurate data than analogue values.
RS232
You could argue that RS232 isn’t a true bus protocol, since it basically can only be used to have 2 devices communicate with each other (although there are some tricks to work around this). The big advantage of RS232 is that the devices can both send and receive data.

#define nodeAddress 0 //Set to 0 for sender, 1 for receiver void setup() { Serial.begin(115200); while (!Serial) { ; // Wait for serial } } void loop() { // Sender if (nodeAddress == 0) { Serial.write("ABC"); delay(1000); // Wait a second } // Receiver if (nodeAddress == 1) { String receiveData; while (Serial.available() > 0) { Serial.print("Receiving: "); receiveData = Serial.readString(); Serial.println(receiveData); } } }
The sample code above will send the string “ABC” to the receiver. Characters A, B and C have ASCII values 0x41, 0x42 and 0x43. Taking a look at the timing diagram, the string “ABC” is sent to the receiver with 8 databits, no parity and 1 stopbit.

Pros | Cons |
Works on all boards | Only between 2 devices |
2 way communication | |
Widely supported |
SPI
A bit more interesting when multiple devices are used is the SPI protocol, since it’s also possible to work in a 2 way communication. Downside with this protocol however, is that for every receiver (or slave) a separate line is required to select the slave (SS or Slave Select) hat the master (sender) wants to talk to. Slaves cannot communicate directly with each other.

Pin | Wire | Signal |
9 | Blue | SS(2) (Slave Select 2) |
10 | Orange | SS(1) (Slave Select 1) |
11 | Green | MOSI (Master Out Slave In) |
12 | Yellow | MISO (Master In Slave Out) |
13 | Purple | SCK (Clock) |
// Include SPI library #include <SPI.h> //Set to 0 for master, 1 or 2 for slaves #define nodeAddress 0 // Slave char recData[50]; // array for receiving data volatile byte idx = 0; // index volatile boolean bDataReady = false; // Flag for data ready /**************************************************************** SETUP ****************************************************************/ void setup() { Serial.begin(115200); while (!Serial) { ; // Wait for serial } // Master - Setup SPI if (nodeAddress == 0) { pinMode(10, OUTPUT); // Slave Select 1 as output pinMode(9, OUTPUT); // Slave Select 2 as output digitalWrite(10, HIGH); // Slave select arduino 1 off digitalWrite(9, HIGH); // Slave select arduino 2 off SPI.begin(); SPI.setClockDivider(SPI_CLOCK_DIV128); // Set clock divider to 128 } // Slave - Setup SPI if (nodeAddress == 1 || nodeAddress == 2) { pinMode(12, OUTPUT); // MISO pin as output on slave pinMode(10, INPUT); // Set SS as input SPCR |= _BV(SPE); // Slave mode (SPI control register) SPI.attachInterrupt(); // Interrupt on SPI: ON } } /**************************************************************** LOOP ****************************************************************/ void loop() { // Master if (nodeAddress == 0) { char c; // Send "one\r" to slave 1 digitalWrite(10, LOW); // Slave 1 select ON (active low) for (const char * p = "one\r" ; c = *p; p++) { SPI.transfer (c); // Send over SPI Serial.print(c); // Show on serial monitor } digitalWrite(10, HIGH); // Slave 1 select OFF // Send "two\r" to slave 2 digitalWrite(9, LOW); // Slave 2 select ON (active low) for (const char * p = "two\r" ; c = *p; p++) { SPI.transfer (c); // Send over SPI Serial.print(c); // Show on serial monitor } digitalWrite(9, HIGH); // Slave 2 select 2 OFF delay(1000); // Wait a second } // Slave if (nodeAddress == 1 || nodeAddress == 2) { if (bDataReady) { bDataReady = false; // Reset data ready flag Serial.println (recData); // Show output on serial monitor idx = 0; // Reset index } } } /**************************************************************** SPI interrupt routine ****************************************************************/ ISR (SPI_STC_vect) { byte c = SPDR; // Read 1 byte from the SPI data register if (idx < sizeof recData) { recData[idx++] = c; // Add byte to data array if (c == '\r') { // Cariage return means end of data bDataReady = true; } } } // END
The code above sends the message “one\r” (that is “one” and a carriage return) to slave 1, and “two\r” to slave 2 (which is not connected). In the timing diagram, since slave 2 is not present, it will not send a response.
Note that characters “one” have ASCII values 0x6F, 0x6E and 0x65 and carriage return has ASCII value 0x0D (13).

Pros | Cons |
Works on all boards | 1 extra line per (slave) device required |
2 way communication | Bit more complex to program |
Multiple devices | |
Synchronised | |
Widely supported |
I2C
When communicating with multiple devices, I2C may be the most interesting one, since every device has its own “address” and therefore sending and receiving data to and from can be selected by this address. (compared to SS line in SPI protocol)

Pin | Wire | Signal |
SCL / A5 | Yellow | SCL (Clock) |
SDA / A4 | Green | SDA (Data) |
// I2C library #include <Wire.h> // Set the node address (0 = master, 2 and 3 are slaves) #define nodeAddress 0 void setup() { // Master or Slave if (nodeAddress == 0) { // Master Wire.begin(); // INIT I2C } else { // Slave Wire.begin(nodeAddress); // INIT I2C as device with address <nodeAddress> Wire.onReceive(i2cReceive); // Function for receiving data } Serial.begin(115200); while (!Serial) { ; // Wait for serial } Serial.println("--- Serial monitor started ---"); } void loop() { // Only master sends messages if (nodeAddress == 0) { // Send to device #2 Wire.beginTransmission(2); // Slave is address 2 Wire.write("22"); Wire.endTransmission(); // End of transmission // Send to device #3 Wire.beginTransmission(3); // Slave is address 3 Wire.write("333"); Wire.endTransmission(); // End of transmission delay(1000); // Wait a second } } void i2cReceive() { Serial.print("Message received: "); while (Wire.available()) { // As long as data is availbale char c = Wire.read(); // Read char by char Serial.print(c); // And print on the serial monitor } Serial.println(""); // Next line }
The sample code above will send “22” to slave with address 2 and “333” to slave with address 3. Characters 2 and 3 have ASCII values 0x32 and 0x33. Taking a look at the timing diagram, message “22” is sent to slave 2 which is connected to the master and replies with “ACK” (ACKnowledge), message “333” is NOT sent to slave 3 since it is not connected, and a “NACK” (Not ACKnowledge”) is set in stead.

Pros | Cons |
Works on all boards | |
2 way communication | |
Multiple devices | |
Synchronised | |
Widely supported |
The code below shows how the master can request information from the slaves.
// I2C library #include <Wire.h> // Set the node address (0 = master ; >0 = slave) #define nodeAddress 3 bool volatile bDataReady = false; char cData[32]; int volatile idx = 0; /**************************************************************** SETUP ****************************************************************/ void setup() { // Master or Slave if (nodeAddress == 0) { // Master Wire.begin(); // INIT I2C } else { // Slave Wire.begin(nodeAddress); // INIT I2C as device with address <nodeAddress> Wire.onReceive(i2cReceive); // ISR on data receive Wire.onRequest(i2cRequest); // ISR on data request } Serial.begin(115200); while (!Serial) { ; // Wait for serial } Serial.println("--- Serial monitor started ---"); } /**************************************************************** LOOP ****************************************************************/ void loop() { // Only master sends messages if (nodeAddress == 0) { // Build string ; MAX 32 bytes by default !!! String data = "Uptime is " + String(millis()) + " ms"; // Convert to char int dataLength = data.length() + 1; // +1, receiver cuts last byte char cData[dataLength]; data.toCharArray(cData, dataLength); // Send to device #2 Wire.beginTransmission(2); // Slave is address 2 Wire.write(cData, dataLength); Wire.endTransmission(); // End of transmission Serial.print("Message sent - "); Serial.print(cData); Serial.println(" - to slave 2"); // Send to device #3 Wire.beginTransmission(3); // Slave is address 3 Wire.write(cData, dataLength); Wire.endTransmission(); // End of transmission Serial.print("Message sent - "); Serial.print(cData); Serial.println(" - to slave 3"); // Send request for data Wire.requestFrom(2, 32); i2cReceive(); showMessage(); Wire.requestFrom(3, 32); i2cReceive(); showMessage(); delay(500); // Wait a second } // Slave part else { showMessage(); } } /**************************************************************** Show received data on serial monitor ****************************************************************/ void showMessage() { if (bDataReady) { bDataReady = false; // Reset data ready flag Serial.print("Message received: "); for (int b = 0; b < idx; b++) { Serial.print(cData[b]); // Convert char array to string auto. } Serial.println(""); idx = 0; } } /**************************************************************** Interrupt call for receiving data ****************************************************************/ void i2cReceive() { char c; while (Wire.available()) { // As long as data is availbale char c = Wire.read(); // Read char by char cData[idx] = c; idx++; } bDataReady = true; // Set data ready flag is char is carriage return } /**************************************************************** Interrupt call for receiving data ****************************************************************/ void i2cRequest() { // Build string ; MAX 32 bytes by default !!! String data = "Uptime is " + String(millis()) + " ms"; // Convert to char int dataLength = data.length() + 1; // +1, receiver cuts last byte char cData[dataLength]; data.toCharArray(cData, dataLength); // Send Wire.write(cData, dataLength); Serial.print("Replying: "); Serial.println(cData); } //END

Other protocols
Software simulated protocols
Besides the previously mentioned protocols, there are dozens of others: Profibus, EtherCAT, RS422, RS485, Ethernet, Modbus, Devicenet, CC-Link, … to name a few. By default, these are not supported by Arduino boards, but some of them can be simulated through software, and libraries are very likely available, like for example RS485.
Pros | Cons |
Officially available protocols are well documented and widely supported | Software may be buggy |
Mostly 2 way communication | May be harder to diagnose |
Almost all support multiple devices |
Converters
Note that for Arduino, some breakout boards are available that support protocols that are by default not supported by Arduino.
It’s also possible to convert protocols with external hardware, for example, RS232 to RS485 converters. This way, the Arduino board could communicate over RS232, by default supported, to a device with RS485 protocol and vice versa.
Own protocol
Writing your own protocol can be very fun and basically can be anything you want from digital I/O with a simple clock signal, to complex serial data transfer.
Pros | Cons |
Anything is possible | Very likely no boards support this |
Can become very complex |
Conclusion
When transferring data that is larger than one bit, or bi-directional traffic is required, no doubt bus protocols are the big winner here. Writing your own protocol can be challenging and fun, but what device could it possible support (except your own maybe?).