VC++电梯调度模拟:从多线程同步到LOOK算法实现
2026/8/8 16:02:18 网站建设 项目流程

1. 项目概述与核心价值

最近在整理硬盘时,翻出了一个大学时期做的老项目——一个用VC++6.0写的电梯调度系统模拟程序。虽然现在看界面有点“复古”,但里面的调度逻辑、多线程同步、状态机设计,放到今天依然是理解操作系统、并发编程和实时系统设计的绝佳案例。这个项目不只是为了模拟电梯上上下下,其核心在于如何用软件精准地模拟一个具有并发请求、资源竞争和实时响应的物理系统。对于正在学习C++、Windows编程,或者对系统设计感兴趣的朋友来说,亲手实现一遍,远比看十篇理论文章来得深刻。

简单来说,这个项目就是用VC++(Visual C++)开发一个图形化程序,模拟一栋大楼里一部或多部电梯的运行。用户可以在不同楼层“按下”上行或下行呼叫按钮,电梯会根据内置的调度算法(比如经典的扫描算法LOOK、最短寻道时间优先SSTF等)来决定响应顺序和运行方向。它要解决的核心问题是:在多个随机、并发的用户请求下,如何让电梯高效、公平地运行,最小化乘客的平均等待时间和电梯的总运行能耗。这听起来像是一个算法问题,但实现起来,却是一个涉及消息循环、线程安全、定时器、图形渲染和数据结构设计的综合性工程。

2. 系统整体设计与核心思路拆解

2.1 为什么选择VC++与MFC?

现在可能很多人会问,为什么不用Qt、C#或者Python?回到那个年代,VC++6.0加上MFC(Microsoft Foundation Classes)是Windows桌面应用开发的事实标准。选择它,首先是教学和学习的经典性。MFC虽然以“难学”著称,但其对Windows消息机制(如按钮点击WM_COMMAND、定时器WM_TIMER、绘图WM_PAINT)的封装,能让你透彻理解事件驱动编程的本质。其次,对于模拟系统这种需要精细控制界面刷新(模拟电梯移动)、处理用户交互(按钮点击)和后台逻辑(调度计算)的应用,MFC提供的文档-视图架构和控件库非常合适。最后,用原生C++实现核心调度算法,能保证性能,也便于进行深入的数据结构优化。

项目的整体架构可以看作一个典型的生产者-消费者模型。生产者是用户界面(UI),它不断产生新的“乘客请求”(某楼层有人按了上行或下行按钮)。消费者是电梯调度器(核心逻辑模块),它按照既定算法,从请求队列中取出请求,并控制电梯(执行器)去响应。而电梯本身是一个状态机,其状态包括:静止、上行、下行、开门、关门。整个系统的挑战在于如何让这三者协调、高效、无冲突地工作。

2.2 核心模块划分与交互

一个健壮的模拟系统至少应包含以下五个核心模块:

  1. 用户界面模块:基于MFC的对话框或单文档视图。主要包含大楼楼层示意图、电梯井道图、电梯轿厢图形、各楼层的上下行呼叫按钮面板、电梯内部的楼层选择按钮面板,以及一个用于显示运行日志的列表框。
  2. 请求生成与管理模块:负责接收来自UI的按钮点击事件,将其转化为内部的“请求”数据结构(通常包含请求楼层、请求方向、时间戳等),并放入一个全局的请求队列。这里需要考虑线程安全,因为UI事件在主线程,而调度器可能在工作者线程。
  3. 电梯调度算法模块:这是项目的大脑。它持续监控请求队列和电梯当前状态(位置、方向、目标楼层列表),根据所选算法决定下一个服务的目标楼层。常见的算法有:
    • 先来先服务:最简单,但效率低下。
    • 扫描算法:电梯持续向一个方向运行,响应沿途的同向请求,到达该方向最远端后反向。
    • LOOK算法:扫描算法的优化版,电梯在前进方向上如果没有待处理的请求,就提前反向,而不是走到物理尽头。
    • 最短寻道时间优先:总是选择距离当前楼层最近的请求,可能导致某些楼层“饥饿”。
  4. 电梯状态机与控制模块:这是一个独立的模拟实体。它接收来自调度器的指令(如“前往5楼”),并根据指令更新自身状态。例如,从“静止”变为“上行”,每隔一个模拟时间单位(如100毫秒)更新一次楼层位置,到达目标楼层后,状态变为“开门”,停留数秒后“关门”,再根据新指令决定下一个状态。这个模块需要驱动UI上的电梯图形移动和门开关动画。
  5. 定时与动画驱动模块:整个模拟世界的时间基准。通常使用Windows的SetTimerAPI或多媒体定时器timeSetEvent来创建一个高精度的定时器。在每个定时器消息中,更新电梯位置、检查状态切换条件、触发UI重绘,从而形成连贯的动画。

注意:在MFC中,直接在主线程进行长时间计算(如复杂的调度算法)会阻塞消息循环,导致界面“卡死”。因此,通常将调度逻辑或电梯状态更新放在一个独立的工作者线程中,通过线程安全的方式与UI线程交换数据(如使用PostMessage发送自定义消息通知UI更新)。

3. 核心细节解析与实操要点

3.1 数据结构设计:请求队列与电梯状态

数据结构是程序的骨架。这里有两个核心:

请求队列的设计:不能简单地用一个std::vectorstd::list。因为请求会从UI线程(生产者)插入,从调度线程(消费者)读取和删除,必须考虑线程安全。一个经典的方案是使用std::queuestd::list配合一个临界区或互斥量。

// 请求结构体 struct ElevatorRequest { int floor; // 请求发出的楼层 Direction dir; // 请求方向:上行、下行、内部(轿厢内按钮) time_t timestamp; // 请求时间,用于分析和统计 bool isInternal; // 是否是轿厢内按钮请求 }; // 线程安全的请求队列 class RequestQueue { private: std::list<ElevatorRequest> m_queue; CCriticalSection m_cs; // MFC提供的临界区类,用于同步 public: void Push(const ElevatorRequest& req) { CSingleLock lock(&m_cs, TRUE); // 加锁 m_queue.push_back(req); } bool Pop(ElevatorRequest& req) { CSingleLock lock(&m_cs, TRUE); if (m_queue.empty()) return false; req = m_queue.front(); m_queue.pop_front(); return true; } // ... 其他方法,如检查队列是否为空、获取队列快照等 };

电梯状态机的实现:电梯本身是一个状态机,可以用枚举定义状态,并用一个类来封装所有属性和行为。

enum ElevatorState { IDLE, MOVING_UP, MOVING_DOWN, DOOR_OPENING, DOOR_OPEN, DOOR_CLOSING }; class CElevator { private: ElevatorState m_state; int m_currentFloor; // 当前楼层(实数,用于平滑移动动画) int m_targetFloor; // 下一个目标楼层 float m_speed; // 每秒移动的楼层数 std::vector<int> m_internalRequests; // 轿厢内按下的目标楼层 // ... 其他属性,如载重、最大楼层、最小楼层等 public: void Update(float deltaTime); // 根据状态和耗时更新位置 void SetTarget(int floor); // 设置目标楼层 void OpenDoor(); // 执行开门动作 void CloseDoor(); // 执行关门动作 // ... 状态获取和判断方法 };

3.2 调度算法实现:以LOOK算法为例

LOOK算法是电梯调度中最实用、最经典的算法之一。其核心思想是“按需扫描”,而不是死板地走到尽头。

算法步骤:

  1. 电梯有一个当前运行方向(上行或下行)。
  2. 在前进方向上,检查是否有请求(包括同向的楼层呼叫和轿厢内目标楼层请求)。
  3. 如果有,则继续向该方向移动,并响应沿途的请求。
  4. 如果前进方向上没有待处理的请求,则检查反方向是否有请求。
  5. 如果有,则改变运行方向,并开始响应反方向的请求。
  6. 如果两个方向都没有请求,则电梯进入空闲状态。

C++实现伪代码:

int Scheduler::GetNextTarget(const CElevator& elevator, const std::list<ElevatorRequest>& allRequests) { int currentFloor = elevator.GetCurrentFloor(); Direction currentDir = elevator.GetDirection(); std::vector<int> targets; // 收集所有可能的候选目标 // 1. 收集所有未完成的请求对应的目标楼层 for (const auto& req : allRequests) { // 如果是轿厢外请求,目标楼层就是请求楼层本身 // 如果是轿厢内请求,目标楼层是请求要去的楼层 targets.push_back(req.targetFloor); } // 加上电梯内部已经按下的目标楼层 auto internalTargets = elevator.GetInternalTargets(); targets.insert(targets.end(), internalTargets.begin(), internalTargets.end()); if (targets.empty()) return -1; // 无请求,返回-1表示空闲 // 2. 根据当前方向筛选候选目标 std::vector<int> candidates; if (currentDir == UP || currentDir == IDLE) { // 空闲时默认向上寻找 for (int floor : targets) { if (floor >= currentFloor) candidates.push_back(floor); } if (!candidates.empty()) { // 选择前进方向上最近的请求 return *std::min_element(candidates.begin(), candidates.end()); } } // 3. 前进方向无请求,则反向 if (currentDir == DOWN || currentDir == IDLE) { candidates.clear(); for (int floor : targets) { if (floor <= currentFloor) candidates.push_back(floor); } if (!candidates.empty()) { // 选择反方向上最远的请求(因为要改变方向,通常先服务最远的,以减少反复转向) return *std::max_element(candidates.begin(), candidates.end()); } } // 理论上不会走到这里,因为targets非空 return -1; }

实操心得:在实现调度器时,一个常见的坑是状态同步。调度器计算出的下一个目标楼层,需要安全地传递给电梯控制模块。同时,当电梯正在响应一个请求时(比如正在开门),调度器不应该频繁地打扰它。我通常的做法是,电梯在“静止”或“移动即将到达”状态时,才主动向调度器询问下一个目标。这可以通过在CElevator::Update函数中设置状态标志来实现。

3.3 图形界面与动画实现

MFC的图形绘制主要在OnPaint函数中完成。为了流畅的动画,我们需要在定时器消息中更新电梯的位置等模型数据,然后强制窗口重绘。

步骤:

  1. 定义绘图数据:在View类或Dialog类中,保存电梯的实时位置(float类型,支持层与层之间的中间位置)、门的状态(开启比例,0.0到1.0)等。
  2. 设置定时器:在OnInitDialogOnInitialUpdate中,用SetTimer(1, 50, NULL)设置一个50毫秒触发一次的定时器。
  3. 更新模型:在OnTimer消息处理函数中,调用CElevator::Update(0.05f)(因为50ms=0.05秒)来更新电梯位置。如果位置或状态发生变化,就调用Invalidate(FALSE)触发重绘。
  4. 绘制图形:在OnPaint函数中,根据最新的模型数据,使用GDI函数(如Rectangle,Ellipse,LineTo,TextOut)绘制大楼、电梯井、电梯轿厢、楼层数字、按钮状态等。
void CElevatorSimView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { // 更新所有电梯状态 float deltaTime = 0.05f; // 50ms for (auto& elevator : m_elevators) { elevator.Update(deltaTime); } // 检查是否需要重绘 if (IsNeedRedraw()) { Invalidate(FALSE); // FALSE表示不擦除背景,减少闪烁 } // 可以在这里也调用调度器逻辑 m_scheduler.Dispatch(m_elevators, m_requestQueue); } CView::OnTimer(nIDEvent); } void CElevatorSimView::OnPaint() { CPaintDC dc(this); // 1. 绘制背景和大楼 DrawBuilding(dc); // 2. 绘制每个电梯 for (const auto& elevator : m_elevators) { DrawElevator(dc, elevator); } // 3. 绘制楼层按钮和状态文本 DrawButtonsAndStatus(dc); }

抗闪烁技巧:直接绘制在窗口上,频繁重绘会导致闪烁。经典的解决方法是双缓冲:先在内存设备上下文(CDC)中绘制完整图像,然后一次性拷贝到屏幕。

void CElevatorSimView::OnPaint() { CPaintDC dcScreen(this); CRect rect; GetClientRect(&rect); // 创建内存DC和位图 CDC dcMem; CBitmap memBitmap; dcMem.CreateCompatibleDC(&dcScreen); memBitmap.CreateCompatibleBitmap(&dcScreen, rect.Width(), rect.Height()); CBitmap* pOldBitmap = dcMem.SelectObject(&memBitmap); // 在内存DC上绘制 DrawEverything(dcMem, rect); // 你的所有绘制函数 // 一次性拷贝到屏幕 dcScreen.BitBlt(0, 0, rect.Width(), rect.Height(), &dcMem, 0, 0, SRCCOPY); // 清理 dcMem.SelectObject(pOldBitmap); }

4. 实操过程与核心环节实现

4.1 项目搭建与MFC基础框架

假设你使用的是较新版本的Visual Studio(如VS2019),创建项目时选择“MFC应用程序”。为了更贴近原始VC++6.0的体验并简化,我们可以选择“基于对话框”的应用类型。

  1. 创建项目:打开VS,新建项目 -> 选择“MFC应用” -> 命名项目(如ElevatorSim)-> 在“应用程序类型”中选择“基于对话框” -> 取消“使用Unicode库”(如果希望和早期代码兼容,但新项目建议使用Unicode)-> 完成。
  2. 设计主对话框:打开资源视图中的主对话框(IDD_ELEVATORSIM_DIALOG)。删除默认的“确定”、“取消”按钮和静态文本。从工具箱拖拽控件:
    • Picture Control:用作绘制电梯运行区域的画布。设置其ID为IDC_DRAW_AREA,并修改其属性,将Type改为RectangleColor改为White
    • List Box:用于显示运行日志。ID设为IDC_LOG_LIST
    • Button:用于控制开始、暂停、重置。ID分别设为IDC_BUTTON_START,IDC_BUTTON_PAUSE,IDC_BUTTON_RESET
    • 另外,需要设计楼层按钮面板。可以动态创建,也可以在对话框上放置两列Button控件,分别代表各楼层的上行和下行呼叫按钮。
  3. 关联变量:为IDC_DRAW_AREA(图片控件)关联一个CStatic类型的控件变量m_picArea。为IDC_LOG_LIST关联一个CListBox类型的控件变量m_logList。为各个按钮添加点击事件处理函数。

4.2 电梯与调度系统的核心类实现

在项目中添加几个关键的类文件(.h.cpp)。

CElevator.h / .cpp:电梯类,如前所述,封装状态、位置、速度、目标列表等。

CRequestQueue.h / .cpp:线程安全的请求队列类。

CScheduler.h / .cpp:调度器基类及具体算法类(如CLookScheduler)。

CSimulatorEngine.h / .cpp:模拟引擎类,这是整个系统的协调中心。它持有电梯对象、请求队列和调度器对象,并提供一个Update(float deltaTime)接口供定时器调用。

// CSimulatorEngine.h 示例 #pragma once #include "Elevator.h" #include "RequestQueue.h" #include "Scheduler.h" class CSimulatorEngine { public: CSimulatorEngine(int floorCount, int elevatorCount); ~CSimulatorEngine(); void Update(float deltaTime); // 驱动模拟 void AddRequest(int floor, Direction dir); // 添加外部请求 void AddInternalRequest(int elevatorId, int floor); // 添加内部请求 const std::vector<CElevator>& GetElevators() const { return m_elevators; } const std::list<ElevatorRequest>& GetAllRequests() const { return m_requestQueue.GetAll(); } // 注意线程安全 void Start() { m_bRunning = true; } void Pause() { m_bRunning = false; } void Reset(); private: std::vector<CElevator> m_elevators; CRequestQueue m_requestQueue; std::unique_ptr<CScheduler> m_scheduler; // 使用智能指针,方便更换算法 bool m_bRunning; int m_totalFloors; };

在主对话框类中,声明一个CSimulatorEngine的成员变量m_engine,并在OnInitDialog中初始化它。

4.3 定时器驱动与图形绘制集成

这是将后台逻辑与前台界面粘合的关键步骤。

  1. 初始化引擎与定时器
    BOOL CElevatorSimDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // ... 其他初始化 // 初始化模拟引擎,假设20层楼,2部电梯 m_engine = std::make_unique<CSimulatorEngine>(20, 2); // 启动一个50ms的定时器 SetTimer(1, 50, nullptr); return TRUE; }
  2. 定时器更新与重绘
    void CElevatorSimDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_engine) { m_engine->Update(0.05f); // 更新模拟世界 // 获取最新的电梯数据,用于更新显示 const auto& elevators = m_engine->GetElevators(); // 这里可以更新一些状态显示文本... // 强制绘图区域重绘 m_picArea.Invalidate(FALSE); } CDialogEx::OnTimer(nIDEvent); }
  3. 绘制电梯图形:我们需要处理IDC_DRAW_AREA(图片控件)的绘制。MFC中,我们可以子类化这个静态控件,或者更方便地,在主对话框的OnPaint里专门绘制这个区域。这里采用响应WM_PAINT消息并手动绘制的方法。
    • 首先,为IDC_DRAW_AREA的父窗口(即对话框)添加WM_PAINT消息处理函数OnPaint
    • OnPaint中,获取IDC_DRAW_AREA的客户区矩形,并创建一个兼容的CDC进行双缓冲绘制。
    void CElevatorSimDlg::OnPaint() { CPaintDC dc(this); if (IsIconic()) { /* ... 处理图标 */ } else { // 绘制其他控件... // 专门绘制电梯区域 DrawElevatorArea(&dc); // 不要调用CDialogEx::OnPaint(),因为我们自己处理了 } } void CElevatorSimDlg::DrawElevatorArea(CDC* pDC) { CRect rect; m_picArea.GetWindowRect(&rect); // 获取屏幕坐标 ScreenToClient(&rect); // 转换为客户区坐标 // 创建内存DC进行双缓冲 CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp = memDC.SelectObject(&memBitmap); // 填充背景 memDC.FillSolidRect(0, 0, rect.Width(), rect.Height(), RGB(240, 240, 240)); // 根据m_engine中的数据,绘制大楼、电梯井、电梯轿厢 if (m_engine) { const auto& elevators = m_engine->GetElevators(); // 计算绘图比例:每层楼的高度(像素) float pixelPerFloor = rect.Height() / (float)m_engine->GetFloorCount(); for (const auto& elevator : elevators) { // 计算电梯轿厢的左上角和右下角坐标 int elevatorWidth = 40; int x = /* 根据电梯ID计算横坐标 */; // 注意:屏幕坐标y轴向下为正,所以楼层越高,y坐标越小 int y = rect.bottom - (int)(elevator.GetCurrentFloor() * pixelPerFloor); CRect elevatorRect(x, y - elevatorWidth/2, x + elevatorWidth, y + elevatorWidth/2); // 绘制轿厢矩形 memDC.Rectangle(elevatorRect); // 绘制楼层数字 CString strFloor; strFloor.Format(_T("%d"), (int)elevator.GetCurrentFloor()); memDC.TextOut(elevatorRect.left + 10, elevatorRect.top + 10, strFloor); // 根据电梯状态绘制门(两条线) if (elevator.GetState() == DOOR_OPEN || elevator.GetState() == DOOR_OPENING) { // 绘制打开的门 } } // 绘制楼层线和数字 for (int i = 0; i <= m_engine->GetFloorCount(); ++i) { int y = rect.bottom - (int)(i * pixelPerFloor); memDC.MoveTo(rect.left, y); memDC.LineTo(rect.right, y); CString strFloor; strFloor.Format(_T("F%d"), i); memDC.TextOut(rect.left + 5, y - 10, strFloor); } } // 将内存位图拷贝到屏幕 pDC->BitBlt(rect.left, rect.top, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }
  4. 按钮事件绑定:为每个楼层按钮的BN_CLICKED事件添加处理函数。在函数中,调用m_engine->AddRequest(floor, direction),并将该请求添加到日志列表。
    void CElevatorSimDlg::OnBnClickedButtonFloorUp(int floor) { if (m_engine) { m_engine->AddRequest(floor, UP); CString log; log.Format(_T("[%s] 楼层 %d 上行呼叫"), CTime::GetCurrentTime().Format("%X"), floor); m_logList.AddString(log); } }

4.4 多电梯调度与高级功能扩展

当系统中有多部电梯时,调度问题从“一部电梯去哪”变成了“哪个电梯去响应哪个请求”。这引入了派梯算法

最简单的派梯策略——最近空闲电梯:对于一个新的楼层呼叫,计算所有空闲或同向即将经过该楼层的电梯,选择距离该楼层最近的一部。

int CMultiElevatorScheduler::DispatchElevator(const ElevatorRequest& req, const std::vector<CElevator>& elevators) { int bestElevatorId = -1; int minCost = INT_MAX; for (size_t i = 0; i < elevators.size(); ++i) { int cost = CalculateCost(req, elevators[i]); if (cost < minCost) { minCost = cost; bestElevatorId = i; } } return bestElevatorId; // 返回被派梯的电梯ID } int CMultiElevatorScheduler::CalculateCost(const ElevatorRequest& req, const CElevator& elevator) { // 成本计算函数,是派梯算法的核心 // 示例:简单距离成本 + 方向惩罚 int distance = abs(elevator.GetCurrentFloor() - req.floor); int cost = distance; // 如果电梯运行方向与请求方向相反,且请求楼层不在其路径上,增加惩罚 if (elevator.GetDirection() != IDLE) { if ((elevator.GetDirection() == UP && req.floor < elevator.GetCurrentFloor()) || (elevator.GetDirection() == DOWN && req.floor > elevator.GetCurrentFloor())) { cost += 10; // 惩罚系数 } } // 还可以考虑电梯内已有乘客数量(负载均衡) cost += elevator.GetPassengerCount() * 2; return cost; }

高级扩展功能

  • 数据统计:在CSimulatorEngine中增加统计变量,记录总服务请求数、平均等待时间、最长等待时间、电梯总运行距离等。在UI上开辟一个区域显示这些实时数据。
  • 算法对比:在UI上增加一个组合框,允许用户运行时切换不同的调度算法(如FCFS、LOOK、SSTF)。这需要设计一个调度器接口,并使用策略模式。
  • 配置文件:允许从文件读取大楼楼层数、电梯数量、电梯速度、开关门时间等参数,使模拟更灵活。
  • 压力测试模式:自动生成随机时间段、随机楼层的请求流,用于测试不同算法在高峰期的表现。

5. 常见问题与排查技巧实录

在开发和调试这个模拟系统的过程中,我踩过不少坑。这里把一些典型问题和解决方法记录下来,希望能帮你节省时间。

5.1 界面闪烁或卡顿严重

  • 问题描述:电梯移动时,画面闪烁严重,或者当请求很多时,界面反应迟钝。
  • 原因分析
    1. 直接绘制:在OnPaintOnTimer中直接使用GDI绘制,每次重绘都导致整个客户区刷新,引起闪烁。
    2. 阻塞主线程:调度算法过于复杂,或者在OnTimer中进行了大量计算,阻塞了Windows消息循环,导致界面无法及时响应其他消息(如重绘、按钮点击)。
    3. 无效重绘区域过大:调用Invalidate()而不带参数,会导致整个窗口重绘,效率低下。
  • 解决方案
    1. 必须使用双缓冲:如前文所述,在内存DC中完成所有绘制,再一次性BitBlt到屏幕。这是消除闪烁最有效的方法。
    2. 将耗时计算移出主线程:将调度器Update逻辑放在一个独立的工作者线程中。主线程定时器只负责触发更新请求和重绘。线程间通过线程安全队列或消息传递数据。
      // 工作者线程函数 UINT SimulatorThread(LPVOID pParam) { CSimulatorEngine* pEngine = (CSimulatorEngine*)pParam; while (pEngine->IsRunning()) { pEngine->Update(0.05f); // 模拟更新 ::Sleep(50); // 模拟50ms间隔 // 通知主窗口更新UI(例如发送自定义消息) ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_UPDATE_UI, 0, 0); } return 0; }
    3. 精确重绘:只重绘发生变化的部分。例如,计算电梯新旧位置形成的矩形区域,只InvalidateRect这个区域。但对于这个项目,由于整个场景关联性强,优化效果有限,双缓冲是根本。

5.2 调度逻辑出现“抖动”或死锁

  • 问题描述:电梯在两个楼层间来回移动,无法稳定服务请求;或者在某些请求组合下,电梯停止响应。
  • 原因分析
    1. 算法逻辑缺陷:在LOOK或SSTF算法中,对“方向判断”或“请求完成条件”的处理有误。例如,电梯到达一个目标楼层后,没有正确从目标列表中移除该请求,导致它认为这个请求一直存在。
    2. 状态机设计不严谨:电梯的状态转换条件有重叠或遗漏。比如,从MOVING_UPDOOR_OPENING的转换,不仅要判断currentFloor == targetFloor,还要判断abs(currentFloor - targetFloor) < epsilon(因为currentFloor可能是浮点数)。
    3. 多线程数据竞争:调度器在读取请求队列时,UI线程正在写入,导致读取到不一致的数据(如漏掉请求)。
  • 解决方案
    1. 打印调试日志:在电梯每次状态改变、调度器做出决策时,输出详细的日志到文件或调试窗口。这是定位逻辑错误最直接的方法。
      void CElevator::SetState(ElevatorState newState) { CString log; log.Format(_T("电梯%d: 状态从 [%s] 变为 [%s], 当前位置: %.2f, 目标: %d"), m_id, StateToString(m_state), StateToString(newState), m_currentFloor, m_targetFloor); OutputDebugString(log); // 输出到VS输出窗口 m_state = newState; }
    2. 绘制调试信息:在电梯图形旁边,实时绘制其当前状态、目标列表、速度等文本信息。可视化调试比看日志更直观。
    3. 强化状态机:使用enumswitch明确所有可能的状态转换,并为每个转换编写清晰的注释和条件检查函数。
      void CElevator::Update(float deltaTime) { switch (m_state) { case MOVING_UP: m_currentFloor += m_speed * deltaTime; if (m_currentFloor >= m_targetFloor - 0.01f) { // 浮点数容差判断 m_currentFloor = m_targetFloor; SetState(DOOR_OPENING); OnArrivedAtFloor(); // 到达处理,如移除目标 } break; case DOOR_OPENING: m_doorOpenRatio += deltaTime / DOOR_OPEN_TIME; if (m_doorOpenRatio >= 1.0f) { m_doorOpenRatio = 1.0f; SetState(DOOR_OPEN); SetTimer(DOOR_HOLD_TIMER); // 开始计时停留时间 } break; // ... 其他状态 } }
    4. 确保线程安全:对共享数据(如CRequestQueue)的所有访问,都必须用临界区或互斥量保护。使用RAII对象(如CSingleLock)管理锁的生命周期,避免死锁。

5.3 动画不平滑或速度不稳定

  • 问题描述:电梯移动看起来一跳一跳的,或者实际运行速度比设定的慢。
  • 原因分析
    1. 定时器不准:Windows的SetTimer精度较低,且消息队列繁忙时,WM_TIMER消息可能被延迟处理,导致deltaTime不稳定。
    2. 位置更新基于整数:如果m_currentFloorint类型,电梯只能“瞬移”到整数楼层,无法显示层与层之间的移动过程。
    3. 绘制与逻辑更新不同步:绘制频率和逻辑更新频率不一致。
  • 解决方案
    1. 使用高精度定时器:对于平滑动画,考虑使用timeSetEvent(多媒体定时器)或CreateWaitableTimer。对于此项目,SetTimer在50ms间隔下基本可用,但要确保OnTimer函数执行时间远小于50ms。
    2. 使用浮点数记录位置m_currentFloor应为floatdouble。在绘制时,将其转换为像素坐标。
    3. 基于时间的动画:在Update函数中,使用deltaTime(自上一帧以来的真实时间)来更新位置,而不是固定增加一个值。这样即使帧率有波动,移动速度也是恒定的。
      void CElevator::Update(float deltaTime) { if (m_state == MOVING_UP) { m_currentFloor += m_speed * deltaTime; // m_speed 单位:层/秒 // ... 到达判断 } }
    4. 计算真实的deltaTime:在OnTimer中,记录上一次调用的时间戳(GetTickCountQueryPerformanceCounter),计算精确的时间差传递给Update函数。

5.4 内存泄漏与资源管理

  • 问题描述:程序运行一段时间后,内存占用持续增长,尤其在频繁开始/重置模拟时。
  • 原因分析
    1. MFC GDI对象未释放:在双缓冲绘图时,创建的CBitmap,CDC等对象,在函数退出前没有正确选回原对象并删除。
    2. 动态创建控件未销毁:如果动态创建了楼层按钮数组,在对话框关闭或重置时没有DestroyWindowdelete
    3. STL容器或自定义对象未清理CSimulatorEngine重置时,其内部的vector,list等没有清空。
  • 解决方案
    1. 遵循GDI对象创建/销毁配对:确保每个CreateCompatibleDC都有对应的DeleteDC,每个CreateCompatibleBitmap都有对应的DeleteObject。使用SelectObject保存旧对象,并在最后选回。
    2. 在对话框的OnDestroy中清理:对于动态创建的控件,遍历并销毁。
      void CElevatorSimDlg::OnDestroy() { for (auto pBtn : m_floorButtons) { if (pBtn && ::IsWindow(pBtn->m_hWnd)) { pBtn->DestroyWindow(); delete pBtn; } } m_floorButtons.clear(); CDialogEx::OnDestroy(); }
    3. 为引擎提供彻底的Reset函数:不仅清空队列和目标列表,还要将电梯状态重置为初始值。
    4. 使用工具检测:在Debug模式下运行,使用Visual Studio自带的内存诊断工具,或第三方工具如Visual Leak Detector来定位泄漏点。

这个VC++电梯调度模拟项目,虽然代码量不大,但“麻雀虽小,五脏俱全”。它强迫你去思考并发控制、状态管理、算法实现和用户交互这些软件开发中的核心问题。调试过程中,看着自己写的电梯从乱跑到有序运行,那种成就感是独一无二的。如果你在实现时遇到了上面没提到的问题,我的建议是:简化问题,先让单部电梯、最简单的算法跑起来,然后逐步增加复杂度,同时用好日志和调试器,每一步都确认逻辑正确。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询