RTP是網絡上進行流媒體傳輸的一種常用協議,現在有很多封裝RTP協議的開源庫,比如:ortp, jrtplib,而其中最有名的要數jrtplib,本文給大家演示怎麼用jrtplib開發一個帶RTP發送和接收功能的應用程序,但這篇文章不會講述jrtplib的基本用法知識,如果你要了解更多關於這個庫的用法,可以參考這篇文章:http://www.cnblogs.com/yuweifeng/p/7550737.html。
本文給大家演示怎麼開發一個基於RTP協議的流媒體播放器。播放器從網絡上接收RTP包,解包後把視頻分離出來,然後用FFmpeg解碼,把圖像顯示出來。這個流媒體播放器實現的功能比較簡單,但是實現了一個典型的網絡播放器的框架。該文章涉及的開發知識和技巧包括:
1. 怎麼用jrtplib發送數據;
2. 怎麼使用jrtplib接收數據;
3. 怎麼讓ffmpeg從內存中讀取流媒體數據,然後進行分離和解碼;
4. 怎麼用雙線程技術同時接收和解碼視頻,提高播放的效率;
5. 怎麼實現一個先入先出的緩衝隊列存儲收到的視頻幀;
該播放器的代碼下載地址:https://download.csdn.net/download/zhoubotong2012/10918971
這個播放器的界面如下所示:
這個播放器主要演示播放網絡流的功能,但爲了方便測試,也集成了發送流媒體的功能,界面上提供一個按鈕選擇一個視頻文件,文件可以是H264、PS、TS等容器格式;然後,需要指定發送的目標IP和目標端口號,這裏的IP可以選本機IP。點擊發送,則程序會以RTP方式打包,通過UDP將數據發送到目標地址。在本程序的接收端,只需要配置接收端口,點擊“開始接收“按鈕則開始接收數據。注意:目標端口要和接收端口一致,發送和接收是一對一的。程序可以分佈在兩臺機器上運行,一個作發送端,一個作接收端;也可以在一臺機上測試發送和接收。
- 如何實現發送
首先,講一下怎麼實現發送流媒體的功能。我們需要創建一個RTPSession的發送對象,然後初始化相關的參數:
RTPSession session;
RTPSessionParams sessionparams;
sessionparams.SetOwnTimestampUnit(1.0 / 90000.0);
sessionparams.SetAcceptOwnPackets(true);
RTPUDPv4TransmissionParams transparams;
transparams.SetPortbase(8000); //這個端口必須未被佔用
int status = session.Create(sessionparams, &transparams);
if (status < 0)
{
//std::cerr << RTPGetErrorString(status) << std::endl;
return - 1;
}
#if 1
RTPIPv4Address addr(ntohl(inet_addr(m_szDestIP)), m_nDestPort);
status = session.AddDestination(addr);
#else
unsigned long addr = ntohl(inet_addr(m_szDestIP));
status = session.AddDestination(addr, m_nDestPort);
#endif
if (status < 0)
{
//std::cerr << RTPGetErrorString(status) << std::endl;
return -2;
}
session.SetDefaultPayloadType(96);
session.SetDefaultMark(false);
session.SetDefaultTimestampIncrement(90000.0 / 25.0);
這裏初始化的參數包括RTP頭的Payload類型(賦值爲96),時間單位(1.0/90000.0),時間戳增量(90000/25=3600),以及Rtp頭的MarkerBit的默認值。
接着讀取一個視頻文件,每次讀1K字節,然後調用jrtplib的RTPSession::SendPacket函數發送數據:
FILE *fp_open;
uint8_t buff[1024 * 5] = { 0 };
DWORD bufsize = 1024; //每次讀1024字節,不超過1400就行
DWORD dwReadBytesPerSec = 2*1024*1024/8; //讀取速度
RTPTime delay(bufsize*1.0/ dwReadBytesPerSec);
//讀取文件
fp_open = fopen(m_szFilePath, "rb");
while (!feof(fp_open) && g_RTPSendThreadRun)
{
int true_size = fread(buff, 1, bufsize, fp_open);
int status = session.SendPacket(buff, true_size);
Sleep(1000* bufsize/dwReadBytesPerSec);
//RTPTime::Wait(delay); //delay for a few milliseconds
}
(注意:這裏讀文件數據只是簡單地將文件數據塊讀出來然後直接發送,沒有對視頻幀做二次封裝和處理,對於某些格式比如H264,一般要求要以NALU單元來傳輸,以FU-A分片方式打包,然後再封裝到RTP包裏面,而本文的方法沒有采取這種方式,大家要注意區分。)
- 如何實現接收
接收的實現較爲複雜一些,用到了多線程技術和緩衝隊列。本文的實現中用到兩條線程,一條用於接收RTP包,從中提取出視頻數據;另一條線程用於解碼視頻,並把視頻幀轉成RGB格式後顯示到窗口中。用到兩條線程的好處是:可以並行接收和解碼,兩個工作相互獨立,提高視頻幀的處理效率,減少播放延時。而如果用一條線程來做,它既要接收又要解碼,線程中處理一個幀的時間就長一些,而這時又不能接收數據,很可能造成後面的數據包丟掉。所以,用雙線程的”分工合作“方式處理效率更高。兩條線程之間需要維護一個隊列,其中一條線程收到數據後放到隊列裏,然後另外一個線程從隊列裏讀取數據,這是一個典型的”生產者-消費者“的模型,我們需要實現一個先入先出的隊列來轉運”視頻幀“,這個隊列的定義如下:
std::list<PacketNode_t> m_packetList; //包列表
其中,PacketNode_t結構體的定義爲:
typedef struct
{
unsigned length;
uint8_t *buf;
}PacketNode_t;
下面對接收線程和解碼線程的工作流程作詳細介紹。
首先,程序在接收前需要創建兩個線程:
g_RTPRecvThreadRun = true;
g_decoding_thread_run = true;
DWORD threadID = 0;
m_hRecvThread = CreateThread(NULL, 0, RTPRecvThread, this, 0, &threadID);
m_hDecodeThread = CreateThread(NULL, 0, decoding_thread, this, 0, &threadID);
RTPRecvThread是RTP數據的接收線程,實現方式如下:
DWORD WINAPI RTPRecvThread(void* param)
{
TRACE("RTPRecvThread began! \n");
CPlayStreamDlg * pThisDlg = (CPlayStreamDlg*)param;
RTPSession session;
//WSADATA dat;
//WSAStartup(MAKEWORD(2, 2), &dat);
RTPSessionParams sessionparams;
sessionparams.SetOwnTimestampUnit(1.0 / 90000.0);
//sessionparams.SetAcceptOwnPackets(true);
RTPUDPv4TransmissionParams transparams;
transparams.SetPortbase(m_nRecvPort); //接收端口
int oldBufSize = transparams.GetRTPReceiveBuffer();
transparams.SetRTPReceiveBuffer(oldBufSize * 2);
int status = session.Create(sessionparams, &transparams);
int newBufSize = transparams.GetRTPReceiveBuffer();
int oldBufSizec = transparams.GetRTCPReceiveBuffer();
transparams.SetRTCPReceiveBuffer(oldBufSizec * 2);
int newBufSizec = transparams.GetRTCPReceiveBuffer();
while (g_RTPRecvThreadRun)
{
#ifndef RTP_SUPPORT_THREAD
int error_status = session.Poll();
#endif // RTP_SUPPORT_THREAD
session.BeginDataAccess();
if (session.GotoFirstSourceWithData())
{
do
{
RTPPacket *pack;
while ((pack = session.GetNextPacket()) != NULL)
{
int nPayType = pack->GetPayloadType();
int nLen = pack->GetPayloadLength();
unsigned char *pPayData = pack->GetPayloadData();
int nPackLen = pack->GetPacketLength();
unsigned char *pPackData = pack->GetPacketData();
int csrc_cont = pack->GetCSRCCount();
int ssrc = pack->GetSSRC();
int nTimestamp = pack->GetTimestamp();
int nSeqNum = pack->GetSequenceNumber();
#if 0
Writebuf((char*)pPayData, nLen);
#else
pThisDlg->m_cs.Lock();
//if (pThisDlg->m_packetList.size() < MAX_PACKET_COUNT)
{
PacketNode_t temNode;
temNode.length = nLen;
temNode.buf = new uint8_t[nLen];
memcpy(temNode.buf, pPayData, nLen);
pThisDlg->m_packetList.push_back(temNode); //存包列表
}
pThisDlg->m_cs.Unlock();
#endif
session.DeletePacket(pack);
}
} while (session.GotoNextSourceWithData());
}
else
{
//Sleep(10);
}
session.EndDataAccess();
Sleep(1);
}
session.Destroy();
TRACE("RTPRecvThread end! \n");
return 0;
}
接收線程裏創建了一個RTPSession對象,這個對象是用於接收RTP包,前面一部分代碼用於初始化一些參數,包括:接收端口,時間戳單位,接收緩衝區大小。然後,進入一個循環,在裏面不停地讀取RTP數據包,如果session.GetNextPacket()返回的指針不爲空,則表示讀取到一個數據包,返回的指針變量是一個RTPPacket*類型,其指向的成員變量包括RTP頭的各個字段的值,以及Payload數據的內存地址和大小。我們關鍵要提取出Payload的數據和大小,然後把它作爲一個元素插入到緩衝隊列中(如下面代碼所示:)
pThisDlg->m_cs.Lock();
PacketNode_t temNode;
temNode.length = nLen;
temNode.buf = new uint8_t[nLen];
memcpy(temNode.buf, pPayData, nLen);
pThisDlg->m_packetList.push_back(temNode); //存包列表
pThisDlg->m_cs.Unlock();
上面的接收線程實現了一個“生成者”,而“消費者”是實現在另外一個線程---decoding_thread,這個線程做的工作是解碼。這個線程調用了很多FFmpeg的函數,但基本的流程是:打開一個文件源或URL地址-》從源中讀取各個流的信息-》初始化解碼器-》解碼和顯示。因爲我們是從網絡中收數據,所以是一個網絡源,從網絡源中讀取數據有兩種方式:一種是用FFmpeg內置的協議棧的支持,比如RTSP/RTMP/RTP,還有一種方式是我們傳數據給FFmpeg,FFmpeg從內存中讀取我們送的數據,然後用它的Demuxer和Parser來進行分析,分離出視頻和音頻。這裏程序使用的是第二種方式,即從網絡中探測數據,然後送數據給FFmpeg去解析。探測網絡數據需要調用FFmpeg的av_probe_input_buffer函數,這個函數要傳入一個內存緩衝區地址和一個回調函數指針,其中回調函數是用來從網絡中讀數據的(即我們放到緩衝隊列裏的數據包)。下面的fill_iobuffer就是讀數據的回調函數,而pIOBuffer指向用於存放讀取數據的緩衝區地址,FFmpeg就是從這裏讀取數據。
pIObuffer = (uint8_t*)av_malloc(4096);
pb = avio_alloc_context(
pIObuffer,
4096,
0,
param,
fill_iobuffer,
NULL,
NULL);
if (av_probe_input_buffer(pb, &piFmt, "", NULL, 0, 0) < 0)//探測從內存中獲取到的媒體流的格式
{
TRACE("Error: probe format failed\n");
return -1;
}
else {
TRACE("input format:%s[%s]\n", piFmt->name, piFmt->long_name);
}
回調函數fill_iobuffer調用了一個ReadBuf的函數:
int fill_iobuffer(void* opaque, uint8_t* buf, int bufSize)
{
ASSERT(opaque != NULL);
CPlayStreamDlg* p_CPSDecoderDlg = (CPlayStreamDlg*)opaque;
//TRACE("ReadBuf----- \n");
int nBytes = ReadBuf((char*)buf, bufSize, (void*)p_CPSDecoderDlg);
return (nBytes > 0) ? bufSize : -1;
}
static int ReadBuf(char* data, int len, void* pContext)
{
CPlayStreamDlg * pThisDlg = (CPlayStreamDlg*)pContext;
int data_to_read = len;
char * pReadPtr = data;
while (g_RTPRecvThreadRun)
{
int nRead = pThisDlg->ReadNetPacket((uint8_t*)pReadPtr, data_to_read);
if (nRead < 0)
{
Sleep(10);
continue;
}
pReadPtr += nRead;
data_to_read -= nRead;
if (data_to_read > 0)
{
Sleep(10);
continue;
}
break;
}
return (data_to_read > 0) ? -1 : len;
}
ReadBuf函數的作用就不用解釋了,大家一看就明白了。它實現了一個我們前面說的“消費者”,從前面實現的緩衝隊列中讀取數據包,讀取之後就會從隊列中刪除相應的元素。如果隊列不爲空,則直接從前面的元素讀取;如果無數據,則繼續等待。
讀了視頻幀數據之後,就到了解碼,解碼的代碼如下:
while (g_decoding_thread_run)
{
av_read_frame(pFormatContext, pAVPacket);
if(pAVPacket->stream_index == video_stream_index)
{
avcodec_decode_video2(pCodecCtx, pFrame, &got_picture, pAVPacket);
if(got_picture)
{
p_uint8_t_temp = pFrame->data[1];
pFrame->data[1] = pFrame->data[2];
pFrame->data[2] = p_uint8_t_temp;
pFrame->data[0] += pFrame->linesize[0] * (pCodecCtx->height - 1);
pFrame->linesize[0] *= -1;
pFrame->data[1] += pFrame->linesize[1] * (pCodecCtx->height / 2 - 1);
pFrame->linesize[1] *= -1;
pFrame->data[2] += pFrame->linesize[2] * (pCodecCtx->height / 2 - 1);
pFrame->linesize[2] *= -1;
got_picture = sws_scale(img_convert_ctx, pFrame->data, pFrame->linesize, 0, pCodecCtx->height, RGB24Data, RGB24Linesize);
got_picture = StretchDIBits(hDC, 0, 0, PlayingWidth, PlayingHeight, 0, 0, pCodecCtx->width, pCodecCtx->height, RGB24Data[0], (BITMAPINFO*)&bmpinfo, DIB_RGB_COLORS, SRCCOPY);
}
}
av_free_packet(pAVPacket);
}
FFmpeg從解碼器輸出的格式是YUV的,我們要轉成RGB圖像格式顯示,所以調用了sws_scale函數來轉換,最後調用Windows GDI函數---StretchDiBits來把圖像顯示到指定的窗口區域。
如果要停止解碼,則退出線程的時候記得要釋放FFmpeg創建的資源:
if (pFormatContext)
{
avformat_close_input(&pFormatContext);
pFormatContext = NULL;
}
sws_freeContext(img_convert_ctx);
av_freep(&RGB24Data[0]);
av_frame_free(&pFrame);
//avcodec_close(pCodecCtx);
//av_free(pIObuffer); //調用了avformat_close_input會自動釋放pIObuffer
ReleaseDC(hwnd, hDC);
到此爲止,一個簡單的流媒體播放器的實現過程就介紹完了。
這篇博文傳輸的視頻是直接從文件讀取到的,並且不分什麼格式,而對於常見的H264,一般需要以FU-A方式打包,需要對數據進行重組後再打成RTP包,我的下一篇博文(https://blog.csdn.net/zhoubotong2012/article/details/86510032)會向大家介紹怎麼用這種方法打包和發送H264,並且RTP協議實現不依賴於rtplib,自己管理Socket實現RTP包收發。
------------------------------------------------------------------------------------
後記:
2019-11-11
某些網友反映用了這個工具後遇到報錯或異常,其中主要問題是對某些格式FFmpeg不能識別或探測時間很長,這個是因爲使用者測試用的媒體文件不是一種可流化的格式,什麼是可流化格式,就是不需要下載整個文件,可以邊下邊播的,比如常見的PS、TS、MKV等封裝格式,而MP4大多數不屬於此類,因爲它的某些元數據不是寫在文件頭,要讀到文件尾部才能拿到所有幀的索引。
2018-01-29:
測試發現用jrtplib接收數據如果數據量很大會出現丟包,弄了半天才發現原來速度瓶頸是在session.Poll()函數,這個函數會等待很久去拿數據。jrtplib庫裏關於這個Poll函數的說明:
/** If you're not using the poll thread, this function must be called regularly to process incoming data
* and to send RTCP data when necessary.
*/
意思是用poll thread就不需要調用這個函數,那可能就沒有這個問題了(我沒有驗證過)。但是,這樣需要用到 jthread 庫。關於這點,我引用網上一篇博文裏的介紹:
jrtp-3.x 中有兩種數據接收方式:第一種是用 jthread 庫提供的線程自動在後臺執行對數據的接收。第二種是用戶自己調用 RTPSession 中的 Poll 方法。如果採取第一種方法則要安裝 jthread 庫。安裝 jthread-1.2.1.tar.gz ,而且 jthread-1.2.1 必須先與jrtp-3.7.1 的安裝。因爲在 jrtp-3.7.1 的 configure 中,會查找系統是否有編譯了 jthread 庫,如果有,那麼編譯的 jrtp 庫會開啓對 jthread 的支持。因此如果先編譯jrtp 再編譯 jthread ,編譯出來的 jrtp 是沒有開啓對 jthread 的支持的。如果採用第二種方法,那麼可以不用編譯 jthread 庫,而直接編譯 jrtp 庫。
但是,我沒有安裝 jthread庫,乾脆不用jrtplib接收了,直接自己寫Socket接收數據、對RTP解包。
新的例子代碼:https://download.csdn.net/download/zhoubotong2012/10943378