美文网首页
热修&插件 - Art加载Class流程

热修&插件 - Art加载Class流程

作者: Stan_Z | 来源:发表于2021-02-01 20:38 被阅读0次

    根据上篇文章了解,对应ClassLoader初始化时,会将对应的dex加载到内存。接下来再继续看Class的加载、链接和初始化过程。

    一、整体过程

    类加载、链接、初始化整体过程
    任务 描述 最终状态
    Load 从.odex/.dex中提取类信息到虚拟机内部。 kStatusLoaded
    Verify 校验类信息是否合法。 kStatusVerified
    Prepare 为该类和相关信息的存储分配一块存储空间。
    Resolve 如果该类的成员还有引用其他类,可能还需要将其他类加载到虚拟机。 kStatusResolved
    Initialize 初始化这个类(初始化静态成员变量、执行static语句块)。 kStatusInitialized

    虽然如图所示,Class的加载、链接、初始化分别对应Load、Link、Initialize三个阶段,但ART代码中相关函数的调用顺序却不能(也没必要)完全与上述三个阶段相对应。

    注:class.h中的完整的类状态

      enum Status {
        kStatusRetired = -3,  // Retired, should not be used. Use the newly cloned one instead.
        kStatusErrorResolved = -2,
        kStatusErrorUnresolved = -1,
        kStatusNotReady = 0,
        kStatusIdx = 1,  // Loaded, DEX idx in super_class_type_idx_ and interfaces_type_idx_.
        kStatusLoaded = 2,  // DEX idx values resolved.
        kStatusResolving = 3,  // Just cloned from temporary class object.
        kStatusResolved = 4,  // Part of linking.
        kStatusVerifying = 5,  // In the process of being verified.
        kStatusRetryVerificationAtRuntime = 6,  // Compile time verification failed, retry at runtime.
        kStatusVerifyingAtRuntime = 7,  // Retrying verification at runtime.
        kStatusVerified = 8,  // Logically part of linking; done pre-init.
        kStatusInitializing = 9,  // Class init in progress.
        kStatusInitialized = 10,  // Ready to go.
        kStatusMax = 11,
      };
    

    二、从ClassLoader.loadClass()看类加载过程

    2.1ClassLoader继承关系
    ClassLoader继承关系类图
    2.2 ClassLoader.loadClass()加载流程
    libcore/ojluni/src/main/java/java/lang/ClassLoader.java
    
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    
    {
            // 首先,判断当前类是否已经被加载过
           Class<?> c = findLoadedClass(name);
           if (c == null) {
                try {
                    if (parent != null) {
                        //其次,递归去父类中查询是否加载到缓存
                        c = parent.loadClass(name, false);
                   } else {
                        //均未缓存,去BootClassLoader中查找
                        c = findBootstrapClassOrNull(name);
                   }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                   // from the non-null parent class loader
               }
                if (c == null) {
                    //以上均未找到,则通过findClass去找
                   c = findClass(name);
               }
            }
            return c;
    }
    

    以上即:双亲委派机制。目的是:避免重复加载以及核心累篡改。

    接下来看findClass流程:

    findClass调用栈

    这里核心在于9。

    art/runtime/class_linker.cc
    
    mirror::Class* ClassLinker::DefineClass(Thread* self,
                                            const char* descriptor,//目标类的字符串描述
                                            size_t hash,
                                            Handle<mirror::ClassLoader> class_loader,
                                            const DexFile& dex_file,//该类所在的dex文件对象
                                            const DexFile::ClassDef& dex_class_def//描述要加载的类在dex文件里的信息
    ) {
      StackHandleScope<3> hs(self);
      auto klass = hs.NewHandle<mirror::Class>(nullptr);
    ...
      if (klass == nullptr) {
        //分配一个class对象
       klass.Assign(AllocClass(self, SizeOfClassWithoutEmbeddedTables(dex_file, dex_class_def)));
      }
    …
    //获取DexCache
     ObjPtr<mirror::DexCache> dex_cache = RegisterDexFile(*new_dex_file, class_loader.Get());
    if (dex_cache == nullptr) {
      self->AssertPendingException();
      return nullptr;
    }
    klass->SetDexCache(dex_cache);
     //kclass设置基本信息:
     //包括:AccessFlags、Status、DexClassDefIndex、DexTypeIndex等。
      SetupClass(*new_dex_file, *new_class_def, klass, class_loader.Get());
    ...
      // Add the newly loaded class to the loaded classes table.
      //将kclass加入到class_table中
      ObjPtr<mirror::Class> existing = InsertClass(descriptor, klass.Get(), hash);
      if (existing != nullptr) {
        // We failed to insert because we raced with another thread. Calling EnsureResolved may cause
       // this thread to block.
       //如果插入失败,则表示类已经被加载过
       return EnsureResolved(self, descriptor, existing);
      }
      // Load the fields and other things after we are inserted in the table. This is so that we don't
      // end up allocating unfree-able linear alloc resources and then lose the race condition. The
      // other reason is that the field roots are only visited from the class table. So we need to be
      // inserted before we allocate / fill in these fields.
      //加载来自dex中目标类的属性和其他信息
      LoadClass(self, *new_dex_file, *new_class_def, klass);
    …
    if (!LoadSuperAndInterfaces(klass, *new_dex_file)) {
      // Loading failed.
      if (!klass->IsErroneous()) {
        mirror::Class::SetStatus(klass, mirror::Class::kStatusErrorUnresolved, self);
      }
      return nullptr;
    }
    ...
    MutableHandle<mirror::Class> h_new_class = hs.NewHandle<mirror::Class>(nullptr);
    if (!LinkClass(self, descriptor, klass, interfaces, &h_new_class)) {
      // Linking failed.
      if (!klass->IsErroneous()) {
        mirror::Class::SetStatus(klass, mirror::Class::kStatusErrorUnresolved, self);
      }
      return nullptr;
    }
    ...
      return h_new_class.Get();
    }
    

    初始化一个mirror Class对象:kclass,优先从DexCache中获取对应的类信息,通过SetupClass设置基本信息,将kclass加入到class_table中,再通过LoadClass和LoadSuperAndInterfaces分别加载dex中目标类的相关信息以及其父类或者实现的接口的相关信息。

    先看LoadClass

    void ClassLinker::LoadClass(Thread* self,
                                const DexFile& dex_file,
                                const DexFile::ClassDef& dex_class_def,
                                Handle<mirror::Class> klass) {
      //获取classData
      const uint8_t* class_data = dex_file.GetClassData(dex_class_def);
      if (class_data == nullptr) {
        return;  // no fields or methods - for example a marker interface
      }
    
      //加载类的属性和方法
      LoadClassMembers(self, dex_file, class_data, klass);
    }
    void ClassLinker::LoadClassMembers(Thread* self,
                                       const DexFile& dex_file,
                                       const uint8_t* class_data,
                                       Handle<mirror::Class> klass) {
      {
        // Note: We cannot have thread suspension until the field and method arrays are setup or else
       // Class::VisitFieldRoots may miss some fields or methods.
    //在没有加载完类成员之前,不允许当前线程挂起
       ScopedAssertNoThreadSuspension nts(__FUNCTION__);
        // Load static fields.
       // We allow duplicate definitions of the same field in a class_data_item
       // but ignore the repeated indexes here, b/21868015.
        //加载静态属性
       LinearAlloc* const allocator = GetAllocatorForClassLoader(klass->GetClassLoader());
        ClassDataItemIterator it(dex_file, class_data);
        LengthPrefixedArray<ArtField>* sfields = AllocArtFieldArray(self,
                                                                    allocator,
                                                                    it.NumStaticFields());
        size_t num_sfields = 0;
        uint32_t last_field_idx = 0u;
        for (; it.HasNextStaticField(); it.Next()) {
          uint32_t field_idx = it.GetMemberIndex();
          DCHECK_GE(field_idx, last_field_idx);  // Ordering enforced by DexFileVerifier.
         if (num_sfields == 0 || LIKELY(field_idx > last_field_idx)) {
            DCHECK_LT(num_sfields, it.NumStaticFields());
            //加载属性
            LoadField(it, klass, &sfields->At(num_sfields));
            ++num_sfields;
            last_field_idx = field_idx;
          }
        }
        // Load instance fields.
        //加载实例属性
       LengthPrefixedArray<ArtField>* ifields = AllocArtFieldArray(self,
                                                                    allocator,
                                                                    it.NumInstanceFields());
        size_t num_ifields = 0u;
        last_field_idx = 0u;
        for (; it.HasNextInstanceField(); it.Next()) {
          uint32_t field_idx = it.GetMemberIndex();
          DCHECK_GE(field_idx, last_field_idx);  // Ordering enforced by DexFileVerifier.
         if (num_ifields == 0 || LIKELY(field_idx > last_field_idx)) {
            DCHECK_LT(num_ifields, it.NumInstanceFields());
            LoadField(it, klass, &ifields->At(num_ifields));
            ++num_ifields;
            last_field_idx = field_idx;
          }
        }
    ...
        // Set the field arrays.
       klass->SetSFieldsPtr(sfields);
         DCHECK_EQ(klass->NumStaticFields(), num_sfields);
         klass->SetIFieldsPtr(ifields);
         DCHECK_EQ(klass->NumInstanceFields(), num_ifields);
         // Load methods.
        bool has_oat_class = false;
         //获取OatClass,通过OatClass可以获取类方法的本地机器指令
         const OatFile::OatClass oat_class =
             (Runtime::Current()->IsStarted() && !Runtime::Current()->IsAotCompiler())
                 ? OatFile::FindOatClass(dex_file, klass->GetDexClassDefIndex(), &has_oat_class)
                 : OatFile::OatClass::Invalid();
         const OatFile::OatClass* oat_class_ptr = has_oat_class ? &oat_class : nullptr;
         //加载方法
         klass->SetMethodsPtr(
             AllocArtMethodArray(self, allocator, it.NumDirectMethods() + it.NumVirtualMethods()),
             it.NumDirectMethods(),
             it.NumVirtualMethods());
         size_t class_def_method_index = 0;
         uint32_t last_dex_method_index = DexFile::kDexNoIndex;
         size_t last_class_def_method_index = 0;
         // TODO These should really use the iterators.
         //加载直接方法
        for (size_t i = 0; it.HasNextDirectMethod(); i++, it.Next()) {
           ArtMethod* method = klass->GetDirectMethodUnchecked(i, image_pointer_size_);
           LoadMethod(dex_file, it, klass, method);
           LinkCode(this, method, oat_class_ptr, class_def_method_index);
           uint32_t it_method_index = it.GetMemberIndex();
           if (last_dex_method_index == it_method_index) {
             // duplicate case
            method->SetMethodIndex(last_class_def_method_index);
           } else {
             method->SetMethodIndex(class_def_method_index);
             last_dex_method_index = it_method_index;
             last_class_def_method_index = class_def_method_index;
           }
           class_def_method_index++;
         }
         //加载虚拟方法
         for (size_t i = 0; it.HasNextVirtualMethod(); i++, it.Next()) {
           ArtMethod* method = klass->GetVirtualMethodUnchecked(i, image_pointer_size_);
           LoadMethod(dex_file, it, klass, method);
           DCHECK_EQ(class_def_method_index, it.NumDirectMethods() + i);
           //将对应方法关联上指令
           LinkCode(this, method, oat_class_ptr, class_def_method_index);
           class_def_method_index++;
         }
         DCHECK(!it.HasNext());
       }
       // Ensure that the card is marked so that remembered sets pick up native roots.
      Runtime::Current()->GetHeap()->WriteBarrierEveryFieldOf(klass.Get());
       self->AllowThreadSuspension();
    }
    

    加载类的属性和方法:
    ArtField: 包括Static Field、Instance Field、Field
    ArtMethod: 包括Direct Method、Virtual Method。

    注:ArtField和ArtMethod在ART中分别用于描述一个类的成员变量和成员函数。
    class.h
    uint64_t ifields_ 和 uint64_t sfields_ 对应ArtField
    uint64_t methods_; 对应ArtMethod

    LinkCode 将对应方法关联上指令

    static void LinkCode(ClassLinker* class_linker,
                         ArtMethod* method,
                         const OatFile::OatClass* oat_class,
                         uint32_t class_def_method_index) REQUIRES_SHARED(Locks::mutator_lock_) {
      Runtime* const runtime = Runtime::Current();
      if (runtime->IsAotCompiler()) {
        // The following code only applies to a non-compiler runtime.
       return;
      }
    
      // Method shouldn't have already been linked.
      DCHECK(method->GetEntryPointFromQuickCompiledCode() == nullptr);
      if (oat_class != nullptr) {
        // Every kind of method should at least get an invoke stub from the oat_method.
       // non-abstract methods also get their code pointers.
    //从OatClass中获取对应的OatMethod,OatMethod中记录了方法的本地机器指令的偏移地址
       //通过OatMethod::LinkMethod将OatMethod中记录的信息设置到ArtMethod当中
       const OatFile::OatMethod oat_method = oat_class->GetOatMethod(class_def_method_index);
        oat_method.LinkMethod(method);
      }
    
      const void* quick_code = method->GetEntryPointFromQuickCompiledCode();
      bool enter_interpreter = class_linker->ShouldUseInterpreterEntrypoint(method, quick_code);
      if (!method->IsInvokable()) {
        EnsureThrowsInvocationError(class_linker, method);
        return;
      }
      if (method->IsStatic() && !method->IsConstructor()) {//静态非构造方法
        // For static methods excluding the class initializer, install the trampoline.
       // It will be replaced by the proper entry point by ClassLinker::FixupStaticTrampolines
       // after initializing class (see ClassLinker::InitializeClass method).
       method->SetEntryPointFromQuickCompiledCode(GetQuickResolutionStub());
      } else if (quick_code == nullptr && method->IsNative()) {//native方法且没有本地机器码
        method->SetEntryPointFromQuickCompiledCode(GetQuickGenericJniStub());
      } else if (enter_interpreter) {
        // Set entry point from compiled code if there's no code or in interpreter only mode.
       method->SetEntryPointFromQuickCompiledCode(GetQuickToInterpreterBridge());
      }
    
      if (method->IsNative()) {
        // Unregistering restores the dlsym lookup stub.
       method->UnregisterNative();
        if (enter_interpreter || quick_code == nullptr) {
          // We have a native method here without code. Then it should have either the generic JNI
         // trampoline as entrypoint (non-static), or the resolution trampoline (static).
         // TODO: this doesn't handle all the cases where trampolines may be installed.
         const void* entry_point = method->GetEntryPointFromQuickCompiledCode();
          DCHECK(class_linker->IsQuickGenericJniStub(entry_point) ||
                 class_linker->IsQuickResolutionStub(entry_point));
        }
      }
    }
    

    这里通过method->SetEntryPointFromQuickCompiledCode来设置对应方法的入口函数:

    • 对于静态非构造方法,当有执行机器指令的方法调用这类函数时,入口函数是:GetQuickResolutionStub()。
    • 当一个执行本地机器指令的方法调用没有机器指令的native方法时,入口函数是GetQuickGenericJniStub()。
    • 抽象方法需要由被子类实现,所以抽象类是没有本地机器指令的,需要由解释器解释执行,当有机器指令的方法调用抽象方法时, 需要设置一个桥接函数,从而转到解释器,由解释器解释执行抽象方法, 这个桥接函数是GetQuickToInterpreterBridge()。

    为什么每个类方法需要一个解释器入口点?

    ART虚拟机执行Java方法主要有两种模式:

    • quick code:执行本地机器指令
    • interpreter:由解释器解释执行虚拟机字节码

    当以解释器执行的类方法在执行的过程中调用了其它的类方法时,解释器就需要进一步知道被调用的类方法是应用以解释方式执行,还是本地机器指令方法执行。为了能够进行统一处理,就给每一个类方法都设置一个解释器入口点。

    例如:
    抽象方法声明类中是没有实现的,必须要由子类实现。因此抽象方法在声明类中是没有对应的本地机器指令的,它们必须要通过解释器来执行。

    然后返回来看DefineClass最后一步:LinkClass

    bool ClassLinker::LinkClass(Thread* self,
                                const char* descriptor,
                                Handle<mirror::Class> klass,
                                Handle<mirror::ObjectArray<mirror::Class>> interfaces,
                                MutableHandle<mirror::Class>* h_new_class_out) {
      CHECK_EQ(mirror::Class::kStatusLoaded, klass->GetStatus());
      if (!LinkSuperClass(klass)) {//link超类
        return false;
      }
    
      ArtMethod* imt_data[ImTable::kSize];
      // If there are any new conflicts compared to super class.
      bool new_conflict = false;
      std::fill_n(imt_data, arraysize(imt_data), Runtime::Current()->GetImtUnimplementedMethod());
      if (!LinkMethods(self, klass, interfaces, &new_conflict, imt_data)) {//link方法
        return false;
      }
    
      if (!LinkInstanceFields(self, klass)) {//link对象属性
        return false;
      }
    
      size_t class_size;
      if (!LinkStaticFields(self, klass, &class_size)) {//link静态属性
        return false;
      }
    
      CreateReferenceInstanceOffsets(klass);
      CHECK_EQ(mirror::Class::kStatusLoaded, klass->GetStatus());
      ImTable* imt = nullptr;
      if (klass->ShouldHaveImt()) {
        // If there are any new conflicts compared to the super class we can not make a copy. There
       // can be cases where both will have a conflict method at the same slot without having the same
       // set of conflicts. In this case, we can not share the IMT since the conflict table slow path
       // will possibly create a table that is incorrect for either of the classes.
       // Same IMT with new_conflict does not happen very often.
       if (!new_conflict) {
          ImTable* super_imt = FindSuperImt(klass.Get(), image_pointer_size_);
          if (super_imt != nullptr) {
            bool imt_equals = true;
            for (size_t i = 0; i < ImTable::kSize && imt_equals; ++i) {
              imt_equals = imt_equals && (super_imt->Get(i, image_pointer_size_) == imt_data[i]);
            }
            if (imt_equals) {
              imt = super_imt;
            }
          }
        }
        if (imt == nullptr) {
          LinearAlloc* allocator = GetAllocatorForClassLoader(klass->GetClassLoader());
          imt = reinterpret_cast<ImTable*>(
              allocator->Alloc(self, ImTable::SizeInBytes(image_pointer_size_)));
          if (imt == nullptr) {
            return false;
          }
          imt->Populate(imt_data, image_pointer_size_);
        }
      }
      if (!klass->IsTemp() || (!init_done_ && klass->GetClassSize() == class_size)) {
        // We don't need to retire this class as it has no embedded tables or it was created the
       // correct size during class linker initialization.
       CHECK_EQ(klass->GetClassSize(), class_size) << klass->PrettyDescriptor();
        if (klass->ShouldHaveEmbeddedVTable()) {
          klass->PopulateEmbeddedVTable(image_pointer_size_);
        }
        if (klass->ShouldHaveImt()) {
          klass->SetImt(imt, image_pointer_size_);
        }
        // Update CHA info based on whether we override methods.
       // Have to do this before setting the class as resolved which allows
       // instantiation of klass.
       Runtime::Current()->GetClassHierarchyAnalysis()->UpdateAfterLoadingOf(klass);
        // This will notify waiters on klass that saw the not yet resolved
       // class in the class_table_ during EnsureResolved.
        //linkClass 输入状态为kStatusLoaded,输出状态为kStatusResolved
       mirror::Class::SetStatus(klass, mirror::Class::kStatusResolved, self);
        h_new_class_out->Assign(klass.Get());
      } else {
        CHECK(!klass->IsResolved());
        // Retire the temporary class and create the correctly sized resolved class.
       StackHandleScope<1> hs(self);
        auto h_new_class = hs.NewHandle(klass->CopyOf(self, class_size, imt, image_pointer_size_));
        // Set arrays to null since we don't want to have multiple classes with the same ArtField or
       // ArtMethod array pointers. If this occurs, it causes bugs in remembered sets since the GC
       // may not see any references to the target space and clean the card for a class if another
       // class had the same array pointer.
       klass->SetMethodsPtrUnchecked(nullptr, 0, 0);
        klass->SetSFieldsPtrUnchecked(nullptr);
        klass->SetIFieldsPtrUnchecked(nullptr);
        if (UNLIKELY(h_new_class == nullptr)) {
          self->AssertPendingOOMException();
          mirror::Class::SetStatus(klass, mirror::Class::kStatusErrorUnresolved, self);
          return false;
        }
    
        CHECK_EQ(h_new_class->GetClassSize(), class_size);
        ObjectLock<mirror::Class> lock(self, h_new_class);
        FixupTemporaryDeclaringClass(klass.Get(), h_new_class.Get());
        {
          WriterMutexLock mu(self, *Locks::classlinker_classes_lock_);
          ObjPtr<mirror::ClassLoader> const class_loader = h_new_class.Get()->GetClassLoader();
          ClassTable* const table = InsertClassTableForClassLoader(class_loader);
          ObjPtr<mirror::Class> existing = table->UpdateClass(descriptor, h_new_class.Get(),
                                                       ComputeModifiedUtf8Hash(descriptor));
          if (class_loader != nullptr) {
            // We updated the class in the class table, perform the write barrier so that the GC knows
           // about the change.
           Runtime::Current()->GetHeap()->WriteBarrierEveryFieldOf(class_loader);
          }
          CHECK_EQ(existing, klass.Get());
          if (log_new_roots_) {
            new_class_roots_.push_back(GcRoot<mirror::Class>(h_new_class.Get()));
          }
        }
        // Update CHA info based on whether we override methods.
       // Have to do this before setting the class as resolved which allows
       // instantiation of klass.
       Runtime::Current()->GetClassHierarchyAnalysis()->UpdateAfterLoadingOf(h_new_class);
        // This will notify waiters on temp class that saw the not yet resolved class in the
       // class_table_ during EnsureResolved.
       mirror::Class::SetStatus(klass, mirror::Class::kStatusRetired, self);
        CHECK_EQ(h_new_class->GetStatus(), mirror::Class::kStatusResolving);
        // This will notify waiters on new_class that saw the not yet resolved
       // class in the class_table_ during EnsureResolved.
       mirror::Class::SetStatus(h_new_class, mirror::Class::kStatusResolved, self);
        // Return the new class.
       h_new_class_out->Assign(h_new_class.Get());
      }
      return true;
    }
    

    LinkClass整体过程是Prepare + Resolve,为该类和相关信息的存储分配一块存储空间,如果该类的成员还有引用其他类,可能还需要将其他类加载到虚拟机。输入状态为kStatusLoaded,输出状态为kStatusResolved。

    这里我们发现,link过程并没有做Verify,那么Verify是在什么时候做的呢?

    这里简单梳理下《深入理解Android Java虚拟机Art》中的结论:
    类的Verify可以在dex2oat阶段进行,这叫预校验。如果出现错误,状态则置为:kStatusRetryVerificationAtRutime,如果是这个状态,此类在之后的虚拟机运行时还会再继续做校验。,如果dex2oat校验成功,状态则置为:kStatusVerified。该类在虚拟机运行时,如果类的初始状态为kStatusVerified,则将该类中methods_数组中所有的ArtMethod对象设置kAccSkipAccessChecks标志,同时还设置kAccVerificationAttempted标记(该标记表示该类已经校验过了,不需要再做校验)。如果类的初始状态为kStatusRetryVerificationAtRutime,则在虚拟机运行时会触发MethodVerifier对该类进行校验。

    回顾下ClassLoader.loadClass()整个过程:

    先通过双亲委派机制判断当前类是否被加载过、是否需要上层的父加载器去加载。如果都没有则走findClass流程,而findClass核心是ClassLinker::DefineClass:
    该方法将类的信息从Dex文件中转换为对应的mirror Class对象。构建过程包括基本参数设置,属性、方法加载,其中方法还会设置对应的入口函数,通过linkClass完成准备和解析流程。最终加入到class_table中(它被ClassLoader用于管理该ClassLoader所加载的类)。这个过程虽然返回了Class对象,但是目前还只完成了加载操作,离目标类最终可用还差:验证工作和初始化相关工作。

    那么findClass的数据结构转换过程总结如下:


    类加载过程数据结构转换

    数据获取流程:
    class_table有表白已经被加载过,直接从loadClass流程就获取了,否则走DefineClass,优先从DexCache中获取dex信息,否则从当前DexFile中获取。然后将dex中的信息转换为对应的mirror Class对象,最后将class加入到class_table中,缓存当前类已经被指定的classLoader加载了。

    参考:
    《深入理解Android Java虚拟机Art》
    https://www.jianshu.com/p/86f9db3ab430
    https://www.jianshu.com/go-wild?ac=2&url=http%3A%2F%2Fblog.csdn.net%2Fluoshengyang%2Farticle%2Fdetails%2F39533503

    相关文章

      网友评论

          本文标题:热修&插件 - Art加载Class流程

          本文链接:https://www.haomeiwen.com/subject/vvcstltx.html