Makefile中级篇:驾驭自动编译的力量

0. 前言

        本篇博客适合帮助Makefile初学者过渡到中高级水平,但深入的技能仍需在实践中不断积累。

1. 基础概念

1.1 目标、依赖和规则: 理解Makefile中的基本结构和工作原理。

        在Makefile中,目标、依赖和规则的概念构成了核心,它们定义了如何构建和更新项目的文件。

目标 (Targets):
        目标通常代表要构建的文件名。它可以是一个可执行文件、一个对象文件,或者甚至是一个标签(伪目标)。

        例如,在以下规则中,`program`是目标:

program: main.o utils.o
    gcc -o program main.o utils.o

依赖 (Dependencies):
        依赖是目标所依赖的文件。只有当一个或多个依赖比目标更新时,目标才会被重新构建。

在上面的例子中,`main.o`和`utils.o`是`program`的依赖。

规则 (Rules):
        规则定义了如何从依赖构建目标。它是一个命令序列,通常是shell命令。

        在上面的例子中,规则是:

gcc -o program main.o utils.o

        当键入`make program`,Make会检查`program`的依赖是否比它新,如果是,则执行规则来构建它。

1.2 变量: 学习如何在Makefile中设置和使用变量。

        在Makefile中,你可以使用变量来保存并复用值。这在多处需要相同值或配置时非常有用。例如,你可能希望在多个规则中使用相同的编译器或相同的编译器标志。

定义变量:

        定义变量的语法很简单:

VARIABLE_NAME = value

        或

VARIABLE_NAME := value

        两者之间的主要区别是`=`使用的是懒惰赋值,只在使用时评估其值,而`:=`使用立即赋值,立即评估其值。

使用变量:

        要在Makefile中使用变量,你可以使用`$(VARIABLE_NAME)`或`${VARIABLE_NAME}`。例如:

CC = gcc
CFLAGS = -Wall

program: main.o utils.o
    $(CC) $(CFLAGS) -o program main.o utils.o

        这里,我们定义了两个变量`CC`和`CFLAGS`,然后在规则中使用它们。这样,如果我们决定更改编译器或标志,我们只需要在一个地方更改它,而不是在整个Makefile中。

        Makefile中的目标、依赖和规则是其核心,它们告诉Make如何构建你的项目。而变量提供了一种方式来复用和配置这些构建步骤,使得Makefile更加灵活和可维护。理解这些基础概念是编写和理解Makefile的关键。

2. 模式规则

        模式规则是Makefile中的一种强大工具,允许你为一组文件定义规则,而不是为每个文件单独定义。这通过使用模式(通常是%字符)来匹配文件名的一部分来实现。

2.1 基本的模式规则:

        一个基本的模式规则看起来像这样:

%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@

在这里:

  • `%.o` 是目标模式,表示所有以`.o`结尾的文件。
  • `%.c` 是依赖模式,表示所有以`.c`结尾的文件。
  • `$<` 是一个自动变量,代表规则的第一个依赖(在本例中是`%.c`文件)。
  • `$@` 是一个自动变量,代表规则的目标(在本例中是`%.o`文件)。

这条规则告诉make如何从一个`.c`文件构建一个`.o`文件。

        例如,假设你的项目有一个名为`main.c`的源文件。由于上面的模式规则,你可以简单地键入`make main.o`,make就会知道如何使用上面的规则来编译`main.c`并生成`main.o`。

2.2 内置模式规则:

        GNU Make提供了一系列内置的模式规则,这意味着在许多情况下,你甚至不需要为常见的操作(如从`.c`文件编译`.o`文件)编写规则。

        这也是为什么你可以在没有Makefile的情况下使用make的原因。例如,如果你只有一个`main.c`文件,只需键入`make main`,GNU Make会使用其内置的模式规则来编译它。

        但是,大多数项目都需要更多的定制,这就是为什么在Makefile中定义你自己的规则通常是有益的。

2.3 自定义模式规则:

        除了基本的模式规则外,你可以定义更复杂的模式,以处理项目中的特定情况。

        例如,假设你的源文件存放在一个名为`src`的目录中,但你希望对象文件生成到`build`目录中。你可以使用以下模式规则:

build/%.o: src/%.c
    $(CC) $(CFLAGS) -c $< -o $@

        现在,假设你有一个文件`src/main.c`,当你键入`make build/main.o`时,上述规则将被用来从`src/main.c`生成`build/main.o`。

        模式规则为Makefile提供了一种高效、简洁的方式,可以为许多文件定义通用的构建和更新规则。它们通过使用模式来匹配文件名部分,使得规则定义更加灵活。

3. 函数

        GNU Make提供了一系列函数,帮助你更加灵活地操作变量、文件名等。下面举例说明常见的函数用法。

3.1 $(wildcard ...)

        这个函数用于获取匹配给定模式的文件列表。例如,要获取当前目录下所有的`.c`文件,你可以这样做:

SOURCES = $(wildcard *.c)

        如果你想获取多级目录下的所有`.c`文件,你可以使用:

SOURCES = $(wildcard src/**/*.c)

3.2 $(patsubst ...)

        这个函数执行模式替换。它的常见用途是从一种文件列表生成另一种。例如,从`.c`文件列表生成对应的`.o`文件列表:

SOURCES = $(wildcard *.c)
OBJECTS = $(patsubst %.c,%.o,$(SOURCES))

        这里,`$(patsubst %.c,%.o,$(SOURCES))`将`SOURCES`中的每个`.c`文件转换为相应的`.o`文件。

3.3 $(foreach ...)

        这个函数用于循环。它对列表中的每个项执行某个操作。例如,给每个名字打招呼:

NAMES = Alice Bob Charlie
GREETINGS = $(foreach name,$(NAMES),Hello $(name)!)

        这会生成`GREETINGS`变量,其内容是"Hello Alice! Hello Bob! Hello Charlie!"。

3.4 $(filter ...) 和 $(filter-out ...)

        $(filter pattern...,text) 返回`text`中所有匹配`pattern`的单词。

  FILES = foo.c bar.js baz.c
  C_FILES = $(filter %.c,$(FILES))

        这会返回所有`.c`文件,即`foo.c baz.c`。

         `$(filter-out pattern...,text)`与`$(filter ...)`相反,返回不匹配`pattern`的单词。

  FILES = foo.c bar.js baz.c
  NOT_C_FILES = $(filter-out %.c,$(FILES))

        这会返回不是`.c`文件的文件,即`bar.js`。

3.5 文件名操作

  •  `$(dir names...)`: 返回每个文件名的目录部分。例如,`$(dir src/foo.c bin/bar)`返回`src/ bin/`.
  •  `$(notdir names...)`: 返回每个文件名的非目录部分。例如,`$(notdir src/foo.c bin/bar)`返回`foo.c bar`.
  •  `$(suffix names...)`: 返回每个文件名的后缀。例如,`$(suffix src/foo.c bin/bar.js)`返回`.c .js`.
  •  `$(basename names...)`: 返回每个文件名,但不带后缀。例如,`$(basename src/foo.c bin/bar.js)`返回`src/foo bin/bar`.

        GNU Make的这些函数为Makefile的作者提供了丰富的工具,使得文件操作、模式匹配和文本处理变得简单且灵活。理解这些函数如何工作对于编写复杂的Makefile是关键。

4. 自动变量

        在Makefile中,自动变量为我们提供了一种方式,可以引用规则的目标、依赖和其他相关内容,而不必明确地指定它们。这增加了Makefile的灵活性和可重用性。

4.1 $@: 目标文件名

        这个变量代表规则的目标。考虑以下规则:

my_program: main.o utils.o
    gcc -o $@ $^

        在这里,`$@`代表`my_program`。

4.2 $^: 所有的依赖文件,不重复

         这个变量表示所有的依赖文件,但是不会重复。继续上面的例子:

my_program: main.o utils.o
    gcc -o $@ $^

        在这里,`$^`将代表`main.o utils.o`。如果有重复的依赖,`$^`只会列出它们一次。

4.3 $<: 第一个依赖文件

        这个变量表示规则中的第一个依赖文件。它在模式规则中特别有用。例如:

%.o: %.c
    gcc -c $< -o $@

        在这个规则中,如果你正在为`main.o`构建,那么`$<`将代表`main.c`。

4.4 $(?): 新于目标的所有依赖文件

        这个变量列出所有比目标新的依赖文件。它在增量构建中非常有用,当你只想重新构建那些由于某些依赖关系而已经更改的部分时。

        考虑以下规则:

my_program: main.o utils.o
    gcc -o $@ $?

        如果`utils.o`是唯一在上次构建`my_program`后修改过的文件,那么`$?`将只代表`utils.o`。

        自动变量在Makefile中为我们提供了一个简洁和灵活的方法来引用规则的目标、依赖和其他关系,使得我们不必多次明确地指定它们。理解并有效地使用这些自动变量可以简化Makefile并提高其可维护性。

5. 条件执行

        在Makefile中,有时你可能想要根据某些条件来执行不同的操作。例如,你可能想要根据当前的操作系统或编译器选项来更改构建参数。为了支持这种需求,GNU Make提供了一系列的条件指令。

5.1 ifeq (arg1, arg2)

        这个指令用于检查两个参数是否相等。如果它们相等,那么`ifeq`块中的代码会被执行。

ifeq ($(CC),gcc)
  CFLAGS = -Wall
else
  CFLAGS = -O2
endif

        在上述例子中,如果变量`CC`的值是`gcc`,则`CFLAGS`被设置为`-Wall`;否则,它被设置为`-O2`。

5.2 ifneq (arg1, arg2)

        这与`ifeq`相反,它检查两个参数是否**不**相等。

ifneq ($(DEBUG),true)
  CFLAGS = -O2
else
  CFLAGS = -g
endif

        如果`DEBUG`变量不等于`true`,则`CFLAGS`被设置为`-O2`;否则,它被设置为`-g`。

5.3 ifdef variable-name

        这个指令检查一个变量是否已定义。如果定义了这个变量,不管它的值是什么(即使为空),`ifdef`块的内容都会被执行。

ifdef OUTPUT_DIR
  ODIR = $(OUTPUT_DIR)
else
  ODIR = ./build
endif

        如果已经定义了`OUTPUT_DIR`变量,`ODIR`将被设置为其值;否则,它将默认为`./build`。

5.4 ifndef variable-name

        这与`ifdef`相反,它检查一个变量是否**没有**被定义。

ifndef CC
  CC = gcc
endif

        如果变量`CC`没有被定义,它将默认设置为`gcc`。

        条件指令在Makefile中允许根据特定的条件执行不同的操作。这为Makefile的作者提供了更大的灵活性,特别是当需要处理不同的平台、编译器或配置选项时。理解并有效地使用这些条件指令可以使你的Makefile更加适应不同的构建环境和需求。

6. 包含其他Makefiles

        在大型项目中,Makefile可能会变得相当庞大和复杂。为了提高可读性和维护性,一个常见的做法是将Makefile分解为多个小文件,然后在主Makefile中包含这些文件。GNU Make提供了`include`指令,用于此目的。

6.1 使用 `include`

        基本语法是:

include filename

        例如,假设你有三个额外的Makefiles:`variables.mk`、`rules.mk`和`config.mk`,你可以这样包含它们:

include variables.mk rules.mk config.mk

        这将按照指定的顺序导入这三个文件的内容。

6.2 动态生成的Makefiles

        你还可以包含由其他规则生成的Makefile。这在自动依赖管理中非常有用。例如,你可以使用gcc的`-M`选项来生成依赖,并将其保存在`.d`文件中:

-include $(DEPFILES)

        `-include`与`include`几乎相同,但它不会因为文件不存在或有任何其他错误而失败。

6.3 条件包含

        你可以结合使用`include`和Make的条件语句,根据特定的条件来决定是否包含某个文件。例如:

ifeq ($(DEBUG),true)
  include debug.mk
else
  include release.mk
endif

        根据`DEBUG`变量的值,你可以决定是包含`debug.mk`还是`release.mk`。

6.4 通配符

        你也可以使用通配符与`include`结合,例如,要包含所有`.mk`文件,你可以这样做:

include *.mk

        或者,要包含子目录中的所有Makefile:

include */*.mk

        `include`指令允许Makefile的作者将其内容分解为多个文件,从而提高了其可读性和维护性。这也为复用规则、变量定义和其他构建逻辑提供了机会。在大型项目中,有效地使用这个特性可以帮助你保持Makefile的结构清晰和组织良好。

7. 伪目标和.PHONY

        在Makefile中,大多数目标都是指向文件的,例如对象文件、库或可执行文件。然而,有些目标并不代表文件,而是代表一组要执行的命令。这种不代表文件的目标被称为"伪目标"。

        伪目标常用于执行一组命令,例如清除构建产物、生成文档、运行测试等。由于伪目标不是文件,它们不受文件的存在性或修改日期的影响。

例如,常见的`clean`目标通常用于删除所有生成的文件:

clean:
    rm -f *.o my_program

        当你执行`make clean`时,关联的命令将被执行,而不管是否存在一个名为`clean`的文件。

7.1 .PHONY

        问题是,如果存在一个名为`clean`的文件或目录,那么`make clean`可能不会按预期工作,因为make会检查文件的修改日期。为了告诉make一个目标是伪目标(不是文件),我们使用`.PHONY`声明。

        在上面的例子中,为了确保`clean`始终被视为伪目标,你可以这样做:

.PHONY: clean

clean:
    rm -f *.o my_program

        这样,即使存在一个名为`clean`的文件,`make clean`也会正常工作。

        你可以为Makefile中的多个伪目标使用`.PHONY`。例如:

.PHONY: clean install test

clean:
    rm -f *.o my_program

install:
    cp my_program /usr/local/bin/

test:
    ./test_script.sh

        伪目标是Makefile中的特殊目标,它们不代表文件,而是代表要执行的操作。使用`.PHONY`声明可以确保make正确地将这些目标视为伪目标,而不是文件。这对于确保命令如预期那样执行(特别是在可能存在与伪目标同名的文件时)非常重要。

8. 静默规则和特殊目标

        默认情况下,当`make`执行规则中的命令时,它会在执行之前回显(即打印)这些命令。虽然这在调试Makefile时很有帮助,但在常规使用中可能会引起混淆或导致输出过于冗长。

        为了阻止命令的回显,可以在命令前放置一个`@`符号。例如:

quiet:
    @echo This will be printed without showing the command

        如果你在Makefile中执行`make quiet`,你将只看到:

This will be printed without showing the command

        而不是:

echo This will be printed without showing the command
This will be printed without showing the command

        为了在整个Makefile中禁用命令回显,可以使用特殊目标`.SILENT`:

.SILENT:

quiet:
    echo This will also be printed without showing the command

8.1 特殊目标

        Make提供了一系列的特殊目标,这些目标具有特定的意义,不是真正的构建目标。

 .SUFFIXES

        `.SUFFIXES`定义了一组文件后缀,以用于旧式的隐式依赖规则。例如,Make默认知道`.c`源文件可以被编译为`.o`对象文件。你可以使用`.SUFFIXES`来清除所有的后缀或添加新的后缀。

# 清除所有的默认后缀
.SUFFIXES:

# 只定义.c和.o为有效的后缀
.SUFFIXES: .c .o

        在现代的Makefile中,许多人倾向于使用模式规则而不是后缀规则,但了解这个特殊目标仍然很有用,特别是当处理旧的Makefile时。

.DEFAULT

        如果make不能找到构建目标的规则,它会尝试使用`.DEFAULT`规则。这对于捕获未定义的目标很有用。

.DEFAULT:
    @echo "Unknown target: $@"

        如果你尝试构建一个未在Makefile中定义的目标,上面的`.DEFAULT`规则会打印一个消息。

        静默规则允许你控制Makefile命令的回显。特殊目标,如`.SUFFIXES`和`.DEFAULT`,为Makefile提供了额外的控制,允许你定义如何处理特定的情况或更改make的默认行为。理解和利用这些特性可以帮助你编写更强大和灵活的Makefile。

9. 高级依赖

        在大型项目中,源文件间的依赖关系可能会变得复杂。例如,一个C++源文件可能包含多个头文件,这些头文件又可能包含其他头文件。如果任何头文件发生更改,与之相关的源文件都应该重新编译。

        手动跟踪这些依赖关系并将其放入Makefile中是不切实际的,尤其是在文件经常变动的情况下。幸运的是,我们可以自动化这个过程。

9.1 生成动态依赖

        编译器(如GCC)通常提供了生成依赖信息的选项。对于GCC,这是`-M`和其变体。

        例如,对于一个名为`example.c`的源文件,以下命令:

gcc -M example.c

        会输出`example.c`文件的依赖关系,通常是这样的形式:

example.o: example.c header1.h header2.h ...

        为了将这些依赖关系保存到文件中,可以使用`-MF`选项。通常,这些依赖关系保存在`.d`文件中。例如:

gcc -M -MF example.d example.c

        这会生成一个名为`example.d`的文件,其中包含了`example.c`的所有依赖关系。

9.2 在Makefile中使用动态依赖

        一旦生成了`.d`文件,我们需要在Makefile中包含它们,这样make就可以知道何时重新编译源文件。

        使用`-include`(或`include`)指令来包含这些依赖文件:

SRC = example.c
OBJ = $(SRC:.c=.o)
DEP = $(OBJ:.o=.d)

my_program: $(OBJ)
    gcc $^ -o $@

-include $(DEP)

在这里:

  • 我们首先定义了源文件、对象文件和依赖文件的列表。
  • 在目标规则中,我们只是像往常一样编译我们的程序。
  • 使用`-include $(DEP)`来包含所有`.d`文件。`-include`是不会出错的,所以如果依赖文件不存在,make不会报错。

9.3 结合动态依赖生成和编译

        一个常见的做法是在编译源文件的同时生成依赖信息。GCC提供了`-MMD`选项,可以在编译时自动创建`.d`文件。

%.o: %.c
    gcc -c -MMD $< -o $@

        使用动态依赖,我们可以确保当头文件或其他依赖性更改时,只有受影响的源文件会被重新编译。这不仅减少了不必要的编译,还确保了构建的正确性。在大型项目中,这种自动依赖跟踪是至关重要的。

10. 内置函数和高级技巧

        Makefile提供了一系列强大的内置函数,允许开发者进行高级操作和处理。以下是其中一些函数的详细描述:

10.1 $(eval ...)

        `$(eval ...)`函数允许你动态地评估Makefile代码。这意味着你可以在Makefile执行时生成和评估新的规则、变量等。这是一个相当高级的功能,通常用于更复杂的Makefile场景。

        例如,假设你想为一个动态生成的文件列表创建规则:

FILES = file1 file2

define RULES
$(1).o: $(1).c
    gcc -c $(1).c -o $(1).o
endef

$(foreach file, $(FILES), $(eval $(call RULES, $(file))))

        上述代码为`file1`和`file2`定义了编译规则,即使这些文件名是从其他地方动态生成的。

10.2 $(call ...)

        `$(call ...)`函数允许你调用一个make宏。这是一种定义可重用的代码块并使用参数调用它的方法。

        在上面的`$(eval ...)`例子中,我们已经看到了`$(call ...)`的用法。在那里,`RULES`是一个宏,它接受一个参数(文件名),然后为该文件定义一个编译规则。

        以下是一个更简单的例子:

define say_hello
    @echo Hello, $(1)!
endef

greet:
    $(call say_hello, world)

        运行`make greet`将会输出"Hello, world!"。

10.3  $(value ...)

        `$(value ...)`函数返回变量的纯文本值,不执行任何变量或函数引用。

考虑以下例子:

VAR = value of VAR

ANOTHER_VAR = $(VAR)

result1:
    @echo $(ANOTHER_VAR) 

result2:
    @echo $(value ANOTHER_VAR)

        在这里,`result1`和`result2`都会输出"value of VAR"。但是,使用`$(value ...)`有其优势,特别是在处理包含多次引用或递归引用的变量时。`$(value ...)`直接获取变量的内容,不对其中的任何东西进行评估或扩展。

        Make的内置函数和高级技巧为开发者提供了大量的灵活性,可以用来处理复杂的构建场景和需求。通过理解并掌握这些功能,你可以更有效地组织和管理你的Makefile,从而优化你的构建流程。

11. 高级话题

        以下是Makefile的一些高级特性和技巧,它们能够帮助你更有效地构建大型或复杂的项目。

11.1 并行构建:使用`-j`参数

        默认情况下,`make`会一次处理一个目标。但在多核/多线程的现代计算机上,我们可以同时处理多个目标,从而加速构建过程。这是通过`make`的`-j`参数实现的,它允许你指定可以同时执行的最大任务数。

        例如,要允许最多4个并行任务,可以运行:

make -j4

        注意:并行构建要求你的Makefile是“线程安全”的。这意味着没有目标依赖于另一个目标的副作用。正确声明所有依赖关系是很重要的。

11.2 使用`VPATH`和`vpath`寻找文件

        在大型项目中,源文件可能会被组织在多个子目录中。`VPATH`和`vpath`是`make`提供的两种方法,用于指定在哪里查找文件。

        `VPATH`:是一个由冒号分隔的目录列表,当`make`需要找到文件时,它会在这些目录中搜索。

VPATH = src:../headers

        上述设置告诉`make`在`src`目录和`../headers`目录中查找文件。

        `vpath`:提供了更精细的控制,允许你为特定的文件模式指定目录。

    vpath %.h ../headers

        这告诉`make`只在`../headers`目录中查找`.h`文件。

11.3 使用`make`的双冒号规则

        通常,我们在Makefile中为每个目标使用一个规则。但是,有时我们可能希望对一个目标应用多个规则。这就是双冒号规则发挥作用的地方。

        使用双冒号(`::`)代替单冒号来定义规则。每个双冒号规则都是独立的,并且只有当目标不存在或比其依赖关系旧时,才会执行。

例如:

file.txt:: source1.txt
    cat source1.txt > file.txt

file.txt:: source2.txt
    cat source2.txt >> file.txt

        这两个规则都适用于`file.txt`。当我们运行`make file.txt`时,两个规则都会按顺序执行,这样`file.txt`会首先包含`source1.txt`的内容,然后是`source2.txt`的内容。

        上述高级技巧和特性可以帮助你更好地管理和优化你的构建流程,特别是在处理大型或复杂的代码库时。理解并正确使用这些特性可以使你的Makefile更加强大和灵活。

12. 最佳实践

        在创建和管理Makefiles时,遵循一些最佳实践可以确保你的构建过程既高效又可维护。

12.1 组织和模块化大型Makefile

        随着项目的增长,Makefile可能变得非常庞大和复杂。在这种情况下,将其模块化并将其组织成逻辑块是有意义的:

  • 将代码划分为多个Makefiles:对于大型项目,考虑将Makefile划分为多个子Makefiles,每个子Makefile负责项目的特定部分。然后,使用`include`指令将它们包含在主Makefile中。
  • 使用注释:在Makefile中,使用`#`开头的行来添加注释。这对于解释目标、变量和其他代码段的用途和工作方式非常有用。
  • 按功能组织:将相关的目标、变量和规则组织在一起,并使用空行和注释为每个部分提供上下文。

12.2 递归Make的问题

        在大型项目中,经常可以看到递归`make`的使用,这意味着从一个Makefile中调用`make`来构建另一个子目录中的目标。虽然这在某些情况下可能是有用的,但它通常是有问题的:

  • 依赖性问题:递归`make`可能会导致在不同的Makefile之间丢失依赖关系,这可能导致构建不正确。
  • 性能问题:对于每个子目录,启动一个新的`make`实例会增加构建时间。

非递归技巧:

        为了避免这些问题,可以使用非递归技巧:

  • 单一Makefile:尽管这可能听起来与直觉相反,但对于大多数项目,使用单一的Makefile(可能通过`include`指令模块化)通常比使用多个递归Makefiles更高效和更易于管理。
  • 使用`VPATH`或`vpath`:这些变量允许你指定`make`在哪里查找源文件,使你可以在单一的Makefile中构建多个目录的代码。

12.3 为可移植性设计

        如果你的项目可能在不同的系统或平台上构建,确保Makefile是可移植的:

  • 使用条件赋值:根据不同的系统或条件,使用条件赋值来设置变量。
  • 避免硬编码路径:使用Makefile变量而不是硬编码的路径和工具名。

        正确地组织和构建Makefile可以大大提高项目的构建效率和可维护性。确保了解递归与非递归的利弊,并始终考虑如何最大限度地提高Makefile的清晰性和可移植性。

13. 学习资源与实践

13.1 学习资源

  •  `man make`: 查看GNU Make的手册页。
  •  [GNU Make Manual](https://www.gnu.org/software/make/manual/make.html): 官方文档提供了详细的信息和示例。

13.2 实践

        学习Makefile的关键是实践。开始编写简单的Makefile,然后逐步增加复杂性。尝试为你的项目或其他开源项目编写或优化Makefile。

        当你遇到问题或不理解的地方,查阅文档、搜索相关问题或询问社区。逐步深入,不断实践,最终你会达到高级水平。

猜你喜欢

转载自blog.csdn.net/crr411422/article/details/131406443