CRC Integrity in BLE Data Pipelines: Catching Silent Corruption Before It Reaches Your App
BLE delivers packets. It does not guarantee your application layer received what was sent. CRC validation is the difference between safe firmware and a bricked device.
Summary
The BLE link layer has its own CRC — but it protects individual radio packets, not application payloads. Across fragmentation, reassembly, and application handling, silent data corruption is possible. Here's the validation strategy that prevented bricked devices in a production automotive OTA system.
Surendra — TEJVON
Senior Android & BLE Engineer
When you're transmitting a firmware image to an embedded device over BLE, the worst possible outcome is not a failed transfer. It's a successful transfer of corrupted data. A device that receives and applies a corrupted firmware image may become permanently inoperable — bricked, in the field, unreachable. That's a warranty claim, a truck roll, and a product recall risk.
We implemented a two-level CRC validation strategy in the automotive OTA system: per-chunk CRC for immediate detection, and full-image CRC before the device applies the firmware. This article covers both levels, the implementation, and the specific failure modes each level catches.
Why BLE's Built-in CRC Is Not Enough
The Bluetooth link layer computes a 24-bit CRC over every transmitted PDU. If the CRC fails, the packet is retransmitted. So why do you need application-level CRC? Because the link layer CRC only protects the radio transmission. Corruption can happen at other stages: memory allocation errors in the receiver, byte order issues in your fragmentation protocol, incorrect buffer offsets, integer overflow in length calculations. The link layer knows nothing about any of this.
CRC-16/CCITT-FALSE: The Right Algorithm for BLE Payloads
CRC-32 has better collision resistance but doubles your per-chunk overhead. For BLE where every byte counts against your MTU budget, CRC-16 is the right trade-off. We used CRC-16/CCITT-FALSE (polynomial 0x1021, initial value 0xFFFF), which is the same algorithm used in X.25, HDLC, and SD card protocols — well-tested on embedded targets.
// Kotlin — Android side
object Crc16Ccitt {
private const val POLYNOMIAL = 0x1021
private const val INITIAL_VALUE = 0xFFFF
fun calculate(data: ByteArray, offset: Int = 0, length: Int = data.size): Int {
var crc = INITIAL_VALUE
for (i in offset until offset + length) {
crc = crc xor ((data[i].toInt() and 0xFF) shl 8)
repeat(8) {
crc = if (crc and 0x8000 != 0) {
(crc shl 1) xor POLYNOMIAL
} else {
crc shl 1
}
crc = crc and 0xFFFF // keep 16 bits
}
}
return crc
}
fun verify(data: ByteArray, expectedCrc: Int): Boolean =
calculate(data) == expectedCrc
}
// Equivalent C — embedded firmware side
uint16_t crc16_ccitt(const uint8_t *data, uint16_t length) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < length; i++) {
crc ^= (uint16_t)data[i] << 8;
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x8000) crc = (crc << 1) ^ 0x1021;
else crc = crc << 1;
}
}
return crc;
}Two-Level Validation Strategy
Level 1: Per-Chunk CRC
Every fragment carries a 2-byte CRC of its payload. The receiver validates each chunk immediately on receipt. On mismatch, the receiver requests retransmission of the specific sequence number rather than the entire transfer. This catches transmission errors early and localises retransmission cost.
Level 2: Full-Image CRC
After all chunks are reassembled, we compute a CRC-16 of the complete payload and compare it against a reference value sent in the transfer initiation packet. This catches reassembly-order errors, buffer overflows, and any corruption that per-chunk CRC missed. The device firmware will not apply the image unless this check passes.
class OtaTransferManager(
private val bleManager: BleConnectionManager,
private val fragmenter: BleFragmenter
) {
suspend fun transfer(firmware: ByteArray): TransferResult {
val fullImageCrc = Crc16Ccitt.calculate(firmware)
val fragments = fragmenter.fragment(firmware)
// Send transfer initiation: total chunks + full-image CRC
bleManager.writeCharacteristic(
OTA_CONTROL_CHAR_UUID,
OtaCommand.Begin(fragments.size, fullImageCrc).toBytes()
)
for (fragment in fragments) {
val packet = fragment.toByteArray()
val ack = bleManager.writeAndAwaitAck(OTA_DATA_CHAR_UUID, packet)
when (ack) {
is OtaAck.ChunkCrcError -> {
// Resend this chunk only
bleManager.writeAndAwaitAck(OTA_DATA_CHAR_UUID, packet)
}
is OtaAck.Accepted -> continue
is OtaAck.Rejected -> return TransferResult.Failed("Device rejected chunk ${fragment.seqNumber}")
}
}
// Device now validates full-image CRC before applying
val applyResult = bleManager.writeAndAwaitAck(
OTA_CONTROL_CHAR_UUID,
OtaCommand.Apply.toBytes()
)
return when (applyResult) {
is OtaAck.ImageCrcValid -> TransferResult.Success
is OtaAck.ImageCrcInvalid -> TransferResult.Failed("Full image CRC mismatch — transfer corrupted")
else -> TransferResult.Failed("Unexpected response: $applyResult")
}
}
}Production Result
In 18 months of production OTA deployments across automotive diagnostic devices, the two-level CRC strategy detected and recovered from 3 corruption events (all from RF interference during transfer). Zero devices were bricked. All events were handled transparently via chunk retransmission.
Topics
Working on a similar challenge?
Let's discuss the architecture before you build.
BLE system design, OTA reliability, and connected product engineering — this is what TEJVON does every day.
Book a Technical Consultation