做汽车电子或者嵌入式控制开发的同行,对V模式开发流程应该都不陌生。在模型交付给代码生成、代码真正跑到目标硬件之前,有两道绕不开的关口:MIL(模型在环)和SIL(软件在环)。这两道关守不住,后边的台架测试、实车测试全都是在给一个可能本来就错的控制器做辩护,问题拖到越后面,修起来越贵。
很多新手刚接触MIL和SIL时容易懵:这两个东西不是都在电脑上跑吗?有什么本质区别?测试步骤文档到底该写什么?覆盖率怎么收集?为什么MIL结果和SIL结果对不上?这篇文章我把做单元测试的完整链路梳理一遍,从MIL和SIL的概念定位、测试环境搭建、测试用例设计,一直讲到测试步骤文档怎么写、常见坑怎么避,所有内容都是基于我实际跑项目时验证过的做法,希望能帮同行省下一些自己摸索的时间。
1. MIL和SIL到底在验证什么
1.1 从V模型看单元测试的位置
在基于模型开发的流程里,V模型的左侧是逐层细化,右侧是逐层验证。模型在环和软件在环都出现在右侧比较靠上的位置,说白了就是“模型刚建完,代码刚生成出来”这个阶段。这个阶段解决的问题非常明确:控制逻辑写得对不对,以及生成的代码跟模型的行为是不是一致。
我见过不少项目组把MIL和SIL混为一谈,觉得反正都是在Simulink里跑仿真,跑一遍就行了。实际上这两个阶段的测试目标、测试对象和发现问题的类型完全不同。MIL阶段测的是“模型里边的算法是否符合需求”,SIL阶段测的是“生成的代码是否忠实地实现了模型”。前者验证的是需求落到模型这一层的正确性,后者验证的是模型落到代码这一层的正确性。
在功能安全相关的项目里,MIL和SIL更是被明确要求的测试手段,分别对应软件单元测试的不同阶段。所以这不仅仅是开发效率问题,还是流程合规问题。就算你做的项目没有严格的安全等级要求,把MIL和SIL做了,也能在早期拦截大量低级错误——比如数据类型不对、参数溢出、状态机分支漏了之类的问题。早期改模型是分钟级成本,等集成到实车上再发现,解bug按周算都不过分。
1.2 MIL和SIL的差别:一个测模型,一个测代码
MIL全称Model-in-the-Loop,模型在环,它跑的纯粹是Simulink模型本身。仿真引擎按你画的框图和状态机一步一步计算,输入信号通过Signal Editor这类工具注入,输出信号通过Scope、To Workspace或者Simulation Data Inspector(SDI)来观察。这个阶段不涉及任何C代码,模型里全是double或者你设置的数据类型,数学上比较理想。
SIL全称Software-in-the-Loop,软件在环,它跑的是模型自动生成的C代码。流程上,先把模型用Embedded Coder生成C代码,把代码编译成可执行文件或者MEX函数,再把这个MEX函数放回Simulink里以S-Function的方式运行。外部看起来还是在Simulink中仿真,输入输出的数据流接口完全一样,但计算的执行者已经不是Simulink的模型,而是你生成的代码。
两者对比下来,直观差异可以看这张表:
| 对比维度 | MIL(模型在环) | SIL(软件在环) |
|---|---|---|
| 测试对象 | Simulink模型/子系统 | 自动生成的C代码 |
| 运行载体 | Simulink求解器 | 编译后的MEX模块 |
| 验证目的 | 算法逻辑是否符合需求 | 代码行为是否忠实于模型 |
| 数据精度 | 按模型数据类型计算 | 受代码数据类型、编译优化影响 |
| 覆盖率含义 | 模型覆盖率 | 代码覆盖率 |
| 发现问题类型 | 逻辑错误、接口错误、需求理解偏差 | 代码生成配置错误、数据类型差异、初始化问题 |
SIL不是MIL的替代品。MIL跑得再完美,也只能说明算法逻辑正确,不能证明代码生成过程和代码执行过程没有问题。反过来,SIL跑得再快再稳,如果模型本身逻辑是错的,那也是把一个错误的逻辑固化到了代码里。所以正确的做法是:同一个测试用例集,先跑MIL,再跑SIL,两次结果做一致性对比。这也就是后面要讲的测试闭环。
2. 单元测试框架怎么搭:TestCase、Test Harness与覆盖率
2.1 测试用例怎么来
测试用例是整个单元测试的灵魂。工具再熟、脚本再漂亮,用例设计是空的,测出来的结果毫无价值。我在项目里主要用四类用例来源做设计,缺一不可:
- 需求条目直接映射:把需求描述里的每个输入条件、每个输出指标拆成具体的激励信号和期望值。比如一条需求说“车速低于30km/h时禁用巡航”,那就要针对29、30、31这几个边界点各设计用例。
- 等价类划分:把输入空间划分为有效等价类和无效等价类,从每个类里挑代表值。这个方法能保证用最少的用例覆盖最多的行为分支。
- 边界值分析:重点关注“刚好等于”“刚好超过”“刚好低于”这几个临界点。控制算法里死区、迟滞、饱和、限幅到处都是,边界是最容易出bug的地方。
- 错误猜测法:靠经验和直觉猜哪里容易出错,比如除零、信号跳变、输入悬空、状态在短时间内高频切换等等。这类用例没有标准可循,纯粹是经验的积累。
在项目里,我习惯把用例整理成Excel表格,列字段包括:用例ID、关联需求ID、输入信号名、输入描述、持续时间、期望输出、容差设置、对应测试层级。测试人员直接用这个表驱动后续的用例导入,比较顺手。
这里有一个很关键的点:期望值怎么来。不要用手算,尤其是控制算法比较复杂的项目,根本算不过来。更稳妥的做法是用一个经过需求评审和专家确认的高保真模型作为参考模型,或者用已经冻结的上一个版本模型跑出来的结果做基线。总之,期望值本身要有权威性,这样才能让“测试失败”有说服力。
2.2 Test Harness的正确打开方式
Test Harness是Simulink里专门为单元测试设计的宿主环境。在Simulink里右键被测子系统,可以直接选择“Create Test Harness”创建测试环境。Test Harness会把被测对象从大模型中隔离出来,只暴露它的输入输出接口,这样你能独立地给这个单元注入信号、观察输出,而不会牵一发动全身。
为什么推荐用Test Harness而不用自己手动搭一套信号源加示波器的模型?因为Test Harness有几个非常实用的特性:
- 它跟被测模块的接口是自动绑定的,减少了手动接线出错的可能。
- 它可以保存多套测试用例,每次运行不同的用例时直接切换,不用反复修改输入源。
- 它内置了对SIL模式的支持。这是后续做MIL/SIL对比最关键的点——同一个Test Harness,先以正常模式运行就是MIL,切换到SIL模式并生成代码后就是SIL,测试用例不用换,对比结果也更直观。
我习惯给每个被测子系统都建一个独立的Test Harness。虽然项目前期多花了一点建模时间,但后面跑回归的时候,每个单元都能独立、并行地测试,效率提升非常明显。
2.3 覆盖率配置不能省
覆盖率不是“可选加分项”,而是判断测试充分性的硬指标。在Simulink里,覆盖率工具一般指Simulink Coverage。它能统计决策覆盖、条件覆盖、MCDC覆盖等多项指标。
- 决策覆盖:每个判定语句的真假分支是否都被执行到。
- 条件覆盖:判定里每个单个条件的真假是否都被执行到。
- MCDC覆盖:每个条件是否独立地影响过判定结果,这个要求最高,用于安全等级高的项目。
在配置覆盖率时,我通常设置三个层级:系统级、子系统级、逻辑对象级。如果是简单模块,看系统级总覆盖率就够了;如果是复杂逻辑模块,一定要展开到逻辑对象级,比如Stateflow里的状态和迁移。MCDC建议对控制类逻辑开启,这是最容易被需求评审方挑战的地方。
覆盖率报告只是在最后拿到一个百分比数字还不够。我之前就犯过这个错:MCDC跑到100%了,但细看配置,某个关键状态机分支被排除掉(Exclusion)了,而这个排除理由是“该分支不可达”,结果代码走查时发现分支实际可以到达。所以说,覆盖率要“看得见、追得到、能解释”。每一条排除规则都要写清楚原因。
3. MIL测试实操:从配置到报告
3.1 第一步:搭好MIL测试环境
MIL测试环境搭建相对直接,核心目标是:保证测试过程中模型的编译和仿真行为跟最终集成环境一致。
需要检查的第一件事是求解器设置。单元测试建议使用固定步长求解器,比如discrete或固定步长的ode3。为什么一定要固定步长?因为SIL阶段生成的代码是按固定采样周期执行的,如果你MIL用变步长连续求解器跑,到了SIL阶段行为差异会非常大,MIL和SIL结果没法做严格对比。数据类型上,MIL阶段默认情况下用double,但不要天真地认为所有模型都是double。如果模型里设置了single或定点数,MIL阶段就应该把这些数据类型反映出来,否则MIL结果不具备参考性。
接着创建Test Harness,把被测子系统包进去。接口信号要检查一遍,尤其是总线信号和枚举类型。很多人在这里踩坑:被测模块的输入是一个Bus,测试框架里没有定义对应的总线对象,结果仿真报错。更合理的做法是让Test Harness自动继承原模型的总线定义,不要在新环境里重新造一个总线对象。如果真的需要重建,一定要通过总线段落编辑器映射好,不然端口类型不匹配,仿真根本起不来。
还有一个容易被忽视的点:模型的回调函数。有些模型在InitFcn或者StartFcn里会加载工作区变量,这些变量可能是参数,也可能是查表数据。Test Harness如果不继承父模型的回调,仿真时就会提示找不到变量。我的习惯是把所有模型回调依赖的变量都整理成base workspace里的MAT文件,在Test Harness的初始化脚本里统一加载,这样MIL和SIL阶段的行为才一致。
3.2 第二步:注入测试用例并执行
MIL测试执行的核心是把测试用例变成实际激励。我最推荐的方式是用Signal Editor工具配合时间表(timetable)数据。信号编辑界面里可以直接画出波形、设置数值表,也可以从外部导入MAT或Excel数据。
但注意:Signal Editor本身适合简单的单用例测试,如果用例数量一大,手动切换就太痛苦了。项目里我会写一个简单的MATLAB脚本,批量导入Excel里的用例数据,将其转换成timetable对象,然后逐用例调用sim函数执行仿真。这样连续跑几十个用例都不需要人工干预,测试结果统一写到结构体里。
执行仿真的过程中,我一般会开Simulation Data Inspector(SDI)来实时查看信号。第一次跑通MIL测试时,不要只盯着最终结果,建议把几个关键中间信号也拉出来看一眼,比如状态量、积分器的输出、使能条件信号。很多时候最终输出是对的,但中间某个状态已经发散了,只是被后面的限幅挡住了而已,这种隐患不提前发现,后面做SIL时很可能对不上。
3.3 第三步:结果比对与覆盖率导出
每个用例跑完之后,需要把仿真输出跟期望值做比较。这里不要直接用眼睛看曲线,而是建议在脚本里做自动比对。比对策略是:
- 按时间轴对齐采样点。固定步长下,MIL和期望值的采样时间应该是一致的。
- 计算绝对误差或者相对误差,跟预先设置的容差比较。
- 定义通道级判定。比如某个通道相对误差超过1e-3,就判为该数据点失败。
- 汇总用例级结果。只要有一条数据点失败,整个用例标记为失败,并记录失败时间点。
容差的设置值得单独说一句。MIL阶段如果模型本身是double且没有定点化,容差可以设得很严,比如1e-6甚至更小。如果模型用了float,容差就需要放宽到1e-5左右。容差设置过严,会把浮点计算误差误判为逻辑错误,测试报告看起来全是红,反而掩盖真实问题;容差设置过松,又会让真实偏差逃逸。比较好的做法是先跑两三个已知正确的用例,统计一下最大误差,再以一个合理余量设定容差。
覆盖率的结果导出也要在两轮测试中都做。MIL阶段的覆盖率直接由Simulink Coverage生成报告,格式可以是HTML或者PDF,我会在项目归档时一并放到配置管理系统里。
4. SIL测试实操:让生成的代码“跑起来”
4.1 生成代码前的配置清单
SIL测试的准入门槛比MIL高,因为生成代码这一步牵涉的配置项非常多。踩坑经验总结下来,这几项必须逐条确认:
- 系统目标文件:单元测试的SIL仿真建议使用ERT(Embedded Real-Time Target)配置,确保代码是面向嵌入式环境的,避免生成一堆只能在Windows上跑的适配层。
- 求解器:固定步长,步长要和期望的运行周期一致。如果模型里还有连续状态,要确保离散化配置正确。
- 代码生成语言:默认C,不要用C++除非你的集成目标是C++环境。
- 优化设置:默认情况下代码优化是开启的,但这会带来一个副作用——部分信号在代码优化过程中被消掉了,SIL阶段想看中间信号可能看不到。测试阶段我建议把“信号级优化”关掉,折中方案是保留执行级优化,但关闭那种“星号消除”之类的深度优化。
- 生成的文件打包格式:选“MODEL”格式,每个被测子系统独立打包,方便在Test Harness中单独调用。
配置好之后,在Test Harness中右键被测模块,选择“C/C++ Code”下的“Build Subsystem”。这一步会调用Embedded Coder生成代码、编译成MEX文件,并且会生成一个SIL仿真用的接口层。构建成功之后,Test Harness的仿真模式里会多出“Software-in-the-loop”的选项。
生成代码之后还要确认一件事:MEX文件的版本。同一个子系统改过模型后重新生成代码,如果MEX文件名没有变化,Simulink可能因为文件时间戳没刷新而继续用旧的MEX文件。这是SIL测试中特别隐蔽的坑。我习惯在构建脚本里主动清空生成的二进制目录,保证每次SIL测试用的都是当前模型生成的代码。
4.2 用SIL模式跑单元测试
SIL模式跑起来其实很简单:在模型工具栏的仿真模式下拉菜单里选“Software-in-the-loop”,或者直接用sim函数时指定'SimulationMode','Software-in-the-loop'。
但这里有个注意事项:SIL仿真的执行速度比MIL慢不少,即使代码经过了优化,每次MEX调用都有仿真引擎跟代码之间的数据交换开销。如果是简单子系统还好说,如果是状态较多的复杂模块,几十个用例跑下来时间还是比较可观。为了效率,我建议在Test Harness中尽量只包含被测子系统,不要带着外围信号处理模型一起仿真。外围逻辑不会替你验证被测对象,只会拖慢SIL时间。
跑SIL仿真的同时,覆盖率工具也要同步打开。SIL阶段的覆盖率其实是非代码级覆盖率的延伸——它统计的是在代码执行过程中,模型中的哪些执行路径被覆盖到了。注意,这里的覆盖率报告呈现的是“模型视角”,不是“C代码的行覆盖”,两者有区别。如果你需要C代码的行覆盖率,一般需要集成第三方覆盖率工具链来做插桩,那属于代码级单元测试的范畴,本文先按下不表。
4.3 MIL与SIL结果对比:误差应该控制在什么范围
跑完SIL之后,最重要的一步:把MIL和SIL的结果拉到一起对比。我见过的对比方式有两种:
- SDI手工对比:把两次运行都记录到SDI里,利用信号比对功能看曲线差异。适合只看一两组关键信号,快但不够严格。
- 脚本自动对比:把两次运行的结构体数据加载进来,按用例ID逐一比对。强制、可追溯,是我在项目里一直用的方式。
怎么定义MIL和SIL的“一致”?我用三个维度来评判:
- 时序一致性:SIL仿真中信号的时间序列与MIL完全对齐,不能出现时间偏移或者步数不一致。
- 数值一致性:计算每个时间点的误差,不超过预定义容差。double到double的代码生成,容差一般1e-6够用;如果模型内部有single,容差建议放宽到1e-4或更大。
- 行为一致性:重点关注模式切换、状态跳变、保护逻辑触发等关键事件发生的时刻和次序是否一致。数值差异是表面现象,行为差异才是真正的问题。
如果出现了较大的模型结果不一致,不要急着改代码,先排查几个高频原因:
- 数据类型变化:模型里某个信号是double,但代码生成配置里被优化成了single,精度差异被放大。
- 初始化顺序:MIL仿真中状态初始化为0,但代码里局部变量没有显式初始化,导致第一次运行的值不可控。
- 代码优化的数学变换:开启fast math相关优化后,浮点运算顺序可能会被改变,误差被累积。
- 采样时间不一致:SIL执行的任务周期跟MIL的步长设置得不一致,导致时间轴上错位。
排查这些问题的思路是先锁定差异出现的时间点,再抓取该时间点前后5个采样步的所有关键信号,对比MIL和SIL中的数值。只要定位到第一个差异点,后面基本就顺藤摸瓜能查出来。
5. 测试步骤文档该怎么写
5.1 文档骨架:一份模板直接复制
测试步骤文档不是写给测试人员自己看的,它是给评审、审计、后续维护的人看的。核心要求是:把测试过程可追溯地讲清楚。一份合格的MIL/SIL测试步骤文档,我建议至少包含以下章节:
| 章节 | 内容要点 | 编写细节 |
|---|---|---|
| 1. 测试目的 | 本次测试要回答什么问题 | 明确范围(哪些单元、哪些需求) |
| 2. 测试环境 | MATLAB版本、工具箱、操作系统、编译器版本 | 版本不一致会导致结果无法复现 |
| 3. 被测对象 | 模型名称、版本号、子系统路径、代码生成配置 | 给出模型和代码的配置项快照 |
| 4. 测试工具 | Test Harness名称、覆盖率配置、比对脚本 | 说明工具链的调用方式 |
| 5. 测试用例清单 | 用例ID、关联需求、输入说明、期望输出、容差 | 表格化,逐条可追溯 |
| 6. 测试执行步骤 | MIL和SIL分开写,每个阶段分步描述 | 包括构建、运行、导出结果的命令或操作 |
| 7. 执行结果记录 | 每个用例通过/失败、最大误差、覆盖率数据 | 失败项必须附根因分析 |
| 8. 结论放行 | 用例通过率、覆盖率是否满足门槛、遗留问题清单 | 明确是否可以进入下一阶段 |
文档里最容易水的是第二部分“测试环境”。很多人写“MATLAB R2021a”就完事了,但真正的可复现要求远不止此。我建议把每个参与仿真的工具箱版本都列出来,例如Simulink、Simulink Coverage、Embedded Coder各自的版本号,外加操作系统的版本和编译链信息。别嫌啰嗦,半年后项目移交时,新接手的人能精准复现环境,就是这些细节在起作用。
5.2 评审中容易被挑战的问题
做测试文档评审时,评审方最爱问的几个问题,提前把这些想清楚,文档就不会被扫两页打回:
- “为什么这个用例算通过?”——因为我的对比脚本里有明确的比对准则:时间对齐、误差阈值、通道判定规则,全部写在文档里。
- “覆盖率80%的目标值是怎么定的?”——根据项目质量目标和安全等级来的,不是拍脑袋。文档里要写明覆盖率目标的来源以及未覆盖分支的逐项分析。
- “这个失败用例的根因是什么?后续怎么处理?”——失败不是简单标红就完了,每一条失败用例都要有原因分析和对应修改记录。如果模型因为测试失败被改过,那就是一个新版本,需要回归整个测试集。
我发现很多测试文档写得不够好的根本原因,不是不会写,而是没有把“测试”当成一个闭环在管理。测试不是跑完拉倒,而是“设计-执行-分析-修改-回归”的反复迭代。文档记录的是这个闭环的轨迹,而不是某一次快照。时刻带着这个意识写文档,写出来的东西才经得起推敲。
还有个小细节:文档里的截图、日志、覆盖率报告要统一命名和归档。我最常用的目录结构是:
MilSil_TestCase/ ├─ MIL/ | ├─ 用例输入.xlsx | ├─ 仿真脚本.m | ├─ 输出结果/ | └─ 覆盖率报告.html ├─ SIL/ | ├─ 生成代码/ | ├─ 编译日志/ | ├─ 输出结果/ | └─ 覆盖率报告.html ├─ 文档/ | ├─ 测试步骤说明.md | ├─ 结果比对表.xlsx | └─ 评审记录.pdf这个结构不复杂,但胜在清晰。任何一次测试都能在目录里找到自己的位置。
6. 实战避坑经验与常见问题
6.1 高频问题和排查思路速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| MIL仿真报“找不到总线对象” | Test Harness没有继承总线定义 | 在模型配置中手动加载总线定义文件,或改由模型自带的Port对象导入 |
| SIL模式启动就报编译错误 | 编译器未配置或MEX环境损坏 | 运行mex -setup C重新配置编译器,或重装对应工具链 |
| SIL仿真能跑但结果全为0 | 子系统的状态没有初始化 | 检查生成代码前模型的状态初始值配置 |
| MIL和SIL结果不匹配 | 数据类型/求解器/优化配置不一致 | 对比两阶段信号的采样时间、数据类型、容差设置 |
| 覆盖率一直不变 | Simulink Coverage作用域设置不对 | 检查覆盖率配置的统计范围,确认包含被测子系统 |
| Test Harness仿真过程中死机 | 输入信号时间表长度太大或步长过小 | 降低仿真时长或增大采样步长,先跑通再逐步加密 |
| 生成代码后模型运行缓慢 | MEX文件没有增量刷新,反复重建 | 清理二进制目录后重新构建一次,确认时间戳更新 |
| 输出曲线带毛刺 | 信号输出端没有加零阶保持 | 数据转换时加Rate Transition或Unit Delay |
这张表是我在多个项目里沉淀下来的高频问题。可以看到,大部分问题都不是算法问题,而是环境配置和测试一致性这类工程细节。这也是为什么很多开发组长会把“测试环境冻结”作为项目里程碑的一个重要节点——环境不稳定,测试结果就永远测不准。
6.2 几个让我少走半年弯路的细节
分享几个在真实项目里让我印象最深的教训,都是常规文档里不会写的东西。
第一,测试用例不是越多越好。我曾经为了“充分测试”,给一个简单的PID模块设计了300多个用例,每个用例跑MIL和SIL各一次,折腾了整整一周。可回头看,很多用例实质上是同一个等价类的重复,真正有意想不到发现的,还是那20个边界用例和异常输入用例。经验是:用例设计时先做等价类分析,再针对边界和风险点加用例,数量控制在80~150个左右就够了。用例数量失控,往往说明需求理解不透彻,而不是测试更充分。
第二,SIL阶段一定要盯着MEX文件的生成时间戳。有次我改了模型里一个增益参数,重新生成代码也成功了,但SIL仿真结果跟预期完全对不上。查到最后发现是Simulink缓存了旧的MEX文件,根本没有重新编译。从那以后我养成了一个习惯:每次SIL测试前,先看MEX文件的修改时间是否比模型修改时间新,不对就直接手动删除缓存目录重新构建。
第三,Test Harness里不要随便用Goto/From标签传递信号。这个设计看起来方便,但在几十个用例切换时特别容易串信号。之前一个项目里,两个不同的测试用例通过Goto标签共享了同一个数据源,结果用例A修改输入后,用例B跑出来的结果也跟着变。这种问题排查起来极度痛苦,因为它不是逻辑错误,而是测试系统本身的污染。后来我全部改成显式连接信号线,彻底消灭了这类隐性Bug。
第四,保存测试结果时,一定要连同被测模型的版本号一起记录。你不需要很复杂的配置管理,只需要在MIL或SIL导出的结果文件名里加上模型的版本快照号。如果哪次测试发现结果有异常,能立刻知道这个结果是不是当前模型跑出来的。在没有版本记录的情况下,一次“结果不一致”就会演变成“哪个结果是准的”争论,白白耗费时间。
最后总结一点个人体会:MIL和SIL测试做得好不好,本质上取决于测试用例质量和过程管理,跟工具有多高级关系不大。Simulink自带的工具已经足够完整,真正拉开差距的是那套用例怎么设计、报告怎么闭环、问题怎么追。遇到MIL和SIL结果不一致时,不要急着怀疑代码生成器或者仿真引擎,先把数据对齐、把环境配置拉平、把特性差异理清楚,九成问题会浮出水面。把这套流程跑顺了,你的测试报告在评审会上会特别硬气。