【Delphi】如何在可执行文件中动态添加数据

简介

        本文将以不同的方式将数据附加到可执行文件中。这样做的好处是,数据不需要在编译时链接进去,可以动态添加或更新。数据可以附加到已经编译好的文件中。这种方法的唯一缺点是在运行时读取数据要比使用资源时稍难一些。

概述

        Windows PE 文件格式允许在可执行文件中添加附加数据。这些数据会被 Windows 程序加载器忽略,因此我们可以为自己的目的使用这些数据,而不会影响程序代码。在本文中,我们称这些数据为有效附加数据

        要解决的问题是如何表示可执行文件有有效附加数据,以及如何找出有效附加数据的大小以及内容。我们必须能够在不修改文件可执行部分的情况下做到这一点。我们将使用一个特殊记录来标识有效附加数据。该记录将跟随有效附加数据数据。

        因此,包含有效附加数据的可执行文件有三个主要组成部分(按顺序排列):

  1. 原始可执行文件。
  2. 有效附加数据
  3. 脚注记录,用于识别是否存在有效附加数据,并记录可执行代码和有效附加数据大小

        因此,我们的任务是编写能够创建、修改、读取、删除和检查有效附加数据是否存在的代码。为此,我们必须能够检测、读取和更新有效附加数据脚注。

        我们首先要讨论的是如何处理脚注。

有效附加数据脚注记录

        在概述中,我们注意到有效附加数据脚注有三个用途:

  1. 有效附加数据
  2. 记录有效附加数据数据的大小。
  3. 记录原始可执行文件的大小。

下面清单  定义了一个记录所有所需信息的 Pascal 记录。

type
   TPayloadFooter = packed record
     WaterMark: TGUID;
     DataSize: LongInt;
     ExeSize: LongInt;
   end;

这些字段的用途如下:

  • WaterMark是一个 "神奇数字",用于识别有效附加数据是否存在。使用 GUID 可尽量确保水印的唯一性,以减少可执行文件的最后几个字节被错误地检测为有效附加数据脚注的可能性。该字段始终设置为相同的固定值。稍后我们将看到该字段是如何用于检测有效附加数据的。
  • DataSize 字段存储有效附加数据本身的大小。这实际上是多余的信息--它的值可以从 ExeSize 字段的值、文件大小和 TPayloadFooter 记录的大小中推导出来。不过,通过提供这个字段,我们可以简化后续代码。
  • ExeSize 字段存储了添加有效附加数据前原始可执行文件的大小。该字段还指定了文件中有效附加数据数据的起始偏移量,因为有效附加数据紧随可执行代码之后。

        我们经常需要创建一个包含正确水印的空白脚注记录。下面清单 2 演示了一个简单的辅助存储过程,用于初始化空白脚注。

  const
    // 任意水印常数:不能全为 0
    cWaterMarkGUID: TGUID = '{9FABA105-EDA8-45C3-89F4-369315A947EB}';
    
  procedure InitFooter(out Footer: TPayloadFooter);
  begin
    FillChar(Footer, SizeOf(Footer), 0);
    Footer.WaterMark := cWaterMarkGUID;
  end;

        该例程只需将脚注记录清零,并在其中存储所需的水印。

        现在让我们来看看如何检查文件中是否存在有效附加数据。具体做法是将文件的最终 SizeOf(TPayloadFooter) 字节读入 TPayloadFooter 记录,并检查水印字段是否包含预期的神奇数字。如果是这样,就可以认为我们有一个有效附加数据,并且该记录提供了有效附加数据和可执行文件大小的有效信息。

        下面清单 3 显示了一个辅助例程的代码,该例程既能检查打开文件中是否存在有效附加数据,也能在存在有效附加数据时获取脚注。该例程在标准 Pascal 无类型文件上运行。

  function ReadFooter(var F: File; out Footer: TPayloadFooter): Boolean;
  var
    FileLen: Integer;
  begin
    // Check that file is large enough for a footer
    FileLen := FileSize(F);
    if FileLen > SizeOf(Footer) then
    begin
      // Big enough: move to start of footer and read it
     Seek(F, FileLen - SizeOf(Footer));
     BlockRead(F, Footer, SizeOf(Footer));
    end
    else
     // File not large enough for footer: zero it
     // .. this ensures watermark is invalid
     FillChar(Footer, SizeOf(Footer), 0);
   // Check if watermark is valid
   Result := IsEqualGUID(Footer.WaterMark, cWaterMarkGUID);
  end;

        ReadFooter 首先会获取文件大小,并检查文件是否大到足以包含有效附加数据脚注。如果文件足够大,它会将文件指针从文件末尾向后移动到 SizeOf(TPayloadFooter) 字节,然后读入脚注记录。如果文件太小,例程就会在脚注记录中填入零,使其无效(即水印为零)。最后,例程会检查记录的水印字段是否包含所需的 GUID,并返回结果。页脚记录将作为参数传递出去。

水印警告

        现在我们知道为什么清单 2 中的注释警告不要使用全为 0 的 GUID 了。这是因为当文件太小时,上述代码会将脚注记录清零,以确保它永远不会与有效水印进行比较。

        现在,我们已经掌握了足够的信息,可以继续开发一个帮助我们管理有效附加数据的类。

有效附加数据管理类

        在本节中,我们将开发一个可以管理有效附加数据的类。首先,让我们定义该类的需求。它们是:

  1. 检查可执行文件是否包含有效附加数据
  2. 查找有效附加数据数据的大小。
  3. 有效附加数据从文件中提取到适当大小的缓冲区中。
  4. 从文件中删除有效附加数据
  5. 在文件中存储有效附加数据

该类的声明如下:

 type
   TPayload = class(TObject)
    private
      fFileName: string;
      fOldFileMode: Integer;
      fFile: File;
      procedure Open(Mode: Integer);
      procedure Close;
    public
     constructor Create(const FileName: string);
     function HasPayload: Boolean;
     function PayloadSize: Integer;
     procedure SetPayload(const Data; const DataSize: Integer);
     procedure GetPayload(var Data);
     procedure RemovePayload;
   end;

公共方法有:

  1. Create - 创建一个对象,用于处理命名的文件。
  2. HasPayload - 如果文件包含有效附加数据,则返回 true。
  3. PayloadSize - 返回有效附加数据的大小。使用 GetPayload 分配可读取有效附加数据的缓冲区时需要此信息。
  4. SetPayload - 从缓冲区复制指定数量的字节,并将其作为有效附加数据存储在文件末尾。会覆盖任何现有的有效附加数据
  5. GetPayload - 将文件的有效附加数据复制到给定的缓冲区。缓冲区必须足够大,以存储所有有效附加数据。所需的缓冲区大小由 PayloadSize 给出。
  6. RemovePayload - 删除文件中的任何有效附加数据,并删除脚注记录。此方法可将文件恢复到原始状态。

此外,还有两个私有辅助方法:

  1. Open - 以指定模式打开
  2. Close - 关闭文件并恢复原始文件模式。

该类还有三个字段:

  1. fFileName - 存储我们正在处理的文件的名称。
  2. fOldFileMode - 保留当前的 Pascal 文件模式。
  3. fFile - Pascal 文件描述符,用于记录打开文件的详细信息。

从对字段的讨论中可以看出,我们将使用标准的非类型 Pascal 文件例程来操作文件。

我们将分块讨论该类的实现。我们从程序清单 5 开始,查看构造函数、一些必需的常量和两个私有辅助方法。

  const
    // Untyped file open modes
    cReadOnlyMode = 0;
    cReadWriteMode = 2;
  
  constructor TPayload.Create(const FileName: string);
  begin
    // create object and record name of payload file
    inherited Create;
   fFileName := FileName;
 end;
 
 procedure TPayload.Open(Mode: Integer);
 begin
   // open file with given mode, recording current one
   fOldFileMode := FileMode;
   AssignFile(fFile, fFileName);
   FileMode := Mode;
   Reset(fFile, 1);
 end;
 
 procedure TPayload.Close;
 begin
   // close file and restore previous file mode
   CloseFile(fFile);
   FileMode := fOldFileMode;
 end;

        这两个常量定义了我们需要的两种 Pascal 文件模式。构造函数只需记录与类相关的文件名。Open 方法首先存储当前文件模式,然后使用所需的文件模式打开文件。最后,Close 关闭文件并恢复原来的文件模式。

        接下来,我们来看看提供文件有效附加数据信息的两个方法--PayloadSize 和 HasPayload:

  function TPayload.PayloadSize: Integer;
  var
    Footer: TPayloadFooter;
  begin
    // assume no data
    Result := 0;
    // open file
    Open(cReadOnlyMode);
    try
     // read footer and if valid return data size
     if ReadFooter(fFile, Footer) then
       Result := Footer.DataSize;
   finally
     Close;
   end;
 end;
 
 function TPayload.HasPayload: Boolean;
 begin
   // we have a payload if size is greater than 0
   Result := PayloadSize > 0;
 end;

        这里唯一有意义的方法是 PayloadSize。我们首先假设有效附加数据大小为零,以防没有有效附加数据。接下来,我们以读取模式打开文件,并尝试读取脚注。我们使用 ReadFooter 辅助例程来完成这项工作。如果脚注读取成功,我们将从脚注记录的 DataSize 字段中获取有效附加数据的大小。然后关闭文件。

        HasPayload 只需调用 PayloadSize 并检查其返回的有效附加数据大小是否大于零。

        现在我们来看看 下面程序清单 7 中描述的 GetPayload。该方法的 Data 参数是一个数据缓冲区,其大小必须至少为 PayloadSize 字节。

  procedure TPayload.GetPayload(var Data);
  var
    Footer: TPayloadFooter;
  begin
    // open file as read only
    Open(cReadOnlyMode);
    try
      // read footer
      if ReadFooter(fFile, Footer) and (Footer.DataSize > 0) then
     begin
       // move to end of exe code and read data
       Seek(fFile, Footer.ExeSize);
       BlockRead(fFile, Data, Footer.DataSize);
     end;
   finally
     // close file
     Close;
   end;
 end;

        GetPayload 以只读模式打开文件,并尝试读取脚注记录。如果我们成功读取了脚注记录,并且有效附加数据包含数据,我们就会将文件指针移动到有效附加数据的起始位置,然后将有效附加数据读入数据缓冲区。请注意,我们使用脚注记录的 ExeSize 字段执行寻道操作,并使用 DataSize 字段确定要读取的字节数。该方法以关闭文件结束。

        最后,我们检查两个修改文件的方法--RemovePayload 和 SetPayload 的实现。

procedure TPayload.RemovePayload;
var
  PLSize: Integer;
  FileLen: Integer;
begin
  // get size of payload
  PLSize := PayloadSize;
  if PLSize > 0 then
  begin
    // we have payload: open file and get size
    Open(cReadWriteMode);
    FileLen := FileSize(fFile);
    try
      // seek to end of exec code an truncate file there
      Seek(fFile, FileLen - PLSize - SizeOf(TPayloadFooter));
      Truncate(fFile);
    finally
      Close;
    end;
  end;
end;

procedure TPayload.SetPayload(const Data; 
  const DataSize: Integer);
var
  Footer: TPayloadFooter;
begin
  // remove any existing payload
  RemovePayload;
  if DataSize > 0 then
  begin
    // we have some data: open file for writing
    Open(cReadWriteMode);
    try
      // create a new footer with required data
      InitFooter(Footer);
      Footer.ExeSize := FileSize(fFile);
      Footer.DataSize := DataSize;
      // write data and footer at end of exe code
      Seek(fFile, Footer.ExeSize);
      BlockWrite(fFile, Data, DataSize);
      BlockWrite(fFile, Footer, SizeOf(Footer));
    finally
      Close;
    end;
  end;
end;

        RemovePayload 会检查现有有效附加数据的大小,只有当有效附加数据存在时才会继续。如果有,则打开文件进行写入,并记录其大小。然后,我们查找文件可执行部分的末尾,并在关闭文件前将其截断。我们通过从文件长度中扣除有效附加数据大小和脚注记录大小来计算可执行部分的结束时间。我们也可以读取脚注,并直接使用其 ExeSize 字段的值。

        SetPayload 需要两个参数:数据缓冲区 (Data) 和缓冲区的大小 (DataSize)。该方法首先使用 RemovePayload 删除任何现有的有效附加数据,确保文件只包含可执行代码。如果有效附加数据包含一些数据,我们就打开文件进行写入。然后使用 InitFooter 辅助例程初始化一个新的有效附加数据记录,并在记录中存储可执行文件和新有效附加数据的大小。最后,我们在关闭文件前将有效附加数据和脚注记录附加到文件中。

        现在我们已经创建了 TPayload 类,要操作有效附加数据就很容易了。遗憾的是,我们必须一次性读写整个有效附加数据,这并不总是很方便。改进的办法是对数据进行随机访问。这就是我们接下来要做的。

随机有效附加数据访问

        提供随机数据访问的 "Delphi 方法 "是从 TStream 派生一个类,并覆盖其抽象方法--这就是我们要做的。我们的新类将称为 TPayloadStream。它将检测有效附加数据并提供读/写随机访问。

        这种方法不仅能提供随机访问,而且还有一个额外的优点,即向类的用户隐藏了如何实现有效附加数据的细节。用户看到的只是熟悉的 TStream 接口,而所有血淋淋的细节都隐藏在 TPayloadStream 的实现中。

        程序清单 9 显示了新类的定义,以及一个枚举 - TPayloadOpenMode - 用于确定有效附加数据流对象是读取还是写入有效附加数据。请注意,除了覆盖 TStream 的抽象方法外,TPayloadStream 还覆盖了虚拟的 SetSize 方法,使用户可以更改有效附加数据的大小。这是必要的,因为默认情况下,SetSize 什么也不做。

 type
    TPayloadOpenMode = (
      pomRead, // read mode
      pomWrite // write (append) mode
    );
  
    TPayloadStream = class(TStream)
    private
      fMode: TPayloadOpenMode; // stream open mode
     fOldFileMode: Integer; // preserves old file mode
     fFile: File; // handle to exec file
     fDataStart: Integer; // start of payload data in file
     fDataSize: Integer; // size of payload
   public
     // opens payload of file in given open mode
     constructor Create(const FileName: string; 
       const Mode: TPayloadOpenMode);
     // close file, updating data in write mode
     destructor Destroy; override;
     // moves to specified position in payload
     function Seek(Offset: LongInt; Origin: Word): LongInt; override;
     // sets size of payload in write mode only
     procedure SetSize(NewSize: LongInt); override;
     // Reads count bytes from payload
     function Read(var Buffer; Count: LongInt): LongInt; override;
     // Writes count bytes to payload in write mode only
     function Write(const Buffer; Count: LongInt): LongInt; override;
   end;

TPayloadStream 的公共方法有:

  1. Create - 创建 TPayloadStream,并以读取或写入模式打开指定文件。
  2. Destroy - 更新有效附加数据脚注、关闭文件并销毁流对象。
  3. Seek - 将数据流指针移动到有效附加数据中的指定位置,确保指针保持在有效附加数据内。
  4. SetSize - 仅在写入模式下设置有效附加数据的大小。在读取模式下使用时会引发异常。请注意,将大小设置为零将删除有效附加数据和相关的脚注记录。
  5. Read - 尝试将指定数量的字节读入缓冲区。如果有效附加数据中的数据不足,则只读取剩余的字节。
  6. Write - 从缓冲区向有效附加数据写入指定数量的字节,如果需要可扩展有效附加数据。仅在写入模式下有效,在读取模式下会出现异常。

该类还使用以下私有字段:

  1. fMode - 记录数据流是开放读取还是写入。
  2. fOldFileMode - 保留当前的 Pascal 文件模式。
  3. fFile - Pascal 文件描述符,记录打开文件的详细信息。
  4. fDataStart - 有效附加数据从可执行文件开始的偏移量。
  5. fDataSize - 有效附加数据的大小。

        我们从程序清单 10 开始回顾该类的实现,它显示了构造函数和析构函数。我们再次使用经典的 Pascal 非类型文件来执行对可执行文件的底层物理访问。不过,这也可以很容易地改变为使用其他文件访问技术。

  constructor TPayloadStream.Create(const FileName: string;
    const Mode: TPayloadOpenMode);
  var
    Footer: TPayloadFooter; // footer record for payload data
  begin
    inherited Create;
    // Open file, saving current mode
    fMode := Mode;
    fOldFileMode := FileMode;
   AssignFile(fFile, FileName);
   case fMode of
     pomRead: FileMode := 0;
     pomWrite: FileMode := 2;
   end;
   Reset(fFile, 1);
   // Check for existing payload
   if ReadFooter(fFile, Footer) then
   begin
     // We have payload: record start and size of data
     fDataStart := Footer.ExeSize;
     fDataSize := Footer.DataSize;
   end
   else
   begin
     // There is no existing payload: start is end of file
     fDataStart := FileSize(fFile);
     fDataSize := 0;
   end;
   // Set required file position per mode
   case fMode of
     pomRead: System.Seek(fFile, fDataStart);
     pomWrite: System.Seek(fFile, fDataStart + fDataSize);
   end;
 end;
 
 destructor TPayloadStream.Destroy;
 var
   Footer: TPayloadFooter; // payload footer record
 begin
   if fMode = pomWrite then
   begin
     // We're in write mode: we need to update footer
     if fDataSize > 0 then
     begin
       // We have payload, so need a footer record
       InitFooter(Footer);
       Footer.ExeSize := fDataStart;
       Footer.DataSize := fDataSize;
       System.Seek(fFile, fDataStart + fDataSize);
       Truncate(fFile);
       BlockWrite(fFile, Footer, SizeOf(Footer));
     end
     else
     begin
       // No payload => no footer
       System.Seek(fFile, fDataStart);
       Truncate(fFile);
     end;
   end;
   // Close file and restore old file mode
   CloseFile(fFile);
   FileMode := fOldFileMode;
   inherited;
 end;

        在构造函数中,我们要做的第一件事就是记录打开模式,然后以所需模式打开底层文件。接下来,我们使用程序清单 3 中开发的 ReadFooter 函数尝试读取有效附加数据脚注记录。

        如果找到了脚注,我们将分别从脚注的 ExeSize 和 DataSize 字段中获取有效附加数据的起始位置(fDataStart)和有效附加数据的大小(fDataSize)。如果没有脚注记录,就没有有效附加数据,因此我们将 fDataStart 设置为文件结束后的起始位置,并将 fDataSize 设置为零。

设置 fDataStart

        fDataStart 与可执行文件的大小相同,因为有效附加数据总是紧随可执行代码之后开始。

        最后,构造函数根据文件模式设置文件指针--在读取模式下,我们将其设置为有效附加数据的起点,而在写入模式下,我们将其设置为终点。

        在析构函数中,我们会根据读取模式或写入模式的不同而采取不同的处理方式:

  • 在读取模式下,只需关闭文件并恢复之前的 Pascal 文件模式。
  • 在写模式下,我们首先要检查是否有有效附加数据(fDataSize > 0)。如果是,我们就使用清单 2 中定义的 InitFooter 例程创建一个脚注记录,并在记录中记录有效附加数据的大小和起始位置。然后,我们查找新有效附加数据的末尾,截断超出其末尾的任何数据(数据大小缩小时会出现这种情况),然后写入脚注。如果没有有效附加数据,我们会在可执行代码末尾截断文件。最后关闭文件并恢复文件模式。

        关于类构造函数和析构函数的讨论到此为止。现在让我们考虑如何覆盖抽象的 "查找"、"读取 "和 "写入 "方法。程序清单 11 提供了详细信息:

  function TPayloadStream.Seek(Offset: Integer;
    Origin: Word): LongInt;
  begin
    // Calculate position in payload after move
    // (this is result value)
    case Origin of
      soFromBeginning:
        // Moving from start of stream: ignore -ve offsets
        if Offset >= 0 then
         Result := Offset
       else
         Result := 0;
     soFromEnd:
       // Moving from end of stream: ignore +ve offsets
       if Offset <= 0 then
         Result := fDataSize + Offset
       else
         Result := fDataSize;
     else // soFromCurrent and other values
       // Moving from current position
       Result := FilePos(fFile) - fDataStart + Offset;
   end;
   // Result must be within payload: make sure it is
   if Result < 0 then
     Result := 0;
   if Result > fDataSize then
     Result := fDataSize;
   // Perform actual seek in underlying file
   System.Seek(fFile, fDataStart + Result);
 end;
 
 function TPayloadStream.Read(var Buffer;
   Count: Integer): LongInt;
 var
   BytesRead: Integer; // number of bytes read
   AvailBytes: Integer; // number of bytes left in stream
 begin
   // Work out how many bytes we can read
   AvailBytes := fDataSize - Position;
   if AvailBytes < Count then
     Count := AvailBytes;
   // Read data from file and return bytes read
   BlockRead(fFile, Buffer, Count, BytesRead);
   Result := BytesRead;
 end;
 
 function TPayloadStream.Write(const Buffer;
   Count: Integer): LongInt;
 var
   BytesWritten: Integer; // number of bytes written
   Pos: Integer; // position in stream
 begin
   // Check in write mode
   if fMode <> pomWrite then
     raise EPayloadStream.Create(
       'TPayloadStream can''t write in read mode.');
   // Write the data, recording bytes read
   BlockWrite(fFile, Buffer, Count, BytesWritten);
   Result := BytesWritten;
   // Check if stream has grown
   Pos := FilePos(fFile);
   if Pos - fDataStart > fDataSize then
     fDataSize := Pos - fDataStart;
 end;

        寻址是三种方法中最复杂的一种。这是因为 FilePos 和 Seek 例程(用于获取和设置文件指针)对整个文件进行操作,而我们的流位置必须相对于有效附加数据的起点。我们还必须确保不能在有效附加数据之外设置文件指针。case 语句包含计算有效附加数据内所需偏移量的代码,具体取决于寻道起始位置。case 语句后面的两行对有效附加数据内的偏移量进行了限制。最后,我们在底层文件上执行实际的寻道操作,从有效附加数据的起点开始偏移。该方法会返回相对于有效附加数据的新偏移量。

        读取方法必须确保读取的数据完全位于有效附加数据内。我们不能假定可以读取数据流中的所有剩余字节,因为有效附加数据后面可能有不属于数据的脚注记录。因此,我们用有效附加数据的大小减去有效附加数据中的当前位置来计算可用字节数。如果没有足够的数据来满足请求,要读取的字节数就会减少到可用字节数。

        请注意,"读取 "使用 TStream 的 "位置 "属性来获取有效附加数据中的当前位置。该属性调用 Seek 方法,正如我们所见,该方法可确保返回的位置位于有效附加数据中。

        写入操作非常简单,我们只需检查是否处于写入模式,如果是,就在当前位置将数据输出到底层文件。写入的字节数会返回。唯一复杂的是,我们必须检查写操作是否超出了当前数据的末尾,如果是,则记录新的数据大小。如果数据流处于读取模式,"写 "会引发异常。

        现在要做的就是覆盖 SetSize 方法。程序清单 12 提供了实现方法。

  procedure TPayloadStream.SetSize(NewSize: Integer);
  var
    Pos: Integer; // current position in stream
  begin
    // Check for write mode
    if fMode <> pomWrite then
      raise EPayloadStream.Create(
        'TPayloadStream can''t change size in read mode.');
    // Update size, adjusting position if required
   if NewSize < fDataSize then
   begin
     Pos := Position;
     fDataSize := NewSize;
     if Pos > fDataSize then
       Position := fDataSize;
   end;
 end;

        显然,我们无法在读取模式下更改数据流的大小,因此在这种情况下会引发异常。在写模式下,我们只有在新大小小于当前有效附加数据大小时才会记录新大小。在这种情况下,我们还必须检查当前数据流的位置是否超出了缩小后的有效附加数据的末尾,如果是,则将位置移动到截断数据的末尾。位置属性用于获取和设置数据流位置。如前所述,该属性会调用我们的重载 Seek 方法。

为什么 SetSize 不能增加有效载荷的大小?
        禁止 SetSize 扩展有效附加数据是我的一个设计决定,因为扩大数据会带来一个问题,那就是必须在有效附加数据中写入填充字节。这些字节应该是什么?零?随机数据?我认为,只有使用 "写入 "方法明确添加数据,才能扩展有效附加数据

至此,我们完成了对 TPayloadStream 类的介绍。

演示程序源代码下载

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

海纳老吴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值