简介:这是一份基于C++实现的时域有限差分(FDTD)微带天线仿真代码库,适合电磁场与天线方向的研究生、工程师以及FDTD初学者研读。源码围绕主程序展开,并附带可执行文件与配套数据结果,覆盖网格定义、场迭代更新、PML边界条件、S参数和辐射方向图输出等核心环节;用户可修改基板介电常数、厚度或馈线尺寸,研究不同参数对天线增益、带宽及方向性的影响。压缩包共12个文件,以cpp源码、exe可执行程序、txt结果记录以及ild/ilc/tds/csm等辅助数据文件为主,整体仅363KB,结构精简。目前已有264人学习下载。代码包含可运行程序与输出文件,既便于入门者对照学习FDTD实现细节,也可作为微带天线仿真实验的参考基座,适用于课程设计、毕业课题或工程预研中的快速建模与验证。
1. 为什么微带天线仿真要自写 FDTD C++ 而不是直接开软件
微带天线设计者拿到一个 2.45 GHz 矩形贴片需求,通常会先用近似公式算长宽,再用商业全波仿真软件验证。近似公式在薄基板、低介电常数时够用,但遇到探针馈电、缝隙耦合、短路针或多层介质结构就明显失真;商业软件能处理这些几何细节,麻烦的是批量扫参、联合仿真以及把电磁内核嵌入自建工具链时,授权和接口都会限制发挥空间。FDTD 把麦克斯韦方程组在时域显式推进,一次宽频脉冲激励就能覆盖整个频段,而微带天线恰好是窄带结构,一次运行拿到全频带响应再取谐振点,比逐频点求解更适合设计初期的快速评估。用 C++ 实现 FDTD 的优势在于更新循环全是数组运算,没有隐式矩阵求解,内存峰值和单步耗时都可以预估;代价是激励源、吸收边界、材料填充和端口提取都需要自己写。这篇文章按网格设计、更新循环、微带建模、结果验证的顺序讲,重点放在最容易算错或跑偏的环节。
2. FDTD C++ 的网格与内存设计:Yee 布局、CFL 条件和 std::vector 组织
2.1 Yee 网格的场分量布置:金属边界要落在哪条棱边上
FDTD 的第一步不是写方程,而是决定电场和磁场在空间上的存放位置。Yee 网格把电场放棱边中点、磁场放面心:E_x 位于 (i+1/2, j, k),E_y 位于 (i, j+1/2, k),E_z 位于 (i, j, k+1/2);H_x 位于 (i, j+1/2, k+1/2),H_y 位于 (i+1/2, j, k+1/2),H_z 位于 (i+1/2, j+1/2, k)。这个空间错位让麦克斯韦旋度方程中的空间差分变成中心差分,每个差商的中点正好落在被更新场的位置上;时间上再错开半步,就得到不需要求解线性方程组的显式递推格式。
这个错位在微带天线建模里直接决定金属掩膜怎么写。理想导体的边界条件是切向电场为零,因此贴片和接地板必须用“棱边电场位置”来定义,而不是用一个布尔矩阵标记整个网格单元。如果只用一个单元掩膜,把所有电场分量在金属单元内清零,金属边界就等于向单元内部移动了半个网格。以一个 2.45 GHz、基板介电常数 4.4、Δx = 1 mm 的贴片为例,半个网格对应约 0.5 mm,贴片等效电长度变化约 1.5%,算出来的谐振频率会偏 2% 到 4%,这个量级足够让 -10 dB 带宽判断失真。
所以在代码里我会把 PEC 掩膜拆成 metalEx、metalEy、metalEz 三个数组,分别标记三条棱边是否位于金属内部。更新电场时按分量跳过,金属内部的电场始终为零。这样金属的几何位置和 Yee 场分量的实际位置严格对应,不会凭空多出或丢失半个网格的材料。
2.2 网格分辨率、库朗时间步长与微带基板的波长缩短
网格大小直接影响微带天线结果的可靠性。天线仿真里通常要求在最高工作频率对应的介质波长方向上放至少 20 到 40 个网格。微带基板会缩短波长,所以不能直接用自由空间波长来取网格。以 FR4(ε_r ≈ 4.4,ε_eff ≈ 3.3)为例,2.45 GHz 的自由空间波长约 122 mm,介质波长约 67 mm;若按 λ/20 取网格,步长约 3.3 mm,这对 1.6 mm 厚的基板来说太粗,厚度方向只有不到一格,谐振频率误差会非常大。经验上要保证基板厚度方向至少有 3 到 5 个网格,同时水平方向满足 λ/30 以上,仿真结果才算基本可信。
| 工作频率 | FR4(εr≈4.4)推荐网格 | Rogers RT5880(εr≈2.2)推荐网格 | 典型时间步长(Δz=0.5mm) |
|---|---|---|---|
| 2.45 GHz | 0.8~1.2 mm | 1.0~1.5 mm | 约 1.2 ps |
| 5.8 GHz | 0.4~0.6 mm | 0.5~0.8 mm | 约 0.6 ps |
| 10 GHz | 0.25~0.4 mm | 0.3~0.5 mm | 约 0.3 ps |
时间步长由库朗条件决定。三维均匀网格的稳定性条件是:
Δt ≤ 1 / (c_max × √(1/Δx² + 1/Δy² + 1/Δz²))
其中 c_max 必须取计算域中的最大相速度,通常就是真空光速 c0,因为 PML 和空气区域的存在不允许按介质中较慢的波速放大时间步。代码实现里我会用一个显式的系数 CFLN = 0.9 来控制余量:
double c0 = 2.99792458e8; double dt_max = 1.0 / (c0 * std::sqrt( 1.0 / (dx * dx) + 1.0 / (dy * dy) + 1.0 / (dz * dz))); double dt = 0.9 * dt_max;这段代码的作用是:先算出满足稳定性的理论上限 dt_max,再乘 0.9 留出安全余量。CFLN 不取 1.0 是因为实际网格中材料不均匀、PML 内部参数渐变,接近临界值时容易出现晚期不稳定性,尤其在高介电常数材料与空气交界处。取 0.9 后单步时间只会增加约 10%,对总步数影响可以忽略。
2.3 一维数组、介电常数掩膜和 PEC 棱边掩膜的 C++ 数据结构
三维场最直接的做法是vector<vector<vector<double>>>,但更新循环的索引会变成三层解引用,可读性和性能都不理想。我会把所有场分量和材料参数压成一维std::vector<double>,用一个内联函数把 (i, j, k) 映射到线性地址。这样循环访问最内层维度时内存是连续的,缓存命中率更高;而且所有掩膜数组的索引方式统一,不容易把坐标搞混。
#include <vector> #include <cstddef> #include <cmath> class FDTDGrid { public: int nx, ny, nz; double dx, dy, dz, dt; std::vector<double> Ex, Ey, Ez; std::vector<double> Hx, Hy, Hz; std::vector<double> epsR, muR; std::vector<unsigned char> metalEx, metalEy, metalEz; FDTDGrid(int nx_, int ny_, int nz_, double dx_, double dy_, double dz_) : nx(nx_), ny(ny_), nz(nz_), dx(dx_), dy(dy_), dz(dz_), Ex(nx_ * ny_ * nz_, 0.0), Ey(nx_ * ny_ * nz_, 0.0), Ez(nx_ * ny_ * nz_, 0.0), Hx(nx_ * ny_ * nz_, 0.0), Hy(nx_ * ny_ * nz_, 0.0), Hz(nx_ * ny_ * nz_, 0.0), epsR(nx_ * ny_ * nz_, 1.0), muR(nx_ * ny_ * nz_, 1.0), metalEx(nx_ * ny_ * nz_, 0), metalEy(nx_ * ny_ * nz_, 0), metalEz(nx_ * ny_ * nz_, 0) {} inline size_t idx(int i, int j, int k) const { return (static_cast<size_t>(i) * ny + j) * nz + k; } void setSubstrate(int k0, int k1, double er) { for (int i = 0; i < nx; i++) for (int j = 0; j < ny; j++) for (int k = k0; k < k1; k++) epsR[idx(i, j, k)] = er; } };代码逻辑说明:构造函数给所有场分量分配内存并初始化为零,介电常数默认 1.0,掩膜默认全部是空气。setSubstrate按 z 方向分层填充介质基板,k0 和 k1 是基板上下表面所在的网格层号。金属掩膜按电场分量分开,是因为 Yee 网格中同一个网格点上 Ex、Ey、Ez 并不在同一个物理位置,一个整体单元掩膜无法准确表达金属边界。
需要强调的是,使用size_t作为idx的返回类型,是为了避免网格数超过 int 上限时索引溢出。微带天线仿真规模通常在 100 万到 1000 万网格以内,int 暂时够用,但写成size_t不会损失性能,还能防止未来加密网格或把代码改造成多线程时出现隐蔽的负下标问题。
3. FDTD C++ 更新循环:磁场推进、宽频激励与 S11 提取
3.1 显式更新循环的最小实现:先由 E 推 H,再由 H 推 E
写好网格布局后,核心任务是把麦克斯韦旋度方程改写成显式时间递推。以一个完整时间步为例,次序是:先由当前时刻的电场推导下一半时刻的磁场,再由更新后的磁场推导下一时刻的电场。下面是三维 FDTD 更新循环的骨架代码:
#include <cmath> #include <vector> const double MU0 = 4e-7 * M_PI; const double EPS0 = 8.8541878128e-12; void stepH(FDTDGrid& g, double dt) { for (int i = 0; i < g.nx - 1; i++) for (int j = 0; j < g.ny - 1; j++) for (int k = 0; k < g.nz - 1; k++) { size_t p = g.idx(i, j, k); double invMu = dt / MU0; g.Hx[p] += invMu * ( (g.Ey[g.idx(i, j, k + 1)] - g.Ey[p]) / g.dz - (g.Ez[g.idx(i, j + 1, k)] - g.Ez[p]) / g.dy); g.Hy[p] += invMu * ( (g.Ez[g.idx(i + 1, j, k)] - g.Ez[p]) / g.dx - (g.Ex[g.idx(i, j, k + 1)] - g.Ex[p]) / g.dz); g.Hz[p] += invMu * ( (g.Ex[g.idx(i, j + 1, k)] - g.Ex[p]) / g.dy - (g.Ey[g.idx(i + 1, j, k)] - g.Ey[p]) / g.dx); } } void stepE(FDTDGrid& g, double dt) { for (int i = 1; i < g.nx - 1; i++) for (int j = 1; j < g.ny - 1; j++) for (int k = 1; k < g.nz - 1; k++) { size_t p = g.idx(i, j, k); if (g.metalEx[p]) { g.Ex[p] = 0.0; continue; } double invEps = dt / (EPS0 * g.epsR[p]); g.Ex[p] += invEps * ( (g.Hz[g.idx(i, j, k)] - g.Hz[g.idx(i, j - 1, k)]) / g.dy - (g.Hy[g.idx(i, j, k)] - g.Hy[g.idx(i, j, k - 1)]) / g.dz); // Ey、Ez 的更新模式与 Ex 轴对称一致,此处省略正文不展开 } }这段代码要注意几个细节:磁场更新循环不检查金属掩膜,因为 PEC 内部磁场并不为零,只是电场被强制为零;电场更新时才需要按 metalEx、metalEy、metalEz 分别跳过。另一个细节是用+=而不要用=覆盖旧值,因为 FDTD 的时间递推本身就是旧值加增量,如果覆盖会把历史场信息丢掉。更新结束后应该检查总能量,确认没有出现指数发散,这个验证方法会在最后一章给出具体实现。
3.2 宽频激励源:微带天线用软源电压缝隙而不是硬点源
根据不同仿真目的,激励源大概有三类选择:
| 激励方式 | 实现写法 | 适用对象 | 常见问题 |
|---|---|---|---|
| 硬源 | Ez[p] = pulse; | 只关心稳态场的简单结构 | 对反射波全反射,S11 完全错误 |
| 软源 | Ez[p] += pulse; | 天线端口、微带馈线激励 | 源点位置要离不连续处有足够距离 |
| 总场散射场平面波 | 在边界上对 E 和 H 同时修正 | 入射波、方向图、RCS 计算 | 只适用于线性无源媒质 |
微带天线最常用的是软源电压缝隙:在微带线上截一个 2 到 3 个网格的小缝,沿 z 方向施加 Ez 电场,相当于在端口处加了一个集总电压源。用+=把脉冲加到现有场上,电磁波从缝隙向两侧传播,反射回来的波可以自由穿过源区,不会像硬源那样被直接弹回。
// 高斯脉冲源参数:t0 为脉冲峰值时刻,tau 控制脉冲宽度 double t = n * dt; double t0 = 4.0 * tau; double pulse = std::exp(-((t - t0) * (t - t0)) / (tau * tau)); // 在缝隙位置(si, sj, sk)沿 z 方向施加软源 for (int k = sk; k <= sk + 1; k++) { size_t p = grid.idx(si, sj, k); grid.Ez[p] += pulse; }脉冲宽度 tau 要由最高仿真频率 f_max 决定,一般取 tau 小于等于 1 / (π × f_max),否则高频段频谱能量下降太多,S11 曲线在高频端会因信噪比不足而出现抖动。t0 取 4 倍 tau 是为了让脉冲从零平滑上升,避免初始时刻出现阶跃旁瓣。
3.3 从时域波形到 S11 参数:端口电压电流和离散傅里叶变换
有了激励源和更新循环,接下来要把仿真结果变成天线工程师熟悉的 S11 曲线。常见做法是在馈电缝隙处记录时域电压 V(t) 和电流 I(t),电压通过对沿缝隙高度的 Ez 字段做线积分得到,电流通过计算环绕源点的磁场环路得到。得到 V(t) 和 I(t) 后输入阻抗在频域的表达式是:
Z_in(ω) = V(ω) / I(ω)
S11(ω) = (Z_in(ω) − Z_0) / (Z_in(ω) + Z_0)
离散傅里叶变换不需要在整个仿真跑完后才做,可以在时间循环里递推累积。下面代码展示了在旁边存下每个频点的复电压和复电流:
std::vector<double> freqList; // 要评估的频点,比如 2.0 GHz 到 3.0 GHz std::vector<std::complex<double>> Vf(freqList.size()), If(freqList.size()); for (int n = 0; n < totalSteps; n++) { // 更新磁场、电场,施加激励源 // 记录当前时刻的 V 和 I double V = 0.0, I = 0.0; // ... 从场分量计算端口电压电流 ... for (size_t f = 0; f < freqList.size(); f++) { double phase = 2.0 * M_PI * freqList[f] * n * dt; std::complex<double> rot(cos(phase), -sin(phase)); Vf[f] += V * rot * dt; If[f] += I * rot * dt; } } for (size_t f = 0; f < freqList.size(); f++) { std::complex<double> Zin = Vf[f] / If[f]; std::complex<double> s11 = (Zin - 50.0) / (Zin + 50.0); double s11dB = 20.0 * log10(std::abs(s11)); // 输出频率、S11 幅度和相位 }这段代码的要点是前面频进的 DFT 结果必须除以仿真总时长归一化吗?不,只需将每步增量累积即可;相位因子中-sin(phase)对应的是复指数e^{-jωt}的展开。Z_0 取 50 还是其他值,取决于微带馈线的特性阻抗,而微带线本身是否严格是 50Ω 又取决于线宽和网格取整,这点在第 4 章展开讲。
4. 微带天线 FDTD 建模的三个硬细节:馈线宽度、PML 参数与介质边界
4.1 用整数网格近似 50Ω 微带馈线:宽度取整和端口阻抗校准
微带天线的馈线若直接连接 50Ω 微带线,就需要在网格中表示出特定宽度。以厚度 h = 1.6 mm 的 FR4 为例,50Ω 微带线的宽度约 3.0 mm。若 Δx = 1 mm,则线宽正好取 3 个网格;若 Δx = 0.8 mm,则是 3.75 个网格,取 4 格后线宽变成 3.2 mm,特性阻抗大约会降到 47Ω。这个偏差在 S11 计算中会表现为端口参考阻抗失配,导致测量出的 S11 与仿真不一致。处理方式有两种:一是调整全局网格,让线宽尽量接近整格数;二是先按取整后的线宽重新算实际 Z_0,并把 S11 公式里的参考阻抗改掉。
我一般会先用下面的近似公式估算 50Ω 对应的线宽,再决定网格大小:
// Hammerstad 近似,适用于 w/h > 1 的宽微带线 double h = 1.6e-3; double er = 4.4; double Z0_target = 50.0; double A = Z0_target / 60.0 * sqrt((er + 1.0) / 2.0) + (er - 1.0) / (er + 1.0) * (0.23 + 0.11 / er); double w = h * 8.0 * exp(A) / (exp(2.0 * A) - 2.0); // 先从窄带公式起步实际手算时可以直接查微带线设计表,或者用开源工具算完再填进代码。关键步骤是:先定网格步长,再看取整后的线宽偏离目标值多少,最后选择是否微调基板厚度或介电常数来补偿。如果偏差超过 3%,推荐改网格而不是改材料,因为材料参数一变,贴片谐振频率也会跟着漂移。
4.2 PML 吸收边界的层数、电导率渐变和放置距离
开放空间的微带天线必须在计算域外围设置吸收边界,否则辐射波会在边界处反弹回来污染 S11。常见的做法是使用 PML 吸收层。PML 参数好坏直接影响仿真动态范围:
| PML 参数 | 常用取值 | 参数过大 | 参数过小 |
|---|---|---|---|
| 层数 npml | 8~12 | 内存和步数浪费 | 低频反射明显,S11 出现波纹 |
| 渐变阶数 m | 3~4 | 相邻层电导率跳变大,产生数值反射 | 吸收效率不足 |
| 反射系数 R0 | 1e-3~1e-4 | 反射偏大 | 需要更厚的 PML 或更复杂渐变 |
PML 的实现方式有很多,最传统的是 Berenger 分裂场 PML,现代 FDTD 代码里更常用单轴各向异性 PML。无论哪种,电导率分布都采用从内到外逐渐增大的曲线,典型形式是:
σ(z) = σ_max × (z / d)^m
其中 z 是 PML 内部到内边界的距离,d 是 PML 厚度,σ_max 按理论反射系数 R0 计算。对于 C++ 代码,我会在一个数组里预先算好每层 PML 的电导率,避免更新循环中重复计算指数或幂函数:
const int npml = 10; const double R0 = 1e-4; const double m = 3.0; const double d = npml * dz; const double sigma_max = -(m + 1) * log(R0) / (2.0 * 377.0 * d); std::vector<double> sigmaPML(npml); for (int k = 0; k < npml; k++) { double ratio = (k + 0.5) / npml; sigmaPML[k] = sigma_max * std::pow(ratio, m); }代码中的0.5偏移是为了让电导率取样在 PML 层中心,而不是边界上。σ_max 公式来自平面波垂直入射的理论反射系数,实际使用中还要注意 PML 外侧还需要一层 PEC 边界,并且 PML 内部场分量需要做裂场或辅助变量处理。PML 离天线太近会把天线的表面波在 PML 入口处反射回来,工程经验是 PML 内边界距天线至少留 0.5 个自由空间波长。
4.3 介质基板表面与贴片边缘:介电常数平均和棱边掩膜
微带天线中基板表面是电介质与空气的交界。Yee 网格更新 Ex、Ey 时,需要用到电场所在位置的介电常数。如果网格边界刚好落在基板表面,而代码只用当前网格位置的 epsR,那么介电常数会在空气和 FR4 之间跳变,造成场分布误差。一个简单的处理是在介质边界处对介电常数做平均:
// 在k层和k+1层之间取平均介电常数作为边界棱边位置的等效值 double epsAvg = 0.5 * (epsR[idx(i, j, k)] + epsR[idx(i, j, k + 1)]);这种做法虽然粗糙,但对矩形贴片天线已经足够。若需要更精确的结果,推荐采用亚网格或者保角技术,不过那会让代码复杂度上升一个量级。金属贴片边缘的处理原则是:沿贴片边界的切向电场必须位于金属棱边掩膜上,如果贴片边缘跑到了网格中间,需要移动金属掩膜或者对掩膜做细网格处理。最终检查时看一眼电场幅度分布图,若贴片边缘出现不正常的场尖峰,通常就说明金属边界和电介质边界没有对齐。
5. FDTD C++ 微带天线仿真的三个快速验证技巧
5.1 网格收敛性检查
写完代码后不能直接信一组仿真的 S11。最快速的验证是网格收敛测试:用当前网格 Δx 跑一次,再把网格加密到 Δx / 2 跑一次,比较两次 S11 最低点对应的谐振频率。二者偏差小于 1% 才认为网格基本收敛,偏差在 1% 到 3% 时可以接受但需要标注不确定性;超过 3% 就要重新加密网格。收敛测试不需要做全频带,只取谐振频点附近窄带即可,能节省一半以上的计算时间。
5.2 用解析公式对照贴片尺寸和谐振频率
对矩形微带贴片,主模谐振频率可以由半波长公式估算:
f_r = c / (2 × (L + 2ΔL) × √ε_eff)
其中 L 是贴片物理长度,ΔL 是由边缘场引起的等效伸长,ε_eff 是有效介电常数。把用 FDTD 算出的谐振频率与这个公式对比,如果偏差在 2% 以内,说明贴片尺寸、介质填充和端口设置基本正确;偏差偏大时优先检查基板介电常数是否赋对、贴片长度对应的是不是最长边方向的主模,以及端口源是否放在了贴片的谐振点附近。
5.3 用能量监视器发现 PML 不稳定和库朗条件破坏
PML 参数设置错误或 dt 略超库朗条件时,仿真往往在前几百步看着正常,几千步后开始指数发散。这类问题很难从单点波形发现,但把计算域总能量随时间画出来就非常明显。总能量定义为:
E_total = Σ (0.5 × ε × |E|² + 0.5 × μ × |H|²) × ΔV
在源停止注入后,总能量应单调下降,因为 PML 在吸收辐射波。如果能量曲线出现上升或反复震荡不衰减,说明 PML 不稳定或库朗余量不够。下面代码用于在更新循环里递推计算总能量:
double energy = 0.0; for (size_t p = 0; p < grid.Ex.size(); p++) { energy += 0.5 * EPS0 * grid.epsR[p] * (grid.Ex[p] * grid.Ex[p] + grid.Ey[p] * grid.Ey[p] + grid.Ez[p] * grid.Ez[p]); energy += 0.5 * MU0 * (grid.Hx[p] * grid.Hx[p] + grid.Hy[p] * grid.Hy[p] + grid.Hz[p] * grid.Hz[p]); } energy *= grid.dx * grid.dy * grid.dz;把这行代码放进每个时间步末尾,最后把 energy 序列写到文件里。判断标准是:源结束后的前几百步能量应平滑下降,下降速率由 PML 反射率决定;如果能量在 20 步内突然上升,直接检查 dt 系数是否超过 0.9,或者把 PML 层数从 10 加到 12 再跑一次。另一个把能量曲线做对数坐标观察,直线衰减不出折点通常说明 PML 工作正常。
本文还有配套的精品资源,点击获取