yolov8 TensorRT C++ 部署实战:从ONNX到engine完整流程
简介:面向工业X射线检测、安检或工业缺陷检测等需要低延迟推理的视觉系统,这份C++部署工程以YOLOv8与TensorRT为主线,覆盖ONNX模型转换、TensorRT引擎构建、推理代码实现与性能调优完整流程。压缩包共85个文件,核心代码由4个cpp源文件、9个h头文件及1个hpp公共头文件组成;同时包含13个tlog日志、24个ipch预编译缓存、4个obj对象文件等构建中间产物,记录详细编译过程;另有测试图片和sln/vcxproj工程文件,整体约379.2MB,便于在Visual Studio中直接打开研读。资源源自zy_Xray_inspection项目,重点展示多尺度检测与SPP-Block结构如何在TensorRT加速管线中落地,提供从模型权重到Engine的转换脚本、C++推理实现和X射线图像测试样本,结合具体代码可直观理解内存分配、GPU显存拷贝与后处理逻辑,能帮助开发者掌握工程化部署关键环节。已有3356人学习这份资源,对正在研究TensorRT加速YOLOv8或需要相似部署参考的开发者,这套文件具有直接参考与复用价值,适合具备C++和深度学习基础的中高级开发者深入研读;压缩包内还包含result.png等输出示例,便于对照验证运行效果。
1. 为什么 yolov8 部署要落到 TensorRT 和 C++
用 conda 里的 Python 环境跑通 yolov8 训练和推理,只是完成了模型侧的一半工作。真正到了产线、实验设备、边缘盒子这些场景,模型要跑在别人没有 Anaconda、没有 PyTorch、甚至没有 GPU 驱动之外的任何依赖的机器上。yolov8 的 PyTorch 推理链路里,一次 forward 会带上 Python 解释器开销、torch 的 dispatch 开销和动态图机制,单帧延迟很难压进 5ms 以内。换成 TensorRT 做模型优化,再通过 C++ 直接调用 engine 做推理,能绕开 Python 的解释执行,把部署包收敛成一个可执行文件加一个 .engine 模型文件。这是目前 yolov8 落地到工业相机、自动驾驶预控制器、RK3588 这类嵌入式平台最常见的工程路径,也是本篇文章要解决的核心问题。本文适合已经用 yolov8 训练过自己的数据集,但现在卡在“模型怎么从 Python 工程变成 C++ 可调用产物”这一步的开发者。你需要具备基础的 C++ 语法、CMake 使用经验和一点 CUDA 概念,不需要会写 kernel。
2. TensorRT 优化 yolov8 的核心路径与运行时约束
2.1 TensorRT 对 yolov8 做了什么
TensorRT 本质上是 NVIDIA 提供的神经网络推理优化器,它把训练框架导出的模型做层融合、精度校准和内核自动调优,然后生成一个针对具体 GPU 架构优化的推理引擎。yolov8 的检测头包含多个卷积层和输出分支,整体结构不复杂,但 PyTorch 默认生成的网络描述文件存在大量冗余计算单元。
TensorRT 对 yolov8 的优化手段有三个层面。第一是层融合,把 Conv + BN + SiLU 这种固定组合融合成单一算子,减少 kernel 启动次数;第二是精度校准,把 FP32 权重降到 FP16 或 INT8,在基本不掉点的前提下减少显存带宽压力;第三是显存复用,推理过程中的中间特征图不需要反复申请释放显存,engine 构建时已经规划好完整的显存池。
C++ 推理相比 Python 端推理,省掉的不是模型本身的计算量,而是每一层算子调度之间的宿主端开销。Python 每跑一个算子就要经过一次 pybind 转换,C++ 直接调用 TensorRT 的 C API,一次 forward 可能快 20%-40%。
2.2 C++ 部署时 TensorRT 版本与 GPU 架构的匹配
部署第一步不是写代码,而是确认你的 GPU 架构和 TensorRT 版本能对上。GTX 1070 属于 Pascal 架构,计算能力是 6.1;GTX 1660 Ti 是 Turing 架构,计算能力 7.5;RTX 3090/4070 是 Ampere 架构(8.6);RTX 5070 是 Blackwell 架构(12.0)。TensorRT 官方对不同架构的支持范围有明确差异。
| GPU 架构 | 计算能力 | TensorRT 8.x 支持 | TensorRT 10.x 支持 | 常见问题 |
|---|---|---|---|---|
| Pascal | 6.0/6.1 | 支持 | 部分支持 | 10.x 老卡驱动报 unsupported |
| Turing | 7.5 | 支持 | 支持 | 主要支持单位 |
| Ampere | 8.0/8.6/8.9 | 支持 | 支持 | 生产环境最稳妥 |
| Ada Lovelace | 8.9 | 支持 | 支持 | 需要较新驱动 |
| Blackwell | 10.x/12.0 | 不支持 | 支持 | 需 RTX 50 系专用版本 |
| TensorRT 版本 | 配套 CUDA | 配套 cuDNN | 适用场景 |
|---|---|---|---|
| 8.5.3 | CUDA 11.8 | cuDNN 8.6 | 老项目、嵌入式设备 |
| 8.6.1 | CUDA 12.0 | cuDNN 8.9 | yolov8 经典组合 |
| 10.x | CUDA 12.x | cuDNN 9.x | 新卡必须用 |
我一般在部署机上先执行 nvidia-smi 查看驱动版本,再用 nvcc --version 确认 CUDA 工具包版本,然后选择匹配的 TensorRT。注意 nvidia-smi 显示的 CUDA Version 是驱动支持的最高版本,不等于你机器上装了对应版本的 CUDA toolkit,这点最容易看错。
nvidia-smi --query-gpu=name,compute_cap,driver_version --format=csv
nvcc --version
这两个命令决定了你后续装哪个版本的 TensorRT。如果 GPU 是 GTX 1070 且计划用 TensorRT 10.x,需要先确认官方是否还提供对应计算能力 6.1 的预编译库,通常的做法是退回 TensorRT 8.6 + CUDA 11.8 组合。对于 RTX 5070 这类新卡,8.x 跑不了,必须装最新版 TensorRT 10 及以上,并且 PyTorch 侧导出 ONNX 的算子也要同步升级,否则后处理算子会导出失败。
2.3 TensorRT 推理的标准运行时状态机
TensorRT 的 C++ 推理有一个固定的对象生命周期,写这个流程的顺序错了,程序不是崩溃就是显存泄漏。
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(logger);
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(modelData, modelSize);
nvinfer1::IExecutionContext* context = engine->createExecutionContext();
runtime 负责把 engine 文件装载进显存, engine 保存网络结构和权重, context 是每次推理的实际执行环境。同一个 engine 可以创建多个 context 来并发推理,但一个 context 同一时间只能执行一次 enqueueV3 。需要注意的是 deserializeCudaEngine 传入的是序列化后的 engine 字节流,不是 ONNX 文件,也不是权重文件。构建 engine 的时机一般在部署机上预先完成,运行机上只加载。
每次推理时先 context->setTensorAddress 绑定输入输出缓冲区的显存地址,然后调用 context->enqueueV3(stream) 把算子发射到 GPU 流里。从名字上也能看出来, enqueueV3 只是入队,真正的计算要等 cudaStreamSynchronize(stream) 执行完毕后才能读结果。这一点和 Python 端 torch.cuda.synchronize() 对应。
3. 从 yolov8 的 pytorch 权重导出 onnx 再到生成 engine
3.1 用 export.py 导出 ONNX 的参数怎么选
yolov8 官方仓库提供了 torch 到 ONNX 的导出脚本,核心命令如下,一般在训练环境(conda)中执行。使用 Python 环境跑导出,是为了借用 torch 的算子集做一次计算图固化。
yolo export model=yolov8s.pt format=onnx opset=12 half=False dynamic=True simplify=True
这条命令会读取 yolov8s.pt ,导出 yolov8s.onnx 。参数维度: opset=12 控制导出的算子版本,TensorRT 对低版本算子兼容性更好,如果 opset 太高,TensorRT 可能找不到对应实现; half=False 表示先导成 FP32 的 ONNX,不要直接导 FP16,engine 构建阶段的精度选择不完全等于权重精度; dynamic=True 允许 batch size 维度动态变化,TensorRT 侧用优化 profile 约束。
疑 opset 是否必须固定在 12?如果你用的 PyTorch 版本较新,可能默认导出 17 或 18,TensorRT 10.x 也能解析,但 torch 新版本导出的一些张量切片算子还是可能不支持。常见做法是先生成 opset=12 试试,如果导出失败再调高,而旧卡 6.1 平台建议就锁死 12。
3.2 用 trtexec 一键生成 tensorRT 引擎
TensorRT 安装包自带一个命令行工具 trtexec ,不需要写 C++ 构建代码就能验证 ONNX 能否转成 engine,这一步能过滤 80% 的模型层不兼容问题。
/opt/tensorrt/bin/trtexec \
--onnx=yolov8s.onnx \
--saveEngine=yolov8s.engine \
--fp16 \
--minShapes=images:1x3x640x640 \
--optShapes=images:1x3x640x640 \
--maxShapes=images:4x3x640x640 \
--workspace=2048
--fp16 让 engine 使用半精度推理,yolov8 的检测头对精度变化不敏感,可以放心加。 --minShapes/optShapes/maxShapes 是动态输入维度三元组, images 需要和 ONNX 里实际输入节点名一致,可以用 trtexec --onnx=yolov8s.onnx --dumpProfile 先看节点名。 --workspace 是构建时显存上限,单位 MB。构建 engine 是个非常吃显存的过程,一张 8GB 显存的卡开 2GB workspace 足够,开太高可能直接 OOM。
3.3 C++ 侧直接构建 engine 的替代方案
生产环境里不一定有命令行工具,更常见的做法是把 ONNX 解析放进 C++ 程序里,调用 TensorRT 的 onnx parser 完成构建。核心流程是先创建 builder,再用 parser 解析 ONNX,把网络结构填充到 builder 配置里。
nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
const auto explicitBatch = 1U << static_cast<uint32_t>(nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
nvinfer1::INetworkDefinition* network = builder->createNetworkV2(explicitBatch);
nvonnxparser::IParser* parser = nvonnxparser::createParser(*network, logger);
parser->parseFromFile("yolov8s.onnx", 1);
nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30);
config->setFlag(nvinfer1::BuilderFlag::kFP16);
nvinfer1::IHostMemory* serialized = builder->buildSerializedNetwork(*network, *config);
这段代码的核心是 createNetworkV2 里的 kEXPLICIT_BATCH 标志。yolov8 导出 ONNX 时如果带动态维度,必须显式开启这个标志,否则维度会被固定成 1。 buildSerializedNetwork 直接把构建好的 engine 序列化到内存,你可以把它写进文件,或者直接用字节数组部署。构建阶段和推理阶段分离是最佳工程实践,运行设备上永远不要放 ONNX parser,那是徒增体积和风险。
3.4 engine 序列化文件与运行环境的架构绑定
生成 engine 文件后要记住一个铁律:engine 是绑定 GPU 架构的二进制产物。在 RTX 3090 上用 trtexec 生成的 engine,直接拷贝到 GTX 1070 的机器上加载会报 invalid engine ,因为 TensorRT 构建时针对 SM 版本做了指令集优化,生成的 kernel 只对目标架构有效。
| 运行环境变化 | engine 是否可用 | 处理方式 |
|---|---|---|
| 同 GPU,换 TensorRT 版本 | 不可用 | 用对应版本的 trtexec 重新构建 |
| 同 TensorRT,换 GPU 型号 | 不可用 | 将 build 阶段的 numa 信息清除后重建 |
| 从 FP32 构建到 FP16 部署 | 不可用 | 重新配置 builder flag 再序列化 |
| 动态 batch 需要 8 但只构建到 4 | 可加载但不可用 | 重新设置 maxShapes |
你需要构建几个 engine 就准备几套构建产物 CI 脚本,把 engine 生成纳入部署流程,而不只是拷贝文件。
4. C++ 工程里加载 engine 并完成一次完整推理
4.1 最小工程目录与 CMakeLists 的配置方法
C++ 部署 yolov8 的工程结构不需要复杂,一个推理类加一个 main 函数就够了,但目录划分最好一开始就清晰。
yolov8_trt/
├── CMakeLists.txt
├── src/
│ ├── yolov8_trt.hpp
│ ├── yolov8_trt.cpp
│ └── main.cpp
└── models/
├── yolov8s.engine
└── labels.txt
yolov8_trt.hpp 定义推理类对外暴露的接口, main.cpp 只负责读图片、调推理、画框。关键在 CMakeLists 里的依赖查找,TensorRT 安装后头文件不在系统默认搜索路径,必须手动指定。
cmake_minimum_required(VERSION 3.18)
project(yolov8_trt)
find_package(CUDA REQUIRED)
include_directories(/opt/tensorrt/include /usr/local/cuda/include)
link_directories(/opt/tensorrt/lib /usr/local/cuda/lib64)
find_library(TENSORRT_LIB nvinfer)
find_library(ONNX_PARSER_LIB nvonnxparser)
add_executable(run_infer src/main.cpp src/yolov8_trt.cpp)
target_link_libraries(run_infer
${TENSORRT_LIB}
${ONNX_PARSER_LIB}
${CUDA_LIBRARIES}
opencv_core opencv_imgproc opencv_imgcodecs)
find_library 比 find_package 直白,避免 TensorRT 官方 CMake 配置文件没被正确识别时卡住。OpenCV 用于图像读取和解析,注意 imread 出来的是 BGR 顺序,yolov8 训练时是 RGB,转换顺序错了检测结果会错乱。链库的顺序有讲究,nvinfer 依赖于 cudart,所以 CUDA 库写在 TensorRT 后面。
4.2 定义输入输出 Buffer 与内存分配
推理类初始化时要做的第一件事是读取 engine 文件到内存,然后通过 engine->getNbIOTensors() 获取输入输出张量数量。yolov8s 的 ONNX 通常有 1 个输入 images 和 1 个输出 /output0 ,但有些自定义导出会带上 NMS 层,输出可能变成 4 个,注意核对。
void Yolov8TRT::loadEngine(const std::string& enginePath) {
std::ifstream file(enginePath, std::ios::binary);
std::vector<char> data((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
runtime = nvinfer1::createInferRuntime(logger);
engine = runtime->deserializeCudaEngine(data.data(), data.size());
context = engine->createExecutionContext();
for (int i = 0; i < engine->getNbIOTensors(); ++i) {
auto name = engine->getIOTensorName(i);
auto dims = engine->getTensorShape(name);
auto type = engine->getTensorDataType(name);
size_t vol = 1;
for (int d = 0; d < dims.nbDims; ++d) vol *= dims.d[d];
if (engine->getTensorIOMode(name) == nvinfer1::TensorIOMode::kINPUT) {
inputSize = vol * 4;
cudaMalloc(&inputBuf, inputSize);
context->setTensorAddress(name, inputBuf);
} else {
outputSize = vol * 4;
cudaMalloc(&outputBuf, outputSize);
context->setTensorAddress(name, outputBuf);
}
}
}
vol * 4 里的 4 是 FP32 的字节数。输出维度是 1x84x8400, vol 算出来正好是 705600,乘以 4 就是 2.8MB 显存。 setTensorAddress 在 TensorRT 8.5 之后是推荐的绑定方式,替代老 API 里的 setBindingDimensions 和 enqueue 的 index 传参。准备完显存后还需要 cudaStreamCreate 创建推理流,不要用默认流,多流能提升并发场景的利用率。
4.3 推理循环的数据搬运与执行
推理循环的逻辑分为三步:数据拷入 GPU、执行 engine、结果拷回 CPU。yolov8 的输入数据是一个归一化后的 NCHW 张量,从 OpenCV 的 Mat 转换来的时候要注意, cudaMemcpyAsync 只负责搬运,不做通道变换和归一化。
bool Yolov8TRT::infer(cv::Mat& image, std::vector<Detection>& detections) {
cv::Mat letterboxed;
float scale = letterbox(image, letterboxed, 640, 640);
letterboxed.convertTo(letterboxed, CV_32FC3, 1.0f / 255.0f);
std::vector<cv::Mat> channels(3);
cv::split(letterboxed, channels);
std::vector<float> inputData(3 * 640 * 640);
for (int c = 0; c < 3; ++c)
memcpy(inputData.data() + c * 640 * 640,
channels[c].data, 640 * 640 * sizeof(float));
cudaMemcpyAsync(inputBuf, inputData.data(), inputSize,
cudaMemcpyHostToDevice, stream);
context->enqueueV3(stream);
cudaStreamSynchronize(stream);
cudaMemcpyAsync(outputHost.data(), outputBuf, outputSize,
cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream);
float* out = outputHost.data();
decodeOutputs(out, scale, detections);
return true;
}
inputData 的组织顺序是 NCHW,即先将 640x640 的所有 R 通道数据连续存放,再存 G 通道和 B 通道,而不是 OpenCV 默认的 HWC 交错排列。 cv::split 在这里就是做通道分离,分离之后的每个 Mat 的数据区可以直接 memcpy 到对应通道偏移位置。 scale 是 letterbox 变换的比例系数,后处理还原坐标时要用到,别丢了。
5. Yolov8 输出解析与 nms 后处理在 C++ 中的实现
5.1 输出张量的排布与坐标还原
yolov8 的输出张量维度是 [1, 84, 8400],84 是 4 个框坐标加 80 个类别得分,8400 是三个尺度特征图展平后的锚点总数。C++ 里把这个三维数据读出来时必须搞清楚排列方式, /output0 实际的内存布局是先把 84 个通道的全部数据展开,再对应到 8400 个位置。
遍历每个锚点时,先找到它的前 4 个值作为 cx、cy、w、h,然后遍历从 4 到 83 的类别分数找最大值。注意这里的框坐标是模型在 640x640 输入下的绝对像素值,不是归一化数值,所以后处理时要把它们还原到原图尺寸。
for (int i = 0; i < 8400; ++i) {
float* ptr = output + i * 84;
float cx = ptr[0];
float cy = ptr[1];
float w = ptr[2];
float h = ptr[3];
float maxScore = 0;
int classId = -1;
for (int c = 4; c < 84; ++c) {
if (ptr[c] > maxScore) {
maxScore = ptr[c];
classId = c - 4;
}
}
if (maxScore < confThresh) continue;
float x1 = (cx - w * 0.5f - padW) / scale;
float y1 = (cy - h * 0.5f - padH) / scale;
float x2 = (cx + w * 0.5f - padW) / scale;
float y2 = (cy + h * 0.5f - padH) / scale;
detections.push_back({x1, y1, x2, y2, maxScore, classId});
}
output + i * 84 的索引方式对应的是 [8400, 84] 的物理排布,所以写代码时先把它看作二维数组,别被三维张量的形状带偏。 padW 和 padH 是 letterbox 时在两侧补的像素数, scale 是等比缩放系数,这两个值必须和预处理时保持一致,否则框的位置会整体偏移。
5.2 NMS 计算与置信度阈值的选择
C++ 中实现 NMS 不需要引入额外依赖,基于面积交并比的排序过滤就够用。yolov8 在训练时用了 TAL 标签分配,输出的类别分数偏高,置信度阈值可以设置在 0.3-0.5 之间。如果检测框之间存在大量重叠,先按类别分组再分别做 NMS,能同时保留不同类别的重叠目标。
std::vector<Detection> nms(std::vector<Detection>& dets, float iouThresh) {
std::sort(dets.begin(), dets.end(),
[](const Detection& a, const Detection& b) { return a.conf > b.conf; });
std::vector<bool> removed(dets.size(), false);
std::vector<Detection> result;
for (int i = 0; i < dets.size(); ++i) {
if (removed[i]) continue;
result.push_back(dets[i]);
for (int j = i + 1; j < dets.size(); ++j) {
if (removed[j]) continue;
if (dets[i].classId != dets[j].classId) continue;
float inter = intersectionArea(dets[i], dets[j]);
float iou = inter / (area(dets[i]) + area(dets[j]) - inter);
if (iou > iouThresh) removed[j] = true;
}
}
return result;
}
iouThresh 一般设 0.45,目标密集场景可以调到 0.5。 intersectionArea 计算两个矩形左上角和右下角的最大最小值,注意坐标已经在原图上,不存在 letterbox 偏移问题。这段代码是串行版本,CPU 上处理 8400 个候选框大约耗时 0.5ms 到 1ms,对端到端延迟影响可接受。如果目标是跑多路视频流,可以把 NMS 放进多个线程里并行执行,每路各做各的过滤。
5.3 letterbox 与 opencv 预处理的精度对齐
C++ 侧 letterbox 是 yolov8 部署踩坑重灾区。Python 端的 letterbox 会用 cv2.resize 加灰色填充,C++ 这边要实现成完全一致的逻辑,否则模型输入分布变化导致精度剧降。
float letterbox(const cv::Mat& src, cv::Mat& dst, int targetSize) {
float scale = std::min(targetSize * 1.0f / src.cols,
targetSize * 1.0f / src.rows);
int newW = std::round(src.cols * scale);
int newH = std::round(src.rows * scale);
int padW = (targetSize - newW) / 2;
int padH = (targetSize - newH) / 2;
cv::Mat resized;
cv::resize(src, resized, cv::Size(newW, newH), 0, 0, cv::INTER_LINEAR);
cv::copyMakeBorder(resized, dst, padH, padH, padW, padW,
cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114));
return scale;
}
缩放插值算法必须用 INTER_LINEAR ,Python 端导出 ONNX 时的预处理不一定影响推理,但运行时用 INTER_AREA 会带来高频率细节的明显损失。填充值 114 是 yolov8 训练时的默认 padding 值,不要改成 0,否则边缘区域会产生大量虚假激活值。
6. C++ 侧推理精度验证与性能排查技巧
6.1 用 python 推理结果当基准逐帧对比
engine 构建完成且 C++ 推理代码跑通后,第一件事不是测帧率,而是验证检测结果和 Python 端是不是一致的。常见做法是准备十张有代表性的图片,分别用 Python 端 PyTorch 或 TensorRT 推理保存检测框坐标,再用 C++ 部署程序跑同样图片,把结果对比出来。
坐标误差允许值一般设置成 2 个像素以内。超过这个值通常是预处理不一致,优先排查三个位置: cv::split 后的通道顺序、letterbox 的 padding 值是不是 114、输入数据的归一化系数是不是 1/255。若所有图的框都整体往右下偏移,大多是 padW 和 padH 没除以 scale;若只在物体边缘出现明显误检,多半是插值方式选错。
./run_infer --engine=yolov8s.engine --image=test.jpg --output=result.jpg
跑通后还可以在 GPU 上开启 cudaEvent 来测量推理耗时,区分预处理时间、engine 执行时间和 NMS 时间。用 nvidia-smi dmon -d 1 看显存占用,如果每次推理都会先涨后降,说明有显存分配泄漏,通常是部分网络分支的临时张量没有通过 TensorRT 的显存池管理。
6.2 engine 加载失败时的日志分级判断方法
TensorRT 的 ILogger 输出是排错第一依据。默认 logger 只打印错误信息,调试时把 severity 调成 kINFO ,能看到每层算子的形状推导和融合情况。
class Logger : public nvinfer1::ILogger {
void log(Severity severity, const char* msg) noexcept override {
if (severity != Severity::kINTERNAL_ERROR)
std::cout << msg << std::endl;
}
};
报 [E] 2: invalid engine 说明 engine 文件和当前 TensorRT 版本或 GPU 型号不匹配;报 [E] 4: unsafe mode 说明有层走的是 fallback 的插件实现,比如某些动态 Resize 算子,需要检查算子是否显式触发 kFP16 精度下的精度问题。日志出现 [W] Detected invalid timing 通常无害,但出现 [E] Could not find implementation 就得回退到 FP32 构建跑一遍确认是不是精度模式的问题。最后建议在程序入口把 TensorRT 版本打印出来, getInferLibVersion() 返回的是构建时版本,和运行时版本混淆排查起来很痛苦。
整个 C++ 部署链路里最值得花时间调试的是输出解析的维度理解,一旦 84 和 8400 两个数在代码里写反,检测框要么全部越界要么全部为空。建议在 decode 函数入口打印输出张量的前 16 个 float 值,对照 Python 端同一张图的输出做二分定位,能省下大半天的排查时间。
更多推荐

所有评论(0)