在过去的十年里,Android蓝牙堆栈一直是导致用户在歌曲播放过程中耳机频繁断开连接的原因。

Gabeldorsche是谷歌为解决这一问题而采取的措施——它并非通过对现有代码进行修补来修复问题,而是彻底重新设计了这一堆栈的内部结构。

本文将解释Gabeldorsche的具体构成、其架构设计原理,以及这些组件在实现层面是如何协同工作的。

我们会详细探讨操作系统抽象层、模块系统、线程与队列模型、人机交互层、数据包解析机制、ACL数据路径、L2CAP协议、安全机制、邻居发现与存储模块、适配层与接口层、构建系统以及测试基础设施,并为每一部分提供具体的代码示例。

本文的重点是蓝牙堆栈的内部架构,而非应用程序所使用的公共Android蓝牙API。如果你曾经好奇在BluetoothDevice.createBond()方法被调用之后,实际的数据传输流程是如何进行的,那么这篇文章会深入探讨这一层面。

目录

先决条件

您应该对现代C++有扎实的了解,熟悉C++17中的各种特性,比如lambda表达式、`std::unique_ptr`、移动语义以及模板的基础知识。同时,你也应该对蓝牙协议栈有一个大致的概念,包括HCI、L2CAP、ATT、GATT等层次,以及经典蓝牙与低功耗蓝牙之间的区别。

熟悉事件驱动和消息传递机制在并发编程中非常重要,因为Gabeldorsche在开发过程中大量依赖这些机制。如果之前接触过epoll或类似的反应式事件循环,那么你会觉得操作系统相关的部分会更加容易理解。

虽然你不需要之前有过修改AOSP代码的经验,但了解AOSP的庞大规模以及构建它的复杂性,会对你后续的工作有所帮助。

Gabeldorsche是什么以及它存在的意义

Gabeldorsche,通常简称为GD,是Android蓝牙协议栈的核心部分。这个名字延续了谷歌的传统——使用巴伐利亚阿尔卑斯山地区的地名作为代号。事实上,几乎所有人都会把它的发音读错,但这反而成了它的一个特色。

在源代码树中,Gabeldorsche位于`packages/modules/Bluetooth/system/gd/`目录下。当初,当蓝牙成为可更新的主线模块后,它被从`system/bt/gd/`这个位置迁移到了现在的地方。

它所取代的旧版本通常被称为Fluoride,在那之前则是BlueDroid。那些旧的代码虽然能够正常运行,但它们的架构结构非常复杂,难以理解;线程的使用方式也因不同的子系统而有所差异。因此,当出现问题时,重现故障现象往往非常困难,而且单独测试各个模块也会遇到很多麻烦。蓝牙领域中的错误之所以出名,正是因为它们具有非确定性特征,而这种非确定性的错误往往会在生产环境中继续存在,进而引发用户的投诉。

Gabeldorsche的设计基于一些明确的理念:每一层都应该是一个独立的模块,其依赖关系应该一目了然;并发处理应该通过定义良好的线程来进行消息传递,而不是通过代码中分散存在的共享锁来实现;数据包的解析过程应该基于正式的规范进行自动化处理,而不是依靠手写的字节运算,因为后者很容易导致安全漏洞的产生;每一层都应该能够在虚拟控制器的环境下单独进行测试,这样持续集成系统就能在问题出现之前及时发现它们;最后,整个系统应该具有足够的便携性,能够在Android之外的平台上运行——事实证明,这一点比任何人预想的都要重要。

简短的历史与发展策略

Gabeldorsche于2019年到2020年期间被正式宣布推出,这是一个需要多年时间才能完成的项目,因此它不可能通过一次性的代码提交就完成开发。在数十亿台设备上同时更换蓝牙协议栈是不可能的,所以其推广过程被设计得循序渐进、可逆且相对平稳——对于基础设施类的项目来说,这三个形容词再合适不过了。

迁移过程是从底层开始,逐层进行的。首先移动的是HCI层,因为它距离控制器最近,且边界最为清晰。接着是ACL层和连接管理层,然后是L2CAP层、安全层,依此类推,逐步向上进行。

每一层都通过相应的标志来进行控制,这样设备就可以在运行新版本的GD实现的同时,继续使用上层和下层原有的实现方式。正因如此,后来才会出现所谓的“垫层”这一设计。

这些标志最初以系统属性的形式存在,后来又被转化为aconfig标志。通过这些标志,开发团队可以将新的GD层应用到一小部分设备上,观察其运行情况以及连接指标;如果发现任何问题,也可以立即将系统恢复到之前的状态。

由于每一层都可以独立地被启用或禁用,因此当出现问题时,可以通过修改相应的标志来确定问题是出在某一层上,而无需繁琐地分析堆栈跟踪信息。这种看似繁琐但实际上非常有效的标志管理机制,是整个迁移过程能够顺利进行的重要原因之一。即使你从不使用蓝牙技术,了解这一机制也是很有价值的。

分层架构

Gabeldorsche的整体结构是由多层堆叠而成的,每一层都是依赖于其下层的模块。下图展示了从底层的无线电硬件到顶层的Android框架之间的主要层次结构。

        +------------------------------------------+
        |     Android框架(Java / AIDL)      |
        +------------------------------------------+
        |     BTIF / BTA(旧版本逻辑)    |
        +------------------------------------------+
        |     垫层(新GD架构与旧BTIF/BTA代码的桥梁)|
        +------------------------------------------+
        |  GATT | 安全层 | L2CAP | 邻近设备管理模块       |
        +------------------------------------------+
        |  AclManager | 控制器 | HCI层                |
        +------------------------------------------+
        |     hci_hal(HAL接口:AIDL / HIDL)   |
        +------------------------------------------+
        |     蓝牙控制器(无线电芯片)    |
        +------------------------------------------+

从下往上看,这个图表示的是各层之间的依赖关系。控制器芯片通过物理传输机制来发送HCI指令。hci_hal层负责封装Android硬件抽象接口,这样堆栈中的其他层次就无需关心具体的传输方式是UART、USB还是虚拟套接字。

在其之上,HCI层负责管理命令流的控制并分离各种事件;控制器模块用于缓存芯片的功能信息,而AclManager则负责管理连接关系。

>

上层层次实现了L2CAP通道、安全机制、邻近设备操作(如查询和寻呼扫描)以及GATT数据库功能。垫层的作用是在迁移过程中,让新的GD模块能够与旧的BTIF和BTA代码共存。最顶层则是应用程序所交互的Android框架。

一个重要的结构原则是:每一层都只能通过明确的接口向下延伸,而绝不能横向侵入另一层的内部结构。

操作系统抽象层

在任何蓝牙相关逻辑被实现之前,Gabeldorsche会在os/目录中定义自己的一套小型操作系统抽象机制。这一基础架构是所有后续功能得以实现的前提,它的存在确保了代码堆栈能够在Android、Linux系统上正常运行,同时也便于进行各种测试。

下表列出了这些核心组件及其各自的功能:

组件名称 功能职责
Thread 拥有一个事件循环引擎,并可设置不同的调度优先级
Reactor 基于epoll的事件循环机制,用于处理文件描述符的可读性变化
Handler 将特定的代码逻辑提交给相应的线程进行执行,确保执行过程是串行且无锁的
Alarm / RepeatingAlarm 用于安排代码在指定延迟后或定期执行
Queue 一种响应式、有界的数据通道,支持单生产者单消费者模式
EnqueueBuffer 一个辅助组件,用于临时存储数据,并根据需要将其添加到Queue

上表明确了每个操作系统组件的具体功能。ThreadReactor构成了执行的基础框架:线程实际上就是封装了内核线程的Reactor;而Handler则允许其他代码在不干扰线程内部结构的情况下将其逻辑调度到相应线程中执行;AlarmRepeatingAlarm用于实现定时或周期性任务;最后,QueueEnqueueBuffer负责在生产者与消费者之间传递数据。

所有位于这一层之上的组件都是由这些基础元素构建而成的,因此理解它们的工作原理对于整个代码库来说都具有重要意义。

在Gabeldorsche的实现中,Thread并不仅仅是一个简单的内核线程。它实际上拥有一个基于epoll的事件循环引擎——Reactor。用户只需将文件描述符注册到这个事件循环中,并为该描述符设置读取完成时的回调函数即可。

线程会持续运行这个事件循环,而其上的所有操作实际上都是对各种事件的响应。线程可以以普通优先级或实时优先级创建,而在处理数据流的线程上会使用更高的优先级,这样在系统繁忙时音频播放也不会出现卡顿现象。这种设计思路与libevent或Node.js的事件循环机制类似,只不过在这里被应用在了蓝牙系统中。

理解Reactor这一组件是非常重要的,因为它是所有线程运行的核心。它负责管理epoll文件描述符以及一组注册好的事件处理函数,每个文件描述符都会关联一个读取完成时的回调函数,有时还会关联一个写入完成时的回调函数。

该循环会调用 `epoll_wait`,对于每一个处于“可读取”状态的描述符,它都会触发相应的回调函数。这里有一个值得注意的细节:在回调函数尝试取消自身注册时,如果反应器正在执行其他操作,系统会自动跟踪当前正在执行的回调函数,并推迟其销毁过程,从而避免出现“使用已释放的资源”这种容易引发问题的情况。此外,反应器还会使用一个内部的控制文件描述符,这样其他线程就可以通过该描述符来唤醒反应器,使其能够干净地停止运行。Handler是一种用于将任务分配到特定线程上的机制。你可以将一个闭包提交给相应的处理器,该闭包就会在处理器的线程上执行。内部而言,每个处理器都会维护一个包含闭包的队列,并且会注册一个事件文件描述符到反应器中;因此,当你向处理器提交任务时,实际上就是将数据写入这个事件文件描述符,这样反应器就会被唤醒,进而会从队列中取出相应的闭包并执行它们。

整个系统正是通过这种方式避免锁竞争的:而不是让两个线程同时获取互斥锁来访问共享状态,而是其中一个线程会将任务信息发送到另一个线程的处理器中,而共享状态始终只由拥有它的线程进行操作。

// 获取当前模块对应的处理器,并将任务提交到其线程上。
os::Handler* handler = GetHandler();

handler->Post(common::BindOnce(
    [](int connection_handle) {
      // 这个lambda函数会在处理器的线程上执行,而不是调用者的线程上。
      LOG_INFO("正在断开连接 %d", connection_handle);
    },
    connection_handle));

这段代码展示了该系统中的核心并发模型。GetHandler()会返回与当前模块关联的处理器对象,而这个处理器对象是绑定到特定线程上的。common::BindOnce会将可执行代码及其参数封装成一个一次性使用的闭包,其功能类似于std::bind,但它的设计考虑到了移动语义以及单次使用的要求——因为许多蓝牙数据都是不可移动的缓冲区。当调用handler->Post时,系统会将该闭包写入处理器的事件文件描述符中,并将其添加到队列中以便在处理器线程上执行。这样一来,lambda函数体内的代码就会在单个线程上顺序执行,因此无需使用互斥锁就能安全地访问模块的状态。

关于Gabeldorsche的设计原则,有一点非常重要:任务总是被发送到处理数据的线程上,而数据本身并不会被传输到执行任务的线程中。

对于需要定时执行的操作,系统提供了AlarmRepeatingAlarm这两个类。它们都是利用计时器文件描述符来安排闭包在指定时间后执行,因此计时器实际上只是循环处理过程中的一个普通文件描述符,并不会专门占用一个独立的线程。

// 在当前模块的处理器线程上触发一次一次性超时事件。
alarm_ = std::make_unique(GetHandler());

alarm_->Schedule(
    common::BindOnce(&MyModule::OnConnectionTimeout, common::Unretained(this)),
    std::chrono::milliseconds(5000));

在这里,报警机制是针对模块的处理器对象来构建的,这样它的回调函数就会在正确的线程上执行。Schedule方法接受一个闭包和持续时间作为参数,会为该持续时间设置一个计时器文件描述符;当计时器触发时,反应器就会在处理器的线程上执行OnConnectionTimeout函数。
common::Unretained(this)这个代码告诉绑定器只需保留对对象的原始指针即可,而不会延长该对象的使用寿命。在这里这样做是安全的,因为报警机制和对象都在同一个线程上运行,而且它们的销毁顺序也是已知且可控的。

最后这一点非常重要。Unretained实际上是一种向编译器做出的承诺;如果你违反了这个约定,程序很可能会崩溃,而且这种崩溃往往会导致系统出现严重的问题。

模块系统

Gabeldorsche中的每一个功能层都是一个模块。每个模块都有自己的生命周期、依赖关系集合,以及专用的处理线程。而模块注册表负责按照依赖关系顺序启动这些模块,并以相反的顺序停止它们。这种设计将原本复杂的初始化顺序问题转化成了计算机能够自动处理的流程,相比那些写着“请不要重新排列这些调用的顺序”的注释来说,这种方式要合理得多。

一个模块会继承自Module类,声明一个静态的ModuleFactory对象,并实现一组方法。其中最重要的方法是ListDependenciesStartStop

class ExampleModule : public bluetooth::Module {
 public:
  static const ModuleFactory Factory;

 protected:
  void ListDependencies(ModuleList* list) const override {
    list->add();
    list->add();
  }

  void Start() override {
    hci_layer_ = GetDependency();
    controller_ = GetDependency();
    // 此时模块已经准备好执行其功能了。
  }

  void Stop() override {
    // 释放对这些对象的引用;注册表会在之后停止这些依赖关系的执行。
    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(); });

这个类完整地展示了模块应该具备的功能与接口。

ListDependencies方法在模块构造时会被调用,用于指定该模块需要依赖哪些其他模块,具体方法是将这些依赖模块的类型添加到ModuleList中。注册表会读取所有模块中的这些声明信息,然后计算出一个合适的启动顺序,以确保HciLayerControllerExampleModule::Start被调用之前就已经启动完毕。

Start方法中,GetDependency()会通过相应的工厂函数找到已经运行的依赖模块实例,并将其作为原始指针返回给当前模块。由于注册表会确保这些依赖模块的生命周期长于当前模块本身,因此该模块可以安全地缓存这些指针。Stop方法则在系统关闭前被调用,用于释放对这些对象的引用并取消尚未完成的任务。

ToString方法为模块提供一个名称,方便在日志记录或dumpsys报告中使用。而静态的Factory对象则是一个简单的构造器,它负责创建新的模块实例,并且注册表会利用这个工厂对象来实例化模块以及确定其在依赖关系图中的位置。由于根本不存在需要严格遵守的全局初始化顺序,因此也就不存在出错的可能性。

注册表本身在系统启动时会负责将所有这些组件连接在一起,同时还会为每个模块分配相应的处理线程。

ModuleList modules;
modules.add();

// 注册系统会按照拓扑结构对模块进行排序,然后启动所有模块。
ModuleRegistry registry;
registry.Start(&modules, thread);

// ……其他代码继续执行……

registry.StopAll();

在这里,应用程序声明了它所需要的顶层模块,并将这些模块以及这些模块将运行的线程一起提交给注册系统。

Start方法会按照依赖关系图的结构,以拓扑顺序依次启动每个模块,处理它们之间的依赖关系,并为每个模块分配一个与指定线程绑定的Handler对象。
StopAll方法则会反向执行这个过程,以与启动顺序相反的顺序调用Stop方法,这样就不会有某个模块在它所依赖的其他模块之前就被终止运行。
由于依赖关系是明确定义的数据,而不是通过命令式代码来实现的,因此同样的机制也可以用于TestModuleRegistry,使得测试可以在使用虚拟的依赖关系来模拟实际环境的情况下,启动真正的模块进行测试。
这种测试方式正是这种设计架构所具备的“隐藏优势”。因为任何模块都是通过GetDependency()方法来获取其依赖关系的,所以测试可以在启动被测模块之前注册一个虚拟的HciLayer对象,而被测模块根本无法区分这两个对象之间的差异。

TestModuleRegistry testregistry;
test_registryInjectTestModule(&HciLayer::Factory, fake_hci_layer);
testRegistry.Start(&testRegistry.GetTestModuleList());

// 操作这个虚拟的HCI层,验证ExampleModule模块的行为是否正常。

在这种测试环境中,InjectTestModule方法会预先将一个虚拟实现注册到与真实HciLayer工厂对象对应的键名下,因此任何依赖于HciLayer的模块都会自动使用这个虚拟实现。随后Start方法会启动被测模块,测试就可以通过这个虚拟HCI层向被测模块发送指令,并验证ExampleModule的行为是否正确。整个测试过程都在一个受控的线程中完成,不需要任何硬件设备或其他中间层。

这才是真正意义上的单元测试——测试对象被完全隔离出来进行测试,在之前的代码架构中这是几乎不可能做到的。

堆栈的初始化过程

必须有一个机制来构建注册系统、选择合适的线程、添加顶层模块,并最终使整个系统开始运行。这个机制就是Stack对象。它是整个系统的唯一入口点,负责管理ModuleRegistry和主线程;当Android系统决定启动蓝牙功能时,也会通过Stack来执行相应的操作。

堆栈的初始化过程分为三个步骤:首先创建一个适合当前配置运行的线程;然后构建一个包含所有顶层模块的ModuleList对象,这个列表会自动包含这些模块所有的依赖关系;最后在这个线程上启动注册系统,并等待所有模块都完成启动后再返回结果,这样当调用结束时,整个系统就已经可以正常运行了。

关闭过程与启动过程正好相反:首先停止注册表的操作,这样就会按逆向依赖关系顺序依次停止所有模块的运行,之后再结束整个程序的执行。

将这一系列操作集中到一个对象中,意味着只有一个地方能够准确了解数据栈的增减过程。这与以往的情况截然不同——在过去,程序的成功启动取决于许多文件能否按照正确的顺序被加载进来,而且这种结果具有很大的不确定性。

队列抽象机制

那些生成或接收数据包的模块并不会直接相互调用。它们是通过一个队列来连接的。这个队列是一种基于反应式设计原理构建的、有界的数据传输通道,它只允许一个生产者向一个消费者发送数据。例如,ACL数据就是通过这种方式在L2CAP层和HCI层之间进行传递的,而双方既不会被阻塞,也不会需要共享锁机制。

队列提供了两个半接口,分别对应两端。生产者端通过注册一个回调函数来实现数据的插入操作;当队列有空闲空间时,队列会调用这个回调函数。消费者端也注册了一个回调函数,当队列中有数据可用时,队列会调用这个回调函数。这样一来,就不存在任何一方因等待而阻塞的情况,也不会因为队列为空或已满而导致系统停滞。

// 生产者端:注册回调函数以便在队列有空闲空间时接收数据包。
queue_end_->RegisterEnqueue(
    handler_,
    common::Bind(&MyModule::OnQueueReadyToSend, common::Unretained(this)));

std::unique_ptr<packet::BasePacketBuilder>> MyModule::OnQueueReadyToSend() {
  if (pending_packets_.empty()) {
    queue_end_->UnregisterEnqueue();  // 没有数据可发送,停止接收请求。
    return nullptr;
  }
  auto packet = std::move(pending_packet_.front());
  pending_packet_.pop();
  return packet;
}

这种数据插入机制与大多数人的预期是相反的。你并不是直接将数据推入队列中,而是通过调用RegisterEnqueue并传入一个回调函数来实现数据的添加。只有当队列有空闲空间时,队列才会调用这个回调函数来请求下一条数据。

你的回调函数会返回一个数据包构建对象,或者在没有数据可发送时返回nullptr。这种设计是刻意为之的——这样就可以实现自动的压力反馈机制。如果下游消费者处理数据的速度较慢,导致队列中的数据堆积过多,那么回调函数就会停止被调用,此时数据会暂时存储在你的缓冲区中,你可以随时查看和处理这些数据,而不会让它们滞留在内核的隐藏缓冲区中,从而引发无法解释的延迟问题。

数据提取的过程与插入过程完全相同。

// 消费者端:注册回调函数以便在收到数据包时得到通知。
queue_end_->RegisterDequeue(
    handler_,
    common::Bind(&MyModule::OnPacketReceived, common::Unretained(this)));

void MyModule::OnPacketReceived() {
  auto packet = queue_end_->>TryDequeue();
  if (packet == nullptr) {
    return;
  }
  // 在handler_的线程中处理接收到的数据包。
}

在消费者端,RegisterDequeue会将一个绑定到处理函数的回调函数传递给队列。当有数据包可用时,队列会将这个回调函数发送到处理函数的线程中,在那里你可以通过调用TryDequeue来获取数据包。不过,TryDequeue仍然有可能返回nullptr,因为可能已经有其他进程取走了该数据包,所以你需要进行检查。同样的线程绑定规则也在这里得到了应用:回调函数是在处理函数的线程中执行的,因此数据的处理过程是串行的,并且不需要使用锁机制。

在底层,这两个部分是通过基于事件文件描述符构建的一个小型信号机制来协调工作的。正是这一机制使得一个线程中的生产者能够安全地向另一个线程中的消费者发送信号。当你需要添加大量数据时,EnqueueBuffer会自动处理整个流程——让你只需添加数据,然后让缓冲区负责后续的注册工作,而这正是大多数实际应用中都会采用的方式。

HCI层

主机控制器接口是主机操作系统栈与控制器芯片之间的通信协议。在Gabeldorsche系统中,HciLayer模块负责管理命令通道,并严格执行一条至关重要的规则:控制器会通过“信用额度”来告知你它一次能够接收多少条指令。如果你发送的指令数量超出了它的信用额度,系统就会出现各种难以排查的问题。

你不能直接编写HCI相关的字节数据。你需要构建一个结构规范的命令数据包,将其交给HCI层处理,同时为可能的响应结果提供一个回调函数。HCI层会负责流量控制、将响应与相应的指令进行匹配,并将未经请求的事件转发给相应的订阅者。

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("重置失败");
  }
}

这段代码会发送HCI重置命令并处理其完成后的反馈。hci::ResetBuilder::Create()用于生成结构规范且经过验证的命令数据包,因此你绝对不可能发送格式错误的重置请求。

EnqueueCommand会将命令放入层内部的命令队列中。只有当有足够的“信用额度”时,才会将命令发送给控制器;否则,这些命令会被暂时保留,直到控制器在响应中归还相应的信用额度。第二个参数是一个通过BindOnceOn绑定到你的处理函数的回调函数,因此完成后的处理操作会在你的线程中执行。

指令分为两种类型:一种会触发“Command Complete”事件,另一种会触发“Command Status”事件。HCI层提供了重载版本的EnqueueCommand函数,以便将不同类型的指令分别转发给相应的回调函数。

当响应数据到达时,你会首先收到一个通用格式的响应对象,然后将其转换为具体的ResetCompleteView类型,并在读取数据之前先检查IsValid()方法是否返回true。这种有效性检查并非形式主义,而是解析器用来确认接收到的字节数据是否符合预期的结构。

HCI层还会将其输出数据分成多个独立的流,而不是通过一个统一的回调函数来处理所有数据。命令响应会直接发送给你在发送命令时指定的回调函数;而对于像远程设备连接这样的未经请求的事件,则会被转发给注册了相应处理函数的模块。对于LE元事件及其子事件,也会分别被转发给对应的订阅者。此外,HCI层还提供了诸如安全接口和LE广告接口之类的子接口,这样各个模块就可以只关注自己真正需要的数据部分,而无需处理全部的信息流。

这种分离将请求-响应逻辑与随机事件处理逻辑分离开来;在旧的架构中,往往同一个函数会试图同时完成三项任务。

HciLayer并列存在的是Controller模块,它的职责是在系统启动时向芯片发送查询指令,并将得到的结果缓存起来。它会发出read-local-version、read-local-supported-commands、read-buffer-size以及LE feature等命令,然后通过简单的获取器函数将这些结果暴露出来。

这一点非常重要,因为系统的其他部分始终需要了解诸如ACL缓冲区的大小、控制器能够容纳多少数据包之类的信息。一次性进行查询并将结果缓存起来,远比反复向芯片发送请求要好得多。当上层模块想要知道某个功能是否被支持时,它会询问Controller,而不会直接去询问硬件。

数据包定义语言

这里有一种设计机制,它在实际应用中发挥了巨大的作用。在旧的架构中,解析数据包意味着需要根据手动计算出的偏移量来读取字节,然后进行位移和掩码操作,并且还要确保所有开发者都正确处理了数据的字节序和边界检查问题。然而他们并不总是能够做到这一点,而格式错误的双蓝牙数据包往往会成为远程攻击的漏洞——这类漏洞通常会有一个容易记住的名称和相应的图标。

Gabeldorsche通过引入“数据包定义语言”来取代原有的处理方式。你只需要在.pdl文件中描述一次数据包的格式,然后一个名为bluetooth_packetgen的工具就会根据这些描述生成C++代码中的解析器和构建类。

一个PDL文件首先会声明数据的字节序,接着定义枚举类型、结构体以及数据包的结构。下表总结了你在PDL文件中最常遇到的各种字段构造方式:

构造方式 含义
field : N 一个宽度为N位的标量字段
field : Enum 其值来自某个命名枚举类型的字段
field : N[] 一个由N位元素组成的可变长度数组
field : N[k] 一个包含k个元素的固定长度数组,每个元素都是N位
_size_(field) 用于存储另一个字段的字节大小的字段
_count_(field) 用于存储数组中元素数量的字段
_payload_ 父数据包中携带的可变数据部分
_fixed_ = value 构建器总会生成的常量字段
_reserved_ 在传输过程中这些位始终为0

上表涵盖了PDL文件中常用的各种字段构造方式。标量字段和枚举字段用于表示具有固定位宽的数值;数组结构则用于描述重复出现的数据,无论是可变长度还是固定长度的数据。_size__count_这两个字段才是真正巧妙的地方:它们使得这种格式描述方式能够仅通过一次设定就能明确长度前缀的具体数值,同时生成器会自动处理相关的算术运算,从而确保在序列化时能够计算出正确的长度,在解析时也能进行验证。_payload_标记用于实现数据包的继承机制,使得子数据包的内容能够被包含在父数据包中。_fixed__reserved_这两个标记用于编码常量以及必须为0的值,这样人们就无需记住这些细节了。

这里所有的内容都只是声明性的描述,而生成器会将这些描述转换成不会忽略任何边界检查的代码。
具体的实现方式就是使用结构体来定义数据格式,其中会明确指定各字段的宽度及约束条件;子数据包会对其父数据包中的某些字段进行限制。

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],
}

这段代码定义了一个操作码枚举类型、一个通用的Command父数据包结构,以及两个具体的数据包类型。Command数据包包含操作码、其负载数据的长度信息,以及实际的数据内容——这种结构是所有命令所共有的。Reset数据包继承自Command,并且将其操作码固定为RESET,因此生成的代码总是会使用正确的操作码;而ReadLocalNameComplete数据包则继承自CommandComplete,并增加了一个status字段以及一个长度为248个8位字节的数据字段——这种设计正好符合蓝牙规范对本地名称字段的要求。
括号中的约束条件使得生成的代码能够根据数据包中携带的操作码,自动将原始字节序列转换为正确类型的数据结构,而完全不需要编写任何手动切换语句。
根据这样的定义,生成器会为每种数据包类型生成两种对应的类:一种是用于将结构化数据序列化为字节流的构建器,另一种则是用于将字节流解析为结构化数据并进行边界检查的访问器。

// 构建过程:结构化数据被转换为经过验证的字节序列。
auto builder = hci::ResetBuilder::Create();
std::vector<uint8_t>> bytes;
BitInserter it(bytes);
builder->Serialize(it);   // 写入操作码、计算出的数据长度以及实际的数据内容

// 解析过程:字节序列被转换为结构化且类型正确的数据对象。
auto command_view = hci::CommandView::Create(
    PacketView<>kLittleEndian>>(std::make_shared<std::vector<uint8_t>>&>(bytes)));
auto name_view = hci::ReadLocalNameCompleteView::Create(command_view);

if (name_view.IsValid()) {
  std::array<uint8_t, 248>> name = name_view.GetLocalName();
}

从构建的角度来看,构建器清楚地知道数据的结构:它会按照顺序遍历各个字段,写入固定的操作码、计算并写入数据长度,最后输出实际的数据内容——因此用户完全不需要手动处理任何偏移量问题。

解析过程采用懒惰处理机制,并且是分层进行的。PacketView类在不复制数据的情况下封装了共享的字节缓冲区,通用的CommandView负责解析常见的命令头信息,而特定的ReadLocalNameCompleteView则会进一步细化解析过程。

关键方法是IsValid(),生成代码会通过这个方法来验证缓冲区的长度是否足够容纳所有字段的信息,同时也会检查各种约束条件是否都得到满足,只有在通过这些检查后,才会调用GetLocalName()方法来获取解析后的数据。

由于解析器是根据相同的规范为所有数据包生成的,因此那些由于计数错误或越界导致的漏洞根本不可能通过手工编写代码来产生。甚至还存在基于相同PDL生成的Python绑定版本,因此测试套件在解析和构建数据包时所使用的逻辑与生产环境中的逻辑是完全一致的。

ACL管理器与连接管理

在原始的HCI层之上是AclManager模块,该模块负责处理异步的、以连接为导向的数据传输机制,当设备连接成功后,实际的用户数据就是通过这一机制进行传输的。AclManager既能够管理传统的连接方式,也能管理低功耗连接;它将连接对象表示为具有回调接口的对象,而不是那些存储在全局表中、容易被误用的整数标识符。在内部实现上,AclManager分为传统连接模式和低功耗连接模式的实现版本,这两种模式都使用了下一节中会介绍的轮询数据调度机制。

当你请求建立连接时,需要注册一个回调函数,该函数会在连接成功或失败时被调用;如果连接成功,你将会获得一个负责管理该连接的数据队列的连接对象。

// 注册对传统连接事件的监听,然后尝试建立连接。
acl_manager_->RegisterCallbacks(this, GetHandler());
acl_manager_->>CreateConnection(remote_address);

void MyModule::OnConnectSuccess(
std::unique_ptr connection) {
uint16_t handle = connection->GetHandle();
// 这个连接对象负责管理该连接的数据队列。
connection->GetAclQueueEnd()->RegisterDequeue(
GetHandler(),
common::Bind(&MyModule::OnAclData, common::Unretained(this)));
connections_[handle] = std::move(connection);
}

从使用者的角度来看,这段代码展示了连接建立的全生命周期。RegisterCallbacks方法会让你所在的模块在处理线程中监听与连接相关的事件,而CreateConnection方法则会启动HCI层的相关操作,以便与远程地址建立连接。

当连接成功时,OnConnectSuccess方法会接收到一个由unique_ptr管理的ClassicAclConnection》对象,这意味着对象的拥有权是明确的,而且当这个对象被销毁时,相应的连接也会被彻底终止。该连接对象拥有自己的数据队列,可以通过GetAclQueueEnd()方法访问这些数据;同时,你也可以像之前一样,为这个数据队列注册一个出队回调函数。

最终,你可以将这个连接放入自己创建的映射中,并使用相应的标识符来管理它。这样一来,就再也不会存在关于谁拥有这个连接的疑问了——在之前的设计中,这种不确定性确实是导致“资源被释放后仍被继续使用”这类错误发生的根本原因。

关于LE设备,还有一个值得提及的问题:隐私保护。LE设备会使用“可解析私有地址”来动态更换自己的广告地址,这样一来,追踪器就无法通过设备的MAC地址来跟踪它了。

Gabeldorsche开发了一个名为LeAddressManager的组件,该组件负责管理本地地址的动态切换以及控制器对地址信息的解析过程。它会协调地址变更的时间点,确保在那些依赖地址稳定性的操作进行过程中,地址不会发生突然的变化。这种工作需要对时间把控得非常精确,而将相关的逻辑集中在一个组件中,而不是分散到整个LE代码体系中,正是这种设计思路让整个系统运行起来更加顺畅。

轮询调度器与ACL数据传输路径

控制器拥有的ACL缓冲区数量是有限的,所有连接都会竞争使用这些缓冲区。如果允许某个正在忙碌运行的连接占用所有的缓冲区,其他连接就会无法正常工作——在手机上,这种情况会导致文件传输操作干扰音频播放。

Gabeldorsche通过一个名为RoundRobinScheduler的调度器来解决这个问题。这个调度器位于各个连接对应的队列与控制器之间的共享链接路径上。

调度器会记录控制器目前还剩下多少个缓冲区可用,然后按照轮询顺序,从每个连接中依次取出一个数据包,将其分割成适合控制器处理的格式后再发送过去,同时相应地减少该连接的缓冲区剩余数量。

当控制器通过“数据包传输完成”事件通知调度器自己已经发送完数据并释放了缓冲区时,调度器会重新为该连接分配相应的缓冲区资源,然后继续进行数据传输。

由于调度器是按轮询顺序处理各个连接的,因此所有链接都能公平地共享带宽,而且任何连接都不需要知道其他连接的存在。数据的分割工作也是在这里完成的:上层应用程序可以认为接收到的数据是一个完整的L2CAP帧,而实际上调度器已经将这些数据分割成了适合控制器处理的较小片段,在传输过程中再重新组装成原来的格式。正是这个组件的存在,才使得“我的耳塞和文件同步功能能够同时正常使用”成为可能。

L2CAP与数据传输管道

L2CAP(逻辑链路控制与适配协议)可以将单一的ACL链接分割成多个逻辑通道,并负责处理较大规模服务数据的分段与重组工作。

Gabeldorsche将L2CAP的常规版本和LE版本分别实现为两个独立的模块:L2capClassicModuleL2capLeModule>》,这两个模块在底层使用了相同的架构。L2CAP区分了“固定通道”和“动态通道”:固定通道始终存在,用于处理信号传输等任务;而动态通道则是在需要时为特定服务而创建的。

某个服务会通过相应的协议或服务多路复用机制进行注册,当远程设备打开与该服务对应的通道时,该服务就会收到一个通道对象,而这个通道对象自然也会包含一个用于存储数据的队列。

// 在PSM上注册一个动态的L2CAP服务。
dynamic_channel_manager_->RegisterService(
    kMyPsm,
    security_policy,
    GetHandler()->BindOnceOn(this, &MyModule::OnServiceRegistered),
    GetHandler()->BindOn(this, &MyModule::OnConnectionOpen);

void MyModule::OnConnectionOpen(
    std::unique_ptr channel) {
  channel->RegisterOnCloseCallback(/* ... */);
  channel->GetQueueUpEnd()->RegisterDequeue(/* ... */);
}

在这里,服务是针对“协议服务多路复用器”这一概念进行注册的,而L2CAP中的“协议服务多路复用器”相当于端口编号。RegisterService函数接受三个参数:PSM、描述该通道所需的配对及加密级别的安全策略、以及一个用于确认注册的一次性回调函数;此外还有一个会在远程设备每次打开通道时被调用的重复执行回调函数。当通道被打开时,OnConnectionOpen函数会接收到一个由unique_ptr管理的DynamicChannel》对象,然后需要为该对象配置关闭回调函数以及与其队列相关的操作。

由于安全策略是在注册时作为参数传入的,而不是在运行时才被检查的,因此物理上不可能以低于服务所要求的安全级别来打开某个通道。将安全性作为类型属性的一部分进行设计,而非在运行时再考虑这一点,是这个技术栈中反复采用的设计原则。

在这个“直观易懂”的通道对象背后,实际上存在着一条真正的数据传输机制,了解一个字节是如何通过这条机制被传输的其实是很有意义的。
外出方向的数据流:
->> 每个通道对应的分段器(将SDU拆分成PDUs,并根据通道模式进行相应处理)
->> 通道调度器(根据优先级决定哪个通道接下来进行传输)
->> 分片器(将PDUs调整到控制器ACL缓冲区的大小)
->> AclManager队列 -> 循环调度器 -> 控制器

进入方向的数据流:
->> 重组器(根据ACL片段重新构建PDUs)
->> 每个通道对应的重组器(将PDUs重新组合成SDU)
->> 通道队列的接收端 -> 上层协议

这个图示展示了L2CAP数据流的两种传输方向。在数据流外出时,来自上层协议的服务数据单元会首先进入通道的分段器,那里会将这些数据单元拆分成相应的协议数据单元,并根据通道所设定的传输模式(如基本模式或增强型重传模式)进行进一步处理,包括添加确认信号并进行必要的重传操作。

随后,链路级别的调度器会根据各通道的优先级来决定哪个通道接下来进行数据传输,这样那些对延迟要求较高的通道就能得到优先处理。分片器会将PDUs调整到控制器ACL缓冲区的大小,之后这些数据单元会被送入AclManager队列,并由前文提到的循环调度器负责后续的传输工作。

在数据流进入时,整个过程会反向进行:重组器会将ACL片段重新组合成PDUs,每个通道对应的重组器则会将这些PDUs重新组装成原始的SDU格式,最终完成的数据单元会被送入通道的接收端,从而被上层协议读取和处理。

每个阶段都是一个功能单一、易于测试的小模块,正因为如此,L2CAP这一历来存在诸多缺陷的协议层,才得以被有效地管理和优化。

安全性与配对功能

SecurityModule模块负责两种传输方式的配对、连接建立及加密操作。传统的配对流程是通过链路来完成的,而LE安全管理协议则通过专门为SMP服务的固定L2CAP通道来执行相关操作。

该模块负责驱动各种关联模型下的状态机,包括数字比较、密码输入、带外通信等功能。它通过回调接口与用户进行交互,从而使框架能够显示配对对话框,并反馈用户的选项。

这种设计的目标是确保没有其他模块会自行实现加密或配对逻辑。当L2CAP需要为数据传输创建加密通道时,它不会直接访问密钥存储系统或启动加密流程,而是通过安全接口向SecurityModule发起请求,等待处理结果后,才会将通道开放给上层应用。

这一原则同样适用于整个协议栈中的其他部分——只有某个模块负责管理配对状态和密钥,其他所有模块都只需向其发送消息即可。将加密相关操作集中到一个受监管的模块中,显然比过去那种安全检查分散在多个文件中的情况要安全得多;因为只要其中某个文件被遗忘或出现故障,就可能导致整个系统的安全性受到威胁。

GATT与ATT

在LE协议栈的顶层,是属性协议ATT以及通用属性配置文件GATT。实际上,几乎所有基于LE协议的设备都会使用GATT这个请求-响应数据库。

ATT定义了针对编号属性的处理流程,包括读取、写入、通知等功能,并且是通过专门的L2CAP通道来运行的。而GATT则将这些属性组织成服务与特性,这些抽象概念是心率监测器或耳塞等设备向手机呈现的数据格式。

在Gabeldorsche的产品中,这些功能都是清晰地分层实现的。ATT作为固定通道L2CAP接口的客户端,它与其他使用相同通道的应用程序一样,通过相同的机制获取通道资源,并按照相同的队列规则传输数据包。其数据包的定义方式与协议栈中的其他部分完全一致,因此ATT的读取请求及其响应结果,都具有与HCI命令相同的有效性保障。

GATT在ATT的基础上构建了自己的服务与特性数据库,并为上层应用提供了注册和通知接口。

这种架构设计带来的好处在于:由于GATT仅仅是一组通过队列和结构化数据包进行通信的模块,因此可以通过RootCanal与虚拟设备进行测试,而无需底层的其他硬件组件真正参与测试过程。

邻居发现模块与存储模块

还有两个辅助模块组进一步完善了整个系统。邻居发现模块负责识别其他设备,而存储模块则负责数据的持久化保存。

发现过程并非由一个操作完成,而是由多个步骤组成。Gabeldorsche中的各个模块都属于这一流程中特定的组成部分。查询功能负责处理经典设备的发现及其扫描模式;页面扫描功能则用于实现设备的连接准备与实际连接过程;名称解析功能则会获取远程设备的人类可读名称。

将这些功能划分为相互独立的组件,并为它们设计简洁的接口,而不是将它们整合为一个庞大的整体,这样做的好处在于每个组件都可以被单独进行分析和测试。同时,这也意味着名称解析功能中的错误不会意外影响到查询功能的状态,因为这两个组件并不共享任何可变状态。

存储模块负责在系统重启后保留各种信息,主要包括已配对设备及其对应的密钥、适配器的相关属性以及设备的其他配置信息。该模块会在内存中创建设备及其属性的模型,并将这些模型保存到配置文件中。它还会分批进行数据写入操作,以避免大量属性变更同时发生时对硬盘造成过大的负担。

由于存储相关的功能是通过模块接口实现的,因此系统的其他部分都是通过方法调用来读取或写入设备属性的,而无需直接解析配置文件。即使磁盘上的存储格式发生了变化,系统的其他层次也不需要对此进行调整。如果每次系统重启后配对设备的信息都会丢失,那将会是一个非常严重的问题——因此这个存储模块在默默地发挥着重要的作用。

垫层与外观层

你不可能通过一次代码提交就完全重写整个蓝牙堆栈并立即将其投入使用。Gabeldorsche是分阶段逐步开发的,每一层都是独立开发的。因此,在很长一段时间里,新的GD模块不得不与旧的Fluoride代码共存。

shim/层就是实现这一目标的关键桥梁。它提供了BTIF和BTA所期望的旧式C语言接口,并将这些接口调用转换为新的模块方法调用。当这些调用跨越不同的代码层时,shim/层会确保它们能够正确地被后续的代码层处理。shim/层还通过一些标志来控制各个层的启用状态,这样在某个构建版本中某层可以在GD堆栈上运行,在另一个版本中则可以在旧代码上运行。这样一来,就可以通过切换这些标志来精确地定位导致问题出现的具体层次,而无需进行繁琐的调试工作。

facade/层的作用则完全不同。每个GD模块都可以提供一个名为“外观层”的gRPC服务,这样外部进程就可以直接控制这个模块。测试框架就是通过这个“外观层”来与被测模块进行交互的。测试人员不需要通过整个Android框架来进行测试,而是可以直接启动某个模块,通过gRPC与其“外观层”建立连接,然后直接向被测模块发送指令,并观察其产生的事件流。

service HciLayerFacade {
  rpc SendCommand(Command) returns (google.protobuf.Empty) {}
  rpc StreamEvents(google.protobuf.Empty) returns (stream Event) {}
}

以上代码展示了用protobuf语言定义的模块“外观层”的结构。它定义了一个gRPC服务,其中包含一个用于向模块发送指令的一元方法,以及一个用于将模块产生的所有事件推回调用方的服务器端流式传输方法。由于使用了gRPC协议,因此进行测试的客户端可以用任何语言编写;而该项目选择了Python语言,是因为它既便于阅读代码,也提高了测试开发的效率。

这种架构使得人们可以在不构建整个操作系统的情况下,从外部访问这些内部层。这就是可测试设计与那些仅在设计文档中提到“可测试”这一字的设计之间的区别。

构建系统集成

如果没有能够将数据包生成工具视为重要组件的构建工具,那么所有这些努力都将毫无意义。Gabeldorsche使用AOSP的构建系统Soong来进行开发,该系统的文件名为Android.bp,而PDL生成器则被作为自定义规则集成到构建流程中,这样.pdl文件就会在构建过程中自动被编译成C++头文件。

这一机制是通过生成源代码规则来实现的。构建规则会指定bluetooth_packetgen工具的使用方式,指明输入文件为.pdl格式,并指定输出结果为.h文件。任何将这些生成的头文件作为依赖项的C++库,都会在需要时自动被编译生成,而且每当.pdl文件发生变化时,这些代码也会被重新编译。

实际效果是:当开发人员修改数据包定义并重新构建系统后,新的构建工具和相关功能会立即在C++目标代码中体现出来;同时,通过并行规则,这些新功能也会出现在测试所使用的Python绑定代码中。因此,不存在因生成代码的版本不一致而导致的问题,也不需要在进行代码提交审查之前手动执行代码生成操作。同一个源代码库同时为生产环境的C++代码和测试环境中的Python代码提供数据支持,构建系统能够确保这两者之间的数据始终保持同步。

日志记录、指标统计与dumpsys工具

如果无法查看程序内部的执行栈结构,就无法对其进行调试;而对于蓝牙这类系统来说,往往是在出现问题后,通过错误报告来进行调试的。

Gabeldorsche对此非常重视。它使用标准的Android日志记录机制,并为每个模块添加了相应的标签,这样就可以通过日志信息判断出哪些模块产生了这些日志。同时,该公司还收集结构化的指标数据,将这些数据输入到平台的统计分析系统中,从而实现对整个系统的健康状况进行全面监控。

最实用的调试工具就是dumpsys。各个模块都可以实现dump方法,系统可以遍历所有正在运行的模块,并要求它们分别序列化自己的当前状态,这些序列化后的数据会包含在错误报告中。

由于每个模块都知道如何描述自身的状态,因此错误报告能够完整地反映整个系统的运行情况,包括连接信息、通道状态以及控制器的功能等——无需在系统出现故障时再进行任何额外的调试操作。

这就是“请在我面前重现问题”与“把你已经收集到的错误报告发给我”之间的区别。对于像蓝牙这样对时间依赖性很强的系统来说,这种区别往往决定了调试工作的成败。

使用Cert测试和RootCanal进行测试

所有这些模块化设计的最终价值体现在测试基础设施上,其中有两个特别重要的组成部分。首先是认证测试套件,通常被称为Cert测试,这套测试是用Python编写的,它通过gRPC接口来测试各个模块的功能;其次是RootCanal,这是一个虚拟的蓝牙控制器。

RootCanal值得特别关注。它是一种基于蓝牙控制器的软件实现,能够处理人机交互协议相关的功能。不过与传统的无线电设备不同,RootCanal使用的是模拟的物理层机制。

你可以将多个软件堆栈连接到同一个RootCanal实例上,并让它们处于彼此的无线电通信范围内,从而实现一个堆栈向另一个堆栈发送广告信息或连接请求的功能。这意味着,在一台完全没有蓝牙硬件的Linux机器上,就可以通过持续集成测试来模拟两台设备之间的配对过程及数据传输过程。传统无线电设备存在的稳定性问题、无线电干扰、时间抖动,以及诸如有人拿着微波炉走过附近这类因素,都在RootCanal的设计中被彻底避免了。

Cert测试会将这些组件连接在一起:它首先启动被测软件堆栈和另一个参考堆栈,然后将它们都与RootCanal连接起来,接着检查在这两个堆栈之间传递的数据包。

class HciTest(GdBaseTestClass):

def test_local_hci_cmd_and_event(self):
# 通过被测设备的接口发送一条人机交互协议命令。
self.dut.hci.SendCommand(
hci_facade.Command(payload=bytes(ResetBuilder().Serialize())))

# 确认是否收到了预期的完成事件。
assert self.dut.hci.get_event_stream()).emits(
HciMatchers COMMANDComplete(OpCode RESET))

这项测试完全不依赖任何硬件,就能端到端地验证人机交互协议层的工作流程。self.dut代表被测设备,实际上它是一个运行在gRPC接口背后的真实GD软件堆栈实例。SendCommand方法会将由Python绑定生成的ResetBuilder对象序列化后发送出去,然后测试程序会检查该设备是否发出了表示命令执行完成的事件,这里使用的匹配器会持续等待这个事件的到来,而不会因为一次检测未成功就放弃测试。

在整个持续集成测试过程中,这一系列操作都能以确定性的方式完成。当这项测试通过时,就可以证明人机交互协议的相关路径是正常工作的;而当测试失败时,每次失败的原因都会是一样的——这一点正是测试所具备的最重要的价值之一。

Floss:Gabeldorsche跨平台解决方案

由于GD软件堆栈是基于自身的操作系统抽象层构建的,并没有直接集成到Android系统中,因此它具有很强的可移植性。Floss项目就是利用GD堆栈作为蓝牙驱动程序,在桌面Linux系统和ChromeOS平台上运行Gabeldorsche核心功能。它重用了相同的模块、相同的数据包处理库以及相同的测试框架,并通过D-Bus接口将这些功能暴露给Linux生态系统,而不是使用Android的AIDL接口。

这一事实真正证明了这种架构设计的合理性。那些用于管理设备连接、控制人机交互协议数据流、实现轮询调度机制,以及由PDL语言生成的解析器等功能,不仅可以在手机上使用,同样也可以在笔记本电脑上运行——因为这些逻辑从一开始就没有与Android系统绑定在一起。

那些在不同平台上存在差异的部分被被安排在系统的边缘部分——负责与底层芯片进行通信的模块以及负责与操作系统交互的接口部分,而位于中间的那些核心协议则可以被所有平台共享。

一种能够顺利移植到完全不同操作系统的架构,必须是那种真正做到了各部分之间无缝连接的架构,而不仅仅是那些声称自己具备这种能力的空洞宣言。

总结

“Gabeldorsche”这个例子恰恰说明了:当一个团队决定通过改进系统架构而非仅仅解决表面问题来修复蓝牙技术十年来一直存在的各种缺陷时,最终就会产生这样的成果。

整个设计体系建立在一些在每一层都被反复应用的统一原则之上。开发工作被分解成多个模块,这些模块之间有着明确规定的依赖关系;注册机制会按照正确的顺序进行操作,并在需要时反向解除这些依赖关系。并发处理是通过消息传递来实现的,数据由拥有这些数据的线程进行处理,这种机制是基于“反应器”和“处理器”构建的,因此程序栈几乎不会使用锁机制,而那些偶尔需要的锁也是经过精心设计的。

数据在各层之间的传输是通过“响应式队列”来完成的,这些队列能够自动提供必要的背压控制;轮询调度机制会公平地分配控制器缓冲区资源,从而确保音频传输与大量数据的传输能够同时进行;L2CAP协议实际上是由一系列功能单一的小模块组成的,而不是一个庞大的整体。数据包的解析工作是由根据正式规范生成的代码来完成的,这种设计有效地消除了许多与内存安全性相关的问题,同时也使得C++和Python这两种编程语言编写的代码在结构上完全一致。连接和通道都是具有明确生命周期的对象,而不是存储在全局表中的整数标识符;安全机制则是作为类型或注册信息的一部分被内置到系统中的,而不是在运行时才被检查的。

测试环节是让整个设计体系显得可信的关键。每一层都提供了gRPC接口,构建系统会在每次代码发生变化时重新生成数据包处理代码,dumpsys工具会将系统的完整状态信息记录到每份缺陷报告中,而RootCanal则提供了一个虚拟控制器,使得多设备联机测试能够在持续集成环境中稳定地进行,而无需使用任何无线电设备。那些每次都会以相同方式失败的测试,其价值远超过那些偶尔能够正常运行的代码;正是这一原则推动了整个系统的重新设计过程。

如果你想进一步了解这个项目,它的源代码可以在AOSP的Bluetooth Mainline模块中找到。如今,通过Floss框架,这套核心代码也可以在Linux桌面系统中运行。你可以先阅读一个.pdl文件,然后查看它生成的头文件内容,再追踪一个从L2CAP层开始、经过轮询调度机制最终到达HCI层的ACL数据包,这样整个项目的设计理念就能在几个小时之内被你彻底理解了。这套设计得如此清晰易懂的代码体系,对于蓝牙技术来说,确实可以说是一个小小的奇迹。

Comments are closed.