尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Qt UDP广播实战:局域网设备发现与高效通信指南

Qt UDP广播实战:局域网设备发现与高效通信指南 1. 项目概述为什么选择Qt与UDP广播在嵌入式设备发现、局域网服务通告或者需要一对多高效发送简单指令的场景里TCP那种“打电话”式的连接就显得有些笨重了。你得先拨号三次握手确保对方在线然后才能说话说完还得确认对方收到ACK最后还要道别四次挥手。如果要对成百上千个设备说同一句话这个成本就太高了。这时候UDP广播就像是在一个大礼堂里用扩音器喊话。你不需要认识在场的每一个人也不需要和他们一一建立联系你只需要对着麦克风说一句所有人都能听到。这种方式简单、直接、开销极低。当然它不保证每个人都听清了也不保证每个人都听了但在很多实时性要求高、允许少量丢包的场景下这恰恰是它的优势。Qt作为一个成熟的跨平台C框架其网络模块QtNetwork对UDP的支持非常友好。它用QUdpSocket类封装了底层的Socket操作让我们能用面向对象的方式轻松实现广播通信而不用去纠结bind、sendto、recvfrom这些底层细节和不同操作系统的差异。无论是做一套智能家居的控制中心向所有灯发送开关指令还是开发一个会议室投屏软件来发现局域网内的接收端UDP广播都是核心技术。我最近在一个工业数据采集项目中就深度使用了这个技术。现场有几十台分散的PLC可编程逻辑控制器我们需要一个上位机程序能快速发现它们并获取其状态。如果采用TCP轮询网络延迟和连接负担会很大。最终我们采用了UDP广播发送“身份查询”指令PLC收到后用自己的IP和端口单播回复瞬间就完成了设备发现。这个实战案例让我对Qt UDP广播的细节和坑点有了更深的理解下面我就把这些经验系统地分享出来。2. 核心原理与Qt实现机制拆解2.1 UDP广播与组播的区分在动手写代码前必须厘清两个概念广播Broadcast和组播Multicast。很多人容易混淆。广播特指向一个子网内的所有主机发送数据包。在IPv4中广播地址是主机位全为1的地址。例如对于192.168.1.0/24这个网段其广播地址就是192.168.1.255。当你向这个地址发送UDP数据包时链路层如交换机会将其转发到该网段下的每一个端口所有IP地址在该网段内的主机都会在网卡层面收到这个包。它的影响范围是整个子网不可控容易引发“广播风暴”在大型网络中需谨慎使用。组播则是向一个“组”内的主机发送数据。这个组由一个特定的D类IP地址范围224.0.0.0到239.255.255.255标识。主机需要主动“加入”这个组播地址才能收到发往该地址的数据。交换机通过IGMP协议学习哪些端口有组播成员从而进行有选择性地转发。组播更像是一个订阅了特定频道组播地址的群体只有订阅者才能收到信息对网络流量更友好。我们本文聚焦的是广播因为它实现更简单无需路由器特殊配置在局域网内即插即用是小型网络或特定网段内服务发现的理想选择。2.2 QUdpSocket 的工作模型Qt的QUdpSocket继承自QAbstractSocket提供了异步、基于信号槽的编程模型这是Qt网络编程的核心优势。关键特性无连接性无需像QTcpSocket那样先connectToHost。你可以直接创建一个Socket绑定到本地一个端口然后开始收发。数据报Datagram导向UDP传输的是一个个独立的数据包每个包都包含了完整的源/目标地址和端口信息。QUdpSocket的readDatagram()和writeDatagram()正是为这种模式设计的。异步IO这是最省心的地方。当Socket收到数据时会发射readyRead()信号发生错误时发射errorOccurred()信号。我们只需要用槽函数连接这些信号就可以在事件循环中处理网络事件完全不需要自己开线程去阻塞读取。一个Socket两种角色作为接收方需要调用bind()函数将自己绑定到一个本地端口有时还需要指定监听的网络接口或地址。绑定后Socket开始监听该端口到来的数据报。作为发送方可以直接调用writeDatagram()向目标地址和端口发送数据。对于广播目标地址就是QHostAddress::Broadcast或具体的广播地址如QHostAddress(“192.168.1.255”)。注意一个QUdpSocket对象可以同时用于发送和接收这很常见。但如果你需要在一个端口上同时监听单播、广播和组播或者有非常复杂的收发逻辑创建多个专门的Socket对象是更清晰的做法。3. 实战步骤从零构建一个广播通信示例让我们通过一个完整的例子来掌握。我们将创建两个简单的控制台程序一个广播发送器Broadcast Sender和一个广播接收器Broadcast Receiver。3.1 环境准备与项目创建首先确保你的开发环境已经安装了Qt建议5.12及以上版本和对应的编译器如MSVC、MinGW或GCC。创建发送端项目打开Qt Creator新建一个“Qt Console Application”项目命名为BroadcastSender。在.pro项目文件中必须添加网络模块QT network。创建接收端项目同样方式再创建一个名为BroadcastReceiver的Qt控制台项目同样记得QT network。3.2 广播发送器Sender实现发送器的核心逻辑是定时或由事件触发向广播地址发送消息。main.cpp(Sender) 实现#include QCoreApplication #include QUdpSocket #include QTimer #include QDebug #include QNetworkInterface #include QHostAddress int main(int argc, char *argv[]) { QCoreApplication a(argc, argv); QUdpSocket senderSocket; quint16 broadcastPort 45454; // 选择一个未被占用的端口接收器需监听此端口 // 构造要发送的消息 QString message QString(Hello from Sender at %1).arg(QDateTime::currentDateTime().toString()); QByteArray datagram message.toUtf8(); // 转换为UTF-8字节数组 // 尝试获取本地第一个非回环的IPv4地址用于构造广播地址可选更精确 QHostAddress broadcastAddress QHostAddress::Broadcast; // 默认是255.255.255.255 foreach (const QNetworkInterface interface, QNetworkInterface::allInterfaces()) { if (interface.flags() QNetworkInterface::IsUp interface.flags() QNetworkInterface::IsRunning !(interface.flags() QNetworkInterface::IsLoopBack)) { foreach (const QNetworkAddressEntry entry, interface.addressEntries()) { if (entry.ip().protocol() QAbstractSocket::IPv4Protocol) { // 根据IP和子网掩码计算广播地址 QHostAddress ip entry.ip(); QHostAddress netmask entry.netmask(); quint32 ipAddr ip.toIPv4Address(); quint32 maskAddr netmask.toIPv4Address(); quint32 broadcastAddr ipAddr | (~maskAddr); // IP OR (NOT MASK) broadcastAddress.setAddress(broadcastAddr); qDebug() “Using broadcast address:” broadcastAddress.toString() “for interface:” interface.humanReadableName(); break; } } break; // 只取第一个符合条件的接口 } } // 设置Socket选项允许广播在某些系统上发送广播需要显式设置 if (!senderSocket.setSocketOption(QUdpSocket::MulticastTtlOption, 1)) { // 虽然叫MulticastTtl但广播也常用此选项检查 qDebug() “Warning: Could not set socket option. Broadcast may fail on some systems.”; } // 使用定时器每秒发送一次广播 QTimer timer; QObject::connect(timer, QTimer::timeout, [](){ qint64 bytesSent senderSocket.writeDatagram(datagram, broadcastAddress, broadcastPort); if (bytesSent -1) { qDebug() “Failed to send broadcast:” senderSocket.errorString(); } else { qDebug() “Broadcast sent:” message “to” broadcastAddress.toString() “:” broadcastPort; } }); timer.start(1000); // 1000毫秒间隔 qDebug() “Broadcast Sender started. Sending to port” broadcastPort; return a.exec(); }代码关键点解析QHostAddress::Broadcast这是一个特殊的地址255.255.255.255代表“有限广播”通常只在本网络内有效。我们代码中尝试计算了具体接口的广播地址如192.168.1.255这被称为“定向广播”更精确。writeDatagram发送数据报的核心函数。参数依次是数据QByteArray、目标地址QHostAddress、目标端口quint16。返回值是发送的字节数-1表示失败。Socket选项setSocketOption(QUdpSocket::MulticastTtlOption, 1)。这里设置TTL生存时间为1意味着数据包最多只能跨越一个路由器。对于纯粹的局域网广播TTL1是正确且安全的防止广播包被意外路由到互联网上。虽然选项名包含“Multicast”但常被用来确保广播权限。3.3 广播接收器Receiver实现接收器需要绑定到发送器指定的端口并监听所有网络接口上该端口的数据。main.cpp(Receiver) 实现#include QCoreApplication #include QUdpSocket #include QDebug int main(int argc, char *argv[]) { QCoreApplication a(argc, argv); QUdpSocket receiverSocket; quint16 listenPort 45454; // 必须和发送器端口一致 // 绑定到指定端口并指定监听模式。QAbstractSocket::ShareAddress允许同一端口多绑定。 if (!receiverSocket.bind(listenPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint)) { qDebug() “Bind failed:” receiverSocket.errorString(); return -1; } qDebug() “Listening for broadcast on port” listenPort; // 连接readyRead信号处理收到的数据 QObject::connect(receiverSocket, QUdpSocket::readyRead, [](){ while (receiverSocket.hasPendingDatagrams()) { // 可能有多个数据报在缓冲区 QByteArray datagram; datagram.resize(receiverSocket.pendingDatagramSize()); // 调整缓冲区大小 QHostAddress senderAddress; quint16 senderPort; // 读取数据报并获取发送者的地址和端口 qint64 bytesRead receiverSocket.readDatagram(datagram.data(), datagram.size(), senderAddress, senderPort); if (bytesRead -1) { qDebug() “Failed to read datagram:” receiverSocket.errorString(); continue; } QString message QString::fromUtf8(datagram.constData(), bytesRead); qDebug() “Received from” senderAddress.toString() “:” senderPort “Message:” message; // 实战扩展点这里可以解析消息触发业务逻辑。 // 例如如果消息是“DISCOVER”可以回复“ACK,IP,ServiceName”。 } }); return a.exec(); }代码关键点解析bind()这是接收方的关键操作。它将Socket绑定到本地的一个特定端口。QUdpSocket::ShareAddress标志允许同一台机器上的多个进程绑定到相同的地址和端口这在某些服务发现场景有用。QUdpSocket::ReuseAddressHint提示系统重用地址避免“Address already in use”错误。readyRead信号这是Qt异步IO的精华。当有数据到达Socket时这个信号被触发。注意一次readyRead信号可能对应多个数据报所以必须在槽函数里用while (socket.hasPendingDatagrams())循环读取直到清空缓冲区。readDatagram()读取一个数据报。datagram.data()是缓冲区指针datagram.size()是缓冲区大小。后两个参数用于获取发送方的地址和端口这在需要回复时至关重要。编码问题我们使用toUtf8()和fromUtf8()进行编解码确保跨平台和跨语言如与Python、Java程序通信时文本不会乱码。这是实际项目中极易忽略但会导致严重问题的细节。4. 进阶实战局域网设备发现服务上面是一个简单的“喊话-收听”模型。现在我们将其升级为一个实用的设备发现服务。这是一个经典案例一个服务端Server在局域网内广播自己的存在客户端Client监听广播并获取服务端信息然后建立进一步的连接可能是TCP。4.1 协议设计首先需要设计一个简单的应用层协议定义广播消息的格式。这里我们使用纯文本JSON因为它易读、易解析、易扩展。广播消息格式JSON{ “type”: “discover_announce”, “service_name”: “DataCollector”, “ip”: “192.168.1.100”, “tcp_port”: 8888, “version”: “1.0” }type: 消息类型这里是服务宣告。service_name: 服务名称客户端用来识别服务类型。ip和tcp_port: 服务端真实的IP和TCP服务端口供客户端后续建立TCP连接使用。version: 协议版本用于兼容性处理。4.2 服务端被发现方实现服务端需要做两件事1. 启动真正的TCP服务或其他服务。2. 定时广播自己的服务信息。关键代码片段服务端// 在服务端主类中 QUdpSocket *discoverySocket; QTimer *broadcastTimer; quint16 discoveryPort 45455; // 设备发现专用端口 QString tcpServerIp; quint16 tcpServerPort 8888; void Server::startDiscoveryService() { discoverySocket new QUdpSocket(this); // 计算并设置广播地址同前文发送器 QHostAddress broadcastAddr calculateBroadcastAddress(); broadcastTimer new QTimer(this); connect(broadcastTimer, QTimer::timeout, this, [this, broadcastAddr](){ QJsonObject announceMsg; announceMsg[“type”] “discover_announce”; announceMsg[“service_name”] “MyDataService”; announceMsg[“ip”] tcpServerIp; // 需要获取本机非回环IP announceMsg[“tcp_port”] (int)tcpServerPort; announceMsg[“version”] “1.0”; QJsonDocument doc(announceMsg); QByteArray datagram doc.toJson(QJsonDocument::Compact); discoverySocket-writeDatagram(datagram, broadcastAddr, discoveryPort); qDebug() “Discovery announcement sent.”; }); broadcastTimer-start(5000); // 每5秒广播一次 } // 需要有一个函数来获取本机正确的IP地址用于广播消息中的ip字段。 QString Server::getLocalIp() { foreach (const QHostAddress address, QNetworkInterface::allAddresses()) { if (address.protocol() QAbstractSocket::IPv4Protocol address ! QHostAddress::LocalHost) { // 简单起见取第一个非回环的IPv4地址。生产环境需根据网卡名等条件筛选。 return address.toString(); } } return QString(); }4.3 客户端发现方实现客户端监听发现端口解析广播消息并维护一个在线的服务端列表。关键代码片段客户端// 在客户端主类中 QUdpSocket *discoverySocket; QMapQString, ServerInfo discoveredServers; // Key可以是 IP:Port QTimer *serverListCleanupTimer; struct ServerInfo { QString ip; quint16 tcpPort; QString serviceName; QDateTime lastSeen; // 最后收到广播的时间 }; void Client::startDiscoveryListener() { discoverySocket new QUdpSocket(this); if (!discoverySocket-bind(discoveryPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint)) { qWarning() “Failed to bind discovery port:” discoverySocket-errorString(); return; } connect(discoverySocket, QUdpSocket::readyRead, this, Client::onDiscoveryDataReceived); // 定时清理长时间未广播的服务端认为已离线 serverListCleanupTimer new QTimer(this); connect(serverListCleanupTimer, QTimer::timeout, this, Client::cleanupServerList); serverListCleanupTimer-start(30000); // 每30秒清理一次 } void Client::onDiscoveryDataReceived() { while (discoverySocket-hasPendingDatagrams()) { QByteArray datagram; datagram.resize(discoverySocket-pendingDatagramSize()); QHostAddress senderAddr; quint16 senderPort; discoverySocket-readDatagram(datagram.data(), datagram.size(), senderAddr, senderPort); QJsonParseError parseError; QJsonDocument doc QJsonDocument::fromJson(datagram, parseError); if (parseError.error ! QJsonParseError::NoError) { qDebug() “Invalid JSON received:” parseError.errorString(); return; } QJsonObject obj doc.object(); if (obj[“type”].toString() “discover_announce”) { QString serverIp obj[“ip”].toString(); quint16 tcpPort obj[“tcp_port”].toInt(); QString serviceName obj[“service_name”].toString(); QString key QString(“%1:%2”).arg(serverIp).arg(tcpPort); ServerInfo info; info.ip serverIp; info.tcpPort tcpPort; info.serviceName serviceName; info.lastSeen QDateTime::currentDateTime(); discoveredServers.insert(key, info); emit serverDiscovered(info); // 发出信号通知UI更新列表 qDebug() “Discovered server:” serviceName “at” serverIp “:” tcpPort; } } } void Client::cleanupServerList() { QDateTime threshold QDateTime::currentDateTime().addSecs(-60); // 超过60秒未更新视为离线 auto it discoveredServers.begin(); while (it ! discoveredServers.end()) { if (it.value().lastSeen threshold) { qDebug() “Server removed (offline):” it.value().serviceName; it discoveredServers.erase(it); } else { it; } } }4.4 项目实战中的架构思考在这个设备发现案例中我们实际上构建了一个简单的发布-订阅模型的变种。服务端是发布者客户端是订阅者。广播信道是“发布”的媒介。这种架构的优点是解耦服务端无需知道客户端的存在客户端也无需预先配置服务端地址。扩展方向安全性广播内容明文传输存在安全风险。可以对广播消息进行签名如HMAC或轻量级加密客户端只信任带有正确签名的宣告。多网卡处理在拥有多个网络接口如同时连接有线、Wi-Fi、虚拟网卡的机器上广播地址的选择变得复杂。我们的代码示例只取了第一个符合条件的接口。生产环境需要根据业务需求可能需要在所有非回环接口上发送广播或者让用户指定一个网络接口。心跳与保活客户端通过lastSeen时间戳来判断服务端是否在线。服务端定期广播就是“心跳”。如果服务端崩溃客户端会在清理周期后将其从列表中移除。这个超时时间示例中60秒需要根据广播间隔5秒和网络状况来合理设置通常设为广播间隔的3-5倍。5. 深度避坑指南与性能调优在实际项目中使用Qt UDP广播我踩过不少坑也总结了一些优化经验。5.1 常见问题与解决方案问题现象可能原因排查步骤与解决方案发送失败错误QAbstractSocket::NetworkError1. 防火墙阻止了UDP发送。2. 未设置Socket广播选项某些系统需要。3. 绑定了错误的本地地址。1. 检查系统防火墙和杀毒软件设置为程序添加出入站规则。2. 在发送前调用socket.setSocketOption(QUdpSocket::SocketOption, QVariant)尝试设置MulticastTtlOption或BroadcastSocketOption如果Qt版本支持。3. 发送广播通常不需要绑定如果绑定了确保绑定到QHostAddress::AnyIPv4。接收方收不到数据1. 端口未正确绑定。2. 发送和接收端口不一致。3. 接收方绑定了特定IP如127.0.0.1而非QHostAddress::Any。4. 多网卡环境下广播发到了另一个网段。1. 确认接收方bind()成功且无错误。2. 用网络抓包工具如Wireshark在接收方机器上抓包看是否有目标端口为45454的UDP包到达。这是最直接的诊断方法。3. 接收方绑定使用bind(QHostAddress::Any, port)或bind(port)。4. 在发送方打印出计算出的广播地址确认它是接收方所在网段的地址。bind()失败Address already in use同一端口被其他进程占用或之前的Socket未完全关闭。1. 使用QUdpSocket::ReuseAddressHint标志。2. 使用命令行工具如netstat -ano | findstr :端口号on Windows,lsof -i :端口号on Linux/Mac查找并结束占用进程。3. 确保程序退出前正确关闭并销毁Socket对象。数据接收不完整或乱码1. UDP数据报大小超过MTU通常1500字节导致分片丢失。2. 发送和接收端编码不一致。1.严格控制单次发送的数据量。建议将UDP数据报大小控制在1472字节以内1500 MTU - 20 IP头 - 8 UDP头。对于大块数据必须在应用层实现分片、重组和确认机制。2. 统一使用QByteArray传输二进制数据或使用toUtf8()/fromUtf8()处理文本。在高频发送下丢失数据报1. 应用程序处理速度跟不上接收速度导致Socket接收缓冲区溢出。2. 系统或网络瓶颈。1. 增大Socket接收缓冲区socket.setSocketOption(QAbstractSocket::ReceiveBufferSizeSocketOption, 1024*1024);// 设置为1MB。2. 优化readyRead槽函数处理逻辑避免耗时操作尽快将数据从缓冲区取出。3. 考虑在单独的线程中进行网络IO和业务处理。5.2 性能调优与资源管理缓冲区大小如前所述通过setSocketOption调整SendBufferSizeSocketOption和ReceiveBufferSizeSocketOption可以显著提升在高流量下的稳定性。但也不要盲目设置过大以免消耗过多内存。使用QNetworkDatagramQt 5.8对于接收方Qt 5.8引入了QNetworkDatagram类它封装了数据、发送方地址、端口、接收接口等信息使用起来比原始的readDatagram更现代、更安全自动管理内存。// 使用QNetworkDatagram接收 while (udpSocket-hasPendingDatagrams()) { QNetworkDatagram datagram udpSocket-receiveDatagram(); QByteArray data datagram.data(); QHostAddress senderAddr datagram.senderAddress(); // ... 处理数据 }多线程处理如果业务逻辑处理非常耗时务必不要在readyRead的槽函数中同步处理。应该将接收到的数据datagram放入一个线程安全的队列如QQueue然后通过信号通知另一个工作线程去消费队列中的数据。QUdpSocket对象本身最好留在主线程或创建它的线程中因为Qt的网络类通常不是完全线程安全的。错误处理一定要连接QUdpSocket::errorOccurred信号。很多网络错误是异步发生的比如网络电缆被拔出。在这个信号的槽函数中记录错误日志并进行相应的重连或状态恢复是编写健壮网络程序的必备环节。5.3 跨平台与部署注意事项Windows防火墙这是新手最大的拦路虎。在Windows上首次运行程序时系统防火墙会弹出警告必须允许程序通过公用和专用网络否则广播包会被拦截。在编写安装程序时可能需要用脚本或工具自动添加防火墙规则。Linux/macOS权限在Linux上绑定1024以下的端口知名端口需要root权限。我们的示例端口45454是用户端口一般不需要。但如果你需要绑定到53(DNS)、67(DHCP)等端口则需要提权运行。虚拟机和容器网络在VMware、VirtualBox虚拟机或Docker容器中测试时网络模式NAT、桥接、主机模式会极大影响广播的可见性。桥接模式通常最接近真实物理网络广播可以跨宿主机和虚拟机。NAT模式下的虚拟机通常无法收到外部广播也无法向外部发送有效的广播。6. 从广播到组播更优雅的局域网通信当你的设备数量增多或者网络环境复杂时广播的缺点全网段泛滥就会凸显。这时组播Multicast是更优的选择。Qt对组播的支持同样简单。组播发送示例QUdpSocket sender; QHostAddress groupAddress(“239.255.43.21”); // 选择一个组播地址范围224.0.0.0-239.255.255.255 quint16 groupPort 45456; // 设置TTL决定数据包能穿越多少路由器 sender.setSocketOption(QUdpSocket::MulticastTtlOption, 1); QByteArray data “Hello Multicast!”; // 发送到组播地址和端口 sender.writeDatagram(data, groupAddress, groupPort);组播接收示例QUdpSocket receiver; quint16 groupPort 45456; QHostAddress groupAddress(“239.255.43.21”); // 绑定到任意地址和组播端口 if (receiver.bind(QHostAddress::AnyIPv4, groupPort, QUdpSocket::ShareAddress)) { // **关键步骤加入组播组** if (receiver.joinMulticastGroup(groupAddress)) { qDebug() “Joined multicast group” groupAddress.toString(); } else { qDebug() “Join multicast group failed:” receiver.errorString(); } } // 之后通过readyRead信号接收发往该组播地址的数据组播需要网络设备交换机、路由器支持IGMP协议。在现代企业级网络中这通常是启用的但在一些简单的家用交换机或特殊网络环境中可能需要配置。从广播过渡到组播主要是发送地址和加入组播组的区别核心的QUdpSocket使用逻辑是一致的。7. 总结与个人心得UDP广播是一种简单粗暴但极其有效的局域网通信技术。Qt的QUdpSocket类将其封装得足够易用让我们能专注于业务逻辑而不是Socket API的细节。在这个实战项目中我最深刻的体会有两点第一网络编程的本质是状态管理。无论是简单的广播发送还是复杂的设备发现服务你都必须清晰地定义并管理每个环节的状态Socket是否已绑定广播地址计算是否正确接收缓冲区是否溢出服务端是否在线把这些状态用日志打出来用UI显示出来是调试复杂网络问题最有效的手段。我习惯在程序里加一个简单的网络状态看板实时显示发送/接收计数、最后错误、发现的设备列表等一目了然。第二一定要尽早进行集成测试。不要只在一个程序里自己发自己收。一定要把发送端和接收端编译成两个独立的程序甚至放到两台不同的物理机或虚拟机上运行。用Wireshark抓包亲眼看到数据包是如何离开网卡、如何在网络中传播、是否到达目标机器。很多逻辑上的Bug在单机自测时发现不了一到真实网络环境就原形毕露。特别是涉及到多网卡、防火墙、虚拟机网络时尽早的跨机器测试能节省大量的后期调试时间。最后关于选择广播还是组播我的经验法则是如果设备数量少几十台以内网络结构简单单一子网并且对网络流量不敏感广播的零配置优势非常明显。一旦设备规模上去或者需要更精细的控制就应该毫不犹豫地转向组播。它们不是互斥的在你的工具箱里两者都应该熟练掌握。
返回列表