无线电设备测试自动化框架

流程理解

一、框架做什么

这个框架解决的核心问题是:让一块无线电设备(DUT)从上电到通过 RF 校准测试

整个过程:上电 → 启动 → 建立连接 → 传输文件 → 启动 Radio 应用 → 跑测试

这些业务逻辑集中在一个核心类里:Radiocommon/radio.py)。入口脚本(bootup_dut_from_*.py)只负责准备工作(加载配置、建立接口板连接、切换 Flash、控制电源),真正对 DUT 的操作全部由 Radio 类完成。


二、Radio 类 — 框架的业务核心

2.1 Radio 的职责

Radio 类封装了对 DUT 的全部操作:

Radio 对象
├── 双通道通信
│   ├── serial_connection (串口 或 Telnet) → 发送 DUT 命令(mamastart、lmclist 等)
│   └── ssh_connection (SSH/SCP)           → 传输文件、执行远程命令
│
├── Slot 管理        → 选择从哪个固件槽位启动
├── 文件传输         → 把 ELF/SWDB/DC/SQLite/lib 传到 DUT 上
├── Radio 应用启动   → mamastart,让 DUT 进入工作状态
└── 状态检查         → 检查 slot 是否正确、radioapp 是否已运行、文件 MD5 是否一致

关于 serial_connection 参数:虽然参数名叫"串口连接",但实际传入的不一定是真正的串口。
根据接口板类型不同,传入的可能是:

  • SerialConnection(FIC 模式,真正的 COM 口串口直连 DUT)
  • TelnetConnection(NIB/TCPE3 模式,通过接口板的 Telnet 端口转发 DUT 串口)

对 Radio 类来说,它不关心底层是串口还是 Telnet,只要这个对象有 write() 方法能发命令就行。

2.2 Radio 的构造函数 — 一切从这里开始

Radio.__init__(config, serial_connection, slot_nr, loading_files, args,
               logger, external_boot)

构造函数不只是赋值,它会立即执行业务逻辑。创建 Radio 对象的那一刻,DUT 的启动流程就开始了:

def __init__(self, ...):
    # 赋值(省略)...

    if self.restart == RestartMode.NOT_RESTART.value:
        self.__handle_not_restart()    # 不重启模式
    else:
        self.__handle_restart()        # 重启模式

这意味着入口脚本中 radio = Radio(...) 这一行,背后发生了大量操作。


三、Radio 的两条主线

3.1 主线一:NOT_RESTART(restart_mode=0)

场景:DUT 已经在运行,不需要重启,只想检查状态或直接跑测试。

__handle_not_restart()
│
├─→ __correct_slot()
│   │  执行 lmclist 命令,检查当前活跃的 slot 是否是目标 slot
│   │
│   │  lmclist 输出示例:
│   │  Slot  Active  Type
│   │  0     No      AUAPPLIC
│   │  2     Yes     AUAPPLIC  ← 如果 slot_nr=2,匹配,返回 True
│   │
│   ├─ slot 正确 → 继续
│   └─ slot 不正确 → 转入 __handle_restart() 重启流程
│
├─→ __bootstrap_ssh()
│   │  通过串口配置 DUT 网络,然后建立 SSH 连接
│   │  (详见第四节)
│
├─→ [如果 external_boot=True] → return,到此结束
│
├─→ __check_started()
│   │  通过 SSH 执行 mamacmd --status | grep radioapp
│   │  检查 Radio 应用是否已经在运行
│   │
│   ├─ 已运行 → __connect_1524()
│   │             连接测试数据库,直接可以跑测试
│   │
│   └─ 未运行 → mamastart() → __post_mamastart()
│                启动 Radio 应用,然后做后续操作

3.2 主线二:RESTART(restart_mode=1~8)

场景:需要重启 DUT 并替换某些文件。

__handle_restart()
│
├─→ [如果 restart_mode=5 (REPLACE_DC)]
│   │  特殊处理:先切到 AUBOOT 模式传输 DC,再正常重启
│   ├─ __boot_slot_auboot(1)    → mr -s 1 -m AUBOOT
│   ├─ __bootstrap_ssh()
│   └─ __transfer_dc(slot_nr)   → SCP 传输 DC 到 /tmp/slotd_debug_{slot}.fifo
│
├─→ __boot_slot(slot_nr)
│   │  执行 mr -s {slot_nr} -m auapplic
│   │  DUT 重启并从指定 slot 加载 AUAPPLIC
│   │  等待 "login" 提示符(最长 180 秒)
│   │  自动登录 root
│
├─→ __bootstrap_ssh()
│   │  重新建立 SSH 连接(因为 DUT 刚重启)
│
├─→ [如果 external_boot=True] → return,到此结束
│   │
│   │  ⚠️ 这是外部启动和内部启动的分水岭
│   │  外部启动时,Radio 只负责 boot_slot + SSH 连接
│   │  文件传输和 mamastart 由入口脚本在 Flash 切换后单独处理
│
├─→ 根据 restart_mode 传输文件:
│   │
│   │  mode=1 (REPLACE_RADIOAPP_SWDB):
│   │    __transfer_swdb()   → .bin 文件 → /opt/radiosw/srv/database/
│   │    __transfer_elf()    → .elf 文件 → /opt/radiosw/bin/(MD5 不同才传)
│   │
│   │  mode=2 (REPLACE_RADIOAPP):
│   │    __transfer_elf()    → .elf 文件 → /opt/radiosw/bin/(MD5 不同才传)
│   │
│   │  mode=3 (REPLACE_SWDB):
│   │    __transfer_swdb()   → .bin 文件 → /opt/radiosw/srv/database/
│   │
│   │  mode=4 (REPLACE_NONE):
│   │    (不传输任何文件)
│   │
│   │  mode=6 (REPLACE_ET_LIB):
│   │    __transfer_lib()    → .so 文件 → /usr/lib/
│   │
│   │  mode=7 (REPLACE_LIB_SWDB):
│   │    __transfer_lib()    → .so 文件 → /usr/lib/
│   │    __transfer_swdb()   → .bin 文件 → /opt/radiosw/srv/database/
│
├─→ mamastart()
│   │  启动 Radio 应用(详见第五节)
│
└─→ __post_mamastart()
    │  ① linksup -d          → 启动链路监控
    │  ② __transfer_1524()   → SCP 传输 .sqlite → /root/persistent-storage/
    │  ③ __connect_1524()    → etswm sql connect → 连接测试数据库
    │
    │  至此 DUT 完全就绪,可以执行 RF 校准测试

四、__bootstrap_ssh() — 建立双通道通信

Radio 与 DUT 之间有两条通信通道,__bootstrap_ssh() 负责建立第二条:

测试 PC ←──串口/Telnet──→ DUT    (第一条:serial_connection,入口脚本创建)
测试 PC ←──SSH/SCP──────→ DUT    (第二条:ssh_connection,Radio 内部创建)

为什么需要两条通道:

  • 串口通道:发送 DUT 命令(mamastartlmclistmr -s),但串口无法传输文件
  • SSH 通道:传输文件(SCP),以及执行需要返回值的命令(mamacmd --statusmd5sum

建立过程的关键:必须先通过串口给 DUT 配好 IP,才能建立 SSH 连接

def __bootstrap_ssh(self):
    # 1. 通过串口检查 DUT 的网络配置
    outp = self.serial_connection.write("ifconfig eth0")

    # 2. 如果 DUT 还没有 IP,通过串口配置
    if self.ip not in outp:
        if not self.external_boot or self.product.family != 'krypton':
            self.serial_connection.write(f"ifconfig eth0 {self.ip}")
        self.serial_connection.write("ping -c1 192.168.1.1")

    # 3. 建立 SSH 连接
    # NIB 接口板:SSH 连到 NIB(192.168.2.51),NIB 转发到 DUT
    # TCPE3 接口板:SSH 直连 DUT(192.168.1.2x)
    target_ip = self.interface_board.ip_addr if self.interface_board.type == InterfaceBoardType.NIB else self.ip
    client.connect(target_ip, port=22, username='root', password="", timeout=30)

这也解释了整个流程的顺序为什么必须是 boot → login → 配 IP → SSH → 传文件 → mamastart

DUT 上电
  ↓
bootloader 运行(PBOOT → SBOOT)
  ↓
Linux 内核启动,加载基本外设(网络、文件系统等)
  ↓
出现 "login" 提示符 ← 此时只有最基本的系统在运行,应用层还没启动
  ↓
通过串口配 IP → 建立 SSH → 通过 SCP 传文件(串口无法传文件,必须走 SSH)
  ↓
发 mamastart → Radio 应用启动,加载动态库(libetsw.so)和数据库(SWDB)
  ↓
waitradiohal 返回 "HAL is ready" → 应用层完全就绪,可以跑测试

关键理解:login 阶段只加载了最基本的外设(网络接口、文件系统等),
应用层的东西(Radio 应用、动态库、数据库)必须等 mamastart 之后才会加载。
所以文件替换必须在 mamastart 之前完成——替换的文件落在文件系统里,
mamastart 启动时才会去加载它们。


五、mamastart() vs startup_mama() — 两种启动方式

Radio 类有两个启动 Radio 应用的方法,分别用于不同场景:

mamastart() — 内部启动时由 Radio 自己调用

def mamastart(self):
    # hawkowl 产品比较特殊,内部启动也需要做 Flash 准备
    # 其他产品(krypton、mongoose-dc)不需要这些步骤,直接发 mamastart 就行
    if self.product.family == 'hawkowl':
        self.transfer_file(self.loading_files.dc, f"/tmp/{dc_name}")  # 传输 DC 文件
        self.serial_connection.write(f'prepare_onboard_flash "{prod_num}" "{r_state}"')
        self.serial_connection.write("source /run/env/mtdenv")
        self.serial_connection.write("refresh_pid")
        self.serial_connection.write("source /run/env/sysenv")

    self.serial_connection.write("mamastart")          # 启动 Radio 应用
    return self.serial_connection.write("waitradiohal", wait_for='HAL is ready')

hawkowl 产品的特殊性:hawkowl 产品即使是内部启动,也需要做
prepare_onboard_flash 等步骤。这些步骤在其他产品中只有外部启动流程才需要。
其他产品传完文件后直接发 mamastart 就行了。

__handle_restart() 流程中被调用,文件传输完成后自动执行。

startup_mama() — 外部启动时由入口脚本调用

def startup_mama(self):
    self.serial_connection.write("refresh_pid")
    self.serial_connection.write("db list /freqClassUsage")
    self.serial_connection.write("cid")
    self.serial_connection.write("parget --flat /sys/hw/")
    self.serial_connection.write("mamastart")
    self.serial_connection.write("waitradiohal", wait_for='HAL is ready')

外部启动时,Radio 构造函数在 external_boot=True 处 return 了,不会调用 mamastart()。入口脚本在完成 Flash 切换(flash-hotswap + prepare_onboard_flash)后,手动调用 radio.startup_mama()

区别:

  • mamastart() 包含 hawkowl 的 DC 传输和 Flash 准备逻辑
  • startup_mama() 不包含(因为外部启动的 Flash 准备已经在入口脚本的 switch_2_internal_flash() 中完成了)

如何判断启动成功

两个方法最终都会发 waitradiohal 命令。如果在超时时间内串口输出中出现 "HAL is ready",就认为 Radio 应用启动成功;超时则启动失败,抛出异常。


六、文件传输 — Radio 的核心能力

6.1 五类文件及其用途

文件 传输方法 DUT 目标路径 服务对象
ELF (.elf) __transfer_elf() /opt/radiosw/bin/ Radio 应用主程序
SWDB (.bin) __transfer_swdb() /opt/radiosw/srv/database/ Radio 应用的参数数据库
DC (.dc) __transfer_dc() /tmp/slotd_debug_{slot}.fifo 硬件配置描述
SQLite (.sqlite) __transfer_1524() /root/persistent-storage/ etswm 测试框架的用例库
libetsw (.so) __transfer_lib() /usr/lib/ ETSW 团队维护的动态库

6.2 为什么要替换文件而不是重新编包

这是理解 restart_mode 存在意义的关键背景:

编一个完整的 FAAP 镜像(包含所有组件)需要约一个小时,因为镜像非常大。
但日常 debug 时,开发人员通常只改了自己维护的那个模块。
所以框架提供了"只替换某个文件"的能力——通过 SCP 传上去后重启就生效。

历史演变:以前 Radio 应用是一个大的 radioapp.elf,所有功能都在里面,编译一次也要几分钟。
后来做了模块化拆分,各部门维护不同的模块:

  • ETSW 团队维护的部分被拆成了独立的动态库 libetsw.so
  • 其他团队维护 Radio 主程序 radioapp.elf

所以现在有两种替换方式:

  • 替换 ELF(radioapp.elf)→ 更新 Radio 主程序
  • 替换 libetsw(.so)→ 只更新 ETSW 动态库,不动主程序

这些文件传到 DUT 后是落到文件系统里的,重启不会丢失,所以替换后重启就能用新版本。
这比重新编一个完整镜像快得多——编完整镜像要一个小时,替换一个动态库只要几秒。

只有 deliver 给其他团队使用时才需要编完整镜像,自己 debug 过程中都是用替换的方式。

6.3 restart_mode 的多种模式就是为了灵活替换

这么多模式的原因:新老产品交替 + 不同团队维护不同模块,需要灵活组合替换哪些文件。

模式 替换什么 场景
0 NOT_RESTART 不替换 设备已在运行,只想跑测试
1 REPLACE_RADIOAPP_SWDB ELF + SWDB 主程序和参数库都改了
2 REPLACE_RADIOAPP 仅 ELF 只改了主程序(老产品模式,以前所有功能都在 ELF 里)
3 REPLACE_SWDB 仅 SWDB 只改了参数库
4 REPLACE_NONE 不替换,仅重启 只想重启,不更新文件
5 REPLACE_DC DC 文件 更新了硬件配置
6 REPLACE_ET_LIB libetsw 只改了 ETSW 动态库(新产品模式,最常用的 debug 方式)
7 REPLACE_LIB_SWDB libetsw + SWDB 动态库和参数库都改了

6.4 智能传输:MD5 校验避免重复

ELF 文件传输前会先比对 MD5,如果 DUT 上已有相同版本就跳过:

def __handle_replace_radioapp(self):
    if not self.__check_correct_file('/opt/radiosw/bin/radioapp.elf', self.loading_files.elf):
        self.__transfer_elf()    # MD5 不同才传输

def __check_correct_file(self, remote_file, local_file):
    # SSH 执行 md5sum 获取 DUT 上文件的 MD5
    remote_md5 = ssh.exec_command(f"md5sum {remote_file}")
    local_md5 = md5(local_file)    # 计算本地文件 MD5
    return remote_md5 == local_md5

6.5 大文件传输:进度监控 + 自动重试

transfer_file() 方法(公开方法,区别于私有的 __transfer_file())用于传输大文件(如 DC 文件),带进度监控和失败重试:

def transfer_file(self, local_filename, remote_filename, max_retries=3):
    for attempt in range(max_retries):
        # 在子线程中执行 SCP 上传
        thread = threading.Thread(target=upload)
        thread.start()

        # 主线程轮询远端文件大小,显示进度
        while thread.is_alive():
            remote_size = ssh.exec_command(f"stat -c%s {remote_filename}")
            percent = (remote_size / file_size) * 100
            logger.info(f"Progress: {percent:.1f}%")

        # 失败则重连 SSH 后重试
        if upload_error and attempt < max_retries - 1:
            self.__bootstrap_ssh()

6.6 restart_mode 与文件传输对照表

restart_mode ELF SWDB DC libetsw SQLite
0 NOT_RESTART 连接
1 REPLACE_RADIOAPP_SWDB
2 REPLACE_RADIOAPP
3 REPLACE_SWDB
4 REPLACE_NONE
5 REPLACE_DC
6 REPLACE_ET_LIB
7 REPLACE_LIB_SWDB

(SQLite 在所有模式下都会在 __post_mamastart() 中传输和连接)


七、Slot 管理 — DUT 上的固件槽位

DUT 内部 eMMC 上有多个 slot,每个 slot 可以存放不同版本的固件。Radio 类通过以下方法管理 slot:

__correct_slot() — 检查当前 slot

def __correct_slot(self):
    outp = self.serial_connection.write("lmclist")
    # 解析输出,找到 Active=Yes 且 Type=AUAPPLIC 的行
    # 如果其 slot 编号等于 self.slot_nr,返回 True

__boot_slot(slot_nr) — 切换到指定 slot

def __boot_slot(self, slot_nr):
    self.serial_connection.write(f'mr -s {slot_nr} -m auapplic', wait_for="login", timeout=180)
    # mr = module restart,-s 指定 slot,-m 指定模式
    # DUT 会重启并从指定 slot 加载 AUAPPLIC
    self.serial_connection.write("root")  # 自动登录

__boot_slot_auboot(slot_nr) — 切换到 AUBOOT 模式

def __boot_slot_auboot(self, slot_nr):
    # AUBOOT 是一个精简的引导模式,用于 DC 文件热更新
    # 先检查是否已经在 AUBOOT 模式,避免不必要的重启
    self.serial_connection.write(f'mr -s {slot_nr} -m AUBOOT', wait_for="login")

AUBOOT 模式只在 REPLACE_DC(mode=5)时使用:先切到 AUBOOT 传输 DC 文件,再切回 AUAPPLIC 正常启动。


八、external_boot 参数的影响

external_boot 是贯穿 Radio 类的核心开关,它改变了多个方法的行为:

                    external_boot=True              external_boot=False
                    (外部启动)                       (内部启动)
                    ─────────────────               ─────────────────
__handle_restart:   boot_slot + SSH → return        boot_slot + SSH → 传文件 → mamastart
__handle_not_restart: SSH → return                  SSH → 检查状态 → 按需 mamastart
__bootstrap_ssh:    Krypton 不配 IP                 始终配 IP
mamastart:          不被调用                         被调用
__post_mamastart:   不被调用                         被调用
文件传输:            不执行                           根据 restart_mode 执行

为什么外部启动时 Radio 要提前 return?

因为外部启动的流程是分两阶段的:

阶段一(Radio 负责):boot_slot + SSH 连接
    ↓
阶段二(入口脚本负责):Flash 切换 + prepare_onboard_flash + startup_mama

Radio 在阶段一建立好 SSH 连接后就停了,把控制权交还给入口脚本。入口脚本完成 Flash 从外部切到内部的操作后,再调用 radio.startup_mama()radio.transfer_file() 来完成剩余工作。


九、入口脚本 vs Radio 类的分工

入口脚本的职责(main 函数)              Radio 类的职责
──────────────────────────              ──────────────────────────
加载产品配置                             Slot 检查和切换
建立接口板连接(Telnet/SSH)             建立 DUT SSH 连接
控制电源(上电/断电/循环)               文件传输(ELF/SWDB/DC/SQLite/lib)
Flash 模式切换(bm ext/int, GPIO)      启动 Radio 应用(mamastart)
flash-hotswap(外部启动时)              状态检查(slot/radioapp/MD5)
prepare_onboard_flash(外部启动时)      测试数据库连接(etswm sql connect)
调用 RF 测试用例                         提供 SSH 通道给测试用例使用

简单说:入口脚本管"接口板和电源",Radio 类管"DUT 本身"


十、完整调用链示例

以内部启动 internal_boot_faap_via_tcpe3.py -r 1 -s 2 为例:

main()
│
├─ config = ProductConfigManager.get_product_config('AIR1672')
├─ loading_files = parse_restart_mode(args)     → 扫描目录,加载 .elf/.bin/.sqlite/.dc
├─ interface_board = TelnetConnection(3000)     → 连接 TCPE3 控制端口
├─ tcpe_swtich_to_internal_flash()              → 发送 "bm int"
├─ gpib.power_cycle()                           → 断电 → 上电
├─ dut_serial = TelnetConnection(3001, "mongoose-dc login")  → 等待 DUT 启动
│
├─ radio = Radio(config, dut_serial, slot=2, loading_files, args, external_boot=False)
│  │
│  │  ┌─ Radio.__init__()
│  │  │  restart_mode=1, external_boot=False
│  │  │  → 进入 __handle_restart()
│  │  │
│  │  ├─ __boot_slot(2)
│  │  │  → serial: "mr -s 2 -m auapplic"     DUT 重启到 slot 2
│  │  │  → serial: "root"                     登录
│  │  │
│  │  ├─ __bootstrap_ssh()
│  │  │  → serial: "ifconfig eth0 192.168.1.21"  配置 DUT IP
│  │  │  → SSH 连接到 192.168.1.21:22
│  │  │
│  │  ├─ [external_boot=False, 不 return]
│  │  │
│  │  ├─ __handle_replace_radioapp_swdb()     ← restart_mode=1
│  │  │  → __transfer_swdb()                  SCP: .bin → /opt/radiosw/srv/database/
│  │  │  → __check_correct_file()             SSH: md5sum /opt/radiosw/bin/radioapp.elf
│  │  │  → __transfer_elf()                   SCP: .elf → /opt/radiosw/bin/(如果 MD5 不同)
│  │  │
│  │  ├─ mamastart()
│  │  │  → serial: "mamastart"                启动 Radio 应用
│  │  │  → serial: "waitradiohal"             等待 "HAL is ready"
│  │  │
│  │  └─ __post_mamastart()
│  │     → serial: "linksup -d"               启动链路监控
│  │     → __transfer_1524()                  SCP: .sqlite → /root/persistent-storage/
│  │     → __connect_1524()                   serial: "etswm sql connect *.sqlite"
│  │
│  │  Radio 构造完成,DUT 完全就绪
│
├─ pa_cal(radio, branch)                       → etswm run tests -g pa_cal
├─ rx_cal(radio, branch)                       → etswm run tests -g rx_cal
├─ rx_perf(radio, branch)                      → etswm run tests -g rx_perf
└─ tx_cal(radio, branch)                       → etswm run tests -g tx_non_spectrum

十一、总结

Radio 类是整个框架的业务核心。入口脚本只是"开门的"(配置、连接、切 Flash、控电源),Radio 类才是"干活的"——它管理 DUT 的 slot 切换、SSH 连接、文件传输、Radio 应用启动和测试数据库连接。external_boot 参数决定了 Radio 是"全程负责"(内部启动)还是"只管前半段"(外部启动)。

整个流程的顺序:boot → login → 配 IP → SSH → 传文件 → mamastart → 测试

  • login 阶段只有基本外设在运行(网络、文件系统),应用层要 mamastart 之后才加载
  • 串口只能发命令不能传文件,所以必须先通过串口配 IP、建立 SSH,才能用 SCP 传文件
  • 文件替换而不是重新编包,是为了节省编译时间(完整镜像要一个小时,替换动态库只要几秒)
  • 替换的文件落在文件系统里,重启不会丢,mamastart 启动时会加载新版本
  • 多种 restart_mode 是因为新老产品交替、不同团队维护不同模块(以前全在 ELF 里,现在拆分成 ELF + 动态库),需要灵活选择替换哪些文件
  • 只有 deliver 给其他团队时才编完整镜像,自己 debug 都用替换方式
posted @ 2026-04-15 10:23  mo686  阅读(2)  评论(0)    收藏  举报