Serial Studio 脚本解析器类型化单元格通道(Spec 0086)设计与实现解析
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
导读
本文围绕 Serial Studio 仓库中 0086-script-parser-typed-results/plan.md 这一技术设计文档,深入讲解其核心方案:在脚本解析器(Lua / JavaScript)与帧构建器(FrameBuilder)之间引入一条类型化单元格通道(cell lane),让解析结果以"数字 + 文本字节视图"的单元格形式直达数据流水线,从而消除每帧"数字→文本→再解析回数字"的往返开销,并使数据集表格捕获(table capture)只在脚本真正引用表格 API 时才开启。读完本文,你将理解 Serial Studio 热路径(hotpath)上的这一关键优化:数据结构设计、两条引擎收集器、数字文本格式化规则、捕获作用域判定、表存储零分配写入,以及对应的测试与验证体系。
背景:为什么需要一条新的解析通道
Serial Studio 的脚本解析器允许用户用 Lua 或 JavaScript 编写解析脚本,将一帧原始数据转换为多个数据集(dataset)值。在引入本方案之前,解析结果通过QList<QStringList>传递:脚本返回的数字先被格式化成字符串(Lua 走luaValueToString,JS 走QJSValue::toString()),随后帧构建器再对这些文本做数值探测(numeric detection)并解析回double。
这条路径在功能上正确,但在热路径上存在明显浪费:
- 数字被"格式化→再解析",每帧产生两次不必要的转换;
QList<QStringList>与逐字符串复制带来堆分配;- 数据集表格捕获(把 dataset 值写入
DataTable供表格视图/API 读取)在"存在 Lua 解析引擎"时无条件开启,即使脚本根本没有引用表格 API,也会为每帧付出写入代价; - 表格存储使用 share-assign 写字符串,既多一次分配,又会让
dataset.value的缓冲区被钉住(pinning),下一次原地写入被迫重新分配。
Spec 0086 正是针对这些问题提出的四阶段计划中的第二阶段(the HOW),其完整需求与验收标准记录在同目录的 spec.md 中。
总体架构:span 车道、cell 车道与列表回退
新方案在原有两条路径之间插入第三条解析通道,FrameBuilder::parseProjectFrameFor的执行顺序变为:
parseProjectFrameFor trySpanLane ........... Native/PlainText,保持不变 tryCellLane ........... PlainText 解码器 && parser.parseCellsUtf8(bytes, sourceId, m_cellRows) for each row (rowStarts): captureLatestChannelSpans (views,与 span 车道相同) [m_captureLatestFrame] applyDatasetValuesCells(frame, cells, count, info) m_stager.stage(sourceId, frame, ts + step * row) list path ............. decodeProjectChannels → applyDatasetValues(不变的回退)对应源码中的实际实现位于 core/Pipeline/DataModel/FrameBuilder.cpp:
tryCellLane(约 L1799)先检查播放器未打开、帧含分组,且解码器为PlainText;随后调用FrameParser::parseCellsUtf8(约 L497)。只有 PlainText 解码器进入 cell 车道,Binary/Hex/Base64 脚本继续走列表路径,作为后续明确跟进项;- 对每一行,按
data->timestamp + step * row打时间戳(与列表路径完全一致),依次做 latest-frame 捕获、applyDatasetValuesCells写数据集、m_stager.stage入队; - 若引擎返回
false(混合形状、JS 非数组结果等),则回退到原有的decodeProjectChannels → applyDatasetValues列表路径,行为与旧版本一致。
IScriptEngine接口(core/Pipeline/DataModel/Scripting/IScriptEngine.h)新增了两个虚方法:
// 类型化单元格通道(spec 0086):true = 已填充 rows;false = 列表结果留在 fallback 中 [[nodiscard]] virtual bool parseUtf8Cells(const QByteArray& frame, ScriptCellRows& rows, QList<QStringList>& fallback) { Q_UNUSED(rows) fallback = parseUtf8(frame); return false; } // 已加载脚本是否命名了表格 API 辅助函数(spec 0086):用于开启按数据集捕获 [[nodiscard]] virtual bool referencesTableApi() const noexcept { return false; }默认实现直接回退到列表路径,因此不支持 cell 车道的引擎(如 Native 解析器)无需任何改动。值得注意的实现细节是:parseUtf8Cells在失败时会把列表结果写回fallback,这样引擎不会在拒绝后把脚本跑两遍。
数据结构:ScriptCell 与可复用的 ScriptCellRows
cell 车道的数据结构定义在 core/Pipeline/DataModel/Scripting/ScriptCells.h:
enum class CellKind : quint8 { Text, Number, }; struct ScriptCell { qsizetype offset; // 在 scratch 中的字节偏移 qsizetype length; // 字节长度 double number; // 数字单元格的数值 CellKind kind; // 类型 }; class ScriptCellRows { public: static constexpr qsizetype kMaxCellsPerResult = 10000; static constexpr qsizetype kNumberTextCapacity = 32; static constexpr qsizetype kBytesPerCellGuess = 16; // ... void clear() noexcept; void reserve(qsizetype cells, qsizetype bytes); void beginRow(); void appendText(const char* bytes, qsizetype length); void appendUtf16(QStringView text); void appendNumber(double value, const char* text, qsizetype length); };几个关键设计点(对应需求 R1 / R2):
- 单元格是"偏移 + 长度"而非视图指针:
ScriptCell保存offset/length指向所属ScriptCellRows的QByteArray m_scratch。这样即使某一帧更宽导致 scratch 重新分配,旧单元格也不会失效; - 引擎归属 + 跨帧复用:
ScriptCellRows由FrameBuilder持有(一个 builder 一个,成员m_cellRows),引擎向其中写入;clear()只resize(0)不释放缓冲区(ScriptCells.cpp),因此稳定的帧形状在稳态下零堆分配(R2)。初始容量为 64 个单元格 / 1024 字节,首次见到更宽的帧时一次性扩容; - 防钉住:
clear()在容量超过高水位线(单元格 2^18、字节 16 MiB)时释放回初始容量,避免单个病态结果把内存钉住整个会话; - 元素上限:
kMaxCellsPerResult = 10000,沿用既有元素上限,不改变每帧/每结果的数量约束。
引擎写入时的规则:Lua 字符串只在 Lua 栈上存活到弹出为止,因此必须复制进 scratch(memcpy到预留字节);JS 文本则以 UTF-16 直接编码进 scratch 尾部(appendUtf16,用QStringEncoder预留最坏情况空间再裁剪,避免临时QByteArray)。
两条引擎收集器:LuaCellCollector 与 JsCellCollector
计划文档将"在引擎内部收集单元格"定义为两处实现,而实际落地(见"Implementation deviations"一节)改为两个与引擎状态无关的收集器类,这样单元测试可以直接驱动一个裸lua_State/QJSEngine,无需链接整个流水线:
- core/Pipeline/DataModel/Scripting/LuaCellCollector.h:
LuaCellCollector::collect(lua_State*, ScriptCellRows&, maxElements)。标量或扁平 table → 一行;table 的 table(二维表)→ 每个内层 table 一行;混合形状返回false走列表路径; - core/Pipeline/DataModel/Scripting/JsCellCollector.h:
JsCellCollector::collect(const QJSValue&, ScriptCellRows&, maxElements)。扁平数组 → 一行;二维数组 → 每内层数组一行;JS 中true/null等非数字非字符串值通过toString()作为文本单元格处理(与今天"true"/"null"的字符串化结果保持一致);非数组或混合结果返回false。
两个收集器都只遍历结果一次:首个元素决定扁平还是嵌套,不匹配时在同一次遍历中直接拒绝,避免二次扫描。
Lua 引擎侧的接线在 core/Pipeline/DataModel/Scripting/LuaScriptEngine.cpp:parseUtf8Cells(约 L821)运行parseLuaText的 pcall 后调用收集器,m_referencesTableApi在loadScript中通过ScriptApiCall::referencesTableApi(script)设置(约 L595)。
数字文本格式化规则(R5):与旧路径逐字节一致
计划中最重要的兼容性约束是 R5:当一个数字单元格必须变成显示文本时(仪表盘值、API 帧、导出),文本必须与今天产生的完全一致。两种引擎各有一套规则(见 plan 的 "Formatting rules (R5)" 一节),均已在 ScriptCells.cpp 中实现为formatLuaNumber与formatJsNumber:
Lua 规则(formatLuaNumber)
- 整数值 →
QString::number(lua_tointeger)即%lld; - 其他 →
QString::number(v, 'g', 15)即 C locale 下的%.15g; - 非有限值沿用 Qt 拼写:
nan(无符号)、inf、-inf; - 常规路径用
std::to_chars(out, out + capacity, value, std::chars_format::general, 15)复现%.15g(Apple 旧平台回退到snprintf_l)。
注意整数检测复用 LuaJIT 兼容层的lua_isintegershim(LuaJIT 用 double 表示整数),保证整数值不带小数部分输出。
JavaScript 规则(formatJsNumber)
目标是精确复现 ECMAScriptNumber::toString(即QJSValue::toString()对数字的输出):
std::to_chars取**最短往返(shortest round-trip)**数字;- 布局规则按 ECMA-262 6.1.6.1.20:
1e-6 ≤ |x| < 1e21用十进制形式(0.000001而不是1e-06),范围外用指数形式(1e+21、1e-7); - 特殊值:
NaN、Infinity、-Infinity、-0 → "0"。
实现里对应常量kJsFixedUpperExponent = 21、kJsFixedLowerExponent = -6,指数形式写作d[.ddd]e[+-]N且无零填充。选择这套方案的原因记录在 plan 的 Tradeoffs 表中:QJSValue::toString()本身就是被移除的分配点;而 Qt 的FloatingPointShortest在指数边界(如0.00001vs1e-05)与 JS 不一致,会破坏 R5。formatJsNumber还包含一个针对 Apple 13.3 之前平台的零分配回退实现(snprintf_l精度搜索 +strtod_l往返校验)。
格式化产生的文本写入 scratch,因此数字单元格同时携带double值与显示文本,单元格携带的文本与旧QStringList完全相同,而帧构建器的 writer 直接从单元格取数字,不再把文本解析回去。
捕获作用域(R6 / R7):只在被引用时开启表格捕获
这是本计划中用户可见规则变化最大的部分。表格捕获(m_captureDatasetValues)的输入从"存在 Lua 解析引擎"变为"某个引擎的源码引用了表格 API 名称",判定方式是在loadScript/ 变换编译时做编译期词扫描,由头文件实现 core/Pipeline/DataModel/Scripting/TableApiScan.h:
static const QRegularExpression s_tableApiName( QStringLiteral("\\b(?:tableGet|tableSet|tableHandle|tableHandleMany|tableGetH|tableSetH|" "datasetGetRaw|datasetGetFinal|__ss)\\b")); return !source.isEmpty() && s_tableApiName.match(source).hasMatch();对九个名称(八个辅助函数 + JS 桥接对象__ss)做词边界匹配。它是刻意保守的:注释或字符串字面量里的名称也会开启捕获(每帧多付出几个百分点),而备选的"运行时首次触达标志"会丢掉第一帧的值。
各输入源的变化(plan 的 Capture scoping 表):
| 输入 | 之前 | 之后 |
|---|---|---|
| 解析引擎 | m_hasLuaEngine(存在任何 Lua 解析器) | 任何引擎源码引用表格 API 名称(FrameParser::refreshEngineCaches,epoch 照常递增) |
| 变换引擎 | hasScriptEngines() | 任何变换(或共享库)源码引用表格 API 名称 |
| 流变换 | 注入即开启,从不关闭 | 被引用才开启,worker 拆除时关闭 |
| 外部用户 | 粘性bool(任何injectTableApi*设置,项目加载时清除) | int计数:控制脚本启停、API 服务器启停、表格视图开关、发送环境 |
外部用户侧,FrameBuilder用m_externalTableUsers计数(core/Pipeline/DataModel/FrameBuilder.cpp),配合armExternalTableUser()/disarmExternalTableUser()(约 L2832 / L2888)与TableApiUserLeaseRAII 封装。选择计数而非布尔的原因记录在 Tradeoffs 表:R7 要求"关闭一个用户后停止捕获",布尔无法表达两个用户同时存在。实际落地的命名是injectTableApi*(++)/releaseTableApiUser()(--)。
新鲜度保证(R7):epoch 与计数都汇入既有的m_captureFlagsDirty → refreshDatasetCaptureFlag()重新推导路径,在 builder 线程上执行(遵守 spec-0051 的双线程刷新规则:刷新槽是 pipeline-affine 的,由 GUI 发射器排队),因此捕获标志永远不会过期——脚本编辑使引用开始/停止后,下一帧即生效。GUI 侧用户的 arm/disarm 通过injectTableApi*已使用的invokeOnBuilderThreadBlocking封送执行,不引入任何新的跨线程信号/槽。
表存储写入(R8):原地赋值与槽位缓存
当捕获开启时,把 dataset 值写入表格必须零分配(R8)。计划针对DataTable的两处改动:
setDatasetRawAt/setDatasetFinalAt:用assign_string_in_place(rv.stringValue, str)替代 share-assign。这同时解决两个问题:移除 profile 中可见的分配;并且不再把dataset.value的缓冲区钉住——这正是 doc/claude/common-mistakes.md 中"share-assign 重新链接缓冲区"的反模式(下一次原地写会先 detach 再重新分配)。原地写之前仍保留等值检查,因此依赖"值未变则不写"的 change-driven 变换跳过(spec-0083 时代)语义不变;- 槽位缓存:每 dataset 的槽位对缓存在
FrameBuilder::m_datasetTableSlots(按 dataset 序号索引,initializeTableStore时重建),替换每帧每 dataset 两次QHash::constFind。实际落地按 review 修正简化为"每帧每 dataset 一次datasetSlots()查找"。
帧级 writer 的共享尾部是applyDatasetToken(评审修正后不再是策略模板),applyDatasetValueCell(约 L2188)与 span 车道的applyDatasetValueSpan共享从原始值复制、捕获、变换、表达式发布到最终捕获的全部后续逻辑。数值取用规则:numericValue = cell.kind == Number ? cell.number : SerialStudio::toDouble(cell.text, &isNumeric),数字单元格的isNumeric恒为true。
热路径与线程影响
计划明确标注了该改动触及热路径,并列出必须遵守的规则:
- 一切在pipeline 线程执行;引擎只在该线程使用;
ScriptCellRows是 builder 成员,永不跨线程; - 稳态下 cell 车道对纯数字结果(Lua)零分配;JS 保留每单元格
QJSValue临时值(见下);列表路径原样保留为回退; dataset.value保持assign_utf8_in_place;存储端字符串复制改为原地,移除拖累 span 车道的 share-assign;- 路由 lambda 内不允许出现
lua_*或QJSValue调用:扫描在loadScript于引擎自身线程执行,单元格收集在既有 parse 调用内部执行; structureGeneration戳记不变(cell 车道与列表路径一样经由m_stager.stage入队);- 时间戳归属不变:行以
data->timestamp + step * row打戳。
FrameParser侧新增parseCellsUtf8镜像parseMultiFrameUtf8的引擎 0 缓存路径,且Native 引擎在不运行的情况下直接拒绝,因此被拒的 Native 帧只需走一次列表路径,而不是三次(review 修正)。
基准计划(--benchmark-hotpath前后对比):Lua 数值、JS 数值、Lua 混合、JS 混合的 FPS;spec-0084 的每帧分配列(Lua 数值目标 0,JS 记录其下限)。
权衡与替代方案(Tradeoffs 摘要)
计划用一张决策表记录了每个关键取舍:
| 决策 | 备选 | 选择及理由 |
|---|---|---|
| 结果表示 | (a) 字节视图单元格;(b)QVariantList;(c)std::vector<std::variant<double, QString>> | (a):复用 span writer 及其原地 UTF-8 赋值;稳态零分配;文本单元格正是 Native 车道已消费的形式。(b)/(c) 每单元格或每字符串分配 |
| 数字显示文本 | 惰性按需 vs 急切写入 scratch | 急切:保持 spec 0055 D6 契约不变,使 R5 成为纯格式化等价性测试;惰性属于未来的 display-text 规范 |
| JS 数字文本 | (a)QJSValue::toString();(b) QtFloatingPointShortest;(c) ECMAScript 兼容格式化器 | (c):a 正是被移除的分配;b 在指数边界与 JS 不一致(0.00001vs1e-05),破坏 R5 |
| JS 每单元格临时值 | (a) 接受并单独记录 JS 下限;(b) 用 typed array 打包一次读回 | (a):QJSEngine无零拷贝 typed-array 读取,b 仍要经过QJSValue;R2 对 Lua 满足,JS 的收益是移除格式化/解析往返 |
| 表格 API 引用检测 | 编译期词扫描 vs 运行时首触标志 | 扫描:保守、无首帧缺口,且与发送环境区分ArmCapture/NamesOnly的方式一致 |
| 外部用户 | 粘性布尔(现状)vs 带 disarm 的计数 | 计数:R7 要求关闭用户即停止捕获,布尔无法表达两个用户 |
| 存储字符串写 | share-assign(现状)vsassign_string_in_place | 原地:移除分配与缓冲区钉住;等值 no-op 检查不变 |
| cell 车道解码器覆盖 | 仅 PlainText vs 全部解码器 | PlainText 先行:门控层级是 PlainText;Binary/Hex/Base64 脚本保持列表路径,作为命名后续项 |
风险与缓解
- 格式化漂移:Lua 与 JS 数字文本必须与现状逐字节一致,否则导出与仪表盘会无声变化。对策是两套语料测试(Lua:同一测试内用
QString::number对照;JS:Node 生成的 fixture); QJSValue::isNumber与旧toString的差异:JS 的true/null今天会字符串化为"true"/"null",cell 车道通过把非数字非字符串值按toString()处理保持相同行为;- 扫描漏报:通过
local g = tableGet别名访问仍含名称;用_G字符串拼接构建的脚本检测不到(已在 transform_lua.md / transform_js.md 中说明),运行时桥接在首次调用时也会开启捕获作为安全网(仅病态场景有一帧缺口); - 漏掉 disarm 点:后果只是捕获保持开启(即现状行为),绝不会在需要时关闭;任务阶段逐一枚举每个
injectTableApi*/noteGuiUser调用点配对; - 共享的可静默破坏类别(
common-mistakes.md):缓存标志输入缺刷新线路(两个输入都已接线并由 AC5/AC6 测试)、span 车道上的 share-assign(正在移除而非新增)、新模板产生的QList<QStringList>(cell 车道即替代品,列表路径仅作回退)、JS 的guardedCall(不变)。
评审阶段(qt-cpp-review)还修复了一批具体缺陷:applyProjectSnapshot不再清零m_externalTableUsers(租约应活得比一次库编辑更久);阻塞式 arm 在 worker 循环被QThread::quit()展开时会被runOnObjectThread跳过,导致后续 release 击穿零值触发 debug 断言——因此两个 arm 与 release 都改为排队投递;splitScientific对指数用有界解析避免越界读;ScriptCellRows::clear()释放高水位以上的容量;~Output::Base仅在 surface 已准备(m_sourceId >= 0)时释放租约。
测试与验证体系
计划的验证分为四层,测试文件均已落在仓库中:
- 单元测试(ctest):app/tests/tst_script_cells.cpp 直接驱动裸
lua_State/QJSEngine上的收集器(链接集只含 LuaJIT 与QJSEngine,不拉入整个流水线),覆盖:- AC3:Lua/JS 混合行
{1, 2.5, "x", "7"}的每单元格kind、number、text与列表路径产物相等,且与等价分隔文本喂给 Native span 车道的数值探测一致; - AC4:二维结果的行数、顺序、值等于列表路径;混合标量/向量结果回退(
parseUtf8Cells返回false); - R2:Lua 数值结果连续解析 1000 次,用 TU 局部
operator new计数器在首次解析后武装,断言零分配(分配探测还检查 scratch、单元格数组与存储注册缓冲区的地址稳定性,因为 Qt 容器与 LuaJIT 在operator new之外分配); - R5:
formatJsNumber对照 tests/fixtures/js-number-format.json 语料;Lua 格式化用例在同测试内对照QString::number。
- AC3:Lua/JS 混合行
- 语料生成:tests/scripts/gen_js_number_corpus.py(Node 的
String(x)对边界值与随机 double 生成)与 tests/scripts/test_js_number_corpus.py(pytest 断言提交的 fixture 与生成器输出一致); - 集成测试(API 服务器运行中):
test_table_capture_scoping.py覆盖 AC5/AC6——仅解析器的项目在 10 秒内datatables写入时钟保持初值;加入调用tableGet的控制脚本后首帧即开始推进;变换中增删tableGet在一帧内反映/停止。实际可观察点是project.dataTable.getValue对__datasets__/raw:<id>的读取(非武装读者),而非不存在的写时钟 verb(实现偏差一节); - 静态与维护工具:
python scripts/code-verify.py --check覆盖所有改动文件(热路径 TU 阻塞违规)、qt-cpp-review审引擎与FrameBuilder.cpp/DataTable.cpp改动块、提交前sanitize-commit.py、文档编辑后claim-verify.py。AC1 由--benchmark-hotpath前后对比把关,AC7 由导出保真与回放集成测试把关。
结语
Spec 0086 是 Serial Studio 脚本解析热路径的一次系统性优化:类型化单元格(R1)、稳态零分配(R2)、文本等价(R3/R5)、多帧结果兼容(R4)、按引用开启的表格捕获(R6/R7)与零分配捕获写入(R8)八项需求,通过一条位于 span 车道与列表回退之间的新通道落地。从设计文档到 ScriptCells.h、TableApiScan.h、FrameBuilder.cpp 等实现,再到 tst_script_cells.cpp 与集成测试,方案的每一处取舍都有源码级证据可循。对希望深入 Serial Studio 数据流水线或在其上扩展解析器的开发者而言,这条 cell 车道是理解项目"性能与正确性并重"工程风格的最佳入口之一。
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考