原文链接:https://leandromoreira.com/2019/08/02/linux-ffmpeg-source-internals-a-good-software-design/
介绍
学习 Linux/FFmpeg 的 C 语言部分代码是如何组织的,使其具有可扩展性并表现得像是为"多态性"而设计。特别地,我们将简要探讨 Linux 中"一切皆文件"的概念在源代码层面是如何工作的,以及 FFmpeg 如何快速轻松地添加对新格式和编解码器的支持。
优秀软件设计 - 简介
为了编写有用且长期可维护的软件,我们倾向于寻找模式并将它们归纳为抽象概念,Linux 和 FFmpeg 背后的开发者们似乎也是这样做的。
软件设计
当我们创建软件时,我们正在构建数据结构并定义它们的行为和依赖关系。我们创建和连接它们的方式可以被视为软件的设计/架构。
假设我们正在构建一个编码/解码视频和音频的媒体框架。AV1、H264、HEVC 和 AAC 这些编解码器都执行一些共同的操作,如果我们能够提供一个包含这些共同操作和数据的通用抽象,我们就可以使用这个概念,而不必依赖于特定编解码器做什么的具体实现。
多年来,许多开发者注意到,随着软件复杂性的增长,良好的软件设计是一个不错的投资。
这就是优秀软件设计背后的一个理念,依赖于松散耦合且边界明确的组件。
Ruby 实现示例
让我们用代码来实践这些概念。下面是一个快速的伪媒体流框架,为多种编解码器提供编码和解码功能:
class AV1def encode(bytes)enddef decode(bytes)end
endclass H264def encode(bytes)enddef decode(bytes)end
end# …supported_codecs = [AV1.new, H264.new, HEVC.new]class MediaFrameworkdef encode(type, bytes)codec = supported_codecs.find {|c| c.class.name.downcase == type}codec.encode(bytes)end
end
这段 Ruby 伪代码试图重现我们上面讨论的内容,这里有一个隐含的编解码器必须具有的操作概念,在这种情况下,操作是编码和解码。由于 Ruby 是一种动态类型语言,任何类只要提供这两个操作就可以作为我们的编解码器使用。
开发者有时会使用术语:合约、API、接口、行为和操作作为同义词。
这种设计可能被认为是好的,因为如果我们想添加一个新的编解码器,我们只需提供一个实现并将其添加到列表中,甚至列表也可以以动态方式构建。这样的代码似乎易于扩展和维护,因为它试图保持组件之间的链接较弱(低耦合),并且每个组件只做它应该做的事情(高内聚)。
Rails 框架甚至强制某种代码组织方式,它采用了模型-视图-控制器(MVC)架构。
Golang 实现
当我们使用 Golang 这样的静态类型语言时,我们需要更加正式地描述所需类型,但这仍然是可行的:
type Codec interface {Encode(data []int) ([]int, error)Decode(data []int) ([]int, error)
} type H264 struct {
}func (H264) Encode(data []int) ([]int, error) {// … 大量代码return data, nil
}var supportedCodecs := []Codec{H264{}, AV1{}}func Encode(codec string, data int[]) {// 在这里我们可以选择使用// supportedCodecs[0].Encode(data)
}
Golang 中的接口类型比 Java 的类似构造更强大,因为它的定义完全与实现分离,反之亦然。我们甚至可以使每个编解码器成为 ReadWriter 并在各处使用它。
C 语言实现
在 C 语言中,我们仍然可以创建相同的行为,但方式略有不同:
struct Codec
{*int (*encode)(*int);*int (*decode)(*int);
};*int h264_encode(int *bytes)
{
// …
}*int h264_decode(int *bytes)
{
// …
}struct Codec av1 =
{.encode = av1_encode,.decode = av1_decode
};struct Codec h264 =
{.encode = h264_encode,.decode = h264_decode
};int main(int argc, char *argv[])
{h264.encode(argv[1]);
}
我们首先在通用结构体中定义抽象操作(在这种情况下是函数),然后用具体代码填充它,比如 av1 编解码器的实际编码和解码代码。
许多其他语言也有类似的机制,可以调度方法或函数,就好像它们是约定协议的一部分,然后系统集成代码只需处理这些高级抽象。
Linux 内核 - 一切皆文件
你是否听说过 Linux 中"一切皆文件"的表达?这个想法是为 Linux 中所有类型的资源提供一个通用接口,例如,Linux 将网络套接字、特殊文件(如 /proc/cpuinfo)甚至 USB 设备都视为文件。
这是一个强大的思想,可以使编写或使用 Linux 程序变得容易,因为我们可以依赖于这个叫做"文件"的抽象所提供的一组众所周知的操作。让我们看看实际应用:
# 第一个例子最简单,我们只是在读取一个纯文本文件
$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
…# 现在,我们认为在读取一个文件,但实际上不是!(技术上来说是的...)
$ cat /proc/meminfo
MemTotal: 2046844 kB
MemFree: 546984 kB
MemAvailable: 1535688 kB
Buffers: 162676 kB
Cached: 892000 kB# 最后,我们打开一个文件(使用 fd=3)用于读写
# 这个"文件"实际上是一个套接字,然后我们向这个文件发送请求 >&3
# 并从同一个"文件"中读取数据
$ exec 3<> /dev/tcp/www.google.com/80
$ printf 'HEAD / HTTP/1.1\nHost: http://www.google.com\nConnection: close\n\n' >&3
$ cat <&3
HTTP/1.1 200 OK
Date: Wed, 21 Aug 2019 12:48:40 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
# ...更多响应头...
这只有在文件的概念(数据结构和操作)被设计为子系统之间的主要通信方式时才可能实现。以下是 file_operations API 的概要:
struct file_operations {struct module *owner;loff_t (*llseek) (struct file *, loff_t, int);ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);//…
}
struct file_operations 定义了文件概念可以做什么的预期操作。
const struct file_operations ext4_dir_operations = {.llseek = ext4_dir_llseek,.read = generic_read_dir,//..
};
这里我们可以看到 ext4 文件系统对目录操作的实现。
static const struct file_operations proc_cpuinfo_operations = {.open = cpuinfo_open,.read = seq_read,.llseek = seq_lseek,.release = seq_release,
};
甚至 cpuinfo proc 文件也是基于这个抽象实现的。当你在 Linux 下操作文件时,实际上是在与 VFS 系统打交道,这个系统会委托给适当的文件实现。
FFmpeg - 格式
以下是 FFmpeg 流程/架构的概述,显示内部组件主要链接到 AVCodec 等抽象概念,而不是直接链接到它们的实现,如 H264、AV1 等。
对于输入文件,FFmpeg 创建一个名为 AVInputFormat 的结构,它由任何希望作为输入使用的格式(视频容器)实现。MKV 文件用其实现填充这个结构,MP4 格式也一样。
typedef struct AVInputFormat {const char *name;const char *long_name;const char *extensions;const char *mime_type;ff_const59 struct AVInputFormat *next;int raw_codec_id;int priv_data_size;int (*read_probe)(const AVProbeData *);int (*read_header)(struct AVFormatContext *);}// matroskaAVInputFormat ff_matroska_demuxer = {.name = "matroska,webm",.long_name = NULL_IF_CONFIG_SMALL("Matroska / WebM"),.extensions = "mkv,mk3d,mka,mks",.priv_data_size = sizeof(MatroskaDemuxContext),.read_probe = matroska_probe,.read_header = matroska_read_header,.read_packet = matroska_read_packet,.read_close = matroska_read_close,.read_seek = matroska_read_seek,.mime_type = "audio/webm,audio/x-matroska,video/webm,video/x-matroska"
};// mov (mp4)AVInputFormat ff_mov_demuxer = {.name = "mov,mp4,m4a,3gp,3g2,mj2",.long_name = NULL_IF_CONFIG_SMALL("QuickTime / MOV"),.priv_class = &mov_class,.priv_data_size = sizeof(MOVContext),.extensions = "mov,mp4,m4a,3gp,3g2,mj2",.read_probe = mov_probe,.read_header = mov_read_header,.read_packet = mov_read_packet,.read_close = mov_read_close,.read_seek = mov_read_seek,.flags = AVFMT_NO_BYTE_SEEK | AVFMT_SEEK_TO_PTS,
};
这种设计允许新的编解码器、格式和协议更容易集成和发布。DAV1d(一种开源 AV1 实现)于今年 5 月集成到 FFmpeg 中,你可以查看提交差异了解它是多么容易实现。最终,它需要将自己注册为可用的编解码器并遵循预期的操作。
+AVCodec ff_libdav1d_decoder = {
+ .name = "libdav1d",
+ .long_name = NULL_IF_CONFIG_SMALL("dav1d AV1 decoder by VideoLAN"),
+ .type = AVMEDIA_TYPE_VIDEO,
+ .id = AV_CODEC_ID_AV1,
+ .priv_data_size = sizeof(Libdav1dContext),
+ .init = libdav1d_init,
+ .close = libdav1d_close,
+ .flush = libdav1d_flush,
+ .receive_frame = libdav1d_receive_frame,
+ .capabilities = AV_CODEC_CAP_DELAY | AV_CODEC_CAP_AUTO_THREADS,
+ .caps_internal = FF_CODEC_CAP_INIT_THREADSAFE | FF_CODEC_CAP_INIT_CLEANUP |
+ FF_CODEC_CAP_SETS_PKT_DTS,
+ .priv_class = &libdav1d_class,
+ .wrapper_name = "libdav1d",
+};
无论我们使用什么语言,我们都可以(或至少尝试)构建具有低耦合和高内聚的软件,这两个基本特性可以使软件更容易维护和扩展。
找到具有 2 个许可证类型的类似代码