公司动态
Simulink S函数从入门到精通:自定义模块开发与C MEX实战
1. 从“黑箱”到“白盒”为什么我们需要S函数在Matlab/Simulink的世界里我们习惯了拖拽各种现成的模块——增益、积分器、传递函数、状态空间模型——然后连上线点击运行一个系统模型就搭建好了。这很方便Simulink就像一个功能强大的“黑箱”组装车间。但当你需要实现一个Simulink库中没有的复杂算法、一个需要与外部硬件实时交互的接口、或者一个用C/C/Fortran写好的高性能计算内核时这些标准模块就显得力不从心了。这时S函数System-Function就是你打开“黑箱”注入自定义逻辑的那把钥匙。简单来说S函数是Simulink的一个编程接口它允许你用Matlab语言、C、C甚至Fortran来编写一个自定义的模块。这个模块可以完全定义自己的输入、输出、状态、采样时间以及最重要的——在每个仿真步长内的动态行为。你可以把它想象成一个完全由你掌控的“万能模块”Simulink的求解器会在每个时间点调用你写的代码来计算模块的输出和更新其内部状态。这解决了几个核心痛点一是将已有的、成熟的算法代码尤其是C/C代码无缝集成到Simulink的仿真流程中二是实现那些用标准模块搭建起来极其复杂或效率低下的特定功能三是为Simulink模型添加与外部世界如硬件驱动、网络通信、数据库交互的能力。从网络热词来看像“qt调用matlab生成的dll”、“cpp调用matlab”、“adams与matlab联合仿真”这些需求其底层桥梁往往就是S函数或其衍生物如C MEX S-Function。S函数是打通Matlab/Simulink与其他软件生态、硬件平台和编程语言的关键技术节点。掌握了它你就从Simulink的使用者变成了它的扩展者。2. S函数的骨架与灵魂回调方法与执行流程一个S函数之所以能被Simulink识别和调度是因为它遵循了一套严格的“协议”。这套协议的核心是一系列预定义的回调方法Callback Methods。Simulink的求解器在仿真的不同阶段会主动调用这些方法。我们的工作就是为这些回调方法填充具体的逻辑。我们可以把S函数想象成一个有生命周期的“对象”。Simulink是导演它按照固定的剧本仿真流程来指挥这个对象该在什么时间点做什么事。这个剧本的主要场次如下2.1 初始化阶段 (mdlInitializeSizes)这是仿真开始前的“建组”阶段。在这个阶段你需要告诉Simulink导演关于你这个“演员”S函数模块的所有基本信息端口信息你有几个输入口ssSetNumInputPorts、几个输出口ssSetNumOutputPorts每个端口的维度是多少是标量、向量还是矩阵。状态信息你的模块内部是否有需要记忆的“状态”ssSetNumContStates连续状态ssSetNumDiscStates离散状态。例如一个积分器的状态就是其累积值。采样时间你的模块以多快的频率“醒来”工作。是继承上游信号的采样时间还是固定的离散间隔如0.01秒或者是连续的适用于变步长求解器。工作向量你是否需要一些临时的内存空间来存储中间计算结果。这个阶段只执行一次目的是为仿真分配好所需的内存和数据结构框架。如果信息定义错误比如输出端口维度设置小了仿真一开始就会报错。2.2 计算输出阶段 (mdlOutputs)这是S函数最常被调用的方法之一。在每个采样时间点Simulink都会调用这个方法。它的任务非常明确根据当前的输入值和可能的内部状态值计算出当前时刻的输出值。例如你实现了一个自定义滤波器。在mdlOutputs中你会读取输入端口传来的最新信号值结合滤波器内部存储的过去若干时刻的信号值这些过去值就是状态按照滤波算法计算出当前的输出值并写入输出端口。注意对于连续系统mdlOutputs可能会在积分步长内被多次调用由求解器决定因此这里的计算必须高效且不应有改变系统状态的副作用状态更新在mdlDerivatives或mdlUpdate中完成。2.3 更新状态阶段 (mdlUpdate或mdlDerivatives)这是S函数的“记忆”核心。状态是模块内部随时间变化的量它决定了系统的动态特性。对于离散系统使用mdlUpdate。在每个离散采样时刻Simulink在调用mdlOutputs之后会调用mdlUpdate。在这里你需要根据当前输入和旧状态计算出下一个采样时刻的新状态值。例如对于一个单位延迟模块Z-1mdlUpdate就是把当前的输入值保存起来作为下一个时刻的输出。// 伪代码示例离散单位延迟 real_T *y ssGetOutputPortRealSignal(S, 0); // 输出端口信号 real_T *x ssGetContStates(S); // 状态指针此处用作离散状态存储 real_T *u ssGetInputPortRealSignal(S, 0); // 输入端口信号 y[0] x[0]; // 当前输出 旧状态 x[0] u[0]; // 新状态 当前输入 (在mdlUpdate中完成)对于连续系统使用mdlDerivatives。连续系统的状态是随时间连续变化的我们用状态变量的导数微分来描述它。Simulink的求解器如ode45会频繁调用mdlDerivatives来获取状态导数dx/dt的值然后通过数值积分来求解状态x本身。例如对于一个简单的质量块系统m*x F我们可以写成状态空间形式令x1 位置,x2 速度那么导数就是dx1/dt x2,dx2/dt F/m。mdlDerivatives函数就是负责计算并返回这个导数向量。2.4 其他重要回调mdlStart和mdlTerminate: 分别在仿真开始和结束时调用一次用于分配/释放自定义资源如打开/关闭文件、初始化硬件连接。mdlGetTimeOfNextVarHit: 用于可变步长离散系统可以动态指定下一个采样时刻。mdlRTW: 用于生成代码时Simulink Coder将S函数中的配置参数写入生成的代码中。理解这个执行流程是编写正确S函数的基础。Simulink严格按照这个流程来调度如果你的逻辑放错了回调方法比如把状态更新写在了mdlOutputs里就会导致仿真结果完全错误。3. 手把手构建一个C MEX S函数以自定义PID控制器为例理论说再多不如动手写一个。我们选择用C MEX S-Function来实现一个带积分抗饱和和输出限幅的增强型PID控制器。为什么用C MEX因为它的执行速度远快于Matlab语言写的S函数即Level-2 M-file S-function更适合用于对性能要求较高的仿真或最终生成嵌入式代码。我们将这个S函数模块命名为sfun_pid_advanced.c。3.1 环境准备与文件骨架首先确保你的系统有支持的C编译器如Windows上的MinGW-w64或Microsoft Visual C。在Matlab命令行输入mex -setup来选择和配置编译器。一个C MEX S函数源文件有一个固定的骨架它包含了我们之前提到的所有回调方法的声明和定义入口。Matlab提供了模板我们可以从sfuntmpl_basic.c开始。在Matlab中打开这个模板文件edit(fullfile(matlabroot, ‘extern’, ‘examples’, ‘simulink’, ‘csfuntmpl_basic.c’))但为了更清晰我们从头开始构建我们的PID控制器骨架。一个C MEX S函数必须包含simstruc.h头文件并定义一个名为mexFunction的入口点Simulink通过它来调用我们的S函数。#define S_FUNCTION_NAME sfun_pid_advanced #define S_FUNCTION_LEVEL 2 #include “simstruc.h” /* 向前声明所有回调函数 */ static void mdlInitializeSizes(SimStruct *S); static void mdlInitializeSampleTimes(SimStruct *S); static void mdlOutputs(SimStruct *S, int_T tid); static void mdlUpdate(SimStruct *S, int_T tid); static void mdlTerminate(SimStruct *S); /* S函数的入口函数必须命名为mexFunction */ void mexFunction(int_T nlhs, mxArray *plhs[], int_T nrhs, const mxArray *prhs[]) { /* 注册S函数将回调函数指针关联到Simulink */ ssSetNumSFcnParams(S, 3); // 我们预计有3个参数Kp, Ki, Kd ssSetNumContStates(S, 0); ssSetNumDiscStates(S, 3); // 我们需要3个离散状态误差积分、上次误差、输出 ssSetNumInputPorts(S, 2); // 两个输入设定值(SP)和过程值(PV) ssSetInputPortWidth(S, 0, 1); // 输入端口0宽度1标量 ssSetInputPortWidth(S, 1, 1); // 输入端口1宽度1标量 ssSetInputPortDirectFeedThrough(S, 0, true); // 端口0直通输出依赖当前输入 ssSetInputPortDirectFeedThrough(S, 1, true); // 端口1直通 ssSetNumOutputPorts(S, 1); // 一个输出控制量 ssSetOutputPortWidth(S, 0, 1); // 输出端口0宽度1 ssSetNumSampleTimes(S, 1); // 一个采样时间 /* 关联具体的回调函数 */ ssSetmdlInitializeSizes(S, mdlInitializeSizes); ssSetmdlInitializeSampleTimes(S, mdlInitializeSampleTimes); ssSetmdlOutputs(S, mdlOutputs); ssSetmdlUpdate(S, mdlUpdate); ssSetmdlTerminate(S, mdlTerminate); /* 可选设置其他属性如状态名称、参数可调性等 */ ssSetOptions(S, SS_OPTION_EXCEPTION_FREE_CODE); }以上是一个非常简化的入口框架。实际上mdlInitializeSizes等函数需要单独实现。我们接下来填充最关键的部分。3.2 定义参数、状态与初始化在我们的PID控制器中我们需要从模块对话框接收三个可调参数比例系数Kp积分系数Ki微分系数Kd。同时我们需要三个离散状态来存储误差积分int_err、上一次的误差prev_err用于计算微分、以及经过限幅后的上一次输出prev_out用于积分抗饱和。static void mdlInitializeSizes(SimStruct *S) { /* 检查参数数量 */ if ( !ssSetNumSFcnParams(S, 3) ) return; // Kp, Ki, Kd /* 指定参数在仿真过程中是否可调 */ ssSetSFcnParamTunable(S, 0, true); // Kp 可调 ssSetSFcnParamTunable(S, 1, true); // Ki 可调 ssSetSFcnParamTunable(S, 2, true); // Kd 可调 /* 设置输入端口特性 */ if (!ssSetNumInputPorts(S, 2)) return; ssSetInputPortWidth(S, 0, 1); // 端口0设定值 SP ssSetInputPortRequiredContiguous(S, 0, true); // 要求内存连续提高访问效率 ssSetInputPortDirectFeedThrough(S, 0, true); // 直通 ssSetInputPortWidth(S, 1, 1); // 端口1过程值 PV ssSetInputPortRequiredContiguous(S, 1, true); ssSetInputPortDirectFeedThrough(S, 1, true); /* 设置输出端口特性 */ if (!ssSetNumOutputPorts(S, 1)) return; ssSetOutputPortWidth(S, 0, 1); // 端口0控制输出 /* 设置离散状态数量为3 */ ssSetNumDiscStates(S, 3); /* 设置采样时间个数为1并指定为继承 */ ssSetNumSampleTimes(S, 1); } static void mdlInitializeSampleTimes(SimStruct *S) { /* 设置采样时间为继承(-1) */ ssSetSampleTime(S, 0, INHERITED_SAMPLE_TIME); ssSetOffsetTime(S, 0, 0.0); }3.3 实现核心算法输出与更新现在来到最核心的部分mdlOutputs和mdlUpdate。这里体现了PID算法和抗饱和逻辑。#define PARAM_KP (ssGetSFcnParam(S, 0)) // 参数指针 #define PARAM_KI (ssGetSFcnParam(S, 1)) #define PARAM_KD (ssGetSFcnParam(S, 2)) #define STATE_INT_ERR (ssGetDiscStates(S)[0]) // 状态数组 #define STATE_PREV_ERR (ssGetDiscStates(S)[1]) #define STATE_PREV_OUT (ssGetDiscStates(S)[2]) static void mdlOutputs(SimStruct *S, int_T tid) { /* 获取输入、输出信号指针 */ InputRealPtrsType uPtrs_SP ssGetInputPortRealSignalPtrs(S, 0); InputRealPtrsType uPtrs_PV ssGetInputPortRealSignalPtrs(S, 1); real_T *y ssGetOutputPortRealSignal(S, 0); /* 获取参数值 */ real_T Kp mxGetPr(PARAM_KP)[0]; real_T Ki mxGetPr(PARAM_KI)[0]; real_T Kd mxGetPr(PARAM_KD)[0]; /* 计算当前误差 */ real_T SP *uPtrs_SP[0]; real_T PV *uPtrs_PV[0]; real_T err SP - PV; /* 读取状态 */ real_T int_err STATE_INT_ERR; real_T prev_err STATE_PREV_ERR; real_T Ts ssGetSampleTime(S, 0); // 获取采样时间 /* PID计算 */ real_T P_term Kp * err; real_T I_term Ki * int_err * Ts; // 积分项 Ki * 积分和 * Ts real_T D_term 0.0; if ( Ts 0 prev_err ! 0.0 ) { // 避免除零首次计算时D项为0 D_term Kd * (err - prev_err) / Ts; } real_T u_unlimited P_term I_term D_term; /* 输出限幅假设限幅值为±10可设计为参数 */ real_T u_limited; if (u_unlimited 10.0) u_limited 10.0; else if (u_unlimited -10.0) u_limited -10.0; else u_limited u_unlimited; /* 写入输出 */ y[0] u_limited; } static void mdlUpdate(SimStruct *S, int_T tid) { /* 获取输入信号指针和参数 */ InputRealPtrsType uPtrs_SP ssGetInputPortRealSignalPtrs(S, 0); InputRealPtrsType uPtrs_PV ssGetInputPortRealSignalPtrs(S, 1); real_T Kp mxGetPr(PARAM_KP)[0]; real_T Ki mxGetPr(PARAM_KI)[0]; real_T Ts ssGetSampleTime(S, 0); real_T SP *uPtrs_SP[0]; real_T PV *uPtrs_PV[0]; real_T err SP - PV; /* 积分抗饱和Conditional Integration */ real_T int_err STATE_INT_ERR; real_T prev_out STATE_PREV_OUT; real_T u_unlimited Kp * err Ki * int_err * Ts; // 简化的PI项用于判断 /* 只有当输出未饱和或饱和但误差方向有助于退出饱和时才进行积分 */ if ( !( (prev_out 10.0 err 0) || (prev_out -10.0 err 0) ) ) { int_err err; // 更新积分和 } // 否则保持积分和不变避免积分饱和 /* 更新所有离散状态 */ STATE_INT_ERR int_err; STATE_PREV_ERR err; STATE_PREV_OUT ssGetOutputPortRealSignal(S, 0)[0]; // 存储本次限幅后的输出 }3.4 编译、封装与在Simulink中使用将上述代码保存为sfun_pid_advanced.c。在Matlab命令行中导航到该文件所在目录执行编译命令mex sfun_pid_advanced.c如果编译成功会生成一个同名的MEX文件如sfun_pid_advanced.mexw64。在Simulink中从库浏览器找到 “User-Defined Functions” 下的 “S-Function” 模块拖到模型中。双击该模块在 “S-function name” 框中填入sfun_pid_advanced不需要扩展名。然后点击 “S-function parameters” 旁的 “Edit” 按钮输入三个参数例如[1.0, 0.1, 0.01]分别对应Kp, Ki, Kd。连接设定值和反馈值作为输入输出连接到被控对象。现在你就拥有了一个完全自定义的、带高级功能的PID控制器模块。它的行为和性能完全由你的C代码控制。4. 从仿真到部署S函数的进阶应用与避坑指南掌握了基础S函数的编写我们就可以探讨更高级的应用场景这也是网络热词中频繁出现的方向。同时在实际项目中有几个“坑”需要特别注意。4.1 生成独立组件DLL与代码生成场景Qt/C程序调用Matlab算法热词 “qt调用matlab生成的dll” 和 “cpp调用matlab” 指向一个常见需求将Matlab中开发验证好的算法集成到独立的C桌面程序或嵌入式系统中。纯Matlab代码无法直接调用而S函数是解决这个问题的标准路径之一。使用Matlab Coder生成C/C代码首先你需要将算法用Matlab语言写成函数或者用S-Function Builder/ Legacy Code Tool 封装你的C代码S函数。然后使用Matlab Coder工具将该S函数模块或Matlab函数编译生成纯C/C代码和头文件。这个过程会剥离对Matlab运行环境的依赖。创建动态链接库DLL在Visual Studio等IDE中将上一步生成的C/C文件创建为一个新的DLL工程。你需要仔细处理生成的代码接口。通常生成的代码会有一个初始化函数、一个主步进函数对应S函数的mdlOutputs和一个终止函数。在Qt或C主程序中你只需要动态加载LoadLibrary这个DLL并调用这些函数即可。注意“ctf文件”热词中提到了“matlab dll ctf文件”。当你使用Matlab Compiler SDK旧称Deployment Tool将S函数或Matlab函数打包成DLL时除了生成DLL还会生成一个.ctf文件Component Technology File。这个文件是一个加密的归档包包含了Matlab运行时MCR的必要组件。你的应用程序在分发时需要将这个CTF文件放在指定位置或者将其链接进DLL。这是脱离完整Matlab环境运行所必需的。避坑点直接调用生成的代码时内存管理特别是mxArray的创建与销毁是最大的难点。务必遵循“谁分配谁释放”的原则仔细阅读生成代码中的注释通常会有emxCreate和emxDestroy之类的函数来管理动态数组。4.2 联合仿真与Adams、Modelsim等工具对接热词 “adams与matlab联合仿真” 和 “matlab和modelsim联合仿真” 是S函数在协同仿真领域的典型应用。Simulink作为控制系统仿真平台需要与机械多体动力学软件Adams或硬件描述语言仿真器Modelsim for VHDL/Verilog进行联合仿真以验证机电一体化或软硬件协同设计。在这种架构下S函数充当“客户端”或“适配器”的角色。与Adams联合仿真通常使用Adams/Controls模块。Adams作为“服务器”提供机械模型的动力学解算。在Simulink中你需要使用一个特定的S函数模块如adams_plant。这个S函数内部通过TCP/IP或共享内存等进程间通信IPC机制在每个仿真步长内将Simulink计算出的力/力矩发送给Adams并接收Adams返回的位置/速度等状态。你编写的S函数可能需要处理通信协议、数据打包/解包和同步问题。与Modelsim联合仿真这通常通过Simulink的HDL Cosimulation模块实现。该模块底层也是一个S函数它启动或连接Modelsim进程将Simulink的数字信号位或向量通过PLI/VPI接口传递给Modelsim中的HDL测试平台并接收HDL仿真结果。你需要编写或配置一个“桥梁”S函数来定义时钟、复位信号以及数据交换的时序。避坑点联合仿真的最大挑战是仿真同步和数据交换速率。两边仿真器可能使用不同的步长Simulink是变步长Adams/Modelsim可能是固定步长。你需要仔细配置S函数中的采样时间并处理好数据插值或保持的问题。调试也更为复杂往往需要同时在两个环境中设置断点或输出日志。4.3 性能优化与调试技巧当你编写的S函数变得复杂或者被用于大型、高速的仿真中时性能就成为关键。避免在mdlOutputs和mdlUpdate中频繁申请内存像mxCreateDoubleMatrix这样的Matlab API调用在C MEX S函数中是昂贵的。理想情况下所有需要的内存都应在mdlStart中一次性分配好在mdlTerminate中释放。使用ssGetWork和ssSetWork来管理S函数内部的工作向量。使用InputRealPtrsType高效访问输入正如我们示例中所示使用ssGetInputPortRealSignalPtrs获取指针数组再解引用是访问输入信号最高效的方式。避免使用ssGetInputPortSignal进行不必要的拷贝。谨慎使用ssPrintf进行调试在S函数中调用ssPrintf相当于Matlab的fprintf会严重拖慢仿真速度尤其是在被频繁调用的mdlOutputs中。建议使用一个调试标志参数在需要时才开启打印。更好的方法是将调试数据通过一个额外的输出端口引出在Simulink中用Scope或To Workspace模块查看。处理代数环如果你的S函数具有直通DirectFeedThrough特性即输出直接依赖于当前输入并且它的输出通过一个反馈回路直接或间接地影响其输入Simulink会报告代数环错误。解决方法是尽可能消除直通例如在输出计算中引入一个单位延迟z^-1或者使用Simulink的代数环求解器会降低仿真速度。4.4 那些年我踩过的“坑”实战经验分享采样时间继承的陷阱我们的例子中采样时间设置为继承INHERITED_SAMPLE_TIME。这很方便但要注意如果上游模块是连续信号源如Sine Wave而你的S函数逻辑本质是离散的例如数字控制器继承到的可能是连续采样时间0这会导致mdlUpdate在每个微积分步长都被调用计算量剧增且不符合物理事实。最佳实践对于数字控制器明确指定一个固定的离散采样时间如ssSetSampleTime(S, 0, 0.01)即使上游是连续的S函数也会按自己的节奏采样。参数可调性的副作用我们将参数设置为可调Tunable这允许在仿真运行时通过滑块或命令修改。但这意味着Simulink不能对这些参数进行某些优化。对于确定不变的参数应设置为不可调以提升性能。状态初始化的遗漏我们示例中没有显式初始化离散状态STATE_INT_ERR等。在mdlInitializeConditions回调中应该将这些状态初始化为0或其他合适的值。否则它们的初始值是未定义的可能导致仿真结果不一致。多速率S函数的复杂性一个S函数可以声明多个采样时间多速率。例如一个模块可能以10ms的频率读取传感器输入但以100ms的频率发送控制命令输出。实现这样的S函数需要精心设计mdlGetTimeOfNextVarHit和区分不同任务在哪个速率下执行复杂度陡增。除非必要尽量拆分成多个单速率S函数模块。编写S函数尤其是C MEX S函数是一个从Simulink用户迈向系统级建模和集成工程师的重要阶梯。它要求你不仅理解控制算法或特定功能还要理解Simulink仿真引擎的运作机制。开始时可能会觉得繁琐但一旦掌握你将获得在Simulink环境中实现任意复杂系统的自由。从简单的自定义算法模块到与外部世界通信的网关再到进行跨领域联合仿真的枢纽S函数都是不可或缺的核心工具。