很多做嵌入式开发或者汽车电子控制器的工程师,应该都遇到过类似的问题:Simulink 里搭的模型,仿真波形干净漂亮,PID 参数怎么调都顺手,Scope 里的曲线也完全符合预期。可一旦通过 Embedded Coder 生成 C 代码,部署到实际 MCU 上,或者集成到别人的工程里,行为就开始“不听话”了。有的只是个别边界条件下响应出现了偏差,有的是浮点数误差被一步步放大,严重的甚至功能完全错乱。
这种时候,大多数人第一反应是怀疑代码生成工具有 bug,或者是编译器优化出了问题。但更常见的真相是:模型和代码之间,缺少了一次系统性的等价性验证。你没有证明过“生成的代码”和“模型本身”在行为上是一致的,就直接进入了硬件集成,问题自然会在最不合适的时候浮出水面。
软件在环仿真(Software-in-the-Loop,SIL)就是用来填补这个空白的。
这篇文章是 Simulink 入门到进阶系列的第十七篇,我会从一个实际工程痛点出发,讲清楚 SIL 到底是什么、它在 MIL/SIL/PIL/HIL 这条完整验证链路里处于什么位置、为什么说 SIL 是模型与代码之间的“翻译检查官”,然后给你一套可以在 Simulink 里直接跑起来的 SIL 验证流程。读完你会发现,SIL 并不是一个复杂的高端功能,而是一个性价比极高的早期风险拦截手段。
1. 为什么需要 SIL:模型仿真和真实代码之间隔着一道鸿沟
在做基于模型的设计(Model-Based Design,MBD)时,很多团队会陷入一种“仿真通过等于一切搞定”的错觉。Simulink 模型本质上是一种高级抽象,它描述的是算法逻辑和控制策略,而不是处理器真正执行的指令。当你点下“Generate Code”按钮,代码生成器会做一系列转换:把连续时间积分器变成离散差分方程,把 double 类型根据配置转成 single 或定点类型,把矩阵运算展开成循环和函数调用,甚至改变某些数学表达式的运算顺序。
每一个转换环节,都可能引入行为差异。
举一个典型的例子。你在 Simulink 模型里写了一个控制器,状态变量是 double 类型,仿真步长 1ms,结果一切正常。但生成代码时,为了满足嵌入式环境的资源限制,你把关键信号改成了 single 或者定点数。在模型层面,Simulink 的仿真引擎用的是 MATLAB 的浮点运算环境;在编译后的代码里,C 编译器可能会重新排列浮点表达式,也可能因为优化选项改变中间量的截断方式。这些差异在单个计算步骤里可能只有 1e-7 的量级,但在闭环系统里经过几千步迭代,就可能积累成一个肉眼可见的偏差。
更隐蔽的问题发生在“未定义行为”层面。比如模型里的除法操作,在 MATLAB 里除零会给出 Inf 或 NaN,但在 C 代码里,整数除零直接导致硬件异常;又比如查表模块,模型对断点之外的数据可能有外推策略,但生成代码后某些目标平台可能直接返回边界值。
SIL 的核心价值,就是让你在不依赖任何硬件的情况下,把生成的 C 代码跑在主机环境里,然后和原始模型跑出来的结果做对比。如果两者一致,说明“算法逻辑”这个层面没有丢失;如果不一致,你可以尽早定位差异来源,而不是等到 HIL 或者实车阶段再排查。
从工程成本角度看,SIL 是 V 流程里单位成本最低的验证手段之一。它不需要实时仿真机,不需要 ECU 硬件,只需要一台安装了 MATLAB 的电脑,就能完成代码逻辑正确性的验证。这也是为什么 SIL 在快速迭代、持续集成盛行的今天越来越受重视。
2. 什么是 SIL:先看清 MIL、SIL、PIL、HIL 的全貌
SIL 不是孤立存在的,它是模型验证链条里的一环。要理解 SIL,最好把四个“环”放在一起看。
MIL(Model-in-the-Loop,模型在环):控制器模型以纯 Simulink 形式运行,被控对象模型也是 Simulink 形式,两者完全在仿真环境里交互。这是你做控制算法开发时最常做的事。MIL 验证的是“控制策略是否正确”,不涉及任何代码生成。
SIL(Software-in-the-Loop,软件在环):控制器模型被替换为“生成的 C 代码编译后的可执行体”,但这个可执行体仍然运行在主机 CPU 上,而不是目标处理器上。被控对象模型可以继续留在 Simulink 环境里。SIL 验证的是“生成的代码是否忠实执行了模型的行为”。
PIL(Processor-in-the-Loop,处理器在环):生成的代码被部署到目标处理器(或者开发板)上,处理器通过串口、以太网或者 JTAG 与 Simulink 通信。被控对象模型依然运行在主机上。PIL 验证的是“目标处理器上实际执行的效果”,包括了编译器、处理器字长、外设时序的影响。
HIL(Hardware-in-the-Loop,硬件在环):真实控制器(ECU)连接到实时仿真机,实时仿真机运行被控对象模型,通过真实 I/O 信号交互。HIL 验证的是“真实控制器在实时环境下的整体行为”。
下面这张表可以更直观地看出它们的差异:
| 验证方式 | 控制器载体 | 被控对象载体 | 是否需要目标硬件 | 验证重点 |
|---|---|---|---|---|
| MIL | Simulink 模型 | Simulink 模型 | 否 | 控制策略和算法逻辑 |
| SIL | 生成代码编译后的程序(运行在主机) | Simulink 模型 | 否 | 模型与代码的等价性 |
| PIL | 生成代码部署到目标处理器 | Simulink 模型(通过接口通信) | 是 | 目标处理器上的实际执行效果 |
| HIL | 真实 ECU | 实时仿真机 | 是 | 真实控制器的实时闭环行为 |
从这张表能看出一个关键结论:SIL 是唯一一个既不需要目标硬件,又能验证“代码层面”正确性的环节。它把“模型到代码”这个传统上的黑盒过程变成了可观察、可对比、可回归的工程环节。
很多人对 SIL 有一个误解,以为 SIL 等于把模型生成的代码直接拿到 MATLAB 外面去跑,跑通了就算完事。实际上,SIL 仍然是在 Simulink 的仿真框架里执行的,只不过控制器部分的执行载体从仿真引擎换成了生成的代码。你可以在 SIL 模式下继续使用信号记录、数据检查、覆盖率分析这些 Simulink 工具,这给验证工作带来了很大的便利。
3. SIL 的核心原理:代码生成后,Simulink 究竟做了什么
要理解 SIL 模式,你需要知道 Simulink 在背后做了哪些事情。
当你对一个子系统启用 SIL 仿真时,Simulink 会经历这样的过程:
- 把子系统的 MATLAB/Simulink 模型转换成目标代码,通常是通过 Embedded Coder 生成 C 代码。
- 调用主机上的 C 编译器,把生成的 C 代码编译成目标文件或可执行文件。
- 将编译好的代码包装成 Simulink 可以调用的 S-Function 或其他仿真目标类型。
- 在接下来的仿真过程中,Simulink 不再调用该子系统原本的仿真方法,而是调用编译好的代码。
从行为上看,SIL 之后模型依然在“跑”,但控制器部分的每一步计算,执行的是 C 代码的逻辑。因此,你可以在同一个模型里,先跑一次 Normal 模式记录 MIL 基线数据,再切到 SIL 模式记录代码执行数据,然后做对比。
这里涉及一个关键概念:等价性。SIL 验证的本质,是证明模型和代码在输入相同的情况下,输出在可接受误差范围内一致。理论上,如果代码生成配置正确、数据类型一致、浮点运算没有改变,MIL 和 SIL 的结果应该非常接近。但在实际工程中,两者的结果不可能完全逐位相等,原因包括:
- C 编译器对浮点表达式的优化,可能导致中间变量保留更高或更低的精度。
- 三角函数、指数函数等数学库函数,在不同平台上的实现精度不一样。
- 代码生成器可能对某些计算进行了等价变换,改变了运算顺序。
- 信号数据类型如果从 double 变成了 single,精度差异会被放大。
所以,SIL 的结果对比不能简单用“是否相等”来判断,而是需要一个“误差容忍范围”。这个范围根据应用场景而定,控制算法通常可以接受微小浮点误差,但安全相关功能就要严格得多。
理解 SIL 的边界同样重要。SIL 验证不了以下几类问题:
- 目标处理器的字长、端序、中断优先级等硬件特性。
- 编译器在目标平台上的真实行为(例如跨平台编译时,主机编译器差异)。
- I/O 时序、看门狗、外设初始化等与硬件强相关的问题。
- 实时性。SIL 跑得快慢取决于主机 CPU,它无法验证代码在目标处理器上是否能满足实时性要求。
因此,SIL 的价值在于快速发现“模型到代码转换”过程中的逻辑问题,而不是替代 PIL 或 HIL 的硬件级验证。
4. 环境准备:SIL 需要哪些配置
在开始 SIL 操作之前,先检查一下你的环境是否满足要求。
SIL 本质上依赖代码生成能力,所以最核心的工具是Embedded Coder。没有这个工具箱,Simulink 的代码生成功能会受限,SIL 模式也无法启用。此外,你还需要一个可用的 C 编译器。MATLAB 通常会自带或自动检测 MinGW 编译器,但如果你的机器上已经有 Visual Studio,也可以手动指定。
另外,为了减少 MIL 和 SIL 之间的差异,建议在模型层面就提前做好规范化。几个关键点:
第一,求解器务必使用固定步长。SIL 模式下,Simulink 执行的是生成的离散代码,它不能像普通仿真那样处理变步长连续积分。如果你模型里只有离散模块,可以选用discrete求解器;如果确实有连续状态,也要在固定步长求解器下运行。实践中,控制算法模型最好从一开始就设计成离散系统。
第二,待测子系统需要是“原子子系统”。在生成代码时,Simulink 需要把子系统作为一个独立单元处理。右键子系统,打开Block Parameters,在代码生成选项里勾选Treat as atomic unit。如果不做这一步,代码生成器可能会把模块逻辑打散,SIL 验证对象就不够清晰。
第三,代码生成配置需要正确。在模型的配置参数里,建议把系统目标文件设置为ert.tlc,这对应 Embedded Coder 的实时目标。如果只是简单验证,可以用默认的grt.tlc,但为了更接近嵌入式部署场景,ert.tlc更合适。
第四,信号记录方式要提前规划。你需要对比 MIL 和 SIL 的输出,所以在模型里给关键信号启用Signal Logging,或者使用To Workspace模块输出仿真数据。
如果你准备用命令行方式跑 SIL 测试,还需要对 MATLAB 脚本有一定了解。下面是一个最基本的配置检查示例:
% 检查 Embedded Coder 是否可用 licenseStatus = license('test', 'Embedded_Coder'); if ~licenseStatus error('缺少 Embedded Coder 许可证,无法进行 SIL 验证'); end % 加载模型(假设模型名称为 myController) mdl = 'myController'; load_system(mdl); % 设置固定步长求解器 set_param(mdl, 'Solver', 'FixedStepDiscrete'); set_param(mdl, 'FixedStep', '0.001'); % 设置系统目标文件为 ert.tlc set_param(mdl, 'SystemTargetFile', 'ert.tlc'); % 关闭模型,后续通过仿真输入对象来配置仿真模式 close_system(mdl, 0);这段代码只是一个起点,实际项目中你还会遇到配置集(Configuration Set)的复用、代码生成目录的管理等问题。但环境准备好之后,真正的 SIL 测试流程就不复杂了。
5. SIL 实操流程:从模型到 SIL 验证
5.1 准备一个最小可验证的模型
为了演示,我们不搭复杂的被控对象,而是用一个典型的闭环结构:信号源产生参考输入,控制器子系统输出控制量,被控对象子系统接受控制量并输出反馈。被你验证的重点,是控制器子系统的代码生成。
在 Simulink 里建立一个模型,结构大致如下:
- 信号生成模块:Step 或 Sine Wave,生成参考输入。
- 控制器子系统:内部可以是一个离散 PID 控制器或其他算法。
- 被控对象模型:可用连续或离散传递函数表示。
- 数据记录:在控制器输出和反馈信号上启用信号记录。
这里的关键是,控制器子系统的输入输出接口要清晰,不要包含任何与硬件相关的模块。SIL 验证的对象应该是“纯算法逻辑”,而不是硬件驱动。
5.2 对控制器子系统执行代码生成
选中控制器子系统,右键点击,在菜单中找到C/C++ Code,选择Build This Subsystem。如果菜单里没有这一项,说明 Embedded Coder 许可证或环境配置还有问题。
构建完成后,Simulink 会在代码生成目录下生成 C 源文件、头文件和构建信息。此时,你可以用slbuild命令验证生成过程:
mdl = 'myController'; slbuild([mdl '/Controller'], 'ModelReferenceCompliant', 'off');如果构建成功,说明代码生成链路已经打通。这一步生成的代码就是后续 SIL 运行要执行的目标。
5.3 切换仿真模式为 SIL
构建完成后,你可以在控制器子系统上右键,选择Block Parameters (Subsystem),然后在Code Generation标签下看到Simulation mode选项。把它从Normal切换为Software-in-the-loop (SIL)。
再点击“运行”按钮,Simulink 就会进入 SIL 仿真模式。此时控制器子系统会执行生成的代码,而不是原本的模型逻辑。
如果希望在命令行里完成这个切换,可以使用Simulink.SimulationInput对象:
% 创建仿真输入对象 simIn = Simulink.SimulationInput(mdl); % 设置控制器子系统为 SIL 模式 % 注意:这里需要根据实际子系统路径修改参数名 simIn = simIn.setModelParameter('SimulationMode', 'Software-in-the-loop (SIL)'); % 运行 SIL 仿真 simOutSIL = sim(simIn);不同 MATLAB 版本对SimulationMode的字符串取值可能有差异,建议在命令行里输入edit sim查看文档,或者先通过界面切换后,再用get_param查看实际生效的参数值。
5.4 运行 MIL 仿真作为基线
SIL 的对比对象是 MIL 仿真结果,所以在跑 SIL 之前,你一定要先保存一份 Normal 模式下的数据。否则,缺少基线,后面就无法做等价性判断。
% 使用 Normal 模式运行 MIL 仿真 simInMIL = Simulink.SimulationInput(mdl); simInMIL = simInMIL.setModelParameter('SimulationMode', 'Normal'); simOutMIL = sim(simInMIL);保存好simOutMIL和simOutSIL后,就可以进入结果对比环节了。
5.5 完整脚本示例:MIL/SIL 自动化对比
在实际项目中,手动切换仿真模式、每次记录输出,效率太低。更推荐的做法是写一个自动化脚本,一次性完成“MIL 跑基线、SIL 跑代码、结果自动对比”三个步骤。
下面是一个可运行的 MATLAB 脚本骨架:
% 文件名:run_sil_verification.m % 功能:运行 MIL 基线,运行 SIL 模式,对比关键信号 clear; clc; % 模型名称 mdl = 'myController'; load_system(mdl); % 固定步长配置(如果模型尚未设置) set_param(mdl, 'Solver', 'FixedStepDiscrete'); set_param(mdl, 'FixedStep', '0.001'); % 配置信号记录 % 假设模型中已经启用了信号记录,如果未启用,可使用以下方式启用: % 需要提前往模型中添加 Signal Logging 或在输出端口配置 To Workspace % 运行 MIL simInMIL = Simulink.SimulationInput(mdl); simInMIL = simInMIL.setModelParameter('SimulationMode', 'Normal'); simOutMIL = sim(simInMIL); % 运行 SIL simInSIL = Simulink.SimulationInput(mdl); simInSIL = simInSIL.setModelParameter('SimulationMode', 'Software-in-the-loop (SIL)'); simOutSIL = sim(simInSIL); % 提取仿真输出信号 % 这一步取决于你的模型如何记录数据,常见方式是通过 logsout logsMIL = simOutMIL.logsout; logsSIL = simOutSIL.logsout; % 假设记录的信号名称为 'ControllerOutput' sigMIL = logsMIL.get('ControllerOutput').Values.Data; sigSIL = logsSIL.get('ControllerOutput').Values.Data; % 计算最大绝对误差 maxDiff = max(abs(sigMIL - sigSIL)); % 设定误差阈值,判断是否通过 threshold = 1e-6; if maxDiff < threshold disp(['SIL 验证通过,最大误差:', num2str(maxDiff)]); else disp(['SIL 验证失败,最大误差:', num2str(maxDiff)]); end这个脚本把 MIL 和 SIL 放在同一个流程里,通过最大绝对误差来判断代码与模型是否等价。你可以根据实际信号量级调整阈值。
5.6 使用 SIL Block 集成到更大测试环境
除了在同一个模型里切换仿真模式,还有另一种常见做法:生成一个SIL 块,把它放到一个独立的测试台模型中。
操作方式与前面类似,在控制器子系统上右键C/C++ Code,选择Generate S-Function或者直接生成 SIL 块。Simulink 会生成一个 S-Function 模块,该模块内部封装了生成代码,可以像普通模块一样参与仿真。
SIL 块的优势在于,它可以被放到一个“测试平台模型”中,与实际的控制器模型分离。这样,被测对象是“代码”,测试平台负责激励和观测,结构上更接近硬件在环的思维模式。缺点是增加了一层 S-Function 封装,排查问题时不如“直接切换模式”直观。
6. 结果验证:如何判断 SIL 是否通过
SIL 验证的“通过”标准不是模型和代码输出完全一致,而是在设定的误差范围内一致。
实际操作中,我们通常关注三个指标:
最大绝对误差(Max Absolute Error):最能反映异常尖峰。如果某个时间点上 MIL 和 SIL 的结果差了好几个数量级,那基本可以断定代码生成配置有问题,或者某些表达式被优化后产生了溢出。
均方根误差(RMSE):反映整体偏差水平。如果 RMSE 很小但最大误差很大,说明可能只是个别点异常;如果两者都持续偏大,就要检查数据类型和定标(Scaling)配置。
波形形态对比:在 Simulink Data Inspector 里同时绘制 MIL 和 SIL 曲线,肉眼观察是否基本重合。对于缓慢变化的信号,波形重合度高通常说明基础行为一致。
如果误差超标,不要急着改代码,按照以下顺序排查:
- 先确认数据提取是否一致。MIL 和 SIL 的采样时刻可能不同,如果直接按数据点相减,可能因为时间对齐问题产生虚假误差。建议对数据做时间序列插值后再比较。
- 检查数据类型。模型里是否混用了 double 和 single?定点数有没有溢出?数据类型不一致是 SIL 差异最常见的原因。
- 检查代码生成配置。
ERTCustomFileTemplate、Optimization里的Signal reuse、Local block output等选项可能改变代码结构,导致浮点运算顺序变化。 - 检查求解器设置。固定步长是否过小或过大?求解器类型是否与模型中的离散模块匹配?
如果用 Simulink Test 做管理,可以给 SIL 对比创建专门的测试用例,设置判据。这样整个验证过程就可以纳入自动化回归体系,每次模型改动后重新跑一遍,防止“改一个参数引出新 bug”。
7. 常见问题与排查思路
SIL 验证在实际操作中,遇到的问题数量并不少。下面整理了一些常见的现象、原因和解决方案,供参考。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| SIL 模式菜单灰色不可选 | 缺少 Embedded Coder 许可证,或模型未配置代码生成目标 | 检查许可证状态,查看模型配置参数中的 SystemTargetFile | 安装 Embedded Coder,并设置系统目标文件为 ert.tlc |
| 构建子系统时提示没有编译器 | 主机上没有安装可用的 C 编译器,或 MATLAB 未正确识别编译器 | 在命令行运行mex -setup查看编译器状态 | 安装 MinGW 或 Visual Studio Build Tools,然后重新设置编译器 |
| 生成代码报错:子系统不是原子子系统 | 子系统未设置原子属性 | 右键子系统,查看 Block Parameters | 勾选Treat as atomic unit |
| SIL 仿真时报变步长求解器错误 | 模型使用的求解器类型与 SIL 不兼容 | 查看 Solver 配置 | 改为 FixedStepDiscrete 或其他固定步长求解器 |
| MIL 与 SIL 差异巨大 | 数据类型不一致,或浮点运算顺序被代码生成优化改变 | 对比关键信号的数值范围,检查数据类型转换模块 | 统一数据类型,或者在代码生成优化中关闭可能改变数学表达式的选项 |
| SIL 仿真速度很慢 | 代码生成时启用了大量调试信息,或主机编译器优化等级过低 | 检查构建日志,查看编译选项 | 在代码生成配置中调整优化级别,但注意与生产环境保持一致 |
如果遇到提示信息不够明确的问题,建议先查看构建日志和仿真诊断信息。Simulink 的每一个错误都会给出具体的模型路径和模块名称,按图索骥通常很快能定位。
8. 最佳实践与工程建议
SIL 是验证链路上非常有用的一环,但如果使用方式不对,很容易沦为“为 SIL 而 SIL”的形式化流程。结合实际工程经验,我有几条建议。
第一,从项目一开始就建立 MIL 基线库。SIL 不能脱离 MIL 单独存在,每一次 SIL 对比都需要一个可信的模型基线。如果模型还在频繁改动阶段,先不要急着做 SIL,等模型相对稳定后再固化基线。否则,模型一天改三遍,基线和代码永远对不上,验证就会失去意义。
第二,SIL 与模型规范化结合。SIL 对模型质量有很强的“溢出要求”。如果模型里大量使用连续积分器、变步长求解器、不受控的数据类型转换,SIL 验证大概率会失败。与其在 SIL 阶段排查问题,不如在建模阶段就规范:离散化、原子子系统、显式数据类型、避免代数环。这些规范同样有利于后续的 PIL 和 HIL。
第三,把 SIL 纳入持续集成流程。在 Simulink Test 中创建测试用例后,可以用 MATLAB 命令行在 CI 工具里批量执行。模型每次更新,自动触发 MIL 和 SIL 回归测试。只要误差超过设定阈值,构建就失败。这样,模型和代码的差异能在最早期被发现,而不是等到集成阶段。
第四,合理设置误差阈值。不要一上来就要求 SIL 和 MIL 完全一致。不同算法对误差的敏感度不同。可以先做一个“观察性运行”,记录正常工况下的最大误差量级,再在量级基础上留出 5 到 10 倍余量作为阈值。阈值太严,每次跑都会失败,浪费团队精力;阈值太宽,真正的问题会被漏掉。
第五,理解 SIL 的“过度信心陷阱”。SIL 通过只是说明“代码逻辑”和“模型逻辑”等价,不意味着代码在目标硬件上能正常工作。编译器不同、处理器字长不同、内存约束不同,都可能导致实际行为变化。所以,SIL 适合做快速回归和早期验证,但不能替代 PIL 和 HIL。正确的做法是:MIL 验证策略 → SIL 验证代码正确性 → PIL 验证目标处理器行为 → HIL 验证真实 I/O 和实时性。
第六,注意代码生成配置与生产环境一致。SIL 验证的代码,应该和你最终部署到硬件上的代码使用同一套配置。如果你在 SIL 验证时关闭了所有优化,而生产中使用了高优化等级,那么验证意义就大打折扣。最好的做法是直接用生产配置生成代码,做 SIL 验证,再部署。如果生产配置会导致 SIL 结果无法通过,说明代码生成配置本身可能有问题,这恰恰是 SIL 最有价值的发现。
9. 总结与下一步学习方向
SIL 解决的核心问题,是让开发者在不依赖硬件的条件下,验证“生成的代码”是否忠实于“模型本身”。它把模型到代码的转换过程从黑盒变成了白盒,为后续的 PIL、HIL 和实际部署提供了一道重要的质量关口。
读完这篇文章,你应该已经掌握了:
- SIL 在 MIL/SIL/PIL/HIL 验证链路中的位置和边界。
- SIL 的核心原理和执行流程。
- 如何在 Simulink 中启用 SIL 模式并运行验证。
- 如何用命令行脚本自动化 MIL 与 SIL 的对比。
- 常见问题的排查思路和工程落地建议。
如果你正在做嵌入式控制、汽车电子或者任何与“Simulink 模型生成 C 代码”相关的开发工作,建议把 SIL 验证固化为模型提交流程的一部分。它花费的成本很低,但能在早期挡住大量因为数据类型、求解器配置、代码生成选项而引入的隐藏问题。
下一步可以继续深入的方向包括:PIL 验证(把代码部署到真实处理器上)、代码覆盖率分析、Simulink Test 的自动化测试框架,以及 HIL 实时仿真。SIL 是这一整套知识体系里性价比最高的起点,值得你先把它跑通。建议收藏这篇文章,等真正需要做模型代码验证的时候,回来对照操作,能省下不少摸索的时间。