Bluetooth Low Energy (BLE)
This example demonstrates the implementation of the Bluetooth Low Energy (BLE) channel level in the Julia language – from frame generation to receiving it with integrity verification. The code simulates the key stages of the protocol: packing useful data into a frame, adding service fields, synchronization by preamble, and error control using cyclic redundant code.
Technologies and methods used
- LSB‑first is a bit representation with the least significant bit in front, as in the BLE specification.
- CRC‑24 – polynomial
0x00065Bwith an initial value0x555555, which provides error detection in the frame. - Correlation search of the preamble – the receiver finds the beginning of the frame by calculating the maximum dot product of the bit stream with the reference sequence
0xAA. - Length duplication – to increase reliability, the length of the payload is transmitted twice (in the header and in a separate field).
What the example shows
- Correct reception in the absence of distortion – CRC converges, data is restored.
- Error detection – accidental inversion of one or two bits leads to a CRC mismatch, and the packet is discarded.
- CRC‑24 collision – a specially selected change of one bit in the second byte of data (
0x65 → 0xE5) does not change the checksum. This illustrates that even a good CRC does not provide a 100% guarantee – the probability of such a match is extremely low (≈6·10⁻), but it exists. - Performance on real data – payload "Hello" (bytes
48 65 6C 6C 6F) is used in all tests.
Realization
The first two functions perform mutual conversion between bytes and bits in LSB-first order (the least significant bit goes first), as required by the BLE specification.
bytes_to_bits– decomposes each byte into 8 separate bits, starting with the lowest (bit 0). The result is a flat array of bits (0 or 1).bits_to_bytes– performs the opposite action: groups the bits by 8, collecting them into bytes (also the lowest bit becomes the first in the group).
function bytes_to_bits(data::Vector{UInt8})::Vector{Int}
bits = Int[]
for byte in data
for i in 0:7
push!(bits, (byte >> i) & 1)
end
end
return bits
end
function bits_to_bytes(bits::Vector{Int})::Vector{UInt8}
bytes = UInt8[]
for i in 1:8:length(bits)
byte = 0
for j in 0:7
if i+j <= length(bits)
byte |= bits[i+j] << j
end
end
push!(bytes, byte)
end
return bytes
end
The following function calculates CRC‑24 according to the BLE standard:
- Uses a polynomial
0x00065Band the initial value0x555555. - For each byte of data: shifts it to the CRC register, then performs a left shift 8 times with a conditional XOR polynomial (if the highest bit is set).
- Returns a 24‑bit checksum, which is added to the end of the frame and checked on reception to detect errors.
function crc24_ble(data::Vector{UInt8})::UInt32
poly = 0x00065B
crc = 0x555555
for byte in data
crc ⊻= UInt32(byte) << 16
for _ in 1:8
crc <<= 1
if (crc & 0x800000) != 0
crc ⊻= poly
end
crc &= 0xFFFFFF
end
end
return crc
end
Function build_frame generates a BLE frame from useful data:
- Adds a preamble
0xAA(8 bits) for synchronization. - Packs the length of the data into a header (16 bits) and a separate length field (8 bits) for reliability.
- Converts useful data into bits.
- Calculates CRC‑24 from the header, length, and data (little‑endian) and adds it (24 bits) to the end.
- Returns the assembled bit vector, which is a ready frame for transmission.
function build_frame(payload::Vector{UInt8})::Vector{Int}
preamble = [1, 0, 1, 0, 1, 0, 1, 0]
len = UInt16(length(payload))
header = [Int((len >> i) & 1) for i in 0:15]
len_byte = UInt8(length(payload))
len_bits = [Int((len_byte >> i) & 1) for i in 0:7]
data_bits = bytes_to_bits(payload)
crc_input = vcat(
[UInt8(len & 0xFF), UInt8((len >> 8) & 0xFF)],
[len_byte],
payload
)
crc_val = crc24_ble(crc_input)
crc_bits = [Int((crc_val >> i) & 1) for i in 0:23]
return vcat(preamble, header, len_bits, data_bits, crc_bits)
end
Function find_preamble searches for the preamble in the bit stream by correlation method:
- The sliding window compares a fragment of the stream with the reference preamble (
preamble). - Calculates the scalar product (the sum of the products of the bits) – the larger the value, the better the match.
- Returns the index of the beginning of the best match.
This is how the receiver determines where the frame starts and can correctly extract the service fields and data.
function find_preamble(stream::Vector{Int}, preamble::Vector{Int})::Int
best_idx = 1
best_corr = -1
for i in 1:(length(stream) - length(preamble) + 1)
corr = sum(stream[i+j-1] * preamble[j] for j in 1:length(preamble))
if corr > best_corr
best_corr = corr
best_idx = i
end
end
return best_idx
end
Function receive_frame accepts and decodes a BLE frame from a bit stream:
- Finds the preamble to determine the beginning of the frame.
- Extracts the length from the header and a separate field, checks their match.
- Reads the payload and CRC (24 bits).
- Recalculates the CRC by header, length, and data, and compares it with the accepted one.
- Returns the extracted data and the success flag (true if the CRC matches, otherwise the packet is considered corrupted).
function receive_frame(stream::Vector{Int})::Tuple{Vector{UInt8}, Bool}
preamble = [1, 0, 1, 0, 1, 0, 1, 0]
start = find_preamble(stream, preamble)
if start == -1
return UInt8[], false
end
pos = start + length(preamble)
if pos + 15 > length(stream)
return UInt8[], false
end
len = 0
for i in 0:15
len |= stream[pos+i] << i
end
pos += 16
if pos + 7 > length(stream)
return UInt8[], false
end
len_from_byte = 0
for i in 0:7
len_from_byte |= stream[pos+i] << i
end
pos += 8
if len != len_from_byte
return UInt8[], false
end
if pos + len*8 - 1 > length(stream)
return UInt8[], false
end
data_bits = stream[pos : pos + len*8 - 1]
pos += len*8
data = bits_to_bytes(data_bits)
if pos + 23 > length(stream)
return UInt8[], false
end
crc_recv = 0
for i in 0:23
crc_recv |= stream[pos+i] << i
end
crc_input = vcat(
[UInt8(len & 0xFF), UInt8((len >> 8) & 0xFF)],
[UInt8(len)],
data
)
crc_calc = crc24_ble(crc_input)
return data, (crc_calc == crc_recv)
end
The final test demonstrates the operation of the BLE channel layer using the example of transmitting the string "Hello" (bytes 48 65 6C 6C 6F). It checks four scenarios sequentially:
- Error–free – the frame is formed, transmitted without distortion, the receiver extracts the data, the CRC converges - the packet is received.
- One random error – a random bit in the frame is inverted. In most cases, CRC detects distortion and discards the packet, which confirms its ability to detect single errors.
- Special error (bit #48) – the bit is inverted, which changes the second byte of the data from
0x65on0xE5. This is a rare case of CRC‑24 collision, when the checksum remains unchanged and the packet is mistakenly received. The test clearly shows that CRC does not provide a 100% guarantee, but the probability of such a match is extremely low (≈6·10⁻⁸). - Two random errors – two random bits are inverted. CRC almost always detects such distortions, and the packet is discarded (as in test 2).
The test covers both regular operation and rare borderline cases, confirming the correctness of the implementation and at the same time demonstrating its limitations.
payload = UInt8[0x48, 0x65, 0x6C, 0x6C, 0x6F]
frame = build_frame(payload)
println("Initial data: ", join(string.(payload, base=16, pad=2), " "))
println("Длина кадра: $(length(frame)) бит\n")
received, ok = receive_frame(frame)
println("Without errors -> ", ok ? "[OK] Принято: $(join(string.(received, base=16, pad=2), " "))" : "[FAIL] CRC error")
println()
noisy = copy(frame)
idx = rand(1:length(noisy))
noisy[idx] = 1 - noisy[idx]
received, ok = receive_frame(noisy)
println("Одна случайная ошибка (бит #$idx) -> ", ok ? "[OK] Accepted (no error detected)" : "[FAIL] CRC error, packet dropped")
println()
noisy = copy(frame)
noisy[48] = 1 - noisy[48]
received, ok = receive_frame(noisy)
print("Special error (bit #48) -> ")
if ok
println("[OK] Accepted (CRC has not changed!) – this is a CRC-24 collision")
println(" Changed data: ", join(string.(received, base=16, pad=2), " "))
println(" (Initial data: 48 65 6c 6c 6f)")
else
println("[FAIL] CRC error (packet dropped)")
end
println()
noisy = copy(frame)
idxs = rand(1:length(noisy), 2)
for i in idxs
noisy[i] = 1 - noisy[i]
end
received, ok = receive_frame(noisy)
println("Two random errors (bits #", join(idxs, ", "), ") -> ", ok ? "[OK] Accepted (no errors found)" : "[FAIL] CRC error, packet dropped")
The tests clearly demonstrate the operation of the BLE channel level with integrity control via CRC‑24:
-
Error–free - the frame is received correctly, the data is extracted without distortion. The CRC matches, and the package passes verification.
-
One random error (in this case, bit #96, which refers to the CRC field) – the error is detected, the packet is discarded. This confirms that the CRC‑24 effectively detects single distortions in most cases.
-
Special error (bit #48, changes the second byte of data from
0x65on0xE5) – the package was mistakenly accepted because the CRC has not changed. This is a rare case of CRC‑24 collision: two different messages give the same checksum. The probability of such an event is extremely low (~2⁻2⁴ ≈ 6·10⁻⁸), but it exists, and the test demonstrates this. -
Two random errors (bits #92 and #37) – CRC detects distortion again, packet dropped. This shows that for most combinations of the two errors, CRC also works reliably.
Conclusion
The developed model implements the basic logic of BLE frame generation and reception: synchronization by preamble, data extraction, and CRC‑24 integrity verification. At the same time, the model is educational and contains simplifications: Access Address is omitted (4 bytes), the preamble is always equal to 0xAA (in reality, it depends on the address), and the length of the payload is duplicated for reliability (in the standard, the length is specified once in the PDU header). CRC‑24 provides high reliability, but not absolute – the found collision illustrates the mathematical limitation of cyclic codes. The example is useful for studying the principles of the channel layer and can serve as a basis for further extensions (scrambling, noise-tolerant coding, etc.).