1. 为什么一上来就要谈MIL:模型仿真测试的真正价值
1.1 MIL到底是什么:一套用来问"模型对不对"的系统化方法
MIL(Model-in-the-Loop,模型在环)测试,很多刚接触Simulink的人以为它就是在Simulink里跑一下仿真、看看波形,其实没那么简单。它是一套把被测控制模型放到仿真环境里,用设计好的输入激励去驱动它,再检查输出是否符合预期的系统化验证方法。我一直觉得,MIL测试的核心不是"仿真",而是"测试方法"——仿真只是它的载体,它的灵魂是那套你写好的用例、判据和覆盖度分析。
在Simulink里做MIL,你面对的被测对象是纯粹的算法模型,比如一个电机控制器的PMSM矢量控制模型、一个电池管理系统的SOC估算模型,或者一个汽车ESP标定逻辑。这些模型还没被编译成C代码,也没有跑在真实硬件上,所以MIL测试更像是在"图纸阶段"就把方案论证清楚:输入端给定转速阶跃,输出端是不是在预期时间内到了目标值、超调量在不在范围内、状态转换是否符合状态机定义。
有人会问:我拿Signal Builder加个阶跃信号,跑一下仿真,再看Scope里的曲线,这不就是MIL测试了吗?严格来说,这叫"仿真验证",不叫"测试"。MIL测试更强调可重复、可量化、可追溯:每条用例对应哪条需求?通过/失败按什么判据判定?覆盖率项次是否跑满?今天跑了,三个月后改了一版模型,能不能一条脚本全量回归?这才是MIL测试真正要做的事。
1.2 从V模型看懂MIL的位置:越早发现问题,成本越低
你随便找一本做汽车电子或航空软件开发的参考书,都会看到V模型。左边从上到下是需求定义、系统设计、软件设计、代码实现;右边从下往上是单元测试、集成测试、系统测试、实车验收。MIL测试的位置,就在软件详细设计完成之后、代码生成之前,也就是"模型既已经搭出来了、但还没转成产品代码"的那个阶段。
这个阶段在整个开发链条里极其关键。按行业内流传的一个成本经验,需求阶段发现一个缺陷,修复成本可能是1;到了模型阶段,变成10;到了代码阶段,是50;到了实车阶段,可能就是200甚至更高。为什么差距这么大?因为模型阶段改的是一根连线、一个判断条件、一个查表数据,编译器都不需要重新跑;到了代码阶段,你还要改代码、重新生成、重新配置集成环境;到了实车阶段,问题往往隐藏在所有系统交互之中,必须用标定工具、CAN报文、录制数据一路排查才能找到根因。
所以我在项目里一直坚持一个原则:模型冻结之前,MIL回归必须全部通过。模型一旦冻结,之后每次修改都要问一个问题——这次改动动了哪个函数?影响了哪些需求?对应的MIL用例是哪几条?这是在用流程成本换测试成本,非常划算。
1.3 哪些项目真正需要MIL:不是所有Simulink模型都值得做
我得说句实话:不是所有Simulink模型都值得搭一整套MIL测试环境。如果你只是课堂上做个demo,或者给论文跑个算法流程图,那直接仿真就够了。MIL测试真正发挥价值的场景有三个特征。
第一,模型会被用于自动代码生成。也就是说,这个Simulink模型最终要经过Embedded Coder之类工具生成产品级C代码。这种情况下,模型本身不只是算法验证工具,它等于"源代码本身"。代码级的Bug有很大概率在模型阶段就有对应表现,如果不在模型层查清楚,等代码生成出来再查,代价高得多。
第二,需求数量多且变更频繁。像车身控制器、域控制器这类项目,需求动辄几百条,而且每周都在变。如果没有一套自动化的MIL回归用例,光靠人眼看波形,改一处就复查全校,能把人累死。
第三,功能安全相关。ISO 26262、DO-178C这类标准都明确要求模型层的验证活动。测试覆盖率、需求可追溯性、用例执行记录,这些不是可选项,是审厂、认证时必须拿得出来的证据。
反过来,那种纯做前期算法预研、搭完模型只是用来出效果图的,MIL测试确实可以做得轻一点,几条关键边界用例跑一遍就够了。我建议你先问自己一个问题:这个模型后面要变成什么?想清楚这个,再决定投入多少精力搭测试环境。
2. 在Simulink里搭一套可复用的MIL测试环境
2.1 被测模型规范化:接口、采样与命名是跑通基线的前提
做一个MIL测试环境,不是新建一个Simulink模型然后把被测模型拖进去那么简单。你首先要处理的就是接口规范化问题。很多时候我们拿到手的模型,它的输入端口上直接怼了一个Constant或者Step模块,根本没有留出外部输入接口;或者输出端口直接接了Scope,信号没命名。这样的模型根本没法作为被测对象,因为你很难把测试激励和判据自动地接到它身上。
所以第一步是先把被测模型改造一遍。我个人的做法是:把被测模型封装成Subsystem,所有输入、输出都用Inport/Outport模块引出,并在外部再用Goto/From或者Signal Label把信号名定好。输入输出端口的名字和需求文档里的信号名保持一致,别嫌麻烦。比如一个电机控制器的转矩控制模型,输入端口就该是Trq_Cmd、Motor_Speed、Bus_Voltage,输出是Cur_D、Cur_Q、SVPWM_Duty,这样后面写判据的时候,信号名一写就知道在测什么。
采样时间也要统一。Simulink模型里常见的问题是混合了连续模块和离散模块,有人直接用了VariableStep求解器,步长还设成了自动。MIL测试环境下,我建议统一用定步长离散求解器,比如FixedStepDiscrete或FixedStepAuto,步长按控制周期的整数倍来设。如果是做电气那类快速环路的,仿真步长可能只有1e-5秒;做整车控制逻辑的,通常10ms就够了。测控制逻辑的MIL用例,没必要把步长缩到比控制任务本身还小几十倍,白浪费时间。
还有一个容易被忽略的规范是模型内部不能有奇怪的初始化依赖。比如有些模型用了Initialize Function模块,里面根据某个全局变量做了条件初始化。MIL测试里每次用例运行时这个状态能不能正确重置,直接决定了回归结果可不可信。我在前面踩过这个坑,后面会专门讲。
2.2 测试激励生成:Signal Builder、Signal Editor与脚本三选一
Simulink里生成激励信号常用的办法有三类:老式的Signal Builder、新版信号编辑器和用MATLAB脚本直接给输入端口赋值。我推荐优先用Signal Editor或者脚本,因为Signal Builder在较新的MATLAB版本里虽然还能用,但功能上已经边缘化,建模和批量管理都不如Signal Editor方便。
Signal Editor适合那种"一段段手动拉出来的波形":给转速信号加一个从0升到4000rpm的斜坡,保持2秒,再突然阶跃到8000rpm,然后再下来。你能直观地看到波形长什么样,适合单条用例调试。但它有一个毛病,就是多组用例之间切来切去比较麻烦,而且你没法用代码很容易地控制它运行哪一组、期望输出是什么。
所以我大多数项目用的是脚本方式。做法很简单:用Simulink.SimulationInput对象生成多个仿真输入,每条用例把输入端口的值直接赋成自己生成的数据序列。比如官方库里的test用例,很多内部测试就是这么干的:先用timeseries对象定义端口信号,再放在Simulink.SimulationInput里,最后用sim批量跑。这样做的好处是,你的测试用例本质上是MATLAB代码和数据文件,天然支持版本管理、批量执行和自动比对。
我举个例子。假设被测模型是VMC_Controller,想测"踩油门踏板从30%突然到80%时输出扭矩是否在50ms内到位"。你可以先做两层数据:一是输入数组,比如踏板开度从0到1秒保持30%,1秒时阶跃到80%,持续2秒;二是期望结果数组,在仿真后根据模型输出计算到达时间、稳态误差等指标。然后用脚本批量跑,最后把结果指标和阈值一比对,生成一份pass/fail报告。
2.3 自动判据与需求追溯:用Simulink Test让用例可重复执行
说到这儿就得介绍Simulink Test工具箱了。它是目前在Simulink里做MIL、SIL测试最主流、最工程化的环境。你把各种输入信号整理到.mat或Excel文件里,用Simulink Test把一组输入和一组判定模板组合成一个TestCase,比如"TC_001_TrqCmd_StepUp_CheckOvershootAndSettleTime"。这个Test Case里有三个关键部分:
- 输入部分:加载外部输入数据,可以是
Signal Editor里的信号组,也可以是从Excel读入的信号表。 - 仿真参数:求解器类型、步长、仿真时长、初始状态。
- 判定部分:用
Assessments逻辑块或Simulink Test自带的验证语句定义通过条件。
我特别喜欢它的一个机制是需求追溯。每个Test Case可以关联到需求条目的唯一标识,比如把需求ID填到Requirement标签里。跑完一遍测试,生成的测试报告中会自动列出每条需求对应跑了哪些用例、结果是通过还是失败。这对过功能安全评审简直是救命稻草,审厂的人就喜欢看这种表。哪怕你不搞认证,想象一下半年后你接手一个自己都快忘掉的模型,有这么一份追溯表在,恢复上下文的速度能快一倍。
判据的定义是整个MIL测试环境里最能体现功力的一环。太严容易误报,比如期望输出1.0就不允许超过1.001,结果仿真噪声大一点就红了;太松呢,模型都错得很离谱了判据还说pass。我的经验是,凡是涉及连续物理量的,不要用"等于",要用绝对误差带或相对误差带;凡是涉及时序的,不要把时间点卡死,要给一个合理的窗口。比如电机类模型,判据写成"0.5秒内达到指令值的95%,稳态误差小于2%",而不是"第0.5秒时值必须等于499.7rpm"。
3. 从"跑通模型"到"测得全面":测试用例设计的思路与坑
3.1 测试用例来源:从需求条目反推,而不是对着模型临场编
很多人拿到一个模型,第一反应是打开Simulink开始"想几个输入信号试试"。这种临场发挥式的方法,测出来的用例要么跟需求没关系,要么重复测同一个功能点,遗漏那些真正容易出问题的场景。做MIL测试,用例来源必须是需求文档,你每写一条用例,都要能说出"我这是在验证需求第几条"。
我常用的拆解方法是按需求类型分类。对于功能需求,比如"当车速大于120km/h时,激活限速报警",那就至少要拆出三类用例:车速从119升到121,报警是否激活;车速从121降到119,报警是否解除;车速一直在120附近抖动,报警是否产生频繁抖振。这三条对应的是正常激活、正常退出、滞回判断,每一个都针对需求表达里可能隐含的逻辑漏洞。
对于性能需求,比如"最大稳态误差不超过±1%",那就要考虑输入在全量程范围内扫过时,哪个工作点是误差最大的。这类用例往往不是一两个激励就能覆盖的,需要做参数扫描。Simulink里可以用Simulink.SimulationInput批量改模型参数,把控制目标值从10%跑到100%,步长5%,跑完把所有稳态误差收集起来生成散点图,一眼就能看出模型在哪个工作区间的边角出了问题。
还有一个很重要但容易被忽略的来源是异常和边界输入,比如信号丢线(NaN或Inf)、传感器值越界(负的转速)、突然断连(信号值跳到0或保持上一拍)。这些用例在功能需求里往往不会写,但恰恰是模型在实车上最容易出Bug的场景。MIL阶段因为仿真成本低,非常适合把这些极端case跑一遍。
3.2 边界值与时序测试:看一个真实的电机控制MIL用例怎么写
我拿以前做过的一个PMSM电机控制器的MIL测试来举例。被测模型包含电流环、速度环、SVPWM调制和过流保护逻辑。需求里有这么一条:"当母线电压低于260V时,系统在10ms内进入降额状态,最大允许扭矩下降50%。"
当时我把这条需求拆成了五条用例:
- 母线电压从270V平缓降到260V,系统必须触发降额。
- 母线电压从260V突然跳到250V(模拟瞬间掉电),降额必须在10ms内生效。
- 母线电压从255V回升到265V,系统需要解除降额,并且解除时不能产生扭矩跳变。
- 母线电压在260V附近以1V幅值、5Hz频率振荡,看降额标志是不是跟着高频抖振。
- 母线电压缺失(NaN信号)时,系统按最保守策略进入降额并闭锁。
你可能注意到了,我把"降额生效时间"这种时序要求单独立了一条用例。为什么?因为很多时候模型逻辑是正确的,但响应延时超标。比如一个低通滤波器把母线电压信号滤得太平滑了,260V跌到255V时,滤波器输出还没到阈值,等到输出低于阈值时已经过了50ms。这种问题是只看稳态波形看不出来的,必须设计专门的时序用例。
在Simulink Test里写这种时序判据很直接:给降额标志信号加一个从下降到触发的事件检测,再用follow逻辑看触发时间和电压跌落时间之间的差值。跑下来确实发现过一版模型因为滤波器时间常数配得太大,降额触发延时到了25ms,远超需求的10ms。后来把滤波器截止频率提高,重新跑这条用例才通过。这就是MIL测试的价值——所有问题都发生在模型还只是一堆方块连线的阶段,改起来成本极低。
3.3 我在实际项目中遇到的MIL"翻车"现场
很多坑不是模型算法本身不对,而是测试环境、测试数据或测试方法出了问题。我在这里分享三个印象最深的现场。
第一个是初始化残留问题。当时测一个状态机模型,它进去之后会记住"当前处于自动模式还是手动模式"。前一版用例跑到自动模式结束,我没有显式重置模型初始状态,下一版用例一跑,显示初始状态被设定成了自动模式,结果第二版用例在"手动模式下允许操作"的判断上直接误报失败。排查了半天,最后发现是Simulink缓存的初始状态没清。从那以后,我所有MIL回归脚本第一行必然先执行clear all级别的工作区清空,并且在每个SimulationInput里显式指定InitialState。
第二个是用了连续模块导致判据忽红忽绿。被测模型里有一个RC滤波环节是连续时间模块,我用的是变步长连续求解器。问题在于变步长在不同的用例里自适应步长不同,导致滤波输出在高频信号段有微小差异,而判据又把阈值写死了。最后统一改成离散滤波器,并把求解器设成定步长,问题才消失。要记住,MIL测试环境的求解器配置必须全局统一,否则同一套判据在不同用例间的数值可比性很差。
第三个是信号类型不一致导致的静默错误。有一次模型接收的是single类型信号,测试激励里给的是double类型,Simulink里自动做了类型转换也没报错,但转换时出现了精度损失,导致一个边界值用例在阈值附近忽长忽短,跑十次能有两种结果。后来我在测试脚本里加了类型检查,凡是激励端口数据类和被测端口不一致就直接报警,不再静默转换。
这些坑单看都不是什么高深问题,但在实际项目中它们会一遍遍地消耗你的调试时间。我把它们写出来,就是希望你搭MIL环境时直接把这三件事做在前面——模型初始状态统一重置、求解器和采样方式全局固定、数据类型显式检查,能帮你省下好几周的踩坑时间。
4. MIL与SIL、HIL:三层测试到底怎么配合
4.1 各查各的问题:模型、代码、硬件三层职责划分
做嵌入式控制开发的人,应该都听过MIL、SIL、HIL这三个词。很多人把它们当成三种不同的测试工具,选一个用就行。其实它们解决的问题完全不同,是一套组合拳。
MIL查的是"算法模型本身对不对",它验证的是你在Simulink里画的那些逻辑块、状态机、查表模块,组合在一起是不是符合需求。SIL(Software-in-the-Loop)查的是"生成的代码和模型是否一致",被测对象从模型变成了Embedded Coder生成的C代码,它验证的是代码生成、编译、配置过程有没有把模型语义改歪。HIL(Hardware-in-the-Loop)查的是"代码跑到目标芯片上、和外部传感器/执行器交互时是否正常",被测对象是真实ECU或控制器硬件,它验证的是驱动、中断、总线通信、I/O映射这些模型和代码层面碰不到的问题。
举一个更具体的例子,电机控制中一个"死区补偿"的逻辑。MIL测试确认了逻辑本身正确:期望电压加上死区时间对应的补偿量,输出脉宽符合预期。SIL测试确认了生成的C代码执行同样的计算逻辑,没有出现整型溢出、定标误差。HIL测试确认了在真实MCU上,中断周期稳定,PWM模块配置出来的波形和软件里算出来的占空比一致。三个测试分别在论文阶段、软件阶段和硬件阶段堵住了不同类型的问题,缺一不可。
4.2 从MIL到SIL的过渡:步长、初值、类型转换三座山要搬掉
做SIL测试最常见的方法是用Embedded Coder把模型生成C代码,然后放到一个SIL仿真模式下运行,输入输出和MIL保持相同。听起来很简单,但实际上从MIL到SIL会遇到三个非常经典的问题。
第一个是仿真步长和求解器类型变化。很多模型在MIL阶段用的是定步长离散求解器,进了代码生成默认会保留这个配置。但如果你模型里混有连续模块,生成代码后这些连续模块会被转换为等价的数值积分函数,积分步长和精度行为会和Simulink仿真有微妙差异。比如一个PID控制器的微分项,在Simulink里用的是较高精度的积分算法,编译到嵌入式环境后,代码生成的离散化积分器会退化成最简单的欧拉法,PID的微分响应可能就不一样了。所以做MIL测试时,尽量让模型直接采用代码生成支持的离散化架构,别留太多"只在仿真环境下能跑"的连续模块。
第二个是初始状态的传递。MIL测试里你可以在SimulationInput里随便设置初始状态,但SIL测试时这些初始状态能不能传到生成的代码结构体里,又是另一回事。我通常的做法是做一个"函数级初始化"封装,模型提供一个Init函数,所有工作区变量和状态都在这个函数里赋初值。MIL和SIL测试都显式调用这个Init函数,从源头上保证两边初始状态一致。
第三个是数据类和精度差异。模型里某条总线信号在MIL中是double,生成代码后在结构体里可能变成single甚至uint16,取决于你设置的数据字典。有些模型在Simulink里double精度下一切正常,到了single精度下数值误差就开始累积了。我在SIL对比测试中就遇到过累积误差超过判据上限的情况,最后把模型中积分环节的精度从single改成double才过关。这个类型问题在MIL阶段就要提前扫描一遍,别拖到SIL再查。
4.3 MIL回归在开发中的节奏:不是跑一次就万事大吉
MIL测试不是一次性工作。一个靠谱的开发流程里,MIL测试至少会在三个节点各跑一轮。
第一轮是模型初版完成后,叫作"基线回归"。这时候的目的是验证模型功能和需求一致,用例要覆盖全部功能需求,这轮通过后模型才算"能拿得出手"。
第二轮是每次模型变更后,叫作"增量回归"。改动一个参数、加一个条件、改一个状态,都要把受影响的用例重新跑一遍。这里有个经验法则:每一条模型变更记录对应一条或一小组回归用例,不要每次都全量跑,也不要直觉猜"这个改动不影响其他功能"就不跑。我在实际中经常看到,改了一个查表的一端数据,结果阶梯函数在另一端产生了映射错误。所以增量回归的范围宁可大一点,也不要漏测。
第三轮是在代码生成之前,叫作"冻结前全量回归"。所有用例、全部覆盖度指标,全部跑一遍并留存报告。这是MIL测试工作的收官,之后模型进入代码生成阶段,原则上不允许再对算法逻辑做任何修改。如果逼不得已必须改,那就要重新走需求-用例-用例更新-全量回归的完整流程,很多项目为了这一个改动可能要付出两天的代价。这也是为什么我一直强调,做MIL测试时用例一定要写得和数据、脚本分离,让更新用例本身变得足够快。
5. 覆盖率、自动化和团队协作:MIL测试落地的最后一公里
5.1 覆盖率度量:别只盯着100%,要盯着"哪些没测到"
MIL测试做完了,需求也都验过了,但总觉得少了点什么。这时候需要看覆盖率。Simulink里自带的覆盖率分析工具能统计模型里的决策覆盖率、条件覆盖率、MCDC覆盖率等指标。对功能安全要求高的项目,一般要求决策覆盖率达到100%,MCDC覆盖率按不同ASIL等级要90%到100%不等。
我刚开始做MIL时,傻傻地盯着"决策覆盖率100%"这个目标跑去,结果发现就算把需求相关的测试用例全跑完,覆盖率也到不了100%,因为模型里有一些防御性逻辑,比如"输入幅值大于10000就报错"这种分支,需求文档里根本不会写,自然没有对应测试用例。硬凑100%只能被迫加一堆莫名其妙的用例,测试价值很低。
后来我调整了思路。覆盖率在MIL测试里不是"目标",而是"探索工具"。我先跑完需求用例,看一眼覆盖率报告,重点去分析那些"没有被覆盖到的分支"。如果某个分支是因为条件不可能发生(比如母线电压>1000V才会触发的保护),那是合理的,可以标记为"不可达"并在报告里说明理由。但如果某个分支理论上应该能触发却始终没触发,那很可能意味着需求理解错了或者测试激励设计得不充分,这才是我真正需要花时间的地方。
5.2 通过脚本实现批量回归:把MIL测试从手工活变成流水线
当你把测试用例增加到几十条、上百条时,在Simulink Test界面里一条条点Run是完全不现实的。正确做法是写一个回归脚本,让它能做三件事:加载测试信息、批量执行仿真、生成汇总报告。
我用的是经典的Simulink.SimulationInput迭代方式。先把所有用例的输入数据、期望结果存成独立的MAT文件或Excel表格,脚本遍历这些数据文件,每一条都构建一个SimulationInput,设置仿真参数,然后调用sim执行。执行完的结果自动和期望结果对比,生成一个数据结构,最后用publish或自写的报告生成函数输出HTML或PDF格式的测试报告。整个回归流程不需要人手动打开Simulink界面,在MATLAB命令行跑一条主函数就能完成。
这种做法还有一个额外的好处,就是能集成到CI流水线里。我们团队甚至用MATLAB Compiler把回归脚本编译成了可执行文件,每天夜里自动跑一遍全量回归,早上来公司先看测试报告,有红的话再定位问题。这样模型的每次变更都留有自动化测试的结果记录,几个月后翻出来还能知道"哪天改坏了什么"。
5.3 团队协作中MIL测试落地的几个细节经验
最后聊聊团队层面的事。MIL测试在技术层面不难,难的是让一整个团队都愿意按这个流程来。我在落地过程中总结了三个关键细节。
第一,测试用例和数据文件必须纳入版本管理,而且和模型文件分开管理。模型目录放模型,测试目录放测试脚本、测试数据、报告模板。这样当两条需求同时变更时,模型和测试用例可以分别在不同的分支上改,合并时冲突也少。别把测试数据放在模型同一目录的slprj里,那一堆自动生成的文件追版本能追到崩溃。
第二,看门狗一样守护"测试基线"。一旦某条用例在模型变更后失败了,不要直接动手改模型或改用例,先看变更前后差异,确认是模型行为变化还是用例期望值需要更新。如果确认是需求变更导致预期结果必须变,那么用例修改记录里要写上对应的需求变更编号,这样审查时可以追查每一个用例调整的理由。
第三,Excel表格和脚本数据文件要规范化命名。我见过团队用"新建文档(2).xlsx"这种文件名管理测试数据,一个月后没人敢动它。我的惯例是每条用例一个名字,包含用例编号、被测功能、激励类型和目标结果关键词,比如TC_042_TrqCmd_ABSActive_RampUp_SpeedChange_ExpectTqFallback.mat。文件一看就大概知道在测什么,即使过了半年再回来接手也不需要问前同事。
MIL测试做到这个程度,才算真正在团队里站稳了脚跟。它不是某个人单独做的一个验证活动,而是整个开发过程的一把尺子,每一次模型改动都要被它量一量。我私下觉得,做MIL测试最重要的品质倒不是多熟悉Simulink操作,而是愿意把每一个"应该没问题"的时刻都转化成一个可验证的自动化用例。模型这次没出问题,不等于下次不出问题,但如果你已经为这个"下次"备好了回归脚本和判据,那么问题一露头就能被抓住。这也是我一直坚持在项目里保留一套MIL自动化回归流程的根本原因。