1. 为什么值得花时间啃 Pstore 这块硬骨头
第一次翻到pstore这个目录的时候,我其实是有点懵的。它在内核源码树里位置不算显眼,fs/pstore/底下就那么几个文件,代码量跟 ext4、xfs 这些动辄几万行的大家伙比起来简直是小巫见大巫。但真正在项目里踩过一次坑之后我才明白,这块"小代码"承担的责任一点都不小——它管的是系统崩溃那一瞬间,怎么把最后那点现场信息给留下来。
Pstore,全称 Persistent Storage,直译过来就是"持久化存储"。名字听着朴素,干的事却很关键:在系统发生 panic、oops、硬件异常重启这类"来不及做任何收尾"的场景下,把内核日志、崩溃现场、甚至一段自定义数据,写进一块掉电不丢的存储介质里,等系统重新起来之后再读出来。你可以把它理解成飞机的黑匣子——平时它安安静静待着不干活,一旦出事,它就是唯一能告诉你"刚才到底发生了什么"的东西。
这个内容适合谁来啃?我的判断是三类人。第一类是做嵌入式、车载、工业控制这类对稳定性要求极高的开发者,设备跑在现场,出了问题你不可能每次都抱着调试器去现场抓,只能靠 pstore 留下的现场。第二类是做内核调试、故障分析的同学,panic 之后串口没输出、日志没落盘的情况太常见了,pstore 是兜底手段。第三类是想深入理解内核"异常路径"设计的爱好者,pstore 的代码把"在极端受限环境下还要保证可靠"这件事演绎得很典型,读它有举一反三的价值。
这篇是系列的第二篇,第一篇我们聊了 Pstore 是什么、能干什么、有哪些后端。这一篇往深里走,专门讲原理:它凭什么能在系统都快挂掉的时候还能写数据?它的数据是怎么组织的?前端和后端是怎么分工的?为什么它要设计成现在这个样子?我会尽量把每个"为什么"都掰开讲清楚,因为这块东西如果只记结论,换个平台、换个后端你又会懵。文中涉及的具体操作和参数,我会基于常见实践给出可复现的方案,同时明确标注哪些是通用做法、哪些需要结合你自己的平台调整。
2. Pstore 的整体架构与设计思路拆解
2.1 前端与后端分离:一个刻意为之的分层
Pstore 最核心的设计思想,就是把"记录什么"和"存到哪里"彻底拆开。这个拆分不是随便分的,而是被现实逼出来的。
你想想,崩溃现场能用的存储介质五花八门:有的是内存里一块掉电不丢的区域(比如各种 RAMoops 类的方案),有的是板子上的 flash 分区,有的是通过固件接口暴露出来的一小块存储,还有的是 UEFI 变量区。这些介质的读写方式、擦除粒度、寿命特性完全不同。如果每接一种介质就要重写一遍"怎么记录日志、怎么组织记录"的逻辑,那代码会烂成一锅粥。
所以 Pstore 抽象出了两层。前端(frontend)负责"记录"这件事:它定义了记录的类型(dmesg、console、pmsg、ftrace 等)、记录的格式、怎么把一条记录写进去、怎么读出来、怎么删除。后端(backend)负责"存储"这件事:它只关心最原始的字节读写和擦除,向上提供一组统一的回调接口。前端调用后端的回调,后端不关心你写的是日志还是别的什么。
这个分层带来的直接好处是:新增一种存储介质,你只需要实现后端的那几个回调(read、write、erase 之类),前端逻辑一行都不用动;反过来,新增一种记录类型,也只需要在前端加,后端无感。我在实际项目里换过一次后端介质,从一种内存保留区换到 flash 分区,前端代码确实没动,只改了设备树里的描述和对应的后端驱动,这个体验很能说明分层设计的价值。
提示:理解这个分层是读懂整个
fs/pstore/目录的钥匙。pstore.h里那个struct pstore_info就是后端要实现的接口契约,前端所有的操作最终都落到这个结构体里的函数指针上。
2.2 记录类型的设计:为什么不是简单写个文件
很多人第一反应是:崩溃了往存储里写个文本文件不就完了,搞这么复杂干嘛?这个想法在正常系统里成立,但在崩溃路径上完全不成立。
正常写文件要走 VFS、要经过文件系统、要分配块、要更新元数据、要保证一致性,这一整套流程在 panic 上下文里根本跑不起来——中断可能已经关了,调度器可能已经停了,内存分配可能已经不可用了,文件系统的锁可能还握在别的 CPU 手里。你在这个环境下调用文件系统接口,大概率是二次崩溃或者直接死锁。
所以 Pstore 的记录格式是极简的、自描述的、不依赖任何文件系统的。每条记录在存储介质里就是一段连续的字节,头部带一个固定格式的描述结构,记录类型、长度、校验信息都在里面。读取的时候按这个头部去解析,不需要目录、不需要 inode、不需要任何外部索引。这种设计牺牲了灵活性(比如你不能像文件一样随机改某一条的中间部分),换来的是在极端环境下的可靠性。
记录类型目前主要有这么几类,我列个表对照着看更清楚:
| 记录类型 | 典型用途 | 数据来源 | 特点 |
|---|---|---|---|
| dmesg | 内核日志 | 内核 printk 环形缓冲区 | 崩溃时把整个日志缓冲 dump 下来,信息量最大 |
| console | 控制台输出 | 控制台驱动 | 只保留真正打印到控制台的部分,比 dmesg 精简 |
| pmsg | 用户态消息 | 用户空间通过接口写入 | 应用层主动记录关键信息,崩溃前可预埋 |
| ftrace | 函数跟踪 | ftrace 框架 | 记录函数调用轨迹,用于性能与流程分析 |
| 自定义 | 业务数据 | 驱动或应用 | 按需扩展,格式自定 |
这里有个细节值得说:dmesg 和 console 看起来重复,其实定位不同。dmesg 抓的是内核日志缓冲区里的全部内容,包括那些因为日志级别没打印到控制台的;console 抓的是真正输出到控制台设备的内容。崩溃时两者都存,能互相印证,有时候 dmesg 里有但 console 里没有的那几条,恰恰是关键线索。
2.3 环形存储与记录淘汰:空间有限下的取舍
存储介质给 Pstore 的空间通常很小,几 KB 到几 MB 不等,不可能无限往里写。所以 Pstore 采用的是环形(或者说循环)使用的策略:空间写满了,就淘汰最老的记录,给新记录腾地方。
这个策略背后有个很实际的判断:崩溃现场分析,最新的记录往往最有价值。系统崩溃前最后那几秒的日志,比一小时前的日志重要得多。所以淘汰最老的、保留最新的,是符合故障分析直觉的。
但这里有个坑我得提前说:淘汰是按记录粒度做的,不是按字节。如果一条 dmesg 记录特别大(比如日志缓冲区设得很大),它可能一次就占掉大半个存储区,把之前所有记录都挤掉。所以实际配置时,存储区大小和日志缓冲区大小要匹配着来,不能一个设得巨大另一个给得可怜,否则要么浪费要么频繁淘汰。这个参数匹配的问题我在第 4 节会具体讲怎么算。
2.4 为什么要在崩溃路径上做这么多保护
Pstore 的代码里到处是防御性的写法:加锁要小心、内存分配要用特殊标志、写操作要尽量短、失败要能优雅降级。这些不是过度设计,每一条都对应着崩溃路径上的一个真实风险。
比如内存分配,正常路径用GFP_KERNEL可以睡眠等待,但崩溃路径上调度器可能已经停了,一睡眠就永远醒不过来。所以 Pstore 在崩溃写入路径上用的分配标志是那种"不允许睡眠、不允许重试"的类型,宁可分配失败也不能卡住。再比如加锁,崩溃时可能是在持有某把锁的上下文里触发的,如果 Pstore 自己再用同一把锁或者有依赖关系的锁,就会死锁。所以它的锁设计得非常克制,临界区尽量短。
这些保护措施单看每一条都很琐碎,但合起来就构成了"在系统濒死状态下还能可靠工作"的能力。读这部分代码的时候,我建议你带着"如果这里出问题会怎样"的思路去看,会比单纯记流程收获大得多。
3. 核心数据结构与关键机制解析
3.1 pstore_info:后端必须遵守的契约
前面说了前端后端分离,那这个"分离面"具体长什么样?答案就是struct pstore_info。这个结构体是后端驱动必须填充的,前端所有操作都通过它来发起。
它里面最关键的几个成员,我按重要性排一下。首先是name,后端的名字,注册后会出现在/sys/module/pstore/或者对应的 sysfs 节点下,方便识别当前用的是哪个后端。然后是flags,一堆标志位,描述这个后端支持哪些能力,比如是否支持压缩、是否是某种特殊类型。接着是核心的回调函数指针:read用来从指定偏移读一段数据,write用来往指定偏移写,erase用来擦除一个范围。这几个是必须实现的,缺一个后端就跑不起来。
还有几个成员容易被忽略但很重要。bufsize表示后端单次读写能处理的最大块大小,前端在组织数据时要按这个来分块,超过就得拆。total_size是后端能提供的总空间。这两个值直接决定了 Pstore 能用多大空间、单条记录最大能多大。我在调试时就遇到过后端bufsize设得太小,导致大日志被拆成很多小块、写入效率极低的情况,后来把bufsize调大才顺畅。
注意:
bufsize不是越大越好。它受限于后端介质的实际能力,比如某些 flash 的擦除块大小、某些接口的单次传输上限。设得超过介质能力,写入会失败或者被底层悄悄截断,反而更难排查。设之前先查清楚你的介质规格。
3.2 pstore_record:一条记录的完整生命周期
如果说pstore_info是后端的契约,那struct pstore_record就是前端操作的基本单位。一条记录从产生到消亡,大致经历这么几个阶段。
创建阶段:前端根据记录类型,从对应的数据源把内容抓过来,填进 record 结构。比如 dmesg 类型就是从内核日志缓冲区里 dump。这个阶段要确定记录的大小,如果超过后端单次能写的量,就要考虑压缩或者截断。
写入阶段:前端把 record 序列化成字节流,调用后端的write回调,按偏移写进存储。写入前会先检查空间够不够,不够就触发淘汰逻辑,把老记录删掉腾地方。这里有个顺序问题:是先淘汰再写,还是边写边淘汰?实现上是先确保有足够空间再写,避免写到一半发现空间不够导致记录残缺。
读取阶段:系统重启后,前端遍历存储区,按记录头部的描述结构解析出每条记录,通过 sysfs 或者/dev/pstore/下的文件暴露给用户态。用户态读文件,前端就去后端读对应偏移的数据返回。
删除阶段:用户态删除某条记录(比如rm /dev/pstore/dmesg-xxx),前端调用后端erase把对应区域擦掉,标记为可复用。
这个生命周期里,最微妙的是写入阶段的"空间检查 + 淘汰"逻辑。因为淘汰本身也是写操作(要擦除),如果淘汰过程中又崩溃了,状态可能不一致。Pstore 对此的处理是尽量让每条记录自包含、带校验,读取时校验不过就跳过,这样即使中途出问题,也不会把整个存储区搞坏,最多丢一条记录。这种"单条记录失败不影响整体"的设计思路,在可靠性要求高的系统里很值得借鉴。
3.3 压缩机制:小空间里塞更多信息
存储空间紧张,日志又大,怎么办?压缩。Pstore 支持对记录做压缩,用的是内核里现成的压缩算法(常见的是 zlib 或者 lzo 这类,具体取决于配置)。
压缩的收益很直接:内核日志文本重复度高、可压缩性强,压缩比经常能到 3 倍甚至更高。也就是说同样 64KB 的空间,不压缩只能存 64KB 日志,压缩后可能存下接近 200KB 的日志内容,这在崩溃分析时差别巨大——多出来的那部分很可能就包含关键线索。
但压缩也有代价。第一是 CPU 开销,崩溃路径上做压缩会消耗时间,如果压缩算法太重,可能还没压完系统就彻底挂了。所以选算法时要权衡压缩比和速度,通常选偏快的算法。第二是内存开销,压缩需要额外的缓冲区,崩溃路径上内存紧张,这个缓冲区要提前准备好或者用固定的小块。第三是兼容性,压缩后的数据读取时要解压,如果解压算法在恢复后的系统里不可用,数据就读不出来,所以压缩算法要和内核配置匹配好。
我在实际配置时的经验是:如果存储空间确实很紧张(比如只有几十 KB),压缩基本是必开的;如果空间比较宽裕(几 MB),可以不开压缩换取更简单的崩溃路径和更快的写入。这个取舍没有标准答案,看你的场景更怕"存不下"还是更怕"写不完"。
3.4 多后端共存与优先级
一个系统里能不能同时挂多个 Pstore 后端?可以。比如既有内存保留区,又有 flash 分区,两个都注册成后端。这时候前端会怎么选?
Pstore 支持给后端指定优先级,写入时会优先用高优先级的后端。这个设计的意义在于:不同介质的可靠性、容量、速度不同,你可以让最可靠的那个优先承接崩溃现场,其他的作为补充或者备份。比如内存保留区速度快、崩溃时写入成功率高,但容量小;flash 容量大但写入慢、崩溃时可能来不及。两个都挂上,让内存区优先,flash 兜底,是个常见组合。
不过多后端共存也带来复杂度:空间是各自独立的,淘汰也是各自进行的,读的时候要分别遍历。调试多后端问题时,先确认当前实际生效的是哪个后端(看 sysfs 里的信息),别对着一个没在用的后端调半天,这是我踩过的坑。
4. 实操过程:从配置到验证的完整链路
4.1 确认内核配置:先把开关打开
原理讲再多,配置没开都是空谈。Pstore 相关的内核配置项分散在几个地方,我按顺序列一下,你对着menuconfig或者.config检查。
首先是总开关,在文件系统相关的配置里,有个 Pstore 文件系统支持项,必须选上,否则fs/pstore/整个不参与编译。然后是各个后端的配置,比如内存保留区类的后端、flash 类的后端,按你实际用的介质选。再然后是记录类型的配置,dmesg、console、pmsg、ftrace 这些,用哪个开哪个,全开也行但会占空间。最后如果要用压缩,还得把对应的压缩算法配置打开。
这里有个容易漏的点:某些后端依赖特定的底层机制,比如内存保留区依赖内核启动时预留内存的参数,这个参数不在 Pstore 配置里,而在启动参数或者设备树里。配置项开了但启动参数没给,后端注册会失败,日志里能看到明确的报错。我第一次配的时候就漏了这个,对着"后端注册失败"的日志找了半天,最后发现是启动参数没加。
提示:配置完之后,用
zcat /proc/config.gz | grep -i pstore(如果内核开了 config 导出)或者直接查.config,确认相关项都是=y或=m。别凭记忆,一定实际查一遍。
4.2 后端空间大小的计算与设定
这是最容易拍脑袋、也最容易出问题的一步。空间给多大?我给个计算思路,你套自己的参数。
先估算单条记录的最大尺寸。以 dmesg 为例,它的大小约等于内核日志缓冲区的大小(log_buf_len参数决定)。如果你的日志缓冲区设的是 128KB,那一条 dmesg 记录压缩前就可能接近 128KB。再看压缩,假设压缩比 3 倍,压缩后约 43KB。那么存储区至少要能放下这一条压缩后的记录,还得留出余量给其他类型(console、pmsg 等)。
再考虑要保留几条历史记录。如果你希望崩溃后能看到最近 3 次崩溃的现场,那空间至少是单条记录大小乘以 3 再留余量。把这些加起来,就是存储区的下限。上限则受介质实际可用空间限制,不能超过。
举个具体的算例:日志缓冲区 128KB,压缩比按 3 算,单条 dmesg 约 43KB;console 记录假设 16KB;pmsg 预留 8KB;希望保留 2 份历史,则总量约 (43+16+8)×2 ≈ 134KB,向上取整到 256KB 比较稳妥。这个 256KB 就是你要在启动参数或设备树里给后端配置的大小。
反过来,如果你给的空间只有 64KB,而单条 dmesg 压缩后就要 43KB,那基本只能存一条,第二条一来就把第一条挤掉,历史记录根本留不住。这种配置在排查偶发问题时很吃亏,因为偶发问题往往需要对比多次崩溃的现场。
4.3 挂载与基础验证:确认它真的在工作
配置好、启动起来之后,第一件事是确认 Pstore 挂上了。Pstore 通过一个伪文件系统暴露给用户态,需要挂载才能访问。挂载点通常选/sys/fs/pstore或者/dev/pstore,具体看你的系统约定。
挂载命令大致是这样(不同发行版路径可能略有差异,按实际调整):
mount -t pstore pstore /sys/fs/pstore挂载成功后,ls一下挂载点,正常情况下应该是空的(还没产生记录)。这时候可以做个主动测试:往 pmsg 写一条消息,看能不能读出来。pmsg 的接口在/dev/pmsg0这类设备节点上,直接echo写入即可:
echo "pstore test message" > /dev/pmsg0然后重新挂载或者刷新一下,看挂载点下有没有出现对应的记录文件,cat出来内容对不对。这一步能验证前端、后端、存储介质整条链路是通的。如果写进去读不出来,问题可能出在后端写入失败、或者读取解析失败,得看内核日志里的报错。
4.4 模拟崩溃验证:真刀真枪测一次
pmsg 测试通过只说明正常路径通了,Pstore 的真正价值在崩溃路径。所以必须做一次真实的崩溃测试,看崩溃时 dmesg 能不能落盘。
测试方法要谨慎,因为会真的让系统挂掉。常见做法是触发一次内核 panic,比如通过 sysrq 接口。操作前务必确认:当前系统是可以随时重启的测试环境,重要数据已保存,而且你有办法在重启后重新进入系统。生产设备上千万别这么干。
触发 panic 的大致流程是:先确保 sysrq 功能开启(/proc/sys/kernel/sysrq的值允许触发),然后往/proc/sysrq-trigger写入触发 panic 的指令。系统会立即崩溃重启。重启后重新挂载 Pstore,看挂载点下有没有出现 dmesg 记录,cat出来看内容是不是崩溃前的那段日志。
这个测试有几个观察点。第一,记录有没有产生——没有的话,说明崩溃路径写入失败,可能是后端在 panic 上下文里不可用,或者空间不够。第二,记录内容完不完整——如果只有开头一小段,可能是写入被截断,检查bufsize和空间。第三,记录时间戳对不对——能帮你确认这条记录确实是这次崩溃产生的,不是上次残留的。
我实测下来,内存保留区类的后端在 panic 场景下成功率比较高,因为它本质就是往一块保留内存里写,不涉及复杂的介质操作;flash 类后端成功率受介质状态影响大一些,如果崩溃时正好在擦除 flash,可能写不进去。这个差异在做方案选型时要有数。
5. 常见问题与排查技巧实录
5.1 挂载后看不到任何记录
这是最高频的问题。现象是挂载成功,但崩溃重启后挂载点空空如也。排查要按链路从后往前捋。
先确认后端有没有注册成功。看内核日志里有没有后端注册相关的信息,注册失败会有明确报错,常见原因是启动参数没给、预留内存冲突、或者介质初始化失败。后端没注册成功,前端就是无源之水,写什么都没地方去。
再确认崩溃时写入有没有发生。有些后端在正常运行时能写(pmsg 测试通过),但 panic 上下文里写失败,因为 panic 时某些资源不可用了。这种情况要看后端实现是否针对 panic 路径做了特殊处理,比如是否用了不允许睡眠的分配、是否避免了可能死锁的锁。
最后确认读取解析有没有问题。数据可能写进去了,但读取时校验不过被跳过。可以看内核日志里有没有校验失败的提示,或者用底层工具直接 dump 存储区原始字节,看头部结构对不对。
5.2 记录被频繁覆盖,历史留不住
现象是每次只能看到最新一条,之前的都没了。根因基本是空间不够或者淘汰策略太激进。
先算一下单条记录实际占多大。把挂载点下记录文件的大小看一下(注意压缩后的实际占用可能和文件显示大小不同,要看后端报告的实际使用量)。如果单条就接近或超过总空间,那必然只能存一条。解决办法要么加大空间,要么减小单条记录(比如调小日志缓冲区、提高压缩比)。
还要注意淘汰是按记录粒度做的。如果某类记录特别大,它会一次性挤掉很多小记录。可以调整各类记录的优先级或者大小配比,让关键类型(比如 dmesg)有稳定空间,非关键类型(比如 ftrace)限制大小。
5.3 压缩开启后读取乱码或失败
压缩相关的故障,排查思路和普通记录不同。首先确认写入端和读取端的压缩算法配置一致——写入时用什么算法压的,读取时就得用什么算法解,配置不匹配必然乱码。其次确认压缩缓冲区够大,压缩后数据如果超过后端bufsize,写入会被拆块,读取时拼接逻辑如果处理不好就会出错。
还有一种隐蔽情况:压缩算法在崩溃路径上执行时间过长,导致写入没完成系统就彻底挂了,留下半条残缺记录。这种记录读取时校验不过会被跳过,表现为"记录丢了"。如果怀疑是这个原因,可以临时关掉压缩测试一次,看记录能不能完整落盘,以此定位。
5.4 多后端场景下行为不符合预期
挂了多个后端,但实际行为和你想的不一样。先确认当前生效的是哪个——看 sysfs 里各后端的状态和优先级。有可能你以为在用 A,实际因为优先级配置在用 B。再确认各后端的空间是独立的,A 写满了不会自动用 B 的空间,除非前端有跨后端的逻辑(通常没有,各管各的)。
如果确实需要多后端协同,要明确设计意图:是主备关系(一个挂了用另一个),还是分工关系(不同类型记录去不同后端)。Pstore 原生更偏向"多个独立后端各自工作",复杂的协同逻辑需要自己在应用层或者驱动层补,别指望框架自动帮你做。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 处理建议 |
|---|---|---|---|
| 挂载点为空 | 后端未注册 | 查内核日志注册信息 | 补启动参数、检查介质 |
| 崩溃后无记录 | panic 路径写入失败 | 查后端是否支持崩溃上下文 | 换后端或改实现 |
| 只有最新一条 | 空间不足或单条过大 | 算单条实际占用 | 加空间或减记录大小 |
| 记录乱码 | 压缩算法不匹配 | 核对读写两端配置 | 统一压缩算法 |
| 记录残缺 | 写入被截断 | 查 bufsize 与空间 | 调大 bufsize 或分块 |
| 多后端行为异常 | 优先级或分工不清 | 查 sysfs 状态 | 明确主备或分工设计 |
注意:排查这类问题,内核日志永远是你的第一手资料。Pstore 在注册、写入、读取各环节都有日志输出,养成先看日志再动手的习惯,能省掉大量瞎猜的时间。
6. 我在实际项目里攒下的几点体会
Pstore 这块东西,看文档和真上手之间隔着一层。我把自己踩过的、觉得最有价值的几点单独拎出来说说。
第一,别把它当普通文件系统用。它的接口长得像文件,但语义完全不同——没有随机写、没有目录、没有权限模型,记录是整体产生整体删除的。用文件系统的思维去用它,会在"为什么不能改这一条"这类问题上卡住。把它当成一个"只追加、按类型分、满了淘汰最老"的环形日志区,理解就顺了。
第二,空间规划要在项目早期做。我见过不少项目是出了问题才想起来加 Pstore,结果发现预留内存不够、flash 分区没规划、启动参数改不动,处处受限。如果项目一开始就预判到需要崩溃现场留存,提前把空间和参数留出来,后面会轻松很多。
第三,崩溃路径的可靠性要实测,不能靠推理。代码看着没问题,实际 panic 时就是写不进去,这种情况太常见了。每换一种介质、每改一次配置,都老老实实做一次真实崩溃测试,看记录到底落没落盘、内容完不完整。测试环境里多崩几次,比现场出问题时抓瞎强得多。
第四,压缩是把双刃剑,按场景选。空间紧张就开,空间宽裕就关,别为了"看起来更专业"无脑开压缩。开了之后一定要验证崩溃路径下压缩能不能在有限时间内完成,否则压缩反而成了丢记录的原因。
最后分享一个小技巧:调试阶段可以临时把 Pstore 的日志级别调高,让它把每次写入的偏移、长度、校验结果都打出来,对着这些数字看,比看最终结果更容易定位是哪一步出的问题。等调通了再把日志级别降回去,避免正常运行时刷屏。这个法子在我排查"记录写进去了但读不出来"的问题时特别管用,一眼就看出是写入偏移算错了还是读取解析错了。