【Python】 U盘CHK文件及误删文件恢复深度探索

Python 数据恢复实战:U盘CHK文件及误删文件恢复深度探索

引言:数据丢失的梦魇与Python的援手

在数字时代,数据已成为我们生活和工作中不可或缺的一部分。无论是珍贵的家庭照片、重要的工作文档,还是多年的研究成果,它们的意外丢失都可能带来无法估量的损失和困扰。U盘(USB闪存驱动器)作为一种便捷的存储介质,因其便携性而被广泛使用,但也常常成为数据丢失的“重灾区”。不当的插拔、病毒攻击、文件系统损坏等原因都可能导致U盘中的数据无法访问,甚至出现大量的 FILExxxx.CHK 文件,让用户束手无策。此外,误删除文件也是一个常见的数据丢失场景。

当Windows的磁盘检查工具(CHKDSK)在修复文件系统错误时,它可能会将找到的但无法归属到正确文件或目录的数据片段保存为 .CHK 文件,存放在 FOUND.xxx 文件夹中。这些 CHK 文件本身包含了原始数据的残片,但失去了文件名和目录结构,使得直接识别和使用它们变得异常困难。同样,当文件被“删除”时,操作系统通常只是在文件系统中做了一些标记(例如,在FAT文件系统中将目录条目的首字节改为0xE5,在NTFS中标记MFT条目为未使用),而实际的数据块在被新数据覆盖之前仍然存在于磁盘上。

传统的商业数据恢复软件虽然功能强大,但往往价格不菲,且其内部工作原理对用户来说是一个黑箱。而Python作为一种功能强大、语法简洁且拥有丰富库支持的编程语言,为我们提供了一个 уникальный视角和工具集,使我们能够深入理解数据恢复的底层机制,并亲手编写脚本来尝试恢复丢失的数据。通过Python,我们可以直接与磁盘的原始字节流交互,解析文件系统结构,识别文件签名,甚至尝试重建损坏的文件片段。

第一部分:数据存储与文件系统核心原理

在尝试恢复任何数据之前,我们必须对数据是如何被存储以及如何被管理的有一个清晰且深入的理解。这包括了物理存储介质的基本工作方式,以及操作系统用于组织和访问这些数据的逻辑结构——即文件系统。

1.1 物理存储介质基础回顾

尽管我们主要关注U盘,但其底层存储原理与其他基于闪存的设备(如SSD)以及传统的机械硬盘(HDD)有共通之处,也有其特性。

  • 机械硬盘 (HDD - Hard Disk Drive):

    • 结构: 由一个或多个高速旋转的磁盘片(Platters)组成,数据通过磁头(Heads)在盘片表面的磁性介质上进行读写。盘片被划分为同心圆的磁道(Tracks),每个磁道又被划分为扇区(Sectors)。扇区是磁盘读写的最小物理单位,通常大小为512字节或4KB(高级格式化硬盘)。
    • 寻址: 通过CHS(Cylinder-Head-Sector,柱面-磁头-扇区)或更现代的LBA(Logical Block Addressing,逻辑块寻址)方式定位数据。LBA将磁盘视为一个线性的扇区序列。
    • 数据读写: 磁头在盘片上移动到指定磁道,等待目标扇区旋转到磁头下方,然后进行读写。这个过程涉及到机械运动,因此HDD的随机访问速度相对较慢。
    • 数据删除: 当文件被删除时,HDD本身通常不会立即擦除数据。操作系统仅修改文件系统中的元数据,标记相关扇区为可用。实际数据会保留在盘片上,直到新的数据写入并覆盖它们。
  • 固态驱动器 (SSD - Solid State Drive) 与 U盘 (USB Flash Drive):

    • 结构: 基于闪存(Flash Memory)技术,通常是NAND闪存。闪存没有机械运动部件,数据存储在浮栅晶体管(Floating Gate Transistors)中。
    • 组织: 闪存被组织成块(Blocks),每个块又包含多个页(Pages)。页是闪存读取和编程(写入)的最小单位(例如4KB, 8KB, 16KB),而块是擦除的最小单位(例如128KB, 256KB, 512KB甚至更大)。
    • 特性:
      • 读写不对称: 读取页相对较快。写入页之前,如果该页已有数据,则不能直接覆盖,必须先擦除其所在的整个块。擦除操作相对耗时。
      • 写入放大 (Write Amplification): 由于擦除单位是块,即使只修改页中的一小部分数据,也可能需要读取整个块的内容,修改后,擦除原块,再将更新后的整个块写回新的位置。这导致实际写入闪存的数据量大于用户请求写入的数据量。
      • 损耗均衡 (Wear Leveling): 闪存单元的擦写次数有限(P/E Cycles)。为了延长SSD/U盘寿命,控制器会使用损耗均衡算法,将写入操作均匀分布到所有闪存块上,避免某些块过早损坏。
      • 垃圾回收 (Garbage Collection): 后台进程,用于整理闪存空间,将有效数据从包含无效(已删除)数据的块中迁移到新的块,然后擦除旧块以备重用。
      • TRIM命令: 操作系统可以通知SSD/U盘哪些数据块不再使用(例如,文件被删除后)。SSD控制器接收到TRIM命令后,可以在垃圾回收过程中主动擦除这些数据块,以提高后续写入性能并辅助损耗均衡。这对数据恢复来说是个巨大的挑战,因为TRIM可能导致已删除数据被物理擦除。 U盘对TRIM的支持情况不一,且操作系统和U盘主控都需要支持。
1.2 文件系统的概念与作用

文件系统是操作系统用于明确存储设备(或分区)上的文件的方法和数据结构;即在存储设备上组织文件的方法。它使得用户能够以文件名和目录(文件夹)的层次结构来创建、访问、修改和删除数据,而无需关心数据在物理介质上的具体存储位置和细节。

文件系统的主要功能包括:

  • 空间管理: 跟踪哪些存储空间已被分配,哪些是空闲的。
  • 命名: 为文件和目录提供人类可读的名称。
  • 目录结构: 组织文件和目录的层次关系。
  • 元数据管理: 存储关于文件的信息,如文件名、大小、创建/修改时间、权限、文件在磁盘上的物理位置等。
  • API提供: 向应用程序提供访问文件系统功能的接口(如打开、读取、写入、关闭文件等)。
1.3 常见文件系统详解 (重点关注U盘常用类型)

我们将重点关注在U盘上常见的文件系统:FAT32、exFAT,并简要提及NTFS,因为NTFS也可能出现在大容量U盘或移动硬盘上。

1.3.1 FAT (File Allocation Table) 文件系统家族

FAT是最早也是最简单的文件系统之一,因其良好的兼容性,在U盘、存储卡等移动存储设备上仍被广泛使用。主要有FAT12, FAT16, FAT32和exFAT等版本。

  • 核心组件:

    1. 引导扇区 (Boot Sector) / DBR (DOS Boot Record):

      • 位于分区的第一个扇区(逻辑扇区0)。
      • 包含文件系统的基本信息和参数,如每个扇区的字节数、每簇的扇区数、FAT表的数量、FAT表的大小、根目录的位置(FAT12/16)、总扇区数、文件系统类型标识等。
      • 还包含一小段启动代码(引导程序),用于从该分区启动操作系统(虽然在U盘数据恢复场景中我们更关心其参数信息)。
      • 对于数据恢复,正确解析引导扇区是理解文件系统布局的第一步。
    2. 文件分配表 (FAT - File Allocation Table):

      • FAT是FAT文件系统的核心,它本质上是一个大数组,数组中的每个条目对应存储区域中的一个簇(Cluster)。簇是文件系统分配磁盘空间的最小单位,由一个或多个连续的扇区组成。
      • FAT条目的含义:
        • 0x0000 (FAT12/16) or 0x00000000 (FAT32): 表示该簇是空闲的,可用于存储新数据。
        • 0xFFF7 (FAT12/16) or 0x0FFFFFF7 (FAT32): 表示该簇是一个坏簇,不应使用。
        • 0xFFF8 - 0xFFFF (FAT12/16) or 0x0FFFFFF8 - 0x0FFFFFFF (FAT32): 表示该簇是文件中最后一个簇(EOF - End Of File)。
        • 其他值: 表示文件中下一个簇的簇号。通过这个值,可以像链表一样将属于同一个文件的多个簇链接起来,形成文件分配链。
      • FAT表的副本: 通常会有两个(或更多)FAT表的副本,存放在引导扇区之后。这是为了数据冗余,如果主FAT表损坏,可以使用副本进行恢复。在实际操作中,操作系统通常只更新主FAT表,副本可能不是最新的。
    3. 根目录区 (Root Directory Area):

      • FAT12/FAT16: 根目录区的大小和位置是固定的,紧随最后一个FAT表之后。它包含固定数量的目录条目。由于大小固定,根目录下的文件和子目录数量有限。
      • FAT32: 根目录区不再是固定大小和位置的特殊区域,而是像普通子目录一样,由一个或多个簇组成,其起始簇号记录在引导扇区中。这使得FAT32的根目录可以存储更多的条目,并且可以扩展。
    4. 数据区 (Data Area):

      • 位于根目录区(FAT12/16)或所有FAT表之后(FAT32的根目录也在数据区)。
      • 这部分区域被划分为一个个的簇,用于存储实际的文件数据和子目录数据。
      • 每个簇都有一个唯一的簇号(从2开始,簇0和簇1是保留的,不用于数据存储)。
  • 目录条目 (Directory Entry):

    • 无论是根目录还是子目录,它们的内容都是一系列32字节的目录条目。

    • 短文件名目录条目 (SFN - Short File Name):

      • 文件名 (Bytes 0-7): 8个字节的文件名,不足用空格 (0x20) 填充。
      • 扩展名 (Bytes 8-10): 3个字节的扩展名,不足用空格填充。
      • 属性 (Byte 11): 文件的属性字节,例如:
        • 0x01: 只读 (Read-only)
        • 0x02: 隐藏 (Hidden)
        • 0x04: 系统 (System)
        • 0x08: 卷标 (Volume Label) - 特殊条目,表示分区名
        • 0x10: 子目录 (Subdirectory)
        • 0x20: 存档 (Archive)
      • 保留 (Bytes 12-21): 通常为0。NT系统可能用一部分存储创建时间和最后访问时间的低位。
      • 最后写入时间 (Bytes 22-23): 编码格式。
      • 最后写入日期 (Bytes 24-25): 编码格式。
      • 起始簇号 (Bytes 26-27 for FAT12/16, higher 2 bytes at 20-21 for FAT32): 文件或子目录数据占用的第一个簇的簇号。对于FAT32,是低16位在26-27,高16位在20-21。这是连接到FAT表找到文件内容的关键。
      • 文件大小 (Bytes 28-31): 文件内容的字节大小。对于子目录,此字段为0。
    • 长文件名目录条目 (LFN - Long File Name):

      • 为了支持超过8.3格式的长文件名和Unicode字符,FAT引入了LFN机制。
      • LFN条目是一种特殊的目录条目,其属性字节为 0x0F (只读 | 隐藏 | 系统 | 卷标)。
      • 一个长文件名可能由多个LFN条目组成,每个LFN条目存储长文件名的一部分 (最多13个Unicode字符)。
      • LFN条目以逆序存储在实际的SFN条目之前。最后一个LFN条目(最靠近SFN条目的那个)的序号字段最高位被置1。
      • 每个LFN条目都有一个校验和,基于其关联的SFN条目的短文件名计算,用于验证LFN和SFN的配对关系。
      • 不支持LFN的旧系统会忽略这些属性为 0x0F 的条目,只读取SFN条目。
  • 文件存储与分配:

    • 当创建一个新文件时,操作系统会:
      1. 在父目录中查找或创建一个新的目录条目,填入文件名、属性、当前时间等。
      2. 在FAT表中查找足够的空闲簇来存储文件数据。
      3. 将找到的第一个空闲簇的簇号写入目录条目的起始簇号字段。
      4. 在FAT表中,将这个簇对应的条目更新为下一个分配给该文件的簇号(如果文件需要多个簇),或者标记为EOF(如果这是最后一个簇)。
      5. 将文件数据写入分配的簇中。
    • 文件数据在数据区可能不是连续存储的,即文件可能是碎片化 (Fragmented) 的。FAT表负责将这些不连续的簇链接起来。
  • 文件删除 (FAT):

    • 当一个文件被删除时,操作系统通常执行以下操作:
      1. 修改目录条目: 将该文件对应的(SFN)目录条目的第一个字节改为 0xE5(一个特殊的标记,表示已删除)。对于LFN条目,它们的首字节也会被标记为 0xE5,但它们的校验和仍然有效。
      2. 清空FAT链 (通常): 将该文件在FAT表中所占用的所有簇对应的条目都清零(标记为未使用/空闲)。这意味着文件簇之间的链接关系丢失了。
      3. 数据区不变: 实际存储文件数据的簇内容不会被立即擦除。它们只是被标记为可用,等待被新的文件数据覆盖。
    • 恢复可能性:
      • 如果被删除文件的簇还没有被新数据覆盖,并且能够找到其原始的目录条目(即使首字节是0xE5),我们就能知道它的起始簇号和文件大小。
      • 最大的挑战在于重建FAT链,因为这个链信息通常在删除时被清除了。
        • 对于非碎片化的文件(即文件数据存储在连续的簇中),恢复相对容易,只需根据起始簇号和文件大小连续读取数据即可。
        • 对于碎片化的文件,如果FAT链信息丢失,完美恢复非常困难。可能需要依赖文件内容本身的结构(例如,JPEG文件中的连续MCU块)或进行高级的数据雕刻。
  • FAT32的特点:

    • 使用32位FAT条目(实际上只用了低28位表示簇号),支持更大的分区容量(理论上可达2TB,但Windows通常限制为32GB进行格式化)。
    • 每个簇的大小可以更小,从而减少小文件占用的磁盘空间浪费(与FAT16相比)。
    • 根目录是可扩展的。
    • 广泛用于U盘和存储卡。
  • exFAT (Extended File Allocation Table):

    • 由微软推出,旨在替代FAT32,特别适用于大容量闪存设备。
    • 主要改进:
      • 理论上支持极大的文件大小和分区容量(远超FAT32)。
      • 改进了空闲空间分配的性能,使用位图(Allocation Bitmap)来跟踪簇的使用情况,类似于NTFS,可以更快地找到空闲簇。
      • 引入了TexFAT(Transaction-safe FAT),用于提高文件操作的可靠性(可选特性)。
      • 目录条目结构有所不同,更灵活。
      • 支持访问控制列表 (ACLs),但实际应用不多。
    • 恢复角度: exFAT的删除操作与FAT32类似,也会标记目录条目和清除(或标记为未使用)文件分配信息。恢复的挑战和策略与FAT32有共通之处,但需要理解其独特的元数据结构,如位图。
1.3.2 NTFS (New Technology File System)

NTFS是现代Windows操作系统(如Windows NT, 2000, XP, Vista, 7, 8, 10, 11)默认的文件系统。它比FAT复杂得多,提供了许多高级特性,如日志记录、安全性(ACLs)、压缩、加密、硬链接、稀疏文件等。大容量U盘或移动硬盘也可能格式化为NTFS。

  • 核心组件:

    1. 引导扇区 (Boot Sector) / VBR (Volume Boot Record):

      • 位于分区的第一个扇区。
      • 包含NTFS版本信息、每个扇区的字节数、每簇的扇区数、MFT(主文件表)的起始逻辑簇号(LCN)、MFT镜像的起始簇号等关键参数。
      • 同样包含引导代码。
    2. 主文件表 (MFT - Master File Table):

      • NTFS的核心。在NTFS中,一切皆文件,包括MFT本身也是一个文件(名为$MFT)。
      • MFT由一系列记录(MFT Records或File Records)组成,每个记录通常大小为1KB。
      • 文件系统中的每个文件和目录(包括元数据文件自身)都在MFT中至少有一个记录。
      • MFT记录结构:
        • 记录头 (Record Header): 包含记录的魔数(如FILEBAAD表示坏记录)、更新序列号(用于保证写入一致性)、此记录的MFT条目号、硬链接计数、序列号(每次重用MFT记录时递增)等。
        • 属性 (Attributes): MFT记录的主体由一系列属性组成。每个属性都有一个类型代码、一个可选的名称,以及属性值(数据)。属性可以是常驻的 (Resident)非常驻的 (Non-resident)
          • 常驻属性: 如果属性数据很小,可以直接存储在MFT记录内部。
          • 非常驻属性: 如果属性数据较大,MFT记录中只存储指向数据区中实际数据块(称为数据运行 Data Runs)的指针。数据运行描述了数据在磁盘上的起始簇号和连续簇的数量。一个非常驻属性可能由多个数据运行组成(表示文件是碎片化的)。
      • 重要的标准属性类型:
        • $STANDARD_INFORMATION (0x10): 包含文件的基本信息,如创建时间、修改时间、最后访问时间、文件属性(只读、隐藏、系统、存档等)、所有者ID、安全ID。
        • $ATTRIBUTE_LIST (0x20): (可选) 如果一个文件的所有属性无法容纳在一个MFT记录中,此属性会列出其他包含该文件属性的MFT记录。
        • $FILE_NAME (0x30): 包含文件名(Unicode)、父目录的MFT引用、文件的时间戳(可能与$STANDARD_INFORMATION中的不同步)、文件分配大小、实际大小、文件属性等。一个文件可以有多个$FILE_NAME属性(例如,一个长文件名和一个兼容DOS的短文件名,或者硬链接)。
        • $VOLUME_NAME (0x60): (用于$Volume文件) 卷标名。
        • $DATA (0x80): 包含文件的实际内容。这是最重要的属性。它可以是常驻的(对于非常小的文件)或非常驻的。
        • $INDEX_ROOT (0x90)$INDEX_ALLOCATION (0xA0): 用于实现目录(B+树索引)。目录的内容(即其包含的文件和子目录的列表)被组织成索引。小目录的索引可能完全常驻在$INDEX_ROOT中;大目录则使用$INDEX_ALLOCATION指向数据区中的索引块。
        • $BITMAP (0xB0): (用于$Bitmap文件和目录的索引) 位图,用于跟踪簇的分配情况或索引条目的使用情况。NTFS的$Bitmap文件(MFT记录号通常是6)就使用此属性来记录卷上所有簇的分配状态(已用或空闲)。
      • 元数据文件: NTFS文件系统本身也由一系列以$开头的特殊文件(元数据文件)组成,它们都有自己的MFT记录。例如:
        • $MFT (记录0): 主文件表本身。
        • $MftMirr (记录1): MFT的前几个(通常是4个)记录的副本,用于灾难恢复。
        • $LogFile (记录2): 日志文件,用于记录文件系统操作,保证文件系统的一致性(类似于事务日志)。
        • $Volume (记录3): 包含卷名、NTFS版本和卷状态等信息。
        • $AttrDef (记录4): 定义了卷上所有允许的属性类型及其特性。
        • $Bitmap (记录6): 卷簇分配位图。
        • $Boot (记录7): 引导扇区(VBR)。
        • $BadClus (记录8): 坏簇表。
        • $Secure (记录9): 安全描述符数据库。
        • $UpCase (记录10): Unicode字符大写转换表。
        • $Extend (目录,记录11): 包含如$Quota, $ObjId, $Reparse等可选的扩展文件系统功能文件。
  • 文件存储与分配 (NTFS):

    • 当创建文件时,NTFS会分配一个新的MFT记录(或重用一个标记为未使用的记录)。
    • 文件的属性(如$STANDARD_INFORMATION, $FILE_NAME, $DATA)被添加到MFT记录中。
    • 如果$DATA属性是非常驻的,NTFS会查询$Bitmap文件找到空闲的簇,并将这些簇分配给文件,更新$Bitmap,然后在$DATA属性中记录数据运行信息。
    • 目录是通过$INDEX_ROOT$INDEX_ALLOCATION属性实现的B+树。当向目录添加文件时,会更新这个索引。
  • 文件删除 (NTFS):

    • 当一个文件被删除时(例如,移到回收站再清空,或Shift+Delete):
      1. MFT记录标记: 该文件在MFT中的记录会被标记为“未使用”(通常是记录头中的一个标志位被设置)。然而,记录的内容(包括所有属性,如文件名、时间戳、数据运行指针等)通常不会立即被清除。
      2. ** B i t m a p 更新 ∗ ∗ : 该文件所占用的簇在 ‘ Bitmap更新**: 该文件所占用的簇在` Bitmap更新:该文件所占用的簇在Bitmap`文件中会被标记为“空闲”。
      3. 目录索引更新: 该文件在父目录索引中的条目会被移除或标记为无效。
      4. 文件名: $FILE_NAME属性中的文件名通常保持不变,但其在目录索引中的链接断开了。
      5. 数据区不变: 类似于FAT,实际存储文件数据的簇内容不会被立即擦除,直到它们被新文件覆盖。
    • 恢复可能性 (NTFS):
      • 比FAT高很多: 由于MFT记录在删除时通常保留了大部分元数据(包括指向数据簇的指针),如果这些MFT记录本身没有被新的文件记录覆盖,并且数据簇也没有被覆盖,那么恢复已删除文件的机会就很大。
      • 关键在于找到并解析MFT记录:
        • 可以扫描整个MFT(或其可能存在的区域),查找被标记为“未使用”但看起来仍然包含有效文件信息的记录。
        • $FILE_NAME属性可以提供原始文件名和路径信息。
        • $DATA属性的数据运行信息是定位实际文件内容的关键。
      • 碎片化文件: NTFS的MFT记录直接存储了数据运行,所以即使文件是碎片化的,只要MFT记录完好且数据簇未被覆盖,也能准确恢复。
      • MFT记录被覆盖: 如果一个已删除文件的MFT记录被新文件重用了,那么恢复该文件将变得非常困难,可能只能依赖于数据雕刻。
1.4 数据恢复的基本原则

无论使用何种文件系统,数据恢复都遵循一些基本原则:

  1. 立即停止使用: 一旦发现数据丢失或误删除,应立即停止对该存储设备(U盘、硬盘等)的任何写入操作。继续使用会增加原始数据被新数据覆盖的风险,从而降低恢复成功的概率。如果是系统盘,最好关闭计算机并从另一系统或恢复盘启动。
  2. 物理损坏优先处理: 如果数据丢失是由于物理损坏(例如,U盘无法识别、有异响、电路板损坏),软件恢复通常无能为力。此时需要专业的硬件级数据恢复服务。本文主要讨论逻辑层面的数据恢复。
  3. 镜像优先 (Write Blocker): 在进行任何恢复尝试之前,如果条件允许,最佳做法是创建一个原始存储设备(或分区)的完整逐扇区镜像(Image)到一个健康的目标磁盘上。然后对镜像文件进行恢复操作。这样可以避免在原始设备上操作引入新的风险,并且可以多次尝试不同的恢复方法。在专业的数字取证中,会使用硬件或软件写保护器(Write Blocker)来确保在创建镜像或分析原始设备时不会对其进行任何写入。
  4. 不要恢复到原始设备: 将恢复出来的文件保存到另一个独立的存储设备上,绝不能保存回正在进行数据恢复的原始设备,因为这会覆盖其他可能需要恢复的数据。
  5. 耐心和细致: 数据恢复可能是一个耗时且复杂的过程,需要耐心和对细节的关注。
  6. 没有绝对的保证: 即使遵循了所有最佳实践,数据恢复也不能保证100%成功。成功率取决于多种因素,如数据丢失的原因、文件系统类型、数据被覆盖的程度、设备类型(SSD的TRIM)等。

理解了这些基础知识,我们就能更有针对性地去分析CHK文件是如何产生的,以及如何尝试恢复它们和其它已删除的文件。


第二部分:CHKDSK与CHK文件探秘

当Windows用户遇到文件系统错误,例如U盘无法正常读取、提示需要格式化、或者文件和目录出现异常时,一个常用的工具就是 CHKDSK (Check Disk)。这个命令行工具会扫描磁盘分区的文件系统结构,查找并尝试修复错误。在这个修复过程中,CHKDSK 可能会创建 FOUND.xxx 文件夹,并在其中生成一系列名为 FILExxxx.CHK 的文件。

2.1 CHKDSK (Check Disk) 工具概述

CHKDSK 是Windows操作系统内置的一个磁盘检查和修复工具。它可以用于检查FAT (FAT12, FAT16, FAT32), exFAT, 和NTFS文件系统的完整性。

  • 启动方式:

    • 命令行: chkdsk <驱动器盘符>: [参数] 例如 chkdsk E: /f
    • 图形界面: 在“我的电脑”或“此电脑”中,右键点击目标驱动器 -> “属性” -> “工具”选项卡 -> “检查”。
  • 主要参数:

    • <驱动器盘符>:: 指定要检查的驱动器,例如 E:
    • /f: 修复磁盘上的错误。如果省略此参数,CHKDSK 只会报告错误,不进行修复。如果驱动器正在被使用(例如系统盘或包含打开的文件),/f 参数可能需要重启计算机才能执行。
    • /r: 定位坏扇区并恢复可读信息。此参数包含了 /f 的功能。扫描坏扇区是一个非常耗时的过程。
    • /x: (与 /f 一起使用时) 强制卸载卷(如果需要)。
    • /v: (在FAT/FAT32上) 显示磁盘上每个文件的完整路径和名称。(在NTFS上) 显示清除消息(如果有)。
    • /scan: (NTFS专用) 运行联机扫描,不需要卸载卷。
    • /spotfix: (NTFS专用) 运行联机点修复,同样不需要卸载卷。
  • CHKDSK 的工作阶段 (以NTFS为例,FAT类似但简化):
    CHKDSK 在修复模式下通常会经历多个阶段:

    1. 阶段1:检查基本文件系统结构: 验证核心元数据文件(如NTFS的$MFT, $Bitmap等)的一致性。
    2. 阶段2:检查文件名链接: 验证目录结构和文件名的有效性,确保每个文件都能在目录索引中正确找到。
    3. 阶段3:检查安全描述符: 验证与文件和目录关联的安全信息(权限等)。
    4. 阶段4:查找坏簇 (如果使用了 /r 参数): 扫描用户文件数据中的坏簇。
    5. 阶段5:检查空闲空间 (如果使用了 /r 参数): 验证未分配簇的列表是否准确。
2.2 为何会产生CHK文件?

CHKDSK在扫描和修复过程中发现文件系统存在以下类型的错误时,可能会生成CHK文件:

  1. 丢失的分配单元 (Lost Allocation Units / Lost Clusters):

    • 这是产生CHK文件最常见的原因。
    • 当FAT表或NTFS的$Bitmap中标记某些簇为“已使用”,但在任何目录条目或MFT记录中都找不到对这些簇的引用时,这些簇就被认为是“丢失的”。这意味着数据存在,但文件系统不知道它们属于哪个文件。
    • CHKDSK 无法确定这些丢失簇的原始文件名或它们在文件中的逻辑顺序(如果是多个不连续的丢失簇)。
    • 为了不直接丢弃这些可能包含有价值数据的数据块,CHKDSK 会将每个找到的连续的丢失簇序列(或单个丢失簇)恢复为一个单独的 .CHK 文件。
  2. 交叉链接的文件 (Cross-linked Files):

    • 当两个或多个文件(或目录)的文件分配信息(FAT链或NTFS数据运行)错误地指向了同一个或同一组簇时,就发生了交叉链接。这意味着文件系统认为这部分数据同时属于多个文件,这显然是不正确的。
    • CHKDSK 可能会尝试解决交叉链接。一种做法是复制共享的簇,为其中一个文件创建新的副本,然后将另一个文件指向原始簇。或者,它可能会将共享的簇数据保存为一个 .CHK 文件,并修复两个文件的分配信息,使其不再共享这些簇(这可能导致其中一个或两个文件数据不完整)。
  3. 无效的目录条目或MFT记录:

    • 目录条目(FAT)或MFT记录(NTFS)本身可能损坏,例如指向无效的簇号、文件大小与分配的簇不匹配、时间戳无效等。
    • 如果CHKDSK发现一个目录条目或MFT记录引用了一些数据簇,但该条目/记录本身已损坏到无法完全重建其原始文件关联,它可能会将这些数据簇恢复为 .CHK 文件。
  4. 损坏的目录结构:

    • 如果目录的索引(FAT的子目录簇链,NTFS的$INDEX_ROOT / $INDEX_ALLOCATION)损坏,CHKDSK 可能无法正确遍历目录树。
    • 在修复过程中,如果找到一些无法归属到有效目录结构中的文件数据,也可能被保存为 .CHK 文件。
2.3 CHK文件的本质和存储位置
  • 本质: FILExxxx.CHK 文件 不是 某种特殊格式的文件。它们是 CHKDSK 从磁盘上直接提取出来的 原始数据块。这些数据块可能是任何类型文件的片段,例如文本文件的一部分、JPEG图像的一部分、程序代码的一部分、数据库文件的一部分,甚至是文件系统元数据本身的片段。

    • 每个 .CHK 文件通常对应于 CHKDSK 找到的一个或多个连续的簇。
    • 它们失去了原始的文件名、扩展名、目录路径以及大部分元数据(如创建/修改时间,这些信息原本存储在目录条目或MFT记录中)。
    • 文件的大小就是 CHKDSK 恢复的那些簇的总大小。
  • 命名:

    • CHK文件通常被命名为 FILE0000.CHK, FILE0001.CHK, FILE0002.CHK,以此类推,序号递增。
  • 存储位置:

    • CHKDSK 会在被检查驱动器的根目录下创建一个或多个名为 FOUND.xxx 的隐藏文件夹(例如 FOUND.000, FOUND.001 等),并将生成的 .CHK 文件存放在这些文件夹中。
    • 如果多次运行 CHKDSK 并且每次都生成了CHK文件,可能会看到多个 FOUND.xxx 文件夹。
2.4 CHK文件恢复的挑战

直接使用这些 .CHK 文件通常是不可行的,因为:

  1. 未知文件类型: 你不知道 FILE0000.CHK 原本是一个文档、一张图片,还是一个可执行文件。双击打开它,系统可能会提示选择程序,或者用错误的程序打开导致乱码。
  2. 内容可能不完整: 一个 .CHK 文件可能只包含原始文件的一部分,特别是如果原始文件是碎片化的,而 CHKDSK 只找到了其中一个片段。
  3. 无序性: 如果一个大文件被分割成了多个 .CHK 文件,你无法知道这些 .CHK 文件的正确顺序来将它们拼接回原始文件。
  4. 数量庞大: 有时 CHKDSK 可能会生成成百上千个 .CHK 文件,手动检查它们是不现实的。

因此,恢复CHK文件的核心任务是 识别这些原始数据块的内容类型,并尝试将它们恢复到可用的状态。

2.5 为何不直接依赖 CHKDSK 的“修复”?

虽然 CHKDSK 的目标是修复文件系统使其恢复可用状态,但它的“修复”有时是以牺牲部分数据为代价的。

  • CHKDSK 将数据保存为 .CHK 文件时,它实际上已经承认无法将这些数据完美地放回原始的文件结构中。
  • 对于交叉链接,CHKDSK 的修复可能会导致其中一个或多个文件内容损坏或不完整。
  • 如果文件系统的关键元数据严重损坏,CHKDSK 可能无法找到所有的文件数据,或者错误地“修复”它们。

因此,如果数据非常重要,在运行 CHKDSK /fCHKDSK /r 之前,强烈建议先尝试使用数据恢复软件(或我们即将学习的Python脚本)对原始状态的驱动器(或其镜像)进行数据提取。 如果已经运行了 CHKDSK 并生成了 .CHK 文件,那么这些 .CHK 文件就成了我们最后的希望之一。

理解了 CHKDSKCHK 文件的这些背景知识后,我们就可以开始思考如何用Python来处理这些“孤儿”数据了。其核心思路是通过分析 CHK 文件自身的内容来推断其原始类型。


第三部分:Python与底层磁盘I/O

要用Python进行数据恢复,我们首先需要学习如何让Python程序直接访问和读取存储设备的原始数据,而不是通过操作系统提供的文件和目录这种高级抽象。这种底层的磁盘I/O能力是进行文件签名分析、数据雕刻以及文件系统结构解析的基础。

3.1 磁盘/分区的表示

在操作系统层面,物理磁盘和逻辑分区有其特定的表示方法。

  • Windows:

    • 物理磁盘: 表示为 \\.\PhysicalDriveX,其中 X 是从0开始的磁盘编号 (例如 \\.\PhysicalDrive0, \\.\PhysicalDrive1)。访问物理磁盘通常需要管理员权限。
    • 逻辑分区/卷: 表示为 \\.\C:\\.\D: 等,其中 C:D: 是分配给该分区的盘符。访问分区的原始数据同样需要管理员权限。直接打开盘符(如open('C:', 'rb'))通常只能访问文件系统层面的内容,而不是原始扇区。
    • 注意: 在Python中,反斜杠 \ 是转义字符,所以路径字符串需要写为 \\\\.\\PhysicalDrive0r'\\.\PhysicalDrive0'
  • Linux:

    • 物理磁盘: 通常表示为 /dev/sdX (对于SATA/SCSI/USB磁盘) 或 /dev/nvmeXnY (对于NVMe SSD),其中 X 是字母 (a, b, c, …),Y是数字。例如 /dev/sda, /dev/sdb
    • 逻辑分区: 在磁盘设备名后附加分区号,例如 /dev/sda1, /dev/sda2
    • 访问这些设备文件通常需要root权限,或者用户需要属于特定的组(如 disk 组)。
3.2 使用Python打开原始设备/分区

Python的内置 open() 函数可以用来打开这些设备文件,就像打开普通文件一样,但需要以二进制模式 ('rb') 打开进行读取。

  • 核心函数: io.open() 或内置 open()
  • 模式: 必须是 'rb' (二进制读取)。写入 ('wb', 'ab') 原始设备非常危险,可能导致数据完全丢失,除非你非常清楚自己在做什么(例如,写回恢复的数据到另一个磁盘的镜像文件)。
  • 缓冲: 为了性能,可以指定缓冲策略,但对于逐扇区读取,通常使用默认缓冲或小的自定义缓冲区。buffering=0 (仅二进制模式) 会禁用缓冲,直接进行系统调用,但这可能不是最高效的。通常,让操作系统处理块设备的缓冲是合理的,或者使用 io.BufferedReader.
3.2.1 在Windows上打开设备

在Windows上,由于权限和设备命名的特殊性,直接使用 open() 可能不够,或者需要特定的API调用来获取设备句柄。更可靠的方式是使用 ctypes 模块调用Windows API函数 CreateFileW

import ctypes # 导入ctypes模块,用于调用C库函数
import os # 导入os模块,提供操作系统相关功能

# 定义Windows API常量
GENERIC_READ = 0x80000000 # 定义通用读权限常量
GENERIC_WRITE = 0x40000000 # 定义通用写权限常量 (恢复时一般不用,除非写镜像)
FILE_SHARE_READ = 0x00000001 # 定义文件共享读权限常量
FILE_SHARE_WRITE = 0x00000002 # 定义文件共享写权限常量
OPEN_EXISTING = 3 # 定义打开已存在文件的常量
FILE_ATTRIBUTE_NORMAL = 0x80 # 定义普通文件属性常量
FILE_FLAG_NO_BUFFERING = 0x20000000 # 定义无缓冲标志 (读取时可能需要对齐)
FILE_FLAG_SEQUENTIAL_SCAN = 0x04000000 # 定义顺序扫描标志 (提示系统优化)

INVALID_HANDLE_VALUE = -1 # 定义无效句柄值的常量

# Kernel32函数原型定义
# CreateFileW 用于打开或创建文件或I/O设备
CreateFileW = ctypes.windll.kernel32.CreateFileW # 获取CreateFileW函数的引用
CreateFileW.argtypes = [ # 定义CreateFileW函数的参数类型
    ctypes.c_wchar_p, # lpFileName: 文件名或设备路径 (宽字符字符串指针)
    ctypes.c_uint32,  # dwDesiredAccess: 访问权限 (读、写等)
    ctypes.c_uint32,  # dwShareMode: 共享模式
    ctypes.c_void_p,  # lpSecurityAttributes: 安全属性 (通常为None)
    ctypes.c_uint32,  # dwCreationDisposition: 创建方式
    ctypes.c_uint32,  # dwFlagsAndAttributes: 文件属性和标志
    ctypes.c_void_p   # hTemplateFile: 模板文件句柄 (通常为None)
]
CreateFileW.restype = ctypes.c_void_p # 定义CreateFileW函数的返回值类型 (句柄)

# ReadFile 用于从文件或I/O设备读取数据
ReadFile = ctypes.windll.kernel32.ReadFile # 获取ReadFile函数的引用
ReadFile.argtypes = [ # 定义ReadFile函数的参数类型
    ctypes.c_void_p,    # hFile: 文件句柄
    ctypes.c_void_p,    # lpBuffer: 读取缓冲区指针
    ctypes.c_uint32,    # nNumberOfBytesToRead: 要读取的字节数
    ctypes.POINTER(ctypes.c_uint32), # lpNumberOfBytesRead: 实际读取的字节数 (指针)
    ctypes.c_void_p     # lpOverlapped: 异步操作结构体指针 (通常为None用于同步)
]
ReadFile.restype = ctypes.c_bool # 定义ReadFile函数的返回值类型 (布尔值,成功或失败)

# SetFilePointerEx 用于设置文件指针位置 (64位偏移)
SetFilePointerEx = ctypes.windll.kernel32.SetFilePointerEx # 获取SetFilePointerEx函数的引用
SetFilePointerEx.argtypes = [ # 定义SetFilePointerEx函数的参数类型
    ctypes.c_void_p,    # hFile: 文件句柄
    ctypes.c_int64,     # liDistanceToMove: 要移动的距离 (64位整数)
    ctypes.POINTER(ctypes.c_int64), # lpNewFilePointer: 新的文件指针位置 (指针,可选)
    ctypes.c_uint32     # dwMoveMethod: 移动方法 (0: 文件开头, 1: 当前位置, 2: 文件末尾)
]
SetFilePointerEx.restype = ctypes.c_bool # 定义SetFilePointerEx函数的返回值类型 (布尔值)

# CloseHandle 用于关闭一个打开的对象句柄
CloseHandle = ctypes.windll.kernel32.CloseHandle # 获取CloseHandle函数的引用
CloseHandle.argtypes = [ctypes.c_void_p] # 定义CloseHandle函数的参数类型 (句柄)
CloseHandle.restype = ctypes.c_bool # 定义CloseHandle函数的返回值类型 (布尔值)

# 尝试获取设备大小的IOCTL (可选,但有助于确定读取范围)
# IOCTL_DISK_GET_LENGTH_INFO = 0x0007405C # 定义获取磁盘长度信息的IOCTL码
# class GET_LENGTH_INFORMATION(ctypes.Structure): # 定义GET_LENGTH_INFORMATION结构体
# _fields_ = [("Length", ctypes.c_int64)] # 结构体字段:Length (64位整数)
# DeviceIoControl = ctypes.windll.kernel32.DeviceIoControl # 获取DeviceIoControl函数的引用

def open_windows_device(device_path): # 定义打开Windows设备的函数
    """
    使用CreateFileW打开Windows上的物理驱动器或分区。
    例如: r'\\.\PhysicalDrive0' 或 r'\\.\C:'
    需要管理员权限。
    返回设备句柄,如果失败则返回INVALID_HANDLE_VALUE。
    """
    print(f"Attempting to open device: {
     device_path} with CreateFileW") # 打印尝试打开设备的信息
    handle = CreateFileW(
        device_path, # 设备路径
        GENERIC_READ, # 请求读权限
        FILE_SHARE_READ | FILE_SHARE_WRITE, # 允许其他进程读写该设备 (重要,否则可能打开失败)
        None, # 安全属性,通常为None
        OPEN_EXISTING, # 只打开已存在的设备
        FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, # 普通属性并提示顺序扫描
        # FILE_FLAG_NO_BUFFERING, # 如果使用无缓冲,读取大小和偏移必须是扇区大小的倍数
        None # 模板文件句柄,通常为None
    )
    if handle == INVALID_HANDLE_VALUE or handle is None: # 如果获取句柄失败
        error_code = ctypes.GetLastError() # 获取最后一个错误代码
        print(f"Failed to open device {
     device_path}. Error code: {
     error_code}") # 打印打开失败信息及错误码
        # 可以根据error_code提供更具体的错误信息,例如5是权限不足
        if error_code == 5: # 如果错误码是5 (拒绝访问)
            print("Error 5: Access Denied. Please ensure you are running this script with Administrator privileges.") # 打印权限不足提示
        elif error_code == 2: # 如果错误码是2 (找不到文件)
             print(f"Error 2: The system cannot find the file specified ({
     device_path}). Check the device path.") # 打印找不到文件提示
        return INVALID_HANDLE_VALUE # 返回无效句柄
    print(f"Successfully opened device {
     device_path}. Handle: {
     handle}") # 打印成功打开设备的信息
    return handle # 返回设备句柄

def close_windows_device(handle): # 定义关闭Windows设备的函数
    """关闭通过CreateFileW打开的设备句柄。"""
    if handle != INVALID_HANDLE_VALUE and handle is not None: # 如果句柄有效
        print(f"Closing device handle: {
     handle}") # 打印关闭句柄信息
        closed = CloseHandle(handle) # 调用CloseHandle关闭句柄
        if not closed: # 如果关闭失败
            error_code = ctypes.GetLastError() # 获取错误码
            print(f"Failed to close device handle {
     handle}. Error code: {
     error_code}") # 打印关闭失败信息
        # else:
            # print(f"Device handle {handle} closed successfully.") # 打印成功关闭信息 (可省略)

# 示例使用 (需要管理员权限运行此脚本):
# if __name__ == "__main__":
#     # 重要: 请极端小心选择device_path_win,错误的操作可能损坏数据
#     # 建议先使用U盘的盘符,例如 r'\\.\E:' (假设E盘是你的U盘)
#     # 或者物理驱动器号,例如 r'\\.\PhysicalDrive1' (你需要先确定哪个是你U盘)
#     # 不要轻易尝试操作你的系统盘 (如 PhysicalDrive0 或 C:)
#     device_path_win = r'\\.\E:' # 将E替换为你的U盘盘符
#     # device_path_win = r'\\.\PhysicalDriveX' # 将X替换为正确的物理驱动器编号

#     print("Requesting Administrator privileges if not already granted...") # 打印请求管理员权限信息
#     # 在实际脚本中,你可能需要更复杂的逻辑来检测和请求管理员权限
#     # 或者直接提示用户以管理员身份运行

#     h_device = open_windows_device(device_path_win) # 调用函数打开设备

#     if h_device != INVALID_HANDLE_VALUE: # 如果设备成功打开
#         print(f"Device {device_path_win} opened. Ready for I/O operations.") # 打印设备已打开信息
#         # 在这里可以进行读取操作,例如读取第一个扇区
#         # close_windows_device(h_device) # 操作完成后关闭设备
#     else:
#         print(f"Could not open device {device_path_win}.") # 打印无法打开设备信息
#         print("Make sure the device path is correct and you have Administrator privileges.") # 提示检查路径和权限

关于 FILE_FLAG_NO_BUFFERING: 如果使用此标志,那么 ReadFile 的缓冲区地址、读取的字节数以及文件指针的偏移量都必须是磁盘扇区大小的倍数。这增加了编程的复杂性,但可以避免操作系统缓存带来的影响,在某些取证场景下可能需要。对于一般的数据恢复读取,使用操作系统的默认缓冲(不设置此标志)通常是可以接受且更简单的。FILE_FLAG_SEQUENTIAL_SCAN 是一个给操作系统的提示,如果打算顺序读取大量数据,它可能有助于优化性能。

3.2.2 在Linux上打开设备

在Linux上,过程相对简单,可以直接使用内置的 open() 函数,但同样需要适当的权限(通常是root权限,或用户属于disk组)。

import os # 导入os模块

def open_linux_device(device_path): # 定义打开Linux设备的函数
    """
    打开Linux上的块设备文件,例如 /dev/sdb 或 /dev/sdb1。
    需要root权限或用户在disk组中。
    返回文件对象,如果失败则返回None。
    """
    print(f"Attempting to open device: {
     device_path} with os.open") # 打印尝试打开设备的信息
    try:
        # os.O_RDONLY: 以只读方式打开
        # os.O_BINARY: 在某些系统上确保二进制模式 (虽然在Linux上对块设备影响不大)
        # os.O_SYNC:  (可选) 确保写入同步,但我们是只读。某些情况下用于确保直接IO。
        # os.O_DIRECT: (可选) 尝试禁用内核缓冲,类似于FILE_FLAG_NO_BUFFERING。
        #              使用O_DIRECT时,读取的偏移、大小和内存缓冲区地址通常需要对齐到扇区边界。
        #              这会增加复杂性,我们暂时不使用它。
        fd = os.open(device_path, os.O_RDONLY | getattr(os, 'O_BINARY', 0)) # 以只读二进制方式打开设备文件,获取文件描述符
        print(f"Successfully opened device {
     device_path}. File Descriptor: {
     fd}") # 打印成功打开设备的信息
        # 将文件描述符包装成Python文件对象,以便使用read, seek等方法
        # buffering=0 表示不使用Python层面的缓冲,直接操作文件描述符,但系统仍有其缓冲
        # 更常见的做法是让Python管理一些缓冲,例如使用默认的缓冲大小
        device_file = os.fdopen(fd, 'rb') # 将文件描述符包装成文件对象,以二进制读取模式
        return device_file # 返回文件对象
    except FileNotFoundError: # 捕获文件未找到错误
        print(f"Error: Device {
     device_path} not found.") # 打印设备未找到信息
        return None # 返回None
    except PermissionError: # 捕获权限错误
        print(f"Error: Permission denied to open {
     device_path}. Try running as root or with sudo.") # 打印权限被拒绝信息
        return None # 返回None
    except Exception as e: # 捕获其他异常
        print(f"An unexpected error occurred while opening {
     device_path}: {
     e}") # 打印意外错误信息
        return None # 返回None

def close_linux_device(device_file): # 定义关闭Linux设备的函数
    """关闭通过os.fdopen打开的文件对象。"""
    if device_file: # 如果文件对象存在
        print(f"Closing device file: {
     device_file.name}") # 打印关闭文件信息
        try:
            device_file.close() # 关闭文件对象
            # print(f"Device file {device_file.name} closed.") # 打印成功关闭信息 (可省略)
        except Exception as e: # 捕获异常
            print(f"Error closing device file {
     device_file.name}: {
     e}") # 打印关闭文件错误信息

# 示例使用 (需要root权限或适当的用户组权限):
# if __name__ == "__main__":
#     # 重要: 小心选择device_path_linux,确保它是你的目标U盘
#     # 可以使用命令如 `lsblk` 或 `fdisk -l` 来确定正确的设备路径
#     # 例如: /dev/sdb (整个U盘), /dev/sdb1 (U盘的第一个分区)
#     device_path_linux = "/dev/sdXN"  # 将XN替换为你的U盘分区,例如 /dev/sdb1

#     if device_path_linux == "/dev/sdXN": # 如果路径是占位符
#         print("Please replace '/dev/sdXN' with the actual device path of your USB drive.") # 提示替换路径
#     else:
#         print(f"Attempting to access Linux device: {device_path_linux}") # 打印尝试访问设备信息
#         usb_device_file = open_linux_device(device_path_linux) # 调用函数打开设备

#         if usb_device_file: # 如果设备成功打开
#             print(f"Device {device_path_linux} opened. Ready for I/O operations.") # 打印设备已打开信息
#             # 在这里可以进行读取操作
#             # close_linux_device(usb_device_file) # 操作完成后关闭设备
#         else:
#             print(f"Could not open device {device_path_linux}.") # 打印无法打开设备信息

关于 os.O_DIRECT (Linux): 类似于Windows的 FILE_FLAG_NO_BUFFERING,它尝试绕过内核的页缓存。使用它时,Python的 read() 操作的参数(如读取大小、内存缓冲区的对齐)必须满足特定的对齐要求(通常是逻辑块大小的倍数)。这会使得编程更复杂,但可以获得更直接的磁盘访问。对于大多数恢复场景,标准的缓冲读取已经足够,除非有特别的性能或取证需求。

3.3 读取扇区数据

一旦成功打开了原始设备/分区,我们就可以使用文件对象的 seek() 方法定位到特定的偏移量,然后使用 read() 方法读取一定数量的字节。扇区通常是512字节或4096字节(4KB)。在进行数据恢复时,了解扇区大小很重要。

3.3.1 获取扇区大小 (可选但推荐)
  • Windows: 可以使用 DeviceIoControlIOCTL_DISK_GET_DRIVE_GEOMETRY_EX 来获取磁盘的几何信息,包括每个扇区的字节数。
  • Linux: 对于块设备,扇区大小(逻辑块大小)通常可以通过读取 /sys/block//queue/logical_block_size (例如 /sys/block/sdb/queue/logical_block_size) 或使用 blockdev --getss /dev/sdb 命令来确定。在Python中,也可以尝试读取一个已知大小(如512字节),如果文件系统元数据(如引导扇区)中包含扇区大小信息,则优先使用那个。

如果无法动态获取,可以先假设为512字节,因为这是非常常见的值。

3.3.2 实现读取函数 (跨平台考虑)

我们可以创建一个包装函数来处理Windows和Linux的差异。

import platform # 导入platform模块,用于获取操作系统信息
# (接续之前的Windows API和Linux函数定义)

DEFAULT_SECTOR_SIZE = 512 # 定义默认扇区大小为512字节

def read_sectors_from_device(device_handle_or_file, start_sector, num_sectors, sector_size=DEFAULT_SECTOR_SIZE): # 定义从设备读取扇区的函数
    """
    从打开的设备句柄(Windows)或文件对象(Linux)读取指定数量的扇区。
    :param device_handle_or_file: Windows设备句柄 或 Linux文件对象。
    :param start_sector: int, 要开始读取的扇区号 (从0开始)。
    :param num_sectors: int, 要读取的扇区数量。
    :param sector_size: int, 每个扇区的大小 (字节)。
    :return: bytes, 读取到的数据;如果失败则返回None。
    """
    offset = start_sector * sector_size # 计算起始偏移量(字节)
    bytes_to_read = num_sectors * sector_size # 计算总共要读取的字节数
    data = None # 初始化数据为None

    current_os = platform.system() # 获取当前操作系统名称

    if current_os == "Windows": # 如果是Windows系统
        if device_handle_or_file == INVALID_HANDLE_VALUE or device_handle_or_file is None: # 如果句柄无效
            print("Error: Invalid device handle for Windows.") # 打印无效句柄错误
            return None # 返回None

        # Windows: 使用 SetFilePointerEx 和 ReadFile
        new_pointer_long = ctypes.c_int64() # 创建一个64位整数用于存储新的文件指针位置
        # FILE_BEGIN = 0
        if not SetFilePointerEx(device_handle_or_file, ctypes.c_int64(offset), ctypes.byref(new_pointer_long), 0): # 设置文件指针
            error_code = ctypes.GetLastError() # 获取错误码
            print(f"Windows: SetFilePointerEx failed to seek to offset {
     offset}. Error: {
     error_code}") # 打印设置指针失败信息
            return None # 返回None
        
        # print(f"Windows: Seeked to offset {offset}, new pointer at {new_pointer_long.value}") # 打印寻址成功信息 (调试用)

        buffer = ctypes.create_string_buffer(bytes_to_read) # 创建一个指定大小的字符缓冲区
        bytes_read_long = ctypes.c_uint32() # 创建一个无符号32位整数用于存储实际读取的字节数

        if ReadFile(device_handle_or_file, buffer, bytes_to_read, ctypes.byref(bytes_read_long), None): # 读取文件
            if bytes_read_long.value == bytes_to_read: # 如果实际读取的字节数等于期望读取的字节数
                data = buffer.raw[:bytes_read_long.value] # 获取读取到的原始字节数据
                # print(f"Windows: Read {bytes_read_long.value} bytes successfully.") # 打印读取成功信息 (调试用)
            elif bytes_read_long.value > 0 : # 如果读取到的字节数大于0但小于期望值 (可能到达文件末尾)
                data = buffer.raw[:bytes_read_long.value] # 获取部分数据
                print(f"Windows: Read {
     bytes_read_long.value} bytes (less than expected {
     bytes_to_read}). Possibly EOF.") # 打印部分读取信息
            else: # 如果实际读取的字节数为0
                 print(f"Windows: ReadFile succeeded but read 0 bytes from offset {
     offset}.") # 打印读取0字节信息
                 data = b'' # 返回空字节串
        else: # 如果ReadFile失败
            error_code = ctypes.GetLastError() # 获取错误码
            print(f"Windows: ReadFile failed to read {
     bytes_to_read} bytes from offset {
     offset}. Error: {
     error_code}") # 打印读取失败信息
            # 常见的错误码23: 数据错误(循环冗余检查)。表示磁盘扇区可能损坏。
            if error_code == 23:
                print("ReadFile Error 23: Data error (cyclic redundancy check). Possible bad sector.")
            return None # 返回None
            
    elif current_os == "Linux": # 如果是Linux系统
        if device_handle_or_file is None: # 如果文件对象无效
            print("Error: Invalid device file object for Linux.") # 打印无效文件对象错误
            return None # 返回None
        try:
            device_handle_or_file.seek(offset) # 移动文件指针到指定偏移量
            # print(f"Linux: Seeked to offset {offset}") # 打印寻址成功信息 (调试用)
            data = device_handle_or_file.read(bytes_to_read) # 读取指定字节数的数据
            if len(data) < bytes_to_read and len(data) > 0: # 如果读取到的数据长度小于期望但大于0
                print(f"Linux: Read {
     len(data)} bytes (less than expected {
     bytes_to_read}). Possibly EOF.") # 打印部分读取信息
            elif len(data) == 0 and bytes_to_read > 0: # 如果期望读取但读到0字节
                print(f"Linux: Read 0 bytes from offset {
     offset} when {
     bytes_to_read} were expected.") # 打印读取0字节信息
            # else:
                # print(f"Linux: Read {len(data)} bytes successfully.") # 打印读取成功信息 (调试用)
        except IOError as e: # 捕获IO错误
            # IOError可能是由于坏道等原因
            print(f"Linux: IOError during seek/read from offset {
     offset}. Error: {
     e}") # 打印IO错误信息
            return None # 返回None
        except Exception as e: # 捕获其他异常
            print(f"Linux: Unexpected error during seek/read: {
     e}") # 打印意外错误信息
            return None # 返回None
    else: # 如果是不支持的操作系统
        print(f"Error: Unsupported operating system '{
     current_os}' for raw disk access.") # 打印不支持的操作系统信息
        return None # 返回None
        
    return data # 返回读取到的数据

# --- 示例如何使用 read_sectors_from_device ---
# if __name__ == "__main__":
#     current_os_name = platform.system() # 获取当前操作系统名称
#     device_handle = None # 初始化设备句柄/文件对象
#
#     # 根据操作系统选择设备路径和打开方式
#     if current_os_name == "Windows":
#         # !!! 再次强调: 确保这是你要操作的U盘,而不是系统盘 !!!
#         # 例如: r'\\.\E:' (E盘是U盘) 或 r'\\.\PhysicalDrive1' (PhysicalDrive1是U盘)
#         win_dev_path = r'\\.\E:' # 修改为你的U盘对应的盘符或物理驱动器
#         if win_dev_path == r'\\.\E:' and not os.path.exists('E:\\'): # 简单检查盘符是否存在 (不完全可靠)
#             print(f"Path {win_dev_path} seems invalid as drive E: does not exist. Please check.")
#         else:
#             device_handle = open_windows_device(win_dev_path) # 打开Windows设备
#     elif current_os_name == "Linux":
#         # !!! 确保这是你要操作的U盘 !!!
#         # 例如: /dev/sdb1 (U盘的第一个分区)
#         linux_dev_path = "/dev/sdb1" # 修改为你的U盘对应的设备路径
#         if not os.path.exists(linux_dev_path): # 检查设备文件是否存在
#             print(f"Path {linux_dev_path} does not exist. Please check.")
#         else:
#             device_handle = open_linux_device(linux_dev_path) # 打开Linux设备
#     else:
#         print(f"OS {current_os_name} not directly supported by this example for raw access.") # 打印不支持的操作系统
#
#     if device_handle and (device_handle != INVALID_HANDLE_VALUE if current_os_name == "Windows" else True): # 如果设备成功打开
#         print("-" * 30) # 打印分隔线
#         print("Attempting to read the first sector (Boot Sector)...") # 打印尝试读取第一个扇区信息
#         # 读取第一个扇区 (扇区0),读取1个扇区,假设扇区大小为512字节
#         boot_sector_data = read_sectors_from_device(device_handle, start_sector=0, num_sectors=1, sector_size=DEFAULT_SECTOR_SIZE) # 读取扇区数据
#
#         if boot_sector_data: # 如果成功读取到数据
#             print(f"Successfully read {len(boot_sector_data)} bytes for the boot sector.") # 打印成功读取字节数
#             # 可以使用hexdump来查看扇区内容
#             import hexdump # 导入hexdump模块
#             print("Hexdump of the first 64 bytes of the boot sector:") # 打印引导扇区前64字节的十六进制转储
#             hexdump.hexdump(boot_sector_data[:64]) # 十六进制转储数据的前64字节
#
#             # 尝试读取FAT表或MFT的开始部分 (这需要知道文件系统类型和布局)
#             # 例如,对于一个FAT32分区,引导扇区会告诉我们FAT表的位置
#             # 假设引导扇区显示保留扇区数为32,每个FAT表占2000个扇区
#             # 则第一个FAT表从扇区32开始
#             # fat_start_sector = 32 # (这只是一个例子,需要从引导扇区解析)
#             # print(f"\nAttempting to read first sector of FAT (example sector {fat_start_sector})...")
#             # fat_sector_data = read_sectors_from_device(device_handle, fat_start_sector, 1)
#             # if fat_sector_data:
#             #    print(f"Read {len(fat_sector_data)} bytes from presumed FAT start.")
#             #    hexdump.hexdump(fat_sector_data[:64])
#
#         else: # 如果读取失败
#             print("Failed to read the boot sector.") # 打印读取引导扇区失败信息
#
#         print("-" * 30) # 打印分隔线
#         # 关闭设备
#         if current_os_name == "Windows": # 如果是Windows系统
#             close_windows_device(device_handle) # 关闭Windows设备
#         elif current_os_name == "Linux": # 如果是Linux系统
#             close_linux_device(device_handle) # 关闭Linux设备
#     else: # 如果打开设备失败
#         print("Device handle/file object is not valid. Cannot proceed with reading.") # 打印设备句柄/文件对象无效信息
#

代码解释与注意事项:

  • 跨平台处理: read_sectors_from_device 函数内部通过 platform.system() 判断操作系统,然后调用相应的API(Windows的 SetFilePointerEx/ReadFile 或 Linux的 seek/read)。
  • 错误处理: 每个平台的API调用都包含了基本的错误检查和打印。在实际的数据恢复工具中,这些错误应该被更详细地记录到日志文件中。特别是Windows的 GetLastError() 可以提供具体的错误原因。Linux的 IOError 尤其需要关注,因为它可能表示磁盘坏道。
  • 管理员/Root权限: 重复强调,执行这些底层磁盘访问操作几乎总是需要提升的权限。脚本需要以管理员身份(Windows)或root用户(Linux,或通过sudo)运行。
  • 设备路径的准确性: 务必确保传递给 open_windows_deviceopen_linux_device 的设备路径是准确无误的,指向你希望进行数据恢复的U盘或其他非系统关键设备。操作错误的设备可能导致灾难性的数据丢失。
  • 扇区大小: DEFAULT_SECTOR_SIZE 设为512。对于现代磁盘(尤其是4K Native磁盘或使用4K模拟扇区的U盘),真实的扇区大小可能是4096字节。如果文件系统(如引导扇区)中明确指出了扇区大小,应优先使用该值。读取操作的 bytes_to_readoffset 都应该基于正确的扇区大小计算,以确保读取到的是完整的逻辑单元。
  • 读取错误 (坏道): 当 ReadFile (Windows) 或 read() (Linux) 遇到物理损坏的扇区(坏道)时,它们通常会失败并返回错误或抛出异常(如Windows的错误码23,Linux的IOError: [Errno 5] Input/output error)。一个健壮的数据恢复程序需要能够处理这种情况,例如:
    • 记录坏道的位置。
    • 尝试跳过坏道,继续读取后续的扇区。
    • 如果一个文件的数据跨越了坏道,那么该文件可能只能部分恢复。
    • 在读取大量数据时,可以实现一个循环,尝试逐个扇区读取,如果某个扇区读取失败,则用占位符数据(如全零字节)填充该扇区的缓冲区,并记录错误,然后继续读取下一个扇区。
3.4 创建磁盘/分区镜像 (可选但强烈推荐)

如前所述,直接在原始故障设备上进行恢复操作是有风险的。创建一个逐位的磁盘镜像是更安全的方法。Python也可以用来创建这样的镜像文件,尽管对于非常大的驱动器,使用专门的工具如 dd (Linux) 或 FTK Imager, dd for Windows 等可能更高效。

def create_disk_image(source_device_handle_or_file, output_image_path, sector_size=DEFAULT_SECTOR_SIZE, chunk_sectors=128, device_size_bytes=None): # 定义创建磁盘镜像的函数
    """
    从源设备创建一个逐位拷贝的镜像文件。
    :param source_device_handle_or_file: 打开的源设备句柄(Win)或文件对象(Lin)。
    :param output_image_path: str, 输出镜像文件的路径。
    :param sector_size: int, 扇区大小。
    :param chunk_sectors: int, 每次读取/写入的扇区数量 (块大小)。
    :param device_size_bytes: int, (可选) 源设备的总大小(字节)。如果为None,会尝试读取直到EOF。
    :return: bool, True如果成功,False如果失败。
    """
    current_os = platform.system() # 获取当前操作系统名称
    print(f"Starting to create image of device to '{
     output_image_path}'...") # 打印开始创建镜像信息
    print(f"Sector size: {
     sector_size} bytes, Chunk size: {
     chunk_sectors} sectors ({
     chunk_sectors * sector_size} bytes)") # 打印扇区大小和块大小信息

    try:
        with open(output_image_path, 'wb') as img_file: # 以二进制写入模式打开输出镜像文件
            total_bytes_written = 0 # 初始化总写入字节数
            start_sector = 0 # 初始化起始扇区
            read_errors = 0 # 初始化读取错误计数
            
            while True: # 无限循环,直到读取完成或遇到问题
                # print(f"Imaging: Reading chunk starting at sector {start_sector}...") # 打印正在读取的块信息 (调试用,会产生大量输出)
                data_chunk = read_sectors_from_device(source_device_handle_or_file, start_sector, chunk_sectors, sector_size) # 从源设备读取一块数据
                
                if data_chunk is None: # 如果读取数据块失败 (可能遇到坏道)
                    print(f"Warning: Failed to read chunk at sector {
     start_sector}. Filling with zeros.") # 打印读取块失败警告
                    # 用零填充这个块,并继续 (或者可以选择终止)
                    img_file.write(b'\x00' * (chunk_sectors * sector_size)) # 向镜像文件写入零字节
                    total_bytes_written += (chunk_sectors * sector_size) # 累加写入字节数 (即使是零)
                    read_errors += 1 # 读取错误计数加1
                elif not data_chunk:  # 如果读取到空数据 (通常表示到达设备末尾)
                    print("Reached end of source device (or read empty chunk).") # 打印到达设备末尾信息
                    break # 跳出循环
                else: # 如果成功读取到数据块
                    img_file.write(data_chunk) # 将数据块写入镜像文件
                    total_bytes_written += len(data_chunk) # 累加实际写入的字节数
                
                start_sector += chunk_sectors # 更新下一个起始扇区号
                
                # 打印进度 (可以根据需要调整频率)
                if start_sector % (chunk_sectors * 10) == 0: # 每10个块打印一次进度
                    progress_mb = total_bytes_written / (1024 * 1024) # 计算已处理的MB数
                    print(f"Progress: {
     progress_mb:.2f} MB written. Current sector: {
     start_sector}. Read errors: {
     read_errors}") # 打印进度信息

                if device_size_bytes is not None and total_bytes_written >= device_size_bytes: # 如果指定了设备大小且已达到
                    print(f"Reached specified device size of {
     device_size_bytes} bytes.") # 打印达到指定设备大小信息
                    break # 跳出循环
                    
            print(f"Disk imaging finished. Total bytes written: {
     total_bytes_written} ({
     total_bytes_written / (1024*1024):.2f} MB).") # 打印镜像完成信息
            if read_errors > 0: # 如果有读取错误
                print(f"Warning: Encountered {
     read_errors} read error(s) during imaging. Image may be incomplete or contain zeroed blocks.") # 打印读取错误警告
            return True # 返回成功标志
            
    except IOError as e: # 捕获IO错误 (通常是写入镜像文件时发生)
        print(f"IOError during disk imaging (likely writing to output file '{
     output_image_path}'): {
     e}") # 打印IO错误信息
        return False # 返回失败标志
    except Exception as e: # 捕获其他异常
        print(f"An unexpected error occurred during disk imaging: {
     e}") # 打印意外错误信息
        return False # 返回失败标志

# --- 示例如何使用 create_disk_image ---
# if __name__ == "__main__":
#     # ... (与之前相同的设备打开逻辑) ...
#     current_os_name = platform.system()
#     device_handle = None
#     source_device_path = ""

#     if current_os_name == "Windows":
#         source_device_path = r'\\.\E:' # 修改为你的U盘对应的盘符或物理驱动器
#         device_handle = open_windows_device(source_device_path)
#     elif current_os_name == "Linux":
#         source_device_path = "/dev/sdb1" # 修改为你的U盘对应的设备路径
#         device_handle = open_linux_device(source_device_path)
#     # else ...

#     output_image_file_path = "usb_image.dd" # 定义输出镜像文件名

#     if device_handle and (device_handle != INVALID_HANDLE_VALUE if current_os_name == "Windows" else True):
#         print(f"\nStarting imaging process for {source_device_path} to {output_image_file_path}") # 打印开始镜像进程信息
#         # 你可能需要一种方法来获取源设备的大小,以避免读取超出范围或无限循环
#         # 对于Windows,可以使用DeviceIoControl与IOCTL_DISK_GET_LENGTH_INFO
#         # 对于Linux,可以使用 os.fstat(device_handle.fileno()).st_size (如果适用) 或 blockdev --getsize64
#         # 这里我们暂时不实现动态获取大小,假设你知道大致范围或让它读到EOF
#         # device_actual_size = get_device_size_in_bytes(device_handle) # (这是一个需要你实现的函数)
#         device_actual_size = None # 暂时不指定大小,让它尝试读到EOF

#         success = create_disk_image(device_handle, output_image_file_path, 
#                                     sector_size=DEFAULT_SECTOR_SIZE, 
#                                     chunk_sectors=2048, # 每次读写 2048 * 512 B = 1MB
#                                     device_size_bytes=device_actual_size)

#         if success: # 如果镜像成功
#             print(f"Image creation to '{output_image_file_path}' completed successfully (or with logged read errors).") # 打印镜像创建成功信息
#             print("It is recommended to perform recovery operations on this image file instead of the original device.") # 建议在镜像文件上操作
#         else: # 如果镜像失败
#             print(f"Image creation to '{output_image_file_path}' failed.") # 打印镜像创建失败信息

#         # 关闭设备
#         if current_os_name == "Windows":
#             close_windows_device(device_handle)
#         elif current_os_name == "Linux":
#             close_linux_device(device_handle)
#     else:
#         print("Device could not be opened. Imaging aborted.")

镜像函数解释:

  • create_disk_image 循环读取源设备的数据块(chunk_sectors * sector_size 字节),并将这些块写入到输出的镜像文件中。
  • 错误处理: 如果 read_sectors_from_device 返回 None(表示读取错误,可能是坏道),它会向镜像文件写入等量的零字节,并记录错误。这是一种常见的做法,叫做“用零填充坏道”,以保持镜像的完整性和偏移量正确,但损坏区域的数据会丢失。
  • 进度报告: 简单地每处理N个块打印一次进度。
  • 设备大小 (device_size_bytes): 如果能预先知道设备的总大小,可以作为循环的终止条件之一。否则,函数会一直读取,直到 read_sectors_from_device 返回空字节串(表示到达设备末尾)或 None。动态获取设备大小是一个依赖于操作系统的复杂任务,这里未完全实现。
  • 块大小 (chunk_sectors): 选择一个合适的块大小(例如1MB到64MB)可以平衡读取效率和内存使用。太小的块会导致过多的读写调用,太大的块可能消耗过多内存。

一旦我们有了打开原始设备(或其镜像文件)并从中读取数据的能力,下一步就是解析这些数据,以识别文件系统的结构、查找CHK文件内容、或者扫描已删除文件的痕迹。这将涉及到对特定文件系统(如FAT32, NTFS)的引导扇区、文件分配表、目录条目、MFT等关键数据结构的深入理解和编程解析。

掌握了这些底层的磁盘I/O操作,我们就为后续更复杂的数据恢复任务打下了坚实的基础。接下来的部分将开始聚焦于如何利用这些能力来识别和恢复CHK文件。

第四部分:CHK文件恢复实战:识别与提取

FILExxxx.CHK 文件本质上是原始数据簇的集合,它们失去了文件名和明确的文件类型信息。恢复它们的核心思想是 通过分析文件内容本身来识别其原始类型,并尝试将其重命名或转换为可用的格式。

4.1 CHK文件恢复的核心策略

主要的恢复策略可以分为以下几种,并且常常结合使用:

  1. 基于文件签名 (File Signature / Magic Number) 的识别:

    • 许多文件类型在其文件数据的开头(有时也在文件末尾)包含特定的字节序列,称为文件签名或魔数。这些签名可以作为识别文件类型的强烈线索。
    • 例如:
      • JPEG图像文件通常以 FF D8 FF E0 xx xx 4A 46 49 46 00 (ÿØÿà..JFIF.) 或 FF D8 FF E1 xx xx 45 78 69 66 00 (ÿØÿá..Exif.) 开头。
      • PNG图像文件以 89 50 4E 47 0D 0A 1A 0A (.PNG....) 开头。
      • PDF文档以 %PDF- (即 25 50 44 46 2D) 开头。
      • ZIP压缩文件以 PK (即 50 4B 03 04) 开头。
      • DOCX, XLSX, PPTX (Office Open XML) 文件本质上是ZIP压缩文件,所以也以 PK 开头,但其内部结构包含特定的XML文件可以进一步确认。
      • MP3文件没有统一的简单头部签名,但可能包含ID3标签 (ID3 字节序列) 或帧同步字 (例如 FFFx)。
    • 优点: 速度快,对于具有明显签名的文件类型识别准确率高。
    • 缺点:
      • 并非所有文件类型都有唯一的、易于识别的签名。
      • 签名可能很短,容易产生误判(例如,某个随机数据块恰好包含了某个短签名)。
      • 文件可能损坏,导致签名缺失或不完整。
      • CHK文件可能只包含文件的一部分,如果签名在文件头部,而CHK文件是中间或尾部片段,则此方法无效。
  2. 基于文件结构和内容特征的分析:

    • 对于某些文件类型,即使没有明确的头部签名,其内部数据结构也可能具有可识别的模式。
    • 例如:
      • 文本文件 (TXT, CSV, LOG, 源码等): 通常包含大量可打印的ASCII或UTF-8字符。可以通过统计字符的分布来判断。如果一个CHK文件大部分由可见字符、换行符、制表符组成,它很可能是一个文本文件。
      • HTML/XML文件: 包含大量的 <> 标签。
      • 数据库文件 (如SQLite): SQLite数据库文件以特定的16字节字符串 “SQLite format 3\000” 开头(在文件偏移0处)。
      • 多媒体文件: MP4, MOV等视频文件有复杂的内部原子/盒子结构 (ftyp, moov, mdat等),可以尝试解析这些结

你可能感兴趣的:(python)