17、OTA升级与快照:snapshot、merge、CowWriter、CowReader

各位好,今天我们聊一个在Android OTA升级里非常核心的机制——快照(Snapshot)。

说实话,我第一次接触这个模块的时候,也被那一堆术语搞得有点晕:snapshot、merge、CowWriter、CowReader……它们到底是什么关系?为什么需要它们?

别急,我们一个一个拆开来看。

17.1 为什么需要快照?

先想一个问题:OTA升级过程中,系统正在写入新的分区数据。如果这时候突然断电了,或者系统崩溃了,会发生什么?

嗯,最坏的情况是——分区数据写了一半,系统既不是旧版本,也不是新版本。手机变砖了。

所以,我们需要一种机制来保证升级的原子性:要么全部成功,要么全部回滚。

快照(Snapshot)就是干这个的。它本质上是一个写时复制(Copy-on-Write, CoW)的机制。升级过程中,所有对分区的写入操作,都先记录到快照里,而不是直接覆盖原始数据。这样,如果升级失败,我们可以轻松地丢弃快照,恢复到升级前的状态。

核心思想: 升级时,旧数据不动,新数据写到一个“增量”区域。成功后再合并。

17.2 Snapshot:升级的“保险箱”

Snapshot,说白了就是一个虚拟设备层。它在物理分区之上,提供了一个“看起来像分区,但实际是写时复制”的视图。

我举个例子。假设你的system分区有1GB数据。升级时,你只需要修改其中100MB。如果没有快照,你得把整个1GB都备份一遍,才能保证回滚。有了快照,你只需要记录那100MB的变化。

在代码层面,Snapshot是通过Device Mapper实现的。Android使用了一个叫做dm-snapshot的内核模块。它会创建一个映射设备,所有读操作优先从快照中读取,写操作则写入快照的“例外区”(exception store)。

# 创建一个快照设备的典型命令
dmsetup create system-snapshot \
    --table "0 2097152 snapshot /dev/block/system /dev/block/system-cow P 8"

这里的system-cow就是Cow设备,用来存放增量数据。

我个人习惯: 在调试快照问题时,先用 dmsetup table 查看当前映射状态,确认快照是否处于激活状态。这比看日志快多了。

17.3 CowWriter:谁在写增量数据?

好,现在我们知道快照需要把增量数据写到某个地方。这个“地方”就是Cow设备。而CowWriter,就是负责往Cow设备里写数据的组件。

CowWriter运行在升级过程中。当系统决定要更新某个块时,它会先调用CowWriter,把原始块的数据复制到Cow设备中(如果是第一次修改),然后再写入新数据。

这个过程,我称之为“先备份,再修改”。

CowWriter的写入策略很有意思。它不是每次写一个块就立即刷盘,而是会批量合并。为什么呢?因为OTA升级通常涉及大量连续块的写入,如果每个块都单独写一次,性能会非常差。

// CowWriter 的简化写入逻辑
void CowWriter::WriteBlock(uint64_t block_num, const std::string& data) {
    // 1. 检查这个块是否已经被备份过
    if (!IsBlockBackedUp(block_num)) {
        // 2. 如果没有,先备份原始数据到Cow设备
        BackupOriginalBlock(block_num);
    }
    // 3. 写入新数据到快照设备
    WriteToSnapshot(block_num, data);
}

我在项目中遇到过一个问题:CowWriter写入时,如果Cow设备空间不足,升级会直接失败。所以,Cow设备的大小估算非常关键。通常,它需要预留出整个分区大小的10%~20%作为增量空间。

17.4 CowReader:谁在读增量数据?

有写就有读。CowReader负责在合并阶段读取Cow设备中的数据。

你可能会问:合并阶段是什么?别急,我们马上讲到。

CowReader的逻辑相对简单。它需要遍历Cow设备中记录的所有“例外块”,把这些增量数据读出来,然后交给合并流程。

它的工作方式有点像日志回放。Cow设备里存储的,本质上就是一个操作日志:记录了哪些块被修改了,修改后的数据是什么。

// CowReader 的简化读取逻辑
bool CowReader::ReadNextBlock(uint64_t* block_num, std::string* data) {
    // 从Cow设备中读取下一条记录
    CowRecord record;
    if (!ReadRecord(&record)) {
        return false; // 没有更多记录了
    }
    *block_num = record.block_num;
    *data = record.data;
    return true;
}
注意: CowReader读取时,必须保证数据的完整性。我曾经遇到过Cow设备在写入过程中崩溃,导致记录损坏的情况。后来我们增加了CRC校验,才解决了这个问题。

17.5 Merge:把快照合并回去

升级完成后,系统需要把快照中的增量数据合并回原始分区。这个过程叫做Merge

Merge是OTA升级的最后一步,也是最关键的一步。合并完成后,快照设备会被销毁,系统直接使用更新后的分区。

Merge的过程是增量式的。系统不会一次性合并所有数据,而是在后台逐步进行。这样做的好处是:即使合并过程中断电,下次开机时合并可以从中断的地方继续,而不是从头开始。

合并的粒度是(block)。每次合并一个块,就把Cow设备中的新数据写回到原始分区,然后释放Cow设备中的对应记录。

// Merge 的简化流程
void MergeManager::PerformMerge() {
    while (true) {
        uint64_t block_num;
        std::string data;
        // 1. 从CowReader读取下一个待合并的块
        if (!cow_reader_->ReadNextBlock(&block_num, &data)) {
            break; // 所有块都合并完了
        }
        // 2. 将数据写回原始分区
        WriteToBaseDevice(block_num, data);
        // 3. 标记该块已合并
        MarkBlockMerged(block_num);
    }
}

这里有一个重要的细节:合并的顺序。系统会优先合并那些被频繁访问的块,比如系统启动时需要的文件。这样可以加快合并后的首次启动速度。

17.6 整体流程与SVG图

说了这么多,我们来画一张图,把整个流程串起来。

OTA升级快照与合并流程 原始分区 /dev/block/system 快照设备 dm-snapshot Cow设备 增量数据存储 映射 写时复制 CowWriter CowReader 合并 (Merge) 读取增量数据 写回 升级阶段 快照保护 增量记录 合并阶段

从图中可以看到,整个流程分为两个阶段:

  1. 升级阶段:CowWriter负责把增量数据写入Cow设备,快照设备保护原始数据不被覆盖。
  2. 合并阶段:CowReader从Cow设备中读取增量数据,Merge模块负责把这些数据写回原始分区。

17.7 避坑指南

最后,分享几个我在实际项目中踩过的坑:

  • Cow设备空间不足:我曾经遇到过OTA升级到一半,Cow设备满了,导致升级失败。后来我们增加了空间预检机制,在升级前就估算好所需空间。
  • 合并中断:合并过程中断电,下次开机后合并继续。但有一次,Cow设备的元数据损坏了,导致合并无法继续。我们后来增加了元数据的冗余备份。
  • 性能问题:快照设备在读写时会有额外的开销。如果分区很大,性能下降会比较明显。我建议在性能敏感的场景下,尽量使用增量升级,减少快照的负担。
一个小技巧: 在调试快照问题时,可以查看 /sys/block/dm-<id>/dm/name 来确认哪个dm设备对应哪个快照。配合 dmsetup status 可以查看快照的使用情况。

好了,关于OTA升级与快照的内容,我们就讲到这里。快照机制是Android系统保证升级安全性的重要手段,理解它的工作原理,对排查升级问题非常有帮助。


公众号:蓝海资料掘金营,微信deep3321