很多做控制仿真的人,电脑里都躺着几段验证过无数遍的 C 代码。可能是上一版嵌入式工程里的滤波函数,可能是合作方提供的电机辨识算法,也可能是老工程师传下来的经典控制律。这些代码稳定、实时性好、经过了实际硬件考验,但一旦想拿到 Simulink 里复用,就成了麻烦事:把 C 算法重写成 Simulink 模块,工作量大不说,还容易引入翻译错误;写 S-Function,又要弄懂 Simulink 内部的数据结构和回调机制,新手往往一看就放弃。
C Caller 模块解决的就是这个非常具体的场景:它允许你在 Simulink 模型里直接添加一个模块,指定一个 C 函数的名字,然后把函数的形参映射成模块的输入、输出端口。模型运行时,Simulink 会自动完成 C 函数的编译、链接和调用。换句话说,你不用把 C 代码“翻译”成 Simulink 风格,也不用学习 S-Function 那一套复杂接口规范,就能让成熟 C 算法直接在仿真里工作。
先说结论:C Caller 的真正价值,是把“在仿真中复用 C 算法”这个需求的操作成本降到了最低。它最适合的场景是仿真验证——你有一份成熟的 C 库,想在控制模型里快速验证效果。如果你的目标是产品级代码生成、追求极致运行效率、或者需要和 Simulink 引擎深度交互,那 S-Function 和 Legacy Code Tool 可能依然是更合适的选择。本文会从 C Caller 的定位和原理讲起,围绕一个汽车纵向动力学的小例子,完整演示头文件配置、源文件设置、函数映射、端口连接和仿真验证,最后给出常见问题排查表和工程实践建议。
1. C Caller 模块到底解决什么问题
先看几种常见的现实场景。
第一个场景是“代码迁移”。团队里已经用 C 语言实现了一套电池 SOC 估算算法,在硬件上跑了好几年,工程文档齐全,但新项目要求用 Simulink 做系统级仿真。如果重写成 Simulink 原生模块,算法细节容易在翻译中丢失,验证工作量也很大。用 C Caller 直接把已有的 C 函数拖进模型,仿真体系和算法核心就同时保留了下来。
第二个场景是“联合仿真里的算法复用”。很多公司用 Simulink 搭整车模型,用 CarSim 或者自主开发的动力学平台做外部联合仿真,外部平台的计算精度完全依赖内部算法。如果希望 Simulink 里的控制器直接调用另一套 C 语言路径规划库或车辆动力学库,C Caller 是最快的一条路,不需要把代码搬来搬去。
第三个场景是“算法快速验证”。工程师手里有一份用 C 写好的控制律,想先在 Simulink 里看看闭环响应,但不想为这件事单独维护一份 MATLAB 版本。这时,C Caller 模块可以做到“一份 C 代码,既不迁移也不重写,直接原地参与仿真”。
那 C Caller 不适合什么场景?这里需要诚实地说清楚:
- 不适合高频、大计算量的细粒度函数。虽然 C 函数本身执行很快,但 Simulink 仿真每一步的外围数据交换和接口转换会有额外开销。
- 不适合需要软件在环(SIL)产品级交付的复杂路径。这类需求通常要求更细的控制粒度,比如 S-Function、TLC、代码生成定制。
- 不适合 C++ 重载、模板等复杂语言特性。C Caller 面向的是 C 函数接口,如果库是 C++ 写的,至少要用 extern "C" 包一层。
把 C Caller 和常见替代方案放在一起对比一下。
| 方案 | 学习成本 | 维护成本 | 适合场景 | 主要局限 |
|---|---|---|---|---|
| C Caller 模块 | 低 | 低 | 仿真中直接调用 C 函数 | 要求函数签名清晰,适合 C 风格接口 |
| S-Function | 高 | 高 | 深度集成、自定义求解器交互 | 需要理解 Simulink 内部机制 |
| MATLAB Function + coder.ceval | 中 | 中 | 在 MATLAB Function 中调用外部 C 函数 | 配置繁琐,排错路径不明显 |
| Legacy Code Tool | 中高 | 中高 | 老版本或复杂代码集成 | 配置脚本较繁琐,但功能更底层 |
| 重写为 MATLAB 实现 | 中 | 高 | 代码量小且无 C 依赖 | 容易引入翻译差异,需要双重验证 |
这个表格给了读者一个选择依据:C Caller 的优势一句话就能说清——它是“C 函数进 Simulink”这条路上门槛最低的入口,但不是功能最深的入口。
2. C Caller 的核心原理与概念边界
C Caller 模块位于 Simulink 的 User-Defined Functions(用户自定义函数)模块库中。从名字就能看出,它的定位是“调用外部 C 代码”,与 S-Function 是两种完全不同的思路。
S-Function 的思路是:你写一个函数,Simulink 在特定回调时刻调用它,函数内部要自己处理输入输出、连续状态、离散状态、采样时间等。它像是一个 Simulink 引擎的“插件”,你必须懂引擎的规则。C Caller 的思路则简单很多:Simulink 把你指定的 C 函数在仿真目标编译阶段链接进来,在模型执行时直接把端口上的数据转换成 C 函数的实参调用。模块本身只是“壳”,真正的计算逻辑仍然留在 C 代码里。
因此,使用 C Caller 时,Simulink 需要知道三件事:
- 头文件在哪里,用来解析函数原型;
- 源文件在哪里,用来参与编译链接;
- 函数的每一个形参应该映射成输入端口、输出端口、全局变量还是常量参数。
这也是 C Caller 和 Legacy Code Tool(LCT)的区别。LCT 通过脚本生成 S-Function 封装,适合把复杂的 C/C++ 代码包装成 Simulink 可调用的模块,功能更强大但也更繁琐。C Caller 相当于把 LCT 最常见的“移植一个函数”这个场景单独拎出来做成了图形化界面。所以,如果你只需要调用几个普通函数,用 C Caller 就够了;如果要处理动态库、复杂结构体、多个编译选项组合,LCT 仍然有它的价值。另外,较新版本 MATLAB 中还提供了 C Function 模块,它与 C Caller 属于同一类可视化外部代码接入方式,但对于大多数场景,C Caller 的配置链路更清晰,也更适合推荐给第一次接触外部代码集成的开发者。
需要特别说明的是,C Caller 并不是“把任意 C 代码都导入 Simulink”。它要求函数原型是可解析的:
- 参数类型尽量是 double、float、int 等原生类型,或是一维数组;
- 数组参数需要明确指定大小;
- 结构体参数在 C Caller 中配置会更复杂,老版本甚至需要借助总线数据类型;
- C++ 的重载、模板、命名空间等特性,C Caller 不直接支持。
也就是说,C Caller 最适合的是“接口清晰的传统 C 函数”:输入是几个标量或数组,输出是标量或数组,内部自己完成所有计算。这一章的结论很简单:C Caller 是 Simulink 外部代码集成方案里最轻量的一种,轻量来自它对函数接口做了严格但简单的假设。
3. 环境准备与前置条件
3.1 软件版本
C Caller 模块在较新的 MATLAB/Simulink 版本中已经作为标准模块提供,推荐在 R2020a 之后的版本中使用,界面会更友好。如果你的 MATLAB 版本比较老,模块库中找不到 C Caller,可以考虑使用 Legacy Code Tool,它在旧版本中同样稳定,只是配置方式不同。本文后续步骤以“较新版本 MATLAB”为例,不同版本之间菜单名称可能有细微差异,但不影响整体思路。
3.2 配置 C 编译器
Simulink 要运行包含外部 C 代码的仿真,必须调用 C 编译器把源文件编译进仿真目标。大多数情况下,MATLAB 默认安装的 MinGW-w64 编译器就够用。可以先在 MATLAB 命令窗口执行:
mex -setup如果系统里安装了多个编译器,命令会列出可用的编译器并询问选择哪个。一般优先选择 Microsoft Visual C++,其次选择 MinGW-w64。选择完成后,可以执行mex -setup C++确认 C++ 编译器配置是否正常,但 C Caller 本身主要使用 C 编译器。
如果mex -setup显示“未找到支持的编译器”,需要先安装 MATLAB 支持的编译器,例如 MinGW-w64,或者在 MATLAB 附加功能中搜索 MinGW 并安装。这一步是新手最容易忽略的地方:模型本身没有做任何复杂设置,但仿真一启动就报编译器错误,通常就是这里没有配置好。
3.3 工程目录结构
建议在实际项目中单独建一个目录存放外部 C 代码,不要把所有 C 文件塞在模型根目录里。下面是一个推荐结构:
project_root/ ├── my_model.slx ├── external_code/ │ ├── include/ │ │ └── vehicle_dynamics_utils.h │ └── src/ │ └── vehicle_dynamics_utils.c └── scripts/ └── verify_c_caller.m这样做的原因是:后续无论是代码生成、版本管理还是团队协作,路径和文件边界都更清晰。C Caller 模块配置中使用的路径建议用相对路径,不要用C:\Users\xxx\...这种绝对路径,否则换一台电脑模型就无法打开或者无法运行。
3.4 是否需要额外模型设置
如果只是在 Simulink 中仿真,不生成嵌入式代码,那么把头文件和源文件配置到 C Caller 模块内部即可,模型级设置通常不需要额外改动。如果后续要使用 Embedded Coder 生成 C 代码,则还需要在模型设置里做全局配置:
- 打开 Configuration Parameters(或者 Model Settings);
- 进入 Code Generation > Custom Code;
- 在 Additional include directories 中添加
external_code/include; - 在 Additional source files 中添加
external_code/src/vehicle_dynamics_utils.c或对应的通配路径; - 根据生成代码的编译环境,可能还需要在 Additional libraries 中补充链接库。
这个配置的作用是:Simulink 模型生成代码时,C Caller 模块会自动生成对外部函数的调用,但头文件和源文件必须对代码生成器可见,否则生成的工程无法编译。对于纯仿真场景,这一节可以先跳过,但建议一开始就按这个规范准备目录,后面迁移成本会小很多。
4. 从一个最小 C 函数开始:汽车纵向阻力计算
为了演示 C Caller 的完整链路,这里用一个汽车纵向动力学中常见的阻力计算函数做例子。这个例子不涉及复杂状态,输入输出都是双精度标量,非常适合第一次上手。
场景:我们现在有一个 Simulink 整车仿真模型,需要实时计算空气阻力和坡度阻力。这些公式在团队已有的 C 代码库中已经实现了,现在直接用 C Caller 调用,而不是在 Simulink 里重写一遍。
先创建头文件external_code/include/vehicle_dynamics_utils.h:
#ifndef VEHICLE_DYNAMICS_UTILS_H #define VEHICLE_DYNAMICS_UTILS_H #ifdef __cplusplus extern "C" { #endif /* * 计算空气阻力 * velocity : 车辆纵向速度,单位 m/s * frontal_area : 迎风面积,单位 m^2 * drag_coefficient : 空气阻力系数,无量纲 * air_density : 空气密度,单位 kg/m^3 * 返回值 : 空气阻力,单位 N */ double calculate_aerodynamic_drag(double velocity, double frontal_area, double drag_coefficient, double air_density); /* * 计算坡度阻力 * mass : 整车质量,单位 kg * grade_angle : 坡度角,单位 rad * 返回值 : 坡度阻力,单位 N */ double calculate_grade_resistance(double mass, double grade_angle); #ifdef __cplusplus } #endif #endif然后创建源文件external_code/src/vehicle_dynamics_utils.c:
#include "vehicle_dynamics_utils.h" #include <math.h> double calculate_aerodynamic_drag(double velocity, double frontal_area, double drag_coefficient, double air_density) { return 0.5 * drag_coefficient * frontal_area * air_density * velocity * velocity; } double calculate_grade_resistance(double mass, double grade_angle) { const double g = 9.81; return mass * g * sin(grade_angle); }这里有几个值得注意的细节:
- 头文件里加了
extern "C"保护。这是为了将来如果工程用 C++ 编译器编译链接,函数名不会被 C++ 编译器改写,C Caller 才能正确找到符号。 - 函数名和头文件里的声明必须完全一致,包括大小写。
- 函数只依赖
math.h中的标准函数,不引入其他自定义头文件,这样配置最省事。
如果函数在头文件中声明了,但 C Caller 拉取函数时看不到,绝大多数时候是头文件路径配置不对,或者源文件没有参与编译链接。
5. 在 Simulink 中配置 C Caller 模块
5.1 添加模块并打开配置
在 Simulink 模型窗口中,打开模块库浏览器,进入 User-Defined Functions,把 C Caller 模块拖到模型画布上。双击模块,打开配置界面。不同版本界面略有差异,但核心区域通常是:头文件配置、源文件配置、函数选择区、参数映射表。
5.2 添加头文件与源文件
在头文件区域填入头文件的路径。这里有两种写法:
- 如果模型和 external_code 在同一个工程根目录下,可以写
external_code/include/vehicle_dynamics_utils.h; - 也可以写成
#include "vehicle_dynamics_utils.h",然后在模型级设置 Additional include directories 里加external_code/include。
在源文件区域,填写external_code/src/vehicle_dynamics_utils.c。配置后,点击刷新或者重新解析,界面上的函数列表应该能看到头文件里声明的两个函数。
这里要提醒一句:有些版本要求头文件路径不带尖括号,有些版本支持 include 风格,建议实际填写后先点一下 Apply,看界面是否报错。如果界面没有报错,并且函数下拉框中能看到目标函数,说明头文件解析成功。
5.3 选择函数并配置参数映射
假设先调用calculate_aerodynamic_drag。在函数下拉框中选择该函数后,界面会自动解析出四个输入形参和一个返回值。参数映射表里通常需要配置:
- 参数名称:固定为函数签名中的变量名,不可修改;
- 数据类型:默认 double,与 C 函数一致;
- 端口映射或参数分类:当前形参是 Input、Output、Global 还是 Constant;
- 数组维度:如果不填默认是 1x1 标量。
四个输入全部映射为 Input,模块会自动生成四个输入端口。返回值映射为 Output,模块生成一个输出端口。如果函数有多个出参,就需要在参数表中将这些形参标记为 Output,Simulink 会把它们在模块上生成对应的输出端口。这个机制理解起来很直观:函数参数是需要读入的,就用输入端口;是需要写回的,就用输出端口。
5.4 连接模型并运行
把 Constant 模块分别连接到四个输入端口,把输出接到 Scope 或 To Workspace。设置一个固定仿真时间,比如 10 秒,运行仿真。第一眼就能看出 C Caller 是否工作正常:如果模块没有报错,输出波形与理论计算结果一致,说明 C 函数已经被成功编译并链接进仿真。
5.5 在同一个模型中复用同库函数
如果需要调用calculate_grade_resistance,不需要重新配置头文件和源文件。再拖一个 C Caller 块,在函数下拉框中选择calculate_grade_resistance,配置两个输入参数映射即可。同一个库文件可以在多个 C Caller 块之间共享。再次强调:C 文件如果在一个模型里被多个 C Caller 块引用,推荐使用模型级 Custom Code 添加源文件,避免重复编译或者出现重复符号定义的问题。
6. 仿真运行与结果验证
6.1 搭一个最小测试模型
为了验证,搭建一个只包含 C Caller 模块的独立模型即可。结构如下:
- Constant 模块:输入 velocity = 20;
- Constant 模块:输入 frontal_area = 2.2;
- Constant 模块:输入 drag_coefficient = 0.3;
- Constant 模块:输入 air_density = 1.225;
- C Caller 模块:调用 calculate_aerodynamic_drag;
- Scope 或 To Workspace:记录输出。
6.2 运行模型并记录数据
把四个常数模块的值直接写在 MATLAB 脚本中,然后调用 sim 命令。示例脚本如下:
% 文件路径:scripts/verify_c_caller.m velocity = 20; frontal_area = 2.2; drag_coefficient = 0.3; air_density = 1.225; % 这些值需要在模型中与 Constant 模块保持一致 mdl = 'c_caller_demo_model'; load_system(mdl); simOut = sim(mdl, 'StopTime', '1'); y = simOut.get('drag_output'); fprintf('C Caller 输出: %.6f N\n', y(end)); % 与理论值对比 expected = 0.5 * drag_coefficient * frontal_area * air_density * velocity * velocity; fprintf('理论计算值: %.6f N\n', expected); fprintf('绝对误差: %.6e N\n', abs(y(end) - expected));6.3 如何判断成功
理想情况下,C Caller 输出与理论值完全一致,误差在浮点精度范围内。如果误差明显大于浮点精度,说明端口映射或者输入常数配置有误。工程上一般做“相对误差”判断,不用绝对误差,特别是当数值本身量级较大时。
6.4 如果仿真失败
失败时,第一件事不是看波形,而是看 MATLAB 命令窗口的编译错误。C Caller 在仿真开始前会先尝试把源文件编译链接进仿真目标,所以编译错误会直接弹出。常见编译错误包括:头文件找不到、函数未声明、变量符号冲突。优先修复这些基础问题,再谈运行。
7. 进阶场景:数组参数、全局变量与 C++ 调用
7.1 数组参数
C Caller 支持一维数组参数。例如有这样一个函数:
double calculate_mean(double *data, int len);配置时,data参数的数据类型保持 double,维度填写[1 10]或[10 1],len参数可以映射为常量参数或输入端口。Simulink 对应的输入端口就会自动变成 1x10 的向量端口。数组参数最容易踩坑的地方是维度不匹配。Simulink 侧输入向量长度必须和 C 函数期望的长度一致,否则 C 代码读到越界数据,表现可能是结果错乱,也可能是内存访问错误。
7.2 全局变量
如果 C 函数内部依赖全局变量,C Caller 也可以把这些全局变量映射为模块端口。在配置界面的全局变量区域,可以添加外部全局变量名称,并指定它作为模块输入或输出。这种用法适合简单场景,但工程上不建议过度依赖。因为 Simulink 模型如果被复制、引用多次,每个实例对全局变量的读写会发生冲突,结果会变得很难排查。如果确实必须用全局变量,请确认模型不存在多实例问题。
7.3 C++ 库怎么调用
C Caller 本质上是按 C 函数符号链接,C++ 编译器会对函数名进行 name mangling。所以如果调用的是 C++ 库里的函数,需要在头文件里加extern "C"强行指定 C 链接方式。前面示例已经做了这一点。
但需要注意,extern "C"只能解决“函数名查找”问题,不能解决 C++ 语法问题。如果函数内部大量使用 C++ 的类、模板、异常,C Caller 仍然很难直接处理。稳妥做法是写一个 C 接口包装层,用纯 C 函数把 C++ 逻辑封装起来,然后在 Simulink 里调用这些 C 函数。
7.4 结合 Embedded Coder 生成代码
C Caller 模块也可以用于生成代码。Simulink 会生成一段对外部 C 函数的调用,外部函数本身不重新生成,只是参与链接。从实践来看,如果最终目标是嵌入式产品交付,C Caller 更适合“快速验证”,不太适合“直接用默认配置生成产品代码”。产品级代码生成通常会对代码的可追溯性、内存分配策略、头文件目录、编译选项有更严格的要求,这时候 S-Function 和 TLC 定制、或者手写集成,会更可控。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 配置界面找不到 C Caller 模块 | MATLAB 版本较旧,模块库中没有该模块 | 在模块库浏览器搜索 C Caller | 升级 MATLAB,或改用 Legacy Code Tool |
| 仿真启动时报找不到编译器 |