ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux下QT5打造十字路口红绿灯模拟器:状态机与QTimer实战

Linux下QT5打造十字路口红绿灯模拟器:状态机与QTimer实战 简介在Linux环境下基于QT5框架开发的十字路口红绿灯模拟程序是面向QT5初学者及嵌入式Linux开发者的实践项目适合教学演示、实验验证与智能交通方案预研。程序利用QT5的信号与槽机制实现事件驱动支持通过自定义协议实时切换各方向信号灯状态定时器改为线程运行后不再阻塞主界面CPU占用率显著降低在计算能力有限的嵌入式设备上也能平稳运行。包内已提供红绿灯图标等预设图像资源避免初学者额外收集素材可基于源码进一步扩展行人灯、倒计时显示或车流量检测等功能。资源压缩包为gz格式体积仅69KB代码结构简洁、便于阅读修改目前已有1334人学习下载。通过该项目可系统掌握GUI编程、多线程、实时信号控制与跨平台开发思路为后续深入智能交通领域打下基础。 说个可能跟你一样的经历我刚接触 Linux 下的 C 开发时第一个自己动手写的 GUI 程序不是计算器也不是记事本而是一个十字路口的红绿灯模拟器。原因很简单在 QT5 里把六个圆形的灯珠点亮再让它们按真实路口的节奏切换看起来是个“小玩具”但真正动手做的时候才发现里面涉及到界面布局、状态转换、定时器调度、信号与槽一路展开全是 Linux 桌面开发的经典知识点。这篇文章就把我完成 linux 下 QT5 十字路口红绿灯信号灯模拟程序 的全过程拆开来讲从路口时序分析、界面怎么搭、定时器怎么用到 Linux 环境下的编译运行和排雷一步一步说清楚。如果你想入门 QT5或者正在做课程设计想找个像样的练手项目这篇应该能直接帮你把路铺平。1. 红绿灯时序拆解先搞清楚路口到底怎么切换很多人第一反应是红绿灯不就是三个灯轮流亮吗真不是。十字路口要考虑东西向和南北向两个方向的车辆通行同时必须保证两个方向互相不冲突。也就是说任意一个时刻不可能出现南北向绿灯、东西向也绿灯的情况。这里的核心是一个循环切换的状态机而不是三个独立的灯。1.1 四个相位一个循环闭环我采用了最常见的四相位时序阶段南北方向东西方向持续时间相位一绿灯红灯15秒相位二黄灯红灯3秒相位三红灯绿灯20秒相位四红灯黄灯3秒相位一让南北向通行东西向禁行。时间快结束时南北向要转黄灯这就是相位二提醒已经进入路口的车辆尽快通过还没进入的就要准备停车。相位三切换到东西向绿灯南北向转红。相位四是东西向黄灯为下一轮南北向绿灯做准备。这里有一个新手容易忽略的点绿灯不能直接切到另一个方向的绿灯中间必须经过黄灯甚至全红清空阶段。真实路口里黄灯之后往往还有一个短暂的全红间隔用来清空路口内还没走完的车辆防止两个方向的车辆在路口中间“打架”。我做的模拟器为了演示方便把全红阶段去掉了但如果你要把它做成更接近真实交通控制的样子可以加一个全红相位比如 1 到 2 秒逻辑会更完整。1.2 为什么必须用状态机而不是三个独立定时器这是我在实际写代码过程中体会最深的一点。刚开始我试图给每个方向分别建一个定时器红灯一个、绿灯一个、黄灯一个各管各的倒计时。结果问题来了东西向和南北向的灯是互相关联的南北向绿灯的时候东西向必须红灯这种联动关系用独立定时器管理代码里全是互相判断的 if-else时间一长根本理不清而且出现临界状态时很容错乱。改成状态机之后就舒服多了。状态枚举只有四个或者加上全红就是五个每一个状态定义了“南北向灯是什么颜色”“东西向灯是什么颜色”“这个状态持续多久”伪代码逻辑大概是程序启动进入相位一。当前状态时间耗尽切换到下一个状态。按新状态刷新两个方向的灯色。重复第 2 步循环往复。这样一来每个时刻程序都明确地知道自己处于哪个相位灯色和剩余时间都由状态派生出来不存在建立独立定时器时的联动问题。状态机这个思路不管是做红绿灯模拟还是以后做嵌入式菜单、网络协议解析都是同一个套路很值得好好体会。2. 界面搭建用 QGridLayout 摆出六个灯珠红绿灯模拟的界面并不复杂核心就是两组灯一组代表南北方向一组代表东西方向。每组三个灯从上到下或者从左到右分别是红、黄、绿。我最终用的是 QLabel 加 QSS 样式表的方案这也是我推荐新手优先尝试的方式。2.1 控件方案和自绘方案怎么选实现灯珠有两种常见路线方案优点缺点适合场景QLabel QSS 样式表代码量小颜色切换直接改样式字符串上手快灵活性有限做不出太复杂的动画新手入门、快速出效果、课程设计演示重写 paintEvent用 QPainter 画圆完全可控能画渐变、光影、发光效果代码量稍大需要理解绘图事件机制想做逼真效果、练图形绘制、做演示视频我自己的建议是第一版先用 QLabel QSS把状态逻辑跑通再考虑要不要升级成 QPainter 自绘。不要一开始就在绘图细节里沉沦红绿灯项目的重点是状态机和定时器调度界面只是载体。QSS 写灯珠效果非常简单。给 QLabel 设定一个固定尺寸比如 60x60 像素然后通过 setStyleSheet 同时设置背景色、圆角和边框ui-lblNorthGreen-setFixedSize(60, 60); ui-lblNorthGreen-setStyleSheet( background-color: #00cc00; border-radius: 30px; border: 2px solid #2e2e2e; );border-radius 设置成宽高的一半正方形就变成了圆形。熄灭状态就改成深灰色#2e2e2e亮起状态就用对应的鲜艳颜色。这个思路类似给 HTML 元素上 CSS写过前端的同学应该非常熟悉。2.2 布局结构和视觉优化思路我用的布局方式是用一个 QGridLayout 作为总容器把两组灯放进去。视觉上把南北方向的灯组竖直排列放在界面左侧东西方向的灯组水平排列放在界面右侧中间用一个 QLabel 写“路口”或者放一个小方块模拟十字路口的感觉。大致的摆放关系// 左侧南北方向灯组竖直排列 gridLayout-addWidget(lblSouthRed, 0, 0); gridLayout-addWidget(lblSouthYellow, 1, 0); gridLayout-addWidget(lblSouthGreen, 2, 0); // 右侧东西方向灯组水平排列 gridLayout-addWidget(lblWestRed, 0, 2); gridLayout-addWidget(lblWestYellow, 0, 3); gridLayout-addWidget(lblWestGreen, 0, 4);行列号只是一个示意实际摆放要按照你定义的顺序来。这里有一个容易搞混的细节界面上的位置并不一定代表真实路口的方位。很多初学者把“南北向灯组”放在了界面下方自己也搞不清到底在模拟哪个方向我在代码里会直接用lblNorth...和lblWest...这样带方向的名字避免自己写出 bug。QSS 还可以统一暗色背景和间距让界面更像真实的信号灯箱用一个 QFrame 把六个灯珠包起来设置深灰色背景灯珠和灯珠之间留出间隔看起来就非常接近现实中的信号灯了。3. 定时切换的核心机制QTimer 的两种用法灯的亮灭是通过定时器驱动的这一块是整个程序的心脏。我用到了两个 QTimer职责不同一个负责状态切换一个负责倒计时数字刷新千万别混在一起用否则逻辑会很拧巴。3.1 第一个定时器单次触发负责推进状态状态切换定时器要解决的核心问题是每个状态的持续时间不一样有 15 秒的有 3 秒的。如果用一个周期性的定时器那么它只能按照一个固定间隔不断触发你就要在每次触发时判断当前处于哪个状态再决定要不要切换。如果用单次定时器事情就简单得多进入某个状态时去读这个状态的持续时间然后启动一个一次性的定时器时间到了就切到下一个状态再启动下一个定时器。// 在构造函数中创建定时器 m_stateTimer new QTimer(this); m_stateTimer-setSingleShot(true); connect(m_stateTimer, QTimer::timeout, this, MainWindow::advanceToNextState);切换函数长这样void MainWindow::advanceToNextState() { switch (m_currentState) { case StateNorthSouthGreen: m_currentState StateNorthSouthYellow; m_remainingSeconds 3; break; case StateNorthSouthYellow: m_currentState StateEastWestGreen; m_remainingSeconds 20; break; case StateEastWestGreen: m_currentState StateEastWestYellow; m_remainingSeconds 3; break; case StateEastWestYellow: m_currentState StateNorthSouthGreen; m_remainingSeconds 15; break; } updateLights(); m_stateTimer-start(m_remainingSeconds * 1000); }这样每次只需要给单次定时器设置一个时间时间到之后自动进入新的状态。代码读起来非常贴近“下一个是什么”的描述顺着 switch 分支就能把整个循环看明白。3.2 第二个定时器每秒刷新剩余时间界面上最好能看到当前相位还剩下多少秒这样才像个完整的模拟程序。我额外加了一个周期为 1000ms 的 QTimer专门负责把 m_remainingSeconds 减一然后刷新界面上的倒计时标签。这个定时器长期运行不随状态切换而重置只是每次状态切换时会把剩余秒数重新赋值。m_countdownTimer new QTimer(this); connect(m_countdownTimer, QTimer::timeout, this, MainWindow::updateCountdown); void MainWindow::updateCountdown() { if (m_remainingSeconds 0) { --m_remainingSeconds; } ui-lblCountdown-setText(QString(剩余 %1 秒).arg(m_remainingSeconds)); }在 main.cpp 里启动程序后调用m_countdownTimer-start(1000)并且调用一次advanceToNextState()让整个循环走起来即可。这里我踩过一个坑如果不调用advanceToNextState()而只是启动了两个定时器那么界面会一直停留在初始状态倒计时也会停留在一个未初始化的值上一直不会变。3.3 状态切换和倒计时为什么必须分开有人可能会问既然单次定时器已经知道当前状态持续多久了直接把剩余时间放在同一个定时器里处理行不行行但会让代码纠缠在一起。状态定时器关心的是到达时间点的“那一刻”该切换了倒计时定时器关心的是每个 1 秒间隔内界面上的剩余秒数如何刷新。两者节奏完全不同一个按状态持续时长触发一个固定每秒触发。混在一起的话每次状态切换你都得推断当前是周期触发的第几次非常容易乱。我实际测试下来两个定时器并发工作的方式是可靠的状态切换几乎零延迟倒计时的刷新频率也足够平滑。如果你还想做更精确的毫秒级同步可以用 QElapsedTimer 做基准时间戳但做红绿灯模拟真的没必要1 秒的精度已经绰绰有余。4. 完整代码骨架从 .pro 文件到 MainWindow下面我把核心代码结构完整写出来方便你直接对照实现。项目文件不多总共四个文件打底。4.1 工程文件 TrafficLight.proQT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET TrafficLight TEMPLATE app SOURCES main.cpp \ mainwindow.cpp HEADERS mainwindow.h这段是 QT5 项目最基本的 pro 写法。特别注意QT widgets在 QT5 中 widgets 模块需要显式加上很多新手把这句话漏掉编译时直接报找不到 QWidget 相关符号。4.2 主窗口头文件 mainwindow.h#ifndef MAINWINDOW_H #define MAINWINDOW_H #include QMainWindow #include QLabel #include QTimer class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent nullptr); private slots: void advanceToNextState(); void updateCountdown(); private: enum State { StateNorthSouthGreen, StateNorthSouthYellow, StateEastWestGreen, StateEastWestYellow }; void updateLights(); void setupUi(); QLabel *lblNRed; QLabel *lblNYellow; QLabel *lblNGreen; QLabel *lblWRed; QLabel *lblWYellow; QLabel *lblWGreen; QLabel *lblCountdown; QTimer *m_stateTimer; QTimer *m_countdownTimer; State m_currentState; int m_remainingSeconds; }; #endif // MAINWINDOW_H4.3 主窗口实现 mainwindow.cpp#include mainwindow.h #include QGridLayout #include QVBoxLayout #include QFrame #include QPalette MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), m_currentState(StateNorthSouthGreen), m_remainingSeconds(0) { setupUi(); m_stateTimer new QTimer(this); m_stateTimer-setSingleShot(true); connect(m_stateTimer, QTimer::timeout, this, MainWindow::advanceToNextState); m_countdownTimer new QTimer(this); connect(m_countdownTimer, QTimer::timeout, this, MainWindow::updateCountdown); updateLights(); advanceToNextState(); m_countdownTimer-start(1000); } void MainWindow::setupUi() { // 创建六个灯珠 QLabel 并设置固定尺寸 lblNRed new QLabel(this); lblNYellow new QLabel(this); lblNGreen new QLabel(this); lblWRed new QLabel(this); lblWYellow new QLabel(this); lblWGreen new QLabel(this); foreach (QLabel *lab, {lblNRed, lblNYellow, lblNGreen, lblWRed, lblWYellow, lblWGreen}) { lab-setFixedSize(60, 60); lab-setAlignment(Qt::AlignCenter); } // 南北灯组垂直排列 QWidget *nsWidget new QWidget(this); QVBoxLayout *nsLayout new QVBoxLayout(nsWidget); nsLayout-addWidget(lblNRed); nsLayout-addWidget(lblNYellow); nsLayout-addWidget(lblNGreen); // 东西灯组水平排列 QWidget *ewWidget new QWidget(this); QVBoxLayout *ewLayout new QVBoxLayout(ewWidget); ewLayout-addWidget(lblWRed); ewLayout-addWidget(lblWYellow); ewLayout-addWidget(lblWGreen); lblCountdown new QLabel(this); lblCountdown-setAlignment(Qt::AlignCenter); lblCountdown-setStyleSheet(font-size: 20px; color: #ffffff;); QGridLayout *mainLayout new QGridLayout; mainLayout-addWidget(nsWidget, 0, 0); mainLayout-addWidget(ewWidget, 0, 1); mainLayout-addWidget(lblCountdown, 1, 0, 1, 2); QWidget *central new QWidget(this); central-setStyleSheet(background-color: #3a3a3a;); central-setLayout(mainLayout); setCentralWidget(central); setWindowTitle(QStringLiteral(十字路口红绿灯模拟)); } void MainWindow::updateLights() { // 先全部置灰 QString offStyle background-color: #2e2e2e; border-radius: 30px; border: 2px solid #555555;; QString redOnStyle background-color: #ff3b30; border-radius: 30px; border: 2px solid #2e2e2e;; QString yellowOnStyle background-color: #ffcc00; border-radius: 30px; border: 2px solid #2e2e2e;; QString greenOnStyle background-color: #00cc00; border-radius: 30px; border: 2px solid #2e2e2e;; lblNRed-setStyleSheet(offStyle); lblNYellow-setStyleSheet(offStyle); lblNGreen-setStyleSheet(offStyle); lblWRed-setStyleSheet(offStyle); lblWYellow-setStyleSheet(offStyle); lblWGreen-setStyleSheet(offStyle); QLabel *nsRed lblNRed; QLabel *nsYellow lblNYellow; QLabel *nsGreen lblNGreen; QLabel *ewRed lblWRed; QLabel *ewYellow lblWYellow; QLabel *ewGreen lblWGreen; switch (m_currentState) { case StateNorthSouthGreen: nsGreen-setStyleSheet(greenOnStyle); ewRed-setStyleSheet(redOnStyle); break; case StateNorthSouthYellow: nsYellow-setStyleSheet(yellowOnStyle); ewRed-setStyleSheet(redOnStyle); break; case StateEastWestGreen: nsRed-setStyleSheet(redOnStyle); ewGreen-setStyleSheet(greenOnStyle); break; case StateEastWestYellow: nsRed-setStyleSheet(redOnStyle); ewYellow-setStyleSheet(yellowOnStyle); break; } }updateLights 的思路就是“先把全灭再按状态点亮对应灯”这样能避免上一次状态残留。这个函数看起来有点啰嗦但它保证了代码可读性不会出现“南北绿灯亮着的同时东西绿灯也亮”这种逻辑错误。4.4 入口文件 main.cpp#include QApplication #include mainwindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow window; window.resize(420, 360); window.show(); return app.exec(); }如果你只是想要一个能跑的模拟器上面的代码已经够了。编译运行后就能看到两组灯按四相位循环亮起。4.5 加一点真实感可配置时间和暂停复位如果想让项目更完整可以在界面上加一个“开始/暂停”按钮和一个“相位时间配置”面板。暂停的逻辑很简单调用m_stateTimer-stop()和m_countdownTimer-stop()恢复时再调用start()。关键点在于暂停期间不能把剩余时间清零否则恢复后倒计时直接归零状态机就乱套了。可配置时间我会把每个相位的秒数从硬编码改成成员变量例如m_nsGreenTime 15;用两个 QSpinBox 在界面上调节界面上的改动实时存入对应变量。这样做的好处是调试阶段可以把 15 秒调到 2 秒、把 3 秒调到 1 秒观察整个循环是否正常等确认逻辑没问题再恢复到真实时间。这个调试技巧我在写状态机类项目时经常用省去了大量干等时间。5. Linux 下编译运行三条命令跑起来以及几个坑因为是 Linux 环境下的 QT5 项目编译和运行有一段独立的经验要单独说。很多开发者在 Windows 上双击 Qt Creator 就能跑到 Linux 下却栽在各种依赖和平台问题上。5.1 环境安装和命令行构建首先保证系统安装了 Qt5 的开发包和编译工具链。以 Ubuntu/Debian 系为例sudo apt update sudo apt install g make qtbase5-devqtbase5-dev包含了 QT5 widgets 模块的头文件、库文件和 qmake。装完之后执行qmake TrafficLight.pro make ./TrafficLightqmake 会根据 .pro 文件生成 Makefilemake 编译出可执行文件直接运行就能看到窗口。如果系统里默认的 qmake 指向的是 Qt6需要指定 Qt5 的版本通常命令是qmake -v看一下版本或者用/usr/lib/qt5/bin/qmake显式调用。5.2 无桌面环境跑 GUI 程序这一点是 Linux 下特有的。你的开发机可能装了完整的桌面环境直接运行没问题但如果部署到一台只有命令行的服务器上运行./TrafficLight会报qt.qpa.xcb: could not connect to display之类的错误。原因是 GUI 程序需要连上一个显示服务。一个简单的解决办法是用 xvfb它可以提供一块虚拟屏幕sudo apt install xvfb xvfb-run -a ./TrafficLight这样即使没有物理显示器程序也能跑起来。如果你想截图验证可以在虚拟屏幕上配合import或者xwd截图工具把窗口内容导出来看。我在没有图形界面的远程机器上调试界面程序时就靠这套方案。另外还有一个常见情况在有桌面环境的 Linux 上运行窗口很暗或者样式怪怪的。这是因为程序没有设置应用级别的样式我的做法是保持默认即可QT5 自带的 Fusion 风格在 Linux 下就很清晰视觉效果不输 Windows。5.3 中文乱码和界面不更新这类经典问题Linux 下的中文乱码一般出在源码文件的字符编码上。现在的 Qt5 和 GCC 默认都能处理 UTF-8只要源文件用 UTF-8 保存直接写中文没问题。老的编译器或者文件被 GBK 编码时中文就会变乱码这时可以用QStringLiteral或者QString::fromUtf8包一层坚持 UTF-8 是更根本的解决办法。还有一个小细节如果你在循环里频繁调用setStyleSheet注意样式字符串要简洁不要每次都拼一大堆否则切换状态时偶尔会有轻微闪烁感。解决办法是把固定不变的样式字符串写成常量每次切换只替换颜色值实测下来流畅度会好很多。灯珠尺寸也要给足QSS 的 border-radius 依赖控件的实际宽高。如果 QLabel 大小没有设置尺寸为 0那你怎么设置圆角都不会出现圆形灯珠。我第一版就是忘了 setFixedSize灯一直是正方形的后来才意识到布局里控件默认大小可能不满足绘制要求。这个和尺寸相关的坑排错时优先级很高。收尾这个小项目比看起来值得做整个项目做完我最强烈的感受是红绿灯模拟虽然代码量不大但它把状态机、定时器调度、界面刷新的节奏感全都串起来了而且每个环节都能直观看到结果。相比去啃厚厚的理论书花一个晚上把灯珠点亮对 QT5 的理解会扎实很多。如果你做完基础版还想继续玩我建议可以加一个键盘事件——按空格切换自动和手动模式手动模式下按一下按钮跳一个相位或者把“全红清空时间”加进去让时序更加贴近真实交通控制甚至可以引入随机车流量让绿灯时长根据车流动态调整。这些扩展方向每一个都能拆成一个独立的小练习足够你练上一阵子了。对了再留一个调试技巧作为结尾做定时器相关的 GUI 程序永远先把时间参数调小再调试。真实 15 秒切换一次太磨人把相位时间全部设成 1 秒跑几个循环确认状态顺序正确后再恢复真实时长。这一招帮我省下的等待时间远比想象中多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进