news 2026/9/8 1:31:02

Qt5实战:从零开发十字路口信号灯模拟器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt5实战:从零开发十字路口信号灯模拟器

简介:面向Linux环境下QT5初学者的十字路口红绿灯模拟程序,适用于嵌入式Linux开发、智能交通课程设计和交通信号控制实验等场景。程序基于QT5的图形界面与信号槽机制搭建,使用户能够通过自定义协议控制红绿灯各灯状态的切换,界面可实时显示变化,也支持快速关闭信号灯,方便教学演示与功能验证。在性能优化方面,开发者将定时器功能改为独立线程运行,避免定时任务阻塞主应用,降低CPU占用率,这一思路对计算资源有限的嵌入式设备尤为重要。资源包为gz压缩格式,总体积仅69KB,体积小巧,便于下载与部署;包内预置红绿灯图标等图像文件,可直接引入项目使用。目前已有1334人学习/下载,是一份兼顾GUI编程、多线程、实时控制与跨平台开发的实践资源,既适合初学者入门,也可为智能交通信号模拟和交通规则验证提供基础平台。

1. 项目概述:一个信号灯模拟器背后的完整Qt5开发路径

第一次在Linux下用Qt5做十字路口信号灯模拟程序,很多人的第一反应是“这有什么难的,画三个圆点,写个定时器切换颜色不就完了”。实际上手之后你会发现,凡是涉及界面状态切换的程序,最核心的问题从来不是“怎么画”,而是“怎么让状态切换的时机、节奏、表现方式都和真实世界对得上”。这个项目恰好把Qt5里几个最常用的技术点全部串起来了:自定义绘制、定时器驱动、事件循环、对象通信,甚至还能把状态机设计的思路一并练到。对初学者来说是特别好的练手项目,对已经写过一些Qt程序的人来说,用这个题目做一次重构和优化,也能梳理出不少平时容易忽略的细节。

目标很明确:在Linux桌面上跑一个Qt5窗口程序,画面中央是一个十字路口,四个方向都有红绿灯,灯色按照标准交通信号逻辑自动切换,同时显示当前相位剩余秒数。程序要能稳定运行,不闪烁、不卡顿、不崩,并且代码结构清晰,方便后续往里面加车辆通行动画、按钮控制、甚至多路口联动。

整个项目不需要任何第三方库,纯Qt5 Widgets模块就够了,编译器用g++,构建工具用qmake或者CMake都行。开发环境我这边是Ubuntu系统,Qt版本5.15,编译器GCC 9.4,下面所有代码和步骤都基于这套环境验证过。如果你用的是其他Linux发行版或者更高版本的Qt,操作上基本一致,个别细节我会单独标注。这个程序同样适合作为嵌入式开发上位机demo、Qt初学者课程设计或者面试作品,关键不在于它多复杂,而在于把“状态+绘制+驱动”这三件事组织得干净利落。

2. 核心设计思路:先把状态机理清楚,再谈画界面

2.1 信号灯相位切换的本质就是四状态循环

红绿灯的控制逻辑听起来很简单,但很多初学者会直接拿三个布尔变量表示三个灯,然后在一个定时器回调里做判断,结果代码越写越乱。真实的路口信号控制不是“三个灯分别定时”,而是以一个完整的“相位序列”为单位循环运行。标准十字路口从东西方向视角来看,一个完整周期包含四个相位:东西绿灯、南北红灯;东西黄灯、南北红灯;东西红灯、南北绿灯;东西红灯、南北黄灯。就这么简单,四个相位依次轮转,周而复始。

理解了这一点之后,这个项目的核心架构就清楚了:程序本质上是一个状态机,四个相位就是四个状态,每个状态有一个持续时间,时间到了就切换到下一个状态。界面层只是把这个状态“画”出来而已。这样设计之后,哪怕你后面要增加左转箭头灯、增加夜间黄闪模式,也只是在状态机里增加状态的事,根本不用去动绘制逻辑。

我在设计代码结构时,直接按照这个思路把程序拆成了三个类:一个负责状态切换和计时逻辑的控制器类,一个负责画面绘制的控件类,再加上一个承载两者的主窗口类。控制器只对外暴露一个“当前相位”的信号,绘图控件只管根据收到的相位和剩余秒数去重绘。两者之间通过信号槽通信,互不干扰。这样做的好处是,你甚至可以暂时不做任何界面,用一个命令行程序先验证状态切换逻辑是否正确,界面最后再挂上去。

2.2 计时驱动的两种方式:逐个递减还是统一tick

定时方案上,常见做法有两种。第一种是给每个状态设置一个独立QTimer,第一次定时结束切到下一个状态并重新设置定时器时长;第二种是用一个固定周期(比如1秒)的QTimer反复触发,每触发一次就把当前相位的剩余秒数减1,减到0就切换相位。我最终选了第二种。

原因是第二个方案天然方便做倒计时显示——界面上的剩余秒数数字,就是状态机里现成维护的那个变量,不需要额外计算。而且调试的时候,把定时器周期改成100毫秒,整个红绿灯循环就会快速运转,用肉眼看灯色切换逻辑是否正确,效率远高于在代码里打断点。这个技巧在真机调试信号机逻辑时特别常用。当然,代价是定时器回调函数必须是轻量级的,里面除了状态判断和数值递减不能做任何阻塞操作,好在信号灯模拟恰恰没有这样的重操作。

顺带提一个细节:QTimer需要一个事件循环来驱动,也就是程序里必须先有QApplication或QCoreApplication对象,再调用exec()进入事件循环。如果有人在main函数里直接写了一个while循环去递减秒数而不让出控制权,会发现界面完全卡死。这不是Qt的bug,而是你忽略了事件循环的控制权问题。正确做法永远是“定时器到点之后在回调里做该做的事”,回调结束立刻把CPU还给事件循环。

2.3 界面绘制方案:QPainter 手绘远比贴图更灵活

界面怎么画,我这里直接排除掉了用QLabel贴三张图的做法,虽然新手用样式表也很容易做出来,但图片缩放容易模糊,而且不同分辨率下表现不稳定。这个项目的最佳方案是用QPainter在QWidget的paintEvent里手绘:先用drawLine画十字路口的道路,再用setBrush配合画椭圆的方式画三个圆形灯位,最后用drawText把倒计时数字画在灯旁边。全部是矢量绘制,窗口拉伸多大都清晰。

手绘的第二个好处是可以自由控制每个元素的状态。画灯的时候,我根据控制器发过来的当前相位,决定哪个灯圈填充颜色、哪个灯圈只留灰色描边,这只需要一个switch分支就能处理完。如果是贴图方案,你得提前准备黄的、红的、绿的、灰的N多张图,状态一多图片管理就头疼。再往后做车辆动画时,QPainter手绘的优势还会进一步放大。

2.4 方向编号和交互入口要提前定好

在开始动手写代码之前,还有一个容易忽略的问题:方向怎么编号。建议用0代表东、1代表南、2代表西、3代表北,顺时针编。虽然现在这个版本只需要画一组灯(默认显示东西方向和南北方向的灯),但编号定好之后,后面做多路口扩展时传参就很清晰,不用担心给灯配错方向。主窗口上我还加了一个“重启循环”按钮,用来把状态机重置回第一个相位,这个按钮在验证代码时非常有用,不用重启程序就能重新走一遍完整时序。

3. 实操落地:从零写出可运行的Qt5信号灯模拟器

3.1 Linux下Qt5开发环境准备

不同Linux发行版装Qt5开发包的命令略有区别。Ubuntu和Debian系使用下面这条命令即可:

sudo apt install qtbase5-dev qt5-qmake g++ make

安装完成后用qmake --version检查版本,正常能看到类似“Using Qt version 5.15.2 in /usr/lib/x86_64-linux-gnu”的输出。如果装的是Fedora这类用dnf的系统,对应命令是sudo dnf install qt5-qtbase-devel gcc-c++ make;Arch系则是sudo pacman -S qt5-base gcc make。如果你需要Qt的图形化IDE支持,额外安装Qt Creator即可,但本文的项目用纯文本编辑器加命令行编译就完全足够。

3.2 项目文件组织与核心代码实现

我把整个项目放在一个目录下,文件结构如下:

traffic_light/ ├── traffic_light.pro ├── main.cpp ├── controller.h ├── controller.cpp ├── lightwidget.h ├── lightwidget.cpp ├── mainwindow.h └── mainwindow.cpp

先看工程文件。我习惯用qmake做这种小项目,它的.pro文件比CMakeLists.txt简洁得多:

QT += core gui widgets TARGET = traffic_light TEMPLATE = app SOURCES += main.cpp controller.cpp lightwidget.cpp mainwindow.cpp HEADERS += controller.h lightwidget.h mainwindow.h

接下来是状态控制类。这个类不需要继承任何Qt类,它本质上就是一个普通的QObject子类,负责维护当前相位和剩余秒数,并通过定时器驱动状态切换。我直接放核心实现:

// controller.h #ifndef CONTROLLER_H #define CONTROLLER_H #include <QObject> #include <QTimer> class Controller : public QObject { Q_OBJECT public: enum Phase { EWGreen, EWYellow, NSGreen, NSYellow }; explicit Controller(QObject *parent = nullptr); void start(); signals: void phaseChanged(Phase phase, int remain); public slots: void tick(); void reset(); private: Phase m_phase; int m_remain; QTimer m_timer; }; #endif

成员变量就三个:当前相位、剩余秒数、定时器。注意m_timer这里我直接声明成了普通成员而不是指针,这样省去了手动new和delete的麻烦,只要在构造函数里设置好父对象和连接关系就行。相应地,controller.cpp里的关键部分这样实现:

// controller.cpp #include "controller.h" static const int kPhaseDuration[4] = {30, 5, 30, 5}; Controller::Controller(QObject *parent) : QObject(parent), m_phase(EWGreen), m_remain(kPhaseDuration[0]) { m_timer.setInterval(1000); connect(&m_timer, &QTimer::timeout, this, &Controller::tick); } void Controller::start() { m_timer.start(); } void Controller::tick() { m_remain--; if (m_remain <= 0) { m_phase = static_cast<Phase>((m_phase + 1) % 4); m_remain = kPhaseDuration[static_cast<int>(m_phase)]; } emit phaseChanged(m_phase, m_remain); } void Controller::reset() { m_phase = EWGreen; m_remain = kPhaseDuration[0]; emit phaseChanged(m_phase, m_remain); }

有几个细节值得解释一下。static const int kPhaseDuration[4]这个数组的四个值分别是东西绿30秒、东西黄5秒、南北绿30秒、南北黄5秒,这正好对应四相位循环。(m_phase + 1) % 4这个取模操作是状态机循环的核心,下一状态和上一状态之间的顺序由这个表达式保证,永远不会越界。还有一个容易被忽略的点:start()里只调用了m_timer.start(),但第一个相位的一秒倒计时从程序启动后的第一秒才开始计时,如果你希望界面在程序一启动就立即显示“剩余30秒”,可以在main函数里启动定时器之前先手动emit一次phaseChanged信号。

3.3 绘图控件:画出十字路口和两组信号灯

绘图控件是整个程序的视觉核心。我把它单独做成了一个QWidget子类,所有绘制逻辑都集中在这里。这个类本身不关心状态怎么切换,它只通过一个槽函数接收相位和剩余秒数,记录下来后调用update()触发重绘。paintEvent里做的事情,可以理解为“把内存里记录的状态画到屏幕上”:

void LightWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 画灰色沥青路面 painter.fillRect(rect(), QColor(70, 70, 70)); // 画十字路口:中心区域留出来,四条道路分别向四个方向延伸 int w = width(); int h = height(); int roadWidth = qMin(w, h) / 6; // 横向道路 QRect hRoad(w/2 - roadWidth/2, 0, roadWidth, h); painter.fillRect(hRoad, QColor(50, 50, 50)); // 纵向道路 QRect vRoad(0, h/2 - roadWidth/2, w, roadWidth); painter.fillRect(vRoad, QColor(50, 50, 50)); // 画车道分隔线(虚线) QPen dashPen(Qt::white); dashPen.setStyle(Qt::DashLine); dashPen.setWidth(2); painter.setPen(dashPen); // 横向道路中心线 painter.drawLine(w/2, 0, w/2, h/2 - roadWidth/2); painter.drawLine(w/2, h/2 + roadWidth/2, w/2, h); // 纵向道路中心线 painter.drawLine(0, h/2, w/2 - roadWidth/2, h/2); painter.drawLine(w/2 + roadWidth/2, h/2, w, h/2); }

画完道路之后,再根据相位画两组灯。这里用了一个辅助函数drawLightGroup,参数包括灯组中心坐标、当前应该亮的颜色、剩余秒数。画灯的时候,三个灯位从上到下或者从左到右排布,我默认采用水平排列的红黄绿三灯,因为大多数路口的大灯组都是水平排列或者竖直排列的,这里选水平看起来更直观。

灯的绘制逻辑是:先用灰色画三个圆圈的轮廓和底色,然后判断当前相位的对应颜色,把该亮的那个圆圈填充成对应颜色,其余保持灰色。这样画面看起来就和真实信号灯一致了。倒计时数字直接画在灯组的旁边,字体大小我设置了24像素,颜色用白色,确保在深色背景上清晰可读。

3.4 主窗口组合逻辑:把控制器和绘图控件接起来

主窗口做的事情很纯粹:创建控制器和绘图控件,用信号槽把两者连接起来,再提供重启按钮。连接的代码只有两条,一条是phaseChanged信号连到LightWidget::onPhaseChanged槽,另一条是按钮的clicked信号连到Controller::reset槽。

MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { Controller *controller = new Controller(this); LightWidget *light = new LightWidget(this); QPushButton *btnReset = new QPushButton(QStringLiteral("重启循环"), this); connect(controller, &Controller::phaseChanged, light, &LightWidget::onPhaseChanged); connect(btnReset, &QPushButton::clicked, controller, &Controller::reset); QVBoxLayout *layout = new QVBoxLayout(this); layout->addWidget(light); layout->addWidget(btnReset); controller->start(); }

注意一个在实际项目中很重要的问题:phaseChanged信号是在Controller的tick槽里发射的,而tick又是由定时器触发的。这里不涉及跨线程问题,所以直连和排队连接效果一样。如果将来你要把Controller放到单独线程里跑,那信号槽连接方式就必须改成Qt::QueuedConnection,并且绘图控件的槽函数里不能直接操作QPainter,因为QWidget必须在主线程。这些扩展点现在可以不用管,但代码组织方式按这个思路写,以后迁移成本会很低。

3.5 编译、运行与Makefile流程

在项目目录下依次执行三条命令:

qmake make ./traffic_light

qmake会根据.pro文件生成对应的Makefile,make完成编译链接,最后一条命令启动程序。如果一切正常,你会看到一个灰底、有十字路口、两组信号灯、带倒计时数字的窗口。

如果编译报错找不到QApplication头文件,大概率是qtbase5-dev这个开发包没装上,用前面说的apt命令补装一下即可。如果链接报错undefined reference toQApplication::QApplication(int&, char**)之类,说明链接阶段没找到Qt库,一般在.pro里确保QT += core gui widgets这一行存在就不会出这个问题。

4. 常见问题与调试技巧实录

4.1 Linux下Qt5程序启动失败的几个典型坑

第一种常见问题是双击可执行文件或者直接./traffic_light运行时报错“QXcbConnection: Could not connect to display”。这基本说明程序找不到X Server环境,或者DISPLAY环境变量没设置。最简单的解决办法是确保你在图形界面终端里运行程序,不要用纯命令行SSH方式运行GUI程序。如果确实需要在远程环境做图形调试,请自行评估是否需要端口转发方案,这个过程涉及网络配置,不同环境差异较大。

第二种问题是字体乱码。Qt5对中文字体支持虽然已经很成熟,但如果系统里没有安装任何中文字体,界面上的中文会变成方块或问号。排查方法很简单,用fc-list :lang=zh命令查看系统中是否有可用的中文字体。没有的话,安装fonts-wqy-microhei或fonts-wqy-zenhei这类开源中文字体包即可解决。在代码里我统一用了QStringLiteral宏把中文字符串包起来,这是Qt5推荐的写法,可以避免源码编码问题带来的乱码隐患。

第三种问题比较隐蔽:在Ubuntu 20.04及以上版本的默认高DPI缩放场景下,程序界面可能出现文字模糊或者控件尺寸异常。这是因为Qt5默认没有开启高DPI缩放支持正好又配上系统开启界面缩放。解决办法有两步,第一步是在main函数最前面、创建QApplication之前的代码中加入配置:

QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);

第二步是在.pro文件里加一句QT += core5compat不适用于5.15版本,这个模块是Qt 6的用法。Qt 5.15环境下只需要设置这两个属性即可,不需要额外模块。

4.2 状态切换与实际时间不同步?先检查定时器周期

如果你发现倒计时数字跳得不均匀,或者偶尔一次跳两秒,优先怀疑是不是CPU负载过高。QTimer的精度依赖操作系统的定时器机制,正常桌面环境下1秒的定时误差在几十毫秒内,肉眼完全感觉不到。但在虚拟机里跑Linux,而且宿主机负载很高时,调度延迟可能被放大。调试时可以临时把定时器周期改成100毫秒,观察是否恢复正常的节奏。需要注意的是,QTimer不是实时定时器,它只能保证在事件循环空闲时尽快补上错过的tick,这个特性决定了它不适合做高精度计时用途,但信号灯这种秒级场景完全没有压力。

还有一种我踩过的坑是,m_remain减到0之后忘记发信号,导致界面上的倒计时停在0不跳了。排查方式是在tick函数开头加一行qDebug()输出当前相位和剩余秒数,对照逻辑检查。加上这行输出之后,程序的运行状态完全透明,很直观就能发现问题在哪。

4.3 调试时查看整张二维状态表:在Qt Creator里用好断点和监视器

项目如果继续往复杂的方向做,比如增加多路口联动、每路口的相位用二维数组存储,这时候调试数组内容就成了刚需。Qt Creator的调试器在“监视”窗口里直接展开数组对象时,默认只能看到第一行,看不到完整二维结构。我的做法是右键监视表达式,手动输入数组名加下标,比如matrix[0][0]@8的意思是查看从matrix[0][0]开始连续8个元素。这种方式通用性很强,换到GDB命令行调试同样适用,省去一遍遍在代码里加qDebug的功夫。

4.4 输入控制与界面参数绑定:让演示更直观

做完基础版之后,我发现纯自动循环虽然能用,但讲给别人听的时候缺乏交互感。所以往程序里加了几个控制项:一个滑块或输入框用来动态调整绿灯持续时长,一个按钮用来暂停循环。这里回顾一个很常见的Qt5小问题——QLineEdit限制只能输入数字,应该用QIntValidator而不是去重写keyPressEvent。代码只要一行:

lineEditDuration->setValidator(new QIntValidator(5, 120, this));

这样用户就只可能输入5到120之间的整数,彻底避免非法字符的干扰。把这些参数从控制器里抽出来做一个公共属性,然后用QSpinBox或滑块绑定起来,调整会变得非常顺手。

5. 真实运行效果与进一步扩展方向

程序跑起来后的效果大致是这样的:窗口默认大小800x600,深灰色路面上一个十字路口,路中央被四条道路围出的矩形区域是街道中心,两侧分别有一组横向灯和一组纵向灯。每组灯三个灯位,从靠近路口的顺序依次是红、黄、绿。当前东西方向绿灯亮时,倒计时数字显示30慢慢往下减,减到0之后黄灯亮起,数字每跳一下黄灯依然亮着,持续5秒后东西方向变红,同时南北方向绿灯立刻亮起并重新从30开始倒计时。循环往复,节奏和现实中的信号灯一致。

体验下来,会觉得这类“小项目”对整个Qt知识体系的串联效果特别明显。做完基础版之后,你可以往几个方向继续扩展。第一个方向是增加车辆动画,每辆车用一个小矩形表示,只能在自己车道的方向上移动,信号灯变绿才允许通过路口。这个扩展需要给车辆建一个类,处理位置更新和碰撞检测,核心的QPainter能力正好继续复用。第二个方向是加入时间统计模块,统计每个方向一个小时内实际通过的绿灯时长,然后生成一份直方图。第三个方向是把控制逻辑从QTimer改写为QStateMachine状态机框架,用Qt自带的图形化状态机抽象来描述相位切换,虽然有点大材小用,但当作框架学习案例非常合适。

我个人建议的顺序是:先加车辆动画,因为它会让demo效果发生质变,而且会逼你去思考“如何让每个移动物体在定时器回调里更新位置并且只重绘需要重绘的区域”。做到这一步,你的Qt5自定义绘制能力基本就稳了。之后再做参数可配置和统计功能,对整个系统的认识会更全面。

最后分享一个小技巧:开发这类带界面刷新的模拟程序时,不要一上来就追求代码精简。先按“控制器只管状态、控件只管绘制、主窗口只管组装”的原则写一版结构清晰但可能比较啰嗦的实现,跑通了再回头重构,把重复的代码抽成工具函数。我见过太多人一上来就玩花活,结果是状态逻辑和绘图逻辑混在一起,任何一边改动都会牵动另一边,改到后面自己都不认识自己的代码。这个项目虽然小,但按工程化习惯去组织,养成的好习惯会带到下一个更大的项目里去。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 1:29:35

GPS+INS组合导航:从卡尔曼滤波到工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 1:29:30

从zip包运行Python项目:环境配置、依赖安装与排错全指南

简介&#xff1a;一份名为 pythonProject 的完整 Python 项目压缩包&#xff0c;面向有一定基础的 Python 开发者&#xff0c;适用于学习项目结构、依赖管理或二次开发。包内共收录 2000 个文件&#xff0c;以 Python 源码&#xff08;1745 个 .py&#xff09;为主&#xff0c;…

作者头像 李华
网站建设 2026/9/8 1:28:25

婚车租赁GPS北斗智能调度方案:从定位原理到落地部署全拆解

所谓“婚车租赁”&#xff0c;表面看是租车和统筹的事&#xff0c;实际上最考验人的是当天早上的“半小时调度”。头车走错路、尾车被红灯截断、车队里有人掉队&#xff0c;新郎新娘在群里催、家长在电话里问&#xff0c;每多等一分钟都在消耗信任。前段时间我帮一家婚庆车队落…

作者头像 李华
网站建设 2026/9/8 1:27:49

JDK HttpClient连接池探秘:机制、误区与连接数限制方案

大概半年前我接手了一套基于 JDK 17 写的网关服务&#xff0c;压测刚开始就发现到后端服务的 TCP 连接数一路往上飙&#xff0c;几百个并发请求硬是打出了上千条连接&#xff0c;TIME_WAIT 状态堆了一地。第一反应是给 JDK17 HttpClient 的 ConnectionPool 配一个连接数上限&am…

作者头像 李华
网站建设 2026/9/8 1:27:38

Magpie开源工具:用全局本地搜索终结收藏即遗忘的困境

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 1:27:28

WorkBuddy Hy3与Hy4内核对比:从选型到切换回滚的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华