简介:python_SimulinkDLL是一套面向Simulink开发者和自动化测试工程师的示例工程,展示如何将Simulink模型编译为DLL,再通过Python调用并复用其算法。资源共138个文件,大小仅876KB,虽然精简但覆盖了核心链路:c/h源文件与ert_main.c等生成代码、slx/mdl模型文件、m脚本、py调用示例、ipynb交互式说明,以及构建所需的dll/so、bat与txt文档,便于对照理解从模型配置到Python包装的完整流程。资源包含bouncing_ball、discrete_tf、dllModel等多个demo,读者可借此掌握模型封装、接口生成、Python调用以及循环中测试(MIL)的关键步骤。适合希望在测试环境中减少Matlab/Simulink开销,或将算法分发给无许可证同事的团队。已有1815人学习下载。 做控制算法的人基本都会遇到这个阶段:模型在 Simulink 里跑得挺好,可一旦要脱离 MATLAB 环境,批量做参数扫描、算法对比,或者把模型直接集成到自己的测试工具链里,就开始头疼了。我最近在搭一套离线参数标定平台,核心思路就是把 Simulink 模型编译成 DLL,然后用 Python 去加载这个 DLL 来跑仿真。实践证明,这套方案跑通之后,一个控制模型的执行速度比 Simulink 软实时快一个量级,而且整个流程完全不依赖 MATLAB 运行时,测试机上装个 Python 就能用。今天就把这套“Python + Simulink DLL”的完整链路整理出来,从模型配置、代码生成、DLL 编译到 ctypes 绑定,全部走一遍,附上我踩过的坑。
这套方案的价值不在于把模型“导出来”这么简单。它解决的是工程上很常见的一个矛盾:Simulink 建模和调参效率高,但真实项目里的数据分析、优化算法、自动化测试大部分是 Python 生态的。让两边各自干自己擅长的事,比强迫团队统一技术栈要实际得多。文章面向的是有一定 Simulink 基础、想打通 Python 和模型仿真边界的工程师,不需要你精通 C 语言,只要能看懂函数调用就行。
1. 项目背景:为什么非要把模型变成 DLL
1.1 传统做法的三个痛点
我最早也是用 MATLAB 引擎(matlab.engine)在 Python 里跑 Simulink 模型的,这种方式最直接,但痛感也最明显。第一是速度,每次仿真从启动引擎到sim()返回结果,光进程开销就有好几秒,做参数优化时跑几千组仿真,时间直接爆炸。第二是环境依赖,测试同事的机器上必须装完整 MATLAB,光 license 和安装体积就劝退不少人。第三是版本管理,MATLAB 一个大版本升级后,原来能跑的脚本很可能因为 Simulink 内部接口变化而报错,连带 Python 侧代码一起动,维护成本相当高。
把模型编译成 DLL 后,这三个问题基本消失。DLL 是标准的动态链接库,任何语言都能调,Python 用 ctypes 或者 cffi 就能加载,不需要额外装任何 MATLAB 组件。而且生成的 C 代码是模型编译后的产物,不存在 MATLAB 版本兼容问题,只要有编译器,放哪儿都能编。
1.2 为什么调用方偏偏选 Python
有人会问,既然都编译成 DLL 了,直接用 C/C++ 调用不是更快吗?确实更快,但现实是,现在做算法验证、数据分析、自动化测试的主流脚本语言就是 Python。NumPy 处理数组、pandas 管理数据、matplotlib 出图、scipy 做优化,这一套组合拳打下来,写业务逻辑的效率比 C/C++ 高太多了。
我这边实际项目里,Python 端负责三件事:生成输入激励信号(正弦扫频、阶跃、随机噪声),把输出数据整理成 DataFrame 存库,然后用 scipy 的优化器自动调模型参数。这三件事如果用 C++ 写,工作量至少翻三倍。Python 做“调度和数据处理”,DLL 做“核心计算”,这个分工非常合理。
1.3 这个方案的边界
不是所有模型都适合走这条路。我的经验是,离散定步长模型最适合,连续模型也能做但要特殊处理。模型里如果有手写 S-Function 依赖特定 MATLAB 环境,或者用了只有 Simulink 运行时才能提供的模块(某些通信库、状态流的高级画布特性),编译成 DLL 的时候就会比较痛苦。实时性要求极高、必须跑在嵌入式目标上的场景(比如硬件在环),也不适合用 Python 这种非实时语言去调,应该直接部署到控制器里。
2. 整体方案设计:技术链路和工具选型
2.1 从模型到 Python 调用的完整链路
整个流程分四步:先在 Simulink 里把模型配置成适合代码生成的状态,然后生成 C 代码,接着用编译器把 C 代码编成 DLL,最后在 Python 里通过 ctypes 加载 DLL 并调用。听起来简单,但每一环都有决定成败的细节。
我用一个非常容易复现的例子来讲:在 Simulink 里搭一个一阶低通滤波模块,采样时间设成 0.01 秒,模型命名lp_filter,外部只暴露一个输入端口和一个输出端口。这个模型简单到十几秒就能搭完,但它完整走一遍这套流程后,你就能把方法平移到任何更复杂的模型上,比如车辆动力学模型、电机控制模型或者电池等效电路模型。
2.2 编译器以及绑定方式的取舍
编译 DLL 在 Windows 上首选 Visual Studio 的 cl.exe。原因是 MATLAB 生成的代码大量使用了 Microsoft 的扩展语法和宏定义,MSVC 兼容性最好。MinGW-w64 的 gcc 也能编,但对于新手来说,VS 的环境变量、SDK 路径、库路径都是配好的,踩坑少很多。Python 绑定侧我用 ctypes,而不是 pybind11 或者 Cython。原因是 ctypes 是 Python 标准库,不需要额外编译任何 C 扩展,写个结构体映射就能调,链路最短,对模型开发者最友好。
2.3 三个替代方案的真实对比
我可以负责任地说,在动手前我至少评估过三条替代路线。一是 MATLAB Compiler SDK 直接生成 Python 包,限制少但必须要对应的 MATLAB Compiler license,而且生成的包依赖 MATLAB Runtime,体积几百 MB,太重。二是 FMU(Functional Mock-up Unit)导出,标准统一,但需要额外安装 FMU 支持包,而且很多 Python FMU 库性能一般。三是直接把 Simulink 模型手动翻译成 Python,等值换算费时费力,还得反复验证数值一致性,模型一旦更新,翻译工作全部重来,完全不可维护。相比之下,生成 DLL + ctypes 是成本最低、效果最可控的方案。
3. Simulink 模型代码生成实操
3.1 模型侧必须改的三个关键配置
生成 DLL 之前,模型的配置是成败的关键。第一,求解器必须改成定步长(Fixed-step)离散。我见过太多人拿默认的变步长连续求解器去生成代码,结果生成的 step 函数里根本没有积分逻辑,跑出来的输出完全不对。定步长离散模式下,每次调用 step 函数就是推进一个采样周期,行为最可预测。
第二,系统目标文件(System target file)要选ert.tlc,也就是 Embedded Coder。这里有个前提:你需要有 Embedded Coder 许可证。ERT 目标生成的代码非常干净,几乎零运行时依赖,独立编译成 DLL 最省事。如果只有 Simulink Coder 许可证,那就只能选grt.tlc,代码也能生成,但里面依赖rt_sim.c等一套运行时文件,手动编译 DLL 时要额外包含进去,步骤繁琐很多,我建议条件允许就直接用 ert.tlc。第三,在代码生成选项里,把 “Generate code only” 勾上,避免 Simulink 在生成时自作主张帮你编译。另外,如果希望生成的代码把输入输出结构体lp_filter_U、lp_filter_Y作为全局变量暴露出来,需要在 Code Generation > Interface 里勾选 “Generate C API from: Model data structures” 和 “Generate C API from: Model functions”。这一步很多人会漏掉,漏了之后 wrapper 里访问不到这两个结构体,DLL 就没法和 Python 交换数据了。
3.2 生成代码后拿到了哪些文件
点击 Build 之后,工作目录下会生成一个lp_filter_ert_rtw文件夹,里面就是全部代码。需要重点关注的文件有五个:lp_filter.c实现模型核心算法,lp_filter.h声明接口,lp_filter_data.c存放参数和信号数据结构体,lp_filter_types.h定义结构体类型,rtwtypes.h定义real_T等数据类型。还有一个lp_filter.mk是自动生成的 Makefile 模板,可以作为参考,但手动编 DLL 时一般直接写个简单的编译脚本。
3.3 把 C 代码编成 DLL 的两种编译方式
第一种方式是在 Visual Studio 的 “x64 Native Tools Command Prompt” 里手动编译。进入lp_filter_ert_rtw目录,用 cl 命令把lp_filter.c、lp_filter_data.c和自写的wrapper.c一起编成 DLL,然后指定两个 include 路径:一个是 MATLAB 安装目录下的rtw\c\src,另一个是其extern\include。命令大致是:
cl /LD /I "C:\Program Files\MATLAB\R2023a\rtw\c\src" /I "C:\Program Files\MATLAB\R2023a\extern\include" /I "." lp_filter.c lp_filter_data.c wrapper.c /Fe:lp_filter.dll第二种方式适合想要一个可复用构建工程的团队,写一个 CMakeLists.txt,用file(GLOB)把模型源文件收集起来,再add_library(lp_filter SHARED ...),目标和 include 目录和上面命令一致。CMake 的好处是以后模型更新了,重新跑一遍 configure 就能编出新的 DLL,不用记那串又长又容易抄错的手动命令。无论哪种方式,编译成功后都会得到一个独立的lp_filter.dll,这个文件就是你要交付给 Python 侧的产物。
4. Python 调用 DLL 的完整实现
4.1 用 wrapper 封装对外接口
最直接的做法是让 Python 直接访问 DLL 里导出的全局结构体变量lp_filter_U和lp_filter_Y。ctypes 完全可以做到,但缺点是你得在 Python 侧重新定义和 Simulink 生成的 C 结构体内存布局一模一样的 structure,模型端口一变,Python 代码就要跟着改。开发中期模型频繁加输入,这种写法非常累。
我的做法是额外写一个 wrapper.c,把对结构体的读写全部封装成几个简单的导出函数。这样 Python 端面对的接口非常稳定:初始化函数、单步计算函数、参数设置函数,不管 Simulink 模型内部怎么变,只要输入输出端口不变,Python 侧代码一行都不用动。wrapper.c 的完整内容大致如下:
#include "lp_filter.h" __declspec(dllexport) void lp_filter_init(void) { lp_filter_initialize(); } __declspec(dllexport) void lp_filter_do_step(const double *u, double *y) { lp_filter_U.In1 = u[0]; lp_filter_step(); y[0] = lp_filter_Y.Out1; }这里假设模型有一个输入端口和一个输出端口,都是 double 类型。如果你的模型是多输入多输出,就把u和y当成数组,根据端口宽度循环赋值,原理一模一样。
4.2 ctypes 加载 DLL 和数组传参
Python 端我直接用 ctypes 标准库。加载 DLL 用ctypes.CDLL,然后必须显式声明lp_filter_do_step的参数类型,不然默认会把所有参数当 32 位整数处理,64 位的 double 数组指针传进去直接崩。
输入输出数组我用 NumPy 创建,指定dtype=np.float64,再用arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double))拿到指针传给 DLL。这样做的好处是,如果你想做批量仿真,NumPy 数组既可以用起来像 C 数组,又天然支持向量化运算,生成不同工况的激励信号特别方便。核心绑定代码是:
import ctypes import numpy as np lib = ctypes.CDLL(r"D:\work\model_build\lp_filter.dll") lib.lp_filter_init() lib.lp_filter_do_step.argtypes = [ ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double), ] lib.lp_filter_do_step.restype = None注意,如果你的 Python 是 64 位的,DLL 也必须是 64 位编译出来的,否则加载的时候会报[WinError 193]。这个坑很常见,后面专门讲。
4.3 批量仿真循环与结果回收
有了上面的绑定,写一个仿真循环非常自然。下面这段代码模拟的是 1 Hz 正弦信号通过低通滤波器,步长 0.01 秒,共 1000 步,输出结果存到列表里并画出来:
u = np.zeros((1,), dtype=np.float64) y = np.zeros((1,), dtype=np.float64) u_p = u.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) y_p = y.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) outs = [] for i in range(1000): u[0] = np.sin(2 * np.pi * 1.0 * i * 0.01) lib.lp_filter_do_step(u_p, y_p) outs.append(y[0]) import matplotlib.pyplot as plt plt.plot(outs) plt.show()这段代码的可扩展性在于,你可以在循环里动态改变u[0]的值,从而自由定义任意激励信号;也可以在每次 step 之后读取outs[-1],做在线判断或存储。做参数扫描优化时,通常会有两层循环:外层用 scipy 的优化器生成一组候选参数,内层跑完整个仿真序列返回一个代价函数值,DLL 在每次内层仿真启动前重新初始化一次,保证模型状态是干净的。
4.4 两个进阶玩法:实时控制和在线调参
这套链路不仅能做离线仿真,还能做“软实时”控制。如果模型的采样时间是 10 ms,你可以在 Python 循环里调用lp_filter_do_step后用time.sleep(0.01)撑住节奏,配合外部采集设备的数据输入,就变成了一个简易的实时控制原型。当然这不是硬实时,但用来验证控制逻辑完全足够了。
另一个很实用的玩法是在线调参。Simulink 模型里的参数,比如一阶低通滤波器的时间常数,生成代码后会放在lp_filter_P结构体里。我封装 wrapper 时会加一个set_param(int param_id, double value)函数,在 wrapper 内部把参数写回对应的结构体字段。这样 Python 端就具备了和 Simulink 外部模式类似的能力:仿真中途改变模型的某个参数,观察系统响应的实时变化。如果你们团队在做标定工作,这个功能非常香。
5. 常见问题与排查技巧实录
5.1 编译阶段的典型报错
我在编译阶段遇到的第一个问题是cannot open include file 'rtwtypes.h'。原因很简单,rtwtypes.h在rtw\c\src目录里,不在模型代码目录里,l 命令需要把 MATLAB 对应路径加到 /I 后面。第二个问题是直接用普通 cmd 窗口运行 cl,结果提示cl 不是内部或外部命令。这个是因为 cl 依赖 Visual Studio 的环境变量,必须从 Developer Command Prompt 启动,或者用 vcvars64.bat 初始化环境。第三个问题更隐蔽,模型没有生成lp_filter_data.c文件,这是因为模型里没有任何参数或常量被标定为可调,编译器优化掉了这个文件的生成。解决办法是在模型中任意一个模块参数上勾选 tunable,或者直接忽略这个文件,不参与编译也行。
5.2 Python 加载 DLL 时的错误
OSError: [WinError 193] %1 不是有效的 Win32 应用程序是这阶段最高频的错误,几乎全是位数不匹配导致的。Python 是 64 位,DLL 是 32 位,或者反过来,都会报这个错。排查方式很简单,编译 DLL 时明确选择 x64 或 x86 的编译环境,然后和 Python 位数对齐。还有一种错误是DLL load failed while importing ...,此时 DLL 本身位数没错,但依赖的 Microsoft Visual C++ 运行库缺失,装上对应的 VC++ Redistributable 就能解决。另外,DLL 路径里尽量不要有中文和空格,避免一些不必要的编码问题。
5.3 模型运行结果和 Simulink 对不上
这是最让人抓狂的一类问题。明明代码编译成功,Python 也跑起来了,但输出曲线和 Simulink 里完全不一样。我的排查经验是,优先检查模型求解器是不是定步长离散。如果是连续模型,生成代码后的 step 函数里没有积分器,输出自然不对。解决办法是把连续模块替换成对应的离散版本,比如 Transfer Fcn 换成 Discrete Transfer Fcn,然后重新生成。第二个常见原因是调用了 step 函数但没有先调用 init 函数,模型的初始状态是内存垃圾,前几步的输出完全随机。第三个原因是多速率模型。Simulink 里不同的模块采样时间不一样时,生成的代码会有多个 step 函数,比如lp_filter_step0和lp_filter_step1,Python 侧必须按照实际速率关系去调度,不能只调用其中一个。
以下是一个简易的排查顺序表,我每次遇到问题都会按这个流程过一遍:
| 现象 | 首选排查方向 | 解决办法 |
|---|---|---|
| 编译报找不到头文件 | include 路径不全 | 加上rtw\c\src和extern\include |
| cl 命令不可用 | 环境变量未初始化 | 使用 VS Native Tools 命令行 |
| Python 报 WinError 193 | DLL 和 Python 位数不同 | 统一编译目标位数和 Python 位数 |
| DLL 加载后提示缺运行库 | 缺少 VC++ 运行时 | 安装对应版本的 vc_redist |
| 仿真曲线和 Simulink 不一致 | 模型用了连续求解器 | 换成定步长离散求解器并验证 |
| 多速率模型输出异常 | step 函数不止一个 | 按采样速率调度多个 step 函数 |
6. 几条个人经验总结
这套方案我在三个项目里完整落过地,踩了这么多坑之后,有几句实在话想分享。第一,不要在小模型上验证通过就以为万事大吉。我建议验证阶段专门用一个多输入多输出、多个采样速率的小模型走一遍全流程,把最棘手的问题在最小范围内暴露出来,比直接拿大模型调试省心得多。
第二,wrapper 层值得多花一点时间设计。输出接口一旦定义好,模型再怎么更新,Python 端都稳定如山。反过来,如果图省事让 Python 直接碰结构体,后面每次模型加输入输出都是一次联动改动,非常磨人。
第三,如果团队里有人不愿意用 Python,不太会配环境,可以考虑把这些逻辑包成一个简单的命令行工具,用 argparse 读取输入文件路径和参数,输出结果写文件,再用任何语言调用,思路和 DLL 方案完全一致,只是多了一层文件 IO。这个方案牺牲了一点性能,但换来了极大的协作便利。
最后再分享一个小技巧。DLL 文件放好后,用 Python 也能快速检查这个 DLL 导出了哪些函数,避免在 ctypes 里调用一个不存在的符号。用dumpbin /exports lp_filter.dll(Visual Studio 自带)或者 Python 里遍历lib._name_to_index就能看到。这个检查在 wrapper 接口改动后特别有用,几秒钟就能确认导出接口是否符合预期。
本文还有配套的精品资源,点击获取