
简介一套基于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轨道半径正弦/余弦调和改正振幅mDelta n平均角速度修正rad/sM0参考时刻平近点角radCuc / Cus纬度幅角正弦/余弦调和改正振幅rade轨道偏心率-sqrt(A)轨道长半轴平方根m^0.5toe星历参考时刻sCic / Cis轨道倾角正余弦调和改正振幅radOMEGA0升交点赤经radi0轨道倾角radomega近地点幅角radOMEGA dot升交点赤经变化率rad/sIDOT轨道倾角变化率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 / TYPE和PRN / # 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^2Delta n 来自广播星历。第二步解偏近点角 EM 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_u、delta_r、delta_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 一致的坐标结果。本文还有配套的精品资源点击获取