基于RT-Thread与lwIP的STM32F407 TCP客户端实现详解

发布时间:2026/9/19 11:59:11

基于RT-Thread与lwIP的STM32F407 TCP客户端实现详解 前阵子给一个设备做远程状态上报功能需要在STM32F407上通过网口把数据传到服务器。项目要求快、稳、易维护我选择了RT-Thread作为操作系统配合lwIP协议栈来做TCP客户端。这一套组合下来的体验说实话比裸机手搓TCP/IP要舒服太多了而且RT-Thread的sensor框架、消息队列、软件定时器这些组件拿来就能用省了不少移植的时间。这篇文章我把整个实现过程梳理了一遍从硬件选型开始到RT-Thread Studio工程配置、lwIP组件裁剪、TCP客户端代码逐段拆解再到最后的联调排查每一步都写了“为什么”而不仅是“怎么做”。如果你正在用STM32F407做以太网通信或者刚开始接触RT-Thread的socket编程这篇文章应该能帮你少走不少弯路。1. 硬件选型与前置条件F407为什么适合做嵌入式网络节点1.1 以太网硬件构成MAC、PHY与RJ45三者的分工很多人一上来就急着写代码结果发现ping不通折腾半天最后发现是硬件没搞对。在做网络项目之前先把硬件链路理清楚非常重要。STM32F407内部已经集成了以太网MAC层控制器支持10M/100M速率符合IEEE 802.3协议规范。但MAC层不等于完整的以太网物理接口它还需要外接一颗PHY芯片来完成物理层编解码、电平转换、载波侦听等工作。也就是说一个能上网的嵌入式节点硬件上至少包括三个部分MAC控制器在MCU内部负责数据帧的封装、拆解、地址过滤。PHY芯片在MCU外部负责将数字信号转换成差分模拟信号送到网线同时负责链路状态检测Link Up/Down。网络变压器RJ45座负责电气隔离和接口连接。F407的MAC通过MIIMedia Independent Interface或RMIIReduced Media Independent Interface接口与PHY芯片连接。对于大多数应用来说RMII是首选原因很实际它只需要7根信号线比MII的16根少了整整一半多节省IO资源且最高100Mbps的速率完全够用。1.2 开发环境与RT-Thread版本选择整个项目的软件栈我用了RT-Thread Studio作为集成开发环境版本不低于4.x配合STM32F4系列的BSP。RT-Thread Studio的好处是图形化配置lwIP、finSH控制台、Pin驱动这些组件在图形界面里勾一勾就能生成工程避免了手动搭建Makefile和链接脚本的麻烦。我这里使用的是RT-Thread Nano还是标准版答案是标准版。Nano版本虽然精简但不带设备驱动框架和lwIP协议栈做网络开发直接用标准版更省事内存占用虽然大一些但在F407的192KB RAM上完全跑得动。1.3 RMII与MII怎么选接口引脚怎么接回到RMII接口本身它的关键点在于时钟。RMII模式下所有信号都同步于一个50MHz的REF_CLK参考时钟收发数据引脚都是2位RXD[1:0]、TXD[1:0]加上CRS_DV、TX_EN以及MDC/MDIO管理接口构成完整物理链路。很多开发板比如正点原子、野火在F407上用的PHY芯片是LAN8720A或者国产YT8512H这两颗都支持RMII模式。引脚分配上不同板子可能略有差异但从F407的引脚定义来看RMII通常占用以下引脚信号F407引脚说明REF_CLKPA150MHz参考时钟输入CRS_DVPA7载波侦听/数据有效RXD0PC4接收数据bit0RXD1PC5接收数据bit1TX_ENPB11发送使能TXD0PB12发送数据bit0TXD1PB13发送数据bit1MDCPC1管理接口时钟MDIOPA2管理接口数据具体的引脚映射一定要以你手上的板子原理图为准。我调试过程中遇到过几次换板子之后忘了改CubeMX里的引脚导致一直初始化失败的问题这种情况查起来很费劲所以画板子或者选板子的时候就把引脚对应关系确认清楚能省掉后面大量排错时间。2. 底层驱动搭建RMII引脚、PHY复位与50MHz时钟2.1 CubeMX下STM32F407的ETH外设初始化打开STM32CubeMX选择对应的F407芯片在Pinout视图中把ETH外设使能选择RMII接口模式。此时CubeMX会自动分配上面表格里的默认引脚如果你的板子引脚不一致手动在Pinout视图里重新映射即可。配置ETH时有一个容易忽略的选项——PHY Address。LAN8720A的PHY地址默认为0x00YT8512H通常也是0x00但必须结合芯片外围电路的地址配置引脚PHYAD0/PHYAD1等来确认。如果PHY地址配置错误MAC层连PHY的寄存器都读不到驱动初始化必然失败。另一个关键配置是PHY的复位引脚。有的开发板把PHY的NRST接到了MCU的某个GPIO上需要先拉低再拉高完成上电复位时序。如果复位引脚配置不对PHY保持复位状态后续一切操作都白搭。这类问题往往不会报编译错误要靠调试时读PHY寄存器才能发现非常折腾。2.2 PHY芯片选型和地址配置在F407开发板上最常见的PHY方案有两种LAN8720A和YT8512H。它们的引脚定义基本兼容RMII模式但寄存器布局存在差异尤其是PHY的ID寄存器和状态寄存器地址。RT-Thread的lwIP驱动中对LAN8720A的支持比较成熟因为大量开发板默认配的就是这颗。如果用的是YT8512H在RT-Thread Studio的PHY配置界面中可能找不到对应的选项此时需要自己添加PHY驱动文件或者直接在以太网驱动初始化时跳过PHY ID检查。我自己踩过YT8512H的坑RT-Thread的drv_eth.c会通过读取PHY ID来确认芯片型号如果匹配不上就直接返回初始化失败。解决方案有两种一是修改PHY驱动的ID匹配表把YT8512H的ID加进去二是在初始化流程里关掉ID校验逻辑靠自协商结果来判断链路是否正常。第二种方式快但不够严谨正式项目中我建议第一种。2.3 参考时钟的三种来源RMII的50MHz参考时钟是整个以太网物理层工作的心脏。在F407系统里这个时钟有三种来源外部50MHz有源晶振直接从晶振模块输出50MHz到REF_CLK引脚简单可靠推荐使用。PHY芯片从25MHz晶振倍频后输出LAN8720A支持这种模式外部接25MHz无源晶振由PHY内部倍频到50MHz然后从REF_CLK引脚输出给MCU。此时PHY的CLKOUT_EN要配置为输出模式。MCU的MCO引脚输出把F407的PLL时钟配置成50MHz从MCO1引脚输出给PHY。我实际项目中用的是第二种方式。LAN8720A使用25MHz晶振通过内部PLL产生50MHz参考时钟输出给MCU。这种方式的好处是只需要一颗普通晶振成本低、布线简单。但要注意REF_CLK信号质量直接影响以太网通信稳定性如果PCB走线过长且没有包地处理高速信号串扰会导致丢包严重甚至完全无法连接。别问我怎么知道的都是拿示波器一点点查出来的。2.4 生成代码导入RT-Thread工程CubeMX配置完成后生成初始化代码如MX_ETH_Init、HAL_ETH_MspInit等。把这些文件放到RT-Thread Studio工程的board目录下然后在构建脚本中确保ETH的HAL驱动和lwIP组件都被包含进来。RT-Thread标准版的以太网驱动框架提供了lwIP的底层对接层会完成网卡初始化、数据包收发、中断处理的注册。你不需要自己处理ETH中断里去解析IP包这种底层逻辑——lwIP协议栈已经把这些处理好了。这一点不夸张地说是RT-Thread对比裸机开发最大的优势之一。3. RT-Thread Studio中的lwIP组件配置3.1 打开lwIP和ETH驱动在RT-Thread Studio的RT-Thread Settings界面中把lwIP组件勾选上同时确保网卡驱动SAL socket抽象层 lwIP也被激活。RT-Thread的SAL层非常贴心它把不同的网络协议栈lwIP、AT Socket、w5500等统一抽象成标准的lwIP socket接口这样应用代码只需要包含sys/socket.h不用关心底层是什么协议栈。在组件配置中还需要设置网卡的数量。只有一个以太网口的话可以把RT_LWIP_ETH_NUM设为1。另外要看清楚DHCP是否使能。如果你的局域网里有路由器提供自动分配IP那DHCP开着就行如果是设备直连电脑或者工控机建议改成静态IP避免每次都要等DHCP超时才获得地址。3.2 PHY配置的匹配在rtconfig.h中或者Settings界面里找到PHY相关的配置项。RT-Thread通常要求你指定PHY芯片的地址和类型。比如#define PHY_USED_ID PHY_ID_LAN8720A #define PHY_USED_ADDRESS 0x00如果你用的是YT8512H这里就要改成对应的PHY ID并且确认PHY地址和你的硬件一致。这个配置直接影响lwIP初始化时能否正确读取PHY的链路状态。如果读取失败网卡会一直处于DOWN状态调用socket时虽然能创建socket但connect永远超时。3.3 内存池和线程栈的调整lwIP需要内存管理RT-Thread为它分配了两个内存池PBUF池和TCP段内存池。默认配置在内存充足的F407上可以跑但如果你开启了多个socket连接、或者收发大数据块内存池太小就会导致分配失败。我的个人建议是在内核配置里把lwIP的MEM_SIZE调整为4096~8192字节PBUF池大小调到10个以上。同时网络线程的栈空间建议设置为2048字节以上否则在高负载下容易触发栈溢出表现就是系统跑着跑着死在某个中断里查都查不到。这里有一个很重要的细节TCP收发缓冲区分别在RT_LWIP_TCP_SND_BUF和RT_LWIP_TCP_RCV_BUF中定义默认值通常是2~6KB。如果项目要求单次传输大文件这些参数需要一起调大否则send返回的字节数可能小于你请求发送的长度需要循环发送直到全部发完。这个处理逻辑我在代码里也有体现。4. TCP客户端核心代码逐段拆解4.1 一整套可用的客户端代码下面是我在项目里用的TCP客户端示例代码功能是把设备状态数据定时上传到服务器同时接收服务器下发的简单控制指令。代码可以直接放到RT-Thread工程中创建线程运行。#include rtthread.h #include sys/socket.h /* RT-Thread的SAL socket接口头文件 */ #include netdb.h #include netinet/in.h #include arpa/inet.h #include string.h #define SERVER_PORT 8080 #define SERVER_IP 192.168.1.100 #define SEND_DATA_SIZE 128 #define RECV_DATA_SIZE 256 static void tcp_client_thread(void *parameter) { int sockfd -1; int ret 0; int count 0; struct sockaddr_in server_addr; char send_buf[SEND_DATA_SIZE]; char recv_buf[RECV_DATA_SIZE]; rt_kprintf(tcp_client_thread start\n); while (1) { /* 创建TCP socket */ sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { rt_kprintf(socket create error\n); rt_thread_mdelay(2000); continue; } /* 配置服务器地址结构体 */ server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); server_addr.sin_addr.s_addr inet_addr(SERVER_IP); memset((server_addr.sin_zero), 0, sizeof(server_addr.sin_zero)); /* 连接服务器 */ ret connect(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)); if (ret 0) { rt_kprintf(connect server failed, error code: %d\n, ret); closesocket(sockfd); sockfd -1; rt_thread_mdelay(5000); continue; } rt_kprintf(connect server success\n); /* 进入收发数据循环 */ while (1) { /* 发送数据 */ memset(send_buf, 0, sizeof(send_buf)); rt_sprintf(send_buf, device status report: %d, count); ret send(sockfd, send_buf, strlen(send_buf), 0); if (ret 0) { rt_kprintf(send failed, ret: %d\n, ret); break; } /* 接收服务器数据 */ memset(recv_buf, 0, sizeof(recv_buf)); ret recv(sockfd, recv_buf, sizeof(recv_buf) - 1, 0); if (ret 0) { recv_buf[ret] \0; rt_kprintf(recv from server: %s\n, recv_buf); } else if (ret 0) { rt_kprintf(server closed the connection\n); break; } else { rt_kprintf(recv error\n); break; } rt_thread_mdelay(2000); } /* 关闭socket进入重连流程 */ closesocket(sockfd); sockfd -1; rt_thread_mdelay(5000); } }使用MSH命令创建线程运行#include rtthread.h static int tcp_client_start(void) { rt_thread_t tid rt_thread_create(tcp_cli, tcp_client_thread, RT_NULL, 2048, 20, 10); if (tid ! RT_NULL) { rt_thread_startup(tid); return 0; } return -1; } INIT_APP_EXPORT(tcp_client_start);4.2 socket创建与地址结构体socket(AF_INET, SOCK_STREAM, 0)创建了一个TCP套接字AF_INET是指IPv4协议族SOCK_STREAM是面向连接的流式套接字协议自动选择TCP。地址结构体struct sockaddr_in是网络编程里绕不开的东西几个字段的细节要搞清楚sin_family必须赋为AF_INET。sin_port是端口号注意使用htons()把主机字节序转换为网络字节序也就是大端序。sin_addr.s_addr是IP地址用inet_addr()把点分十进制字符串转换成32位整数同样是大端序。写代码时经常有人忘记htons()对端口做字节序转换导致连接的端口不对。在STM32这种小端处理器上如果你直接写server_addr.sin_port 8080网络中实际访问的是端口0x901F对应的十进制数也就是36911自然连不上服务器。这种问题不看抓包基本很难想到。4.3 连接、收发与异常处理connect()是阻塞式的它会等待TCP三次握手完成或者超时。局域网内正常情况毫秒级就能建立连接但如果服务器IP不可达connect可能会卡几十秒才返回错误。因此我建议把connect放到独立线程中执行避免阻塞主逻辑。上述代码中整个网络收发逻辑就运行在独立线程里主线程可以继续做采样、显示之类的任务。收发数据使用send()和recv()它们的使用有几个容易踩的坑send并不保证一次发送全量数据。TCP是流式协议没有消息边界send返回的字节数可能小于你传入的长度所以大数据的发送需要用循环来保证全部发出。recv的缓冲区要留一个字节给结尾。recv(sockfd, recv_buf, sizeof(recv_buf) - 1, 0)确保recv_buf[ret] \0不会越界这是字符串处理的安全习惯。recv返回0代表对端关闭。返回-1说明出错这时应该主动关闭socket进入重连流程。很多初学者把recv放到while(1)里却不检查返回值结果服务器断开后客户端还傻傻地等数据看起来就像程序卡死了。4.4 断线重连的策略实际工业场景中网络不可能永远稳定。服务器重启、网线松动、交换机故障都可能导致TCP连接断开。断线之后最直接的恢复方式是重新走一遍“创建socket - connect”流程。我在代码中设计了完整独立的连接循环外层while负责重新创建socket和connect内层while负责正常收发。内层循环一旦检测到收发异常就break出来关闭socket然后外层循环延迟5秒再重连。这个机制几乎能把各种网络抖动问题兜住。延迟5秒是经过考量的时间太短会让系统在网络不稳定时陷入疯狂重连消耗CPU和网络资源时间太长又会让设备在上报任务中失联过久。针对大多数场景5秒是个不错的折中值。如果你做的是实时性要求极高的上报任务可以把这个时间缩短到2秒但在代码里要增加重连次数的计数逻辑连续失败N次后可能需要进一步加大延迟避免风暴式重连。5. 联调实测与踩坑排查5.1 用网络调试助手验证代码编译下载后在PC端打开网络调试助手创建一个TCP Server监听8080端口IP地址设为192.168.1.100确保和代码里的SERVER_IP一致。设备启动后先通过RT-Thread的finSH控制台查看网卡状态。在串口终端输入ifconfig命令确认网卡获取到了IP地址比如192.168.1.30。如果没有获取到IP先别急着跑TCP回去检查DHCP或者静态IP配置。网络链路正常后设备就会自动connect到服务器在TCP Server的窗口能看到接收到的“device status report: 0/1/2...”报文同时在串口终端能看到服务器回发的数据。如果没通按照下面的排查链路走一遍。以下是我实测中遇到的几类典型问题每一类后面都跟了完整的排查思路5.2 常见问题排查链路问题一ifconfig显示网卡DOWN先确认PHY芯片的复位引脚有没有正常工作用万用表测NRST引脚的电平正常情况应该在上电后从低到高跳变。然后看PHY的供电电压3.3V必须稳定。如果硬件没问题那大概率是PHY ID匹配失败在RT-Thread的PHY驱动中打印读取到的PHY ID和芯片手册对照。问题二能够获取IP但ping不通PC先确认网线是交叉线还是直通线。现代网卡的自动翻转功能一般都能自适应但有些老设备不行。更常见的原因是ARP解析失败在PC上抓包看设备有没有发出ARP请求如果没有检查设备端的IP配置是不是和PC在同一网段网关是否正确。另一个高频原因RMII参考时钟相噪过大或REF_CLK引脚没接对。我遇到过把MCO引脚的50MHz时钟接到PHY后网络时通时断最后查出来是接线柱虚焊导致时钟信号幅度不足。这种问题用示波器看波形一眼就能锁定。问题三connect一直超时大概率是防火墙阻挡了socket连接。Windows防火墙默认会拦截外部设备的入站连接需要在“允许应用通过防火墙”里放行TCP Server对应的端口。另外确认PC端的Server是否真的在监听用netstat -an查看端口状态。排除这些之后用ping测试两台设备在IP层的连通情况。如果ping通但connect失败则问题更多集中在TCP握手阶段抓包看SYN包有没有发出、有没有收到SYN-ACK可以快速定位是发不出去还是回复被过滤。问题四能连接但send返回后服务器收不到数据这种问题往往让人非常困惑因为从应用层看来send已经成功。实际上TCP是可靠协议send成功只代表数据进入了内核发送缓冲区不保证对方已经处理。检查发送缓冲区是否溢出如果发送频率太快数据堆积在缓冲区很久才发出去会让对方觉得你这边卡顿。另外要关注MTU大小。如果链路层MTU设置得过大TCP分段后超过网络设备的最大传输单元会被静默丢弃。RT-Thread里可以手动调低MTU值试试或者在进行大数据传输时禁用TCP的Nagle算法TCP_NODELAY降低小包堆积带来的延迟。5.3 几个值得长期沿用的经验做嵌入式网络编程这几年有几个体会特别深。第一网络通信功能一定要设计成可观测的。在RT-Thread里我习惯把连接状态、收发计数、错误码这些变量通过事件日志或者finsh命令打印出来设备在现场出问题时可以通过串口快速判断是链路断、服务器挂还是代码bug不用拿着调试器到处插。第二不要在主循环里直接做网络阻塞操作。哪怕只是调用一次recv在服务器无响应时也可能卡住几百毫秒到几秒不等直接影响其他实时任务的调度。我上面的代码里把网络逻辑全部丢进一个独立的低优先级线程就是基于这个考虑。第三代码里的超时参数要设计成可以被修改的。比如重连间隔、连接超时时间不要直接写死成常量尽量通过宏定义或者配置项来实现。这个习惯在后期调试和现场调优时非常有用。我曾经一个项目因为重连间隔太短在现场把整个局域网都打满了最后改成一个可配置参数才彻底解决。如果后续想再往前走一步可以考虑把TCP客户端扩展成MQTT客户端在RT-Thread上基于lwIP对接paho-MQTT即可。毕竟TCP只是可靠传输管道业务层面如果设备量大了MQTT的消息订阅发布模式比长连接裸收发的工程管理成本低得多。这块我后面也可以单独写一篇。这次就把TCP客户端的完整实现先留在这里代码我实测过是能直接用的。
延伸阅读

更多相关文章

2026/9/19 11:59:11

OpenClaw 登顶日报,模型通道改到 TaoToken 再跑龙虾助手

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

2026/9/19 11:59:11

直播高并发环境搭建实战:从缓存设计到压测调优

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

2026/9/19 11:54:11

大模型驱动的AI运维Agent:告警诊断与自动修复实战

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

2026/9/19 13:14:15

卡尔曼滤波原理与Python实战:从状态建模到工程调参

简介:本资源是一份面向自动化、控制工程及信号处理方向初学者与进阶学习者的卡尔曼滤波入门教学课件,聚焦状态估计核心原理与工程落地逻辑。课件系统讲解状态估计的统计基础(如无偏性、最小方差准则)、卡尔曼滤波的递推机制&#…

2026/9/19 13:09:14

MSI文件双击没反应?从关联到服务,一文搞定Windows Installer故障排查

1. 双击没反应这件事,到底卡在哪一环MSI 文件双击之后没反应、弹不出安装向导、甚至直接跳出一个“打开方式”的对话框让你选程序,这类问题在 Windows 上出现的频率远比想象中高。我自己经手的机器里,十台有问题的至少有六台是同一个根因。先…

2026/9/18 14:13:01

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/19 0:03:10

验证 OpenSpec 兼容性,Cursor 的 Token 从 TaoToken 出

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

2026/9/19 0:03:10

书桌角落的 Mac mini,OpenClaw 通过 TaoToken 跑任务。

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

2026/9/19 0:03:10

oh-my-hermes:打造跨工具的命令编排与插件化工作流

1. 项目概述与设计初衷1.1 它到底是什么先说结论:oh-my-hermes 是一个面向开发者日常终端操作的效率工具套件,核心定位是“把分散在各类命令行工具里的高频操作,统一收拢成一套插件化、可编排的工作流”。项目灵感来源很明显——oh-my-zsh 重…

2026/9/18 14:13:03

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/18 14:13:02

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/18 14:13:02

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码