gcov代码覆盖率测试-原理和实践总结

https://blog.csdn.net/yanxiangyfg/article/details/80989680
阅读:https://github.com/yanxiangyfg/gcov
gcov源码:http://www.opensource.apple.com/source/gcc/gcc-5484/gcc/gcov.c

一、gcov简介

gcov是什么

  • gcov是一个测试代码覆盖率的工具。与GCC一起使用来分析程序,以帮助创建更高效、更快的运行代码,并发现程序的未测试部分
  • 是一个命令行方式的控制台程序。需要结合lcov,gcovr等前端图形工具才能实现统计数据图形化
  • 伴随GCC发布,不需要单独下载gcov工具。配合GCC共同实现对c/c++文件的语句覆盖和分支覆盖测试
  • 与程序概要分析工具(profiling tool,例如gprof)一起工作,可以估计程序中哪段代码最耗时

gcov能做什么

使用象gcov或gprof这样的分析器,您可以找到一些基本的性能统计数据:
* 每一行代码执行的频率是多少
* 实际执行了哪些行代码,配合测试用例达到满意的覆盖率和预期工作
* 每段代码使用了多少计算时间,从而找到热点优化代码
* gcov创建一个sourcefile.gcov的日志文件,此文件标识源文件sourcefile.c每一行执行的次数,您可以与gprof一起使用这些日志文件来帮助优化程序的性能。gprof提供了您可以使用的时间信息以及从gcov获得的信息。

注意事项

  • 通过将一些代码行合并到一个函数中,可能不会提供足够的信息来查找代码使用大量计算机时间的“热点”。同样地,由于gcov按行(在最低的分辨率下)积累统计数据,它最适合于只在每行上放置一个语句的编程风格。如果您使用扩展到循环或其他控制结构的复杂宏,那么统计信息就没有那么有用了——它们只报告出现宏调用的行。如果您的复杂宏的行为类似于函数,那么您可以用inline fu替换它们。
  • gcov只在使用GCC编译的代码上工作。它与任何其他概要或测试覆盖机制不兼容。

二、gcov过程概况

<主要工作流>
  • 1) 编译前,在编译器中加入编译器参数-fprofile-arcs -ftest-coverage
  • 2) 源码经过编译预处理,然后编译成汇编文件,在生成汇编文件的同时完成插桩。插桩是在生成汇编文件的阶段完成的,因此插桩是汇编时候的插桩,每个桩点插入3~4条汇编语句,直接插入生成的*.s文件中,最后汇编文件汇编生成目标文件,生成可执行文件;并且生成关联BB和ARC的.gcno文件;
  • 3) 执行可执行文件,在运行过程中之前插入桩点负责收集程序的执行信息。所谓桩点,其实就是一个变量,内存中的一个格子,对应的代码执行一次,则其值增加一次;
  • 4) 生成.gcda文件,其中有BB和ARC的执行统计次数等,由此经过加工可得到覆盖率。

三、使用gcov的3个阶段

1. 编译阶段

要开启gcov功能,需要在源码编译参数中加入-fprofile-arcs -ftest-coverage
* -ftest-coverage:在编译的时候产生.gcno文件,它包含了重建基本块图和相应的块的源码的行号的信息。
* -fprofile-arcs:在运行编译过的程序的时候,会产生.gcda文件,它包含了弧跳变的次数等信息。

如下以helloworld_gcov.c为例子,源码如下:

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{   
    if (argc >=2) {
        printf("=====argc>=2\n");
        return;
    }
    printf("helloworld begin\n");

    if (argc <2){
        printf("=====argc<2\n");
        return;
    }
    return;
}

  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

helloworld_gcov.c的Makefile的书写如下,在编译选项CFLAGS中加入-fprofile-arcs -ftest-coverage选项:

#加入gcov编译选项,通过宏PRJRELEASE=gcov控制
ifeq ("$(PRJRELEASE)","gcov")
CFLAGS+= -fprofile-arcs -ftest-coverage
endif

CC=gcc

.PHONE: all

all: helloworld

helloworld: *.c
#   编译出汇编和gcno文件
    @echo ${CFLAGS}
    @${CC} ${CFLAGS} -S -o helloworld_gcov.s helloworld_gcov.c 
    @${CC} ${CFLAGS} -o helloworld_gcov helloworld_gcov.c 

.PHONE: clean
clean:
    @-rm helloworld_gcov helloworld_gcov.gcno helloworld_gcov.gcda helloworld_gcov.c.gcov helloworld_gcov.s
  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 在helloworld目录下执行make命令后,产生helloworld_gcov.s,helloworld_gcov helloworld_gcov.gcno. helloworld_gcov.gcno只要源码不变,编译出来永远不改变.
  • 运行gcov helloworld_gcov.c命令产生原始的代码覆盖率数据文件helloworld_gcov.c.gcov, 由于此时没有运行./helloworld_gcov,没有helloworld_gcov.gcda统计数据,覆盖率为0

2. gcov收集代码运行信息

  • 运行./helloworld_gcov产生helloworld_gcov.gcda文件,其中包含了代码基本块和狐跳变次数统计信息

3. 生成gcov代码覆盖率报告

  • 再次运行gcov helloworld_gcov.c产生的helloworld_gcov.c.gcov中包含了代码覆盖率数据,其数据的来源为helloworld_gcov.gcda
  • 为了对比运行./helloworld_gcov前后的覆盖率数据文件helloworld_gcov.c.gcov信息,直接执行如下脚本,产生前后数据对比

$ make    #编译
$ gcov helloworld_gcov.c          #生成原始的helloworld_gcov.c.gcov文件
$ cp helloworld_gcov.c.gcov helloworld_gcov.c.gcov-old            #备份好原始的helloworld_gcov.c.gcov文件,方便后续对比
$ cp helloworld_gcov.gcno helloworld_gcov.gcno-old                #备份好原始的helloworld_gcov.gcno文件,方便后续对比
$ ./helloworld_gcov                   #产生helloworld_gcov.gcda文件,记录的代码运行的统计数据
$ gcov helloworld_gcov.c              #根据gcda文件,再次生成helloworld_gcov.c.gcov文件


#最后显示如下,可以对比先后的gcov文件,前后汇编文件.

yangfogen@ubuntu:~/work/helloworld_gcov$ ls
helloworld_gcov    helloworld_gcov.c.gcov      helloworld_gcov.gcda  helloworld_gcov.gcno-old  helloworld_gcov.s
helloworld_gcov.c  helloworld_gcov.c.gcov-old  helloworld_gcov.gcno  helloworld_gcov-gcov.s    Makefile
  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

  • 其中#####表示未运行的行
  • 每行前面的数字表示行运行的次数
  • 上述生成的.c.gcov文件可视化成都较低,需要借助lcov,genhtml工具直接生成html报告。

    • 根据.gcno .gcda文件生成图形化报告

      包括运行mysql都尽量使用sudo,否则有些文件会打不开
      先运行:(helloworld_gcov.info为生成的文件数据集)
      $ sudo lcov -c -d . -o helloworld_gcov0604.info --rc lcov_branch_coverage=1
      再运行:(helloworld_gcov.info为输入的文件数据集)生成html
      $ sudo genhtml -o 111 helloworld_gcov.info

  • lcov生成覆盖率常用命令
    生成全量覆盖率
    lcov -b <测试代码路径> -d <gcda目录位置> -c -o result.info --rc lcov_branch_coverage=1
    参数比较多,一个个说
    -b为指定原代码路径,即生成gcno数据时编译的代码路径
    -d为gcda所在目录,可以将所有的gcda放置在一个目录中
    -c代表生成覆盖率数据,后面不用给其他参数
    -o指定生成的文件名,这里指定文件为当前目录下的result.info
    –rc lcov_branch_coverage=1表示包含分支数据

    Merg多份覆盖率数据
    lcov -a phase1.info -a phase2.info -o out.info
    其中phase1.info以及phase2.info为独立了的两份覆盖率数据,他们整合为一份out.info

    生成html格式的覆盖率报告
    genhtml -o result out.info
    其中out.info为要解析的覆盖率数据文件,生成的网页会放在result目录下

    genhtml失败时:(https://stackoverflow.com/questions/30345686/c-using-gcov-lcov-in-a-cmake-project/54900625#54900625)
    try to put --ignore-errors with genhtml command like that :
    genhtml -o out name.info --ignore-errors source


    4. 产生覆盖率文件,去除不需要的文件(include),或者包含需要的(source)

    转自:https://blog.csdn.net/hanshuai584044490/article/details/83374617
    问题:在产生了.gcno 和 .gcda两个文件后,使用lcov -c -d Debug/source/ -o Debug/coverage.info 产生中间文件coverage.info文件,然后用genhtml -o output/ Debug/coverage.info产生html文件,发现产生的index.xml文件包含了include,甚至/usr/*下的公共头文件,怎么去除这些不需要统计覆盖率的文件?

    1。正向提取需要的文件:

    //比如希望把source相关的路径提取出来

    lcov --extract Debug/coverage.info ‘source/’ -o Debug/finalresult.info

    2。反向去除不需要的文件:

    //比如希望去除UnitTest 和/usr/相关文件:

    lcov --remove Debug/coverage.info ‘UnitTest/’ ‘/usr/*’ -o Debug/finalresult.info

    使用:

    sudo lcov --remove helloworld_gcov0604.info  '/usr/*' 'Build/*' 'dbug/*' 'include/boost_1_59_0/*' '*/boost_1_59_0/*' -o remove06042.info
    

    使用过后,.info文件中也没有重复的行了!!!



    此处参考: https://blog.csdn.net/kevin_ji/article/details/50885680


    四、gcov检测代码覆盖率的原理

    原理概述

    Gcc中指定-ftest-coverage 等覆盖率测试选项后,gcc 会:
    * 在输出目标文件中留出一段存储区保存统计数据
    * 在源代码中每行可执行语句生成的代码之后附加一段更新覆盖率统计结果的代码,也就是前文说的插桩
    * 在最终可执行文件中进入用户代码 main 函数之前调用 gcov_init 内部函数初始化统计数据区,并将gcov_exit 内部函数注册为 exit handlers用户代码调用 exit 正常结束时,gcov_exit 函数得到调用,其继续调用 __gcov_flush 函数输出统计数据到 *.gcda 文件中

    说了这么多,其实还是很模糊,这里有几个要点需要深入
    • 怎么计算统计数据的?
    • gcov怎样插桩来更新覆盖率数据的
    • gcov_initgcov_exit怎样放到编译的可执行文件中的
    • gcno和gcda文件格式是咋样的

    只有把这几个问题搞明白了,才算真正搞懂gcov的原理.那么下面就来好好分析这几个问题

    1. gcov数据统计原理(即:gcov怎么计算统计数据的)

    gcov是使用 基本块BB跳转ARC 计数,结合程序流图来实现代码覆盖率统计的:

    • 1.基本块BB

    如果一段程序的第一条语句被执行过一次,这段程序中的每一个都要执行一次,称为基本块。一个BB中的所有语句的执行次数一定是相同的。一般由多个顺序执行语句后边跟一个跳转语句组成。所以一般情况下BB的最后一条语句一定是一个跳转语句,跳转的目的地是另外一个BB的第一条语句,如果跳转时有条件的,就产生了分支,该BB就有两个BB作为目的地。

  • 2.跳转ARC
  • 从一个BB到另外一个BB的跳转叫做一个arc,要想知道程序中的每个语句和分支的执行次数,就必须知道每个BB和ARC的执行次数

  • 3. 程序流图
  • 如果把BB作为一个节点,这样一个函数中的所有BB就构成了一个有向图。,要想知道程序中的每个语句和分支的执行次数,就必须知道每个BB和ARC的执行次数。根据图论可以知道有向图中BB的入度和出度是相同的,所以只要知道了部分的BB或者arc大小,就可以推断所有的大小。

    bb-arc-程序流图

    这里选择由arc的执行次数来推断BB的执行次数。

    所以对部分 ARC插桩,只要满足可以统计出来所有的BB和ARC的执行次数即可。

    2. gcov怎样插桩来更新覆盖率数据的

    当打开gcov编译选项是,在汇编阶段,插桩就已经完成,这里引用写的很好的一篇文章来说明:

    https://github.com/yanxiangyfg/gcov

    4. gcno和gcda文件格式

    https://github.com/tejainece/gcov


    五、服务程序覆盖率统计

    • 从 gcc coverage test 实现原理可知,若用户进程并非调用 exit 正常退出,覆盖率统计数据就无法输出,也就无从生成报告了。
    • 后台服务程序一旦启动就很少主动退出,用 kill 杀死进程强制退出时就不会调用 exit,因此没有覆盖率统计结果产生。

    为了解决这个问题,我们可以给待测程序增加一个 signal handler,拦截 SIGHUP、SIGINT、SIGQUIT、SIGTERM 等常见强制退出信号,并在 signal handler 中主动调用 exit 或 __gcov_flush 函数输出统计结果即可。

    该方案仍然需要修改待测程序代码,不过借用动态库预加载技术和 gcc 扩展的 constructor 属性,我们可以将 signalhandler 和其注册过程都封装到一个独立的动态库中,并在预加载动态库时实现信号拦截注册。这样,就可以简单地通过如下命令行来实现异常退出时的统计结果输出了:

    LD_PRELOAD=./libgcov_preload.so ./helloworld_server
    
    #或者:
    echo "/sbin/gcov_preload.so" >/etc/ld.so.preload
    ./helloworld_server
      
      
    • 1
    • 2
    • 3
    • 4
    • 5

    其中__attribute__ ((constructor))是gcc的符号,它修饰的函数会在main函数执行之前调用,我们利用它把异常信号拦截到我们自己的函数中. 【注:具体代码请看文章后面的例子章节】

    测试完毕后可直接 kill 掉 helloworld_server 进程,并获得正常的统计结果文件 *.gcda。

    六、内核和模块的gcov代码覆盖率测试

    • 从Linux内核2.6.31开始,gcov-kernel是Linux内核的一部分,可以不使用额外的补丁
    • 启用gcov-kernel配置选项:

      CONFIG_DEBUG_FS=y
      CONFIG_GCOV_KERNEL=y
      CONFIG_GCOV_PROFILE_ALL=y #获取内核数据覆盖率
      CONFIG_GCOV_FORMAT_AUTODETECT=y #选择gcov的格式
    • 编译,安装,启动内核,然后挂载debugfs: mount -t debugfs none /sys/kernel/debug
    • 内核相关文件介绍

    
    #支持gcov的内核在debugfs中创建如下几个文件或文件夹
    
    
    
    #所有gcov相关文件的父目录
    
    /sys/kernel/debug/gcov
    
    
    #全局重置文件:在写入时将所有覆盖率数据重置为零
    
    /sys/kernel/debug/gcov/reset
    
    
    #gcov工具理解的实际gcov数据文件。当写入文件时,将文件覆盖率数据重置为零
    
    /sys/kernel/debug/gcov/path/to/compile/dir/file.gcda
    
    
    #gcov工具所需的静态数据文件的符号链接。这个文件是gcc在编译时生成的, 选项:-ftest-coverage
    
    /sys/kernel/debug/gcov/path/to/compile/dir/file.gcno
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    需要注意的是/sys/kernel/debug 文件夹是一个临时文件夹,不存在于磁盘当中,是在内存当中存在的,其中的文件也是系统运行是动态产生的

    七、lcov工具使用

    • 安装lcov工具, 以ubuntu为例子: sudo apt install lcov,用于使gcno和gcda文件生成info覆盖率统计文件.
    • 在home目录下创建一个~/.lcovrc文件,并加入一行geninfo_auto_base = 1,用于消除ERROR: could not read source file错误

    八、info文件格式信息

    lcov生成的.info文件包含一个或多个源文件所对应的覆盖率信息,一个源文件对应一条“记录”,“记录”中的详细格式如下

    TN: <Test name> 表示测试用例名称,即通过geninfo中的--test-name选项来命名的测试用例名称,默认为空;
    
    SF: <File name> 表示带全路径的源代码文件名;
    
    FN: <函数启始行号>, <函数名>; <函数有效行总数>; <函数有效行总数中被执行个数>
    
    FNDA: <函数被执行的次数>, <函数名>; <函数有效行总数>; <函数有效行总数中被执行个数>
    
    FNF: <函数总数>
    
    FNH: <函数总数中被执行到的个数> BRDA: <分支所在行号>, <对应的代码块编号>, <分支编号>, <执行的次数> BRF: <分支总数> BRH: <分支总数中被执行到的个数> DA: <代码行号>, <当前行被执行到的次数> LF: < counts> 代码有效行总数 LH: <counts> 代码有效行总数中被执行到的个数 end_of_record 一条“记录”结束符
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    九、例子

    1. 合并不同用例的代码覆盖率

    #include <stdio.h>
    #include <string.h>
    
    int main(int argc, char *argv[])
    {   
        if (argc >=2) {
            printf("=====argc>=2\n");
            return;
        }
        printf("helloworld begin\n");
    
        if (argc <2){
            printf("=====argc<2\n");
            return;
        }
        return;
    }
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    简单编写的Makefile如下:

    .PHONE: all
    all: helloworld
    
    CFLAGS+= -fprofile-arcs -ftest-coverage
    CC=gcc
    
    helloworld: *.c
        @echo ${CFLAGS}
        @${CC} ${CFLAGS} -o helloworld helloworld_gcov.c
    
    .PHONE: clean
    clean:
        @-rm helloworld 
    
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    单独产生同一个程序不同用例的info并合并

    make
    #运行两个参数用例并产生info文件和html文件
    ./helloworld  i 2
    lcov -c -d . -o helloworld2.info
    genhtml -o 222 helloworld2.info
    
    #运行无参数用例并产生info文件和html文件
    rm helloworld_gcov.gcda 
    ./helloworld
    lcov -c -d . -o helloworld1.info
    genhtml -o 111 helloworld1.info 
    
    #合并两个用例产生的info文件,输出同一个模块不同用例的总的统计数据
    genhtml -o 333 helloworld1.info helloworld2.info 
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    2. 服务程序无exit时产生gcda文件的方法

    helloworld_server_gcov.c的代码:
    #include <stdio.h>
    #include <string.h>
    #include <unistd.h>
    
    #include <stdlib.h>  
    #include <dlfcn.h>  
    #include <signal.h>  
    #include <errno.h>  
    
    int main(int argc, char *argv[])
    {   
        if (argc >=2) {
            printf("=====argc>=2\n");
        }
        printf("helloworld begin\n");
    
        if (argc <2){
            printf("=====argc<2\n");
        }
    
        while(1){
    
            printf("this is the server body");
            sleep(5);
        }
        return 0;
    }
    
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    编译helloworld_server_gcov.c的Makefile:
    ifeq ("$(PRJRELEASE)","gcov")
    CFLAGS+= -fprofile-arcs -ftest-coverage
    endif
    
    CC=gcc
    
    .PHONE: all
    
    all: helloworld
    
    helloworld: *.c
            @echo ${CFLAGS}
            @${CC} ${CFLAGS} -o helloworld_server helloworld_server_gcov.c 
    
    .PHONE: clean
    clean:
            @-rm helloworld_server helloworld_server_gcov.gcno helloworld_server_gcov.gcda
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    gcov_preload.c主要作用为捕获信号,调用gcov相关函数产生gcda文件。此文件编译成gcov_preload.so
    #include <stdio.h>
    #include <stdlib.h>
    #include <signal.h>
    #include <signal.h>
    #define SIMPLE_WAY
    
    void sighandler(int signo) 
    { 
    #ifdef SIMPLE_WAY
        exit(signo); 
    #else
        extern void __gcov_flush();     
        __gcov_flush(); /* flush out gcov stats data */
        raise(signo); /* raise the signal again to crash process */ 
    #endif 
    } 
    
    /**
    * 用来预加载的动态库gcov_preload.so的代码如下,其中__attribute__ ((constructor))是gcc的符号,
    * 它修饰的函数会在main函数执行之前调用,我们利用它把异常信号拦截到我们自己的函数中,然后调用__gcov_flush()输出错误信息
    * 设置预加载库 LD_PRELOAD=./gcov_preload.so
    */
    
    __attribute__ ((constructor))
    
    void ctor() 
    {
        int sigs[] = {
            SIGILL, SIGFPE, SIGABRT, SIGBUS,
            SIGSEGV, SIGHUP, SIGINT, SIGQUIT,
            SIGTERM     
        };
        int i; 
        struct sigaction sa;
        sa.sa_handler = sighandler;
        sigemptyset(&sa.sa_mask);
        sa.sa_flags = SA_RESETHAND;
    
        for(i = 0; i < sizeof(sigs)/sizeof(sigs[0]); ++i) {
            if (sigaction(sigs[i], &sa, NULL) == -1) {
                perror("Could not set signal handler");
            }
        } 
    }
    
      
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    编译gcov_preload.c
    gcc -shared -fpic gcov_preload.c -o libgcov_preload.so
      
      
    • 1

    编译出libgcov_preload.so后拷贝到helloworld_server_gcov.c同目录下,然后在编译helloworld_server_gcov.c,最后运行,执行CTRL+c正常结束helloworld_server且产生了gcda文件。


    FAQ

    • 问题1

    ERROR: could not read source file /home/user/project/sub-dir1/subdir2/subdir1/subdir2/file.c
      
      
    • 1
    解决方法

    在home目录下创建一个~/.lcovrc文件,并加入一行geninfo_auto_base = 1

    出现此问题的原因是: 当编译工具链和源码不在同一个目录下时,会出现ERROR: could not read source file错误,这个geninfo_auto_base = 1选项指定geninfo需要自动确定基础目录来收集代码覆盖率数据.

  • 问题2

  • 使用lcov [srcfile]的命令生成.info文件的时候,提示如下错误, 无法生成info文件:

    xxxxxxxxxxxx.gcno:version '402*', prefer '408*'
    Segmentation fault
      
      
    • 1
    • 2
    解决方法

    在lcov工具中使用–gcov-tool选项选择需要的gcov版本,如lcov --gcov-tool /usr/bin/gcov-4.2


    参考


    猜你喜欢

    转载自blog.csdn.net/qq_32534441/article/details/90645316