BLE GATT Services and Characteristics Explained: How Every BLE Device Is Structured
Every BLE peripheral on the planet — heart-rate strap, smart lock, environmental sensor, beacon-adjacent tag — exposes itself the same way: as a tree of services, characteristics, and descriptors called the GATT (Generic Attribute Profile). GATT is the data model. It defines what a peripheral has, how a client reads or writes it, and how it pushes updates. If you build, debug, or reverse-engineer anything BLE, this is the model you operate on.
The mechanics underneath are notoriously underspecified in field guides: documentation tends to drop straight into UUIDs without explaining the layering, the difference between ATT and GATT, or how a central actually walks the tree. This guide closes that gap. We cover ATT vs GATT, the hierarchy, UUID formats, characteristic properties, the CCCD, the ATT operations a client uses to fetch data, and a complete walk-through of the Heart Rate Service byte layout. We finish with how macOS CoreBluetooth exposes this tree to native tools.
If you also need to debug BLE peripherals on macOS while learning the model, see our companion piece on BLE debugging on macOS. For a broader look at how BLE fits alongside industrial protocols, see the industrial protocol comparison.
ATT vs GATT: The Protocol Layers
GATT builds on top of ATT (Attribute Protocol). They are not interchangeable:
- ATT is the wire protocol. It defines a flat set of up to 65,535 attributes on the peripheral, each addressed by a 16-bit handle. ATT defines the request/response PDUs (Read by Type Request, Find Information Request, Write Request, and so on) and the error codes (Attribute Not Found, Insufficient Authentication, Read Not Permitted).
- GATT is the profile that sits on top of ATT and gives the flat attribute list a hierarchy. GATT defines what grouping a primary service declaration means, what a characteristic declaration means, what the CCCD means. GATT defines the meaning; ATT provides the transport.
Every operation you think of as reading a characteristic is actually a few ATT operations under the hood: a Read by Type Request to find the characteristic declaration, parsing it to learn the value handle, then a Read Request on that value handle. The mental model matters because it explains why a GATT browser needs several round trips to populate the tree, and why tools that try to be clever about caching break when the peripheral changes.
The GATT Hierarchy
Profile
A Profile is a specification document (or an adopted Bluetooth SIG profile) that references a set of services and the expected behavior across them. The profile itself is not an attribute; it is a contract. The Heart Rate Profile references the Heart Rate Service, the Battery Service, and the Device Information Service, and constrains how a collector should behave.
Service
A service is a grouping of characteristics. It is declared on the wire as an attribute with the UUID 0x2800 (Primary Service) or 0x2801 (Secondary Service). The value of the declaration is the 16-bit or 128-bit UUID of the service itself — for example 0x180D for Heart Rate. A peripheral can advertise which services it offers in its advertising packets (the Complete List of 16-bit Service UUIDs AD type), or it can be discovered only after a connection via ATT.
Characteristic
A characteristic is a single value plus its metadata. It is declared on the wire as an attribute with UUID 0x2803 (Characteristic). The declaration value packs three things: 1 byte of properties (read, write, notify, and so on, as a bitmask), 2 bytes for the value handle (where the actual payload lives), and 2 or 16 bytes for the characteristic UUID. The characteristic value is then stored at the value handle. Read or write that handle and you read or write the characteristic.
Descriptor
Descriptors are attributes attached to a characteristic that carry metadata or configuration. The most important one is the Client Characteristic Configuration Descriptor (CCCD), UUID 0x2902, which enables and disables notifications and indications. Others include the Characteristic User Description (0x2901, a human-readable string), the Characteristic Presentation Format (0x2904, units and type), and the Characteristic Extended Properties (0x2900).
UUIDs: 16-bit, 32-bit, 128-bit
BLE attribute UUIDs come in three sizes. The size you see tells you a lot about what you are looking at:
- 16-bit (2 bytes): Bluetooth SIG-assigned, standardized.
0x180Dis Heart Rate,0x180Fis Battery,0x1800is Generic Access,0x1801is Generic Attribute. The full UUID is implicitly the Bluetooth Base UUID00000000-0000-1000-8000-00805F9B34FBwith the 16-bit value substituted into the third and fourth bytes. - 32-bit (4 bytes): A SIG-assigned range. Rarely seen in practice; used by vendors that have purchased a 32-bit assignment.
- 128-bit (16 bytes): Vendor-specific. Any value outside the SIG base UUID. Custom peripherals use a randomly generated 128-bit UUID for their proprietary services and characteristics. Once a 128-bit base is chosen, a vendor may use 16-bit shortcuts within it.
When you see a tool that decodes UUIDs to names, it is doing a lookup against the SIG assignment table for 16-bit values. Custom 128-bit UUIDs are opaque unless the tool lets you add custom labels.
Standard Services and Characteristics
A few SIG-assigned services appear on almost every device:
- Generic Access (
0x1800) — carries the device name (0x2A00), appearance (0x2A01), preferred connection parameters (0x2A04), and a few central-only items. Required on every BLE peripheral. - Generic Attribute (
0x1801) — exposes the Service Changed characteristic (0x2A05), which a peripheral uses to tell the client that its service set has changed and that it must re-discover. Required on every BLE peripheral. - Device Information (
0x180A) — manufacturer name (0x2A29), model number (0x2A24), firmware revision (0x2A26), serial number (0x2A25), hardware revision (0x2A27). Optional but extremely common. - Battery (
0x180F) — single characteristic Battery Level (0x2A19), 0 to 100 percent, usually notify-enabled. - Heart Rate (
0x180D) — see the walk-through below.
Knowing these by heart is the fastest way to read a GATT tree without consulting a database.
Characteristic Properties
Each characteristic declaration carries a 1-byte properties bitmask. The bits map to:
0x01Broadcast (used with ADV, rarely in GATT context)0x02Read0x04Write Without Response (no ATT response, fire-and-forget)0x08Write (Write Request, server replies with Write Response)0x10Notify (server pushes value, no ACK)0x20Indicate (server pushes value, client returns ATT Confirmation)0x40Authenticated Signed Write (write with a signature)0x80Extended Properties (more bits in the0x2900descriptor)
The properties byte is what a GATT browser renders as badges. A characteristic with bits 0x02, 0x10, 0x20 set supports Read, Notify, and Indicate; it does not support any Write mode. A common mistake when debugging is trying to Write a value to a characteristic whose Write bit is not set — you get back ATT error 0x02 (Write Not Permitted).
The CCCD and Notifications
Notifications and indications are not enabled by default. A characteristic that supports them must also expose a CCCD (0x2902). The CCCD is a 16-bit, read/write attribute where each bit selects a mode:
0x0001enables notifications,0x0002enables indications,0x0000disables both.
A client that wants to receive sensor values writes 0x0001 to the CCCD. From then on, whenever the peripheral updates the value, the central receives a Handle Value Notification PDU. There is no acknowledgment, no flow control beyond the link-layer queue, and no per-message delivery guarantee. For reliability, the peripheral uses Indicate instead and waits for a Handle Value confirmation before it can Indicate again.
The CCCD is the only standard descriptor a client must write to enable a push flow. Every other descriptor is read-only metadata.
ATT Operations
The set of ATT operations relevant to a GATT browse:
- Exchange MTU Request — negotiated at connection start so both sides know the maximum PDU size (23 bytes default, up to 517).
- Find By Type Value Request — used to find primary services by service UUID.
- Read by Group Type Request (group type
0x2800= Primary Service) — returns all primary service declarations in one round trip per MTU batch. - Read by Type Request — used to find characteristic declarations (type
0x2803) within a handle range. - Find Information Request — returns descriptors and their UUIDs within a handle range.
- Read Request — reads a single attribute value by handle.
- Read Blob Request — reads the remainder of a value longer than one MTU.
- Write Request / Write Command — write with or without response.
- Handle Value Notification / Indication — server-initiated push.
A GATT browser internally issues all of the above to populate the tree. That is why browsing takes several seconds on a complex peripheral: each step is a request/response round trip.
Walk-through: The Heart Rate Service
The Heart Rate Service (0x180D) is the canonical teaching example because every field is small enough to fit in one byte and the layout is published by the SIG. It declares three characteristics:
- Heart Rate Measurement (
0x2A37) — mandatory, notify-only. - Body Sensor Location (
0x2A38) — optional, read-only. - Heart Rate Control Point (
0x2A39) — optional, write-only.
The Heart Rate Measurement payload is what the peripheral pushes to the client on every notification. Its byte layout:
- Byte 0 — Flags:
- bit 0: Heart Rate Value Format (0 = uint8, 1 = uint16)
- bit 1: Sensor Contact Status (only meaningful if bit 2 is set)
- bit 2: Sensor Contact Supported
- bit 3: Energy Expended Status (1 = Energy Expended field present)
- bit 4: RR-Interval bit (1 = one or more RR intervals follow)
- bits 5 to 7: reserved
- Byte 1 — Heart Rate Measurement Value: uint8 if bit 0 = 0, otherwise uint16 in little-endian.
- Optional — Energy Expended: uint16, in kilojoules, present if bit 3 = 1.
- Optional — RR-Intervals: one or more uint16 values in 1.024 ms units, present if bit 4 = 1.
So a notification payload of 0x00 0x48 parses as: flags 0x00 means uint8 heart rate, sensor contact not supported, no energy expended, no RR-intervals; heart rate 0x48 = 72 BPM. A payload of 0x06 0x48 parses as: bit 1 and bit 2 set means sensor contact supported and detected, heart rate uint8 0x48 = 72 BPM.
The single most common mistake implementers make is reading the heart rate as the first byte of the payload. The first byte is always the flags. You must parse bit 0 before you know whether the heart rate occupies one or two bytes.
How CoreBluetooth Discovers the Tree
On macOS, CoreBluetooth exposes the GATT hierarchy through a strict sequence of callbacks on a CBCentralManager / CBPeripheral delegate. The flow is:
- Connect to the peripheral. The
peripheral(_:didConnect:)callback fires. - Call
peripheral.discoverServices(nil)to discover all primary services.peripheral(_:didDiscoverServices:)fires. - For each discovered
CBService, callperipheral.discoverCharacteristics(nil, for: service).peripheral(_:didDiscoverCharacteristicsFor:)fires per service. - For each
CBCharacteristic, callperipheral.discoverDescriptors(for: characteristic)if you want descriptors (including the CCCD). The corresponding callback fires. - To read, call
peripheral.readValue(for: characteristic); the result arrives inperipheral(_:didUpdateValueFor:error:). - To enable notifications, call
peripheral.setNotifyValue(true, for: characteristic). CoreBluetooth writes the CCCD for you. New values arrive in the samedidUpdateValueForcallback. - To write, call
peripheral.writeValue(_:for:type:)with.withResponseor.withoutResponse.
A native macOS tool like MacTools BLE Inspector wraps this exact sequence and renders the tree as you expand each level. The slowness of GATT discovery on any platform is not the tool's fault; it is the cost of the round-trip-per-level protocol. For connectionless BLE broadcasts instead, see our guide to iBeacon vs Eddystone beacon formats.
Common Mistakes
- Assuming a fixed offset for a characteristic value. Standard layouts start with a flags byte; vendor layouts vary. Parse the flags first.
- Polling a notify-capable characteristic instead of subscribing. Polling wastes energy and misses intermediate values. Write the CCCD and consume notifications.
- Forgetting the CCCD on reconnect. The CCCD value is not necessarily persisted across connections. Re-enable notifications after each reconnect.
- Treating GATT as a filesystem. The tree is conceptual. On the wire it is a flat attribute array. The handle range of a service is whatever the peripheral assigned; never hardcode handle numbers, even on the same device across firmware versions.
- Reading the 16-bit UUID without the Base UUID context. Some peripherals use 128-bit UUIDs whose first 12 bytes match the SIG Base UUID but whose 16-bit prefix is custom. Treat those as vendor UUIDs unless the full 16 bytes match exactly.
For the encryption that protects writes against eavesdropping, see our companion guide on BLE pairing and bonding.
Notify vs Indicate vs Read
Try the BLE Inspector
MacTools BLE Inspector for macOS renders the full GATT tree, decodes SIG UUIDs to names, parses standard value layouts, and exposes CCCD-driven notification subscription in a native GUI. $9.99 one-time.
Get MacTools BLE InspectorFrequently Asked Questions
What is a BLE GATT service?
A GATT service is a collection of characteristics on a BLE peripheral that expose a specific function, such as Heart Rate or Battery. Each service is identified by a UUID (for example 0x180D for Heart Rate) and groups one or more characteristics that a client can read, write, or subscribe to.
What is the CCCD (Client Characteristic Configuration Descriptor)?
The CCCD is a 16-bit descriptor (UUID 0x2902) attached to a characteristic that supports Notify or Indicate. Writing 0x0001 enables notifications, 0x0002 enables indications, and 0x0000 disables both. It is the only standard way a client subscribes to push updates from a peripheral.
What is the difference between Notify and Indicate?
Notify sends a value to the client without requiring an acknowledgment at the ATT layer. Indicate sends a value and expects an ATT Confirmation in return, giving the server a guarantee of delivery. Notify is lower latency and lower overhead; Indicate is more reliable but slower.
How does macOS CoreBluetooth discover GATT services?
After connecting to a peripheral, CoreBluetooth calls discoverServices, then discoverCharacteristics for each service, then discoverDescriptors for each characteristic. Each step triggers a delegate callback. The peripheral GATT server drives the order and contents, and the central must walk the hierarchy level by level rather than requesting it in one operation.
Related: Full SCADA System
Need continuous monitoring with dashboards, alarms, and trending across all your devices? Voltrus SCADA supports Modbus, OPC-UA, Siemens S7, Allen-Bradley, DNP3, BACnet, MQTT, and more. Lifetime license from $249.