经过一个(多)月的折腾,算是把视频组件的架构和储存模块搓出来了。这篇博客自顶向下+自下到上描述rockchip风格的视频程序的结构设计。

作为计算机专业的老菜鸟,软件(工程)开发的新手,这篇博客用于讨论和记录如何学习和开展一个项目,包含一些个人的方法论,可能谈不上“正确指导作用”,欢迎交流。

Part 1 起草抽象化业务需求

首先,分析项目的需求,从需求入手。因为我们的目的是做项目和产品,对于整个开发来说,这是宇宙万法的源头(雾):

  • 进行运动检测,更新录像帧率
  • 进行视频录制,但是需要获得动态的录像帧率
  • 进行实时推流,可能需要自动关闭以节约资源

为了降低代码的耦合性,方便对不同的业务进行后续的客制化,我们应该需要三个视频源,来分别应对这三个业务。举个例子,我们需要更改视频的帧率,但是如果随意更改帧率,会对实时运动检测造成影响。实际上,视频源的分离也是RK IPC在做的事情。这种设计可以在RKIPC文档(”/sdk_path/project/app/rkipc/rkipc”)找到:

我们将项目的需求进行解构,下一步应该是利用抽象和模型。(可能有天赋的人能自己设计或者想出来,但是我不知道也想不出来LOL, 于是就找示例程序和官方项目了)。

我们需要的“视频源”是能单独获得视频数据的东西,显然对此我们一无所知。这就回到我们之前的博客当中,需要自底向上学习单个视频源和视频输出的结构是怎样的。经过学习,根据rk的文档和源码,我们知道以下组件可以实现获得视频的流程:

  • VI:这个东西是Video Input, 用来获取摄像头的视频(可以加ISP和AIQ处理获得人类想看的视频),输出的是原始视频的内容,比如RGB888原始格式。
  • VENC:把输入的视频进行编码,编码成H264/5等格式,方便进行推流和储存,这一步调用的是硬件编码,很快。
  • IVSIntelligent Video Surveillance,智能监控模块,这是用来进行运动检测MD或者遮盖检测 OD的模块,基本由硬件执行,也很快。
  • MB:内存池,基本是自动的,记得申请和销毁即可
  • 其他模块:如OSDVPSS,等项目迭代和优化再进行处理。

同时我们需要了解模块间的信息传递。根据MPI文档开头,常用的是,Bind,绑定,通过数据接收者绑定数据源来建立两者之间的关联关系,数据就会自动传输同步。

手册提供了绑定关系表,我们可以利用绑定传输图像进行绘制处理,也可以拿来控制帧率和编码。截图为部分绑定关系:

因此,结合我们自顶向下分析的业务抽象,我们就可设计这三条简单的视频传输通道 ,也就是如下视频源:

  • VI 0 => bind VENC 0 => Storage 视频用于录制
  • VI 1 => bind VENC 1 => RTSP/RTMP 视频用于推流
  • VI 2 => bind IVS for OD MD 视频用于运动检测,更新帧率

需要的是有些模块也有自己的层级,比如VI 就分为vi设备(dev),输入 pipe、输出通道(channel)三个层级。

Part 2 学习实现抽象业务需求的实现

Part 2.0 学习项目结构,例程视频架构

根据简单的例程,比如sdk里面的例程(各路sample和example文件夹里)和luckfox的例程,我们应该已经学会怎样实现单条视频通道,并且熟悉视频的获取,下一步就是选择合适的代码结构,并且加入系统编程的元素。

我目前仅仅对Linux有了解,能熟练使用C语言(没错,就是大学和教材那种C语言基础),那么下一步要做的是看官方完整的工程。

我们先分析和学习Rockchip的IPC示例工程是怎么组织项目的。

“/your_sdk/project/app/rkipc/rkipc“ 里面有build,cmake,common,docs,lib,src这六个文件夹,还有.clang-format,CMakeLists.txt,format.sh,.gitignore, LICENSE这几个文件。

文件很简单,是编译规则,语法格式化,git需要忽略的文件(夹),许可证。

对于文件夹,build,cmake是编译相关,前文提到过docs是文档。

至于代码和库的本体,分别是:

common,用来储存各种通用模块与接口

lib,根据编译器存放的静态和动态库

src,源代码,不同的子项目使用不同的文件夹,本次借鉴(抄)的代码是rv1106_ipc

更多细节的规范可以借鉴Rockchip_Developer_Guide_Linux_RKIPC_CN,我们这次只是观摩学习。

现在我们已经学习了一种项目文件的组织方式,理明白了什么代码在哪里,就可以寻找对我们有用的部分了。

查看main.c来分析项目流程。

根据分析, main.c文件的作用有:

  • 入口(废话)
  • 信号处理和外部事件处理,例如sig_proc,用于终止程序,wait_key_event用来处理关键事件
  • 主程序启动参数处理,比如变量short_optionslong_options,函数usage_tiprkipc_get_opt
  • 功能和模块的初始化,你会发现一堆init函数
  • 保证所有功能和模块一直执行,这也是while (g_main_run_)的意义,空转
  • 循环过后是一堆去初始化deinit函数

这里我们其实就可以看出来g_main_run_是非常重要的控制变量,如果这个变量变成了false,程序马上就会终止while,去执行deinit部分,退出。而g_main_run_这个变量就是由信号处理函数sig_proc控制和写入的。很好,我们学习到了一种设计,来对程序进度进行控制。

这里插一个技巧,函数不一定就是“干实事”,函数也可以作为标志来使程序有更好的可读性和debug,比如void rk_system_init() { LOG_DEBUG(“%s\n”, __func__); }就只用来显示函数名。

下面我们就可以看视频部分的代码。在main.c当中,视频相关的单个功能初始化有这两步,ISP初始化不是必须的,就是不初始化颜色会发黄。可以参考例程和手册:rk_isp_init(自己封装,就是先初始化ISP设备)=>>调用RK_MPI_SYS_Init。然后我们可以研究rk_video_init();

这个函数在video 文件夹的video.c里面。

这个初始化函数同样给一个静态的全局变量g_video_run_赋值1,可能有和main.c类似的设计。

从ini文件读取一些必要的参数后,就开始进行初始化,

对于我们的设计,初始化部分我们临时需要的看的就是初始化设备,初始化通道,也就是rkipc_vi_dev_init()rkipc_pipe_0_init()这两个函数。里面有很详细的注释,结合前文提到的视频架构,我反而觉得不算信息差,只要C语言的基础数据结构过关,学会找引用,视频配置部分可以自行解决,这里不多说。

其中rk_param_get_string(“video.0:aq_step_p”, NULL);这种函数用于从ini文件读取参数,我们的工程目前不需要这样做,直接用常量配置即可。项目完成后再进行反思和升级。

现在我们已经可以配置绑定我们需要的三个通道,录制推流运动检测

配置完成,下面要做的是学习怎样各自从配置好的视频源获取数据或者结果。从Luckfox的demo,获取一帧的数据不是难事。但是,数据和结果的获取需要一直进行,另外在每一个rkipc_pipe_X_init后半部分我们发现有pthread_create函数,没错,我们要用到多线程函数。

Part 2.1 多线程

个人观点:针对这种单核的小小SoC,我们依然使用了多线程,因为多线程不只是常说的“充分利用大型设备的多核CPU” ,线程可以作为我们任务的最小的单位是一个进程允许“同时”执行多个任务的抽象,而进程间的通讯和切换要付出比线程更大的成本,针对我们的使用场景,使用线程可以很完美的解决问题。快说感谢操作系统!XD

另外因为我们在Linux编写多线程,也没有特别复杂和定制化的要求,我们可以选择符合POSIX标准的方式,使用pthread头文件来解决问题。没错,pthread前面那个P就是POSIX的意思。

根据我们研究代码的路线,我们目前找到了线程的创建,没关系,那就把线程创建看一下学一下。

pthread_create(&venc_thread_0, NULL, rkipc_get_venc_0, NULL);

rk选择了最简单的方式创建线程,也就是给pthread_create一个线程对象venc_thread_0,(通过pthread_t venc_thread_0; 直接创建,没什么初始化的)然后传入函数指针rkipc_get_venc_0,函数指针指向的函数这样声明,前面也可以加static

void *rkipc_get_venc_0(void *arg) {
// lalala
return NULL;}

在对线程创建和管理有个大概思路之前,我们看看线程运行的函数rkipc_get_venc_0。定位到rkipc_get_venc_0,看一下这个线程怎样设计。

static void *rkipc_get_venc_0(void *arg) {
	LOG_DEBUG("#Start %s thread, arg:%p\n", __func__, arg);
	prctl(PR_SET_NAME, "RkipcVenc0", 0, 0, 0);
	VENC_STREAM_S stFrame;
	VI_CHN_STATUS_S stChnStatus;
	int loopCount = 0;
	int ret = 0;
	stFrame.pstPack = malloc(sizeof(VENC_PACK_S));

	while (g_video_run_) {
		// 5.get the frame
		ret = RK_MPI_VENC_GetStream(VIDEO_PIPE_0, &stFrame, 2500);
		if (ret == RK_SUCCESS) {
			void *data = RK_MPI_MB_Handle2VirAddr(stFrame.pstPack->pMbBlk);
			// fwrite(data, 1, stFrame.pstPack->u32Len, fp);
			// fflush(fp);
			// LOG_DEBUG("Count:%d, Len:%d, PTS is %" PRId64", enH264EType is %d\n", loopCount,
			// stFrame.pstPack->u32Len, stFrame.pstPack->u64PTS,
			// stFrame.pstPack->DataType.enH264EType);
			rkipc_rtsp_write_video_frame(0, data, stFrame.pstPack->u32Len, stFrame.pstPack->u64PTS);
			if ((stFrame.pstPack->DataType.enH264EType == H264E_NALU_IDRSLICE) ||
			    (stFrame.pstPack->DataType.enH264EType == H264E_NALU_ISLICE) ||
			    (stFrame.pstPack->DataType.enH265EType == H265E_NALU_IDRSLICE) ||
			    (stFrame.pstPack->DataType.enH265EType == H265E_NALU_ISLICE)) {
				rk_storage_write_video_frame(0, data, stFrame.pstPack->u32Len,
				                             stFrame.pstPack->u64PTS, 1);
				if (enable_rtmp)
					rk_rtmp_write_video_frame(0, data, stFrame.pstPack->u32Len,
					                          stFrame.pstPack->u64PTS, 1);
			} else {
				rk_storage_write_video_frame(0, data, stFrame.pstPack->u32Len,
				                             stFrame.pstPack->u64PTS, 0);
				if (enable_rtmp)
					rk_rtmp_write_video_frame(0, data, stFrame.pstPack->u32Len,
					                          stFrame.pstPack->u64PTS, 0);
			}
			// 7.release the frame
			ret = RK_MPI_VENC_ReleaseStream(VIDEO_PIPE_0, &stFrame);
			if (ret != RK_SUCCESS) {
				LOG_ERROR("RK_MPI_VENC_ReleaseStream fail %x\n", ret);
			}
			loopCount++;
		} else {
			LOG_ERROR("RK_MPI_VENC_GetStream timeout %x\n", ret);
		}
	}
	if (stFrame.pstPack)
		free(stFrame.pstPack);
	// if (fp)
	// fclose(fp);

	return 0;
}

我们可以看到这个函数和main.c的思路是很相似的,也是有个运行标志g_video_run_来控制主while循环,最后return个0或者NULL线程就结束了。注意,是线程结束,不是进程结束。

其实这样又是一个设计,我们创建线程的地方可以放在相应资源初始化的函数里面,这样就不需要全部初始化之后统一启动管理线程,也不用担心线程在不合时宜的时候启动访问不该访问的东西。

现在我们掌握了怎样创建一个线程,有目前的这些,已经足够我们把程序跑起来了。

可是程序有入口也有出口,对于c语言来讲,释放占用的资源和内存更是重中之重。 这就涉及到deinit函数。rk的deinit是有一个总的去初始化入口rk_video_deinit(),这个总入口再一步步去初始化各个通道和视频源。

前面我们已经对这些进程和资源了然于胸,去初始化就变得小菜一碟了,记住三步走

  1. 更新全局控制变量为false,让相应的线程自行退出while循环,释放内存资源(比如一帧视频)。
  2. 使用pthread_join(venc_thread_0, NULL);加入线程,保证线程的彻底结束和资源释放。
  3. 调用RK_MPI的函数,该解绑的解绑,该停止的就停止,该销毁的就销毁。

其中对于视频组件的销毁,一定注意不要提前禁用底层依赖项目,比如IVS和VENC还活动着,你直接把视频设备VI摧毁了,会导致上层组件结果和缓存无法释放,直接卡死循环。要从上到下清除。最后在主函数main调用RK_MPI_SYS_Exit();彻底退出。

Part 2.2 线程之间的通讯和同步

其实对于线程来说,他们的变量都是共享的,因此通讯也并不是难事。在研究RKIPC的代码的同时,我们已经见到了最基本和裸奔的通信方式:g_video_run_ 变量作为标志,控制线程是否跳过循环。

这是非常简单的模型,只要有信号或者调用去初始化就刷新运行标志,而且我们可以看到,不管是main.c里面的g_main_run_还是video.c里面的g_video_run_,都只有一个修改标志的入口,而且只有程序即将结束的时候才写一次,直接裸奔不进行额外的保护也没问题。

但是,在抽象和理想的情况下,操作系统的调度可比一行c语言指令细致的多,多线程是“同时”进行的。尤其是在多次次读写甚至“同时”读写的情况(比如我们可能需要更新当前帧率),如果当前写入/读取文件或者变量被打断,会让程序变得不可靠,甚至有难以预料的结果(鬼知道编译器是怎么编译的,操作系统是怎么调度线程和分配内存的)。

所以,我们需要一些“科技”来保护一些资源的读取与写入,还需要确保核心的步骤不能被打断。这些不能被打断的步骤和操作,我们称之为“原子操作”,取原子不可再分之意。而实现这些操作的科技就是信号量和条件变量。 好了你可以在网上或者书上找关于锁的内容了,下班!

说到打断,阻止打断就是让打断者暂停,就提到了一个重要的概念,阻塞。既然我们在两个线程竞争写入同一个资源的时候,我们选择保护一个,另一个就得等待,这个就是阻塞。

但是逻辑上会出现一个问题,A先占用资源1,再占用资源2;B先占用资源2,再占用资源1,等这俩卧龙凤雏都各自占完第一个资源,再申请第二个资源的时候,于是A会等资源2, B会等资源1,俩人都不松口(没解锁怎么松!),成了鹬蚌相争了,两个线程都卡了,主线程也一直等这俩,整个程序不能继续进行,便成了死锁(死了啦)。

常见的线程同步和控制需要的技术或者模型有:

  1. 互斥锁(使用者的角度来说,是最基本的保护变量和原子性的手段,开头一加锁结尾一解锁就行了)
  2. 读写锁(适合读取频率远远大于写入频率,或者有一个写入多个读取这种情况,我们的帧率控制就是符合这两个条件)
  3. 条件变量(配合互斥锁使用,合理使用条件变量可以实现线程休眠,唤醒,给CPU更多的时钟周期)
  4. 信号量(可以更高级控制多个线程的运行,并且由”是和否“升级到了“数值“,本次项目没遇到这种模型,先不管它)
  5. 消息队列和管道(对于C来说不够原生啊,得用其他队列来搓,咱临时也永不上,不管它)
  6. 抽象模型:状态机(用不到,咱的状态很简单,只有高帧率和低帧率,可以用状态机表示两个状态,但是能跑就不改了XD )

这次的视频组件目前用到的只有读写锁

读写锁分为读锁和写锁,把加锁和解锁包裹你想保护或者读取的变量或者代码部分就行了。

Part 2.3 帧率控制和读帧的方法

目前我们已经更新了帧率变量,可以自由快乐地读取+写入帧了。

30帧和1帧这种速度对计算机太慢了,我们完全可以让它等,定时睡眠,便有了

  1. 计时
  2. 循环周期减去消耗时间得到休眠时间
  3. 休眠
  4. 新一轮开始

RK的帧率控制就是这样的:

vi_2_send

上面的方法简单粗暴,还有第二种就不是帧率控制了,是控制VENC或者VI 的帧率,来达到源头改变,再使用epoll来等待新的文件句柄。

大佬lyphotoes的代码

注意文件句柄和epoll要查看RK_MPI手册的建议和实现。

Part 3 本项目的示例结构

3.2 文件结构

tree

3.2 主函数

src/main.c

3.3 video.h

include/video.h

视频的架构暂时分析到这里,等demo完成后会加入osd来打日期,甚至标记运动的框框。

下一篇记录储存,主要想记录的东西有:引入第三方库media-server,文件处理,hls文件处理,日期切换和切片控制逻辑,线程条件锁。

Views: 86

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.