• Makefile模板以及多层Makefile编译(附源码)


    本文将介绍几个Makefile模板:编译可执行程序、编译静态库、编译动态库。以及简单地介绍在项目工程多目录下多层Makefile如何编译

    关于Makefile的介绍,欢迎查阅之前发过的文章,认识Makefile以及基础知识Linux库概念、静态库、动态库

    1、前言

     现在很多IDE都集成了编译器,如Visual Studio等等,点击相关的编译按钮的一个操作即可完成编译、链接、生成目标文件。

     在Linux系统下,开发并编译程序一般用的gcc/g++编译器,如果是开发ARM架构环境下的Linux程序,还需用到arm-linux-gcc/arm-linux-g++交叉编译器。

     其实Linux下也可以实现“一键编译”功能,特别是在大工程项目下,此时需要借助Makefile,一个命令就完成编译、链接、生成目标文件就显得很有用处了。Makefile可以手动编写,也可以借助自动化构建工具(如scons、CMake、autotools等)生成。一般地一个通用的Makefile能够适合大部分Linux项目程序。

    2、Makefile工程管理

     工程管理器,顾名思义,是指管理较多的文件 Make工程管理器也就是个“自动编译管理器”,这里的“自动”是指它能构根据文件时间戳自动发现更新过的文件而减少编译的工作量,同时,它通过读入Makefile文件文件的内容来执行大量的编译工作。
     在一个makefile中通常包含如下内容:

      需要由make工具创建的目标体(target),通常是目标文件或可执行文件;

      要创建的目标体所依赖的文件(dependency_file);

      创建每个目标体时需要运行的命令(command),这一行必须以制表符(tab键)开头。

    makefile格式

    target: dependency_files
      <TAB>command      #该行必须以tab键开头
    
    • 1
    • 2

    3、Makefile模板

    3.1 使用Makefile编译可执行文件

    PROJECT_ROOT_PATH = /home/yimning/MakefileTemplate
    VERSION     = 1.0.0
    
    CC   =  gcc
    DEBUG   =  -DUSE_DEBUG
    CFLAGS  =  -Wall
    SOURCES   =  $(wildcard *.c)
    INCLUDES   =  -I../../include
    #LIB_NAMES  =-lfun_a -lfun_so	# 如果使用到相关库,可添加。若没有就注释该行。
    LIB_NAMES  =  -lfoo -lbar      # 比如我这里分别使用了静态库libfoo.a.1.0.0和libbar.so.1.0.0(已放到了/lib下)
    LIB_PATH  =  -L./lib		  #使用到库的路径,一般放于/lib或在/usr/lib目录下 
    OBJ   =  $(patsubst %.c, %.o, $(SOURCES))
    TARGET  =  app
    
    #links
    $(TARGET):$(OBJ)
    	@mkdir -p output
    	$(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    	@rm -rf $(OBJ)
     
    #compile
    %.o: %.c
    	$(CC) $(INCLUDES) $(DEBUG) -c $(CFLAGS) $< -o $@
    
    .PHONY:clean
    clean:
    	@echo "Remove linked and compiled files......"
    	rm -rf $(OBJ) $(TARGET) output 
    
    • 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

    本文将使用到的项目目录如下图所示:

    【要点说明】

    (01)程序版本

    开发调试过程可能产生多个程序版本,可以在目标文件后(前)增加版本号标识。如果不指定输出路径则会默认放到当前目录下。

    VERSION     = 1.0.0
    $(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    
    • 1
    • 2

    (02)编译器选择

    Linux下为gcc/g++;arm下为arm-linux-gcc;不同CPU厂商提供的定制交叉编译器名称可能不同,如使用“arm-linux-gnueabi-gcc”。

    VERSION = 1.00
    $(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    CC = gcc
    
    • 1
    • 2
    • 3

    (03)宏定义

    开发过程中,特殊代码一般增加宏条件来选择是否编译,如调试打印输出代码。-D是标识,后面接着的是“宏”。

    VERSION = 1.0.0
    $(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    DEBUG = -DUSE_DEBUG
    
    • 1
    • 2
    • 3

    (04)编译选项

    可以指定编译条件,如显示警告(-Wall),优化等级(-O)。

    VERSION = 1.0.0
    $(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    CFLAGS =  -Wall -O
    
    • 1
    • 2
    • 3

    (05)源文件

    指定源文件目的路径(相对路径或者绝对路径),利用“wildcard”获取路径下所有依赖源文件。

    VERSION = 1.0.0
    $(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    SOURCES   =  $(wildcard *.c)
    
    • 1
    • 2
    • 3

    (06)头文件

    包含依赖的头文件,包括源码文件和库文件的头文件。

    VERSION = 1.0.0
    $(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    INCLUDES   =  -I../../include
    
    • 1
    • 2
    • 3

    (07)库文件名称

    指定库文件名称,库文件有固定格式,静态库为libxxx.a;动态库为libxxx.so,指定库文件名称只需写“xxx”部分,

    #LIB_NAMES  =-lfun_a -lfun_so	# 如果使用到相关库,可添加。若没有就注释该行。
    LIB_NAMES  =  -lfoo -lbar      # 比如我这里分别使用了静态库libfoo.a.1.0.0和libbar.so.1.0.0(默认已放到了/lib下)
    
    • 1
    • 2

    (08)库文件路径

    指定依赖库文件的存放路径。注意如果引用的是动态库,动态库也许拷贝到“/lib”或者“/usr/lib”目录下,执行应用程序时,系统默认在该文件下索引动态库。

    LIB_PATH  =  -L./lib		  #使用到库的路径,一般放于/lib或在/usr/lib目录下 
    
    • 1

    (09)目标文件

    调用“patsubst”将源文件(.c)编译为目标文件(.o)。

    OBJ   =  $(patsubst %.c, %.o, $(SOURCES)) #符合模式[%.c]的单词替换成[%.o] 即将.c文件转为.o文件 
    
    • 1

    (10)执行文件

    执行文件名称

    TARGET  =  app
    
    • 1

    (11)编译

    #compile
    %.o: %.c
    	$(CC) $(INCLUDES) $(DEBUG) -c $(CFLAGS) $< -o $@
    
    • 1
    • 2
    • 3

    (12)链接

    可创建一个“output”文件夹存放目标执行文件。链接完输出目标执行文件,可以删除编译产生的临时文件(.o)。

    #links
    $(TARGET):$(OBJ)
    	@mkdir -p output
    	$(CC) $(OBJ) $(LIB_PATH) $(LIB_NAMES) -o output/$(TARGET).$(VERSION)
    	@rm -rf $(OBJ)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    (13)清除编译信息

    执行“make clean”清除编译产生的临时文件。

    .PHONY:clean
    clean:
    	@echo "Remove linked and compiled files......"
    	rm -rf $(OBJ) $(TARGET) output 
    
    • 1
    • 2
    • 3
    • 4

    (14)使用与编译

    在对应例程目录下,输入make即可把编译后的执行文件放到output目录下(注:本例程使用到静态库和动态库。)

    然后执行可执行文件如下:

    小结:上面已经大概介绍了使用Makefile编译可执行程序的通用模板。接下来将介绍如何使用Makefile编译静态库与动态库,以及简单地介绍在项目工程多目录下多层Makefile如何编译。

    2.2 使用Makefile编译静态库

    LIB_NAME = libfoo
    
    PROJECT_ROOT_PATH = /home/yimning/MakefileTemplate
    VERSION     = 1.0.0
    
    CCSRCS	= $(wildcard *.c)
    CCOBJS = $(patsubst %.c,%.o, $(CCSRCS))
    
    CC = gcc
    DEBUG   =-DUSE_DEBUG
    CFLAGS  =-Wall
    AR = ar
    ARFLAGS = -rv
    SOFLAGS = -fPIC -shared
    INCPATH = -I$(PROJECT_ROOT_PATH)/include
    LDFLAGS = -L.
    LIB_NAMES =  
    
    #links
    ifdef TARGET_SO
    	TARGET = $(LIB_NAME).$(VERSION).so
    endif
    
    ifdef TARGET_AR
    	TARGET = $(LIB_NAME).$(VERSION).a
    endif
    
    ifdef TARGET_SO
    $(TARGET): $(CCOBJS)
    	$(CC) $(SOFLAGS) $^ -o $@ $(LDFLAGS) $(LIB_NAMES)
    endif
    
    ifdef TARGET_AR
    $(TARGET):$(CCOBJS)
    	$(AR) $(ARFLAGS) $@ $^
    endif
    
    #compile
    $(CCOBJS): %.o: %.c
    	$(CC) $(INCPATH) $(CFLAGS) -c $< -o $@
    	
    .PHONY: install
    install:
    	mkdir -p $(PROJECT_ROOT_PATH)/lib
    	cp $(TARGET) $(PROJECT_ROOT_PATH)/lib
    	
    .PHONY: clean
    clean:
    	@echo "Remove linked and compiled files......"
    	-rm -rf $(CCOBJS)
    	-rm -rf $(TARGET)
    	-rm -rf $(PROJECT_ROOT_PATH)/include/$(LIB_NAME)
    	-rm -rf $(PROJECT_ROOT_PATH)/lib/$(TARGET)
    
    • 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
    • 49
    • 50
    • 51
    • 52
    • 53

    【要点说明】

    (1)基本格式与“使用Makefile编译可执行文件”大体一致。

    (2)使用到“ar”命令将目标文件(.o)链接成静态库文件(.a)。静态库文件固定命名格式为:libxxx.a。以及自己定义的版本信息。

    (3)使用了条件编译与链接。

    #links
    ifdef TARGET_AR
     TARGET = $(LIB_NAME).a.$(VERSION)
    endif
    
    ifdef TARGET_AR
    $(TARGET):$(CCOBJS)
     $(AR) $(ARFLAGS) $@ $^
    endif
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    (4)使用与编译

    在对应例程目录下,输入“make TARGET_AR=1”命令即可在当前目录下编译生成静态库;输入“make TARGET_AR=1 clean”清空链接与编译生成的相关文件。(注:使用了条件编译)。如下图所示。

    2.3 使用Makefile编译动态库

    LIB_NAME = libbar
    
    PROJECT_ROOT_PATH = /home/yimning/MakefileTemplate
    VERSION     = 1.0.0
    
    CCSRCS	= $(wildcard *.c)
    CCOBJS = $(patsubst %.c,%.o, $(CCSRCS))
    
    CC = gcc
    DEBUG   =-DUSE_DEBUG
    CFLAGS  =-Wall
    AR = ar
    ARFLAGS = -rv
    SOFLAGS = -fPIC -shared
    INCPATH = -I$(PROJECT_ROOT_PATH)/include
    LDFLAGS = -L.
    LIB_NAMES =  
    
    #links
    ifdef TARGET_SO
    	TARGET = $(LIB_NAME).$(VERSION).so
    endif
    
    ifdef TARGET_AR
    	TARGET = $(LIB_NAME).$(VERSION).a
    endif
    
    ifdef TARGET_SO
    $(TARGET): $(CCOBJS)
    	$(CC) $(SOFLAGS) $^ -o $@ $(LDFLAGS) $(LIB_NAMES)
    endif
    
    ifdef TARGET_AR
    $(TARGET):$(CCOBJS)
    	$(AR) $(ARFLAGS) $@ $^
    endif
    
    #compile
    $(CCOBJS): %.o: %.c
    	$(CC) $(INCPATH) $(CFLAGS) -c $< -o $@
    	
    .PHONY: install
    install:
    	mkdir -p $(PROJECT_ROOT_PATH)/lib
    	cp $(TARGET) $(PROJECT_ROOT_PATH)/lib
    	
    .PHONY: clean
    clean:
    	@echo "Remove linked and compiled files......"
    	-rm -rf $(CCOBJS)
    	-rm -rf $(TARGET)
    	-rm -rf $(PROJECT_ROOT_PATH)/include/$(LIB_NAME)
    	-rm -rf $(PROJECT_ROOT_PATH)/lib/$(TARGET)
    
    • 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
    • 49
    • 50
    • 51
    • 52
    • 53

    【要点说明】

    (1)基本格式与“使用Makefile编译可执行文件”大体一致。

    (2)编译选项和链接选项增加“-fPIC -shared ”选项。动态库文件固定命名格式为libxxx.so。以及自己定义的版本信息。

    (3)使用了条件编译与链接。

    #links
    ifdef TARGET_SO
     TARGET = $(LIB_NAME).so.$(VERSION)
    endif
    
    ifdef TARGET_SO
    $(TARGET): $(CCOBJS)
     $(CC) $(SOFLAGS) $^ -o $@ $(LDFLAGS) $(LIB_NAMES)
    endif
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    (4)使用与编译

    在对应例程目录下,输入“make TARGET_SO=1”命令即可在当前目录下编译生成动态库;输入“make TARGET_SO=1 clean”清空链接与编译生成的相关文件。(注:使用了条件编译)。如下图所示。

    4、Makefile多目标多层次编译

    本小节来简单地介绍在工程项目多目录下多层Makefile如何编译。多个Makefile文件编译,多目标编译,多层次编译。以该项目例程为例,标红色框的Makefile为一级Makefile(也称顶层Makefile),标蓝色框的Makefile为二级Makefile。实际开发中还可以有更多层级的Makefile对工程进行多目标编译与链接等等。这里就简单的举例子介绍。其目录树结构如下:

    其中二级的Makefile在前文贴出来了。那么顶层的Makefile如何写?总的来说,写法有点类似于总分结构(或者说父子关系)。本例程的顶层Makefile如下:

    PROJECT_ROOT_PATH=$(shell pwd)
    VERSION  = 1.0.0
    
    MFLAGS = "CFLAGS=-w"
    DFLAGS = "CFLAGS=-w -g -fPIC -DDEBUG -DREENTRANT"
    
    #如果想编译静态库 指定TARGET_AR=1即可	
    all :
    	cd $(PROJECT_ROOT_PATH)/src/bar && $(MAKE) $(DFLAGS) TARGET_SO=1 && $(MAKE) install TARGET_SO=1		
    	cd $(PROJECT_ROOT_PATH)/src/foo && $(MAKE) $(DFLAGS) TARGET_AR=1 && $(MAKE) install TARGET_AR=1
    	cd $(PROJECT_ROOT_PATH)/src/test && $(MAKE) $(DFLAGS) && $(MAKE) install 
    
    .PHONY: clean
    clean :
    	@echo "Remove linked and compiled files......"
    	cd $(PROJECT_ROOT_PATH)/src/bar && $(MAKE) clean TARGET_SO=1
    	cd $(PROJECT_ROOT_PATH)/src/foo && $(MAKE) clean TARGET_AR=1
    	cd $(PROJECT_ROOT_PATH)/src/test && $(MAKE) clean
    	-rm -rf $(CCOBJS)
    	-rm -rf $(TARGET)
    	-rm -rf $(PROJECT_ROOT_PATH)/lib/$(TARGET)
    
    #export出来的变量将被子make进程继承。 
    export PROJECT_ROOT_PATH VERSION
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    注:makeFile中export的使用。export出来的变量将被子make进程继承。 如果在确定是需要这种方式去传输变量,那么可以在子Makefile中注释相关的变量(比如注释下文test/Makefile中的PROJECT_ROOT_PATH和VERSION)。(也就说顶层Makefile中export的变量在子Makefile中可使用生效(相当于export全局变量),当然在子Makefile中可以重复定义变量,只在局部生效(相当于局部变量))。

    测试例程

    使用Makefile多目标编译和多层次编译的方式测试整个例程,其中包含使用静态库与动态库来进行编译与链接等,最终生成可执行文件。

    测试例程的bar.h、bar.c

    #ifndef BAR_H
    #define BAR_H
    int bar(int x);
    #endif
    
    • 1
    • 2
    • 3
    • 4
    /*
     * @Author: Yimning 1148967988@qq.com
     * @Date: 2022-07-02 10:05:07
    */
    #include "bar/bar.h"
    
    int add(int x, int y) {
      return x + y;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    测试例程的foo.h、foo.c

    #ifndef FOO_H
    #define FOO_H
    int foo(int x);
    #endif
    
    • 1
    • 2
    • 3
    • 4
    /*
     * @Author: Yimning 1148967988@qq.com
     * @Date: 2022-07-02 10:20:07
    */
    #include "foo/foo.h"
    #include "bar/bar.h"
    
    int foo(int x) {
         printf("foo use bar function---output:%d\n",add(33,66));
         return x+1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    测试例程的test/main.c

    /*
     * @Author: Yimning 1148967988@qq.com
     * @Date: 2022-07-02 10:05:07
    */
    #include 
    #include "bar/bar.h"
    #include "foo/foo.h"
    int main(void) {
      printf("this is bar function---output:%d\n", add(44, 44));  // 88
      printf("this is foo function---output:%d\n", foo(add(33, 66)));  // 99
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    测试例程的test/Makefile

    EXE_NAME = app
    DEBUG = -D_MACRO #宏定义 
    
    #PROJECT_ROOT_PATH = /home/yimning/MakefileTemplate
    #VERSION     = 1.0.0
    
    CC = gcc
    DEBUG   =-DUSE_DEBUG
    CFLAGS  =-Wall
    CCSRCS	= $(wildcard *.c)			#获取所有的.c文件 
    CCOBJS = $(patsubst %.c,%.o, $(CCSRCS))		#符合模式[%.c]的单词替换成[%.o] 即将.c文件转为.o文件 
    INCPATH = -I../include -I$(PROJECT_ROOT_PATH)/include			#头文件路径 
    LDFLAGS = -L$(PROJECT_ROOT_PATH)/lib			#库文件路径
    LIB_NAMES = $(LIB_SO_OBJS)	$(LIBS_A_OBJS)		#链接库文件名字 
    
    # 获取静态库名字
    LIBS_A	= $(wildcard $(PROJECT_ROOT_PATH)/lib/*.a)   # 获取该目录下动态库(*.a)的路径名
    LIBS_A_NOTDIR    = $(notdir $(LIBS_A))			# 去掉路路径,只留下文件名为libxxx.a
    LIBS_A_NOTLIB    = $(subst lib,,$(LIBS_A_NOTDIR))	# 去掉文件名的lib,留下部分为xxx.a
    LIBS_A_OBJS = $(patsubst %.a,-l%, $(LIBS_A_NOTLIB))		# 去掉文件名的后缀并在前面添加-l,留下部分为-lxxx
    
    # 获取动态库名字
    LIBS_SO	= $(wildcard $(PROJECT_ROOT_PATH)/lib/*.so)   # 获取该目录下动态库(*.so)的路径名
    LIBS_SO_NOTDIR    = $(notdir $(LIBS_SO))			# 去掉路路径,只留下文件名为libxxx.so
    LIBS_SO_NOTLIB    = $(subst lib,,$(LIBS_SO_NOTDIR))	# 去掉文件名的lib,留下部分为xxx.so
    LIB_SO_OBJS = $(patsubst %.so,-l%, $(LIBS_SO_NOTLIB))		# 去掉文件名的后缀并在前面添加-l,留下部分为-lxxx
    
    #links
    $(EXE_NAME): $(CCOBJS)
    	$(CC) $^ -o $@ $(LDFLAGS) $(LIB_NAMES)
    
    #compile
    $(CCOBJS): %.o: %.c
    	$(CC) $(INCPATH) $(CFLAGS) -c $< -o $@
    	
    .PHONY: install
    install:
    	cp $(EXE_NAME) $(PROJECT_ROOT_PATH)
    # 	make print						#调试打印变量
    
    .PHONY: clean
    clean:
    	@echo "Remove linked and compiled files......"
    	-rm -rf $(CCOBJS)
    	-rm -rf $(EXE_NAME)
    	-rm -rf $(PROJECT_ROOT_PATH)/$(EXE_NAME)
    
    print:
    	@echo $(LIBS_SO)
    	@echo $(LIBS_SO_NOTDIR)
    	@echo $(LIBS_SO_NOTLIB)
    	@echo $(LIB_SO_OBJS)
    
    • 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
    • 49
    • 50
    • 51
    • 52

    使用与编译

    在对应例程目录下,输入“make”命令即可在当前目录下编译生成可执行文件app.1.0.0;输入“make clean”清空链接与编译时生成的相关文件。(注:动态库已放到/lib/下)。如下图所示。


    好了。本期的介绍到这里就结束了。感谢阅读与分享!扫码关注回复"0728"即可获取本文例程。扫码关注我们世间万物,千奇百怪,都等待着你去发觉…

    微信扫一扫关注该公众号

  • 相关阅读:
    部署Netlify站点博客
    JAVA春之梦理发店管理计算机毕业设计Mybatis+系统+数据库+调试部署
    利用python数据分析——Numpy基础:通用函数、利用数组进行数据处理
    LeetCode中等题之旋转图像
    海格里斯HEGERLS工程项目案例|陕西西安某新能源电池制造集团企业三期自放电立体库工程项目安装过程
    pycharm进阶使用学习
    MySQL字符集大小写不敏感导致的主键冲突问题记录
    【Linux】详细介绍Linux重入不可重入带例子
    【历史上的今天】11 月 7 日:图灵奖女性得主诞生;Twitter 告别 140 字符时代;首位中国 AI 主播
    经过半年的努力,终于成为了谷歌开发者专家(GDE)
  • 原文地址:https://blog.csdn.net/Youning_Yim/article/details/126011088