简介:这份压缩包面向希望在VC6.0环境中学习GPS数据采集的开发者与高校学生,围绕串口通信、NMEA协议解析和MFC界面设计展开,可用于理解GPS接收器与计算机之间实时位置、速度和时间信息的读取流程。资源包为rar格式、约2.99MB,文件总数一项显示为0,类型明细暂未提供,其内容定位更偏向课程设计、小型采集工具原型和VC6.0串口编程练习的参考。已有126人学习或浏览。读者可从中对照串口参数配置、CreateFile/ReadFile等接口使用、GPGGA/GPGLL报文拆分与字段提取、异常与通信中断处理,以及按钮、文本框等MFC控件的实时数据显示思路,也可延伸到GPS记录保存、数据库集成和调试优化等环节,适合按模块查漏补缺。
1. 老工控机上的 VC6.0:GPS 数据采集到底在做什么
一台还在跑 VC6.0 的工控机,一条 RS232 串口线,一个每秒吐一帧$GPRMC的 GPS 模组——这就是很多人接手的「VC6.0 下编写的 GPS 数据采集的程序」的真实模样。它做的事情并不复杂:打开串口、收字节、缓存字节、按行切出 NMEA-0183 报文、校验、拆字段、把经纬度和时间落盘或上报。难的是在老编译器、老控件、老机器上让这套流程连续跑几个月不出事。这篇写给正在维护同类老工程、或者要给旧设备补定位功能的人,从串口怎么开讲到解析怎么写、坑在哪。
2. 串口这一层怎么落地:MSComm 控件与 Win32 串口 API 的选择
在 VC6.0 时代做 GPS 数据采集,串口收数基本只有两条路:一条是 MFC 项目里拖一个 MSComm 控件,靠OnComm事件驱动;另一条是直接调CreateFile/ReadFile这一套 Win32 串口 API,自己起线程读。选哪条,直接决定了后面三年的维护成本,所以先把选型说清楚再动手。
2.1 老项目为什么大多留着 MSComm:三条现实理由
第一条理由是上手快。MSComm 把打开串口、设波特率、设缓冲区、触发事件全封装成属性,一个对话框拖上去,十行代码就能看到数据进来,对当时没有多少串口经验的人来说几乎是唯一可行路径。第二条理由是和 MFC 的对话框消息循环天然贴合,GPS 采集程序通常还有个界面要显示经纬度、卫星数、定位状态,事件驱动模型下不用考虑跨线程问题。第三条理由是历史包袱:老工程里已经有几百行围绕OnComm写的逻辑,换 API 等于重写。
但 MSComm 的边界也很清楚。它是 ActiveX 控件,GetInput()返回的是VARIANT包着的SAFEARRAY,每次取数据都要走一遍 COM 的内存管理,稍不注意就是内存泄漏;它的事件在界面线程里跑,一旦你在OnComm里做解析、写文件、刷界面,串口缓冲区就会被你自己的慢代码拖爆。我的习惯是:接手维护就留着 MSComm,但要把它降级成「只负责搬运字节」的角色;如果是新写或者要大改,直接上 Win32 API 加工作线程,后面省下来的排查时间远超前期多写的两百行。
还有一点容易被忽略:MSComm 的RThreshold设成 1 表示收到 1 个字节就触发一次事件。GPS 模组以 4800 或 9600 波特率连续输出,一帧 70 字节左右的报文会触发几十次事件,界面线程被切得稀碎。参数怎么调在 2.4 节给表。
2.2 用 MSComm 打开串口并配好参数:可直接抄的一段
下面这段是对话框初始化时调用的,m_comm是拖进对话框的 MSComm 控件绑定的成员变量。注意所有Set调用都放在SetPortOpen(TRUE)之前,顺序错了参数不生效。
// 打开串口并完成参数配置,返回 FALSE 表示端口被占用或不存在 BOOL CGpsDlg::OpenPort(int nPort, long nBaud) { if (m_comm.GetPortOpen()) m_comm.SetPortOpen(FALSE); // 先关,避免重入时句柄泄漏 m_comm.SetCommPort(nPort); // 1 对应 COM1,换口只改这个数 CString sSet; sSet.Format(_T("%ld,n,8,1"), nBaud); // 波特率,无校验,8 数据位,1 停止位 m_comm.SetSettings(sSet); m_comm.SetInputMode(1); // 1 = 二进制模式,按字节收 m_comm.SetInputLen(0); // 0 = 一次把接收缓冲区读空 m_comm.SetRThreshold(1); // 收到 1 字节即触发 OnComm m_comm.SetSThreshold(0); // 不触发发送事件,省一次回调 m_comm.SetHandshaking(0); // 无流控,GPS 模组一般不用 RTS/CTS m_comm.SetNullDiscard(TRUE); // 丢弃 0x00,防止空字符搅乱组帧 m_comm.SetInBufferSize(4096); // 接收缓冲,默认 1024 偏小 m_comm.SetOutBufferSize(1024); try { m_comm.SetPortOpen(TRUE); // 真正占住端口,可能抛异常 } catch (CException* e) { e->Delete(); // VC6 里必须手动 Delete,否则泄漏 return FALSE; } return TRUE; }逻辑上就三步:把端口号和线路参数写进控件属性,设置接收行为(二进制、读空、触发阈值),最后开端口并用try/catch兜住「端口被别的程序占着」这种最常见的失败。SetInBufferSize(4096)是我加的经验值,GPS 帧很小但界面卡顿时缓冲区会堆,1024 字节大约只够十几帧,一卡就溢出丢数据。SetInputMode(1)必须写,默认的文本模式会把 0x00 到 0x1F 之间的字节当控制字符处理,NMEA 里虽然都是可见 ASCII,但模组上电瞬间可能吐乱码,文本模式下这些乱码会被改写,反而更难判断问题。
2.3 收数只做搬运:OnComm 里的 SAFEARRAY 怎么取怎么放
OnComm被触发时不要写任何业务逻辑,只做三件事:判断事件类型、把数据从VARIANT里抠出来、扔进自己的缓冲区。解析交给主循环或定时器。
void CGpsDlg::OnCommMscomm1() { if (m_comm.GetCommEvent() != 2) // 2 = EV_RECEIVE,只处理收数事件 return; VARIANT vIn = m_comm.GetInput(); // 取走整个接收缓冲区 if (vIn.vt != (VT_ARRAY | VT_UI1)) // 不是字节数组就丢弃 return; SAFEARRAY* psa = vIn.parray; long lLow = 0, lHigh = -1; SafeArrayGetLBound(psa, 1, &lLow); SafeArrayGetUBound(psa, 1, &lHigh); long nLen = lHigh - lLow + 1; if (nLen <= 0 || nLen > 8192) { SafeArrayDestroy(psa); // 异常分支同样要释放 return; } void* pData = NULL; SafeArrayAccessData(psa, &pData); // 拿到底层连续内存 m_ring.Push((const unsigned char*)pData, (int)nLen); SafeArrayUnaccessData(psa); SafeArrayDestroy(psa); // 关键:不销毁就是长期内存上涨 if (!m_bParsing) { // 防止定时器重入 m_bParsing = TRUE; ParseRingBuffer(); // 就地解析,见第 3 章 m_bParsing = FALSE; } }这里有两个必须说透的点。GetInput()返回的VARIANT里装的是新分配的SAFEARRAY,MSComm 不会帮你回收,SafeArrayDestroy(psa)漏一次就泄漏几十到上千字节,跑一天下来内存曲线是斜向上的——这就是很多现场「跑了三天越来越卡」的根子。另一个是SafeArrayAccessData与SafeArrayUnaccessData必须成对,中间这段内存是被锁住的,不Unaccess后续操作会失败。
至于m_ring这个环形缓冲,作用是解耦「收」和「解析」的速度差。GPS 每秒最多几百字节,看似不需要缓冲,但界面线程一旦去刷个地图或者写个文件,OnComm就会被延迟,延迟期间新数据仍然在到达。有了缓冲区,OnComm只管往里塞,解析跟不上就攒着,不会丢帧。
2.4 GPS 采集必须调对的六个参数
| 参数 | 常见值 | 设置位置 | 改错了会怎样 |
|---|---|---|---|
| 波特率 | 4800 或 9600 | SetSettings | 与模组不一致收到的全是乱码,但长度看起来正常 |
| 数据位/校验/停止位 | 8 / None / 1 | SetSettings | 校验位写错会丢高比特,ASCII 变成怪字符 |
| RThreshold | 1 | SetRThreshold | 设成大值会导致短帧迟迟不触发,实时性变差 |
| InputLen | 0(读空) | SetInputLen | 设成固定长度会把半帧截断,组帧逻辑全乱 |
| InputMode | 1(二进制) | SetInputMode | 文本模式改写控制字符,乱码原因查不出来 |
| InBufferSize | 4096 | SetInBufferSize | 太小会在界面卡顿时丢数据,且不报错 |
提示:波特率不要靠猜。模组手册上写的是出厂默认值,实际设备可能被上一任维护者改过。用串口助手以 4800 和 9600 各监听五秒,能读出
$GP开头的可打印字符就是对的,读出一堆方块就是错的。
3. NMEA-0183 报文解析:把$GPRMC拆成经纬度和时间
串口通了只是把字节搬进内存,真正决定这个 GPS 数据采集程序好不好用的是解析层。NMEA-0183 是一个纯文本、逗号分隔、带异或校验的协议,看着简单,但半包、粘包、空字段、南纬西经的符号这四件事能让人返工好几轮。
3.1 报文结构先看明白:四个符号决定一切
一帧典型报文长这样:$GPRMC,083559.00,A,4717.11437,N,00833.91522,E,0.004,77.52,091202,,,A*57。$是帧头,*后面两位十六进制是校验和,末尾跟\r\n。中间用逗号分成若干字段,字段可能为空——两个逗号挨在一起就表示这个字段没数据,这种情况在冷启动、卫星数不足的时候极常见。
$GPRMC的字段顺序是固定的:时间、定位状态(A 有效 / V 无效)、纬度、南北纬、经度、东西经、地面速度(节)、地面航向、日期、磁偏角、磁偏角方向、模式指示。$GPGGA则是时间、纬度、南北纬、经度、东西经、定位质量、卫星数、水平精度因子、海拔高度……两个都收,用 GGA 判断定位质量,用 RMC 拿速度和日期。
实际串口里拿到的不是整齐的一帧一帧,而是一条连续的字节流。一帧可能被切成两段分别到达(半包),也可能两帧粘在一起一次到达(粘包)。所以不要用strstr找$GPRMC然后就地atof,正确做法是:把字节流按\r\n切成行,逐行判断帧头,再解析。
3.2 校验和与字段切分:两段能直接复用的函数
// 校验和:'$' 与 '*' 之间所有字符逐字节异或,再与 '*' 后两位十六进制比较 BOOL CheckSumOk(const char* pLine) { if (!pLine || pLine[0] != '$') return FALSE; const char* p = pLine + 1; unsigned char sum = 0; while (*p && *p != '*') sum ^= (unsigned char)(*p++); if (*p != '*') return FALSE; // 没有校验位,视为不完整帧 unsigned int v = 0; if (sscanf(p + 1, "%2X", &v) != 1) return FALSE; return sum == (unsigned char)v; } // 原地切分:把逗号替换成 '\0',pField 里存各字段起始地址,返回字段个数 int SplitNmea(char* pLine, char** pField, int nMax) { int n = 0; pField[n++] = pLine; for (char* p = pLine; *p && n < nMax; ++p) { if (*p == ',') { *p = '\0'; pField[n++] = p + 1; } } return n; }CheckSumOk的写法要留意%2X:校验和永远是两位,一位数的情况下模组会补前导零,用%2X正好。故意不校验也能解析出数据,但现场电磁干扰大的时候,一个比特翻转就能把纬度从 47 度变成 87 度,而界面上看起来完全正常——这是最难查的一类问题,所以校验必须开。
SplitNmea选择原地修改而不是strtok,因为strtok会跳过连续分隔符,遇到,,这种空字段时字段下标就整体错位了,经纬度会跑到别的字段上去,得到的结果看起来像个正常数字,实际完全错误。原地切分保持下标严格对应,空字段就是一个空字符串,后面用if (p[0])判断即可。
3.3 度分转十进制度与 UTC 时间:两个必须自己算的转换
原始报文里的纬度是4717.11437这种「度分」格式,前两位是度,后面全是分,要除 60。经度是三位度(00833.91522),所以不能简单按两位切,标准做法是除以 100 取整得度,余数当分。
// 度分格式转十进制度,pDir 为 N/S/E/W,S 与 W 取负 double DmToDeg(const char* p, const char* pDir) { if (!p || !p[0]) return 0.0; // 空字段直接返回 0,不要 atof 空串 double v = atof(p); int deg = (int)(v / 100.0); // 4717.11437 -> 47 double min = v - deg * 100.0; // 17.11437 double d = deg + min / 60.0; // 47 + 0.2852... if (pDir && (*pDir == 'S' || *pDir == 'W')) d = -d; return d; } // 时间字段 hhmmss.ss 与日期 ddmmyy,转成本地时间结构 void FillTime(const char* pTime, const char* pDate, SYSTEMTIME& st) { memset(&st, 0, sizeof(st)); if (pTime && strlen(pTime) >= 6) { char t[3] = {0}; memcpy(t, pTime, 2); st.wHour = (WORD)atoi(t); memcpy(t, pTime + 2, 2); st.wMinute = (WORD)atoi(t); memcpy(t, pTime + 4, 2); st.wSecond = (WORD)atoi(t); } if (pDate && strlen(pDate) >= 6) { char t[3] = {0}; memcpy(t, pDate, 2); st.wDay = (WORD)atoi(t); memcpy(t, pDate + 2, 2); st.wMonth = (WORD)atoi(t); memcpy(t, pDate + 4, 2); st.wYear = (WORD)(2000 + atoi(t)); } }DmToDeg里那句atof前的空串判断是血泪经验:atof("")返回 0.0,不报错,于是未定位时程序会老老实实写一条(0.0, 0.0)的记录,这个坐标在几内亚湾外的海面上,后续做轨迹回放时会出现一条从内陆直接跳到海里的直线,排查半天才发现源头是空字段。
时间这块要注意,报文里的时间是 UTC,直接显示会差八小时。我的做法是解析层只存 UTC,展示和入库时再转,别在解析函数里偷偷加八小时——一旦有人后来加了跨时区设备,这个隐式偏移就成了黑匣子。
3.4 数据落盘:文本日志、CSV 还是嵌入式数据库
小体量采集(单设备、单点、每秒一帧)我一般写两种文件:一份原始报文日志.nmea,一份解析后的 CSV。原始日志的价值在于事后回放——解析逻辑改了,可以直接拿昨天的日志重跑,不用等设备再跑一遍现场。CSV 每行固定字段,用fopen以"a"追加,每 60 秒fflush一次,避免突然断电时缓冲区里几十分钟数据全丢。
// 追加一行解析结果并定期落盘,避免断电丢数据 void CGpsDlg::WriteCsv(const GpsFix& fix, const SYSTEMTIME& st) { if (!m_pCsv) { m_pCsv = fopen("gps_track.csv", "a"); if (!m_pCsv) return; fprintf(m_pCsv, "date,time,lat,lon,speed_knot,course\r\n"); } fprintf(m_pCsv, "%04d-%02d-%02d,%02d:%02d:%02d,%.7f,%.7f,%.3f,%.3f\r\n", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, fix.dLat, fix.dLon, fix.dSpeedKnot, fix.dCourse); if (++m_nFlushCnt >= 60) { // 大约每 60 帧刷一次 fflush(m_pCsv); m_nFlushCnt = 0; } }经纬度用%.7f而不是%f,是因为默认六位小数在十进制度下只有约 11 厘米精度,看着够用,但四舍五入到第六位时会把连续轨迹磨成台阶,做速度计算时误差被放大。文件用追加模式打开并且不关闭,是为了减少每秒一次fopen的开销,代价是必须fflush。
4. 采集线程与断线重连:让程序连续跑三十天不重启
串口通了、报文也能解析了,接下来的问题全是时序问题。界面线程里做收数,一旦用户拖一下窗口或者打开了文件对话框,OnComm就被阻塞,数据就丢;串口线松一下,程序不知道,继续往缓冲区里读零字节,日志上看起来「一直有数据」,实际全是旧值。这一章解决的就是这两件事。
4.1 把收数搬进工作线程:ReadFile加超时才是正解
从 MSComm 换成 Win32 API 的核心动机,是可以把ReadFile放在自己的线程里阻塞等待,界面线程彻底解放。第一步是把端口打开改成 Win32 写法,并且把超时设明白。
// Win32 打开串口并设置超时,pszPort 形如 "COM3" HANDLE OpenCom(const char* pszPort, DWORD dwBaud) { char szPath[32]; sprintf(szPath, "\\\\.\\%s", pszPort); // 加前缀才支持 COM10 及以上 HANDLE h = CreateFile(szPath, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (h == INVALID_HANDLE_VALUE) return INVALID_HANDLE_VALUE; DCB dcb; memset(&dcb, 0, sizeof(dcb)); dcb.DCBlength = sizeof(dcb); GetCommState(h, &dcb); dcb.BaudRate = dwBaud; dcb.ByteSize = 8; dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; dcb.fBinary = TRUE; dcb.fParity = FALSE; dcb.fOutxCtsFlow = FALSE; dcb.fRtsControl = RTS_CONTROL_ENABLE; // 部分模组靠 RTS 电平决定是否输出 SetCommState(h, &dcb); COMMTIMEOUTS t; t.ReadIntervalTimeout = 50; // 字节间隔超时,防止半包卡死 t.ReadTotalTimeoutMultiplier = 0; t.ReadTotalTimeoutConstant = 100; // 单次 ReadFile 最多等 100ms t.WriteTotalTimeoutMultiplier = 0; t.WriteTotalTimeoutConstant = 500; SetCommTimeouts(h, &t); SetupComm(h, 4096, 1024); // 收发缓冲 PurgeComm(h, PURGE_RXCLEAR | PURGE_TXCLEAR); return h; }ReadIntervalTimeout = 50是关键参数。它表示「两个字节之间如果超过 50 毫秒没有新字节,ReadFile就返回当前已收到的部分」。GPS 以连续流方式输出,帧内字节间隔远小于 1 毫秒,50 毫秒足够区分「一帧结束」和「流中断」。如果不设这个参数而用默认值,ReadFile会一直等到缓冲区满或者超时才返回,程序对断线的反应会慢到分钟级。
ReadTotalTimeoutConstant = 100保证即使完全没数据,ReadFile也会在 100 毫秒后返回,线程有机会检查退出标志。这两个超时组合起来,线程既不会忙等烧 CPU,也不会卡死。
4.2 环形缓冲要自己写:一个 30 行的版本够用
不用 STL 的deque是有原因的:VC6 自带的 STL 在 Debug 模式下有迭代器检查,跨线程使用容易触发断言,而且deque的内存分配行为在长时间运行下不够可预测。自己写一个定长环形缓冲,内存一次分配、永不增长,跑一个月内存曲线是平的。
// 定长环形缓冲:单写单读,用临界区保护,满了丢新数据保老数据 class CRingBuf { public: CRingBuf(int nCap = 16384) : m_pBuf(new unsigned char[nCap]), m_nCap(nCap), m_nHead(0), m_nTail(0) { InitializeCriticalSection(&m_cs); } ~CRingBuf() { DeleteCriticalSection(&m_cs); delete[] m_pBuf; } void Push(const unsigned char* p, int n) { EnterCriticalSection(&m_cs); for (int i = 0; i < n; ++i) { int next = (m_nHead + 1) % m_nCap; if (next == m_nTail) break; // 满,丢弃后续字节 m_pBuf[m_nHead] = p[i]; m_nHead = next; } LeaveCriticalSection(&m_cs); } int Pop(unsigned char* p, int n) { int cnt = 0; EnterCriticalSection(&m_cs); while (cnt < n && m_nTail != m_nHead) { p[cnt++] = m_pBuf[m_nTail]; m_nTail = (m_nTail + 1) % m_nCap; } LeaveCriticalSection(&m_cs); return cnt; } int GetUsed() { EnterCriticalSection(&m_cs); int n = (m_nHead - m_nTail + m_nCap) % m_nCap; LeaveCriticalSection(&m_cs); return n; } private: unsigned char* m_pBuf; int m_nCap, m_nHead, m_nTail; CRITICAL_SECTION m_cs; };缓冲区留一个空位来区分「满」和「空」,所以 16384 的实际容量是 16383 字节,大约够 200 帧。丢数据时选择丢新保老,是因为 NMEA 帧之间独立,丢一帧只损失一个点,而丢半个旧帧会让组帧逻辑错位。
工作线程本身非常薄,只做「读 → 塞 → 通知」:
UINT CGpsDlg::CollectThread(LPVOID pParam) { CGpsDlg* pThis = (CGpsDlg*)pParam; unsigned char buf[512]; while (pThis->m_bRun) { DWORD nRead = 0; BOOL ok = ReadFile(pThis->m_hCom, buf, sizeof(buf), &nRead, NULL); if (!ok) { // 句柄失效,退出让主线程重连 pThis->m_bComAlive = FALSE; break; } if (nRead == 0) continue; // 超时无数据,正常现象 pThis->m_dwLastRx = GetTickCount(); // 记录最后收数时刻,看门狗用 pThis->m_ring.Push(buf, (int)nRead); if (pThis->m_ring.GetUsed() >= 128 && !pThis->m_bPosted) { pThis->m_bPosted = TRUE; ::PostMessage(pThis->m_hWnd, WM_GPS_DATA, 0, 0); // 只发通知,不带指针 } } return 0; }PostMessage只发一个「有数据了」的信号,绝不把缓冲区指针或者new出来的结构体塞进WPARAM。跨线程传指针是 VC6 项目里最经典的翻车点:接收方处理时发送方已经把内存释放了,表现是随机的访问冲突,位置每次都不同,查起来像玄学。界面线程收到WM_GPS_DATA后自己从环形缓冲里Pop,数据归谁管一目了然。
4.3 看门狗与重连:判断「串口活着」而不是「程序在跑」
工作线程不阻塞不代表数据在流动。线松了、模组断电了、USB 转串口被拔了,ReadFile依然每 100 毫秒正常返回零字节,程序看起来一切正常。判断依据只能是「最后一次收到有效字节的时间」。
| 判据 | 阈值 | 动作 |
|---|---|---|
| 距最后收数超过 3 秒 | 3000 ms | 界面提示「信号中断」,日志记一条 |
| 距最后收数超过 15 秒 | 15000 ms | 关闭句柄,重新CreateFile打开同一端口 |
| 连续重连 5 次仍无数据 | 5 次 | 换备用波特率重试一轮(4800 ↔ 9600) |
界面线程用一个 500 毫秒的SetTimer定时检查GetTickCount() - m_dwLastRx,超过阈值就走重连流程。重连时顺序不能乱:先m_bRun = FALSE并等线程退出(或者直接CloseHandle让ReadFile失败返回),再关闭旧句柄,再打开新句柄,最后重置m_dwLastRx。先开新的再关旧的,会出现两个线程抢同一个端口的情况,两边各收到半帧,组帧永远不完整。
重连成功后要清空环形缓冲,因为里面残留的可能是断线前的半帧,和新数据拼起来会产生一条校验和错误的假帧。PurgeComm(h, PURGE_RXCLEAR)加上m_ring重置,两步都做。
5. 避坑与排查:VC6.0 做 GPS 采集最容易翻车的五个现场
这一章全部来自实际重开过的老工程。每条按「现象 → 原因 → 解决」写,遇到相似症状可以直接对号入座。
5.1 现象:跑三天内存一路涨,最后弹「内存不足」
原因几乎都是OnComm里的SAFEARRAY没有SafeArrayDestroy,或者每次事件里new CString、new char[]之后在异常分支提前return漏了释放。VC6 没有 RAII 的习惯,try/catch里catch完不Delete()也是同一个后果。
解决分两步。第一步在OnComm里加上SafeArrayDestroy(psa),且保证每个return分支之前都执行到;第二步在任务管理器里观察GDI 对象和用户对象两列,如果这两个数也在涨,说明泄漏的不是堆内存而是 GDI 句柄,去查有没有在重绘函数里CreatePen/CreateFont之后忘记DeleteObject。另有一个土办法:在 Debug 版启动时加一行_CrtSetDbgFlag(_CRTDBG_LEAK_CHECK_DF | _CRTDBG_ALLOC_MEM_DF),退出时输出窗口会打印泄漏块的序号,配合_CrtSetBreakAlloc能精确定位到行。
5.2 现象:现场数据全是 0.000000,但日志里明明有报文
两个原因叠在一起。一是没有判断定位状态:$GPRMC的第 2 个字段是V时表示未定位,此时后面几个字段确实可能是空的或者全零,程序照单全收就写进了一堆无效点。二是atof对空字符串返回 0.0 且不报错,把「没数据」和「数据是零」混为一谈。
解决方案是加一道有效性闸门:状态字段必须是A,纬度经度字段必须非空,$GPGGA的定位质量必须大于 0,三个条件同时满足才写入。另外加一个「卫星数」字段的显示,冷启动后第一次定位通常需要 30 到 60 秒,这段时间的数据直接丢弃比写进去更干净。如果现场是老设备,还要注意备份电池失效导致的每次上电都冷启动,这种情况定位延迟会稳定在 40 秒以上,属于硬件问题不是程序问题。
5.3 现象:串口助手能收到数据,自己程序收到全是乱码方块
乱码但长度看起来正常,八成是波特率不匹配;乱码且长度忽长忽短,八成是电平不匹配。GPS 模组输出的通常是 TTL 电平(0 到 3.3V 或 5V),工控机串口是 RS232 电平(正负 12V),中间必须有电平转换,直连会得到一堆看似随机的字节。
排查顺序:先用串口助手以 9600 固定波特率监听,读不出$GP就换 4800 再试;两种都读不出,用万用表量模组 TX 引脚在空闲时是否为高电平,如果不确定电平标准,先接转换模块再试。还有一个常被忽略的原因是不共地:模组和工控机各自用独立电源时,信号地没有连在一起,收数据时好时坏,抖动一下就对不上。这条在实验室里几乎不会出现,装到现场机柜里才暴露。
5.4 现象:偶尔丢一帧,日志里出现两条帧粘在一起的长行
原因是没有做流式组帧,直接对每次收到的字节块做strstr找帧头。RThreshold设成 1 时,一帧 70 字节会被拆成几十次事件;如果某次事件恰好把$GPRMC里的$和G分到两次,strstr就找不到帧头,整帧丢掉。反过来,界面卡顿时一次事件可能拿到两帧,strstr找到第一个帧头后如果没有按\r\n截断,就会把两帧当成一帧解析。
正确做法是维护一个行缓冲:把收到的字节逐个追加,遇到\r或\n就认为一行结束,把这一行交给解析函数,然后清空行缓冲。行缓冲要设上限(比如 256 字节),超过就强制丢弃,防止乱码时缓冲区无限增长。按行处理之后,半包和粘包自动消失,这是整个解析层最值得先做对的一件事。
5.5 现象:Debug 版一运行就弹断言,Release 版又正常
这是 VC6 时代最经典的组合问题。常见成因有三个:一是把CString或者 STL 容器作为参数跨 DLL 传递,两边的 CRT 不是同一份,释放时堆不匹配;二是工程设置里_AFXDLL不一致,MFC 静态链接和动态链接混用;三是 Debug 模式下 STL 的迭代器检查对已经失效的迭代器做了读操作,Release 下不检查所以「看起来正常」。
解决办法是先统一运行时库:整个解决方案里所有工程(包括 MFC 扩展 DLL)的Code Generation里Use run-time library必须一致,要么全是Debug Multithreaded DLL,要么全是Debug Multithreaded。跨模块传接口时不用CString,改用LPCTSTR加调用方分配缓冲区的老写法。断言不是噪音,它指向的位置通常在 MFC 源码里,往上翻两层调用栈就能看到自己的代码,别急着用#define NDEBUG把它关掉——那只是把问题推到客户现场。
6. 拿日志回放验证解析器:比在现场守三天更靠谱
解析层改完之后,最有效的验证手段不是接上设备看界面,而是把现场抓下来的.nmea原始日志当输入源,做一个「文件代替串口」的回放模式。我一般会留一个开关:m_bReplay为真时,工作线程不读ReadFile,而是用fgets从日志文件里按行读,其余流程一字不改。这样一次十分钟的回放就能覆盖三天现场数据里所有的半包、空字段、未定位片段,改一行解析代码立刻能看到影响。
回放时按这张表核对,四项都对上才算解析层过关:
| 校验项 | 期望结果 | 判错含义 |
|---|---|---|
| 有效帧率 | 有效帧 / 总帧数 与现场一致(通常 95% 以上) | 偏低说明组帧或校验逻辑有问题 |
| 经纬度跳变 | 相邻点位移不超过速度乘以时间间隔 | 出现大跳变说明度分转换或符号处理错 |
| 速度与位移一致 | 位移/时间 ≈ 速度(节 × 0.5144 转 m/s) | 不一致说明字段下标错位 |
| 时间单调 | 时间戳严格递增,无重复段 | 重复说明缓冲区残留被重复解析 |
最后说个我自己的习惯:这个采集程序无论加什么新功能,我都会保留原始.nmea日志的完整落盘,一天一个文件,按日期命名,出问题时先看日志再看代码。这套程序真正难的地方不在写,而在三五年后还有人能看懂当初为什么这么写,日志是最好的后悔药。别急着上花哨的框架,把串口读稳、把行切对、把无效点拦住,一个 VC6.0 的老对话框就能扛住长期运行的考验。希望帮到你。
本文还有配套的精品资源,点击获取