热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Kati详解Android10.0编译系统(五)

摘要:本节主要来讲解Kati把Makefile编译成build-xxx.ninja,那么Kati是什么?是如何工作的呢?阅读本文大约需要花

摘要:本节主要来讲解Kati把Makefile编译成build-xxx.ninja,那么Kati是什么? 是如何工作的呢?


阅读本文大约需要花费24分钟。

文章首发微信公众号:IngresGe

专注于Android系统级源码分析,Android的平台设计,欢迎关注我,谢谢!


欢迎关注我的公众号!

[Android取经之路] 的源码都基于Android-Q(10.0) 进行分析

[Android取经之路] 系列文章:

《系统启动篇》


  1. Android系统架构
  2. Android是怎么启动的
  3. Android 10.0系统启动之init进程
  4. Android10.0系统启动之Zygote进程
  5. Android 10.0 系统启动之SystemServer进程
  6. Android 10.0 系统服务之ActivityMnagerService
  7. Android10.0系统启动之Launcher(桌面)启动流程
  8. Android10.0应用进程创建过程以及Zygote的fork流程
  9. Android 10.0 PackageManagerService(一)工作原理及启动流程
  10. Android 10.0 PackageManagerService(二)权限扫描
  11. Android 10.0 PackageManagerService(三)APK扫描
  12. Android 10.0 PackageManagerService(四)APK安装流程

《日志系统篇》


  1. Android10.0 日志系统分析(一)-logd、logcat 指令说明、分类和属性
  2. Android10.0 日志系统分析(二)-logd、logcat架构分析及日志系统初始化
  3. Android10.0 日志系统分析(三)-logd、logcat读写日志源码分析
  4. Android10.0 日志系统分析(四)-selinux、kernel日志在logd中的实现​

《Binder通信原理》:


  1. Android10.0 Binder通信原理(一)Binder、HwBinder、VndBinder概要
  2. Android10.0 Binder通信原理(二)-Binder入门篇
  3. Android10.0 Binder通信原理(三)-ServiceManager篇
  4. Android10.0 Binder通信原理(四)-Native-C\C++实例分析
  5. Android10.0 Binder通信原理(五)-Binder驱动分析
  6. Android10.0 Binder通信原理(六)-Binder数据如何完成定向打击
  7. Android10.0 Binder通信原理(七)-Framework binder示例
  8. Android10.0 Binder通信原理(八)-Framework层分析
  9. Android10.0 Binder通信原理(九)-AIDL Binder示例
  10. Android10.0 Binder通信原理(十)-AIDL原理分析-Proxy-Stub设计模式
  11. Android10.0 Binder通信原理(十一)-Binder总结

  《HwBinder通信原理》


  1. HwBinder入门篇-Android10.0 HwBinder通信原理(一)
  2.  HIDL详解-Android10.0 HwBinder通信原理(二)
  3. HIDL示例-C++服务创建Client验证-Android10.0 HwBinder通信原理(三)
  4. HIDL示例-JAVA服务创建-Client验证-Android10.0 HwBinder通信原理(四)
  5. HwServiceManager篇-Android10.0 HwBinder通信原理(五)
  6. Native层HIDL服务的注册原理-Android10.0 HwBinder通信原理(六)
  7. Native层HIDL服务的获取原理-Android10.0 HwBinder通信原理(七)
  8. JAVA层HIDL服务的注册原理-Android10.0 HwBinder通信原理(八)
  9. JAVA层HIDL服务的获取原理-Android10.0 HwBinder通信原理(九)
  10. HwBinder驱动篇-Android10.0 HwBinder通信原理(十)
  11. HwBinder原理总结-Android10.0 HwBinder通信原理(十一)

《编译原理》


  1. 编译系统入门篇-Android10.0编译系统(一)
  2. 编译环境初始化-Android10.0编译系统(二)
  3. make编译过程-Android10.0编译系统(三)
  4. Image打包流程-Android10.0编译系统(四)
  5. Kati详解-Android10.0编译系统(五)
  6. Blueprint简介-Android10.0编译系统(六)
  7. Blueprint代码详细分析-Android10.0编译系统(七)

1 概述

       kati是Google专门为了Android而开发的一个小项目,基于Golang和C++。目的是为了把Android中的Makefile,转换成Ninja文件。

    在最新的Android R(11)中,Google已经移除了/build/kati目录,只保留了一个预先编译出来的可执行文件:prebuilts/build-tools/linux-x86/bin/ckati,这意味着Google在逐渐从编译系统中移除kati,预计1-2个Android大版本,*.mk文件全部都切换成*.bp文件后,kati将会正式退出Android历史舞台。

 


2 kati 、ckati区别

    kati是go语言写的,而ckati是c++写的。kati官方文档对它的描述是:kati is an experimental GNU make clone。也就是说,kati是对等make命令的。只不过kati并不执行具体的编译工作,而是生成ninja文件。

    这里有个疑惑?为什么有两个版本的kati:kati/ckati?

kati刚开始是使用Golang编写的,但是后来验证下来发现编译速度不行,于是改成C++编写,所以现在存在两个版本:kati、ckati。我们在Android10.0编译过程中,是通过ckati来把makefile文件转换成ninja文件的。

    关于Go版本kati编译速度问题,可以通过kati自带文档:build/kati/INTERNALS.md来查看:

 

    Go版本比C++版本有更多的不必要的字符串分配。至于Go本身,似乎GC是主要的展示器。例如,Android的构建系统定义了大约一百万个变量,缓冲区将永远不会被释放。,这种分配格局对于非代际GC(non-generational)是不利的。

    因此采用C++编译会减少缓冲区分配问题,提高编译速度,因此我们现在主要还是使用ckati进行mk文件的转换。


 


3 Kati整体架构

    Kati由以下组件组成:


  • 解析器(Parser)

  • 评估器(Evaluator)

  • 依赖构建器(Dependency builder)

  • 执行器(Executor)

  • Ninja生成器(Ninja generator)

    Makefile有一些由零个或多个表达式组成的语句。有两个解析器和两个评估器, 一个用于Makefile的语句,另一个用于Makefile的表达式。

    GNU make的大部分用户可能不太关心评估器。但是,GNU make的评估器非常强大,并且是图灵完整的。对于Android的空构建,大部分时间都花在这个阶段。其他任务,例如构建依赖关系图和调用构建目标的stat函数,并不是瓶颈。这将是一个非常具体的Android特性。Android的构建系统使用了大量的GNU make黑魔法。

    评估器输出构建规则(build rules)和变量表(variable table)的列表。依赖构建器从构建规则列表中创建依赖图(dependency graph)。注意这一步不使用变量表。

    然后将使用执行器或Ninja生成器。无论哪种方式,Kati再次为命令行运行其评估器。该变量表再次用于此步骤。

 


4 kati是如何生成的


4.1 代码位置

    Android10.0中kati的代码位置:build/kati,AOSP中自带编译好的ckati。

prebuilts/build-tools/linux-x86/asan/bin/ckati
prebuilts/build-tools/linux-x86/bin/ckati
prebuilts/build-tools/darwin-x86/bin/ckati

    kati也是它也是一个独立发布的项目,在GitHub上的位置是google/kati。

git clone https://github.com/google/kati.git

 


4.2 Kati的使用方法

    在Android的编译过程中,ckati会自动被使用,无须开发者担心。

    单独使用时,在包含Makefile的目录下,执行ckati,效果与make基本相同。执行ckati --ninja,可以根据Makefile生成build.ninja文件,并且附带env-aosp_arm.sh和ninja-aosp_arm.sh 。通过env-aosp_arm.sh来配置环境,通过执行./ninja-aosp_arm.sh来启动Ninja、使用build.ninja编译。

除了--ninja以外,ckati支持很多其它参数。比如,和make一样,可以通过-f指定Makefile位置,通过-j指定线程数。另外,在kati项目的m2n脚本中,就可以看到以下的复杂用法:

${kati} --ninja ${ninja_suffix_flag} --ignore_optional_include=out/%.P --ignore_dirty=out/% --use_find_emulator --detect_android_echo --detect_depfiles --gen_all_targets ${goma_flag} ${extra_flags} ${targets}

 


4.3 生成kati

    Android10.0编译时都是使用编译好的ckati(prebuilts/build-tools/linux-x86/bin/ckati)进行makefile的转换,不会再编译一下ckati,但是我们可以看看ckati是如何被编译出来的。

    ckati的编译方法:

cd .../build/kati
make ckati

    会在build/kati的目录中生成一个二进制文件ckati


 


4.4 KATI生成过程

    在build/kati 中有个Makefile,执行make时,会编译其中的内容。

[build/kati/Makefile]
all: ckati ckati_testsinclude Makefile.kati
include Makefile.ckatitest: all ckati_testsgo test --ckati --ninjaclean: ckati_clean.PHONY: test clean ckati_tests

    Makefile中有两个目标:ckat和ckati_tests,其中ckati就是我们要编译出来的内容,它对应的Makefile为 Makefile.ckati。

 


4.4.1 Makefile.ckati

    从Makefile.ckati中可以看出,ckati通过C++进行编译,而且依赖于KATI_CXX_OBJS和KATI_CXX_GENERATED_OBJS。

# Makefile.ckati
# Rule to build ckati into KATI_BIN_PATH
$(KATI_BIN_PATH)/ckati: $(KATI_CXX_OBJS) $(KATI_CXX_GENERATED_OBJS)@mkdir -p $(dir $@)$(KATI_LD) -std=c++11 $(KATI_CXXFLAGS) -o $@ $^ $(KATI_LIBS)# Rule to build normal source files into object files in KATI_INTERMEDIATES_PATH
$(KATI_CXX_OBJS) $(KATI_CXX_TEST_OBJS): $(KATI_INTERMEDIATES_PATH)/%.o: $(KATI_SRC_PATH)/%.cc&#64;mkdir -p $(dir $&#64;)$(KATI_CXX) -c -std&#61;c&#43;&#43;11 $(KATI_CXXFLAGS) -o $&#64; $<# Rule to build generated source files into object files in KATI_INTERMEDIATES_PATH
$(KATI_CXX_GENERATED_OBJS): $(KATI_INTERMEDIATES_PATH)/%.o: $(KATI_INTERMEDIATES_PATH)/%.cc&#64;mkdir -p $(dir $&#64;)$(KATI_CXX) -c -std&#61;c&#43;&#43;11 $(KATI_CXXFLAGS) -o $&#64; $<

  ckati的编译log:

g&#43;&#43; -c -std&#61;c&#43;&#43;11 -g -W -Wall -MMD -MP -O -DNOLOG -march&#61;native -o main.o main.cc
g&#43;&#43; -c -std&#61;c&#43;&#43;11 -g -W -Wall -MMD -MP -O -DNOLOG -march&#61;native -o ninja.o ninja.cc
g&#43;&#43; -c -std&#61;c&#43;&#43;11 -g -W -Wall -MMD -MP -O -DNOLOG -march&#61;native -o parser.o parser.cc
g&#43;&#43; -c -std&#61;c&#43;&#43;11 -g -W -Wall -MMD -MP -O -DNOLOG -march&#61;native -o regen.o regen.cc
g&#43;&#43; -c -std&#61;c&#43;&#43;11 -g -W -Wall -MMD -MP -O -DNOLOG -march&#61;native -o rule.o rule.cc

 


4.4.2 [/build/kati/main.cc]

    ckati的入口在main.cc中

    调用栈如下&#xff1a;

 


4.4.2.1 main(&#xff09;

main()主要步骤&#xff1a;


  • 进行环境的初始化&#xff0c;初始化makefile解析器&#xff0c;包括include、define、ifndef等语法规则

  • 解析ckati传入的参数内容&#xff0c;例如&#xff1a;"--ninja"\"--regen"等

  • 执行编译&#xff0c;最终生成build-xxxx.ninja文件

  • 退出ckati

接下来针对相关的函数&#xff0c;进行分析。

[/build/kati/main.cc]
int main(int argc, char* argv[]) {if (argc >&#61; 2 && !strcmp(argv[1], "--realpath")) {HandleRealpath(argc - 2, argv &#43; 2);return 0;}Init();string orig_args;for (int i &#61; 0; i }

 


4.4.2.2 Flags::Parse()

解析ckati传入的参数内容&#xff0c;例如&#xff1a;"--ninja"\"--regen"等

void Flags::Parse(int argc, char** argv) {
...for (int i &#61; 1; i ...}
}

 


4.4.2.3 Run()

    根据传入的参数包含--ninja时&#xff0c;需要执行GenerateNinja()&#xff0c;Kati如果指定了--regen标志&#xff0c;则Kati会检查你的环境中的任何内容是否在上次运行后发生更改。如果Kati认为它不需要重新生成Ninja文件&#xff0c;它会很快完成。对于Android&#xff0c;第一次运行Kati需要接近30秒&#xff0c;但第二次运行只需要1秒。

static int Run(const vector& targets,const vector& cl_vars,const string& orig_args) {double start_time &#61; GetTime();//传入参数包含--ninja 和 (--regen 或者--dump_kati_stamp)时&#xff0c;进入该流程if (g_flags.generate_ninja && (g_flags.regen || g_flags.dump_kati_stamp)) {ScopedTimeReporter tr("regen check time");if (!NeedsRegen(start_time, orig_args)) {fprintf(stderr, "No need to regenerate ninja file\n");return 0;}if (g_flags.dump_kati_stamp) {printf("Need to regenerate ninja file\n");return 0;}ClearGlobCache();}...
//传入参数包含--ninja时&#xff0c;需要执行GenerateNinja()if (g_flags.generate_ninja) {ScopedTimeReporter tr("generate ninja time");GenerateNinja(nodes, ev.get(), orig_args, start_time);ev->DumpStackStats();return 0;}...return 0;
}

 


4.4.2.4  GenerateNinja()

    GenerateNinja()会先初始化一个 NinjaGenerator的结构&#xff0c;然后解析之前的makefile&#xff0c;并且将node进行整理&#xff0c;会将所依赖的.o;.a; .so进行归类&#xff0c;在整理好了依赖之后&#xff0c;会将所的步骤写入文件build-xxxx.ninja中。

void GenerateNinja(const vector& nodes,Evaluator* ev,const string& orig_args,double start_time) {NinjaGenerator ng(ev, start_time); //初始化了一个 NinjaGenerator的结构ng.Generate(nodes, orig_args);
}void Generate(const vector& nodes, const string& orig_args) {unlink(GetNinjaStampFilename().c_str());PopulateNinjaNodes(nodes); //对前面include的makefile进行解析&#xff0c;并且将node进行整理&#xff0c;会将所依赖的.o;.a; .so进行归类GenerateNinja(); //在整理好了依赖之后&#xff0c;会将所的步骤写入文件build-xxxx.ninja中GenerateShell();GenerateStamp(orig_args);}

GenerateNinja() 会产生build-aosp_arm.ninja

GenerateShell()会产生env-aosp_arm.sh、ninja-aosp_arm.sh        

这里我们主要关注build-aosp_arm.ninja的生成过程。

void GenerateNinja() {ScopedTimeReporter tr("ninja gen (emit)");fp_ &#61; fopen(GetNinjaFilename().c_str(), "wb");if (fp_ &#61;&#61; NULL)PERROR("fopen(build.ninja) failed");fprintf(fp_, "# Generated by kati %s\n", kGitVersion);fprintf(fp_, "\n");if (!used_envs_.empty()) {fprintf(fp_, "# Environment variables used:\n");for (const auto& p : used_envs_) {fprintf(fp_, "# %s&#61;%s\n", p.first.c_str(), p.second.c_str());}fprintf(fp_, "\n");}if (!g_flags.no_ninja_prelude) {if (g_flags.ninja_dir) {fprintf(fp_, "builddir &#61; %s\n\n", g_flags.ninja_dir);}fprintf(fp_, "pool local_pool\n");fprintf(fp_, " depth &#61; %d\n\n", g_flags.num_jobs);fprintf(fp_, "build _kati_always_build_: phony\n\n");}unique_ptr tp(NewThreadPool(g_flags.num_jobs));CHECK(g_flags.num_jobs);int num_nodes_per_task &#61; nodes_.size() / (g_flags.num_jobs * 10) &#43; 1;int num_tasks &#61; nodes_.size() / num_nodes_per_task &#43; 1;vector bufs(num_tasks);for (int i &#61; 0; i Submit([this, i, num_nodes_per_task, &bufs]() {int l &#61;min(num_nodes_per_task * (i &#43; 1), static_cast(nodes_.size()));for (int j &#61; num_nodes_per_task * i; j Wait();if (!g_flags.generate_empty_ninja) {for (const ostringstream& buf : bufs) {fprintf(fp_, "%s", buf.str().c_str());}}SymbolSet used_env_vars(Vars::used_env_vars());// PATH changes $(shell).used_env_vars.insert(Intern("PATH"));for (Symbol e : used_env_vars) {StringPiece val(getenv(e.c_str()));used_envs_.emplace(e.str(), val.as_string());}string default_targets;if (g_flags.targets.empty() || g_flags.gen_all_targets) {CHECK(default_target_);default_targets &#61; EscapeBuildTarget(default_target_->output);} else {for (Symbol s : g_flags.targets) {if (!default_targets.empty())default_targets &#43;&#61; &#39; &#39;;default_targets &#43;&#61; EscapeBuildTarget(s);}}if (!g_flags.generate_empty_ninja) {fprintf(fp_, "\n");fprintf(fp_, "default %s\n", default_targets.c_str());}fclose(fp_);}

 

Kati认为&#xff0c;当更改以下任一项时&#xff0c;需要重新生成Ninja文件&#xff1a;


  • 传递给Kati的命令行标志

  • 用于生成上一个ninja文件的Makefile的时间戳

  • 评估Makefile时使用的环境变量

  • $(wildcard ...)的结果

  • $(shell ...)的结果

 


5 kati执行过程

    在第三节的make编译过程中&#xff0c;我们知道soong_ui执行编译时&#xff0c;会调用ckati把makefile编译成*.ninja文件&#xff0c;这里我们就看看具体的流程是如何执行的。

 


5.1 soong_ui build调用栈

    在之前的编译过程中&#xff0c;其中第三步和第四步&#xff0c;运行runKatiBuild()和runKatiPackage()&#xff0c;加载core/main.mk和packaging/main.mk&#xff0c;搜集所有的Android.mk文件&#xff0c;分别生成out/build-aosp_arm.ninja 和out/build-aosp_arm-package.ninja&#xff0c;这就是kati/ckati的编译过程。

    下面我们来一起看看具体的执行过程。

 


5.2 runKatiBuild()


[/build/soong/ui/build/kati.go]
func runKatiBuild(ctx Context, config Config) {ctx.BeginTrace(metrics.RunKati, "kati build")defer ctx.EndTrace()args :&#61; []string{"--writable", config.OutDir() &#43; "/","-f", "build/make/core/main.mk",}// PDK builds still uses a few implicit rulesif !config.IsPdkBuild() {args &#61; append(args, "--werror_implicit_rules")}if !config.BuildBrokenDupRules() {args &#61; append(args, "--werror_overriding_commands")}if !config.BuildBrokenPhonyTargets() {args &#61; append(args,"--werror_real_to_phony","--werror_phony_looks_real","--werror_writable")}args &#61; append(args, config.KatiArgs()...)args &#61; append(args,"SOONG_MAKEVARS_MK&#61;"&#43;config.SoongMakeVarsMk(),"SOONG_ANDROID_MK&#61;"&#43;config.SoongAndroidMk(),"TARGET_DEVICE_DIR&#61;"&#43;config.TargetDeviceDir(),"KATI_PACKAGE_MK_DIR&#61;"&#43;config.KatiPackageMkDir())runKati(ctx, config, katiBuildSuffix, args, func(env *Environment) {})
}

这里的参数args&#xff0c;通过fmt打印后&#xff0c;内容为&#xff1a;

[--writable out/ -f build/make/core/main.mk --werror_implicit_rules --werror_overriding_commands --werror_real_to_phony --werror_phony_looks_real --werror_writable SOONG_MAKEVARS_MK&#61;out/soong/make_vars-aosp_arm.mk SOONG_ANDROID_MK&#61;out/soong/Android-aosp_arm.mk TARGET_DEVICE_DIR&#61;build/target/board/generic KATI_PACKAGE_MK_DIR&#61;out/target/product/generic/obj/CONFIG/kati_packaging]

这里指定了makefile的入口为build/make/core/main.mk&#xff0c;编译target的目录为build/target/board/generic


func runKati(ctx Context, config Config, extraSuffix string, args []string, envFunc func(*Environment)) {executable :&#61; config.PrebuiltBuildTool("ckati")args &#61; append([]string{"--ninja","--ninja_dir&#61;" &#43; config.OutDir(),"--ninja_suffix&#61;" &#43; config.KatiSuffix() &#43; extraSuffix,"--no_ninja_prelude","--regen","--ignore_optional_include&#61;" &#43; filepath.Join(config.OutDir(), "%.P"),"--detect_android_echo","--color_warnings","--gen_all_targets","--use_find_emulator","--werror_find_emulator","--no_builtin_rules","--werror_suffix_rules","--warn_real_to_phony","--warn_phony_looks_real","--top_level_phony","--kati_stats",}, args...)if config.Environment().IsEnvTrue("EMPTY_NINJA_FILE") {args &#61; append(args, "--empty_ninja_file")}cmd :&#61; Command(ctx, config, "ckati", executable, args...)cmd.Sandbox &#61; katiSandboxpipe, err :&#61; cmd.StdoutPipe()if err !&#61; nil {ctx.Fatalln("Error getting output pipe for ckati:", err)}cmd.Stderr &#61; cmd.StdoutenvFunc(cmd.Environment)if _, ok :&#61; cmd.Environment.Get("BUILD_USERNAME"); !ok {u, err :&#61; user.Current()if err !&#61; nil {ctx.Println("Failed to get current user")}cmd.Environment.Set("BUILD_USERNAME", u.Username)}if _, ok :&#61; cmd.Environment.Get("BUILD_HOSTNAME"); !ok {hostname, err :&#61; os.Hostname()if err !&#61; nil {ctx.Println("Failed to read hostname")}cmd.Environment.Set("BUILD_HOSTNAME", hostname)}cmd.StartOrFatal()status.KatiReader(ctx.Status.StartTool(), pipe)cmd.WaitOrFatal()
}

调用Command()&#xff0c;根据传入的参数&#xff0c;生成一个cmd的结构&#xff0c;其中相关参数如下&#xff1a;

args:

[--ninja --ninja_dir&#61;out --ninja_suffix&#61;-aosp_arm --no_ninja_prelude --regen --ignore_optional_include&#61;out/%.P --detect_android_echo --color_warnings --gen_all_targets --use_find_emulator --werror_find_emulator --no_builtin_rules --werror_suffix_rules --warn_real_to_phony --warn_phony_looks_real --top_level_phony --kati_stats --writable out/ -f build/make/core/main.mk --werror_implicit_rules --werror_overriding_commands --werror_real_to_phony --werror_phony_looks_real --werror_writable SOONG_MAKEVARS_MK&#61;out/soong/make_vars-aosp_arm.mk SOONG_ANDROID_MK&#61;out/soong/Android-aosp_arm.mk TARGET_DEVICE_DIR&#61;build/target/board/generic KATI_PACKAGE_MK_DIR&#61;out/target/product/generic/obj/CONFIG/kati_packaging]

config:

{%!s(*build.configImpl&#61;&{[] false 0xc00000ecc0 out/dist 16 1 false false false false [] [droid] -aosp_arm generic build/target/board/generic false false false false true})}

executable:

prebuilts/build-tools/linux-x86/bin/ckati

Command封装方法如下&#xff1a;

[/build/soong/ui/build/exec.go]
func Command(ctx Context, config Config, name string, executable string, args ...string) *Cmd {ret :&#61; &Cmd{Cmd: exec.CommandContext(ctx.Context, executable, args...),Environment: config.Environment().Copy(),Sandbox: noSandbox,ctx: ctx,config: config,name: name,}return ret
}

    根据上述的相关参数可知&#xff0c;最终是调用系统准备好的prebuilts/build-tools/linux-x86/bin/ckati参与编译&#xff0c;其中传入的参数有 --ninja\ --regen\--detect_android_echo 等&#xff0c;最终编译出build-aosp_arm.ninja。具体的kati实现过程比较复杂&#xff0c;这里就不详细展开&#xff0c;有兴趣的朋友&#xff0c;可以把/build/kati中的C&#43;&#43;源码详细的分析一下。

 


6.总结

    Kati的主要功能就是把Makefile转换成build-xxx.ninja文件从而来参与系统编译&#xff0c;随着Android逐步消除版本中的Makefile文件&#xff0c;Kati最终也会退出Android的历史舞台。

 

参考&#xff1a;

《kati-INTERNALS.md》

我的微信公众号&#xff1a;IngresGe

 

 

 

 


推荐阅读
author-avatar
一粒星尘ch
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有