一个监控模拟器要做到什么程度才算"够用"?我的答案是:当你能把每天重复几十遍的机械操作录成一段脚本,一键跑完的时候,这个模拟器才真正从"玩具"变成了"工具"。
这是我做 Pelco KBD300A 模拟器系列的第 7 篇。前 6 篇我陆陆续续把键盘面板、LCD 显示、摇杆手感、协议收发都跑通了,但当时我面临一个很现实的问题:每次联调测试都要手工按一堆键——调预置位、拉变焦、切巡视、改波特率——一天几十遍下来,手指比脑子先罢工。于是我开始给这个模拟器设计一个"宏脚本"能力:把一串键盘操作写成一段可编辑的文本脚本,解释执行后让虚拟键盘自己"按"出来。
这篇文章就把这部分的设计过程和实现思路完整拆开。内容包括指令集如何设计、编辑器用什么样的数据模型、解释器怎么组织状态、以及最容易翻车的时序和错误处理。如果你也在做设备模拟器、开发工具链或者任何需要"脚本化操作序列"的东西,这篇应该能给你一些可以抄作业的方案。
先说清楚这个宏脚本在整体架构里的位置。KBD300A 模拟器本质是一个"虚拟键盘",它的核心输出是 Pelco-D / Pelco-P 协议报文,最终送到矩阵或球机。宏脚本不直接生成报文,它生成的是"键钮状态变化序列"——比如某个时刻自动按下 "FOCUS NEAR" 键、某个时刻摇杆往右上推了多少。真实键盘产生这些操作靠的是人手,宏脚本产生这些操作靠的是解释器。这样一个分层的好处是:协议层复用,宏脚本层独立,调试时可以分别在两层设断点。
1. 宏语言设计的取舍:为什么不做"按键录制回放",而是做一套脚本指令集
早期其实有个更省事儿的方案:按键录制。就是把用户按下的每个键、每个时间间隔原样存下来,然后顺序回放。我当时真的试过这个方案,在一个白模上跑通了,但用了不到两天就放弃了——原因有三个。
第一是录制回放没法表达"变焦持续 2 秒"这种参数化操作。你录下的是"按住 WIDE 键 800 毫秒",但换一台球机、换一种焦距状态,这 800 毫秒完全不够用。我需要的是"按 WIDE 键持续 2 秒"这种可改参数的语义,而不是一段死的时序采样。
第二是录制回放没法做逻辑控制。真实运维场景里常有"如果当前协议是 Pelco-P 就用 A 方案,否则用 B 方案"这类需求。为了一个交互操作去改代码再编译,太重了。
第三是回放结果难维护。录一段 5 分钟的操作,中间一个键按错了,整个重录。文本形式的脚本至少还能 diff、能注释、能版本管理。
所以我最终定下来的宏脚本是一套基于键盘原语的命令式指令集。它不追求通用编程语言的能力,只覆盖 KBD300A 键盘能输出的全部操作,外加一些顺序控制、延时和变量。
整个指令集按功能分四类,我列个表:
| 指令类别 | 代表指令 | 作用范围 |
|---|---|---|
| 按键类 | KEY_PRESS, KEY_HOLD, KEY_RELEASE | 面板按键、数字键、功能键 |
| 摇杆类 | JOY_SET, JOY_RESET | 云台方向、速度,模拟摇杆量 |
| 时序类 | WAIT, WAIT_PROTOCOL_ACK | 延时、等待协议回复 |
| 参数/控制类 | SET_PRESET, CALL_PRESET, RUN_PATTERN, SET_VAR | 设置变量、调用预置位/巡视 |
为什么用"KEY_PRESS/KEY_HOLD/KEY_RELEASE"三个指令而不是一个"KEY_PRESS"带时长参数?因为底层事件模型就是三段的。我在前几篇实现键盘扫描的时候,按键状态机天然就是按下、保持、释放三个事件。宏指令与底层事件一一对应,解释器几乎不需要做语义转换,每一行指令都能直接翻译成一个或多个内部事件。
这里有一个关键设计点:宏脚本层不感知协议细节。也就是说脚本里写 CALL_PRESET 1,不代表模拟器立刻发一条 Pelco-D 的预置位调用指令。它要做的是"模拟操作员在键盘上按下 [PRESET] 键 -> 按 [1] -> 确认"这个过程,协议报文是这个过程执行后由协议栈自然产生的。这样切到 Pelco-P、切换到其它协议族,宏脚本完全不用改。
这个设计当时把我从一个大坑里捞了出来。因为我最早的想法是让宏脚本直接调协议层 API——比如"发送预置位指令 0x00 0x0F 0x00 0x01"。后来我发现,散热风扇噪声、屏幕提示、操作员确认对话框这些交互行为全都绕不过去了。直接调协议 API 就像你写自动化测试时直接调内部函数而不是走用户界面,测的是协议转换,不是"操作流程"。而宏脚本的价值恰恰在"流程"。
2. 指令集设计与语法结构:AST 是怎么长出来的
指令集的语法我参照了常见的脚本语言风格,没有发明新东西。每条指令一行,指令名大写,参数间用空格或逗号分隔。注释用分号开头。
一个完整的宏脚本长这样:
; 宏示例:调用 1 号预置位,然后执行 2 号巡视,最后回到初始状态 SET_VAR speed 60 CALL_PRESET 1 WAIT 800 RUN_PATTERN 2 WAIT_PROTOCOL_ACK WAIT 300 JOY_RESET KEY_PRESS FOCUS_NEAR HOLD 500 RELEASE FOCUS_NEAR从解析的角度看,这里面有几种语法模式:
- 无参数指令:
JOY_RESET - 单参数指令:
CALL_PRESET 1 - 双参数指令:
SET_VAR speed 60 - 带隐含状态的三段指令:
KEY_PRESS之后必须跟HOLD再跟RELEASE
解析器我用了手写递归下降,没用 Lex/Yacc 或 ANTLR 这类生成器。原因很实在:这个语言的语法极其简单,手写解析器不到 300 行就能覆盖,还能完全控制错误提示的格式。对这种嵌入式 DSL 来说,引入一套完整的 parser generator 是杀鸡用牛刀,反而会给后续打包带来依赖麻烦。
语法层的产出是一棵 AST(抽象语法树),但在这个设计里 AST 做得很"扁"。每个语法节点就是一条指令,节点的子节点是指令的参数表达式。我贴一下核心节点定义:
# 指令节点的核心结构 class MacroNode: def __init__(self, cmd_type, args=None, lineno=0): self.cmd_type = cmd_type # 指令类型,如 'KEY_PRESS' self.args = args or [] # 参数列表,如 ['speed', 60] self.lineno = lineno # 源码行号,用于报错定位 def __repr__(self): return f"{self.cmd_type}({', '.join(map(str, self.args))})"AST 出来之后,后面要做两步。第一步是静态校验,第二步才是解释执行。静态校验很像编译器前端的类型检查,但做的更轻——只查三类问题:
参数个数与类型:CALL_PRESET后面到底是不是一个整数,SET_VAR的第一个参数是不是合法的变量名,WAIT的时间值有没有超出范围(我限制在 0 到 60000 毫秒)。
指令上下文合法性:KEY_PRESS指令在宏脚本顶层可以出现(表示先按下),但是HOLD和RELEASE的前面必须有一个未释放的按键或摇杆动作。简单说就是维护一个"当前曾经按下但未释放"的上下文栈。这个栈在静态检查阶段是用来查错的,在解释执行阶段则被复用成了实际的键盘事件状态机。
变量作用域:SET_VAR创建的变量只在本宏内有效。如果引用了未定义的变量,静态检查阶段就该报错,而不是等到运行时才触发。变量类型我只支持整数和字符串两种。为什么不做布尔类型?因为所有条件控制的场景都能用整数 0/1 表达,布尔类型只会让指令解析多一层映射。
静态检查的执行我放在宏编辑器的"保存"按钮里。用户点保存的时候,脚本经过"解析 -> 构建 AST -> 静态检查"三个步骤。任何一个步骤不过,都只保存不了,而且在对应的行号上给红色波浪线提示。这个体验跟 IDE 的语法检查几乎一致,用户不需要运行宏就能发现自己写的脚本哪里有问题。
我特别看重行号定位。因为宏脚本是文本文档,用户更关心"哪一行写错了",而不是"哪个 token 错了"。AST 里保存 lineno 这个字段,就是为了在编辑器侧做错误提示时能精确到行。错误信息的格式要尽量朴素直白——"第 12 行:CALL_PRESET 需要一个整数参数,但收到 'abc'"。这种格式机器能读,人也看得懂。
3. 编辑器数据模型与界面逻辑:脚本文本和操作命令之间的映射
宏脚本编辑器不是一个用来"写作文"的纯文本编辑器,它得同时承担两个角色:对人工输入提供补全和校验,对自动化操作提供结构化数据入口。我的编辑器窗口分成三个区域:
- 左侧是指令面板,罗列全部可用指令,点一下就能插入到光标处
- 中间是脚本编辑区,带行号、语法高亮和错误提示
- 右侧是参数检查区,显示当前光标所在指令的参数格式和合法范围
这个布局的灵感其实来自我平时用的交易终端脚本编辑器。指令面板解决"记不住指令名"的问题,参数检查区解决"记不住参数范围"的问题。对新手友好,对老手也不碍事。
但编辑器真正的核心不是界面,而是数据模型。我用了 Qt 的 QTextDocument 作为底层文本存储,然后在这个基础上叠加了一层"指令模型"。指令模型的作用是,把文本内容实时解析成结构化指令列表,并在界面上做同步映射。
关键点在于我是如何做"实时解析"的。QTextDocument 的变化信号驱动解析器重跑,解析结果的 AST 塞回一个 view-model 层,view-model 再把指令列表关联给语法高亮器和错误提示层。这里有个性能问题:如果用户每次击键都重跑完整解析,文本长的时候会卡。
我用了一个很土但有效的优化——防抖(debounce)。文本停止变化 400 毫秒后才触发一次全量重解析。用户连续输入的时候不会频繁解析,停顿下来才会检查。实际测试下,一个 200 行的宏脚本,重解析耗时稳定在 10 毫秒以内,完全无感。
还有一个设计是"文本与指令双向映射":用户既可以在编辑区改文本,也可以通过左侧面板的指令列表拖拽排序。拖拽操作背后改的其实是文本——把对应行的指令文本重新排列并重写回 QTextDocument。这样双向修改的最终结果都收敛到文本本身,避免出现"文本内容与指令列表不一致"的两个真相。这个我踩过坑,最开始试过维护一份独立的指令对象列表,结果用户在文本区改了参数,指令列表里还是旧值,运行宏的时候跑的又是另一套,细节不同步非常折磨人。
语法高亮我用的是 QSyntaxHighlighter 的子类。高亮规则分三层:注释用灰色斜体,指令名用深绿色粗体,参数用蓝色,数字用橙色。高亮器本身不会修改文本,它只负责根据文本内容生成格式化块。这意味着高亮器必须在解析器之后运行,并且复用解析结果。我把解析结果缓存到高亮器内部,这样避免高亮时重新解析一遍。
在这个编辑器里还有一个我特别满意的功能:指令悬停提示。鼠标悬停在指令名上时,弹出一个 tooltip,说明这个指令的作用和参数格式。这个 tooltip 的内容不是硬编码的,而是从指令注册表里动态读取的。指令注册表是一个 Python 字典,定义每条指令的名称、参数格式、说明和取值范围。编辑器、解析器、高亮器都共用这一份注册表,所以指令集的修改只需要改一个地方,三处同步生效。
# 指令注册表的简化结构 INSTRUCTION_REGISTRY = { 'CALL_PRESET': { 'arg_spec': [('int', 1, 255)], 'desc': '调用指定编号的预置位', 'example': 'CALL_PRESET 1', }, 'SET_VAR': { 'arg_spec': [('var_name',), ('int|str',)], 'desc': '设置宏变量', 'example': 'SET_VAR speed 60', }, # ... 其它指令 }这套注册表的设计让编辑器左侧的指令面板也是自动生成的。遍历注册表,按类别分组,生成按钮列表。后面我如果给模拟器新增指令,只要在注册表里加一条,编辑器左侧面板自动出现新按钮,高亮器自动识别新指令名,检查器自动校验新参数格式。零额外维护成本。
4. 解释器核心:一个带状态机的执行引擎
宏解释器的实现,我没有做成"逐行读取 -> 逐个执行"这种最简单的直译模式,而是用了基于状态机的执行引擎。这是我觉得整个项目里最重要也是设计得最值的一个组件。
为什么不用简单的顺序执行?因为宏脚本经常要在运行中被用户打断。真实键盘上操作员随时可以用摇杆抢占控制权——如果宏脚本正在执行 RUN_PATTERN 2,这时候操作员拨了一下摇杆,宏脚本应该立刻让出控制权,把键盘交还给操作员手动控制。顺序执行的解释器对"抢占"的处理很麻烦,你得在每步执行间隙检查中断标志,代码到处都是 if-break。状态机的模式天然适合这种场景:每个状态检查是否收到抢占事件,收到就状态迁移到"中断"并退出。
执行引擎的状态枚举如下:
| 状态 | 含义 | 进入条件 | 退出条件 |
|---|---|---|---|
| IDLE | 空闲,无宏运行 | 初始化/宏结束 | 收到宏启动命令 |
| PARSING | 解析中 | 启动宏 | 解析完成,AST 构建成功 |
| CHECKING | 静态检查中 | 解析完成 | 检查通过/报错 |
| RUNNABLE | 待执行 | 检查通过 | 执行引擎启动 |
| EXECUTING | 执行中 | 引擎启动 | 所有指令执行完/出错 |
| WAITING | 等待中(延时时/等协议回复) | 执行到 WAIT/WAIT_PROTOCOL_ACK | 延时超时/收到协议回复 |
| INTERRUPTED | 被用户手动打断 | 运行中收到抢占事件 | 状态复位到 IDLE |
| ERROR | 运行出错 | 指令执行时报错 | 用户确认/重新编辑 |
这个状态机的核心逻辑在一个MacroInterpreter类里,每个 tick(我固定在 10 毫秒一次)驱动一次状态流转。EXECUTING 状态下每次 tick 执行一条指令,执行完检查是否有待处理的用户抢占事件。WAITING 状态下每次 tick 减少剩余等待时间,减到零就回到 EXECUTING。
WAIT_PROTOCOL_ACK这个指令要特别说明一下。它依赖协议栈的应答回调。解释器在进入 WAITING 状态之前会注册一个一次性回调,当协议栈收到目标设备的应答报文时,回调把状态机提前唤醒。这样就实现了"宏脚本可以等待设备实际响应再继续执行",而不是盲目延时。
执行引擎的指令分发用的是注册器模式,跟编辑器的指令注册表对应起来。每条指令对应一个 handler 函数,handler 的签名是这样:
def handle_call_preset(ctx, args): # ctx 是执行上下文,里面包含: 当前状态、变量表、键盘事件队列 preset_id = int(args[0]) ctx.key_event_queue.push(KeyEvent('KEY_PRESS', 'PRESET')) ctx.key_event_queue.push(KeyEvent('KEY_PRESS', str(preset_id))) ctx.key_event_queue.push(KeyEvent('KEY_PRESS', 'ENTER')) return ExecStatus.CONTINUE注意这里的 handler 并不直接发送协议报文,它只是往"键盘事件队列"里塞事件。这个事件队列会被模拟器的键盘扫描层消费——也就是说,宏执行的指令最终和真实键盘按键走的是同一条路径。这样做的好处非常明显:你不需要为宏脚本单独验证一遍键盘逻辑,你只需要验证"键盘事件队列 -> 协议报文"这条链路人手操作时是对的,宏脚本等于就是自动的人手操作,天然正确。
虚拟键盘的摇杆指令JOY_SET稍微复杂一点,因为它涉及到一个速度向量。KBD300A 的摇杆在按压时输出的是 X/Y 方向的比例量,模拟器实现里是一个包含 dx、dy 和 pressed 三个字段的结构体。宏脚本的JOY_SET指令就是设置这个结构体的值,JOY_RESET则是清零且释放按压状态。
执行上下文的变量表也很简单。变量存储在ctx.vars字典里,整数和字符串混存。变量的作用域是"当前宏一次运行过程",也就是说每次宏启动都会重置变量表。这个设计是有意为之——宏脚本不应该有能力把状态泄漏到下一次运行。如果你确实想跨宏保存状态,那应该去改模拟器的配置,而不是依赖宏变量。这是我在使用中明确的一个边界。
5. 时序设计:为什么宏脚本的延时最小粒度是 10 毫秒
宏脚本里出现最多的指令大概就是WAIT了。调完预置位要等云台转动到位,切完协议要等串口重新握手,按完一个键要等 LCD 刷新。时序设计一旦不对,整个宏脚本像是喝醉的人在操作键盘。
这里要说明一个基本事实:真实操作员的按键间隔是百毫秒级别的,但指令产生内部事件的间隔可以远小于这个值。我给宏脚本设计的最小延时单位是 10 毫秒,原因有两个。
第一是模拟器的主循环 tick 就是 10 毫秒。每次 tick,键盘扫描层、LCD 刷新层、协议发送层都会各自跑一遍。如果宏脚本支持小于 10 毫秒的延时,就必须在一个 tick 里处理多条宏指令,这会跟主循环的时间片模型冲突,引入不必要的复杂度。
第二是 10 毫秒已经足够平滑地模拟摇杆动作。比如JOY_SET dx 100 dy 0和后续的JOY_RESET之间如果只隔 10 毫秒,产生的影响就是摇杆被"点"了一下,而不是"推"了一下。实际测试中 10 毫秒的精度肉眼根本分辨不出与 20 或 30 毫秒的差异。但如果放到 100 毫秒级别,摇杆操作就会显得非常顿挫。
所以我的宏语义是这样规定的:每条指令的执行时刻都是上一指令执行时刻 + 该指令自己声明的延时。指令本身不维护执行时刻,而是用序列化的顺序自然推进。一个WAIT 800会让后续指令在 800 毫秒后才执行,一个JOY_SET如果没有后续的JOY_RESET,那它产生的摇杆状态会一直保持到宏结束。这个语义直观,也好调试。
这里有一个实际的时序坑:协议超时重发机制与宏延时的相互作用。KBD300A 在发送 Pelco-D 指令后如果没收到应答,会在 1 秒后重发一次。如果宏脚本用WAIT 1500等待协议应答,协议栈可能在 1000 毫秒处超时重发,重发后的应答又会在 1500 毫秒之后才收到——这样整个流程会向后滑动 500 毫秒。如果你的宏脚本严格依赖时序,这个问题会表现为"偶尔这次执行比上次慢半拍"。
我的解决办法是让WAIT_PROTOCOL_ACK成为所有等待协议场景的推荐指令,因为它不是固定延时,而是等到事件到达才继续。如果收到超时重发导致的延迟 ACK,状态机会在 ACK 到达时立即唤醒,而不是傻等。只有当你要模拟"操作员主观停顿"这种真实人为节奏时,才用固定WAIT。这个语义区分在宏脚本的文档里写得非常明确。
6. 调试支持:单步执行、宏运行状态面板与断言指令
宏脚本运行出问题的时候,如果只有一个"第 N 行出错"的错误弹窗,那调试体验是非常痛苦的。我给宏解释器加了三个调试支持,在实际排错过程中帮了大忙。
第一个是单步执行。宏脚本可以在编辑器中按行打断点,运行到断点处暂停,然后按 F10 单步执行。单步执行的本质是让状态机在 EXECUTING 状态下每次 tick 只执行一条指令,同时把当前的 AST 节点位置同步到编辑器高亮行。这样用户能看到脚本走到哪一步了。单步模式下如果遇到 WAIT 指令,会弹出提示让用户选择"跳过等待"或"实际等待"。这个功能在处理长延时宏的时候非常重要——调试时没人愿意真的等 10 秒。
第二个是运行状态面板。面板上实时显示当前状态机状态、当前指令、当前变量表、以及最近的 10 条键盘事件。这个面板帮我在排查问题时立刻区分:是宏逻辑错了,还是键盘事件产生了但协议层没发出去。有了事件日志,很多问题一眼就能定位。比如我曾经调了一个"宏执行到一半效果对、后一半全无效"的问题,打开事件日志发现是某个按键的队列塞太满,后续事件被键盘扫描层的缓冲丢弃了。这是时序问题,不是逻辑问题,没有事件日志根本发现不了。
第三个是断言指令ASSERT。这算是我给宏脚本语言扩的一个小能力。ASSERT 接受一个条件参数,条件不满足时宏立即终止并上报错误。这个指令给自动化测试提供了极大的便利。比如我可以写ASSERT VAR_EQ mode 2,如果模式不对就终止。作为一个模拟器项目,宏脚本不只是给用户用的,也可以被项目的自动化集成测试调用。ASSERT 让这些测试有了"通过/失败"的判定依据。
; 自动化测试片段 SET_VAR protocol_ok 0 CALL_PRESET 1 WAIT_PROTOCOL_ACK ASSERT VAR_EQ protocol_ok 1上面这段脚本配合测试框架,就可以自动验证"调用预置位后协议是否正常应答"。宏脚本从"给用户偷懒用的工具"升级成了"项目质量的守护者"。
宏执行的性能数据我也摸了底。一段 100 条指令的宏,在没有 WAIT 阻塞的情况下,约 1 秒内执行完毕(每条指令 10ms 的生效周期),加上协议事件的异步扩散,整体在 1.5 秒内完成。作为对比,人工操作同样的流程大概需要 15 秒。这个提速效果给我的直观感受是:原来联调一个预置位流程要反复按十几下,现在跑一段宏脚本,喝口水的功夫就验证完了。
7. 宏指令到协议报文的完整一次执行链:拿"调用预置位"串一遍
光讲抽象设计,不如把一条具体指令走一遍完整链路。我用宏脚本里最常见的CALL_PRESET 1举例,把从解释器执行到最终网络报文发送的整个过程串起来。
第一步,解释器执行到CALL_PRESET 1这行,调用注册表里的handle_call_presethandler。这个 handler 做的事情刚才已经说过了,它往键盘事件队列里顺序推入三个按键事件:PRESET 键按下、数字 1 键按下、ENTER 键按下。
第二步,模拟器主循环的下一个 tick(10 毫秒内)会取走键盘事件队列里的第一个事件。键盘扫描层把这个事件转成"按键状态变化",更新虚拟键盘的按键状态矩阵。注意这一步是关键分界线:宏脚本产生的按键事件到这里,和真实物理按键产生的扫描结果就完全一样了。
第三步,虚拟键盘的按键状态矩阵更新后,键盘处理逻辑检测到 PRESET 键被按下,触发 LCD 进入预设调用模式,同时把模式状态同步给协议编码层。协议编码层收到"调用预置位"的动作,生成一条 7 字节的 Pelco-D 报文——地址、命令码、数据位、校验位。
第四步,报文通过模拟器配置的串口或 TCP/UDP 通道发出去。这一步走的是我在系列第 4 篇做的链路层,协议编码的结果会显示在模拟器的日志窗口里。
第五步,如果目标设备正常响应,应答报文回传,协议栈触发应答回调。如果宏脚本这条指令后面跟着WAIT_PROTOCOL_ACK,此时状态机被唤醒,继续执行后续指令。
完整链路从宏指令到报文出口,耗时在 10 到 50 毫秒之间,大头是键盘事件队列的消费节奏和协议栈处理时间。这个延迟对于模拟真实操作来说完全可接受,而且链路中任何一环出问题,事件的流水痕迹都会留在运行状态面板里,查起来非常方便。
给个具体的协议报文示例,调用预置位 1 的行为(假设地址为 1)产生的报文是:
FF 01 00 0F 00 00 01其中0x00是命令字节的"无动作"位,0x0F是"调用预置位"的高位命令,0x00 0x00是数据位,0x01是预置位编号(严格说某些厂家实现里数据位另有含义,这里按我模拟器的实现来)。这个报文不是宏脚本直接生成的,是宏脚本模拟按键之后,协议栈根据键盘状态自动生成的。理解这条链路的读者应该能感受到,宏脚本的本质就是"给虚拟键盘装上了一个会按顺序操作的手指"。
8. 不足与后续打算:宏脚本的边界、变量扩展和协议抽象层重构
这个宏脚本系统目前已经跑通了主流程,在日常联调里用得很顺手。但我知道它有几个明显的薄弱点,将来如果继续往下发展,我的改进顺序已经排好了。
第一个薄弱点是循环能力。目前宏脚本语言只有顺序指令和变量,没有LOOP或跳转。这意味着如果你要重复执行某段操作 10 次,就得在脚本里写 10 遍。后续我会加上一个简单的计次循环REPEAT n ... END_REPEAT,解析器加两个节点,执行引擎加一个循环上下文栈,工作量不大。更复杂的 while 循环我暂时不做——宏脚本是给监控操作准备的,无限循环在真实操作里有风险,万一脚本写疯了可能让云台一直转下去。
第二个薄弱点是真条件分支。现在ASSERT只能判定错误终止,不能做条件跳转。后续会指令集加一个IF_VAR配合JUMP_TO标签使用,支持 "如果协议是 P 就做 A 否则做 B" 这种逻辑。但我对这块比较谨慎,因为条件分支一多,宏脚本就变得像程程序语言了,调试成本和用户学习成本都会上升。我更倾向于提供一个INCLUDE指令,让用户把公共片段提取成子宏,整体复用。子宏放在一个公共脚本目录下,可以在任何一个宏里被INCLUDE调用。这个方案能覆盖大多数复用需求,又不需要引入函数和栈帧。
第三个薄弱点是协议抽象层。目前模拟器主要支持 Pelco-D/P 两种协议族,将来如果扩展到 ONVIF 或其它私有协议,宏脚本指令集是否需要跟着扩充?我在指令注册表里预留了协议字段,每条指令可以标注适用的协议族,编辑器会在指令面板里自动过滤掉当前不支持的指令。这套小机制已经为将来的协议扩展做好了准备,避免宏脚本改语法又重新设计的麻烦。
最后一个想说的是宏脚本的持久化格式。我没有用自定义二进制格式,就直接用纯文本的.macro文件。文本格式的好处是能 diff、能注释、能放进 git 里做版本管理。坏处是如果有人误改了编码(比如用 Windows 记事本存成 UTF-8 BOM),第一行解析会带上不可见字符导致报错。我的编辑器里做了 BOM 自动剥离和数据无损写入,但如果你在自己的项目里实现类似功能,记得在文件读取入口和保存出口各加一个编码归一化处理。别问我怎么知道的,我因为这个 BOM 问题浪费了整整一个下午。
就这样,宏脚本编辑器与解释器这部分的完整设计、实现思路和踩坑记录都摊开讲完了。每个模块的代码量都不大,但合起来解决了模拟器"从手工操作到自动化联调"的关键一跳。如果你正在做一个需要"可编程操作序列"的工具,希望这篇的经验能让你少走几个我要再走一遍的弯路。