Keeping Bytes Honest: Data Integrity in Serial Communications
Communication is a critical component of embedded systems, whether it occurs within a single system or between multiple systems. In embedded systems, communication typically falls into two categories: parallel interfaces, which transfer streams of data simultaneously over multiple channels and serial interfaces, which transmit data one bit at a time. This article will focus on serial communication and strategies for maintaining data integrity in environments where even a single dropped byte can jeopardize a project’s success.1
Serial communications transfer data one bit at a time between systems. In a well-architected embedded system, the chosen microcontroller or IC often includes one of several common serial interfaces, such as SPI, UART, or I2C. Selecting a specific interface typically depends on project requirements, cost considerations (for example, 3-wire SPI versus 2-wire UART or I2C), or the native interface of a module the system interacts with (for instance, an SPI display versus an I2C display).
In most cases, communication proceeds in predictable sequences or timings, satisfying device and project specifications and while the occasional dropped bytes may occur, they are usually non-critical and do not compromise overall system functionality. The subject of data integrity usually comes up in instances where data precision is absolute and not optional; embedded systems in medical devices or automotive systems rely on high precision data where a single lost byte could end up being catastrophic. In this article, UART is the serial interface for our data integrity communications case study because of its simplicity and ease of use.
An Approach
UART, for the uninitiated, which stands for Universal Asynchronous Receiver/Transmitter, is one of the oldest serial interfaces still in use today. It works by sending and receiving data over two wires or channels aptly named RX and TX. Both RX and TX are connected to the other device in a crisscrossed fashion where Device A's RX is connected to Device B's TX and vice versa.
In most common serial interfaces, there's usually another line that acts as the clock to synchronize data exchange between the endpoints but on UART data synchronization relies on a baud rate system in which both ends of the communication interface must agree on a common baud rate (among other conditions including voltage level, data bits size and more) for exchanging data before any transactions are sent or received. As a result of all these conditions, each byte transmitted is framed with a start bit at the beginning and one or more stop bits at the end with an optional parity bit included for error detection.
This framing allows the receiving device to distinguish valid bytes from noise on the data line. Even with properly framed data, errors can and still occur; timing mismatches, voltage spikes and buffer overruns can corrupt or drop bytes and in most critical applications, a single incorrect byte is the difference between an implementation being buggy and not working vs one that works fine. To mitigate these issues and maintain data integrity, several measures can be taken.
One of the simplest ways to maintain data integrity is to wrap data in a protocol. Protocols structure the transmitted data with headers that describe length, type, address, or other metadata. This ensures the receiver can correctly parse the incoming bytes, detect missing data, and avoid misinterpretation. The example below shows a minimal implementation that frames a data array by prepending its length as a two-byte header.
int8_t frame_data(uint8_t *data, uint16_t data_len, uint8_t *framing_out) {
if (data == NULL || framing_out == NULL) {
return -1; // invalid input or data too long
}
// add length header (2 bytes)
// notice the left shift aligns with the big endian style of splitting data
framing_out[0] = (uint8_t)((data_len >> 8) & 0xff);
framing_out[1] = (uint8_t)(data_len & 0xff);
// copy data
for (uint16_t i = 0; i < data_len; i++) {
framing_out[i + 2] = data[i];
}
return 0;
}
Even with a well-defined protocol, hardware conditions are not always perfect. Noise, timing mismatches, or buffer overruns can corrupt transmitted data.
Checksum For Intergrity
A simple, resource-light method to detect such corruption is a checksum. One common choice is the XOR checksum: each byte of data is XORed together, then appended to the frame before transmission. The receiver performs the same XOR operation on the received data and compares it with the transmitted checksum. If the values match, the data is considered valid, and the receiver can send an acknowledgment (ACK) before the next block is sent. This handshaking process ensures reliability over raw speed, which is especially important in critical embedded applications like flash programming. Here's an implementation of a simple XOR checksum.
uint8_t calculate_checksum(uint8_t *data, size_t length) {
uint8_t chksum = 0;
for (size_t i = 0; i < length; i++) {
chksum ^= data[i];
}
return chksum;
}
Yet again there's another problem that can come up even with XORed checksum; the transposition problem.
The transposition problem comes up as a limitation of a simple XOR checksum as it cannot detect transposed bytes. Since XOR is commutative (A ^ B == B ^ A), swapping two bytes in the data produces the same checksum and in critical applications, this could allow a subtle corruption to go unnoticed. A lightweight way to address this is to make the checksum position-sensitive; take two data bytes 0x12 and 0x34 at indices of 0 and 1 respectively. Next to each other when an XOR checksum is ran against both, the result is 0x26 no matter how you interchange their positions.
Data bytes: [0x12, 0x34]
XOR Checksum:
0x12 ^ 0x34 = 0x26
Transposed bytes: [0x34, 0x12]
0x34 ^ 0x12 = 0x26 <-- XOR misses transposition
As seen above, the transposition problem could be a silent failure for data integrity in serial communications.
A better approach is to weigh each byte by its index when computing the checksum which should catch any transposition issues. Using the same bytes from above, we have two completely different checksum when adding in their index which should solve the problem.
Data bytes: [0x12, 0x34]
Weighted XOR with modulo for position
Byte 0: 0x12 * ((0 % 255) + 1) = 0x12 * 1 = 0x12
Byte 1: 0x34 * ((1 % 255) + 1) = 0x34 * 2 = 0x68
Checksum: 0x12 ^ 0x68 = 0x7a
Transposed bytes: [0x34, 0x12]
Byte 0: 0x34 * 1 = 0x34
Byte 1: 0x12 * 2 = 0x24
Checksum: 0x34 ^ 0x24 = 0x10 <-- catches transposition
An implementation of the weighted approach would look like this:
uint8_t calculate_weighted_checksum(uint8_t *data, size_t length) {
uint8_t chksum = 0;
for (size_t i = 0; i < length; i++) {
// truncation is intentional and deterministic
chksum ^= data[i] * (uint8_t)((i % 255) + 1); // weight each byte by position
}
return chksum;
}
This simple change ensures that swapping two bytes produces a different checksum, catching a class of errors that XOR alone cannot. For even stronger detection, a small CRC (8- or 16-bit) can be used, but the weighted XOR is often sufficient in low-resource microcontroller applications. Combining this with ACK/NACK handshaking provides reliable serial transfers without significantly increasing computation or memory overhead.
Bringing these together, the protocol can be extended like so:
// calculate a lightweight weighted XOR checksum (position-sensitive)
uint8_t calculate_weighted_checksum(uint8_t *data, size_t length) {
uint8_t chksum = 0;
for (size_t i = 0; i < length; i++) {
// truncation is intentional and deterministic
chksum ^= data[i] * (uint8_t)((i % 255) + 1); // weight each byte by position
}
return chksum;
}
// frame data for serial transmission
// adds 2-byte length header + data + 1-byte weighted checksum
int8_t frame_data(uint8_t *data, uint16_t data_len, uint8_t *framing_out) {
if (data == NULL || framing_out == NULL) {
return -1; // invalid input or data too long
}
// add length header (2 bytes)
framing_out[0] = (uint8_t)((data_len >> 8) & 0xff);
framing_out[1] = (uint8_t)(data_len & 0xff);
// copy data
for (uint16_t i = 0; i < data_len; i++) {
framing_out[i + 2] = data[i];
}
// append weighted XOR checksum
framing_out[data_len + 2] = calculate_weighted_checksum(data, data_len);
return 0;
}
This works by storing the length of the payload for the receiver using 2 bytes, the data itself being an arbitrary count up to UINT16_MAX in this case, and a single byte XOR checksum to detect corruption and byte transpositions. The receiver side would then recompute the checksum on the received data and compare it to the appended byte. If it matches, an ACK is sent otherwise a NACK and depending on logic of and needs of the system, a retransmission of the data is done again.
protocol frame
+------------+------------+----------------+------------+
| length high| length low | payload | checksum |
+------------+------------+----------------+------------+
1 byte 1 byte uint16_t max bytes 1 byte
Wrapping Up
Serial communication may seem simple at first glance, but in real-world embedded systems, maintaining data integrity is far from trivial. A single dropped or corrupted byte can turn a working system into a malfunctioning one, especially in applications where precision matters.
By combining framing, length headers, and a position-sensitive checksum, you can build a lightweight, reliable protocol that works even under imperfect hardware conditions. Weighted XOR checksums catch not just random bit errors but also subtle transpositions that a plain XOR would miss. Adding simple ACK/NACK handshaking ensures that the system can recover gracefully from transmission errors without requiring expensive hardware or complex software overhead.
The approach shown here is minimal yet practical, and it’s directly applicable to real embedded projects like flash programmers, microcontroller communication, or even custom chip bring-ups.
All examples are written in C but should be transferable to other programming languages. Finally all examples are big-endian and in a multi-architecture system, defining a consistent "Network Byte Order" (similar to higher level networking) is critical to prevent misinterpreting payload length.↩