认识 deb 文件
在 Debian 系列系统中,.deb 包是软件分发的标准格式,其本质是一个 ar 归档文件,内部包含多个压缩的 tar 文件和其他元数据文件。以下是 .deb 文件的组成结构和提取方法:
1. deb 包的结构解析
一个 .deb 文件由 3 个核心文件组成,按顺序排列在 ar 归档中,使用编辑器查看一个 deb 包也能看到:
如:man-db 的二进制包,头部数据是:
!<arch>
debian-binary 1582650825 0 0 100644 4
2.0
control.tar.xz 1582650825 0 0 100644 20524
control 数据之后就是:
data.tar.xz 1582650825 0 0 100644 1223816
1、debian-binary
作用: 标识 deb 格式版本(纯文本文件)。
内容: 通常为 2.0,表示遵循 Debian Package Format 2.0 规范。
2、control.tar.*
作用: 存储包的元数据和控制脚本(如安装/卸载前后执行的脚本)。
压缩格式: 可能是 xz (.tar.xz) 或 gzip (.tar.gz),具体取决于打包工具。
内容:
control: 包名、版本、依赖、描述等元信息。
postinst/prerm: 安装后/卸载前的执行脚本。
md5sums: 包内文件的校验和。
3、data.tar.*
作用: 存储实际要安装到系统中的文件(如二进制、配置文件、文档等)。
压缩格式: 同样可能是 xz、gzip 或 bzip2。
内容: 文件路径与系统安装路径一致(如 /usr/bin/, /etc/)
处理 deb 文件
上面已经介绍过 deb 文件的基本组成,它是一个 ar 归档文件。当我们使用 dpkg_ar_open 打开 deb 文件,从 deb 读出来 data.tar.* 等数据流。读取文件内容,判断是什么类型的压缩包格式,决定解压工具用哪个 decompressor。假设 tar.xz 文件,对应的解压器就是 xz,解压函数 decompressor_xz。解压后的 TAR 归档文件经过 GZIP 压缩后的组合格式,需要分为两层来解析:
外层:XZ 压缩层
【在 dpkg-deb 子进程 c2 中把它解压了】
XZ 文件由多个 数据块(Blocks) 组成,每个块包含以下部分:
组成部分 | 描述 |
---|---|
文件头 | 12 字节标识,包含魔数 FD 37 7A 58 5A 00(ASCII 为 "FD7zXZ\0") |
压缩数据块 | 使用 LZMA2 压缩后的数据,可能分多个块以提高并行处理效率。 |
索引(Index) | 记录所有压缩块的偏移和大小,用于快速定位。 |
文件尾 | 包含 CRC-32 校验和,验证文件完整性。 |
使用 LZMA2 压缩算法对原始 TAR 归档数据进行压缩。
内层:TAR 归档层
【dpkg 父进程拿到这个归档数据,进行tar_extractor 提取】
压缩层解压后得到原始的 TAR 归档文件,其结构由多个 文件条目(File Entry) 组成,每个条目包含 512 字节的头部(Header) 和 文件数据:
文件头(Header Block,512 字节):记录文件元数据(文件名、大小、权限等)。
文件数据:实际文件内容,按 512 字节对齐(不足部分填充 \0)。
结束标记:两个连续的 512 字节全 \0 块,表示 TAR 文件结束。
字段名 | 字节范围 | 说明 | 示例值(ASCII字符串) |
---|---|---|---|
name | 0-99 | 文件名(含路径) | "dir/file.txt" |
mode | 100-107 | 文件权限(八进制) | "0644" |
uid | 108-115 | 用户 ID(八进制) | "1000" |
gid | 116-123 | 组 ID(八进制) | "1000" |
size | 124-135 | 文件大小(字节,八进制) | "12345" → 十进制 5349 |
mtime | 136-147 | 最后修改时间(八进制,Unix 时间戳) | "1620000000" → 十进制时间 |
typeflag | 156 | 文件类型标志 | '0'(普通文件) |
linkname | 157-256 | 链接目标路径(仅符号链接) | "/usr/bin/sh" |
magic | 257-262 | 魔数(标识格式版本) | "ustar" |
checksum | 148-155 | Header 校验和(八进制) | "0000000"(需重新计算验证) |
#define DEBMAGIC "debian-binary"
#define ADMINMEMBER "control.tar"
#define DATAMEMBER "data.tar"
进程间关系介绍
父进程 dpkg,接受许多 deb 包作为参数,解析参数并挨个处理 deb 文件。处理时,fork 若干个子进程来一起工作:
第一个子进程 dpkg-deb --control + deb包
这个进程将包的控制脚本等提取到临时的 ci 目录,一般是 /var/lib/dpkg/tmp.ci
执行的函数是 extracthalf(debar, dir, DPKG_TAR_EXTRACT, 1);
第二个子进程 dpkg-deb --fsys-tarfile +deb包
这个进程将 deb 包解压,并将数据流信息 data.tar 直接写到标准输出中。
执行的函数是 extracthalf(debar, NULL, DPKG_TAR_PASSTHROUGH, 0);
为了实现上面两个功能,函数 extracthalf 也会根据情况 fork 两到三个子进程,使用过滤器模式将解压提取文件的过程通过管道串起来。
管道 | 进程端操作 | 数据方向 | 数据类型 |
---|---|---|---|
p1 | c1: 关闭读端(p1[0]),写入 p1[1] c2: 关闭写端(p1[1]),读取 p1[0] | c1 → c2 | 原始压缩数据 |
p2 | c2: 关闭读端(p2[0]),写入 p2[1] c3: 关闭写端(p2[1]),读取 p2[0] | c2 → c3 | 解压后的 tar 数据 |
.deb 文件 → c1 (原始数据) → p1 → c2 (解压) → p2 → c3 (tar处理) → 输出结果
不同解压参数时 c2 进程的通信差异:
1、DPKG_TAR_PASSTHROUGH 时,无p2管道,从c1进程读出来的数据,经过解压后,直接写到标准输出。
2、DPKG_TAR_EXTRACT 时,创建p2管道,将 c1 进程的数据,解压后,传给管道 p2 的读端。
提取 control.tar.*
以 control.tar.xz 数据流为例,对应的解压参数是 DPKG_TAR_EXTRACT,有三个子进程两条管道:
第一个子进程 c1
执行 fd_fd_copy (ar->fd, p1[1], memberlen, &err), 将ar->fd(即.deb文件的内容)通过fd_fd_copy复制到p1的写端。
第二个子进程 c2
从管道 p1 收到 deb 数据流,通过 decompress_filter 解压,将解压后的 tar 归档流写入 p2。
第三个子进程 c3
提取 tar 文件,c2 和 c3 实现的类似这样一条命令 xz -dc archive.tar.xz | tar -x file/to/extract
提取 data.tar.*
对应的解压参数是 DPKG_TAR_PASSTHROUGH,有两个子进程一条管道:
第一个子进程 c1
执行 fd_fd_copy (ar->fd, p1[1], memberlen, &err), 将ar->fd(即.deb文件的内容)通过fd_fd_copy复制到p1的写端。
第二个子进程 c2
从管道 p1 收到 deb 数据流,通过 decompress_filter 解压,将解压后的 tar 归档流写到标准输出。
分析 extracthalf 函数,它的第一个参数是 deb 包,第二个参数是解压的目标路径,第三个是解压选项,第四个admininfo 。【在解析到 data 这一节数据时,如果这个参数不为0就跳过 fd_skip】
解压选项 DPKG_TAR_PASSTHROUGH (0) 表示直接输出压缩文件内容,不做任何处理【输出到管道,被下一个进程处理】
TAR归档数据流
父进程 dpkg,在 fork 子进程来执行对 data.tar.* 提取的时候,创建了管道,并在子进程中将管道的写端复制到标准输出。这样的目的,是子进程中写到标准输出的信息都会写入管道的写端。上面第二个子进程 c2 将解压后的 tar 归档写到标准输出,就这样传给了 dpkg 主进程。
m_pipe(p1);
push_cleanup(cu_closepipe, ehflag_bombout, 1, (void *)&p1[0]);
pid = subproc_fork();
if (pid == 0) {
m_dup2(p1[1],1); close(p1[0]); close(p1[1]);
execlp(BACKEND, BACKEND, "--fsys-tarfile", filename, NULL);
……
}
close(p1[1]);
p1[1] = -1;
……
tc.pkg= pkg;
tc.backendpipe= p1[0];
tc.pkgset_getting_in_sync = pkgset_getting_in_sync(pkg);
……
rc = tar_extractor(&tar);
这个操作很重要,接下来的 tar_extractor 就是因为这个操作拿到了数据流。tar_extractor 函数的主要功能是从一个 tar 归档文件中提取内容。它通过 tarfileread 函数从管道读取 tar 文件数据流,从头部信息解析每个文件的元数据(如文件名、文件类型、权限等),接着根据文件类型执行相应的操作(比如提取文件、创建目录、创建符号链接等)。
分析这个函数,多次用到了 ops->mkdir ops->extract_file 等,在 tar_operations 中对应的都是 tarobject。
static const struct tar_operations tf = {
.read = tarfileread,
.extract_file = tarobject,
.link = tarobject,
.symlink = tarobject,
.mkdir = tarobject,
.mknod = tarobject,
};
tarobject 函数在 archive.c 中定义,,它的主要作用是处理 tar 归档文件中的单个对象(如文件、目录、符号链接等)。根据对象的类型执行相应的操作,包括提取文件、创建目录、处理符号链接等。
前面已经介绍过TAR归档文件的数据组成:
文件头(Header Block,512 字节):记录文件元数据(文件名、大小、权限等)。
文件数据:实际文件内容,按 512 字节对齐(不足部分填充 \0)。
结束标记:两个连续的 512 字节全 \0 块,表示 TAR 文件结束。
解析tar包头部信息
函数 tar_header_decoder 映射 512 字节的头部信息,解析 tar_header 并填充 tar_entry 结构。比较重要的的是 linkflag,dpkg 代码中定义的结构名称是 linkflag,不过在标准 tar 规范中叫 typeflag。偏移位置和大小都正确,名字不一样不影响解析,根据这个字段的信息可以知道数据流中的是普通文件还是目录、链接文件等。
struct tar_header {
char name[100];
char mode[8];
char uid[8];
char gid[8];
char size[12];
char mtime[12];
char checksum[8];
char linkflag; // tar规范中对应的字段是typeflag,位于第156字节
char linkname[100];
/* Only valid on ustar and gnu. */
char magic[8]; // 根据标准 magic 从 257 开始
char user[32];
char group[32];
char devmajor[8];
char devminor[8];
/* Only valid on ustar. */
char prefix[155];
};
提取文件和目录
从TAR归档中解析到文件的类型,执行对应的操作。tarobject 在实际提取压缩文件的时候,先 setupfnamevbs 追加了后缀名 .dpkg-tmp, .dpkg-new 等,比如:
setupvnamevbs 中的 fnamevb.buf, fnametmpvb.buf, fnamenewvb.buf 分别是:
main='/opt/usr/lib/aarch64-linux-gnu/libcurl.so.4'
tmp='/opt/usr/lib/aarch64-linux-gnu/libcurl.so.4.dpkg-tmp'
new='/opt/usr/lib/aarch64-linux-gnu/libcurl.so.4.dpkg-new'
顺便思考一下,为什么 control 的解压参数是 EXTRACT,而数据文件的解压参数是 PAATHROUGH? 为什么实际提取的时候,增加了上面这些后缀?
在更新或安装文件时,文件内容可能分多次写入磁盘。如果直接覆盖原文件,中途发生系统崩溃或进程终止,会导致文件处于 不完整状态。通过将新内容先写入 .dpkg-tmp
或 .dpkg-new
后缀的临时文件,仅在完全写入成功 后,通过 rename()
系统调用将临时文件 原子性重命名 为最终文件名。此操作在文件系统层面是原子的,确保用户要么看到旧文件,要么看到完整的新文件,永远不会处于中间状态。
通过为文件添加 .dpkg-tmp
和 .dpkg-new
后缀,dpkg
实现了以下核心目标:
原子性:确保文件操作要么完全成功,要么完全失败。
可靠性:防止因意外中断导致的数据不一致。
可维护性:简化错误恢复和系统监控流程。
完成文件落盘操作
在 tarobject 的最后,已经执行过 tarobject_extract 了,这时候去做重命名。重命名有两个时机:
a、一个就在 tarobject的最后,直接 rename(fnamenewvb.buf, fnamevb.buf);
b、另一个是延迟,对于普通文件或者是硬链接、软链接文件,都会给 namenode->flags 增加掩码 FNNF_DEFERRED_RENAME,
tar_deferred_extract 函数去执行 rename(fnamenewvb.buf, fnamevb.buf) 来完成重命名。
只要完成了rename,文件状态就加上掩码 FNNF_PLACED_ON_DISK,表明落盘了。
数据库更新
提取包文件并更新数据库的函数调用链如下:
archivefiles -> process_archive-> modstatdb_note-> modstatdb_note_core-> varbufrecord (&uvb, pkg, &pkg->installed) -> fip->wcall -> varbuf_add_str
Debian 包管理中,在安装时有两个目录,分别是安装目录和管理目录:
安装目录 instdir
存放软件的实际文件:安装目录是软件包内文件(如二进制程序、库、配置文件等)被解压并复制到系统中的目标路径。
管理目录
记录软件包元数据:存储 dpkg 管理软件包所需的状态信息,例如:
/var/lib/dpkg:核心数据库,记录所有已安装软件包的状态(status 文件),包的本地数据库就是这个 status 文件,读写数据库也就是对这个文件的读写操作。
包的集合关系
为了能有组织的理解系统中已安装包的结构关系,需要关注一下两个数据结构。在 lib/dpkg/dpkg.db.h 使用这样的结构来表示具有同样包名的可用包与已安装包:
struct pkgset {
struct pkgset *next;
const char *name;
struct pkginfo pkg;
struct {
struct deppossi *available; // 正在被安装的包
struct deppossi *installed; // 已经安装的包
} depended;
int installed_instances;
};
struct pkginfo {
struct pkgset *set;
struct pkginfo *arch_next;
enum pkgwant want;
/** The error flag bitmask. */
enum pkgeflag eflag;
enum pkgstatus status;
enum pkgpriority priority;
const char *otherpriority;
const char *section;
struct dpkg_version configversion;
// pkgbin 是一个描述二进制包信息的数据结构,包括:depends、arch、description、maintainer、source、installedsize、version、conffiles 等
struct pkgbin installed;
struct pkgbin available;
struct perpackagestate *clientdata;
struct archivedetails *archives;
struct {
/* ->aw == this */
struct trigaw *head, *tail;
} trigaw; // trigger await 在触发器那块应该会用到
/* ->pend == this, non-NULL for us when Triggers-Pending. */
struct trigaw *othertrigaw_head;
struct trigpend *trigpend_head;
……
};
这两个都是链表结构,pkginfo 链表保存同一个包名的不同架构包信息,pkgset 是系统中所有包的集合。可视化的示意图如下:
当我们在命令行执行 dpkg -L libcurl4 请求输出这个包安装到系统中的文件时,首先根据给定的包名找到 pkgset,再根据架构从pkgset 中找到pkginfo。
Deb222 风格特征
系统本地数据库文件 /var/lib/dpkg/status 就是这个格式的,读取数据库信息是采用下面这样的解析规则。
(1) 多段落结构
文件由多个 段落(Stanza) 组成,每个段落描述一个软件包或源码包。
段落之间通过 空行 分隔。
示例(
Packages
文件片段):
Package: curl
Version: 8.5.0
Description: Command-line tool for transferring data with URL syntax
Supports HTTP, HTTPS, FTP, and more.
.
This package provides the curl binary.
Package: libcurl4
Version: 8.5.0
Depends: libc6, libssl3
...
(2) 字段语法规则
字段名与值:每行一个字段,格式为
Field-Name: Value
。多行值:后续行以 空格开头(缩进),表示延续内容。
段落分隔:空行表示当前段落结束,新段落开始。
-
严格校验:
字段名不能以连字符(
-
)开头。字段名后必须紧跟冒号(
:
)。值部分不允许空行(
parse_error
处理空白行)。
读写数据库
当用户在命令行 dpkg -l 来查询包的信息,或者 dpkg -i 安装包之前,或者 apt update 更新可用包信息等,都需要先加载本地数据库,从数据库查询包的相关信息,或者下载服务器的 Packages.xz 来跟本地数据库作对比,列出可用包信息。
这个本地数据库的构建,就是通过 parsedb_open 读文件 /var/lib/dpkg/status 来获取的。
由于 status 文件和 Packages.xz 都是 deb222 风格的,所以创建一个 deb822 解析器上下文 parsedb_state *ps,根据 ps 上下文来加载数据库信息 parsedb_load(ps)
load 分两种情况,一种是从 FIFO文件读:封装 buffer_copy,函数 fd_vbuf_copy 从文件句柄中读到 buf 中,并进一步将数据指针保存到上下文中 ps->dataptr;另一个解析普通文件,将文件映射到内存中来解析。
等到解析完成,将一坨包信息加载到解析器上下文了。都在 ps->dataptr, ps 事关重大,需要格外关注。
解析数据库文件 status
Status 文件是每一个包对应一个段落,每个段落包含若干条字段信息。在 dpkg 中,为了方便读写相关字段,定义了该字段信息读写数据库的函数,如下:
const struct fieldinfo fieldinfos[]= {
/* name namelen rcall wcall integar */
{ FIELD("Package"), f_name, w_name },
{ FIELD("Essential"), f_boolean, w_booleandefno, PKGIFPOFF(essential) },
{ FIELD("Status"), f_status, w_status },
{ FIELD("Priority"), f_priority, w_priority },
{ FIELD("Section"), f_section, w_section },
{ FIELD("Installed-Size"), f_charfield, w_charfield, PKGIFPOFF(installedsize) },
{ FIELD("Origin"), f_charfield, w_charfield, PKGIFPOFF(origin) },
{ FIELD("Maintainer"), f_charfield, w_charfield, PKGIFPOFF(maintainer) },
……
rcall 负责读信息【比如命令行查询版本号】:
lib/dpkg/pkg-hash.c 函数 pkg_hash_find_set从 bins 中找信息,目前bins数组中存的是 pkgset ,数组大小接近 Debian suite 的数目 65521 个。
如果内部数据库已经存在这个包了,find_set 返回已经存在的结果,否则就会创建一个新的pkgset结构体来返回。
wcall 负责写数据库【比如安装一个包之后需要更新到status文件中】:
lib/dpkg/dump.c 这个函数就是追加字符串信息,对于 fip->wcall(vb, pkg, pkgbin, fw_printheader, fip); name字段 是 "Package" 的函数,对照上表,查出来的函数 就是 w_name,函数定义如下:
w_name(struct varbuf *vb, const struct pkginfo *pkg, const struct pkgbin *pkgbin, enum fwriteflags flags, const struct fieldinfo *fip)
段落解析 parse_stanza
Status 文件很长,系统中上千个包都写在这个文件中了。每个包描述信息就是一个段落,解析这个文件按照段落来的。
parse_stanza 有两个循环,第一个循环逐段解析,第二个循环逐字段解析:
1、每个包是一个段
解析到的段通过 fs->valuestart、 fs->valuelen 控制范围.
每当执行 parse_stanza 解析包数据前,都会执行 pkgset_blank 清空上一次解析的pkginfo,确保每次传递的 pkg_obj 是待填充的空包结构
new_pkg = &tmp_set.pkg;
……
pkg_obj.pkg = new_pkg;
pkg_obj.pkgbin = new_pkgbin;
pkgset_blank(&tmp_set);
if (!parse_stanza(ps, &fs, pkg_parse_field, &pkg_obj))
2、每一个段中包含了许多字段
ps的 dataptr 数据量太多了,只截取其中一段(一个独立包)解析的数据在 fs 中,通过parse_field(ps, fs, parse_obj); 再去挨个分析字段来填充
每个包信息的 fs 数据结构是:
struct field_state {
const char *fieldstart;
const char *valuestart;
struct varbuf value;
int fieldlen;
int valuelen;
int *fieldencountered;
};
经过解析,在逐字段解析函数 pkg_parse_field 中,fs->value 这个 varbuf 存的就是每一个字段对应的值:
parse field, fs->value is xz-utils, len is 8
parse field, fs->value is install ok installed, len is 20
parse field, fs->value is required, len is 8
parse field, fs->value is utils, len is 5
parse field, fs->value is 437, len is 3
parse field, fs->value is Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>, len is 57
parse field, fs->value is arm64, len is 5
parse field, fs->value is foreign, len is 7
parse field, fs->value is 5.2.4-1kylin2.1, len is 15
parse field, fs->value is lzip (<< 1.8~rc2), xz-lzma, len is 26
parse field, fs->value is lzma, len is 4
parse field, fs->value is libc6 (>= 2.17), liblzma5 (>= 5.2.2), len is 36
parse field, fs->value is lzip (<< 1.8~rc2), len is 17
parse field, fs->value is lzma (<< 9.22-1), xz-lzma, len is 25
parse field, fs->value is XZ-format compression utilities
XZ is the successor to the Lempel-Ziv/Markov-chain Algorithm
compression format, which provides memory-hungry but powerful
compression (often better than bzip2) and fast, easy decompression.
.
This package provides the command line tools for working with XZ
compression, including xz, unxz, xzcat, xzgrep, and so on. They can
also handle the older LZMA format, and if invoked via appropriate
symlinks will emulate the behavior of the commands in the lzma
package.
.
The XZ format is similar to the older LZMA format but includes some
improvements for general use:
.
'file' magic for detecting XZ files;
crc64 data integrity check;
limited random-access reading support;
improved support for multithreading (not used in xz-utils);
support for flushing the encoder., len is 828
有了这个值再去执行 fip->rcall(pkg_obj->pkg, pkg_obj->pkgbin, ps, fs->value.buf, fip); rcall 即读函数,对照上面的每一个字段,这个 rcall 就是:
f_name、f_status、f_priority、f_charfield 等等等,
fieldinfos 中每一个字段,读着读着就都保存到 pkg_obj->pkg 和pkg_obj->pkgbin 里面了。
最后再将 pkginfo 挂接到 pkgset 的链表上。
dpkg 中系统包的组织结构
结构一:哈希桶 bins,它的哈希算法是 FNV-1a 哈希算法,函数 str_fnv_hash 实现了。在一开始加载数据库时,通过读数据库文件 /var/lib/dpkg/status,解析 fieldinfos 中每一个包的“Package”字段,根据包名来构建哈希桶和 pkgset、pkginfo 链表。
pkg_hash_find_set (pkgname) 函数通过哈希算法将包名计算为哈希值,定位哈希桶的位置,并返回 pkgset。如果哈希桶对应位置上没有pkgset,就创建一个 pkgset 落进去,先占个坑。
对输入包名的每个字符进行以下操作:
异或操作:将当前哈希值与字符的 ASCII 码进行异或(h ^= *str++),将字符信息融入哈希值。
乘法混合:将结果与固定质数 FNV_MIXING_PRIME(例如 32 位版本的 0x01000193)相乘(h *= p),增强哈希值的分散性。
结构二:pkgset,每一个二进制包名,对应一个 pkgset结构,其中的 pkg 成员就是这个包名对应的所有架构的包。哈希桶bins的哈希算法根据二进制包名来计算的,有可能会出现两个或多个 pkgset 落到同一个哈希桶的情况,比如:
bins[0]: pkgset(vim) → pkgset(gcc) → NULL
├─ pkginfo(vim:amd64) → pkginfo(vim:i386) → NULL
└─ pkginfo(gcc:amd64) → NULL
bins[1]:pkgset(libcurl)->NULL
└─ pkginfo(curl:all) → NULL
结构三:pkginfo,维护了同一个包名在系统上不同架构包的信息,串成一个链表。
当 dpkg 开始安装包时,首先打开数据库,读取 status 文件,根据每个段落中的 Package 信息将所有包落入哈希桶bins 中,并且组织 pkgset 和 pkginfo 链表。为了方便后续跟踪,函数调用链总结如下:
parsedb -> parsedb_parse -> parse_stanza -> pkg_parse_field -> fip->rcall ( 即 f_name) -> pkg_hash_find_set -> bins 与 pkgset
安装配置新包过程中,需要及时更新包的状态,比如 installed、halfconfigured、 notinstalled 等各种状态信息,都是操作 pkg_set_status 更新 pkginfo 状态。包括 pkginfo 的 available 和 installed。
当安装结束,要写数据库。会有一个结构的转换操作,将哈希桶 bins 中的 pkgset 中的每一个 pkginfo,都转到线性数据库 array 中。然后再遍历这个 array,将每个包的信息组成字符串写到数据库文件 status。
哈希桶与数组的转换
对每个哈希桶中的 pkgset 链表,逐个访问其 pkginfo 的所有架构实例(通过 arch_next)。当一个 pkgset 的所有实例遍历完成后,跳转到下一个 pkgset(通过 pkg->set->next)。目标数组:pkg_array 结构(包含 pkgs 数组和 n_pkgs 数量)。
pkg_array_init_from_hash(&array);
iter = pkg_hash_iter_new();
pkg_hash_iter_next_pkg(struct pkg_hash_iter *iter)
遍历保存到数组的转换过程:
1、从哈希桶 bins 中取出 pkgset 中的 pkginfo 结构,保存到 iter->pkg
2、开始遍历这个 pkginfo 的各个架构的包
3、如果没有其他架构了,看看桶里对应的位置还有么有其他 pkgset?有的话继续遍历它的包
4、直到把 bins 中所有的都遍历完,保存到数组 pkg_array 中。
为了方便后续跟踪,函数调用链如下:
modstatdb_shutdown -> writedb -> writedb_recwritedbords -> pkg_array_init_from_hash -> pkg_hash_iter_next_pkg -> varbufrecord -> fip->wcall -> varbuf_add_str
fip->wcall 就是 fieldinfos 结构中定义的各个字段的写函数,比如 Package 包名字段,对应函数就是 w_name。
触发器
触发器分类
触发器分为 interest 和 activate 两种,前者表示对某些事情感兴趣,当事件发生时自己的触发器脚本就会被执行到;后者表示主动触发一个事件。
比如软件包 libc-bin,声明 “interest-await ldconfig”,向 dpkg 注册对触发器事件(ldconfig)的关注。这表示 libc-bin 需要在此类事件发生时执行后续操作,但不会立即触发。后续操作在 postint 脚本中定义:
if [ "$1" = "triggered" ] || [ "$1" = "configure" ]; then
LDCONFIG_NOTRIGGER=y //防止无限触发interest ldconfig 的触发器
export LDCONFIG_NOTRIGGER
ldconfig || ldconfig --verbose
exit 0
fi
软件包 libqt5gui5 通过 activate 主动触发一个事件“activate-noawait ldconfig”,通知 dpkg 所有已声明 interest 的软件包执行相关操作。当libqt5gui5主动触发的 ldconfig 执行完成后,就会触发订阅器 libc-bin 的 interest,从而再一次执行“ldconfig”,让libc-bin 作为系统级动态链接器缓存的管理者,完成最终的强制覆盖更新,确保缓存状态与最新库路径完全同步。
[图片上传失败...(image-de29bd-1752210510653)]
触发器订阅机制
安装包在解压安装,删除了旧版本的文件,安装了新版本的文件之后,就开始考虑新包和旧包的的触发器问题了。
trig_parse_ci(pkg_infodb_get_file(pkg, &pkg->installed, TRIGGERSCIFILE), trig_cicb_interest_delete, NULL, pkg, &pkg->installed);
trig_parse_ci(cidir, trig_cicb_interest_add, NULL, pkg, &pkg->available); // cidir 此时追加了 TRIGGERSCIFILE,/var/lib/dpkg/tmp.ci/triggers
上面这两行代码的主要工作就是删除旧版本包的所有 interest 订阅列表,并增加新包的 interest 订阅者【如果有的话】,比如 ldconfig 触发器的订阅者 libc-bin,查阅文件 /var/lib/dpkg/triggers/<trigger-name> 可以看到。
trig_parse_ci (const char *file, trig_parse_cicb *interest, trig_parse_cicb *activate, struct pkginfo *pkg, struct pkgbin *pkgbin)
parse_ci_call (const char *file, const char *cmd, trig_parse_cicb *cb,
const char *trig, struct pkginfo *pkg, struct pkgbin *pkgbin,
enum trig_options opts)
trig_parse_ci 负责解析包的 triggers 文件,即解压 control归档时提取出来的 /var/lib/dpkg/tmp.ci/triggers。读文件内容,提取第一个字段,一般是 interest 或者 activate。根据这个字段来匹配参数传过来 的 interest 和 activate 函数,并调用 parse_ci_call 执行它:
如果第一个字段(cmd)为 interest、 interest-await 或者 interest-nowait ,对应执行参数中的 interest 函数。
如果第一个字段(cmd) 为 activate、 activate-await 或者 activate-nowait,对应执行参数中的 activate 函数。
订阅者更新对应的 interest 函数分别是:trig_cicb_interest_delete、trig_cicb_interest_add。对应核心函数 trk_explicit_interest_change,操作原子文件来更新相关订阅者。目前整个系统中,对 ldconfig 的订阅者只有 libc-bin。其他大部分订阅者关心的都是目录的变化,这一类基本上都记录在 /var/lib/dpkg/triggers/File。
触发器的钩子函数
在 dpkg 的触发器机制中,struct trig_hooks 定义了五个关键钩子函数,用于管理触发器的生命周期和事件处理逻辑。
static const struct trig_hooks trig_our_hooks = {
.enqueue_deferred = trigproc_enqueue_deferred,
.transitional_activate = trig_transitional_activate,
.namenode_find = th_nn_find,
.namenode_interested = th_nn_interested,
.namenode_name = th_nn_name,
};
unpack 解压包的时候,执行 trigproc_install_hooks 填充触发器的钩子函数表 。
第一个钩子函数
当安装包时发现 activate 类型的触发器时,不会立即执行相关操作,而是通过 enqueue_deferred 将事件加入一个内部延迟触发器处理队列 deferred。dpkg 在所有软件包操作(安装、升级、卸载等)完成后,再统一处理队列中的所有触发器事件。
触发器处理队列 deferred
在安装包前会先把它的触发器识别出来,通过第一个钩子函数 enqueue_deferred 将这些触发器的新订阅者加到全局 deferred 队列中。对于订阅者,给它维护一个 trigpend_head 链表,保存订阅者所有关注的触发器。
dpkg 在子进程 dpkg-deb --control 和 dpkg-deb --fsys-tarfile 之间,也就是刚完成控制脚本提取,还没开始解压包文件。先执行函数 trig_activate_packageprocessing 来先处理activate类型的触发器。
假设正要安装 libqt5gui5 libqt5core5 libc-bin 这三个包,那么系统中原先装的这些包的 activate 触发器函数会被执行。过程如下:
trig_parse_ci(pkg_infodb_get_file(pkg, &pkg->installed, TRIGGERSCIFILE), NULL, trig_cicb_statuschange_activate, pkg, &pkg->installed);
该函数首先解析三个安装包的 triggers 文件,分别是:
libc-bin.triggers : interest-await ldconfig
libqt5gui5.triggers: activate-noawait ldconfig
libqt5core5a.triggers: activate-noawait ldconfig
interest 和 activate 都有,interest 类型的 libc-bin 包,trig_parse_ci 未指定 interest 函数暂不关注,只关注下面两个触发器。他们都是 activate-noawait, 对应的 activate 函数是 trig_cicb_statuschange_activate。
activate 触发器又分为 explicit 和 file 两种类型,安装libqt5gui5的触发器是 “ldconfig”,直接执行命令而不是监控目录变化,所以属于explicit 类型。那么 trig_cicb_statuschange_activate 执行 dtki->activate_awaiter 对应为 trk_explicit_activate_awaiter。通过下面这条调用链将订阅者 libc-bin 加入 deferred 队列中:
trk_explicit_activate_awaiter -> trig_record_activation -> reigh.enqueue_deferred 【即trigproc_enqueue_deferred 钩子函数】将 pend 的包加到 deferred 队列中。
通过下面这条调用链维护订阅者的 trigpend_head 链表:
trk_explicit_activate_awaiter -> trig_record_activation -> trig_note_pend -> trig_note_pend_core
如前所说,订阅者从/var/lib/dpkg/triggers/<trigger-name> 解析得到。比如系统中已经有 libqt5gui5,我再次安装 libqt5gui5 时,就能看到这几条日志:
D020000: trigproc_activate_packageprocessing pkg=libqt5gui5:arm64
……
trk_explicit_activate_awaiter: explict is ldconfig, pend pkg is libc-bin
可能会有一个疑问,上面流程中函数传递的 pkginfo 一直是待安装包,比如 libqt5gui5 等。pend 包 libc-bin 是如何被识别的?
回顾“触发器订阅机制”,提到在解压函数 process_archives 调用了 trig_parce_ci,用于更新订阅者。函数指针分别是 trig_cicb_interest_delete 和 trig_cicb_interest_add。
更新订阅者-> trig_cicb_interest_delete/add -> trig_cicb_interest_change -> tki->interest_change
因为触发器 ldconfig 是 explicit 类型,因此对应的 tki->interest_change 函数是 trk_explicit_interest_change ,它接着调用 trk_explicit_start 函数来打开一个文件句柄,文件就是触发器的 triggers 文件,对于 ldconfig 来说,文件是 /var/lib/dpkg/triggers/ldconfig。
再回到 trk_explicit_activate_awaiter 看看怎么加 deferred?它的参数是 libqt5gui5 包对应的 pkginfo 结构,包的触发器是 ldconfig。主要功能是:
首先,检查触发器的 triggers 文件是否存在,没有就算了不做任何操作。
接着,读文件 /var/lib/dpkg/triggers/ldconfig,解析相关字段,并解析触发器类型是否 noawait 的类型的。
通过 trig_record_activation,将pend包加到 deferred 队列中。同时维护 pend 包的 trigpend_head 链表。【trig_note_pend_core: 触发器 ldconfig ,pend 包 libc-bin】
触发器循环检测与执行
系统组件那么多,触发器之间的互相依赖不可避免,几乎所有 lib* 包都会执行 ldconfig 触发器,甚至于安装 libc-bin 都会导致订阅者 man-db 的 interest 触发器执行。如果存在循环触发器,就可能一直循环触发永无止境,所以 dpkg 专门定义了一些结构来帮助查找循环触发器,以下是几个关键结构 :
struct trigcyclenode {
struct trigcyclenode *next;
struct trigcycleperpkg *pkgs;
struct pkginfo *then_processed;
}; // 遍历所有数据库中包,用pkgs链表来维护其他每个包的的激活触发器信息
struct trigcycleperpkg {
struct trigcycleperpkg *next;
struct pkginfo *pkg;
struct trigpend *then_trigs; // 记录每一个历史状态时对应包的 trigpend_head
}; // 存储其他存在激活触发器的包和触发器信息
struct trigpend {
struct trigpend *next;
const char *name;
}; // 触发器链表
每当完成一个 deb 包的解压提取,archivefiles 函数在最后会执行 trigproc_run_deferred。该函数遍历 deferred 队列,对每一个包执行函数 trigproc。trigproc 函数用来处理触发器,它会检查包的状态,处理可能的触发器循环依赖,条件满足的时候执行触发器。
archivefiles -> 【解压完成】trigproc_run_deferred -> trigproc -> check_trigger_cycle -> trigproc_new_cyclenode
trigproc_run_deferred 遍历 deferred 队列,弹出队列成员pkginfo,对这个包执行 trigproc函数。为了检测循环依赖,用到了龟兔赛跑算法。
就以上面 deferred 队列中取出来的 libc-bin 为例,它作为 ldconfig触发器的订阅者被 libqt5gui 激活,trigproc 通过 check_trigger_cycle 来检查循环是否存在。
首先 trigproc_new_cyclenode (libc-bin) 遍历哈希桶中的所有包【注意是所有包】,找到有 trigpend_head 的那些,有则说明这个包有激活的触发器处于pending状态。假设存在许多这样情况的包,将它们用链表维护起来。
一般情况下,我们用 dpkg -i 会一次安装许多包,archivefiles 将在所有包的文件都被解压提取之后,才执行 trigproc_run_deferred,遍历队列 deferred,对它们执行 trigproc【查archivefiles 函数,在 for 循环遍历处理 process_archive 之后】。
根据分析,我们安装 libc-bin、libqt5gui 会有两个deferred 成员:被libqt5gui激活的 libc-bin 以及被 libc-bin 激活的 man-db。
1、处理第一个 deferred 成员 libc-bin,遍历它的 trigpend_head,目前libc-bin也就一个 ldconfig触发器。检查libc-bin 包的依赖情况,没啥问题再去检查是否存在循环触发器 check_triggers_cycle(libc-bin)
check_triggers_cycle(libc-bin)第一回合:
将系统中所有存在激活触发器的包都收集到 tcn1 节点的 pkgs 链表维护起来(结合trigcyclenode定义)。
将兔指针、龟指针都指向 tcn1 ,并且返回空表明无循环,trigproc 发现没有触发器循环,直接执行 libc-bin的触发器。
2、接着处理 deferred 第二个成员 man-db,它的触发器是目录,因为libc-bin 有文件安装到了 /usr/share/man
check_triggers_cycle(man-db)第二回合:
同样将这一事件发生时系统中所有存在激活触发器的包都收集到 tcn2 节点的 pkgs 链表维护起来。
将两次的节点串成链表tcn1, tcn1-> tcn2->next,此时 hare指针移到下一个,指向 tcn2。tortoise 通过一个开关控制,每间隔一次才会使其打开指向下一个。慢一拍。
3、依次类推,如果还有其他 deferred 包,
check_triggers_cycle(man-db)第三回合:
将该时间点时系统中所有存在激活触发器的包都收集到 tcn3 节点的 pkgs 链表维护起来。将三次的节点串成链表tcn1, tcn1-> tcn2->tcn3->next,此时 hare指针移到下一个,指向 tcn3, tortoise 开关打开前进指向 tcn2。当 hare 前进到 tcn4 时,tortoise 开关开关还是指向 tcn2。
这里虽然说的是龟兔赛跑算法,但其实实现手段跟常规的快慢指针相遇不一样。这里判断存在循环,用的是一段时间后的范围覆盖检测。因为在许多包的安装处理过程中,当一个包处理完,那么它的 pending 触发器执行完成就不再 pending了。比如第一个 libc-bin,判断没有循环直接就执行了,它的 trigpend_head 就清空,即:触发器的状态链表是会发生变化的,总体趋势是越来越少。
核心判断函数 tortoise_in_hare,遍历龟指针 tcn 节点中的 pkgs 们,对历史状态 pending triggers 做判断。判断经过若干包的触发器处理后,链表是否有变化了?上面每一个回合,在每一个时间节点,我们收集系统中所有存在激活触发器的包,都将他们的 trigpend_head 保存到 then_trigs,用它来记录某一历史时刻这个包的 pending 的触发器名称。
printf ("比较龟记录的旧状态 then_trigs %lx 和当前最新状态的 trigpend_head %lx\n", tortoise_pkg->then_trigs, tortoise_pkg->pkg->trigpend_head);
debug(dbg_triggersdetail, "%s pnow=%s tortoise=%s", __func__,
processing_now_name, tortoise_name);
for (tortoise_trig = tortoise_pkg->then_trigs;
tortoise_trig;
tortoise_trig = tortoise_trig->next) {
debug(dbg_triggersdetail,
"%s pnow=%s tortoise=%s tortoisetrig=%s", __func__,
processing_now_name, tortoise_name, tortoise_trig->name);
/* hare 指向当前最新状态,所以我们直接用实际的数据就行 */
for (hare_trig = tortoise_pkg->pkg->trigpend_head;
hare_trig;
hare_trig = hare_trig->next) {
debug(dbg_triggersstupid, "%s pnow=%s tortoise=%s"
" tortoisetrig=%s haretrig=%s", __func__,
processing_now_name, tortoise_name,
tortoise_trig->name, hare_trig->name);
if (strcmp(hare_trig->name, tortoise_trig->name) == 0)
break;
}
if (hare_trig == NULL) {
/* Not found in hare, yay! */
debug(dbg_triggersdetail, "%s pnow=%s tortoise=%s OK",
__func__, processing_now_name, tortoise_name);
return false;
}
}
假如经过一系列处理后,历史时刻(即龟节点)的每一个触发器,在当前时间点都还存在,一个都没能被更新处理掉,那就说明可能陷入循环了,因为后续操作未引入新包,链表状态未更新。
一旦检测到循环触发器,则放弃本次 trigproc 操作,将龟节点的最早的那个包丢弃,并设置为已安装未配置的iU状态,代表一个异常。如果没有循环,说明包的触发器可以执行,以 libc-bin 为例,它作为订阅者并激活,在它的 postinst 脚本定义了 triggered 行为。maintscript_postinst 函数将执行它的 postinst 脚本中的 triggered 相关操作。