
07 · logic.yaml 作为领域语言:expr 语法、setter 语义与编译策略前 6 篇把平台是什么、“数据怎么流”、工具链怎么护讲完了。这一篇换一个角度——把logic.yaml当一门领域语言(DSL)看:它的 expr 语法长什么样、7 个 setter 的签名怎么设计、ExprTk 编译踩了哪些坑、preprocessQuotes为什么必须存在。看完你会明白:这门 DSL 不是配置文件里塞表达式,而是标定工程师跟平台 c 之间的一份契约。一条 rule 长什么样config/logic.yaml里的一条规则:-id:bat_overvolt_alarmdesc:电池过压 420V → 触发 high 报警 灯闪烁can_signals:[bat_volt]expr:|if (isovertime(bat_volt) 0 and bat_volt 420) { setwarnon(bat_overvolt); setlightshine(bat_warn_light); } else { setwarnoff(bat_overvolt); }四个字段,职责分明:字段谁写干什么id标定工程师规则唯一标识,日志/调试用desc标定工程师自然语言描述,不参与执行can_signals标定工程师声明这条 rule 依赖哪些 CAN 信号expr标定工程师业务逻辑本体注意can_signals不是从哪读变量——expr里可以引用所有can_ids.yaml声明的信号。can_signals的作用是声明依赖,让引擎知道这条 rule 跟哪些信号相关(日志、超时判定、未来可能的增量 tick 优化都靠它)。expr 的语法:ExprTk 子集expr不是自由 C,是 ExprTk 表达式引擎支持的子集。支持的东西:变量 bat_volt, vehicle_speed, motor_temp ... ← can_ids.yaml 字段名 字面量 420, 120, 0, 1.0 ← 数字 字符串 bat_overvolt, -- ← 单引号(预处理后) 算术 , -, *, / 比较 , , , , , ! 逻辑 and, or, not 控制流 if / else if / else 函数调用 setwarnon(...), isovertime(...) 等 7 个 setter不支持的东西(标定工程师常踩的坑):没有for/while循环——仪表逻辑不需要,也不该有没有数组 / 对象——信号是标量,显示值是标量没有自定义函数——setter 是平台预注册的,业务不能自己加没有switch——用if / else if链代替这些限制是有意的。DSL 的价值不在于什么都能写,而在于写不出错的东西。一个不能写循环、不能定义变量的语言,标定工程师不可能写出死循环或内存泄漏。7 个 setter:DSL 的全部副作用logic.yaml的 expr 是纯读 副作用模型:读信号变量,通过 setter 写状态。7 个 setter 覆盖了仪表 HMI 的全部输出:显示值setdisplayvalue(name, type, value) name: 显示 id 字符串,如 bat_volt_disp type: int / float / string value: 数字或字符串(union 签名 SST | SSS)这是唯一有多态参数的 setter。type告诉 QML 端怎么格式化,value是实际值。当typestring时 value 是字符串字面量(如--表示超时占位);当typefloat时 value 是信号变量。超时检测isovertime(name) → 1.0 | 0.0 name: CAN 信号名 返回 1.0 超时(5 × cycle_ms 没更新), 0.0 正常这不是 setter,是查询函数。它读m_lastUpdateMs[name]跟当前时间比较,超时阈值 cycle_ms × 5(从can_ids.yaml的period_ms来)。为什么阈值是5 × cycle_ms而不是固定值?因为不同信号周期差 10 倍(VCPU 50ms, HYBRID_EV_RANGE 1000ms),固定阈值要么误报(短周期信号偶尔丢帧)、要么漏报(长周期信号超时了还没发现)。5 ×是经验值——5 个周期没到基本可以确定总线断了。报警setwarnon(name) ← 触发报警 setwarnoff(name) ← 消除报警 name: 报警 id,必须在 warn.yaml 里声明过指示灯setlighton(name) ← 常亮 setlightshine(name) ← 闪烁 setlightoff(name) ← 灭 name: 灯 id,必须在 lights.yaml 里声明过7 个 setter 的命名刻意对称:on/off一对,light多一个shine(闪烁是仪表高频需求)。签名设计:SSS|SST union 的来由setdisplayvalue是 7 个 setter 里唯一需要多态的——第三个参数可能是字符串也可能是数字。ExprTk 的igeneric_function用签名串声明参数类型:S stringT scalar(double)V vector单签名直接写SSS或SST。但setdisplayvalue要同时支持两种——用SSS|SSTunion 签名。union 签名的代价:ExprTk 走multimode_genfunction_node路径,调的是2 参 overloadoperator()(const std::size_t, parameter_list_t),而不是单参operator()(parameter_list_t)。两个 overload 必须同时 override,否则 2 参版本走基类empty_body返回 NaN。这是 ExprTk 的隐藏坑,写在SetterBundle的注释里:// 注意: union 签名 (|) 让 ExprTk 走 multimode_genfunction_node,// 调用的是 operator()(const std::size_t, parameter_list_t) (2 参版本),// 不是单参 operator()(parameter_list_t).// 所以必须同时 override 两个 overload, 否则 2 参版本走基类 default empty_body, 返回 NaN.其他 6 个 setter 都是纯S签名,只 override 单参版本就行。preprocessQuotes:为什么必须存在yaml 里写字符串自然用双引号:expr:|if (isovertime(bat_volt) 0 and bat_volt 420) { setwarnon(bat_overvolt); }但 ExprTk不认双引号——它用单引号...当字符串字面量。如果直接把 yaml 的 expr 喂给 ExprTk,bat_volt会被当成未知标识符,编译报错。preprocessQuotes做的事很简单:把 expr 里所有...替换成...。staticstd::stringpreprocessQuotes(conststd::stringsrc){std::string out;boolin_strfalse;for(size_t i0;isrc.size();i){charcsrc[i];if(!in_str){if(c){in_strtrue;out\;}else{outc;}}else{if(c){in_strfalse;out\;}else{outc;}}}returnout;}为什么不让标定工程师直接写单引号?因为 yaml 的|块里双引号是最自然的写法,强制写单引号会在 yaml 高亮、编辑器补全、复制粘贴时处处别扭。DSL 的用户体验也是设计——让写的人舒服,让机器去适配。编译策略:两步法compileRule用了一个不太常见的两步编译:// 第 1 步: 1 参 overload 探测错误exprtk::parserdoubleparser;exprtk::expressiondoubleprobe;probe.register_symbol_table(*rule.syms);if(!parser.compile(rule.expr_source,probe)){std::fprintf(stderr,[LogicEngine] compile FAIL in %s: %s\n,rule.id.c_str(),parser.error().c_str());returnfalse;}// 第 2 步: 2 参 overload 拿 expressionautocompiledparser.compile(rule.expr_source,*rule.syms);rule.exprstd::make_sharedexprtk::expressiondouble(std::move(compiled));为什么不走一步?因为 ExprTk 的 2 参 overloadcompile(string, symbol_table)在某些错误路径上parser.error()返回No Error——错误诊断不可靠。但 1 参 overloadcompile(string, expression)的错误报告是准的。两步法的代价是同一 expr 编译两次。但 rule 数量少(几十条),且只在启动时编译一次,运行时只调expr-value()——编译开销可以忽略。注释里还有一条关键约束:rule.syms必须在compileRule之前就绑到rule上(emplace_back后地址稳定),因为add_variable/add_function注册的指针会被编译产物引用。如果 vector 扩容导致rule搬移,symbol_table 里的指针就悬空了。变量绑定:从 can_ids.yaml 到 expr 槽compileRule里有一段看似冗余的变量绑定:autobind[](conststd::stringname){if(std::find(rule.var_names.begin(),rule.var_names.end(),name)!rule.var_names.end())return;rule.var_names.push_back(name);};for(constautos:rule.can_signals)bind(s);// 先绑声明的依赖for(constauto[n,_]:m_signals)bind(n);// 再绑全部信号为什么不只绑can_signals里声明的?因为expr里可能引用未声明的信号——can_signals是我声明我依赖这些,但 expr 里写bat_curr也是合法的(只是日志/超时判定不跟踪它)。全量绑定保证 expr 里写任何信号名都能编译通过。var_slots是vectordouble,每个 slot 对应一个信号变量。tick时:for(constauto[name,val]:ctx){for(autorule:m_rules){autoitstd::find(rule.var_names.begin(),rule.var_names.end(),name);if(itrule.var_names.end())continue;rule.var_slots[it-rule.var_names.begin()]val;}}ctx 里的每个信号值被写进对应 rule 的 slot——ExprTk 编译时拿的是 slot 的地址,之后每次expr-value()读的是 slot 的当前值。这就是为什么var_slots必须在emplace_back之后稳定:地址不能变。DSL 的边界:什么该写、什么不该写回到开篇说的契约。logic.yaml作为 DSL,边界很清晰:该写的:信号阈值判断(bat_volt 420)报警触发/消除(setwarnon/setwarnoff)显示值格式化(setdisplayvalue)指示灯控制(setlighton/setlightshine/setlightoff)超时降级(isovertime→ 显示--)不该写的:字节解析(那是 DBC /can_ids.yaml的活)UI 布局 / 颜色 / 字号(那是warn.yaml/lights.yaml/ QML 的活)优先级排序(那是 QtBinder / QML 的活)跨帧关联计算(如果复杂到需要状态机,应该升级 c setter,不是塞 expr)最后一条尤其重要:如果标定工程师发现自己在 expr 里写if ... else if ... else if ...超过 5 层,那说明这个逻辑不该用 DSL 表达——应该让平台 c 加一个新 setter,把复杂度封装掉。DSL 的价值是让 90% 的变更留在 yaml 里,但剩下 10% 的复杂度应该回流到平台,而不是在 DSL 里硬撑。一句话总结logic.yaml是一门受限的 DSL:ExprTk 子集语法 7 个 setter 覆盖全部副作用 preprocessQuotes适配 yaml 双引号 两步编译拿可靠错误诊断。它的价值不是什么都能写,而是标定工程师写不出错的东西。接下来08 · ExprTk 实战坑:string_view 生命周期与签名声明——igeneric_function的type_store::string_view为什么不能reinterpret_caststd::string*,union 签名的 multimode 分派细节09 · 测试哲学:怎么用测试护住平台化承诺——test_decode_table/test_exprtk_string/test_dbc_to_yaml三层测试各护什么