简介:在MATLAB Simulink建模中,S-Function是扩展自定义算法与控制逻辑的核心工具。这份资源面向正在学习Simulink高级建模、希望掌握S-Function开发方法的初学者与工程师,提供了两个入门级示例:times2实现输入信号乘以2输出,unit_delay实现单步延迟,可直观理解初始化、仿真、输出等基本函数结构以及S-Function与模型的集成方式。压缩包内共4个文件,包含2个M函数源文件与2个Simulink模型文件(slx),便于对照查看参数配置、端口属性及连接方法,整体仅38KB,轻量易用。已有1276人学习浏览。通过学习这两个典型示例,能够熟悉M文件S-Function的编写流程和模型交互方式,并由此延伸到C/C++ S-Function与编译链接等进阶内容,为自建复杂系统模型打下扎实基础。
1. S-Function:为什么它在 Simulink 里像个黑匣子
第一次被 S-Function 卡住是在调一个自适应前馈控制器的时候。Simulink 模型里一个名为 sfun_ff 的模块,双击进去只有一个函数名,看不到任何业务逻辑——那一刻它就是个黑匣子。S-Function(系统函数)是 Simulink 预留的编程入口,用来把 MATLAB 脚本、C/C++ 代码以模块形态接入仿真。它不挑语言,但恰恰因为接口灵活,很多人拿到示例压缩包后照着敲都会跑不起来。这份资源里的两个示例正好覆盖了两种最常用的写法,适合做控制算法验证、嵌入式模型开发、以及需要把已有 C 代码搬进 Simulink 的工程师。
2. Level-1 与 Level-2:两种 S-Function 的选型逻辑与最小模板
2.1 两种 S-Function 的原理差异:从回调函数说起
S-Function 在 Simulink 里的运行机制可以理解为“回调驱动”:求解器在每个仿真步长推进时,会调用一组预先注册好的函数,完成初始化、计算输出、更新状态、结束清理。这些函数就是回调函数。老一代接口(Level-1)暴露的是以 tStart、tInput、tOutput 等命名的六个固定函数,函数名以 sfun_ 开头,Simulink 通过名称匹配自动调用。新一代接口(Level-2)改成了对象式注册,开发者在一个 setup 函数里告诉 Simulink“我有几个输入、几个输出、几种状态”,然后用 block.RegBlockMethod 显式注册回调。
下面这张表是我拆这个压缩包时整理的差异点,也是选型时最需要看的边界:
| 对比项 | Level-1(老接口) | Level-2(新接口) |
|---|---|---|
| 函数结构 | 6 个固定名称回调 | setup 加若干注册方法 |
| 数据类型支持 | 主要为 double 标量 | 矩阵、总线、定点、复数、可变维 |
| 实例隔离 | 容易用全局变量,容易出问题 | Dwork、ContStates 天然隔离 |
| 调试体验 | printf / mexPrintf | MATLAB 断点直接命中 |
| 代码生成 | 基本不推荐 | 完整支持 ERT 代码生成 |
| 官方维护状态 | 遗留接口 | 新模型主推接口 |
选型结论很直接:新模型一律用 Level-2,除非你要看的旧代码里已经写死了 Level-1。注意一个反直觉的点——Level-2 不是 Level-1 的“加参数升级版”,而是接口设计的重构。它的输入输出端口是好几个结构体数组,每个端口都有 Dimension、DatatypeID、Complexity 等属性;而 Level-1 里这些都是靠 ssSetInputPortWidth 这类全局风格函数来设的。如果你按 Level-1 的开发习惯去理解 Level-2,会在端口配置上被绕晕。
2.2 选型边界:什么时候不该用 S-Function
很多新手上手就写 S-Function,这是最常见的过度设计。我一般这样建议:如果只是做加减乘除、查表、饱和限幅这类简单逻辑,用 MATLAB Function 块或者 Simulink 标准库就够了,S-Function 的优势不在简单运算,而在下面三个场景。
第一个场景是你手头有一段已经调试好的 C/C++ 库,且需要保持仿真行为与嵌入式端一致。用 MATLAB Function 逐行翻译费时费力还容易引入翻译错误,直接把 C 代码通过 C MEX S-Function 包一层,编译后跑出来的就是原生 C 行为。第二个场景是同一算法要在模型里实例化多次,比如六轴机械臂的六个关节控制器,每个实例有独立的状态变量。S-Function 用 Dwork 保存离散状态后,拖六个模块进模型互不干扰,这比在 MATLAB Function 里用 persistent 变量安全得多。第三个场景是代码生成目标使用模拟项目X或定点后端,Level-2 S-Function 的端口类型声明能直接映射成目标代码里的定点类型。
反过来,如果你只是想在仿真里试一个控制率、对性能没有硬性要求,那 MATLAB Function 的迭代速度会比 S-Function 快好几倍,因为它不涉及编译环节,改完就能跑。
2.3 一个能跑的最小 Level-2 S-Function 模板
这份资源里的两个示例虽然复杂,但骨架和下面这个最小模板一致。我把模板压缩到能理解的最小程度,保存为 sfun_minimal.m:
function sfun_minimal(block) % Level-2 S-Function 最小可运行模板 setup(block); function setup(block) % 注册一个输入端口、一个输出端口 block.NumInputPorts = 1; block.NumOutputPorts = 1; % 端口信息在连线时自动适配 block.SetPreCompInpPortInfoToDynamic; block.SetPreCompOutPortInfoToDynamic; % 输入输出维数固定为标量 block.InputPort(1).Dimensions = 1; block.InputPort(1).DirectFeedthrough = true; block.OutputPort(1).Dimensions = 1; % 采样时间:第一列是周期,第二列是偏移 block.SampleTimes = [0 0]; % 连续采样时间 % 注册回调方法 block.RegBlockMethod('InitializeConditions', @InitializeConditions); block.RegBlockMethod('Outputs', @Outputs); block.RegBlockMethod('Update', @Update); function InitializeConditions(block) % 连续状态初始值 block.ContStates.Data = 0; function Outputs(block) % 输出 = 输入 + 内部状态 block.OutputPort(1).Data = block.InputPort(1).Data + block.ContStates.Data; function Update(block) % 每个仿真步更新一次连续状态 block.ContStates.Data = block.ContStates.Data + 1;关键逻辑有两处:Outputs 回调里读取 InputPort(1).Data 计算输出;Update 回调里推进连续状态。Simulink 在每个步长内先调用 Outputs 得输出,再调用 Update 更新状态,顺序是固定的。
DirectFeedthrough 这里要特别说一句。它表示“当前时刻的输出是否依赖当前时刻的输入”。本模板输出整体包含了 u,所以必须设为 true。如果设成 false,Simulink 会假设输出不依赖当前输入,从而可能调整模块调用顺序,产生代数环或者相位错位,仿真结果直接不对。判断规则很简单:在 Outputs 回调里读取了某个输入端口的值,那个端口的 DirectFeedthrough 就要设为 true;只有输出依赖上一时刻状态、不读当前输入时,才能放心设 false。
SampleTimes 用 [0 0] 表示连续采样时间。这是控制类 S-Function 最常见的设定,含义是输出在每个求解器步长都计算,由求解器决定步长大小。如果你想要固定周期赋值,后面章节会专门讲。
3. 拆解两个示例:采样时间、直接馈通与状态更新的实现细节
3.1 示例一:MATLAB 语言 Level-2 S-Function 的标准写法
压缩包里第一个示例走的是纯 MATLAB 路线,不需要编译。它演示的核心是离散状态管理,用 Dwork 保存状态值。Dwork 这个名字我刚开始也觉得玄学,实际上它就是 S-Function 自己的“私有内存”。Level-2 S-Function 里可以用 block.ContStates.Data 存连续状态,用 block.Dwork 存离散状态,两者都必须先在 setup 里声明数量、维度和类型。
示例里初始化部分长这样:
block.NumDworks = 1; block.Dwork(1).Name = 'x'; block.Dwork(1).Dimensions = 1; block.Dwork(1).DatatypeID = 0; % 0 表示 double block.Dwork(1).Complexity = 'Real'; % 采样时间:10 ms 离散 block.SampleTimes = [0.01 0];Name 字段可以随便起,它是给代码生成器和调试器看的变量名,不参与仿真计算。DatatypeID 决定状态的数据类型,0 是 double、1 是 single、2 是 int32 等,常量定义在 Simulink 文档里有表,实际开发里我九成时间只用 double 和定点类型。
Outputs 回调里读取状态:
function Outputs(block) x = block.Dwork(1).Data; block.OutputPort(1).Data = block.InputPort(1).Data + x; endUpdate 回调里推进状态:
function Update(block) block.Dwork(1).Data = block.Dwork(1).Data + 1; end为什么要用 Dwork 而不用 MATLAB 函数里的 persistent 变量?因为 Dwork 是绑定到模块实例上的,同一个 S-Function 模型里放两个模块,每个模块都有自己的 Dwork 存储区;而 persistent 变量是 MATLAB 全局上下文里的,两个模块实例会互相踩踏。仿真里状态错乱很难排查,结果表现为两个模块的输出完全相同、或者一个模块的初值被另一个改掉,这就是很多人反复调试但找不到原因的典型情况。
3.2 示例二:C MEX S-Function 的工作流程
第二个示例走的是 C MEX 路线,重点在 C 代码如何变成 Simulink 模块。它的主体是带 S_FUNCTION_NAME 宏定义的 .c 文件,例如 sfun_c_demo.c,需要先用 mex 命令编译成 .mexw64 文件,然后在模型里引用模块名 sfun_c_demo。
C MEX 版本的核心代码结构如下:
#define S_FUNCTION_NAME sfun_c_demo #define S_FUNCTION_LEVEL 2 #include "simstruc.h" static void mdlInitializeSizes(SimStruct *S) { ssSetNumSFcnParams(S, 0); if (ssGetNumSFcnParams(S) != ssGetSFcnParamsCount(S)) { return; } ssSetNumInputPorts(S, 1); ssSetInputPortWidth(S, 0, 1); ssSetInputPortDirectFeedThrough(S, 0, 1); ssSetNumOutputPorts(S, 1); ssSetOutputPortWidth(S, 0, 1); ssSetNumSampleTimes(S, 1); ssSetNumContStates(S, 1); } static void mdlInitializeSampleTimes(SimStruct *S) { ssSetSampleTime(S, 0, CONTINUOUS_SAMPLE_TIME); ssSetOffsetTime(S, 0, 0.0); } static void mdlOutputs(SimStruct *S, int_T tid) { real_T *y = ssGetOutputPortRealSignal(S, 0); const real_T *u = ssGetInputPortRealSignal(S, 0); y[0] = u[0] + ssGetContStates(S)[0]; } static void mdlUpdate(SimStruct *S, int_T tid) { ssGetContStates(S)[0] += 1.0; }C MEX 的优点是执行速度快,和 C 库无缝衔接;缺点是编译环境配置麻烦。编译命令就一行:
mex sfun_c_demo.cmex 默认路径里已经包含了 simstruc.h 所在目录,所以大部分情况下不需要额外加 -I。如果编译报了找不到头文件的错误,先检查文件名和 S_FUNCTION_NAME 宏是否一致,再去看 MATLAB 的 mex -setup 是否配好了编译器。2020 之后的 MATLAB 默认不安装 MinGW-w64,需要自己装,然后在 MATLAB 里运行 mex -setup C 并选中该编译器。
3.3 两个示例对照:优先学哪一个
我把两个示例放在同一张表里对比,读者可以按自己的目标选一个为主线:
| 对比项 | MATLAB Level-2 示例 | C MEX 示例 |
|---|---|---|
| 开发速度 | 快,改完即跑 | 慢,要编译 |
| 执行效率 | 中等 | 接近原生 |
| 调试 | MATLAB 断点 | printf / mexPrintf |
| 代码生成 | 支持但步骤多 | 直接支持 ERT |
| 适用对象 | 算法验证、控制逻辑 | 已有 C 库移植、产品级模型 |
我的建议是先在 MATLAB 示例上把回调流程跑通,再去看 C 示例。两个示例用的参量一致,只是语言载体不同,对照着读反而能把“哪些是 S-Function 通用概念、哪些是语言绑定细节”分清楚。
最后补一个必踩的采样时间知识。SampleTimes 矩阵有四种最常用写法:
| 写法 | 含义 | 使用场景 |
|---|---|---|
| [0 0] | 连续采样时间 | 连续状态、变步长求解器 |
| [0.01 0] | 离散采样时间 10 ms | 固定步长控制模型 |
| [0 1] | 连续采样时间,偏移 1 秒 | 多速率同步的偏置 |
| [-2 0] | 继承驱动模块采样时间 | 不知道上游周期时最安全 |
偏移不为零的写法新手经常不知道,它的作用是让模块在一个周期内稍晚执行,用于多速率模型中错开采样点。通常默认写 0 即可,只有遇到代数环或者多个采样速率相互耦合时才需要动偏移。
4. 动手跑起来:从新建脚本到 Scope 联调
4.1 准备工程文件夹并固定当前目录
这一步看起来多余,但很多人漏掉之后会翻车。S-Function 的脚本和模型之间是靠文件名绑定的,Simulink 在仿真启动时从当前路径搜索回调函数。如果模型文件在项目目录 A,脚本在桌面乱放,模型一打开就会报“无法找到 S-Function”。
打开 MATLAB,建立工程文件夹并进入:
mkdir('sfunc_demo'); cd('sfunc_demo');然后把下载的示例脚本复制到这个目录。注意脚本文件名不要和 MATLAB 内置函数同名,sfunc_demo 这类前缀没问题,但千万别叫 demo.m。以前有同事把自定义脚本存成 demo.m,结果模型里调用时 MATLAB 优先找到了自带 demo,整个模型加载后模块显示成感叹号,查了半天才反应过来是命名冲突。
4.2 编写脚本并注册为 Simulink 模块
现在在工程目录里新建脚本 sfun_minimal.m,把 2.3 小节的完整代码粘贴进去保存。然后在 Simulink 模型里从 Library Browser 找到 User-Defined Functions 分类下的 Level-2 MATLAB S-Function 模块,拖一个进画布,双击打开模块参数对话框。
在 S-function name 里填 sfun_minimal,不带 .m 后缀。Parameters 框可以先空着,因为模板脚本没读取任何参数。如果你要看带参数的版本,脚本里要用 block.DialogPrm(1).Data 读取第一个模块参数,Parameters 框里就写对应数值,程序运行时 Simulink 会按顺序把值塞进 DialogPrm 数组。
注册完成后用一条短模型做冒烟测试:
open_system(new_system('sfun_smoke')); add_block('simulink/Sources/Sine Wave', 'sfun_smoke/In'); add_block('simulink/User-Defined Functions/Level-2 MATLAB S-Function', ... 'sfun_smoke/SFun'); add_block('simulink/Sinks/Scope', 'sfun_smoke/Out'); add_line('sfun_smoke', 'In/1', 'SFun/1'); add_line('sfun_smoke', 'SFun/1', 'Out/1'); set_param('sfun_smoke/SFun', 'FunctionName', 'sfun_minimal'); save_system('sfun_smoke');这组脚本的作用是纯自动建模,避免每次手动画图。add_block 和 add_line 是 Simulink 的建模型命令,第一个参数是模块库路径,第二个参数是模型内实例名。FunctionName 参数指定 S-Function 入口,和双击模块后填写的内容等价。
4.3 配置仿真步长与求解器
S-Function 的采样时间和模型求解器不匹配时,行为会很离谱。比如 SampleTimes 是 [0.01 0],但模型求解器用变步长 ode45,仿真实测会发现模块输出只在部分步长上更新,Scope 里是一条阶梯状折线;反之 SampleTimes 是连续采样时间 [0 0],模型却选了 FixedStepDiscrete 离散求解器,仿真直接报错说存在连续状态。
组合配置用两条命令完成:
set_param('sfun_smoke', 'Solver', 'FixedStepDiscrete', 'FixedStep', '0.01'); set_param('sfun_smoke', 'StopTime', '5');这里的 FixedStepDiscrete 是纯离散固定步长求解器,适合示例一里的 10 ms 离散采样时间。如果你要跑连续采样时间的 S-Function,求解器可以选 ode4,固定步长同样填 0.01:
set_param('sfun_smoke', 'Solver', 'ode4', 'FixedStep', '0.01'); set_param('sfun_smoke', 'StopTime', '5');初学者最容易踩的坑是 SampleTimes 写成 [0 0],却把求解器设成 ode45 变步长。逻辑上连续采样时间允许变步长,但变步长下求解器会在一个步长内多次调用 Outputs 回调做试探估值,如果 Outputs 里不恰当地改了持久状态,波形就会生成一些毛刺。
4.4 用 Scope 检查输出波形验证模块行为
设置好求解器后运行仿真:
sim('sfun_smoke');Scope 里第一路波形应该是正弦波,第二路是正弦波叠加一个线性斜坡。斜坡来自 Update 回调里每次步长加 1 的状态。固定步长 0.01 秒、跑 5 秒时,状态从 0 递增到 500,因此输出在正弦形态上叠加了一条明显上升的基线。
只要看到这个趋势,说明模块已经完成了一个完整的“输入路径到回调路径”闭环。如果输出完全平直没斜坡,多半是 Update 回调没有注册上,检查 setup 里的 RegBlockMethod 是否写全。
5. 避坑:S-Function 最容易翻车的五处细节
5.1 现象:模型能仿真但输出全是 0
我第一次写 Level-2 S-Function 时就遇到了:模块连线正常、仿真不报错,但 Scope 里输出恒为 0。
原因在 DirectFeedthrough。Outputs 回调里读取了 InputPort 的信号,但 setup 里对应端口的 DirectFeedthrough 设成了 false。Simulink 按声明决定调度顺序,声明“不直接馈通”后,它认为当前时刻的输出只依赖上一时刻状态,不会在那个时步给你送输入值,模块内部拿到的 u 就是缓存里的旧值或者初始值。
解决方法是回到 setup 里改成:
block.InputPort(1).DirectFeedthrough = true;修改后必须重新运行仿真,Simulink 不会在模型编译时提醒你这类语义矛盾,它只会安静地给出错误结果。
5.2 现象:报错 Sample time must be a double scalar or a matrix
调用 S-Function 后模型编译阶段报这个错,几乎都出在 SampleTimes 赋值格式上。Level-2 的 SampleTimes 属性要求每行两列,第一列是周期、第二列是偏移。常见错误写法是把两列写反,或者写成两行一列:
% 错误写法 block.SampleTimes = [0; 0.01]; % 两行一列 block.SampleTimes = [0.01]; % 缺偏移列 % 正确写法 block.SampleTimes = [0 0]; % 连续 block.SampleTimes = [0.01 0]; % 离散 10 ms这个报错的好处是提示直白,坏处是它可能出现在模型里任意一个模块上,误导新手去查信号线。排查顺序应该是:先查所有 S-Function 的 SampleTimes 赋值,再查自定义模块里的 S-Function 封装。
5.3 现象:C MEX 示例编译不通过,找不到 simstruc.h
mex 命令编译 .c 文件时报找不到 simstruc.h,第一反应可能是路径问题,但这个头文件位于 MATLAB 安装目录的 simulink/include 下,mex 默认已经包含它。真正常见的原因是 .c 文件顶部少了宏定义:
#define S_FUNCTION_NAME sfun_c_demo #define S_FUNCTION_LEVEL 2S_FUNCTION_LEVEL 必须定义成 2,simstruc.h 里面的条件编译规则才会包含 Level-2 的接口声明。如果漏掉,include 头文件时默认走 Level-1 分支,许多 ssSet 开头的函数原型不存在,编译器报出的“未定义函数”会连成一串,看起来就像头文件路径有问题。解决方法是先检查这两个宏,再去折腾编译器配置。
5.4 现象:仿真结果和标准 Simulink 模块对不上
同一个算法用标准库模块实现时输出一个值,用 S-Function 实现时输出另一个值,且偏差没有规律。
我遇到过两种典型原因。第一种是回调函数里用了全局变量,例如在 InitializeConditions 里重置了一个 global 变量,然后在 Outputs 里读它。如果模型里有多个 S-Function 实例,它们共享同一份全局变量,实例 A 写入的数据会被实例 B 覆盖。第二种是 Outputs 回调里做了不适合求解器临时估值操作的事,比如调用 drawnow 刷新 GUI。变步长求解器在一个步长内会多次试探性调用 Outputs,这些试探结果最终不一定会被采纳,如果回调里改动了持久状态,状态就会被污染。
解决第一条用 Dwork 替换全局变量;解决第二条保证 Outputs 回调是纯函数,不修改除输出端口以外的任何状态,状态修改只在 Update 回调里做。
5.5 现象:模型在代码生成阶段报不支持
MATLAB Level-2 S-Function 默认可以走代码生成,但前提是脚本里只使用了官方列出的可生成回调接口。很多人写脚本时会图方便,在回调里调用 eval、data 或者日期时间函数,这些在仿真阶段没问题,到了代码生成阶段就变成“不支持的操作”。
应对办法是把这类调用挪到 InitializeConditions 里执行一次,用 Dwork 保存结果,Outputs 和 Update 只做算术和赋值。另外,如果代码生成的目标是定点硬件仿真,端口 DatatypeID 要明确定成定点,不能留给自动匹配,否则生成出来的代码变量类型完全是随机的。
6. 把内部状态引出来:一个 S-Function 调试技巧
调 S-Function 最难受的是内部状态看不见。Simulink 的 Scope 只能看模块输出端口,而很多算法问题出在状态更新逻辑上。我常用的调试技巧是在调试版本里多开一个输出端口,把内部状态引出来单独观察。
在 setup 里把输出端口数量改成 2,第 2 个端口专门输出离散状态:
block.NumOutputPorts = 2; block.OutputPort(2).Dimensions = 1;Outputs 回调里顺带把状态写到第 2 个端口:
function Outputs(block) block.OutputPort(1).Data = block.InputPort(1).Data + block.Dwork(1).Data; block.OutputPort(2).Data = block.Dwork(1).Data; % 观察状态轨迹 end这样 Scope 里接上第 2 个端口,就能单独看到 Dwork 状态的变化曲线。如果状态在某个时步突然跳变,曲线上的毛刺位置直接对应求解器调用次数,配合变步长求解器能快速定位是 Update 写错了还是 Outputs 误改了状态。
更彻底一点的做法是在 Update 回调里加断言,状态超范围时直接终止仿真并打印诊断信息。做控制算法验证时这个习惯能省大量时间——状态发散不必等到 Simulink 数值溢出才停下来:
function Update(block) block.Dwork(1).Data = block.Dwork(1).Data + 1; assert(block.Dwork(1).Data < 1e6, 'S-Function 状态异常增大'); endassert 在仿真阶段好用,但在代码生成阶段会被优化掉,所以我只把它写进调试用的示例里,正式版本会改成显式错误上报函数。这个技巧的价值在于:内部状态可视化后,S-Function 就不再是黑匣子。
从那以后我每次写 S-Function,都会先做一个带状态观测端口的调试版本,波形验证通过后再决定是保留还是剪掉端口。剪掉之后还要强制跑一遍回归仿真,确认输出端口数量变化没有影响编译顺序。这个习惯帮我避开了至少三次线上模型与仿真结果不一致的事故。至于这份 s-function 的两个示例.zip,正好是两种语言实现的同一套逻辑,拿它按上面的步骤练一遍,比只看官方文档理解得快。希望帮到你。
本文还有配套的精品资源,点击获取