如何發送和接收RTP包,用FFmpeg分離、解碼

 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章