简介这份资源围绕格子Boltzmann方法LBM的流动数值模拟展开面向流体力学、计算物理方向的学习者与研究人员尤其适合希望用C动手实现LBM、理解其算法流程的中高级读者。压缩包为tgz格式体积约1.79MB内含基于OpenLatticeBoltzmann项目0.7r1版本的源码与示例涵盖二维与三维多种典型流动场景如管道流动、绕流、水槽波浪生成等可用于流场分析、湍流与传热研究的入门实践。资源以C实现兼顾计算效率与模块化设计便于参数配置、几何导入与结果分析。目前已有804人学习下载读者可借助其中的核心算法、数据结构与示例工程快速上手LBM建模并在此基础上进行二次开发扩展至多相流或流固耦合等特定研究需求是理解并应用该方法的一份实用参考。1. 从零手写 LBM 流动模拟一份能跑通的 C 源码拆解顶盖驱动方腔流是 CFD 圈子里公认的“Hello World”但真拿格子玻尔兹曼方法LBM从零写一遍很多人卡在第一个时间步就翻车——密度场炸了、速度场发散、边界条件写反。我手里这份基于 LBM 的流动模拟 C 源码包就是冲着这个痛点来的它不依赖任何第三方 CFD 库纯 C 实现 D2Q9 模型包含碰撞、迁移、宏观量提取、边界处理四个核心模块编译完直接出速度场和流线数据。适合两类人一是想搞懂 LBM 底层数据结构的 C 开发者二是需要快速搭一个可改可调的流动模拟骨架做算法验证的研究生。源码结构干净没有过度封装每个函数对应一个物理步骤改参数不用翻三层继承。2. LBM 核心原理与 D2Q9 数据结构为什么用分布函数而不是解 N-S 方程2.1 从玻尔兹曼方程到格子离散LBM 到底在算什么传统 CFD 解的是宏观连续方程LBM 走的是另一条路它模拟流体微观粒子的分布函数演化。你可以把每个格点想象成一个小房间里面站着 9 个方向的“信使”每个信使手里拿着一个数值代表该方向上的粒子分布密度。每个时间步信使先在自己房间里按碰撞规则交换手里的数值然后整体往各自方向挪一格到隔壁房间。宏观的密度和速度就是这 9 个数值的加权和。这套逻辑的数学根基是玻尔兹曼输运方程经过速度空间离散D2Q9 就是 2 维 9 速度、空间离散格子、时间离散后得到格子玻尔兹曼方程f_i(x e_i·Δt, t Δt) f_i(x, t) Ω_i(f)其中 f_i 是第 i 个方向的分布函数e_i 是离散速度向量Ω_i 是碰撞算子。最常见的碰撞模型是 BGK 近似把碰撞简化成向平衡态松弛Ω_i -(f_i - f_i^eq) / ττ 是松弛时间直接决定运动黏度 ν c_s²(τ - 0.5)Δt。c_s 是格子声速D2Q9 里等于 1/√3。这个公式是整个模拟的命门——τ 必须大于 0.5否则黏度为负模拟必炸。很多新手第一次跑出 NaN九成是 τ 设到了 0.5 以下。D2Q9 的 9 个速度方向不是随便定的它要满足质量、动量、能量守恒的矩条件。具体权重和速度向量如下表方向索引 i速度向量 (e_ix, e_iy)权重 w_i说明0(0, 0)4/9静止粒子1-4(±1, 0), (0, ±1)1/9轴向运动5-8(±1, ±1)1/36对角运动平衡态分布函数 f_i^eq 的公式是f_i^eq w_i · ρ · [1 (e_i·u)/c_s² (e_i·u)²/(2c_s⁴) - u²/(2c_s²)]这个公式看着复杂代码里就是几行乘加。真正容易写错的是速度向量和权重的对应关系——索引 5 到 8 的对角方向e_ix 和 e_iy 的符号组合必须和权重表严格对齐错一个符号流场就会出现诡异的各向异性。2.2 源码里的数据结构一维数组存二维分布打开源码的lbm.h核心数据结构就两个数组// lbm.h 核心数据结构节选 const int NX 256; // x 方向格点数 const int NY 256; // y 方向格点数 const int Q 9; // D2Q9 模型速度方向数 // 分布函数存储f[i][y][x] 用一维数组展平方便缓存命中 // 索引计算idx i * NX * NY y * NX x double* f; // 当前时间步分布函数 double* f_new; // 迁移后的分布函数 double* rho; // 宏观密度场 double* ux; // 宏观速度 x 分量 double* uy; // 宏观速度 y 分量这里有个设计选择值得说为什么用一维数组而不是double f[Q][NY][NX]因为 C 里多维数组的内存布局虽然是连续的但编译器对指针别名和边界检查的处理会影响向量化。展平成一维后手动算索引编译器更容易生成 SIMD 指令。代价是代码可读性下降但 LBM 每个时间步要遍历所有格点性能收益是实打实的。速度向量和权重通常定义成静态常量数组// 速度向量ex[i], ey[i] 对应第 i 个方向 const double ex[Q] {0, 1, 0, -1, 0, 1, -1, -1, 1}; const double ey[Q] {0, 0, 1, 0, -1, 1, 1, -1, -1}; // 权重 const double w[Q] {4.0/9, 1.0/9, 1.0/9, 1.0/9, 1.0/9, 1.0/36, 1.0/36, 1.0/36, 1.0/36};注意 ex 和 ey 的排列顺序索引 1 是 (1,0)索引 2 是 (0,1)索引 3 是 (-1,0)索引 4 是 (0,-1)然后 5 到 8 是对角。这个顺序不是唯一的但一旦定了后面所有循环都必须按这个顺序来。我见过有人把对角方向的顺序换了结果流场出现棋盘格振荡查了一整天。2.3 碰撞与迁移两个循环的顺序不能反LBM 的一个时间步分两步碰撞collision和迁移streaming。碰撞在本地格点完成只涉及当前格点的 9 个分布函数迁移把碰撞后的分布函数沿各自速度方向送到邻居格点。// 碰撞步骤计算平衡态并松弛 for (int y 0; y NY; y) { for (int x 0; x NX; x) { int idx y * NX x; double rho_local 0.0, ux_local 0.0, uy_local 0.0; // 先算宏观量 for (int i 0; i Q; i) { double fi f[i * NX * NY idx]; rho_local fi; ux_local fi * ex[i]; uy_local fi * ey[i]; } ux_local / rho_local; uy_local / rho_local; // 再算平衡态并碰撞 for (int i 0; i Q; i) { double eu ex[i] * ux_local ey[i] * uy_local; double feq w[i] * rho_local * (1.0 eu / (cs * cs) (eu * eu) / (2.0 * cs * cs * cs * cs) - (ux_local * ux_local uy_local * uy_local) / (2.0 * cs * cs)); int idf i * NX * NY idx; f_new[idf] f[idf] - (f[idf] - feq) / tau; } } }碰撞循环里宏观量的计算必须在平衡态之前因为 feq 依赖 ρ 和 u。这里有个性能细节f[i * NX * NY idx]这种索引方式当 i 变化时跨步很大缓存不友好。更优的做法是把分布函数按格点连续存储即f[idx * Q i]这样内层循环访问连续内存。源码里用的是前者因为更直观但如果你要跑大网格建议改成后者实测有 20% 到 30% 的性能差距。迁移步骤紧接着碰撞// 迁移步骤把 f_new 沿速度方向送到邻居 for (int y 0; y NY; y) { for (int x 0; x NX; x) { for (int i 0; i Q; i) { int x_src x - (int)ex[i]; // 来源格点 int y_src y - (int)ey[i]; // 周期性边界越界则绕回 if (x_src 0) x_src NX; if (x_src NX) x_src - NX; if (y_src 0) y_src NY; if (y_src NY) y_src - NY; f[i * NX * NY y * NX x] f_new[i * NX * NY y_src * NX x_src]; } } }迁移的方向容易搞反x_src x - ex[i]表示“从上游拿”而不是“往下游送”。两种写法在周期性边界下等价但在有壁面的情况下边界处理逻辑完全不同。源码用的是“拉”的方式配合边界条件更自然。提示碰撞和迁移必须用两个独立的数组不能原地更新。如果碰撞后直接写回 f迁移时就会用到已经被覆盖的数据结果完全错误。这是 LBM 实现里最经典的坑之一。3. 编译、运行与参数调优从源码到速度场云图3.1 编译环境与依赖纯 C 标准库零外部依赖这份源码的一个好处是没有任何第三方依赖不需要 MPI、不需要 OpenMP、不需要 CUDA只要有 C11 以上的编译器就能跑。我一般用 g 直接编译# 编译开启 O3 优化和自动向量化 g -O3 -marchnative -stdc11 -o lbm_sim main.cpp lbm.cpp -lm # 运行默认参数是 256x256 网格10000 步 ./lbm_sim-O3是必须的LBM 的计算密集度很高不开优化跑 256×256 网格 10000 步大概要几分钟开了之后能压到几十秒。-marchnative让编译器针对当前 CPU 生成 AVX 指令对碰撞循环里的乘加运算提升明显。如果你在 ARM 机器上跑把-marchnative换成-mcpunative。源码的main.cpp里参数是硬编码的但都集中在文件开头// main.cpp 可调参数区 const int MAX_ITER 10000; // 总时间步数 const double tau 0.8; // 松弛时间决定黏度 const double U_lid 0.1; // 顶盖驱动速度格子单位 const int OUTPUT_INTERVAL 1000; // 每隔多少步输出一次场数据tau 0.8对应运动黏度 ν (0.8 - 0.5)/3 ≈ 0.1。这个值不算小流场会比较“稠”适合看稳态涡结构。如果你想看更接近湍流的瞬态把 tau 降到 0.55 到 0.6 之间但要注意稳定性——tau 越接近 0.5马赫数稍微大一点就会发散。3.2 顶盖驱动方腔的边界条件实现顶盖驱动方腔的边界条件分三类顶盖是运动壁面左右和底部是静止壁面四个角是奇点。源码里用的是“反弹格式”处理静止壁面顶盖用“动量注入”方式。// 边界处理在迁移之后调用 void applyBoundaryConditions(double* f, double* f_new) { // 顶盖y NY-1速度 U_lid 沿 x 正方向 int y NY - 1; for (int x 0; x NX; x) { int idx y * NX x; double rho_wall 0.0; // 先算壁面密度 for (int i 0; i Q; i) rho_wall f[i * NX * NY idx]; // 对指向壁面内部的分布函数用平衡态重构 // 这里以方向 40,-1和方向 7、8 为例 double ux_wall U_lid, uy_wall 0.0; for (int i : {4, 7, 8}) { double eu ex[i] * ux_wall ey[i] * uy_wall; double feq w[i] * rho_wall * (1.0 eu / (cs * cs) (eu * eu) / (2.0 * cs * cs * cs * cs) - (ux_wall * ux_wall uy_wall * uy_wall) / (2.0 * cs * cs)); f[i * NX * NY idx] feq; } } // 左右和底部标准反弹格式 // 以左壁 x0 为例方向 11,0的分布函数来自方向 3-1,0 for (int y 0; y NY; y) { int idx y * NX 0; f[1 * NX * NY idx] f[3 * NX * NY idx]; } // 右壁、底部类似略 }顶盖的处理是新手最容易翻车的地方。方向 4、7、8 是“指向壁面内部”的方向它们的分布函数不能从邻居迁移得到必须用壁面速度重构。重构时用的密度是壁面格点的局部密度不是全局平均密度——用错会导致质量不守恒长时间跑下来总质量漂移。反弹格式的原理很简单粒子撞到静止壁面后原路返回。所以左壁 x0 处方向 1向右的分布函数等于方向 3向左的分布函数。但要注意这个赋值发生在迁移之后因为迁移已经把方向 3 的粒子送到了壁面格点。3.3 输出格式与可视化用 Python 画速度场源码每 1000 步输出一次ux、uy、rho三个二进制文件格式是简单的 double 数组按行优先排列。用 Python 读进来画图import numpy as np import matplotlib.pyplot as plt NX, NY 256, 256 # 读取二进制场数据 ux np.fromfile(ux_10000.bin, dtypenp.float64).reshape(NY, NX) uy np.fromfile(uy_10000.bin, dtypenp.float64).reshape(NY, NX) rho np.fromfile(rho_10000.bin, dtypenp.float64).reshape(NY, NX) # 画速度幅值云图 speed np.sqrt(ux**2 uy**2) plt.figure(figsize(8, 6)) plt.imshow(speed, originlower, cmapjet) plt.colorbar(labelVelocity magnitude) plt.title(Lid-driven cavity flow, tau0.8, 10000 steps) plt.xlabel(X) plt.ylabel(Y) plt.savefig(velocity_field.png, dpi150) plt.show() # 画流线 plt.figure(figsize(8, 6)) plt.streamplot(np.arange(NX), np.arange(NY), ux, uy, density2, colorspeed, cmapjet) plt.title(Streamlines) plt.savefig(streamlines.png, dpi150) plt.show()reshape(NY, NX)的顺序不能反因为 C 输出时是 y 外层循环、x 内层循环对应行优先。如果画出来的图上下颠倒把originlower改成originupper就行。判断模拟是否收敛看两个指标一是总动能随时间的变化稳态后应该趋于常数二是涡心位置顶盖驱动方腔在 Re1000 左右时主涡心大约在 (0.53, 0.57) 附近归一化坐标。如果涡心跑到角落或者出现多个涡说明边界条件或者 tau 有问题。注意源码输出的二进制文件没有头信息纯 double 数组。如果你改了 NX 或 NYPython 读取时的 reshape 参数必须同步改否则数据会错位。4. 避坑与排查LBM 模拟发散的五个血泪教训4.1 现象跑几百步后密度场出现 NaN原因松弛时间 tau 太接近 0.5或者顶盖速度 U_lid 太大导致马赫数超限。LBM 的弱可压缩性要求马赫数 Ma U_lid / c_s 小于 0.3 左右c_s 1/√3 ≈ 0.577所以 U_lid 不应超过 0.17。源码默认 0.1 是安全的但有人为了“加快收敛”调到 0.3基本几十步就炸。解决把 U_lid 降回 0.1 以下同时检查 tau 是否大于 0.55。如果必须用高雷诺数应该增加网格分辨率而不是提高速度。4.2 现象流场出现棋盘格振荡速度场一正一负交替原因碰撞和迁移的循环顺序写反了或者迁移时用了原地更新。LBM 的碰撞必须在本地完成迁移必须从旧数组读、往新数组写。如果碰撞后直接覆盖了 f迁移时读到的就是已经碰撞过的数据物理上完全错误。解决严格用两个数组碰撞写 f_new迁移从 f_new 读到 f。源码里f和f_new的角色在每个时间步末尾交换不要手动去改。4.3 现象总质量随时间缓慢漂移跑久了密度整体偏移原因边界条件的密度重构用了全局平均密度而不是局部密度。顶盖壁面的平衡态重构需要该格点的局部密度如果用全局 ρ0 1.0壁面附近的质量就不守恒。解决在边界处理函数里先遍历该格点的 9 个分布函数求和得到局部密度再用这个密度算 feq。源码里rho_wall就是这么来的。4.4 现象编译通过但运行时报段错误原因数组索引越界。最常见的是迁移步骤里x_src或y_src算出负数后没有正确加 NX/NY或者加了之后仍然越界。另一个可能是f和f_new分配的内存大小是Q * NX * NY但索引时用了i * NX * NY y * NX x如果 i 到了 9 就溢出了。解决在迁移循环里加断言assert(x_src 0 x_src NX)编译时加-g -fsanitizeaddress跑一遍能直接定位越界位置。4.5 现象模拟结果和文献对不上涡心位置偏差大原因网格分辨率不够。顶盖驱动方腔在 Re1000 时256×256 网格勉强够用但如果 Re 更高或者要看更精细的涡结构需要 512×512 甚至 1024×1024。另一个原因是收敛判据太松10000 步可能还没到稳态。解决先跑一个粗网格看趋势确认边界条件没问题后再加密网格。收敛判据用速度场的变化率比如max|u_new - u_old| 1e-6而不是固定步数。5. 进阶玩法从方腔流到多孔介质改三处就能跑源码的骨架是通用的换成其他流动场景只需要改三个地方边界条件函数、初始场、以及输出逻辑。我拿多孔介质渗流举个例子——这是 LBM 的经典应用场景比方腔流更贴近实际工程。多孔介质的做法是在流场里随机放置一批固体格点粒子撞到固体格点就反弹。不需要改碰撞和迁移只需要在边界处理里加一个判断// 多孔介质固体格点反弹 // 先用一个 bool 数组标记固体 bool* is_solid new bool[NX * NY]; // 随机初始化孔隙率 0.7 for (int i 0; i NX * NY; i) is_solid[i] (rand() / (double)RAND_MAX) 0.7; // 在迁移之后对固体格点做反弹 for (int y 0; y NY; y) { for (int x 0; x NX; x) { int idx y * NX x; if (!is_solid[idx]) continue; // 固体格点所有方向的分布函数反弹 // 方向 i 的分布函数来自相反方向 i_op int i_op[Q] {0, 3, 4, 1, 2, 7, 8, 5, 6}; for (int i 0; i Q; i) { f[i * NX * NY idx] f_new[i_op[i] * NX * NY idx]; } } }i_op数组是方向索引的相反映射0 对 01 对 32 对 45 对 76 对 8。这个映射表必须和速度向量的定义严格一致错一个就漏质量。压力驱动用周期性边界加一个体积力实现。在碰撞步骤里给速度加一个增量// 在碰撞循环里算完宏观速度后加体积力 ux_local force_x * tau; // force_x 是 x 方向体积力 uy_local force_y * tau;体积力的大小控制雷诺数force_x 1e-5对应很慢的渗流1e-3就快很多。跑够步数后统计平均速度用达西定律反算渗透率K ν * u_avg / force_x。这个值和孔隙率、固体分布有关可以拿来验证代码是否正确。验证方法很简单把孔隙率设成 1.0没有固体结果应该退化成方腔流或者周期性槽道流。如果对不上说明反弹逻辑或者体积力实现有问题。我一般会先跑这个退化测试确认无误后再加固体。从那以后我每次改 LBM 代码都强制走一遍“退化测试—粗网格—细网格”三步再急也不跳过。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?