ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C#模拟电梯控制源码实战:状态机与调度算法深度解析

C#模拟电梯控制源码实战:状态机与调度算法深度解析 简介这是一份面向C#学习者的电梯控制系统模拟源码围绕电梯调度、楼层请求响应、开关门状态切换等核心逻辑展开适合正在学习Windows桌面应用开发或需要完成课程设计的读者参考。包内共27个文件压缩包大小约246KB主要包含cs源代码、sln/csproj工程文件、exe可执行程序、resx资源文件以及ico、bmp等界面素材工程结构完整可直接用Visual Studio打开编译运行。代码中实践了类与对象建模、委托与事件、多线程同步、状态机、简单调度算法等C#关键知识并辅以try-catch异常处理和单元测试思路有助于把抽象语法落到实际控制系统场景中。项目已有280人学习下载源码中还保留了可执行文件和调试文件便于边运行边对照代码理解电梯在上升、下降、开门、关门过程中的状态转换与请求调度机制对巩固C#编程基础和系统设计能力都有帮助。1. 整体设计思路拆解为什么拿C#写模拟电梯控制源码C#模拟电梯控制源码这套东西是我在地下室项目里最值得收藏的一份练手代码。当时为了给团队里新来的上位机开发同事出一道“不依赖外部硬件也能练逻辑”的考核题我花了两天时间把一台模拟电梯从状态定义推到多线程调度最终落地成一套可运行的控制台程序。做完整套才发现它几乎囊括了C#开发里最容易翻车的那批知识点状态机建模、调度算法选型、UI与业务逻辑分离、定时器精度、多线程安全。无论你是刚学C#的新人还是已经在工控、上位机领域写了好几年业务代码的老兵这套模拟电梯控制源码都有它的参考价值。新人可以用它理解“程序如何感知真实世界的变化”老手则可以用它快速验证调度策略、练手重构、甚至作为C#面试题的原型。为什么选电梯这个场景因为电梯控制本质上是一个“离散事件驱动系统”外部输入楼层按钮、内部输入轿厢按钮、物理状态位置、速度、门状态不断变化控制器需要在每个决策点给出合理响应。这种模型和很多实际项目是同构的——收费系统的闸机控制、仓储物流的分拣线路、自助设备的流程状态管理本质上都是一回事。搞透电梯模拟你就掌握了这类系统的基本盘。1.1 核心模块划分从一台电梯抽象出几个“对象”模拟电梯控制源码最忌讳一上来就写一个大而全的“电梯类”把所有逻辑都塞进去。真实可控的项目必须把职责拆开。我最终划分成四个核心模块电梯本体Elevator记录当前位置、目标楼层、当前状态、是否有人进出、门状态。调度决策模块Dispatcher负责从请求池中取出一个“最合理”的楼层作为新目标。请求池RequestPool保存所有内呼/外呼请求请求带方向属性和来源楼层。运行模拟模块SimulationCore用定时器推进时间驱动电梯状态变化刷新显示。这套拆分不是学院派设计纯粹是从调试经验里逼出来的。最开始我把调度逻辑写在电梯类内部结果每次改算法都要重新编译电梯类测试一个场景要盯着好几个字段来回切。拆开之后调度算法可以独立测试电梯本体只负责“按指令运动”职责清晰排查问题也快得多。1.2 方案选型控制台版 vs WinForms/WPF版控制台版本最大的优势是逻辑透明没有界面事件的干扰非常适合第一版实现和算法验证。而WinForms/WPF版本的优势是直观——能实时看到轿厢位置、方向箭头、楼层按钮灯亮灭更适合演示和面试展示。我的建议是先写出一个控制台版本把调度和状态机都调通了再花一到两小时套一个WinForms壳。直接上手带界面的版本很容易把时间耗死在跨线程UI刷新上核心逻辑反而没理顺。毕竟这个项目的核心价值是“控制逻辑”不是“画界面”。2. 核心细节解析状态机与调度策略怎么选2.1 电梯控制核心——状态机设计电梯的行为不是线性的你没法用一个“当前楼层”就把它描述清楚。它可能正在匀速上行可能刚到目标层正在平层可能门开了一半也可能停在原地等待指令。这一堆互斥的状态就是状态机发挥作用的场景。我最终定义了这么几个核心状态public enum ElevatorState { Idle, // 待机无目标 Accelerating, // 启动加速 Moving, // 匀速运行中 Decelerating, // 到达前减速 Leveling, // 平层停靠位置已对准楼层 DoorOpening, // 开门过程中 DoorOpen, // 门已完全打开停留等待 DoorClosing, // 关门过程中 }面试或者练手的话状态可以简化成 Idle、Moving、DoorOpen 三个但做完整项目我建议保留全部状态因为后续加特效加速曲线、超时蜂鸣会自然需要这些“中间过程”。状态机的黄金法则是每个状态只处理“当前状态下该做的事”转移条件集中在状态转移函数里。比如只有 DoorOpen 状态下才能响应“关门计时结束”事件否则事件来了也直接忽略。这里有一个特别容易踩的坑状态机最怕“没有单一入口”。很多新手写状态切换时直接在定时器回调里写if (state Moving) { ... state Idle; }又在按钮事件里写if (state Idle) { state Moving; }两处都能改状态一旦逻辑变复杂就会出现状态被覆盖的问题。我的习惯是统一走ChangeState(ElevatorState newState)方法所有转移都从这一个入口过打印状态变更日志也方便。2.2 调度策略取舍什么才是“像电梯”的算法“电梯怎么决定下一站去哪”是整个项目里最见水平的部分。我第一次实现时用的是最粗暴的先来先服务FCFS结果电梯在6楼向上走时8楼有人按了向下电梯就立刻掉头去8楼然后又发现1楼有请求又往下冲——这种行为在真实电梯里是绝对不允许的乘客会骂娘。后来我换成了 LOOK 算法也叫电梯算法核心思路是保持当前运行方向优先处理同方向的顺路请求当前方向再也没有请求时才掉头。这种策略与真实电梯的运行惯性一致也符合乘客的直觉。下面是我实际使用的 LOOK 调度核心代码private int FindNextTargetFloor(Elevator elevator, RequestPool pool) { int current elevator.CurrentFloor; int direction elevator.Direction; // 1 向上, -1 向下, 0 为无方向 // 先找同方向且顺路的最近请求 int nearest -1; if (direction 0) { for (int floor current; floor maxFloor; floor) { if (pool.HasRequest(floor, Direction.Up) || pool.HasRequest(floor, Direction.Down)) { nearest floor; break; } } } else if (direction 0) { for (int floor current; floor minFloor; floor--) { if (pool.HasRequest(floor, Direction.Up) || pool.HasRequest(floor, Direction.Down)) { nearest floor; break; } } } if (nearest ! -1) return nearest; // 当前方向没有请求掉头找另一方向的 for (int floor 0; floor maxFloor; floor) { if (pool.HasRequest(floor, Direction.Up) || pool.HasRequest(floor, Direction.Down)) return floor; } return -1; // 无任何请求 }这段代码的思路非常直白先从当前楼层向当前方向扫描遇到第一个有请求的楼层就作为目标扫描不到再整体扫描一遍。这种做法能保证电梯不会因为新来的反向请求而立刻掉头乘客体验会稳定很多。当然调度算法没有银弹。如果你做的是多电梯群控场景还需要考虑“闲时电梯停靠层”“分散候梯策略”等更复杂的逻辑但单梯场景用 LOOK 已经足够自然。2.3 请求池与事件处理别在高频刷新里找数据请求池设计得是否优雅决定了代码是否好调试。我采用的是“字典 状态标志”的方式而不是简单Listpublic class RequestPool { private Dictionaryint, bool upCalls; private Dictionaryint, bool downCalls; private HashSetint cabinCalls; public void AddUpCall(int floor) { upCalls[floor] true; } public void AddDownCall(int floor) { downCalls[floor] true; } public void AddCabinCall(int floor) { cabinCalls.Add(floor); } public bool HasRequest(int floor, Direction dir) { if (dir Direction.Up upCalls.ContainsKey(floor) upCalls[floor]) return true; if (dir Direction.Down downCalls.ContainsKey(floor) downCalls[floor]) return true; if (cabinCalls.Contains(floor)) return true; return false; } public void ClearFloor(int floor) { upCalls[floor] false; downCalls[floor] false; cabinCalls.Remove(floor); } }注意这里外呼按钮区分了上/下行方向因为真实电梯系统里同一个楼层的“向上”和“向下”按钮是独立的。请求被响应后必须显式清除该楼层的所有呼叫标志否则会出现电梯到了目标层却不“消号”导致下一轮调度又选中它——这个问题在调试时坑了我整整一个下午。3. 实操过程与核心代码实现跑通一套最小闭环3.1 环境准备与项目搭建我建议使用 .NET 6 及以上版本创建控制台项目即可不需要额外NuGet包。创建工程后先建四个文件Elevator.cs、RequestPool.cs、Dispatcher.cs、Program.cs目录结构清晰是第一生产力。开发环境我用的 Visual Studio 2022但如果你习惯 VS Code dotnet CLI 也一样。确认环境无误后先跑一个空的主程序再逐模块填充。3.2 定时器驱动的模拟运行时电梯模拟最核心的运行机制是“时间推进”。真实电梯靠电机驱动和传感器反馈我们的模拟器需要一个心跳源。我推荐用System.Threading.Timer而不是 WinForms 的Timer因为后者强依赖UI线程的消息循环控制台项目里根本不触发。关键代码如下private Timer _timer; private readonly object _lock new object(); public void Start() { _timer new Timer(Tick, null, 0, 200); // 每200ms执行一次Tick } private void Tick(object state) { lock (_lock) { if (_elevator.State ElevatorState.Moving) { MoveElevator(); } else if (_elevator.State ElevatorState.Idle) { TryDispatchNextTarget(); } RenderStatus(); } }每 200ms 刷新一次电梯的位置变化计算公式为private void MoveElevator() { int direction _elevator.Direction; double step _elevator.Speed * 0.2; // 速度(m/s) * 时间间隔(s) _elevator.Position direction * step; // 检查是否到达目标层 double diff _elevator.TargetFloor - _elevator.Position; if (Math.Abs(diff) 0.1) { _elevator.CurrentFloor _elevator.TargetFloor; _elevator.Position _elevator.TargetFloor; _elevator.State ElevatorState.Leveling; _requestPool.ClearFloor(_elevator.CurrentFloor); _elevator.Direction 0; } }这里有一个非常重要的细节位置是浮点数楼层是整数。你不能直接判断Position TargetFloor因为浮点数累加会有误差永远可能差一个 0.000001。必须用阈值比较我取 0.1 米作为平层误差范围——因为这个模拟器里层高默认是 3 米0.1 米在真实电梯里已经是比较精准的平层精度了。阈值设大了会出现“电梯没到楼层就开门”的假象设小了可能出现永远对不齐的bug。加锁lock (_lock)是为了防止多个入口同时修改电梯状态。虽然在控制台项目里并发冲突概率不高但如果你后续把代码迁移到WinForms并添加按钮事件这一步是防患于未然。3.3 电梯本体类的核心结构电梯本体类不能只是“用数字记录楼层”它要承载完整的运行状态public class Elevator { public int CurrentFloor { get; set; } public double Position { get; set; } public int TargetFloor { get; set; } public int Direction { get; set; } // -1 下, 0 待机, 1 上 public ElevatorState State { get; set; } public double Speed { get; set; } // 默认 1.0 m/s public int Capacity { get; set; } public int PassengerCount { get; set; } public bool IsOverloaded PassengerCount Capacity; public bool IsMoving State ElevatorState.Moving || State ElevatorState.Accelerating; }这里面Speed是可调参数我建议留成字段而不是硬编码。因为面试或演示时把速度调成 2.0 能快速看到调度效果调成 0.5 能仔细观察平层精度体验完全不同。3.4 决策触发逻辑什么时候调用调度器调度不能每 200ms 无脑调用否则电梯在运行过程中反复重新选目标会破坏 LOOK 算法的方向保持特性。我的规则是只有电梯处于 Idle 状态或者刚完成一个目标到达 Leveling 时才触发下一轮调度。private void TryDispatchNextTarget() { int next _dispatcher.FindNextTargetFloor(_elevator, _requestPool); if (next -1) { RenderMessage(所有请求已响应电梯待机...); return; } _elevator.TargetFloor next; _elevator.Direction next _elevator.CurrentFloor ? 1 : -1; _elevator.State ElevatorState.Moving; RenderMessage($调度决策: 从 {_elevator.CurrentFloor} 楼 - {next} 楼); }这里如果next CurrentFloor但请求还没清理干净可能会造成电梯“空跑”又停下。所以调度器返回目标后要判断next和当前楼层是否相等如果相等则立即清理请求并再次调度这段防御性逻辑在实际调试中能省不少事。3.5 主程序入口与按钮事件模拟为了让这个模拟器能“动”起来我在主程序里加了一个简单的命令循环模拟外部乘客按楼层按钮static void Main(string[] args) { var elevator new Elevator { CurrentFloor 1, Position 1 }; var pool new RequestPool(); var dispatcher new Dispatcher(pool); var sim new SimulationCore(elevator, pool, dispatcher); sim.Start(); while (true) { var input Console.ReadLine(); if (input exit) break; // 输入格式: up 5 表示5楼按上行按钮 // 输入格式: down 3 表示3楼按下行按钮 // 输入格式: call 7 表示轿厢内去7楼 var parts input.Split( ); if (parts.Length ! 2) continue; int floor int.Parse(parts[1]); switch (parts[0]) { case up: pool.AddUpCall(floor); break; case down: pool.AddDownCall(floor); break; case call: pool.AddCabinCall(floor); break; } sim.WakeUp(); // 唤醒调度 } }这段代码的思路是把控制台输入当作“物理世界的按钮信号”请求进入请求池后调度器在下一次心跳中响应。这是一个很干净的模型——业务逻辑只依赖请求池的状态不依赖请求是键盘输入、真实按钮还是网络报文。3.6 控制台状态刷新让运行过程肉眼可见控制台版渲染不要求华丽但必须能一眼看出电梯在哪、往哪开、门的状态、哪些楼层有请求。我的做法是保留一个固定区域刷新的模式private void RenderStatus() { int top Console.CursorTop; Console.SetCursorPosition(0, 0); var arrow _elevator.Direction 1 ? ↑ : _elevator.Direction -1 ? ↓ : ·; Console.WriteLine($当前楼层: {_elevator.CurrentFloor,2} 目标楼层: {_elevator.TargetFloor,2} 方向: {arrow} 状态: {_elevator.State}); // 打印楼层列表用[ ]表示有上行请求( )表示有下行请求 for (int floor maxFloor; floor minFloor; floor--) { string flag ; if (pool.HasRequest(floor, Direction.Up)) flag ▲; if (pool.HasRequest(floor, Direction.Down)) flag ▼; if (_elevator.CurrentFloor floor) Console.WriteLine(${floor,2} [电梯] {flag}); else Console.WriteLine(${floor,2} {flag}); } Console.SetCursorPosition(0, top); }实际运行时控制台顶部固定的“当前楼层/目标楼层/状态”信息会每 200ms 刷新一次下面的楼层列表也跟着更新。这个效果虽然土但逻辑清晰特别适合断点调试时看状态变化。4. 常见问题与排查技巧实录我踩过的五个坑4.1 电梯反复来回“空跑”同一个请求被响应了两遍症状表现是电梯明明已经停靠在某个楼层并且执行了ClearFloor但下一次调度又把它选为目标导致电梯在原地开关一次门后继续跑。问题根源是ClearFloor的清理逻辑没有覆盖所有请求类型。我最早只清理了外呼的upCalls漏了downCalls和cabinCalls。调度器一查cabinCalls还有这个楼层就又把这个楼层当目标了。排查方法在FindNextTargetFloor入口和退出处各打印一次当前请求池的完整状态对比电梯停靠前后的变化。修复方式就是统一用ClearFloor一次性清理该楼层所有类型的请求并验证清理不可重复。4.2 定时器Tick里做大量字符串拼写和渲染界面卡成PPT控制台版本也会卡。最开始我每 200ms 调一次Console.WriteLine而且同时输出十几行控制台的字符缓冲来不及刷新就出现明显的丢帧。解决思路是降低渲染频率、提高计算频率。我后来把计算和渲染分成了两个Timer一个100ms计算电梯位置另一个500ms刷新界面。计算保持实时渲染没必要太快人眼本来就感知不到500ms以内的变化。4.3 平层误差阈值设置不当电梯停不准上面提过位置判断不能直接比较浮点数和整数。但我最初把阈值设成了0.01米结果因为累加步长是速度 * 0.2秒速度是0.8m/s时步长0.16米跳到目标层附近时直接越过了0.01这个误差带导致电梯永远不能在目标层停下来“穿堂而过”。解法是用“逼近法”而不是“等距法”在移动前先计算下一步会不会超过或到达目标层如果会就强制平层double nextPosition _elevator.Position direction * step; if ((direction 0 nextPosition _elevator.TargetFloor) || (direction 0 nextPosition _elevator.TargetFloor)) { _elevator.Position _elevator.TargetFloor; // 触发平层逻辑 } else { _elevator.Position nextPosition; }这种处理方式在真实电梯里叫“预减速点判断”虽然模拟器不需要那么复杂的S曲线但“提前判断能否到达”的思路是一致的。4.4 多线程环境下状态被覆盖WinForms版本里有一个典型的“死法”用户在按钮点击事件里直接改了elevator.State同一时刻定时器Tick也在改状态两个线程互相覆盖最后电梯飘在空中。解决方案是我在Tick和所有对外接口里统一加锁并且立了一条规矩任何外部代码不得直接修改电梯的 State 字段只能通过PressButton、TickMove这样的公开方法触发状态变更状态变更只能由状态机入口ChangeState完成。4.5 调度器死循环电梯原地打转出现过一次电梯在 3 楼和 4 楼之间反复跑的场景。原因是调度器选择的下一目标正好是当前楼层Direction算出来是 0MoveElevator里方向为0时位置不更新但状态仍然被设置成Moving于是每 200ms 都进入移动分支却永远到不了任何楼层。修复方式是调度器返回前加一个判断如果next currentFloor则直接清理请求、保持Idle状态等待定时器下一轮重新调度。这段逻辑如同保险丝能拦截掉几乎所有“原地打转”类问题。4.6 常见问题速查表症状可能原因解决方式电梯反复开关门不走请求池清理不完整统一调用ClearFloor清理所有请求类型电梯冲过目标层平层误差阈值设置不当使用“逼近法”判断下一位置是否到达界面卡顿渲染频率过高拆分计算Timer和渲染Timer状态错乱多线程直接修改State加锁 统一ChangeState入口电梯空跑调度器误判当前楼层为目标增加next currentFloor防御分支5. 从模拟器到真实系统扩展思路与进阶方向写完模拟电梯控制源码后这个项目还能向几个真实方向延伸对C#上位机开发尤其有参考价值加入多电梯群控请求池不变但调度器需要维护多台电梯的“任务列表”并引入“最小等待时间”策略。这是目前真实楼宇中最常见的系统形态。通过Socket或HTTP暴露控制接口把调度器封装成服务用TCP/HTTP接收外部请求——这类设计在上位机领域非常普遍电梯模拟器是最好的练手载体。用反射实现调度器插件化把FCFS、SCAN、LOOK算法各自封装成独立类通过反射加载。热词里反复出现“C#反射”这个项目正好给你一个不尴尬的应用场景。事件驱动重构用事件替代轮询比如“到达楼层事件”“门超时事件”“按钮按下事件”更接近真实消息驱动的嵌入式架构。真正的电梯控制系统远比模拟复杂涉及真实硬件传感器、安全回路、速度曲线模型但控制逻辑层面的核心思维和这套模拟器是相通的。用C#把模拟版本写通再去接触PLC、真实通信协议时你会有一种“这题我见过”的笃定。最后再分享一个我实际操作中的体会不要一上来就追求“完美架构”先把最简状态机跑通再逐步增加请求、调度、刷新逻辑。每加一个功能点就运行一次观察电梯的动态变化。这套“小步迭代”的方法比一次写完再调三个小时 bug 的效率高得多。如果你准备拿它去面试强烈建议把 LOOK 算法的推导过程自己先讲一遍——能讲清楚“为什么电梯不会中途掉头”的人和只会背代码的人面试官一眼就能分辨出来。本文还有配套的精品资源点击获取
返回列表