The Android Bluetooth stack spent about a decade being the reason your headphones disconnected during the good part of a song.
Gabeldorsche is Google’s attempt to fix that at the architectural level, not with another patch on top of the old code but with a ground-up rewrite of the stack internals.
This article explains what Gabeldorsche actually is, how its architecture is put together, and how the pieces fit at the implementation level.
We’ll walk through the OS abstraction, the module system, the threading and queue model, the HCI layer, the packet parsing generator, the ACL data path, L2CAP, security, the neighbor and storage modules, the shim and facade layers, the build system, and the testing infrastructure, with real code patterns for each.
The scope here is the internal architecture of the stack, not the public Android Bluetooth APIs your app calls. If you’ve ever wondered what happens between BluetoothDevice.createBond() and the actual radio, this is that layer, and this article goes deep into it.
Table of Contents
Prerequisites
You should be comfortable with modern C++ (C++17 idioms like lambdas, std::unique_ptr, move semantics, and template basics), and you should have a rough mental model of the Bluetooth protocol stack (HCI, L2CAP, ATT, GATT, the difference between Classic and Low Energy).
Familiarity with event-driven and message-passing concurrency helps a great deal, because Gabeldorsche leans on it hard. Some exposure to epoll or a reactor-style event loop will make the OS layer feel familiar.
You don’t need to have hacked on AOSP before, but knowing that AOSP is enormous and slow to build will emotionally prepare you.
What Gabeldorsche Is and Why It Exists
Gabeldorsche, usually abbreviated as GD, is the rearchitected core of the Android Bluetooth stack. The name is a running Google tradition of picking place names in the Bavarian Alps region as codenames. And yes, nearly everyone mispronounces it, which is arguably part of the charm.
In the source tree it lives under the Bluetooth module at packages/modules/Bluetooth/system/gd/, having graduated from its original home at system/bt/gd/ when Bluetooth became an updatable Mainline module.
The stack it replaces is usually called Fluoride, and before that BlueDroid. That older code worked, in the sense that a bridge held up by hope also works.
It was built around a large web of global state, callback chains that were difficult to reason about, and threading that varied by subsystem. When something went wrong, reproducing it was a coin flip, and unit testing individual layers in isolation ranged from painful to impossible. Bluetooth bugs became famous for being non-deterministic, and non-deterministic bugs are the ones that survive to production and then to angry reviews.
Gabeldorsche was designed around a small number of firm opinions. Every layer should be a self-contained module with explicit dependencies. Concurrency should be message passing on well-defined threads instead of shared locks scattered through the code. Packet parsing should be generated from a formal specification rather than hand-written byte arithmetic, because hand-written byte arithmetic is where security bugs go to be born. Every layer should be testable in isolation against a virtual controller, so continuous integration can catch regressions before your earbuds do. And the whole thing should be portable enough to run somewhere other than Android, which turned out to matter more than anyone expected.
A Short History and the Migration Strategy
Gabeldorsche was announced publicly around 2019 and 2020 as a multi-year effort, and it was never going to land as a single commit. You can’t atomically swap the Bluetooth stack on a billion devices, so the rollout was designed to be gradual, reversible, and boring, which are three excellent adjectives for infrastructure work.
The migration proceeded one layer at a time from the bottom up. The HCI layer moved to GD first, because it sits closest to the controller and has the cleanest boundary. Then the ACL and connection management layers, then L2CAP, then security, and so on upward.
Each layer was gated behind a flag so that a device could run the new GD implementation of one layer while still using the legacy implementation of the layers above and below it. This is why the shim layer, discussed later, exists at all.
The flags themselves lived as system properties and later as aconfig flags, and they let the team ship a GD layer to a small population, watch the crash and connection metrics, and roll it back instantly if something regressed.
Because each layer was independently toggleable, a regression could be bisected to a single layer by flipping flags rather than by staring at stack traces. This unglamorous flag discipline is a large part of why the rewrite reached production without a catastrophe, and it’s worth studying even if you never touch Bluetooth.
The Layered Architecture
Gabeldorsche is organized as a stack of layers, each one a module that depends on the layers beneath it. The following diagram shows the major layers from the radio hardware at the bottom to the Android framework at the top.
+------------------------------------------+
| Android Framework (Java / AIDL) |
+------------------------------------------+
| BTIF / BTA (legacy profile logic) |
+------------------------------------------+
| Shim layer (GD <~> legacy bridge) |
+------------------------------------------+
| GATT | Security | L2CAP | Neighbor |
+------------------------------------------+
| AclManager | Controller | HciLayer |
+------------------------------------------+
| hci_hal (HAL: AIDL / HIDL interface) |
+------------------------------------------+
| Bluetooth Controller (radio chip) |
+------------------------------------------+
The diagram reads bottom to top as a dependency ladder. The controller chip speaks HCI over a physical transport. The hci_hal layer wraps the Android hardware abstraction interface so the rest of the stack doesn’t care whether the transport is UART, USB, or a virtual socket.
Above it, the HCI layer manages command flow control and demultiplexes events, the Controller module caches the chip’s capabilities, and AclManager owns connections.
The upper layers implement L2CAP channels, security, neighbor operations like inquiry and page scan, and the GATT database. The shim layer is a temporary bridge that lets the new GD modules coexist with the older BTIF and BTA profile code during migration. At the very top sits the Android framework that applications talk to.
The important structural point is that each layer only reaches downward through explicit interfaces, never sideways into another layer’s internals.
The OS Abstraction Layer
Before any Bluetooth logic exists, Gabeldorsche defines its own small operating system abstraction in the os/ directory. This is the foundation everything else stands on, and it exists so the stack can run unchanged on Android, Linux, and inside tests.
The following table lists the core primitives and what each one is responsible for:
| Primitive | Responsibility |
|---|---|
Thread |
Owns a reactor and runs its event loop, with a selectable scheduling priority |
Reactor |
An epoll-based event loop that dispatches on file descriptor readiness |
Handler |
Posts closures onto a specific thread for serial, lock-free execution |
Alarm / RepeatingAlarm |
Schedules closures to run after a delay or on an interval |
Queue |
A reactive, bounded, single-producer single-consumer channel |
EnqueueBuffer |
A helper that buffers items and feeds them into a Queue on demand |
This table maps each OS primitive to its one job. Thread and Reactor are the execution substrate: a thread is a reactor plus a real kernel thread wrapped around it. Handler is how other code schedules work onto a thread without touching its internals. Alarm and RepeatingAlarm add time to the model. Queue and EnqueueBuffer are the data-movement primitives that connect producers to consumers.
Everything above this layer is built out of exactly these pieces, which is why understanding them pays off across the entire codebase.
A Thread in GD isn’t just a raw kernel thread. It owns a Reactor, which is an epoll-based event loop. Instead of blocking on a socket read, you register a file descriptor with the reactor and hand it a callback to run when the descriptor becomes readable.
The thread spins the reactor loop, and all work on that thread happens as reactions to events. Threads can be created at normal or real-time priority, and the stack uses a higher priority for the threads on the data path so that audio doesn’t stutter when the system is busy. This is the same idea as libevent or the Node.js event loop, just wearing a Bluetooth badge.
The Reactor is worth understanding because it’s the beating heart of every thread. It holds an epoll file descriptor and a set of registered reactables, each pairing a file descriptor with an on-read and an optional on-write callback.
The loop calls epoll_wait, and for each ready descriptor it invokes the registered callback. One subtle detail is unregistration safety: a callback might ask to unregister its own reactable while the reactor is mid-dispatch, so the reactor tracks the currently executing reactable and defers its destruction, which prevents a use-after-free that would otherwise be trivial to trigger. The reactor also uses an internal control file descriptor so another thread can wake it up to stop cleanly.
A Handler is the mechanism for getting work onto a specific thread. You post a closure to a handler, and the closure runs on that handler’s thread. Internally, a handler owns a queue of closures plus an eventfd registered with the reactor, so posting work writes to the eventfd, which wakes the reactor, which drains the closure queue.
This is how the entire stack avoids locks: instead of two threads grabbing a mutex to touch shared state, one thread posts a message to the other thread’s handler, and the state is only ever touched by its owning thread.
// Get the module's handler and post work onto its thread.
os::Handler* handler = GetHandler();
handler->Post(common::BindOnce(
[](int connection_handle) {
// This lambda runs on the handler's thread, not the caller's.
LOG_INFO("Tearing down connection %d", connection_handle);
},
connection_handle));
This snippet shows the central concurrency pattern of the stack. GetHandler() returns the handler associated with the current module, which is bound to a specific thread. common::BindOnce packages a callable together with its arguments into a one-shot closure, similar to std::bind but move-aware and single-use, which matters because many Bluetooth payloads are move-only buffers. handler->Post writes to the handler’s eventfd and enqueues that closure to run on the handler’s thread. The consequence is that the lambda body executes serially on one thread, so it can read and write that module’s state without any mutex.
If you internalize one thing about Gabeldorsche, make it this: work travels to the data’s thread, the data does not travel to the work.
For time-based work, there’s Alarm and RepeatingAlarm. An alarm schedules a closure to run on a handler after a delay, and it’s built on top of the same reactor using a timerfd, so timers are just another readable file descriptor in the loop rather than a separate timer thread.
// Fire a one-shot timeout on this module's handler thread.
alarm_ = std::make_unique<:alarm>(GetHandler());
alarm_->Schedule(
common::BindOnce(&MyModule::OnConnectionTimeout, common::Unretained(this)),
std::chrono::milliseconds(5000));
The alarm here is constructed against the module’s handler, which ties its callback to the correct thread. Schedule takes a closure and a duration, arms a timerfd for that duration, and when it fires the reactor runs OnConnectionTimeout on the handler thread.
common::Unretained(this) tells the binder to keep a raw pointer to the object without extending its lifetime, which is safe here precisely because the alarm and the object live on the same thread and are torn down in a known order.
That last point matters. Unretained is a promise you’re making to the compiler, and if you break it, the crash will be memorable and probably remote.
The Module System
Every functional layer in Gabeldorsche is a Module. A module has a lifecycle, a set of dependencies, and its own handler thread affinity. The ModuleRegistry is responsible for starting modules in dependency order and stopping them in reverse. This turns the old tangle of initialization ordering into something a computer can figure out on its own, which is a healthier arrangement than a comment that says “do not reorder these calls.”
A module subclasses Module, declares a static ModuleFactory, and implements a small set of methods. The most important ones are ListDependencies, Start, and Stop.
class ExampleModule : public bluetooth::Module {
public:
static const ModuleFactory Factory;
protected:
void ListDependencies(ModuleList* list) const override {
list->add<:hcilayer>();
list->add<:controller>();
}
void Start() override {
hci_layer_ = GetDependency<:hcilayer>();
controller_ = GetDependency<:controller>();
// Module is now ready to do work on GetHandler().
}
void Stop() override {
// Release references; the registry stops dependencies after this.
hci_layer_ = nullptr;
controller_ = nullptr;
}
std::string ToString() const override { return "ExampleModule"; }
private:
hci::HciLayer* hci_layer_ = nullptr;
hci::Controller* controller_ = nullptr;
};
const ModuleFactory ExampleModule::Factory =
ModuleFactory([]() { return new ExampleModule(); });
This class demonstrates the full contract of a module.
ListDependencies declares, at construction time, which other modules this one needs, by adding their types to the ModuleList. The registry reads these declarations across all modules and computes a start order so that HciLayer and Controller are running before ExampleModule::Start is ever called.
Inside Start, GetDependency looks up the already-running instance of each dependency by its factory and returns it as a raw pointer, which the module caches because the registry guarantees the dependency outlives it. Stop runs during shutdown before the dependencies are stopped, giving the module a chance to release references and cancel outstanding work.
ToString gives the module a name for logging and dumpsys. The static Factory is a small object holding a lambda that constructs the module, and it is the handle the registry uses both to instantiate the module and to identify it in the dependency graph. There’s no global initialization order to get wrong, because you never write the order at all.
The registry itself is what glues this together at boot, and it also assigns each module its handler.
ModuleList modules;
modules.add();
// The registry topologically sorts and starts everything.
ModuleRegistry registry;
registry.Start(&modules, thread);
// ... stack runs ...
registry.StopAll();
Here the application declares the top-level modules it wants and hands them to the registry along with the thread the modules will run on.
Start walks the dependency graph, starts each module exactly once in topological order, injects dependencies, and gives each module a Handler bound to the supplied thread.
StopAll reverses the process, calling Stop in the exact reverse of the start order so no module is ever torn down before something that depends on it.
Because dependencies are explicit data rather than imperative code, the same machinery powers a TestModuleRegistry that lets a test start a single real module on top of fake dependencies.
That test variant is the quiet superpower of this design. Because a module only ever reaches its dependencies through GetDependency, a test can register a fake HciLayer before starting the real module under test, and the module can’t tell the difference.
TestModuleRegistry test_registry;
test_registry.InjectTestModule(&HciLayer::Factory, fake_hci_layer_);
test_registry.Start(&test_registry.GetTestModuleList());
// Drive the fake HCI layer, assert on what ExampleModule does in response.
In this test setup, InjectTestModule pre-registers a fake implementation against the real HciLayer factory key, so any module that depends on HciLayer transparently receives the fake. Start then brings up the real module under test on top of it. The test can now feed events into the fake HCI layer and assert on the commands ExampleModule sends, all on a controlled thread, with no hardware and no other layers involved.
This is unit testing in the honest sense of the word, where the unit is genuinely isolated, which was close to impossible in the previous stack.
The Stack Bootstrap
Something has to construct the registry, pick the thread, add the top-level modules, and bring the whole thing to life. That something is the Stack object. It’s the single entry point that owns the ModuleRegistry and the main thread, and it’s what the shim layer talks to when Android decides Bluetooth should turn on.
The bootstrap does three things in order. It creates a Thread at the appropriate priority for the stack to run on. It builds a ModuleList containing the top-level modules for the current configuration, which pulls in all of their transitive dependencies automatically. Then it starts the registry against that thread and blocks until every module has finished starting, so that by the time the call returns the stack is fully operational.
Shutdown is the mirror image: stop the registry, which stops every module in reverse dependency order, then join the thread.
Concentrating this in one object means there’s exactly one place that knows how the stack comes up and goes down, instead of the historical situation where startup was an emergent property of many files being included in the right order and hoping.
The Queue Abstraction
Modules that produce and consume a stream of packets don’t call each other directly. They connect through a Queue, which is a reactive, bounded, single-producer single-consumer channel built on the reactor. This is how ACL data, for example, flows between L2CAP and the HCI layer without either side blocking or sharing a lock.
The queue exposes two half-interfaces, one for each end. The producer side implements enqueue by registering a callback that the queue invokes when there’s room. The consumer side registers a callback that the queue invokes when data is available. Nobody ever busy-waits, and nobody blocks on a full or empty queue.
// Producer side: register to be asked for the next packet.
queue_end_->RegisterEnqueue(
handler_,
common::Bind(&MyModule::OnQueueReadyToSend, common::Unretained(this)));
std::unique_ptr<:basepacketbuilder> MyModule::OnQueueReadyToSend() {
if (pending_packets_.empty()) {
queue_end_->UnregisterEnqueue(); // Nothing to send; stop being asked.
return nullptr;
}
auto packet = std::move(pending_packets_.front());
pending_packets_.pop();
return packet;
}
This is the enqueue half of the pattern and it’s inverted from what most people expect. You don’t push data into the queue. Instead you call RegisterEnqueue with a callback, and when the queue has capacity it calls your callback asking for the next item.
Your callback returns one packet builder, or returns nullptr after calling UnregisterEnqueue when you have nothing left to send. This inversion is deliberate: it means backpressure is automatic. If the downstream consumer is slow and the queue fills up, your callback simply stops being called, and packets pile up in your own buffer where you can see them and account for them, rather than in some hidden kernel buffer where they turn into latency you can’t explain.
The dequeue half mirrors this exactly.
// Consumer side: register to be told when a packet arrives.
queue_end_->RegisterDequeue(
handler_,
common::Bind(&MyModule::OnPacketReceived, common::Unretained(this)));
void MyModule::OnPacketReceived() {
auto packet = queue_end_->TryDequeue();
if (packet == nullptr) {
return;
}
// Process the received packet on handler_'s thread.
}
On the consuming side, RegisterDequeue hands the queue a callback bound to a handler. When a packet becomes available, the queue posts that callback to the handler thread, and inside it you call TryDequeue to retrieve the packet. The TryDequeue can still return nullptr if the item was already taken, so you check. The same thread-affinity rule applies: the callback runs on handler_‘s thread, so packet processing is serial and lock-free.
Under the hood the two ends coordinate through a small reactive semaphore built on an eventfd, which is what lets one thread’s producer safely signal another thread’s consumer. When you have many items to push, EnqueueBuffer wraps this whole dance so you can add items and let the buffer drive the registration for you, which is what most call sites actually use.
The HCI Layer
The Host Controller Interface is the protocol between the host stack and the controller chip. In Gabeldorsche the HciLayer module owns the command channel and enforces the one rule that trips up every naïve implementation: the controller tells you how many commands it can accept at once through a credit count. If you send more than it has credits for, things break in ways that are miserable to trace.
You don’t write HCI bytes directly. You build a typed command packet, hand it to the HCI layer, and provide a callback for the eventual response. The layer handles flow control, matching responses to the commands that triggered them, and routing unsolicited events to subscribers.
hci_layer_->EnqueueCommand(
hci::ResetBuilder::Create(),
GetHandler()->BindOnceOn(this, &MyModule::OnResetComplete));
void MyModule::OnResetComplete(hci::CommandCompleteView view) {
auto reset_view = hci::ResetCompleteView::Create(view);
ASSERT(reset_view.IsValid());
if (reset_view.GetStatus() != hci::ErrorCode::SUCCESS) {
LOG_ERROR("Reset failed");
}
}
This sends the HCI Reset command and handles its completion. hci::ResetBuilder::Create() builds a typed, validated command packet, so you physically can’t send a malformed Reset.
EnqueueCommand places the command in the layer’s internal command queue, which only releases a command to the controller when a credit is available and holds the rest until the controller returns credits in its responses. The second argument is a callback bound to your handler with BindOnceOn, so the completion runs on your thread.
Commands come in two flavors, those answered by a Command Complete event and those answered by a Command Status event, and the layer has EnqueueCommand overloads that route each to the right callback type.
When the response arrives you receive a generic view, narrow it to the specific ResetCompleteView, and check IsValid() before reading fields. That validity check isn’t ceremony. It’s the parser telling you whether the bytes actually match the structure you expect.
The HCI layer also splits its outputs into distinct streams instead of one overloaded callback. Command responses go to the callback you supplied with the command. Unsolicited events like a remote device connecting are delivered to whichever module registered a handler for that specific event code. LE meta-events, subevents of the single LE Meta Event opcode, are demultiplexed to their own subscribers. And the layer exposes narrower sub-interfaces, such as a security interface and an LE advertising interface, so that a module only sees the slice of HCI it actually cares about rather than the entire firehose.
This separation keeps request-response logic away from spontaneous-event logic, which in the old stack were frequently the same function trying to do three jobs at once.
Sitting beside HciLayer is the Controller module, whose job is to interrogate the chip once at startup and cache the answers. It issues the read-local-version, read-local-supported-commands, read-buffer-size, and LE feature commands, then exposes the results through simple getters.
This matters because the rest of the stack constantly needs to know things like the ACL buffer size and how many packets the controller can hold, and asking once and caching is far better than asking the chip repeatedly. When a higher layer wants to know whether a feature is supported, it asks the Controller, not the hardware.
The Packet Definition Language
Here’s the feature that quietly does the most good. In the old stack, parsing a packet meant reading bytes at hand-computed offsets, shifting and masking by hand, and hoping every author got the endianness and bounds checking right. They didn’t always get it right, and malformed Bluetooth packets are a classic remote attack surface, the kind that ends up with a catchy name and a logo.
Gabeldorsche replaces all of that with a Packet Definition Language, or PDL. You describe the wire format once in a .pdl file, and a generator called bluetooth_packetgen emits C++ parser and builder classes from it.
A PDL file starts by declaring endianness and then defines enums, structs, and packets. The following table summarizes the field constructs you’ll see most often:
| Construct | Meaning |
|---|---|
field : N |
A scalar field that is N bits wide |
field : Enum |
A field whose values are drawn from a named enum |
field : N[] |
A variable-length array of N-bit elements |
field : N[k] |
A fixed array of k elements, each N bits |
_size_(field) |
A field that holds the byte size of another field |
_count_(field) |
A field that holds the element count of an array |
_payload_ |
The variable body carried by a parent packet |
_fixed_ = value |
A constant field the builder always emits |
_reserved_ |
Bits that are always zero on the wire |
This table covers the vocabulary of a PDL file. Scalar and enum fields describe individual values with exact bit widths, so a 3-bit field is genuinely 3 bits and the generator packs it accordingly. The array forms describe repeated data, either variable or fixed length.
The _size_ and _count_ fields are the clever part: they let the format describe a length prefix once, and the generator wires up the arithmetic so that the builder computes the length on serialize and the view validates it on parse.
The _payload_ marker is how packet inheritance carries a child’s body inside a parent. And _fixed_ and _reserved_ encode constants and mandatory zeroes so no human has to remember them.
Everything here is declarative, and the generator turns it into code that can’t forget a bounds check.
A concrete definition looks like a struct description with explicit widths and constraints, where child packets constrain fields of their parents.
little_endian_packets
enum OpCode : 16 {
RESET = 0x0C03,
READ_LOCAL_NAME = 0x0C14,
}
packet Command {
op_code : OpCode,
_size_(payload) : 8,
_payload_,
}
packet Reset : Command (op_code = RESET) {
}
packet ReadLocalNameComplete : CommandComplete (command_op_code = READ_LOCAL_NAME) {
status : ErrorCode,
local_name : 8[248],
}
This defines an opcode enum, a generic Command parent, and two concrete packets. The Command packet carries an op_code, a one-byte size of its payload, and the payload itself, which is the generic shape every command shares. Reset inherits from Command and fixes op_code to RESET, so the generated builder always emits the correct opcode and the generated view can recognize a Reset by matching that constraint. ReadLocalNameComplete inherits from CommandComplete and adds a status enum field plus local_name written as 8[248], meaning 248 elements of 8 bits each, which is exactly how the Bluetooth specification defines the local name field.
The constraints in parentheses are what let generated code route a raw buffer to the correct view type based on the opcode it carries, without a hand-written switch statement anywhere.
From that definition, the generator produces two kinds of classes per packet. A Builder serializes structured data into bytes, and a View parses bytes into structured, bounds-checked accessors.
// Building: structured data becomes validated bytes.
auto builder = hci::ResetBuilder::Create();
std::vector bytes;
BitInserter it(bytes);
builder->Serialize(it); // writes op_code, computed size, payload
// Parsing: bytes become a validated, typed view.
auto command_view = hci::CommandView::Create(
PacketView(std::make_shared<:vector>>(bytes)));
auto name_view = hci::ReadLocalNameCompleteView::Create(command_view);
if (name_view.IsValid()) {
std::array name = name_view.GetLocalName();
}
The building side shows that a builder knows the exact layout: Serialize walks the fields in order through a BitInserter, writing the fixed opcode, computing and writing the payload size, and emitting the payload, so you never touch an offset.
The parsing side is lazy and layered. A PacketView wraps a shared byte buffer without copying it, a generic CommandView interprets the common command header, and the specific ReadLocalNameCompleteView narrows it further.
The critical method is IsValid(), which the generated code implements to verify that the buffer is long enough for every field and that all constraints hold before you read anything. Only after that check do you call GetLocalName() to pull out the parsed field as a typed array.
Because the parser is generated from the same specification for every packet, a whole category of off-by-one and out-of-bounds bugs simply can’t be written by hand anymore. There’s even a Python binding, generated from the same PDL, so the test suite parses and builds packets with byte-for-byte identical logic to the production stack.
The ACL Manager and Connection Management
Above the raw HCI layer sits the AclManager, the module responsible for asynchronous connection-oriented links, which is what carries actual user data once devices are connected. It manages both Classic and Low Energy connections, and it presents connections as objects with their own callback interfaces rather than as integer handles floating around global tables waiting to be misused. Internally it splits into a classic implementation and an LE implementation that share the round-robin data scheduler described in the next section.
When you request a connection, you register a callback that fires when the connection succeeds or fails, and on success you receive a connection object that owns the queue for that link.
// Register interest in Classic connection events, then connect.
acl_manager_->RegisterCallbacks(this, GetHandler());
acl_manager_->CreateConnection(remote_address);
void MyModule::OnConnectSuccess(
std::unique_ptr<:acl_manager::classicaclconnection> connection) {
uint16_t handle = connection->GetHandle();
// The connection object owns the data queue for this link.
connection->GetAclQueueEnd()->RegisterDequeue(
GetHandler(),
common::Bind(&MyModule::OnAclData, common::Unretained(this)));
connections_[handle] = std::move(connection);
}
This shows the connection lifecycle from the consumer’s point of view. RegisterCallbacks subscribes your module to connection events on your handler thread, and CreateConnection kicks off the HCI dance to page and connect to the remote address.
When it succeeds, OnConnectSuccess receives a ClassicAclConnection owned by a unique_ptr, which means ownership is explicit and the link is torn down deterministically when the object is destroyed. The connection carries its own data queue, accessed through GetAclQueueEnd(), and you register a dequeue callback on it using the exact same queue pattern from earlier.
Finally you move the connection into your own map keyed by handle. There’s no ambiguity about who owns the link, which was a genuine source of use-after-free bugs in the previous design.
The LE side adds one wrinkle that deserves a mention: privacy. LE devices rotate their advertising address using a Resolvable Private Address so that trackers can’t follow a device by its MAC.
Gabeldorsche has an LeAddressManager that owns the local address rotation and the controller’s resolving list, coordinating when the address may change so that it doesn’t rotate in the middle of an operation that depends on address stability. This is fiddly, timing-sensitive work, and concentrating it in one component rather than spreading address logic across the LE code is exactly the kind of decision the whole architecture is built to make easy.
The Round Robin Scheduler and the ACL Data Path
The controller has a finite number of ACL buffers, and every connection competes for them. If one busy connection is allowed to consume every buffer, the other connections starve, which on a phone means your file transfer strangles your audio.
Gabeldorsche solves this with a RoundRobinScheduler that sits between the per-connection queues and the single shared link to the controller.
The scheduler tracks how many buffer credits the controller has, dequeues one packet at a time from each connection in rotation, fragments it to the controller’s ACL packet size, and sends it downward, decrementing the credit count.
When the controller reports that it has transmitted packets and freed buffers, through the number-of-completed-packets event, the scheduler adds those credits back and resumes sending.
Because it visits connections in round-robin order rather than draining one before touching the next, bandwidth is shared fairly across links without any connection needing to know the others exist. The fragmentation happens here too, so upper layers get to think in whole L2CAP frames while the scheduler quietly chops them into controller-sized bites and reassembles credits on the way back. This one component is the difference between “my earbuds and my file sync coexist” and “pick one.”
L2CAP and the Data Pipeline
L2CAP, the Logical Link Control and Adaptation Protocol, multiplexes the single ACL link into many logical channels and handles segmentation and reassembly of larger service data units.
Gabeldorsche implements Classic and LE variants as separate modules, L2capClassicModule and L2capLeModule, sharing common machinery underneath. It distinguishes fixed channels, which are always present for duties like signalling, from dynamic channels, which are opened on demand for a specific service.
A service registers itself by protocol or service multiplexer, and when a remote device opens a channel to it, the service receives a channel object that, unsurprisingly by now, owns a queue.
// Register a dynamic L2CAP service on a PSM.
dynamic_channel_manager_->RegisterService(
kMyPsm,
security_policy,
GetHandler()->BindOnceOn(this, &MyModule::OnServiceRegistered),
GetHandler()->BindOn(this, &MyModule::OnConnectionOpen));
void MyModule::OnConnectionOpen(
std::unique_ptr<:classic::dynamicchannel> channel) {
channel->RegisterOnCloseCallback(/* ... */);
channel->GetQueueUpEnd()->RegisterDequeue(/* ... */);
}
Here a service is registered against a Protocol Service Multiplexer value, the L2CAP equivalent of a port number. RegisterService takes the PSM, a security policy describing what level of pairing and encryption the channel requires, a one-shot callback confirming registration, and a repeating callback that fires each time a remote peer opens a channel. When a channel opens, OnConnectionOpen receives a DynamicChannel owned by a unique_ptr, and you wire up a close callback and a dequeue on its queue.
The security policy being a parameter of registration, rather than something checked ad hoc later, means a channel physically can’t be opened at a lower security level than the service demanded. Security as a property of the type, not a runtime afterthought, is a recurring design choice in this stack.
Underneath that friendly channel object is a genuine data pipeline, and it’s worth picturing how a byte travels through it.
outgoing SDU
-> per-channel Segmenter (splits SDU into PDUs, applies mode)
-> channel Scheduler (picks which channel sends next, by priority)
-> Fragmenter (splits PDUs to ACL buffer size)
-> AclManager queue -> RoundRobinScheduler -> controller
incoming from controller
-> Reassembler (rebuilds PDUs from ACL fragments)
-> per-channel Recombiner (rebuilds SDUs from PDUs)
-> channel queue up-end -> upper layer
This diagram traces the two directions of L2CAP data flow. On the way out, a service data unit from an upper layer enters the channel’s Segmenter, which breaks it into protocol data units and applies the channel’s transmission mode, such as Basic Mode or Enhanced Retransmission Mode with its acknowledgements and retransmits.
A per-link Scheduler then decides which channel gets to transmit next based on channel priority, so a latency-sensitive channel can be favored. The Fragmenter cuts those PDUs down to the controller’s ACL buffer size, and they hand off to the AclManager queue and the round-robin scheduler from the previous section.
On the way in, the process runs in reverse: a Reassembler stitches ACL fragments back into PDUs, a per-channel Recombiner rebuilds the original SDUs, and the finished SDU is delivered to the channel’s queue up-end where the upper layer reads it.
Each stage is a small, testable component with a single responsibility, which is why L2CAP, historically one of the buggiest layers, becomes tractable.
Security and Pairing
The SecurityModule centralizes pairing, bonding, and encryption for both transports. Classic pairing runs its procedures over the link, while the LE Security Manager Protocol runs over a fixed L2CAP channel dedicated to SMP.
The module drives the state machines for the various association models, including numeric comparison, passkey entry, out-of-band, and just works. It surfaces user interaction through a callback interface so the framework can display the pairing dialog and report back what the user chose.
The design goal is that no other module implements its own crypto or pairing logic. When L2CAP needs a channel encrypted before it will carry data, it doesn’t reach into key storage or start an encryption procedure itself. It asks the security module through an enforcement interface, waits for the result on its handler, and only then opens the channel to upper layers.
This is the same principle as the rest of the stack, applied to the most sensitive state: one module owns the pairing state machines and the keys, and everyone else sends it messages. Concentrating cryptographic decisions in one audited place is considerably safer than the historical situation, where security-relevant checks were sprinkled across many files and occasionally forgotten in exactly one of them, which is all it takes.
GATT and ATT
At the top of the LE protocol stack sit the Attribute Protocol, ATT, and the Generic Attribute Profile, GATT, which is the request-response database that nearly every LE product actually uses.
ATT defines the wire operations, reads, writes, notifications, and indications against numbered attribute handles, and it runs over its own fixed L2CAP channel. GATT organizes those attributes into services and characteristics, the abstraction that a heart-rate monitor or a pair of earbuds presents to the phone.
In Gabeldorsche this is layered cleanly on everything below it. ATT is a client of the fixed-channel L2CAP interface, so it receives its channel through the same mechanism as any other fixed channel and moves packets through the same queue pattern. Its packets are defined in PDL like everything else, so an ATT read request and its response are generated builders and views with the same validity guarantees as an HCI command.
GATT builds its service and characteristic database on top of ATT and exposes registration and notification interfaces to the profiles above.
The payoff of the architecture shows up here in a subtle way: because GATT is just another set of modules speaking through queues and typed packets, it can be tested against a virtual peer over RootCanal without any of the layers below it being real hardware.
The Neighbor and Storage Modules
Two supporting module groups round out the picture. The neighbor modules handle discovery and identification of other devices, and the storage module handles persistence.
Discovery isn’t one operation but several, and Gabeldorsche models each as a focused component under the neighbor umbrella. Inquiry handles Classic device discovery and its scan modes. Page and page-scan handle becoming connectable and connecting. Name resolution fetches a remote device’s human-readable name.
Keeping these as distinct components with narrow interfaces, rather than one large discovery blob, means each can be reasoned about and tested on its own. It also it means a bug in name resolution can’t accidentally corrupt inquiry state because they don’t share mutable state.
The storage module is responsible for remembering things across reboots, chiefly bonded devices and their keys, along with adapter and device properties. It presents an in-memory model of devices and their properties and persists that model to a configuration file, batching writes so that a burst of property changes does not thrash the disk.
Because persistence lives behind a module interface, the rest of the stack reads and writes device properties through method calls rather than parsing a config file directly. The on-disk format can change without every layer needing to know. Losing your paired devices on every reboot would be a memorable kind of bad, so this module earns its keep quietly.
The Shim and Facade Layers
You can’t rewrite an entire Bluetooth stack in one commit and ship it. Gabeldorsche was rolled out incrementally, one layer at a time, which means for a long stretch the new GD modules had to coexist with the old Fluoride profile code.
The shim/ layer is the diplomatic bridge that makes this possible. It exposes the old C-style interfaces that BTIF and BTA expect, and translates those calls into the new module method calls underneath, hopping onto the GD stack thread as it crosses the boundary. Individual layers were gated behind flags so that a given layer could run on GD in one build and on legacy code in another, which made it possible to bisect regressions to a specific layer by toggling flags rather than by archaeology.
The facade/ layer serves a different purpose entirely. Each GD module can expose a gRPC service, called a facade, that lets an external process drive the module directly. This is the seam that the test framework plugs into. Instead of testing through the entire Android framework, a test can start a single module, connect to its facade over gRPC, and issue commands straight at the layer under test while observing its event stream.
service HciLayerFacade {
rpc SendCommand(Command) returns (google.protobuf.Empty) {}
rpc StreamEvents(google.protobuf.Empty) returns (stream Event) {}
}
This is a sketch of what a module facade looks like in protobuf. It defines a gRPC service with a unary method to send a command into the module and a server-streaming method that pushes every event the module emits back to the caller. Because it’s gRPC, the client driving the test can be written in any language, and the project chose Python for readability and speed of writing tests.
The facade turns each internal layer into something you can poke at from outside the process without building the entire operating system around it. This is the difference between a testable design and a design that merely has the word testable in its design doc.
Build System Integration
None of this would be pleasant without build tooling that treats the packet generator as a first-class citizen. Gabeldorsche builds with Soong, the AOSP build system whose files are named Android.bp, and the PDL generator is wired in as a custom rule so that .pdl files are compiled to C++ headers automatically as part of the build graph.
The mechanism is a generated-sources rule. A build rule names the bluetooth_packetgen tool, points it at the .pdl inputs, and declares the .h outputs. Any C++ library that lists those generated headers as sources gets them built on demand and rebuilt whenever a .pdl file changes.
The practical effect is that a developer edits a packet definition, rebuilds, and the new typed builders and views simply exist, both in the C++ target and, through a parallel rule, in the Python bindings used by tests. There’s no checked-in generated code to fall out of sync and no manual codegen step to forget before sending a change for review. The same source of truth feeds production C++ and test Python, and the build system guarantees they never drift.
Logging, Metrics, and dumpsys
A stack you can’t see into is a stack you can’t debug, and Bluetooth debugging often happens after the fact from a bug report.
Gabeldorsche invests accordingly. It logs through the standard Android logging macros with per-module tags, so a log line tells you which layer produced it. It also maintains structured metrics that feed the platform’s statistics pipeline for aggregate health monitoring across the fleet.
The most useful debugging affordance is dumpsys integration. Modules can implement a dump method, and the registry can walk the running modules and ask each to serialize its current state, which lands in the Bluetooth section of a bug report.
Because every module knows how to describe itself, a bug report captures a coherent snapshot of the whole stack, connection tables, channel states, controller capabilities, without a human needing to instrument anything at the moment of failure.
This is the difference between “please reproduce it while I watch” and “send me the bug report you already have.” For a component as timing-dependent as Bluetooth, that difference is often the entire investigation.
Testing with Cert Tests and RootCanal
The payoff for all this modularity is the test infrastructure, and it has two standout pieces. The first is the certification test suite, usually called Cert tests, written in Python and driving modules through their gRPC facades. The second is RootCanal, a virtual Bluetooth controller.
RootCanal deserves special attention. It’s a software implementation of a Bluetooth controller that speaks HCI. But instead of a radio, it has a simulated physical layer.
You can connect multiple stacks to a single RootCanal instance, and it models them being in radio range of each other, delivering one stack’s advertisements and connection requests to another. This means an entire pairing and data-transfer scenario between two devices can run in continuous integration on a Linux machine with no Bluetooth hardware present at all. The flakiness of real radios, radio interference, timing jitter, a colleague walking past with a microwave, which made the old test story hopeless, is removed by construction.
A Cert test wires these together: it starts the stack under test and a second reference stack, connects both to RootCanal, and asserts on the packets that cross between them.
class HciTest(GdBaseTestClass):
def test_local_hci_cmd_and_event(self):
# Send an HCI command through the DUT's facade.
self.dut.hci.SendCommand(
hci_facade.Command(payload=bytes(ResetBuilder().Serialize())))
# Assert the expected completion event comes back.
assertThat(self.dut.hci.get_event_stream()).emits(
HciMatchers.CommandComplete(OpCode.RESET))
This test exercises the HCI layer end to end without any hardware. self.dut is the device under test, a real GD stack instance running behind its gRPC facade. SendCommand serializes a ResetBuilder, built by the Python bindings generated from the same PDL as the C++ code, and pushes it into the stack’s HCI facade. The test then takes the stack’s event stream and asserts that it emits a command-complete event for the Reset opcode, using a matcher that waits for the event to arrive rather than checking once and giving up on a race.
The whole scenario runs deterministically in CI. When this test passes, you know the HCI command path works. When it fails, it fails the same way every time, which is the single most valuable property a test can have.
Floss: Gabeldorsche Beyond Android
Because the GD stack was built on its own OS abstraction rather than baked into Android internals, it turned out to be portable. Floss, which stands for Fluoride Linux OS Stack, is the project that runs the Gabeldorsche core as the Bluetooth stack for desktop Linux and ChromeOS. It reuses the same modules, the same packet library, and the same testing infrastructure, and exposes them to the Linux world through a D-Bus interface instead of Android AIDL.
This is a real vindication of the architecture. The same connection management, HCI flow control, round-robin scheduling, and PDL-generated parsers that keep your phone’s earbuds connected also run on a laptop, because none of that logic was ever coupled to Android in the first place.
The parts that differ between platforms are pushed to the edges, the transport that talks to the chip at the bottom and the interface that talks to the OS at the top, while the large protocol middle is shared.
An architecture that ports cleanly to an entirely different operating system is one that had genuinely clean seams, not just aspirational comments claiming it did.
Summary
Gabeldorsche is what happens when a team decides that the way to fix a decade of flaky Bluetooth is to fix the architecture rather than the symptoms.
The whole design rests on a few consistent ideas repeated at every layer. Work is organized into modules with explicit, declared dependencies that a registry starts in the right order and tears down in reverse. Concurrency is message passing to a thread that owns its data, built on a reactor and handlers, so the stack barely uses locks and the ones it does use are rare and deliberate.
Data moves between layers through reactive queues that provide backpressure for free, a round-robin scheduler shares controller buffers fairly across connections so audio and bulk transfer coexist, and L2CAP is a pipeline of small single-purpose stages rather than one monolith. Packets are parsed by code generated from a formal specification, which erases an entire family of memory-safety bugs and keeps the C++ and Python paths byte-for-byte identical. Connections and channels are owned objects with clear lifetimes rather than integer handles in global tables, and security is a property attached to types and registrations rather than a runtime check someone might forget.
The testing story is the part that makes the rest believable. Every layer exposes a gRPC facade, the build system regenerates packet code on every change, dumpsys captures a full state snapshot into every bug report, and RootCanal provides a virtual controller so full multi-device scenarios run deterministically in continuous integration without any radios. Tests that fail the same way every time are worth more than a stack that occasionally works, and that principle drove the whole rewrite.
If you want to explore further, the code lives in AOSP under the Bluetooth Mainline module, and the same core now runs on Linux desktops through Floss. Read a .pdl file, then read its generated header, then follow one ACL packet from L2CAP down through the round-robin scheduler to the HCI layer, and the philosophy of the whole project becomes clear in an afternoon. It’s a stack that was designed to be understood, which, for Bluetooth, still feels like a small miracle.