Simulink中调用DLL的实用指南:从快速原型到嵌入式部署

在控制系统开发中,我们常常面临这样一个现实:核心算法早已用C/C++实现,并被封装成DLL用于生产环境。而当我们想在Simulink中进行系统级仿真时,却不想、也不能重写这些成熟代码。如何让图形化的Simulink模型与这些预编译的二进制模块无缝协作?这正是本文要解决的问题。

答案其实很明确——通过S-Function或MATLAB Function结合外部函数调用机制,将DLL“嫁接”进Simulink模型。这不是简单的技术拼接,而是一种工程智慧:复用已有资产、保护知识产权、提升执行效率。下面我们就从实际出发,一步步拆解这两种主流方案的实现细节。

先来看一个典型场景:假设你正在开发一款电机控制器,其中PID参数自整定算法由第三方提供,仅以 motor_ctrl.dll 形式交付。你需要在Simulink中搭建完整的闭环仿真系统,包括电机模型、传感器噪声、PWM驱动等,但控制律部分必须依赖这个DLL。这时候,无论是出于保密要求还是性能考虑,直接集成DLL都是最优选择。

S-Function:掌握底层控制权的硬核方式

如果你追求对模块行为的完全掌控,S-Function是不二之选。它本质上是一个用C/MEX编写的动态链接模块,能够深度介入Simulink的仿真生命周期。我们可以把它看作一个“定制化黑盒”,其输入输出、状态更新、内存管理全部由开发者定义。

以调用数学函数库为例,设想有一个DLL导出了 double my_math_func(double x) ,功能为计算$(x+1)^2$。我们要做的,就是在S-Function中完成三件事:加载DLL、获取函数指针、安全调用并返回结果。

关键在于 mdlStart 回调函数中的动态加载逻辑:

static void mdlStart(SimStruct *S)
{
    hLib = LoadLibrary(L"my_math_lib.dll");
    if (!hLib) {
        ssSetErrorStatus(S, "Failed to load my_math_lib.dll");
        return;
    }

    funcPtr = (MY_FUNC)GetProcAddress(hLib, "my_math_func");
    if (!funcPtr) {
        FreeLibrary(hLib);
        ssSetErrorStatus(S, "Failed to get function address from DLL");
        return;
    }

    ssGetPWork(S)[0] = hLib; // 保存句柄供终止时释放
}

这里有几个容易踩坑的地方。第一, LoadLibrary 传入的是宽字符字符串(L”“),否则中文路径会出问题;第二,必须通过 PWork 向量保存DLL句柄,确保在 mdlTerminate 中能正确卸载,避免资源泄漏;第三,错误处理不能省略,否则一旦DLL缺失会导致Simulink崩溃而非优雅报错。

mdlOutputs 中调用函数就相对简单了:

static void mdlOutputs(SimStruct *S, int_T tid)
{
    const real_T *u = (const real_T*) ssGetInputPortSignal(S, 0);
    real_T       *y = (real_T*) ssGetOutputPortSignal(S, 0);

    if (funcPtr && u && y) {
        y[0] = funcPtr(u[0]);
    } else {
        ssSetErrorStatus(S, "DLL function call failed");
    }
}

整个S-Function需要通过MATLAB的 mex 命令编译。如果你使用Visual Studio作为后端编译器,先运行 mex -setup C 指定环境,然后执行:

mex sfun_call_dll.c

生成的 .mexw64 文件即可在Simulink中作为普通模块使用。值得注意的是,S-Function支持自动代码生成——这意味着你在仿真阶段验证无误后,可以直接用Embedded Coder将其转换为目标平台的C代码,只需确保构建系统能链接对应的 .lib 导入库即可。

这种方案的优势非常明显:性能接近原生C执行,适用于实时性要求高的场景;支持复杂状态管理和多速率采样;更重要的是,在生成的嵌入式代码中仍保留对外部函数的引用,便于后续与固件集成。

当然,代价也很明显:调试困难,一个小错误可能导致MATLAB崩溃;开发周期长,需要熟悉S-Function的全套API规范。

MATLAB Function + coder.ceval:高效原型设计的利器

如果你更关注开发速度和可维护性,尤其是项目最终目标是生成嵌入式代码,那么推荐使用MATLAB Function配合 coder.ceval 的方式。这种方法的核心思想是“分阶段处理”:仿真阶段用MEX包装函数,代码生成阶段则插入原始C调用。

具体来说,首先在Simulink中添加一个MATLAB Function模块,内部代码如下:

function y = fcn(u)
%#codegen
y = 0.0;
coder.extrinsic('call_my_dll_function');
y = call_my_dll_function(u);

这里的 coder.extrinsic 告诉Simulink:“别尝试解析这个函数,它是个外部实体”。紧接着,我们需要实现 call_my_dll_function 这个MEX函数,结构上与S-Function类似:

void mexFunction(int nlhs, mxArray *plhs[], int nrhs, const mxArray *prhs[])
{
    double input, output;

    if (!hLib) {
        hLib = LoadLibrary(L"my_math_lib.dll");
        // ... 获取函数指针
    }

    input = mxGetScalar(prhs[0]);
    output = funcPtr(input);
    plhs[0] = mxCreateDoubleScalar(output);
}

编译后得到 call_my_dll_function.mexw64 ,仿真时就能正常运行。

但真正精彩的部分在代码生成阶段。此时我们将MATLAB Function内的代码改为:

function y = fcn(u)
%#codegen
y = coder.ceval('my_math_func', u);
coder.updateBuildInfo('addHeaderFiles', 'my_math_lib.h');

coder.ceval 是一个编译指令,它不会参与仿真,而是告诉代码生成器:“在这里插入一句C代码调用 my_math_func(u) ”。为了保证类型匹配,你还需提供头文件声明:

// my_math_lib.h
#ifdef __cplusplus
extern "C" {
#endif
double my_math_func(double x);
#ifdef __cplusplus
}
#endif

最后通过模型配置或脚本注册构建信息:

hBld = getImplBuildDir('your_model_name', true);
addIncludePaths(hBld, 'include');
addLinkObjects(hBld, 'lib/my_math_lib.lib', '', 1, true);

这样生成的C代码中就会包含对 my_math_func 的直接调用,并在链接阶段绑定到 .lib 文件。运行时操作系统自动加载对应的DLL。

这种方式的最大优势在于开发效率高,适合快速迭代。你可以在早期用MEX快速验证逻辑,后期无缝切换到生产级代码生成流程。而且由于主体逻辑仍是MATLAB代码,团队协作和维护成本更低。

不过要注意, coder.ceval 对数据类型极其敏感。传递结构体或数组时,必须确保内存布局完全一致,必要时使用 eml.structTmpl coder.typeof 显式声明类型。

工程实践中的那些“坑”

无论选择哪种方案,以下几个实战经验都值得牢记:

首先是线程安全问题 。默认情况下Simulink可能启用多线程仿真(Multi-threaded simulation),如果DLL内部使用了静态变量或全局缓冲区,极有可能引发竞态条件。解决方案要么将DLL改为纯函数式设计(无副作用),要么在模型设置中关闭多线程模式。

其次是运行时依赖 。很多初学者编译完DLL后发现Simulink无法加载,原因往往是缺少VC++运行库。建议静态链接CRT(/MT选项),或者将 vcruntimeXXX.dll 等一同部署。可以用Dependency Walker工具检查DLL的依赖项。

路径管理也常被忽视 。不要假设DLL一定在当前目录。稳妥做法是在代码中使用相对路径(如 ..\libs\my_math_lib.dll )并配合 addpath 确保工作目录正确,或干脆把DLL放在系统PATH中。

版本兼容性不容小觑 。曾有项目因主机安装的是VS2019而目标板使用VS2017工具链,导致生成的DLL接口不兼容。务必统一开发与部署环境的编译器版本。

对于跨平台需求,虽然Windows下是DLL,Linux对应SO,macOS是DYLIB,但可通过条件编译抽象加载过程:

#ifdef _WIN32
    #include <windows.h>
    typedef HMODULE dll_handle;
    #define load_dll(name) LoadLibraryA(name)
    #define get_func(lib, name) GetProcAddress(lib, name)
    #define close_dll(lib) FreeLibrary(lib)
#elif __linux__
    #include <dlfcn.h>
    typedef void* dll_handle;
    #define load_dll(name) dlopen(name, RTLD_LAZY)
    #define get_func(lib, name) dlsym(lib, name)
    #define close_dll(lib) dlclose(lib)
#endif

这样一来,同一套接口代码可在不同平台上编译运行。

写在最后:不只是技术,更是工程思维

调用DLL看似只是一个技术点,实则体现了现代控制系统开发的一种典型范式:分层协作、各司其职。算法工程师专注核心逻辑并封装成稳定接口,系统工程师则利用Simulink构建完整验证环境,两者通过标准化的二进制接口连接。

随着AI在控制领域的渗透,这种模式愈发重要。例如将ONNX推理引擎打包为DLL,在Simulink中调用实现智能控制策略;或将硬件驱动SDK封装后用于HIL测试。掌握DLL集成能力,意味着你能更灵活地打通仿真与实物之间的鸿沟。

未来,随着FPGA和异构计算的发展,类似的外部模块集成需求只会越来越多。而今天你在Simulink中学到的这套方法论——如何安全加载、如何管理生命周期、如何桥接不同类型系统——其价值远超一个具体功能本身。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐