Part 2 of 3 — Endianness, Address Types & Network Layer Internals
Network
Big Endian
4 Types
BlueZ mesh/net.c
What This Part Covers
In Part 1 we worked through data encoding conventions and the two bearer types. Now we step one layer up: the network layer. This is where PDUs receive source and destination addressing, TTL, a sequence number (for replay protection), and network-level encryption. The network layer is also the routing engine — it decides whether an incoming PDU should be delivered to the local application, relayed to other nodes, or discarded.
Two things must be correct before anything else in the network layer can work: byte order and addresses. This part covers both in full detail, with BlueZ-style C implementations you can compile and test independently.
Topics in this post
4. Network Layer Endianness — Always Big Endian
The Bluetooth Mesh specification mandates that every multi-octet numeric value in the network layer is transmitted in big-endian byte order, also called network byte order. This is the same convention used in IPv4, TCP, and most other wide-area networking protocols — the most significant byte travels first.
This is worth stating explicitly because BLE’s own packet formats (HCI, ATT, L2CAP headers) predominantly use little-endian byte order. Switching to big endian at the network layer means your PDU parser must apply the correct byte-swap for every field depending on which layer it belongs to. Getting this wrong produces silent, hard-to-diagnose decryption failures because the network key derivation uses the numeric values of address and sequence number fields.
| Value | Field | Big-Endian Wire Bytes — CORRECT | Little-Endian — NOT used here |
|---|---|---|---|
| 0x1234 | 16-bit address | 0x12, 0x34 | 0x34, 0x12 |
| 0x000ABC | 24-bit sequence number | 0x00, 0x0A, 0xBC | 0xBC, 0x0A, 0x00 |
| 0x123456 | Generic 24-bit value | 0x12, 0x34, 0x56 | 0x56, 0x34, 0x12 |
BlueZ uses a small set of inline helper functions throughout mesh/net.c and mesh/crypto.c. The pattern below gives you portable, zero-overhead helpers that work correctly on both little-endian ARM targets and big-endian hosts without relying on htons / ntohl (which only cover 16 and 32 bits and require the host to be little-endian to be useful).
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ============================================================
* Big-endian read helpers
* ============================================================ */
/* Read 16-bit big-endian value from byte buffer */
static inline uint16_t get_be16(const uint8_t *buf)
{
return ((uint16_t)buf[0] << 8) | (uint16_t)buf[1];
}
/* Read 24-bit big-endian value from byte buffer */
static inline uint32_t get_be24(const uint8_t *buf)
{
return ((uint32_t)buf[0] << 16) |
((uint32_t)buf[1] << 8) |
(uint32_t)buf[2];
}
/* Read 32-bit big-endian value from byte buffer */
static inline uint32_t get_be32(const uint8_t *buf)
{
return ((uint32_t)buf[0] << 24) |
((uint32_t)buf[1] << 16) |
((uint32_t)buf[2] << 8) |
(uint32_t)buf[3];
}
/* ============================================================
* Big-endian write helpers
* ============================================================ */
static inline void put_be16(uint16_t val, uint8_t *buf)
{
buf[0] = (uint8_t)(val >> 8);
buf[1] = (uint8_t)(val);
}
static inline void put_be24(uint32_t val, uint8_t *buf)
{
buf[0] = (uint8_t)(val >> 16);
buf[1] = (uint8_t)(val >> 8);
buf[2] = (uint8_t)(val);
}
static inline void put_be32(uint32_t val, uint8_t *buf)
{
buf[0] = (uint8_t)(val >> 24);
buf[1] = (uint8_t)(val >> 16);
buf[2] = (uint8_t)(val >> 8);
buf[3] = (uint8_t)(val);
}
/* ============================================================
* Network PDU field extraction (big-endian throughout)
*
* Network PDU layout (obfuscated fields shown as raw bytes):
* [0] : IVI (1 bit) | NID (7 bits)
* [1] : CTL (1 bit) | TTL (7 bits)
* [2..4] : SEQ (24-bit sequence number, big-endian)
* [5..6] : SRC (16-bit source unicast address, big-endian)
* [7..8] : DST (16-bit destination address, big-endian)
* [9..] : TransportPDU + NetMIC
* ============================================================ */
typedef struct {
uint8_t ivi; /* IV Index least-significant bit */
uint8_t nid; /* Network ID (7 bits) */
uint8_t ctl; /* Control message flag */
uint8_t ttl; /* Time To Live */
uint32_t seq; /* Sequence number (24 bits) */
uint16_t src; /* Source address */
uint16_t dst; /* Destination address */
} net_pdu_hdr_t;
void net_pdu_parse(const uint8_t *pdu, net_pdu_hdr_t *hdr)
{
hdr->ivi = (pdu[0] >> 7) & 0x01;
hdr->nid = pdu[0] & 0x7F;
hdr->ctl = (pdu[1] >> 7) & 0x01;
hdr->ttl = pdu[1] & 0x7F;
hdr->seq = get_be24(&pdu[2]); /* big-endian 24-bit */
hdr->src = get_be16(&pdu[5]); /* big-endian 16-bit */
hdr->dst = get_be16(&pdu[7]); /* big-endian 16-bit */
}
int main(void)
{
/* Synthetic Network PDU header bytes for demonstration */
uint8_t raw[] = {
0x68, /* IVI=0, NID=0x68 */
0x45, /* CTL=0, TTL=0x45 */
0x00, 0x00, 0x01, /* SEQ = 1 */
0x00, 0x12, /* SRC = 0x0012 (unicast) */
0xFF, 0xFF, /* DST = 0xFFFF (All-Nodes) */
};
net_pdu_hdr_t hdr;
net_pdu_parse(raw, &hdr);
printf("IVI=%u NID=0x%02X\n", hdr.ivi, hdr.nid);
printf("CTL=%u TTL=%u\n", hdr.ctl, hdr.ttl);
printf("SEQ=0x%06X\n", hdr.seq);
printf("SRC=0x%04X DST=0x%04X\n", hdr.src, hdr.dst);
/* Expected: SRC=0x0012, DST=0xFFFF */
return 0;
}
5. The Four Mesh Address Types
Every element in a mesh network is identified by a 16-bit address. The top two bits of that address encode its type, which tells the network layer at a glance how to handle a PDU carrying that address as its destination.
| Bit 15 | Bit 14 | Address Type | Hex Range | Valid in SRC | Valid in DST |
|---|---|---|---|---|---|
| 0 | 0 | Unassigned | 0x0000 only | × Forbidden | × Forbidden |
| 0 | x | Unicast | 0x0001–0x7FFF | ✓ Required | ✓ Allowed |
| 1 | 0 | Virtual | 0x8000–0xBFFF | × Forbidden | ✓ Allowed |
| 1 | 1 | Group | 0xC000–0xFFFF | × Forbidden | ✓ Allowed |
The unassigned address is the all-zeros value. It signals one of two things: either an element has not yet been provisioned and therefore has no address, or an address has been deliberately cleared. A typical use case is disabling message publishing for a model: setting its publish address to 0x0000 instructs the model to stop sending publications without touching any other configuration.
Bit layout:
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | | | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| bit 15 ← Octet 0 → bit 8 | bit 7 ← Octet 1 → bit 0 | |||||||||||||||
The spec prohibits the unassigned address from appearing in the SRC or DST field of any transmitted PDU. Any node that receives a PDU with SRC = 0x0000 must discard it without processing.
A unicast address identifies one specific element within the mesh network. Bit 15 is always 0. The remaining 15 bits can represent any non-zero value, giving 32,767 distinct unicast addresses per network.
A Provisioner assigns unicast addresses during the provisioning process. For a multi-element node (for example, a luminaire with three independently addressable channels), the Provisioner assigns a contiguous block: if the node base address is 0x0020, the three elements receive 0x0020, 0x0021, and 0x0022. The network layer on all other nodes must know about this block to route correctly.
Bit layout:
| 0 bit 15 always 0 |
Unicast Address Value bits [14:0] — any non-zero combination |
Routing rule: a PDU whose DST is a unicast address is processed by at most one element. When a relay node receives such a PDU, it checks if the DST matches any of its own element addresses. If yes, it delivers it upward and may optionally also relay. If no match, it decrements TTL and retransmits (provided TTL > 1).
/* Unicast address utilities
* Mirrors conventions in BlueZ mesh/mesh-defs.h
*/
#include <stdint.h>
#define MESH_UNASSIGNED_ADDR 0x0000u
/* A valid unicast address has bit 15 = 0 and is not 0x0000 */
static inline int mesh_addr_is_unicast(uint16_t addr)
{
return (addr != MESH_UNASSIGNED_ADDR) && !(addr & 0x8000u);
}
/*
* Check whether 'test_addr' belongs to this node.
*
* Mesh nodes have one or more elements, each with a consecutive
* unicast address starting from 'base_addr'.
*
* base_addr : unicast address of the node's primary element
* element_count : number of elements in the node
*/
static inline int mesh_addr_is_local(uint16_t base_addr,
uint8_t element_count,
uint16_t test_addr)
{
return mesh_addr_is_unicast(test_addr) &&
test_addr >= base_addr &&
test_addr < (uint16_t)(base_addr + element_count);
}
/* Example: process or relay an incoming PDU */
void net_layer_rx(uint16_t dst, uint8_t ttl,
uint16_t my_base, uint8_t my_elems)
{
if (dst == MESH_UNASSIGNED_ADDR) {
/* Spec violation — discard silently */
return;
}
if (mesh_addr_is_local(my_base, my_elems, dst)) {
int elem_idx = dst - my_base;
/* deliver to element elem_idx */
(void)elem_idx;
return;
}
if (mesh_addr_is_unicast(dst) && ttl > 1) {
/* relay with TTL decremented */
}
}
A virtual address is a 16-bit hash derived from a 128-bit Label UUID. Bits 15 and 14 are set to 1 and 0 respectively, identifying the address as virtual. The lower 14 bits carry a hash of the Label UUID, yielding up to 16,384 possible virtual addresses.
The design intent is elegant: you can address a large, changing group of nodes — for example “every light in meeting room 4” — without centrally managing a unique group address. You define a Label UUID for the room (a random 128-bit value), provision each luminaire to subscribe to it, and send messages to the derived 16-bit hash. Because the hash space is small (14 bits) there is a theoretical collision risk; the upper transport layer defends against this by including the full 128-bit Label UUID in the message authentication computation. A node that holds the Label UUID and successfully decrypts the message knows the PDU was genuinely addressed to it, not to a hash collision.
Important: the Label UUID is never transmitted on the wire. Only the 16-bit hash travels in the network PDU. Receiving nodes must already know the Label UUID for any virtual address they subscribe to.
Bit layout:
| 1 0 bits 15–14 type marker |
Hash of Label UUID bits [13:0] — 14 bits, derived by AES-CMAC |
/*
* Virtual address helpers.
*
* In production BlueZ code (mesh/crypto.c) the virtual address is
* derived using:
* AES-CMAC( key = "vtad", message = Label UUID[16] ) → 16 bytes
* virtual_addr = 0x8000 | (result[14] << 6 | result[15] >> 2) & 0x3FFF
*
* The function below shows the classification and a simplified
* hash-folding illustration only. Use the BlueZ mesh_virtual_addr()
* function in mesh/crypto.c for the real derivation.
*/
#include <stdint.h>
#include <string.h>
/* Returns 1 if addr is a virtual address (bits 15-14 = 0b10) */
static inline int mesh_addr_is_virtual(uint16_t addr)
{
return (addr & 0xC000u) == 0x8000u;
}
/*
* Illustrative hash fold: XOR all 16 UUID bytes pairwise into 16 bits,
* then stamp the top two bits as 1,0.
*
* Do NOT use this for real key derivation — the spec requires AES-CMAC.
*/
static uint16_t virtual_addr_demo(const uint8_t uuid[16])
{
uint16_t h = 0;
int i;
for (i = 0; i < 16; i += 2)
h ^= ((uint16_t)uuid[i] << 8) | uuid[i + 1];
return 0x8000u | (h & 0x3FFFu);
}
/*
* Walk the node's virtual address subscription list and
* return 1 if the incoming vaddr matches any subscribed Label UUID.
*
* uuids : array of 16-byte Label UUIDs this element subscribes to
* uuid_count : number of entries
*/
int mesh_virtual_addr_match(uint16_t vaddr,
const uint8_t *uuids,
int uuid_count)
{
int i;
if (!mesh_addr_is_virtual(vaddr))
return 0;
for (i = 0; i < uuid_count; i++) {
/* Replace virtual_addr_demo() with real AES-CMAC derivation */
uint16_t candidate = virtual_addr_demo(uuids + i * 16);
if (candidate == vaddr)
return 1;
}
return 0;
}
Group addresses are the simplest form of multicast. Both bit 15 and bit 14 are set. Any element that has subscribed to a given group address will process a PDU sent to it. There is no UUID indirection and no authentication beyond the standard network-layer MIC — the 16-bit address itself is the entire subscription key.
Bit layout:
| 1 1 bits 15–14 type marker |
Group Address Value bits [13:0] — 14 bits user-defined or SIG-reserved |
The Bluetooth SIG reserves four specific group addresses for network-wide management messages. These are fixed and must never be used as user-defined group addresses:
| Address | Name | Processed by |
|---|---|---|
| 0xFFFF | All-Nodes | Every node in the network |
| 0xFFFE | All-Relays | All nodes with the Relay feature enabled |
| 0xFFFD | All-Friends | All nodes with the Friend feature enabled |
| 0xFFFC | All-Proxies | All nodes with the Proxy feature enabled |
The function below classifies any 16-bit mesh address, validates it for use in SRC and DST positions, and prints a human-readable description. The logic directly maps to mesh/mesh-defs.h in the BlueZ source tree.
#include <stdint.h>
#include <stdio.h>
/* ============================================================
* Mesh address type constants
* ============================================================ */
#define MESH_UNASSIGNED_ADDR 0x0000u
#define MESH_ADDR_UNICAST_MAX 0x7FFFu
#define MESH_ADDR_VIRTUAL_MIN 0x8000u
#define MESH_ADDR_VIRTUAL_MAX 0xBFFFu
#define MESH_ADDR_GROUP_MIN 0xC000u
/* Fixed group addresses (Bluetooth SIG assigned) */
#define MESH_ALL_PROXIES_ADDR 0xFFFCu
#define MESH_ALL_FRIENDS_ADDR 0xFFFDu
#define MESH_ALL_RELAYS_ADDR 0xFFFEu
#define MESH_ALL_NODES_ADDR 0xFFFFu
typedef enum {
MESH_ADDR_TYPE_UNASSIGNED = 0,
MESH_ADDR_TYPE_UNICAST,
MESH_ADDR_TYPE_VIRTUAL,
MESH_ADDR_TYPE_GROUP,
} mesh_addr_type_t;
/* ============================================================
* Classification
* ============================================================ */
mesh_addr_type_t mesh_addr_classify(uint16_t addr)
{
if (addr == MESH_UNASSIGNED_ADDR)
return MESH_ADDR_TYPE_UNASSIGNED;
/* Bit pattern inspection via top 2 bits */
switch (addr & 0xC000u) {
case 0x0000u: /* bits[15:14] = 00 and addr != 0 */
case 0x4000u: /* bits[15:14] = 01 */
return MESH_ADDR_TYPE_UNICAST;
case 0x8000u: /* bits[15:14] = 10 */
return MESH_ADDR_TYPE_VIRTUAL;
default: /* bits[15:14] = 11 */
return MESH_ADDR_TYPE_GROUP;
}
}
/* Valid in the SRC field: only unicast addresses */
static inline int mesh_addr_valid_src(uint16_t addr)
{
return mesh_addr_classify(addr) == MESH_ADDR_TYPE_UNICAST;
}
/* Valid in the DST field: anything except unassigned */
static inline int mesh_addr_valid_dst(uint16_t addr)
{
return addr != MESH_UNASSIGNED_ADDR;
}
/* ============================================================
* Debug helper
* ============================================================ */
void mesh_addr_dump(uint16_t addr)
{
switch (mesh_addr_classify(addr)) {
case MESH_ADDR_TYPE_UNASSIGNED:
printf("0x%04X [UNASSIGNED] — forbidden in SRC and DST\n", addr);
break;
case MESH_ADDR_TYPE_UNICAST:
printf("0x%04X [UNICAST] — valid SRC; valid DST; "
"processed by at most one element\n", addr);
break;
case MESH_ADDR_TYPE_VIRTUAL:
printf("0x%04X [VIRTUAL] — hash of a 128-bit Label UUID; "
"valid DST only\n", addr);
break;
case MESH_ADDR_TYPE_GROUP:
if (addr >= MESH_ALL_PROXIES_ADDR) {
const char *name =
(addr == MESH_ALL_NODES_ADDR) ? "All-Nodes" :
(addr == MESH_ALL_RELAYS_ADDR) ? "All-Relays" :
(addr == MESH_ALL_FRIENDS_ADDR) ? "All-Friends" :
"All-Proxies";
printf("0x%04X [GROUP / SIG-reserved: %s]\n", addr, name);
} else {
printf("0x%04X [GROUP / user-defined] — multicast; "
"valid DST only\n", addr);
}
break;
}
}
/* ============================================================
* Test sweep
* ============================================================ */
int main(void)
{
uint16_t probes[] = {
0x0000, /* unassigned */
0x0001, /* first unicast */
0x0012, /* typical element */
0x7FFF, /* last unicast */
0x8000, /* first virtual */
0x9ABC, /* mid-range virtual */
0xBFFF, /* last virtual */
0xC000, /* first user group */
0xD000, /* user-defined group*/
0xFFFC, /* All-Proxies */
0xFFFD, /* All-Friends */
0xFFFE, /* All-Relays */
0xFFFF, /* All-Nodes */
};
int n = (int)(sizeof(probes) / sizeof(probes[0]));
int i;
printf("%-8s %-14s SRC-ok DST-ok\n",
"Address", "Type");
printf("%-8s %-14s ------ ------\n",
"-------", "----");
for (i = 0; i < n; i++) {
uint16_t a = probes[i];
const char *type_str[] = {
"UNASSIGNED", "UNICAST", "VIRTUAL", "GROUP"
};
printf("0x%04X %-14s %-6s %-6s\n",
a,
type_str[mesh_addr_classify(a)],
mesh_addr_valid_src(a) ? "yes" : "no",
mesh_addr_valid_dst(a) ? "yes" : "no");
}
return 0;
}
Compile and run: gcc -o addr_classifier addr_classifier.c && ./addr_classifier
Expected output confirms that only unicast addresses pass the SRC validity check, unassigned fails both, and virtual/group addresses pass DST but not SRC.
| Concept | Key Point | Where It Matters |
|---|---|---|
| Field ordering | Big-endian → first field at MSB. Little-endian → first field at LSB. | PDU parser, struct packing |
| Relay feature | Decrement TTL, re-broadcast if TTL was > 1. Range extension for free. | Network coverage planning |
| Advertising bearer | ADV_NONCONN_IND, AD Type 0x2A, no Flags field (saves 2 bytes for PDU). | HCI setup, sniffer analysis |
| GATT bearer | Write Without Response to Data In; Notify from Data Out. Enable CCCD before data flows. | Provisioner app, GATT debug |
| Network layer byte order | All multi-byte network layer values are big-endian. Use get_be16 / put_be16 helpers. | PDU encoding/decoding |
| Address classification | Top 2 bits: 00/01 = unicast, 10 = virtual, 11 = group. 0x0000 = unassigned (forbidden in SRC and DST). | Routing, subscription matching |
| Virtual address | 16-bit hash of 128-bit Label UUID. UUID never transmitted; used in transport MIC. Collision-safe by design. | Room / zone addressing |
Up Next — Part 3
Part 3 digs into the full Network PDU format field by field: IVI, NID, CTL, TTL, SEQ, SRC, DST, the TransportPDU, and the Network MIC. We walk through the obfuscation and encryption steps that the network layer applies to every outgoing PDU, using the BlueZ mesh/crypto.c functions as the reference implementation.
