简介:这份车站计算机联锁程序代码基于VC++6.0开发,面向铁路信号专业学生、计算机联锁初学者及C++项目研究者,演示了标准车站界面的绘制与下行站基本联锁逻辑,虽未包含故障处理,但为理解联锁原理提供了可运行的仿真样本。压缩包共105个文件,约4.1MB,包含23个h头文件与21个cpp源文件构成核心逻辑,另有dsp/dsw工程配置、res/rc界面资源、exe可执行程序及SQL2000数据库交互相关组件,便于直接编译运行和断点调试。该资源已有1411人学习下载。通过研读代码,可掌握轨道电路、信号机、道岔状态采集到安全控制指令输出的完整流程,同时熟悉MFC对话框程序架构、ADO数据库操作及项目文件组织方式;其CmpInterlock核心模块清晰划分了联锁运算与界面响应逻辑,适合作为课程设计或毕业设计的改造起点。
1. 车站计算机联锁:VC++ 6.0代码到底在做什么
做铁路信号这行的人,几乎都绕不开车站计算机联锁这套东西。它是车站里管道岔、管信号机的“大脑”,列车能不能进某条股道、信号机能不能给绿灯,都得靠它的安全逻辑说了算。而直到现在,市面上仍有大量在用和正在维护的联锁上位机程序,就是用 VC++ 6.0 写出来的。拿到这类代码,第一眼会觉得它和普通上位机软件没差别:一样的 MFC 对话框,一样的按钮、自绘画面。但真正往里看就会明白,难点根本不在界面,而在联锁运算那一块。这篇文章就按我实际梳理这类代码的习惯来写:先拆层、拆线程、拆数据表,再讲进路选排和信号开放的核心逻辑,然后专门列几个我接手代码时踩过的坑——状态采集延迟、道岔超时、断电恢复、双机切换。最后给你一套能在自己电脑上把联锁逻辑跑起来验证的方法。适合正在接维护项目、想读懂代码的人,也适合用 VC++ 6.0 做联锁仿真作业的读者。
2. 先拆解代码结构:联锁三层架构与线程划分
2.1 三层架构:操作显示层、联锁运算层、执行驱动层
一套完整的车站计算机联锁系统,在硬件上通常分为人机界面、联锁主机和现场设备三层。对应到 VC++ 6.0 代码里,也会分成三个逻辑层。第一层是操作显示层,用 MFC 写的界面程序,负责显示站场图、进路排列状态、设备状态和报警信息,接收车站值班员的鼠标操作。常见实现是用对话框加自绘控件,站场图用位图加动态刷新,它的特点是只管显示和输入,不做安全判断。
第二层是联锁运算层,这是整个程序的核心。它接收操作层的命令,结合现场采集的轨道、道岔、信号机状态,按进路表做联锁运算,输出控制命令。这一层代码必须按故障-安全原则设计,对每一站状态的判断都必须冗余。第三层是执行驱动层,跟轨道电路、转辙机、信号灯相连。常见有两种做法:一种是联锁主机直接通过 I/O 卡采集和输出,VC++ 代码里就要写驱动程序或调用动态库;另一种是联锁主机通过串口或现场总线和现场执行单元通信,那么这一层就是通信协议解析代码。
# 代码目录的常见组织方式 Interlock/ ├── Interface/ // 操作显示层 │ ├── StationView.cpp // 站场显示 │ └── OperDlg.cpp // 值班员操作对话框 ├── Interlock/ // 联锁运算层 │ ├── Route.cpp // 进路建立/解锁 │ ├── Signal.cpp // 信号开放逻辑 │ ├── Switch.cpp // 道岔控制逻辑 │ └── Track.cpp // 区段状态处理 ├── DeviceIO/ // 执行驱动层 │ ├── SerialComm.cpp // 串口通信 │ └── Protocol.cpp // 与执行单元的协议解析 └── Common/ // 公共数据表与工具 ├── RouteTable.cpp ├── SwitchTable.cpp └── TrackTable.cpp在实际项目里,模块划分并不统一。我见过有的代码把联锁运算直接写在按钮响应函数里,点击“排列进路”按钮后同步把进路建立了。这种做法在小站仿真里能跑,但拿到真实设备上非常危险——如果界面线程卡一下,整个联锁运算就失去响应。所以我帮某个维护项目梳理代码时,第一个任务就是把联锁运算从界面线程里拆出来,让它在独立线程里按固定周期跑。
三层结构有个很实在的好处:联锁运算层可以在不依赖真实硬件的情况下单独测试。把 DeviceIO 层替换成仿真数据源,就能在普通 PC 上把整套站场逻辑跑起来。这也是后面搭验证环境的基础。你可以把三层理解成:界面是前台,联锁是后台决策,IO 是手脚,三者各司其职。
2.2 线程划分:为什么联锁运算不能放在界面线程里
VC++ 6.0 的 MFC 程序默认是单线程的。很多初学写的程序把逻辑都放在按钮响应函数里,看起来没问题,但联锁程序对实时性有硬要求。轨道区段的占用变化、道岔的到位反馈、信号机的灯丝状态,这些信息随时可能从通信口进来。界面线程要处理绘图和鼠标消息,没法保证每 200ms 就处理一次 IO,更不敢在绘图过程中去读串口。
常见做法是开两个线程:一个是联锁运算线程,以固定周期扫描所有状态;另一个是通信线程,专门收发与现场设备的报文。界面线程只在状态变化时被通知刷新。下面这个线程函数是典型的联锁运算周期结构:
// 联锁运算线程:每个周期做一次采集、运算、输出 UINT InterlockThread(LPVOID pParam) { CInterlockApp* pApp = (CInterlockApp*)pParam; while (!pApp->m_bStopThread) { DWORD dwStart = GetTickCount(); pApp->CollectDeviceState(); // 采集轨道/道岔/信号机状态 pApp->ProcessCommands(); // 处理值班员操作命令 pApp->RunInterlockLogic(); // 执行联锁运算 pApp->OutputControlCmd(); // 输出控制命令 pApp->UpdateUI(); // 通知界面刷新 DWORD dwCost = GetTickCount() - dwStart; if (dwCost < 200) // 周期200ms Sleep(200 - dwCost); } return 0; }逻辑说明:CollectDeviceState 把通信线程收到的实时状态写入共享数据区,不直接去读串口。ProcessCommands 处理值班员的操作指令,比如“排列进路 X”。RunInterlockLogic 是核心运算,进路选排、信号开放判定都在这一步。OutputControlCmd 将运算结果通过通信线程发出去。每周期 200ms 是工程上的常见折中,太快会导致 IO 压力大,太慢会让人感觉信号开放不跟手。
参数说明:周期时间 200ms 不是一个统一标准,不同项目常用 100ms 到 500ms 之间。共享数据区的访问原则上要做锁保护,用 VC++ 6.0 时一般用 CRITICAL_SECTION 或简单标志位。注意不要在持锁时调用 Sleep,否则另一个线程就卡死了。界面刷新不要每个周期都做,我一般只在状态变化时置一个脏标记,界面线程检测到标记再重绘,不然站场图闪烁会很严重。
2.3 四张数据表:进路表、道岔表、信号机表、区段表
联锁逻辑不是一堆 if-else 硬编码出来的。真正的工程代码都会把车站的静态拓扑信息——哪个信号机管哪条进路、哪组道岔在什么位置——抽成数据表。进路表是最重要的一张表,每条进路记录它的始端信号机、终端信号机、经过的区段、道岔及其位置、需要照查的敌对进路、以及进路锁闭后要驱动的道岔动作。
在 VC++ 6.0 里,最常见是用结构体数组来组织:
typedef struct { int nRouteID; // 进路编号 int nStartSignal; // 始端信号机ID int nEndSignal; // 终端信号机ID int nTrackSections[8]; // 经过的轨道区段ID列表,-1结尾 int nSwitches[4]; // 进路上的道岔ID列表 int nSwitchPos[4]; // 道岔要求位置,1=定位,2=反位 int nOppRoute[4]; // 敌对进路ID列表,-1结尾 int nRouteType; // 0=列车进路,1=调车进路 } ROUTE_TABLE;说明:nTrackSections 数组长度 8 是在最大区段数小于 8 的小站常用写法。如果你在维护的站场有跨多股道的长进路,要注意数组边界,越界写是 VC++ 6.0 时代最隐蔽的内存问题。nSwitchPos 代表进路建立时要求道岔所处的位置。定位和反位在代码里通常用 1/2 表示,但也有人用 0/1,这个全看项目约定,接手代码时第一件事就是对图纸。nOppRoute 里的敌对进路判断是联锁里最容易漏逻辑的点,两条进路只要存在共用区段,或者存在侧向冲突,就必须互斥。
区段表单独存储轨道区段的占用状态、锁闭状态。道岔表存道岔当前位置、允许转换状态、锁闭状态。信号机表存灯色、灯丝状态、开放允许等信息。四张表加进路表,构成联锁运算的全部静态加动态数据源。动态状态每周期刷新,在旧代码里经常能看到用全局数组保存状态,而不是写在类成员里。这不算错,但注意可维护性——如果你接手的代码里到处是 extern int track_status[64],先忍住别重构,保证能跑比好看重要。
3. 核心联锁逻辑:用 VC++ 6.0 写进路选排与信号开放检查
3.1 进路选排:先查表、再转岔、再锁闭
进路选排是指值班员点击“排列进路”命令后,联锁程序把这条进路涉及的道岔转到要求位置,并把进路锁闭起来的过程。逻辑顺序不能反:先确认进路空闲,再转岔,道岔到位后锁闭进路和道岔,最后才允许信号开放。这个顺序一旦乱,就可能出现道岔还没到位信号就开放的事故。
常规代码流程可以写成下面这个样子:
BOOL CInterlockLogic::ArrangeRoute(int nRouteID) { ROUTE_TABLE* pRt = GetRouteByID(nRouteID); if (pRt == NULL) return FALSE; // 1. 检查进路上的所有轨道区段空闲且未锁闭 for (int i = 0; pRt->nTrackSections[i] != -1; i++) { if (GetTrackOccupied(pRt->nTrackSections[i]) || GetTrackLocked(pRt->nTrackSections[i])) { SetErrMsg("进路有区段占用或已锁闭"); return FALSE; } } // 2. 检查敌对进路是否已建立或已锁闭 for (int j = 0; pRt->nOppRoute[j] != -1; j++) { if (GetRouteActive(pRt->nOppRoute[j])) { SetErrMsg("敌对进路未解锁"); return FALSE; } } // 3. 转换道岔到要求位置 for (int k = 0; pRt->nSwitches[k] != 0; k++) { if (GetSwitchPos(pRt->nSwitches[k]) != pRt->nSwitchPos[k]) { MoveSwitch(pRt->nSwitches[k], pRt->nSwitchPos[k]); } } // 4. 等待道岔全部到位后锁闭进路 if (AllSwitchInPos(pRt)) { LockRoute(pRt->nRouteID); SetRouteActive(pRt->nRouteID, TRUE); return TRUE; } return FALSE; }逻辑说明:第三步只是发出转岔命令,道岔转换需要时间,实际工程代码里进路选排通常是一个状态机,要等道岔到位反馈来了才能进入“锁闭”状态。道岔转换期间如果有人取消排列,必须能把已转了一半的道岔停住并回转,这叫道岔转换中途控制,很多初版代码都漏掉。第四步的锁闭包括进路锁闭和道岔锁闭,锁的是动态状态,避免道岔在进路使用中被转动。SetRouteActive 是标记敌对进路关系的关键,只要有敌对进路活动,这条进路就别想建立。
参数说明:GetSwitchPos 返回的 1/2 要和你道岔表的定义保持一致。最常见的坑是设计图纸里定位写作 1、反位写作 2,代码里却用了 0/1,导致控制命令全部反转。接手代码时第一时间要把表里的定义和现场图纸对上。MoveSwitch 通常是异步的,只发命令,不能假设道岔已经到位,这也是为什么有第 4 步的等待判断。
3.2 信号开放检查:区段空闲、道岔位置、敌对进路一条不能少
进路锁闭之后,信号机才允许开放。信号开放判断的每一项都不能单独作为充分条件,必须全部满足,缺一项都是拿安全开玩笑。我梳理信号开放检查代码时,习惯按固定顺序逐项检查,写出来的逻辑就是下面这种:
BOOL CInterlockLogic::CanOpenSignal(int nRouteID) { ROUTE_TABLE* pRt = GetRouteByID(nRouteID); // 1. 进路必须处于锁闭状态 if (GetRouteLockState(nRouteID) != LOCKED) return FALSE; // 2. 进路内区段空闲,锁闭后、开放前再确认一次 for (int i = 0; pRt->nTrackSections[i] != -1; i++) { if (GetTrackOccupied(pRt->nTrackSections[i])) return FALSE; } // 3. 道岔位置在要求位置 for (int k = 0; pRt->nSwitches[k] != 0; k++) { if (GetSwitchPos(pRt->nSwitches[k]) != pRt->nSwitchPos[k]) return FALSE; } // 4. 敌对进路未建立、未锁闭 for (int j = 0; pRt->nOppRoute[j] != -1; j++) { if (GetRouteActive(pRt->nOppRoute[j])) return FALSE; } // 5. 信号机灯丝完好,允许开放 if (GetSignalLampState(pRt->nStartSignal) != LAMP_OK) return FALSE; return TRUE; }逻辑说明:信号开放不是“一键就能放”,而是要反复确认。第 1 步和第 4 步合起来是“联锁”二字的核心含义:没有锁闭不能开,有敌对进路不能开。第 2 步在锁闭后还要再查一次区段空闲,是因为存在“锁闭时空闲、开放前突然占用”的极端情况,这个重复检查不是多余的。
参数说明:GetRouteActive 判断敌对进路是否“已建立或已锁闭”,比单纯判断信号是否已开放更严格。有些旧代码只判断信号机开放状态,没判断敌对进路锁闭状态,这个漏洞很致命。灯丝检查在仿真代码里经常被省略,但真机上必须有。灯丝断丝会导致信号不能开放或自动降级为红灯。如果你在做仿真,至少要把这套检查流程完整保留,灯丝状态可以用一个开关模拟,别偷懒删掉。
3.3 状态采集与命令输出:通信线程和联锁线程怎么配合
联锁主机和现场设备之间,在工程上有两种接法。一种是通过安全继电器接口,直接用 I/O 卡采位和输出;另一种是通过现场总线或串口通信。不管是哪种,在 VC++ 6.0 代码里都逃不开一个通信线程不断读数据、联锁线程定期取数据的过程。这里最常见的反面教材是直接把采集代码写在联锁运算周期里,用串口同步读:
// 反面示例:直接在联锁周期里阻塞读串口 char buf[32]; DWORD dwRead = 0; ReadFile(hCom, buf, 32, &dwRead, NULL); // 设备无响应时会一直卡住这样写的问题很直接:设备一旦不响应,整个联锁运算停摆,界面完全假死。我在某次维护中就遇到真实翻车现场,某设备在高温天气下通信偶发中断,程序直接卡在串口读上,直到值班员反馈“信号全没了”才查出来。正确做法是把通信收和运算解耦,分成两个线程:
// 通信线程:循环收包,解析后写入共享缓冲 void CommThread() { while (!bStop) { char recvBuf[64]; DWORD nRead = 0; if (ReadFile(hCom, recvBuf, sizeof(recvBuf), &nRead, NULL)) { EnterCriticalSection(&cs); ParseAndStore(recvBuf, nRead); // 解析并写入共享缓冲 LeaveCriticalSection(&cs); } } } // 联锁线程:每个周期从共享缓冲取最新状态 void InterlockThread() { while (!bStop) { EnterCriticalSection(&cs); CopyLatestState(g_DeviceState, g_RecvBuffer); LeaveCriticalSection(&cs); RunInterlockLogic(); } }逻辑说明:通信线程只负责收包和解析,解析后的数据放进共享缓冲。联锁线程每个周期开始时取一次最新状态,取完立即释放锁,再去做运算。这样即使通信卡顿,联锁线程也只是拿到旧状态,不会卡死。
参数说明:通信线程读设备超时时间一般设 500ms 到 1000ms,用 WaitForSingleObject 配合 OVERLAPPED I/O 实现。VC++ 6.0 里直接用同步 ReadFile 简单,但必须开超时或独立线程,别在联锁周期里同步读。共享缓冲要防止读写冲突,用 CRITICAL_SECTION 最省事,注意临界区对象要全局或类成员,不能是局部变量。老项目的通信协议常见格式是固定字节,包含帧头、设备号、数据、校验,校验不对的帧直接丢弃,不能因为一帧坏数据就中断程序。
4. 联锁代码避坑指南:五个经典翻车现场与处理
4.1 信号不开放:状态采集延迟导致的数据旧账
现象:进路条件肉眼看着全都满足,区段没车、道岔在正确位置、敌对进路也没排,但信号就是不开。值班员反复取消重排好几次,信号才慢吞吞开放。
原因:状态采集有延迟。通信线程收到的轨道区段占用信息是上一周期的数据,联锁线程却把旧状态当成实时状态。常见于只采集状态却没有加“状态有效时间”标志,设备前一秒断线恢复后,旧状态被当成实时状态参与运算。
解决:给每条动态状态加时间戳。只有采集时间在最近一个周期内才参与联锁运算,超过周期阈值判为“状态超时”,进路按不安全处理,信号禁止开放。我在某站维护时就在轨道状态结构体里加了个 DWORD dwTimeStamp,每次更新时赋值 GetTickCount(),运算前先检查时间差。折腾几个小时后定位到的问题就是它。
4.2 道岔不动:控制命令发出但没有执行回执
现象:命令已经发给道岔了,现场转辙机没动作,程序里也不报任何错误。值班员盯着站场图,道岔显示还是原来的位置,过一会儿进路自动取消了,但报警记录里什么都没有。
原因:道岔转换是通过继电器或转辙机控制器执行的,命令帧发出去不代表装置动作。旧代码里 MoveSwitch 只负责发命令,没有跟踪执行结果。通信线上有噪声丢包、控制电路故障、转辙机卡阻,都可能导致命令没被执行,但程序一无所知。
解决:加“控制输出-状态回执”的双向确认。道岔命令发出后记录目标位置,然后在每个周期检查实际位置是否等于目标位置,超过设定时间仍未到位则判定道岔转换超时,给出报警,并取消锁闭、回退进路。超时时间一般设 3 到 5 秒,转辙机正常转换时间小于 2 秒,留出余量。
4.3 断电恢复后状态不一致:初始化顺序必须分两步
现象:程序断电重启后,站场图上显示某些区段还在锁闭状态,但现场设备实际已经解锁了。值班员想重新排进路,程序提示“进路未解锁”,直接把人搞懵。
原因:联锁程序里进路锁闭、道岔锁闭等状态属于动态数据,断电重启后程序按固定顺序初始化。有些老代码会用文件或内存映射保存状态,但保存顺序和数据有效性没处理好,导致恢复后的状态是“半锁闭”状态。
解决:初始化分两步。第一步清空全部动态状态,然后读外部记忆数据;第二步根据外部数据还原进路状态。如果外部记忆数据缺失,只能按“全站处于解锁状态”处理,然后通过与现场设备逐点核对来重建状态。这看起来慢,但安全。注意不要在初始化时自动“补锁”,那是拿猜测当事实。
4.4 运行越来越卡:VC++ 6.0 的 GDI 对象泄漏
现象:程序跑个两三天,操作响应越来越慢,站场图刷新像慢动作。重启程序又能坚持几天,过几天又卡。值班员普遍反映“这软件要天天重启”。
原因:内存泄漏。VC++ 6.0 下最常见的是 GDI 对象泄漏和内存碎片。站场显示刷新时,每帧创建新的画刷、画笔画布,旧对象没释放。或每次状态变化都 new 一个字符串对象,忘了 Delete。长时间跑下来句柄数飙升,系统资源耗尽。
解决:写代码时养成好习惯,凡是 CreateCompatibleDC、CreateFont、new 出来的对象,用完后记得 DeleteObject、Delete。排查时开任务管理器看 GDI 对象数,如果持续增长就是泄漏。还可以在代码里用 C++ 的析构机制封装 GDI 对象,析构函数里统一释放。VC++ 6.0 时代这个风格不太流行,但值得自己动手写一个。
4.5 双机切换后逻辑异常:状态同步不能只传快照
现象:双机热备系统切换后,联锁逻辑出现异常。轨道区段明明占用着,信号却开出来了;或者明明该解锁的进路一直锁着。值班员说“切完机就不对劲”。
原因:主备机数据不同步。联锁主备机靠通信线同步状态,如果同步的是“当前状态快照”而不是“状态变化日志”,在某一瞬间切换就会丢失变化事件。比如轨道区段在切换瞬间刚变成占用,备机没收到变化事件,只收到一份之前的空闲快照,于是保留旧的空闲状态,做出了错误判断。
解决:同步数据设计时同步“操作日志加状态快照”。每次联锁运算产生的状态变化事件带序号,备机按序号跟踪。如果序号丢失则拒绝对接。切换时先切换状态缓存,再重启联锁周期,不能直接拿主机的实时状态当全部历史。双机切换的代码逻辑要反复测试,我在实验室里用一个定时器模拟主备切换,每隔几十秒切一次,跑一整个晚上看会不会出问题。
5. 进阶:VC++ 6.0 下搭建联锁仿真验证环境
5.1 把 IO 层替换成虚拟设备,在 PC 上把联锁跑起来
写联锁代码时,先把 IO 层独立出来是个能省下大量时间的习惯。我一般会在类 CDeviceIO 里定义纯虚接口,真机实现是串口通信,仿真实现是内存变量。这样联锁运算层不依赖真实硬件,任何一台 PC 都能跑通。
class CDeviceIO { public: virtual BOOL ReadTrackState(int nTrackID, BOOL* pOccupied) = 0; virtual BOOL ReadSwitchPos(int nSwitchID, int* pPos) = 0; virtual BOOL WriteSwitchCmd(int nSwitchID, int nPos) = 0; virtual BOOL WriteSignalCmd(int nSignalID, int nColor) = 0; }; class CSimDeviceIO : public CDeviceIO { public: BOOL ReadTrackState(int nTrackID, BOOL* pOccupied) { *pOccupied = m_bTrackOccupied[nTrackID]; // 仿真空闲/占用 return TRUE; } BOOL WriteSwitchCmd(int nSwitchID, int nPos) { m_nSwitchPos[nSwitchID] = nPos; // 仿真直接到位 return TRUE; } };逻辑说明:仿真 IO 核心是状态翻转和命令直接生效。道岔从定位转反位,真机需要两三秒,仿真是立刻完成。所以我建议在仿真里加入延迟模拟,比如移动到位前先延迟一小段时间,否则发现不了道岔超时这类时序 bug。m_bTrackOccupied 这些仿真状态可以用 MFC 对话框上的复选框模拟,也可以加一个定时器随机翻转,看联锁能不能正确处理。
参数说明:仿真建议故意加入 2 到 3 秒道岔转换延迟、1 秒信号开放延迟,把真实环境的不确定性引进来。随机占用要在所有区段上都做,不能只测一两条,否则边界情况全漏了。
5.2 用固定的进路表做逐条进路遍历验证
搭建完仿真环境,最重要的验证就是逐条进路测试。把进路表里每条进路都排列一遍,验证信号开放是否正常;然后人为设置区段占用、道岔位置错误、敌对进路未解锁,分别验证信号是否被拒绝开放。测试时我习惯准备一张纸,把每条进路按列车进路和调车进路分类,逐条打钩。
做完这些后一定记得测取消进路、人工解锁、接近锁闭这些次级逻辑。很多仿真程序都栽在“正常排列都能过,一到取消就不行”上。取消进路时,程序要把已锁闭的进路完整解开,道岔恢复可动,这个反向流程比正向排列更容易出问题。
处理这类问题的经验是:别怕麻烦,把每条进路的正反流程都跑一遍,跑完再看双机切换和断电恢复。仿真环境里能发现的问题,到了现场那就是事故隐患。这条习惯是我吃过亏换来的,希望你也能用上,希望帮到你。
本文还有配套的精品资源,点击获取