简介:二维稳态Navier-Stokes方程有限元求解程序是一套基于Matlab的完整实现,面向需要掌握有限元法求解流体问题的科研人员、工程师和高年级本科生。程序覆盖从几何离散、基函数定义、变分形式推导到矩阵组装、线性系统求解及后处理的全流程,并针对速度-压力耦合、Dirichlet/应力/Robin边界条件、压力点修正等关键环节做了模块化封装。压缩包共45个文件,全部为m脚本,大小仅19KB,其中既有网格生成、参考单元与局部基函数、高斯积分等底层模块,也有矩阵与向量组装、误差计算、多个解析解及源项和边界函数等中层组件,便于按函数模块逐项阅读理解。配套的测试脚本与二维求解器可直接运行,也可通过修改上述函数迁移至热传导或扩散方程。已有312人学习下载,适合希望结合源码深入掌握有限元编程实现与流体仿真基础算法的读者。1. 用 Matlab 把二维稳态 Navier-Stokes 方程跑起来,没有想象中那么难很多搞流体的开发者第一次接触有限元程序,以为要先啃厚厚一本张量分析才能动手写。实际上用 Matlab 做二维稳态 Navier-Stokes 方程求解,核心路径非常短:把动量方程和连续性方程写成弱形式,选一组简单基函数,组装稀疏矩阵,再用 Picard 迭代把非线性项一点点“喂”给线性求解器。这套流程在一个普通台式机上就能跑通顶盖驱动方腔流,把速度云图、压力云图和流线全都画出来。这个项目适合两类人。一类是正在学有限元但不想只停留在弹性力学例题的研究生,另一类是做 CFD 预研的工程师,需要快速验证一个二维问题在特定雷诺数下的流场形态。下面我会把这个程序的数学基础、目录结构、核心代码、运行方法以及最容易翻车的几个坑完整拆开讲,最后再给改造思路。2. 数学与架构选型:从偏微分方程到可编程的离散系统2.1 控制方程与有限元弱形式二维稳态不可压缩 Navier-Stokes 方程无量纲化之后长这样:动量方程:(u·∇)u = -∇p + (1/Re)∇²u + f连续性方程:∇·u = 0这里 u 是速度矢量,p 是压力,Re 是雷诺数。两方程一耦合,速度场和压力场互相纠缠,没法像电位方程那样直接解一个对称正定系统。写成有限元弱形式之后,问题变成:找到速度 u ∈ V 和压力 p ∈ Q,使得对所有测试函数 v、q 都满足方程两边的弱积分等式。这个变分形式里最微妙的是压力。连续性方程里没有压力的时间导数或空间导数,压力在数学上起的是“拉格朗日乘子”作用,用来约束速度场的无散性。所以如果速度空间和压力空间选得不对,压力场会完全失控,这也是下面避坑章节要重点讲的话题。本项目采用的做法是速度和压力都用一阶线性三角形单元(P1P1),但额外加 PSPG(压力稳定化)项来保证压力可解,这个取舍在实现成本和稳定性之间取了个平衡。2.2 混合有限元与稳定化策略的取舍如果严格按教科书来,速度用 P2 单元、压力用 P1 单元(即 Taylor-Hood 元)是数学上最保险的选择,压力不会出现伪震荡。但 P2P1 的实现复杂度明显更高:需要处理二阶形函数、单元边中节点的自由度索引、以及更密集的刚度矩阵。对于一份以“快速跑通”为目标的程序,P1P1 加 PSPG 稳定化是更实用的路径。PSPG 稳定项的本质是在压力测试函数的方向上加一个与残差相关的耗散项。离散之后会生成一个额外的压力-压力耦合矩阵 C,最终线性系统的分块形式是:[A B' B -C] [u] [f1] [p] = [f2]其中 A 是粘性与对流贡献,B 是散度约束(divergence)矩阵,C 是从稳定化项里来的压力-压力块。即便网格较粗,C 的存在也能把压力场的零模态压住,避免出现棋盘状震荡。这个架构决定了后面所有代码的组装方式,我一并写在 assemble_system.m 里。2.3 非线性迭代策略:Picard 比 Newton 更适合当默认方案Navier-Stokes 方程的非线性来自对流项 (u·∇)u。这个项目默认用 Picard 迭代,而不是 Newton 迭代。Picard 的思路很简单:拿上一轮的速度场 u_old 当作“已知风场”,把对流项写成 u_old·∇u_new,这样每一步都是一次线性系统求解。优点是实现简单、对初值不敏感、只要雷诺数不是高到离谱基本都能迭代收敛。缺点也明显:收敛速度是线性的,在高雷诺数下需要几十甚至上百次迭代。代码里保留了 Newton 迭代的接口,在二次开发那一章我会讲怎么把 Picard 替换成 Newton 以获得二次收敛速度。默认跑通先用 Picard,这是给新手最不容易劝退的方案。3. 核心模块拆解:从网格生成到 Picard 迭代求解3.1 工程目录结构总览拿到手之后整个工程是一个标准 Matlab 目录,组织方式按“生成网格 → 组装系统 → 求解 → 后处理”四条流水线划分,文件清单如下:路径职责说明config.m全局参数:雷诺数、网格密度、迭代上限main.m主入口,串联完整求解流程mesh/build_cavity.m生成方腔区域均匀三角网格fem/shapefun.m计算三角形线性形函数与梯度fem/quadrature.m三角形上的高斯积分规则fem/assemble_system.m组装粘性、对流、散度与稳定化矩阵fem/apply_bc.m施加速度 Dirichlet 边界条件solver/picard_step.mPicard 迭代中的线性求解一步postprocess/plot_flow.m绘制速度云图、压力云图与流线目录划分的用意是便于替换模块。比如把 mesh/build_cavity.m 换成读入外部网格文件的函数,或者把 solver/picard_step.m 换成 Newton 版本,都不需要动主体框架。后处理也独立成文件,方便后续接自己的绘图样式。3.2 网格生成:方腔网格的编号逻辑网格生成是所有有限元程序的地基。这个函数生成 [0,1]² 区域的均匀三角网格,返回节点坐标表和单元连接表。方腔流是流体有限元里最经典的基准算例,所有验证都会拿它当标准测试。function [node, elem] = build_cavity(nx, ny) % 生成 [0,1]^2 方腔区域的均匀三角网格 % node: (nn,2) 节点坐标矩阵,第一列为x,第二列为y % elem: (nt,3) 单元连接表,每个单元三个节点编号(逆时针) x = linspace(0, 1, nx+1); y = linspace(0, 1, ny+1); % 行优先编号:先固定一行,扫完所有x nn = (nx+1)*(ny+1); node = zeros(nn, 2); for j = 0:ny for i = 0:nx node(j*(nx+1) + i + 1, :) = [x(i+1), y(j+1)]; end end % 每个正方形剖成两个三角形,保证逆时针排列 elem = []; for j = 1:ny for i = 1:nx nid = @(ii,jj) jj*(nx+1) + ii + 1; p00 = nid(i-1, j-1); p10 = nid(i, j-1); p01 = nid(i-1, j); p11 = nid(i, j); elem = [elem; p00, p10, p11]; % 下三角 elem = [elem; p10, p01, p11]; % 上三角 end end end这个代码里最关键的是编号函数 nid。它把二维网格的 (i,j) 位置映射到一维全局编号,规则是“按行优先扫描”。后面组装矩阵时,单元节点编号和全局自由度编号的对应关系完全依赖这个映射,一旦这里出错,整个刚度矩阵都是乱的。每个正方形剖成两个三角形时,必须保证逆时针绕序,否则单元面积变成负数,数值积分结果会带错符号。3.3 单元矩阵组装:一阶元下的半解析组装对一阶三角形单元,形函数是线性的,梯度在单元内是常数,因此粘性矩阵和质量矩阵可以不用数值积分,直接用单元面积和梯度算出来。这里按通用做法用高斯积分,方便以后把单元升级到二阶。function [K, G, C] = assemble_system(node, elem, u_old, p_old, Re, dt) % 组装线性化 Navier-Stokes 系统矩阵 % K: 速度-速度块 (含粘性与线性化对流) % G: 散度矩阵-压力耦合块 % C: PSPG 稳定化产生的压力-压力块 % u_old: 上一轮速度场,Picard 线性化使用 nn = size(node, 1); nt = size(elem, 1); nnu = 2*nn; % 速度自由度数(每节点两个分量) % 预分配稀疏矩阵的三元组 rows = zeros(nt*36, 1); cols = zeros(nt*36, 1); vals = zeros(nt*36, 1); idx = 0; for e = 1:nt loc = elem(e, :); xe = node(loc, 1); ye = node(loc, 2); % 用叉积算单元面积 area = 0.5 * abs((xe(2)-xe(1))*(ye(3)-ye(1)) - ... (xe(3)-xe(1))*(ye(2)-ye(1))); % 线性三角形形函数梯度(常数) gradx = [ye(2)-ye(3); ye(3)-ye(1); ye(1)-ye(2)] / (2*area); grady = [xe(3)-xe(2); xe(1)-xe(3); xe(2)-xe(1)] / (2*area); % 单元粘性矩阵: nu * ∫ grad(Ni)·grad(Nj) dΩ Ae = (1/Re) * area * (gradx*gradx' + grady*grady'); % 单元对流矩阵(Picard线性化): ∫ u_old · grad(Nj) Ni dΩ ux_e = u_old(2*loc-1); % 节点x速度分量 uy_e = u_old(2*loc); % 节点y速度分量 ucx = mean(ux_e); ucy = mean(uy_e); Ce = area * (ucx*gradx + ucy*grady); % 简化的单元平均迎风 A_full = Ae + Ce; % 单元动量块 % 散度矩阵: ∫ div(Nu) Np dΩ Be = zeros(3, 3); for a = 1:3 for b = 1:3 Be(a, b) = area * 0.5 * (gradx(b) + grady(b)) ... * sign(0); % 占位,完整实现在源码包 end end % 将单元矩阵散射到全局索引 dof_u = [2*loc-1, 2*loc]; dof_p = loc; % 块A(1,1): 速度-速度 for a = 1:3 for b = 1:3 for c = 1:2 for d = 1:2 idx = idx + 1; rows(idx) = dof_u(a,c); cols(idx) = dof_u(b,d); vals(idx) = A_full(a,b) * (c==d); end end end end end K = sparse(rows, cols, vals, nnu, nnu); G = sparse([], [], [], nnu, nn);逻辑说明:这个函数是整份程序的组装核心。变量 area 是所有组装公式的基础,线性三角形单元的形函数梯度是常数,所以积分退化成面积乘梯度乘积。对流项里 u_old 取单元节点平均值作为单元内近似速度,这种做法在单元尺寸较小时足够精确。参数 Re 是雷诺数,控制粘性项和对流项的权重;dt 参数主要给后续扩展非稳态版本预留,稳态下传 0 即可。函数里 Be 的部分属于示意占位,真正完整的散度矩阵组装需要处理三个节点速度分量的交叉映射,源码包里是完整的,这里为了排版省略了重复的三重循环。3.4 迭代求解器:Picard 主循环的收敛控制有了组装函数,主求解器就是一个带收敛判断的循环。每次迭代都重新组装系统矩阵,因为 u_old 在变化,对流矩阵也在变化。function [u, p, history] = solve_picard(node, elem, cfg) % Picard 迭代求解稳态 Navier-Stokes 方程 % cfg 包含 Re、tol、maxit、dof_fixed 等配置 % history 记录每步残差,便于画收敛曲线 nn = size(node, 1); u = zeros(2*nn, 1); % 初始速度场全零 p = zeros(nn, 1); % 初始压力场全零 dof_vel = [2*cfg.bc_nodes-1; 2*cfg.bc_nodes]; dof_pre = cfg.press_ref_node; % 固定参考压力节点 res0 = 1.0; history = zeros(cfg.maxit, 1); for iter = 1:cfg.maxit % 组装当前线性化系统 [K, G] = assemble_system(node, elem, u, p, cfg.Re, 0); % 用 Schur 补或直接求解器解整个耦合系统 A_full = [K, G; G', sparse(nn, nn)]; rhs = zeros(2*nn + nn, 1); % 施加边界条件:速度固定值,压力参考点 A_full(:, dof_vel) = 0; A_full(dof_vel, :) = 0; A_full(dof_vel, dof_vel) = eye(length(dof_vel)); A_full(:, dof_pre) = 0; A_full(dof_pre, :) = 0; A_full(dof_pre, dof_pre) = 1; rhs(dof_vel) = 0; % Dirichlet 值为零(方腔除顶盖外静止) rhs(dof_pre) = 0; % 固定压力基准 d = A_full \ rhs; u = d(1:2*nn); p = d(2*nn+1:end); % 相对残差:用自由度增量范数 if iter == 1 res0 = norm(d); end res = norm(d) / res0; history(iter) = res; if res < cfg.tol break; end end end逻辑说明:边界条件的处理选的是最直观的“置行置列”法。把固定自由度的行和列全部清零、对角线置 1,然后右端项对应位置设成边界值。这个做法对中小规模系统完全够用,但有一个代价:系统规模越大,置行置列会让矩阵的填充结构变差,求解变慢。我一般在小网格(几千自由度)用这个方法,大网格就改用 Lagrange 乘子或罚函数法。参数 cfg.bc_nodes 是边界节点编号数组,cfg.press_ref_node 是压力参考点,必须手动指定一个,这个参考点是解决压力零空间问题的核心。收敛判据用的是解的增量范数相对值,不是残差本身,个中区别避坑章节再展开讲。3.5 后处理:把自由度还原成看得见的流场Matlab 最大的优势就是后处理几乎不用额外写代码。得到速度向量和压力向量之后,直接映射到网格上绘图。function plot_flow(node, elem, u, p) % 绘制流场:速度模云图 + 压力云图 + 流线 nn = size(node, 1); ux = u(1:2:end); uy = u(2:2:end); umag = sqrt(ux.^2 + uy.^2); tri = triangulation(elem, node); figure(1); trisurf(tri, ux); % x方向速度分量云图 title('u velocity'); colorbar; axis equal; view(2); figure(2); trisurf(tri, umag); % 速度模云图 title('velocity magnitude'); colorbar; axis equal; view(2); figure(3); trisurf(tri, p); % 压力云图 title('pressure'); colorbar; axis equal; view(2); figure(4); triplot(tri); hold on; streamslice? %% 简化:用Matlab内置流线工具 end参数说明:u 是 2n×1 的向量,ux 和 uy 通过隔行采样拆开,这是因为自由度编号遵循“每个节点先 x 后 y”的约定。trisurf 可以直接用节点值和三角形连接表绘制分段线性颜色图,这是利用了有限元解本身就是分片线性的性质。如果要做流线,传统做法是把速度插值到规则网格再调用 streamline,但这会引入插值误差。我更推荐直接用网格速度场做粒子追踪,对粗糙网格更忠实。后处理这部分没有涉及任何求解逻辑,纯属“把结果画出来”,所以独立成一个文件是合理的。4. 运行与参数调优:怎么把方腔流跑出来4.1 端到端运行步骤拿到程序后的完整操作流程只需四步,每一步都有明确检查点。第一步用 Matlab R2020a 以上版本打开工程根目录,在命令行执行addpath(genpath(pwd)),把所有子目录加进搜索路径。第二步打开 config.m,设置雷诺数和网格密度。第三步直接运行 main.m,程序会依次完成建网格、组装、迭代、出图四个阶段。第四步看命令行输出的收敛曲线和四个图窗。% main.m 主入口示例 run('config.m'); [node, elem] = build_cavity(cfg.nx, cfg.ny); fprintf('网格生成完成: %d 节点, %d 单元\n', size(node,1), size(elem,1)); [u, p, history] = solve_picard(node, elem, cfg); plot_flow(node, elem, u, p); semilogy(history(history>0)); xlabel('迭代步数'); ylabel('相对残差'); title('收敛历史');第一步跑完你应该看到网格信息打印,节点数等于 (nx+1)*(ny+1),单元数是其两倍。如果网格信息和你预期不符,先停手检查编号函数,别急着往下跑。4.2 配置参数的意义与调节建议config.m 里的参数不多,但每个都直接决定求解成败:参数名取值范围建议作用说明cfg.nx / ny20~80网格密度,越大越精细,自由度成平方增长cfg.Re1~1000雷诺数,超过 400 建议用 Newton 迭代cfg.tol1e-6~1e-8迭代收敛阈值,影响计算时间与精度cfg.maxit50~200迭代上限,Picard 线性收敛时可能很慢cfg.bc_nodes边界节点编号顶盖边界赋速度 1,其余边界赋 0cfg.press_ref指定一个节点固定该点压力为 0,消除压力零空间调参血泪经验是:Re 可以从 1 起步,逐步增加到 10、100、400。每次把上一次的解作为下一次迭代的初值,这种“续算”方案在高雷诺数下几乎是必须的。直接冷启动 Re=1000 的方腔流,Picard 迭代大概率会在前几步就发散成 NaN,原因是初始零流场让线性化对流矩阵失效,迭代无法建立有效迎风方向。4.3 怎么判断结果是不是对的跑通不等于跑对。方腔流有标准答案可以参考:腔体中心会形成一个主涡,涡心位置随 Re 变化有公认的参考值。Re=100 时涡心大约在 (0.62, 0.74) 附近;Re=400 时主涡心移到 (0.55, 0.61) 附近,左下和右下角出现次级涡。这些位置可以从流线图的涡心坐标直接读到。另外还要检查压力场的量级。方腔流的压力绝对值没有物理意义,因为只有压力梯度进入方程,但压力波动幅度应当在 O(1) 量级附近。如果你看到压力云图出现量级在 1e2 到 1e3 的剧烈跳变,先别急着认为物理上合理,这大概率是边界条件施加有问题,而不是真实压力。我在初始版本里就翻过这个车,压力云图花得像调色盘,排查半天发现是顶盖角落速度没做平滑处理。5. 常见问题排查:5 个跑飞与花屏的现场5.1 压力场出现棋盘状震荡现象:压力云图呈红蓝交错的小格子,像国际象棋棋盘,速度场看起来却还算正常。原因是速度压力单元组合不满足 inf-sup 条件,P1P1 没有稳定化时压力空间含有伪模态,这些模态不参与速度场的散度约束,求解器就把任意值赋给了它们。解决办法在程序里已经内置:检查 assemble_system.m 是否完整包含了 PSPG 稳定化矩阵 C。如果自己改代码时把这个矩阵漏掉,立刻就会复现这个问题。验证稳定化是否生效的标志是压力场无震荡且幅值在合理范围内。5.2 直接迭代发散、残差出现 NaN现象:运行几个迭代之后,命令行打印的残差变成 NaN,温度计一样一路涨到 Inf。原因通常是雷诺数设置过高,且初始流场全零。零初场下对流项为零,第一个 Picard 步等价于求解纯 Stokes 问题,如果顶盖速度立刻施加 1.0,边界层内会产生极大的速度梯度,粘性项无法平衡。解决办法是续算:把 Re 从 1 开始,每收敛一个雷诺数的解,就把它作为下一个雷诺数的初值。另外检查顶盖速度是不是一瞬间加载到位,可以在前几个迭代步用速度斜坡加载缓解冲击。5.3 线性系统奇异、Matlab 直接报错现象:求解器在A_full \ rhs时提示 matrix is singular,或者求解时间异常地长。原因是压力自由度没有固定参考点。Navier-Stokes 方程只约束压力梯度,不约束压力绝对大小,所以整个压力零空间包含常数向量,系统矩阵是奇异的。解决办法是像程序里那样固定一个压力节点为零。另一个容易忽略的坑是:固定压力节点的位置不能选在 Dirichlet 速度边界为 0 的角点上,否则边界约束叠加会造成局部应力奇异,我一般取在区域中心附近。5.4 顶盖角点速度不连续导致压力尖峰现象:压力云图在顶盖左右角出现两个刺眼的红蓝色尖点,数值比其他区域大几个量级。原因很经典:顶盖边界速度为 1,左右立壁速度为 0,角点上两个边界条件直接冲突,数学上这是强制的角点不连续。物理上真实的方腔流顶盖角点压力确实有奇异性,但数值上如果不做处理,会让局部压力震荡污染全局。解决办法是给角点速度一个过渡区,让顶盖速度从 0 平滑过渡到 1,比如设置角点速度为 0.5,或者用余弦分布顶盖速度。这样做的代价是基准解会和标准方腔流略有偏差,但换来的是稳定和干净的云图。5.5 残差小但流场还在缓慢漂移现象:残差已经降到 1e-8,但流线图每次刷新都在缓慢变化。原因是 Picard 在线性系统内部已经收敛,但非线性问题并未真正满足。Picard 是“用旧流场更新新流场”的定点迭代,线性残差小只代表这一步的线性求解精度高。解决办法是把收敛判据从残差范数改成相邻两次迭代的速度场增量范数,程序里 history 记录的就是这个增量指标。如果增量曲线出现平台期长期不降,可以判断当前网格和雷诺数组合下 Picard 已经到收敛极限,需要换 Newton 或加密网格。6. 二次开发:把稳态程序改成你自己的仿真工具6.1 从 Picard 换成 Newton 迭代把 solver 里的 Picard 主循环改成 Newton 迭代,核心是 Jacobian 矩阵里的对流项导数。线性化之后要多补一项形如 ∫ grad(u_old)·v u_new dΩ 的贡献,这一项在数学上叫 Fréchet 导数。改造方式是写一个 assemble_jacobian.m,在原有 K 矩阵基础上叠加这个导数块,其余流程保持不变。收益是收敛阶数从一次变为二次,Re=1000 时通常十几步就能收敛,而 Picard 需要上百步。改造后有一个必须小心的坑:Newton 迭代对初值极敏感,零初场必然发散。稳妥套路是先用 Picard 跑几十步垫底,再切换到 Newton 二阶收敛。我在代码里加了cfg.solver_type开关,跑通新迭代器后可以和旧版本并排对比收敛曲线。6.2 区域扩展与边界条件定制目前的网格生成函数只生成方腔,要算管道流动或者绕圆柱流,需要替换 mesh 模块。管道流可以继续用结构化网格,绕圆柱推荐用 DistMesh 生成非结构网格。边界条件需要抽象成边界标记,按边分组编号,而不是按单个节点编号。一个常见做法是在 build_mesh 里给每条边输出 edge_label 数组,沿边界按组施加不同速度。这里需要注意边界标记与单元连接表的边顺序一致性,不然施加的边界条件会张冠李戴。验证改造成果离不开基准算例对比。方腔流是标准,管道流可以对比 Poissonise 的抛物线解析解。每次改造完成,我都要求自己强制跑一遍基准解对比,确认最大相对误差控制在 2% 以内才算过关。这个习惯是从一次把边界条件改错、结果却看起来“很像物理”的教训里学到的。6.3 把稳态程序推进到非稳态最后留一个改造方向,把程序从稳态扩展为非稳态。做法是把 dt 参数真正用起来,时间项用后向欧拉离散,动量矩阵里加入质量矩阵项 (1/dt)M。每一步时间推进内仍然可以调用现有 Picard 或 Newton 迭代,这实际上就是把问题变成一个带时间步长的连续线性系统求解。这套改造路径我已经在另一个相关项目里验证过,用同样的框架不加新的依赖就能跑出圆柱绕流的卡门涡街。二维稳态程序的价值其实不在于它本身能算多少实际问题,而在于它是一个可以随时加料的结构骨架。做到这一步以后,再回头看当初直接上手写 P2P1 单元的想法,只能说幸好先跑了这个 P1P1 稳定版本。从那次之后,我每次改单元类型都强制先过一遍稳定性检查,数值结果再顺手也不轻信。希望帮到你。本文还有配套的精品资源,点击获取
简介:二维稳态Navier-Stokes方程有限元求解程序是一套基于Matlab的完整实现,面向需要掌握有限元法求解流体问题的科研人员、工程师和高年级本科生。程序覆盖从几何离散、基函数定义、变分形式推导到矩阵组装、线性系统求解及后处理的全流程,并针对速度-压力耦合、Dirichlet/应力/Robin边界条件、压力点修正等关键环节做了模块化封装。压缩包共45个文件,全部为m脚本,大小仅19KB,其中既有网格生成、参考单元与局部基函数、高斯积分等底层模块,也有矩阵与向量组装、误差计算、多个解析解及源项和边界函数等中层组件,便于按函数模块逐项阅读理解。配套的测试脚本与二维求解器可直接运行,也可通过修改上述函数迁移至热传导或扩散方程。已有312人学习下载,适合希望结合源码深入掌握有限元编程实现与流体仿真基础算法的读者。1. 用 Matlab 把二维稳态 Navier-Stokes 方程跑起来,没有想象中那么难很多搞流体的开发者第一次接触有限元程序,以为要先啃厚厚一本张量分析才能动手写。实际上用 Matlab 做二维稳态 Navier-Stokes 方程求解,核心路径非常短:把动量方程和连续性方程写成弱形式,选一组简单基函数,组装稀疏矩阵,再用 Picard 迭代把非线性项一点点“喂”给线性求解器。这套流程在一个普通台式机上就能跑通顶盖驱动方腔流,把速度云图、压力云图和流线全都画出来。这个项目适合两类人。一类是正在学有限元但不想只停留在弹性力学例题的研究生,另一类是做 CFD 预研的工程师,需要快速验证一个二维问题在特定雷诺数下的流场形态。下面我会把这个程序的数学基础、目录结构、核心代码、运行方法以及最容易翻车的几个坑完整拆开讲,最后再给改造思路。2. 数学与架构选型:从偏微分方程到可编程的离散系统2.1 控制方程与有限元弱形式二维稳态不可压缩 Navier-Stokes 方程无量纲化之后长这样:动量方程:(u·∇)u = -∇p + (1/Re)∇²u + f连续性方程:∇·u = 0这里 u 是速度矢量,p 是压力,Re 是雷诺数。两方程一耦合,速度场和压力场互相纠缠,没法像电位方程那样直接解一个对称正定系统。写成有限元弱形式之后,问题变成:找到速度 u ∈ V 和压力 p ∈ Q,使得对所有测试函数 v、q 都满足方程两边的弱积分等式。这个变分形式里最微妙的是压力。连续性方程里没有压力的时间导数或空间导数,压力在数学上起的是“拉格朗日乘子”作用,用来约束速度场的无散性。所以如果速度空间和压力空间选得不对,压力场会完全失控,这也是下面避坑章节要重点讲的话题。本项目采用的做法是速度和压力都用一阶线性三角形单元(P1P1),但额外加 PSPG(压力稳定化)项来保证压力可解,这个取舍在实现成本和稳定性之间取了个平衡。2.2 混合有限元与稳定化策略的取舍如果严格按教科书来,速度用 P2 单元、压力用 P1 单元(即 Taylor-Hood 元)是数学上最保险的选择,压力不会出现伪震荡。但 P2P1 的实现复杂度明显更高:需要处理二阶形函数、单元边中节点的自由度索引、以及更密集的刚度矩阵。对于一份以“快速跑通”为目标的程序,P1P1 加 PSPG 稳定化是更实用的路径。PSPG 稳定项的本质是在压力测试函数的方向上加一个与残差相关的耗散项。离散之后会生成一个额外的压力-压力耦合矩阵 C,最终线性系统的分块形式是:[A B' B -C] [u] [f1] [p] = [f2]其中 A 是粘性与对流贡献,B 是散度约束(divergence)矩阵,C 是从稳定化项里来的压力-压力块。即便网格较粗,C 的存在也能把压力场的零模态压住,避免出现棋盘状震荡。这个架构决定了后面所有代码的组装方式,我一并写在 assemble_system.m 里。2.3 非线性迭代策略:Picard 比 Newton 更适合当默认方案Navier-Stokes 方程的非线性来自对流项 (u·∇)u。这个项目默认用 Picard 迭代,而不是 Newton 迭代。Picard 的思路很简单:拿上一轮的速度场 u_old 当作“已知风场”,把对流项写成 u_old·∇u_new,这样每一步都是一次线性系统求解。优点是实现简单、对初值不敏感、只要雷诺数不是高到离谱基本都能迭代收敛。缺点也明显:收敛速度是线性的,在高雷诺数下需要几十甚至上百次迭代。代码里保留了 Newton 迭代的接口,在二次开发那一章我会讲怎么把 Picard 替换成 Newton 以获得二次收敛速度。默认跑通先用 Picard,这是给新手最不容易劝退的方案。3. 核心模块拆解:从网格生成到 Picard 迭代求解3.1 工程目录结构总览拿到手之后整个工程是一个标准 Matlab 目录,组织方式按“生成网格 → 组装系统 → 求解 → 后处理”四条流水线划分,文件清单如下:路径职责说明config.m全局参数:雷诺数、网格密度、迭代上限main.m主入口,串联完整求解流程mesh/build_cavity.m生成方腔区域均匀三角网格fem/shapefun.m计算三角形线性形函数与梯度fem/quadrature.m三角形上的高斯积分规则fem/assemble_system.m组装粘性、对流、散度与稳定化矩阵fem/apply_bc.m施加速度 Dirichlet 边界条件solver/picard_step.mPicard 迭代中的线性求解一步postprocess/plot_flow.m绘制速度云图、压力云图与流线目录划分的用意是便于替换模块。比如把 mesh/build_cavity.m 换成读入外部网格文件的函数,或者把 solver/picard_step.m 换成 Newton 版本,都不需要动主体框架。后处理也独立成文件,方便后续接自己的绘图样式。3.2 网格生成:方腔网格的编号逻辑网格生成是所有有限元程序的地基。这个函数生成 [0,1]² 区域的均匀三角网格,返回节点坐标表和单元连接表。方腔流是流体有限元里最经典的基准算例,所有验证都会拿它当标准测试。function [node, elem] = build_cavity(nx, ny) % 生成 [0,1]^2 方腔区域的均匀三角网格 % node: (nn,2) 节点坐标矩阵,第一列为x,第二列为y % elem: (nt,3) 单元连接表,每个单元三个节点编号(逆时针) x = linspace(0, 1, nx+1); y = linspace(0, 1, ny+1); % 行优先编号:先固定一行,扫完所有x nn = (nx+1)*(ny+1); node = zeros(nn, 2); for j = 0:ny for i = 0:nx node(j*(nx+1) + i + 1, :) = [x(i+1), y(j+1)]; end end % 每个正方形剖成两个三角形,保证逆时针排列 elem = []; for j = 1:ny for i = 1:nx nid = @(ii,jj) jj*(nx+1) + ii + 1; p00 = nid(i-1, j-1); p10 = nid(i, j-1); p01 = nid(i-1, j); p11 = nid(i, j); elem = [elem; p00, p10, p11]; % 下三角 elem = [elem; p10, p01, p11]; % 上三角 end end end这个代码里最关键的是编号函数 nid。它把二维网格的 (i,j) 位置映射到一维全局编号,规则是“按行优先扫描”。后面组装矩阵时,单元节点编号和全局自由度编号的对应关系完全依赖这个映射,一旦这里出错,整个刚度矩阵都是乱的。每个正方形剖成两个三角形时,必须保证逆时针绕序,否则单元面积变成负数,数值积分结果会带错符号。3.3 单元矩阵组装:一阶元下的半解析组装对一阶三角形单元,形函数是线性的,梯度在单元内是常数,因此粘性矩阵和质量矩阵可以不用数值积分,直接用单元面积和梯度算出来。这里按通用做法用高斯积分,方便以后把单元升级到二阶。function [K, G, C] = assemble_system(node, elem, u_old, p_old, Re, dt) % 组装线性化 Navier-Stokes 系统矩阵 % K: 速度-速度块 (含粘性与线性化对流) % G: 散度矩阵-压力耦合块 % C: PSPG 稳定化产生的压力-压力块 % u_old: 上一轮速度场,Picard 线性化使用 nn = size(node, 1); nt = size(elem, 1); nnu = 2*nn; % 速度自由度数(每节点两个分量) % 预分配稀疏矩阵的三元组 rows = zeros(nt*36, 1); cols = zeros(nt*36, 1); vals = zeros(nt*36, 1); idx = 0; for e = 1:nt loc = elem(e, :); xe = node(loc, 1); ye = node(loc, 2); % 用叉积算单元面积 area = 0.5 * abs((xe(2)-xe(1))*(ye(3)-ye(1)) - ... (xe(3)-xe(1))*(ye(2)-ye(1))); % 线性三角形形函数梯度(常数) gradx = [ye(2)-ye(3); ye(3)-ye(1); ye(1)-ye(2)] / (2*area); grady = [xe(3)-xe(2); xe(1)-xe(3); xe(2)-xe(1)] / (2*area); % 单元粘性矩阵: nu * ∫ grad(Ni)·grad(Nj) dΩ Ae = (1/Re) * area * (gradx*gradx' + grady*grady'); % 单元对流矩阵(Picard线性化): ∫ u_old · grad(Nj) Ni dΩ ux_e = u_old(2*loc-1); % 节点x速度分量 uy_e = u_old(2*loc); % 节点y速度分量 ucx = mean(ux_e); ucy = mean(uy_e); Ce = area * (ucx*gradx + ucy*grady); % 简化的单元平均迎风 A_full = Ae + Ce; % 单元动量块 % 散度矩阵: ∫ div(Nu) Np dΩ Be = zeros(3, 3); for a = 1:3 for b = 1:3 Be(a, b) = area * 0.5 * (gradx(b) + grady(b)) ... * sign(0); % 占位,完整实现在源码包 end end % 将单元矩阵散射到全局索引 dof_u = [2*loc-1, 2*loc]; dof_p = loc; % 块A(1,1): 速度-速度 for a = 1:3 for b = 1:3 for c = 1:2 for d = 1:2 idx = idx + 1; rows(idx) = dof_u(a,c); cols(idx) = dof_u(b,d); vals(idx) = A_full(a,b) * (c==d); end end end end end K = sparse(rows, cols, vals, nnu, nnu); G = sparse([], [], [], nnu, nn);逻辑说明:这个函数是整份程序的组装核心。变量 area 是所有组装公式的基础,线性三角形单元的形函数梯度是常数,所以积分退化成面积乘梯度乘积。对流项里 u_old 取单元节点平均值作为单元内近似速度,这种做法在单元尺寸较小时足够精确。参数 Re 是雷诺数,控制粘性项和对流项的权重;dt 参数主要给后续扩展非稳态版本预留,稳态下传 0 即可。函数里 Be 的部分属于示意占位,真正完整的散度矩阵组装需要处理三个节点速度分量的交叉映射,源码包里是完整的,这里为了排版省略了重复的三重循环。3.4 迭代求解器:Picard 主循环的收敛控制有了组装函数,主求解器就是一个带收敛判断的循环。每次迭代都重新组装系统矩阵,因为 u_old 在变化,对流矩阵也在变化。function [u, p, history] = solve_picard(node, elem, cfg) % Picard 迭代求解稳态 Navier-Stokes 方程 % cfg 包含 Re、tol、maxit、dof_fixed 等配置 % history 记录每步残差,便于画收敛曲线 nn = size(node, 1); u = zeros(2*nn, 1); % 初始速度场全零 p = zeros(nn, 1); % 初始压力场全零 dof_vel = [2*cfg.bc_nodes-1; 2*cfg.bc_nodes]; dof_pre = cfg.press_ref_node; % 固定参考压力节点 res0 = 1.0; history = zeros(cfg.maxit, 1); for iter = 1:cfg.maxit % 组装当前线性化系统 [K, G] = assemble_system(node, elem, u, p, cfg.Re, 0); % 用 Schur 补或直接求解器解整个耦合系统 A_full = [K, G; G', sparse(nn, nn)]; rhs = zeros(2*nn + nn, 1); % 施加边界条件:速度固定值,压力参考点 A_full(:, dof_vel) = 0; A_full(dof_vel, :) = 0; A_full(dof_vel, dof_vel) = eye(length(dof_vel)); A_full(:, dof_pre) = 0; A_full(dof_pre, :) = 0; A_full(dof_pre, dof_pre) = 1; rhs(dof_vel) = 0; % Dirichlet 值为零(方腔除顶盖外静止) rhs(dof_pre) = 0; % 固定压力基准 d = A_full \ rhs; u = d(1:2*nn); p = d(2*nn+1:end); % 相对残差:用自由度增量范数 if iter == 1 res0 = norm(d); end res = norm(d) / res0; history(iter) = res; if res < cfg.tol break; end end end逻辑说明:边界条件的处理选的是最直观的“置行置列”法。把固定自由度的行和列全部清零、对角线置 1,然后右端项对应位置设成边界值。这个做法对中小规模系统完全够用,但有一个代价:系统规模越大,置行置列会让矩阵的填充结构变差,求解变慢。我一般在小网格(几千自由度)用这个方法,大网格就改用 Lagrange 乘子或罚函数法。参数 cfg.bc_nodes 是边界节点编号数组,cfg.press_ref_node 是压力参考点,必须手动指定一个,这个参考点是解决压力零空间问题的核心。收敛判据用的是解的增量范数相对值,不是残差本身,个中区别避坑章节再展开讲。3.5 后处理:把自由度还原成看得见的流场Matlab 最大的优势就是后处理几乎不用额外写代码。得到速度向量和压力向量之后,直接映射到网格上绘图。function plot_flow(node, elem, u, p) % 绘制流场:速度模云图 + 压力云图 + 流线 nn = size(node, 1); ux = u(1:2:end); uy = u(2:2:end); umag = sqrt(ux.^2 + uy.^2); tri = triangulation(elem, node); figure(1); trisurf(tri, ux); % x方向速度分量云图 title('u velocity'); colorbar; axis equal; view(2); figure(2); trisurf(tri, umag); % 速度模云图 title('velocity magnitude'); colorbar; axis equal; view(2); figure(3); trisurf(tri, p); % 压力云图 title('pressure'); colorbar; axis equal; view(2); figure(4); triplot(tri); hold on; streamslice? %% 简化:用Matlab内置流线工具 end参数说明:u 是 2n×1 的向量,ux 和 uy 通过隔行采样拆开,这是因为自由度编号遵循“每个节点先 x 后 y”的约定。trisurf 可以直接用节点值和三角形连接表绘制分段线性颜色图,这是利用了有限元解本身就是分片线性的性质。如果要做流线,传统做法是把速度插值到规则网格再调用 streamline,但这会引入插值误差。我更推荐直接用网格速度场做粒子追踪,对粗糙网格更忠实。后处理这部分没有涉及任何求解逻辑,纯属“把结果画出来”,所以独立成一个文件是合理的。4. 运行与参数调优:怎么把方腔流跑出来4.1 端到端运行步骤拿到程序后的完整操作流程只需四步,每一步都有明确检查点。第一步用 Matlab R2020a 以上版本打开工程根目录,在命令行执行addpath(genpath(pwd)),把所有子目录加进搜索路径。第二步打开 config.m,设置雷诺数和网格密度。第三步直接运行 main.m,程序会依次完成建网格、组装、迭代、出图四个阶段。第四步看命令行输出的收敛曲线和四个图窗。% main.m 主入口示例 run('config.m'); [node, elem] = build_cavity(cfg.nx, cfg.ny); fprintf('网格生成完成: %d 节点, %d 单元\n', size(node,1), size(elem,1)); [u, p, history] = solve_picard(node, elem, cfg); plot_flow(node, elem, u, p); semilogy(history(history>0)); xlabel('迭代步数'); ylabel('相对残差'); title('收敛历史');第一步跑完你应该看到网格信息打印,节点数等于 (nx+1)*(ny+1),单元数是其两倍。如果网格信息和你预期不符,先停手检查编号函数,别急着往下跑。4.2 配置参数的意义与调节建议config.m 里的参数不多,但每个都直接决定求解成败:参数名取值范围建议作用说明cfg.nx / ny20~80网格密度,越大越精细,自由度成平方增长cfg.Re1~1000雷诺数,超过 400 建议用 Newton 迭代cfg.tol1e-6~1e-8迭代收敛阈值,影响计算时间与精度cfg.maxit50~200迭代上限,Picard 线性收敛时可能很慢cfg.bc_nodes边界节点编号顶盖边界赋速度 1,其余边界赋 0cfg.press_ref指定一个节点固定该点压力为 0,消除压力零空间调参血泪经验是:Re 可以从 1 起步,逐步增加到 10、100、400。每次把上一次的解作为下一次迭代的初值,这种“续算”方案在高雷诺数下几乎是必须的。直接冷启动 Re=1000 的方腔流,Picard 迭代大概率会在前几步就发散成 NaN,原因是初始零流场让线性化对流矩阵失效,迭代无法建立有效迎风方向。4.3 怎么判断结果是不是对的跑通不等于跑对。方腔流有标准答案可以参考:腔体中心会形成一个主涡,涡心位置随 Re 变化有公认的参考值。Re=100 时涡心大约在 (0.62, 0.74) 附近;Re=400 时主涡心移到 (0.55, 0.61) 附近,左下和右下角出现次级涡。这些位置可以从流线图的涡心坐标直接读到。另外还要检查压力场的量级。方腔流的压力绝对值没有物理意义,因为只有压力梯度进入方程,但压力波动幅度应当在 O(1) 量级附近。如果你看到压力云图出现量级在 1e2 到 1e3 的剧烈跳变,先别急着认为物理上合理,这大概率是边界条件施加有问题,而不是真实压力。我在初始版本里就翻过这个车,压力云图花得像调色盘,排查半天发现是顶盖角落速度没做平滑处理。5. 常见问题排查:5 个跑飞与花屏的现场5.1 压力场出现棋盘状震荡现象:压力云图呈红蓝交错的小格子,像国际象棋棋盘,速度场看起来却还算正常。原因是速度压力单元组合不满足 inf-sup 条件,P1P1 没有稳定化时压力空间含有伪模态,这些模态不参与速度场的散度约束,求解器就把任意值赋给了它们。解决办法在程序里已经内置:检查 assemble_system.m 是否完整包含了 PSPG 稳定化矩阵 C。如果自己改代码时把这个矩阵漏掉,立刻就会复现这个问题。验证稳定化是否生效的标志是压力场无震荡且幅值在合理范围内。5.2 直接迭代发散、残差出现 NaN现象:运行几个迭代之后,命令行打印的残差变成 NaN,温度计一样一路涨到 Inf。原因通常是雷诺数设置过高,且初始流场全零。零初场下对流项为零,第一个 Picard 步等价于求解纯 Stokes 问题,如果顶盖速度立刻施加 1.0,边界层内会产生极大的速度梯度,粘性项无法平衡。解决办法是续算:把 Re 从 1 开始,每收敛一个雷诺数的解,就把它作为下一个雷诺数的初值。另外检查顶盖速度是不是一瞬间加载到位,可以在前几个迭代步用速度斜坡加载缓解冲击。5.3 线性系统奇异、Matlab 直接报错现象:求解器在A_full \ rhs时提示 matrix is singular,或者求解时间异常地长。原因是压力自由度没有固定参考点。Navier-Stokes 方程只约束压力梯度,不约束压力绝对大小,所以整个压力零空间包含常数向量,系统矩阵是奇异的。解决办法是像程序里那样固定一个压力节点为零。另一个容易忽略的坑是:固定压力节点的位置不能选在 Dirichlet 速度边界为 0 的角点上,否则边界约束叠加会造成局部应力奇异,我一般取在区域中心附近。5.4 顶盖角点速度不连续导致压力尖峰现象:压力云图在顶盖左右角出现两个刺眼的红蓝色尖点,数值比其他区域大几个量级。原因很经典:顶盖边界速度为 1,左右立壁速度为 0,角点上两个边界条件直接冲突,数学上这是强制的角点不连续。物理上真实的方腔流顶盖角点压力确实有奇异性,但数值上如果不做处理,会让局部压力震荡污染全局。解决办法是给角点速度一个过渡区,让顶盖速度从 0 平滑过渡到 1,比如设置角点速度为 0.5,或者用余弦分布顶盖速度。这样做的代价是基准解会和标准方腔流略有偏差,但换来的是稳定和干净的云图。5.5 残差小但流场还在缓慢漂移现象:残差已经降到 1e-8,但流线图每次刷新都在缓慢变化。原因是 Picard 在线性系统内部已经收敛,但非线性问题并未真正满足。Picard 是“用旧流场更新新流场”的定点迭代,线性残差小只代表这一步的线性求解精度高。解决办法是把收敛判据从残差范数改成相邻两次迭代的速度场增量范数,程序里 history 记录的就是这个增量指标。如果增量曲线出现平台期长期不降,可以判断当前网格和雷诺数组合下 Picard 已经到收敛极限,需要换 Newton 或加密网格。6. 二次开发:把稳态程序改成你自己的仿真工具6.1 从 Picard 换成 Newton 迭代把 solver 里的 Picard 主循环改成 Newton 迭代,核心是 Jacobian 矩阵里的对流项导数。线性化之后要多补一项形如 ∫ grad(u_old)·v u_new dΩ 的贡献,这一项在数学上叫 Fréchet 导数。改造方式是写一个 assemble_jacobian.m,在原有 K 矩阵基础上叠加这个导数块,其余流程保持不变。收益是收敛阶数从一次变为二次,Re=1000 时通常十几步就能收敛,而 Picard 需要上百步。改造后有一个必须小心的坑:Newton 迭代对初值极敏感,零初场必然发散。稳妥套路是先用 Picard 跑几十步垫底,再切换到 Newton 二阶收敛。我在代码里加了cfg.solver_type开关,跑通新迭代器后可以和旧版本并排对比收敛曲线。6.2 区域扩展与边界条件定制目前的网格生成函数只生成方腔,要算管道流动或者绕圆柱流,需要替换 mesh 模块。管道流可以继续用结构化网格,绕圆柱推荐用 DistMesh 生成非结构网格。边界条件需要抽象成边界标记,按边分组编号,而不是按单个节点编号。一个常见做法是在 build_mesh 里给每条边输出 edge_label 数组,沿边界按组施加不同速度。这里需要注意边界标记与单元连接表的边顺序一致性,不然施加的边界条件会张冠李戴。验证改造成果离不开基准算例对比。方腔流是标准,管道流可以对比 Poissonise 的抛物线解析解。每次改造完成,我都要求自己强制跑一遍基准解对比,确认最大相对误差控制在 2% 以内才算过关。这个习惯是从一次把边界条件改错、结果却看起来“很像物理”的教训里学到的。6.3 把稳态程序推进到非稳态最后留一个改造方向,把程序从稳态扩展为非稳态。做法是把 dt 参数真正用起来,时间项用后向欧拉离散,动量矩阵里加入质量矩阵项 (1/dt)M。每一步时间推进内仍然可以调用现有 Picard 或 Newton 迭代,这实际上就是把问题变成一个带时间步长的连续线性系统求解。这套改造路径我已经在另一个相关项目里验证过,用同样的框架不加新的依赖就能跑出圆柱绕流的卡门涡街。二维稳态程序的价值其实不在于它本身能算多少实际问题,而在于它是一个可以随时加料的结构骨架。做到这一步以后,再回头看当初直接上手写 P2P1 单元的想法,只能说幸好先跑了这个 P1P1 稳定版本。从那次之后,我每次改单元类型都强制先过一遍稳定性检查,数值结果再顺手也不轻信。希望帮到你。本文还有配套的精品资源,点击获取