编译2
Posted megachen
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了编译2相关的知识,希望对你有一定的参考价值。
4 编译2
编译
- 编译阶段主要就是生成指令, 打一个比方, 现在有一个项目, 领导为了完成这个计划, 将这个计划按照几个步骤划分(就是指令), 领导就是用这些指令指定了计划的运行流程, 但是项目具体怎么落实是交给员工的, 员工得到了这个指令, 怎么执行就让员工自己来完成, 比如, BUY_FOOD指令, 员工执行此指令, 就可能开车去超市买菜, 也可能骑自行车去等等
- 函数签名指的是方法名+参数列表, 所以生成函数调用指令签名很重要
- 对于函数, 类等特殊变量采用特殊的编译方法, 也就是把名字改一改罢了, 如果一个函数名为test, 那么编译器将其编译成Fn test, 空格用户是输入不了的, 我们这样一搞就独一无二了
- processArgList与expression, 在有多个表达式, 并且使用‘,‘分隔的时候使用processArgList, 在是有一个表达式的时候使用expression, 很好的例子就是解析a[1] = 2时, 会编译成a.[args[1]]=(args[2]), [1]中使用processArgList, =右边使用expression
- 在符号的nud和led方法中, 如果该符号可以被赋值, 那么该nud或者led方法参数中需要bool canAssign
- expression等将结果保存到了栈顶, 如果我们要调用的是一个函数, 则还需要调用emitCall之类的方法获取栈顶元素进行方法调用
- 基本上所有的运算符都被称之为operator, 但是&&, ||, ?:等不是操作符, 他们可以理解为一个if-else, 他们是和num, string一样的符号
- 一个类中使用static修饰的静态变量是编译单元的局部变量
- localVar指的是Value值的name
温习编译模块设涉及到的结构体
- LocalVar
typedef struct localvar{
const char *name;
uint32_t length;
bool isUpvalue; // 是不是upvalue
int scopeDepth;
} LocalVar;
- Upvalue
typedef struct upvalue {
bool isEnclosingLocalVar; // 如果为true, 则表示index索引的是父编译单元的localVars, 如果为false, 则表示索引的是父编译单元的upvalues, NB啊
uint32_t index;
} Upvalue;
- ClassBookKeep
typedef struct classbookkeep {
ObjString *name;
SymbolTable fields;
bool isStatic;
IntBuffer instantMethods;
IntBuffer staticMethods;
Signature *sign;
} ClassBookKeep;
- Loop
typedef struct loop {
int condStartIndex;
int bodyStartIndex;
int exitIndex;
int scopeDepth;
struct loop *enclosingLoop;
} Loop;
- CompileUnit
typedef struct compileunit {
ObjFn *fn;
LocalVar localVars[128];
uint32_t localVarNum;
Upvalue upvalues[128];
uint32_t upvalueNum;
Loop *curLoop;
ClassBookKeep *ClassBK;
uint32_t stackSlotNum;
int scopeDepth;
struct compileunit *enclosingCompileUnit;
} CompileUnit;
- SymbolBindRule
typedef struct symbolbindrule {
const char *id;
BindPower lbp;
DenotationFn nud;
DenotationFn led;
MethodSignature methodSign;
} SymbolBindRule;
CompileUnit方法(最重要的是关于语法分析(TDOP)的方法, 就是各个符号的nud与led方法)
关于局部变量
- findLocal, 查找局部变量, 返回下标
- addUpvalue, 添加upvalue, upvalue的值主要是isEnclosingLocalVar和index
- findUpvalue, 查找upvalue, 在父编译单元和之上查看upvalues属性, 参数为局部变量的名称
- getVarFromLocalOrUpvalue, 对findLocal和findUpvalue统一封装
- isLocalName, 本编程语言规定, 小写字母开头的为局部变量
写指令(原子性)
- writeByte, 是所有写指令的基础哈
0.5. writeOpCode, 写入一个操作码, 但是该操作码没有操作数 - writeOpCodeByteOperand, 写入一个操作码, 一个字节的操作数到cu->fn->instrStream
- writeOpCodeShortOperand, 写入一个操作码, 两个字节的操作数到cu->fn->instrStream
- emitLoadConstant, 添加常量到符号表中, 并生成LOAD_CONSTANT index指令
- emitCallBySignature(基本上有参数的都会调用该方法), 将方法签名添加到符号表中, 并生成CALLX index的指令, bySignature的意思就是参数是Signature, 这样是为了区别emitCall的参数
- emitCall, 与emitCallBySignature类似, 但是此方法更加高层, 它要call的方法签名已经被添加到符号表中了
- processArgList, 主要是针对方法调用时的形参是一个表达式的时, 需要计算, 那就需要调用expression方法计算表达式的结果
- emitLoadVariable, 生成把变量var加载到栈的指令
- emitStoreVariable, 为变量var生成存储的指令
- emitLoadThis, 生成将this加载到栈的指令
写指令(宏观性)
- compileBlock
- compileBody, 内部会调用compileBlock, 也就是处理完block{}之后, 处理一下返回值, 如果是构造方法, 则调用emitLoadThis将this加载到栈, 作为返回值, 也就是压栈
- endCompileUnit, 第一步写入OPCODE_END指令标志一个编译单元结束, 接着调用将当前cu的fn添加到父编译单元的constants中作为常量, 接着生成创建闭包的指令CREATE_CLOSURE, 因为我们的结构是内层函数以闭包的形式存在在外层函数中, 就在上一句我们将当前的cu的fn添加到外层编译单元的constants中, 这个过程就是闭包, 除此之外, 内层函数可能引用到了外层函数局部变量, 所以还要生成写入upvalues的指令, 既然编译完毕了, 则返回真正有用的cu->fn
- emitGetterMethodCall, 生成getter或一般method调用指令, 4和5核心是调用emitCallBySignature或者其他的, 反正就是封装了
- emitMethodCall, 生成方法调用指令, setter与getter, emitMethodCall与emitGetterMethodCall需要接受一个OpCode参数, 其实一开始我在怀疑这样不是多此一举么, emitMethodCall与emitGetterMethodCall他们的OpCode不都是OPCODE_CALL0起头的么, 后来才明白, super的指令也是通过emitMethodCall与emitGetterMethodCall生成的, 现在有了两个指令, 一个是OPCODE_CALLX, 另外一个就是OPCODE_SUPERX, 所以要传入参数OpCode
- emitInstrWithPlaceholder, 接受一个指令的参数, 一般为OPCODE_JUMP或者OPCODE_JUMP_IF_FLASE, 操作码原样写, 但是操作数采用0xffff填充两个字节(大段字节序), 到时候使用patchPlaceholder方法回填0xffff
static void processArgList() {
do {
// expression会将是表达式的实参的计算过程的指令写入到instStream流中, 这里到时候VM执行的时候就可以处理实参了
this->expression(BP_LOWSET);
} while (this->curParser->matchToken(TOKEN_COMMA))
}
存符号
- declareModuleVar, 声明一个模块变量, 如类
- declareVariable, 声明一个变量, 该变量可能是全局变量(模块变量), 也可能是局部变量,需要在该函数中进行判别, 接着添加到符号表中, 如果是模块变量调用declareModuleVar方法添加到模块的符号表中, 如果是局部的, 则添加到CompileUnit的符号表中
- processParaList, 将方法的形参(也就是参数的声明)添加到符号表中
static void processArgList(Signature *sign) {
do {
this->curParser->consumeToken(TOKEN_ID);
this->declareVariable(this->curParser->preToken.str);
} while(this->curParser->matchToken(TOEKN_COMMA));
}
创建方法标签
- 单目运算符
static void unaryMethodSignature(Signature *sign) {
// 方法名已经解析了, 所有只需要改变类型
sign->type = SIGN_GETTER;
}
- 中置运算符
static void infixMethodSignature(Signature *sign) {
sign->type = SIGN_METHOD;
this->curParser->consumeToken(TOKEN_LEFT_PEREN, "expect (");
this->curParser->consumeToken(TOKEN_ID, "expect id");
this->declareVariable(this->curParser->curToken.str);
this->curParser->consumeToken(TOKEN_RIGHT_PEREN, "expect )");
}
- 既可以是前置又可以是中置运算符的方法签名
static void mixMethodSignature(Signature *sign) {
// 该方法就是对上面的unaryMethodSignature和infixMethodSignature的封装吧了
// 就是两种情况都考虑进去
// 默认的情况为xxx, 此为getter形式的
sign->type = SIGN_GETTER;
if (!this->curParser->matchToken(TOKEN_ASSIGN)) {
// 后面是不=, 那就是getter了
return;
}
this->curParser->consumeToken(TOKEN_LEFT_PAREN);
sign->type = SIGN_METHOD;
sign->argNum = 1;
/* 注意: 这里最好不要调用processList方法, 该方法是转为给普通方法使用的, 而不是符号方法, 在这里我们使用declareVariable这种底层的方法处理, 还有不同方法是通过, 分别参数个数的, 我们这里的符号方法参数最多就1个 */
// this->processParaList(sign);
this->curParser->consumeToken(TOKEN_ID);
this->declareVariable(this->curParser->preToken.str);
this->curParser->consumeToken(TOKEN_RIGHT_PAREN);
}
- 标识符版本的方法
// 标识符版本的方法包括, setter, getter, subscript, subsriptsetter, method, construct
static void idMethodSignature(Signature *sign) {
sign->type = SIGN_METHOD;
// 构造方法new->Person.new(_, ...)
if (sign->name == "new") {
if (this->curParser->matchToken(TOKEN_ASSIGN)) {
COMPILE_ERROR
}
if (!this->curParser->matchToken(TOKEN_LEFT_PAREN)) {
COMPILE_ERROR
}
sign->type = SIGN_CONSTRUCT;
if (this->curParser->matchToken(TOKEN_RIGHT_PAREN)) {
// 无参数直接返回
return;
}
} else { // 是其余的方法
// 1. 处理setter情况
// 2. 处理getter
// 3. 处理普通method
// 解决这3种情况即可
}
// 注意: 函数是有参数的, 在最后要处理参数问题, 在这里仅仅是声明参数(添加到符号表中)
this->processParaList(sign);
this->curParser->consumeToken(TOKEN_RIGHT_PAREN, "expect )");
}
- 下标操作符[编译签名
static void subscriptMethodSignature(Signature *sign) {
sign->type = SIGN_SUBSCRIPT;
cu->processParaList(sign);
cu->curParser->consumeToken(TOKEN_RIGHT_BRACKET, "expect ‘]‘ after index list!");
trySetter(cu, sign);
}
语法分析
- expression, javascript中的TDOP的expression返回的是一个树, 但是我们这里是void, 因为我们的脚本语言不使用树, 而是通过expression方法可以计算出结果的指令, 到时候交给VM执行就可以得出结果了
static void expression(BindPower rbp) {
SymbolBindRule *rule = this->curParser->curToken.getRule();
this->curParser->getNextToken();
rule->nud(this);
rule = this->curParser->curToken.getRule();
while (rbp < rule->lbp) {
this->curParser->getNextToken();
rule->led(this);
}
}
SymbolBindRule方法
nud方法
- 字面量(数字与字符串)的nud, 将id添加到符号表中, 并生成对应的LOAD_CONSTANT index指令, 一开始遇到这种情况的时候, 我在想为什么还要生成对应的LOAD_CONSTANT index指令, 指令不是来让VM读取并执行的么, 其实这一句话就是关键, 语法分析的过程不是声明的过程, 而是定义, 如1 + 2, 这一句其实就是1.+(2), 这是一个方法调用, 显然我们对其要生成指令交给虚拟机指令, 这就是为什么需要将id添加到符号表中之后还要生成对应的LOAD_CONSTANT index指令
// 字面量.nud()方法 static void literal(CompileUnit *cu) { // 加载到符号表中 uint32_t index = cu.addConstant(); // 生成指令 cu.writeOpCodeShortOperand(index); }
- 前置运算符(单元运算符), 前置运算符没有左操作数, 所以不关心左操作数, 所有有nud方法但是没有led方法, 在该nud方法中需要生成callX指令, 因为在面向对象的语言中, 运算符也是特殊的方法
static void unaryOperator(CompileUnit *cu) {
cu->expression(BP_UNARY);
// 生成0个参数, 方法名为this->id的签名, 这里不需要调用emitCallBySignature, 因为0个参数的方法签名就是方法名
cu->emitCall(0, this->id);
}
- id(变量名与函数名等标识符的nud()方法, 这里将变量名与函数名放在一起, 是为了说明他们都是对象)
static void id(CompileUnit *cu, bool canAssign) {
// id包括函数名与变量名, 而变量名又有很多种, 我们按照如下的方法判断
// 函数名->局部变量名->实例变量名->静态变量名->模块变量名(包括模块变量与模块函数)
Token name = cu->curParser->preToken; // 是id名
// 为函数名, 首先当前的编译单元要为模块编译单元
if (cu->enclosingUnit == NULL && cu->curParser->matchToken(TOKEN_LEFT_PAREN)) {
name.str = "Fn " + name.str;
Variable var;
var.scopeType = VAR_SCOPE_MODULE;
// 函数在模块的varName中是Fn funcName保存的
var.index = cu->getIndexFromSymbolTable(&cu->curParser->curModule->moduleVarNames, id);
// 没有定义该函数, 报编译错误
if (var.index == -1) {
COMPILE_ERROR(cur->curParser, "function %s is undefined!", name.str[3:]);
}
// 如果该函数在之间已经定义过了, 则现将该函数(闭包)压栈, 也就是生成压栈的指令
// 为什么要将其压栈, 因为我们把函数也看成了对象了, 对象有一个call方法, 我们会调用它的call方法
cu->emitLoadVariable(var);
// 下面会将函数调用编译成如下形式
// Fn functionName.call(_, ...)
// 显然, 这时call成了方法名, 而我们调用的方法成了一个对象, 这个和Python的思想是一样的
Signature sign;
sign.name = "call";
sign.argNum = 0;
if (!cu->curParser->matchToken(TOKEN_RIGHT_PAREN)) {
cu->processArgList(&sign); // 内部会生成计算出实参的表达式
cu->curParser->consumeToken(TOKEN_RIGHT_PAREN, "expect ‘)‘ after argument list!");
}
// 调用call方法
cu->emitCallBySignature(&sign);
} else {
Variable var = cu->getVarFromLocalOrUpvalue(name.str);
// 找到了
if (var.index != -1) {
// 是读取还是保存就交给emitLoadOrStoreVariable方法
cu->emitLoadOrStoreVariable(var);
return;
}
// 实例变量
if (cu->enclosingClassBK != NULL) {
int fieldIndex = cu->getIndexFromSymbolTable(&cu->enclosingClassBK->fields, name.str);
// 在class的实例中找到了该域
if (fieldIndex != -1) {
bool isRead = true;
if (cu->curParser->matchToken(TOKEN_ASSIGN) && assign) {
isRead = false;
cu->expression(BP_LOWEST);
}
if (cu->enclosingUnit != NULL) {
cu->writeOpCodeByteOperand(isRead ? OPCODE_LOAD_THIS_FIELD : OPCODE_STORE_THIS_FIELD);
} else {
cu->emitLoadThis();
cu->writeOpCodeByteOperand(isRead ? OPCODE_LOAD_FIELD : OPCODE_STORE_FIELD);
}
return;
}
}
// 按照静态域查找
if (cu->enclosingClassBK != NULL) {
string *staticFieldName = "Cls " + cu->enclosingClassBK->name.str + " " + name.str;
Variable var = cu->getVarFromLocalVarOrUpvalue(staticFieldName);
// 找到了
if (var.index != -1) {
cu->emitLoadOrStoreVariable(var, canAssign);
return;
}
}
}
// 为模块变量
var.scopeType = VAR_SCOPE_MODULE;
var.index = cu->getIndexFromSymbolTable(name.str);
// 没有找到模块变量, 那就在看一个是不是函数名
if (var.index == -1) {
string name = "Fn " + name.str;
var.index = cu->getIndexFromSymbolTable(&cu->curParser->curModule->moduleNames, name);
// 还是没有找到
if (var.index == -1) {
// 先声明一下, 对模块变量放宽限制
cu->declareModuleVar(cu->curParser->curModule, name, NUM_TO_VALUE(cu->curParser->lineNo));
}
}
cu->emitLoadOrStoreVariable(var, canAssign);
}
- bool类型的nud()
static void boolean() {
Token name = this->curParser->preToken;
OpCode opCode = name.type == TOKEN_TRUE ? OPCODE_PUSH_TRUE : OPCODE_PUSH_FALSE;
this->writeOpCode(opCode);
}
- null的nud()
static void null() {
cu->writeOpCode(OPCODE_PUSH_NULL);
}
- this的nud()
static void this() {
this->emitLoadThis();
}
- super的nud()
static void super() {
// 先判断是否在class中
if (cu->enclosingClassNK == NULL) {
COMPILE_ERROR(cu->curParser, "super should be used in a class");
}
// 1. super出现的第一种形式, super.methodName()
// 如果当前的Token为.号
if (cu->curParser->matchToken(TOKEN_DOT)) {
// 这个时候是不需要考虑this的, 之后再一个方法的内部才考虑, 而我们现在这里只是一个方法调用的语句
cu->consumeToken(TOKEN_ID, "expect id after ‘.‘");
cu->emitMethodCall(cu->curParser->preToken.name, OPCODE_SUPER0);
} else {
// super():
emitGetterMethodCall(cu, cu->enclosingCalssBK->signature, OPCODE_SUPER0);
}
}
- (号的nud()方法
static void parentheses() {
// (右边一定是一个表达式
cu->expression(BP_LOWEST);
cu->consumeToken(TOKEN_RIGHT_PAREN, "expect ‘)‘ after expression!");
}
- [号的nud()方法, [是mix类型的符号, 既会有nud(), 又会有led(), 要看具体情况, 在list字面量表达的时候就是nud()方法
// listLiteral的字面量, 也就是一个list对象, 所有在该nud()方法中我们应该生成创建list对象, 并且调用方法的指令
static void listLiteral() {
// 定义list对象
cu->emitLoadModuleVar("List");
// 调用new()构造方法
cu->emitCall("new()");
do {
// 遇到了一个]结束
if (PEEK_TOKEN(cu->curParser) == TOKEN_RIGHT_BRACKET) {
break;
}
// 处理一下表达式
cu->expression(BP_LOWEST);
cu->emitCall("addCore_(_)");
} while (cu->matchToken(TOKEN_COMMA));
cu->curParser->consumeCurToken("expect ‘]‘");
}
- {号的nud()方法, 代表的是Map的字面量
static void mapLiteral() {
// 与list的字面量一样, 先加载Map class模块变量, 创建map对象
// 在当前parser处理的模块中查找的Map并放到栈顶
this->emitLoadModuleVar("Map");
this->emitCall("new()", 0);
do {
if (PEEK_TOKEN(this->curParser) == TOKEN_RIGHT_BRACE) {
break;
}
// key
this->expression(BP_LOWEST);
// :
this->curParser->consumeCurToken(TOKEN_COLON, "expect ‘:‘ after key");
// value
this->expression(BP_LOWEST);
this->emitCall("addCore_(_,_)", 2);
} while (this->curParser->matchToken(TOKEN_COMMA));
this->curParser->consumeCurToken(TOKEN_RIGHT_BRACE, "expect ‘}‘ to present map");
}
led方法
- 中置运算符
static void infixOperator(CompileUnit *cu) {
cu->expression(this->lbp);
// 中置运算符需要一个方法签名, 因为他是有参数的, 参数名为this->id, 参数个数为1
Signature sign = {SIGN_METHOD, this->id, 1};
cu->emitCallBySignature(sign);
}
- [号的led, 在最为subscript的时候, a[1] = 100
// 下标有两种形式, 一种是getter, 另外一种是setter
static void subscript(CompileUnit *cu, bool canAssign) {
// getter时
// []里面不能为空
if (cu->curParser->matchToken(TOKEN_RIGHT_BRACKET)) {
COMPILE_ERROR(cu->curParser, "[] cannot be empty");
}
// []是方法, 所以里面是参数, 我们调用processArgList
Signature sign = {SIGN_SUBSCRIPT, "", 0, 0};
cu->expression(BP_LOWEST);
cu->processArgList(&sign);
cu->consumeCurToken(TOKEN_RIGHT_BRACKET, "expect ‘]‘!");
// 如果后面还有=
if (cu->matchToken(TOKEN_ASSIGN) && canAssign) {
sign.type = SIGN_SUBSCRIPT_SETTER;
cu->expression(BP_LOWEST);
}
cu->emitCallBySignature(&sign, OPCODE_CALL0);
}
- 特殊标识符的led()方法
- &&, ||与if-else在实质上一样的, 他们两个分支都会被编译, 采用回填技术完成后置编译, 所以需要有专门的填坑与补坑的方法, emitInstrWithPlaceholder和patchPlaceholder
emitInstrWithPlaceholder方法
// OpCode应为与jump或则jump_if_false static uint32_t emitInstrWithPlaceholder(OpCode) { this->writeOpCode(OpCode); this->writeByte(0xff); return this->writeByte(0xff) - 1; // 返回到高地址地址 }
patchPlaceholder方法
// absIndex就是一开始emitInstrWithPlaceholder返回到的值 static void patchPlaceholder(uint32_t absIndex) { // 为什么还要-2, 我现在也不清楚 uint32_t offset = this->fn->instrStream.count - absIndex - 2; this->fn->instrStream.datas[absIndex] = (offset >> 8) & 0xff this->fn->instrStream.datas[absIndex + 1] = offset & 0xff; }
- &&.led()
// expression1 && expression2 static void logicAnd() { // 现在在expression2 uint32_t index = this->emitInstrWithPlaceholder(OPCODE_AND); this->expression(BP_LOGIC_AND); this->patchPlaceholder(index); }
||.led()
static void logicOr() { uint32_t index = this->emitInstrWithPlaceholder(OPCODE_OR); this->expression(BP_LOGIC_OR); this->patchPlaceholder(index); }
"? :".led()
static void condition() { uint32_t falseStart = this->emitInstrWithPlaceholder(OPCODE_JUMP_IF_FLASE); // 编译true分支 this->expression(BP_LOWEST); // 便已完毕true分支就知道了false分支的位置了 this->patchPlaceholder(falseStart); uint32_t falseEnd = this->emitInstrWitPlaceholder(OPCODE_JUMP); this->expression(BP_LOWEST); this->patchPlaceholder(falseEnd); }
既可以是前置又可以中置的运算符
- 要合理的选择上面提到的nud和led的组合
编译变量定义
- 变量的定义分为静态域定义, 实例域定义, 局部变量定义和模块变量定义, 我们知道当一个CompileUnit编译模块的时候, 模块变量不在他的localVars中, 而是在对应的模块的符号表中, 但是我们不能空留着localVars, 所以如果正在编译一个类的时候, 但是还没有编译方法的时候, 将定义的静态域变量存放到该cu的localVars, 而实例变量是存放在classBK的fields符号表中
- compileVarDefinition实现
static compileVarDefinition(bool isStatic) {
this->curParser->consumeCurToken(TOKEN_ID, "expect ‘name‘ after var keyword!");
Token name = cu->curParser->preToken;
// 当前编译的是class
if (this->enclosingUnit == NULL
&& this->enclosingClassBK != NULL) {
// 当前编译的是静态域
if (isStatic) {
string staticFieldId = "Cls " + this->classBK->name + " " + name.str;
// staticFieldId是存放在cu的localVars中的
// 查找看是否重复定义了
// 之前没有定义
if (this->findLocal(staticFieldId) == -1) {
// 静态域默认值为NULL
this->writeOpCode(OPCODE_PUSH_NULL);
uint32_t index = this->declareVariable(staticFieldId);
this->defineVariable(index);
// 静态域可以初始化
Variable var = this->findVariable(staticFieldId);
if (this->curParser->matchToken(TOKEN_ASSIGN)) {
this->expression(BP_LOWEST);
this->emitStoreVariable(var);
}
} else {
COMPILE_ERROR(cu->curParser, "%s is redefined!", name.str);
}
} else {
// 在实例域中查找
int fieldIndex = this->classBK->fields->getIndexFromSymbolTable(name.str);
if (fieldIndex == -1) {
fieldIndex = this->classBK->fields->addSymbol(name.str);
} else {
COMPILE_ERROR(this->curParser, "%s redefined!", name.str);
}
return;
}
}
// 其他类型变量的定义
if (this->curParser->matchToken(TOKEN_ASSIGN)) {
var a = 1 + 1
this->expression(BP_LOWEST);
} else {
// var a
this->writeOpCode(OPCODE_PUSH_NULL);
}
// declare是添加符号
uint32_t index = this->declareVariable(name.str);
// define生成value相关的指令, localVars与栈中的局部变量布局一致
this->defineVariable(index);
}
以上是关于编译2的主要内容,如果未能解决你的问题,请参考以下文章
Android 逆向Android 逆向通用工具开发 ( Android 平台运行的 cmd 程序类型 | Android 平台运行的 cmd 程序编译选项 | 编译 cmd 可执行程序 )(代码片段
错误记录Android Studio 编译报错 ( Could not determine java version from ‘11.0.8‘. | Android Studio 降级 )(代码片段