
Linux pstore崩溃日志存储与efi变量持久化pstorePersistent Store框架的核心实现在fs/pstore/platform.c它不生产日志只是日志的搬运工——将kmsg_dump机制捕获的内核崩溃、Oops、panic日志分发给注册的后端驱动程序持久化。后端的注册入口pstore_register()是一个严格单例设计全局指针psinfo只能被赋值一次第二次调用直接返回-EBUSY。这意味着系统中同时只能有一个活跃的pstore后端ramoops和efi-pstore是互斥的不可能同时注册。cint pstore_register(struct pstore_info *psi){if (psinfo)return -EBUSY;if (backend strcmp(backend, psi-name))return -EINVAL;psinfo psi;if (owner !try_module_get(owner)) {psinfo NULL;return -EINVAL;}if (pstore_is_mounted())pstore_get_records();kmsg_dump_register(pstore_dumper);return 0;}EXPORT_SYMBOL_GPL(pstore_register);每个后端通过struct pstore_info定义自己的操作向量表定义在include/linux/pstore.h。buf和bufsize由后端预分配pstore核心在写路径中填充这个缓冲区然后调用后端的write回调。read_mutex序列化读取操作但这个锁在panic上下文中毫无意义——如果读取发生在crash后的重启过程中那是冷读取路径不需要考虑竞态。cstruct pstore_info {struct module *owner;const char *name;char *buf;size_t bufsize;struct mutex read_mutex;ssize_t (*read)(u64 *id, enum pstore_type_id *type,struct timespec64 *time, struct pstore_info *psi);u64 (*write)(enum pstore_type_id type, unsigned int part,size_t size, struct pstore_info *psi);int (*erase)(u64 id, struct pstore_info *psi);};记录类型的枚举enum pstore_type_id是一个隐含的ABI因为这些整数值会被写入持久存储EFI NVRAM变量名中嵌入了type字段一旦变更会导致旧记录无法解析。PSTORE_TYPE_DMESG(0)是核心的崩溃转储类型PSTORE_TYPE_CONSOLE(2)捕获printk输出PSTORE_TYPE_FTRACE(3)保存函数跟踪缓冲区PSTORE_TYPE_PMSG(7)留给用户态写入持久消息。EFI后端在变量命名时直接将这些值编码进字符串这是前向兼容性的硬约束。写路径是整个子系统中最敏感的代码路径没有之一。pstore_dump()被kmsg_dump回调链在panic或Oops发生时调用此时系统已经处于不稳定状态可能运行在NMI上下文、RCU读侧临界区、或者本地中断已经被禁用的上下文中。任何可能导致睡眠的操作都是致命的。cbool pstore_cannot_block_path(enum kmsg_dump_reason reason){if (in_nmi())return true;switch (reason) {case KMSG_DUMP_PANIC:case KMSG_DUMP_EMERG:return true;default:return false;}}当pstore_cannot_block_path()返回true后端write回调必须非阻塞完成。这个判断覆盖了NMI和panic场景但有一个边界条件in_nmi()检查的是硬件NMI如果系统在panic之后又被同一个CPU上的另一个NMI中断嵌套此时reason是KMSG_DUMP_PANIC但in_nmi()也返回true双重判定是冗余正确的。更隐蔽的问题是CONFIG_PREEMPT_RCU下__rcu_read_lock()不改变preempt_count()早期内核用preemptible()来判断是否可阻塞是完全错误的。c// pstore_dump() 核心逻辑简化void pstore_dump(struct kmsg_dumper *dumper, enum kmsg_dump_reason reason){unsigned long flags;int ret;if (!psinfo-write)return;if (pstore_cannot_block_path(reason)) {if (!spin_trylock_irqsave(psinfo-buf_lock, flags))return; // 拿不到锁直接丢弃本次dump} else {spin_lock_irqsave(psinfo-buf_lock, flags);}kmsg_dump_get_buffer(dumper, true, psinfo-buf,psinfo-bufsize, header_size);ret psinfo-write(PSTORE_TYPE_DMESG, ...);spin_unlock_irqrestore(psinfo-buf_lock, flags);}spin_trylock的设计是一个有意的折中——panic路径中宁可丢日志也不能死锁。如果另一个CPU正在持有buf_lock做写入比如并发Oops这个CPU直接放弃。在多核系统中如果多个CPU同时进入panic只有一个CPU能成功拿到锁其他CPU的日志全部丢失。这是一个已知的限制在fs/pstore/platform.c的注释中明确记载。2024年Wen Yang将buf_lock从spinlock_t替换为raw_spinlock_tcommit在include/linux/pstore.h原因是在CONFIG_PREEMPT_RT内核中spinlock_t被降级为可睡眠锁在panic上下文使用它会触发might_sleep()警告。EFI pstore后端实现位于drivers/firmware/efi/efi-pstore.c。它将日志数据写入UEFI运行时服务的NVRAM变量中利用UEFI固件对NVRAM的持久化能力。每个日志记录对应一个EFI变量变量名编码了完整的元数据。dump-type----type对应enum pstore_type_id的整数值part是分区号count是序列计数器2012年Seiji Aguchi的补丁引入解决快速连续panic导致变量名冲突覆盖的问题timestamp是自epoch起的秒数compression_flag为C压缩或D未压缩。变量使用固定的GUID LINUX_EFI_CRASH_GUIDcfc8fc79-be2e-4ddc-97f0-9f98bfe298a0这是pstore和UEFI固件之间的约定标识避免与其他EFI变量冲突。变量属性固定为PSTORE_EFI_ATTRIBUTESc#define PSTORE_EFI_ATTRIBUTES \(EFI_VARIABLE_NON_VOLATILE | \EFI_VARIABLE_BOOTSERVICE_ACCESS | \EFI_VARIABLE_RUNTIME_ACCESS)EFI_VARIABLE_NON_VOLATILE确保重启后数据不丢失。BOOTSERVICE_ACCESS | RUNTIME_ACCESS的组合使得变量在ExitBootServices前后均可访问——这对crash场景至关重要因为panic可能在启动服务的任何阶段发生。写路径efi_pstore_write()构造变量名后调用efivar_entry_set_safe()写入NVRAM。这个函数内部做了一次关键的上下文判断如果当前在panic或NMI路径中不可阻塞它使用efivar_set_nonblocking()绕过信号量和锁直接调用SetVariable()运行时服务。否则使用阻塞版本。但这种判断在早期实现中是通过preemptible()完成的而2022年Jann Horn指出pstore_dump()始终在原子上下文中被调用RCU读侧临界区或spinlock保护下preemptible()在CONFIG_PREEMPT_RCU下返回错误值导致路径判断完全失效。c// efi-pstore 写路径基于kernel 6.xstatic u64 efi_pstore_write(enum pstore_type_id type, unsigned int part,size_t size, struct pstore_info *psi){char name[EFI_VARIABLE_NAME_MAX];u64 record_id;unsigned int count;int ret;count atomic_inc_return(efi_pstore_seq_counter);record_id generic_id(ktime_get_real_seconds(), part, count);snprintf(name, sizeof(name), dump-type%u-%u-%u-%llu-%c,type, part, count, record_id,psi-flags PSTORE_FLAGS_COMPRESS ? C : D);efivar_entry_set_safe(name, LINUX_EFI_CRASH_GUID,PSTORE_EFI_ATTRIBUTES,psi-buf, size);return record_id;}generic_id()是一个关键函数它将timestamp、part和count编码为一个64位id该id在擦除时用于重建变量名。但这里存在一个32位时间戳的溢出风险ktime_get_real_seconds()返回time64_t而早期的EFI变量名中使用get_seconds()返回unsigned long在32位平台上2038年问题会直接影响变量名的唯一性和pstore记录的解析。读路径efi_pstore_read()需要扫描NVRAM中所有匹配LINUX_EFI_CRASH_GUID的变量解析变量名提取元数据字段然后调用GetVariable()读取数据。扫描过程在efivar_entry_iter_begin()/efivar_entry_iter_end()保护的链表中进行。但pstore的读接口是基于游标的——每次调用read返回一条记录上层通过循环调用直到返回0。这意味着锁需要在多次调用间保持一致性而efi_pstore_read()的实现中锁的持有周期和游标的推进必须严格匹配否则会出现记录遗漏或重复读取。c// 读路径的游标管理static ssize_t efi_pstore_read(u64 *id, enum pstore_type_id *type,struct timespec64 *time,struct pstore_info *psi){static struct efivar_entry *entry;ssize_t size;unsigned int part, count;unsigned long sec;if (!entry)entry list_first_entry(efivar_sysfs_list, ...);// 遍历链表查找匹配的dump变量while (entry-list ! efivar_sysfs_list) {// 解析变量名提取 type/part/count/timestamp// 调用 GetVariable 读取数据size efivar_entry_read(entry, ...);if (size 0) {// 成功返回游标指向下一个entry list_next_entry(entry, list);return size;}if (size 0)// 变量存在但数据为空可能是其他EFI子系统创建continue;// size 0 时停止读取entry NULL;return -EIO;}entry NULL;return 0;}这个实现有一个竞态窗口用户态通过/sys/fs/pstore/读取记录后删除文件删除操作调用efi_pstore_erase()从NVRAM中移除变量。如果读取过程中另一个进程删除了当前游标指向的变量list_next_entry()会访问已释放的链表节点造成UAFUse-After-Free。早期的补丁如Seiji Aguchi的v3补丁Hold off deletion of sysfs entry until the scan is completed试图通过引用计数延迟删除来解决此问题但链表扫描和变量删除之间的序列化始终是这个后端的阿喀琉斯之踵。EFI NVRAM的写入寿命和空间限制是另一个不容忽视的现实约束。大多数UEFI固件的NVRAM使用SPI NOR Flash擦写寿命在10万次量级且每次SetVariable()的写入延迟在毫秒级别——这在panic路径中是不可接受的因为panic时系统可能只有几百微秒的稳定窗口来写入数据。efi-pstore的每次写入操作都会产生一次完整的NVRAM写入周期如果系统频繁崩溃例如在驱动开发调试阶段NVRAM介质可能提前磨损。更隐蔽的问题是EFI变量的大小限制。UEFI规范要求每个变量最大不超过1024字节某些固件实现可能更小而一个完整的dmesg缓冲区可能达到128KB甚至更大。efi-pstore无法像ramoops那样存储完整的dmesg——它只能存储截断后的首部。kmsg_dump_get_buffer()的size参数受到psinfo-bufsize的限制而这个bufsize在efi-pstore中受限于EFI变量的大小上限。实际使用中通常只能保存4KB左右的日志首部这往往不足以捕获导致crash的完整调用栈和上下文。相比之下ramoops后端fs/pstore/ram.c使用预保留的物理内存区域作为存储介质完全绕过了固件NVRAM的大小和寿命限制。它通过memblock_reserve()或mem命令行参数在系统启动早期预留一段连续物理内存然后通过ioremap()映射为内核虚拟地址直接读写。但ramoops也有其自身的边界问题1. 内存预留的时机限制——必须在memblock分配器仍有效时预留这意味着只能通过命令行参数或早期platform驱动完成模块加载时再申请内存已经来不及。2. 缓冲区损坏检测——ramoops通过在缓冲区头部写入魔数struct persistent_ram_buffer中的sig字段来检测数据完整性但如果系统在两次写入之间crash魔数可能处于不一致状态导致读取时误判缓冲区有效或无效。3. 环形缓冲区的覆盖问题——当写入次数超过mem_size / record_size时旧记录被新记录覆盖。如果用户没有及时通过pstore文件系统读取并清除记录关键崩溃数据可能在无警告的情况下被静默覆盖。c// ramoops的环形缓冲区管理struct ramoops_context {void *virt_addr;phys_addr_t phys_addr;unsigned long size;size_t record_size;unsigned int count; // 当前写入位置unsigned int max_count; // 总槽位数 size / record_sizeunsigned int read_count; // 读取游标int dump_oops;struct persistent_ram_zone **przs; // 每个槽位的PRZ指针struct pstore_info pstore;};写入时count的计算是(count 1) % max_count这是一个无锁的原子操作——panic路径中不持有任何锁来保护count。如果两个CPU同时进入panic且该后端支持并发写入ramoops当前不支持count的更新会出现经典的读-改-写竞态。实际上ramoops的write回调是通过psinfo-write调用的而pstore核心在调用write之前已经用buf_lock做了序列化trylock语义所以ramoops自身不需要额外的锁保护count。但理解这一层保护链对正确分析问题至关重要不是ramoops的count字段本身是原子的而是pstore核心的调用方保证了一次只有一个CPU在写。efi-pstore的压缩处理引入了另一层复杂性。变量名末尾的-C和-D标记指示数据是否经过zstd或早期内核中的LZO/DEFLATE压缩。pstore核心在pstore_compress()中执行压缩压缩后的数据块作为EFI变量的value写入NVRAM。读取时pstore核心根据标记决定是否解压。但这个压缩是在写回调之前完成的——pstore_dump()先将原始日志写入psinfo-buf然后由pstore核心压缩到另一个缓冲区最后将压缩后的数据传给后端的write回调。这意味着psinfo-buf需要是未压缩的原始数据而后端write的参数才是压缩后的数据。这个设计有一个容易被忽略的后果压缩缓冲区在fs/pstore/platform.c中是动态分配的compress_buf但在panic路径中kmalloc可能失败导致压缩回退到不压缩模式此时-D标记被写入变量名。c// pstore压缩路径fs/pstore/platform.cstatic int pstore_compress(const char *buf, size_t size, char **compressed){int ret;if (!IS_ENABLED(CONFIG_PSTORE_COMPRESS))return -EOPNOTSUPP;if (!zstream) {zstream kmalloc(sizeof(*zstream), GFP_KERNEL);if (!zstream)return -ENOMEM;zstd_init_stream(zstream);}ret zstd_compress(zstream, *compressed, size, buf, size);if (ret 0)return ret; // 返回压缩后大小return -EINVAL;}zstream是延迟分配的静态变量首次压缩时分配。如果在panic路径中第一次触发压缩系统崩溃前从未发生过需要压缩的日志写入kmalloc在原子上下文中分配内存可能失败——因为panic时系统内存分配器可能已经处于不一致状态。这导致第一次panic时压缩路径必然失败记录以未压缩形式写入。后续的panic如果内存分配器状态未进一步恶化压缩可能成功。这种行为的不确定性使得系统行为不可预测。从性能角度看efi-pstore的写入延迟是ramoops的数个数量级之上。ramoops的写入是一个memcpy到预映射内存区域延迟在纳秒级。efi-pstore需要经过UEFI运行时服务调用通过SetVirtualAddressMap映射后的虚拟地址进入SMMSystem Management Mode或类似的固件执行环境完成NVRAM磨损均衡、垃圾回收等固件内部操作延迟通常在毫秒级。在panic路径中系统可能已经没有足够的稳定性来等待EFI运行时服务完成——特别是在SMM模式下固件可能访问PCIe配置空间或其他已损坏的系统状态导致固件自身挂起。这也是为什么efi_pstore_write()在panic路径中使用efivar_set_nonblocking()的原因但非阻塞只是不等待内核锁并不能控制固件内部的执行时间。后端的erase操作也存在一致性风险。efi_pstore_erase()通过set_variable(vendor, name, attributes, 0, NULL)删除变量将size设为0是UEFI删除变量的标准方式。但如果删除操作在NVRAM垃圾回收中途失败例如固件崩溃或系统掉电NVRAM中可能出现孤立条目既不可读也不可写占据存储空间直到下一次固件垃圾回收运行。pstore文件系统在读取记录时无法检测这种不一致状态——它只能解析所有匹配GUID的变量对于损坏的变量名直接跳过efi_pstore_read()在sscanf解析失败时返回0继续扫描。对于长期运行的嵌入式系统EFI NVRAM中的pstore记录如果没有被定期清理会累积占用NVRAM空间。UEFI规范定义的NVRAM空间通常只有64KB到256KBpstore记录的累积可能导致其他EFI变量如Secure Boot密钥、Boot Manager条目的写入失败因为NVRAM空间不足。这个问题在桌面和服务器系统中可以通过定期检查/sys/firmware/efi/efivars/目录并清理pstore条目来缓解但在无人值守的嵌入式设备中是一个真实的运维隐患。另一个被广泛低估的边界情况是efi-pstore在kexec环境中的行为。当使用kexec启动第二个内核时新内核会枚举EFI运行时服务并初始化efi-pstore后端。此时NVRAM中来自第一个内核的pstore记录应该被新内核的pstore文件系统读取。但kexec跳过了正常的固件初始化流程EFI变量服务的映射状态可能不一致特别是在启用了页面粒度的EFI虚拟地址映射后。如果第二个内核的EFI映射与第一个内核的映射冲突GetVariable()运行时服务可能返回EFI_INVALID_PARAMETER导致第二个内核无法读取任何pstore记录。社区对此的修复是在efi-pstore的probe路径中验证运行时服务指针的有效性但如果验证失败所有历史记录将永久不可访问。