无线电设备测试自动化框架
流程理解
一、框架做什么
这个框架解决的核心问题是:让一块无线电设备(DUT)从上电到通过 RF 校准测试。
整个过程:上电 → 启动 → 建立连接 → 传输文件 → 启动 Radio 应用 → 跑测试
这些业务逻辑集中在一个核心类里:Radio(common/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 命令(
mamastart、lmclist、mr -s),但串口无法传输文件 - SSH 通道:传输文件(SCP),以及执行需要返回值的命令(
mamacmd --status、md5sum)
建立过程的关键:必须先通过串口给 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 都用替换方式

浙公网安备 33010602011771号