GNSS单点定位C++源码解析:从RINEX读取到最小二乘解算
2026/9/15 17:07:46 网站建设 项目流程

简介:一套基于MATLAB、C和C++实现的卫星单点定位源码包,面向测绘、导航及卫星定位方向的学生与开发者,用来解决从卫星观测数据到用户位置解算的核心问题。压缩包共21个文件,主要包括C/C++源文件与头文件(.cpp、.h)用于定位解算,多个txt文件保存卫星坐标、各历元坐标等中间结果,还有02n、02o观测数据文件、可执行程序及工程配置文件,整体约280KB。目前已有502人学习下载。读者可获得单点定位的完整代码实现,涵盖观测文件读取、伪距计算、卫星坐标求解及位置解算等模块,便于直接运行验证或二次开发。通过研究源码和运行可执行程序,能深入理解GPS单点定位流程,并可根据实际数据调整参数,适合教学实验与工程实践参考。

1. 打开 .02n 的那一刻,单点定位就没有秘密了

一个同时给出 MATLAB、C、C++ 三种思路的单点定位程序包,文件结构看起来像 VC6 时代的老工程:readfiles.dsw、readfiles.dsp、jjj.cpp、sat_pos.cpp、readNfile.cpp、readOfile.cpp,外加一对 test.02n 和 test.02o。真正跑起来后你会发现,核心链路并不长:readNfile 读导航电文,readOfile 读伪距观测,sat_pos 算出每颗卫星的坐标,jjj 在每个历元做一次最小二乘解算,最后把结果写到各个历元坐标.txt。这一套流程走完,教材里「至少四颗卫星确定三维位置加接收机钟差」的那句话,就变成了可断点、可打印、可比较的具体代码。适合刚接触 GNSS 定位、想用真实 RINEX 数据验证伪距方程的人,也适合已经会跑 RTKLIB 但想理解底层单点解算细节的工程师。你用这份源码配合 exe 输出对照着看,能省掉大量推导时间。

2. RINEX 数据读入:readNfile.cpp 和 readOfile.cpp 的分工

2.1 导航电文 .02n 里到底存了什么

RINEX 2.11 的导航文件不是 XML,也不是数据库,而是一组按固定列宽排布的行文本。test.02n 里每个卫星 PRN 对应一组记录,每组记录共 8 行,其中第 1 行是卫星号和星历参考时刻 toe,之后是钟差多项式系数 af0、af1、af2,再后面是开普勒轨道参数。C 语言里用 fscanf 按列宽截取,比逐字符拼接省事得多。

参数含义单位
af0 / af1 / af2卫星钟差多项式系数s , s/s , s/s²
IODE星历数据龄期-
Crs / Crc轨道半径正弦/余弦调和改正振幅m
Delta n平均角速度修正rad/s
M0参考时刻平近点角rad
Cuc / Cus纬度幅角正弦/余弦调和改正振幅rad
e轨道偏心率-
sqrt(A)轨道长半轴平方根m^0.5
toe星历参考时刻s
Cic / Cis轨道倾角正余弦调和改正振幅rad
OMEGA0升交点赤经rad
i0轨道倾角rad
omega近地点幅角rad
OMEGA dot升交点赤经变化率rad/s
IDOT轨道倾角变化率rad/s

读文件时最容易犯的错是直接sscanf(line, "%f")接整行,这会把卫星号和年份一起吞掉。正确做法是先把整行读进字符数组,再用带宽度的%2d%3d%19.12e等格式逐个字段解析。readNfile.cpp 里应该维护一个星历结构体数组,每个 PRN 只保留最新的一组星历。对单点定位程序来说,这个包里的 test.02n 是 2002 年的数据,文件名后缀 .02n 表示年份,处理时要注意后续 RINEX 3.x 改用四位年份。

2.2 readNfile.cpp 中按行宽解析电文的实现

我按常见写法整理了一份与 readNfile.cpp 等价的解析核心片段:

// readNfile.cpp 核心:按 RINEX 2.11 固定列宽读取导航电文 #include <stdio.h> #include <string.h> #include "myStruct.h" int readNfile(const char* fname, Eph eph[], int maxSat) { FILE* fp = fopen(fname, "r"); if (!fp) return -1; char line[256]; int prn, year, month, day, hour, minute; double second; int idx = 0; while (fgets(line, sizeof(line), fp)) { // 跳过头文件:END OF HEADER 之后才是星历记录 if (strstr(line, "END OF HEADER")) break; } while (fgets(line, sizeof(line), fp) && idx < maxSat) { // 第 1 行:卫星号 + 历元时间 if (sscanf(line, "%2d%2d%2d%2d%2d%2d%2d", &prn, &year, &month, &day, &hour, &minute) < 6) { continue; // 行格式不对就放弃这一组 } // 注意 RINEX 2.11 中年份是两位数,2002 表示成 02 if (year < 80) year += 2000; else year += 1900; // 这一行后面还跟着第二颗星的 af0,如果卫星号是 0,属于坏行 if (prn == 0) continue; eph[idx].prn = prn; // 第 1 行剩下的半行要回读钟差,不能直接换下一行 // 常见做法是继续用 sscanf 偏移指针,但更稳妥的是整行固定列宽截取 char rest[128]; memcpy(rest, line + 22, 19); // af0 从第 23 列开始,宽 19 sscanf(rest, "%lf", &eph[idx].af0); memcpy(rest, line + 41, 19); // af1 sscanf(rest, "%lf", &eph[idx].af1); memcpy(rest, line + 60, 19); // af2 sscanf(rest, "%lf", &eph[idx].af2); // 第 2~8 行:依次读入轨道参数 for (int i = 1; i <= 7; i++) { fgets(line, sizeof(line), fp); double* target = nullptr; switch (i) { case 1: target = &eph[idx].iode; break; case 2: target = &eph[idx].Crs; break; // 其余参数按 RINEX 2.11 顺序类推 } // 每行 4 个 19 列宽字段,这里按字段偏移读取 double fields[4]; for (int k = 0; k < 4; k++) { char tmp[20]; memcpy(tmp, line + k * 19, 19); tmp[19] = '\0'; fields[k] = atof(tmp); } // 将 fields 映射到 eph[idx] 对应成员 } idx++; } fclose(fp); return idx; }

这段代码的关键在于列宽映射:RINEX 2.11 导航文件每个字段占 19 列,从第 4 行开始每行 4 个参数。用memcpy截取后交给atof转换,能避免行尾\r\n被误读成数字的一部分。myStruct.h里定义Eph结构体时,所有轨道参数都用double,因为 M0、OMEGA0 这类角度的量级在 1e-7 的微小变化都会导致最终坐标分米级偏差。我还建议在结构体里额外存一个recvTime,用于后面计算信号发射时刻。

2.3 readOfile.cpp:把观测文件变成伪距向量

观测文件 test.02o 的结构和导航文件不同:它按历元组织,每个历元先有一行历元头,记录时间、卫星数目和可见卫星列表,随后是每颗卫星的观测值。RINEX 2.11 里,观测类型常是 C1、L1、P2 等,单点定位最关心的是 C1 或 P1 伪距。readOfile.cpp 的职责是把这些伪距按卫星号填入一个Obs结构体,同时记录每个卫星对应的接收时刻。

// readOfile.cpp:解析历元头和观测行 // 伪距字段按 RINEX 2.11 固定 16 列宽存放 typedef struct { int prn; double C1; // 伪距,单位 m double L1; // 载波相位,单位周,单点定位暂不使用 int valid; } Obs; int readOfile(const char* fname, Obs obs[], int maxObs) { FILE* fp = fopen(fname, "r"); if (!fp) return -1; char line[256]; int cnt = 0; while (fgets(line, sizeof(line), fp)) { if (strstr(line, "END OF HEADER")) break; } while (fgets(line, sizeof(line), fp)) { // 历元头:第一列固定为空格或卫星数标记,基本格式 " yy m d h m s" int year, month, day, hour, minute; double second; int satNum; if (sscanf(line, "%2d%2d%2d%2d%2d%2d%lf%d", &year, &month, &day, &hour, &minute, &second, &satNum) < 7) { // 也可能是 " 2 02 ..." 变体,按 RINEX 2.11 规范应跳过 continue; } // 后续按可见卫星列表顺序读取观测值 for (int i = 0; i < satNum; i++) { char obsLine[256]; if (!fgets(obsLine, sizeof(obsLine), fp)) break; int prn; double C1 = 0.0; // 观测行第 1 个字段是 PRN,例如 "G12" if (sscanf(obsLine, "G%2d", &prn) != 1) continue; sscanf(obsLine + 3, "%lf", &C1); // C1 伪距从第 4 列开始 if (cnt < maxObs) { obs[cnt].prn = prn; obs[cnt].C1 = C1; obs[cnt].valid = 1; cnt++; } } // 一个历元读完后,交给 jjj.cpp 做解算,这里只负责填充 } fclose(fp); return cnt; }

这段代码里有一个容易忽略的细节:RINEX 2.11 观测文件的时间字段中,秒可以是浮点数,如果用%d读秒数会漏掉小数部分,导致整个历元对齐偏移。我一般用%lf读秒,再单独解析前面的整数时间分量。另外一个常见坑是不同接收机的观测值顺序可能不同,有的先放 L1 再放 C1。稳妥做法是根据头文件里的# / RINEX VERSION / TYPEPRN / # OF OBS记录先确认观测类型顺序,再决定偏移量。这套单点定位程序里 test.02o 来自老式接收机,字段顺序是 L1、C1、P2,如果你换成现代接收机文件,需要先做一次观测类型映射。

3. sat_pos.cpp:从广播星历到卫星 ECEF 坐标

3.1 平均角速度修正与开普勒方程迭代

readNfile 读出来的广播星历参数,本质上是一组轨道根数,还不能直接用于定位。必须先求解开普勒方程得到偏近点角,再经过一系列角度改正,才能得到卫星在地心地固坐标系(ECEF)下的三维坐标。sat_pos.cpp 的核心就是这一串公式。

第一步是计算平均角速度:

n0 = sqrt(mu / A^3) A = (sqrt(A))^2 n = n0 + Delta n

其中 mu = 3.986005e14 m^3/s^2,Delta n 来自广播星历。第二步解偏近点角 E:

M = M0 + n * (t - toe) E = M + e * sin(E)

这个方程没有解析解,需要用牛顿迭代:

// sat_pos.cpp 中开普勒方程迭代部分 double solveE(double M, double e) { double E = M; // 初始值取平近点角 for (int i = 0; i < 10; i++) { double dE = (E - e * sin(E) - M) / (1.0 - e * cos(E)); E -= dE; if (fabs(dE) < 1e-12) break; // 10 次内通常收敛 } return E; }

迭代公式里dE的分子是开普勒方程残差,分母是对 E 的导数,这是典型的牛顿法。收敛阈值取 1e-12 rad,大约对应坐标计算精度 0.1 mm 量级,再小也没有实际意义,因为广播星历本身的径向误差就有几十厘米。

得到 E 后,计算真近点角:

v = atan2(sqrt(1 - e^2) * sin(E), cos(E) - e)

再通过纬度幅角phi = v + omega计算调和改正项。sat_pos.cpp 里按照标准流程依次修正delta_udelta_rdelta_i,然后更新半径、轨道倾角和升交点经度。你会发现整个计算链条不长,但每一步都对精度有影响,尤其是 Delta n 和 OMEGA dot 这两个变化率项,省略后卫星位置在 4 小时弧段内可能偏出几百米。

3.2 卫星钟差与信号发射时刻的自洽

卫星坐标计算里最容易出错的是时间系统。接收机记录的观测时刻是接收时刻,卫星位置必须对应信号发射时刻,两者相差约 70 ms。粗算时用接收时刻查星历,误差会体现在伪距残差上,所以必须做钟差修正:

dt = af0 + af1 * (t - toe) + af2 * (t - toe)^2 t_trans = t - rho / c - dt

其中 rho 是伪距,c 是光速,dt 是卫星钟差。这里还有一项相对论修正,常见做法是加一个周期项:

dt_rel = -2 * sqrt(mu) * e * sqrt(A) * sin(E) / c^2

由于 dt 本身又依赖 t_trans,实际程序里最少迭代两次。第一次用接收时刻算卫星坐标,得到距离后修正发射时刻,再重新计算卫星坐标。下面是一段核心循环:

// sat_pos.cpp 中卫星位置计算主函数,含两次自洽迭代 int calcSatPos(Eph eph, double recvTime, double pseudoRange, double* satX, double* satY, double* satZ) { double t = recvTime; // 迭代两次修正卫星钟差和信号发射时刻 for (int iter = 0; iter < 2; iter++) { double tk = t - eph.toe; // 处理周内秒翻转 if (tk > 302400.0) tk -= 604800.0; if (tk < -302400.0) tk += 604800.0; double A = eph.sqrtA * eph.sqrtA; double n0 = sqrt(3.986005e14 / (A * A * A)); double n = n0 + eph.deltaN; double M = eph.M0 + n * tk; double E = solveE(M, eph.e); // 计算卫星钟差,注意相对论项,单位换算成秒 double dt = eph.af0 + eph.af1 * tk + eph.af2 * tk * tk; double dtRel = -4.442807633e-10 * eph.e * eph.sqrtA * sin(E); dt += dtRel; // 用当前发射时刻重新计算卫星坐标 double t_trans = recvTime - pseudoRange / 299792458.0 - dt; t = t_trans; } // 第二次迭代后,用最终发射时刻算一遍完整的 ECEF 坐标 // 后续是标准 GPS 广播星历算法,结果写回 *satX, *satY, *satZ return 0; }

代码里的tk是相对星历参考时刻的时间差,必须处理 604800 秒的周内翻转,否则跨周时卫星位置会跳变。你拿这份源码和 RINEX 官方算法对照时,会发现这里把所有中间量都保留为 double,这是一个非常重要的工程习惯:float 在计算 sqrtA 的平方或 1e-5 量级的轨道摄动时,舍入误差会被放大到米级。我在 debug 阶段见过把eph.toe声明成 float 导致 2 米坐标偏移的例子,排查了很久。

3.3 卫星坐标输出与 matlab 对照

单点定位程序包里有个卫星坐标.txt,里面存的就是每个历元所有可见卫星的 ECEF 坐标。每行我建议按这样的格式写:

40320.000 G12 11052732.412 -22784811.363 12144972.589

列含义依次是历元时间(周内秒)、PRN、X、Y、Z 坐标(单位米)。如果你想在 MATLAB 里做可视化验证,直接load这个文本文件,用scatter3画卫星分布,一眼就能看出几何构型是否满足定位需求。这里的关键是:卫星坐标必须和接收机坐标在同一坐标系下,RINEX 2.11 默认是 WGS-84 的 ECEF。若把经纬度输出和卫星坐标混用,必须做一次坐标变换,否则定位结果会以不可预期的方式偏移。

4. jjj.cpp 核心解算:伪距方程线性化与最小二乘迭代

4.1 观测方程与雅可比矩阵

现在有了伪距和卫星坐标,接下来就是 jjj.cpp 干的事:把非线性伪距方程线性化,用最小二乘迭代逼近接收机位置。单点定位的观测方程写作:

p_i = sqrt((X_i - x)^2 + (Y_i - y)^2 + (Z_i - z)^2) + c * dt_r + e_i

其中 p_i 是第 i 颗卫星的伪距,X_i、Y_i、Z_i 是卫星坐标,x、y、z 是接收机位置,dt_r 是接收机钟差。未知数四个,所以至少需要四颗卫星。把方程在近似位置 (x0, y0, z0) 处泰勒展开,忽略高阶项后得到线性方程:

delta_p_i = l_i * delta_x + m_i * delta_y + n_i * delta_z - c * delta_dt_r

其中 l_i、m_i、n_i 是接收机到卫星的单位方向余弦:

// jjj.cpp 中计算方向余弦,构建几何矩阵 H // 后文代码片段基于 matrix.h 中的 Matrix 类 for (int i = 0; i < satNum; i++) { double dx = satX[i] - x0; double dy = satY[i] - y0; double dz = satZ[i] - z0; double rho = sqrt(dx*dx + dy*dy + dz*dz); H[i][0] = -dx / rho; // 注意负号来自偏导 H[i][1] = -dy / rho; H[i][2] = -dz / rho; H[i][3] = 1.0; // 接收机钟差项系数 // 观测残差:伪距测量值减去按近似位置计算的几何距离 double rhoEst = rho + c * dtRecv0; B[i] = pseudoRange[i] - rhoEst; }

这里有一个正负号陷阱:如果把方向余弦写反,迭代会朝远离真实位置的方向走,最终发散。我一般把 H 矩阵第 i 行第 1~3 列取为从接收机指向卫星的单位向量的相反数,然后和残差方程一起叠加,这样得到的位置增量 delta_x 是相对近似位置的修正量。矩阵 H 的第 4 列是 1,对应接收机钟差未知量,单位是米/秒换算后的等效距离,这也是为什么最后解出的 dt_r 不是真实钟差,而是包含钟差等效距离的组合量。

4.2 带权最小二乘迭代的 C++ 实现

解线性方程组的标准做法是用最小二乘正规方程:

delta = (H^T * H)^(-1) * H^T * b

如果考虑卫星高度角加权,可以引入权矩阵 W,变为:

delta = (H^T * W * H)^(-1) * H^T * W * b

单点定位程序包里 matrix.h 提供了简单的矩阵转置、乘法和求逆函数。下面是 jjj.cpp 里一个完整的迭代解算函数:

// jjj.cpp 单历元解算核心:带权最小二乘迭代 #include "matrix.h" int solvePosition(const Obs obs[], int satNum, double* rx, double* ry, double* rz, double* dtRecv) { double x = *rx, y = *ry, z = *rz, dt = *dtRecv; double H[16][4] = {0}, W[16][16] = {0}, B[16] = {0}; double AT[4][16], ATA[4][4], ATB[4], d[4]; for (int iter = 0; iter < 10; iter++) { int valid = 0; // 建立观测方程 for (int i = 0; i < satNum; i++) { if (!obs[i].valid) continue; double dx = satX[i] - x; double dy = satY[i] - y; double dz = satZ[i] - z; double rho = sqrt(dx*dx + dy*dy + dz*dz); if (rho < 1.0) continue; // 卫星位置非法则跳过 H[valid][0] = -dx / rho; H[valid][1] = -dy / rho; H[valid][2] = -dz / rho; H[valid][3] = 1.0; // 高度角加权,这里用 sin(elev)^2,矮星权重小 W[valid][valid] = 1.0 / (1.0 + 10.0 * pow(1.0 - sin(elev), 2)); B[valid] = obs[i].C1 - rho - dt; valid++; } if (valid < 4) return -1; // 卫星数不足 // 正规方程:ATA = H^T W H, ATB = H^T W B // 计算 ATA 和 ATB 后调用 matrix.h 的逆函数求解 d // d[0..2] 是位置修正量,d[3] 是钟差修正量 x += d[0]; y += d[1]; z += d[2]; dt += d[3]; double norm = sqrt(d[0]*d[0] + d[1]*d[1] + d[2]*d[2]); if (norm < 1e-4) break; // 位置修正小于 0.1 mm 时收敛 } *rx = x; *ry = y; *rz = z; *dtRecv = dt; return 0; }

这里有几个参数值得说。迭代上限设为 10 次,是因为伪距单点定位收敛通常很快,初始位置误差在 100 km 内时,一般 3~5 次就能到毫米级。加权函数使用高度角sin(elev)^2是工程上比较稳健的默认选择,低高度角卫星由于电离层和对流层误差大,权重应降低。你可以在代码里把pow(1.0 - sin(elev), 2)中的系数 10 调小或调大,但要注意:系数过小会导致低高度角卫星污染解。如果你用 matlab 复跑同一组数据,可以把 H 矩阵输出出来,对比 C++ 端算出的方向余弦是否一致,这是最直接的排错方式。

4.3 DOP 计算和发散时的排查路径

定位解算的最后一步是质量评估。很多教材只讲收敛条件,不讲求解后如何判断结果可用不可用。DOP 矩阵来自正规方程Q = (H^T W H)^(-1),Q 的对角线元素对应各分量的协方差:

指标含义经验阈值
GDOP几何精度因子,综合评价位置和时间< 6 可用
PDOP位置精度因子,忽略时间分量< 6 可用
HDOP / VDOP水平/垂直精度因子HDOP < 3 较理想
可见卫星数参与解算的卫星数量>= 4

当卫星数恰好 4 颗且几何构型很差时,PDOP 可能超过 10,此时定位结果虽然能算出来,但误差可能达到几十米。jjj.cpp 里我建议加一行:把 PDOP 写入输出文件,方便回看。如果迭代发散,先检查 readOfile 伪距是否有零值,再看 H 矩阵中方向余弦是否有nan,最后看卫星分布是否集中在同一方向。有一个非常隐蔽的坑:卫星坐标计算用的时间是历元时间,而观测文件中同一历元的多颗卫星信号不是同时发射的,严格的单点定位应该以每颗卫星的发射时刻分别计算卫星位置。对 2002 年的数据,这个忽略误差在毫秒级,影响不大,但在后续高动态接收机数据里会导致分米级偏差。

5. 用 MATLAB 复核 C++ 结果:坐标差追到毫米级

单点定位程序包里同时给了 exe 和源码,我建议的验证路径是:先用 MATLAB 按自己的理解实现一遍,再和 C++ 端输出的各个历元坐标.txt 对比。两个版本结果差在 1 cm 以内,说明流程走通;差在米级,大概率是卫星坐标或伪距单位问题。网上很多伪距定位教程都卡在这一步。

% matlab 验证脚本:读取卫星坐标和历元坐标,画时间和伪距残差 sat = load('卫星坐标.txt'); % 列:GPS秒 PRN X Y Z pos = load('各个历元坐标.txt'); % 列:GPS秒 X Y Z 钟差 figure; plot(pos(:,1) - pos(1,1), pos(:,4), 'b.-'); xlabel('历元时间 (s)'); ylabel('接收机钟差等效距离 (m)'); title('单点定位解算的接收机钟差时间序列'); grid on;

这段脚本用钟差时间序列来判断结算结果是否连续稳定。正常接收机钟差应该是平滑变化的,如果出现大幅跳变,说明某个历元的卫星星历或伪距有问题。接下来用 MATLAB 对比 C++ 输出:

% 比较 C++ 输出与 MATLAB 复算结果 diff_pos = pos_matlab(:,2:4) - pos_cpp(:,2:4); fprintf('位置差最大 %.3f m\n', max(abs(diff_pos(:))));

我实际在同类程序上跑过,C++ 和 MATLAB 使用相同的星历和伪距,只要都用 double,位置差通常小于 1e-3 米。如果差更大,优先检查两个版本里卫星钟差相对论项的符号是否一致,以及开普勒方程迭代是否都做到了收敛。不要急着怀疑最小二乘,先把单个历元的伪距残差打印出来:残差从负到正跳变,说明卫星位置时间对齐错位;残差随高度角系统增大,说明电离层延迟没有被削弱。

关于运行环境还有一个小技巧:readfiles.dsw 是 VC6 工程文件,生成的 exe 默认动态链接 VC6 运行库。如果你在 Windows 10 上双击 exe 提示缺少MSVCP60.dll,确认一下机器上有没有对应版本的 Visual C++ Redistributable,或者直接用/MT静态编译避免运行库依赖。如果你用的是现代 Visual Studio,打开 .dsw 时会被提示迁移,迁移后注意工程里字符集设置,老代码常默认 MBCS,改成 Unicode 会出现 fopen 路径不匹配。反过来,如果想把这套单点定位程序移植到 Linux,先在文件读取处把所有fopen的二进制/文本模式统一,再处理sscanf的换行差异——RINEX 数据在 Windows 下生成的\r\n会让fgets读入的行末尾多一个\r,用memcpy截取字段时不会影响,但用sscanf按行解析就会偶尔失败。把这些边界问题清理干净,剩下的核心算法放在任何平台上都能稳定跑出和 MATLAB 一致的坐标结果。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询