Matlab中eig内置函数转为C语言
做算法的人都懂这种痛:MATLAB里一条[V,D] = eig(A)用得飞起,结果项目一落地,要么是嵌入式环境跑不了MATLAB,要么是客户现场不允许装庞大的MATLAB运行时,要么是算法要集成到别人写的C++框架里,这时候你就得老老实实把eig从MATLAB里“请”到C语言世界来。
这篇文章我打算把这件折腾了我至少三个晚上的事情聊透。我会先讲清楚为什么需要做这个转换、有哪几条可行路线,再带你从接口设计和数学原理层面搞明白eig到底在算什么,最后手把手给出两条能落地的转换路线(Eigen库和LAPACK),并把对拍验证、特征值顺序、复数处理这些坑一次性排掉。无论你是刚接触MATLAB转C的新手,还是已经在嵌入式项目里苦于数值库选型的工程师,这篇文章都应该能给你省下一整周的试错时间。
1. 项目背景:为什么非要把 eig 搬到 C 语言
1.1 典型场景与需求来源
先说个最常见的场景:你在MATLAB里搭了一套控制算法原型,里面用eig判断系统稳定性,仿真结果非常漂亮。下一步要把算法部署到DSP或者ARM开发板上,发现板子上根本跑不了MATLAB生成的解释型代码,于是整个算法模块都得转成C语言。
第二个场景我遇到得更多:算法要作为一个功能模块被嵌进一个更大的C/C++系统里,比如工业视觉软件、实时音频处理引擎、动力学仿真框架。这些系统的架构师不可能为了你的特征值分解单独装一套MATLAB Runtime,他们只会给你一句“用标准C把函数实现出来”。
第三个场景和性能、授权有关。MATLAB本身是付费软件,在某些交付场景里你不能要求客户购买MATLAB License,而数值计算函数如果自己用纯C手写,性能上又很难赶上经过几十年优化的LAPACK/Eigen这类成熟库。所以“转C语言”本质上是三件事:脱离运行时、嵌入现有工程、保证精度与性能不缩水。
1.2 搞清楚你的矩阵是什么类型
动手之前,先别急着写代码。eig这个函数在不同输入下走的是完全不同的算法路径,这一步搞错,后面全部白干。
如果你的矩阵是对称矩阵(或者Hermitian复矩阵),MATLAB底层会走dsyev这类专门针对对称问题优化的求解器,算法是Jacobi旋转或者分治法的变体,速度极快且稳定。如果你的矩阵是一般的非对称实矩阵,则会走dgeev,底层是Hessenberg约化加QR迭代,特征值有可能是复数。还有一种情况是广义特征值问题eig(A,B),对应的是QZ算法,转换复杂度会更高。
所以转C语言前,第一件事是把需求钉死:
- 输入矩阵是方阵吗?
- 是实矩阵还是复矩阵?
- 对称吗?如果对称,能不能在文档里强制约束?
- 只要特征值,还是特征向量也要同时输出?
- 矩阵规模大概多大?几十阶还是几千阶?
这些答案决定了你选哪条转换路线。我在实际项目中见过好几个同事拿着非对称矩阵强行套对称矩阵求解器,算出来的结果离谱到天际,最后查了半天才发现是对矩阵性质把握错了。
2. 四条路线怎么选:MATLAB Coder、LAPACK、Eigen、手写
2.1 路线一:MATLAB Coder 自动生成 C 代码
MATLAB Coder是MathWorks自家的代码生成工具,理论上你只要写一个支持Coder的子函数,输入codegen命令,就能拿到C代码。
这个路线的优点是省心,你从eig到C代码几乎不用改算法,生成的代码和MATLAB行为高度一致。而且如果代码里只是eig这种基础函数,Coder 是支持直接转换的。
缺点也明显:生成的代码可读性极差,动辄几千行中间变量,维护困难;代码里会有一堆运行时支持和内部状态结构体,你不一定愿意把它合入已有的工程风格;另外Coder对输入尺寸的定义有严格限制,变长数组的支持在旧版本里很痛苦。
我自己的实践结论是,如果项目紧、任务急,而且MATLAB代码里没有太多Coder不支持的高级语法,这条路可以快速交差。但如果你想得到一份“正常人看得懂、后面能维护”的C代码,我建议往下看路线二和三。
2.2 路线二:直接对接 LAPACK
LAPACK(Linear Algebra PACKage)是数值计算领域的事实标准,MATLAB自身底层就是用它做矩阵分解的(具体可能是Intel MKL、OpenBLAS等BLAS实现)。所以从理论上讲,你调LAPACK得到的结果和MATLAB是最接近的。
- 实矩阵:
dgeev,返回实特征值数组和虚特征值数组。 - 对称矩阵:
dsyev,专门优化,速度快。 - 复矩阵:
zgeev或zheev。
优点是精度顶级、可控性强、源码透明、任何编译器都能链接;缺点是你得自己管理内存、理解Fortran接口的列优先存储,并且处理一些反直觉的工作区参数。其实这些能接受,LAPACK的接口并不复杂,真正坑人的是存储顺序和链接方式。
2.3 路线三:用 C++ 的 Eigen 库
Eigen是一个纯头文件的C++模板库,它不需要编译库文件,直接include就能用。Eigen里的EigenSolver类对应非对称矩阵特征值分解,SelfAdjointEigenSolver对应对称矩阵,用法非常直观。
Eigen::EigenSolver<Eigen::MatrixXd> es(A); es.eigenvalues(); // 复数向量 es.eigenvectors(); // 复数矩阵如果你的工程本来就用C++,Eigen是我目前最推荐的路线。理由有三个:代码写起来像MATLAB一样简洁清晰,性能经过大量优化不输LAPACK,而且文档和社区资料非常丰富。唯一的问题是项目必须从纯C变成C++编译,如果你们团队的代码规范锁死了纯C,那只能绕道。
2.4 路线四:手写特征值分解,我为什么不建议
网上确实有人分享过“从零手写QR算法求特征值”的文章,看起来特别硬核。但我不建议你在工程交付里这么干。原因不是手写不行,而是数值稳定性太难保证。一个看起来正确的QR迭代,在矩阵有重特征值或接近病态时,收敛速度和精度都会出问题,而这些边界情况正是工程师最怕的暗坑。除非你是数值计算专业出身、并且有充足的时间去啃《Matrix Computations》,否则别在项目里挑战这件事。调库不是耻辱,是专业素养。
2.5 选型对照表
给你们整理一张表,方便在方案评审时直接拍板:
| 路线 | 开发语言 | 精度 | 集成难度 | 可读性 | 适用场景 |
|---|---|---|---|---|---|
| MATLAB Coder | C/C++ | 高 | 低 | 差 | 快速交付原型 |
| LAPACK dgeev | C调用Fortran | 极高 | 中 | 中 | 要求精度与标准库 |
| Eigen EigenSolver | C++ | 高 | 低 | 好 | C++工程、经典推荐 |
| 手写QR | C | 不确定 | 高 | 高 | 学习研究为主 |
3. eig 的关键原理,以及转换成 C 函数的接口设计
3.1 特征值分解到底在算什么
数学上的定义大家都懂:对矩阵A,如果有非零向量v和标量λ满足A v = λ v,那么λ是特征值,v是对应特征向量。写成矩阵形式就是A V = V D,其中D是对角矩阵(非对称情况下可能是分块对角)。
但工程上真正困难的是怎么稳定地算出来。LAPACK和Eigen用于非对称矩阵的核心流程基本都是这个套路:
- 化为Hessenberg矩阵:通过Householder变换把一般矩阵逐步约化成上Hessenberg形式,也就是只有次对角线以下全为0的准上三角矩阵。
- QR迭代(带移位):在Hessenberg矩阵上反复做QR分解和相似变换,让它逐渐收敛到实Schur形式。
- 求特征值:实Schur形式下,对角线上1×1块就是实特征值,2×2块对应一对共轭复特征值。
- 回代求特征向量:如果需要特征向量输出,再通过反幂法或回代解上三角系统得到。
这一套流程用一句话概括就是:特征值分解的本质是把矩阵变成“几乎对角”的形式,而从Schur形式提取特征值和特征向量是相对简单的一件事。理解了这一步,你就能明白为什么转C语言后,特征向量的列并不是简单地对应对角线元素,因为复特征值对应的2×2块会让特征向量变成复数向量,输出格式必须处理成实部/虚部两套。
3.2 C 函数接口怎么设计
一个可用的C函数至少应该做到和MATLAB的[V,D] = eig(A)等价。如果矩阵是实非对称的,MATLAB返回的特征向量矩阵V是复数矩阵,D是复数对角矩阵。但在嵌入式场景里,我们通常更愿意用分离实部虚部的方式输出,避免在系统中引入complex.h的复数类型。
我常用的接口设计长这样:
// 对实矩阵 A (n*n, 列优先存储) 做特征分解 // 输入: // n : 矩阵阶数 // A : n*n 的列优先数组,会被内部拷贝,不修改 // 输出: // wr, wi : 长度为 n 的实数组,特征值的实部和虚部 // Vr, Vi : n*n 的实数组,特征向量矩阵的实部和虚部 // 返回: // 0 成功 // -1 参数无效 // -2 数值分解失败 int my_eig(int n, const double* A, double* wr, double* wi, double* Vr, double* Vi);如果你只需要特征值而不需要向量,可以传NULL给Vr/Vi,然后在内部选择更省内存的路径。强烈建议你在接口层就把“原始数据不修改”这件事定死,因为无论是LAPACK还是Eigen,内部都可能修改输入的A副本,如果调用方发现传入的矩阵被改掉了,很容易引起隐蔽的bug。
3.3 不得不注意的复数与存储顺序
LAPACK使用列优先存储,意思是矩阵元素A(i,j)存在A[j * lda + i]的位置上。MATLAB也是列优先。但很多C/C++工程默认行优先,Eigen默认也是列优先的,不过Eigen支持Eigen::RowMajor的矩阵类型。
我在转换时最常犯的错就是:直接从MATLAB导出一份按行读出的数组,填到按列优先解释的LAPACK函数里。那结果简直像把魔方每个面的颜色都打乱后再拼,怎么都对不上。解决方案很简单:列优先、列优先、列优先。如果项目里其他地方用了行优先,要么统一转一下,要么用一个Transpose视图让它转置着看。
复数方面要看情况。如果你使用C99的double complex,LAPACK的zgeev可以直接接受double complex*数组。Eigen则有自己的std::complex<double>版本。但如果你的嵌入式编译器对复数支持不好,那就老老实实用double*存实部虚部,最后通过接口封装成复数视图即可。
4. 实操:以 Eigen 和 LAPACK 各走一遍
4.1 环境准备
我以Ubuntu环境为例,几步就能把依赖准备齐。
# Eigen 直接用apt装 sudo apt install libeigen3-dev # LAPACK 开发库 sudo apt install liblapack-dev libblas-dev # 编译工具 sudo apt install build-essential cmake如果你在Windows上用Visual Studio,Eigen也是直接include头文件,不需要安装任何二进制。LAPACK则建议直接用vcpkg安装:
vcpkg install lapack4.2 EigenSolver 示例
假设矩阵是:
A = [4, -2; -1, 1]先用Eigen转换。完整代码我贴在这里,注释写到能直接抄的程度:
#include <Eigen/Dense> #include <iostream> void print_eigen_info(const Eigen::MatrixXd& A) { // 非对称矩阵用 EigenSolver Eigen::EigenSolver<Eigen::MatrixXd> es(A); // 特征值,复数向量 Eigen::VectorXcd eigenvalues = es.eigenvalues(); // 特征向量,矩阵的每一列对应一个特征向量 Eigen::MatrixXcd eigenvectors = es.eigenvectors(); std::cout << "Eigenvalues:\n" << eigenvalues << std::endl; std::cout << "Eigenvectors:\n" << eigenvectors << std::endl; // 验证 A * v = lambda * v Eigen::VectorXcd v1 = eigenvectors.col(0); double lambda_real = eigenvalues(0).real(); double lambda_imag = eigenvalues(0).imag(); Eigen::VectorXcd Av1 = A * v1; std::cout << "Residual check: \n" << (Av1 - eigenvalues(0) * v1).norm() << std::endl; } int main() { Eigen::MatrixXd A(2, 2); A << 4.0, -2.0, -1.0, 1.0; print_eigen_info(A); return 0; }编译命令:
g++ -O2 -I/usr/include/eigen3 -o test_eigen test_eigen.cpp运行后你会发现Eigen输出的特征值顺序和MATLAB不完全一样,但数值基本一致。2×2矩阵在这个例子里特征值是3和2,MATLAB会按从小到大排成2、3,Eigen不保证排序。这是正常现象,后面我会专门讲排序对齐的问题。
4.3 LAPACK dgeev 示例
用LAPACK写同样的例子会多一些工作量,因为它是Fortran接口。代码写起来长,但每一步都透明可控。
#include <stdio.h> #include <stdlib.h> #include <string.h> // LAPACK dgeev 的 Fortran 符号 extern void dgeev_(char* jobvl, char* jobvr, int* n, double* A, int* lda, double* wr, double* wi, double* vl, int* ldvl, double* vr, int* ldvr, double* work, int* lwork, int* info); void lapack_eig(int n, const double* A, double* wr, double* wi, double* vr) { // 将输入拷贝一份,因为 dgeev 会破坏原矩阵 double* Acopy = (double*)malloc(n * n * sizeof(double)); memcpy(Acopy, A, n * n * sizeof(double)); char jobvl = 'N'; // 不需要左特征向量 char jobvr = vr ? 'V' : 'N'; int lda = n, ldvl = 1, ldvr = n; int info = 0; // 工作区查询 double work_query; int lwork = -1; dgeev_(&jobvl, &jobvr, &n, Acopy, &lda, wr, wi, NULL, &ldvl, vr, &ldvr, &work_query, &lwork, &info); lwork = (int)work_query; double* work = (double*)malloc(lwork * sizeof(double)); dgeev_(&jobvl, &jobvr, &n, Acopy, &lda, wr, wi, NULL, &ldvl, vr, &ldvr, work, &lwork, &info); if (info != 0) { fprintf(stderr, "dgeev failed, info=%d\n", info); } free(Acopy); free(work); } int main() { // 列优先存储,A = [4, -1; -2, 1] 这其实是转置放在数组里,见下文说明 // 数据按列填: A(0,0)=4, A(1,0)=-2, A(0,1)=-1, A(1,1)=1 double A[4] = {4.0, -2.0, -1.0, 1.0}; int n = 2; double wr[2], wi[2], vr[4]; lapack_eig(n, A, wr, wi, vr); for (int i = 0; i < n; i++) { printf("lambda_%d = %.6f + %.6fj\n", i, wr[i], wi[i]); } return 0; }这里有个容易混淆的点:我在数组里填的是{4.0, -2.0, -1.0, 1.0},按列优先解释就是A(0,0)=4, A(1,0)=-2, A(0,1)=-1, A(1,1)=1,这其实是MATLAB示例里的转置。因为LAPACK列优先,所以你在C里初始化时得想想你按什么逻辑填数的。上面这个写法实际上求的是原矩阵的转置的特征值,而转置不改变特征值,所以结果仍然是3和2。
编译链接LAPACK的命令:
gcc -O2 -o test_lapack test_lapack.c -llapack -lblas如果忘了-lblas,链接阶段一般会报一堆未定义符号。
4.4 与 MATLAB 结果对拍
拿到C程序的结果后,怎么确认你没算错?
我建议直接写一个MATLAB脚本做对拍,输出到文件再对比:
A = [4 -2; -1 1]; [V, D] = eig(A); % 打印特征值实部虚部 for i = 1:size(D,1) fprintf('lambda_%d_real=%.15f lambda_%d_imag=%.15f\n', ... i, real(D(i,i)), i, imag(D(i,i))); end然后写一个简单的脚本比较C程序输出,误差在1e-10以内基本可以放心。
不要只看特征值对得上就结束,特征向量也要对拍。怎么对特征向量?特征向量有一个自由度,每一列可以乘以任意非零常数。所以不能用逐元素差来比较,一个实用的做法是:把C输出的第i列向量和MATLAB输出的第i列向量做归一化,然后看它们的归一化点积绝对值是否为1。如果等于1,说明方向一致。
vc = load('c_eigvec.txt'); % C输出的特征向量 vm = V(:,1); scale = dot(vc, vm) / dot(vm, vm); err = norm(vc - scale * vm, inf); fprintf('vec error = %.3e\n', err);这一步很关键,很多项目就是栽在“特征值对了,但对拍时候发现特征向量符号反了”这类问题上。
5. 那些让我踩过坑的常见问题
5.1 特征值顺序乱
这是转C之后第一个遇到的“玄学问题”。MATLAB的eig输出特征值,对称矩阵会升序排列,非对称矩阵不保证顺序但通常是计算出来的自然顺序;LAPACKdgeev返回的特征值顺序和Schur分解中出现的顺序一致,Eigen则完全按内部迭代顺序来。
所以你需要一个统一的排序策略。我常用的做法是这样:
- 按特征值实部升序排列。
- 实部相同则按虚部升序排列。
- 排序的时候,特征向量矩阵的列也要跟着一起交换。
自己写个不算豪华的排序函数就行,可以用稳定的归并排序或冒泡。注意,复数特征值一般是成对共轭出现的,排序时不要把a+bi和a-bi拆散到相隔很远的地方,虽然数学上没关系,但后处理时容易看晕。
5.2 复数特征值让人抓狂
非对称实矩阵的特征值可能是复数。dgeev对每个特征值返回wr[i]和wi[i],如果wi[i] != 0,那么wr[i+1]=wr[i]且wi[i+1]=-wi[i],这一对就是共轭复数。Eigen的eigenvalues()向量则直接返回复数类型。
转换到C接口时,我建议不要试图强行把复数塞进一个double数组里,而是用两个数组wr/wi分开存。好处是后续再封装成复数时,语义非常清晰。之前我在一个项目里图方便,把复数特征值拼成一个double[2*n],结果下游逻辑在判断“是不是实数特征值”时,要用wi == 0.0来判,浮点误差导致某些本该是实数的特征值被判成复数,折腾了很久。后来改成显式的wr/wi数组,再配合一个if (fabs(wi[i]) < 1e-12)的容差判断,问题就没了。
5.3 收敛失败和 NaN
dgeev返回info > 0时表示QR迭代没有收敛。这种情况一般出现在矩阵非常病态或者规模极大时。Eigen内部也有一个info()方法可以判断求解器是否成功。
处理办法有三个方向:
- 检查输入数据是否包含NaN或Inf,这些垃圾数据几乎必然导致分解失败。
- 改用对称矩阵专用求解器,对称矩阵特征值问题在数值上比非对称稳定得多。
- 考虑对矩阵做预处理,比如平移(shift)到某个更优的区域,再对特征值做逆变换。
提一句,如果你的矩阵规模特别大(上千阶),建议先考虑矩阵是否稀疏,然后换用eigs(ARPACK)那类迭代法思路,而不是直接硬上稠密矩阵的dgeev。全稠密的特征分解复杂度是O(n^3),几千阶已经要算很久了。
5.4 性能优化从哪下手
如果对拍结束后出现性能瓶颈,通常按下面的顺序查:
- 是不是每次都重新分配了工作区内存?LAPACK的
lwork查询和分配可以复用,Eigen的求解器对象也可以复用。 - 是否启用了编译优化?
-O2只算基础,-O3+-march=native在某些CPU上能显著加速。 - 是否是多线程环境?链接OpenBLAS/MKL后,LAPACK底层BLAS能自动多线程;Eigen的发行版默认没有打开OpenMP,如果要打开,需要编译时加
-fopenmp,并在代码里启动Eigen的并行支持。 - 如果矩阵是对称的,一定不要用非对称求解器,换
SelfAdjointEigenSolver能提速一大截。 - 如果你只需要特征值,不要请求特征向量,省掉回代这一步,性能差异可以到两倍以上。
5.5 矩阵规模对算法选择的影响
最后给一个非常实际的经验参考。小矩阵(比如4阶、6阶的Robotics Jacobian),直接完整QR迭代也没问题;中等矩阵(几十到几百阶),LAPACKdgeev或Eigen的EigenSolver都很稳;到了上千阶的有限元模型特征分析,你需要的其实是dsyevr或者ARPACK这类只求部分特征值的算法。转换前先问清楚:你只需要最小的几个特征值来判断稳定性?那就别全算,局部特征值求解会快太多。
结尾
写到这里,我回想自己第一次把MATLAB里的eig转到C语言,最大的教训就是:别急着写代码,先搞清楚矩阵是什么性质、你的下游到底需要什么格式的输出、以及你愿意为这份代码引入多少外部依赖。选Eigen还是LAPACK不重要,重要的是你能不能让算法在不同环境里稳定复现MATLAB的行为。我现在做这个转换的标准流程是:先写MATLAB自动化测试脚本,把各种矩阵(对称、非对称、复数、病态、带重特征值)的参考结果全部导出,然后再动C代码,每改一步就拿C程序跑一遍对拍,直到所有用例的残差都过阈值。最后再分享一个小技巧:所有从MATLAB导出的测试矩阵,都用A = (A + A.')/2或者直接随机生成可复现的种子矩阵,这样你在不同平台之间切换时也不会因为“同样的算法、不同的输入”而白白消耗时间排查。