简介:针对Linux环境下使用Qt框架连接Wi-Fi的开发需求,这套源码提供完整的示例工程,适合对C++有一定基础、想要学习Qt网络编程或Linux无线管理的开发者。项目围绕Ubuntu 16.04系统,借助NetworkManager与wpa_supplicant实现Wi-Fi热点扫描、密码输入、连接与状态反馈,涉及网络配置管理类的调用、连接激活、信号与槽驱动界面更新等核心知识点。压缩包体积约9KB,共包含12个文件,其中有4个C++源文件、3个头文件、3个界面描述文件,以及工程文件和用户配置文件,各文件分工明确,导入Qt后可直接编译运行。目前已有4523人参与学习。通过阅读源码,可以快速掌握使用Qt与Linux系统网络服务交互的实现思路,并在此基础上扩展自定义界面、自动重连或静态IP设置,同时为相关课程设计与毕业设计提供可复用的范式,是入门Linux无线网络程序开发的实用参考。
1. 没有 Wi-Fi 图形界面的 Linux 设备,我用 Qt 补了一个
接过一台没装桌面环境的嵌入式设备,现场人员要配 Wi-Fi,只能对着终端敲nmcli。第一次连还好,第二次换热点就翻车,密码一长串根本看不出来是哪里漏了字符。这个项目的出发点很具体:用 Qt 写一个图形化的 WiFi 连接程序,面对的就是精简 Linux 系统,界面只要一个热点列表、一个密码输入框、一个连接按钮。它不碰驱动、不碰 wpa_supplicant 的实现细节,全部走 NetworkManager 的 D-Bus 接口,把扫描、连接、状态监听串成一条完整链路。适合那些要在无桌面或轻量桌面环境下交付配网工具的开发者,也适合刚接触 Qt 与 D-Bus 编程、想找一个真实系统组件练手的人。
2. 技术选型与架构:Qt Widgets + NetworkManager D-Bus,把 wpa_supplicant 留在底层
2.1 为什么不直接操作 wpa_supplicant
写 Linux 下连接 WiFi 的程序,很多人的第一反应是找 wpa_supplicant 的接口。wpa_supplicant 确实承担了真正的认证和密钥协商,但它对外暴露的是 Unix socket 和一套控制接口协议,你要自己维护连接状态机、处理 EAPOL 回调、手动触发 DHCP。这套流程写下来,光状态分支就够写一本书。而这个项目的目标是快速交付一个能用的 GUI 工具,不是重新发明无线协议栈。
NetworkManager 把 wpa_supplicant 包了一层,对外开放 D-Bus API。扫描、连接、断线、信号强度变化都以属性或信号的形式暴露出来。用 Qt 的 QtDBus 模块可以直接调用,不需要额外引入第三方库。程序要做的事情收敛为三件:找到 Wi-Fi 设备、拿到热点列表、发起带密码的连接请求。剩下的连接生命周期、IP 地址获取、重连策略,统统交给 NetworkManager 管理。这种"少做底层、多做业务"的取舍,适合绝大多数应用场景。
2.2 界面组件与后台职责划分
界面层用 QWidget,原因是目标设备普遍没有 GPU 加速,QWidget 的绘制路径比 QML 场景图轻量,渲染开销小,代码也更直观。后台逻辑单独抽成一个WifiManager类,继承自QObject,方便用信号槽把扫描结果和连接状态推送给界面。MainWindow只负责展示QListWidget中的热点列表、弹出密码输入框,以及在状态栏显示当前连接状态。
整个项目文件结构如下:
wifi-connector/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── mainwindow.h │ ├── mainwindow.cpp │ ├── wifimanager.h │ ├── wifimanager.cpp │ ├── accesspoint.h # 热点数据模型 │ └── accesspoint.cppAccessPoint是一个纯数据类,保存 SSID、BSSID、信号强度、安全类型。WifiManager承担所有 D-Bus 通信,MainWindow持有WifiManager实例,通过信号槽接收热点列表更新和连接结果。三层职责分离,后续要替换成其他后台实现,只需要改WifiManager一个类。
2.3 核心调用链路的整体视图
程序启动后,WifiManager先请求 NetworkManager 返回设备列表,过滤出类型为 Wi-Fi 的设备对象路径。点击「刷新」按钮时,向该路径发出RequestScan方法调用,等待数秒后调用GetAccessPoints获取热点路径数组,再逐个读取属性。属性读取用到了org.freedesktop.DBus.Properties接口的Get方法,拿到 SSID、信号强度、加密标志位。
发起连接时调用的是org.freedesktop.NetworkManager.AddAndActivateConnection,参数是一个嵌套字典,描述连接类型、Wi-Fi 参数、安全参数和 IP 配置方式。这里的参数结构比普通 D-Bus 方法复杂,第三部分我会把这块拆开讲。整条链路从用户点击界面到 Wi-Fi 真正连上,中间没有一行业务代码操作 wpa_supplicant,也没有调用外部命令,全部走 D-Bus,这也是这个项目最值得借鉴的地方。
3. 核心模块拆解:扫描、属性解析与 AddAndActivateConnection 的参数构造
3.1 扫描热点:先发请求,再等结果
扫描的起点是找到 Wi-Fi 设备。NetworkManager 的设备列表在根对象/org/freedesktop/NetworkManager上,调用GetDevices返回一组对象路径,其中DeviceType属性值为 2 的才是 Wi-Fi 设备。拿到设备路径之后,调用RequestScan触发扫描。注意这个方法没有返回值,扫描是异步的,实际代码里要等几秒再去拿热点列表,否则拿到的还是旧数据。
QDBusInterface nm("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager", "org.freedesktop.NetworkManager", QDBusConnection::systemBus()); QDBusReply<QList<QDBusObjectPath>> devicesReply = nm.call("GetDevices"); foreach (const QDBusObjectPath &devicePath, devicesReply.value()) { QDBusInterface device("org.freedesktop.NetworkManager", devicePath.path(), "org.freedesktop.NetworkManager.Device", QDBusConnection::systemBus()); int type = device.property("DeviceType").toInt(); if (type == 2) { // NM_DEVICE_TYPE_WIFI m_wifiDevicePath = devicePath.path(); QDBusInterface wifi("org.freedesktop.NetworkManager", m_wifiDevicePath, "org.freedesktop.NetworkManager.Device.Wireless", QDBusConnection::systemBus()); wifi.call("RequestScan", QVariantMap{{"ssid", QVariant(QByteArray())}}); } } QThread::msleep(3000); // 等待扫描完成参数说明:RequestScan接受一个a{sv}字典,键ssid为空表示全频段扫描。这里用msleep只是一种快速实现,严谨的做法是监听AccessPointAdded信号,或者轮询LastScan属性的时间戳。项目落地时,我一般会把msleep换成带超时的等待循环,避免界面卡死。扫描在后台线程执行,通过信号把结果发回主线程刷新列表。
3.2 热点属性读取与安全类型判断
GetAccessPoints返回的是AccessPoint对象路径数组。每个路径对应一个唯一热点,同一个 SSID 在不同信道会被识别为多个 AccessPoint。读取属性用org.freedesktop.DBus.Properties接口,逐个Get会发很多次 D-Bus 消息,更高效的办法是一次性调用GetAll拿到全部属性字典。
QDBusInterface wifi("org.freedesktop.NetworkManager", m_wifiDevicePath, "org.freedesktop.NetworkManager.Device.Wireless", QDBusConnection::systemBus()); QDBusReply<QList<QDBusObjectPath>> apReply = wifi.call("GetAccessPoints"); foreach (const QDBusObjectPath &path, apReply.value()) { QDBusInterface props("org.freedesktop.NetworkManager", path.path(), "org.freedesktop.DBus.Properties", QDBusConnection::systemBus()); QDBusReply<QVariantMap> all = props.call("GetAll", "org.freedesktop.NetworkManager.AccessPoint"); QVariantMap map = all.value(); QByteArray ssidBytes = map.value("Ssid").toByteArray(); uint strength = map.value("Strength").toUInt(); uint flags = map.value("Flags").toUInt(); // AP 广播标志 uint wpaFlags = map.value("WpaFlags").toUInt(); // WPA 加密能力 uint rsnFlags = map.value("RsnFlags").toUInt(); // RSN 加密能力 }逻辑说明:Ssid在 D-Bus 上的类型是ay,本质是字节数组,所以这里用toByteArray(),再通过QString::fromUtf8(ssidBytes)转成可显示的字符串。如果热点名包含中文,直接调用toInt()或toString()都会出问题。安全类型判断要看三个标志位:Flags表示设备是否启用加密,WpaFlags和RsnFlags共同表示具体支持 WPA、WPA2 还是 WPA3。rsnFlags中如果包含 WPA2 对应位,界面就显示 WPA2,包含 WPA3 对应位就显示 WPA3。
3.3 发起连接:嵌套字典参数的构造细节
AddAndActivateConnection是这个项目里最容易写崩的地方。它的 D-Bus 签名是a{sa{sv}}o,第一个参数是一个嵌套字典:外层键是连接设置的 section 名,例如connection、802-11-wireless、802-11-wireless-security、ipv4;内层是每个 section 下的具体配置项。Qt 的QDBusInterface::call对这种嵌套结构的支持有限,直接用QDBusMessage手工构造最稳。
QDBusMessage msg = QDBusMessage::createMethodCall( "org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager", "org.freedesktop.NetworkManager", "AddAndActivateConnection"); QMap<QString, QVariantMap> connection; connection["connection"] = QVariantMap{ {"type", "802-11-wireless"}, {"id", ssid}, {"uuid", QUuid::createUuid().toString(QUuid::WithoutBraces)} }; connection["802-11-wireless"] = QVariantMap{ {"ssid", QByteArray(ssid.toUtf8())} }; if (!password.isEmpty()) { connection["802-11-wireless-security"] = QVariantMap{ {"key-mgmt", "wpa-psk"}, {"psk", password} }; } connection["ipv4"] = QVariantMap{{"method", "auto"}}; connection["ipv6"] = QVariantMap{{"method", "auto"}}; msg << QVariant::fromValue(connection) << QVariant(QDBusObjectPath(m_wifiDevicePath)); QDBusReply<QDBusObjectPath> activeReply = QDBusConnection::systemBus().call(msg);参数说明:connection.id是 NetworkManager 里显示的名称,不是 Wi-Fi SSID,可以随意起;uuid必须全局唯一,重复会导致连接配置互相覆盖。802-11-wireless.ssid同样是字节数组,要转成QByteArray。key-mgmt设置为wpa-psk覆盖大多数家用路由器,如果是开放网络则整个802-11-wireless-security段都不填。方法成功后返回(o,o),第一个是新建的连接对象路径,第二个是激活连接对象路径,后续监听连接状态就靠第二个返回值。
3.4 连接状态监听:信号要绑定对象路径
AddAndActivateConnection返回不代表已经连上,它只是说明激活请求被受理。真正的连接状态变化通过org.freedesktop.NetworkManager.Connection.Active接口的StateChanged信号通知。NetworkManager 连接状态的枚举值中,100 是Activated,也就是完全连上;50 是Activating,正在认证或获取 IP。
QDBusConnection::systemBus().connect( "org.freedesktop.NetworkManager", m_activeConnectionPath, "org.freedesktop.NetworkManager.Connection.Active", "StateChanged", this, SLOT(onActiveStateChanged(uint)));信号绑定时要特别注意第二个参数,也就是期望监听的对象路径,必须和AddAndActivateConnection返回的一致。很多实现挂在这里,原因是拿了一个固定的路径去监听,或者根本没取返回值。onActiveStateChanged里判断参数值,等于 100 就发connectionSucceeded信号给界面,等于 40 或 50 就更新状态栏文字,如果变成 20 以下就说明连接被取消或失败。设备层面的断线通知要监听org.freedesktop.NetworkManager.Device的StateChanged信号,参数格式是(新状态, 旧状态, 原因),这两个信号一配合,界面上的连接状态基本就实时了。
4. 编译与运行:从装依赖到在目标机器上出现热点列表
4.1 依赖准备与 CMake 配置
这个项目需要 Qt 的 Core、Widgets、DBus 三个模块,外加运行环境中必须有 NetworkManager 服务。编译阶段的依赖在基于 .deb 的发行版上安装方式是apt install qt6-base-dev,基于 .rpm 的对应用dnf install qt6-qtbase-devel。不同发行版包名略有差异,装完之后能find_package(Qt6 REQUIRED COMPONENTS Core Widgets DBus)找到就算通过。
cmake_minimum_required(VERSION 3.16) project(wifi-connector LANGUAGES CXX) find_package(Qt6 REQUIRED COMPONENTS Core Widgets DBus) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) add_executable(wifi-connector src/main.cpp src/mainwindow.cpp src/wifimanager.cpp src/accesspoint.cpp ) target_link_libraries(wifi-connector PRIVATE Qt6::Core Qt6::Widgets Qt6::DBus)CMAKE_AUTOMOC必须开着,因为WifiManager和MainWindow都用了Q_OBJECT宏,信号槽的元对象代码需要 moc 预处理生成。很多第一次用 Qt 的开发者编译报undefined reference to vtable,十有八九是 AUTOMOC 没开。编译命令常规执行:cmake -B build和cmake --build build -j$(nproc),成功后拿到可执行文件wifi-connector。
4.2 运行前的环境检查
程序跑起来之前先确认两件事:system bus 可用,NetworkManager 服务在线。命令/lib/systemd/systemd-dbus-daemon不常出问题,容易出问题的是 NetworkManager 没有启动或被某个安全策略禁用。检查命令如下:
systemctl status NetworkManager nmcli device status如果设备状态列显示unavailable,多半是 rfkill 把无线网卡软禁了,执行rfkill list看情况,然后rfkill unblock wifi。这个检查和程序本身无关,但排查界面一片空白时有九成概率落到这里。启动程序后给它几秒钟扫描,日志里出现D-Bus connection established这类输出就说明服务连接正常。
4.3 调试手段:dbus-monitor 看完整请求链路
程序里涉及 D-Bus 的代码出问题,第一现场不在项目日志里,而在总线消息里。开发机上开启一个终端执行dbus-monitor --system "interface='org.freedesktop.NetworkManager'",然后启动程序点一次连接按钮。AddAndActivateConnection的入参和返回值会原样显示在终端上,参数结构是否完整、字段名是否拼错一眼就能看出来。
dbus-monitor --system "type='method_call',interface='org.freedesktop.NetworkManager'"这一步能省掉大半的「改代码、重编译、看现象」循环。特别是嵌套字典结构,字段名拼错不会编译报错,只会在运行时报invalid-property或者invalid-setting,看到错误名再回 dbus-monitor 对照入参是最快路径。NetworkManager 自己的日志也能提供补充信息,journalctl -u NetworkManager -f查看服务端输出,里面的原因描述比 D-Bus 错误更细,比如认证失败的明确原因。
4.4 打包部署到无桌面环境
把程序带到现场机器上有两种常见做法。要求不高就直接拷贝可执行文件,但目标机器上必须装了相同版本的 Qt 库。更稳妥的是打成应用目录或者安装包,把所有动态库一并带上,这样能避免目标机库版本对不上。下面是一个部署用脚本的骨架:
#!/bin/bash APPDIR=/opt/wifi-connector mkdir -p $APPDIR/lib $APPDIR/plugins cp build/wifi-connector $APPDIR/ ldd build/wifi-connector | grep 'Qt\|icu' | \ awk '{print $3}' | while read lib; do cp -L "$lib" $APPDIR/lib/ done cat > $APPDIR/run.sh <<EOF #!/bin/bash export LD_LIBRARY_PATH=$APPDIR/lib:\$LD_LIBRARY_PATH export QT_PLUGIN_PATH=$APPDIR/plugins exec $APPDIR/wifi-connector EOF无桌面环境里程序启动依赖 systemd 服务,写一个极简 service 单元就能开机自启。socket、总线策略这些细节在这个项目里不需要额外处理,Qt 的 DBus 模块走的是默认 system bus 策略,普通用户权限就能调用 NetworkManager 的读写接口。
5. 避坑与排查:5 个让配网程序变黑匣子的细节
5.1 界面一直空白,热点列表加载不出来
现象:程序启动后点刷新,QListWidget里始终没有数据,NetworkManager 日志里也没有报错。
原因:这个现象极大概率不是代码问题,而是无线设备没有被 NetworkManager 接管。设备处于unavailable状态时,GetAccessPoints只会返回空数组,不抛异常,代码里无从感知。rfkill 软禁、设备被 NetworkManager 配置为unmanaged、驱动没有加载,都会让设备停在这个状态。
解决:启动程序前先跑一遍nmcli device status,状态列看到connected或disconnected才继续调试。如果显示unavailable,先处理 rfkill 和驱动问题,再回头跑程序。把这段环境检查写进项目的启动脚本里,血泪经验:配网程序的黑匣子从来都是环境先黑了。
5.2 中文 SSID 显示成乱码或直接消失
现象:扫描列表里部分热点名称完全不可读,或者明明有中文热点却消失在列表里,英文热点正常。
原因:D-Bus 上Ssid属性是字节数组ay,不是字符串。直接把QVariant转成QString会按 Latin-1 解码,中文编码对不上自然乱码。如果在转换前先把字节数组截断或做过滤,字段可能因为长度不够被界面丢弃。
解决:一律走toByteArray()再QString::fromUtf8(),并且不能用toInt()、toString()这些隐蔽转换。我一般会在AccessPoint模型里加一个字段保存原始字节数组,界面显示做一次转换,后续需要按 SSID 精确匹配时直接比较字节数组,绕开字符串编码的坑。
5.3 密码没问题却连不上,报 invalid-property
现象:确认 Wi-Fi 密码正确,D-Bus 调用返回错误invalid-property,连接配置没建出来。
原因:集中在802-11-wireless-security段的参数上。常见错误是key-mgmt写成了WPA-PSK,实际这个字段要求小写wpa-psk;另一种是psk密码长度小于 8 个字符,NetworkManager 直接拒绝。还有一种是正确配置了 WPA2 却忘了pmf字段,某些新版本 NetworkManager 会要求显式声明管理帧保护策略。
解决:验证流程固定为三步:密码最短 8 位,key-mgmt用小写,参考nmcli connection add自动生成的配置字典。我的做法是先手动用nmcli连一次,如果命令行能连上但程序连不上,就dbus-monitor抓一次命令行的调用参数,照抄字段名。
5.4 状态信号一直不触发
现象:AddAndActivateConnection返回正常,但界面上连接状态卡在「连接中」,始终收不到成功回调。
原因:StateChanged信号绑定的对象路径错了。这个方法返回两个对象路径,第一个是连接配置路径,第二个才是 active connection 路径。很多人监听的是第一个,或者干脆没取返回值。还有一个隐蔽原因:Qt 的QDBusConnection::connect对信号签名很敏感,声明槽函数参数时必须和 D-Bus 信号签名严格一致,多一个参数或少一个类型都会让连接静默失败。
解决:把返回值里.value<QDBusObjectPath>()取到的路径打印出来,手工核对监听路径。槽函数参数类型用uint,不要用int,无符号类型不匹配也会出现"信号看起来绑了就是不触发"的现象。我遇到过一次因为QVariant类型不匹配导致信号回调静默消失的问题,排查到这一步基本就收敛了。
5.5 程序在 systemd 服务里起不来,命令行跑就没问题
现象:现场交付时用 systemd 托管程序,设备重启后服务显示active (running)但界面拉不起来,D-Bus 调用全部超时。
原因:systemd 服务默认的Environment=是空的,Qt 找不到平台插件路径,QPA加载失败,程序可能在启动早期就退出了,但服务状态看起来还在 running。另一个相关因素是PATH被精简,程序里如果间接调用了外部命令,也会在这里踩坑。
解决:服务文件里显式指定运行环境,平台插件路径用QT_QPA_PLATFORM_PLUGIN_PATH指到部署目录下的插件位置。如果无桌面环境想跑 Qt 程序,设置QT_QPA_PLATFORM=linuxfb或offscreen这类轻量平台,但注意offscreen模式下看不到界面,只适合自动化测试。
6. 二次开发方向:自动重连、加密存储与软 AP
拿到源码之后,改动优先级最高的是两个点,一个是密码存储方式,一个是断线自动恢复。当前版本的密码只存在于内存里,没有落盘,这是安全边界内最稳妥的临时方案。如果产品上要求「记住这个网络」,不要直接把密码写进配置文件的明文属性里,正确的做法是接入 Secret Service 的org.freedesktop.Secrets接口,或者退一步用QSettings存放但至少做一层混淆。D-Bus 上调用org.freedesktop.Secrets的回调流程有些繁琐,建议封装在WifiManager类里,对外只暴露savePassword(ssid, psk)和loadPassword(ssid)两个方法。
自动重连要监听org.freedesktop.NetworkManager.Device的StateChanged信号,设备状态值为 30 表示完全断开。拿到这个信号后按 30 秒、1 分钟、5 分钟的间隔尝试重新激活已有连接配置。这里不建议每次都以AddAndActivateConnection新建连接,会产生大量重复配置。先用ListConnections找出id匹配的连接路径,再调用ActivateConnection复用。
QDBusInterface nm("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager", "org.freedesktop.NetworkManager", QDBusConnection::systemBus()); QDBusReply<QList<QDBusObjectPath>> conns = nm.call("ListConnections"); for (const QDBusObjectPath &p : conns.value()) { QDBusInterface conn("org.freedesktop.NetworkManager", p.path(), "org.freedesktop.NetworkManager.Connection", QDBusConnection::systemBus()); if (conn.property("Id").toString() == ssid) { nm.call("ActivateConnection", QVariant::fromValue(p), QVariant(QDBusObjectPath(m_wifiDevicePath)), QVariant(QDBusObjectPath("/"))); break; } }如果要扩展到软 AP 模式,做法是把AddAndActivateConnection换成先AddConnection建好热点配置,再通过Device.Wireless接口调用StartAp。热点模式下connection.type仍是802-11-wireless,但mode属性要额外设为"ap",IP 配置不再用 auto 而是静态地址,子网掩码和网关都要显式写明,否则客户端连上后拿不到 IP。
验证改动是否靠谱,我的习惯是先不开真机,在装有 NetworkManager 的虚拟机上跑一个 open 网络测试端,依次跑过无密码、WPA2、中文 SSID 三组用例,然后看StateChanged信号序列是否走到 100。这套流程走完再上真机,基本不会在现场出丑。从那以后我做任何配网项目的回归测试,都会强制走一遍这三组用例加一次 systemd 重启验证,脚本化的流程省掉很多夜间电话,希望帮到你。
本文还有配套的精品资源,点击获取