很久就想写一篇关于动态库和静态库互相引用的一篇文章,但是总感觉准备不充分,而一直没有勇气下笔,最近在和同事的讨论中,似乎有了一些新的认识,想把这些记录下来,和大家进行一次分享,同时也是做一次记录。

这篇文章将从以下几个方面进行分析讲解

1.程序的编译过程

2.什么是静态编译,动态编译

3.如何生成静态库,如何生成动态库

4.动态库和静态库相互引用后,应用程序是否可以只使用一个库

(例如:应用用到了静态库a,而静态库a里使用了库b,那么应用程序是不是只要链接a就可以了呢)

5.动态库的两种加载方式

1.程序的编译过程

我们通常说的编译程序,是指生成可执行的二进制文件,主要分为四个步骤

1.预处理 -E

处理所有以#开头的代码,包括 头文件 宏定义 条件编译

预处理后的文件我们通常.i结尾

gcc -E hello.c -o hello.i

2.编译 -S

语法检查以及将C语言变成汇编语言

编译后的文件我们通常.s 结尾

gcc -E hello.i -o hello.s

3.汇编 -c

将汇编语言变成二进制文件

编译后的文件我们通常.o结尾

gcc -c hello.s -o hello.o

4.链接 (啥也不写默认就是链接)

链接代码需要用到的其他文件(其他库等)

gcc hello.o -o hello

这个过程也就是说明里,未经过链接的.o文件是不能被执行的。

一道面试题:为什么汇编生成的二进制文件需要链接后才能执行?

编译生成.o文件时,它是一个可重定位文件,编译器还不清楚一些外部函数(变量)的地址,当链接器将.o文件链接成为可执行文件时,必须确定那些函数(变量)的性质,如果是静态目标模块提供的按照静态链接的规则,如果是动态共享对象提供的按照动态链接规则。动态链接时,并不是真正的链接,而是对符号的引用标记为动态链接符号,不重定位,装载时再进行。

所以目标文件还是需要先链接成为可执行文件。动态链接也不是完全在运行时执行链接这个过程,而是先链接对符号进行处理,在运行时重定位。

2.什么是静态编译,动态编译

所谓的动态编译(动态链接)和静态编译(静态链接) 指的是生成可执行文件的情况下。

在生成库的时候,不会存在这两个概念,这个要注意区分。

比方说 main函数里用到了printf函数,如果动态编译的话,程序运行的时候

还是需要在运行环境中有libc的库,而静态编译的话,把这个可执行的程序拿到没有libc库的环境一样可以运行。可执行文件,要求代码里必须有main函数

2.1 静态编译必须要链接静态库吗

是的,如果你强制指定了静态编译,那么就会链接静态库

例子:我在main函数里指定了,链接两个库,显示的进行 静态链接

我的指令如下

gcc main.c -o main -static  -I ./add -I ./sub  -L ./add -L ./sub  -l add -l sub

会报下面对错误,

/usr/bin/ld: cannot find -lsub

collect2: error: ld returned 1 exit status

因为在我指定的目录 sub下没有静态库,也就是如果显示的指定静态链接的话

用到对所有库都是静态库,如果没有静态库就会报错。

2.2 不能指定 ,只使用动态库,也就是不能直接写成这个shared

当不写的时候,会优先找到动态库,如果没有就会使用静态库,(注意一个问题,就是不能指定为-shared 否则会报段错误)

而我在github的代码上,又会营造这样的场景,就是我的库里面,一个是静态的,一个是动态的

那么我采用 不写的方式,会编译链接成功吗,答案是可以。

gcc main.c -o main -I ./add -I ./sub  -L ./add -L ./sub  -l add -l sub

具体的代码见 github 链接

https://github.com/zhc2019github/static_compileand_dynamic_compile.git

3.如何生成静态库,如何生成动态库

3.1 静态库的制作方法

1. gcc -c xxx.c xxx.c(预处理,编译,汇编,链接 -c是汇编阶段)

2.ar -crv libx.a xxx.o xxx.o

lib 是固定写法,我们库的名字是x

ar -t libx.a

可以查看静态链接库包含的文件

用nm指令可以查看静态库里包含哪些函数的定义,哪些函数的引用,定义没在这里。

程序使用静态库

gcc hello.c -o hello -static -L . -l x

-L 代表 库的路径 -l 代表 库的名字

3.2 动态库的制作方法

1.gcc -fPIC -shared -o libxx.so xxx.c xxx.c

程序使用动态库(有两种使用方式,这个是第一种,第一种和第二种的区别参考 动态库的两种加载方式)

gcc hello.c -o hello -L . -l xx

-L 代表 库的路径 -l 代表 库的名字

ldd hello 可以查看动态库的 依赖的库的路径在哪里

用nm指令可以查看动态库里包含哪些函数的定义,哪些函数的引用,定义没在这里。

4.动态库和静态库相互引用后,应用程序是否可以只使用一个库

这个问题也是本篇文章的重点所在,我先要强调一点是,这个的使用场景是这样的,作为一个提供库的一方,在形成自己的库的时候,可能会引入其它第三方库,那么为了简单话,我作为提供者,我不太想让应用程序感知到,我使用哪些库了,而我提供了一个总的库,就可以了,这样也是对应用层屏蔽他们不关心的东西,但是这里这个库之间的引用,就比较麻烦了,设计到下面的四种情况

静态库引用静态库

静态库引用动态库

动态库引用静态库

动态库引用动态库

下面给出分析,然后,再进行代码上的验证。先说下我的验证思路,就是我现在在主函数里进行如下调用。先调用b,然后b再调用a. 具体的目录结构如下:

然后主要的逻辑是在这个shell脚本,build.sh里,build.sh里的具体代码如下:

#!/bin/bashbuildStaticA(){cd funcAecho "Build Static A"gcc -c funcA.c -shared -fPIC -o funcA.oar -crv libstaticA.a funcA.oecho "=======nm libstaticA.a======"nm libstaticA.acd ..
}buildStaticB(){cd funcBecho "Build Static B"path=""lib=""case "$1" in1)path=funcAlib=staticA;;2)path=funcAlib=sharedA;;*);;esac# gcc -c -I${PWD}/../${path}/ funcB.c -L${PWD}/../${path}/ -l${lib}# 后面的-L${PWD}/../${path}/ -l${lib}没有作用,有头文件就行了.gcc -c -I${PWD}/../${path}/ funcB.car -crv libstaticB.a funcB.oecho "=======nm libstaticB.a======"nm libstaticB.acd ..
}buildSharedA(){echo "Build Shared A"cd funcAgcc -c funcA.cgcc -shared -fPIC -o libsharedA.so funcA.oecho "=======nm libsharedA.so======"nm libsharedA.so | grep "func*"cd ..
}buildSharedB(){echo "Build Shared B"cd funcBpath=""lib=""case "$1" in1)path=funcAlib=staticA;;2)path=funcAlib=sharedA;;*);;esacecho ${lib}#gcc -c -I${PWD}/../${path}/ funcB.c -L${PWD}/../${path}/ -l${lib}gcc -o funcB.o -c funcB.c -I${PWD}/../${path}/gcc -o libsharedB.so funcB.o -shared -fPIC -L${PWD}/../${path}/ -l${lib}#gcc -shared -fPIC -o libsharedB.so funcB.c -I${PWD}/../${path}/ -L${PWD}/../${path}/ -l${lib}
#上面注释的行也能达到同样的效果,可以代替它上面的两行echo "=======nm libsharedB.so======"nm libsharedB.so | grep "func*"cd ..
}buildMain(){echo "Build Main"rm a.out *.a -rfcase "$1" in1)# ./build.sh 1 1 1  a b 都是静态库,一起使用A和Bgcc -I${PWD}/funcB/ -I${PWD}/funcA/ main.c -L${PWD}/funcB/ -lstaticB -L${PWD}/funcA/ -lstaticAecho "=======nm a.out======"nm a.out | grep "func*"echo "=======run a.out======"./a.out;;2)# ./build.sh 1 1 2  a b 都是静态库,独立使用B,结论是不可以gcc -I${PWD}/funcB/  main.c -L${PWD}/funcB/ -lstaticB;;3)# ./build.sh 1 1 3  a b 都是静态库,打包使用A和Bar -crT libstaticAB.a ${PWD}/funcA/libstaticA.a ${PWD}/funcB/libstaticB.aecho "=======nm libstaticAB.a======"nm libstaticAB.agcc -I${PWD}/funcB/ main.c -L${PWD} -lstaticABecho "=======nm a.out======"nm a.out | grep "func*"echo "=======run a.out======"./a.out;;4)# ./build.sh 2 1 4   a是动态,b是静态,一起使用a和bgcc -I${PWD}/funcB/ -I${PWD}/funcA/ main.c -L${PWD}/funcB/ -lstaticB -L${PWD}/funcA/ -lsharedAecho "=======nm a.out======"nm a.out | grep "func*"echo "=======run a.out======"export LD_LIBRARY_PATH=${PWD}/funcA;./a.out;;5)# ./build.sh 2 1 5   a是动态,b是静态,独立使用b不可以gcc -I${PWD}/funcB/  main.c -L${PWD}/funcB/ -lstaticB;;6)# ./build.sh 1 2 6  a是静态,b是动态,独立使用b就可以了gcc -I${PWD}/funcB/ main.c -L${PWD}/funcB/ -lsharedBecho "=======nm a.out======"nm a.out | grep "func*"echo "=======run a.out======"export LD_LIBRARY_PATH=${PWD}/funcB;./a.out;;7)# ./build.sh 2 2 7   a 是动态,b是动态,一起使用a和bgcc -I${PWD}/funcB/ -I${PWD}/funcA/ main.c -L${PWD}/funcB/ -lsharedB -L${PWD}/funcA/ -lsharedAecho "=======nm a.out======"nm a.out | grep "func*"echo "=======run a.out======"export LD_LIBRARY_PATH=${PWD}/funcB:${PWD}/funcA;./a.out;;8)# ./build.sh 2 2 8   a是动态,b是动态,独立使用b不可以gcc -I${PWD}/funcB/  main.c -L${PWD}/funcB/ -lsharedBecho "=======nm a.out======"nm a.out | grep "func*"echo "=======run a.out======"export LD_LIBRARY_PATH=${PWD}/funcB:${PWD}/funcA;./a.out;;*)echo "do nothing";;esac
}clear() {
find . -name "*.o" | xargs rm
find . -name "*.a" | xargs rm
find . -name "*.so" | xargs rm
rm a.out
}clearcase "$1" in1)buildStaticA;;2)buildSharedA;;*)echo "do nothing";;
esaccase "$2" in1)buildStaticB $1;;2)buildSharedB $1;;*)echo "do nothing";;
esacbuildMain $3

脚本执行的时候,传入三个参数,具体的含义解释如下:

第一个参数决定 a库编译成静态库还是动态库,1.静态库,2.动态库

第二个参数决定b 库编译成静态库还是动态库,1.静态库,2.动态库

第三个参数主要用来区分所有的情况,具体如下:

        1)# ./build.sh 1 1 1  a b 都是静态库,一起使用A和B2)# ./build.sh 1 1 2  a b 都是静态库,独立使用B,结论是不可以3)# ./build.sh 1 1 3  a b 都是静态库,打包使用A和B4)# ./build.sh 2 1 4   a是动态,b是静态,一起使用a和b5)# ./build.sh 2 1 5   a是动态,b是静态,独立使用b不可以6)# ./build.sh 1 2 6  a是静态,b是动态,独立使用b就可以了7)# ./build.sh 2 2 7   a 是动态,b是动态,一起使用a和b8)# ./build.sh 2 2 8   a是动态,b是动态,独立使用b不可以

如上图,也是build脚本的运行时使用的参数

还有我们假设,我们是库b,然后使用了第三方库a,然后我们手里有第三方库的 .a和.so(也就是静态库和动态库),但是没有第三库的.o文件。

4.1 静态库引用静态库

4.1.1 静态库a和静态库b应用程序都引用,是没有问题的

对应的执行脚本就是  ./build.sh 1 1 1 a b 都是静态库,一起使用A和B

执行后的输出如下图:

主要看下我们nm指令的输出,其中的T 是代表库中含有这个函数的实现,U 代表只是引用了这个函数,并没有这个函数的实现。

我们在 libstatic.a里看到了它有函数funcA 的实现。

而在这个libstatic.b里看到了,它没有函数funcA 的实现,只是进行了引用。

这也就解释了,应用程序中。单独链接库b,是不可以的。因为它根本没有funcA的实现。

静态库不是可以执行的二进制文件,他只是目标文件的集合(库是预编译的目标文件(object  files)的集合,它们可以被链接进程序)

编译静态库时只有编译过程,没有链接过程,静态库引用其它库并不会在编译的时候把引用的库函数编译到生成的 lib 中,只是简单的将编译后的中间文件打包,在编译最终的可执行项目(.exe 和 .dll)的时候,需要引用所有的库,进行符号消解。

4.1.2 独立使用静态库b

./build.sh 1 1 2  a b 都是静态库,独立使用B,结论是不可以

4.1.3 打包使用静态库a和b,就是把a和b打包后进行使用。

./build.sh 1 1 3  a b 都是静态库,打包使用A和B

具体看下shell脚本中的代码如下:

3)
# ./build.sh 1 1 3  a b 都是静态库,打包使用A和B
ar -crT libstaticAB.a ${PWD}/funcA/libstaticA.a ${PWD}/funcB/libstaticB.a
echo "=======nm libstaticAB.a======"
nm libstaticAB.a
gcc -I${PWD}/funcB/ main.c -L${PWD} -lstaticAB
echo "=======nm a.out======"
nm a.out | grep "func*"
echo "=======run a.out======"
./a.out
;;

我虽然没看过,ar -crT的底层实现,但是我感觉大致实现就是。反得到两个库的.o,然后把两个点o连接到一起,进行生成 libstaticAB.a

4.2 静态库引用动态库

4.2.1  静态库引用动态库后,不能链接到静态库里,需要一起使用a和b

# ./build.sh 2 1 4   a是动态,b是静态,一起使用a和b

4.2.2  验证只是使用静态库会报错的过程

# ./build.sh 2 1 5   a是动态,b是静态,独立使用b不可以

4.3 动态库引用静态库

这个引用后,是可以的,静态库会被链接进动态库。

# ./build.sh 1 2 6  a是静态,b是动态,独立使用b就可以了

执行脚本后的输出如下:

Build Static A
a - funcA.o
=======nm libstaticA.a======funcA.o:
0000000000000000 T funcAU _GLOBAL_OFFSET_TABLE_U puts
Build Shared B
staticA
=======nm libsharedB.so======
0000000000000677 T funcA
000000000000065a T funcB
Build Main
=======nm a.out======U funcB
=======run a.out======
main
func B enter
func A enter

我们可以清楚的看到这个,libsharedB.so中已经有了这个 func的实现了,对应的类型是T。

但是这个要注意下:

这个编译静态库A的时候,要使用 -shared -fPIC,这个具体的原因可以参考我在文章末尾给出的链接。

4.4 动态库引用动态库

4.4.1  动态库b引用动态库a后,也不能把a中的函数链接到b中,所以还是要一起使用b和a

# ./build.sh 2 2 7   a 是动态,b是动态,一起使用a和b
4.4.2 验证单独使用b会出现问题
# ./build.sh 2 2 8   a是动态,b是动态,独立使用b不可以

对应刚才程序的github链接如下:

github代码链接

总结3点,

1.当生成库B 时,需要链接库A时,只有在B是动态库,A是静态库的时候,才有意义,其它的情况不需要链接这个库A,只要在生成可以生成可执行文件进行依赖就可以了,这个要注意下。

2.当B是动态库,A是静态库,这个时候,可以在生成B的时候,直接把A链接进来,然后可执行程序编译的时候不用再链接A了,也可以动态库B不进行链接静态库A,而让可执行程序进行链接。

3.当我们对外提供一个动态库的时候,我们可以不链接任何一个我们使用的第三方库,然后让可执行程序在编译的时候,链接所有的第三方库。

(以上三个结论在linux下x86验证通过,其他平台,如Android可能不同)

最后再附加一个内容:

动态库依赖动态库的时候,不能单独使用动态库B,官方一点的答案就是,自从binutils 2.22版本以后,如果你在程序中使用了你依赖的动态库所依赖的动态库中的函数时,你就必须显式的指定你依赖的动态库所依赖的动态库。实际上,这是binutils在2.22版本以后,默认把--no-copy-dt-needed-entries这个选项打开了。当打开了这个选项的时候,编译器在链接的时候是不会递归的去获取依赖动态库的依赖项的,于是就会出现上述的问题。

使用--copy-dt-needed-entries则相反。也就是使用下面的指令来编译mian.cpp就可以避免该问题了。只用链接库B,不需要再显示的链接库A。

加上这个参数--copy-dt-needed-entries就好用了的前提,是在生成动态库b的时候一定要连接动态库a,

具体的代码改动如下:

gcc -shared -fPIC -o libsharedB.so funcB.c -I${PWD}/../${path}/ -L${PWD}/../${path}/ -l${lib}

可执行文件的代码编译如下:

gcc -I${PWD}/funcB/  main.c  -Wl,--copy-dt-needed-entries -L${PWD}/funcB/ -lsharedB -Wl,-rpath=${PWD}/funcA/

5.动态库的两种加载方式

max.h 中的内容如下:

void maxfunc();

max.c 中的内容如下:

#include<stdio.h>
void maxfunc(){
printf("i am max func\n");
}

用如下指令生成  动态库:

gcc max.c -fPIC -shared -o libtest.so

5.1 动态库的静态加载

main.c 中代码如下:

#include<max.h>
int main(int argc,char*argv[]){maxfunc();return 0;
}
gcc main.c  -I . -L . -ltest -o test

然后执行可执行文件的时候要设置下变量:

export LD_LIBRARY_PATH=${PWD};./test

5.2 动态库的动态加载

通过 dlopen,dlsym,dlerror,dlclose在代码中直接打开与使用动态链接库

dlopen 用于打开动态链接库,返回句柄

dlsym 使用dlopen返回的句柄与函数名来获得函数位置,返回函数指针

dlclose 关闭动态链接库

dlerror 当动态链接库函数操作失败时,返回出错信息,成功返回NULL.

现在换一个主函数,main1.c函数代码如下:

#include<stdio.h>
#include<dlfcn.h>
#include<stdlib.h>
#define LIB_PATH  "./libtest.so"
//定义函数指针typedef void (*func_ptr)(void);int main(){void *handler=dlopen(LIB_PATH,RTLD_LAZY);
if(!handler){printf("dlopen error \n");exit(-1);
}func_ptr max= (func_ptr)(dlsym(handler,"maxfunc"));if(max!=NULL){max();
}else{printf("max is NULL\n");
}}

然后编译程序,用如下指令

gcc -rdynamic main1.c -ldl -o a1.out
执行程序

./a1.out

比较下两种方式的优缺点:

静态链接:

优点:

1.程序简洁,不用依赖于其他库,不用依赖 dlopen,dlsym,dlerror,dlclose所在的 libdl.so库。

2.ldd命令查看一个二进制文件中依赖的动态库。

缺点:1.一般都需要include头文件。用

动态链接:

优点:

1.不用包含头文件。

2.第二个优点比较常用:能够动态的拉起动态库,不用关心其头文件,具体的实现。

这个之前在我们的项目中常用,就是在写框架的时候,它会拉取业务的动态库,然后为了和业务解决耦合的问题,它会遍历动态库的目录,然后只要是这个目录下的,他就认为这个是需要加载的动态库,然后得到库的名字,然后dllopen,然后每一个动态库都按照标准来实现初始化函数,和去初始化函数就行了。然后框架把库拉起来,调用相关函数,使其进行正常的运转。

缺点:

1.需要在程序中或者配置文件中注明库的位置

2.需要使用额外的库函数,dlopen,dlsym,dlerror,dlclose所在的 libdl.so库

3.ldd 看不到二进制文件中依赖的动态库

参考链接:

动态库(.so)链接静态库(.a)的总结 - 很实用讲解很清楚_sevencheng798的博客-CSDN博客_动态库静态链接

Linux下静态库与动态库的引用关系深入分析相关推荐

  1. linux下怎么查看一个动态库链接了其他哪些库

    有时需要分析某个动态库有哪些依赖库,以此来分析可移植性 使用readelf -d命令 测试程序 hello.c #include <stdio.h> extern void test(vo ...

  2. C++静态库与动态库

    C++静态库与动态库 这次分享的宗旨是--让大家学会创建与使用静态库.动态库,知道静态库与动态库的区别,知道使用的时候如何选择.这里不深入介绍静态库.动态库的底层格式,内存布局等,有兴趣的同学,推荐一 ...

  3. c++静态库和动态库

    C++静态库与动态库 这次分享的宗旨是--让大家学会创建与使用静态库.动态库,知道静态库与动态库的区别,知道使用的时候如何选择.这里不深入介绍静态库.动态库的底层格式,内存布局等,有兴趣的同学,推荐一 ...

  4. C++静态库与动态库(转)

    预处理阶段:预处理器根据以字符#开头的命令,修改原始的C程序,将源代码插入到程序文本中,得到另一个C程序,通常以.i作为文件扩展名 编译阶段:编译器ccl将文本文件hello.i翻译成文本文件hell ...

  5. C++静态库与动态库的区别?

    C++静态库与动态库 这次分享的宗旨是--让大家学会创建与使用静态库.动态库,知道静态库与动态库的区别,知道使用的时候如何选择.这里不深入介绍静态库.动态库的底层格式,内存布局等,有兴趣的同学,推荐一 ...

  6. 静态库与动态库之间的区别

    什么是库 库是写好的,现有的,成熟的,可以复用的代码.现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常. 本质上来说,库是一种可执行代码的二进制形式,可以 ...

  7. 详谈静态库和动态库的区别

    一.什么是库: 库是写好的,现有的,成熟的,可以复用的代码.现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常. 本质上来说,库是一种可执行代码的二进制形式 ...

  8. x64 编译 静态链接_C++静态库与动态库

    这次分享的宗旨是--让大家学会创建与使用静态库.动态库,知道静态库与动态库的区别,知道使用的时候如何选择.这里不深入介绍静态库.动态库的底层格式,内存布局等,有兴趣的同学,推荐一本书<程序员的自 ...

  9. C++静态库与动态库详解与使用

    福利 | 百度 AI 开发者大会免费门票领取    CSDN日报20170628--<实习,背后的选择?>    [直播]探究Linux的总线.设备.驱动模型! C++静态库与动态库详解与 ...

  10. 一篇文章教你理解什么是静态库和动态库

    https://www.cnblogs.com/skynet/p/3372855.htm C++静态库与动态库 这次分享的宗旨是--让大家学会创建与使用静态库.动态库,知道静态库与动态库的区别,知道使 ...

最新文章

  1. zabbix系列(四)Zabbix3.0.4添加对Nginx服务的监控
  2. 不再内卷!视觉字幕化新任务合集
  3. java entity公共属性_java – 如何从Entity Manager获取jpa数据源属性
  4. 91.91p10.space v.php,luogu P1091 合唱队形
  5. LeetCode LCS 03. 主题空间(广度优先搜索BFS)
  6. 行内元素和块级元素的区别,为何img、input等行内元素可以设置宽高??(夯实基础)
  7. 清北学堂dp图论营游记day4
  8. easyui的datagrid的使用方法
  9. “Python编程及大数据应用”课程教师(厦门)寒假研修班
  10. JavaWeb -- Struts1 使用示例: 表单校验 防表单重复提交 表单数据封装到实体
  11. SQLException: Value '0000-00-00 00:00:00' can not be represented as java.sql.Timestamp
  12. 第一章 架构 1.4 编译 amp; 1.5总结
  13. vim编辑器删除空行
  14. softmgr主程序_360软件管家下载的安装程序在哪个文件夹
  15. 神经网络和深度学习二者之间的关系
  16. 零基础入门学习HTML(下)
  17. 7款英文语法检查工具推荐
  18. 单盘位小先锋 群晖DS112j家用NAS评测
  19. wifi连接一段时间才能上网_Win7连接Wifi一段时间后就掉线的解决方法
  20. 程序员应该了解的计算机知识(一)——基础理论

热门文章

  1. 早期贝尔实验室中UNIX办公室是什么样的?
  2. 板式橡胶支座弹性模量怎样计算_板式橡胶支座抗压弹性模量试验分析(详细)
  3. Photoshop测量角度
  4. Android查看手机内部储存目录及数据库文件[转]
  5. 安利一波VGGNet
  6. Smartbi推出NLA自然语言分析新技术,只要说话就能生成图表
  7. python3 with_Python3 startswith()方法
  8. XXE漏洞原理--简单理解
  9. 用菲教切入下沉市场,51TALK会成为在线教育的拼多多吗?
  10. 健身房管理系统那个好