RTMP协议

xiaoxiao2021-02-27  118

RTMP是Real Time Messaging Protocol(实时消息传输协议)的首字母缩写。该协议基于TCP,是一个协议族,包括RTMP基本协议及RTMPT/RTMPS/RTMPE等多种变种。RTMP是一种设计用来进行实时数据通信的网络协议,主要用来在Flash/AIR平台和支持RTMP协议的流媒体/交互服务器之间进行音视频和数据通信。

RTMP协议是一个互联网五层体系结构中应用层的协议。RTMP协议中基本的数据单元称为消息(Message)。当RTMP协议在互联网中传输数据的时候,消息会被拆分成更小的单元,称为块(Chunk)。

一.定义

Payload(载荷):包含于一个数据包中的数据,例如音频采样或者视频压缩数据。 Packet(数据包):一个数据包由固定头和载荷数据构成。一些底层协议可能会要求对数据包进行封装。 Port(端口):TCP/IP使用小的正整数对端口进行标识。OSI传输层使用的运输选择器 (TSEL) 相当于端口。 Transport address(传输地址):用以识别传输层端点的网络地址和端口的组合,例如一个IP地址和一个TCP端口。 Message stream(消息流):通信中消息流通的一个逻辑通道。 Message stream ID(消息流ID):每个消息有一个关联的ID,使用ID可以识别出该消息属于哪个消息流。 Chunk(块):消息的一段。消息在网络发送之前被拆分成很多小的部分。块按照时间戳的顺序进行端到端的传输。 Chunk stream(块流):通信中允许块流向一个特定方向的逻辑通道。块流可以从客户端流向服务器,也可以从服务器流向客户端。 Chunk stream ID(块流 ID):每个块有一个关联的ID,使用ID可以识别出该块属于哪个块流。 Multiplexing(合成):将独立的音频/视频数据合成为一个连续的音频/视频流,这样就可以同时发送视频和音频了。 DeMultiplexing(分解):Multiplexing 的逆向处理,将交叉的音频和视频数据还原成原始音频和视频数据的格式。 Remote Procedure Call(RPC 远程方法调用):允许客户端或服务器调用对端的一个子程序或者程序的请求。 Metadata(元数据):关于数据的描述。比如电影的 metadata 包括电影标题、持续时间、创建时间等等。 Application Instance (应用实例):应用实例运行于服务器上,客户端可连接这个实例并发送连接请求,连接服务器。 Action Message Format (AMF,操作消息格式):AMF是Adobe独家开发出来的通信协议,它采用二进制压缩,序列化、反序列化、传输数据,从而为Flash 播放器与Flash Remoting网关通信提供了一种轻量级的、高效能的通信方式。如下图所示。

AMF的初衷只是为了支持Flash ActionScript的数据类型,目前有两个版本:AMF0和AMF3。AMF从Flash MX时代的AMF0发展到现在的AMF3。AMF3用作Flash Playe 9的ActionScript 3.0的默认序列化格式,而AMF0则用作旧版的ActionScript 1.0和2.0的序列化格式。在网络传输数据方面,AMF3比AMF0更有效率。AMF3能将int和uint对象作为整数(integer)传输,并且能序列化 ActionScript 3.0才支持的数据类型, 比如ByteArray,XML和Iexternalizable。

二.握手

握手以客户端发送C0和C1块开始。 客户端必须等待接收到S1才能发送C2。 客户端必须等待接收到S2才能发送任何其他数据。 服务器端必须等待接收到C0才能发送S0和S1,也可以等待接收到C1再发送S0和S1。服务器端必须等待接收到C1才能发送S2。服务器端必须等待接收到C2才能发送任何其他数据。

1.C0和S0格式

C0和S0都是八位,即一个字节,如下所示:

Version(8bits):在C0中,它表示客户端的RTMP版本;在S0中,它表示服务器端的RTMP版本。RTMP规范目前将它定义为3。0—2用于早期的产品,已经被废弃。4—31保留,用于RTMP未来版本。32—255禁止使用。

2.C1和S1格式

C1和S1都是1536个字节,如下所示:

time(4字节):包含时间戳,该时间戳应该被用做本终端发送的块的起点。该字段可以为0或者其他任意值。

zero(4字节):该字段必须为0。

random data(1528字节):该字段可以包含任意值。终端需要区分出是它发起的握手还是对端发起的握手,所以这该字段应该发送一些足够随机的数。

3.C2和S2格式

C2和S2也都是1536个字节,几乎是C1和S1的副本,如下所示:

time(4字节):包含时间戳,必须与C1或S1中的时间戳相同。

time2(4字节):包含时间戳,必须与前一个C1或S1中的时间戳相同。

random echo(1528字节):该字段必须与S1或者S2中的随机数相同。

4.握手示意图

三.块

握手之后,连接开始对一个或多个块流进行合并。每个块都有一个唯一ID对其进行关联,这个ID叫做chunk stream ID(块流ID)。这些块通过网络进行传输,在发送端,每个块必须被完全发送才可以发送下一块。在接收端,这些块根据块流ID被组装成消息。

每个块都是由块头和块数据体组成,而块头自身也是由三部分组成,块格式如下所示:

Basic Header(基本头,1—3字节):该字段编码了块流ID和块类型。块类型决定了Message Header(消息头)的编码格式。该字段长度完全取决于块流ID,因为块流ID是一个可变长度的字段。

Message Header(消息头,0、3、7或11字节):该字段编码了消息的相关信息,标识了块数据所属的消息。该字段的长度由块类型决定。

Extended Timestamp(扩展时间戳,0或4字节):该字段只在特定情况下出现。

Chunk Data(块数据,可变大小):块的载荷部分,取决于配置的最大块尺寸,一般为128字节。

1.Basic Header

块基本头对块类型(用fmt 字段表示,参见下图) 和块流ID(chunk stream ID)进行编码。fmt字段占2bits,取值范围时0—3。RTMP协议最多支持65597个流,流的ID范围是3—65599。ID值0、1和2被保留,0表示两字节形式,1表示三字节形式,2的块流ID被保留,用于下层协议控制消息和命令。

☆一字节形式

ID取值范围3—63,0、1和2用于标识多字节形式。

☆两字节形式

ID取值范围64—319,即第二个字节+64。

☆三字节形式

ID取值范围64—68899,即第三个字节*256+第二个字节+64。

2.Message Header

有四种类型的块消息头,由块基本头中的“fmt”字段决定。

☆类型0

由11个字节组成,必须用于块流的起始块或者流时间戳重置的时候。

timestamp(3字节):消息的绝对时间戳,如果大于等于16777215(0xFFFFFF),该字段仍为16777215,此时Extend Timestamp(扩展时间戳)字段存在,用于对溢出值进行扩展。否则,该字段标识整个时间戳,不需要扩展。

message length(3字节):通常与块载荷的长度不同,块载荷长度通常表示块的最大长度128字节(除了最后一个块)和最后一个块的剩余空间。

message type id(1字节):消息类型。

message stream id(4字节):该字段用小端模式保存。

☆类型1

由7个字节组成,不包括message stream ID(消息流ID),此时块与之前的块取相同的消息流ID。可变长度消息的流(例如,一些视频格式)应该在第一块之后使用这一格式表示之后的每个新块。

timestamp delta(3字节):前一个块时间戳与当前块时间戳的差值,即相对时间戳,如果大于等于16777215(0xFFFFFF),该字段仍为16777215,此时Extend Timestamp(扩展时间戳)字段存在,用于对溢出值进行扩展。否则,该字段标识整个差值,不需要扩展。

message length(3字节):通常与块载荷的长度不同,块载荷长度通常表示块的最大长度(除了最后一个块)和最后一个块的剩余空间。该长度是指为块载荷AMF编码后的长度。

message type id(1字节):消息类型。

☆类型2

由3个字节组成,既不包括message stream ID(消息流ID),也不包括message length(消息长度),此时块与之前的块取相同的消息流ID和消息长度。固定长度消息的流(例如,一些音频格式)应该在第一块之后使用这一格式表示之后的每个新块。

timestamp delta(3字节):前一个块时间戳与当前块时间戳的差值,如果大于等于16777215(0xFFFFFF),该字段仍为16777215,此时Extend Timestamp(扩展时间戳)字段存在,用于对溢出值进行扩展。否则,该字段标识整个差值,不需要扩展。

☆类型3

没有消息头,从之前具有相同块流ID的块中取相应的值。当一条消息被分割成多个块时,所有的块(除了第一个块)应该使用这种类型。

3.Extended timestamp(3字节)

只有当块消息头中的普通时间戳设置为0x00ffffff时,本字段才被传送。如果普通时间戳的值小于0x00ffffff,那么本字段一定不能出现。如果普通时间戳字段不出现本字段也一定不能出现。

4.例子

☆不分割消息

从上面两个表中可以看出,从消息3开始,数据处理得到优化,此时Chunk除了载荷以外,只多了一个块基本头。

☆分割消息

当消息的载荷长度超过128字节时,需要将消息分割为若干个块进行传输。

从上面两个例子可以看出,块类型3有两种用法。一个是指定一个新消息的消息头可以派生自已经存在的状态数据(例一),另一个是指定消息的连续性(例二)。

四.消息

消息是RTMP协议中基本的数据单元。不同种类的消息包含不同的Message Type ID,代表不同的功能。RTMP协议中一共规定了十多种消息类型,分别发挥着不同的作用。例如,Message Type ID在1-7的消息用于协议控制,这些消息一般是RTMP协议自身管理要使用的消息,用户一般情况下无需操作其中的数据。Message Type ID为8,9的消息分别用于传输音频和视频数据。Message Type ID为15-20的消息用于发送AMF编码的命令,负责用户与服务器之间的交互,比如播放,暂停等等。

1.消息头

消息头(Message Header)有四部分组成:标志消息类型的Message Type ID,标志载荷长度的Payload Length,标识时间戳的Timestamp,标识消息所属媒体流的Stream ID。消息的格式如下所示。

2.载荷

载荷是消息包含的实际数据,它们可能是音频采样数据或者是视频压缩数据。

由于端与端之间实际传输的是块,所以只需要将载荷加上块头封装成块。实际应用中,无扩展时间戳,一字节形式的块基本头就能满足要求,整个块头满足以下四种长度:

fmt=0:Basic Head+Message Head=1+11=12

fmt=1:Basic Head+Message Head=1+7=8

fmt=2:Basic Head+Message Head=1+3=4

fmt=3:Basic Head+Message Head=1+0=1

需要注意的是,当载荷为H.264数据时,要使用AMF3进行编码(即序列化),关于AMF3可以参考:AMF3中文版

五.打包H.264

如果整个打包过程都自己弄,是非常繁琐的,还好网上有大神开源了RTMP库,这里使用librtmp进行H.264数据的打包推送。

librtmp的编译可以参考:linux 编译安装TRMPdump(libRTMP)

使用librtmp时,解析RTMP地址、握手、建立流媒体链接和AMF编码这块我们都不需要关心,但是数据是如何打包并通过int RTMP_SendPacket(RTMP *r, RTMPPacket *packet, int queue) 函数推送的还是得学习一下。

RTMPPacket类型的结构体定义如下,一个RTMPPacket对应RTMP协议规范里面的一个块(Chunk)。

[cpp]  view plain  copy   typedef struct RTMPPacket       {         uint8_t m_headerType;//块消息头的类型(4种)         uint8_t m_packetType;//消息类型ID(1-7协议控制;8,9音视频;10以后为AMF编码消息)         uint8_t m_hasAbsTimestamp;  //时间戳是绝对值还是相对值         int m_nChannel;         //块流ID         uint32_t m_nTimeStamp;  //时间戳       int32_t m_nInfoField2;  //last 4 bytes in a long header,消息流ID          uint32_t m_nBodySize;   //消息载荷大小         uint32_t m_nBytesRead;  //暂时没用到       RTMPChunk *m_chunk;     //<span style="font-family: Arial, Helvetica, sans-serif;">暂时没用到</span>       char *m_body;           //消息载荷,可分割为多个块载荷     } RTMPPacket;    一些定义 [cpp]  view plain  copy   #define RTMP_DEFAULT_CHUNKSIZE  128//默认块大小      #define RTMP_BUFFER_CACHE_SIZE (16*1024)//开辟16K字节空间      #define RTMP_PACKET_TYPE_AUDIO 0x08//音频的消息类型   #define RTMP_PACKET_TYPE_VIDEO 0x09//视频的消息类型      #define RTMP_MAX_HEADER_SIZE 18//块基本头+块消息头+扩展时间戳=3+11+4=18      #define RTMP_PACKET_SIZE_LARGE    0//块消息头类型0   #define RTMP_PACKET_SIZE_MEDIUM   1//块消息头类型1   #define RTMP_PACKET_SIZE_SMALL    2//块消息头类型2   #define RTMP_PACKET_SIZE_MINIMUM  3//块消息头类型3  

RTMP_SendPacket函数

[cpp]  view plain  copy   //queue:TRUE为放进发送队列,FALSE是不放进发送队列,直接发送   int RTMP_SendPacket(RTMP *r, RTMPPacket *packet, int queue)     {       const RTMPPacket *prevPacket = r->m_vecChannelsOut[packet->m_nChannel];       uint32_t last = 0;//上一个块的时间戳     int nSize;//消息载荷大小,可分割为多个块载荷大小     int hSize;//块头大小     int cSize;//块基本头大小增量     char *header;//指向块头起始位置       char *hptr;     char *hend;//指向块头结束位置      char hbuf[RTMP_MAX_HEADER_SIZE];      char c;       uint32_t t;//相对时间戳       char *buffer;//指向消息载荷     char *tbuf = NULL;     char *toff = NULL;       int nChunkSize;//块载荷大小      int tlen;       //不是完整块消息头(即不是11字节的块消息头)       if (prevPacket && packet->m_headerType != RTMP_PACKET_SIZE_LARGE)       {             //前一个块和这个块对比         //原理参考 例子—不分割消息         if (prevPacket->m_nBodySize == packet->m_nBodySize           && prevPacket->m_packetType == packet->m_packetType           && packet->m_headerType == RTMP_PACKET_SIZE_MEDIUM)         packet->m_headerType = RTMP_PACKET_SIZE_SMALL;           //原理参考 例子—分割消息         if (prevPacket->m_nTimeStamp == packet->m_nTimeStamp           && packet->m_headerType == RTMP_PACKET_SIZE_SMALL)         packet->m_headerType = RTMP_PACKET_SIZE_MINIMUM;           //上一个块的时间戳         last = prevPacket->m_nTimeStamp;       }       //非法     if (packet->m_headerType > 3)     {           RTMP_Log(RTMP_LOGERROR, "sanity failed!! trying to send header of type: 0xx.",           (unsigned char)packet->m_headerType);           return FALSE;       }       //nSize暂时设置为块头大小;packetSize[] = { 12, 8, 4, 1 }       nSize = packetSize[packet->m_headerType];      //块头大小初始化     hSize = nSize;     cSize = 0;       //相对时间戳,当块时间戳与上一个块时间戳的差值     t = packet->m_nTimeStamp - last;            if (packet->m_body)       {               //m_body是指向载荷数据首地址的指针,“-”号用于指针前移          //header:块头起始位置          header = packet->m_body - nSize;           //hend:块头结束位置         hend = packet->m_body;       }       else       {           header = hbuf + 6;           hend = hbuf + sizeof(hbuf);       }       //当块流ID大于319时       if (packet->m_nChannel > 319)         //块基本头是3个字节         cSize = 2;       //当块流ID大于63时       else if (packet->m_nChannel > 63)         //块基本头是2个字节         cSize = 1;       if (cSize)       {           //header指针指块头起始位置,“-”号用于指针前移          header -= cSize;           //当cSize不为0时,块头需要进行扩展,默认的块基本头为1字节,但是也可能是2字节或3字节         hSize += cSize;       }       //如果块消息头存在,且相对时间戳大于0xffffff,此时需要使用ExtendTimeStamp       if (nSize > 1 && t >= 0xffffff)       {           header -= 4;           hSize += 4;       }            hptr = header;       //把块基本头的fmt类型左移6位。      c = packet->m_headerType << 6;       switch (cSize)       {         //把块基本头的低6位设置成块流ID       case 0:           c |= packet->m_nChannel;           break;         //同理,但低6位设置成000000         case 1:           break;         //同理,但低6位设置成000001         case 2:           c |= 1;           break;       }       //可以拆分成两句*hptr=c;hptr++,此时hptr指向第2个字节       *hptr++ = c;       //cSize>0,即块基本头大于1字节       if (cSize)       {         //将要放到第2字节的内容tmp           int tmp = packet->m_nChannel - 64;         //获取低位存储于第2字节           *hptr++ = tmp & 0xff;         //块基本头是最大的3字节时           if (cSize == 2)         //获取高位存储于第三个字节(注意:排序使用大端序列,和主机相反)         *hptr++ = tmp >> 8;       }       //块消息头一共有4种,包含的字段数不同,nSize>1,块消息头存在。       if (nSize > 1)       {           //块消息头的最开始三个字节为时间戳,返回值hptr=hptr+3         hptr = AMF_EncodeInt24(hptr, hend, t > 0xffffff ? 0xffffff : t);       }       //如果块消息头包括MessageLength+MessageTypeID(4字节)       if (nSize > 4)       {           //消息长度,为消息载荷AMF编码后的长度          hptr = AMF_EncodeInt24(hptr, hend, packet->m_nBodySize);           //消息类型ID         *hptr++ = packet->m_packetType;       }       //消息流ID(4字节)       if (nSize > 8)         hptr += EncodeInt32LE(hptr, packet->m_nInfoField2);              //如果块消息头存在,且相对时间戳大于0xffffff,此时需要使用ExtendTimeStamp        if (nSize > 1 && t >= 0xffffff)         hptr = AMF_EncodeInt32(hptr, hend, t);       //消息载荷大小      nSize = packet->m_nBodySize;       //消息载荷指针     buffer = packet->m_body;       //块大小,默认128字节       nChunkSize = r->m_outChunkSize;            RTMP_Log(RTMP_LOGDEBUG2, "%s: fd=%d, size=%d", __FUNCTION__, r->m_sb.sb_socket,           nSize);        //使用HTTP       if (r->Link.protocol & RTMP_FEATURE_HTTP)       {         //nSize:消息载荷大小;nChunkSize:块载荷大小        //例nSize:307,nChunkSize:128;         //可分为(307+128-1)/128=3个         //为什么减1?因为除法会只取整数部分!         int chunks = (nSize+nChunkSize-1) / nChunkSize;         //如果块的个数超过一个         if (chunks > 1)         {         //消息分n块后总的开销:         //n个块基本头,1个块消息头,1个消息载荷,这里是没有扩展时间戳的情况         //实际中只有第一个块是完整的,剩下的只有块基本头        tlen = chunks * (cSize + 1) + nSize + hSize;//这里其实多算了一个块基本头         //分配内存         tbuf = (char *) malloc(tlen);         if (!tbuf)            return FALSE;         toff = tbuf;         }       }       while (nSize + hSize)       {           int wrote;           //消息载荷小于块载荷(不用分块)           if (nSize < nChunkSize)           nChunkSize = nSize;                RTMP_LogHexString(RTMP_LOGDEBUG2, (uint8_t *)header, hSize);           RTMP_LogHexString(RTMP_LOGDEBUG2, (uint8_t *)buffer, nChunkSize);           if (tbuf)           {              memcpy(toff, header, nChunkSize + hSize);             toff += nChunkSize + hSize;           }           else           {             wrote = WriteN(r, header, nChunkSize + hSize);             if (!wrote)               return FALSE;           }           //消息载荷长度块载荷长度           nSize -= nChunkSize;           //Buffer指针前移1个块载荷长度           buffer += nChunkSize;           hSize = 0;                      //如果消息没有发完           if (nSize > 0)           {             header = buffer - 1;             hSize = 1;             if (cSize)             {               header -= cSize;               hSize += cSize;             }             //块基本头第1个字节             *header = (0xc0 | c);             //如果块基本头大于1字节             if (cSize)             {               int tmp = packet->m_nChannel - 64;               header[1] = tmp & 0xff;               if (cSize == 2)               header[2] = tmp >> 8;             }            }       }       if (tbuf)       {           int wrote = WriteN(r, tbuf, toff-tbuf);           free(tbuf);           tbuf = NULL;           if (!wrote)             return FALSE;       }            /* we invoked a remote method */       if (packet->m_packetType == 0x14)       {           AVal method;           char *ptr;           ptr = packet->m_body + 1;           AMF_DecodeString(ptr, &method);           RTMP_Log(RTMP_LOGDEBUG, "Invoking %s", method.av_val);           /* keep it in call queue till result arrives */           if (queue)          {             int txn;             ptr += 3 + method.av_len;             txn = (int)AMF_DecodeNumber(ptr);             AV_queue(&r->m_methodCalls, &r->m_numCalls, &method, txn);           }       }            if (!r->m_vecChannelsOut[packet->m_nChannel])         r->m_vecChannelsOut[packet->m_nChannel] = (RTMPPacket *) malloc(sizeof(RTMPPacket));       memcpy(r->m_vecChannelsOut[packet->m_nChannel], packet, sizeof(RTMPPacket));       return TRUE;     }    

现在要解决的是如何给结构体RTMPPacket中的消息载荷m_body赋值,即如何将H.264的NALU打包进消息载荷。

1.sps和pps的打包

sps和pps是需要在其他NALU之前打包推送给服务器。由于RTMP推送的音视频流的封装形式和FLV格式相似,向FMS等流媒体服务器推送H264和AAC直播流时,需要首先发送"AVC sequence header"和"AAC sequence header"(这两项数据包含的是重要的编码信息,没有它们,解码器将无法解码),因此这里的"AVC sequence header"就是用来打包sps和pps的。

AVC sequence header其实就是AVCDecoderConfigurationRecord结构,该结构在标准文档“ISO/IEC-14496-15:2004”的5.2.4.1章节中有详细说明,如下所示:

用表格表示如下:

FLV 是一个二进制文件,简单来说,其是由一个文件头(FLV header)和很多 tag 组成(FLV body)。tag 又可以分成三类: audio, video, script,分别代表音频流,视频流,脚本流,而每个 tag 又由 tag header 和 tag data 组成。

然后参照“Video File Format Specification Version 10”中The FLV File Format的Video tags章节,如下所示:

上表中tag header为两个4bits,即一个字节,其他的是tag data。inter frame即P frame。

AVC时,3字节CompositionTime无意义,通常设置为0。

AVCDecoderConfigurationRecord结构的表格中可以看出,由于NALUnitLength-1=3,因此每个NALU包都有NALUnitLength=4个字节来描述它的长度。这4个字节需要添加到每个NALU的前面,因此上表中Data的结构实际上如下所示:

一个典型的打包示例如下所示:

[cpp]  view plain  copy   body = (unsigned char *)packet->m_body;   i = 0;   body[i++] = 0x17;// 1:Iframe  7:AVC ,元数据当做keyframe发送</span>   body[i++] = 0x00;      body[i++] = 0x00;   body[i++] = 0x00;   body[i++] = 0x00;      //AVCDecoderConfigurationRecord   body[i++] = 0x01;   body[i++] = sps[1];   body[i++] = sps[2];   body[i++] = sps[3];   body[i++] = 0xff;      /*sps*/   body[i++]   = 0xe1;   body[i++] = (sps_len >> 8) & 0xff;   body[i++] = sps_len & 0xff;   memcpy(&body[i],sps,sps_len);   i +=  sps_len;      /*pps*/   body[i++]   = 0x01;   body[i++] = (pps_len >> 8) & 0xff;   body[i++] = (pps_len) & 0xff;   memcpy(&body[i],pps,pps_len);   i +=  pps_len;   2.其它NALU的打包

一个典型的打包示例如下所示:

[cpp]  view plain  copy   int i = 0;    if(bIsKeyFrame)   {         body[i++] = 0x17;// 1:Iframe  7:AVC          body[i++] = 0x01;// AVC NALU          body[i++] = 0x00;         body[i++] = 0x00;         body[i++] = 0x00;               // NALU size          body[i++] = size>>24 &0xff;         body[i++] = size>>16 &0xff;         body[i++] = size>>8 &0xff;         body[i++] = size&0xff;       // NALU data          memcpy(&body[i],data,size);     }   else   {         body[i++] = 0x27;// 2:Pframe  7:AVC          body[i++] = 0x01;// AVC NALU          body[i++] = 0x00;         body[i++] = 0x00;         body[i++] = 0x00;               // NALU size          body[i++] = size>>24 &0xff;         body[i++] = size>>16 &0xff;         body[i++] = size>>8 &0xff;         body[i++] = size&0xff;       // NALU data          memcpy(&body[i],data,size);     }  

    使用Wireshark抓包工具抓取的RTMP通信的所有数据:RTMP数据包。其中:

    RTMP server ip =  192.168.0.5

    VLC player ip     =  192.168.0.118

    Push Data ip       = 192.168.0.7

本文转载自:http://blog.csdn.net/caoshangpa/article/details/52872146

转载请注明原文地址: https://www.6miu.com/read-14826.html

最新回复(0)