车站计算机联锁VC++6.0代码解析:从联锁逻辑到工程实践 简介一套车站计算机联锁系统的VC6.0程序代码面向铁路信号专业学生、C桌面开发者及联锁控制初学者属于教学仿真项目程序重点呈现标准车站界面与下行站简单联锁逻辑覆盖进路选择、信号开放与道岔控制等基础安全流程但未包含故障处理部分。压缩包为RAR格式共105个文件整体4.1MB源码主体为23个h头文件与21个cpp源文件并附带obj中间代码、sbr源浏览器数据库、rc资源脚本、dsp工程文件以及一个可执行exe便于在VC6环境中直接编译和对照学习目前已有1411人浏览学习。从预览看核心模块包括CmpInterlock主程序、CmpInterlockDlg对话框交互、datagrid数据表格显示、adodc与_recordset数据库访问以及RoundButton自定义按钮等同时采用SQL2000存储设备状态和联锁规则。通过研读代码可以掌握MFC界面构建、联锁逻辑函数设计、Windows下数据库连接与操作等技能并为进一步扩展故障诊断、完善联锁功能打下基础整体而言该代码包兼顾原理讲解与工程实践是理解计算机联锁系统的合适范本。1. 车站计算机联锁的VC6.0代码这套老系统为什么还活着第一次接触车站计算机联锁的VC6.0工程是在一个既有站改造项目里。白天行车不能停天窗点只有凌晨三个小时现场信号楼里那台工控机跑的还是2005年编译出来的exe——联锁机开机自检、道岔表示正常、信号开放无误唯独没人说得清代码里那套进路搜索到底是怎么组织的。后来厂家远程把整个工程发过来打开一看一个.dsw工作区、二十多个.cpp文件、满屏MFC对话框资源代码风格还是上世纪九十年代的。那时候我才意识到车站计算机联锁的核心不是界面炫不炫而是用C代码把进路、道岔、信号机之间的安全约束写对这套逻辑一跑就是十几年谁都不敢轻易重写。这篇就聊VC6.0时代的联锁程序代码怎么组织、核心逻辑怎么实现、有哪些绕不开的坑以及新接手的人该怎么下手。2. 联锁逻辑与C数据模型读代码前先弄清三个设备一张表2.1 联锁关系到底在锁什么进路、道岔与信号机的约束车站计算机联锁的“联锁”翻译成大白话就是信号机能不能开放取决于它身前的道岔位置对不对、轨道区段空不空、有没有敌对进路已经建立。这三个条件相互制约不允许靠人来拍脑袋必须由程序在几十毫秒内逐项校验。常见的做法是把整个站场抽象成三类设备信号机、道岔、轨道区段。进路是一条从始端信号机到终端信号机的路径路径上要经过一组道岔和若干个轨道区段。联锁程序要做的事情就是当值班员办理某条进路时逐条去查进路上每一组道岔是否已经转换到目标位置进路上的所有轨道区段是否都处于空闲状态有没有敌对进路已经办理并锁闭尤其是迎面敌对进路始端信号机灯丝是否完好、能否正常开放。这套约束关系在设计阶段就固化在一张“联锁表”里。表上每一行描述一条进路进路号、始端信号、终端信号、经过的道岔编号及位置、经过的区段、敌对进路号。你在代码里做的事情本质是把这张二维表翻译成C数据结构再用搜索和遍历把它跑起来。很多新手以为联锁程序难在“控制”实际难在“判断”——先判断能不能办再判断能不能锁最后判断能不能开放信号。2.2 为什么三十年老代码还在车站里跑选型与演进逻辑车站联锁这个领域有个特殊性安全认证成本极高。一套联锁系统从逻辑设计到现场开通要经历仿真测试、室内试验、现场联挂、试运行等多个环节任何一行代码改动都可能触发重新验证。对运营方来说已经在现场稳定运行十几年的VC6.0工程反而比一套用现代C标准重写但只测试了三个月的新系统更可信。从技术环境看当年选VC6.0也是顺理成章。那个年代信号楼里的工控机配置很低Windows 2000/XP是主流VC6的MFC做监控类界面效率高编译出的程序对老硬件兼容性好WinSock和串口API用起来直接不需要引入额外运行库。很多现场至今保留着当年用于驱动测试的采集板卡驱动只认老VC6生成的代码换个编译器重新编译都可能出现板卡时序不匹配的问题。再者联锁程序需要长期无人值守运行。VC6生成的优化代码形态相对固定反而让安全评估容易做。我接触过的某段信号设备维护单位直到现在排查问题时第一反应还是打开VC6和Source Insight来看代码。选型这件事在通用软件领域是技术问题在车站联锁领域是安全与责任问题所以不轻易追新。2.3 联锁程序的分层结构界面、运算与采集怎么分工一套完整的车站计算机联锁程序虽然文件名很多逻辑上一般分三层。第一层是人机界面层也就是控制台界面。它负责显示站场图形、道岔位置、轨道占用、信号显示以及接收值班员的按钮操作。这层用MFC的对话框或单文档视图框架都可以做关键是画面刷新要快、不能闪烁、操作要能快速响应。第二层是联锁运算层这是整个工程的核心。它维护站场状态的内部数据结构接收界面层下发的进路办理请求执行进路搜索、敌对检查、道岔转换命令、进路锁闭、信号开放、解锁等逻辑。这一层不允许有界面代码也不允许直接操作串口保持纯粹的逻辑运算方便安全测试。第三层是驱动采集层负责和室外设备通信。它周期性采集轨道区段占用状态、道岔表示位置、信号灯丝状态并把联锁运算层下发的命令翻译成串口或板卡IO信号发出去。三层之间用自定义消息或共享内存衔接。常见做法是界面层产生操作事件通过PostMessage通知运算层线程运算层在固定周期内扫描事件队列做完联锁运算后刷新界面数据和采集输出。程序启动时先初始化设备表再启动三个线程。理解了这个三层结构你打开任何一个联锁工程的cpp文件就能从函数归属判断出它属于哪一层不会迷路。3. 联锁表驱动的核心代码从站场图到进路搜索3.1 用枚举与结构体描述轨道区段、道岔和信号机设备模型是联锁代码的地基。站场上千个设备归根到底可以浓缩成几张表信号机表、道岔表、区段表、进路表。在VC6.C里我一般用枚举加结构体定义不用类继承那一套因为联锁设备是二维的不是多层抽象。// 信号机显示状态 enum SignalAspect { ASPECT_STOP 0, // 红灯 ASPECT_GREEN 1, // 绿灯 ASPECT_YELLOW 2, // 黄灯 ASPECT_BROKEN 3 // 灯丝断丝 }; // 道岔位置状态 enum SwitchPosition { SW_POS_NORMAL 0, // 定位 SW_POS_REVERSE 1, // 反位 SW_POS_LOST 2 // 表示丢失四开 }; // 轨道区段状态 enum TrackOccupation { TRACK_FREE 0, // 空闲 TRACK_OCCUPIED 1 // 占用 }; // 道岔设备描述 struct SwitchUnit { int switchId; // 道岔编号 int currentPos; // 当前表示位置 int targetPos; // 联锁要求的目标位置 int isLocked; // 是否已锁闭 }; // 信号机设备描述 struct SignalUnit { int signalId; // 信号机编号 int aspect; // 当前显示状态 int isLocked; // 是否处于锁闭状态 int homeSignal; // 是否为列车信号机 }; // 轨道区段设备描述 struct TrackSection { int sectionId; // 区段编号 int occupation; // 空闲/占用 int isLocked; // 是否被进路锁闭 };代码里的逻辑说明枚举值全部从0开始是刻意为之——老VC6对不同编译器对enum默认值的处理一致而且0值在初始化时可以一揽子清零把整块内存memset之后所有设备都是空闲状态避免漏初始化。结构体里不用位域因为位域在VC6下有大小端和内存对齐的坑调试起来在监视窗口里看字段值特别费劲。参数说明inc锁字段的isLocked是联锁程序中最关键的标志位。进路锁闭后相关道岔和区段的isLocked被置1这时候即使外界有别的进路请求也要先检查它。区分currentPos和targetPos也是为了这两个值——道岔在转换过程中两者不一致恰恰是判断道岔是否到位、通讯是否中断的依据。3.2 站场有向图与进路搜索邻接表和DFS/BFS实战有了设备表接下来要把站场平面图变成程序能搜索的图结构。我见过有人在代码里用三层嵌套数组硬编码站场形状扩展性极差。更可靠的做法是建邻接表每个区段或信号机是顶点相邻关系是边道岔作为特殊边挂在顶点之间。// 邻接边描述区段A和区段B相邻 struct AdjacentEdge { int fromSection; // 起始区段 int toSection; // 到达区段 int switchOnEdge; // 边上是否有道岔0表示无 int switchReqPos; // 经过此边道岔需要的位置 }; // 站场有向图 std::mapint, std::vectorAdjacentEdge stationGraph; // 进路搜索从始端信号机出发找终端信号机 bool searchRoute(int startSignal, int endSignal, std::vectorint routeSections) { std::queueint q; std::mapint, int parent; // 记录BFS前驱恢复完整路径 int cur, next; // 将始端信号机对应的区段入队 q.push(startSignal); parent[startSignal] -1; while (!q.empty()) { cur q.front(); q.pop(); if (cur endSignal) { // 回溯parent恢复路径 while (cur ! -1) { routeSections.push_back(cur); cur parent[cur]; } std::reverse(routeSections.begin(), routeSections.end()); return true; } // 遍历当前顶点的所有相邻边 for (size_t i 0; i stationGraph[cur].size(); i) { next stationGraph[cur][i].toSection; if (parent.find(next) parent.end()) { parent[next] cur; q.push(next); } } } return false; }逻辑说明这个搜索用BFS找最短路径但站场拓扑简单时DFS也一样。真正的关键不是找路而是找路过程中同时检查边上道岔的位置是否满足要求。很多联锁代码在这里做剪枝——某个相邻边上的道岔不在目标位置这条边直接跳过不扩展。参数说明routeSections是一个出参返回进路包含的区段顺序。后面锁闭进路、解锁进路都要按这个顺序逐段操作。VC6下使用std::map标准库本身效率够但如果工程里map嵌套太深导致编译慢原因为VC6的模板实例化效率低我一般把stationGraph拆成纯C数组的邻接表结构,接口一样编译时间能缩短一半。3.3 信号开放与道岔动作办理进路时的时序控制进路的办理过程是有严格时序的先后顺序错了就是安全隐患。正常顺序是先检查、再转换道岔、后锁闭、最后开放信号。其中道岔转换是最耗时的操作因为转辙机转完一组道岔要几百毫秒甚至更久这期间程序处在一个等待外部设备响应的状态。void handleRouteRequest(RouteEntry route) { RouteStatus status checkRouteCondition(route); if (status ! ROUTE_OK) { // 条件不满足直接拒绝并在界面提示原因 postAlarm(route.routeId, status); return; } // 第1步转换进路相关道岔 for (size_t i 0; i route.switchCount; i) { int swId route.switchList[i]; int reqPos route.switchTargetPos[i]; SwitchUnit sw switchTable[swId]; if (sw.currentPos ! reqPos) { sendSwitchCommand(swId, reqPos); // 下发转辙命令 sw.isLocked 1; // 转换期间先锁闭道岔 } } // 第2步等待道岔转换完成并检查表示 waitForSwitches(route, 5000); // 最多等5秒 // 第3步全部到位后锁闭进路区段道岔 for (size_t i 0; i route.sectionCount; i) { trackTable[route.sectionList[i]].isLocked 1; } // 第4步开放信号 openSignal(route.startSignal); }逻辑说明第1步转换道岔前先调checkRouteCondition防止在敌对进路存在时下发转辙命令。waitForSwitches内部是循环查询各道岔的currentPos是否等于targetPos并带超时。超时后必须回滚——把已经转换完的道岔和未转换的道岔一起恢复并上报“道岔转换故障”。参数说明5秒超时来自转辙机动作时间加安全余量现场如果道岔旧了这个值要允许维护人员调整。openSignal函数内部还有一层检查会再确认进路锁闭标志全部成立这是“二次确认”原则——即使刚检查过条件到开放信号的瞬间还要重新校验一次。这是联锁代码里最常见的冗余安全设计能防住道岔表示瞬间抖动引发的误开放。4. 工程文件怎么落地MFC站场图、串口通信与多线程保护4.1 MFC站场图绘制双缓冲与GDI资源管理站场图是值班员唯一直接看到的画面绘制卡顿、闪烁都会被投诉。VC6的MFC里标准OnDraw直接画会有两个问题重绘频繁导致闪烁GDI对象创建后忘了删除导致内存和画刷泄漏。void CStationView::OnDraw(CDC* pDC) { // 双重缓冲先在内存DC里画再一次BitBlt到屏幕 CRect rect; GetClientRect(rect); CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(pDC); memBmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 绘制底色、轨道线、道岔符号、信号机圆圈 DrawStationBackground(memDC); DrawTracks(memDC); DrawSwitches(memDC); DrawSignals(memDC); // 一次复制到屏幕上消除闪烁 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); // 恢复并释放GDI对象 memDC.SelectObject(pOldBmp); memBmp.DeleteObject(); memDC.DeleteDC(); }逻辑说明内存DC和位图在一次绘制中创建画完立即释放GDI对象不会累积。站场图上每个设备的状态变化由联锁运算线程更新共享数据界面线程用定时器或消息触发Invalidate保证画的永远是最近一帧状态。参数说明BitBlt是GDI中最快的块传输比逐个画线高一个量级。要注意的是如果站场图很大内存位图尺寸与窗口尺寸一致即可不要直接全站场图创建巨型位图否则老工控机的显卡驱动会撑不住。另一种常见做法是只对“脏矩形”做局部更新但对联锁界面来说全屏重绘加双缓冲足够响应人眼不必过度优化。4.2 串口采集与命令下发重叠I/O与CRC校验联锁程序与联锁机柜或采集板之间的通信老工程里大多是RS-485/RS-422串口。VC6下串口通信最稳的方式是用CreateFile打开COM口配合重叠I/O异步读写而不是死等同步API。同步读在数据量大时会卡住界面线程天窗点联调时一卡就是事故。// 打开串口并配置参数 HANDLE hCom CreateFile(COM1, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // 关键重叠I/O标志 NULL); DCB dcb; GetCommState(hCom, dcb); dcb.BaudRate CBR_9600; // 联锁采集常用9600bps dcb.ByteSize 8; dcb.Parity NOPARITY; dcb.StopBits ONESTOPBIT; SetCommState(hCom, dcb); // 提交异步读请求 OVERLAPPED ov {0}; char buf[256]; BOOL ret ReadFile(hCom, buf, sizeof(buf), bytesRead, ov); if (!ret GetLastError() ERROR_IO_PENDING) { // 读操作在后台进行当前线程可以继续做联锁运算 // 在消息循环里用WaitForSingleObject检查完成事件 }逻辑说明重叠I/O的核心意义在于联锁运算线程不需要阻塞等待串口数据。数据到达后再由独立的串口接收线程解析帧并更新设备状态。命令下发同理道岔转辙命令发送后不需要等回读由接收线程在后续帧中判断表示值变化。参数说明波特率9600是很多老联锁设备默认速率实际以采集板手册为准我见过不少用19200导致误码率升高的例子。串口帧一般带CRC16校验代码里要保证发送方和接收方的CRC表和多字节处理顺序一致。CRC在联锁通信里特别重要——因为干扰可能把道岔表示从“定位”变成“反位”没有校验就误认为道岔转换成功后果不堪设想。4.3 共享站点数据与Windows临界区保护联锁程序是多线程的典型场景采集线程写设备状态运算线程读写设备状态界面线程读设备状态。如果不做同步道岔表示一边写一边读读出来的是半新半旧的数据逻辑判断就会出错。// 全局设备状态 TrackSection trackTable[MAX_TRACK_SECTIONS]; SwitchUnit switchTable[MAX_SWITCHES]; SignalUnit signalTable[MAX_SIGNALS]; // 定义全局临界区 CRITICAL_SECTION g_csStationData; // 采集线程更新区段状态 void UpdateTrackOccupation(int sectionId, int newState) { EnterCriticalSection(g_csStationData); trackTable[sectionId].occupation newState; LeaveCriticalSection(g_csStationData); } // 联锁运算读取区段状态 int GetTrackOccupation(int sectionId) { int state; EnterCriticalSection(g_csStationData); state trackTable[sectionId].occupation; LeaveCriticalSection(g_csStationData); return state; }逻辑说明临界区是Windows下开销最小的线程同步原语适合保护这种小规模共享数据。初始化时在程序入口调用InitializeCriticalSection程序结束统一DeleteCriticalSection。在VC6时代要注意一点临界区不支持递归进入同一个线程对同一个临界区连续Enter两次会死锁。联锁代码里最容易出现这类问题的地方是“二次检查”——checkRouteCondition内部加了一次锁openSignal内部又调了checkRouteCondition结果锁嵌套锁。我一般约定带锁的函数命名后缀加Locked暴露给外部的是无锁入口锁只出现在入口层。5. 联锁开发避坑指南VC6下最常见的五个故障5.1 现象运行几天后内存与GDI持续上涨现象系统刚开机正常每天内存占用涨几十MB运行一周后界面图形开始错乱道岔符号变黑块最后操作无响应。原因界面绘图时创建了画刷、画笔、字体但只SelectObject进DC没有DeleteObject。VC6默认工程不开启内存泄漏检测GDI对象泄漏在Windows任务管理器里看不出来只能用GDIView查看。解决所有GDI对象在函数退出前统一释放。我习惯用宏封装绘图资源#define SELECT_OBJECT(dc, obj) \ HGDIOBJ old dc.SelectObject(obj) #define RESTORE_OBJECT(dc, old) \ dc.SelectObject(old); dc.DeleteObject(old)另外在main函数开头加_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_CHECK_ALWAYS_DF)让调试输出窗口直接打印泄漏点。真查出是哪个函数创建了对象没释放改代码才有的放矢。5.2 现象两条进路同时办理时道岔动作打架现象值班员快速点了两条交叉进路结果道岔先转到一半又往回转室外道岔表示跳来跳去现场维护人员说“像抽风了一样”。原因界面层同时把两个进路请求下发到了联锁运算线程但代码里没做进路办理互斥。第二个进路请求进来时没有去检查第一条进路是否已经锁闭了相关道岔。解决进路办理模块最前面加一个全局“正在办理进路”标志同时用临界区保护进路表。办理开始置位办理完成或失败后复位。两条进路有关联道岔时后到的请求必须看到第一轮的路它还没解锁直接拒绝并给出“敌对进路”提示。记住联锁程序里的道岔同一时刻只能被一条进路驱动。5.3 现象站场图缩放拖动时闪烁甚至卡死现象界面放大缩小或调整窗口大小时画面疯狂闪烁偶尔界面卡死任务管理器显示CPU 100%。原因OnDraw直接往屏幕上绘制外层窗口移动不断触发擦除背景再重画形成闪屏同时站场图形对象数量大OnDraw里重复创建字体和画刷导致绘制时间过长消息队列阻塞。解决4.1节的双缓冲是必需项。另外有两个细节容易被忽略在OnEraseBkgnd里直接return TRUE禁止基类擦除背景在OnSize里不频繁重绘而是只更新视图范围并Invalidate一次。如果站场图很大把轨道、信号机、文字分别画在三个预渲染的位图上缩放时只做位图缩放比每次递归画几百条线快得多。5.4 现象串口命令偶发丢失联锁机没有响应现象道岔转换命令下发后室外转辙机偶尔不动作测试时十次里丢一两次。看串口调试工具发送端发了但联锁机没收到。原因串口缓冲区溢出采集周期内数据量超过4096字节默认缓冲或者命令帧和状态帧混用同一串口发送与接收之间切换方向时没有等待线路稳定。解决串口缓冲区改用128KB以上调用SetupComm重新设置输入输出缓冲大小。发送和接收方向上要做方向切换延时RS-485半双工收发切换后等待50ms等线路稳定否则第一帧数据必丢。然后在协议层加重传机制命令帧发出后5秒没收到确认帧自动重发一次重发两次仍无确认上报通道故障。5.5 现象Release版到现场才崩溃Debug版一切正常现象在开发机上用Debug编译测试一切正常同样代码用Release编译到现场联锁机上跑几分钟就崩溃。原因VC6的Release默认启用“纯优化”和“速度优先”优化代码里带有未初始化的局部变量时优化后变量地址被复用读出来是垃圾值。Debug下局部变量会自动清零掩盖了问题。解决先把Release优化选项改成“禁用”逐个功能模块压测定位到崩溃函数后再逐步打开优化。这类问题的根源是代码本身依赖了变量初始值必须在所有入口显式初始化。比如int status; // 错误写法未初始化 int status 0; // 正确写法排查时用Release加调试信息编译运行时弹窗显示错误位置比在代码里贴日志更快。6. 把老代码盘活仿真进路验证与代码审查清单6.1 外部注入站场状态批量验证进路逻辑老联锁代码改一处逻辑后要在现场反复试验是很麻烦的。我现在的做法是写一个外部验证脚本把联锁运算核心编译成动态库然后通过Python注入站场状态、发起进路请求、检查输出时序。这样每次改动后都能跑一遍全站几百条进路的回归。# 仿真验证脚本自动排列进路并比对结果 # 假设联锁核心库提供查询接口这里模拟站场状态 def run_validation(station_model): routes station_model.get_all_routes() failed [] for route in routes: # 复位全场设备到初始状态 station_model.reset_and_release_all() station_model.set_switches_to_route(route) # 办理进路并检测结果 result station_model.request_route(route.id) # 核对道岔位置、区段锁闭、信号开放 if result ACCEPTED: if not station_model.check_route_locked(route): failed.append((route.id, route not locked)) if not station_model.check_signal_go(route): failed.append((route.id, signal not open)) else: if station_model.check_route_locked(route): failed.append((route.id, route should be rejected but locked)) return failed这段脚本的价值是把“人工看信号机状态”变成“自动比对预期结果”。正常来说联锁逻辑是确定的同样的进路同样的站场状态输出必须和联锁表完全一致。不一致的地方就是代码改动引入的回归点。我把这套脚本放在每次代码提交前跑一遍比人工盯Led灯状态可靠得多。6.2 每次改动前的联锁代码审查清单动手改联锁代码前我会把工程里所有diff逐行过一遍重点查这几类问题检查项关注点lock标志的置位与复位isLocked是否成对出现有没有某个分支只置位不复位临界区嵌套同一个线程是否重复进入同一个CRITICAL_SECTION超时处理道岔转换超时、串口重传超时是否有回滚逻辑缓冲区边界接收串口帧的缓冲区是否固定长度会不会溢出枚举值越界外部采集回来的数值能否直接当数组下标用设备状态互斥同一道岔是否可能同时被两条进路驱动这套清单看起来基础但联锁系统的问题绝大多数出在这些基础环节而不是算法复杂度上。我在一个模拟项目X里改了信号开放条件漏看了一条敌对进路关系仿真脚本跑出来几十条冲突全部锁定如果直接拿到现场去试就是严重事故。后来我养成一个习惯每改完一块逻辑先把相关进路表打印成txt逐行比对一次再用脚本跑全量回归两步都过了才敢提交。这个习惯帮我避开了不止一次翻车。希望帮到你。本文还有配套的精品资源点击获取