Android 应用开发之Dex解析和类加载

  1. APP安装

对于一个Android的apk应用程序,其主要的执行代码都在其中的class.dex文件中。在程序第一次被加载的时候,为了提高以后的启动速度和执行效率,Android系统会对这个class.dex文件做一定程度的优化,
并生成一个ODEX文件,存放在/data/dalvik-cache目录下。以后再运行这个程序的时候,就只要直接加载这个优化过的ODEX文件就行了,省去了每次都要优化的时间。

说明优化代码的第一部分只执行一次

PackagemanagerService中

mInstaller.install(pkgName, useEncryptedFSDir, pkg.applicationInfo.uid,
pkg.applicationInfo.uid);

ret = mInstaller.dexopt(path, pkg.applicationInfo.uid,  
                    !isForwardLocked(pkg));  

      mInstaller.dexopt 通过socket通信 让installd 进程(由init进程起来了)执行do_dexopt-->dexopt-->fork出子进程去执行run_dexopt,安装和优化的

                   dexopt(const char *apk_path, uid_t uid, int is_public) (在/frameworks/native/cmds/installd/commands.c中 )

                             if (create_cache_path(out_path, apk_path))     //这句的apk_path为apk的路径  即data/app/xxx.apk 表明此时已经在

                                     sprintf(path,"%s%s%s",DALVIK_CACHE_PREFIX,src + 1, DALVIK_CACHE_POSTFIX);  //其中installd.h   #define DALVIK_CACHE_PREFIX "/data/dalvik-cache/"
                                                                                                                //     installd.h    #define DALVIK_CACHE_POSTFIX "/classes.dex"

                                        out_fd = open(out_path, O_RDWR | O_CREAT | O_EXCL, 0644); 自此说明/data/dalvik-cache/目录下已经生成了data@app@*.apk@classes.dex文件,之后的文件优化验证都是对这个文件进行读写操作


                            tatic void run_dexopt(int zip_fd, int odex_fd, const char* input_file_name, const char* dexopt_flags) 

                                static const char* Dex_OPT_BIN = "/system/bin/dexopt"

DEX文件优化与验证:(完成了Odex文件头的构造)

run_dexopt:

static const char* Dex_OPT_BIN = “/system/bin/dexopt” 由run_dexopt执行/system/bin/dexopt进入dalvik的Optmain.cpp

\dexopt\Optmain.cpp:extractAndProcessZip() //从apk包zip文件中读取和抽出dex,加上odex文件头,设置优化选项,可以看作DEX文件优化的主控函数

                  err = dexOptCreateEmptyHeader(cacheFd);//创建 odex文件头,然后写入cacheFd指向的data@app@*.apk@classes.dex文件,并且将文件位置定位到真正的class.dex要填充的位置

                                write(fd, &optHdr, sizeof(optHdr))  

         if (dexZipExtractEntryToFile(&zippy, zipEntry, cacheFd) != 0) {  //将zip中的class.dex内容抽取到cacheFd指向文件的当前当前偏移处 dexOffset开始的位置

                    if (sysCopyFileToFile(fd, pArchive->mFd, uncompLen) != 0)
                             if (sysWriteFully(outFd, buf, getSize, "sysCopyFileToFile") != 0)
                                     ssize_t actual = TEMP_FAILURE_RETRY(write(fd, buf, count));

                       \vm\analysis\DexPrepare.cpp:dvmContinueOptimization()//生成odex文件,可以说优化与验证工作的完成就是生成odex文件

                                       mapAddr = mmap(NULL, dexOffset + dexLength, PROT_READ|PROT_WRITE,    MAP_SHARED, fd, 0); 将之前已经写入odex文件头(未设置)和zip中的class.dex内容的整个文件映射到内存继续优化验证


                                              rewriteDex(((u1*) mapAddr) + dexOffset, dexLength, doVerify, doOpt, &pClassLookup, NULL); 只调整从odex头部偏移dexOffset处的原始dex字节序等

                            if (dvmDexFileOpenPartial(dexAddr, dexLength, &pDvmDex) != 0) {    //创建一个Dexfile结构,此处是真正的dex文件

                                    dexFileParse  来具体解析Dex文件

                                     allocateAuxStructures(pDexFile); //设置Dexfile的辅助数据字段

                                               if (dvmDexFileOpenPartial(dexAddr, dexLength, &pDvmDex) != 0) {           //再次调用该函数来验证odex文件 ,   Returns nonzero on error. 返回值非0即失败  

                        dvmGenerateRegisterMaps(pDvmDex);   //填充辅助数据区结构

                       updateChecksum(dexAddr, dexLength, pHeader);   //重写优化后dex的checksum

                                               writeDependencies(fd, modWhen, crc) != 0)      //写入依赖库信息

                        writeOptData(fd, pClassLookup, pRegMapBuilder)   //写入其他优化信息,包括类索引信息以及寄存器映射关系。

                                                  DexOptHeader optHdr;  //填充odex头部
                                        memset(&optHdr, 0xff, sizeof(optHdr));
                                    memcpy(optHdr.magic, DEX_OPT_MAGIC, 4);
                                        memcpy(optHdr.magic+4, DEX_OPT_MAGIC_VERS, 4);
                                        optHdr.dexOffset = (u4) dexOffset;
                                     optHdr.dexLength = (u4) dexLength;

                                                  至此生成odex文件,其实就填充了/data/dalvik-cache/xxx@classes.dex文件的内容

  1. 点击APP icon图标,从Launcher Activity所在进程切入AMS

    startProcessLocked 函数,该函数最终又是通过调用 Process.start 方法请求 Zygote 进程 fork 目标进程的:
           app的新进程fork完毕
    

    (I) app的新进程–》 handlerChildProc 加载目标apk所有类

    (1.1) 加载好dex文件到内存

         指定并初始化apk的加载器是 PathClassLoader ,加载好dex文件到内存
             ClassLoader cloader;
                if (parsedArgs.classpath != null) {
                   cloader = new PathClassLoader(parsedArgs.classpath, ClassLoader.getSystemClassLoader()); 
                   //ClassLoader.getSystemClassLoader()系统类加载器  PathClassLoader[DexPathList [[directory "."],nativeLibraryDirectories=[/vendor/lib,/system/lib]]]
    
                  PathClassLoader基类是 BaseDexClassLoader
                        BaseDexClassLoader 的构造函数如下:
    
                                public BaseDexClassLoader(String dexPath, File optimizedDirectory,
                                          String libraryPath, ClassLoader parent) {
                                    super(parent);
                                    this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
                                }
    
                                       public DexPathList(ClassLoader definingContext, String dexPath,
                                            String libraryPath, File optimizedDirectory) {
                                                                            ………..    
                                                    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                       suppressedExceptions);
                                                            ………..
                                                                }
    
                                                前面是一些对于传入参数的验证,然后调用了makeDexElements。
    
                                                private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory,
                                                                                             ArrayList<IOException> suppressedExceptions) {
                                                ArrayList<Element> elements = new ArrayList<Element>();
                                                        for (File file : files) {
                                                            File zip = null;
                                                            DexFile dex = null;
                                                            String name = file.getName();
    
                                                            if (name.endsWith(DEX_SUFFIX)) {               //dex文件处理
                                                                // Raw dex file (not inside a zip/jar).
                                                                try {
                                                                    dex = loadDexFile(file, optimizedDirectory);
                                                                } catch (IOException ex) {
                                                                    System.logE(“Unable to load dex file: ” + file, ex);
                                                                }
                                                            } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
                                                                    || name.endsWith(ZIP_SUFFIX)) {   //apk,jar,zip文件处理
                                                                zip = file;
    
                                                                try {
                                                                    dex = loadDexFile(file, optimizedDirectory);
                                                                } catch (IOException suppressed) {
                                                                    suppressedExceptions.add(suppressed);
                                                                }
                                                            } else if (file.isDirectory()) {
                                                                elements.add(new Element(file, true, null, null));
                                                            } else {
                                                                System.logW(“Unknown file type for: ” + file);
                                                            }
    
                                                            if ((zip != null) || (dex != null)) {
                                                                elements.add(new Element(file, false, zip, dex));
                                                            }
                                                        }
    
                                                        return elements.toArray(new Element[elements.size()]);
                                                    }
                                                }
    
                                                不管是dex文件,还是apk文件最终加载的都是loadDexFile,跟进这个函数:
    
                                                private static DexFile loadDexFile(File file, File optimizedDirectory)
                                                            throws IOException {
                                                        if (optimizedDirectory == null) {
                                                            return new DexFile(file);
                                                        } else {
                                                            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
                                                            return DexFile.loadDex(file.getPath(), optimizedPath, 0);
                                                        }
                                                }
    
                                                如果optimizedDirectory为null就会调用openDexFile(fileName, null, 0);加载文件。
    
                                                否则调用DexFile.loadDex(file.getPath(), optimizedPath, 0);
    
                                                而这个函数也只是直接调用new DexFile(sourcePathName, outputPathName, flags);
    
                                                里面调用的也是openDexFile(sourceName, outputName, flags);
    
                                                所以最后都是调用openDexFile,跟进这个函数-------------------------------------------(openDexFileNative native层hook就hook这里):
    
                                                private static int openDexFile(String sourceName, String outputName,
                                                        int flags) throws IOException {
    
                                                        return openDexFileNative(new File(sourceName).getCanonicalPath(),   (outputName == null) ? null : new File(outputName).getCanonicalPath(), flags);
    
                                                        //而这个函数调用的是so的Dalvik_dalvik_system_DexFile_openDexFileNative个函数。 *******打开成功则返回一个cookie(会传递给下面1.2步) *******。这个cookie基本上是已经脱过壳的(Zj)
    
                                                               static void Dalvik_dalvik_system_DexFile_openDexFileNative(const u4* args, JValue* pResult)
                                                                        {
                                                                                        ……………
                                                                                    if (hasDexExtension(sourceName)
                                                                                                && dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) {
    
                                                                                                          ******* dvmRawDexFileOpen *******    打开 dex 文件并进行优化与加载,如果已经有odex则直接加载,否则用/system/bin/dexopt先优化后加载
    
                                                                                                                                函数的最后会(*ppRawDexFile)->pDvmDex = pDvmDex;  ,也就是下面 (1.2)的 pDvmDex来源, *******至此dex加载完成了 *******
    
                                                                                                                           dvmRawDexFileOpen 的作用就是给 DexOrJar 的成员 RawDexFile* pRawDexFile 赋值,赋值后返回这个 DexOrJar,在 java 层对应的就是一个 int 值 DexFile.mCookie
                                                                                                                             RawDexFile.cpp:dvmRawDexFileOpen()//DEX文件解析的主控函数   
    
                                                                                                                              \libdex\OptInvocation.cpp:dexOptGenerateCacheFileName()//构造一个dex cache name 
    
                                                                                                                               optFd = dvmOpenCachedDexFile(fileName, cachedName, modTime,adler32, isBootstrap, &newFile, /*createIfMissing=*/true);  //用刚刚创建的cache文件名到对应的/data/dalvik-cache目录下找odex文件,不存在的话会newFile置位
    
                                                                                                                              if (newFile) {
    
                                                                                                                                        result = dvmOptimizeDexFile(optFd, dexOffset, fileSize,
                                                                                                                                         fileName, modTime, adler32, isBootstrap);                   //,调用/system/bin/dexopt 重新生成/system/bin/dexopt该dex对应的odex文件
    
    
    
                                                                                                                             \vm\DvmDex.cpp:dvmDexFileOpenFromFd()//如果newFile==false(/data/dalvik-cache目录下本来就有odex),或者已经在newFile代码块中在/data/dalvik-cache目录下创建好了odex文件,则直接调用mmap对DEX文件映射,设置为只读文件,并进一步优化
    
                                                                                                                                 \libdex\DexFile.cpp:dexFileParse()//真正的解析ODEX,
    
                                                                                                                                最终初始化好了文件的结构体引用
    
                                                                                                                   在初始化文件结构体的引用时,虚拟机根据全局变量gDvm中的启动类路径来为每个类生成一个ClassPathEntry的结构体引用,处理的过程中为压缩文件/dex调用了不同的函数,处理dex文件时顺便对齐进行了优化,生成了odex文件。
    
    
                                                                                            ALOGV(“Opening DEX file ‘%s’ (DEX)”, sourceName);
    
                                                                                            pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
                                                                                            pDexOrJar->isDex = true;
                                                                                            pDexOrJar->pRawDexFile = pRawDexFile;
                                                                                            pDexOrJar->pDexMemory = NULL;
                                                                                        } else if (dvmJarFileOpen(sourceName, outputName, &pJarFile, false) == 0) {
                                                                                            ALOGV(“Opening DEX file ‘%s’ (Jar)”, sourceName);
    
                                                                                            pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
                                                                                            pDexOrJar->isDex = false;
                                                                                            pDexOrJar->pJarFile = pJarFile;
                                                                                            pDexOrJar->pDexMemory = NULL;
                                                                                        } else {
                                                                                            ALOGV(“Unable to open DEX file ‘%s’”, sourceName);
                                                                                            dvmThrowIOException(“unable to open DEX file”);
                                                                                        }
                                                                                        ……………
                                                                                    }
    
    
                                                }
    

Note:第0步就是为了生成一个好用的odex文件,第1.1步将odex结构放到内存中以DvmDex存在

在实践中,我们发现并不是所有的dvmDexFileOpenPartial都能断下来,该函数之前用在dex的优化过程中(创建Dexfile结构时),
但 _Z27dexOptGenerateCacheFileNamePKcS0
dvmdexfileopenfromfd3
dvmdexfileparse
这些函数一定能断下来,因为每次应用启动后都会执行dvmRawDexFileOpen。而dvmDexFileOpenPartial常常在有壳时(之所以能断到,是因为这些壳自己调用了dvmdvmDexFileOpenPartial进行优化,。)

可知,其实dalvik很喜欢走的路就是用odex,而不是dex。dex文件只有在odex文件不好用时才去用,这与oat文件一致。


(1.2) 加载APK 包中所有类(不包括代码中动态加载的dex包)

            在 APK 的 ClassLoader 被指定后,APK 包中所有类(不包括代码中动态加载的dex包)都由该 ClassLoader 来加载,我们从 PathClassLoader 的 
                                                    注意:DexClassLoader和PathClassLoader其实都是通过DexFile这个类来实现类加载的。DexFile在加载类时,具体是调用成员方法loadClass(DexClassLoader)或者loadClassBinaryName(PathClassLoader)。
                                                    //PathClassLoader在加载类时调用的是DexFile的loadClassBinaryName************************

                                                                                                 而DexClassLoader调用的是loadClass。************************

                                                                                                DexFile.java

                                                                                                public Class loadClass(String name, ClassLoader loader) 
                                                                                                {  
                                                                                                                        String slashName = name.replace('.', '/');  //因此,在使用PathClassLoader时类全名需要用”/”替换”.”
                                                                                                                        return loadClassBinaryName(slashName, loader);  
                                                                                                }  

                PathClassLoader loadClass 实际进入了  ClassLoader  loadClass

                ClassLoader  loadClass   // loadClass 方法看起,由于 PathClassLoader 并没有复写 loadClass,所以调用的仍是 ClassLoader 类的 loadClass 方法:

                             findClass  //此时在PathClassLoader调用该方法实际是继承自父类BaseDexClassLoader findClass
                                   DexPathList       findClass

                                                   DexFile    dex.loadClassBinaryName
                                                              defineClass
                                                                 defineClassNative
                                                                   dalvik_system_DexFile.cpp      Dalvik_dalvik_system_DexFile_defineClassNative(const u4* args, JValue* pResult)
                                                                    int cookie = args[2];
                                                                                                                                                                                            struct DexOrJar {
                                                                         *******    //此处用到的cookie就是第(1.1)步中ClassLoader构造时创建的         *******                                                                                                        char*       fileName;
                                                                                                                                                                                                bool        isDex;
                                                                                                                                                                                                bool        okayToFree;
                                                                                                                                                                                                RawDexFile* pRawDexFile;
                                                                                                                                                                                                JarFile*    pJarFile;
                                                                                                                                                                                                u1*         pDexMemory; // malloc()ed memory, if any
                                                                                                                                                                                            };
                                                                   DexOrJar* pDexOrJar = (DexOrJar*) cookie;
                                                                        if (pDexOrJar->isDex)
                                                                          DvmDex*   pDvmDex = dvmGetRawDexFileDex(pDexOrJar->pRawDexFile);//RawDexFile* pRawDexFile                         struct RawDexFile {
                                                                                                    return pRawDexFile->pDvmDex;    //从cookie中拿到内存中已经加载好的dex                                                                                                                char*       cacheFileName;
                                                                                                                                                                                                                    DvmDex*     pDvmDex;
                                                                                                                                                                                                                };
                                                                       Class.cpp:dvmDefineClass(pDvmDex, descriptor, loader)

                                                                         Class.cpp:findClassNoInit(onst char* descriptor, Object* loader, DvmDex* pDvmDex)//在findClassNoInit 中执行寻找类、加载类的逻辑,但不会执行初始化类的逻辑
                                                                                      clazz = dvmLookupClass(descriptor, loader, true);  //首先查找指定类加载器加载过的类,如果已经加载,则不会执行加载的逻辑
                                                                                                        found = dvmHashTableLookup(gDvm.loadedClasses, hash, &crit,hashcmpClassByCrit, false);//从已经加载过的类(gDvm.loadedClasses)哈希表中查找该类是否存在


                                                                                                         if (found && !unprepOkay && !dvmIsClassLinked((ClassObject*)found)) {//如果找到匹配了,判断该类是否已经链接,如果已经链接,就是已经被加载了,如果还没有链接,那就仍被认为没找到

                                                                                              dexFindClass(pDvmDex->pDexFile, descriptor)  //如果通过 dvmLookupClass 发现该类没有加载,就会首先通过dexFindClass从加载进来的 Dex 文件中查找该类的定义
                                                                                                     const DexClassLookup* pLookup = pDexFile->pClassLookup;
                                                                                                               if (pLookup->table[idx].classDescriptorHash == hash) {//DexFile::pClassLookup 实际上是在加载 Dex 文件时解析的每个类存储的一个映射表,key 是通过类的说明descriptor计算的哈希,value 是存放解析的类的偏移吗,这一步是计算hash和表的下标;
                                                                                                                                if (strcmp(str, descriptor) == 0) {
                                                                                                                            return (const DexClassDef*) (pDexFile->baseAddr + pLookup->table[idx].classDefOffset);//找到了就返回该类定义 DexClassDef:
                                                                                         clazz = loadClassFromDex(pDvmDex, pClassDef, loader);

                                                                                                    pEncodedData = dexGetClassData(pDexFile, pClassDef);  //拿到 ClassData 的指针;
                                                                                                    dexReadClassDataHeader(&pEncodedData, &header);       //读取 ClassData 的头信息
                                                                                                                                                          //根据前两步拿到的信息loadClass
                                                                                                    loadClassFromDex0(pDvmDex, pClassDef, &header, pEncodedData,   classLoader);

                                                                                                            newClass = (ClassObject*) dvmMalloc(size, ALLOC_NON_MOVING);//分配 ClassObject 对象(newClass)的内存;

                                                                                                            //DVM_OBJECT_INIT(newClass, gDvm.classJavaLangClass); // 初始化 java.lang.Class 成员    初始化该类的 java.lang.Class 成员,每一个 java 对象在 native 层都会对应的 ClassObject 结构体其实都是继承于 Object: 

                                                                                                                 dvmSetFieldObject((Object *)newClass,
                                                                                                                      OFFSETOF_MEMBER(ClassObject, classLoader),
                                                                                                                      (Object *)classLoader); // 初始化 ClassLoader
                                                                                                                newClass->pDvmDex = pDvmDex;
                                                                                                                newClass->primitiveType = PRIM_NOT;                                      //初始化 ClassLoader、Dex对象、加载状态等,此时的状态为 CLASS_IDX,区别于 CLASS_LOADED,CLASS_IDX 状态时 ClassObject 中的成员都不是直接的指针/引用而是数字下标index;
                                                                                                                newClass->status = CLASS_IDX; // 初始化类加载状态为 CLASS_IDX

                                                                                                                newClass->super = (ClassObject*) pClassDef->superclassIdx;   //父类(newClass->super)初始化;

                                                                                                                 pInterfacesList = dexGetInterfacesList(pDexFile, pClassDef);//接口(newClass->interfaces)初始化;


                                                                                                                第六步:静态成员初始化;
                                                                                                                第七步:实例成员初始化;
                                                                                                                第八步:普通函数初始化;
                                                                                                                第九步:虚函数初始化;

第3步才是odex文件自身结构的解析

往后便是FindClass、GetStaticMthodID、CallStaticMthod、dvmInterpretStd解释Dalvik字节码的宏指令

*************************************************************************************************************************************************************************************************************************************8

dalvikvm做的事情:

  1. 将dex从zip文件中拽出来;

  2. dex验证优化 : 读取抽出dex加上odex头 并对dex字节码进行替换修改 ,写入odex辅助信息和依赖库;

  3. dex文件解析: 利用之前的odex头生成odex文件于 /data/dalvik-cache/包名下;

3.解析释放odex到内存以Dvmdex结构存在

  1. 利用之前的cookie中拿到DvmDex 然后进行类加载—-》 找到类,找到方法,指令,最后执行

    第二代脱壳、加固需要搞 pRawDexFile文件在 dalvik_system_DexFile.cpp中 老版360脱壳扣pDvmDex(内存中dex文件)

    脱壳机就是对比较好的系统源码进行hook修改和dump dvmAddClassToHash(clazz)//将加载了的类添加进哈希表 gDvm.loadedClasses 中:以后可以不用加载了

<script type="math/tex; mode=display" id="MathJax-Element-1"></script>
<script type="math/tex; mode=display" id="MathJax-Element-2"></script>
<script type="math/tex; mode=display" id="MathJax-Element-3"></script>
<script type="math/tex; mode=display" id="MathJax-Element-4"></script>
<script type="math/tex; mode=display" id="MathJax-Element-5"></script>
<script type="math/tex; mode=display" id="MathJax-Element-6"></script>
<script type="math/tex; mode=display" id="MathJax-Element-7"></script>
<script type="math/tex; mode=display" id="MathJax-Element-8"></script>
<script type="math/tex; mode=display" id="MathJax-Element-9"></script>
<script type="math/tex; mode=display" id="MathJax-Element-10"></script>
<script type="math/tex; mode=display" id="MathJax-Element-11"></script>
<script type="math/tex; mode=display" id="MathJax-Element-12"></script>
<script type="math/tex; mode=display" id="MathJax-Element-13"></script>
<script type="math/tex; mode=display" id="MathJax-Element-14"></script>
<script type="math/tex; mode=display" id="MathJax-Element-15"></script>
<script type="math/tex; mode=display" id="MathJax-Element-16"></script>
<script type="math/tex; mode=display" id="MathJax-Element-17"></script>
<script type="math/tex; mode=display" id="MathJax-Element-18"></script>
<script type="math/tex; mode=display" id="MathJax-Element-19"></script>
<script type="math/tex; mode=display" id="MathJax-Element-20"></script>
<script type="math/tex; mode=display" id="MathJax-Element-21"></script>
<script type="math/tex; mode=display" id="MathJax-Element-22"></script>
<script type="math/tex; mode=display" id="MathJax-Element-23"></script>
<script type="math/tex; mode=display" id="MathJax-Element-24"></script>
<script type="math/tex; mode=display" id="MathJax-Element-25"></script>
<script type="math/tex; mode=display" id="MathJax-Element-26"></script>
<script type="math/tex; mode=display" id="MathJax-Element-27"></script>
<script type="math/tex; mode=display" id="MathJax-Element-28"></script>
<script type="math/tex; mode=display" id="MathJax-Element-29"></script>
<script type="math/tex; mode=display" id="MathJax-Element-30"></script>
<script type="math/tex; mode=display" id="MathJax-Element-31"></script>
<script type="math/tex; mode=display" id="MathJax-Element-32"></script>
<script type="math/tex; mode=display" id="MathJax-Element-33"></script>
<script type="math/tex; mode=display" id="MathJax-Element-34"></script>
<script type="math/tex; mode=display" id="MathJax-Element-35"></script>

(II) app的新进程–》运行目标APP的ActivityThread线程main方法,然后加载创建Application类

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值