简介:linux下QT5十字路口红绿灯模拟程序是一份面向QT5图形界面开发与嵌入式Linux学习者的实践型资源,适合初学者借助十字路口信号灯这一具体场景,系统掌握GUI编程、事件驱动、多线程与实时控制等关键技能。压缩包采用gz格式,整体大小仅69KB,文件总数暂未在详情页标注,但资源已内置红绿灯图标等常用图像素材,免去自行寻找图片的麻烦,拿到后即可直接运行和二次修改。项目将原本的定时器功能改为线程运行,避免阻塞主应用界面,有效降低CPU占用,契合嵌入式Linux设备计算资源有限的客观条件;同时基于QT5的信号与槽机制,支持通过自定义协议控制各灯状态,实现红绿灯的实时切换与快速关闭,代码结构清晰、便于拓展到更大规模的交通模拟场景。已有1337人学习浏览该资源,无论是作为智能交通实验、课堂教学案例还是毕业设计参考,都能让读者从实际工程中理解跨平台开发思路,并为后续交通管理算法验证提供平台。
1. 红绿灯模拟程序:一个把状态机、定时器和绘图焊在一起的练手项目
写一个 Linux 下的 QT5 十字路口红绿灯模拟,本质上在解决三件事:状态怎么表达、时间怎么驱动、画面怎么画。这三个问题恰好对应了 QT5 开发里最常见的三个坑——状态机设计、QTimer 事件机制、QPainter 绘制性能。新人第一次接触 QT5 往往直接冲复杂业务界面,连窗口重绘时机都没搞明白;做完这个模拟程序,等于把 QT5 的基础补了一遍。本文把我的实现方案拆开讲,从选型到参数再到踩坑,新手能跟着一步步跑通,熟手也能看到边界处理。适合刚入 QT5 的开发者、做嵌入式上位机仿真的人、想在桌面端快速验证绘图与定时器联动方案的工程师。
2. 先定选型:为什么是 QTimer + QPainter,而不是多线程加自定义控件
2.1 单线程事件循环 vs 多线程模拟时序
很多第一次写模拟类程序的人,第一反应是让每个方向的红绿灯各开一个线程去 sleep,然后切换状态。这个思路在概念上没问题,但落到 QT5 里就是灾难。QThread::sleep 是静态阻塞,把计时逻辑放在工作线程里,就得自己封装跨线程的信号槽通知 UI 刷新;而信号槽本身是队列调用,主线程的事件循环一旦被阻塞,通知照样排队,界面跟着卡顿。
正确做法是把时间驱动统一放在主线程的 QTimer 里。QTimer 本质是事件循环管理器里的一个定时源,timeout 信号发出后,默认在当前线程的 exec 循环里投递和处理。对红绿灯这种毫秒级精度就够的场景,单线程完全满足需求。有人担心长时间运行积累误差,后面第 4 章的 QElapsedTimer 方案就是干这个的。
单线程还有一个额外收益:状态变量不需要加锁。m_phase、m_lightGreen 这些字段全在 UI 线程里读写,paintEvent 也在同一线程,天然没有竞态,调试时少掉一大半烦恼。我见过某开发者为了模拟多路口的联动,开了 4 个线程各自跑一个方向的信号灯,结果每次状态同步都要用 QMutex 锁住共享数组,最后死锁排查了两天。记住这个原则:能用单线程解决的时间控制,不要引入多线程。
2.2 QPainter 绘制 vs QGraphicsView 搭场景
绘制路口有两条路线:重写 QWidget 的 paintEvent 用 QPainter 画道路和灯;或者搭 QGraphicsView + QGraphicsItem 场景。对纯静态画面的模拟器来说,推荐前者。QGraphicsView 的优势在于图元的碰撞检测、移动和可编辑性,适合做交互式编辑器;信号灯模拟的核心是循环时序,画面本身不怎么变,用 QGraphicsView 反而要维护图元生命周期和坐标变换,徒增复杂度。
QPainter 在 paintEvent 里直接从窗口画布原点开始画,坐标系统就是窗口坐标,y 轴向下、x 轴向右。画十字路口无非是几根线、几个矩形、几个圆:四条路用矩形条带表示,灯用带颜色的圆表示。抗锯齿一开,视觉效果就是平滑的。
绘制代码的复用性也强:把画灯封装成一个函数,传入位置、颜色、是否点亮,后续想扩展成带倒计时数字的信号灯,也只是在这个函数里多加一行 drawText 的事。不会像 QGraphicsView 那样,为了加一个倒计时文本还得新建一个自定义 QGraphicsItem 类。
2.3 绘制与状态解耦:paintEvent 里只做画,不做算
代码结构上要刻意把「状态计算」和「画面渲染」分开。常见做法是让 paintEvent 里只根据成员变量去画,比如 m_phase 和方向灯色数组;状态推进放在定时器的槽函数里完成。这样 paintEvent 可以被系统频繁调用(拖拽窗口、遮挡恢复都会触发),但每次都只是按最新状态重绘,不会产生状态变更的副作用。
如果反过来,在 paintEvent 里计算时间并修改状态变量,窗口一重绘,时序就乱了——这是新手最容易写出来的隐藏 bug。某次我看到一个帖子,现象是窗口最小化再恢复后,红绿灯直接跳了好几个相位,原因就是状态更新逻辑被写进了 paintEvent,每次重绘都会把相位推进一遍。解耦之后,绘制函数是纯函数式的:输入状态、输出画面,任何重绘都不会影响程序逻辑。
3. 搭出路口场景:坐标系、道路绘制与最小可跑代码
3.1 定义路口几何:坐标计算与道路宽度参数
先定义路口的几何参数。我习惯把画布看作一个正方形区域,十字路口位于正中央,四条道路各占一块区域。道路宽度取画布最小边长的 0.6 倍,这样中央路口区域和四周的路段都能清晰显示。中心区域是四条道路交汇的十字区,也就是一个同样边长的正方形。
代码里用一个成员函数 updateGeometry() 统一计算道路矩形。它在窗口初始化、resize 时重新计算 QRectF m_roadRect,让绘制代码始终基于 m_roadRect 取值,而不是用写死的像素。窗口拉大缩小,画面比例不会乱。
void TrafficWidget::updateGeometry() { qreal w = width(), h = height(); qreal side = qMin(w, h); // 以短边为基准 qreal roadW = side * 0.6; // 道路宽:短边的 60% m_roadRect = QRectF((w - roadW) / 2.0, (h - roadW) / 2.0, roadW, roadW); update(); // 重算后触发重绘 }这里 roadW 取短边的 60% 而非 100%,是为了给四周留出路边区域和信号灯的位置。如果取 100%,灯就只能画在路中间,视觉上很别扭。0.6 是我试出来的观感取值,想更紧凑调到 0.7 也完全没问题,关键在于理解这个参数控制的是路口占画布的比例。窗口变得很扁时,以短边为基准能保证路口区域始终完整可见,不会被拉伸变形。
画道路的核心是四个矩形。以北向道路为例:位置在路口正上方,宽度等于 roadW,高度从画布顶部到十字区顶边。用 QPainter::fillRect 填充深灰色路面,再用画笔画浅色的车道分隔线。四条方向的道路都按这个思路画,十字区自然就被四条道路围出来。
3.2 信号灯的位置布局与最小工程文件
每个方向设一组信号灯:红、黄、绿三个圆灯竖直排列,放在该方向的停止线外侧。南北方向的灯组画在道路的左右两侧,东西方向的灯组画在道路的上下两侧。这样布局符合真实路口的观感——你站在路口面对红绿灯,灯就在对面的路边。
信号灯与道路之间需要留出间距,间距太小会重叠在路面上影响识别。以南北方向为例,灯的圆心取m_roadRect.right() + roadW * 0.08,也就是道路右边向外偏移 8% 的 roadW。三个灯圆心的 y 坐标分别是m_roadRect.top() + roadW * 0.1、居中、m_roadRect.bottom() - roadW * 0.1。
画灯函数我这么封装:
void TrafficWidget::drawLight(QPainter &p, QPointF center, bool redOn, bool yellowOn, bool greenOn) { qreal r = m_roadRect.width() * 0.035; // 灯半径 = 路面宽的 3.5% QRectF redBox(center.x() - r, center.y() - 3 * r, 2 * r, 2 * r); QRectF yellowBox(center.x() - r, center.y() - r, 2 * r, 2 * r); QRectF greenBox(center.x() - r, center.y() + r, 2 * r, 2 * r); p.setPen(Qt::NoPen); p.setBrush(QColor(60, 60, 60)); // 灯壳底色 p.drawEllipse(redBox); p.drawEllipse(yellowBox); p.drawEllipse(greenBox); if (redOn) { p.setBrush(Qt::red); p.drawEllipse(redBox); } if (yellowOn) { p.setBrush(Qt::yellow);p.drawEllipse(yellowBox); } if (greenOn) { p.setBrush(Qt::green); p.drawEllipse(greenBox); } }这个函数接收中心点和三个灯色布尔值,一次性画出一组信号灯。先画三个深灰色圆作为灯壳,哪个灯亮就用对应颜色在同一个圆上覆盖一遍,熄灭的灯保留灰色底。灯半径取路面宽的 3.5%,这个比例在 600x600 的窗口下显示效果刚好,窗口放大到 1200 也清晰可见。
工程文件用 qmake 组织,最小结构就三个文件,一个 .pro 加一个头文件加一个源文件。命令行编译流程:qmake生成 Makefile,然后make,最后直接运行可执行文件,不需要 IDE。
# TrafficSim.pro QT += core gui widgets TARGET = TrafficSim TEMPLATE = app SOURCES += main.cpp TrafficWidget.cpp HEADERS += TrafficWidget.hmain.cpp 里就做三件事:创建 QApplication、创建主窗口、进入事件循环。窗口默认大小 800x800,标题设置为「十字路口信号灯模拟」,其他什么都不用配置。
3.3 resizeEvent 里的重算逻辑与调用顺序
resizeEvent 是窗口大小变化时系统回调的函数。在它里面调用 updateGeometry() 重算路口矩形,同时把信号灯位置、停止线这些派生坐标全部基于 m_roadRect 重新推导,最后触发 update()。窗口无论缩到多小、拉到多大,画面比例关系都被保留。
一个容易踩的坑是:resizeEvent 里调 update() 之后,paintEvent 马上执行,但如果没先重算坐标,paintEvent 用的还是旧矩形,画面会出现一瞬间的错乱。所以 updateGeometry() 内部必须先更新 m_roadRect 再调用 update()——先改数据、再通知重绘,这个调用顺序在 QT5 里非常重要。
void TrafficWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); updateGeometry(); // 先重算路面矩形 // 再触发 update()。顺序反过来会闪一下旧画面 }有同学会问,paintEvent 里直接用 this->width() 取而代之每次动态算行不行?行,但代码里会到处都是 width() * 0.31、height() * 0.42 这种魔法数,改一处漏一处。集中到 m_roadRect 一个来源,所有布局都从它推导,后期调整布局只需改一处。
4. 信号灯时序逻辑:状态机落地与 QTimer 参数设置
4.1 相位状态表与状态转移:查表代替 if-else
十字路口红绿灯最基础的时序是四个相位:东西方向绿灯(南北红灯)、东西方向黄灯、南北方向绿灯(东西红灯)、南北方向黄灯。绿灯持续 30 秒,黄灯持续 3 秒,一个完整周期 66 秒。这个配时是国内典型的路口方案,实际路口会根据车流量调整,模拟程序里做成可配置数组即可。
下面是相位表,我用它来驱动灯色和转移逻辑:
| 相位编号 | 东西直行 | 东西左转 | 南北直行 | 南北左转 | 持续时间 |
|---|---|---|---|---|---|
| 0 | 绿 | 绿 | 红 | 红 | 30s |
| 1 | 黄 | 黄 | 红 | 红 | 3s |
| 2 | 红 | 红 | 绿 | 绿 | 30s |
| 3 | 红 | 红 | 黄 | 黄 | 3s |
用四个数组把上面这张表存下来,比在槽函数里写一堆 if-else 要清晰得多。数组的长度是 4,下标就是相位编号。paintEvent 里查表画灯色,槽函数里根据持续时间判断是否切相位,状态展示和状态流转完全分离。
static const int kPhaseDuration[4] = {30, 3, 30, 3}; static const bool kPhaseEWGreen[4] = {true, false, false, false}; static const bool kPhaseEWYellow[4] = {false, true, false, false}; static const bool kPhaseNSGreen[4] = {false, false, true, false}; static const bool kPhaseNSYellow[4] = {false, false, false, true};为什么要查表而不是写 if-else?因为查表可以让配时参数和状态逻辑完全解耦。想把东西向绿灯改成 45 秒,只改数组里第一个元素,不用碰任何判断语句。想做早高峰和晚高峰两套配时方案,复制两个数组再切换指针即可。模拟程序哪怕是练手项目,也要养成这种数据驱动逻辑的习惯,后面接真实配时方案时改动成本为零。
4.2 QTimer 参数:1000ms 间隔 + QElapsedTimer 消除漂移
定时器最直观的写法是间隔 1000ms,timeout 里每次让计数器加 1,判断累计秒数是否达到相位时长。但这个方案在窗口事件循环受阻塞时不靠谱:拖拽窗口、弹出系统菜单、或者后台同步 I/O 都会导致 timeout 触发延后,而计数器「少触发几次就少计几次」,误差一点点累积上去。
我常用的方案是保持 QTimer 每 1000ms tick 一次,但判断是否切相位用 QElapsedTimer 测真实经过时间。m_clock 在相位切换时 restart(),timeout 里读 m_clock.elapsed() 和相位持续时间比较。这样即使某次 timeout 延后触发,elapsed() 反映的还是真实经过时间,不会丢秒。
TrafficWidget::TrafficWidget(QWidget *parent) : QWidget(parent), m_phase(0) { m_timer.setInterval(1000); // 1 秒唤醒一次 m_timer.start(); m_clock.start(); // 相位计时从 0 开始 connect(&m_timer, &QTimer::timeout, this, &TrafficWidget::onTimeout); } void TrafficWidget::onTimeout() { qint64 elapsedSec = m_clock.elapsed() / 1000; if (elapsedSec >= kPhaseDuration[m_phase]) { m_phase = (m_phase + 1) % 4; // 切换到下一相位 m_clock.restart(); // 相位计时清零 } update(); // 请求重绘 }代码里的两个关键参数:QTimer 的 interval 决定唤醒频率;kPhaseDuration 数组里的值是绿灯和黄灯的持续秒数。interval 设 1000ms 时,相位切换的判定期望误差在 1 秒内;对纯视觉模拟完全够用。如果后面接车辆移动做排队仿真,想把判定期望误差压到 100ms,就把 interval 改成 100,判断逻辑不用动。
需要说明的是 QTimer 只负责周期性唤醒,不做绝对计时。即使某个 timeout 延迟了 200ms 才触发,elapsed 判断用的还是 QElapsedTimer 的单调时钟真实值,最终表现为「延迟 200ms 判定」,不会把这个延迟累积进下一相位。对于信号灯模拟,几百毫秒的判定时延肉眼无感,但长期运行不会出现越走越慢的情况。
4.3 定时器与重绘:update、repaint 怎么选不闪屏
QT5 的 QWidget 提供两个触发重绘的接口:update() 和 repaint()。update() 是异步的,它向事件循环注册一个绘制请求,系统会把同一帧内的多次请求合并为一次重绘;repaint() 是同步的,立即触发 paintEvent,不做合并。模拟程序里定时器槽函数每次调用 update(),多轮 timeout 合并后每帧最多画一次,性能开销很小。
有人为了让画面「更及时」用 repaint(),这是典型的反向优化。如果槽函数在紧凑循环里被反复调用,repaint() 每一轮都立即全量重绘,CPU 占用直接飙高;更严重的是重绘期间如果主线程被其他事件阻塞,repaint() 会卡住事件循环,表现为拖动窗口时画面撕裂。绘制代码里要是有任何耗时操作,这个问题会立刻放大。
| 接口 | 触发方式 | 合并重绘 | 典型用途 |
|---|---|---|---|
| update() | 异步 | 合并 | 定时器驱动、状态轮询 |
| repaint() | 同步 | 不合并 | 强交互场景、拖拽实时反馈 |
规则很简单:状态驱动型画面一律用 update();只有用户交互拖动这类需要立刻响应的场景才考虑 repaint()。一个不容易察觉的细节是,update() 之后即使 paintEvent 没被立即执行,状态数据也已经更新了,下次重绘自然按最新状态画。这是 QT5 事件循环的合并机制带来的优点,用好它,闪烁问题少一半。
5. 常见问题排查与避坑记录:模拟器跑起来之后的那些翻车现场
5.1 窗口拖拽时画面闪烁
现象:程序刚写完运行时一切正常,但把窗口拖到另一个屏幕或者快速改变大小时,整个画面肉眼可见地闪烁,有时还会出现残留的旧画面。
原因:闪烁的根源在于 paintEvent 没有设置抗锯齿,线条边缘出现锯齿和残影,加上 update() 请求虽然合并了,但绘制代码里用了硬编码坐标,窗口缩放时旧画面的残留区域没有及时覆盖。更常见的是 paintEvent 里读到的坐标没同步——resizeEvent 只改了成员变量,绘制函数还在用旧值推灯的位置。
解决:第一,paintEvent 开头设置 QPainter::Antialiasing,线条立刻变平滑;第二,确保所有坐标计算都基于 m_roadRect,不要在 paintEvent 里临时从 height() 反推。第三,paintEvent 第一行用 fillRect 把整个画布刷成统一底色,清掉上一帧残留,然后再画道路。
void TrafficWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.fillRect(rect(), QColor(45, 45, 45)); // 先刷背景,再画道路、停止线、信号灯 drawRoad(p); drawStopLine(p); drawLights(p); }5.2 QTimer 时间漂移,绿灯越走越慢
现象:程序运行几分钟后,绿灯切黄灯的节奏明显变慢,最夸张的时候绿灯亮了 40 秒才变黄,配时完全对不上。
原因:最初实现用了静态计数器,QTimer 每 tick 一次计数器加 1,以此判断相位时长。窗口在拖拽、最小化恢复、或者终端输出日志时,事件循环被阻塞,timeout 触发延后甚至丢失,计数器少加了几秒,于是相位时间被拉长。这是「按触发次数计时」方案的通病——它假设每次触发间隔恒定,但实际做不到。
解决:改用 QElapsedTimer 按墙钟时间计时,就像第 4 章代码里那样,事件循环被阻塞的时间会被真实计入 elapsed()。验证方法很简单:在相位切换日志里打印切换时刻,对比相邻两次切换时间差,30 秒相位就应该在 29.8 到 30.2 秒之间波动。这个误差范围肉眼完全无感,而且不会随运行时间累积。
5.3 窗口缩小后信号灯挤到画面外
现象:把窗口从 800x800 缩到 400x300,信号灯直接画在了窗口边界外面,道路和灯叠在一起,画面没法看了。
原因:绘制代码里灯的位置用了固定像素坐标。窗口尺寸一变,这些常数坐标不跟着变,灯就跑偏了。任何窗口类程序都不该在 paintEvent 里用绝对像素做布局,除非窗口禁用缩放。
解决:所有灯的位置改成基于 m_roadRect 的坐标映射。灯的圆心是 m_roadRect.right() 向外偏移 roadW 的某个比例,而不是一个固定点。resizeEvent 里记得先调 updateGeometry() 更新矩形,再触发 update(),否则改完尺寸的第一次绘制用的还是旧数据。这个坑我在带 A 同学的时候他踩了整整一个下午,最后发现只是把 updateGeometry 和 update 的顺序写反了。
5.4 关掉窗口进程不退出,CPU 占用还很高
现象:点右上角 X,窗口关了,但终端里进程还挂着,CPU 占用一直是满的,信号灯模拟还在后台跑着。
原因:QTimer 还在运行,事件循环没有退出。QWidget 虽销毁了,但 QTimer 的 timeout 信号还接着某个活着的对象,定时器继续触发。更隐蔽的是,如果没设置 QApplication::setQuitOnLastWindowClosed(true),窗口关闭默认只是 hide,不会主动退出应用。
解决:在 main.cpp 开头设置 setQuitOnLastWindowClosed(true),并在主窗口的 closeEvent 里调 m_timer.stop()。两步都做最保险——前者保证最后一个窗口关闭时退出事件循环,后者防止定时器在析构链条里还挂着。
int main(int argc, char *argv[]) { QApplication a(argc, argv); QApplication::setQuitOnLastWindowClosed(true); TrafficWidget w; w.resize(800, 800); w.show(); return a.exec(); }6. 进阶玩法:让车辆沿路移动并验证时序正确性
6.1 给路口加车辆:用一个结构体数组驱动动画
信号灯时序稳定后,下一步自然是让画面真正动起来。经典做法是维护车辆列表,用struct Vehicle存方向、坐标、速度。每次 QTimer timeout 更新车辆坐标,方向字段决定车辆沿哪条道路移动,红灯亮时在停止线前等待。这个扩展只用到第 4 章的状态查表结果,不需要新增任何线程或控制器。
struct Vehicle { int dir; // 0=向北, 1=向南, 2=向东, 3=向西 qreal x, y; qreal speed; // 每 tick 移动像素数 }; QVector<Vehicle> m_vehicles;车动起来的核心逻辑是:每 tick 检查对应方向的红灯是否点亮,点亮且车辆未过停止线就停在原地,熄灭则沿道路方向推进坐标。前车距离判断可以简单做——遍历同一方向的车辆,比较前后位置差,小于一个车身长度就让后车停车。串口数据这类真实接口可以先不接,把车辆排队长度和平均等待时间打印到终端,就已经能支撑一个简单的自适应配时演示了。
6.2 用日志验证相位时序:QElapsedTimer 是唯一可靠基准
验证时序正确性最直接方式是打日志。在相位切换瞬间输出一行数据,包含当前时间戳、新相位编号、理论持续时间。我常用格式是QTime::currentTime().toString("hh:mm:ss.zzz")加相位信息,这样每次切换的精确时刻都留痕了。
qDebug() << QTime::currentTime().toString("hh:mm:ss.zzz") << "phase ->" << m_phase << "duration:" << kPhaseDuration[m_phase];运行完 100 个完整周期后,看日志里每个相位的实际间隔与理论值之差。绿灯均值在 30.05 秒内,黄灯均值在 3.02 秒内,说明时序误差在 0.2% 以内,这个指标已经足够支撑后续车辆级仿真。连续跑整夜验证不跳相位,再接入其他模块,是我做这类模拟器一直保持的习惯。信号灯模拟看着简单,但把定时器和绘制的边界摸清楚之后,以后再写 QT5 的上位机界面,心里就有谱了,希望帮到你。
本文还有配套的精品资源,点击获取