前阵子有个做计算声学方向的同学来问我,说手头这个课题翻来覆去就一句话“利用matlab编程实现的声波的时域差分方程”,看起来每个字都认识,但真坐到电脑前面,不知道第一步该在Matlab里敲什么。这个问题我太熟了——当年我做类似课题时,也卡在这个地方:公式推导能看懂,但一行代码都写不出来。
其实这类课题的本质并不复杂:把描述声波传播的偏微分方程,在空间和时间上离散成差分格式,然后让计算机一步步推演声压在网格上的变化。难点在于,从连续方程到离散差分、从理论公式到可运行程序、从“算出一个东西”到“确信算对了”,中间隔着好几个容易被忽略的细节。这篇文章我就完整走一遍这个流程,从为什么选时域差分入手,到计算参数怎么定,再到Matlab代码分模块怎么搭,最后讲三组验证方法和几个我亲眼见过的翻车现场。
1. 为什么要把声波方程搬到时域差分的框架里
1.1 频域方法解决不了的那几类问题
声学计算里,大家最早接触的往往是频域方法。把声压写成简谐波的叠加,假设所有场量都随时间做 e^{-iωt} 的振荡,波动方程就简化成了 Helmholtz 方程,剩下的问题变成了解一个椭圆形偏微分方程。频域方法的好处是成熟、快、有大量现成工具,对单频稳态问题特别合适。
但它有几个天然的痛点。第一,宽带信号不好处理。脉冲声、爆炸声、还有语言和音乐这类宽频信号,频域方法要么逐个频率算一遍再合成,计算量成倍上涨,要么就得做模态截断,截断选得不好就丢信息。第二,非线性效应没法碰。时域方法天然能处理波在传播过程中因为介质参数变化或大振幅导致的波形畸变,频域方法一旦碰到非线性,基本上等于重新发明一套工具。第三,瞬态过程看不全。很多实际场景关心的是“波从源出发,碰到障碍物,再绕射回来”的完整时间历程,比如噪声预测里关心反射回波的到达时刻,频域方法能告诉你总声场长什么样,但很难告诉你某一时刻某个位置的声压究竟是多少。
这时体现出了“时域差分”的价值。它的做法很直接:不给波找一个简谐假设,而是把时间也当成一个维度,让计算机一小步一小步地推着声压往前走。你按下运行键,屏幕上动画显示的是声压波阵面逐渐向外扩散、碰到边界反射回来的过程。这个优势在我做的那个课题里体现得很明显——我需要模拟一个短脉冲在带有凹槽的通道里的传播与反射,每个时刻的波阵面位置、反射时间都非常直观,换成频域方法光是把算出来的频谱还原成时间历程,就得额外做一大批逆变换。
1.2 差分方程从连续方程到离散格点的变形
时域差分的数学起点,是二维无源声波方程。均匀介质、常数声速、没有衰减的情况下,声压 p(x, y, t) 满足:
[ \frac{\partial^2 p}{\partial t^2} = c^2 \left( \frac{\partial^2 p}{\partial x^2} + \frac{\partial^2 p}{\partial y^2} \right) ]
其中 c 是声速,标准大气条件下取 343 m/s 就行。这个方程在连续世界里非常漂亮,但计算机没法直接处理无限多个点。解决办法是把空间切成网格,每个格点存一个声压值,把导数替换成相邻格点之间的差值,这就是“差分”二字的由来。
二阶导数的标准中心差分格式长这样:
[ \frac{\partial^2 p}{\partial x^2} \approx \frac{p_{i-1,j} - 2p_{i,j} + p_{i+1,j}}{\Delta x^2} ]
y 方向同理。时间方向也用同样的二阶中心差分。两个方向的近似同时代进去,整理之后得到离散推进公式:
[ p_{i,j}^{n+1} = 2p_{i,j}^{n} - p_{i,j}^{n-1} + s \left( p_{i+1,j}^{n} + p_{i-1,j}^{n} + p_{i,j+1}^{n} + p_{i,j-1}^{n} - 4p_{i,j}^{n} \right) ]
其中 s 是一个关键的系数,写作:
[ s = \left( \frac{c\Delta t}{\Delta x} \right)^2 ]
只要空间步长 Δx 定了、声速 c 定了,s 就完全由时间步长 Δt 决定。这个式子我建议每个做这个课题的人都亲手推一遍,别看它长得吓人,推完后你会清楚地看到:下一时刻某点的声压,是当前时刻它自己和周围四个邻居的加权组合,再扣除一点上一时刻的值。整个计算就仿佛在网格上不断传递信息,每走一步,声压信息就从每个格点向四周扩散一圈。这种“局部更新”的特性,也正是时域差分能够做复杂几何和瞬态问题的根本原因。
2. 计算参数定不好,程序第一步就崩:CFL条件、色散和源频率
2.1 CFL条件:时间步长和空间步长的捆绑关系
很多初学这个课题的同学,程序写完后随手填一个时间步长,比如“就定 0.001 秒吧”,跑两步就发现声压值变成了天文数字,图像一片红一片蓝,再过几步直接出现 NaN。这不是程序逻辑的锅,是没满足 CFL 稳定性条件。
CFL 条件说的是物理因果性的离散版:在一个时间步里,声波实际传播的距离是 cΔt,这个距离不能超过一个网格间距 Δx 附近的数值依赖范围。换句话说,一个格点的信息在一个时间步内最多能传到相邻格点。如果时间步长取得太大,一步之间波跑过了好几个格点,数值格式就会把这种“超光速”的信息传递变成指数增长的振荡,最终炸掉。
对于二维声波方程,稳定性条件是:
[ \alpha = \frac{c\Delta t}{\Delta x} \leq \frac{1}{\sqrt{2}} ]
二维比一维更苛刻,是因为一个点需要同时从 x 和 y 两个方向的邻居获取信息。实际做程序时我不会压着上限取,通常取 α = 0.5 留一半余量。假设计算域边长 0.2 m,划分成 301 个网格点,那么:
[ \Delta x = \frac{0.2}{300} = 6.667 \times 10^{-4} \text{ m} ]
取 α = 0.5,则:
[ \Delta t = \frac{0.5 \times 6.667 \times 10^{-4}}{343 \times \sqrt{2}} \approx 6.87 \times 10^{-7} \text{ s} ]
这个数字意味着总物理模拟时间只有几毫秒时,就需要上千个时间步。别嫌步数多,这正是时域方法的代价与优势并存的地方——每一步算得小,才能把波动的每个细节都捕捉到。
2.2 网格色散:高频分量跑得比低频分量慢
满足 CFL 条件只保证不崩,不保证算得准。第二个隐藏的坑是数值色散。真实介质里声速对所有频率是一样的,所以一个脉冲在传播过程中形状应该保持不变。但在离散网格上,高频短波分量的“数值相速度”会比低频分量略慢,结果就是一个原本干净的脉冲,跑着跑着后面拖出一串高频尾巴,波形越传越胖、越传越钝。
造成色散的根本原因是差分近似对短波长的分辨能力下降。可以用离散平面波代入差分方程推出离散频散关系,一个时间步内的频散关系是:
[ \sin^2\left(\frac{\omega\Delta t}{2}\right) = \alpha^2 \left[ \sin^2\left(\frac{k_x\Delta x}{2}\right) + \sin^2\left(\frac{k_y\Delta y}{2}\right) \right] ]
在波长远大于网格间距时,sin 函数可以近似成它的自变量,这个式子会退化成连续情况下的 ω = ck。可一旦波长缩小到接近网格尺寸,sin 项偏离线性越来越远,数值波速就开始与频率耦合。
工程上控制色散的办法,是保证最大有效频率对应的波长至少覆盖 8 到 10 个网格。假设你用 20 kHz 中心频率的高斯包络脉冲作为声源,它的有效能量能延伸到 50 kHz 附近,那最小波长是:
[ \lambda_{\min} = \frac{343}{50000} = 6.86 \times 10^{-3} \text{ m} ]
而我们刚才定的 Δx 是 6.667e-4 m,波长覆盖大约 10.3 个网格,刚好在可接受范围边缘。如果网格太粗,你看到的不是波传播,而是一团快速散开的振荡毛刺,那时再去检查程序逻辑就完全找错方向了。
2.3 源参数和网格分辨率的匹配
源的设计也是同样的逻辑。我的习惯是用高斯包络调制的正弦脉冲作为激励:
[ p_{\text{src}}(t) = \sin(2\pi f_0 (t-t_0)) \cdot \exp \left( -\left( \frac{t-t_0}{\sigma_t} \right)^2 \right) ]
中心频率 f0 取 20 kHz,时间标准差 σt 由需要的带宽决定。取:
[ \sigma_t = \frac{\sqrt{\ln 2}}{2\pi f_0} ]
让脉冲在频域有合理的展宽,但又不至于把能量推到网格无法分辨的超高频段。t0 取 6 个 σt 左右,保证脉冲起始时数值上接近零,避免生硬的截断产生额外的高频噪声。
参数之间是环环相扣的。源频率定太高,要求网格更密;网格更密,CFL 条件又强制时间步长更小;时间步长更小,同样物理时间内的迭代次数就更多。我做的第一个版本就是贪快,用粗网格跑 100 kHz 的源,结果波还没传播到边,高频毛刺已经淹没了整个信号。后面按表 1 这套参数重跑,一切才正常。
| 参数 | 符号 | 数值 | 说明 |
|---|---|---|---|
| 计算域边长 | L | 0.2 m | 二维方形区域 |
| 网格点数 | N | 301 | 含边界格点 |
| 空间步长 | Δx | 6.667e-4 m | 对应约 10 个最小波长 |
| 声速 | c | 343 m/s | 标准大气条件 |
| CFL 系数 | α | 0.5 | 二维稳定性上限的 0.707 倍 |
| 时间步长 | Δt | 6.87e-7 s | 由 α、Δx、c 共同决定 |
| 中心频率 | f0 | 20000 Hz | 高斯脉冲载频 |
| 时间标准差 | σt | 6.63e-6 s | 控制脉冲时域宽度与带宽 |
| 总迭代步数 | Nt | 1200 | 物理时间约 0.824 ms |
表格里的参数之间不需要每个都“调试到完美”,真正需要认真调整的是 Δx 与 f0 的匹配关系,其他参数都是被它们牵着的。
3. Matlab程序分模块实现:初始化、时间循环、吸收边界和可视化
3.1 初始化:计算域、场量数组与源参数
Matlab 实现这个课题,我喜欢把代码拆成几个分块,每一个分块只做一件事,这样出问题时排查起来非常快。先看初始化部分:
clear; close all; clc; % 计算域与网格参数 L = 0.2; % 计算域边长,米 N = 301; % 网格点数(含边界) dx = L / (N - 1); % 空间步长 c = 343; % 声速,m/s % CFL 条件与时间步长 CFL = 0.5; dt = CFL * dx / (c * sqrt(2)); % 二维稳定性条件 s = (c * dt / dx)^2; % 差分方程中的系数 % 源参数 f0 = 20000; % 中心频率,Hz sigma_t = sqrt(log(2)) / (2*pi*f0); t0 = 6 * sigma_t; % 脉冲延迟 Nt = 1200; % 总迭代步数 % 场量分配:三个时间层 p0 = zeros(N, N); p1 = zeros(N, N); p2 = zeros(N, N); % 源点位置:网格中心 nx_src = round(N / 2); ny_src = round(N / 2);这里一个大家容易忽略的点是为什么要分配三个数组 p0、p1、p2。因为差分公式里,计算 n+1 时刻需要用到 n 时刻和 n-1 时刻的值,所以必须额外保留一个“上一时刻”的数组,不能只开一个数组原地更新。三个数组轮流滚动,p2 算完给 p1,p1 变成 p0,这样从场量管理上就保证了时间推进的正确性。
3.2 时间推进主循环:用向量化代替三重循环
最天然的写法是三重嵌套循环:对 i、对 j、对 n。200×200 的网格跑一千步,看起来没什么,但放到实际课题里,尤其是后期需要反复试参数时,跑一次十几分钟的效率是完全不能接受的。Matlab 最核心的加速手段是向量化——把空间维度的循环交给矩阵切片运算。
主推进循环的核心就三行:
for n = 1:Nt % 1. 声源注入 t_value = (n - 1) * dt; p1(nx_src, ny_src) = p1(nx_src, ny_src) + ... sin(2*pi*f0*(t_value - t0)) * exp(-((t_value - t0)/sigma_t)^2); % 2. 整场向量化推进(内部格点) p2(2:end-1, 2:end-1) = 2*p1(2:end-1, 2:end-1) - p0(2:end-1, 2:end-1) ... + s * (p1(3:end, 2:end-1) + p1(1:end-2, 2:end-1) ... + p1(2:end-1, 3:end ) + p1(2:end-1, 1:end-2) ... - 4*p1(2:end-1, 2:end-1)); % 3. 边界处理(本版本为刚壁,边界格点不更新) % 4. 时间层滚动 p0 = p1; p1 = p2; end这段代码很多人第一次看会晕,尤其是 p1(3:end, 2:end-1) 这种切片到底对应谁。我提供一个记忆方法:p1(i+1, j) 等于这个点右边的邻居,所以用 p1(3:end, 2:end-1) 对应内部区域右移一格;p1(i-1, j) 用 p1(1:end-2, 2:end-1) 对应左移一格。y 方向的上下邻居同理。这样四条切片恰好把内部每个格点的四个邻居全部取到,s 系数一乘,就是离散差分公式的全部内容。
刚壁边界在这里非常简单:边界格点的值根本不在更新范围里,它们的 p 值始终是初始状态 0。波传到边界会被反射回来,反射点处声压加倍。如果你的课题正好需要模拟封闭空间,这个边界就够用。如果需要模拟开放空间里的传播,就得在第 3 步加上吸收边界。
3.3 吸收边界:从刚壁到简化海绵层的实现
实际声学课题很少真的需要一个全反射盒子,更多是希望波从源出发向外传播,走到计算域边缘时不产生明显反射,仿佛计算域外面还有无限空间。最严格的做法是实现卷积 PML(完美匹配层),但那是另外一篇文章的篇幅了。在这个课题里,我推荐先用一个简化海绵层把流程跑通,之后再升级严格版本。
海绵层的思路很直白:在计算域最外面留出若干层网格,每走一步,对这些层里的声压值额外乘一个小于 1 的衰减系数。衰减系数从内到外逐渐减小,形成一个“渐入式”的吸收区。实现起来只需要在初始化和主循环里各加一点东西:
% 海绵层初始化 Npml = 20; % 吸收层厚度 sigma_max = 0.1 / dt; % 吸收强度上限,实际需微调 profile = ((Npml:-1:1) / Npml).^2; % 平方剖面,内小外大 sigma_x = zeros(N, 1); sigma_x(1:Npml) = sigma_max * profile; sigma_x(N-Npml+1:N) = sigma_max * fliplr(profile); sigma_y = sigma_x; % 本例 x/y 对称 absorb_factor = exp(-(sigma_x + sigma_y') * dt); absorb_factor(1+Npml:end-Npml, 1+Npml:end-Npml) = 1; % 内部区域不衰减然后在时间推进的步骤 3 后面加一行:
p2 = p2 .* absorb_factor;这个简化方案不是严格的 PML,它对大角度入射波仍然有明显的反射,吸收层参数调不好时甚至会在层内产生新的反射源。但它对理解“吸收边界在程序里到底做了什么”非常有效:边界区域耗散能量,内部区域不受影响。等主流程完全正确了,再往严格版本的卷积 PML 迁移,核心数据结构基本不用变。
3.4 边算边看:可视化与动画输出
时域方法最好玩的地方就是能看着波慢慢长出来。在循环里每 20 步左右绘制一次图像:
if mod(n, 20) == 0 imagesc((1:N)*dx*1000, (1:N)*dx*1000, p1); axis xy; axis equal; colorbar; caxis([-1 1]); % 根据源幅度调整 title(sprintf('t = %.4f ms', n*dt*1000)); xlabel('x / mm'); ylabel('y / mm'); drawnow; end这里有两个容易踩的小细节。第一,imagesc 默认把矩阵第一维画成 y 方向,而矩阵的第一维其实是 x 方向,所以必须加 axis xy,否则左右会颠倒。第二,caxis 的色标范围要固定,不要让它每帧自动缩放,否则你会误以为波的振幅在无中生有,其实是色标在变化。固定色标后,波传播、衰减和反射都一目了然。
4. 程序跑通后怎么验证它算对了:三组独立检验
跑出图来别急着激动,图像像样不代表数值正确。我见过好几次“图像看起来是那么回事,但把某个点的数据提出来对,差了一大截”的情况。所以程序写完,第一件事是验证。
4.1 平面波自相似解对比
最简单的解析验证方案,是初始时刻在网格中间一行放一个高斯包络脉冲,取消点源,观察它分裂成左右两个半幅高斯脉冲的过程。在自由空间、无限大计算域的理想情况下,解析形式是:
[ p(x, t) = \frac{1}{2} f(x - ct) + \frac{1}{2} f(x + ct) ]
即初始扰动均匀地向两边传播,每个方向各带一半幅度,波形保持高斯形状不变,传输速度等于声速 c。实现方法:初始时把 p0 和 p1 的中间一行设成相同的高斯分布:
x_axis = (1:N) * dx; x0_pos = 0.5 * L; sigma_x_pos = 0.005; % 空间高斯宽度 p1(round(N/2), :) = exp(-((x_axis - x0_pos) / sigma_x_pos).^2); p0(round(N/2), :) = p1(round(N/2), :);然后在某几个接收点记录声压随时间变化,看波峰到达时刻是否等于距离差除以声速。比如接收点距离源线 0.05 m,那么波峰到达时刻应该大约是 0.05/343 ≈ 1.458e-4 s,也就是第 212 个时间步左右。把数值波峰时刻与理论值对比,偏差在几个时间步以内,说明离散格式的时间推进和空间传播是对的。
这种验证方案的巧妙之处在于,它不依赖复杂的格林函数或数值积分,几乎做到了“自己解自己”。同时它又能暴露最常见的问题:如果 CFL 条件没满足,你会看到波形完全变形;如果色散严重,你会看到高斯脉冲后面拖着一串小振荡。
4.2 全场能量趋势判定边界质量
第二组检验看能量的整体变化。在没有源注入、只有初始扰动的情况下,刚壁边界应该近似保持能量守恒。离散格式会有微小耗散,但总体趋势应该是平直的。当你引入吸收边界后,全场能量应当单调下降,下降曲线的形态还反过来透露吸收层的质量。
E_history(n) = sum(p1(:).^2) * dx^2;刚壁情况下,如果 E_history 在某个时刻突然明显增大或者出现剧烈振荡,说明数值格式不稳定,多半还是时间步长问题。海绵层情况下,如果 E_history 前半段下降正常,某段之后出现平台甚至回升,那基本是两个原因:吸收层反射被计算域内部再次捕获,或者吸收层参数设置形成阻抗不匹配。处理方式是把被吸收层的厚度加厚、剖面调平滑、σmax 调低一点,然后观察能量曲线的变化。这个“能量曲线诊断法”比盯着动画找反射波要靠谱得多,因为反射返回到源区附近往往已经很微弱,肉眼很难判断,但全场能量对这种全局性事件非常敏感。
4.3 网格收敛性检验
第三组验证是网格收敛性。原理很简单:真正的偏微分方程解只有一个,网格越密,数值解应该越接近它。把同样的工况分别在 N=151、301、601 网格下跑一遍,记录同一个接收点的波形,以最细网格为参考,计算粗网格解的 L2 误差。
| 网格数 | 空间步长 Δx (mm) | 相对最细网格的 L2 误差 |
|---|---|---|
| 151 | 1.333 | 1.86e-2 |
| 301 | 0.667 | 4.70e-3 |
| 601 | 0.333 | 参考解 |
表格里这个结果是我印象中一次典型运行的量级,各组结果会略有浮动,但关键要看误差比值。空间步长减半,误差大约降到原来的四分之一,这是二阶中心差分格式应有的表现。如果你算出来的误差降幅远小于四分之一,说明你的数值解还没步入收敛区,网格还需要加密;如果误差降幅忽大忽小没有规律,那往往不是格式问题,是代码里有隐藏 bug。
这三组验证做完,你的程序才算真正“算得对”,而不是“算出个东西”。之后再往里面加复杂边界、加障碍物、加不均匀介质,心里才会有一本明账——后续出现的误差到底来源于物理建模,还是来源数值格式。
5. 踩坑记录:我从这个课题里总结的五条实际经验
5.1 时间步长超标,推到一半开始爆数
这个坑几乎每个初学者都会踩。最开始我图省事,直接把 dt 写成了 1e-5,觉得“这么小应该够了吧”,结果主循环跑了几百步后 p1 里的最大值从 0.01 级别直接飙到 1e30,之后的图像全是纯色块。排查过程非常典型:最初怀疑是源的写法有问题,后来用平面波初始场测试,仍然爆数,最后回到 CFL 条件一算,发现我的 dt 是安全上限的十几倍。
经验是:时间步长永远不要手填,一定要从空间步长和 CFL 系数推导出来。程序里只定义 CFL 值,dt 由公式自动算出,这样无论怎么改网格,稳定性都有保障。
5.2 三重循环和向量化之间的性能差距
第一次完整跑通用的三重循环版,301×301 网格、1200 步,我足足等了半个多小时。改成向量化切片之后,同样的工况几秒钟跑完。这个性能差距决定了一件事:你是否敢在课题中做参数扫描。三重循环版本下,你想把 σt 调 5 组、网格调 3 组,等于要过夜等结果;向量化版本下,一杯水没喝完,一组参数就出结果了。
向量化的写法在 3.2 节已经给出了,核心就是用切片平移代替 i±1 索引。如果担心出错,我的做法是先写一个三重循环版,在小网格上跑通,确认结果正确,再用向量化版对上同一个输出,两者一致后,循环版就可以永久封存了。
5.3 海绵层参数调不好反而成了反射源
加海绵层时我犯过一个典型的过度设计错误:sigma_max 直接取了 5/dt,想着“吸收得越狠越好”。结果波进入吸收层后,还没来得及被平滑地衰减,就被急剧变化的吸收系数“弹”了回去,计算域里形成了一条清晰的假反射带。后来把 sigma_max 降到 0.1/dt,剖面改成平方渐变,反射才明显降下去。
调吸收层参数的经验法则是:吸收强度不要追求一步到位,先跑一组能量曲线看看是否有异常回升;加厚吸收层往往比单纯增大 sigma_max 更有效;真实课题中如果要严格处理边界,还是要上卷积 PML,海绵层只适合做流程验证。
5.4 读取验证波形时离源太近,波形被近场效应污染
验证平面波自相似解时,我开始把接收点放在距离源线 5 个网格的位置,结果波形怎么看都不是标准的高斯,周围的人给我建议说先检查差分格式,我再三确认格式没问题,最后把接收点移到 20 个网格之外,波形就干净了。原因是激发线附近有很强的近场效应,压缩波和稀疏波尚未完全形成稳定的平面波传播形态,数据自然不理想。
这类问题最坑人:它会让仔细检查代码、甚至怀疑格式推导,实际上程序和格式都是对的,只是测量点位选错了。以后凡是验证型的波形对比,我都会先看一眼波形是否符合物理直觉,再决定是不是要深挖程序 bug。
5.5 后处理点位选择与参考时刻一致性
最后一个坑藏在“对比”这个动作里。有一次我拿两次不同网格分辨率的波形做差值分析,怎么都对不上,最后发现两次运行里接收点索引虽然一样,但因为网格数变了,同样的索引对应的物理坐标根本不同。用索引比较网格变密前后的波形,等于拿苹果跟橘子比。
正确的做法是,后处理中一律用物理坐标,不要用数组索引。运行时把接收点定位写成“离 x=0.07 m 最近的网格点”,而不是“第 100 个点”。这个习惯能省掉无数冤枉时间,也让我后来的所有网格收敛性分析都在一个统一坐标系下完成,结果解释起来非常干净。
这个课题做到最后,回头看真正难的其实不是公式推导,也不是代码本身,而是参数之间的环环相扣,以及“算完到底对不对”这个问题怎么回答。如果你照着上面的流程把参数定好、程序分块搭好、验证三件套做完,哪怕中途碰上一两个小问题,定位起来也会快得多。之后想往里面加障碍物、加声速分层、甚至做三维拓展,框架都不用大改,只是推公式和设计边界时需要多花些心思。