ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103 GPS解析实战:NMEA 0183协议、串口处理与定位精度优化

STM32F103 GPS解析实战:NMEA 0183协议、串口处理与定位精度优化 简介这款基于STM32F103与ATGM332D-5N模块的GPS定位例程适合嵌入式初学者和物联网开发者参考目标是解决STM32与GPS模块的串口通信、NMEA协议解析以及定位数据提取等核心问题。压缩包共133个文件以C源码和H头文件为主同时包含Keil工程配置、编译生成的AXF/HEX文件以及bat清理脚本等整体仅1.4MB结构清晰便于快速定位主程序与驱动代码。已有2430人学习下载。例程覆盖RCC时钟初始化、USART波特率匹配、GPGGA/GPRMC语句解析和经纬度提取等关键环节可帮助读者打通从硬件接线到串口调试、再到数据输出的完整链路。同时保留完整Keil工程配置适合直接修改移植。1. 一块 STM32F103 把 GPS 定位数据吃干榨净难点根本不在硬件一块几十元的 STM32F103C8T6接一只 NEO-8M 或 ATGM336H 模块就能稳定输出经纬度、速度、航向和 UTC 时间这套组合在共享定位终端、车载定位器、环境监测小车里到处都是。很多工程师以为 GPS 定位设计的难点在射频电路实际上手后才发现真正让人熬夜的是软件侧串口丢帧、GPRMC 一直是 V 状态、度分格式怎么转成地图能用的十进制度还有 GPS 翻转补丁没打导致跨周后坐标凭空偏出去几百公里。这篇文章从串口接线、NMEA 0183 解析、校验和算法讲到 C/C 工程里的封装与误差处理把 GPS 模块变成 STM32F103 上稳定可用的数据源新手可以照抄代码熟手也能在参数和边界处理里找到值得抠的细节。2. GPS 模块选型与 STM32F103 串口接线先把信号干净地拿进来2.1 先决定用哪颗 GPS 芯片别只看价格下单STM32F103 的例程里GPS 模块选型往往被一句话带过实际上模块差异直接决定串口参数和后期调试方式。目前开源项目里常见的几颗芯片分别是 u-blox NEO-6M/NEO-8M、中科微 ATGM336H 系列以及国产的 UM980 这类 RTK 级产品它们在 STM32F103 场景下的定位完全不同。模块方案常用供电默认波特率输出协议典型场景u-blox NEO-8M3.3V板载 LDO 可接 5V9600NMEA 0183 / UBX低成本手持、车载定位ATGM336H-5N3.3V9600可配NMEA 0183对国产化有要求的项目NEO-M8N3.3V9600NMEA / UBX低功耗电池供电设备RTK 模块如 ZED-F9P3.3V/5V115200UBX / RTCM厘米级定位超出本例程范围选型时除了看价格还要看三个容易被忽略的点第一模块是否内置天线馈电电路这决定了你能否直接接有源天线第二默认波特率是否支持通过命令修改ATGM336H 的波特率在上电后可以用配置语句写死而部分模块需要外接 EEPROM 保存配置第三语句输出频率NEO-8M 默认 1Hz如果你做高速移动载体需要通过 UBX-CFG-RATE 命令提高到 5Hz 或 10Hz但 STM32F103 解析压力也会随之上升。我一般会建议项目上用 ATGM336H 或 NEO-8M 起步理由很简单资料多、底子稳、默认 9600 波特率对 STM32F103 的 USART 压力小解析代码也最容易调通。等整条链路跑顺了再换更高性能模块只需要改波特率和对应的配置命令解析层完全不用动。2.2 供电、电平与无源陶瓷天线三处一不留神就烧板子GPS 模块和 STM32F103 连线看起来只有四根线但这四个细节我见过太多人踩坑。首先是电平匹配STM32F103 的 GPIO 是 5V 容忍但 GPS 模块的串口 TX/RX 通常是 3.3V CMOS 电平直接用一个 5V 单片机的 TX 去驱动 3.3V 的 RX 一般没问题反过来如果模块是 5V 逻辑就必须加电平转换否则 STM32F103 的 PA10USART1_RX可能被拉坏。其次是供电纹波GPS 接收机对电源噪声比想象中敏感模块的 VCC 上最好并联一个 10uF 钽电容和一个 100nF 陶瓷电容如果模块和电机、继电器共用电源还要串磁珠隔离。很多“GPS 收不到星”的故障排查到最后其实是电源纹波太大导致接收灵敏度下降。第三是天线。板载无源陶瓷天线是大多数评估板的默认配置成本低但抗干扰差屏蔽盖下方贴近金属就会失锁。如果项目要外接有源天线需要确保模块的 ANT_BIAS 引脚或者 RF_IN 馈电电路能输出 3.3V/5V 偏置电压。常见的做法是在射频输入端串联一个 10nH 到 47nH 的电感给天线馈电同时在馈电点并联一个 100pF 电容接地这个电路在多数模块的硬件参考手册里有完整图直接抄参考设计即可。注意如果你拿到的是无源天线模块又强行插有源天线天线放大器没供电信号强度不会提升反而可能更差。判断模块是否有源馈电看规格书里是否有 ANT_BIAS 或 VCC_RF 引脚。2.3 用 USART1 加中断把第一句 NMEA 拉回来接线固定后先不急着写解析第一步是把 GPS 的原始输出在串口上打出来。USART1 的 PA9/PA10 通常被用作调试串口这里建议 GPS 挂 USART2调试信息走 USART1互不干扰后面在 PC 上看日志会舒服很多。这里以 STM32F103 标准外设库或 HAL 库为例CubeMX 配置如下USART2 异步模式波特率 96008 数据位、无校验、1 停止位开启 USART2 全局中断。/* USART2 初始化GPS 模块挂这里9600-8-N-1 */ static void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 9600; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; huart2.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart2); }初始化完成后调用一次HAL_UART_Receive_IT(huart2, rx_byte, 1)让串口每收到一个字节就进入一次中断然后在回调函数里把字节转发到调试串口uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { /* 先把原始字节发到调试串口确认 GPS 在输出 */ HAL_UART_Transmit(huart1, rx_byte, 1, 10); /* 重新开启单字节接收保证下一字节还能进中断 */ HAL_UART_Receive_IT(huart2, rx_byte, 1); } }这段代码的逻辑是GPS 模块上电冷启动后一般 30 秒到几分钟内会开始向串口持续发送$GPRMC、$GPGGA等语句单片机每收到一个字节就进中断原样转发到 USART1。参数说明上HAL_UART_Receive_IT的第三个参数填 1 表示只接收一个字节配合回调函数实现逐字节流式处理这是嵌入式串口接收 GPS 数据最常用的做法内存占用小、响应快也方便后续接入解析状态机。在 PC 端用串口助手打开 USART1 对应的 COM 口波特率同为 9600如果能看到以$GPRMC开头、以*加两位十六进制数结尾的行就说明 GPS 数据已经进入单片机。看不到就依次检查模块电源指示灯、TX 是否接到 PA3USART2_RX、串口助手波特率是否被模块的配置覆盖过。这里如果用的是 ST-Link 的虚拟串口设备管理器里出现感叹号通常是驱动没装好属于另一条排错线和 GPS 本身无关。3. NMEA 0183 逐字段解析把 GPRMC 变成结构体3.1 先读懂一帧 GPRMC字段索引要背下来NMEA 0183 是 GPS 模块最通用的输出协议语句以$开头以*加两位十六进制校验和结尾字段用逗号分隔。STM32F103 例程里最常用的是$GPRMC它包含时间、定位状态、经纬度、速度、航向和日期一帧就能拿到定位所需的全部信息。字段索引含义示例值0语句标识GPRMC1UTC 时间 hhmmss.ss082315.0002定位状态A有效V无效A3纬度 ddmm.mmmm度分格式3103.45674北纬 N / 南纬 SN5经度 dddmm.mmmm度分格式12011.78906东经 E / 西经 WE7对地速度节12.348对地航向度270.59UTC 日期 ddmmyy14042510磁偏角003.211磁偏角方向E12定位模式指示A一帧真实的 GPRMC 长这样$GPRMC,082315.000,A,3103.4567,N,12011.7890,E,12.34,270.5,140425,003.2,E,A*5F。解析的核心不是用strstr去整帧查找而是把每个字段按逗号拆开按索引取值。这里要注意的是速度字段单位是节转 km/h 要乘 1.852很多例程漏掉这个换算导致显示速度和手机地图差一倍。定位模式字段比我们想象中更有用。NMEA 4.0 之后字段 12 的A表示自主定位D表示差分定位E表示估算定位如果这个字段是E即使前面字段 2 是A坐标可信度也要打折扣。STM32F103 端的判断逻辑应该把字段 2 和字段 12 同时考虑而不是只看单一状态位。3.2 用状态机代替字符串查找串口回调直接喂字节裸机环境下用HAL_UART_Receive_IT逐字节接收时最忌讳在中断里做strlen、strstr这类耗时操作。正确的做法是把每个字节喂给一个状态机状态机只做字符分类和字节拷贝等一整帧接收完成后再在主循环里做正式解析。typedef enum { GPS_IDLE 0, /* 等待起始符 $ */ GPS_TAG, /* 读取语句名如 GPRMC */ GPS_BODY, /* 读取数据体按逗号分字段 */ GPS_CHKSUM /* 读取 * 后面的两位校验和 */ } GpsParseState; static GpsParseState state GPS_IDLE; static uint8_t tag[6]; static uint8_t tag_len 0; static uint8_t body[96]; static uint8_t body_len 0; static uint8_t calc_xor 0; /* 边收边算的异或值 */ static uint8_t rcv_xor 0; /* 从帧尾读到的异或值 */ static uint8_t chk_cnt 0; void gps_byte_rx(uint8_t c) { switch (state) { case GPS_IDLE: if (c $) { state GPS_TAG; tag_len 0; calc_xor 0; chk_cnt 0; } break; case GPS_TAG: if (c ,) { state GPS_BODY; body_len 0; } else if (tag_len sizeof(tag) - 1) { tag[tag_len] c; calc_xor ^ c; /* $ 之后到 * 之前的所有字符参与异或 */ } break; case GPS_BODY: if (c *) { state GPS_CHKSUM; } else { calc_xor ^ c; if (body_len sizeof(body) - 1) { body[body_len] c; } } break; case GPS_CHKSUM: if (hex_val(c) 0) { rcv_xor (rcv_xor 4) | hex_val(c); if (chk_cnt 2) { if (rcv_xor calc_xor) { gps_dispatch_frame(tag, tag_len, body, body_len); } state GPS_IDLE; } } else { state GPS_IDLE; /* 非法字符扔掉整帧 */ } break; } }这段状态机的逻辑是在GPS_TAG阶段同时完成异或累加在GPS_BODY阶段把数据体拷进缓冲区读到*后进入校验和阶段收满两位十六进制字符后做一次比对一致才交给上层解析。calc_xor是边收边算的不是等收完再遍历这保证了中断回调的时间复杂度是 O(1)不会被长帧拖住。hex_val是一个把 ASCII 字符0-9A-F转成数值的小函数注意 GPS 模块输出的校验和是大写十六进制解析时要兼容小写。注意NMEA 帧结尾是\r\n状态机不需要显式处理这两个字符读到*后两个校验和字符收完就直接复位。如果模块配置了其它语句输出比如$GPGGA这个状态机同样能正确切分只需要在gps_dispatch_frame里按tag区分语句类型即可。3.3 校验和计算算不对就丢掉不要用错误数据NMEA 的校验和算法非常简单把$和*之间所有字符的 ASCII 码做按位异或结果用大写十六进制表示。上面状态机里已经实时算了calc_xor但这里单独再讲一遍算法是因为很多例程会在收完帧后用循环重算在波特率 9600 下其实感觉不到性能差异一旦波特率提到 115200每帧重算一次循环就白白浪费几十微秒。/* 标准 NMEA 校验和对 $ 到 * 之间所有字节做异或 */ static uint8_t nmea_checksum(const uint8_t *buf, uint16_t len) { uint8_t cs 0; for (uint16_t i 0; i len; i) { cs ^ buf[i]; } return cs; }使用方式是把 GPRMC 帧体不含$不含*xx传入得到的结果和帧尾两位十六进制数做比较。参数说明buf指向帧体起始位置len是帧体长度函数返回一个 0x00 到 0xFF 的校验字节。这里有个常见误区校验和只覆盖 ASCII 字符本身不做任何移位或求补操作和 Modbus CRC 完全是两回事不要套用 CRC16 的算法。实践中即使校验和不一致也不建议直接丢弃整帧可以加上一帧错误计数连续出错才触发重新初始化串口这样能避免偶发干扰导致 GPS 数据长时间中断。3.4 定位状态 A 和 V 的差别以及 GGA 什么时候更合适GPRMC 字段 2 的A表示当前定位有效V表示接收机已经输出数据但定位不可用。刚上电的 GPS 模块在搜到足够卫星之前会持续输出大量V状态帧这时经纬度字段可能是上一次定位的缓存值也可能是零填充直接使用会酿成大错。STM32F103 端必须在解析后检查状态位只有A状态的数据才允许写入定位结构体。/* 只接受有效定位V 状态直接跳过 */ if (body[2] A) { gps_data.valid 1; gps_data.latitude parse_latitude(body 3); gps_data.longitude parse_longitude(body 5); } else { gps_data.valid 0; }$GPGGA则多提供海拔高度、卫星数和 HDOP 水平精度因子如果项目需要显示海拔或评估定位质量建议同时解析 GGA。两者在 STM32F103 例程里可以共用同一个状态机tag为GPRMC时更新位置和速度tag为GPGGA时更新高度和卫星数互补使用。对于飞行器或坡度较大的车载场景只用 RMC 会拿不到高度信息这时候 GGA 的字段 9椭球高就派上用场了。4. 度分转换、UTC 时区换算与 C 封装落地4.1 度分格式转十进制度差一位小数点就偏出去一公里多NMEA 输出的纬度是ddmm.mmmm经度是dddmm.mmmm这是六十进制的度分格式而地图 API 和高德、百度坐标系需要的是十进制度。转换公式是十进制度 度 分 / 60以纬度3103.4567为例31 是度03.4567是分转换结果是31 3.4567 / 60 31.0576117。注意这里的分是带小数的不能先取整再除否则会引入几百米的误差。在 STM32F103 上用 C 实现这个转换最简单的写法是直接对浮点数做整数和小数拆分/* 把 NMEA 度分浮点数转换成十进制度 */ float nmea_to_decimal(float ddmm_mmmm) { int deg (int)(ddmm_mmmm / 100.0f); /* 取度3103.4567 / 100 31 */ float min ddmm_mmmm - deg * 100.0f; /* 取分3103.4567 - 3100 */ return deg min / 60.0f; }参数说明ddmm_mmmm是从 NMEA 帧里用atof转出来的浮点数函数先把整数部分除以 100 取出度再用原值减掉整百度得到分最后做一次除法。南纬和西经需要在返回后取负数代码里一般留到上层处理因为 NMEA 帧里N/S/E/W是独立字段转换函数本身不应该关心象限。浮点精度方面STM32F103 的硬件 FPU 是单精度十进制度保留 6 位小数已经对应约 0.1 米的精度例程够用但如果后续要做高精度定位建议改成 double 或直接以整数形式输出。4.2 UTC 时间到北京时间跨日处理是例程里最容易漏的GPS 模块输出的时间和日期都是 UTC和北京时间差 8 个小时。STM32F103 例程里最常见的做法是在解析出时、分、秒后各加 8 小时但这样会算错跨日场景比如 UTC 时间23:30:00加 8 小时后应该是第二天早上07:30:00直接把小时加到 31 就显然不对了。UTC 时间北京时间日期变化00:30:0008:30:00同一天16:00:00次日 00:00:00日期加 123:30:00次日 07:30:00日期加 1正确处理顺序是先转换时间再判断日期是否进位。我一般建议先做小时加 8如果结果大于等于 24 就减 24 并让日期加 1但日期加 1 之后还要处理月末和年末这就复杂了。更稳妥的做法是引入一个极简的日期补算函数或者把日期转成 UNIX 时间戳加 8 小时后再转回年月日。前者代码量小后者逻辑最不容易出错但需要额外的日期运算库。例程阶段推荐第一种配合一个days_in_month查表处理月份天数即可注意 2 月要考虑闰年。typedef struct { uint16_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute; uint8_t second; } gps_datetime_t; /* UTC 转北京时间日期如果在加 8 小时后跨日自动进位 */ void utc_to_beijing(gps_datetime_t *dt) { dt-hour 8; if (dt-hour 24) { dt-hour - 24; dt-day 1; if (dt-day days_in_month(dt-year, dt-month)) { dt-day 1; dt-month 1; if (dt-month 12) { dt-month 1; dt-year 1; } } } }days_in_month函数建议用静态查表实现20 年内闰年规则固定不需要写完整的万年历算法。这段代码的边界情况是月末跨日和年末跨日逻辑上只要先处理小时进位再处理天数溢出顺序不能反否则月末判断会用到错误的月份值。时区换算做完后时间字段就可以直接用于显示和日志记录但注意 GPS 模块本身也有本地时间设置能力通过 NMEA 命令可以改时区那样例程里就不需要再做换算代价是模块配置掉电丢失所以多数例程选择在单片机端换算。4.3 用 C 把解析器封装成组件移植到其它 MCU 也更方便标题里提到 C/C实际项目中我倾向于用 C 写底层解析用 C 做一层薄封装这样 STM32F103 上可以用 C 编译也可以脱离 HAL 库移植到其它平台。C 封装的核心是GpsParser类内部维护状态机、解析结果和时间换算逻辑对外只暴露push()和一组只读的 getter。class GpsParser { public: GpsParser() : valid_(false), lat_(0.0f), lon_(0.0f) {} /* 串口中断里直接调用逐字节喂入 */ void push(uint8_t byte) { gps_byte_rx(byte); } /* 坐标是否有效对应 GPRMC 的 A/V 状态 */ bool hasFix() const { return valid_; } float latitude() const { return lat_; } float longitude() const { return lon_; } float speed() const { return speed_; } /* 单位km/h */ float course() const { return course_; } /* 单位度 */ private: bool valid_; float lat_; float lon_; float speed_; float course_; };封装的关键在两点一是把状态机相关变量从全局静态改成成员变量这样同一个解析器可以同时处理多个串口或模块实例化两个GpsParser对象分别对应两个 GPS 模块互不干扰二是把内部 buffer 的大小做成模板参数比如template lt;size_t BODY_MAXgt;内存占用可控在 STM32F103 这种资源紧张的 MCU 上尤其重要。C 封装后上层的定位逻辑只需要判断hasFix()再取值代码可读性明显提升也方便写单元测试——你可以在 PC 上直接用真实 NMEA 日志喂给GpsParser跑测试不用烧录一次看一次串口。编译时要注意 STM32F103 的 C 运行时默认不启用new/delete封装类最好完全避开动态内存分配所有 buffer 都在对象内部静态分配。使用arm-none-eabi-g时需要在 Makefile 里把启动文件换成带 C 初始化支持的版本同时启用-fno-exceptions -fno-rtti来减小代码体积否则默认异常和 RTTI 支持会吃掉不少 Flash。4.4 把坐标送给显示设备、树莓派或 K210 前的最后一道工序解析出来的十进制度数不能直接拿去画地图还有两类问题要处理坐标系和输出格式。GPS 模块输出的是 WGS-84 坐标系高德地图用的是 GCJ-02百度地图用 BD-09如果直接拿 WGS-84 坐标在高德地图上打点会出现几百米的偏移。STM32F103 的资源不适合跑完整坐标转换算法常见做法是只在单片机端做简单的 WGS-84 转 GCJ-02网上有成熟公式大约几十行代码但对北纬 55 度以上的高纬度地区精度会下降工程上通常判断若项目只在中国大陆使用就做转换否则保持原生坐标上传服务器。输出方式上最直观的是通过 USART1 以格式化字符串发送/* 格式化输出坐标和时间方便在串口助手里观察 */ char line[128]; snprintf(line, sizeof(line), %.6f,%.6f,%.1f,%.1f,%02d:%02d:%02d\r\n, gps_data.latitude, gps_data.longitude, gps_data.speed, gps_data.course, gps_data.hour, gps_data.minute, gps_data.second); HAL_UART_Transmit(huart1, (uint8_t*)line, strlen(line), 100);这套输出的核心是 5 维数据纬度、经度、速度、航向和时间树莓派 3B 这类 Linux 板卡只要用串口或 USB 转接读取这个格式即可K210 这类视觉模组也可以通过 UART 拿到坐标后做地理围栏逻辑。车载导航屏和导航终端通常直接消费 NMEA 原句串口配置工具能改的往往只是端口、波特率和语句过滤规则所以保留原始 NMEA 输出通道和格式化输出通道并存是比较稳妥的架构。5. 误差修正、GPS 翻转补丁与静态验证技巧5.1 GPS 误差不是白噪音多径和 PDOP 才是精度大头GPS 定位误差主要来自卫星钟差、电离层延迟、对流层延迟、多径效应和接收机噪声其中多径是城市峡谷场景里最让人头疼的。卫星信号打到玻璃幕墙上反射后再进入天线和直射信号叠加伪距测量就会出现几米到几十米的偏差。STM32F103 端能做的主要是软件滤波不要指望单片机级代码能消除多径但可以通过两个手段降低影响误差来源典型量级STM32F103 端处理方式多径效应5-30 米丢弃低仰角卫星或对坐标做滑动平均电离层延迟2-10 米依赖模块内部算法例程无法干预PDOP 过大3 倍以上几何误差读取 GGA 的 PDOP 字段超过阈值标记为低质量接收机钟差纳秒级模块已处理输出时间即校正后项目里可以每秒输出一次 GGA 中的 PDOP 值PDOP 大于 6 时直接视为定位不可信。对于静态放置的定位设备坐标会在真实位置附近无规则漂移直径约 3 到 5 米这时候做滑动平均能有效平滑但如果设备在移动滑动平均反而会引入滞后所以滤波窗口必须根据运动状态动态切换——有速度时缩短窗口静止时拉长窗口。5.2 GPS 翻转补丁严格说不是 bug是周计数溢出GPS 系统使用 10 bit 存储周计数每 1024 周翻转一次最近一次翻转发生在 2019 年 4 月 6 日。翻转本身是系统设计如此但如果接收机固件没有做补丁处理周计数归零后解算出的日期会倒退 20 年位置可能偏移数百公里。STM32F103 例程里如果直接使用模块输出的年月日字段这个问题不会暴露因为模块内部已经做了补丁但如果通过 UBX 协议读取 raw 的周计数或者使用老旧固件的模块就需要自己在应用层判断。/* 简单翻转判断当前周数远小于上一帧时补一个 1024 周期 */ if (week last_week last_week - week 100) { week 1024; }这段代码用在读取原始周数的场景逻辑是如果新周数和上一帧相比突然减小超过 100就判定发生翻转并自动加上 1024。参数阈值 100 是为了避免正常的数据抖动误判GPS 周数是单调递增的一帧内不可能倒退 100 周。补丁处理完后推荐同步做一次日期校验生成年月日之后再和系统 RTC 对比如果差出十几二十年说明补丁没有生效此时应直接丢弃坐标并输出告警而不是把错误日期写入日志。5.3 静态验证手机地图对坐标比看串口日志直观得多最后一招是验证整个 STM32F103 定位设计的正确性。拿着设备到户外开阔地固定不动等 GPS 状态变为 A 后用手机地图在同一位置打点对比设备输出的坐标和手机显示坐标。两者画在同一地图上差 3 到 5 米是正常水平如果是 WGS-84 直接叠在 GCJ-02 地图上差几百米则说明坐标转换那层没做和模块性能无关。验证时还要记录冷启动时间从上电到第一条 A 状态数据的耗时正常开阔地应该在 30 到 90 秒之间超过 3 分钟就要回头查天线馈电和电源纹波。用两张时间戳日志叠加对比能看出模块是否有周期性丢帧如果每 60 秒固定丢 5 秒数据多半是板上有射频干扰源比如排针上的 SPI 时钟线在特定频率上压制了 GPS 频段——这种问题换模块是没用的改布局或加屏蔽才是正解。本文还有配套的精品资源点击获取
返回列表