GATT Characteristic Value Read & Write
Chapter 13 · Part 4 of 5 · Sections 13.6.6–13.6.7 · 4 Read Procedures · 5 Write Procedures · Long Values · Reliable Writes
13.6.6 — Characteristic Value Read
13.6.6.1 — Read Characteristic Value
The direct, single-shot read. The client already knows the Characteristic Value Handle (discovered in section 13.6.4) and fires a Read Request. The server replies with the current value. This is the most common GATT operation — it is how you read a battery level, a device name, a sensor reading, or any other characteristic that fits in one ATT PDU.
The server can return an Error Response instead if: the attribute is not readable, authentication is required and the link is unauthenticated, authorization is required and the client has not been authorized, or the encryption key size is insufficient. These permissions are set by the profile that defines the characteristic.
/* Read single characteristic by handle: */
gatttool -b AA:BB:CC:DD:EE:FF --char-read --handle=0x000c
/* Read Device Name (handle 0x0003 in GAP service): */
gatttool -b AA:BB:CC:DD:EE:FF --char-read --handle=0x0003
/* Output: Characteristic value/descriptor: 4d 79 44 65 76.. */
/* (hex bytes of "MyDevice") */
/* bluetoothctl read: */
[AA:BB:CC:DD:EE:FF]# select-attribute /org/bluez/hci0/.../char000b
[AA:BB:CC:DD:EE:FF]# read
/* Attempting to read /org/bluez/hci0/.../char000b */
/* [CHG] Attribute .../char000b Value: */
/* 48 00 00 ff 00 01 */
/* Python / BlueZ D-Bus: */
value = char_iface.ReadValue({})
# Returns dbus.Array of dbus.Byte objects
13.6.6.2 — Read Long Characteristic Value
When the characteristic value is larger than ATT_MTU−1 bytes, a single Read Request cannot carry it all. Read Long is the solution — it reads the value in offset-based chunks using Read Blob Request. The client first reads bytes 0 through ATT_MTU-2 using a regular Read Request, then continues from offset ATT_MTU-1 using Read Blob Requests, each time advancing the offset by ATT_MTU-1 until the response returns fewer bytes than the maximum, which signals the end of the value.
An important edge case: if the server detects that the characteristic value is actually shorter than ATT_MTU-1 bytes and the client sends a Read Blob Request for it, the server returns an Error Response. That is why it is important to use a regular Read Request first and only switch to Read Blob if the response comes back fully filled.
13.6.6.3 — Read Using Characteristic UUID
Usually a client reads a characteristic by handle. But what if the client wants a value and knows the UUID but not the handle? This procedure skips the need for a prior discovery step. The client sends a Read By Type Request targeting the characteristic UUID within a handle range (typically the whole service range). The server returns the handle and current value directly in one response.
/* Read by UUID — no handle lookup needed: */
gatttool -b AA:BB:CC:DD:EE:FF --char-read --uuid=0x2A37
/* This fires a Read By Type Request directly */
/* Returns value AND handle so client can cache the handle */
/* Useful for first-time reads where discovery was skipped */
/* Less efficient than caching the handle for repeated reads */
13.6.6.4 — Read Multiple Characteristic Values
For applications that need to poll several characteristics in one round trip — like reading temperature, humidity, and pressure from a multi-sensor device simultaneously — this sub-procedure sends all handles at once and receives all values concatenated in one response. The critical constraint: all requested values must have a known, fixed length, because the concatenated response has no length delimiters between values.
/* BlueZ D-Bus: Read Multiple is not exposed directly via */
/* bluetoothctl but is used internally by gatt-client.c */
/* for performance optimizations */
/* C level — ATT Read Multiple Request opcode = 0x0E: */
uint8_t handles[] = {
0x25, 0x00, /* handle 0x0025 little-endian */
0x27, 0x00, /* handle 0x0027 */
0x29, 0x00 /* handle 0x0029 */
};
uint8_t pdu[1 + sizeof(handles)];
pdu[0] = 0x0E; /* ATT_OP_READ_MULTI_REQ */
memcpy(pdu + 1, handles, sizeof(handles));
send(att_sock, pdu, sizeof(pdu), 0);
/* Receive 0x0F Read Multiple Response, parse concatenated values */
13.6.7 — Characteristic Value Write
13.6.7.1 — Write Without Response
The fastest possible write. The client sends a Write Command to the server and moves on immediately — no waiting, no acknowledgement, no error feedback. If the packet is lost or the server rejects it, the client will never know. Despite this unreliability, Write Without Response is widely used for streaming commands where latency matters more than guaranteed delivery, such as writing playback commands to a BLE audio device or sending UART data over BLE SPP-style profiles.
/* Write Without Response (Write Command) with gatttool: */
gatttool -b AA:BB:CC:DD:EE:FF \
--char-write --handle=0x0025 --value=01
/* --char-write sends Write Command (opcode 0x52) */
/* Compare: --char-write-req sends Write Request (opcode 0x12) */
/* which waits for Write Response */
/* C — Write Command opcode 0x52: */
uint8_t pdu[] = {
0x52, /* Write Command opcode (bit6=CMD=1) */
0x25, 0x00, /* Attribute Handle little-endian */
0x01 /* Value */
};
/* No recv() needed — no response coming */
send(att_sock, pdu, sizeof(pdu), 0);
13.6.7.2 — Signed Write Without Response
Like Write Without Response, but with a 12-byte authentication signature appended. The signature is computed using the CSRK (Connection Signature Resolving Key), which was shared during SM Phase 3 pairing. This gives the server a way to verify the write came from the legitimate paired client, even without link-layer encryption. A replay counter prevents old signed writes from being reused.
This sub-procedure requires the devices to be bonded — the server needs the client’s CSRK to verify the signature, which was distributed during pairing. It cannot be used between non-bonded devices.
13.6.7.3 — Write Characteristic Value (Acknowledged)
The reliable write for values that fit in one PDU. Unlike Write Without Response, the server sends back a Write Response once the value is saved. If anything goes wrong, the server sends an Error Response explaining why — insufficient permissions, authentication required, encryption needed, or the value is invalid. The client knows for certain whether the write succeeded.
/* Acknowledged write — waits for Write Response: */
gatttool -b AA:BB:CC:DD:EE:FF \
--char-write-req --handle=0x0025 --value=01
/* Enable notification by writing to CCCD: */
gatttool -b AA:BB:CC:DD:EE:FF \
--char-write-req --handle=0x000d --value=0100
/* 0x0100 LE = 0x0001 = notifications enabled */
/* Python: */
char_iface.WriteValue([0x01], {}) # default = Write Request */
13.6.7.4 — Write Long Characteristic Value
When the value to be written is too large to fit in one Write Request (ATT_MTU−3 bytes max), the client splits it into chunks and queues them on the server using Prepare Write Requests. The server holds the chunks in a write queue, echoing each one back to confirm receipt. Once all chunks are sent, the client sends Execute Write Request with Flags=0x01 to commit everything atomically.
Note the key difference from Reliable Writes: here, the client is not required to check the echoed values from Prepare Write Responses. The echo is there as a service but ignoring it is acceptable for this sub-procedure.
13.6.7.5 — Reliable Writes
Reliable Writes adds a mandatory verification step on top of Write Long. After every Prepare Write Response, the client must compare the echoed data with what it sent. Any discrepancy means data was corrupted in transit — the client should cancel the entire operation with Execute Write (Flags=0x00) and retry from the start.
Reliable Writes also serves a second purpose: writing multiple characteristics atomically. By using different Attribute Handles in subsequent Prepare Write Requests, the client can write to Handle A, Handle B, and Handle C in one operation — the server commits all three simultaneously when Execute Write arrives, with no risk of another client interleaving writes between them.
/* Reliable Writes use the same Prepare+Execute pattern */
/* but the client verifies each response */
/* BlueZ gatt-client.c implements Reliable Writes in */
/* bt_gatt_client_write_long_value() with verify=true: */
/* From src/shared/gatt-client.c: */
struct long_write_op {
struct bt_gatt_client *client;
bool reliable_writes; /* true = verify each response */
uint16_t handle;
size_t offset;
uint8_t *value;
size_t length;
/* ... */
};
/* Pseudo-flow for each Prepare Write response: */
static void prepare_write_cb(uint8_t opcode, const void *pdu,
uint16_t len, void *user_data) {
struct long_write_op *op = user_data;
if (op->reliable_writes) {
/* Compare echoed value with sent value */
if (memcmp(pdu + 4, op->value + op->offset,
len - 4) != 0) {
/* Mismatch — cancel with Execute Write 0x00 */
cancel_long_write(op);
return;
}
}
/* All good — send next chunk or Execute Write 0x01 */
send_next_chunk(op);
}
Write Sub-Procedures Comparison
| Sub-Procedure | ATT Opcode | ACK? | Long values? | Auth Sig? | Echo Verify? |
|---|---|---|---|---|---|
| Write Without Response | 0x52 Write Cmd | No | No | No | No |
| Signed Write | 0xD2 Signed Cmd | No | No | Yes | No |
| Write Char Value | 0x12 Write Req | Yes | No | No | No |
| Write Long | 0x16+0x18 Prep+Exec | Yes | Yes | No | Optional |
| Reliable Writes | 0x16+0x18 Prep+Exec | Yes | Yes | No | Mandatory |
Next — Notifications, Indications, Descriptor Ops & GATT Service
Part 5 covers Notifications, Indications, all four Descriptor operations, the 30-second timeout, and the Service Changed characteristic.
