我一直对 deal.II 抱有"敬畏感"。两年前第一次打开它的文档时,满屏 C++ 模板和 Triangulation、DoFHandler 这类名词,第一反应就是关掉浏览器。直到手里真的接了一个偏微分方程求解的活,需要一套既灵活又能承受自适应网格的框架,才硬着头皮从教程第一个例子 step-1 开始啃。走完一遍之后回头看,step-1 其实并不难,但它把"离散计算域"这一步讲得非常清楚,是理解整个 deal.II 世界的地基。
这篇记录就写我这段时间学习 step-1 的完整过程,包括源码怎么读、网格是怎么生成的、细化粗化背后的机制、输出格式怎么选,以及新手上路最容易踩的编译坑。如果你跟我一样是第一次接触 deal.II,这篇文章应该能帮你少走不少弯路。
1. 我为什么从 step-1 开始啃 deal.II,又该怎么装环境
1.1 deal.II 解决的是什么问题
很多人第一次听说有限元,会以为它是个"软件",双击装好、导入模型、点击计算就出结果。deal.II 完全不是这个路线,它是一个用 C++ 写成的开源有限元库,给你的是构建有限元程序的一整套零件。它内部帮你处理了网格数据结构、自由度管理、矩阵装配、线性求解、后处理输出这些繁琐但通用的东西,而你要做的是把具体的偏微分方程、边界条件、单元类型这些"物理相关"的部分填进去。
相比 FreeFEM 这类脚本化工具,deal.II 的学习曲线明显更陡,但换来的是两个好处:一是性能,C++ 模板化的底层实现在大规模计算时不会拖后腿;二是灵活度,几乎所有环节都可以定制,尤其适合做自适应网格、多物理场耦合和非标准有限元方法。step-1 作为官方教程第一个例子,看起来只是画网格,实际上是在给你建立整体框架感:一个有限元程序从计算域离散开始,后面所有事情都建立在这套网格之上。
1.2 安装版本和依赖的取舍
我在安装时踩过的第一个坑是版本选择。deal.II 的 API 在不同版本之间会有调整,教程里的代码往往基于某个稳定版本。这里我的建议是:别追最新,直接用发行版或官方推荐版本,比如 9.4/9.5 之后的某个稳定版本。我当时用包管理器装,省去了不少编译时间,但代价是对底层依赖控制较弱。如果你需要并行计算或者特定的直接求解器,最好还是自己编译,把 MPI、p4est、UMFPACK 这些可选项都配上。
安装完毕后有个简单的自检方法:在终端里运行deal.II自带的示例编译脚本,如果能顺利编译运行,说明环境基本可用。千万不要一上来就在自己的项目里折腾 CMake,先把官方 examples 跑通,确认 deal.II 本身没问题,再去碰自己的代码。
2. 看懂 first_grid():Triangulation 和 GridGenerator 是一对搭档
2.1 网格到底"存"了什么
学习 step-1 之前,我一直以为有限元里的网格就是一堆点和三角形/四边形,程序里保存一个顶点数组、一个单元连接关系就够了。但 deal.II 的Triangulation远比这复杂:它不仅要存顶点和单元,还要存单元之间的邻接关系、面与边的关系、父单元和子单元的层级关系,以及用于并行分布的信息。这些额外的拓扑信息在单纯画图时用不上,但后面做自由度编号、矩阵装配、自适应细化时全是必需品。
所以 step-1 里第一个关键概念是:Triangulation负责"存储和管理网格",它自己不负责创造网格。真正"造"网格的是GridGenerator。这个分工在后续所有教程中都会反复出现:先生成初始网格,放入Triangulation,再通过细化/粗化改变它,最后交给DoFHandler等组件去使用。
2.2 逐行读 first_grid():从 hyper_cube 到 refine_global(4)
先放一段我整理过的代码,对应 step-1 的第一个网格示例:
#include <deal.II/grid/tria.h> #include <deal.II/grid/grid_generator.h> #include <deal.II/grid/grid_out.h> #include <fstream> #include <iostream> using namespace dealii; void first_grid() { Triangulation<2> triangulation; GridGenerator::hyper_cube(triangulation); triangulation.refine_global(4); std::ofstream out("grid-1.eps"); GridOut grid_out; grid_out.write_eps(triangulation, out); std::cout << "Grid written to grid-1.eps" << std::endl; }GridGenerator::hyper_cube(triangulation)生成了 ( [0,1] \times [0,1] ) 区域上的一个四边形单元。注意这里"一个单元"的说法:整个单位正方形初始只有 1 个单元,没有内部网格线。真正让网格"看起来像网格"的是refine_global(4),也就是全局细化 4 次。
为什么要全局细化和几何级数挂钩?因为每细化一次,每个单元都会被等分成 ( 2^d ) 个子单元,d是维度。在二维情况下就是 1 个变 4 个、4 个变 16 个、16 个变 64 个、64 个变 256 个。细化 4 次后,初始 1 个单元变成 256 个。如果是三维,同样细化 4 次就是 1→8→64→512→4096。这个增长速度一定要有直觉,否则后面设置细化次数时会心里没底。
2.3 模板参数 <2> 的含义:维度是编译期确定的
Triangulation<2>这个尖括号一度让我疑惑:为什么维度不作为一个构造函数的参数?原因是 deal.II 把维度作为模板参数,在编译期就确定了整个网格数据结构的内部布局。这样做的优势在于,很多循环可以被编译器优化,例如单元遍历、面的访问在编译时就知道维度,不需要在运行时用 if 判断。代价是如果你需要同时处理不同维度的问题,代码里往往要写模板函数或者用switch分发。
但这里更值得留意的细节是:Triangulation<2>里的 2 是"问题的拓扑维度",也就是一片二维曲面。它还有第二个模板参数,默认等于第一个,叫做空间维度。比如你要描述一个嵌在三维空间里的二维球壳,可以用Triangulation<2,3>。step-1 里没有碰到这种情形,但理解这一点对后续学习流形映射(Manifold)非常重要。
从这段代码我学到的第一件事:deal.II 的网格操作是"先声明一个 Triangulation 对象,然后调 GridGenerator 往里面填充初值,再调 refine 方法去细化"。这套流程在后续成千上万行的项目里都是一样的,只是填充和细化的方式越来越高级。
3. 细化与粗化混用的 second_grid():从圆环网格看层级机制
3.1 hyper_shell 的创建和等分数选择
step-1 的第二个示例不再用正方形,而是生成一个二维圆环:
void second_grid() { Triangulation<2> triangulation; const Point<2> center(0.2, 0.2); const double inner_radius = 0.5; const double outer_radius = 1.0; GridGenerator::hyper_shell(triangulation, center, inner_radius, outer_radius, 10); for (unsigned int step = 0; step < 5; ++step) { for (const auto &cell : triangulation.active_cell_iterators()) { if (cell->center()[0] > 0 && cell->center()[1] > 0) cell->set_refine_flag(); else if (cell->center()[0] < 0 && cell->center()[1] < 0) cell->set_coarsen_flag(); } triangulation.execute_coarsening_and_refinement(); } std::ofstream out("grid-2.eps"); GridOut grid_out; grid_out.write_eps(triangulation, out); std::cout << "Grid written to grid-2.eps" << std::endl; }hyper_shell生成的是内半径 0.5、外半径 1.0 的圆环,圆心在 (0.2, 0.2)。最后一个参数 10 表示周向初始等分数。这里我一开始忽略了一个问题:圆环不是矩形,它在网格形态上有明显的曲率。deal.II 默认用直线逼近单元边界,如果初始等分数太少,圆环看起来就是一个多边形环;等分数越多,几何逼近越光滑。但也别无限调大,因为初始单元越多,后续细化和计算成本跟着涨。
3.2 set_refine_flag / set_coarsen_flag 的分区判断
step-1 最值得玩味的是后面这个循环。它在每次迭代中遍历所有活跃单元(active_cell_iterators()),取出单元中心点坐标,判断它在圆环的哪个区域:
- 第一象限(x>0 且 y>0):标记需要细化;
- 第三象限(x<0 且 y<0):标记需要粗化;
- 其他地方:不做任何标记。
然后统一调用execute_coarsening_and_refinement()一次性执行。这个"先标记、后执行"的设计贯穿 deal.II 的自适应网格逻辑。为什么不是直接马上细化某个单元?因为细化一个单元会影响它周围的单元,特别是会产生悬挂节点,也就是两个相邻单元在共享边上的节点数不一致。分批处理才能保证网格的协调性。
cell->center()[0]里的[0]是 x 坐标,[1]是 y 坐标。这是一种非常直观的"几何区域标记"写法。学到这里我已经意识到,未来做自适应有限元时,判断条件不会这么简单,而是换成误差估计子:误差大的单元标记细化,误差小的标记粗化。step-1 相当于先用几何位置演示了这个机制。
3.3 兄弟节点约束和 conforming 网格
执行了 5 轮细化+粗化后,第一象限的单元一层层变细,第三象限的单元一层层变粗,圆环网格出现明显的疏密对比。这时候有一个隐藏机制需要特别注意:deal.II 在粗化时不会随便把某个单元粗化掉,它要求同一个父单元下的所有活跃子单元都被标记为粗化时,才真正执行这一步。
这个约束保证网格是 conforming 的,也就是相邻单元之间不会出现"一边有中点、另一边没有中点"的悬空情况。如果某个粗化请求不满足这个条件,deal.II 会直接忽略它。我在学习时一直以为是自己代码写错了,后来翻文档才知道这是刻意设计。所以在设置set_coarsen_flag()时,不要假设它一定会生效,要想清楚兄弟单元是否都被标记了。
3.4 三种细化策略的关系
学完 step-1 的两种网格,可以把细化策略理一理:
| 策略 | 触发方式 | 单元数量增长速度 | 误差分布假设 |
|---|---|---|---|
| 全局细化 | refine_global(n) | 每次乘 ( 2^d ),指数爆炸 | 误差全域均匀 |
| 局部几何细化 | 按坐标区域标记 | 仅选中区域增长 | 已知关注区域 |
| 自适应细化 | 误差估计子标记 | 集中于高误差区 | 误差未知但可估 |
step-1 只演示了前两种,但第三种才是 deal.II 的看家本领。你会发现它们的底层机制完全一样,都是set_refine_flag()+execute_coarsening_and_refinement(),区别只在于标记的依据。这也是为什么我强烈建议初学者把 step-1 的第二个示例亲手跑一遍、多改几次判断条件的原因:自适应框架其实就是在这个基础上,把"看坐标"换成"看误差"。
4. 用 GridOut 把网格"导出"成看得见的形式
4.1 step-1 用到的 eps 输出
GridOut是 deal.II 里专门负责网格输出的类。step-1 里用到的是write_eps(),把网格写成一个 PostScript 矢量图文件。EPS 最大的优势是矢量格式,无限放大不会糊,而且可以直接插入 LaTeX 文档,这也是为什么官方教程选择用它做默认演示。
在我实际使用时,发现单独输出网格 EPS 最常用的场景是:论文里展示计算域离散示意图、自适应用例里展示网格疏密分布。它只画网格线,不包含解场信息,所以非常轻量。
4.2 其它输出格式怎么选
step-1 的官方文档还提到了很多输出格式,我把常用几种列一下:
write_svg:和 EPS 类似,矢量图,浏览器直接打开方便。write_gnuplot:输出单元顶点坐标的数据文本,配合 gnuplot 脚本可以快速看网格结构,适合服务器上没有图形界面时调试。write_vtk:传统 VTK 格式,可以被 ParaView 打开。write_vtu:XML 格式的 VTK,ParaView 推荐,也能保存后续的有限元解。write_dx:OpenDX 格式,现在用得少了。
我的建议是:调试阶段用 EPS 或 SVG,看结构最直观;如果网格要跟求解结果一起分析,直接上 VTU,一步到位。后面教程里当你输出有限元解时,DataOut类默认推荐的也是 VTU 格式,顺手和 ParaView 打通,对理解自适应过程帮助巨大。
4.3 把网格导入 ParaView 的延展玩法
step-1 没有直接写 VTU,但你不妨自己改一行代码试试,把write_eps换成write_vtu,然后打开 ParaView。这样你能立刻看到每个单元、每个顶点编号,配合Threshold、Clip这些过滤器,可以从不同视角观察网格疏密。我在做局部细化练习时,就是靠 ParaView 里旋转模型才发现我的细化区域判断条件写反了——只看 EPS 静态图确实不容易察觉这类问题。
5. 编译运行 step-1 时绕不过去的 CMake 与常见坑
5.1 一份能用的 CMakeLists.txt 长什么样
deal.II 的官方示例都会带一份 CMakeLists.txt,建议直接拿它改。我最开始自己手写了一个极其精简的版本,结果编译报错折腾了半天。官方推荐的是这种写法:
cmake_minimum_required(VERSION 3.13.0) project(step1) find_package(deal.II REQUIRED) deal_ii_initialize_cached_variables() add_executable(step1 step1.cc) deal_ii_setup_target(step1)find_package(deal.II REQUIRED)会去系统路径找 deal.II 的 CMake 配置,deal_ii_initialize_cached_variables()读取相关变量,最后deal_ii_setup_target把需要的头文件路径、依赖库、编译选项一股脑配好。这里的关键点是:不要自己手动填 include 路径,因为 deal.II 的依赖非常多,手动维护很容易漏。
如果你在终端里直接敲cmake -S . -B build发现找不到 deal.II,通常要先把 install 路径告诉 CMake,最省事的方式是设置环境变量:
export DEAL_II_DIR=/path/to/deal.II5.2 新手最容易踩的安装和编译问题
我把自己遇到的编译运行问题整理了一下,都是新手高频坑:
- CMake 提示找不到 deal.II Package:说明
DEAL_II_DIR没设置或者安装路径不对,检查一下你的 deal.II 安装目录下有没有deal.IIConfig.cmake文件。 - 编译时报 API 不匹配:deal.II 的代码在不同版本间会有调整,比如某些函数需要增加参数、某些类改了名字。解决办法不是硬改代码,而是看对应版本的手册。我当时就是因为照着网上旧教程写代码,死活编译不过。
- 程序能编译但运行时崩溃:多半是输出文件路径没有写权限,或者路径里有中文/空格。另外记得先建好输出目录,
ofstream不会自动帮你创建目录。 - 链接时大量未定义符号:几乎都是依赖缺失导致的,不是你的代码问题。检查 deal.II 编译时启用了哪些依赖,比如是否启用了 MPI、p4est,然后确保自己的环境里也有对应的库。
5.3 main() 的顺序:从网格开始的一次完整旅程
step-1 的main()很直白,就是调用first_grid()和second_grid()。但别小看这个顺序,它实际上透露了 deal.II 中一个有限元程序的典型生命周期:
- 创建
Triangulation,生成初始网格; - 细化网格到满意程度(可以是全局、几何局部或自适应);
- 后续教程里会引入
DoFHandler,在网格上分配自由度; - 再往后是矩阵装配、边界条件处理、线性求解,最后后处理。
step-1 停在了第一步,但它是整个生命周期的地基。我见过不少人在后面的 step 里绕回来改网格生成代码,就是因为一开始没把网格逻辑写清晰。所以 step-1 的 main 虽然短,但我建议你多花点时间把两个函数里的每一步都吃透。
6. 做完 step-1 的练习,我总结了这些经验
6.1 练习的意义:改条件、换几何、换格式
官方教程在 step-1 末尾留了几个练习,我强烈建议不要跳过。第一个练习是修改细化区域,比如把"第一象限细化"改成"左上角细化"。别看只是把坐标判断条件改一下,这个动作会让你真正理解单元遍历和坐标系统的关系。我改完后还顺手把粗化条件也换了个区域,观察到了很多有趣的网格形态,比如不同象限之间形成明显的密度过渡带。
第二个练习是换初始几何,把hyper_cube换成hyper_ball、subdivided_hyper_cube、hyper_cube_with_cylindrical_hole等其它GridGenerator函数。这个练习特别有用,因为网格生成器提供的不同初始形状,决定了你后续能模拟什么区域。比如hyper_cube_with_cylindrical_hole就非常适合做"带孔板的应力集中"这类经典算例。
第三个练习是换输出格式,比如用write_vtk替代write_eps。这一步最好的结果是逼你去装一个 ParaView 或者至少了解一下 VTK 格式,这对后面处理真实计算结果非常关键。
6.2 从 step-1 到 step-6 的学习路线
step-1 跑通之后,我建议的学习路径是 step-2 到 step-6,而不是中途跳到某个炫酷的高级功能。step-2 讲DoFHandler,解决的是"网格上如何给自由度编号"的问题;step-3 是最简单的 Laplace 方程求解,把网格、自由度、矩阵装配、线性求解串起来;step-4 开始讲如何自定义系数和精确解;step-6 是经典的基于误差估计的自适应求解,它在网格细化逻辑上跟 step-1 一脉相承,只是把几何区域判断换成了 Kelly 误差估计器。
把这一串学完后,你再回头看 step-1,才能真正理解官方教程为什么拿网格生成作为起点。因为不管是自适应细化、并行分布式网格,还是多物理场耦合里的网格重划分,都是从Triangulation这个概念长出来的。
6.3 给初学者的几条建议
这几条是我从自己踩坑经历里总结的,不一定全面,但应该能帮到刚上手的读者。
第一,不要只看教程代码,一定要自己敲一遍再运行。复制粘贴会给人一种"我会了"的错觉,等你关掉官方文件自己从头写时,才会发现Triangulation和GridGenerator的配合有很多细节需要熟悉。
第二,遇到报错先翻官方文档,再查搜索引擎。deal.II 的官方文档做得非常细,每个类每个函数都有说明和示例。网上很多旧代码跟新版本对不上,直接复制反而浪费时间。
第三,多观察网格。不要只盯着命令行输出"Grid written to grid-1.eps",真的把 EPS 或 VTU 打开,看看细化后的单元布局是否合理。尤其是做局部细化时,图形化观察能帮你快速发现标记逻辑的错误。
第四,善用官方的 examples 目录。deal.II 安装时自带了大量教程代码,step-1 只是第一个。装好后去examples/目录里找step-1/,里面的 CMakeLists.txt 和源码都是经过严格测试的,非常适合作为起点。
我从 step-1 里最大的收获,倒不是学会了几个 API,而是彻底理解了"网格在有限元程序里不是一张静态的画,而是一棵可以被剪枝和生长的树"。这个认知让我在之后学自适应、学并行、学时变网格时,都没有再被基础概念卡住。如果你也刚开始 deal.II,沉下心把 step-1 啃完,后面会顺畅得多。