C++ NVR 核心模块设计实战:从视频接入到录像存储
C++ NVR 核心模块设计实战:从视频接入到录像存储
一、引言
在视频监控系统中,NVR(Network Video Recorder,网络视频录像机)承担着视频接入、录像存储、录像查询与回放等重要职责。对于基于 Linux 和 C++ 开发的嵌入式 NVR 来说,除了处理视频流,还需要面对多路并发、磁盘容量有限、设备异常断电、录像文件管理以及不同项目之间的功能差异等工程问题。
以 RK3568 等嵌入式 Linux 平台为例,设备可能需要长期运行,接入多路网络摄像机,将视频保存到多块硬盘,并通过 GB28181 等协议与上级平台交互。
在这样的场景下,NVR 的设计不能仅仅停留在“接收视频,然后写入文件”的层面,而需要考虑各个模块之间的职责划分、状态管理和异常处理。
本文将从实际工程设计的角度,分析一个 C++ NVR 程序的核心模块,以及这些模块应该如何协作。
二、NVR 的整体架构设计
在进行代码开发之前,首先需要明确系统包含哪些模块,以及模块之间的依赖关系。
2.1 核心模块划分
一个典型的 NVR 系统可以按照职责划分为以下模块:
| 模块 | 核心职责 |
|---|---|
| 设备管理 | 管理摄像机、通道及其配置信息 |
| 媒体接入 | 获取摄像机视频流,例如 RTSP 流 |
| 录像管理 | 控制录像任务的启动、停止和状态 |
| 磁盘管理 | 管理硬盘状态、挂载路径和可用空间 |
| 存储管理 | 决定录像写入位置、切盘及空间回收 |
| 协议通信 | 与上级平台交互,处理信令和状态消息 |
| 配置管理 | 读取设备、通道、存储及项目配置 |
| 日志与异常处理 | 记录运行状态、错误信息和故障现场 |
这些模块并不是彼此独立的。例如,录像管理需要调用媒体接入模块获取数据,同时依赖存储管理模块确定录像保存位置。
2.2 整体架构图
上级国标平台
│
GB28181 信令
│
▼
协议通信模块
│
▼
设备管理模块
│
▼
媒体接入模块
RTSP / RTP
│
▼
录像管理模块
│
▼
存储管理模块
┌────┴────┐
▼ ▼
磁盘管理 文件管理
│ │
└────┬────┘
▼
多块硬盘
需要注意,以上是逻辑架构图,并不意味着实际项目必须按照这种单向调用方式实现。
例如,磁盘管理模块检测到某块硬盘异常后,也需要将状态变化通知存储管理模块;录像管理模块停止某个通道时,也需要通知媒体接入模块释放相关资源。
实际项目中可以采用回调、事件通知、消息队列或者明确的接口调用来完成这些协作。
2.3 为什么要进行模块化设计?
假设将媒体接入、录像文件创建、磁盘空间判断和文件删除全部写在同一个类中,初期代码量可能比较少,但随着功能增加,会逐渐出现以下问题:
-
修改磁盘回收策略时,可能影响录像线程。
-
增加新的存储策略时,需要修改多个业务函数。
-
摄像机断流与磁盘故障难以区分,异常处理容易混乱。
-
多个线程同时修改录像状态,容易出现竞争条件。
-
不同项目存在差异时,条件判断散落在代码各处。
因此,模块化设计的核心并不是增加类的数量,而是让每个模块拥有明确的职责和可控的依赖关系。
三、设备管理与媒体接入模块
3.1 设备管理:管理的是业务对象
NVR 中的设备管理模块主要维护摄像机及通道的配置信息,例如:
-
通道编号和名称。
-
摄像机 RTSP 地址。
-
通道启用状态。
-
录像策略。
-
当前连接状态。
这里需要区分两个概念:设备和通道。
一台物理摄像机可能对应一个视频通道,也可能提供多个逻辑通道。因此,不能简单地认为设备数量始终等于通道数量。
可以使用结构体描述通道配置:
#include <string>
struct ChannelConfig {
std::string channelId;
std::string channelName;
std::string rtspUrl;
bool enabled = true;
};
设备管理模块负责保存和更新这些信息,但不应该同时承担视频数据接收、文件写入和磁盘回收等工作。
这种划分能够避免设备配置与媒体运行状态耦合在一起。
3.2 媒体接入:负责获取视频数据
如果 NVR 直接配置 RTSP 流地址,那么媒体接入模块的主要任务就是建立连接、接收媒体数据,并处理断流和重连。
需要注意,RTSP 主要用于建立和控制媒体会话,实际媒体数据通常通过 RTP 传输,也可能使用 RTP over RTSP/TCP 等方式。
典型流程如下:
读取 RTSP 地址
│
▼
建立 RTSP 会话
│
▼
获取媒体参数
│
▼
接收视频数据
│
▼
向录像模块提供数据
│
├── 正常接收 → 持续处理
│
└── 连接异常 → 重连或上报故障
媒体接入模块应当关注数据是否正常到达,而录像管理模块应当关注录像任务是否正常执行。
例如,摄像机断网后,媒体接入模块负责识别连接失败;录像管理模块则根据业务策略决定暂停录像、记录异常状态,并在流恢复后重新建立录像任务。
3.3 RTSP 接入和 GB28181 接入有什么区别?
这两个概念经常出现在 NVR 项目中,但它们并不完全相同。
-
RTSP 直连: NVR 根据配置的流地址直接获取媒体流。
-
GB28181 接入: NVR 与国标平台通过信令交互建立业务关系,再根据具体流程获取媒体流。
如果设备只需要直接接收摄像机的视频流,未必需要通过 GB28181 平台。
反过来,如果 NVR 需要向上级平台注册、上报目录、响应查询或处理控制命令,就需要相应的国标协议处理能力。
工程上应当将协议通信与媒体数据处理尽量分离。协议层负责信令和业务控制,媒体层负责视频数据的接收与处理,两者通过明确的接口协作。
四、录像管理模块:控制录像任务的生命周期
4.1 为什么需要独立的录像管理模块?
媒体接入成功并不意味着录像已经成功。
例如:
-
摄像机正常推流,但目标硬盘已经损坏。
-
视频数据能够接收,但录像文件创建失败。
-
录像文件已经创建,但写入过程中磁盘空间不足。
-
摄像机断流后,原有录像任务没有及时更新状态。
这些问题都说明,录像管理不能仅仅依赖媒体连接状态。
建议将录像任务作为一个独立的业务对象,负责协调媒体数据与存储操作。
4.2 录像任务状态设计
可以使用枚举表示录像任务状态:
enum class RecordState {
Stopped,
Starting,
Recording,
Stopping,
Error
};
状态含义如下:
| 状态 | 含义 |
|---|---|
| Stopped | 未执行录像任务 |
| Starting | 正在建立录像资源 |
| Recording | 正常录像 |
| Stopping | 正在停止录像并释放资源 |
| Error | 发生错误,需要进一步处理 |
一个简化的录像任务结构可以写成:
#include <string>
#include <memory>
struct RecordTask {
std::string channelId;
RecordState state = RecordState::Stopped;
std::string currentRecordPath;
};
这里的结构体仅用于展示设计思路。实际项目通常还需要管理录像文件句柄、媒体源引用、时间信息、错误原因等资源。
4.3 录像启动时需要做什么?
录像启动不应该只是创建文件并开始写入,而应当进行必要的资源检查。
典型流程如下:
-
检查通道是否启用。
-
确认媒体数据是否可用。
-
获取合适的存储位置。
-
创建录像文件。
-
初始化封装器及相关写入资源。
-
开始接收并写入媒体数据。
-
更新录像状态。
如果任何关键步骤失败,应当释放已经申请的资源,并记录失败原因。
这就是典型的资源生命周期管理问题。
在 C++ 中,可以使用 RAII(资源获取即初始化)管理文件句柄、锁和其他需要释放的资源,减少异常路径中遗漏释放操作的风险。
4.4 录像停止时需要注意什么?
录像停止也不是简单地关闭文件。
如果视频采用 MP4 等容器格式,停止录像时可能还需要完成封装、写入索引信息或更新文件元数据。
因此,正常停止流程通常包括:
停止接收当前任务的数据
│
▼
处理剩余数据
│
▼
完成文件封装
│
▼
关闭文件资源
│
▼
更新录像状态
具体步骤取决于实际使用的媒体库和封装格式。
如果直接终止写入线程或者进程,文件可能无法正常完成封装。因此,工程设计应当区分正常停止与异常终止。
五、多硬盘存储设计:切盘与磁盘回收
对于需要长时间运行的 NVR,磁盘管理通常是最重要、也最容易出现复杂问题的模块之一。
磁盘管理至少需要解决两个不同的问题:
-
切盘: 决定后续录像写入哪块硬盘。
-
磁盘回收: 当空间不足时,删除符合策略的旧录像文件。
两者相互关联,但不应该混为一个操作。
5.1 磁盘管理模块的职责
磁盘管理模块可以维护每块硬盘的基本信息,例如:
#include <string>
#include <cstdint>
enum class DiskState {
Unavailable,
Available,
ReadOnly,
Error
};
struct DiskInfo {
std::string mountPath;
DiskState state = DiskState::Unavailable;
std::uint64_t freeBytes = 0;
std::uint64_t totalBytes = 0;
};
以上结构仅描述必要的基本信息。实际项目还可能需要维护文件系统类型、挂载状态、磁盘标识、最近一次检测时间以及故障原因。
特别需要注意:挂载目录存在,并不代表磁盘一定正常。
例如,原本挂载在 /hdd/sda1 的硬盘被拔出后,如果系统仍然存在这个目录,程序就不能仅通过目录是否存在来判断硬盘可用。
应当结合实际挂载状态、文件系统状态和必要的读写检查判断磁盘是否可用。
5.2 切盘策略应该如何设计?
假设设备配置了四块硬盘,录像程序需要根据空间和磁盘状态选择目标盘。
一种简单的策略是选择可用空间充足的硬盘:
#include <vector>
#include <optional>
std::optional<std::size_t> selectDisk(
const std::vector<DiskInfo>& disks,
std::uint64_t requiredBytes)
{
for (std::size_t i = 0; i < disks.size(); ++i) {
const auto& disk = disks[i];
if (disk.state != DiskState::Available) {
continue;
}
if (disk.freeBytes >= requiredBytes) {
return i;
}
}
return std::nullopt;
}
这段代码只是一个最小示例,表示从候选硬盘中选择第一块满足空间要求的硬盘。
它还不是完整的生产级切盘算法,因为它没有考虑以下问题:
-
多个录像任务同时申请存储空间。
-
文件系统保留空间和安全余量。
-
不同硬盘的性能差异。
-
当前正在写入的文件是否需要继续保留在原盘。
-
故障磁盘上的录像文件如何处理。
-
多块磁盘之间的负载均衡。
特别是 freeBytes 只是某一时刻的空间快照。如果多个线程同时根据同一个数值申请存储空间,可能出现重复分配的问题。
因此,实际工程中应当由存储管理模块统一协调存储分配,并在必要时使用锁或其他同步机制保护共享状态。
5.3 切盘不等于迁移当前录像文件
切盘最常见的设计方式是:当前录像文件按照既定流程结束,后续录像任务或者新的录像文件使用另一块硬盘。
例如:
硬盘 A:当前录像文件
│
▼
判断是否需要切盘
│
▼
选择硬盘 B
│
▼
创建新的录像文件
│
▼
硬盘 B:继续录像
实际切换时机可以根据文件大小、录像时长、磁盘空间或者磁盘故障情况确定。
如果磁盘发生严重故障,系统可能需要提前终止当前文件并切换到其他可用硬盘。
需要明确的是,切盘策略并不能自动保证录像数据不丢失。如果切换过程中没有可靠的缓冲、写入错误处理和资源释放机制,仍然可能丢失部分数据。
5.4 磁盘回收为什么不能直接删除最旧的目录?
最直观的做法是,当磁盘空间不足时,直接删除最早日期的录像目录。
但这种做法存在风险。
例如:
-
目录中可能存在仍在写入的文件。
-
删除目录时可能影响其他录像任务。
-
文件名排序不一定代表真实的录像时间顺序。
-
文件可能因为权限或文件系统错误而无法删除。
-
删除过程中可能出现磁盘状态变化。
因此,磁盘回收应该有明确的筛选规则和安全检查。
一种更可靠的设计流程是:
检测可用空间
│
▼
是否低于回收阈值?
│
├── 否 → 继续监控
│
└── 是
│
▼
获取候选录像文件
│
▼
排除正在写入的文件
│
▼
按策略选择旧录像
│
▼
删除并检查结果
│
▼
重新检查可用空间
│
▼
空间满足要求?
┌──┴──┐
是 否
│ │
结束 继续回收或上报告警
这里有一个重要的设计原则:回收的目标不是删除固定数量的文件,而是让可用空间恢复到安全水平。
例如,可以设置两个阈值:
-
低水位:空间低于该值时开始回收。
-
高水位:回收后希望达到的目标空间。
这样可以避免磁盘空间在临界值附近反复触发回收。
实际阈值需要根据录像码率、磁盘容量、并发通道数量和设备的存储策略确定,不应直接套用固定数值。
5.5 删除录像时如何避免与录像线程冲突?
假设录像线程正在向某个文件写入,而磁盘回收线程同时删除该文件,可能导致录像失败或者文件状态异常。
一种常见设计是维护录像文件的生命周期状态,并在删除前检查文件是否仍被录像任务使用。
例如:
struct RecordFileInfo {
std::string path;
bool writing = false;
};
回收程序只将 writing == false 的文件作为候选对象。
不过,这个布尔变量本身并不能解决所有并发问题。如果录像线程和回收线程同时访问它,仍然可能发生数据竞争。
更合理的做法是通过统一的文件管理接口、互斥锁或线程安全的状态管理机制,确保检查与状态变更之间不会产生竞争。
另外,不能只根据文件扩展名或文件是否存在判断文件是否正在写入。判断依据应当来自录像管理模块的实际状态。
六、多线程设计:避免数据竞争和资源生命周期问题
NVR 通常需要同时处理多个通道、媒体连接、录像写入、磁盘检测和协议通信,因此多线程是常见的实现方式。
但多线程并不意味着所有模块都应该共享同一批可变对象。
6.1 共享资源为什么容易出问题?
假设程序维护一个通道到媒体源的映射:
std::map<std::string,
std::shared_ptr<MediaSource>> mapMediaSource;
其中,MediaSource 表示某个通道对应的媒体源对象。
当一个线程更新媒体源,另一个线程查询媒体源时,如果没有适当的同步机制,就可能出现数据竞争。
需要区分两个层次:
-
std::map容器本身的并发访问安全性。 -
MediaSource对象内部成员的并发访问安全性。
即使使用了 std::shared_ptr,也不意味着整个映射或媒体源对象自动变成线程安全的。
6.2 使用互斥锁保护共享映射
下面是一个简化的示例:
#include <map>
#include <memory>
#include <mutex>
#include <string>
std::map<std::string,
std::shared_ptr<MediaSource>> mapMediaSource;
std::mutex mtxMapMediaSource;
void updateMediaSource(
const std::string& channel,
std::shared_ptr<MediaSource> source)
{
std::lock_guard<std::mutex> lock(mtxMapMediaSource);
mapMediaSource[channel] = std::move(source);
}
std::lock_guard 的作用是获取互斥锁,并在作用域结束时自动释放锁。
相比手动调用 lock() 和 unlock(),这种方式更不容易因为提前返回或异常而忘记解锁。
查询时也需要对映射进行同步保护:
std::shared_ptr<MediaSource> getMediaSource(
const std::string& channel)
{
std::lock_guard<std::mutex> lock(mtxMapMediaSource);
auto it = mapMediaSource.find(channel);
if (it == mapMediaSource.end()) {
return nullptr;
}
return it->second;
}
这里返回一个 shared_ptr 副本,意味着即使映射中的对应项随后被删除,已经取得该副本的调用者仍然可以保持对象的生命周期。
但这仍然不能保证 MediaSource 内部的成员访问是线程安全的。
6.3 不要在持锁期间执行耗时操作
例如,不建议在持有媒体源映射锁时执行长时间的磁盘写入、网络请求或等待其他线程结束。
否则,其他需要访问该映射的线程可能长时间阻塞。
比较合理的思路是:
-
获取锁,查找并复制所需的智能指针。
-
释放锁。
-
在锁外执行实际业务操作。
这样可以缩短临界区,降低线程之间相互等待的概率。
但需要注意,涉及对象状态变更、资源关闭或删除时,仍然需要专门的生命周期管理规则,不能简单地把所有操作移到锁外。
七、异常处理:断流、磁盘故障与设备重启
对于长期运行的嵌入式 NVR,异常处理能力往往比单次运行速度更加重要。
系统可能遇到以下问题:
-
摄像机断网或停止推流。
-
硬盘被拔出或者文件系统变为只读。
-
录像文件写入失败。
-
可用空间不足。
-
媒体线程异常退出。
-
设备突然断电或进程崩溃。
这些问题需要分别处理,不能简单地统一成“程序出错后重启”。
7.1 区分不同类型的故障
| 故障类型 | 建议处理方向 |
|---|---|
| 摄像机断流 | 记录通道异常,按策略重连 |
| 硬盘不可用 | 停止向故障盘分配新任务,评估切换目标 |
| 文件创建失败 | 检查路径、权限、挂载状态及空间 |
| 磁盘空间不足 | 触发回收;无法恢复时上报告警 |
| 录像线程异常 | 更新任务状态,释放资源并按策略恢复 |
| 进程崩溃 | 保存必要的故障信息,由监督机制决定是否重启 |
需要注意,异常处理应当尽量局部化。
例如,单个通道断流通常不应该导致整个 NVR 退出;单块硬盘故障也不应该在没有必要的情况下影响其他正常硬盘上的录像任务。
7.2 为什么需要看门狗?
嵌入式设备可以使用看门狗或其他监督机制,在应用程序异常退出时重新拉起进程。
但看门狗主要解决的是进程存活和故障恢复问题,并不能代替应用程序内部的异常处理。
如果程序一直运行,却因为死锁而无法正常录像,那么单纯检查进程是否存在可能无法发现问题。
因此,应用程序还应该关注关键业务是否正常,例如:
-
媒体连接状态。
-
录像任务是否持续推进。
-
最近一次成功写入的时间。
-
磁盘是否可用。
-
录像错误是否持续发生。
对于突然断电,还需要考虑文件系统和录像文件的一致性。程序可以尽量完成正常关闭流程,但无法保证在任何断电时刻都能执行清理代码。
因此,除了进程内的处理,还需要合理选择文件系统和存储格式,并在启动后检查未完成的录像文件。
八、配置管理与多项目差异
在实际 NVR 产品中,一套代码可能需要适配多个项目。不同项目可能在通道数量、协议、组播、录像策略和设备配置等方面存在差异。
如果每个项目都复制一套完整代码,后续修复问题时就容易出现重复修改。
更好的思路是将共性逻辑与项目差异分开。
8.1 哪些差异适合通过配置控制?
例如:
-
录像文件保存策略。
-
磁盘回收阈值。
-
通道启用状态。
-
某些可选业务功能。
-
网络地址和端口。
这些属于运行期间可能变化的参数,可以考虑使用配置文件管理。
8.2 哪些差异适合条件编译?
如果某个功能只在特定项目中构建,或者依赖不同的硬件平台、库和编译选项,可以使用 CMake 选项或编译宏控制。
例如:
option(ENABLE_MULTICAST
"Enable multicast support"
OFF)
if(ENABLE_MULTICAST)
target_compile_definitions(
nvr_core
PRIVATE ENABLE_MULTICAST=1
)
endif()
对应的 C++ 代码可以使用:
#ifdef ENABLE_MULTICAST
void initializeMulticast()
{
// 初始化组播相关资源
}
#endif
这里的 nvr_core 是示例目标名称,实际项目应替换为自己的 CMake target。
需要注意,条件编译和运行时配置并不完全等价:
-
条件编译决定某段代码是否参与编译。
-
运行时配置决定已编译功能是否启用。
如果只是改变某个功能的运行策略,通常优先考虑配置化;如果涉及平台依赖、可选组件或编译时功能裁剪,则可以考虑条件编译。
九、常见问题与排查思路
9.1 磁盘空间充足,为什么录像仍然失败?
可能的原因包括:
-
目标路径所在的文件系统并非预期中的硬盘。
-
目录权限或文件系统状态异常。
-
文件描述符或其他系统资源不足。
-
存储模块的磁盘状态尚未更新。
-
多个录像任务竞争同一块硬盘。
-
文件创建成功,但后续写入失败。
建议按照“目标路径 → 实际挂载 → 文件创建 → 数据写入 → 写入结果”逐步排查,而不是只检查 df 显示的可用空间。
9.2 磁盘回收为什么会导致录像中断?
重点检查:
-
回收程序是否可能删除正在写入的文件。
-
回收过程是否与切盘过程并发执行。
-
磁盘状态更新是否及时。
-
删除失败后是否仍然认为空间已经释放。
-
回收后是否重新确认实际可用空间。
特别要注意,删除操作返回成功与预期空间立即增加并不总是等价。例如,仍被进程打开的文件即使目录项已经删除,其占用空间也可能直到最后一个文件描述符关闭后才释放。
9.3 为什么使用了 shared_ptr,程序仍然会崩溃?
shared_ptr 主要管理对象的共享所有权和生命周期,并不能自动解决所有问题。
常见原因包括:
-
空指针访问。
-
多线程同时修改对象内部成员。
-
对象析构时仍有异步任务访问其资源。
-
原始指针或引用在对象销毁后继续使用。
-
多个
shared_ptr独立接管同一个原始指针。 -
使用
shared_ptr管理存在循环引用的对象关系。
因此,除了智能指针,还需要设计清晰的所有权关系、线程退出顺序和对象关闭流程。
9.4 如何判断设备是断电还是程序崩溃?
可以结合进程启动时间、退出日志、系统日志、看门狗行为以及文件系统状态进行分析。
如果程序异常退出后看门狗能够迅速重启,那么前一次进程结束与下一次进程启动之间通常会比较短。
但这不是绝对判断依据:日志可能没有及时刷盘,看门狗可能未能及时执行,设备也可能发生重启或断电。
因此,最好综合多个证据判断,而不是仅凭两条日志之间的时间差作出结论。
十、总结
NVR 是一个涉及网络通信、媒体处理、并发编程和存储管理的综合性系统。要让它长期稳定运行,核心不只是实现某个功能,而是让模块之间的协作关系足够清晰。
本文重点讨论了以下设计原则:
-
明确模块职责。 设备管理、媒体接入、录像管理和存储管理应有清晰的边界。
-
管理好录像生命周期。 启动、写入、停止和异常恢复都需要明确的状态与资源管理。
-
将切盘与磁盘回收分开设计。 前者解决后续录像写到哪里,后者解决空间不足时如何安全释放空间。
-
正确处理并发。 智能指针、互斥锁和线程安全接口需要配合使用。
-
建立分层异常处理机制。 摄像机断流、磁盘故障和进程崩溃应分别处理。
-
合理管理项目差异。 运行时策略优先考虑配置化,编译时功能裁剪可以考虑条件编译。
-
关注长期运行的可靠性。 日志、状态监控、文件一致性检查和故障恢复同样重要。
对于一个已有的 C++ NVR 工程,最值得优先梳理的通常是录像任务的生命周期、磁盘状态管理以及录像写入与空间回收之间的协作关系。这些模块往往相互影响,也是出现录像丢失、文件异常和设备长期运行故障时需要重点检查的地方。
好的 NVR 架构,不是把所有异常都消灭在理想条件下,而是当异常不可避免地发生时,系统依然能够明确问题、限制影响范围,并尽可能恢复正常录像。
- 点赞
- 收藏
- 关注作者
评论(0)