前言

文章基于 1e19c030559e84a42c98effa19e35665c7ae4b7c (2.16.0-dev) ,即截止文章撰写时的最新代码版本。

这篇文章主要从我的视角记录了 entity manager 的工作过程,可能存在不准确的地方,还望补充修正。

这篇文章之所以产生,是因为我发现了一个逻辑不通:就以其自带的 configurations/1ux16_riser.json 这样一个配置文件为例。在这个配置文件中,并没有指定这张卡挂在哪个总线上,所有总线相关的条目都是 $bus ,甚至连 eeprom 所对应的设备地址也都是 $address,而且,在相关 kernel devicetree 中,也没有任何与这张卡相关的总线和地址信息。那么,问题来了,是谁为 $address$bus 填入了对应的地址? I2C 不是不可探测的总线吗?那设备是怎么被扫描出来的?完全未知不就意味着驱动都没有?那设备又是怎么被识别出来的?

设计架构

在开始之前,先来介绍一下 openbmc 对于板卡管理的架构设计。

总的来说,OpenBMC 的社区设计,将部件管理分为三大个模块:

  • 探测器
  • 配置管理器
  • 执行器

“探测器”用于发现硬件设备并搜集这些设备的原始信息。这些原始信息将被推送至 dbus 上,供“配置管理器”之类的其它模块使用。
“探测器”的典型代表是接下来要介绍的 fru-device ,当然,社区也有其它的“探测器”实现,比如 peci-pcie

“配置管理器”负责将本地的配置文件与“设备原始信息”进行匹配,从而正确的识别这些设备并形成更加详细的设备配置信息。这些配置信息也将被推送至 dbus 上,供“执行器”之类的其它模块使用。
“配置管理器”的典型代表是 entity-manager

“执行器”有许多不同的种类。它们所做的事,是根据“配置管理器”挂在 dbus 上的信息,找出自己可以操作的设备,获取需要的信息,最后再将信息推回到 dbus 上。
“执行器”的典型代表是 dbus-sensors

也许你对上面所说的过程并没有什么概念,但是我们可以举个更详细的例子:

  • “探测器”扫描到了一个硬件设备,但是只知道这个设备的 id 是 xxxx ,于是它将这个 id 推送到了 dbus 上。
  • “配置管理器”根据这个 id ,结合本地的配置文件,确定了这到底是哪一张板卡。于是“配置管理器”便可以往 dbus 上推送与这张板卡有关的信息,比如它的名称、描述,它所带有的传感器地址等等。
  • “执行器”根据“配置管理器”提供的传感器地址信息,读取传感器数据,最终又将数据推回到 dbus 上。
  • 由于所有数据都在 dbus 上,因此,需要获取信息的东西,如 bmcweb 提供的 redfish 接口,便可以轻松的从 dbus 上拿到任何想要的东西。

用一张图来总结这个过程:

包结构

好了,了解了设计架构,接下来看看 entity-manager 这个包(配方)的结构。
来看看它的编译产物:

meson.build

......

executable(
    'entity-manager',
    'entity_manager.cpp',
    'expression.cpp',
    'perform_scan.cpp',
    'perform_probe.cpp',
    'overlay.cpp',
    'topology.cpp',
    'utils.cpp',
......
)

......
    executable(
        'fru-device',
        'expression.cpp',
        'fru_device.cpp',
        'utils.cpp',
        'fru_utils.cpp',
        'fru_reader.cpp',
......
    )
......

可以看到,entity-manager 虽然是一个包,但却会构建两个可执行文件,一个是 entity-manager ,另一个是 fru-device 。这两个可执行文件分别是由 xyz.openbmc_project.EntityManager.servicexyz.openbmc_project.FruDevice.service 独立负责启动的,两者在启动时并没有直接的依赖关系。

从上面我们的架构分析可知,这两者,一个是“配置管理器”,另一个则是“探测器”。说句实在话,把 fru-device 放在这个包里其实并没有那么合适,倒是有种作为“探测器” demo 的感觉,它完全可以像 peci-pcie 一样自己起一个包。

接下来,我们分别探一探这两个可执行文件,看看它们分别是如何工作的。

fru-device

概述

先来概况一下这个可执行文件的工作内容:它需要在没有设备驱动的情况下扫描所有 i2c 总线下的每一个地址(实际上是指定范围的地址),如果发现地址上存在 FRU ,则解析 FRU 的内容并将其推到 dbus 上。

什么是 FRU ?说白了,它就是一种电子标签,用以存储部件信息。在物理上,它一般存储在板卡上的特有 EEPROM 里,与 BMC 通过 i2c 的方式取得连接。fru-device 在根本上所做的事,便是通过 i2c 来寻找 FRU ,从而发现各种各样的板卡。

扫描

接下来看一个简要的 fru-device 代码分析:

main() 函数中,其会调用 rescanBusses() ,开始第一轮的扫描:

src/fru_device.cpp

int main()
{
    ......
    // run the initial scan
    rescanBusses(busMap, dbusInterfaceMap, unknownBusObjectCount, powerIsOn,
                 objServer, systemBus);
......
}

rescanBusses() 会创建一个 FindDevicesWithCallback 回调。这个回调会在扫描完成后被调用,可以看到,扫描完成后,它就 addFruObjectToDbus() 来将扫描的结果挂到 dbus 上去了:

src/fru_device.cpp

void rescanBusses(
    BusMap& busmap,
    boost::container::flat_map<
        std::pair<size_t, size_t>,
        std::shared_ptr<sdbusplus::asio::dbus_interface>>& dbusInterfaceMap,
    size_t& unknownBusObjectCount, const bool& powerIsOn,
    sdbusplus::asio::object_server& objServer,
    std::shared_ptr<sdbusplus::asio::connection>& systemBus)
{
    ......
        ......

        auto scan = std::make_shared<FindDevicesWithCallback>(
            i2cBuses, busmap, powerIsOn, objServer, [&]() {
                ......
                for (auto& devicemap : busmap)
                {
                    for (auto& device : *devicemap.second)
                    {
                        addFruObjectToDbus(device.second, dbusInterfaceMap,
                                           devicemap.first, device.first,
                                           unknownBusObjectCount, powerIsOn,
                                           objServer, systemBus);
                    }
                }
            }); 
        scan->run();
    ......
}

接下来,我们主要聚焦于扫描的过程, scan->run() 最终会调用 findI2CDevices() ,我们直接来看这个函数:

src/fru_device.cpp

static void findI2CDevices(const std::vector<fs::path>& i2cBuses,
                           BusMap& busmap, const bool& powerIsOn,
                           sdbusplus::asio::object_server& objServer)
{
    for (const auto& i2cBus : i2cBuses)
    {
        int bus = busStrToInt(i2cBus.string());

......

        auto& device = busmap[bus];
        device = std::make_shared<DeviceMap>();
......

        // fd is closed in this function in case the bus locks up
        getBusFRUs(file, 0x03, 0x77, bus, device, powerIsOn, objServer);

......
    }
}

在上方的这个函数中,它会遍历所有存在的 i2c 总线,(检测这些总线的特性,比如 I2C_FUNC_SMBUS_READ_BYTEI2C_FUNC_SMBUS_READ_I2C_BLOCK,上面的代码里省略掉了 ),最后调用 getBusFRUs() 来扫描这一条总线。

传入的参数,0x03 代表扫描的起始地址,0x77 代表扫描的结束地址。
在这里, device 以引用的方式传递,是扫描结果的输出。device 以引用的方式被绑定到 busmap ,其整体结构是这样的:

using DeviceMap = boost::container::flat_map<int, std::vector<uint8_t>>;
using BusMap = boost::container::flat_map<int, std::shared_ptr<DeviceMap>>;

如果用树状结构画出来:

busmap
├── device-map-i2c-0
│   ├── device-0x57-fru-content-vector
│   └── device-0x58-fru-content-vector
└── device-map-i2c-1

即,busmap 装有所有总线的所有设备的所有 FRU 信息。
busmap 由 DeviceMap 组成,代表单个总线上的所有 device 的所有 FRU 信息。
DeviceMap 里装的是一组 std::vector<uint8_t> ,一个 vector 代表一个设备的 FRU 信息。
在这里,FRU 的信息仍然是原始未解析的,解析的过程是在后续 add to dbus 的时候进行的。

在明白了重要参数的含义后,我们进一步看看 getBusFRUs() 是如何获取各个设备的 FRU 的:

src/fru_device.cpp

int getBusFRUs(int file, int first, int last, int bus,
               std::shared_ptr<DeviceMap> devices, const bool& powerIsOn,
               sdbusplus::asio::object_server& objServer)
{

    std::future<int> future = std::async(std::launch::async, [&]() {
        ......
        // Scan for i2c eeproms loaded on this bus.
        std::set<size_t> skipList = findI2CEeproms(bus, devices);
        ......

        for (int ii = first; ii <= last; ii++)
        {
            ......
            if (skipList.find(ii) != skipList.end())
            {
                continue;
            }
            ......
            // Set slave address
            if (ioctl(file, I2C_SLAVE, ii) < 0)
            {
                std::cerr << "device at bus " << bus << " address " << ii
                          << " busy\n";
                continue;
            }
            // probe
            if (i2c_smbus_read_byte(file) < 0)
            {
                continue;
            }
            ......
            makeProbeInterface(bus, ii, objServer);
            ......
            auto readFunc = [is16BitBool, file, ii](off_t offset, size_t length,
                                                    uint8_t* outbuf) {
                return readData(is16BitBool, false, file, ii, offset, length,
                                outbuf);
            };
            FRUReader reader(std::move(readFunc));
            std::string errorMessage = "bus " + std::to_string(bus) +
                                       " address " + std::to_string(ii);
            std::pair<std::vector<uint8_t>, bool> pair =
                readFRUContents(reader, errorMessage);
            ......
            devices->emplace(ii, pair.first);
        }
        return 1;
    });
    ......
}

上面的代码中其实隐含了两种 FRU 的扫描读取方式。
第一种包含在 findI2CEeproms() 中:针对已经安装了设备驱动的设备,(即可以直接找到对应的设备目录以及 eeprom 文件),直接读取 eeprom 的内容并将其作为输出结果。既然通过这种方式已经能够拿到结果了,那么就可以将其加入到 skipList 避免下面货真价实的硬扫描了(1)。

第二种方式则是硬扫描:
首先的首先,什么是“硬扫描”?这里的硬扫描是指直接调用 i2c 驱动的接口,在 i2c 总线上发送原始的请求,并获取原始的结果。硬扫描是直接与 i2c 驱动交互的,并不依赖特定设备的设备驱动,因此我们可以通过其,在没有设备驱动的情况下与设备通信(其实就是手动模拟设备驱动的功能)。这种原始的通信方式是通过 /dev/i2c-xx 的字符设备进行,依赖在内核 config 中开启选项 CONFIG_I2C_CHARDEV

好,接下来照着上面的代码看看“硬扫描”的过程:

  • 首先,其对该总线执行了一个 ioctl 来设置从机地址。
  • 然后,它直接尝试从从机读取内容。这一步我有些疑问,因为照理说 i2c 应该还需要发送一个 command 从机才会响应(比如指定所读取的数据在从机内的地址),但是,有相关资料Some devices are so simple that this interface is enough,那我们也只能认为 FRU 所在的 eeprom 也是这样 simple 的设备。
  • 假如成功从从机读取了内容,它便会认为此处有 i2c 设备,会调用 makeProbeInterface() 在 dbus 上挂一个 xyz.openbmc_project.Inventory.Item.I2CDevice 类型的接口,这个接口仅仅代表此处可能有 i2c 设备,并不包含 FRU 的信息。
  • 假如成功从从机读取了内容,它便会调用 readFRUContents() 来读取 FRU 的原始数据,并将数据填入到输出结果中。

传入给 readFRUContents()FRUReader 是一层简单的包装,能够将原来受 i2c 协议限制的指定大小读写转换为任意大小的读写,并附以缓存来提高性能。而上面作为 lambda 传入的 readData() 才是真正负责从 i2c 读取内容的函数,其最终通过调用 libi2c 库提供的接口从 /dev/i2c-xx 取得结果。

readFRUContents() 的详细过程此处不再展开,其主要由两个过程组成:

  • 校验 FRU 的头部:它会按照 FRU 的标准,读取 eeprom 头部的数个字节进行校验,确保这个 i2c 设备真的是一个 FRU 。
  • 结合头部的 offset 信息,获取 FRU 的完整原始内容:FRU 本身是由不同的区块组成的,FRU 的头部填写了这些区块在 FRU 本体中的偏移量,这个函数会结合这些偏移量信息,将 FRU 的完整原始内容读取出来。这中间会有许多跳来跳去,在不同 offset 读取内容的过程,这就该轮到 FRUReader 中的缓存发挥作用了。

到这里,上面关键的 busmap 就被填充完成了。也就是说,我们扫描了所有的 i2c 总线,对疑似有设备的地址进行了读取,在确定了设备可能是 FRU 后,将设备中的原始内容全部取出,暂时保存在 busmap 中,等待后续处理。

注释:

  • (1) 这种情况主要针对的是后触发的重扫描,毕竟刚开机的时候可没有什么设备驱动,至于设备驱动是如何安装的,则将在后续对 entity-manager 的介绍中再进行。当然,这种情况也可以是对于在 dts 中写死了驱动的设备,不过比较少见。对于已经安装了驱动的设备,直接进行硬扫描可能导致与驱动发生冲突,造成不可预期的后果,因此预先判断该设备有没有设备驱动十分重要。

解析与推送

接下来就是 FRU 的解析过程了,FRU 的解析发生在往 dbus 上推送对应 object 的时候。
接着上面的 FindDevicesWithCallback 中的 addFruObjectToDbus() 来看:

src/fru_device.cpp

void addFruObjectToDbus(
    std::vector<uint8_t>& device,
    boost::container::flat_map<
        std::pair<size_t, size_t>,
        std::shared_ptr<sdbusplus::asio::dbus_interface>>& dbusInterfaceMap,
    uint32_t bus, uint32_t address, size_t& unknownBusObjectCount,
    const bool& powerIsOn, sdbusplus::asio::object_server& objServer,
    std::shared_ptr<sdbusplus::asio::connection>& systemBus)
{
    boost::container::flat_map<std::string, std::string> formattedFRU;

    std::optional<std::string> optionalProductName = getProductName(
        device, formattedFRU, bus, address, unknownBusObjectCount);
    ......

    std::string productName = "/xyz/openbmc_project/FruDevice/" +
                              optionalProductName.value();

    ......

    std::shared_ptr<sdbusplus::asio::dbus_interface> iface =
        objServer.add_interface(productName, "xyz.openbmc_project.FruDevice");
    dbusInterfaceMap[std::pair<size_t, size_t>(bus, address)] = iface;

    for (auto& property : formattedFRU)
    {
        ......

        if (property.first == "PRODUCT_ASSET_TAG")
        {
            std::string propertyName = property.first;
            iface->register_property(
                key, property.second + '\0',
                [bus, address, propertyName, &dbusInterfaceMap,
                 &unknownBusObjectCount, &powerIsOn, &objServer,
                 &systemBus](const std::string& req, std::string& resp) {
                ......
                });
        }
        else if (!iface->register_property(key, property.second + '\0'))
        {
            ......
        }
        ......
    }

    // baseboard will be 0, 0
    iface->register_property("BUS", bus);
    iface->register_property("ADDRESS", address);

    iface->initialize();
}

真正的解析过程发生在 getProductName() -> formatIPMIFRU() 里,解析结果存储在 formattedFRU 中,这个变量会全程以引用的形式被传进去,最后被作为结果读出来。接着,解析结果会被逐属性的推上 dbus 。在这里,PRODUCT_ASSET_TAG 属性会被特殊对待,它除了被推上去外,还被额外设置了一个 setter (也就是那个 lambda 表达式),这种 setter 会劫持如 busctl set-property 的过程,确保属性被别的程序设置时,fru-device 也能感知到这个变化,在这里,它被用于 updateFRUProperty() 也就是 FRU 回写,这里的内容就不再进一步展开了。

接下来简单看看 getProductName() 调用的 formatIPMIFRU() 的解析过程:

  • 首先, 其会按照标准将 FRU 解析成键值对。看起来 FRU 的键主要有两种,一种是固定的,其值对应 FRU 中的特定偏移量。这些固定的键可以在 src/fru_utils.hpp 中找到。另一种是自定义的键,这一种键不可自定义名称,显示的键名统一为 const std::string fruCustomFieldName = "INFO_AM"; + 一个数字,而值则通过解析 FRU 来获得。
  • 此外,在键的名称之前,还会加上一个该键所对应的区域的名称。比如 BOARD_PRODUCT_NAME 代表 BOARD Area 中的 PRODUCT_NAME Field, PRODUCT_MANUFACTURER 代表 PRODUCT Area 中的 MANUFACTURER Field 。

再接下来,回到 getProductName() 中,这个方法主要用于根据刚刚 formatIPMIFRU() 的解析结果来确定设备名称:其会按顺序尝试读取解析结果中的 BOARD_PRODUCT_NAMEPRODUCT_PRODUCT_NAME ,遇到有值的则将值作为设备名,如果没有值则将设备名设为 UNKNOWN

设备名会在推上 dbus 时被使用。解析出来的 FRU 会以 xyz.openbmc_project.FruDevice 的类型被推到 /xyz/openbmc_project/FruDevice/<设备名> 路径下。当然,对设备名也有一些针对重名的处理,比如在发生重名时增加数字后缀以避免 dbus throw exception ,这里就略过了。

以下是一个被推上 dbus 的 FRU 数据示例:

xyz.openbmc_project.FruDevice       interface -         -                                        -
.ADDRESS                            property  u         xx                                       emits-change
.BOARD_FRU_VERSION_ID               property  s         "xxx"                                    emits-change
.BOARD_INFO_AM1                     property  s         "xxx"                                    emits-change
.BOARD_INFO_AM2                     property  s         "xxx"                                    emits-change
.BOARD_LANGUAGE_CODE                property  s         "x"                                      emits-change
.BOARD_MANUFACTURER                 property  s         "xxx"                                    emits-change
.BOARD_MANUFACTURE_DATE             property  s         "xxx"                                    emits-change
.BOARD_PART_NUMBER                  property  s         "xxx"                                    emits-change
.BOARD_PRODUCT_NAME                 property  s         "xxx"                                    emits-change
.BOARD_SERIAL_NUMBER                property  s         "xxx"                                    emits-change
.BUS                                property  u         x                                        emits-change
.Common_Format_Version              property  s         "xxx"                                    emits-change
.PRODUCT_ASSET_TAG                  property  s         "xxx"                                    emits-change writable
.PRODUCT_FRU_VERSION_ID             property  s         "xxx"                                    emits-change
.PRODUCT_LANGUAGE_CODE              property  s         "x"                                      emits-change
.PRODUCT_MANUFACTURER               property  s         "xxx"                                    emits-change

(FRU 内容已隐去,只看个格式)

总结

总结: fru-device 负责在没有设备驱动的情况下发现 i2c 总线上所有可能存在的 FRU ,读取解析 FRU 的内容并将其挂到 dbus 上。读取的过程可能有一些约定的依赖性,比如设备必须能够直接响应不带有 command 的 i2c read 。同时,这个读取过程是低效且可能超时的,其默认的超时处理是将超时的总线直接加入黑名单以避免下一次扫描。但是这样的过程提供了非常不错的灵活性,设备所在的总线和地址不再需要写死了,可以在扫描的过程中动态生成。

entity-manager

这个可执行文件负责根据 fru-device 等“探测器”挂在 dbus 上的内容,将本地的 json 与之进行匹配,选出那些存在的设备,再去完整的初始化这些设备(安装驱动),最后,将这些可用的设备所对应的 json 配置进行一个组合,合并形成 system.json 放置在 /var/configuration/system.json 。最后,entity-manager 将这个 json 的条目推上 dbus 供其它服务使用。

其所做的最重要的事情,便是根据先前“探测器”的原始结果,对 json 中的 $bus$address 等变量进行填充,得到真正完整可用的配置文件。

事件驱动架构

嗯,其实上面的 fru-device 采用的也是事件驱动的架构,只不过上面分析的那一点代码并不能很好的体现这一点。
到了 entity-manager 这里,就避不开了。

先来思考一个问题,从上面的总体架构分析中我们已经可以知道,entity-manager 作为“配置管理器”需要从“探测器”处获取原始数据,但是,entity-manager 和如 fru-device 之类的“探测器”之间并没有启动上的依赖关系,那 fru-device 该如何告诉 entity-manager 数据已经 ready 呢?entity-manager 在数据 ready 时又该如何处理呢?

嗯,对于第一个问题,可以直接说 dbus 是它们之间的通信桥梁。
但是对于第二个问题,却有许多千奇百怪的解法。

设想一:entity-manager 不断轮询,从 dbus 处拉取数据,检查“探测器”的原始数据是否 ready —— 性能太差。
设想二:entity-manager 向 dbus 注册一个 listener ,当数据 ready 时 dbus 拉起 entity-manager 让其处理数据 —— 这很高效,这也就是所谓的“事件驱动”。

但是,事件驱动会牵扯到许多问题,比如该由谁来感知事件的到来,又由谁来处理事件?

事件的监听

先来看第一个问题:dbus 该如何在事件到来时拉起 entity-manager ?
对于 dbus 来说,其在 linux 上使用的底层通信方式多半是 unix domain socket ,事件的到来可以被简单抽象为:socket 上有数据、可读。所以监听事件的方式,无非也就是 I/O 模式的那几套:

  • 1、普通的阻塞 I/O 。
  • 2、普通的非阻塞 I/O 。
  • 3、select / poll / epoll 等 I/O 复用机制 。

1 和 3 ,本质上都是在等待事件时阻塞休眠,socket 可读时由内核唤醒进程,从而实现“拉起”。
而 2 ,则是由应用自己负责监控事件的发生,相当于前面的“不断轮询”。

(这里不考虑冷门且复杂的异步 I/O )
(如果你对这些机制感兴趣,这有一篇不错的文章,值得一读)

但是,所有这些事件监听方式都是需要主动去调用的,比如调用一个 read() 然后被阻塞住,接着才能等着事件上门,这意味着 entity-manager 中必须有个调用者。

事件的处理

那第二个问题就来了:该由谁来作为这个调用者,又该由谁来处理事件?
由于主线程肯定有逻辑得跑,因此一种朴素的想法是新开一个线程甚至多个线程专门用来监听和处理事件。
但是,存在多线程就意味着可能存在竞争,存在竞争就要考虑加锁,代码的复杂程度便会往上攀升。

所以,有没有一种办法,能够优雅的解决这个问题呢?

so, entity-manager 采用了一种比较特殊的“主线程任务队列(消息队列)”模式。当然,这其实也不是什么很新的东西了,它与 Android 的 Looper 、Windows 的 message queue 以及 Linux Kernel 中的 singlethread workqueue 在思想上是十分相似的。

用伪代码来说,它们都是类似这样的机制:

run()
{
    while (true) {
        if (队列.获取下一个任务()) {
            执行任务();
        }
    }
}

说白了,存在一个任务队列和一个工作线程,当工作线程调用了 run() 方法后,便会循环尝试从任务队列中取出任务,如果能获取到任务则立刻执行该任务,获取不到任务则休眠(阻塞在 队列.获取下一个任务() 上)。

当有事件到来时,我们只需要将事件对应的处理逻辑作为一个任务塞进任务队列,然后等着它被工作线程执行就行了。
你可能会问,那不还是至少有两个线程吗?一个主线程,一个工作线程,那这能解决啥问题?
但是,有没有一种可能,主线程本身就能作为工作线程,比如:

int main()
{
    // 先把初始化工作做完
    ......
    // 开始循环处理任务队列
    run();
    // 理论上永远不会 return
    return 0;
}

所以,我叫它“主线程任务队列”,因为它真的可以只有一个线程。
这种方式能够使事件得到处理的同时,有效解决竞争问题。
不过需要注意的是,就像中断处理函数一样,你同样不应该在事件处理函数中进行长耗时操作,否则它会影响后续事件的响应;对于需要延时的操作,你应该选择往任务队列里塞一个被延时的新任务(下面再详细看),然后立刻结束当前处理函数,而非原地 sleep() 等待。

但是,上面的问题并没有得到解决:这只是事件的处理过程,那事件的监听过程呢?谁是调用者?是谁在从 socket 读取数据?又是谁来负责把来自 socket 的事件塞进队列里?难道这不需要第二个线程吗?

既监听又处理

这里就该隆重请出 boost::asio 库了,其中的 boost::asio::io_context 就可以被看作一个任务队列,但它却更加高级,因为它集成了 epoll() 等功能,可以绑定并监听文件描述符,并在文件描述符有响应时,将对应的处理函数自动塞进任务队列。
这和使用 socket 通信的 dbus 简直完美契合:我们既可以让它来监听 dbus ,又可以让它来处理来自 dbus 的事件,全程还只需要一个线程,没有任何竞争问题,优雅,实在是太优雅了。

说白了,这个高级的任务队列可以:

  • 处理自己布置的任务,比如一个定时任务。
  • 处理别人布置的任务,就比如来自 dbus 的调用。

想让这个任务队列跑起来,同样需要调用其 run() 函数,比如:

int main()
{
    boost::asio::io_context io;
    // 绑定文件描述符与处理函数
    ......
    // 也可以往里面塞点自己的任务
    ......
    // 开始监听文件描述符,循环处理任务
    io.run();
    // 只要还有任务 / 还监听着就不会走到这里
    return 0;
}

boost::asio 库的实现本身还是相当复杂的,这里也只是利用了其一部分的功能,如果你有兴趣,可以看看这篇文章,似乎还是挺深入的。

此外,我在这里也对 io_context 的工作方式进行了解释,也许能够帮助理解。

初始化

ok,在了解了事件驱动与消息队列后,我们可以开始看 entity-manager 的 main() 函数了:

src/entity_manager.cpp

boost::asio::io_context io;
......
int main()
{
    ......
    systemBus = std::make_shared<sdbusplus::asio::connection>(io);
    ......
    sdbusplus::asio::object_server objServer(systemBus, /*skipManager=*/true);
    ......
    nlohmann::json systemConfiguration = nlohmann::json::object();
    ......
    sdbusplus::bus::match_t interfacesAddedMatch(
        static_cast<sdbusplus::bus_t&>(*systemBus),
        sdbusplus::bus::match::rules::interfacesAdded(),
        [&](sdbusplus::message_t& msg) {
        ......
            propertiesChangedCallback(systemConfiguration, objServer);
        ......
        });
    sdbusplus::bus::match_t interfacesRemovedMatch(
        static_cast<sdbusplus::bus_t&>(*systemBus),
        sdbusplus::bus::match::rules::interfacesRemoved(),
        [&](sdbusplus::message_t& msg) {
        ......
            propertiesChangedCallback(systemConfiguration, objServer);
        ......
        });

    boost::asio::post(io, [&]() {
        propertiesChangedCallback(systemConfiguration, objServer);
    });

    ......

    io.run();

    return 0;
}

在这里,高级的任务队列被以全局变量的方式定义,名为 io

systemBus 是一个与 dbus 的连接,这个连接被绑定到了 io 任务队列上(1),因此与 dbus 之间的交互可以由任务队列负责处理。

objServer 是一个 dbus 对象管理器(2),当 entity-manager 往 dbus 上推送内容时需要用到它。

两个 sdbusplus::bus::match_t 是在向 dbus 注册回调(3),也就是在指定事件发生时调用传入的 lambda 表达式,我们不需要关心这个过程在底层是怎么走的,高级的任务队列已经帮我们处理好了一切:在底层的 socket 收到指定的消息时,我们传入的 lambda 表达式会被自动作为任务加入任务队列,等待执行。
这两个回调分别对应 dbus 上接口增删事件,也就是说,但凡有服务在 dbus 上创建或删除了接口,entity-manager 都能感知到,回调函数也会被调用。在回调函数中,entity-manager 会根据 dbus 上的内容刷新自身,这便是为什么 entity-manager 可以不直接依赖于 fru-device 之类的“探测器”,它们之间是通过这种监听的方式同步数据的。

systemConfiguration 代表最终生成和推送到 dbus 上的配置文件,它的内容就是我们上面所说的 system.json ,之所以它需要在 main() 函数中出现,是因为它需要拥有全局的生命周期(4),从而在不同轮次的扫描之间保留数据,便于进行差分比较(比如,找出之前存在而现在不存在了的设备等)。

boost::asio::post 相当于是我们手动给任务队列中塞进了一个任务,任务的内容就是传入的 lambda 表达式,这个任务要等到 io.run() 之后才会被取出和执行。

最后是 io.run() 的调用,dbus 开始被监听,先前布置的任务也开始被执行。

注释:

  • (1)(2)(3) dbus 与 asio 的集成依赖于 openbmc 实现的 sdbusplus 库,其为 modern c++ 提供了高度的封装,我们只需要进行一些简单的操作就可以使 dbus 与 asio 密切配合,达成所需的效果。
  • (4) 全局的生命周期是指,像全局变量一样,永不析构,由于下面 io.run() 的存在,main() 函数的栈就是永不销毁的,因此其生命周期是全局的,基本等价于全局变量。

刷新

接下来,我们进入 propertiesChangedCallback() ,这是一个非常重要的函数——在 entity-manager 启动时或发生“刷新”时,它都会被调用,它会做以下几件事情,构成了 entity-manager 的核心逻辑:
1、加载 entity-manager 的本地配置文件。
2、根据本地配置文件,扫描和匹配“探测器”推送在 dbus 上的接口。
3、综合本地配置和“探测器”推送在 dbus 上的信息,合成 system.json 配置,代表所有可用的设备/板卡。
4、推送 system.json 到 dbus ,以及一些杂项处理(比如为设备安装驱动、移除先前存在但现在消失了的设备等)。

发生“刷新”是指当 dbus 上的现有接口发生了变化时,比如 fru-device 发现了新的 FRU 设备,那 entity-manager 自然需要为此做出反应,匹配新设备的配置文件并将其整合到 system.json 中。上面的 sdbusplus::bus::match_t 所监控的东西,便是这种“刷新”。目前,entity-manager 还没有聪明到会为那个被刷新的东西单独跑一遍匹配逻辑,因此当“刷新”发生时,上面的 1 2 3 4 步都会被完整的重跑一次。需要注意的是,entity-manager 只会监控接口级别的变化(比如增删),不会监控接口内容的变化,因此当接口只是内容变化却想要触发刷新时,需要先移除接口再重新增加接口才能让 entity-manager 感知到。

我们接下来会一点一点的看上面几步是如何实现的。

src/entity_manager.cpp

// main properties changed entry
void propertiesChangedCallback(nlohmann::json& systemConfiguration,
                               sdbusplus::asio::object_server& objServer)
{
    static boost::asio::steady_timer timer(io);
    ......
    timer.expires_after(std::chrono::seconds(5));

    // setup an async wait as we normally get flooded with new requests
    timer.async_wait([&systemConfiguration, &objServer,
                      count](const boost::system::error_code& ec) {
        ......

        nlohmann::json oldConfiguration = systemConfiguration;
        auto missingConfigurations = std::make_shared<nlohmann::json>();
        *missingConfigurations = systemConfiguration;

        std::list<nlohmann::json> configurations;
        if (!loadConfigurations(configurations))
        {
            std::cerr << "Could not load configurations\n";
            inProgress = false;
            return;
        }

        auto perfScan = std::make_shared<PerformScan>(
            systemConfiguration, *missingConfigurations, configurations,
            objServer,
            [&systemConfiguration, &objServer, count, oldConfiguration,
             missingConfigurations]() {
            // this is something that since ac has been applied to the bmc
            // we saw, and we no longer see it
            bool powerOff = !isPowerOn();
            for (const auto& [name, device] : missingConfigurations->items())
            {
                pruneConfiguration(systemConfiguration, objServer, powerOff,
                                   name, device);
            }

            nlohmann::json newConfiguration = systemConfiguration;

            deriveNewConfiguration(oldConfiguration, newConfiguration);

            for (const auto& [_, device] : newConfiguration.items())
            {
                logDeviceAdded(device);
            }

            inProgress = false;

            boost::asio::post(
                io, std::bind_front(publishNewConfiguration, std::ref(instance),
                                    count, std::ref(timer),
                                    std::ref(systemConfiguration),
                                    newConfiguration, std::ref(objServer)));
        });
        perfScan->run();
    });
}

由于这个函数在每次刷新时都会被调用,而刷新往往不会独立发生,而是一次发生一堆:想象一下如果 fru-device 重启了,那它在重新往 dbus 上推送接口时,每推送一个都会触发一次 entity-manager 的刷新,频繁的刷新会引入大量的开销。

因此,在进入这个函数时,首先做的事情是“消抖”,它是利用一个 static timer 实现的:

  • 进入函数时,使用 timer.expires_after()timer.async_wait() 配置定时任务,也就是 5 秒后再开始执行 async_wait() 中的 lambda 表达式。
  • 如果 5 秒内此方法被再次调用,由于 timer 是 static 的,再次在相同的 timer 上执行 expires_after()导致之前的定时被取消,并配置一个新的定时任务。
  • 于是在 5 秒的消抖窗口时间内(1),无论 propertiesChangedCallback() 被调用了多少次,最后 async_wait() 的 lambda 表达式只会被执行一次。

接下来,其实是一个双层结构,为了把各个变量在这两层之间的状态解释清楚,我们先来打一下这个结构:

    timer.async_wait([&systemConfiguration, &objServer,
                      count](const boost::system::error_code& ec) {
        // 不妨称这里为第一层
        ......
        auto perfScan = std::make_shared<PerformScan>(
            systemConfiguration, *missingConfigurations, configurations,
            objServer,
            [&systemConfiguration, &objServer, count, oldConfiguration,
             missingConfigurations]() {
            // 不妨称这里为第二层
            ......
        });
        perfScan->run();
    });

第一层,是 timer 的回调,也就是 5 秒消抖后要做的事;第二层,是 PerformScan 的回调,也就是在新 system.json 准备好时(但尚未持久化)要做的事。

没错,最关键的,根据本地配置文件,扫描和匹配“探测器”推送在 dbus 上的接口综合本地配置和“探测器”推送在 dbus 上的信息,合成 system.json 配置,代表所有可用的设备/板卡 都是在 PerformScan 的里面进行的,我们稍后再看,先来关注这里的两层。

在这两层中,有一系列的 json 变量:systemConfigurationoldConfigurationmissingConfigurationsconfigurationsnewConfiguration ,梳理了它们,这里的逻辑也就清晰了。

oldConfigurationmissingConfigurationsnewConfiguration 的作用,是做加减法。
systemConfiguration 我们之前提到过,它的生命周期是全局的,可以在不同的扫描轮次之间保留数据
于是,missingConfigurationsnewConfiguration 就是用来体现两轮扫描之间的差值的:

  • missingConfigurations 代表之前有,但是现在没了的设备(配置)。
  • newConfiguration 则代表之前没有,但现在有了的设备(配置)。

接下来,我们来走一遍这个过程:

  • 1、在第一层中, oldConfigurationmissingConfigurations 均被赋值为 systemConfiguration ,也就是上一轮扫描的结果。
  • 2、使用 loadConfigurations() 加载 entity-manager 的本地配置文件到 configurations 中。
  • 3、执行 PerformScan 的内部逻辑(由 perfScan->run() 触发执行),在其内部逻辑中 systemConfiguration 的值会被新一轮扫描的结果取代,missingConfigurations 则会被遍历和 erase() 掉新 systemConfiguration 的内容,从而得到“之前有,但是现在没了的设备(配置)”。
  • 4、在 PerformScan 的内部逻辑执行完毕后(2),第二层会被执行,此时 systemConfiguration 已经变成了本轮扫描的结果,missingConfigurations 也已经得到了正确的值。接下来,newConfiguration 会被赋值为新的 systemConfiguration ,然后传入到 deriveNewConfiguration() ,遍历并移除 oldConfiguration 的内容,从而得到“之前没有,但现在有了的设备(配置)”。

所以,missingConfigurationsnewConfiguration 是结果,systemConfiguration 是扫描轮次间的持久化,剩下的全是中间变量。
missingConfigurations 中的项目会被 pruneConfiguration() 处理,从而完成设备的移除。
newConfiguration 中的项目,则会被丢给 publishNewConfiguration() ,从而完成设备的添加。

注释:

  • (1) 这个窗口是会“动态延长”的,因为 5 秒不是相对于第一次调用而言的,而是相对于最后一次调用而言的,也就是说只有在最后一次调用的 5 秒内没有新的调用,才会执行下一步操作。
  • (2) 第二层是一个 lambda 表达式,它不是在被传给 PerformScan 后马上就会被执行的,而是要等到 PerformScan 的内部逻辑全部执行完毕,在 PerformScan 析构时才会被执行(这一时序保障方式,见下面的“利用 RAII 机制保障异步操作时序”)。

前置知识:利用 RAII 机制保障异步操作时序

在开始分析扫描 dbus 和匹配配置文件的过程前,我们得先巩固一下基础的 c++ 知识,不然接下来的时序逻辑是根本看不懂的。

struct GuardCallback
{
    const std::function<void()> _cb;

    GuardCallback(const std::function<void()>& cb) : _cb(cb)
    {}

    ~GuardCallback()
    {
        _cb();
    }
};

inline void foo()
{
    auto finalCallback = std::make_shared<GuardCallback>([]() {
        // do something 2
    });

    auto callback = [finalCallback](...) {
        // do something 1
    };

    for (xxx)
    {
        // callback 表示异步 dbus 调用的回调
       systemBus->async_method_call(callback, xxx);
    }
}

思考:运行 foo() 时,上面的程序是如何执行的?
其执行逻辑非常的独特。

auto finalCallbackauto callback 都只是变量的定义。callback 是一个可调用对象( lambda 表达式),可以通过 callback() 来调用其内容,也就是说,执行callback() 后调用到的是 do something 1 。finalCallback 则比较特殊,它是一个智能指针,包住了一个GuardCallback 类型的对象,该对象会将构造时传入的 lambda 表达式在析构时调用。也就是说,当 GuardCallback 析构时,do something 2 会被调用。那么智能指针的作用是什么呢?智能指针可以用来控制对象的生命周期,即何时析构并释放内存。上面代码中的 make_shared 创建的是一个 shared_ptr 类型的智能指针,该智能指针采用引用计数的方式来维护对象的生命周期,也就是说,当持有这个智能指针(及其副本)的所有代码块均运行结束时,该智能指针中包裹的对象会被析构。callback 在创建时采用值捕获的方式获得了 finalCallback 的副本,这种行为会增加finalCallback 的引用计数,也就是说,只有在 callback 析构后,才会轮到 finalCallback 的引用计数清零,才会轮到 GuardCallback 析构,才会轮到 do something 2 被调用。下面的 async_method_call() 是一个 dbus 异步方法调用,其发出 dbus 请求,但不会原地阻塞等待结果;当结果到来时,传入的 callback 会被调用,我们可以在 callback 的表达式体中,对 dbus 请求的结果进行处理(结果作为参数传入,上面使用…省略了;处理过程也就是 do something 1 的部分)。格外需要注意的是,async_method_call() 时传入 callback ,是一个值传递在 sdbusplus 中经历了两层完美转发,但最终是一个值传递),这意味着,callback 会被复制到 systemBus 中,并在结果到来、回调结束后析构销毁。这个复制的过程意味着,其值捕获的 finalCallback 也会被复制,于是这会同步反映到智能指针的引用计数上:已经发起、还没结束的异步请求数量越多,finalCallback 的引用计数值也就越高,当异步请求逐个拿到结果后,复制到 systemBus 中的 callback 会逐个析构,值捕获的 finalCallback 会被逐个释放,于是 finalCallback 的引用计数会不断降低,直到归零触发 GuardCallback 的析构,从而造成 do something 2 的调用。当然,引用计数归零还有一个前提,那就是定义在 foo() 中的 callback 也被释放了,不过这是一个很自然的过程,因为在异步请求拿到结果之前,foo() 早应该执行完了。

上面这个过程看起来会有些绕,这里有一个 gif 可以帮助理解(1):

上面的这一整套东西,其实是用来解决一个场景下的时序控制问题的:我发起了一批异步调用,我的下一步操作依赖这批异步调用的结果,我该如何得知这批异步调用已经全部完成?这里的 finalCallback 便是那个要找的东西,它是全部异步调用完成之后的回调,是用来存放“下一步操作”的地方。

在 entity-manager 的业务逻辑中,就存在这样的场景,为了覆盖此类场景,其采用了与这里类似的时序控制方式,这导致 PerformScanPerformProbe 的代码变得很“别扭”,第一次看可能完全无法搞懂,但是,只要把握了这种时序控制方式的精髓,弄懂它们的逻辑应该不成问题,接下来我们再慢慢看。

注释:

  • (1) gif 中的 callback 是顺序消失(被执行)的,实际运行时 callback 不一定是顺序被执行的,其完全取决于远程服务的响应情况,无法预知哪个 callback 是最后被执行的( e.g. 第一个发起的调用的 callback 可能是最后一个被执行的)。

dbus 扫描与配置匹配

总览

接下来,我们开始进入 PerformScan 的内部逻辑。
这一块内容做了下面几件事情:
1、扫描和获取“探测器”推送到 dbus 上的接口。
2、根据 1 获取到的接口内容,匹配和激活本地配置文件。
3、根据 1 获取到的接口内容,完成配置文件中的模板变量替换。
4、合成 system.json

其之所以复杂,是因为存在着“明线”和“暗线”。
“明线”是 perfScan->run() 以及其能够调用到的所有函数。
“暗线”是基于 RAII 的析构时序,说白了就是上面的 finalCallback
“明线”不会直接调用到“暗线”,“暗线”的触发时机完全取决于何时异步调用全部结束。

总的来说,“明线”所做的事情是上面的 1 ,代码几乎全部在 perform_scan.cpp 中。
“暗线”所做的事情则是上面的 2 3 4 ,其中匹配的逻辑主要在 perform_probe.cpp 中,配置生成的逻辑则也在 perform_scan.cpp 中。

之所以会有“明线”和“暗线”,是因为从 dbus 上获取接口信息使用的是一系列的异步调用,需要使用 RAII 进行时序控制,我们马上开始介绍。

明线:dbus 扫描与接口内容获取

我们就从 perfScan->run() 开始看。

汇总目标接口类型

src/perform_scan.cpp

void PerformScan::run()
{
    boost::container::flat_set<std::string> dbusProbeInterfaces;
    std::vector<std::shared_ptr<PerformProbe>> dbusProbePointers;

    for (auto it = _configurations.begin(); it != _configurations.end();)
    {
        // check for poorly formatted fields, probe must be an array
        auto findProbe = it->find("Probe");
        ......
        auto findName = it->find("Name");
        ......
        std::string probeName = *findName;
        ......
        nlohmann::json& recordRef = *it;
        nlohmann::json probeCommand;
        if ((*findProbe).type() != nlohmann::json::value_t::array)
        {
            probeCommand = nlohmann::json::array();
            probeCommand.push_back(*findProbe);
        }
        else
        {
            probeCommand = *findProbe;
        }
        ......
        auto thisRef = shared_from_this();
        auto probePointer = std::make_shared<PerformProbe>(
            recordRef, probeCommand, probeName, thisRef);

        // parse out dbus probes by discarding other probe types, store in a
        // map
        for (const nlohmann::json& probeJson : probeCommand)
        {
            ......
            // syntax requires probe before first open brace
            auto findStart = probe->find('(');
            std::string interface = probe->substr(0, findStart);
            dbusProbeInterfaces.emplace(interface);
            dbusProbePointers.emplace_back(probePointer);
        }
        it++;
    }

    // probe vector stores a shared_ptr to each PerformProbe that cares
    // about a dbus interface
    findDbusObjects(std::move(dbusProbePointers),
                    std::move(dbusProbeInterfaces), shared_from_this());
    ......
}

run() 函数所做的事是简单的,它解析 entity-manager 的配置文件,并从中得到要去 dbus 上寻找的接口类型(接口名)列表。

比如对于这样一个配置文件:

configurations/1ux16_riser.json

[
    {
        "Exposes": [
            ......
        ],
        "Name": "1Ux16 Riser 1",
        "Probe": "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})",
        "Type": "Board",
        "xyz.openbmc_project.Inventory.Decorator.Asset": {
            .....
        }
    },
    ......
]

其中的 Probe 字段代表了何时该激活此配置文件,将这个字段的值翻译过来就是:在 dbus 上存在 xyz.openbmc_project.FruDevice 类型的接口,且接口中的 BOARD_PRODUCT_NAME 属性的属性值符合正则表达式 F1UL16RISER\d ,且接口中的 ADDRESS 属性的属性值等于 80 时,激活此配置文件。

说白了,这是某款 riser 卡的配置文件,Probe 字段就是这款 riser 卡的在位判断方法:只有在 fru-device 扫描到这款 riser 卡特有的 fru 时,才认为该 riser 卡在位。其中,xyz.openbmc_project.FruDevice 接口便是 fru-device 推送到 dbus 上的扫描结果(一个接口代表一个 fru ),entity-manager 在这里要做的事情便是去 dbus 上寻找所有的 xyz.openbmc_project.FruDevice 类型的接口(也就是寻找所有的 fru ),遍历这一堆 fru 并看看有没有符合 Probe 字段中表达式所要求的这种 fru ,如果有,则说明这款 riser 卡在位,如果没有,这款 riser 卡便不在位。

所以,我们在这里做的是第一步:遍历所有的配置文件,看看我们要去 dbus 上寻找哪些类型的接口。
_configurations 便是由所有本地配置文件组成的列表,我们遍历了这个列表,其中:

  • findProbe 代表找到的配置文件中的 Probe 字段(的迭代器)。
  • Probe 有两种格式,可以是字符串,也可以是数组,上面的 if ((*findProbe).type() != nlohmann::json::value_t::array) 是在做这种兼容性处理。
  • 要提取的是 Probe 的属性值,比如 xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80}) ,这个属性值被存储在 probeCommand 中(它是一个数组,当 Probe 是简单字符串时,它是一个单元素数组;当 Probe 本身是个数组时,它存储着 Probe 中的每一个元素)。
  • 接着,我们遍历 probeCommand ,提取 ( 前的内容作为接口名,存储在 dbusProbeInterfaces 中。比如对于 xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80}) ,提取到的就是 xyz.openbmc_project.FruDevice
  • 于是 dbusProbeInterfaces 便是得到的结果:所有配置文件中有用到的接口列表(全部目标接口类型)。我们接下来要去 dbus 上根据这个列表获取接口内容。

你可能会好奇 dbusProbePointers 是干啥的。它是一个装满用于控制时序的智能指针的列表,等价于我们之前介绍的 std::make_shared<GuardCallback> ,它没有实际作用,只被用来追踪下面的异步过程,负责在合适的时机触发 finalCallbackshared_from_this() 也是同理,起时序控制作用,这里就不展开了,后面再看。

异步调用一:获取目标接口列表

我们接着 findDbusObjects() 往下看。

src/perform_scan.cpp

void findDbusObjects(std::vector<std::shared_ptr<PerformProbe>>&& probeVector,
                     boost::container::flat_set<std::string>&& interfaces,
                     const std::shared_ptr<PerformScan>& scan,
                     size_t retries = 5)
{
    ......

    // find all connections in the mapper that expose a specific type
    systemBus->async_method_call(
        [interfaces, probeVector{std::move(probeVector)}, scan,
         retries](boost::system::error_code& ec,
                  const GetSubTreeType& interfaceSubtree) mutable {
        if (ec)
        {
            ......
        }

        processDbusObjects(probeVector, scan, interfaceSubtree);
    },
        "xyz.openbmc_project.ObjectMapper",
        "/xyz/openbmc_project/object_mapper",
        "xyz.openbmc_project.ObjectMapper", "GetSubTree", "/", maxMapperDepth,
        interfaces);

    ......
}

这个方法的逻辑非常简单(1),它异步调用了 object mapper(2) 的 GetSubTree 方法,去获取关于所有目标接口的信息(也就是上一步中解析出的所有配置文件中有用到的接口列表的信息)。

GetSubTree 会返回以下信息:目标接口存在于哪些服务中,目标接口存在于哪些路径下。我们并不能通过这个方法拿到接口的全部属性,这个方法只会告诉我们要去哪里寻找目标接口。

说白了,我问 GetSubTree :该去哪里寻找名为 xyz.openbmc_project.FruDevice 的接口?
GetSubTree 答:可以在 xyz.openbmc_project.FruDevice 服务的 /xyz/openbmc_project/FruDevice/AAA/xyz/openbmc_project/FruDevice/BBB/xyz/openbmc_project/FruDevice/CCC 下找到目标接口。

GetSubTree 的返回结构有点复杂,是一个套娃:

src/perform_scan.cpp

using GetSubTreeType = std::vector<
    std::pair<std::string,
              std::vector<std::pair<std::string, std::vector<std::string>>>>>;

它的内部结构是:vector(pair(接口路径, vector(pair(服务名,接口名))))
其实就是一张由接口路径、服务名、接口名组成的表。我们需要拿着这张表,根据其内容,发起第二步异步调用,那就是 processDbusObjects() 的事情了。

注释:

  • (1) 这里忽略了一些内容,比如 if (ec) 当中其实还有一系列的失败重试逻辑,占用了非常多的行数,但因为它们不是主干情况就不看了。
  • (2) object mapper 的 openbmc 整出来的一个辅助服务,用于查找和关联 dbus 接口,它的文档可以在这里找到。

异步调用二:获取接口内容

接着 processDbusObjects() 往下看。
在上一步,我们只获得了一张关于“该去哪里寻找目标接口”的表。
这一步中,我们将根据这张表来获得各个接口的内容。

src/perform_scan.cpp

void getInterfaces(
    const DBusInterfaceInstance& instance,
    const std::vector<std::shared_ptr<PerformProbe>>& probeVector,
    const std::shared_ptr<PerformScan>& scan, size_t retries = 5)
{
    ......
    systemBus->async_method_call(
        [instance, scan, probeVector, retries](boost::system::error_code& errc,
                                               const DBusInterface& resp) {
        if (errc)
        {
            ......
        }

        scan->dbusProbeObjects[instance.path][instance.interface] = resp;
    },
        instance.busName, instance.path, "org.freedesktop.DBus.Properties",
        "GetAll", instance.interface);
    ......
}
......
static void
    processDbusObjects(std::vector<std::shared_ptr<PerformProbe>>& probeVector,
                       const std::shared_ptr<PerformScan>& scan,
                       const GetSubTreeType& interfaceSubtree)
{
    for (const auto& [path, object] : interfaceSubtree)
    {
        ......
        for (const auto& [busname, ifaces] : object)
        {
            for (const std::string& iface : ifaces)
            {
                ......
                    getInterfaces({busname, path, iface}, probeVector, scan);
                ......
            }
        }
    }
}

processDbusObjects() 所做的事,是遍历这张表,并为其中的每一项调用 getInterfaces()
getInterfaces() (1)会调用目标服务的目标路径下的 org.freedesktop.DBus.Properties 接口的 GetAll(2) 方法,并传入目标接口名,从而得到目标接口的内容,即接口中的全部属性。

GetAll 的结果是一个属性名到属性值的 map ,包含了一个接口中的全部属性:

using DBusValueVariant =
    std::variant<std::string, int64_t, uint64_t, double, int32_t, uint32_t,
                 int16_t, uint16_t, uint8_t, bool, std::vector<uint8_t>>;
using DBusInterface = boost::container::flat_map<std::string, DBusValueVariant>;

GetAll 的异步回调中,会将结果保存到 dbusProbeObjects 里,这东西是个双层 map ,简单来说:

dbusProbeObjects[接口路径][接口名] = 接口的全部属性;

于是,所有本地配置文件中,Probe 所牵扯到的所有接口的所有属性都被暂时存储在了 dbusProbeObjects 里,这些信息将被用于接下来的配置匹配过程。

注释:

明暗切换

刚刚,我们走了一遍明线,明线的尽头在 dbusProbeObjects ,也就是说,将相关接口的全部属性拉取到本地后,明线就结束了,没有下一步了。

首先,来理解一下为什么不能有下一步。

明线的异步调用是有两层的,第一层获取了包含接口信息的列表,第二层则要针对列表中的每一个接口执行异步的属性获取,说白了,就是这样的一个结构:

// 伪代码
foreach (接口列表)
{
    async_method_call([]() {
        // 在回调中把接口属性保存在 dbusProbeObjects 里
    }, "org.freedesktop.DBus.Properties->GetAll");
}

那么问题来了,下一步是配置文件的匹配,匹配过程是依赖完整数据的(1):必须等到这里的异步调用全部完成,且将结果存储在 dbusProbeObjects 里面后,匹配过程才能开始。可是,我该如何知道这批异步调用已经全部完成了?放在 for 循环后面吗?可是这是异步调用,for 循环后面只能代表这批异步调用已经全部开始,不能代表它们已经全部结束。同时,我又不能把下一步放在异步回调当中,因为我完全没法知道哪个异步操作是最后完成的,完全没法知道何时 dbusProbeObjects 已经“完整”了,一切都变得非常棘手。

于是,这里的场景就已经和之前在“利用 RAII 机制保障异步操作时序”中所描述的完全一致了。

也许你会注意到,在这两层异步调用中,const std::vector<std::shared_ptr<PerformProbe>>& probeVector 被全程带着,但又没有被实际使用,那这个看起来没用的参数是不是可以被删掉呢?

绝对不可以!它就是明线与暗线之间切换的关键。

现在,我们把“利用 RAII 机制保障异步操作时序”中描述过的知识带入到这个场景当中:在进行异步调用时,带上(值传递) probeVector 确保了异步调用的完成情况能够反映在智能指针的引用计数中,于是,在所有异步调用全部都完成后,智能指针的引用计数清零,PerformProbe 析构,我们也就掌握了这一关键时序。

PerformProbe 几乎等价于我们的 GuardCallback ,其关键触发动作和我们的 finalCallback 一样,发生在析构函数里:

src/perform_probe.cpp

PerformProbe::~PerformProbe()
{
    FoundDevices foundDevs;
    if (probe(_probeCommand, scan, foundDevs))
    {
        scan->updateSystemConfiguration(recordRef, probeName, foundDevs);
    }
}

看到了吗?这里,暗线开始了,配置匹配的流程开始了。

src/perform_scan.cpp

        auto thisRef = shared_from_this();
        auto probePointer = std::make_shared<PerformProbe>(
            recordRef, probeCommand, probeName, thisRef);

↑ 先前,在创建 PerformProbe 时,传入了 PerformScan 的智能指针,这起到了另一重的时序控制作用:这个智能指针能够通过引用计数反映 PerformProbe 的完成状态,只有在 PerformProbe 已经全部析构结束(完成)后,引用计数才会清零,此时 PerformScan 才会析构(为了确保 system.json 已完整生成),PerformScan 析构时,调用的就是我们之前的“第二层” ↓

        auto perfScan = std::make_shared<PerformScan>(
            systemConfiguration, *missingConfigurations, configurations,
            objServer,
            [&systemConfiguration, &objServer, count, oldConfiguration,
             missingConfigurations]() {
            // 不妨称这里为第二层
            ......
        });
        perfScan->run();

于是,这一切就串了起来:perfScan->run() 发起了一批异步调用,在异步调用全部结束后利用 RAII 触发一批 PerformProbe 的析构,在其中完成配置匹配,并向 system.json 添加内容;全部完成后,PerformScan 的析构被触发,“第二层”被调用,接着就走到了向 dbus 推送 system.json 之类的后续逻辑。

好了,在明白了这一切是如何运行后,可以开始看暗线的代码了。

注释:

  • (1) 至于为什么必须依赖完整数据,不能获取一个匹配一个,那是因为 Probe 的语法还支持条件表达式,比如 ANDOR ,可以结合多个接口的状态来决定是否激活某个配置文件。如果是获取一个匹配一个,那这一特性压根就难以存在。

暗线:配置匹配与 system.json 生成

总览

我们从 PerformProbe 开始。

PerformProbe 是针对配置文件的。我们会为每一份配置文件(1)创建一个 PerformProbe

src/perform_scan.cpp

void PerformScan::run()
{
    ......
    for (auto it = _configurations.begin(); it != _configurations.end();)
    {
        ......
        nlohmann::json& recordRef = *it;
        ......
        auto probePointer = std::make_shared<PerformProbe>(
            recordRef, probeCommand, probeName, thisRef);
        ......
    }
    ......
}

于是,顺理成章的,这个 PerformProbe 也就负责这份配置文件的匹配工作,确切的说,不仅要匹配,还要将配置文件塞进 system.json (如果匹配成功)。

接下来,从暗线开始的 ~PerformProbe() 看起。

src/perform_probe.cpp

PerformProbe::~PerformProbe()
{
    FoundDevices foundDevs;
    if (probe(_probeCommand, scan, foundDevs))
    {
        scan->updateSystemConfiguration(recordRef, probeName, foundDevs);
    }
}

在这里 foundDevs 代表成功匹配上的设备( dbus 接口 ),它是这样一个结构:

src/entity_manager.hpp

struct DBusDeviceDescriptor
{
    DBusInterface interface;
    std::string path;
};

using FoundDevices = std::vector<DBusDeviceDescriptor>;

也就是说,foundDevs 是一个数组,装着成功匹配上的接口所对应路径与接口内容( DBusInterface 就是接口中的属性名到属性值的 map 了)。

probe() 是实际执行匹配的函数,foundDevs 以引用的形式传入,成功匹配上的接口会被塞进 foundDevs 里。
如果有一个或多个匹配成功的接口,则 PerformScanupdateSystemConfiguration() 会被调用,这个函数用于往 system.json 中添加内容,我们稍后再分析。

也就是说,一个 PerformProbe 对象代表一份配置文件的匹配逻辑,其主要做了两件事:
1、匹配配置文件,生成与本配置文件相匹配的接口(设备)列表。
2、如果(至少有一个接口)匹配成功,则调用 PerformScan 的对应方法,将本配置添加到 system.json 中。

接下来,我们分别看看这两个过程。

注释:

  • (1) 配置文件有两种格式,它既可以直接是一个 json object ,也可以是一个 json 数组,数组里装着一个或多个 json object 。一个 json object 就被认为是一份配置文件,即使多个 json object 可以被放在一个物理文件中。将多个 json object 合并到一个物理文件中或拆分到多个物理文件中没有本质上的区别,一个 json object 就是 _configurations 中的一个成员。

配置匹配

这是一块复杂而关键的内容,涉及到 Probe 字段的语法和规则,所以,我不想直接看代码,而是想要先从 Probe 的写法出发。在熟悉了写法之后,理解代码自然而然会变得容易一些。

先前,已经介绍过了 Probe 的最基本写法,比如:

"Probe": "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})"
在 dbus 上存在 xyz.openbmc_project.FruDevice 类型的接口,且接口中的 BOARD_PRODUCT_NAME 属性的属性值符合正则表达式 F1UL16RISER\d ,且接口中的 ADDRESS 属性的属性值等于 80 时,激活此配置文件。

( ↑ 没错它支持正则表达式)

Probe 是可以写成数组的形式的,比如(与上面的写法等价):

"Probe": [
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})"
]

在数组写法下,每一行都是一条命令。我们不妨称上面的这种命令为“匹配命令”。
除“匹配命令”外,还有一些“特殊命令”,它们不代表实际的 dbus 接口匹配,只起到一些控制作用,比如条件连接符 ANDOR

"Probe": [
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d'})",
    "AND",
    "xyz.openbmc_project.FruDevice({'ADDRESS' : 80})"
]

↑ 代表两个条件均成立时,才算配置匹配成功。

"Probe": [
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d'})",
    "OR",
    "xyz.openbmc_project.FruDevice({'ADDRESS' : 80})"
]

↑ 代表两个条件中有一个成立时,就算配置匹配成功。

再来看下一个“特殊命令” FOUND()

"Probe": "FOUND('Bletchley Baseboard')",

↑ 这代表,只有在 Name 属性为 Bletchley Baseboard 的配置文件匹配成功时,才应该匹配激活本配置。
(也就是说,本配置的激活依赖于其它配置的激活成功,描述了配置文件之间的依赖关系)
同样的,FOUND() 也可以和别的东西混合使用,比如:

"Probe": [
    "FOUND('Bletchley Baseboard')",
    "AND",
    "xyz.openbmc_project.Inventory.Decorator.Asset({'Model': 'Bletchley_FPB_SI7021'})"
],

还要一种“特殊命令”叫 MATCH_ONE ,代表即使匹配上了多个接口(设备),也只取最后一个:

"Probe": [
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})",
    "MATCH_ONE"
],

↑ 比如对于这个配置,即使有多个接口满足第一行所描述的条件,最终也只会将最后一个匹配上的设备加入到 system.json 中。

所有这一系列语法,都可以灵活混合使用,比如:

"Probe": [
    "FOUND('WFP Baseboard')",
    "AND",
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d'})",
    "AND",
    "xyz.openbmc_project.FruDevice({'PRODUCT_PRODUCT_NAME': '.*WFT'})",
    "MATCH_ONE"
],

此外,还有偏调试向的“特殊命令”:TRUEFALSE ,代表无条件的真和假,在实际配置文件中它们基本不会出现。
用法也很简单,比如:

"Probe": "TRUE"

↑ 代表无条件的加载此配置文件。

接下来,进入代码分析,从匹配开始的 probe() 函数开始看。

函数很长,但是我一行也不想省略,因为都很关键,于是就把解释写成注释了:

src/perform_probe.cpp

// probeCommand 是由 Probe 字段中的一行一行构成的数组。
// foundDevs 是一个“ out 变量”,得把匹配成功的结果塞进去。
bool probe(const std::vector<std::string>& probeCommand,
           const std::shared_ptr<PerformScan>& scan, FoundDevices& foundDevs)
{
    // 正则表达式,用于解析 Probe 字段。
    // 以括号为匹配标准,match[1] 会捕获括号里面的内容。
    // 比如对于 FOUND('Bletchley Baseboard')
    // 匹配成功且
    // match[0] = FOUND('Bletchley Baseboard')
    // match[1] = 'Bletchley Baseboard'
    const static std::regex command(R"(\((.*)\))");
    std::smatch match;
    // 代表总匹配结果,即本配置是否匹配成功,要被添加到 system.json 中。
    bool ret = false;
    // 是否激活了 MATCH_ONE ,即是否只取最后一个匹配成功的设备。
    bool matchOne = false;
    // 代表本条件的匹配结果,由于 Probe 可能由好几行,有很多条件,
    // 这代表其中一个条件是否匹配成功。
    bool cur = true;
    // 用于临时存储“特殊命令”,比如 AND 和 OR ,在遇到下一个“匹配命令”时,
    // 本命令的匹配结果将根据“特殊命令”,与先前的结果取 && 或 || 。
    probe_type_codes lastCommand = probe_type_codes::FALSE_T;
    bool first = true;

    // 开始一行一行的遍历 Probe 。
    for (const auto& probe : probeCommand)
    {
        // 解析 Probe 中的“特殊命令”,包括 AND OR FOUND MATCH_ONE TRUE FALSE 。
        FoundProbeTypeT probeType = findProbeType(probe);
        if (probeType)
        {
            // 处理“特殊命令”。
            switch ((*probeType)->second)
            {
                // 简单,直接标记本条件的匹配结果。
                case probe_type_codes::FALSE_T:
                {
                    cur = false;
                    break;
                }
                // 简单,直接标记本条件的匹配结果。
                case probe_type_codes::TRUE_T:
                {
                    cur = true;
                    break;
                }
                // 标记本条件的匹配结果,并置位 matchOne 。
                case probe_type_codes::MATCH_ONE:
                {
                    // 为了避免影响结果,本条件的匹配
                    // 结果被标记为和输出结果相同。
                    cur = ret;
                    matchOne = true;
                    break;
                }
                // 衔接性的“特殊命令”,不在此处处理。
                /*case probe_type_codes::AND:
                  break;
                case probe_type_codes::OR:
                  break;
                  // these are no-ops until the last command switch
                  */
                case probe_type_codes::FOUND:
                {
                    if (!std::regex_search(probe, match, command))
                    {
                        std::cerr << "found probe syntax error " << probe
                                  << "\n";
                        return false;
                    }
                    // 利用正则表达式取出括号内的内容。
                    std::string commandStr = *(match.begin() + 1);
                    // 替换掉引号,得到纯粹的名词。
                    boost::replace_all(commandStr, "'", "");
                    // 在已匹配成功的列表中寻找目标,并作为本条件的结果。
                    cur = (std::find(scan->passedProbes.begin(),
                                     scan->passedProbes.end(),
                                     commandStr) != scan->passedProbes.end());
                    break;
                }
                default:
                {
                    break;
                }
            }
        }
        // 不是“特殊命令”,而是正宗的“匹配命令”
        else
        {
            if (!std::regex_search(probe, match, command))
            {
                std::cerr << "dbus probe syntax error " << probe << "\n";
                return false;
            }
            // 通过正则表达式,取出括号内内容。
            // 比如从 xyz.openbmc_project.FruDevice({'ADDRESS' : 80})
            // 里面取出 {'ADDRESS' : 80} 。
            std::string commandStr = *(match.begin() + 1);
            // 进行简单的字符串替换,将括号内的内容变为合格的 json 格式。
            boost::replace_all(commandStr, "'", "\"");
            boost::replace_all(commandStr, R"(\)", R"(\\)");
            // 将括号内的内容解析为 json 对象。
            auto json = nlohmann::json::parse(commandStr, nullptr, false);
            if (json.is_discarded())
            {
                std::cerr << "dbus command syntax error " << commandStr << "\n";
                return false;
            }
            // 将 json 对象转换为一个 map ,比如 ADDRESS -> 80 。
            // 当然,由于 value 可以是多种类型(数字/布尔/字符串...)的,
            // 所以 map 的值的类型仍然是 json 。
            std::map<std::string, nlohmann::json> dbusProbeMap =
                json.get<std::map<std::string, nlohmann::json>>();
            // 根据第一个括号的位置,取得接口名,比如从
            // xyz.openbmc_project.FruDevice({'ADDRESS' : 80}) 里面
            // 拿到 xyz.openbmc_project.FruDevice ,接口名被存储在
            // probeInterface 中,将被用于和我们之前拉取的 dbus 接口进行
            // 比较和匹配。
            auto findStart = probe.find('(');
            if (findStart == std::string::npos)
            {
                return false;
            }
            // 没啥用的变量,即使被扔进了 probeDbus() 也还是没啥用,可以删了。
            bool foundProbe = !!probeType;
            // 存储接口名。
            std::string probeInterface = probe.substr(0, findStart);
            // 实际执行匹配过程,也就是根据之前拉取的 dbus 对象,在其中查找目标接口,
            // 找到了就根据刚刚生成的 dbusProbeMap 匹配接口中的属性,匹配成功就把
            // 接口加到 foundDevs 中。
            // 返回值代表是否有接口匹配成功。
            cur = probeDbus(probeInterface, dbusProbeMap, foundDevs, scan,
                            foundProbe);
        }

        // 在这里处理 AND 和 OR 。
        if (lastCommand == probe_type_codes::AND)
        {
            // 与之前的结果。
            ret = cur && ret;
        }
        else if (lastCommand == probe_type_codes::OR)
        {
            // 或之前的结果。
            ret = cur || ret;
        }

        if (first)
        {
            ret = cur;
            first = false;
        }
        // 如果本命令是 AND 或 OR 的话,临时保存一下,下一轮再用。
        lastCommand = probeType ? (*probeType)->second
                                : probe_type_codes::FALSE_T;
    }

    // 处理特殊情况,比如 Probe 里只有一个 TRUE 。
    // 此时虽然匹配成功但是没有找到任何设备,会塞一个空接口进去占位。
    if (ret && foundDevs.empty())
    {
        foundDevs.emplace_back(
            boost::container::flat_map<std::string, DBusValueVariant>{},
            std::string{});
    }
    // 处理 matchOne 。
    if (matchOne && ret)
    {
        // match the last one
        auto last = foundDevs.back();
        foundDevs.clear();

        foundDevs.emplace_back(std::move(last));
    }
    return ret;
}

简单来说,它就是遍历了一遍配置文件的 Probe 字段:

  • 对于“匹配命令”,调用 probeDbus() ,根据之前拉取的 dbus 对象执行匹配。
  • 对于“特殊命令”,处理好上下逻辑衔接。
  • 找到的匹配成功的 dbus 对象会被塞进 foundDevs ,以 out 变量(1)的形式向上层返回。

接下来来看 probeDbus()

src/perform_probe.cpp

bool probeDbus(const std::string& interfaceName,
               const std::map<std::string, nlohmann::json>& matches,
               FoundDevices& devices, const std::shared_ptr<PerformScan>& scan,
               bool& foundProbe)
{
    bool foundMatch = false;
    ......

    for (const auto& [path, interfaces] : scan->dbusProbeObjects)
    {
        auto it = interfaces.find(interfaceName);
        if (it == interfaces.end())
        {
            continue;
        }
        ......

        bool deviceMatches = true;
        const DBusInterface& interface = it->second;

        for (const auto& [matchProp, matchJSON] : matches)
        {
            auto deviceValue = interface.find(matchProp);
            if (deviceValue != interface.end())
            {
                deviceMatches = deviceMatches &&
                                matchProbe(matchJSON, deviceValue->second);
            }
            else
            {
                // Move on to the next DBus path
                deviceMatches = false;
                break;
            }
        }
        if (deviceMatches)
        {
            ......
            devices.emplace_back(interface, path);
            foundMatch = true;
        }
    }
    return foundMatch;
}

这个函数的逻辑是非常简单的,它由两层循环组成。

第一层循环遍历了 scan->dbusProbeObjects ,它就是我们在前几节中提到的:

于是,所有本地配置文件中,Probe 所牵扯到的所有接口的所有属性都被暂时存储在了 dbusProbeObjects 里,这些信息将被用于接下来的配置匹配过程。

嗯,之前从 dbus 上拉取的信息就是在这里被使用的。

在第一层循环中,它在 dbusProbeObjects 里寻找与本“匹配命令”的接口名相符合的接口。
如果找到了,则进入第二层循环。
第二层循环中,它会遍历由本匹配命令生成的 map ,调用 matchProbe() 逐个对比 map 中的条目与 dbus 接口的内容,只有在本匹配命令中的每一个属性都与 dbus 接口的内容相符时,deviceMatches 才会为 true ,此时这个匹配成功的接口会被塞到 devices ,这个 devices 本质上就是上层 foundDevs 的引用,于是一个匹配成功的接口就被这么返回了出去。

matchProbe() 就不深入分析了,里面不过是类型解析和正则匹配之类的东西,把它理解为一个支持正则的 == 就好了。

好了,到这里,配置匹配的流程就分析完毕了,也许这看起来很清晰,但实际上这个匹配流程充满了 edge case ,如果应用到实际生产环境会遇到不少问题,我们在后面的“问题”一节中再聊。

注释:

  • (1) out 变量是 C# 里的说法,这里是指 foundDevs 是以引用形式传入的,被作为函数的第二个返回值。

system.json 生成

src/perform_probe.cpp

PerformProbe::~PerformProbe()
{
    FoundDevices foundDevs;
    if (probe(_probeCommand, scan, foundDevs))
    {
        scan->updateSystemConfiguration(recordRef, probeName, foundDevs);
    }
}

每一份配置文件对应一个 PerformProbe 对象,每一个 PerformProbe 在配置匹配成功后都会调用 PerformScanupdateSystemConfiguration() 将配置放入 system.json 中。

当然,这里的“放入 system.json 中”并不是指写入到 system.json 文件里,此时的 system.json 还只是内存里的一个变量,写入文件是很后面的事了。

在开始之前,先来介绍一下 system.json 的结构。

{
    "xxxxxx": {
        "Exposes": [
            ......
        ],
        "Name": "......",
        "Probe": "......",
        ......
    },
    "xxxxxx": {
        "Exposes": [
            ......
        ],
        "Name": "......",
        "Probe": "......",
        ......
    },
    "xxxxxx": {
        "Exposes": [
            ......
        ],
        "Name": "......",
        "Probe": "......",
        ......
    },
    ......
}

system.json 是所有匹配成功的配置文件的合集,代表着 entity-manager 在 dbus 上能找到的所有设备,其整体是一个大 json object ,里面放着许多子 json object ,每一个子 json object 代表着一份被激活的配置文件。
你可能会注意到,每个子 json object 对应着一个 key ,也就是上面的 xxxxxx ,这个 key 是一个 checksum ,用于在刷新/增删设备时快速区分已有设备。

system.json 的内容是完整的。我是指,在原始配置文件中,可能会有诸如 $address$bus 这样的模板变量 ↓

configurations/1ux16_riser.json

    {
        "Exposes": [
            {
                "Address": "$address",
                "Bus": "$bus",
                "Name": "Riser 1 Fru",
                "Type": "EEPROM"
            },
            ......
        ],
        "Name": "1Ux16 Riser 1",
        "Probe": [
            "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})"
        ],
        "Type": "Board",
        ......
    }

在最终生成的 system.json 中,此类变量会被实际值取代。

所以,我们大体可以总结出 updateSystemConfiguration() 要做的几件事:
1、为配置文件计算 checksum 。
2、完成模板变量到实际值的替换。
3、将配置文件以子 json object 的形式增加到 system.json 中。

接下来看代码。

void PerformScan::updateSystemConfiguration(const nlohmann::json& recordRef,
                                            const std::string& probeName,
                                            FoundDevices& foundDevices)
{
    ......
    // 这里主要是针对配置文件中 Name 字段的处理,这个字段会决定后续推送到
    // dbus 上时,接口的名称。因此,Name 不可重复。
    // 在某些场景下,一份配置文件可能匹配到多个设备,造成其被多次添加到
    // system.json 中,此时 Name 就会出现重复。
    // 这里的解决方案是,可以在 Name 中使用模板变量 $index ,比如
    // Name: "Test $index" 。$index 会由代码自动替换,从而确保 Name 的
    // 唯一性。
    // 这个变量用于记录已使用过的名称(如果在 Name 中使用了模板变量的话)。
    std::set<nlohmann::json> usedNames;
    // 这个变量用于记录可用的 $index 值。
    std::list<size_t> indexes(foundDevices.size());
    // 默认为每一个设备生成一个 $index ,确保不会发生重复。
    // 这里的“每一个设备”是指这个配置文件匹配到的每一个设备。
    std::iota(indexes.begin(), indexes.end(), 1);

    // 非主干逻辑,省略。
    // 其主要针对 entity-manager / BMC 重启后的用户修改保留。
    // 因为 entity-manager 推送到 dbus 上的部分内容是可以通过 set-property
    // 修改的,这些修改会反映到持久化的 system.json 文件中。这里的逻辑主要
    // 用于确保这些修改不会因为重生成 system.json 而被丢弃。
    ......

    // 后续模板变量替换时的中间变量。
    std::optional<std::string> replaceStr;

    ......

    // 开始遍历匹配的接口(设备),为每一个设备生成一份专有配置文件并
    // 添加到 system.json 中。比如,一份配置文件同时在三个设备上发生
    // 了匹配,那这份配置文件最终会在 system.json 中出现三份。
    for (const auto& [foundDevice, path] : foundDevices)
    {
        // 这是一个意义不明的地方,我觉得是屎山的坍塌,见“问题”小节。
        // 下面这两行完全可以用一行
        // const DBusObject dbusObject = {{path, foundDevice}};
        // 进行代替。
        // 但不管怎么样,dbusObject 包含了此设备的信息( dbus 接口
        // 的内容),会在后续的模板替换过程中被使用。
        // 后续的模板替换过程完全也可以直接使用 foundDevice 进行,
        // 完全搞不懂为什么这里要去 dbusProbeObjects 里兜一圈。
        auto objectIt = dbusProbeObjects.find(path);
        const DBusObject& dbusObject = (objectIt == dbusProbeObjects.end())
                                           ? emptyObject
                                           : objectIt->second;

        nlohmann::json record = recordRef;
        // recordName 是一个 checksum (也就是 system.json 中的 key ),
        // 其根据配置文件的 Name 字段和匹配上的接口内容计算,因此,相同配置
        // 匹配上多个不同接口时 checksum 是不会重复的,不同配置匹配上相同
        // 接口时也不会重复。
        std::string recordName = getRecordName(foundDevice, probeName);
        // 掏出一个未使用的 $index 值,这是本设备的特有 $index 。
        size_t foundDeviceIdx = indexes.front();
        indexes.pop_front();

        ......

        // 生成最终的 Name 字段。在 Name 字段中存在如 $index 之类的模板变量时,
        // 这个函数会执行替换操作,确保“同一份配置匹配多个设备”的情况下不会出现
        // 名称重复。当然,如果 Name 字段没有使用模板变量,这个函数实际上是
        // no-op 的。
        std::string deviceName = generateDeviceName(
            usedNames, dbusObject, foundDeviceIdx, getName.value(), replaceStr);
        getName.value() = deviceName;
        // usedNames 主要用于二次去重,因为模板变量是支持数学运算的,它怕运算
        // 之后把名称搞重了,于是用 usedNames 记录已使用过的名称。这个二次去重
        // 的过程见 generateDeviceName() 的内部实现。
        usedNames.insert(deviceName);

        // 对配置文件中的每一行进行模板变量替换,形成完整配置。
        // 模板替换所做的事是:将模板变量用匹配的接口中的同名(不区分大小写)属性
        // 进行替换。(当然,$index 除外,那是个特例)
        for (auto keyPair = record.begin(); keyPair != record.end(); keyPair++)
        {
            // 因为 Name 已经被 generateDeviceName() 操作过了,没必要再搞一次。
            if (keyPair.key() != "Name")
            {
                templateCharReplace(keyPair, dbusObject, foundDeviceIdx,
                                    replaceStr);
            }
        }

        // 这里还有针对 expose action 的处理,比如 DisableNode 和 Bindxxx 属性。
        // 不是很主干的东西就略过了。
        ......

        // 将完整配置添加到 system.json 中。
        _systemConfiguration[recordName] = record;
        // 维护 missingConfigurations ,见之前在“刷新”一节中描述的。
        _missingConfigurations.erase(recordName);
    }
}

这个函数也是又臭又长,于是省略掉了其中不关键的部分。

它最重要的工作,便是调用 templateCharReplace() 进行模板变量替换,在替换完成后将完整的配置文件塞进 system.json 中。
模板变量替换所做的事,是将模板变量用所匹配的 dbus 接口中的同名属性进行替换。
比如,如果我们 Probe 的是 xyz.openbmc_project.FruDevice 接口,这个接口中有这些属性:

xyz.openbmc_project.FruDevice       interface -         -                                        -
.ADDRESS                            property  u         xx                                       emits-change
.BOARD_FRU_VERSION_ID               property  s         "xxx"                                    emits-change
.BOARD_INFO_AM1                     property  s         "xxx"                                    emits-change
......

那么,我们就可以在配置文件中使用类似 $ADDRESS$BOARD_FRU_VERSION_ID$BOARD_INFO_AM1 这样的变量来引用 dbus 上的属性内容。这个引用过程是不区分大小写的,$ADDRESS$address 是等价的。

于是,这就回答了文章开头的问题,$address$bus 就是在此时被填入值的,它们的值来自于 dbus ,来自于“探测器”,比如 fru-device 的扫描结果。

对于 system.json 的生成过程,另一个需要掌握的关键点是,同一份配置文件可能匹配多个不同设备,并在 system.json 中产生多个结果,上面的代码分析中也多次提到了这种情况。这种情况其实很好理解,毕竟这是在不同接口上的匹配,模板变量所引用到的值可能是不一样的,那最终产生的配置份数自然而然会与实际匹配的设备数量相等。

templateCharReplace() 就不展开分析了,它是真的更臭更长,但是可以简单讲讲它的函数签名。

std::optional<std::string> templateCharReplace(
    nlohmann::json::iterator& keyPair, const DBusInterface& interface,
    size_t index, const std::optional<std::string>& replaceStr = std::nullopt);
  • keyPair 是一个 json 迭代器。这个迭代器可以指向值属性,也可以指向子 object ,还可以指向子 array 。
    对于值属性,它执行模板变量替换。
    对于 object 和 array ,它会递归的对它们当中的所有属性执行模板变量替换。
  • interface 存储着来自 dbus 接口的信息,它是模板变量替换的信息源。
  • index 是一个特殊处理,专门指 $index 模板变量的值。
  • replaceStr 也是一个特殊处理,是指要把什么东西额外替换成 index
  • templateCharReplace() 是支持数学运算的,比如 $index + 1$index * 2 这种,具体可以去看 test/test_entity-manager.cpp ,里面有一系列的测试用例。只有在发生数学运算时,templateCharReplace() 才会有非空返回值,返回的是数学运算的结果,其它时候它永远返回 std::nulloptreplaceStr 和返回值的唯一作用是服务于 generateDeviceName() 也就是生成设备名的时候。由于数学运算可能导致设备名出现重复,因此它需要借助返回值进行 double check ,同时利用 replaceStr 替换掉意外重复的设备名。

templateCharReplace() 还有另一个重载:

std::optional<std::string> templateCharReplace(
    nlohmann::json::iterator& keyPair, const DBusObject& object, size_t index,
    const std::optional<std::string>& replaceStr = std::nullopt);

这个重载本质上就是遍历 object 并为其中的每一个 interface 调用上一个重载。
这个重载其实没有任何存在的意义,它只服务于上面我在注释中说的“意义不明”的代码,我会在后面的“问题”小节再来谈论这个东西。

另外,templateCharReplace() 还自带了类型转换功能,纯数字的字符串在被 replace 之后可能会从 string 变成 number ,0x 开头的十六进制数 string 也会被贴心的转为 number ,这在实际开发过程中可能带来一些麻烦。

细节补充:配置的多轮匹配

还记得上面的“特殊命令” FOUND() 吗?
试想,A 配置文件依赖于 B 配置文件,但是假如 A 配置文件先于 B 配置加载,会发生什么?
在 A 配置文件匹配时,B 配置文件自然还没匹配过,此时 FOUND() 一定不会成功。
难道,这种情况下 A 就永远无法匹配成功吗?

PerformScan 对这种情况是做了特殊处理的,它会利用多轮匹配来解开这个依赖关系。
直观理解是:第一轮 FOUND() 不成功,那我就再匹配一轮,这下 B 已经匹配上了,那依赖于 B 的 A 也就能够加载了。

在代码中,passedProbes 用于记录哪些配置文件已经匹配成功,它会在不同的匹配轮次之间传递,_passed 则标志本轮是否有新增的成功匹配。只要本轮还有新增的匹配,就意味着依赖关系还没有解完全,下一轮仍可能出现新增的匹配。

于是,PerformScan 会一轮一轮的持续运行,直到没有新增匹配项的出现:

src/perform_scan.cpp

PerformScan::~PerformScan()
{
    if (_passed)
    {
        auto nextScan = std::make_shared<PerformScan>(
            _systemConfiguration, _missingConfigurations, _configurations,
            objServer, std::move(_callback));
        nextScan->passedProbes = std::move(passedProbes);
        nextScan->dbusProbeObjects = std::move(dbusProbeObjects);
        nextScan->run();
        ......
    }
    else
    {
        _callback();
        ......
    }
}

当然,dbusProbeObjects 也会在轮次之间复用,避免频繁从 dbus 上拉取数据。

装驱动

好了,上面的流程跑完后,就回到了这个 lambda 表达式中:

auto perfScan = std::make_shared<PerformScan>(
    systemConfiguration, *missingConfigurations, configurations,
    objServer,
    [&systemConfiguration, &objServer, count, oldConfiguration,
        missingConfigurations]() {
    ......
    boost::asio::post(
        io, std::bind_front(publishNewConfiguration, std::ref(instance),
                            count, std::ref(timer),
                            std::ref(systemConfiguration),
                            newConfiguration, std::ref(objServer)));
});
perfScan->run();

下一步,便是 publishNewConfiguration()

static void publishNewConfiguration(
    const size_t& instance, const size_t count,
    boost::asio::steady_timer& timer, nlohmann::json& systemConfiguration,
    ......
    const nlohmann::json newConfiguration,
    sdbusplus::asio::object_server& objServer)
{
    loadOverlays(newConfiguration);

    boost::asio::post(io, [systemConfiguration]() {
        if (!writeJsonFiles(systemConfiguration))
        {
            std::cerr << "Error writing json files\n";
        }
    });

    boost::asio::post(io, [&instance, count, &timer, newConfiguration,
                           &systemConfiguration, &objServer]() {
        postToDbus(newConfiguration, systemConfiguration, objServer);
        if (count == instance)
        {
            startRemovedTimer(timer, systemConfiguration);
        }
    });
}

看起来,就该往 dbus 上推送完整配置了,但 postToDbus() 才是推送配置的流程,在这之前还会做两件事,一件是 writeJsonFiles()system.json 写到文件当中,另一件事便是装驱动。

还记得配置文件中的 Exposes 字段吗?之前没有介绍过它的作用。
这个字段下放有一个 json object 数组,代表本设备(板卡)具有的功能。
简单来说,比如这张板卡上有个温度传感器,那在这张板卡的配置文件匹配激活后,就得告诉大家我有温度传感器。
于是在后面 Exposes 的内容也会被推送到 dbus 上,向大家展示本设备拥有的功能。
但是,在这之前得有一点预处理,便是为这个传感器安装驱动。

装驱动的过程位于 loadOverlays() -> exportDevice()

还是先不看代码,先来看配置文件的格式。

configurations/1ux16_riser.json

    {
        "Exposes": [
            {
                "Address": "$address",
                "Bus": "$bus",
                "Name": "Riser 1 Fru",
                "Type": "EEPROM"
            },
            {
                "Address": "0x72",
                "Bus": "$bus",
                ......
                "Name": "Riser 1 Mux",
                "Type": "PCA9545Mux"
            },
            {
                "Address": "0x48",
                "Bus": "$bus",
                "Name": "Riser 1 Temp",
                ......
                "Type": "TMP75"
            }
        ],
        "Name": "1Ux16 Riser 1",
        "Probe": "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})",
        "Type": "Board",
        ......
    }

装驱动是只针对 i2c 的,Exposes 中的每一项都代表了一个设备功能,其中的 Type 则指明了需要何种类型的驱动,而 AddressBus 指定了该往哪条 i2c 总线上的哪个地址安装驱动。

在之前的过程中,entity-manager 已经将 $address$bus 替换为了 xyz.openbmc_project.FruDevice 接口中对应的地址和总线。
在这里,首先思考一个问题,接口中的地址和总线是谁的?别忘了,它们代表着 fru-device 在哪里找到了 FRU ,也就是说,这里的 $address$bus 实际被替换为了当时 fru-device 探测到的,FRU 所在的 EEPROM 的地址和总线!

所以,这配置实际上在说:

  • 在之前找到 FRU 的地方装个 EEPROM 驱动。
  • 在之前找到 FRU 的那条总线的 0x72 地址装个 PCA9545Mux 驱动。
  • 在之前找到 FRU 的那条总线的 0x48 地址装个 TMP75 驱动。

接下来看看代码。

device.hpp 中有这样一张表,描述了对于每一个 Type 要选择哪个驱动,要如何初始化设备:

src/devices.hpp

const boost::container::flat_map<const char*, ExportTemplate, CmpStr>
    exportTemplates{
        ......
         {"EEPROM",
          ExportTemplate("eeprom $Address", "/sys/bus/i2c/devices/i2c-$Bus",
                         "new_device", "delete_device",
                         createsHWMon::noHWMonDir)},
        ......
         {"PCA9545Mux",
          ExportTemplate("pca9545 $Address", "/sys/bus/i2c/devices/i2c-$Bus",
                         "new_device", "delete_device",
                         createsHWMon::noHWMonDir)},
         ......}};

安装驱动的过程是非常简单的,它只是普通的把表中的参数写入了对应的内核节点,代码如下。

src/overlay.cpp

static int createDevice(const std::string& busPath,
                        const std::string& parameters,
                        const std::string& constructor)
{
    std::filesystem::path deviceConstructor(busPath);
    deviceConstructor /= constructor;
    std::ofstream deviceFile(deviceConstructor);
    ......
    deviceFile << parameters;
    deviceFile.close();

    return 0;
}

PCA9545Mux 为例,其进行的操作等价于 echo "pca9545 $Address" > /sys/bus/i2c/devices/i2c-$Bus/new_device ,当然命令中的模板变量会被替换掉,替换规则是对应 expose object 中的同名属性,比如 $Address 就用 Address 属性的值替换,那取到的就是 0x72 ,替换过程具体见 exportDevice() ,这里就不分析了。你可能会问,如果属性是 "Address": "$address" ,那填入的会是 $address 吗?不会的,因为对 $address 的替换是在上个阶段完成的,运行到这个阶段时 $address 已经被 dbus 上的实际值取代了(除非上个阶段的替换没有成功)。

安装驱动能让内核为我们提供更多的功能,例如 pca9545 是一个四路的 i2c 选通开关,安装驱动能使我们获得四条新的 i2c 逻辑总线,每条总线对应 9545 下的一路,于是我们就能够更方便的访问挂在 9545 下的器件。
同理,在安装 EEPROM 驱动后,对应的 i2c 设备路径 (/sys/bus/i2c/devices/<bus>-<address>)下便会出现一个名为 eeprom 的文件,我们可以通过这个文件直接读取 EEPROM 的内容,而无需再通过 i2c 命令“硬读”(这便是之前介绍 fru-device 时所谓“已经安装了设备驱动的设备”)。

也许你会问,为什么 TMP75 温度传感器没有在上面的设备表里?
嗯,因为温度传感器的驱动现在是由别的模块管理的,见这个提交。也就是说,entity-manager 只会对设备表里 Type 有涉及的设备安装驱动,对于 Type 未在设备表里出现的 expose object ,它会直接忽略(只是忽略装驱动,在后面仍然会推到 dbus 上)。驱动也并不全是由 entity-manager 管理的,别的模块也可能会根据这个 Type 自己装驱动。

配置推送

接下来就是将 system.json 推上 dbus 的过程了,也是 entity-manager 主流程的最后一步。

推上 dbus 的主流程位于 postToDbus() ,这个函数会根据配置文件的结构在 dbus 上创建接口,再调用 populateInterfaceFromJson() 为接口填充属性,其主要代码如下。

void postToDbus(const nlohmann::json& newConfiguration,
                nlohmann::json& systemConfiguration,
                sdbusplus::asio::object_server& objServer)

{
    ......
    // 针对每个“新设备”执行推送。
    // 不会出现老设备数据变化却漏推送的情况,因为数据变化必然导致 checksum 改变,
    // 老设备会自己变成新设备。
    for (const auto& [boardId, boardConfig] : newConfiguration.items())
    {
        ......
        std::string boardPath = "/xyz/openbmc_project/inventory/system/";
        boardPath += boardtypeLower;
        boardPath += "/";
        boardPath += boardName;

        // 在 /xyz/openbmc_project/inventory/system/<type>/<name> 下创建
        // xyz.openbmc_project.Inventory.Item 接口。这个接口不会被实际创建,
        // 因为它没有通过 populateInterfaceFromJson() 进行初始化,可能是遗
        // 留的 bug 。
        std::shared_ptr<sdbusplus::asio::dbus_interface> inventoryIface =
            createInterface(objServer, boardPath,
                            "xyz.openbmc_project.Inventory.Item", boardName);

        // 在 /xyz/openbmc_project/inventory/system/<type>/<name> 下创建
        // xyz.openbmc_project.Inventory.Item.<type> 接口。
        std::shared_ptr<sdbusplus::asio::dbus_interface> boardIface =
            createInterface(objServer, boardPath,
                            "xyz.openbmc_project.Inventory.Item." + boardType,
                            boardNameOrig);

        // 在 /xyz/openbmc_project/inventory/system/<type>/<name> 下创建
        // xyz.openbmc_project.AddObject 接口,并在其中加入 AddObject 方法。
        // 这个接口用于动态添加 Exposes 中的 object 。
        createAddObjectMethod(jsonPointerPath, boardPath, systemConfiguration,
                              objServer, boardNameOrig);

        // 为 xyz.openbmc_project.Inventory.Item.<type> 接口填充内容。
        populateInterfaceFromJson(systemConfiguration, jsonPointerPath,
                                  boardIface, boardValues, objServer);
        ......
        // 推送配置中与 Probe 同级的其它 json object 作为接口。
        for (const auto& [propName, propValue] : boardValues.items())
        {
            if (propValue.type() == nlohmann::json::value_t::object)
            {
                // 在 /xyz/openbmc_project/inventory/system/<type>/<name>
                // 下创建与 propName 同名的接口。
                std::shared_ptr<sdbusplus::asio::dbus_interface> iface =
                    createInterface(objServer, boardPath, propName,
                                    boardNameOrig);

                populateInterfaceFromJson(systemConfiguration,
                                          jsonPointerPath + propName, iface,
                                          propValue, objServer);
            }
        }
        ......
        // 逐个推送 Exposes 中的 object 。
        for (auto& item : *exposes)
        {
            ......
            std::string ifacePath = boardPath;
            ifacePath += "/";
            ifacePath += itemName;

            // 在 /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name>
            // 下创建名为 xyz.openbmc_project.Configuration.<expose type> 的接口。
            std::shared_ptr<sdbusplus::asio::dbus_interface> itemIface =
                createInterface(objServer, ifacePath,
                                "xyz.openbmc_project.Configuration." + itemType,
                                boardNameOrig);

            // 针对几种特殊 Type ,额外创建 Inventory 接口,似乎是在接管 inventory
            // manager 的功能。
            if (itemType == "BMC")
            {
                // 在 /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name>
                // 下创建 xyz.openbmc_project.Inventory.Item.Bmc 接口。
                std::shared_ptr<sdbusplus::asio::dbus_interface> bmcIface =
                    createInterface(objServer, ifacePath,
                                    "xyz.openbmc_project.Inventory.Item.Bmc",
                                    boardNameOrig);
                // 填充接口内容。
                populateInterfaceFromJson(systemConfiguration, jsonPointerPath,
                                          bmcIface, item, objServer,
                                          getPermission(itemType));
            }
            else if (itemType == "System")
            {
                // 在 /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name>
                // 下创建 xyz.openbmc_project.Inventory.Item.System 接口。
                std::shared_ptr<sdbusplus::asio::dbus_interface> systemIface =
                    createInterface(objServer, ifacePath,
                                    "xyz.openbmc_project.Inventory.Item.System",
                                    boardNameOrig);
                // 填充接口内容。
                populateInterfaceFromJson(systemConfiguration, jsonPointerPath,
                                          systemIface, item, objServer,
                                          getPermission(itemType));
            }

            // 填充上面创建的 xyz.openbmc_project.Configuration.<expose type> 接口。
            populateInterfaceFromJson(systemConfiguration, jsonPointerPath,
                                      itemIface, item, objServer,
                                      getPermission(itemType));

            // 特殊处理 expose object 中的子对象与对象数组。
            for (const auto& [name, config] : item.items())
            {
                ......
                // 对子对象,在
                // /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name>
                // 下创建 xyz.openbmc_project.Configuration.<expose type>.<item name>
                // 接口。
                if (config.type() == nlohmann::json::value_t::object)
                {
                    std::string ifaceName =
                        "xyz.openbmc_project.Configuration.";
                    ifaceName.append(itemType).append(".").append(name);

                    std::shared_ptr<sdbusplus::asio::dbus_interface>
                        objectIface = createInterface(objServer, ifacePath,
                                                      ifaceName, boardNameOrig);

                    populateInterfaceFromJson(
                        systemConfiguration, jsonPointerPath, objectIface,
                        config, objServer, getPermission(name));
                }
                // 对数组对象,在
                // /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name>
                // 下创建 xyz.openbmc_project.Configuration.<expose type>.<item name><index>
                // 接口。
                else if (config.type() == nlohmann::json::value_t::array)
                {
                    ......
                    if (type != nlohmann::json::value_t::object)
                    {
                        continue;
                    }
                    ......
                    for (auto& arrayItem : config)
                    {
                        std::string ifaceName =
                            "xyz.openbmc_project.Configuration.";
                        ifaceName.append(itemType).append(".").append(name);
                        ifaceName.append(std::to_string(index));

                        std::shared_ptr<sdbusplus::asio::dbus_interface>
                            objectIface = createInterface(
                                objServer, ifacePath, ifaceName, boardNameOrig);

                        populateInterfaceFromJson(
                            systemConfiguration,
                            jsonPointerPath + "/" + std::to_string(index),
                            objectIface, arrayItem, objServer,
                            getPermission(name));
                        ......
                    }
                }
            }
            ......
        }
        ......
    }
    ......
}

总结一下,在配置匹配成功后,会:

1、在 /xyz/openbmc_project/inventory/system/<type>/<name> 下创建名为 xyz.openbmc_project.Inventory.Item.<type> 的接口。其中 <type> 就是配置文件中与 Probe 同级的 Type 字段的值,<name> 同理 ↓

{
    ......
    "Name": "1Ux16 Riser 2",
    "Probe": "TRUE",
    "Type": "Board",
    ......
}
root@libxzr:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/board/1Ux16_Riser_2
NAME                                          TYPE      SIGNATURE RESULT/VALUE           FLAGS
......
xyz.openbmc_project.Inventory.Item.Board      interface -         -                      -
.Name                                         property  s         "1Ux16 Riser 2"        emits-change
.Probe                                        property  s         "TRUE"                 emits-change
.Type                                         property  s         "Board"                emits-change

2、在 /xyz/openbmc_project/inventory/system/<type>/<name> 下为与 Probe 同级的其它 json object 创建独立接口。接口名与访问子 object 的 key 相同 ↓

{
    ......
    "Name": "1Ux16 Riser 2",
    "Probe": "TRUE",
    "Type": "Board",
    "xyz.openbmc_project.Inventory.Decorator.Asset": {
        "Manufacturer": "$BOARD_MANUFACTURER",
        "Model": "$BOARD_PRODUCT_NAME",
        "PartNumber": "$BOARD_PART_NUMBER",
        "SerialNumber": "$BOARD_SERIAL_NUMBER"
    }
}
root@libxzr:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/board/1Ux16_Riser_2
NAME                                          TYPE      SIGNATURE RESULT/VALUE           FLAGS
......
xyz.openbmc_project.Inventory.Decorator.Asset interface -         -                      -
.Manufacturer                                 property  s         "xxxxxx"               emits-change
.Model                                        property  s         "xxxxxx"               emits-change
.PartNumber                                   property  s         "xxxxxx"               emits-change
.SerialNumber
xyz.openbmc_project.Inventory.Item.Board      interface -         -                      -
......

3、为 Exposes 中的每一个子对象在 /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name> 创建名为 xyz.openbmc_project.Configuration.<expose type> 的接口,其中 <expose name> 对应每一个子对象的 Name<expose type> 对应每一个子对象的 Type

{
    "Exposes": [
        {
            "Address": "$address",
            "Bus": "$bus",
            "Name": "Riser 2 Fru",
            "Type": "EEPROM"
        },
        ......
    ],
    "Name": "1Ux16 Riser 2",
    "Probe": "TRUE",
    "Type": "Board",
    .....
}
root@libxzr:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/board/1Ux16_Riser_2/Riser_2_Fru
NAME                                     TYPE      SIGNATURE RESULT/VALUE  FLAGS
......
xyz.openbmc_project.Configuration.EEPROM interface -         -             -
.Address                                 property  s         "xx"          emits-change
.Bus                                     property  s         "xx"          emits-change
.Name                                    property  s         "Riser 2 Fru" emits-change
.Type                                    property  s         "EEPROM"      emits-change

4、如果 Exposes 中的子对象还有子对象或子对象数组,它们会被推送到 /xyz/openbmc_project/inventory/system/<type>/<name>/<expose name> 下的 xyz.openbmc_project.Configuration.<expose type>.<item name>(子对象)或 xyz.openbmc_project.Configuration.<expose type>.<item name><index>(子对象数组),其中 <item name> 对应访问子对象/对象数组的 key ,<index> 则是数组的成员下标 ↓

{
    "Exposes": [
        {
            "Name": "TestName",
            "SubObj": [
                {
                    "key": "value"
                }
            ],
            "ObjArray": [
                {
                    "key": "value"
                },
                {
                    "key": "value"
                }
            ],
            "Type": "Test"
        }
    ],
    "Name": "1Ux16 Riser 2",
    "Probe": "TRUE",
    "Type": "Board",
    ......
}
root@libxzr:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/board/1Ux16_Riser_2/TestName
NAME                                             TYPE      SIGNATURE RESULT/VALUE FLAGS
......
xyz.openbmc_project.Configuration.Test           interface -         -            -
.Name                                            property  s         "TestName"   emits-change
.Type                                            property  s         "Test"       emits-change
xyz.openbmc_project.Configuration.Test.ObjArray0 interface -         -            -
.key                                             property  s         "value"      emits-change
xyz.openbmc_project.Configuration.Test.ObjArray1 interface -         -            -
.key                                             property  s         "value"      emits-change
xyz.openbmc_project.Configuration.Test.SubObj0   interface -         -            -
.key                                             property  s         "value"      emits-change

需要注意的是,子 object / object 数组最多只能套娃到这一层 ↑ ,再套一层就不会被推送到 dbus 上了,这是在代码结构中写死的。

需要被特殊处理的只有 json object 数组,普通的数组是可以被直接推上 dbus 的,具体见 addArrayToDbus()

此外,还有一些特殊情况,比如对于 BMCSystem 等 expose type 会自动生成相关 inventory 接口,具体就看上面的代码注释了。

好了,到此为止,entity-manager 的全流程就梳理结束了。

问题

这里主要记录一些问题,它们可能是 bug ,也可能是 by design ,在实际开发中稍有不慎就可能被坑到。

EEPROM 寻址位数的判断

在 EEPROM 未安装驱动时,fru-device 采取的是“硬读”的操作,也就是直接发送原始的 i2c 命令。

但是,这当中牵扯到了如何组装 i2c 命令的问题。

比如对于一次 i2c read byte (单字节数据读取),SDA 上会传输以下数据,<> 表示主机发送的内容,[] 表示从机回复的内容,下面的数据中,除了 ACKNACK 是一个 bit 外,其余内容都是一个字节 ↓

<开始信号> <从机地址|写标志> [ACK] <偏移量> [ACK] <开始信号> <从机地址|读标志> [ACK] [数据] <NACK> <停止信号>

其中,<偏移量> 是一个 8 位地址,代表要从哪里开始读取数据。
也就是说,执行 i2c read byte 时,我们先写了一个 8 位地址进去,切换到读模式之后 EEPROM 会回复这个地址对应的数据。

再来看看 i2c write byte (单字节数据写入)↓

<开始信号> <从机地址|写标志> [ACK] <偏移量> [ACK] <数据> [ACK] <停止信号>

我们先写了一个 8 位的偏移量进去,然后紧随其后写入这个偏移量所对应的数据,于是 EEPROM 上的数据就会被更新。

但是,上面的全部内容都是对应 8 位寻址的 EEPROM ,还有一部分 EEPROM 是 16 位寻址的,其要求的 i2c read byte (单字节数据读取)命令会变成这样 ↓

<开始信号> <从机地址|写标志> [ACK] <偏移量高位> [ACK] <偏移量低位> [ACK] <开始信号> <从机地址|读标志> [ACK] [数据] <NACK> <停止信号>

可以看到,偏移量变成了两个字节,需要两次写入。

这就产生了一个问题,fru-device 该采用哪种方式读取 EEPROM ?是 8 位寻址的方式,还是 16 位寻址的方式?
有一个非常致命的点是:16 位 EEPROM 写入偏移量的命令与 8 位 EEPROM 写入数据的命令是重合的,如果在 8 位 EEPROM 上采用 16 位的方式读取,可能导致数据损坏

所以,fru-device 是如何选择寻址位数的呢?
它用了一种稀奇古怪的方式。

src/fru_device.cpp

static std::optional<bool> isDevice16Bit(int file)
{
    // Set the higher data word address bits to 0. It's safe on 8-bit addressing
    // EEPROMs because it doesn't write any actual data.
    int ret = i2c_smbus_write_byte(file, 0);
    if (ret < 0)
    {
        return std::nullopt;
    }

    /* Get first byte */
    int byte1 = i2c_smbus_read_byte_data(file, 0);
    if (byte1 < 0)
    {
        return std::nullopt;
    }
    /* Read 7 more bytes, it will read same first byte in case of
     * 8 bit but it will read next byte in case of 16 bit
     */
    for (int i = 0; i < 7; i++)
    {
        int byte2 = i2c_smbus_read_byte_data(file, 0);
        if (byte2 < 0)
        {
            return std::nullopt;
        }
        if (byte2 != byte1)
        {
            return true;
        }
    }
    return false;
}

它先写入了一个 0 地址,然后开始以 8 位寻址的方式尝试多次读取 0 地址的数据,并认为:

  • 如果是 8 位寻址的 EEPROM ,则会持续读到相同的字节(也就是偏移量不变)。
  • 如果是 16 位寻址的 EEPROM ,则会读到不同的字节(也就是偏移量发生自增)。

完全不能理解这是一种什么样的逻辑,实践也证明这种方式没法区分 EEPROM 的实际寻址位数,它仍会将 16 位寻址的 EEPROM 误判为 8 位的。

区分 EEPROM 的寻址位数是一个老大难的问题了,社区这边也有两个相关 issue #1 #15

这种摸黑箱的全扫描架构本身就决定了它很难判断 EEPROM 的实际类型,毕竟在一般的实践中这个逻辑是反过来的,比如驱动中是由型号来决定寻址位数的 ↓

linux-aspeed/drivers/misc/eeprom/at24.c

// 8 位寻址的型号。
AT24_CHIP_DATA(at24_data_24c16, 16384 / 8, 0);
......
// 16 位寻址的型号,AT24_FLAG_ADDR16 决定寻址位数。
AT24_CHIP_DATA(at24_data_24c128, 131072 / 8, AT24_FLAG_ADDR16);

所以,要规避这个问题,要么在 dts 中写死 EEPROM 型号,利用驱动来规避硬扫描,要么在 FRU 解析失败时尝试不同的寻址位数,但要注意不能对数据造成破坏。

配置匹配过程中的 edge case

AND 的两端可能是不同的接口对象

"Probe": [
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d'})",
    "AND",
    "xyz.openbmc_project.FruDevice({'ADDRESS' : 80})"
]

对于这样的一个 Probe ,按照最浅显的理解:我们正在寻找一个 BOARD_PRODUCT_NAMEF1UL16RISER\\d 正则匹配且 ADDRESS 等于 80 的 FRU 设备(接口)。
但是,当我们带入之前分析的匹配过程后,却会发现好像完全不是这回事。

实际运行时,它会先去已拉取的所有 dbus 接口(dbusProbeObjects)中寻找第一个条件的匹配,再去所有接口中寻找第二个条件的匹配,这两个寻找过程是相互独立的,只要在其中一个条件上发生匹配,匹配的接口就会被添加到结果(foundDevs,下称“匹配的接口列表”)中。

也就是说,我们预期找到的设备是既符合第一个条件又符合第二个条件的,两个条件之间是“与”的关系;但实际上,只有反应“匹配成功”的布尔变量是“与”的关系,但“匹配的接口列表”本身却是一个“或”的关系。

只是这么说可能有点晕,可以看看下面这几种情况:

情况零:

  • 条件一匹配到了某些接口,但是条件二什么都没匹配到。由于连接词是 AND ,因此反映“匹配成功”的布尔变量为假,此时这个配置文件会被直接略过,system.json 不新增任何内容。但需要注意的是,此时“匹配的接口列表”并不是空的,它里面装着与条件一相匹配的接口。

情况一:

  • 条件一匹配到了接口 A ,条件二同样匹配到了接口 A ,此时 A 既符合条件一又符合条件二,很好。“匹配的接口列表”中会有两个 A ,但是经过了 key(checksum) 的覆盖(1),最终只在 system.json 中生成了一个由 A 产生的配置,符合要求。

情况二:

  • 条件一匹配到了接口 A ,但是条件二却匹配到了接口 B ,相当于 AND 的两端并没有匹配在同一个接口对象上(虽然它们的接口名相同,但是路径不同,不是同一个接口对象),这种情况下,匹配也能够成功,“匹配的接口列表”会同时包含 A 和 B ,最终的 system.json 中会包含分别由 A 和 B 生成的两份配置。这是一种很要命的情况,因为无论是接口 A 还是接口 B 都不完全符合 Probe 的要求,但 Probe 却通过了,还会生成两份配置

我们要格外对情况二留一个心眼,最好的避免方法就是将匹配条件写到一行当中,比如 xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d', 'ADDRESS' : 80})

接下来对情况二进行一个加强版拓展,假如我们的 Probe 长这样:

"Probe": [
    "xyz.openbmc_project.FruDevice({......})",
    "AND",
    "xyz.openbmc_project.Inventory.Item.PCIeDevice({......})"
]

那么它会是一种什么样的匹配操作呢?
嗯,都说了是情况二的拓展,结果和情况二其实是一样的:“匹配的接口列表”会同时包含条件一和条件二独立匹配的结果,最终的 system.json 中也会产生多份配置。

注释:

  • (1) checksum 是基于 Name 和匹配上的接口内容生成的,参见“system.json 生成”一节,如果同一个接口(甚至内容相同的不同接口)匹配上了多次,其计算产生的 checksum 是相同的。因此,即使“匹配的接口列表”中存在多个相同接口,虽然其会为每个接口独立生成配置,但当添加到 system.json 时它们会因 key 相同而相互覆盖,造成 system.json 中最后只有一份配置的结果。

OR 的不短路性

"Probe": [
    "xyz.openbmc_project.FruDevice({'BOARD_PRODUCT_NAME': 'F1UL16RISER\\d'})",
    "OR",
    "xyz.openbmc_project.FruDevice({'ADDRESS' : 80})"
]

再来看这个 Probe ,这次将中间的 AND 改为 OR 。来猜一猜,效果会如何呢?
效果还真不一定,得分类讨论。

情况零:

  • 条件一匹配到了某些接口,但是条件二什么都没匹配到。由于连接词是 OR ,因此反映“匹配成功”的布尔变量为真,“匹配的接口列表”包含了与条件一相匹配的接口(们),system.json 中也会添加基于这些接口生成的配置文件。

情况一:

  • 条件一匹配到了接口 A ,条件二同样匹配到了接口 A 。“匹配的接口列表”中会有两个 A ,但是经过了 key(checksum) 的覆盖,最终只在 system.json 中生成了一个由 A 产生的配置,符合要求。

情况二:

  • 条件一匹配到了接口 A ,条件二匹配到了接口 B 。“匹配的接口列表”会同时包含 A 和 B ,最终的 system.json 中也会包含分别由 A 和 B 生成的两份配置。逻辑上看没啥问题,相当于有不同的设备都被这份配置文件匹上了呗,那自然要为不同的设备生成独立的配置,最后独立的在 dbus 上创建东西。

到目前为止,没啥问题,逻辑很通,那么反直觉的点在哪里呢?
比如我有这样一个需求:我有一个设备,其既可以通过 fru-device 检测到,又可以通过 peci-pcie 扫到,现在我想,只要两者之一检测到了设备,就激活这份配置文件,我可能会想当然的这么写:

    "Probe": [
        "xyz.openbmc_project.FruDevice({......})",
        "OR",
        "xyz.openbmc_project.Inventory.Item.PCIeDevice({......})"
    ],

但是,这是有问题的:仔细想想,这和上面的情况二有什么本质区别吗?
如果该设备的 fru 和 pcie 接口同时存在,那么“匹配的接口列表”将会同时包含它们,最终的 system.json 将会包含分别由这两个接口生成的两份配置,也就是说,我们只有一个设备,但是 dbus 上将会出现多份与该设备相对应的配置。

OR 是不会短路的,它不会因为第一个条件匹配上了就忽略剩下的条件,因此在默认情况下,这种尝试用多种 Probe 方式来初始化同一设备的操作是不可行的。

意义不明的模板替换数据源

void PerformScan::updateSystemConfiguration(const nlohmann::json& recordRef,
                                            const std::string& probeName,
                                            FoundDevices& foundDevices)
{
    ......
    for (const auto& [foundDevice, path] : foundDevices)
    {
        // 这是一个意义不明的地方,我觉得是屎山的坍塌,见“问题”小节。
        // 下面这两行完全可以用一行
        // const DBusObject dbusObject = {{path, foundDevice}};
        // 进行代替。
        // 但不管怎么样,dbusObject 包含了此设备的信息( dbus 接口
        // 的内容),会在后续的模板替换过程中被使用。
        // 后续的模板替换过程完全也可以直接使用 foundDevice 进行,
        // 完全搞不懂为什么这里要去 dbusProbeObjects 里兜一圈。
        auto objectIt = dbusProbeObjects.find(path);
        const DBusObject& dbusObject = (objectIt == dbusProbeObjects.end())
                                           ? emptyObject
                                           : objectIt->second;

        ......
                templateCharReplace(keyPair, dbusObject, foundDeviceIdx,
                                    replaceStr);
        ......
    }
}

在“system.json 生成”小节中,我提到了这个意义不明的地方。
具体来说,foundDevices 是本配置文件匹配上的设备(接口)列表,我们要根据接口中的信息对本配置进行模板变量替换,为每一个设备(接口)生成特定的完整配置。

照理说,foundDevice 本身就是接口的属性 map ,我们可以直接拿着它做模板替换——事实也正是这样,我们完全可以把 templateCharReplace() 的第二个参数改为 foundDevice ,编译能通过,运行起来也没有任何功能上的问题。

编译之所以能通过,是因为 templateCharReplace() 本身就有两个重载 ↓

std::optional<std::string> templateCharReplace(
    nlohmann::json::iterator& keyPair, const DBusInterface& interface,
    size_t index, const std::optional<std::string>& replaceStr = std::nullopt);

std::optional<std::string> templateCharReplace(
    nlohmann::json::iterator& keyPair, const DBusObject& object, size_t index,
    const std::optional<std::string>& replaceStr = std::nullopt);

默认情况下传入 DBusObject 调用的是第二个重载,而直接传入 foundDevice 则会调用第一个重载。
DBusObject 本身是一个 path 到 DBusInterface 的 map ,第二个重载所做的事情不过是针对 map 中的每一个 DBusInterface 调用一次第一个重载。所以,替换 templateCharReplace() 的第二个参数为 foundDevice ,效果与我注释中的 const DBusObject dbusObject = {{path, foundDevice}} 是相同的。

既然直接传入 foundDevice 就能起到替换效果,那原始代码是在干啥呢?
这就是我看不懂的地方。
原始代码尝试拿取 dbusProbeObjects 中所有与 foundDevice path 相同的接口,并用它们作为信息源。

说白了,假设在 A 配置文件中存在 "Probe": "AAA(...)" ,B 配置文件中存在 "Probe": "BBB(...)" 。假设 AAA 和 BBB 在 dbus 上位于相同的 path 下(哪怕位于不同的服务中),那么在匹配成功时,A 配置文件不仅可以引用 AAA 接口的内容,甚至还可以引用 BBB 接口的内容,B 配置文件亦然。你说这是一种什么逻辑?如果说这是一种特性,那么在删除 B 配置文件后,A 配置文件将不再能引用 BBB 接口的内容(因为此时就不会去 dbus 上拉取 BBB 接口了),删除一个配置文件却能影响其它完全独立的配置文件,根本就没有这样的道理。更重要的是,如果 AAA 和 BBB 接口中具有同名的属性,这可能会导致属性的来源不明,因为完全无法确定 AAA 和 BBB 在执行替换时的顺序。

因此,这完全不可能是一个特性,要么是屎山崩塌了,要么是 coder 脑抽了。这个问题在实践中几乎不可能触发,只能在 code review 中发现,因为很少有不同的接口会使用相同的 path 。

参考资料