大华NVR硬盘格式之探索
前言
TL;DR:专有格式是坏的,但是好像这个专有格式没有那么坏
如果想要从大华NVR上取下的硬盘中dump出能被正常工具正常使用的视频,你可能会首先发现这个硬盘不能直接挂在,然后也许在咨询客服之后使用大华官方工具尝试,结果被官方工具的反人类UX以及难以言表的速度创飞。大华的NVR并不使用一般的文件系统和分区表,而是使用了一套自己定义的,专有的硬盘存储格式。在Linux上的特征是:
- 在且仅在磁盘尾部有一个16GB的xfs分区。这个分区内部是NVR的一些数据库之类的文件。
- 从硬盘0x0位置开始,7个字节,是ASCII的
DHFS4.1
这篇Blog Post记载这个文件系统的一些特性,以及如何进行高效的,自动化的视频提取。
接下来的分析参考一块ST8000NM000A-2KE101,录像机采集15个通道。
磁盘元数据
Sector 0
零扇区的开头7字节为前面讲到的DHFS4.1 Magic Number. 同时其结尾携带一个正确格式的GPT保护性MBR记录。
| 偏移 | 大小 | 字段 | 观察值 |
|---|---|---|---|
0x0000 | 7 | DHFS 魔数,ASCII | DHFS4.1 |
0x0020 | 43 | 卷 UUID,ASCII,以 \n 结尾 | uuid:{9472a9a2-2d23-6b99-1356-a5852ba70b75} |
0x01BE | 16 | MBR 条目 1 | 类型 0xEE,LBA 1,大小 0xFFFFFFFF |
0x01CE | 48 | MBR 条目 2-4 | 零 |
0x01FE | 2 | MBR 签名 | 55 AA |
这里的“卷UUID”是一个ASCII字符串,字面值如上所述,而不是通常二进制存储的UUID。应当为大华录像机私有读取的值。
GPT分区表
LBA 1 存放着一个合法的EFI PART头,LBA 2 存放合法的GPT条目数组。其中有一个条目,位于磁盘尾部:
| 字段 | 值 |
|---|---|
| 备份 LBA / 磁盘大小 | 15,627,167,407 -> 8,001,109,712,896 B |
| 首个 / 末个可用 LBA | 18 / 15,627,167,390 |
| 条目数组 | LBA 2,64 x 128 B |
| 分区 1 类型 | EBD0A0A2-B9E5-4433-87C0-68B6B72699C7(Microsoft Basic Data) |
| 分区 1 名称 | msftdata |
| 分区 1 范围 | LBA 15,594,782,720 - 15,627,124,736(16.56 GB) |
| 分区 1 属性 | bit 63 - 禁止自动挂载 |
因此从识别GPT的磁盘工具来看,这块盘的结构是这样的:
+--+--------------------------------------------------------+--------+--+
|MG| Unassigned 7.98 TB |msftdata|BG|
+--+--------------------------------------------------------+--------+--+
^ 16.56GB ^
MBR + GPT Header Backup GPTDHFS分区表 (DPT)
GPT识别的未分配空间内实际上是DHFS的数据存储区域。DHFS对这个区域也进行了分区。分区表在LBA 30(base0x3C00),观察到的结构如下:
0x3C00 +--------------------------------------+ header 64 B
0x3C40 +--------------------------------------+ entry 0 64 B
0x3C80 +--------------------------------------+ entry 1 64 B
0x3CC0 +--------------------------------------+ entry 2 64 B
0x3D00 +--------------------------------------+ entry 3 64 B
0x3D40 | "DHFS_DPT:3.0" | trailerDPT Header
64字节:
| 偏移 | 类型 | 字段 | 观察值 |
|---|---|---|---|
+0x00 | 40 B | zero | |
+0x28 | u32 | Unknown | 3 |
+0x2C | u32 | Unknown | 1 |
+0x30 | u32 | Partition count | 4 |
+0x34 | 12 B | zero |
DPT Entry
64字节:
| 偏移 | 类型 | 字段 | 观察值 |
|---|---|---|---|
+0x00 | 8 B | zero | |
+0x08 | u32 | Superblock偏移,单位blocks | 均为34: 17,408 B |
+0x0C | 16 B | zero | |
+0x1C | u32 | Unknown | 均为1 |
+0x20 | u32 | zero | |
+0x24 | u64 | 分区起始block | 见下 |
+0x2C | u32 | 分区长度,单位blocks | 见下 |
+0x30 | u32 | zero | |
+0x34 | u32 | 终止标记 | 最后一个条目为AA55AA55,其余为00000000 |
+0x38 | 8 B | zero |
参考磁盘的分区细节
| 分区 | 起始(块) | 起始(字节) | 长度(块) | 长度(字节) | 分片数 | 视频数 |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 3,907,013,292 | 2,000,390,805,504 | 953,829 | 1,052 |
| 1 | 3,907,013,376 | 2,000,390,869,632 | 3,907,013,208 | 2,000,390,762,496 | 953,829 | 1,052 |
| 2 | 7,814,026,752 | 4,000,781,733,888 | 3,907,013,124 | 2,000,390,719,488 | 953,829 | 1,060 |
| 3 | 11,721,040,128 | 6,000,908,225,536 | 3,750,732,508 | 1,920,375,044,096 | 915,676 | 1,005 |
Superblock
每个分区的superblock位于该分区起始位置之后的第17,408字节处(34个块)。Superblock的大小可能为512字节或更大,下表列出已知的关键字段(offset为相对superblock基址):
| 偏移 | 类型 | 字段 | 观察值 | 说明 |
|---|---|---|---|---|
0x10 | u32 | 首个录制时间戳 | packed | 分区内最早的录制时间(格式见下文"时间戳编码") |
0x14 | u32 | 最后录制时间戳 | packed | 分区内最新的录制时间(格式见下文"时间戳编码") |
0x2C | u32 | Block大小(字节) | 512 | 磁盘block大小,恒定值 |
0x30 | u32 | 分片大小(block数) | 4096 | 单位为block,即2 MiB |
0x38 | u32 | 预留分片数 | 2120 | 保留不用的分片数 |
0x44 | u32 | 描述符表偏移(block数) | 2048 | 相对分区起始位置 |
0x48 | u32 | 分片区偏移(block数) | 126976 | 分片数据的起始位置 |
0x4C | u32 | 分片总数 | 见下表 | 可用分片的总数 |
0xF8 | u32 | 日志区偏移(block数) | 待探索 | 可能存储日志或元数据 |
各分区Superblock字段对比
几乎所有字段都在四个分区间保持一致,只有两个字段会变化:
| 字段 | P0 | P1 | P2 | P3 |
|---|---|---|---|---|
| Block大小 | 512 | 512 | 512 | 512 |
| 分片大小(块数) | 4096 | 4096 | 4096 | 4096 |
| 预留分片数 | 2,120 | 2,120 | 2,120 | 2,120 |
| 描述符表偏移 | 2,048 | 2,048 | 2,048 | 2,048 |
| 分片区偏移 | 126,976 | 126,976 | 126,976 | 120,832 |
| 分片总数 | 953,829 | 953,829 | 953,829 | 915,676 |
注意我们不能假设所有分区的几何参数都相同。虽然在这个参考磁盘上前三个分区完全一致,但分片区偏移和分片数必须从各分区的superblock单独读取。
录制时间窗口
每个分区记录了自己内部视频数据的时间范围,这四个时间窗口是完全分离且连续的:
| 分区 | 首个录制时间 | 最后录制时间 | 跨度 |
|---|---|---|---|
| 0 | 2025-06-01 23:35:27 | 2025-06-04 23:19:12 | 2.98天 |
| 1 | 2025-06-04 23:19:08 | 2025-06-10 22:47:07 | 5.98天 |
| 2 | 2025-06-10 22:47:00 | 2025-06-13 22:31:53 | 2.99天 |
| 3 | 2025-06-13 22:31:47 | 2025-06-16 19:23:32 | 2.95天 |
每个分区的起始时间比前一个分区的结束时间早4-7秒,这表示录制的边界视频同时出现在两个相邻分区中。整个磁盘覆盖了将近14天19小时的录像。由此我们可以看出,NVR采用的是全磁盘ring buffer策略,而不是每分区独立。磁盘将分区0、1、2、3按顺序填充,然后回到分区0覆盖最早的数据。新旧数据的分界线会在某个分区边界上。
物理排列与寻址
磁盘结构
DISK - 8 TB
+---+-------------+-------------+-------------+-------------+
|hdr| Partition 0 | Partition 1 | Partition 2 | Partition 3 |
+---+-------------+-------------+-------------+-------------+
0x0 0 2.00 TB 4.00 TB 6.00 TB分区内部结构
PARTITION - ~2.00 TB
+----------+-----------+-----------+-----------------------------+
| Superblk | Descr.Tbl | Reserved | Fragment Area |
| | | | |
| 17,408B | ~29.11 MiB| ~2 MiB | 953,829 x 2 MiB = ~1.92 TB |
+----------+-----------+-----------+-----------------------------+
0 2,048 126,976
(blocks) (blocks)分片(Fragment)
DHFS的最小分配单位是分片,大小为2 MiB。一个分片完全对应描述符表中的一个条目。
地址计算
描述符表的索引与分片区的索引是直接对应的,没有额外的映射信息。给定分区编号和分片索引,可以计算出分片在磁盘上的绝对字节偏移:
其中
对应的描述符在磁盘上的绝对字节偏移为:
描述符是固定32字节的结构。
描述符区域
在分片区偏移和描述符表偏移之间,预留了一段区域(可能用于备份或扩展):
| 分区 | 描述符区域大小 | 描述符表大小 | 占比 |
|---|---|---|---|
| 0-2 | 61.00 MiB | 29.11 MiB | 2.10x |
| 3 | 58.00 MiB | 27.94 MiB | 2.08x |
区域大小的计算规律为:,然后对齐到MiB边界。这可能表示设计上预留了表的两份副本加一些额外空间。
描述符与DHAV流
每个描述符是32字节的结构,描述一个分片内的DHAV视频流。DHAV(Dahua Audio-Visual)是大华专有的视频编码格式,通常包含:
- 视频编码:H.264 Main Profile,2560x1440 分辨率,25 fps
- 音频编码:PCM A-law,8 kHz 单声道
一个分片可能包含多个视频帧,或者一个完整的视频文件,取决于记录时的配置。
描述符表
描述符表是整个DHFS的元数据存储。每个分片对应一个32字节的固定描述符,记录该分片的内容。
| 偏移 | 类型 | 字段 | 说明 |
|---|---|---|---|
0x00 | u8 | Type | 0=free, 1=video head, 2=continuation, 254=reserved |
0x01 | u8 | Channel | 摄像头编号 = byte - 47,以ASCII存储 |
0x02 | u16 | 分片计数/序号 | Type 1: 总分片数-1; Type 2: 视频内的序列号 |
0x04 | u32 | Begin timestamp | 开始时间戳(压缩格式,见下节) |
0x08 | u32 | End timestamp | 结束时间戳(压缩格式) |
0x0C | u32 | Next descriptor | 下一个分片的索引号;0表示链表终止 |
0x10 | u32 | Last frag length | 仅在Type 1中有效,末分片长度(block数)x512得字节数 |
0x14 | u32 | Previous descriptor | 前一个分片的索引号 |
0x18 | u32 | Head descriptor | 该视频的首分片索引,也是视频的标识符 |
0x1C | u32 | - | 在所有观察中都为零 |
注意,一个视频并不是连续的分片,而是通过next字段形成的链表。由于NVR使用多通道并发录制,片段按轮转方式分配,所以单个视频的分片会散布在整个分片区,中间相隔大致等同于通道数量的分片。
时间戳编码(Packed Timestamp)
一个32位无符号整数编码完整的日期和时间。描述符、DHAV帧头和分区Superblock都用这个格式存储,由此帧级时间戳可以和描述符时间窗口直接进行对比。
| 比特位 | 宽度 | 字段 | 编码方式 |
|---|---|---|---|
| 31-26 | 6 | 年份 | 从2000年开始的偏移 |
| 25-22 | 4 | 月份 | 1-12 |
| 21-17 | 5 | 日期 | 1-31 |
| 16-12 | 5 | 小时 | 0-23 |
| 11-6 | 6 | 分钟 | 0-59 |
| 5-0 | 6 | 秒数 | 0-59 |
Python解码示例:
second = timestamp & 0x3F
minute = timestamp >> 6 & 0x3F
hour = timestamp >> 12 & 0x1F
day = timestamp >> 17 & 0x1F
month = timestamp >> 22 & 0x0F
year = (timestamp >> 26 & 0x3F) + 2000因为字段按年->月->日->时->分->秒的比特序递减排列,原始32位值可以直接作为时间的大小比较。这意味着可以用整数比较做时间窗口过滤,无需为每一帧构造datetime对象。
DHAV流格式
连接一个视频的所有分片链(按chain顺序)得到一个Dahua DHAV 流。这和通过大华专用工具导出的.dav文件在结构上大致相同,但是存在下面的这些问题让我们并不能直接这样做:
Inter-fragment Padding
大约每个2 MiB分片中会出现一个4096字节的非DHAV块。在一个920分片的视频中观察到922个这样的间隙,总共4,241,455字节,占整个流的0.22%。
这些填充块总是在帧边界上,所以如果demuxer能在下一个DHAV magic重新同步就能跨过它们。但是ffmpeg的dhav dmemuxer不能 。因此直接给ffmpeg的话会第一个填充块处halt。如果直接用ffmpeg -f dhav处理连接后的流,会从3600秒的录像中仅提取244秒,且返回状态码为0(成功)。因此,需要一个前置步骤filter掉所有不合法的DHAV块。
Head分片中的Stale Frames(旧帧)
DHFS是一个ring buffer。视频的首个fragment仍然可能包含属于它覆盖的上一个录制的帧:基准视频中观察到204个这样的帧,时间戳比描述符时间窗口早45天。这些仍然是结构上有效的DHAV,所以即使经过了filter,它们仍然会幸存,并在喂给ffmpeg的流中产生DTS不连续,导致下游muxer丢弃后续数据。因此需要额外filter掉帧上时间戳落在描述符的begin/end窗口之外的帧。考虑到这个时间戳设计的很不错,所以直接用整数比较就行,不需要解析后做比较。
DHII Header Block
一个视频的首个fragment以DHII magic开头,而不是DHAV。
44 48 49 49 00 e0 1f 00 01 00 00 00 01 00 00 00 DHII ....
40 00 00 00 f8 4d 02 00 00 00 00 00 00 00 00 00DHAV Frame Header
| 偏移 | 类型 | 字段 | 说明 |
|---|---|---|---|
0x00 | 4 B | Magic DHAV | 帧同步标记 |
0x04 | u8 | Type | 帧类型 |
0x05 | u8 | Subtype | 帧子类型 |
0x06 | u8 | Channel | 摄像头通道号 |
0x07 | u8 | Frame subnumber | 帧子编号 |
0x08 | u32 | Frame number | 帧编号 |
0x0C | u32 | Frame length | 帧长度(包括头部) |
0x10 | u32 | Timestamp, packed | 压缩时间戳(见前面) |
DHAV存储layout
一个视频在磁盘上不是连续存储的。因为我们参考的这个NVR有15个摄像头并发录制,分片分配器采用轮转策略:每次分配一个分片给某个通道,然后轮到下一个通道。结果是单个视频的数据被分散在约15倍其自身大小的范围内。
通过描述符的next字段形成链表,这样虽然物理上分片互相隔开,但逻辑上它们是连接的:
Descriptor Table
+--+--+--+-----+-----+-----+--+-----+--+
|0 |1 |2 |....|ch1_2|ch2_1| |ch1_3| | <- 描述符索引
+--+--+--+-----+-----+-----+--+-----+--+
|
+-> video_A head (ch1)
next: 16 (ch1_2)
ch1_2: next: 31 (ch1_3)
Fragment Area
+------+------+------+-----+------+------+-----+------+------+
|ch1_0 |ch2_0 |ch3_0 |.....|ch1_1 |ch2_1 |.....|ch1_2 |ch2_2 |
+------+------+------+-----+------+------+-----+------+------+
0 1 2 16 17 31 32
| | |
+---- video_A head +-- video_A frag 2 +-- video_A frag 3
video_A frag 1访问性能的影响很大:
- 顺序读取分片区能达到 202 MB/s
- 按视频链表跳跃读取仅 56 MiB/s(性能下降3.6倍)
这是因为链表访问产生大量随机寻址。一个920分片的视频有919个不连续点,典型跳跃为+15或+16。
因此要实现一个高效的提取器,合理的方式应该是:
- 创建
num_channels这么多个处理器,分别先向一个SSD或内存盘上buffer中的文件写入 - 线性地扫描原始磁盘,解析 fragment 属于哪个channel,并路由给对应处理器
- 按预先策略对文件分片,完成的分片在串行地移动到目标磁盘上
注意这里的「磁盘」,它确定性地指代具有旋转盘片和摆动磁头的机械磁存储介质。当然也许线性的磁存储介质也可以使用这样的方式进行读写优化。