新手村视角看 openUBMC:bingo build 流程全程追踪

作为一个 UBMC 零基础小白,本来还在新手村捡木棍,结果一脚踏进了 openUBMC 的大地图。趁着 LTS 版本发布前,我给自己安排了一个“主线任务”:顺着工具链一路追踪 build 流程。
为了把这条流水线看得更清楚,我在代码里塞了大量探针,基本属于“到处打点、逮住就问你是谁”的程度 :joy:。这篇帖子就当是我追踪过程的复盘笔记,一方面给后来者留个路标,另一方面也欢迎各位大佬指正(轻喷)。

全程 是参照下方教程实现的Vim 跳转 ,增加探针链路追完大概花了 1.5 天,亲测基本上跳转无误,用的也是开源社区最受欢迎的fzf以及协同tselect。

跨文件/跨语言(py / lua / yml)无缝联动。支持 tselect 与 fzf 双路跳转、代码结构预览与快速检索。

综述 / 跳转导览(bingo)

本文给出从系统入口到 create_target_scheduler('personal')最短跳转路线,配合截图与探针日志做到“一步一锚点、可复现”。其后涉及的实际执行(由 start 启动子执行单元跑各 work)此文不展开。

 /usr/local/bin/bingo
   → bmcgo.bmcgo.run(args)
     → bmcgo.cli.cli.main(args) → bmcgo.cli.cli.run(args)
       → Command.run(argv)
         → [method 分发]
            valid_command='build'
            return method(command_args)
            ≡ return Command.build(command_args)   ← 跳不过去就直接搜 `def build(`
         → Command.build(argv)
           → Command.frame_build(argv)
             → Frame.parse(args, ext_targets_dir)
             → Frame.run()
               → create_target_scheduler('personal', ..., target=.../target/personal.yml)
               → (其后由 `start` 启动子执行单元执行各 work,本文不展开)

阅读方式(极简):

  • 每到一个锚点配一张“跳转截图”;遇到 method 跳不过去,按上面等价式直接搜 def build(

  • 日志对齐要点:RESOLVED COMMAND buildPATH = B (METHOD)DISPATCH → frame_buildTARGET YAML OPEN → personal.yml==== SUBWORKS TREE (TOP) ==== / tasks.total = …(此处为递归打印/计数,用于预览,不是执行循环)。

bingo 命令入口:/usr/local/bin/bingo

bingo 安装完成后,系统里会多出一个可执行文件:

 which bingo
 # /usr/local/bin/bingo

把这个文件用 Vim 打开,可以看到它其实只是一个很薄的 Python 启动脚本:

 #!/usr/bin/python3
 # -*- coding: utf-8 -*-
 import re
 import sys
 from bmcgo.bmcgo import run
 ​
 if __name__ == '__main__':
     # 兼容 pip 安装生成的入口脚本,把结尾的 -script.pyw / .exe 去掉
     sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
     # 真正的入口在 bmcgo.bmcgo.run()
     sys.exit(run())

这段代码做的事情非常简单:

  1. 通过 #!/usr/bin/python3 声明用 Python3 解释执行;

  2. 导入 bmcgo.bmcgo 模块中的 run() 函数;

  3. __main__ 分支里,对 sys.argv[0] 做一次正则替换(去掉可能存在的 -script.pyw.exe 后缀),这是 pip 生成入口脚本时的一个通用套路;

  4. 最后调用 run(),并把返回值作为进程的退出码返回给系统。

也就是说:

命令行里的 bingo 本质上就是在调用 bmcgo.bmcgo.run()

下面这张图展示的是我在 Vim 中通过 tags/fzf 跳转到 run 定义的位置,作为后续分析调用链的起点:

(上图中左侧是 tags/fzf 搜索到的各个 run 符号,右侧是 /usr/local/bin/bingo 启动脚本本身。)

2. 从 bmcgo.bmcgo.run() 继续追到 CLI 入口

在上一节我们看到,/usr/local/bin/bingo 只是调用了 bmcgo.bmcgo.run()。 使用代码跳转功能(如 Vim 里对 runCTRL-]),可以直接跳到下面这个文件:

 #!/usr/bin/python3
 #省略xxx
 #xxxx
 ​
 import sys
 from bmcgo.cli.cli import main
 ​
 ​
 def run():
     # 把命令行参数(去掉程序名)交给 CLI 模块的 main()
     sys.exit(main(sys.argv[1:]))
 ​
 ​
 if __name__ == '__main__':
     run()

这层代码的作用非常“薄”,主要就是两件事:

  1. 导入真正的 CLI 入口

     from bmcgo.cli.cli import main
    

    可以看出,后续所有子命令解析、任务分发的核心逻辑,其实都集中在 bmcgo.cli.cli.main() 里。

  2. 转发命令行参数并退出

     def run():
         sys.exit(main(sys.argv[1:]))
    
    • sys.argv[1:]:丢掉程序名,只把用户输入的参数(如 build -b openUBMC)传给 main()

    • sys.exit(...):将 main() 的返回值作为进程退出码返回给操作系统。

到这里,我们已经完成了第二次“跟踪跳转”: bingobmcgo.bmcgo.run()bmcgo.cli.cli.main()

接下来,只需要在编辑器里继续对 main 做一次代码跳转(例如在 from bmcgo.cli.cli import main 上按 CTRL-]),就可以进入 CLI 层的真正入口函数,看到 bingo 是如何解析子命令、选择对应 task 的,这也是后面整个“代码跳转解析工具链”要重点讲解的部分。

图 使用代码跳转从 /usr/local/bin/bingo 追踪到 bmcgo/bmcgo.py,可以看到 run() 只是简单调用 bmcgo.cli.cli.main()

CLI 总入口:bmcgo.cli.cli.main() / run()

在上一节我们已经到了 bmcgo.bmcgo.run()。 继续代码跳转,可以看到 bmcgo/cli/cli.py 里有:

 def main(args):
     return run(args)

也就是说,真正干活的是 run(args, command=None)main() 只是一个薄封装,给外层 sys.exit(main(sys.argv[1:])) 调用用的。

run() 的职责可以一句话概括:

准备好运行环境,然后把命令行参数交给 Command.run() 处理,最后返回退出码。

具体做了几件事

  1. 记录入参 把收到的 argscommand 写到日志里,方便之后排查“到底收到了什么命令”。

  2. 准备工作目录 创建/检查一个缓存目录(misc.CACHE_DIR),后面的一些功能会往这里写中间文件。

  3. 准备 Conan 环境 调用 prepare_conan()

  4. 创建 Command 对象 如果没有从外面注入 command,就自己 command = Command()。这个对象里包含了所有子命令(build、deploy 等)的解析和分发逻辑。

  5. 真正执行命令:error = command.run(args) 这一句是核心跳转点

    • Command.run() 会解析 args,判断你是执行 build 还是其他子命令;

    • 再选出对应的 Task 类(比如 BuildTask),最后调用具体任务的 run()

  6. 处理“正常退出”场景 如果内部抛出了一个叫 ExitOk 的异常(典型是 --help--version 这种),这里会把它当成“正常结束”,退出码统一设为 0

  7. 兜底异常处理 其他没预料到的异常会在这里统一处理:

    • 打一条错误日志(如果开了调试环境变量,就顺便把堆栈也打出来);

    • 返回一个固定的错误码,比如 -1,表示“程序本身出错”。

  8. 返回退出码 run() 最后返回的数字,会被外层 sys.exit(...) 用掉,变成 shell 里看到的 $?


用探针 + build 命令确认调用链

为了写这份教程,我在 run() 里前后加了几行简单的打印(这些不在原始源码里,只是临时加的探针),然后执行了一次:

 bingo build -b openUBMC -bt debug

从终端输出可以看到两件事情:

  1. bmcgo.run args=[...] 这样的日志,说明命令行参数已经正确传到了 run()

  2. 接着能看到类似“将要调用 command.run(…)”以及“命令实例是什么”的输出,说明确实是从这里跳进 Command.run() 的。

这些探针只是用来验证调用顺序bingo → bmcgo.bmcgo.run → bmcgo.cli.cli.main → bmcgo.cli.cli.run → Command.run → 具体 Task

cli.run()Command.run():命令解析与分发

上一节里,我们看到 cli.run() 最终会调用:

 error = command.run(args)

接下来,通过 tags/fzf 在同一个 bmcgo/cli/cli.py 文件里搜索 def run(self, *args):,就能跳转到 Command.run。这就是 bingo 命令的真正“分发中心”

Command.run 的职责可以概括为三步:

  1. 把传入的 argv_list 拆成「命令名 + 参数」;

  2. 通过 _find_real_command() 解析出“真实命令”(考虑别名、集成规则等);

  3. 根据真实命令,选择对应的实现(模块 / 方法),并把参数传递过去执行,最后返回退出码。

为了观察这条链路,在 Command.run 中预先埋了一些日志探针。下面通过这些输出,一步步看它是如何把

 bingo build -b openUBMC -bt debug

变成一次对 Command.build() 的调用的。


1. 拆分 argv:命令名与命令参数

Command.run 的调用约定是:上层传入的是一个 argv_list,例如:

 ['build', '-b', 'openUBMC', '-bt', 'debug']

因此,在函数内先做了一次拆分:

  • 第一个 token 当作命令名command = 'build'

  • 剩余 token 当作命令参数command_args = ['-b', 'openUBMC', '-bt', 'debug']

从日志里可以验证这一点(见下图/下列输出):

 [bmcgo] raw command  = 'build'
 [bmcgo] valid_command= 'build'
 [bmcgo] command_args = ['-b', 'openUBMC', '-bt', 'debug']

到这里,命令行已经被整理成了「要执行 build 命令,并且带上 -b openUBMC -bt debug 这些参数」的形式。


2. _find_real_command():解析出“真实命令”

接着,Command.run 会调用:

 valid_command = self._find_real_command(command)

这一层可以看成是“命令别名 / 集成规则”的统一入口:有些命令可能被映射成另外一个实现,或者经过一层规则判断之后再决定最终执行什么。

通过探针输出,可以看到本例的解析过程大致如下:

 ****INTEGRATED PROBE****
 ...
 ****MATCH PROBE****
 group: 'Integrated commands'
 name: 'build'
 ...
 ****HIT****
 return.type: str
 return.value: 'build'
 [bmcgo] resolved valid_command='build'
 [bmcgo] RESOLVED COMMAND
 [bmcgo] raw command  = 'build'
 [bmcgo] valid_command= 'build'
 [bmcgo] command_args = ['-b', 'openUBMC', '-bt', 'debug']

从这些信息可以得出两个结论:

  1. _find_real_command('build') 确认这是一个有效命令;

  2. 返回的 valid_command 仍然是字符串 'build'(而不是某个模块描述对象)。

这一点很关键,因为后面会根据 valid_command类型选择不同的分发路径。


3. Path B:方法式命令,分发到 Command.build

Command.run 的分发逻辑可以简化成两条分支:

 # A:模块式命令(valid_command 是 CommandInfo)
 if isinstance(valid_command, misc.CommandInfo):
     ...
 ​
 # B:方法式命令(valid_command 是 str)
 elif isinstance(valid_command, str):
     ...

在这次 build 的例子中,valid_command 是字符串 'build',因此进入的是 Path B(方法式命令)。日志输出中也给出了非常直观的提示:

 ****DISPATCH PROBE****
 raw command: 'build'
 valid_command.type: 'str'
 valid_command.repr: 'build'
 command_args: ['-b', 'openUBMC', '-bt', 'debug']
 ════════════════════════════════════════════════════════════════════════════
 ****PATH = B (METHOD)****
 method.key: 'build'
 args: ['-b', 'openUBMC', '-bt', 'debug']
 ****COMMANDS TABLE****
 keys.count: 8
 has[build]: True
 keys.preview: ['build', 'deploy', 'frame_build', 'gen', 'help', 'inte_publish', 'run', 'test']
 ...
 ****METHOD LOOKUP****
 lookup.key: 'build'
 found: True
 method.impl: '/usr/local/lib/python3.12/dist-packages/bmcgo/cli/cli.py:434'
 method.qual: 'bmcgo.cli.cli:Command.build'

从这几段输出可以读出整个分发过程:

  1. Command.run 先调用 self._commands(True) 拿到一张“命令表”,里面是类似 { 'build': Command.build, 'deploy': Command.deploy, ... } 的映射;

  2. 然后以 valid_command 作为 key 去这张表里查找:

    • has[build]: True 表示命令表中确实存在 build;

    • method.implmethod.qual 标明了最终命中的实现位于 cli.py:434,函数全名为 Command.build

  3. 最终,Command.run 会执行:

     return method(command_args)
    

    对于本例来说,可以理解成:

     return self.build(['-b', 'openUBMC', '-bt', 'debug'])
    

至此,命令分发链路就从“命令行参数”自然收敛到了具体方法 Command.build()

 bingo build -b openUBMC -bt debug
 → Command.run(['build', '-b', 'openUBMC', '-bt', 'debug'])
 → Command.build(['-b', 'openUBMC', '-bt', 'debug'])

4. 小结:这一节需要记住什么?

  • Command.run 是 CLI 层的“中央路由器”,负责把 argv 拆成命令名和参数;

  • _find_real_command() 会根据规则解析出真实命令,返回值的类型决定是走模块式命令(Path A)还是方法式命令(Path B);

  • 通过观察探针输出,可以清楚看到:

    • build 被解析成 valid_command='build'

    • 分发路径选择了 Path B;

    • 最终落到 Command.build 这个方法上。

build 子命令:从 Command.build 跳到 frame_build

前一节里,通过 Command.run() 的日志我们已经确认:

 method.qual: 'bmcgo.cli.cli:Command.build'

也就是说,bingo build ... 最终会调用到 Command.build(...)

由于终端里看到的是一个“方法指针”,没法直接从那里跳转函数定义,我们可以在编辑器里这样操作:

  • bmcgo/cli/cli.py 中搜索 def build

  • 或者在 tags/fzf 中搜索 Command.build, 就能定位到类 Command 里对应的 build() 方法(如左图所示)。

Command.build 的核心逻辑其实可以压缩成三步:

  1. 接收命令行参数

     def build(self, *args):
         argv = args[0]  # 例如 ['-b', 'openUBMC', '-bt', 'debug']
    
  2. 判断是否处于“集成工程(Manifest/Integrated)”场景

     is_integrated, root = self._is_integrated()
    
    • 如果当前目录是一个 Manifest/集成工程,那么 is_integratedTrue

    • 否则就是“单组件/本地组件”的构建路径。

  3. 根据 is_integrated 决定后续走向

    • 集成工程场景(重点)is_integratedTrue 时,build() 不直接做具体构建,而是把工作交给另一个方法:

       ret = self.frame_build(argv)
       return ret
      

      也就是说:在 Manifest 场景下,build 只是一个“门面函数”,真正负责处理集成工程构建的是 frame_build()


通过日志探针确认:确实进入了 frame_build

为了验证这条调用链,在 build()frame_build() 里都加了一些日志探针。执行命令:

 bingo build -b openUBMC -bt debug

可以看到类似下面这样的一段输出:

从这几行就可以很直观地确认两点:

  1. build() 收到了和命令行一致的参数 ['-b', 'openUBMC', '-bt', 'debug']

  2. 由于当前路径被识别为 Manifest 工程(is_integrated: True),build() 选择了 DISPATCH -> frame_build(argv),而后面的 ****FRAME BUILD ENTRY**** 则说明程序已经进入 frame_build()

换句话说,在集成工程场景下,这条调用链可以概括为: bingo build -b openUBMC -bt debugCommand.run(...)Command.build(['-b', 'openUBMC', '-bt', 'debug'])Command.frame_build(['-b', 'openUBMC', '-bt', 'debug'])


下一步:跳转到 frame_build

既然日志已经证明构建流程在 Manifest 场景下会落到 frame_build(),下一步就可以在 cli.py 中继续搜索:

 def frame_build(self, *args):

用代码跳转功能进入这个方法,从这里开始就能看到:

frame_build:把集成工程交给 Frame 处理

在上一节里,Command.build集成工程(Manifest) 场景下会调用:

 ret = self.frame_build(argv)
 return ret

继续在 cli.py 里搜索 def frame_build,可以看到截图中的实现(抽最关键几行):

 def frame_build(self, args=None):
     # 0) 入参处理
     argv = args or []
 ​
     # 1) 再次确认当前是集成工程,记录 root / work_dir 等
     is_integrated, root = self._is_integrated()
     os.chdir(root)
 ​
     # 3) 基于当前 bconfig 初始化配置对象
     config = Config(self.bconfig)
     # 这里有大量 dump_bconfig 的打印,我们在教程里可以略过
 ​
     # 4) 创建 Frame 实例,用来承载构建配置与状态
     frame = Frame(self.bconfig, config)
 ​
     # 5) 解析命令行与 targets(会落在 frame.parse 里)
     frame.parse(args, self.ext_targets_dir)
 ​
     # 6) 执行构建流程,直接返回其退出码(0 成功,非 0 为错误码)
     return frame.run()

这个函数的作用,可以用一句话概括:

在 Manifest 场景下,frame_build 负责把当前工程上下文和配置封装成一个 Frame 对象,然后交给 Frame.parse() + Frame.run() 来完成真正的构建。


用日志输出确认:已经从 build → frame_build → Frame 了

这次的探针输出,大致是这样一个结构:

从这些日志可以看到几个关键事实:

  1. build() 确实把参数原样传给了 frame_build(argv)

  2. frame_build 又把相同的参数交给了 Frame.parse(...) 做解析;

  3. impl.parse: ...frame.py:107 给出了 Frame.parse 的源码位置;

  4. parse() 打完 BEFORE/AFTER 的日志之后,就会回到 frame_build,继续执行 return frame.run()

也就是说,「集成工程构建」这条路径现在已经变成:

 bingo build -b openUBMC -bt debug
 → Command.build(...)
 → Command.frame_build(...)
 → Frame.parse(...)
 → Frame.run()

下一步:用代码跳转进入 Frame.run

到这里,已经知道:

  • frame_build“集成工程的桥接函数”

  • 真正完整的构建流程入口在 Frame.run()

Frame.run:集成工程构建的最终入口

前面几节一路跟到这里:

bingo build -b openUBMC -bt debug → Command.run(…) → Command.build(…) → Command.frame_build(…) → Frame.parse(…) → Frame.run() ← 现在这一节

在 bmcgo/frame.py 里的 Frame.run(self),可以理解成:

“在集成工程(Frame)里,从 准备环境 → 启动状态服务 → 创建调度器执行目标 → 可选部署 的完整收尾流程。”

这段代码里,有几个“可以直接下结论”的关键点:

Frame.run() 是集成工程构建的最终入口: frame_build() 把 Frame 对象构造好之后,只剩这一句 frame.run()。

真正启动构建的是 create_target_scheduler(…):

传入了 args.target、self.config、self.targets[args.target] 等参数;

返回值赋给 succ,后面所有逻辑都围绕这个布尔值展开。

部署逻辑也在这里收尾:

成功时构造 rootfs_{board_name}.hpm 路径;

在 need_deploy 且文件存在的情况下,调用 Deploy(…).run()。

run 的返回值固定为 0:

正常路径下总是 return 0;

出错则抛异常,由上一层统一转换成非 0 退出码。

现在可以用一句话收束:

对于集成工程(Manifest)场景下的 bingo build -b openUBMC -bt debug,代码执行路径是:

 /usr/local/bin/bingo             # 命令入口脚本
 → bmcgo.bmcgo.run(...)
 → bmcgo.cli.cli.main(...)
 → bmcgo.cli.cli.run(...)
 → Command.run(argv_list)
 → Command.build(...)
 → Command.frame_build(...)
 → Frame.parse(...)               # 解析 YAML + 命令行参数
 → Frame.run()                    # 创建 target 调度器并执行,必要时部署

其中真正“让构建发生”的,是 Frame.run() 里那次对 create_target_scheduler(…) 的调用,以及后续可选的 Deploy.run()。

1. create_target_scheduler 做的就两件事

结合源码,只说肉眼能确认的部分:

所以可以很简单地概括为:

  • 先用 read_config 把目标 YAML(例如 personal.yml)读成 work_list

  • 如果是 dict:

    • target_config 更新配置;

    • environment 设置环境变量;

    • subworks 里拿到要执行的任务树,交给 exec_works

  • 如果是 list:直接把这个列表当成任务列表丢给 exec_works


1.1探针源代码

2. 探针输出证明:确实读的是 personal.yml

探针输出里有这一段:

 ****TARGET YAML OPEN****
 target_file: '/usr/local/lib/python3.12/dist-packages/bmcgo/target/personal.yml'
 ****TARGET YAML LOADED****
 type: dict
 keys: subworks, target_config

这跟源码是一一对应的:

  • 先打印 target_file,说明 read_config 读的正是 personal.yml

  • type: dict + keys: subworks, target_config,说明 work_list 的结构就是:

 work_list = {
     "target_config": {...},
     "subworks":      [...],
     # 可选: "environment": {...}
 }

3. personal.yml 的结构和日志里的树是对得上的

personal.yml 里顶层大概是这样:

而探针里打印的 SUBWORKS TREE 正好是同一棵树的“ASCII 形态”:

所以可以很直观地看到:

  • personal.yml 里的每一个 subworks 节点,都会在日志树里出现一行;

  • name / klass / ignore_not_exist 等字段完全一致。

这里清晰地建立一个“YAML → Python 代码”的映射关系:

  1. 先看 personal.yml 里某个节点的 klass,比如:

     klass: bmcgo.tasks.task_build_rootfs_img
    
  2. 在编辑器里搜索这个模块路径,跳到对应的 Python 文件:

    • 搜索 bmcgo.tasks.task_build_rootfs_img

    • 打开 bmcgo/tasks/task_build_rootfs_img.py

    • 再在文件里找 class TaskClass(Task): 或对应的 run()

  3. 日志的 SUBWORKS TREE 可以充当“运行时确认”:

    • 如果树里能看到 work.task_build_rootfs_img 这一行,说明这一步确实在执行计划里;

    • 如果配合后续 exec_works 的探针,还能看到具体执行顺序。——待研究

一句话总结:

create_target_schedulerpersonal.yml 解析成一个任务树(subworks), 然后交给 exec_works("TOP", ...) 去一个个执行。 想看某一步到底做了什么,就去找它的 klass 指向的 Python 模块。

4 个赞