当前位置:   article > 正文

ARM交叉编译入门及交叉编译第三方库常见问题解析

arm交叉编译

1. 交叉编译是什么?

交叉编译简单说来,就是编译成果物的地儿不是你运行这个成果物的地儿。最常见的场景,就是我们要编译一个 ARM版本 的可执行程序,但我们编译这个 ARM版本 可执行程序的地方,是在一个 x86_x64 的平台上。

2. 为什么需要交叉编译?

绝大部分的原因,是目标平台不具备编译成果物的算力。具体说来,就是ARM平台早期是 并没有 编译代码所需的 算力相关空间 的。所以,不得不借助性能更高的平台来辅助进行编译成果物,然后ARM平台仅负责运行成果物即可。

3. 交叉编译只能在目标平台同一系统上吗?

虽然绝大多数的 ARM Linux系统中编译的成果物是在对应的 x86_x64平台的Linux系统中进行的,所以大多数时候使用Windows平台电脑需要安装一个虚拟机或者连接到某个x86_x64平台的Linux编译服务器中,但实际上这种搭配大多数情况是为了方便,也为了让编译者熟悉Linux环境,但这种搭配并不是唯一的解决方案。

ARM官网 上就能直接下载各种平台( LinuxWindows )的编译工具链,另外一个很常见的第三方工具链制作商,linaro也是对外直接提供可用版本的工具链,但不支持全平台, linaro gnu

如果上述两个网站你都觉得不是很符合自己的开发板,那么你也可以自己动手做一个链子,符合目标平台的交叉编译链制作及简单分析。 这里主要使用crosstool-ng这个工具进行的制作,自己做链子的好处是你可以自己按需选择对应的gcc版本,以及glibc版本和一些其他重要基础库的版本。而上面现成的链子可能并不刚好是你需要的版本搭配。

4. 如何编译一个第三方开源库?

以下编译前提均假设编译人员已经获得了一个可以使用的交叉编译工具链,并且已经将编译工具链的可执行程序设置进环境变量。

4.1 最常见也是最简单的编译方式

大多数的简单第三方库,均可以尝试以下的方式。这里的简单,值得是不额外依赖一些其他第三方库的库。

configure --host=arm-linux-gnueabi  --prefix=${PWD}/build
make
make install
  • 1
  • 2
  • 3

一般验证自己是不是编译的正确的主要检查步骤就是,在make的时候查看对应输出打印,如果开头的的编译的确是对应编译工具链名称的开头,那么至少配置是对的,这里给个例子:

/bin/sh ../libtool  --tag=CXX   --mode=compile arm-at91-linux-gnueabi-g++ -std=gnu++11 -DHAVE_CONFIG_H -I. -I..  -I/data1/xiaoyanyi/work/snmp++/snmp++-3.5.0/include -pthread -I/data1/xiaoyanyi/work/openssllll/include -D_GNU_SOURCE  -g -O2 -pthread -MT asn1.lo -MD -MP -MF .deps/asn1.Tpo -c -o asn1.lo asn1.cpp
libtool: compile:  arm-at91-linux-gnueabi-g++ -std=gnu++11 -DHAVE_CONFIG_H -I. -I.. -I/data1/xiaoyanyi/work/snmp++/snmp++-3.5.0/include -pthread -I/data1/xiaoyanyi/work/openssllll/include -D_GNU_SOURCE -g -O2 -pthread -MT asn1.lo -MD -MP -MF .deps/asn1.Tpo -c asn1.cpp  -fPIC -DPIC -o .libs/asn1.o
libtool: compile:  arm-at91-linux-gnueabi-g++ -std=gnu++11 -DHAVE_CONFIG_H -I. -I.. -I/data1/xiaoyanyi/work/snmp++/snmp++-3.5.0/include -pthread -I/data1/xiaoyanyi/work/openssllll/include -D_GNU_SOURCE -g -O2 -pthread -MT asn1.lo -MD -MP -MF .deps/asn1.Tpo -c asn1.cpp -o asn1.o >/dev/null 2>&1
  • 1
  • 2
  • 3

make的过程中,输出的打印里的确是以我们指定的host开头的,那么这步骤至少是没问题的了。

4.2.1 简单解释一下configure里面常见通用参数的含义

先简单的介绍一下 configure make make install三部曲每部分是做了什么,然后在展开介绍configure的通用参数。

configure : 通过配置生成makefile文件
make:使用configure生成的Makefile文件进行编译工作
make install:将make生成的成果物文件按照prefix的路径进行复制,有时候也会动态生成一些说明文档

所以大多数时候,configure配置对了,后面两步就一定能走通,99%的编译不过问题基本都是configure的时候配置不对。以下重点讲解configure相关的参数。

4.2.1.1 prefix

这里prefix中文解释就是安装路径,也就是make install的时候,最终这些库会放到哪里去。一般对于交叉编译而言,是需要指定的,因为默认的路径是 /usr/local/,但这路径实际上对于交叉编译而言一定是不行的。因为这个路径通常放的是本身系统的库,如果交叉编译的库放进去后,本身系统也会去检索这个路径下的库,名字虽然匹配上了,但是用不起来,后续会造成极大的麻烦。

这里还有一点需要额外注意的,后文会重新展开这个问题。这里先说结论:

如果你需要编译的库ABC依赖DEF,那么你先编译DEF的时候,最好把prefix设置成自己交叉编译链的 sysroot/usr 中。

4.2.1.2 host

简单解释一下,交叉编译和普通的编译第三方库差异主要在于需要指定host这个变量。这里贴一段标准解释:

System types:
  --build=BUILD     configure for building on BUILD [guessed]
  --host=HOST       cross-compile to build programs to run on HOST [BUILD]
  • 1
  • 2
  • 3

这里仅需要额外强调一点,host这个参数的指定逻辑和使用的目标编译工具链名称有关,假设你的编译工具链的gcc名字叫 arm-at91-linux-gnueabi-gcc,那么这里的host名字就是 arm-at91-linux-gnueabi。具体的逻辑就是把对应链子的 -gcc部分拆掉就是host的名字。其实configure脚本的逻辑也就是对应的反向在host后拼接一个 -gcc而已。

4.2.1.3 enable-xxxxx disable-xxxxx

这两个参数一般是在编译的时候,可配置的打开某些功能或者关闭某些功能的时候,会使用到。每个第三方库的特色不一样,这里推荐遇到编译不过的问题,首先就去看看:


./configure --help
  • 1
  • 2

有时候发现你编译的第三方库依赖了过多的其他库,而且这些功能对你并不需要的时候,可以尽可能的--disable-xxxxx--without-xxxx。这样,在后续make的时候,就不会出现某些依赖库找不到的报错了。

4.2.1.4 CXX CC CFLAGS

这些shell环境变量也会产生一定的效果,有时候你百度一些博客教程的时候,会搜到交叉编译的某些指导文档会这么写:


./configure CC=arm-linux-gnueabi-gcc  --prefix=${PWD}/build
make
make install
  • 1
  • 2
  • 3
  • 4

或者写成这样:

export CC=arm-linux-gnueabi-gcc 
./configure  --prefix=${PWD}/build
make
make instal
  • 1
  • 2
  • 3
  • 4

这两种手法的思路一致的,都是利用./configure 脚本是可以阅读当前shell环境变量中的CC,并且将这个环境变量替换到脚本里,从而实现CC替换成对应链子的目的。相关解释同样可以在--help中看到

Some influential environment variables:
  CC          C compiler command
  CFLAGS      C compiler flags
  LDFLAGS     linker flags, e.g. -L<lib dir> if you have libraries in a
              nonstandard directory <lib dir>
  • 1
  • 2
  • 3
  • 4
  • 5

但需要注意的一点是:这种方法有一些隐患,主要在于在很多时候,交叉编译并不是把gcc变成arm-linux-gnueabi-gcc一切就完事了。我们可以在Makefile中和交叉编译链的bin路径中看到如下打印:

(base) xiaoyanyi@snmp++-3.5.0$ls ~/cross-tool/arm-at91-linux-gnueabi/bin
arm-at91-linux-gnueabi-addr2line     arm-at91-linux-gnueabi-gcc-4.9.2   arm-at91-linux-gnueabi-nm
arm-at91-linux-gnueabi-ar            arm-at91-linux-gnueabi-gcc-ar      arm-at91-linux-gnueabi-objcopy
arm-at91-linux-gnueabi-as            arm-at91-linux-gnueabi-gcc-nm      arm-at91-linux-gnueabi-objdump
arm-at91-linux-gnueabi-c++           arm-at91-linux-gnueabi-gcc-ranlib  arm-at91-linux-gnueabi-populate
arm-at91-linux-gnueabi-cc            arm-at91-linux-gnueabi-gcov        arm-at91-linux-gnueabi-ranlib
arm-at91-linux-gnueabi-c++filt       arm-at91-linux-gnueabi-gdb         arm-at91-linux-gnueabi-readelf
arm-at91-linux-gnueabi-cpp           arm-at91-linux-gnueabi-gprof       arm-at91-linux-gnueabi-size
arm-at91-linux-gnueabi-ct-ng.config  arm-at91-linux-gnueabi-ld          arm-at91-linux-gnueabi-strings
arm-at91-linux-gnueabi-elfedit       arm-at91-linux-gnueabi-ld.bfd      arm-at91-linux-gnueabi-strip
arm-at91-linux-gnueabi-g++           arm-at91-linux-gnueabi-ldd
arm-at91-linux-gnueabi-gcc           arm-at91-linux-gnueabi-ld.gold
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

有些Makefile生成的环境中还需要使用ARNMRANLIB等等,这些东西也需要使用对应的交叉编译工具链版本的程序。而上述的CC手法仅仅替换了一个。所以为什么是存在风险和后续还会遇到问题的。

这里推荐,如果能用host,就优先使用host指定,这种方式相当于自动帮你设置了上述每一个需要替换的变量。

在极少数的时候,host不被confiugre支持,那么能用的方法只有这种了,但同时也是最不同推荐的。

4.2.2 对于编译自依赖的第三方库族的推荐方法

有时候,我们使用的第三方库有两层甚至多层,底层他们自己开发了一个基础库,然后在自己的基础库上,封了一层应用。例如protobuf和grpc,snmp++和agent++。这个时候,我们需要先编译基础库,然后在编译应用库。这一点很容易理解,从下到上,但对于交叉编译来说,又有一点需要注意的。主要和 prefix 有关。

当我们编译完基础库之后,一般make install之后,成果物大多情况是这样的[假设我们安装到了一个build目录]:

(base) xiaoyanyi@build$ls
bin  include  lib
  • 1
  • 2

bin: 目录一般是这个库的一些demo样例或者一些可执行程序。
include:目录一般就是这个第三方库的头文件
lib:目录一般就是这个第三方库的静态、动态库文件

那么在编译上层应用库的时候,有些教程会推荐按照configure中的指定底层库路径宏变量的方式,去显式指定对应路径。

例如agent++这个库,依赖snmp++,其中agent++的confiugre文件里有这样一个变量:

Some influential environment variables:
  PKG_CONFIG  path to pkg-config utility
  PKG_CONFIG_PATH
              directories to add to pkg-config's search path
  PKG_CONFIG_LIBDIR
              path overriding pkg-config's built-in search path
  CXXCPP      C++ preprocessor
  snmp_CFLAGS C compiler flags for snmp, overriding pkg-config
  snmp_LIBS   linker flags for snmp, overriding pkg-config
  LT_SYS_LIBRARY_PATH
              User-defined run-time library search path.

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

这里可以通过snmp_LIB这个变量显式指定去libsnmp++.so的位置。这种方式在表面上是能够通过编译并且不会出现什么太大问题的。但会买下一个隐患在于后续使用库或者维护的时候会出现问题。这里需要讲解造成这个隐患问题的另外两个文件。

4.2.2.1 *.la

一般编译完成的第三方库的lib文件夹下面,会有一个对应扩展名为la的文件。

例如:

(base) xiaoyanyi@lib$ls
libsnmp++.a  libsnmp++.la  libsnmp++.so  libsnmp++.so.35  libsnmp++.so.35.0.0  pkgconfig
  • 1
  • 2

这个la文件实际上并不是一个静/动态库程序,而是一个配置文件,我们可以直接vim打开:

# libsnmp++.la - a libtool library file
# Generated by libtool (GNU libtool) 2.4.6 Debian-2.4.6-14
#
# Please DO NOT delete this file!
# It is necessary for linking the library.

# The name that we can dlopen(3).
dlname='libsnmp++.so.35'

# Names of this library.
library_names='libsnmp++.so.35.0.0 libsnmp++.so.35 libsnmp++.so'

# The name of the static archive.
old_library='libsnmp++.a'

# Linker flags that cannot go in dependency_libs.
inherited_linker_flags=' -pthread'

# Libraries that this one depends upon.
dependency_libs=' -lssl -lcrypto 
/data1/xiaoyanyi/cross-tool/arm-at91-linux-gnueabi/arm-at91-linux-gnueabi/lib/libstdc++.la'

# Names of additional weak libraries provided by this library
weak_library_names=''

# Version information for libsnmp++.
current=35
age=0
revision=0

# Is this an already installed library?
installed=yes

# Should we warn about portability when linking against -modules?
shouldnotlink=no

# Files to dlopen/dlpreopen
dlopen=''
dlpreopen=''

# Directory that this library needs to be installed in:
libdir='/data1/xiaoyanyi/work/snmp++/snmp++-3.5.0/build/lib'

  • 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

关键的问题就在最后的libdir里,这个路径指明了这个动态库的安装路径,在连接的过程中,如果有动态库依赖动态库的情况,gcc的连接应用会顺着路径去查找随影目标文件。而如果我们安装的时候,就随意选一个build目录。最后即便是把这个路径放到了sysroot里去之后,这个la文件里面的内容依旧不会变,导致最后就找不到了。

4.2.2.2 pkgconfig *.pc

大多数库编完之后,除了上述的la,其实大家也可以在lib里面找到有一个pkgconfig文件夹,里面会有一个对应的pc文件。

这个文件和la文件的作用极为相似,也是一个配置文件,我们可以打开看看。


prefix=/data1/xiaoyanyi/work/snmp++/snmp++-3.5.0/build
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include
modules=

Name: snmp++
Version: 3.5.0
Description: SNMP C++ framework version 3
Requires:
Libs: -L${libdir} -lsnmp++
Libs.private:  -lssl -lcrypto
Cflags: -I${includedir}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

这里造成错误的原因是因为prefix这个变量也是会因为随意指定从而即便pc文件的位置移动也无法正确索引。pc文件主要是pkg-config这个应用为了编译的时候自动指定FLAGS和自动找库用的。

4.2.2.3 那么应该安装在哪里比较合适呢?

这里推荐的路径是安装在编译工具链的sysroot中。

一般用crosstool-ng做的交叉工具链,对应的sysroot是在

${host}/${host}/sysroot/

对于其他的链子,基本也都有一个sysroot,可以自行查找。而我们安装一般的第三方库,一般推荐放在sysroot中的usr中,因为不是系统库,而是用户自己制作的。

这个路径下的,例如我做了一个链子,host是 arm-at91-linux-gnueabi,那么对应的sysroot/usr路径是:

arm-at91-linux-gnueabi/arm-at91-linux-gnueabi/sysroot/usr/

所以绝大部分的时候,将第三方库的prefix指定为上述路径,可以直接将第三方库安装到编译工具链中,从而达到后续编译其他库的时候,链子会自动索引已经安装过的库,不需要显示指定对应路径的目的。

但这里同样有一个风险点:如果对应第三方库的更新较为频繁,那么就可能存在要编译的新库但sysroot里有个老库的场景。这里推荐是更新较为频繁的库不要放到sysroot里去

4.2 一些不常见但是常用的第三方库可能的编译方式

4.1里介绍的手法基本上能处理绝大多数的第三方库,但仍旧有某些库的configure写的比较奇特,按照常规三部曲无法解决的。一般通用的处理思路还是好好阅读configure --help,同时这里重点解释两个场景。

4.2.1 常见开源加密算法库OpenSSL

这个动态库的交叉编译就不合适上述的情况,configure中并没有指定host的能力,这里推荐的建议是这样,先说结果:


./Configure linux-armv4 no-asm shared
  • 1
  • 2

这个库为什么会这么输入,我们又是如何知道的呢?其实还是看configure --help就能发现端倪。

(base) xiaoyanyi@openssl-1.0.2t$./Configure --help
Configuring for
Usage: Configure [no-<cipher> ...] [enable-<cipher> ...] [experimental-<cipher> ...] [-Dxxx] [-lxxx] [-Lxxx] [-fxxx] [-Kxxx] [no-hw-xxx|no-hw] [[no-]threads] [[no-]shared] [[no-]zlib|zlib-dynamic] [no-asm] [no-dso] [no-krb5] [sctp] [386] [--prefix=DIR] [--openssldir=OPENSSLDIR] [--with-xxx[=vvv]] [--test-sanity] os/compiler[:flags]

pick os/compiler from:
BC-32 BS2000-OSD BSD-generic32 BSD-generic64 BSD-ia64 BSD-sparc64 BSD-sparcv8
BSD-x86 BSD-x86-elf BSD-x86_64 Cygwin Cygwin-x86_64 DJGPP MPE/iX-gcc OS2-EMX
OS390-Unix QNX6 QNX6-i386 ReliantUNIX SINIX SINIX-N UWIN VC-CE VC-WIN32
VC-WIN64A VC-WIN64I aix-cc aix-gcc aix3-cc aix64-cc aix64-gcc android
android-armv7 android-mips android-x86 android64-aarch64 aux3-gcc
beos-x86-bone beos-x86-r5 bsdi-elf-gcc cc cray-j90 cray-t3e darwin-i386-cc
darwin-ppc-cc darwin64-ppc-cc darwin64-x86_64-cc dgux-R3-gcc dgux-R4-gcc
dgux-R4-x86-gcc dist gcc hpux-cc hpux-gcc hpux-ia64-cc hpux-ia64-gcc
hpux-parisc-cc hpux-parisc-cc-o4 hpux-parisc-gcc hpux-parisc1_1-cc
hpux-parisc1_1-gcc hpux-parisc2-cc hpux-parisc2-gcc hpux64-ia64-cc
hpux64-ia64-gcc hpux64-parisc2-cc hpux64-parisc2-gcc hurd-x86 iphoneos-cross
irix-cc irix-gcc irix-mips3-cc irix-mips3-gcc irix64-mips4-cc irix64-mips4-gcc
linux-aarch64 linux-alpha+bwx-ccc linux-alpha+bwx-gcc linux-alpha-ccc
linux-alpha-gcc linux-aout linux-armv4 linux-elf linux-generic32
linux-generic64 linux-ia32-icc linux-ia64 linux-ia64-icc linux-mips32
linux-mips64 linux-ppc linux-ppc64 linux-ppc64le linux-sparcv8 linux-sparcv9
linux-x32 linux-x86_64 linux-x86_64-clang linux-x86_64-icc linux32-s390x
linux64-mips64 linux64-s390x linux64-sparcv9 mingw mingw64 ncr-scde
netware-clib netware-clib-bsdsock netware-clib-bsdsock-gcc netware-clib-gcc
netware-libc netware-libc-bsdsock netware-libc-bsdsock-gcc netware-libc-gcc
newsos4-gcc nextstep nextstep3.3 osf1-alpha-cc osf1-alpha-gcc purify qnx4
rhapsody-ppc-cc sco5-cc sco5-gcc solaris-sparcv7-cc solaris-sparcv7-gcc
solaris-sparcv8-cc solaris-sparcv8-gcc solaris-sparcv9-cc solaris-sparcv9-gcc
solaris-x86-cc solaris-x86-gcc solaris64-sparcv9-cc solaris64-sparcv9-gcc
solaris64-x86_64-cc solaris64-x86_64-gcc sunos-gcc tandem-c89 tru64-alpha-cc
uClinux-dist uClinux-dist64 ultrix-cc ultrix-gcc unixware-2.0 unixware-2.1
unixware-7 unixware-7-gcc vos-gcc vxworks-mips vxworks-ppc405 vxworks-ppc60x
vxworks-ppc750 vxworks-ppc750-debug vxworks-ppc860 vxworks-ppcgen
vxworks-simlinux debug debug-BSD-x86-elf debug-VC-WIN32 debug-VC-WIN64A
debug-VC-WIN64I debug-ben debug-ben-darwin64 debug-ben-debug
debug-ben-debug-64 debug-ben-debug-64-clang debug-ben-macos
debug-ben-macos-gcc46 debug-ben-no-opt debug-ben-openbsd
debug-ben-openbsd-debug debug-ben-strict debug-bodo debug-darwin-i386-cc
debug-darwin-ppc-cc debug-darwin64-x86_64-cc debug-geoff32 debug-geoff64
debug-levitte-linux-elf debug-levitte-linux-elf-extreme
debug-levitte-linux-noasm debug-levitte-linux-noasm-extreme debug-linux-elf
debug-linux-elf-noefence debug-linux-generic32 debug-linux-generic64
debug-linux-ia32-aes debug-linux-pentium debug-linux-ppro debug-linux-x86_64
debug-linux-x86_64-clang debug-rse debug-solaris-sparcv8-cc
debug-solaris-sparcv8-gcc debug-solaris-sparcv9-cc debug-solaris-sparcv9-gcc
debug-steve-opt debug-steve32 debug-steve64 debug-vos-gcc

NOTE: If in doubt, on Unix-ish systems use './config'.
  • 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
  • 46
  • 47
  • 48

这个库显式指定了所有它能支持的平台种类,所以我们只能按照这个指定了。这样指定完之后,需要把生成的Makefile中对应宏定义进行显式修改,包括但不限于CCARNMRANLIBCXX等等。

4.2.2 有些库压根就没有configure,需要用cmake

有些开源库就没给configure,但是能看到cmakelist的文件,那么这个时候,把cmakelist当做configure就好,但是它的指定写法会稍显不同。一般需要提供一个对应的cmake配置,对于我们交叉编译来说,需要指定对应的host系统名称CMAKE_SYSTEM_NAME和处理器名称CMAKE_SYSTEM_PROCESSOR,然后需要指定对应的编译链路径CMAKE_C_COMPILERCMAKE_CXX_COMPILER

这里给出一个样例:

 #arm.cmake
 set(CMAKE_SYSTEM_NAME Linux)
 set(CMAKE_SYSTEM_PROCESSOR arm)
 
 set(tools /data1/xiaoyanyi/cross-tool/arm-imx6ul-linux-gnueabihf/bin/arm-imx6ul-linux-gnueabihf-)
 set(CMAKE_C_COMPILER ${tools}gcc)
 set(CMAKE_CXX_COMPILER ${tools}g++)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

cmake文件准备好后,就可以直接cmake了,机理是和configure一样的:

cmake CMakeLists.txt -DCMAKE_TOOLCHAIN_FILE=./arm.cmake
  • 1

上述命令执行完后,最终会生成一个Makefile文件,接着makemake install就好了。

5. 总结

  • 绝大多数场景,交叉编译的时候configuremakemake install三部曲就好,与普通编译不一样的是需要指定host

  • 自依赖第三方库在不频繁迭代更新的条件下,建议安装到交叉编译链的sysroot目录中

  • 如果怎么编译都有点问题,建议仔细阅读configure --help或者README分析查找端倪

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/282338
推荐阅读
相关标签
  

闽ICP备14008679号