zoukankan      html  css  js  c++  java
  • AndroidNDK——makefile语法详解

    AndroidNDK——makefile语法详解


    一、编译流程详解

    编译流程

    • 编译:将高级语言编写的程序转换为二进制代码可执行性目标程序的过程
    • 四大过程:预处理、编译、汇编、链接

    1、预处理

    完成宏替换、文件引入,以及去除空行、注释等,为下一步的编译做准备;也就是对各种预处理命令进行处理,包括头文件的包含、宏定义的扩展、条件编译的选择等。

    // test.c文件内容
    #include <stdio.h>
    int main(){
        printf("hello world!
    ");
        return 0;
    }

    对test.c文件进行预处理:

    $ gcc -E test.c -o test.i
    • 选项-E:让gcc在预处理结束后停止编译,test.i文件为预处理后输出的文件。
    • 选项-o:指定输出文件。

    此时,test.i 就是 test.c 预编译后的产物,体积会增大,此时test.i还是一个文本文件,可以用文本编译器打开查看。

    2、编译

    • 将预处理后的代码编译成汇编代码。在这个阶段中,首先要检查代码的规范性、是否有语法错误等,以确定代码实际要做的工作,在检查无误后,再把代码翻译成汇编语言。
    • 编译程序执行时,先分析,后综合。分析,就是指词法分析、语法分析、语义分析和中间代码生成。综合,就是指代码优化和代码生成。
    • 大多数的编译程序直接产生机器语言的目标代码,形成可执行的目标文件,也有的是先产生汇编语言一级的符号代码文件,再调用汇编程序进行翻译和加工处理,最后产生可执行的机器语言目标文件。
    extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
    
    extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
    # 868 "/usr/include/stdio.h" 3 4
    
    # 2 "test.c" 2
    
    # 3 "test.c"
    int main(){
     printf("hello world
    ");
     return 0;
    }

    上面是预处理后test.i文件的部分内容,下面对test.i文件进行编译:

    $ gcc -S test.i -o test.s
    • 选项-S:让gcc在编译结束后停止编译过程,"test.s"文件为编译后生成的汇编代码。

    此时,test.s 就是 test.i 文件汇编后的产物,同样也可以用文本编译器打开查看。

    3、汇编

    汇编就是把编译阶段生成的".s"文件转成二进制目标代码,也就是机器代码(01序列)。

    .file   "test.c"
        .text
        .section    .rodata
    .LC0:
        .string "hello world"
        .text
        .globl  main
        .type   main, @function
    main:
    .LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        leaq    .LC0(%rip), %rdi
        call    puts@PLT
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
    .LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
        .section    .note.GNU-stack,"",@progbits

    上面是编译后生成的test.s文件里的汇编代码,下面对test.s文件进行汇编:

    $ gcc -c test.s -o test.o
    • 选项-c:让gcc在汇编结束后停止编译过程,"test.o"文件为汇编后生成的机器码目标文件。

    4、链接

    链接就是将多个目标文件以及所需的库文件链接生成可执行目标文件的过程。

    下面对test.o进行链接:

    $ gcc test.o -o test
    $ ./test
    hello world!
    • 选项-o:本质上是一个重命名选项。不使用-o选项时,默认生成的是a.out文件。这里生成的是可执行文件test。
    • ./test执行后输出hello world!

    5、简化

    一般情况下,我们会使用gcc命令,一步生成可执行文件,简化编译流程:

    $ gcc -o test test.c
    $ ./test
    hello world!

    二、 静态库与动态库原理

    1、 静态库

    1) 什么是静态库

    • 静态库实际就是一些目标文件(一般以.o结尾)的集合,静态库一般以.a结尾,只用于生成可执行文件阶段。
    • 在链接步骤中,链接器将从库文件取得所需代码,复制到生成的可执行文件中。这种库称为静态库。其特点是可执行文件中包含了库代码的一份完整拷贝,在编译过程中被载入程序中。缺点就是多次使用就会有多份冗余拷贝,并且对程序的更新、部署和发布会带来麻烦,如果静态库有更新,那么所有使用它的程序都需要重新编译、发布。

    2) 生成静态库

    • 首先生成test.o目标文件。
    • 使用ar命令将test.o打包成libtest.a静态库。
    # 生成目标文件
    $ gcc -c test.c -o test.o
    # 使用ar命令将目标文件打包成静态库
    $ ar  libtest.a test.o
    ar: creating libtest.a
    # 使用ar t libtest.a 查看静态库内容
    $ar t libtest.a
    test.o

    选项rcs各自的含义:

    • 选项r:更新或增加新文件到静态库中。
    • 选项c:创建一个库,不管存在与否,都创建。
    • 选项s:创建文档索引,在创建较大的库时,能够加快编译速度。

    2、 动态库

    1)什么是动态库

    • 动态库在链接阶段没有被复制到程序中,而是在程序运行时由系统动态加载到内存中供程序调用。
    • 系统只需载入一次动态库,不同的程序可以得到内存中相同动态库的副本,因此节省了很多内存。

    2)生成动态库

    • 首先生成test.o目标文件。
    • 使用-shared和-fPIC参数生成动态库。
    # 首先生成目标文件
    $ gcc -c test.c -o test.o
    # 使用-fPIC和-shared生成动态库
    $ gcc -shared -fPIC -o libtest.so test.o

    fPIC:全称是 Position Independent Code, 用于生成位置无关代码。

    3、案例

    编写一个工具方法(tool.h + tool.c文件),查找出数组的最大值:

    // tool.h 文件
    int find_max(int arr[], int n);
    
    // tool.c 文件
    #include "tool.h"
    int find_max(int arr[], int n){
        int max = arr[0];
        int i;
        for(i = 0; i < n; i++){
            if(arr[i] > max){
                max = arr[i];
            }
        }
        return max;
    }

    在main.c文件中,调用tool.h的find_max函数:

    // main.c 文件
    #include <stdio.h>
    #include "tool.h"
    
    int main(){
        int arr[] = {1,3,5,8,2};
        int max = find_max(arr, 5);
        printf("max = %d
    ", max);
        return 0;
    }

    1)编译&使用静态库

    编译tool静态库:

    # 编译tool.c。可以省略"-o tool.o",默认gcc会生成一个与tool.c同名的.o文件。
    $ gcc -c tool.c
    
    # 编译生成libtool.a静态库
    $ ar rcs libtool.a tool.o
    
    # 编译main可执行文件。
    # -l用来指定要链接的库,后面接库的名字;-L表示编译程序根据指定路径寻找库文件。
    $ gcc -o main main.c -L. -ltool
    
    $ ./main
    max = 8

    可以用ldd命令查看main文件依赖了哪些库:

    $ ldd main

    AndroidNDK——makefile语法详解

    2)编译&使用动态库

    # 编译tool.c,生成tool.o
    $ gcc -c tool.c
    
    # 编译生成libtool.so动态库
    $ gcc -shared -fPIC -o libtool.so tool.o
    
    # 编译main可执行文件
    $ gcc -o main main.c -L. -ltool
    
    $ ./main
    ./main: error while loading shared libraries: libtool.so: cannot open shared object file: No such file or directory

    注意,当静态库与动态库同名时,gcc会优先加载动态库。即,此时目录下即有libtool.a,又有libtool.so,编译main时指定了-ltool,gcc会链接libtool.so!

    可以用ldd命令查看main文件依赖了哪些库:

    $ ldd main

    AndroidNDK——makefile语法详解

    可以看到,libtool.so找不到,这是因为在系统的默认动态链接库路径下没有这个libtool.so文件,可以在执行之前,给main设置环境变量解决:

    # 将当前目录设置到环境变量中
    $ LD_LIBRARY_PATH=. ./main
    max = 8

    LD_LIBRARY_PATH 指定查找共享库,即动态链接库时,除默认路径以外,其他的路径。

    4、区别总结

    载入时刻不同:

    静态库
    动态库

    三、makefile走读与语法基础

    1、makefile是什么

    在一个工程中,源文件很多,按类型、功能、模块分别被存放在若干个目录中,需要按一定的顺序、规则进行编译,这时就需要使用到makefile。

    • makefile定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要重新编译,如何进行链接等操作。
    • makefile就是“自动化编译”,告诉make命令如何编译和链接。

    makefile是make工具的配置脚本,默认情况下,make命令会在当前目录下去寻找该文件(按顺序找寻文件名为 “GNUmakefile” 、 “makefile” 、 “Makefile” 的文件)。

    在这三个文件名中,最好使用“Makefile”这个文件名,因为,这个文件名第一个字符为大写,这样有一种显目的感觉。

    最好不要用“GNUmakefile”,这个文件是GNU的make识别的。有另外一些make只对全小写的“makefile”文件名敏感。

    但是基本上来说,大多数的make都支持“makefile”和“Makefile”这两种默认文件名。

    当然,配置文件的文件名也可以不是makefile,比如:config.debug,这时需要通过 -f 或 --file 指定配置文件,即:

    # 使用-f
    $ make -f config.debug
    # 使用--file
    $ make --file config.debug

    2、makefile里有什么

    makefile包含以下五个:

    显示规则
    隐晦规则
    变量定义
    文件指示
    注释

    3、makefile的规则

    target ... : prerequisites ...
        command
    或者:
    target ... : prerequisites ... ; command

    若prerequisites与command在同一行,需要用 ; 分隔。

    若prerequisites与command不在同一行,则command前面需要用tab键开头。

    另外,如果命令太长,可以用 作为换行符。

    • target:目标文件。可以是ObjectFile,也可以是执行文件,还可以是标签(Label);如果有多个文件,可以用空格隔开;可以使用通配符。
    • prerequisites:依赖文件,既要生成那个target所需要的文件或其他target。
    • command:make需要执行的命令。

    makefile的作用:

    告诉make,文件的依赖关系,以及如何生成目标文件。prerequisites中,如果有一个及以上的文件比target要新的话,target就会被认为是过时的,需要重新生成,command就会被执行,从而生成新的target。

    4、makefile示例

    # 当前目录存在main.c、tool.c、tool.h三个文件
    # 下面是makefile文件内容
    main: main.o tool.o
        gcc main.o tool.o -o main
    .PHONY: clean
    clean:
        -rm main *.o
    -----------------------------
    // 执行 make 后输出如下:
    cc  -c -o main.o main.c
    cc  -c -o tool.o tool.c
    gcc main.o tool.o -o main
    // 并且生成了一个可执行文件main
    -o
    clean
    .PHONY

    make会自动推导main.o、tool.o如何生成。

    伪目标的名字不能和文件名重复,即当前目录下,不能有clean文件。

    可以通过 make clean 执行删除命令。

    AndroidNDK——makefile语法详解

    5、makefile如何工作

    默认方式下,输入make命令后:

      • make会在当前目录下找名字叫“Makefile”或“makefile”的文件。
      • 如果找到,它会找文件中第一个目标文件(target),并把这个target作为最终的目标文件,如前面示例中的“main”。
      • 如果main文件不存在,或main所依赖的.o文件的修改时间要比main文件要新,那么它会执行后面所定义的命令来生成main文件。
    • 如果main所依赖的.o文件也存在,那么main会在当前文件中找目标为.o文件的依赖性,若找到则根据规则生成.o文件。
    • make再用.o文件声明make的终极任务,也就是执行文件“main”。

    6、makefile中使用变量

    objects = main.o tool.o
    main: $(objects)
        gcc $(objects) -o main
    .PHONY: clean
    clean:
        -rm main $(objects)
    -----------------------------
    // 执行 make 后输出如下:
    cc  -c -o main.o main.c
    cc  -c -o tool.o tool.c
    gcc main.o tool.o -o main
    • 为了makefile的易维护,在makefile中我们可以使用变量。makefile的变量也就是一个字符串,理解成C语言中的宏可能会更好。
    • 比如:我们声明一个变量,叫 objects ,于是,我们就可以很方便地在我们的makefile中以“ $(objects) ”的方式来使用这个变量了。

    7、makefile中引用其他的makefile

    # 语法格式
    include <filename>
    
    # 举个例子,你有这样几个 Makefile:a.mk、b.mk、c.mk,还有一个文件叫 # foo.make,以及一个变量$(bar),其包含了 e.mk 和 f.mk
    
    include foo.make *.mk $(bar)
    # 等价于:
    include foo.make a.mk b.mk c.mk e.mk f.mk
    
    # 如果文件找不到,而你希望make时不理会那些无法读取的文件而继续执行
    # 可以在include前加一个减号“-”,如:
    -include <filename>

    使用include关键字可以把其它Makefile包含进来,include语法格式:

    include <filename>

    8、环境变量MAKEFILES

    MAKEFILES

    如果当前环境中字义了环境变量 MAKEFILES,那么,make会把这个变量中的值做一个类似于 include 的动作。这个变量中的值是其它的 Makefile,用空格分隔。只是,它和include不同的是,从这个环境中引入的Makefile的“目标”不会起作用,如果环境变量中定义的文件发现错误,make也会不理。但是建议不要使用这个环境变量,因为只要这个变量一被定义,那么当你使用make时,所有的Makefile都会受到它的影响。

    也许有时候Makefile出现了奇怪的事,那么可以查看当前环境中有没有定义这个变量。

    9、Makefile预定义变量

    变量名描述默认值
    CC C语言编译器的名称 cc
    CPP C语言预处理器的名称 $(CC) -E
    CXX C++语言编译器的名称 g++
    RM 删除文件程序的名称 rm -f
    CFLAGS C语言编译器的编译选项
    CPPFLAGS C语言预处理器的编译选项
    CXXFLAGS C++语言编译器的编译选项

    10、Makefile自动变量

    自动变量描述
    $* 目标文件的名称,不包含扩展名
    $@ 目标文件的名称,包含扩展名
    $+ 所有的依赖文件,以空格隔开,可能含有重复的文件
    $^ 所有的依赖文件,以空格隔开,不重复
    $< 依赖项中第一个依赖文件的名称
    $? 依赖项中所有比目标文件新的依赖文件

    11、Makefile函数

    define本质是定义一个多行的变量,没办法直接调用,但可以在call的作用下,当作函数来使用。

    不带参数

    define FUNC
    $(info echo "hello")
    endef
    
    $(call FUNC)
    --------------------
    输出:hello

    带参数

    define FUNC1
    $(info echo $(1)$(2))
    endef
    
    $(call FUNC1,hello,world)
    --------------------
    输出:hello world

    12、make的工作流程

    GNU的make工作时的执行步骤如下:

    1. 读入所有的Makefile。
    2. 读入被include的其它Makefile。
    3. 初始化文件中的变量。
    4. 推导隐晦规则,并分析所有规则。
    5. 为所有的目标文件创建依赖关系链。
    6. 根据依赖关系,决定哪些目标要重新生成。
    7. 执行生成命令。

    1 5是第一阶段,6 7为第二阶段。在第一阶段中,如果定义的变量被使用了,那么make会把变量展开在使用的位置,但是make并不是完全的马上展开,如果变量出现在依赖关系的规则中,那么只有当这条依赖被决定要使用的时候,变量才会被展开。

    三、Android.mk基础

    1、Android.mk简介

    Android.mk是一个向Android NDK构建系统描述NDK项目的GNU makefile片段。主要用来编译生成以下几种:

    • APK程序:一般的Android应用程序,系统级别的直接push即可。
    • JAVA库:Java类库,编译打包生成JAR文件。
    • CC++应用程序:可执行的CC++应用程序。
    • CC++静态库:编译生成CC++静态库,并打包成.a文件。
    • CC++共享库:编译生成共享库,并打包成.so文件。

    2、Android.mk基本格式

    这是一个简单的Android.mk文件的内容:

    # 定义模块当前路径(必须定义在文件开头,只需定义一次)
    LOCAL_PATH := $(call my-dir)
    
    # 清空当前环境变量(LOCAL_PATH除外)
    include $(CLEAR_VARS)
    
    # 当前模块名(这里会生成libhello-jni.so)
    LOCAL_MODULE := hello-jni
    
    # 当前模块包含的源代码文件
    LOCAL_SRC_FILES := hello-jni.c
    
    # 表示当前模块将被编译成一个共享库
    include $(BUILD_SHARED_LIBRARY)
    • my-dir:是由编译系统提供的宏函数,返回当前.mk文件的路径。
    • CLEAR_VARS:是由编译系统提供的变量,指向一个特定的GNU makefile片段,可以清除除了LOCAL_PATH以外的以 LOCAL_ 开头的变量,如: LOCAL_MODULE 、 LOCAL_SRC_FILES 。这样做是因为编译系统在单次执行中,会解析多个构建文件和模块定义,而以 LOCAL_ 开头的变量是全局变量,所以描述每个模块之前,都会声明 CLEAR_VARS 变量,可以避免冲突。
    • LOCAL_MODULE:定义当前模块名,模块名必须唯一,而且不能包含空格。模块名为"hello-jni"时,会生成libhello-jni.so,如果模块名为"libhello-jni"时,则生成的还是libhello-jni.so!
    • LOCAL_SRC_FILES:当前模块包含的源文件,当源文件有多个时,用空格隔开。

    3、编译多个共享库

    一个Android.mk可能编译产生多个共享库模块。

    LOCAL_PATH := $(call my-dir)
    
    # 模块1
    include $(CLEAR_VARS)
    LOCAL_MODULE := module1
    LOCAL_SRC_FILES := module1.c
    include $(BUILD_SHARED_LIBRARY)
    
    # 模块2
    include $(CLEAR_VARS)
    LOCAL_MODULE := module2
    LOCAL_SRC_FILES := module2.c
    include $(BUILD_SHARED_LIBRARY)

    这里会产生libmodule1.so和libmodule2.so两个动态库。

    4、编译静态库

    虽然Android应用程序不能直接使用静态库,但静态库可以用来编译动态库。比如在将第三方代码添加到原生项目中时,可以不用直接将第三方源码包括在原生项目中,而是将第三方源码编译成静态库,然后并入共享库。

    LOCAL_PATH := $(call my-dir)
    
    # 第三方AVI库
    include $(CLEAR_VARS)
    LOCAL_MODULE := avilib
    LOCAL_SRC_FILES := avilib.c platform_posix.c
    include $(BUILD_STATIC_LIBRARY)
    
    # 原生模块
    include $(CLEAR_VARS)
    LOCAL_MODULE := module
    LOCAL_SRC_FILES := module.c
    # 将静态库模块名添加到LOCAL_STATIC_LIBRARIES变量
    LOCAL_STATIC_LIBRARIES := avilib
    include $(BUILD_SHARED_LIBRARY)

    5、使用共享库共享通用模块

    静态库可以保证源代码模块化,但是当静态库与共享库相连时,它就变成了共享库的一部分。在多个共享库的情况下,多个共享库与静态库连接时,需要将通用模块的多个副本与不同的共享库重复相连,这样就增加了APP的大小。这种情况,可以将通用模块作为共享库。

    LOCAL_PATH := $(call my-dir)
    
    # 第三方AVI库
    include $(CLEAR_VARS)
    LOCAL_MODULE := avilib
    LOCAL_SRC_FILES := avilib.c platform_posix.c
    include $(BUILD_SHARED_LIBRARY)
    
    # 原生模块1
    include $(CLEAR_VARS)
    LOCAL_MODULE := module1
    LOCAL_SRC_FILES := module1.c
    LOCAL_SHARED_LIBRARIES := avilib
    include $(BUILD_SHARED_LIBRARY)
    
    # 原生模块2
    include $(CLEAR_VARS)
    LOCAL_MODULE := module2
    LOCAL_SRC_FILES := module2.c
    LOCAL_SHARED_LIBRARIES := avilib
    include $(BUILD_SHARED_LIBRARY)

    以上的做法必须基于同一个NDK项目。

    6、在多个NDK项目间共享模块

    • 首先将avilib源代码移动到NDK项目以外的位置,比如: C:androidshared-modules ranscodeavilib 。
    • 作为共享库模块,avilib需要有自己的Android.mk文件。
    • 以 transcode/avilib 为参数调用函数宏 import-module 添加到NDK项目的Android.mk文档末尾。

    import-module 函数宏在NDK版本r5以后才有。

    # avilib模块自己的Android.mk文件
    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    LOCAL_MODULE := avilib
    LOCAL_SRC_FILES := avilib.c platform_posix.c
    include $(BUILD_SHARED_LIBRARY)
    ---------------------------------------------
    # 使用共享模块的NDK项目1的Android.mk文件
    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    LOCAL_MODULE := module1
    LOCAL_SRC_FILES := module1.c
    LOCAL_SHARED_LIBRARIES := avilib
    include $(BUILD_SHARED_LIBRARY)
    $(call import-module,transcode/avilib)
    ---------------------------------------------
    # 使用共享模块的NDK项目2的Android.mk文件
    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    LOCAL_MODULE := module2
    LOCAL_SRC_FILES := module2.c
    LOCAL_SHARED_LIBRARIES := avilib
    include $(BUILD_SHARED_LIBRARY)
    $(call import-module,transcode/avilib)

    当心细的你在看到 $(call import-module,transcode/avilib) 这句时,一定会问,为什么NDK会知道要去 C:androidshared-modules 目录下面找 transcode/avilib 呢?是的,NDK并没有这么智能,默认情况下, import-module 函数宏只会搜索AndroidNDK下面的sources目录。

    如我的NDK路径是: C:UserslqrAppDataLocalAndroidSdk dk-bundle ,那么 import-module 函数宏默认的寻找目录就是 C:UserslqrAppDataLocalAndroidSdk dk-bundlesources

    要正确使用 import-module ,就需要对NDK_MODULE_PATH进行配置,把 C:androidshared-modules 配置到环境变量中即可,当有多个共享库目录时,用 ; 隔开。

    更多关于import-module的介绍,请翻到文末查看。

    7、使用预编译库

    • 想在不发布源代码的情况下将模块发布给他人。
    • 想使用共享库模块的预编译版来加速编译过程。

    现在我们手上有第三方预编译好的库libavilib.so,想集成到自己项目中使用,则需要在Android.mk中进行如下配置:

    # 预编译共享模块的Android.mk文件
    LOCAL_PATH := $(call my-dir)
    # 第三方预编译的库
    include $(CLEAR_VARS)
    LOCAL_MODULE := avilib
    LOCAL_SRC_FILES := libavilib.so
    include $(PREBUILT_SHARED_LIBRARY)

    可以看到, LOCAL_SRC_FILES 指向的不再是源文件,而是预编译好的libavilib.so,相对于LOCAL_PATH的位置。

    8、编译独立的可执行文件

    为了方便测试和进行快速开发,可以编译成可执行文件。不用打包成APK就可以得到到Android设备上直接执行。

    # 独立可执行模块的Android.mk文件
    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    LOCAL_MODULE := module
    LOCAL_SRC_FILES := module.c
    LOCAL_STATIC_LIBRARIES := avilib
    include $(BUILD_EXECUTABLE)

    9、注意事项

    假如我们本地库libhello-jni.so依赖于libTest.so(可以使用NDK下的ndk-depends查看so的依赖关系)。

    • 在Android6.0版本之前,需要在加载本地库前先加载被依赖的so。
    • 在Android6.0版本之后,不能再使用预编译的动态库(静态库没问题)。
    // Android 6.0版本之前:
    System.loadlibrary("Test");
    System.loadlibrary("hello-jni");
    
    // Android 6.0版本之后:  
    System.loadlibrary("hello-jni");

    四、附加

    1、import_module 详解

    以下内容引用自 《import-module的注意事项与NDK_MODULE_PATH的配置》

    AndroidNDK——makefile语法详解

    AndroidNDK——makefile语法详解

    查看原文: AndroidNDK——makefile语法详解

  • 相关阅读:
    js分享插件
    json格式
    事物TransactionScope
    CheckBox全选、取消全选
    JQuery中的prop和attr
    [转]javascript之数组操作
    pcntl_fork()函数说明
    从库因为sql错误导致主从同步被中断的问题解决
    查看进程的命令ps
    给mysql创建用户
  • 原文地址:https://www.cnblogs.com/it-tsz/p/12325258.html
Copyright © 2011-2022 走看看