Polygon zkEVM zkASM编译器——zkasmcom

1. 引言

Polygon zkEVM采用zkASM(zero-knowledge Assembly language)语言来解析EVM bytecode。

zkASM编译器代码见:

本文重点关注zkasm代码,其主要依赖3个库:

  • yargs:交互式命令行工具,负责参数解析。
  • ffjavascript:Finite Field Library in Javascript。
  • jison:一个用JavaScript语言实现的一个语法分析器生成器。
"build_parser_zkasm": "mkdir -p build; ./node_modules/.bin/jison src/zkasm_parser.jison -o build/zkasm_parser.js",
"build_parser_command": "mkdir -p build; ./node_modules/.bin/jison src/command_parser.jison -o build/command_parser.js",
"build": "npm run build_parser_zkasm && npm run build_parser_command"

npm run build会生成2个解析器文件:

  • 1)zkasm_parser.js:compile.js中调用const lines = zkasm_parser.parse(src); 对 *.zkasm文件进行编译。
  • 2)command_parser.js:当为main入口时,调用cmdList[i] = command_parser.parse(cmdList[i]); 对command进行解析。

zkasm中的常量参数有:【STEP和ROTL_C为只读寄存器。】

const maxConst = (1n << 32n) - 1n;
const minConst = -(1n << 31n);
const maxConstl = (1n << 256n) - 1n;
const minConstl = -(1n << 255n);
const readOnlyRegisters = ['STEP', 'ROTL_C'];

以arrays.zkasm为例:【冒号右侧为OPCODE,对应的相应常量多项式设置见zkasm_parser.jison中的op内相应操作码的设置,如ARITH操作码对应$$ = { arith: 1, arithEq0: 1},表示会设置arith和arithEq0常量多项式在该行的值为1。

VAR GLOBAL a[100] # 以type、scope、name、count来描述。
VAR GLOBAL b
VAR GLOBAL c[300]
VAR GLOBAL d

start: # 对应type为“label”,identifier为“start”,line为所在代码行,此处为6。里面的每行type为“step”。
        STEP => A
        0   :ASSERT

        1   :MSTORE(a)
        2   :MSTORE(b)
        3   :MSTORE(c)
        4   :MSTORE(d)
        @a => A
        @b => A
        @c => A
        @d => A

end:
       0 => A,B,C,D,E,CTX, SP, PC, GAS, MAXMEM, SR

finalWait:
        ${beforeLast()}  : JMPN(finalWait)

                         : JMP(start)
opINVALID:

执行node src/zkasm.js test/arrays.zkasm -o arrays.json进行编译。

其中,const lines = zkasm_parser.parse(src);解析后的结果为:

[
 { # VAR GLOBAL a[100]
  "type": "var",
  "scope": "GLOBAL",
  "name": "a",
  "count": 100
 },
 { # VAR GLOBAL b
  "type": "var",
  "scope": "GLOBAL",
  "name": "b",
  "count": 1
 },
 { # VAR GLOBAL c[300]
  "type": "var",
  "scope": "GLOBAL",
  "name": "c",
  "count": 300
 },
 { # VAR GLOBAL d
  "type": "var",
  "scope": "GLOBAL",
  "name": "d",
  "count": 1
 },
 { # start:
  "type": "label",
  "identifier": "start",
  "line": 6
 },
 { # STEP => A,将STEP寄存器的值直接赋值给A寄存器。
  "type": "step",
  "assignment": {
   "in": {
    "type": "REG",
    "reg": "STEP"
   },
   "out": [
    "A"
   ]
  },
  "ops": [],
  "line": 7
 },
 { # 0   :ASSERT,assert常量0"type": "step",
  "assignment": {
   "in": {
    "type": "CONST",
    "const": 0
   },
   "out": []
  },
  "ops": [
   {
    "assert": 1
   }
  ],
  "line": 8
 },
 { # 1   :MSTORE(a),将常量1存入a数组中的第一位置。
  "type": "step",
  "assignment": {
   "in": {
    "type": "CONST",
    "const": 1
   },
   "out": []
  },
  "ops": [
   {
    "offset": "a",
    "mOp": 1,
    "mWR": 1
   }
  ],
  "line": 10
 },
 { # 2   :MSTORE(b),将常量2存入b中。
  "type": "step",
  "assignment": {
   "in": {
    "type": "CONST",
    "const": 2
   },
   "out": []
  },
  "ops": [
   {
    "offset": "b",
    "mOp": 1,
    "mWR": 1
   }
  ],
  "line": 11
 },
 { # 3   :MSTORE(c),将常量3存入数组c中。
  "type": "step",
  "assignment": {
   "in": {
    "type": "CONST",
    "const": 3
   },
   "out": []
  },
  "ops": [
   {
    "offset": "c",
    "mOp": 1,
    "mWR": 1
   }
  ],
  "line": 12
 },
 { # 4   :MSTORE(d),将常量4存入d中。
  "type": "step",
  "assignment": {
   "in": {
    "type": "CONST",
    "const": 4
   },
   "out": []
  },
  "ops": [
   {
    "offset": "d",
    "mOp": 1,
    "mWR": 1
   }
  ],
  "line": 13
 },
 { # @a => A,将a的索引赋值给A"type": "step",
  "assignment": {
   "in": {
    "type": "reference",
    "identifier": "a"
   },
   "out": [
    "A"
   ]
  },
  "ops": [],
  "line": 14
 },
 { # @b => A,将b的索引赋值给A"type": "step",
  "assignment": {
   "in": {
    "type": "reference",
    "identifier": "b"
   },
   "out": [
    "A"
   ]
  },
  "ops": [],
  "line": 15
 },
 { # @c => A,将c的索引赋值给A"type": "step",
  "assignment": {
   "in": {
    "type": "reference",
    "identifier": "c"
   },
   "out": [
    "A"
   ]
  },
  "ops": [],
  "line": 16
 },
 { # @d => A,将d的索引赋值给A"type": "step",
  "assignment": {
   "in": {
    "type": "reference",
    "identifier": "d"
   },
   "out": [
    "A"
   ]
  },
  "ops": [],
  "line": 17
 },
 { # end:
  "type": "label",
  "identifier": "end",
  "line": 19
 },
 { # 0 => A,B,C,D,E,CTX, SP, PC, GAS, MAXMEM, SR,将这些寄存器清零。
  "type": "step",
  "assignment": {
   "in": {
    "type": "CONST",
    "const": 0
   },
   "out": [
    "A",
    "B",
    "C",
    "D",
    "E",
    "CTX",
    "SP",
    "PC",
    "GAS",
    "MAXMEM",
    "SR"
   ]
  },
  "ops": [],
  "line": 20
 },
 { # finalWait:
  "type": "label",
  "identifier": "finalWait",
  "line": 22
 },
 { # ${beforeLast()}  : JMPN(finalWait)
  "type": "step",
  "assignment": {
   "in": {
    "type": "TAG",
    "tag": "beforeLast()" # 为标签。
   },
   "out": []
  },
  "ops": [
   {
    "JMPC": 0,
    "JMPN": 1,
    "offset": "finalWait"
   }
  ],
  "line": 23
 },
 { # : JMP(start)
  "type": "step",
  "assignment": null,
  "ops": [
   {
    "JMP": 1,
    "JMPC": 0,
    "JMPN": 0,
    "offset": "start"
   }
  ],
  "line": 25
 },
 { # opINVALID:
  "type": "label",
  "identifier": "opINVALID",
  "line": 26
 }
]

然后对以上内容逐行处理:

	for (let i=0; i<lines.length; i++) {
        const l = lines[i];
        ctx.currentLine = l;
        l.fileName = relativeFileName;
        if (l.type == "include") {
            const fullFileNameI = path.resolve(fileDir, l.file);
            await compile(fullFileNameI, ctx);
            if (pendingCommands.length>0) error(l, "command not allowed before include");
            lastLineAllowsCommand = false;
        } else if (l.type == "var") {
            if (typeof ctx.vars[l.name] !== "undefined") error(l, `Variable ${l.name} already defined`);
            if (l.scope == "GLOBAL") { // 给全局变量根据名称分配,不允许有重名情况。
                ctx.vars[l.name] = {
                    scope: "GLOBAL",
                    offset: ctx.lastGlobalVarAssigned + 1
                }
                ctx.lastGlobalVarAssigned += l.count; // 适于按数组分配。
            } else if (l.scope == "CTX") {
                ctx.vars[l.name] = {
                    scope: "CTX",
                    offset: ctx.lastLocalVarCtxAssigned + 1
                }
                ctx.lastLocalVarCtxAssigned += l.count;
            } else {
                throw error(l, `Invalid scope ${l.scope}`);
            }
            if (pendingCommands.length>0) error(l, "command not allowed before var");
            lastLineAllowsCommand = false;
        } else if (l.type == 'constdef' || l.type == 'constldef' ) {
            const value = evaluateExpression(ctx, l.value);
            let ctype = l.type == 'constldef' ? 'CONSTL':'CONST';
            defineConstant(ctx, l.name, ctype, value);
        } else if (l.type == "step") { // start/end等标签下的实际执行语句
            const traceStep = { // traceStep内map:step[key]=op[key]
                // type: "step"
            };
            try {
                for (let j=0; j< l.ops.length; j++) { //过滤校验下规则,不能同时定义2个assignement。
                    if (!l.ops[j].assignment) continue;
                    if (l.assignment) {
                        error(l, "not allowed assignments with this operation");
                    }
                    l.assignment = l.ops[j].assignment;
                    delete l.ops[j].assignment;
                }
				
				/*function appendOp(step, op) {
				    Object.keys(op).forEach(function(key) {
				        if (typeof step[key] !== "undefined") throw new Error(`Var ${key} already defined`);
				        step[key] = op[key];
				    });
				}*/
                if (l.assignment) { //处理assignment中的in和out内容。
                    appendOp(traceStep, processAssignmentIn(ctx, l.assignment.in, ctx.out.length));
                    appendOp(traceStep, processAssignmentOut(ctx, l.assignment.out));
                }
                for (let j=0; j< l.ops.length; j++) { //将每个ops元素存入step map中。
                    appendOp(traceStep, l.ops[j])
                }

                if (traceStep.JMPC && !traceStep.bin) {
                    error(l, "JMPC must go together with a binary op");
                }
            } catch (err) {
                error(l, err);
            }
            // traceStep.lineNum = ctx.out.length;
            traceStep.line = l;
            ctx.out.push(traceStep); //将traceStep放入ctx.out数组中。
            if (pendingCommands.length>0) {
                traceStep.cmdBefore = pendingCommands;
                pendingCommands = [];
            }
            lastLineAllowsCommand = !(traceStep.JMP || traceStep.JMPC || traceStep.JMPN);
        } else if (l.type == "label") { // start/end等标识符,不允许有重名情况。
            const id = l.identifier
            if (ctx.definedLabels[id]) error(l, `RedefinedLabel: ${id}` );
            ctx.definedLabels[id] = ctx.out.length;
            if (pendingCommands.length>0) error(l, "command not allowed before label")
            lastLineAllowsCommand = false;
        } else if (l.type == "command") {
            if (lastLineAllowsCommand) {
                if (typeof ctx.out[ctx.out.length-1].cmdAfter === "undefined")
                    ctx.out[ctx.out.length-1].cmdAfter = [];
                ctx.out[ctx.out.length-1].cmdAfter.push(l.cmd);
            } else {
                pendingCommands.push(l.cmd);
            }
        } else {
            error(l, `Invalid line type: ${l.type}`);
        }
    }

assignment中的in内容的处理规则为:

function processAssignmentIn(ctx, input, currentLine) {
    const res = {};
    let E1, E2;
    if (input.type == "TAG") { # ${beforeLast()}  : JMPN(finalWait),会调用command_parser。
        res.freeInTag = input.tag ? command_parser.parse(input.tag) : { op: ""};
        res.inFREE = 1n;
        return res;
    }
    if (input.type == "REG") {
        if (input.reg == "zkPC") {
            res.CONST = BigInt(currentLine);
        }
        else {
            res["in"+ input.reg] = 1n;
        }
        return res;
    }
    if (input.type == "COUNTER") {
        let res = {};
        res["in" + input.counter.charAt(0).toUpperCase() + input.counter.slice(1)] = 1n;
        return res;
    }
    if (input.type == "CONST") {
        res.CONST = BigInt(input.const);
        return res;
    }
    if (input.type == "CONSTL") {
        res.CONSTL = BigInt(input.const);
        return res;
    }
    if (input.type == 'CONSTID') {
        const [value, ctype] = getConstant(ctx, input.identifier);
        res[ctype] = value;
        return res;
    }

    if (input.type == "exp") {
        res.CONST = BigInt(input.values[0])**BigInt(input.values[1]);
        return res;
    }
    if ((input.type == "add") || (input.type == "sub") || (input.type == "neg") || (input.type == "mul")) {
        E1 = processAssignmentIn(ctx, input.values[0], currentLine);
    }
    if ((input.type == "add") || (input.type == "sub") || (input.type == "mul")) {
        E2 = processAssignmentIn(ctx, input.values[1], currentLine);
    }
    if (input.type == "mul") {
        if (isConstant(E1)) {
            if (typeof E2.CONSTL !== 'undefined') {
                throw new Error("Not allowed CONST and CONSTL in same operation");
            }
            Object.keys(E2).forEach(function(key) {
                E2[key] *= E1.CONST;
            });
            return E2;
        } else if (isConstant(E2)) {
            if (typeof E1.CONSTL !== 'undefined') {
                throw new Error("Not allowed CONST and CONSTL in same operation");
            }
            Object.keys(E1).forEach(function(key) {
                E1[key] *= E2.CONST;
            });
            return E1;
        } else {
            throw new Error("Multiplication not allowed in input");
        }
    }
    if (input.type == "neg") {
        Object.keys(E1).forEach(function(key) {
            E1[key] = -E1[key];
        });
        return E1;
    }
    if (input.type == "sub") {
        Object.keys(E2).forEach(function(key) {
            if (key != "freeInTag") {
                E2[key] = -E2[key];
            }
        });
        input.type = "add";
    }
    if (input.type == "add") {
        if (E1.freeInTag && E2.freeInTag) throw new Error("Only one tag allowed");
        Object.keys(E2).forEach(function(key) {
            if (E1[key]) {
                E1[key] += E2[key];
            } else {
                E1[key] = E2[key];
            }
        });
        if (typeof E1.CONST !== 'undefined' && typeof E1.CONSTL !== 'undefined') {
            throw new Error("Not allowed CONST and CONSTL in same operation");
        }
        return E1;
    }
    if (input.type == 'reference') {
        res.labelCONST = input.identifier;
        if (typeof ctx.definedLabels[input.identifier] !== 'undefined') {
            res.CONST = BigInt(ctx.definedLabels[input.identifier]);
        }
        else if (typeof ctx.vars[input.identifier] !== 'undefined') {
            res.CONST = BigInt(ctx.vars[input.identifier].offset);
        }
        else {
            throw new Error(`Not found label/variable ${input.identifier}`)
        }
        return res;
    }
    throw new Error( `Invalid type: ${input.type}`);


    function isConstant(o) {
        let res = true;
        Object.keys(o).forEach(function(key) {
            if (key != "CONST") res = false;
        });
        return res;
    }
}

assignment中out内容的处理规则为:

function processAssignmentOut(ctx, outputs) {
    const res = {};
    for (let i=0; i<outputs.length; i++) {
        if (typeof res["set"+ outputs[i]] !== "undefined") throw new Error(`Register ${outputs[i]} added twice in asssignment output`);
        if (readOnlyRegisters.includes(outputs[i])) { // 预留的只读寄存器不可写,不能在out中。
            const l = ctx.currentLine;
            throw new Error(`Register ${outputs[i]} is readonly register, could not be used as output destination. ${l.fileName}:${l.line}`);
        }
        res["set"+ outputs[i]] = 1;
    }
    return res;
}

最后再进一步将ctx.out中的内容铺平展开:

	if (isMain) {
        for (let i=0; i<ctx.out.length; i++) {
            if (
                    (typeof ctx.out[i].offset !== "undefined") &&
                    (isNaN(ctx.out[i].offset))
               ) {
                if (ctx.out[i].JMP || ctx.out[i].JMPC || ctx.out[i].JMPN) {
                    if (typeof ctx.definedLabels[ctx.out[i].offset] === "undefined") {
                        error(ctx.out[i].line, `Label: ${ctx.out[i].offset} not defined.`);
                    }
                    ctx.out[i].offsetLabel = ctx.out[i].offset;
                    ctx.out[i].offset = ctx.definedLabels[ctx.out[i].offset];
                } else {
                    ctx.out[i].offsetLabel = ctx.out[i].offset;
                    if (typeof ctx.vars[ctx.out[i].offset] === "undefined") {
                        error(ctx.out[i].line, `Variable: ${ctx.out[i].offset} not defined.`);
                    }
                    if (ctx.vars[ctx.out[i].offset].scope === 'CTX') {
                        ctx.out[i].useCTX = 1;
                    } else if (ctx.vars[ctx.out[i].offset].scope === 'GLOBAL') {
                        ctx.out[i].useCTX = 0;
                    } else {
                        error(ctx.out[i].line, `Invalid variable scpoe: ${ctx.out[i].offset} not defined.`);
                    }
                    ctx.out[i].offset = ctx.vars[ctx.out[i].offset].offset;
                }
            }
            try {
                parseCommands(ctx.out[i].cmdBefore);
                parseCommands(ctx.out[i].cmdAfter);
            } catch (err) {
                err.message = "Error parsing tag: " + err.message;
                error(ctx.out[i].line, err);
            }
            resolveDataOffset(i, ctx.out[i]);
            ctx.out[i].fileName = ctx.out[i].line.fileName;
            ctx.out[i].line = ctx.out[i].line.line;
            ctx.out[i].lineStr = ctx.srcLines[ctx.out[i].fileName][ctx.out[i].line - 1] ?? '';
        }

        const res = {
            program:  stringifyBigInts(ctx.out),
            labels: ctx.definedLabels
        }

        return res;
    }

最终arrays.zkasm的编译结果为:

{
 "program": [
  {
   "inSTEP": "1",
   "setA": 1,
   "line": 7,
   "fileName": "arrays.zkasm",
   "lineStr": "        STEP => A"
  },
  {
   "CONST": "0",
   "assert": 1,
   "line": 8,
   "fileName": "arrays.zkasm",
   "lineStr": "        0   :ASSERT"
  },
  {
   "CONST": "1",
   "offset": 0,
   "mOp": 1,
   "mWR": 1,
   "line": 10,
   "offsetLabel": "a",
   "useCTX": 0,
   "fileName": "arrays.zkasm",
   "lineStr": "        1   :MSTORE(a)"
  },
  {
   "CONST": "2",
   "offset": 100,
   "mOp": 1,
   "mWR": 1,
   "line": 11,
   "offsetLabel": "b",
   "useCTX": 0,
   "fileName": "arrays.zkasm",
   "lineStr": "        2   :MSTORE(b)"
  },
  {
   "CONST": "3",
   "offset": 101,
   "mOp": 1,
   "mWR": 1,
   "line": 12,
   "offsetLabel": "c",
   "useCTX": 0,
   "fileName": "arrays.zkasm",
   "lineStr": "        3   :MSTORE(c)"
  },
  {
   "CONST": "4",
   "offset": 401,
   "mOp": 1,
   "mWR": 1,
   "line": 13,
   "offsetLabel": "d",
   "useCTX": 0,
   "fileName": "arrays.zkasm",
   "lineStr": "        4   :MSTORE(d)"
  },
  {
   "labelCONST": "a",
   "CONST": "0",
   "setA": 1,
   "line": 14,
   "fileName": "arrays.zkasm",
   "lineStr": "        @a => A"
  },
  {
   "labelCONST": "b",
   "CONST": "100",
   "setA": 1,
   "line": 15,
   "fileName": "arrays.zkasm",
   "lineStr": "        @b => A"
  },
  {
   "labelCONST": "c",
   "CONST": "101",
   "setA": 1,
   "line": 16,
   "fileName": "arrays.zkasm",
   "lineStr": "        @c => A"
  },
  {
   "labelCONST": "d",
   "CONST": "401",
   "setA": 1,
   "line": 17,
   "fileName": "arrays.zkasm",
   "lineStr": "        @d => A"
  },
  {
   "CONST": "0",
   "setA": 1,
   "setB": 1,
   "setC": 1,
   "setD": 1,
   "setE": 1,
   "setCTX": 1,
   "setSP": 1,
   "setPC": 1,
   "setGAS": 1,
   "setMAXMEM": 1,
   "setSR": 1,
   "line": 20,
   "fileName": "arrays.zkasm",
   "lineStr": "       0 => A,B,C,D,E,CTX, SP, PC, GAS, MAXMEM, SR"
  },
  {
   "freeInTag": {
    "op": "functionCall",
    "funcName": "beforeLast",
    "params": []
   },
   "inFREE": "1",
   "JMPC": 0,
   "JMPN": 1,
   "offset": 11,
   "line": 23,
   "offsetLabel": "finalWait",
   "fileName": "arrays.zkasm",
   "lineStr": "        ${beforeLast()}  : JMPN(finalWait)"
  },
  {
   "JMP": 1,
   "JMPC": 0,
   "JMPN": 0,
   "offset": 0,
   "line": 25,
   "offsetLabel": "start",
   "fileName": "arrays.zkasm",
   "lineStr": "                         : JMP(start)"
  }
 ],
 "labels": {
  "start": 0,
  "end": 10,
  "finalWait": 11,
  "opINVALID": 13
 }
}

参考资料

[1] zkASM基础语法

附录:Polygon Hermez 2.0 zkEVM系列博客

程序名称:RadASM 版 本:2.2.1.9 汉 化 人:cao_cong 联系方式:cao_cong_hx@yahoo.com.cn 使用说明: 此汉化增强版根据RadASM作者网站正式发布的 2.2.1.9 版汉化,可对中文完美支持,可编译DOS下的程 序并可看到运行结果。这个版本增强了对 C 编译器的支持,增加了从已有具体的更新内容请大家参考安 装目录下的 WhatsNew.txt。增强版中附带的 MASM32 更新为 10.0,我在其中放了开发驱动的相关文件 ,安装后即可使用,可以直接开发驱动程序。我还写了一篇《如何配置RasASM来支持你的编译器》的文 章放在安装包中,希望能给大家在为 RadASM 配置新的编译器时提供一点参考。汉化增强版适合于未安 装Masm32及Viusual C++的用户,添加了RadASM的帮助文件及Win32 Api等帮助文件。RadASM可通过添加 ini文件来支持别的语言,可以自己配置ini文件来支持你所使用的编程语言。此汉化增强版根据网友 aboil的建议,添加了我最新修正的 OllyDBG 汉化第二版,选择安装后路径会自动设置好,直接可在 RadASM中调试你编译后的程序。 注意: 1、如果你曾安装了以前版本的RadASM汉化增强版,请不要卸载,只需覆盖安装即可。安装版本 除了你选择了注册文件类型会在你的注册表中添加数据(可到ICON目录下查看具体添加内容,如果选择卸 载同样会删除这些数据)外,不会产生别的垃圾文件,所以没必要卸载。因为卸载时可能把你安装后新建 的一些工程一并删除,请谨慎使用卸载(默认在Masm和Cpp中新添加的工程不会被删除,但还是小心一点 比较好)。若必须要卸载的话,请把你安装后新建的工程及配置文件备份到其它目录,再执行卸载! 2、这个版本我去掉了 TASM 5.0 的安装文件(主要为减小安装包体积),若要编译Tasm的程序的 话请大家自己去下载TASM。 3、考虑到在有的未装VC的机器上测试时,编译时会提示找不到MSPDB60.DLL的错误,我在这个 安装版本中复制了一个VC的MSPDB60.DLL到你的系统目录,因为有些程序可能会用到它,所以在卸载时未 作处理。你要是不需要的话,可到你的系统目录手工删除(建议保留这个文件)。 4、如果你第一次编译 MASM 的 Dos App,可能会在构建的时候提示找不到 *.obj 文件,其实 这时 *.obj 文件已经生成了。简单的方法就是重新启动一下 RadASM,再编译、构建时就正常了。 增强版主要更新: 1、包含了编译 Win32 Asm 、C++ 的必须文件及我汉化的 OllyDBG(安装时需选择OllyDBG、 Masm32及VC6.0这几个组件)。 2、添加了用于RadASM关联汇编文件的图标(安装时需选择文件关联组件),安装后你可在安装目 录下的Icon目录内使用你喜欢的图标来定制关联文件的显示图标(替换图标时请把你需要替换的图标更名 为原目录下的对应图标名称)。 3、添加了由怜香整理的8086汇编教程、Venjiang整理的 Win32 汇编教程、陈国强整理的Win32 API参考(VB描述)、www.vcok.com整理的C语言教程及经典的 Windows 程序设计电子书。 4、添加了一个Cpp的对话框程序模板文件。 5、添加了一个Masm的注册机程序示例,位于Masm的工程目录下的ASMkeyg文件夹内,推荐大家 看一下。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值