GCC学习笔记

GCC学习笔记

[TOC]
作为Linux下的C/C++使用者有必要稍微深入了解一下GCC编译器.本文为学习笔记,包含GCC基础知识(部署,编译选项等),编译过程,静态动态库的生成与使用.

GCC是Linux平台上最为常用的编译工具,全称GNU Compiler Collection,即GNU编译器套件,是GNU project里的一个重要产物,它包含多种语言的编译器及对应的库.logo是非洲牛羚,如下:
508px-GNU_Compiler_Collection_logo.svg.png

基础

本文主要整理自如下两个分享:知乎专栏,网上教程.

部署(Ubuntu)

Linux 发行版一般都默认安装有 GCC 编译器(版本通常都较低).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
gcc --version #查看已安装版本

#一键安装默认版本(一系列软件包,包括gcc,g++,和make)
sudo apt update
sudo apt install build-essential

#安装多个 GCC 版本
sudo apt install gcc-8 g++-8 gcc-9 g++-9 gcc-10 g++-10

#设置版本优先级
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 --slave /usr/bin/g++ g++ /usr/bin/g++-10 --slave /usr/bin/gcov gcov /usr/bin/gcov-10
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-9 90 --slave /usr/bin/g++ g++ /usr/bin/g++-9 --slave /usr/bin/gcov gcov /usr/bin/gcov-9
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-8 80 --slave /usr/bin/g++ g++ /usr/bin/g++-8 --slave /usr/bin/gcov gcov /usr/bin/gcov-8

#修改优先级
sudo update-alternatives --config gcc

gcc和g++的区别

gcc 是 GCC 编译器的通用编译指令,因为根据程序文件的后缀名,gcc 指令可以自行判断出当前程序所用编程语言的类别(-x选项手动指定).

  • xxx.c:默认以编译 C 语言程序的方式编译此文件;
  • xxx.cpp:默认以编译 C++ 程序的方式编译此文件.
  • xxx.m:默认以编译 Objective-C 程序的方式编译此文件;

g++ 指令,则无论目标文件的后缀名是什么,该指令都一律按照编译 C++ 代码的方式编译该文件.
如果想使用 gcc 指令来编译执行 C++ 程序,需要在使用 gcc 指令时,手动为其添加 -lstdc++ -shared-libgcc 选项.

GCC自动识别的文件扩展名

文件名称+扩展名 GCC 编译器识别的文件类型
file.c 尚未经过预处理操作的 C 源程序文件.
file.i 经过预处理操作,但尚未进行编译,汇编和连接的 C 源代码文件.
file.cpp file.cp file.cc file.cxx file.CPP file.c++ file.C 尚未经过预处理操作的 C++ 源代码文件.
file.ii 已经预处理操作,但尚未进行编译,汇编和连接的 C++ 源代码文件.
file.s 经过编译生成的汇编代码文件.
file.h C,C++ 或者 Objective-C++ 语言头文件.
file.hh file.H file.hp file.hxx file.hpp file.HPP file.h++ file.tcc C++ 头文件.

也可以-x选项直接指定文件类型跳过上面的自动识别.

指定编译语言版本

例如-std=c++14选项指定版本.
GCC支持语言版本.png

GCC常用的编译选项

常用选项如下:

gcc/g++指令选项 功 能
-E(大写) 预处理指定的源文件,不进行编译,输出.i文件.
-S(大写) 对源文件进行预处理,编译,输出.s文件,但是不进行汇编.
-c 编译,汇编指定的源文件,但是不进行链接,输出.o文件.
-o 指定输出文件名称路径.
-llibrary(-I library) 手动链接库. library 为库文件名.建议 -l 和库文件名之间不使用空格.
比如 -lm 表示链接数学库libm.a,只需要除去前缀lib与后缀的.a取中间的基本名即可.
-ansi 对于 C 语言程序来说,其等价于 -std=c90;对于 C++ 程序来说,其等价于 -std=c++98.
-std= 手动指令编程语言所遵循的标准,例如 c89,c90,c++98,c++11 等.
-Wall 对代码所有可能有问题的地方发出警告.
-g 在目标文件中嵌入调试信息,便于gdb调试.
-v 查看的详细编译过程.

警告选项

警告编译选项就是用于控制需要告警的警告类型的(警告:不是错误但是疑似错误或者可能存在风险的地方).

一般启用特定类型警告的格式为-Wxxx,而排除特定类型的警告的格式则一般为-Wno-xxx.

-Wall

这是一个非常常用的编译选项,用于启用一批比较常见且易于修改的警告,这些选项都是对代码进行基本的检查,比如下面:

选项 作用
-Waddress 检查是否存在可疑的内存地址使用
-Wformat 检查标准库函数的使用格式是否正确,比如printf的格式化字符串中的格式符和对应的参数是否匹配
-Wunused-function 对已声明但是未定义的静态函数和未被使用的非内联静态函数发出警告
-Wswitch 当用switch用于枚举类型时,判断分支是否包含所有枚举值,否则发出警告
-Wunused-variable 对声明但未被使用的变量发出警告
-Wunused-but-set-variable 对声明且被赋值但未被使用的变量发出警告
-Warray-bounds=1 数组越界检查,需启用选项-ftree-vrp

-Wextra

作为-Wall的补充,例子如下:

选项 作用
-Wcast-function-type 当函数被强转为不兼容的函数指针时发出警告
-Wempty-body 当存在空的if,else或者do while语句时发出警告
-Wunused-parameter 当函数有未被使用的参数时发出警告,需配合-Wall
-Wunused-but-set-parameter 当存在被设置但是未被使用的参数发出警告,需配合-Wall
-Wsign-compare 当比较有符号和无符号值时发出警告

-Werror

用于将所有警告视为错误.如果不希望某些类型的警告被视为错误可以使用-Wno-error=<警告类型>.

-Wpedantic

对于所有不符合ISO C/ISO C++语言标准的源代码发出警告,等价于-pedantic.

-pedantic-errors参数将这些警告视为错误,等同于-Werror=pedantic.

-Wshadow

当局部变量屏蔽(shadow)已有已有变量时发出警告.比如以下代码:

1
2
3
4
int ret = 0;
for (int i = 0; i < 10; ++i) {
int ret = i; // warning: declaration shadows a local variable
}

-Wconversion

隐式转换可能导致值变化的时候发出警告.

优化选项

详细的介绍请查看官网**Optimize-Options**.

-O0/-Og

-O0是默认选项,不执行任何优化.在编译调试版本的时候,一般使用-O0,可以确保调试执行过程完全和代码一致(如果使用优化选项,根据源文件设定的断点和经过优化编译得到的程序可能对不上,所以不能准确停在预期的地方).

-Og是针对调试的优化选项,它会启用-O1的优化指令,除了那么可能会干扰调试的优化选项,同时可获取到更多的调试信息,提供更好好的编译体验,

-O/-O1

执行级别1的优化,尝试减少代码大小和提高性能,但是不包括需要花费大量编译时间的优化选项.

比如:

  1. -fdce:移除不可能执行到的代码
  2. -fif-conversion:尝试简化if语句.使用更少的分支,转化成标志位等操作
  3. -fmerge-constants:尝试合并相同的常量

-O2

执行-O1所有优化选项,同时额外执行几乎全部不需要在空间和性能之间平衡的优化选项.

比如:

  1. -fgcse:优化全局公共表达式,常量的传递
  2. -fcode-hoisting:将所有分支都需要执行的表达式尽早执行(对于优化代码大小很有用,同时也提升性能)
  3. -finline-functions:考虑将所有函数变成内联函数(即使没有被声明为inline)

-Os

这是专门用于优化代码大小的优化级别,执行-O2所有优化选项,排除那些可能导致程序大小增加的优化选项.

-O3

最高优化等级.该优化级别较高,执行的优化不会很直观,所以可能也会出现一些问题,需要看实际情况选择是否需要使用-O3.

-ffunction-sections/-fdata-sections/-Wl,–gc-sections

在开发过程中,可能会实现一些实际并不会用到的函数或者对外有多个接口,并不是所有的接口都需要用到所有的模块和函数,但是默认情况下,GCC会把整个静态库链接到目标可执行文件,所以会增加可执行文件的大小.

GCC在链接的时候以section为单元处理,所以可以尝试使用-ffunction-sections/-fdata-sections将每个函数或者符号创建成独立的section,然后结合选项-Wl,--gc-sections让链接器忽略用不到的section,这样就可以减少目标可执行程序的大小.其中-Wl表示将后面的参数传递给链接器,所以也可以直接设置链接器选项.

注意这个并不是所有的链接器都支持的,大多数支持编译ELF目标文件的工具链都是支持的,一般嵌入式开发会比较常用的.

代码生成选项

最常用的就只有-fPIC,该选项用于生成位置无关代码(PIC,position-independent code),主要是为了生成共享库.此类代码通过全局偏移表 (GOT) 访问所有常量地址,在程序启动的时候,动态加载器会确定需要使用的共享库的GOT.

-fPIE-fPIC是类似的,但-fPIE产生的位置无关代码只能用于链接可执行文件.

另外可能还会偶尔用到-fpic-fpie,它们和全大写的区别只在于系统对GOT大小有一定限制.

调试选项

一般情况下是为了能够正常使用调试器调试程序,必须要让编译器给编译目标添加额外的调试信息.

最常使用的是-g,一般可以满足需求.但是如果为了提升一些调试程序的性能,可以配合使用针对调试的优化选项-Og.

添加的调试信息可以使用strip工具移除,一般对于需要release的程序代码可以都通过此工具移除一些敏感信息,同时也能够减少目标文件的大小.

更多选项查看GCC 手册.

编译过程

完整的编译过程如下(主要指Linux下):

  1. 配置(configure):通知系统环境(比如标准库位置,软件的安装位置,需要安装组件明细等)给编译器,以适应不同的平台.一般由autoconf工具生成的configure脚本文件.如果有自定义的内容,用户也可以提供编译参数.
  2. 确定标准库和头文件的位置:编译器从configure配置文件找到目录清单然后查找.
  3. 确定依赖关系:例如A依赖B,那么B要在A之前编译完成.并且只有B改变了,A才会根据自身更改与否重新编译.整个过程被保存在makefile里.一般它由configure生成.
  4. 头文件的预编译(precompilation):不同的源码文件,可能引用同一个头文件.编译的时候,头文件也必须一起编译.为了节省时间,编译器会在编译源码之前,先编译头文件.这保证了头文件只需编译一次,不必每次用到的时候,都重新编译了.不过,并不是头文件的所有内容,都会被预编译.用来声明宏的#define命令,就不会被预编译.
  5. 预处理(Preprocessing):将源文件处理为.ii/.i,处理各种预处理指令,如#include,#ifdef,#if等等,同时也会清除注释;
  6. 编译(Compilation):将.ii/.i处理为.S/.asm,即机器语言的汇编文件;
  7. 汇编(Assembly):将.asm/.S处理为.o,把汇编文件变成机器码;
  8. 链接(Linking):将各种依赖的静态/动态库文件,.o文件,启动文件链接成最终的可执行文件或者共享库文件.
  9. 安装(Installation):创建目录,保存文件,设置权限.
  10. 操作系统连接:可执行文件安装后,必须以某种方式通知操作系统,让其知道可以使用这个程序了.这就要求在操作系统中,登记这个程序的元数据:文件名,文件描述,关联后缀名等等.Linux系统中,这些信息通常保存在/usr/share/applications目录下的.desktop文件中.

主要步骤也就4个:预处理,编译,汇编,链接.这也是GCC参与的部分.

预处理

默认情况下 gcc -E 指令只会将预处理操作的结果输出到屏幕上,并不会自动保存到某个文件.因此该指令往往会和 -o 选项连用,将结果导入到指令的文件中.得到的.i文件还是文本文件.

1
gcc -E demo.c -o demo.i

编译

整个过程中最复杂的一部分. 使用gcc -S指令. -fverbose-asm 选项,GCC 编译器会自行为汇编代码添加必要的注释.

1
2
3
4
gcc -S demo.i #or gcc -S demo.c -o demo.s
gcc -S demo.c -fverbose-asm
ls
demo.c demo.i demo.s

汇编

使用gcc -c指令

汇编其实就是将汇编代码转换成可以执行的机器指令.大部分汇编语句对应一条机器指令,有的汇编语句对应多条机器指令.相对于编译操作,汇编过程会简单很多,它并没有复杂的语法,也没有语义,也不需要做指令优化,只需要根据汇编语句和机器指令的对照表一一翻译即可.

1
2
3
gcc -c demo.c # 生成同名.o文件
# 或者
gcc -c demo.s # 生成同名.o文件

在这一步,其实背后是使用汇编器as.s文件处理成.o文件的,所以下面的命令生成的.o文件和前面两条命令完全一样:

1
as -o demo.o demo.s

链接

使用gcc命令不带任何参数,指定源文件(.i/.s/.o/.c)则就会按需完成前面的3个步骤,并进行最终的链接.

1
2
3
gcc democ.o -o democ.exe
gcc demoi.o -o demoi.exe
gcc demos.o -o demos.exe

指定链接目录

  1. 把链接库作为一般的目标文件,为 GCC 指定该链接库的完整路径与文件名.
1
gcc main.c -o main.out /usr/lib/libm.a
  1. 使用-L选项,为 GCC 增加另一个搜索链接库的目录.可以使用多个-L选项,或者在一个-L选项内使用冒号分割的路径列表.
1
gcc main.c -o main.out -L/usr/lib -lm
  1. 把包括所需链接库的目录加到环境变量 LIBRARYPATH 中.

多文件处理

gcc指令一次处理多个文件

1
2
3
4
5
gcc -c demo1.c demo2.c
gcc -c demo1.c demo2.c -o demo1.o demo2.o #无法使用 -o 选项分别输出到指定文件
gcc -c demo1.i demo2.c
ls
demo1.c demo1.i demo1.o demo2.c demo2.o #同一项目中,不同的源文件,预处理文件,汇编文件以及目标文件,可以使用一条 gcc 指令,最终生成一个可执行文件

编译多文件项目

1
2
gcc myfun.c main.c -o main.exe
gcc *.c -o main.exe #支持通配符

使用静态链接库和动态链接库

库的介绍

所谓库文件,可以将其等价为压缩包文件,该文件内部通常包含不止一个目标文件.库文件中每个目标文件存储的代码,并非完整的程序,而是一个个实用的功能模块.库文件是无法直接使用的,只能通过头文件间接调用.这种头文件和库文件相结合的访问机制,最大的好处在于,实现了隐藏源码.

静态链接库,GCC 编译器就会将库中的模板代码直接复制到程序文件的适当位置,最终生成可执行文件.

  • 优势,生成的可执行文件不再需要任何静态库文件的支持就可以独立运行(可移植性强);
  • 劣势,如果程序文件中多次调用库中的同一功能模块,则该模块代码势必就会被复制多次,生成的可执行文件中会包含多段完全相同的代码,造成代码的冗余.

动态链接库,又称为共享链接库.GCC 编译器不会直接将该功能模块的代码拷贝到文件中,而是将功能模块的位置信息记录到文件中.可执行文件运行时,GCC 编译器会将对应的动态链接库一同加载在内存中,由于可执行文件中事先记录了所需功能模块的位置信息,所以在现有动态链接库的支持下,也可以成功运行.因此优劣势与静态库相反.

在 Linux 发行版系统中,静态链接库文件的后缀名通常用 .a 表示,动态链接库的后缀名通常用 .so 表示;在 Windows 系统中,静态链接库文件的后缀名为 .lib,动态链接库的后缀名为 .dll.

GCC 编译器默认优先使用动态链接库实现链接操作,若没有动态库才会选择相应的静态链接库.如果两种都没有(或者 GCC 编译器未找到),则链接失败.

GCC寻找库文件的位置与顺序

静态库

  • 不带-l选项

    1
    gcc -static main.c libmymath.a -o main.exe

    GCC 编译器只会在当前目录中查找静态链接库.

  • -l选项

    1
    gcc -static main.c -lmymath -o main.exe

    查找顺序:

    1. 如果 gcc 指令使用 -L 选项指定了查找路径,则 GCC 编译器会优先选择去该路径下查找所需要的库文件;
    2. 再到 Linux 系统中 LIBRARY_PATH 环境变量指定的路径中搜索需要的库文件;
    3. 最后到 GCC 编译器默认的搜索路径(如 /lib,/lib64,/usr/lib,/usr/lib64,/usr/local/lib,/usr/local/lib64 等,不同系统环境略有差异)中查找.

    如果报错找不到库文件:

    • 手动找到该库文件,并在 gcc 指令中用 -L 选项明确指明其存储路径.

    • 将库文件的存储路径添加到 LIBRARY_PATH 环境变量.

    • 将库文件移动到 GCC 编译器默认的搜索路径中.

动态库

  1. 如果在生成可执行文件时,用户使用了-Wl,-rpath=dir(其中 dir 表示要查找的具体路径,如果查找路径有多个,中间用 : 冒号分隔)选项指定动态库的搜索路径,则运行该文件时 GCC 会首先到指定的路径中查找所需的库文件;
  2. GCC 编译器会前往 LD_LIBRARY_PATH 环境变量指明的路径中查找所需的动态库文件;
  3. GCC 编译器会前往 /ect/ld.so.conf 文件中指定的搜索路径查找动态库文件;
  4. GCC 编译器会前往默认的搜索路径中查找所需的动态库文件.

注意,对于动态库可执行文件的当前存储路径,并不在默认的搜索路径范围内!

创建静态链接库

  1. 首先使用 gcc 命令把源文件编译为目标文件,也即.o文件:
1
gcc -c 源文件列表
  1. 然后使用 ar 命令将.o文件打包成静态链接库,具体格式为:
1
ar rcs + 静态库文件的名字 + 目标文件列表

ar 是 Linux 的一个备份压缩命令,它可以将多个文件打包成一个备份文件(也叫归档文件),也可以从备份文件中提取成员文件.

对参数的说明:

  • 参数 r 用来替换库中已有的目标文件,或者加入新的目标文件.
  • 参数 c 表示创建一个库.不管库否存在,都将创建. 
  • 参数 s 用来创建目标文件索引,这在创建较大的库时能提高速度.

例如,下面的写法表示将目标文件 a.o,b.o 和 c.o 打包成一个静态库文件 libdemo.a:

1
ar rcs libdemo.a a.o b.o c.o

使用静态链接库

文件结构如下:

1
2
3
4
5
6
|-- include
| `-- test.h
|-- lib
| `-- libtest.a
`-- src
`-- main.c
1
#include "test.h"  //main.c必须引入头文件
1
gcc src/main.c -I include/ -L lib/ -l test -o math.out

创建动态链接库

-shared选项 + -fPIC选项.

1
gcc -fPIC -shared func.c -o libfunc.so

使用动态链接库

动态链接库的调用方式有 2 种,分别是:

  • 隐式调用(静态调用):将动态链接库和其它源程序文件(或者目标文件)一起参与链接;

    1
    gcc main.c libfunc.so -o app.out
  • 显式调用(动态调用):手动调用动态链接库中包含的资源,同时用完后要手动将资源释放.需要时就申请,不需要时就将占用的资源释放.

    1. 调用动态链接库头文件.

      1
      #include <dlfcn.h>
    2. 打开该库文件(将库文件装载到内存中).

      1
      void *dlopen (const char *filename, int flag);

      其中,filename 参数用于表明目标库文件的存储位置和库名;flag 参数的值有 2 种:

      1. RTLD_NOW:将库文件中所有的资源都载入内存;
      2. RTLD_LAZY:暂时不降库文件中的资源载入内存,使用时才载入.
    3. 借助 dlsym() 函数获得指定函数在内存中的位置.

      1
      void *dlsym(void *handle, char *symbol);

      其中,hanle 参数表示指向已打开库文件的指针;symbol 参数用于指定目标函数的函数名.如果 dlsym() 函数成功找到指定函数,会返回一个指向该函数的指针;反之如果查找失败,函数会返回 NULL.

    4. 借助 dlclose() 函数可以关闭已打开的动态链接库.

      1
      int dlclose (void *handle);

      handle 表示已打开的库文件指针.当函数返回 0 时,表示函数操作成功;反之,函数执行失败.调用 dlclose() 函数并不一定会将目标库彻底释放,它只会是目标库的引用计数减 1,当引用计数减为 0 时,库文件所占用的资源才会被彻底释放.

    5. 借助 dlerror() 函数,获得最近一次 dlopen(),dlsym() 或者 dlclose() 函数操作失败的错误信息.

      1
      const char *dlerror(void);

      如果函数返回 NULL,则表明最近一次操作执行成功.

显式调用实例

项目结构如下:

1
2
3
4
5
6
7
8
demo项目
├─ headers
│ └─ test.h
└─ sources
├─ add.c
├─ sub.c
├─ div.c
└─ main.c

项目中各个文件包含的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//test.h
#ifndef __TEST_H_
#define __TEST_H_

int add(int a,int b);
int sub(int a,int b);
int div(int a,int b);

#endif

//add.c
#include "test.h"
int add(int a,int b){
return a + b;
}

//sub.c
#include "test.h"
int sub(int a,int b){
return a - b;
}
}

打包生成一个动态链接库:

1
gcc -fpic -shared add.c sub.c -o libmymath.so

main.c 主程序文件:

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
#include <stdio.h>
#include <dlfcn.h>
int main(){
int m,n;
//打开库文件
void* handler = dlopen("libmymath.so",RTLD_LAZY);
if(dlerror() != NULL){
printf("%s",dlerror());
}
//获取库文件中的 add() 函数
int(*add)(int,int)=dlsym(handler,"add");
if(dlerror()!=NULL){
printf("%s",dlerror());
}
//获取库文件中的 sub() 函数
int(*sub)(int,int)=dlsym(handler,"sub");
if(dlerror()!=NULL){
printf("%s",dlerror());
}
//使用库文件中的函数实现相关功能
printf("Input two numbers: ");
scanf("%d %d", &m, &n);
printf("%d+%d=%d\n", m, n, add(m, n));
printf("%d-%d=%d\n", m, n, sub(m, n));
//关闭库文件
dlclose(handler);
return 0;}

ldd 指令

定位可执行文件所需的库文件.

1
2
3
4
ldd main.exe
linux-vdso.so.1 => (0x00007fff06fb3000)
libmymath.so => /lib64/libmymath.so (0x00007f65b2a62000)
libc.so.6 => /lib64/libc.so.6 (0x00000037e2c00000)

如果某个动态库文件未找到,则 => 后面会显示 not found,表明 GCC 编译器无法找到该动态库.

附录

gcc -E 常用选项

选 项 功 能
-D name[=definition] 在处理源文件之前,先定义宏 name.宏 name 必须是在源文件和头文件中都没有被定义过的.将该选项搭配源代码中的#ifdef name命令使用,可以实现条件式编译.如果没有指定一个替换的值(即省略 =definition),该宏被定义为值 1.
-U name 如果在命令行或 GCC 默认设置中定义过宏 name,则”取消”name 的定义.-D 和 -U 选项会依据在命令行中出现的先后顺序进行处理.
-include file 如同在源代码中添加 #include “file” 一样.
-iquote dir 对于以引号(#include “”)导入的头文件中,-iquote 指令可以指定该头文件的搜索路径.当 GCC 在源程序所在目录下找不到此头文件时,就会去 -iquote 指令指定的目录中查找.
-I dir 同时适用于以引号 “” 和 <> 导入的头文件.当 GCC 在 -iquote 指令指定的目录下搜索头文件失败时,会再自动去 -I 指定的目录中查找.该选项在 GCC 10.1 版本中已被弃用,并建议用 -iquote 选项代替.
-isystem dir-idirafter dir 都用于指定搜索头文件的目录,适用于以引号 “” 和 <> 导入的头文件.
-C 阻止 GCC 删除源文件和头文件中的注释.

其中,对于指定 #include 搜索路径的几个选项,作用的先后顺序如下:

  1. 对于用 #include “” 引号形式引入的头文件,首先搜索当前程序文件所在的目录,其次再前往 -iquote 选项指定的目录中查找;

  2. 前往 -I 选项指定的目录中搜索;

  3. 前往 -isystem 选项指定的目录中搜索;

  4. 前往默认的系统路径下搜索;

  5. 前往 -idirafter 选项指定的目录中搜索.

参考链接:
http://www.ruanyifeng.com/blog/2014/11/compiler.html

作者

cx

发布于

2021-12-08

更新于

2022-07-16

许可协议