title: initramfs
categories:
- linux
- fs
tags: - linux
- fs
abbrlink: e7194024
date: 2025-10-03 09:01:49

文章目录
- [init/initramfs.c 初始RAM文件系统(Initial RAM Filesystem) 内核启动的早期用户空间](#init/initramfs.c 初始RAM文件系统(Initial RAM Filesystem) 内核启动的早期用户空间)
-
- CPIO解析器辅助函数:写入、链接、错误和时间戳管理
- CPIO解析器状态机实现:从头部解析到文件创建
- CPIO解析器状态机:将字节流转换为文件系统
- Initramfs解包器:解压并提取归档至根文件系统
- 传统Initrd处理:将内存镜像保存为文件
- Rootfs填充:从内存镜像到可用的初始文件系统
init/initramfs.c 初始RAM文件系统(Initial RAM Filesystem) 内核启动的早期用户空间
历史与背景
这项技术是为了解决什么特定问题而诞生的?
initramfs(Initial RAM Filesystem)的诞生是为了解决一个经典的**"鸡生蛋,蛋生鸡"问题,即内核在启动过程中如何加载那些访问真正根文件系统所必需的驱动程序**。
具体来说,它解决了以下核心问题:
- 模块化的挑战 :现代Linux内核是高度模块化的。为了保持内核镜像(vmlinuz)的小巧,大量驱动程序(如SATA/SCSI/NVMe控制器驱动、LVM/RAID逻辑卷驱动、网络驱动、加密模块等)都被编译成了独立的内核模块(
.ko文件)。 - 根文件系统访问 :内核启动后,其首要任务之一是挂载根文件系统(
/)。但如果根文件系统位于一个SATA硬盘上,而SATA驱动又是一个内核模块,那么内核在没有加载这个模块之前,根本无法识别和访问这个硬盘,也就无法挂载根文件系统,启动过程陷入死锁。 - 早期用户空间的需求 :在挂载真正的根文件系统之前,可能需要执行一些复杂的初始化任务,例如:
- 通过网络(NFS, iSCSI)挂载根文件系统,这需要加载网络驱动、配置IP地址。
- 解密一个加密的根分区,这需要加载加密模块并向用户索要密码。
- 启动逻辑卷管理器(LVM)或软件RAID来组装根文件系统所在的逻辑卷。
- 在嵌入式系统中,可能需要在挂载主存储之前,先加载一些特定的硬件驱动。
initramfs通过提供一个临时的、完全在内存中运行的、初始的根文件系统 来解决这个问题。这个临时的文件系统包含了所有必需的驱动模块、工具(如insmod, lvm, cryptsetup)和脚本,使得内核可以在一个早期的用户空间环境中完成所有准备工作,然后再切换到真正的根文件系统。
它的发展经历了哪些重要的里程碑或版本迭代?
initramfs是其前身initrd(Initial RAM Disk)的现代化演进。
initrd时代 :initrd是一个被内核加载到RAM中的块设备镜像 。内核会像挂载一个微型磁盘一样挂载它(通常格式化为ext2等)。这种方式存在一些问题:- 固定大小 :
initrd镜像大小固定,不易扩展。 - 双重缓存 :
initrd本身作为一个块设备,其内容会被内核的页面缓存所缓存,造成内存的浪费。
- 固定大小 :
initramfs的引入 :initramfs代表了根本性的改变。它不再是一个块设备镜像,而是一个cpio归档文件 (通常是.cpio.gz)。- 集成到内核 :在构建内核时,这个cpio归档可以被直接链接到内核镜像 中。内核启动时,会直接在内存中解压这个归档,并将其内容填充到一个特殊的、基于RAM的文件系统实例------**
rootfs**中。 tmpfs/ramfs后端 :initramfs本质上是一个tmpfs或ramfs实例。这意味着它的大小是动态的,并且它直接使用内存,避免了initrd的双重缓存问题。
- 集成到内核 :在构建内核时,这个cpio归档可以被直接链接到内核镜像 中。内核启动时,会直接在内存中解压这个归档,并将其内容填充到一个特殊的、基于RAM的文件系统实例------**
init/initramfs.c的角色 :这个文件实现了内核解压和填充initramfs的核心逻辑。函数populate_rootfs()负责解析cpio归档,并在rootfs中创建对应的文件和目录。- 从外部加载
initramfs:除了内嵌到内核,initramfs也可以作为一个独立的文件(如initramfs-linux.img)由引导加载程序(如GRUB, systemd-boot)加载到内存中,并将其地址告知内核。
目前该技术的社区活跃度和主流应用情况如何?
initramfs是所有现代通用Linux发行版(如Ubuntu, Fedora, Arch Linux, Debian)标准启动流程中不可或缺的一部分。
- 绝对核心 :几乎所有的桌面、服务器和许多嵌入式Linux系统都使用
initramfs。 - 自动化工具 :用户通常不直接创建
initramfs。像dracut和mkinitcpio这样的工具会自动扫描当前系统所需的驱动,并生成一个定制化的initramfs镜像。 - 无盘系统 :对于无盘工作站或网络启动(PXE)的服务器,
initramfs是实现其启动的关键。 - 社区状态 :
init/initramfs.c中的内核代码非常稳定和成熟。社区的活跃度更多地体现在用户空间的生成工具(dracut等)和引导加载程序的持续发展上。
核心原理与设计
它的核心工作原理是什么?
initramfs.c的核心工作流程是在内核启动的极早期阶段,构建起第一个可用的根文件系统。
rootfs的创建 :内核在启动时会创建一个特殊的、基于内存的文件系统,名为rootfs。这是所有挂载点的"祖先"。initramfs源的定位 :内核会查找initramfs的来源。它会检查一个特殊的ELF段,看是否有cpio归档被链接进了内核镜像。如果没有,它会检查引导加载程序是否在启动参数中提供了一个外部initramfs的内存地址。- 解压和填充 (
populate_rootfs) :这是init/initramfs.c的主函数。它会:- 将定位到的cpio归档(可能是压缩的)作为一个数据流进行读取。
- 逐个解析cpio归档中的条目(header + data)。每个条目都描述了一个文件、目录或符号链接。
- 根据条目信息,在
rootfs中执行相应的VFS操作:创建目录(vfs_mkdir)、创建文件(vfs_create)、写入文件内容(vfs_write)、创建符号链接(vfs_symlink)等。
- 切换到早期用户空间 :当
populate_rootfs完成后,rootfs中就包含了initramfs的所有内容。内核的启动流程继续,最终会执行rootfs中的/init程序。 init脚本执行 :这个/init程序(通常是一个shell脚本)现在作为PID 1运行在一个临时的内存文件系统中。它负责执行所有必要的准备工作:- 挂载
/sys,/proc等伪文件系统。 - 使用
modprobe或insmod加载必需的内核模块。 - 扫描设备,启动LVM/RAID。
- 向用户索要加密密码。
- 挂载
- 切换真正的根 (
switch_root) :当所有准备工作完成,并且真正的根设备可用时,/init脚本会挂载真正的根文件系统到一个临时目录(如/new_root),然后执行switch_root命令。switch_root是一个特殊工具,它会删除initramfs的所有内容,并将挂载点从/new_root移动到/,最后在新的根文件系统上执行真正的init程序(如/sbin/init或systemd)。至此,启动的接力棒就从initramfs交给了真正的系统。
它的主要优势体現在哪些方面?
- 灵活性和强大功能 :通过提供一个完整的早期用户空间环境,
initramfs可以执行任意复杂的初始化逻辑,这是initrd无法比拟的。 - 高效 :基于
tmpfs/ramfs,避免了块设备层和双重缓存的开销,内存使用更高效。 - 简化内核 :使得内核主干可以保持小巧,将大量的硬件探测和初始化逻辑推迟到
initramfs的用户空间阶段。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 增加了启动的复杂性:引入了额外的启动阶段和组件,增加了排查启动问题的难度。
- 镜像大小 :
initramfs需要包含内核模块和用户空间工具,这会增加其镜像大小,从而可能略微延长启动的初始阶段(加载内核和initramfs到内存)。 - 非必需场景 :对于内核被编译为单体(monolithic)、所有必需驱动都内建(built-in)的系统(常见于一些定制的嵌入式设备),
initramfs是不必要的,内核可以直接挂载根文件系统。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
它是现代通用Linux系统启动的唯一且标准的解决方案。
- 所有使用模块化驱动的桌面/服务器系统。
- 基于LVM或软件RAID的根分区。
- 全盘加密(FDE)系统。
- 通过网络(NFS, iSCSI)启动的系统。
- "Live CD"/"Live USB"系统 :它们通常将整个操作系统放在一个压缩的文件系统镜像中(如SquashFS),
initramfs负责找到并挂载这个镜像。
是否有不推荐使用该技术的场景?为什么?
- 资源极其受限的嵌入式系统 :如果Flash空间和RAM都非常宝贵,且硬件配置固定,那么将所有必需驱动都编译进内核,并完全禁用
initramfs,是一种常见的优化策略,可以减小存储占用并可能加快启动速度。 - 追求极简启动的特定场景 :在一些特定的虚拟化或容器场景中,如果根文件系统非常简单(例如,一个
virtio-blk设备,其驱动可以内建),为了最快的启动速度,也可能会选择不使用initramfs。
对比分析
请将其 与 其他相似技术 进行详细对比。
initramfs vs. initrd (传统)
| 特性 | initramfs (现代) |
initrd (传统) |
|---|---|---|
| 格式 | cpio归档文件。 | 块设备镜像 (通常是ext2格式)。 |
| 内核处理 | 内核直接在内存中解压并填充到rootfs (tmpfs)。 |
内核将其作为一个RAM盘块设备来处理,需要文件系统驱动来挂载它。 |
| 缓存 | 无双重缓存。直接使用内存。 | 有双重缓存。RAM盘本身被页面缓存所缓存。 |
| 内存使用 | 高效。使用后,内存可以被完全释放和回收。 | 低效。RAM盘占用的内存直到被显式释放前都无法被用作他途。 |
| 大小 | 动态 。tmpfs的大小可根据需要增长。 |
固定。镜像创建时大小就已确定。 |
| 当前状态 | 现代标准 | 已被取代的遗留技术 |
CPIO解析器辅助函数:写入、链接、错误和时间戳管理
本代码片段提供了CPIO解析状态机所需的一系列核心辅助函数。这些函数是状态机中do_*动作函数的底层实现,负责处理具体的任务,包括:将文件数据写入rootfs并计算校验和(xwrite),管理硬链接的创建(find_link),处理错误(error),以及一个精巧的机制用于在文件提取完成后正确地设置目录的修改时间(dir_utime)。它们是确保initramfs被正确、完整地提取到rootfs中的基础工具集。
实现原理分析
1. 文件写入与校验和 (xwrite)
xwrite是一个对底层kernel_write的封装,旨在提供更强健的大文件写入能力和集成的校验和计算。
- 分块写入 : 内核的
write系统调用有最大单次写入长度的限制(通常是MAX_RW_COUNT)。xwrite通过一个while循环,将可能超过此限制的大块数据分解成多个小的kernel_write调用,确保所有数据都能被写入。 - 错误重试 : 它能正确处理
-EINTR(被信号中断)和-EAGAIN(临时不可用)等可恢复的错误,通过continue语句实现重试,增强了写入的健壮性。 - 校验和计算 : 如果CPIO归档格式是带校验和的(
csum_present为真),xwrite会在数据成功写入的同时,逐字节地累加出一个运行校验和io_csum。这个和将在文件写完后与头部中记录的hdr_csum进行比较,以验证数据完整性。
2. 硬链接管理 (Link Hash)
为了正确处理硬链接(多个文件名指向同一个inode),代码实现了一个小型的、专用的哈希表。
- 数据结构 :
struct hash存储了唯一标识一个inode所需的信息(ino,major,minor,mode)以及第一次遇到该inode时的文件名。head数组是一个包含32个链表头的哈希桶。 find_link函数 : 当CPIO解析器遇到一个nlink大于1的文件时,会调用此函数。- 它使用inode号、设备号等信息通过
hash()函数计算出一个哈希值,定位到对应的哈希桶。 - 它遍历桶中的链表,查找是否有已记录的、匹配当前inode信息的条目。
- 如果找到匹配项 ,意味着这个inode之前已经出现过。函数返回之前记录的文件名。调用者(
do_name)会使用这个返回的路径创建一个硬链接,而不是创建新文件和写入数据。 - 如果未找到 ,意味着这是此inode第一次出现。函数会分配一个新的
hash节点,记录下当前inode的信息和文件名,将其添加到哈希表中,并返回NULL。调用者会继续按正常流程创建文件。
- 它使用inode号、设备号等信息通过
free_hash函数 : 在整个initramfs解析完毕后,此函数负责遍历整个哈希表,释放所有为硬链接跟踪而分配的内存。
3. 时间戳管理 (Timestamp Management)
在CPIO流中,目录的条目出现在其包含的文件条目之前。如果在一创建目录时就设置其修改时间(mtime),那么后续在其中创建文件时,VFS会自动将目录的mtime更新为当前时间,从而丢失了归档中原始的mtime。
- 延迟处理 : 为了解决这个问题,代码实现了一个延迟处理机制。当
do_name创建一个目录时,它不直接设置mtime,而是调用dir_add。 dir_add: 这个函数将目录的名称和原始mtime存储在一个dir_entry结构体中,并将其添加到一个全局的链表dir_list里。dir_utime: 在整个unpack_to_rootfs的末尾,dir_utime函数被调用。它遍历dir_list链表,此时所有的文件和子目录都已创建完毕。它为链表中的每个目录调用do_utime,将归档中记录的原始mtime设置给它,从而确保了时间戳的正确性。
代码分析
c
static __initdata bool csum_present; // 标志,指示当前CPIO条目是否带校验和。
static __initdata u32 io_csum; // 用于计算文件内容的运行校验和。
// xwrite: 一个封装了kernel_write的写入函数,支持大文件和校验和计算。
static ssize_t __init xwrite(struct file *file, const unsigned char *p,
size_t count, loff_t *pos)
{
ssize_t out = 0;
// 循环写入,直到所有数据都被写入,以处理大于MAX_RW_COUNT的写入。
while (count) {
ssize_t rv = kernel_write(file, p, count, pos);
if (rv < 0) {
// 如果被信号中断或资源临时不可用,则重试。
if (rv == -EINTR || rv == -EAGAIN)
continue;
return out ? out : rv; // 返回已写入的字节数或错误码。
} else if (rv == 0) // 写入0字节通常表示无法再写入,中断循环。
break;
if (csum_present) { // 如果需要计算校验和
ssize_t i;
// 逐字节累加到io_csum。
for (i = 0; i < rv; i++)
io_csum += p[i];
}
p += rv;
out += rv;
count -= rv;
}
return out;
}
static __initdata char *message; // 用于存储第一个发生的错误信息。
static void __init error(char *x)
{
if (!message) // 只记录第一个错误。
message = x;
}
// panic_show_mem: 打印内存信息后,使系统panic。用于不可恢复的严重错误。
#define panic_show_mem(fmt, ...) \
({ show_mem(); panic(fmt, ##__VA_ARGS__); })
/* 硬链接哈希表 (link hash) */
// ... (N_ALIGN宏定义) ...
// 哈希表节点结构体
static __initdata struct hash {
int ino, minor, major;
umode_t mode;
struct hash *next;
char name[N_ALIGN(PATH_MAX)];
} *head[32]; // 32个哈希桶
static __initdata bool hardlink_seen; // 优化标志,避免在没有硬链接时扫描哈希表。
// hash: 计算inode信息的哈希值,返回0-31的桶索引。
static inline int hash(int major, int minor, int ino)
{
// ... (简单的位运算哈希算法) ...
}
// find_link: 查找或创建一个硬链接记录。
static char __init *find_link(int major, int minor, int ino,
umode_t mode, char *name)
{
struct hash **p, *q;
// 遍历对应的哈希链表。
for (p = head + hash(major, minor, ino); *p; p = &(*p)->next) {
// 逐一比较inode元数据。
if ((*p)->ino != ino) continue;
if ((*p)->minor != minor) continue;
if ((*p)->major != major) continue;
if (((*p)->mode ^ mode) & S_IFMT) continue;
// 如果找到匹配项,返回已存在的文件名。
return (*p)->name;
}
// 如果未找到,分配新节点,存储当前信息,并添加到哈希表中。
q = kmalloc(sizeof(struct hash), GFP_KERNEL);
if (!q)
panic_show_mem("can't allocate link hash entry");
// ... (填充q) ...
*p = q;
hardlink_seen = true;
// 返回NULL表示这是此inode第一次出现。
return NULL;
}
// free_hash: 释放哈希表占用的所有内存。
static void __init free_hash(void)
{
// ... (遍历所有桶和链表,kfree每个节点) ...
}
#ifdef CONFIG_INITRAMFS_PRESERVE_MTIME // 仅在配置了保留mtime时编译
// do_utime: 设置指定文件的访问和修改时间。
static void __init do_utime(char *filename, time64_t mtime)
{
// ...
}
// do_utime_path: 功能同上,但操作对象是struct path。
static void __init do_utime_path(const struct path *path, time64_t mtime)
{
// ...
}
// dir_list: 用于延迟处理目录mtime的全局链表头。
static __initdata LIST_HEAD(dir_list);
struct dir_entry { // 链表节点
struct list_head list;
time64_t mtime;
char name[];
};
// dir_add: 将一个目录名及其mtime添加到dir_list中。
static void __init dir_add(const char *name, size_t nlen, time64_t mtime)
{
// ... (分配dir_entry,填充信息,添加到链表) ...
}
// dir_utime: 在所有文件创建完成后,遍历dir_list,为所有目录设置正确的mtime。
static void __init dir_utime(void)
{
struct dir_entry *de, *tmp;
list_for_each_entry_safe(de, tmp, &dir_list, list) {
list_del(&de->list);
do_utime(de->name, de->mtime);
kfree(de);
}
}
#endif
CPIO解析器状态机实现:从头部解析到文件创建
本代码片段是CPIO解析状态机的具体实现,它包含了一系列do_*函数,每个函数对应状态机的一个状态,以及核心的parse_header函数。代码的核心功能是消费来自victim缓冲区的字节流,解析CPIO头部信息,并根据解析出的元数据(文件名、类型、权限、大小等)在rootfs中执行相应的文件系统操作,如创建文件、目录、符号链接,处理硬链接,以及写入文件内容。这是将initramfs归档数据真正转化为一个可用文件系统的执行层。
实现原理分析
该状态机通过一系列短小、专注的函数协同工作,每个函数负责处理解析过程中的一个特定阶段。其核心原理是分段收集和处理,以及在数据不足时暂停并等待更多数据。
- 头部解析 (
parse_header) : 这是理解整个流程的关键。- CPIO "newc" (
070701) 和 "crc" (070702) 格式的头部是一个110字节的结构,其中所有元数据都以8个字符的ASCII十六进制字符串表示。 parse_header函数接收到完整的头部数据后,它的for循环会精确地遍历这13个8字节的字段。- 在循环中,
simple_strntoul函数被调用,它将每个8字符的十六进制字符串(如"0000A1ED")转换为一个无符号长整型数值。 - 转换后的数值被存入
parsed数组,然后被赋给一系列全局静态变量(ino,mode,uid,body_len等)。这一步将ASCII头部"解码"为内核可以直接使用的二进制元数据。
- CPIO "newc" (
- 状态与数据流 : 全局变量
state和next_state控制着状态机的流程。victim指向当前数据块,byte_count记录其大小。eat()函数是消费数据流的基本操作,它前移victim指针并减少byte_count。 - 数据收集 (
read_into,do_collect) :read_into是一个关键的辅助函数。它尝试从victim缓冲区中直接满足一个size大小的数据读取请求。如果当前缓冲区数据足够,它就直接返回指向victim中数据的指针,并将状态切换到next。- 如果数据不足,它会将状态切换到
Collect,并设置好目标缓冲区collect、剩余所需字节数remains和完成后的下一个状态next_state。 do_collect函数在这种情况下被激活,它会不断地从victim拷贝数据到collect缓冲区,直到remains减为0,然后它会将状态切换到之前设定的next_state。
- 状态流转 :
- Start -> GotHeader :
do_start启动整个流程,它调用read_into请求CPIO_HDRLEN字节的头部数据。 - GotHeader -> (GotName | GotSymlink | SkipIt) :
do_header是核心的决策函数。它首先检查CPIO魔数,然后调用parse_header解码头部。根据解码出的mode(文件类型),它决定下一个状态。 - GotName -> (CopyFile | SkipIt) :
do_name负责创建文件系统对象。它首先处理硬链接(maybe_link)。对于普通文件,它会通过filp_open创建并打开文件,然后将状态切换到CopyFile准备写入数据。对于目录、设备节点等,它直接调用init_mkdir,init_mknod等创建,然后进入SkipIt。 - CopyFile -> SkipIt :
do_copy负责将文件内容写入do_name中打开的文件。它使用body_len作为写入长度的依据,分块写入直到完成,然后关闭文件并将状态切换到SkipIt。 - GotSymlink -> SkipIt :
do_symlink在收集完文件名和链接目标后,调用init_symlink创建符号链接,然后进入SkipIt。 - SkipIt -> Reset :
do_skip是一个通用状态,用于跳过当前CPIO条目中剩余的所有数据,然后将状态切换到Reset。 - Reset -> Start :
do_reset负责清理填充的空字节,为解析下一个归档做准备。
- Start -> GotHeader :
代码分析
CPIO头部解析 (CPIO Header Parsing)
c
static __initdata time64_t mtime; // 文件修改时间
/* cpio header parsing */
// 以下为解析CPIO头部后,用于存储文件元数据的全局变量。
static __initdata unsigned long ino, major, minor, nlink;
static __initdata umode_t mode; // 文件模式 (类型 + 权限)
static __initdata unsigned long body_len, name_len; // 文件体长度和文件名长度
static __initdata uid_t uid; // 用户ID
static __initdata gid_t gid; // 组ID
static __initdata unsigned rdev; // 设备号 (用于设备文件)
static __initdata u32 hdr_csum; // 头部的校验和 (用于crc格式)
// parse_header: 解析CPIO头部字符串,并填充上述全局变量。
// @s: 指向包含110字节CPIO头部的缓冲区的指针。
static void __init parse_header(char *s)
{
unsigned long parsed[13];
int i;
// 循环13次,每次解析一个8字节的十六进制ASCII字符串。
// s += 6 跳过头6个字节的魔数 (e.g., "070701")。
for (i = 0, s += 6; i < 13; i++, s += 8)
// simple_strntoul: 将8个字符的十六进制字符串转换为无符号长整数。
parsed[i] = simple_strntoul(s, NULL, 16, 8);
// 将解析出的数值赋给对应的全局变量。
ino = parsed[0];
mode = parsed[1];
uid = parsed[2];
gid = parsed[3];
nlink = parsed[4];
mtime = parsed[5];
body_len = parsed[6];
major = parsed[7];
minor = parsed[8];
// 将CPIO头中的主/次设备号(parsed[9], parsed[10])合并成内核使用的设备号格式。
rdev = new_encode_dev(MKDEV(parsed[9], parsed[10]));
name_len = parsed[11];
hdr_csum = parsed[12];
}
状态机实现 (State Machine Implementation)
c
// 定义状态机的各种状态。
static __initdata enum state {
Start, // 开始解析一个新条目
Collect, // 正在收集中(文件名或文件体)
GotHeader, // 已收到完整的头部
SkipIt, // 跳过当前条目的剩余部分
GotName, // 已收到完整的文件名
CopyFile, // 正在拷贝文件内容
GotSymlink, // 已收到完整的符号链接
Reset // 重置,准备下一个压缩块
} state, next_state;
// ... (victim, byte_count, this_header, next_header, eat, collected, remains, collect - 全局状态变量和辅助函数) ...
// read_into: 尝试读取size字节到buf,如果数据不足则进入Collect状态。
static void __init read_into(char *buf, unsigned size, enum state next)
{
if (byte_count >= size) { // 如果当前数据块足够
collected = victim; // 直接使用victim中的数据
eat(size);
state = next; // 进入下一个状态
} else { // 如果数据不足
collect = collected = buf; // 设置目标缓冲区
remains = size; // 设置还需要多少字节
next_state = next; // 设置完成后的下一个状态
state = Collect; // 进入Collect状态等待更多数据
}
}
// ... (header_buf, symlink_buf, name_buf - 缓冲区指针) ...
// do_start: Start状态的处理函数。
static int __init do_start(void)
{
// 尝试读取一个完整的CPIO头部。
read_into(header_buf, CPIO_HDRLEN, GotHeader);
return 0; // 返回0表示当前状态未完成(可能进入了Collect)。
}
// do_collect: Collect状态的处理函数。
static int __init do_collect(void)
{
// ... (从victim拷贝数据到collect缓冲区) ...
if ((remains -= n) != 0)
return 1; // 返回1表示还需要更多数据。
state = next_state; // 数据收集完毕,进入预定的下一个状态。
return 0; // 返回0表示Collect状态已完成。
}
// do_header: GotHeader状态的处理函数。
static int __init do_header(void)
{
// ... (检查CPIO魔数) ...
// 调用核心解析函数,填充元数据全局变量。
parse_header(collected);
// ... (计算下一个头部的偏移量,并根据mode决定下一个状态) ...
if (S_ISLNK(mode)) {
// ... 设置为收集符号链接 ...
next_state = GotSymlink;
state = Collect;
}
if (S_ISREG(mode) || !body_len)
// ... 设置为收集文件名 ...
read_into(name_buf, N_ALIGN(name_len), GotName);
return 0;
}
// ... (do_skip, do_reset, clean_path, maybe_link - 其他状态和辅助函数) ...
// do_name: GotName状态的处理函数。
static int __init do_name(void)
{
// ... (检查文件名是否合法,是否为TRAILER!!!) ...
clean_path(collected, mode); // 如果同名文件已存在且类型不同,则删除。
if (S_ISREG(mode)) { // 如果是普通文件
// ... (处理硬链接,然后用filp_open创建文件) ...
state = CopyFile; // 设置下一状态为拷贝文件内容。
} else if (S_ISDIR(mode)) { // 如果是目录
init_mkdir(collected, mode); // 创建目录。
// ... (设置所有者、权限、时间戳) ...
} else if (S_ISBLK(mode) || S_ISCHR(mode) || ... ) { // 如果是设备文件等
// ... (处理硬链接,然后用init_mknod创建设备节点) ...
}
return 0;
}
// do_copy: CopyFile状态的处理函数。
static int __init do_copy(void)
{
// ... (从victim中读取文件内容,通过xwrite写入wfile) ...
if (byte_count >= body_len) { // 如果文件内容已全部写入
// ... (更新时间戳,关闭文件,检查校验和) ...
eat(body_len);
state = SkipIt; // 进入SkipIt状态。
return 0;
} else { // 如果当前数据块不足以写完整个文件
// ... (写入当前块的数据) ...
return 1; // 返回1表示还需要更多数据。
}
}
// do_symlink: GotSymlink状态的处理函数。
static int __init do_symlink(void)
{
// ... (从collected缓冲区中提取出链接名和目标名) ...
init_symlink(collected + N_ALIGN(name_len), collected); // 创建符号链接。
// ... (设置所有者和时间戳) ...
state = SkipIt; // 进入SkipIt状态。
next_state = Reset;
return 0;
}
CPIO解析器状态机:将字节流转换为文件系统
本代码片段是unpack_to_rootfs函数的"心脏",它实现了一个经典的状态机,用于逐字节地解析CPIO归档流。flush_buffer作为从解压器接收明文数据的入口,write_buffer作为驱动状态机运转的引擎,而actions数组则是状态机的大脑,将不同的解析状态映射到具体的处理函数。其核心功能是将一个连续的、无结构的CPIO字节流,精确地转换为在rootfs中创建目录、文件、符号链接等一系列VFS操作。
实现原理分析
该代码的实现是一个精巧的流式处理器,其原理如下:
- 状态机定义 (
actions数组) : 代码的核心是一个名为actions的函数指针数组。这个数组的索引是一个枚举值(enum state,如Start,Collect,GotHeader等),代表了解析器当前所处的状态。数组的每个元素都指向一个相应的处理函数(do_start,do_collect等)。这种设计将"状态"和"行为"清晰地解耦。 - 状态机引擎 (
write_buffer) : 这个函数是驱动整个状态机运转的引擎。它接收一小块数据,然后进入一个while循环。在循环中,它根据当前的全局state变量,从actions数组中取出对应的函数指针并执行。- 每个
do_*动作函数被设计为返回0表示"当前状态的工作还未完成,需要更多数据或更多处理",返回非0则表示"当前状态的工作已完成,可以转换到下一个状态了"。 while循环会一直调用当前状态的动作函数,直到该函数返回非0,表示一个状态转换点已经达到。- 函数返回消耗的字节数,告知上游调用者(
flush_buffer)数据处理的进度。
- 每个
- 数据注入与流控制 (
flush_buffer) : 这个函数是解压器和CPIO解析器之间的桥梁。- 它被注册为解压器的回调函数,因此会分批次地接收解压后的明文CPIO数据。
- 它调用
write_buffer来驱动状态机处理这些数据。 - 它包含一个关键的
while循环,用于处理write_buffer未能一次性消耗完所有输入数据的情况,这是流式处理的典型特征。 - 最重要的是,它还负责处理串联的CPIO归档 。在一个数据块被
write_buffer处理完毕后,flush_buffer会检查下一个字节。如果是CPIO魔数'0',它就将状态机重置到Start状态,准备解析下一个归档。如果是\0空字节(常用作填充),则进入Reset状态,为下一个压缩段做准备。
整个流程可以概括为 :解压器产生明文数据 -> flush_buffer接收数据 -> flush_buffer调用write_buffer -> write_buffer根据state调用actions数组中的do_*函数 -> do_*函数解析数据、执行VFS操作(如创建文件)并更新state -> 循环往复,直到所有数据被解析完毕。
代码分析
c
// actions: 状态机动作分派表。
// 这是一个函数指针数组,索引是解析器的当前状态(State枚举值)。
// VFS会根据当前状态,调用对应的do_*函数来处理字节流。
static __initdata int (*actions[])(void) = {
[Start] = do_start, // 初始状态,寻找CPIO头部。
[Collect] = do_collect, // 收集中(例如,文件名或文件内容)。
[GotHeader] = do_header, // 已完整接收头部,准备解析。
[SkipIt] = do_skip, // 跳过当前文件的数据(例如,如果无法创建)。
[GotName] = do_name, // 已接收文件名,准备创建inode。
[CopyFile] = do_copy, // 正在拷贝文件内容。
[GotSymlink] = do_symlink, // 已接收符号链接目标,准备创建链接。
[Reset] = do_reset, // 重置状态,为下一个压缩块做准备。
};
// write_buffer: 驱动CPIO解析状态机运转的核心引擎。
// @buf: 指向当前要处理的数据块的指针。
// @len: 数据块的长度。
// 返回值: 成功处理(消耗)的字节数。
static long __init write_buffer(char *buf, unsigned long len)
{
// 设置全局变量,供actions中的do_*函数访问。
byte_count = len; // 剩余待处理字节数。
victim = buf; // 当前正在被处理的数据缓冲区。
// 循环调用当前状态对应的动作函数,直到该函数返回非零值,
// 表示当前状态已完成,可以进行状态转移。
while (!actions[state]())
;
// 返回实际消耗的字节数。
return len - byte_count;
}
// flush_buffer: 解压器的回调函数,接收解压后的明文CPIO流。
// @bufv: 指向数据缓冲区的void指针。
// @len: 数据长度。
// 返回值: 成功则返回len,失败则返回-1。
static long __init flush_buffer(void *bufv, unsigned long len)
{
char *buf = bufv;
long written;
long origLen = len;
if (message) // 如果已经发生错误,则不再处理。
return -1;
// 循环处理,直到当前缓冲区的数据被完全消耗或发生错误。
// write_buffer可能不会一次消耗完整个len长度的数据。
while ((written = write_buffer(buf, len)) < len && !message) {
char c = buf[written]; // 获取已处理部分的下一个字节。
// 这个逻辑用于处理串联的CPIO归档。
if (c == '0') { // 如果是'0' (CPIO魔数),表示一个新的归档开始。
buf += written;
len -= written;
state = Start; // 重置状态机到初始状态。
} else if (c == 0) { // 如果是空字节(padding)。
buf += written;
len -= written;
state = Reset; // 重置状态,为下一个压缩流做准备。
} else
error("junk within compressed archive"); // 否则,是无效数据。
}
// 向上游的解压器报告,我们已经处理了所有被给予的数据。
return origLen;
}
Initramfs解包器:解压并提取归档至根文件系统
本代码片段是Linux内核中负责initramfs解压和提取的核心函数unpack_to_rootfs。其主要功能是接收一个内存中的initramfs归档(archive)作为输入,自动检测其压缩格式(如gzip, bzip2, lzma等),对其进行解压,并将解压出的CPIO(Copy In, Copy Out)归档流解析,最终在rootfs(一个空的ramfs实例)中创建出对应的目录和文件结构。这是内核从一个二进制镜像文件过渡到拥有一个可用文件系统的关键步骤。
实现原理分析
unpack_to_rootfs的实现是一个巧妙的流式处理器,它能够处理一个或多个串联在一起的(压缩或未压缩的)数据块。其工作原理如下:
- 缓冲区分配 : 函数首先通过
kmalloc分配一块内存,用于存放CPIO头部、符号链接目标路径和文件名等解析过程中的临时数据。 - 主处理循环 : 函数的核心是一个
while循环,只要还有未处理的数据(len > 0),循环就会继续。这个循环的设计使得它可以处理由多个数据段拼接而成的initramfs镜像。 - 压缩格式检测 : 在循环的每次迭代中,它首先调用
decompress_method。这个函数会检查输入缓冲区buf头部的"魔数"(magic numbers),以识别出数据流的压缩格式(例如,gzip的魔数是0x1f 0x8b)。如果识别成功,它会返回一个对应的解压缩函数指针(如decompress_gunzip)。 - 解压与数据流处理 :
- 如果
decompress_method返回了一个有效的解压函数,unpack_to_rootfs就会调用这个函数。 - 这个解压函数采用回调机制 工作。它会从
buf中读取压缩数据,进行解压,然后将解压出的明文数据块 (即CPIO归档流)分批次地传递给一个名为flush_buffer的回调函数(该函数未在此代码段中显示,但属于同一文件)。 flush_buffer函数内部实现了一个CPIO解析的状态机。它接收明文数据,解析CPIO头部,根据头部信息在rootfs中创建文件、目录、符号链接等,并写入文件内容。
- 如果
- 处理未压缩数据 : 如果输入数据段不是一个已知的压缩格式,但以CPIO的魔数'0'开头,代码会直接调用
write_buffer,将这段未压缩的数据直接送入CPIO解析状态机。 - 迭代处理 : 当一个压缩流解压完毕或一个未压缩段处理完毕后,主循环会更新
buf指针和剩余长度len,使其指向下一段数据,然后开始新一轮的格式检测和处理。这个过程会一直持续,直到所有输入数据都被消耗完毕。 - 清理: 循环结束后,函数会释放之前分配的缓冲区和CPIO解析过程中可能产生的其他资源(如硬链接哈希表)。
代码分析
c
#define CPIO_HDRLEN 110 // 定义CPIO头部固定长度为110字节。
// my_inptr: 用于记录当前压缩流已处理的字节数,在解压函数返回后更新主循环的指针。
static unsigned long my_inptr __initdata;
#include <linux/decompress/generic.hh> // 包含通用解压缩框架的头文件。
// unpack_to_rootfs: 解压并提取一个initramfs归档。
// @buf: 指向内存中initramfs归档数据的指针。
// @len: 归档数据的总长度。
// 返回值: 成功则为NULL,失败则返回一个描述错误的字符串。
char * __init unpack_to_rootfs(char *buf, unsigned long len)
{
long written;
decompress_fn decompress; // 函数指针,用于指向具体的解压函数。
const char *compress_name;
// 定义一个结构体来统一分配所有需要的临时缓冲区。
struct {
char header[CPIO_HDRLEN];
char symlink[PATH_MAX + N_ALIGN(PATH_MAX) + 1];
char name[N_ALIGN(PATH_MAX)];
} *bufs = kmalloc(sizeof(*bufs), GFP_KERNEL); // 分配缓冲区内存。
if (!bufs)
panic_show_mem("can't allocate buffers"); // 分配失败则系统panic。
// 设置全局指针指向分配好的缓冲区。
header_buf = bufs->header;
symlink_buf = bufs->symlink;
name_buf = bufs->name;
// 初始化CPIO解析状态机。
state = Start;
this_header = 0;
message = NULL;
// 循环处理输入数据,直到数据耗尽或发生错误。
while (!message && len) {
loff_t saved_offset = this_header;
// 检查是否为未压缩的CPIO归档(以'0'开头且4字节对齐)。
if (*buf == '0' && !(this_header & 3)) {
state = Start;
// 直接将未压缩的数据写入CPIO解析器。
written = write_buffer(buf, len);
buf += written;
len -= written;
continue;
}
// 跳过填充的空字节。
if (!*buf) {
buf++;
len--;
this_header++;
continue;
}
this_header = 0;
// 检测压缩方法,获取对应的解压函数和压缩名。
decompress = decompress_method(buf, len, &compress_name);
pr_debug("Detected %s compressed data\n", compress_name);
if (decompress) { // 如果找到了解压函数
// 调用解压函数。它会通过flush_buffer回调来处理解压后的数据。
int res = decompress(buf, len, NULL, flush_buffer, NULL,
&my_inptr, error);
if (res)
error("decompressor failed");
} else if (compress_name) { // 如果识别出格式但内核未配置支持
pr_err("compression method %s not configured\n",
compress_name);
error("decompressor failed");
} else // 如果未识别出任何已知的压缩格式
error("invalid magic at start of compressed archive");
if (state != Reset)
error("junk at the end of compressed archive"); // 检查解压后是否有多余数据。
// 更新文件指针和剩余长度,准备处理下一个数据块。
this_header = saved_offset + my_inptr;
buf += my_inptr;
len -= my_inptr;
}
dir_utime(); // 更新所有创建目录的时间戳。
free_hash(); // 释放硬链接处理时使用的哈希表。
kfree(bufs); // 释放临时缓冲区。
return message; // 返回NULL表示成功,或返回错误信息。
}
传统Initrd处理:将内存镜像保存为文件
本代码片段定义了populate_initrd_image函数,它是内核rootfs填充过程中的一个回退(fallback)机制 。当引导加载程序提供了一个内存镜像,而内核尝试将其作为现代initramfs(CPIO归档)解压失败时,此函数就会被调用。它的核心功能是假定该镜像是传统的initrd(一个原始的文件系统镜像,如ext2),并将其完整地、不加修改地保存为rootfs中的一个名为/initrd.image的文件。这个过程将后续解析和挂载initrd的责任委托 给了用户空间的/init脚本。
实现原理分析
此函数的实现逻辑简单而直接,它扮演了一个数据"搬运工"的角色,而不是解析器。
- 条件编译 : 整个函数被包裹在
#ifdef CONFIG_BLK_DEV_RAM中。这是因为传统initrd的处理流程在用户空间通常需要一个RAM块设备(如/dev/ram0)。如果内核没有编译RAM块设备的支持,那么保存这个镜像文件就毫无意义,因此整个功能被编译排除。 - 信息通知 : 函数首先打印一条内核日志,明确告知用户,传递的镜像不是
initramfs,现在正被当作initrd处理。这对于系统启动调试至关重要。 - 文件创建 : 它使用VFS函数
filp_open,在当前的rootfs(这是一个ramfs实例)中创建一个新文件/initrd.image。O_WRONLY|O_CREAT标志确保了文件被创建并以只写模式打开。权限被设置为0700(所有者读、写、执行)。 - 数据转储 : 函数的核心操作是调用
xwrite。这个辅助函数负责将引导加载程序放置在内存中(从物理地址initrd_start到initrd_end)的整个initrd镜像的原始二进制数据,完整地写入到刚刚创建的/initrd.image文件中。 - 资源释放 : 写入完成后,调用
fput(file)来关闭文件描述符并减少其引用计数,释放相关资源。
至此,内核对initrd的处理就结束了。后续的典型用户空间启动脚本 (/init) 会执行如下操作:
- 创建RAM块设备节点:
mknod /dev/ram0 b 1 0 - 将镜像内容拷贝到RAM块设备:
dd if=/initrd.image of=/dev/ram0 - 挂载RAM块设备作为新的根文件系统:
mount /dev/ram0 /new_root - 切换根目录到新的根文件系统:
pivot_root /new_root /new_root/old_root
代码分析
c
#ifdef CONFIG_BLK_DEV_RAM // 仅当内核配置了RAM块设备支持时,才编译此函数。
// populate_initrd_image: 处理传统initrd镜像的回退函数。
// @err: 从initramfs解压失败时传递过来的错误信息字符串。
static void __init populate_initrd_image(char *err)
{
ssize_t written;
struct file *file;
loff_t pos = 0;
// 打印内核日志,告知用户该镜像被当作initrd处理。
printk(KERN_INFO "rootfs image is not initramfs (%s); looks like an initrd\n",
err);
// 在当前的根文件系统(ramfs)中,创建并打开一个名为"/initrd.image"的文件。
// 模式为只写、创建、支持大文件,权限为0700 (所有者读/写/执行)。
file = filp_open("/initrd.image", O_WRONLY|O_CREAT|O_LARGEFILE, 0700);
if (IS_ERR(file)) // 检查文件打开是否成功。
return;
// 调用xwrite,将引导加载程序加载到内存中的initrd镜像的全部内容
// (从initrd_start到initrd_end) 写入到"/initrd.image"文件中。
written = xwrite(file, (char *)initrd_start, initrd_end - initrd_start,
&pos);
// 检查写入的字节数是否与镜像大小一致。
if (written != initrd_end - initrd_start)
pr_err("/initrd.image: incomplete write (%zd != %ld)\n",
written, initrd_end - initrd_start);
// 关闭文件,释放文件对象。
fput(file);
}
#endif /* CONFIG_BLK_DEV_RAM */
Rootfs填充:从内存镜像到可用的初始文件系统
本代码片段是Linux内核启动过程中一个至关重要的环节。它的核心功能是找到、解压并填充初始根文件系统(rootfs)。rootfs最初是一个空的ramfs实例,而这段代码负责将一个初始的用户空间环境(通常是initramfs CPIO归档)解压到其中,从而为内核执行第一个用户空间进程(/init)提供必要的文件和目录。代码还优雅地处理了现代initramfs和传统initrd两种不同的初始RAM磁盘机制。
实现原理分析
该功能的实现逻辑清晰地分层,并在一个rootfs_initcall中被触发,确保它在VFS初始化后、大部分驱动加载前执行。
- 异步调度 (
populate_rootfs) :- 它不直接执行填充操作,而是通过
async_schedule_domain将核心工作函数do_populate_rootfs调度为异步执行。这在多核系统上可以与其它初始化任务并行。 - 它提供了一个同步点
wait_for_initramfs。如果内核的其他部分(在rootfs_initcall之后)需要访问文件系统,可以调用此函数来确保rootfs的填充已经完成。
- 它不直接执行填充操作,而是通过
- 核心填充逻辑 (
do_populate_rootfs) :- 第一优先级:内置initramfs : 首先,它会无条件地尝试解压一个内置 的
initramfs。__initramfs_start和__initramfs_size是链接器在内核编译时定义的符号,指向一个被链接进内核二进制文件(vmlinux)的CPIO归档。这是现代嵌入式系统中最常见的配置。如果解压失败,系统会panic,因为这是最基本的根文件系统来源。 - 第二优先级:外部initramfs : 接下来,它检查
initrd_start。这个变量(如果不为0)指向由引导加载程序 (如U-Boot, GRUB)加载到内存中的外部镜像。内核会尝试将这个外部镜像也作为initramfs(CPIO归档)来解压 。如果成功,其内容会覆盖 在内置initramfs之上,允许在不重新编译内核的情况下更新或修改初始用户空间。 - 失败回退:传统initrd (
populate_initrd_image) : 如果将外部镜像作为initramfs解压失败(例如,因为它不是一个CPIO归档),代码会进入一个回退逻辑。这通常意味着外部镜像是一个传统的initrd,即一个文件系统的原始镜像(如ext2, romfs等)。- 在这种情况下,内核不会去解析这个文件系统镜像。
- 相反,它会调用
populate_initrd_image,这个函数会在已经部分填充的rootfs中创建一个名为/initrd.image的文件,并将外部镜像的原始二进制数据完整地写入这个文件中。 - 后续的启动过程依赖于
/init脚本来处理这个文件,典型的步骤是:mount -t ramfs ramfs /dev,mknod /dev/ram0 b 1 0,dd if=/initrd.image of=/dev/ram0,然后挂载/dev/ram0作为新的根。
- 第一优先级:内置initramfs : 首先,它会无条件地尝试解压一个内置 的
- 内存管理 : 填充操作完成后,代码会检查是否需要保留(
do_retain_initrd)外部镜像的内存。通常情况下,内存会被free_initrd_mem释放。如果需要保留(例如为了kexec),则会在sysfs中创建一个接口/sys/firmware/initrd,允许用户空间回读原始镜像。
代码分析
c
#ifdef CONFIG_BLK_DEV_RAM
// populate_initrd_image: 处理传统initrd镜像的回退函数。
static void __init populate_initrd_image(char *err)
{
ssize_t written;
struct file *file;
loff_t pos = 0;
// 打印信息,告知用户外部镜像不是initramfs,而是被当作initrd处理。
printk(KERN_INFO "rootfs image is not initramfs (%s); looks like an initrd\n",
err);
// 在当前的rootfs中创建一个名为"/initrd.image"的文件。
file = filp_open("/initrd.image", O_WRONLY|O_CREAT|O_LARGEFILE, 0700);
if (IS_ERR(file))
return;
// 将引导加载程序加载到内存的initrd镜像的原始数据,完整地写入到/initrd.image文件中。
written = xwrite(file, (char *)initrd_start, initrd_end - initrd_start,
&pos);
if (written != initrd_end - initrd_start)
pr_err("/initrd.image: incomplete write (%zd != %ld)\n",
written, initrd_end - initrd_start);
// 关闭文件。
fput(file);
}
#endif /* CONFIG_BLK_DEV_RAM */
// do_populate_rootfs: 填充rootfs的核心工作函数。
static void __init do_populate_rootfs(void *unused, async_cookie_t cookie)
{
/* 首先,加载并解压内置于内核二进制文件的initramfs。*/
char *err = unpack_to_rootfs(__initramfs_start, __initramfs_size);
if (err)
panic_show_mem("%s", err); // 如果失败,这是致命错误,系统panic。
// 如果没有外部initrd,或者配置了强制使用内置initramfs,则直接结束。
if (!initrd_start || IS_ENABLED(CONFIG_INITRAMFS_FORCE))
goto done;
printk(KERN_INFO "Trying to unpack rootfs image as initramfs...\n");
// 尝试将外部initrd也作为initramfs(CPIO归档)来解压。
err = unpack_to_rootfs((char *)initrd_start, initrd_end - initrd_start);
if (err) { // 如果解压失败
#ifdef CONFIG_BLK_DEV_RAM
// 假定它是一个传统的initrd镜像,并调用回退函数处理。
populate_initrd_image(err);
#else
// 如果不支持RAM块设备,则无法处理传统initrd,只能报错。
printk(KERN_EMERG "Initramfs unpacking failed: %s\n", err);
#endif
}
done:
security_initramfs_populated(); // 通知安全子系统initramfs已填充。
// 根据启动参数决定是否释放或保留initrd占用的内存。
if (!do_retain_initrd && initrd_start && !kexec_free_initrd()) {
free_initrd_mem(initrd_start, initrd_end);
} else if (do_retain_initrd && initrd_start) {
// 如果保留,则在sysfs中创建一个接口/sys/firmware/initrd以供访问。
bin_attr_initrd.size = initrd_end - initrd_start;
bin_attr_initrd.private = (void *)initrd_start;
if (sysfs_create_bin_file(firmware_kobj, &bin_attr_initrd))
pr_err("Failed to create initrd sysfs file");
}
// 清除全局变量,标记initrd已被处理。
initrd_start = 0;
initrd_end = 0;
init_flush_fput();
}
// 定义一个专用的异步执行域,确保initramfs填充操作是互斥的。
static ASYNC_DOMAIN_EXCLUSIVE(initramfs_domain);
static async_cookie_t initramfs_cookie;
// wait_for_initramfs: 提供一个同步点,供其他需要访问rootfs的模块调用。
void wait_for_initramfs(void)
{
if (!initramfs_cookie) {
pr_warn_once("wait_for_initramfs() called before rootfs_initcalls\n");
return;
}
// 等待initramfs填充任务(及其后续任务)完成。
async_synchronize_cookie_domain(initramfs_cookie + 1, &initramfs_domain);
}
EXPORT_SYMBOL_GPL(wait_for_initramfs);
// populate_rootfs: 注册到initcall框架的入口函数。
static int __init populate_rootfs(void)
{
// 异步调度do_populate_rootfs函数执行。
initramfs_cookie = async_schedule_domain(do_populate_rootfs, NULL,
&initramfs_domain);
usermodehelper_enable(); // 启用用户模式助手。
// 如果配置为同步模式,则立即等待填充完成。
if (!initramfs_async)
wait_for_initramfs();
return 0;
}
// 将populate_rootfs注册为rootfs_initcall,确保在极早的启动阶段被调用。
rootfs_initcall(populate_rootfs);