资讯专栏INFORMATION COLUMN

[译]使用 Python 编写虚拟机解释器

trilever / 2008人阅读

摘要:接下来,我们实现虚拟机这个类。在虚拟机中我们需要两个堆栈以及一些内存空间来存储程序本身译者注这里的程序请结合下文理解。截止现在,恭喜你,一个虚拟机就完成了。实际上,一个好的程序空间布局很有可能把主程序当成一个名为的子例程。

原文地址:Making a simple VM interpreter in Python

更新:根据大家的评论我对代码做了轻微的改动。感谢 robin-gvx、 bs4h 和 Dagur,具体代码见这里

Stack Machine 本身并没有任何的寄存器,它将所需要处理的值全部放入堆栈中而后进行处理。Stack Machine 虽然简单但是却十分强大,这也是为神马 Python,Java,PostScript,Forth 和其他语言都选择它作为自己的虚拟机的原因。

首先,我们先来谈谈堆栈。我们需要一个指令指针栈用于保存返回地址。这样当我们调用了一个子例程(比如调用一个函数)的时候我们就能够返回到我们开始调用的地方了。我们可以使用自修改代码(self-modifying code)来做这件事,恰如 Donald Knuth 发起的 MIX 所做的那样。但是如果这么做的话你不得不自己维护堆栈从而保证递归能正常工作。在这篇文章中,我并不会真正的实现子例程调用,但是要实现它其实并不难(可以考虑把实现它当成练习)。

有了堆栈之后你会省很多事儿。举个例子来说,考虑这样一个表达式(2+3)*4。在 Stack Machine 上与这个表达式等价的代码为 2 3 + 4 *。首先,将 23 推入堆栈中,接下来的是操作符 +,此时让堆栈弹出这两个数值,再把它两加合之后的结果重新入栈。然后将 4 入堆,而后让堆栈弹出两个数值,再把他们相乘之后的结果重新入栈。多么简单啊!

让我们开始写一个简单的堆栈类吧。让这个类继承 collections.deque

</>复制代码

  1. from collections import deque
  2. class Stack(deque):
  3. push = deque.append
  4. def top(self):
  5. return self[-1]

现在我们有了 pushpoptop 这三个方法。top 方法用于查看栈顶元素。

接下来,我们实现虚拟机这个类。在虚拟机中我们需要两个堆栈以及一些内存空间来存储程序本身(译者注:这里的程序请结合下文理解)。得益于 Pyhton 的动态类型我们可以往 list 中放入任何类型。唯一的问题是我们无法区分出哪些是字符串哪些是内置函数。正确的做法是只将真正的 Python 函数放入 list 中。我可能会在将来实现这一点。

我们同时还需要一个指令指针指向程序中下一个要执行的代码。

</>复制代码

  1. class Machine:
  2. def __init__(self, code):
  3. self.data_stack = Stack()
  4. self.return_addr_stack = Stack()
  5. self.instruction_pointer = 0
  6. self.code = code

这时候我们增加一些方便使用的函数省得以后多敲键盘。

</>复制代码

  1. def pop(self):
  2. return self.data_stack.pop()
  3. def push(self, value):
  4. self.data_stack.push(value)
  5. def top(self):
  6. return self.data_stack.top()

然后我们增加一个 dispatch 函数来完成每一个操作码做的事儿(我们并不是真正的使用操作码,只是动态展开它,你懂的)。首先,增加一个解释器所必须的循环:

</>复制代码

  1. def run(self):
  2. while self.instruction_pointer < len(self.code):
  3. opcode = self.code[self.instruction_pointer]
  4. self.instruction_pointer += 1
  5. self.dispatch(opcode)

诚如您所见的,这货只好好的做一件事儿,即获取下一条指令,让指令指针执自增,然后根据操作码分别处理。dispatch 函数的代码稍微长了一点。

</>复制代码

  1. def dispatch(self, op):
  2. dispatch_map = {
  3. "%": self.mod,
  4. "*": self.mul,
  5. "+": self.plus,
  6. "-": self.minus,
  7. "/": self.div,
  8. "==": self.eq,
  9. "cast_int": self.cast_int,
  10. "cast_str": self.cast_str,
  11. "drop": self.drop,
  12. "dup": self.dup,
  13. "if": self.if_stmt,
  14. "jmp": self.jmp,
  15. "over": self.over,
  16. "print": self.print_,
  17. "println": self.println,
  18. "read": self.read,
  19. "stack": self.dump_stack,
  20. "swap": self.swap,
  21. }
  22. if op in dispatch_map:
  23. dispatch_map[op]()
  24. elif isinstance(op, int):
  25. # push numbers on the data stack
  26. self.push(op)
  27. elif isinstance(op, str) and op[0]==op[-1]==""":
  28. # push quoted strings on the data stack
  29. self.push(op[1:-1])
  30. else:
  31. raise RuntimeError("Unknown opcode: "%s"" % op)

基本上,这段代码只是根据操作码查找是都有对应的处理函数,例如 * 对应 self.muldrop 对应 self.dropdup对应 self.dup。顺便说一句,你在这里看到的这段代码其实本质上就是简单版的 Forth。而且,Forth 语言还是值得您看看的。

总之捏,它一但发现操作码是 * 的话就直接调用 self.mul 并执行它。就像这样:

</>复制代码

  1. def mul(self):
  2. self.push(self.pop() * self.pop())

其他的函数也是类似这样的。如果我们在 dispatch_map 中查找不到相应操作函数,我们首先检查他是不是数字类型,如果是的话直接入栈;如果是被引号括起来的字符串的话也是同样处理--直接入栈。

截止现在,恭喜你,一个虚拟机就完成了。

让我们定义更多的操作,然后使用我们刚完成的虚拟机和 p-code 语言来写程序。

</>复制代码

  1. # Allow to use "print" as a name for our own method:
  2. from __future__ import print_function
  3. # ...
  4. def plus(self):
  5. self.push(self.pop() + self.pop())
  6. def minus(self):
  7. last = self.pop()
  8. self.push(self.pop() - last)
  9. def mul(self):
  10. self.push(self.pop() * self.pop())
  11. def div(self):
  12. last = self.pop()
  13. self.push(self.pop() / last)
  14. def print(self):
  15. sys.stdout.write(str(self.pop()))
  16. sys.stdout.flush()
  17. def println(self):
  18. sys.stdout.write("%s
  19. " % self.pop())
  20. sys.stdout.flush()

让我们用我们的虚拟机写个与 print((2+3)*4) 等同效果的例子。

</>复制代码

  1. Machine([2, 3, "+", 4, "*", "println"]).run()

你可以试着运行它。

现在引入一个新的操作 jump, 即 go-to 操作

</>复制代码

  1. def jmp(self):
  2. addr = self.pop()
  3. if isinstance(addr, int) and 0 <= addr < len(self.code):
  4. self.instruction_pointer = addr
  5. else:
  6. raise RuntimeError("JMP address must be a valid integer.")

它只改变指令指针的值。我们再看看分支跳转是怎么做的。

</>复制代码

  1. def if_stmt(self):
  2. false_clause = self.pop()
  3. true_clause = self.pop()
  4. test = self.pop()
  5. self.push(true_clause if test else false_clause)

这同样也是很直白的。如果你想要添加一个条件跳转,你只要简单的执行 test-value true-value false-value IF JMP 就可以了.(分支处理是很常见的操作,许多虚拟机都提供类似 JNE 这样的操作。JNEjump if not equal 的缩写)。

下面的程序要求使用者输入两个数字,然后打印出他们的和和乘积。

</>复制代码

  1. Machine([
  2. ""Enter a number: "", "print", "read", "cast_int",
  3. ""Enter another number: "", "print", "read", "cast_int",
  4. "over", "over",
  5. ""Their sum is: "", "print", "+", "println",
  6. ""Their product is: "", "print", "*", "println"
  7. ]).run()

overreadcast_int 这三个操作是长这样滴:

</>复制代码

  1. def cast_int(self):
  2. self.push(int(self.pop()))
  3. def over(self):
  4. b = self.pop()
  5. a = self.pop()
  6. self.push(a)
  7. self.push(b)
  8. self.push(a)
  9. def read(self):
  10. self.push(raw_input())

以下这一段程序要求使用者输入一个数字,然后打印出这个数字是奇数还是偶数。

</>复制代码

  1. Machine([
  2. ""Enter a number: "", "print", "read", "cast_int",
  3. ""The number "", "print", "dup", "print", "" is "", "print",
  4. 2, "%", 0, "==", ""even."", ""odd."", "if", "println",
  5. 0, "jmp" # loop forever!
  6. ]).run()

这里有个小练习给你去实现:增加 callreturn 这两个操作码。call 操作码将会做如下事情 :将当前地址推入返回堆栈中,然后调用 self.jmp()return 操作码将会做如下事情:返回堆栈弹栈,将弹栈出来元素的值赋予指令指针(这个值可以让你跳转回去或者从 call 调用中返回)。当你完成这两个命令,那么你的虚拟机就可以调用子例程了。

一个简单的解析器

创造一个模仿上述程序的小型语言。我们将把它编译成我们的机器码。

</>复制代码

  1. import tokenize
  2. from StringIO import StringIO
  3. # ...
  4. def parse(text):
  5. tokens = tokenize.generate_tokens(StringIO(text).readline)
  6. for toknum, tokval, _, _, _ in tokens:
  7. if toknum == tokenize.NUMBER:
  8. yield int(tokval)
  9. elif toknum in [tokenize.OP, tokenize.STRING, tokenize.NAME]:
  10. yield tokval
  11. elif toknum == tokenize.ENDMARKER:
  12. break
  13. else:
  14. raise RuntimeError("Unknown token %s: "%s"" %
  15. (tokenize.tok_name[toknum], tokval))
一个简单的优化:常量折叠

常量折叠(Constant folding)是窥孔优化(peephole optimization)的一个例子,也即是说再在编译期间可以针对某些明显的代码片段做些预计算的工作。比如,对于涉及到常量的数学表达式例如 2 3 +就可以很轻松的实现这种优化。

</>复制代码

  1. def constant_fold(code):
  2. """Constant-folds simple mathematical expressions like 2 3 + to 5."""
  3. while True:
  4. # Find two consecutive numbers and an arithmetic operator
  5. for i, (a, b, op) in enumerate(zip(code, code[1:], code[2:])):
  6. if isinstance(a, int) and isinstance(b, int)
  7. and op in {"+", "-", "*", "/"}:
  8. m = Machine((a, b, op))
  9. m.run()
  10. code[i:i+3] = [m.top()]
  11. print("Constant-folded %s%s%s to %s" % (a,op,b,m.top()))
  12. break
  13. else:
  14. break
  15. return code

采用常量折叠遇到唯一问题就是我们不得不更新跳转地址,但在很多情况这是很难办到的(例如:test cast_int jmp)。针对这个问题有很多解决方法,其中一个简单的方法就是只允许跳转到程序中的命名标签上,然后在优化之后解析出他们真正的地址。

如果你实现了 Forth words,也即函数,你可以做更多的优化,比如删除可能永远不会被用到的程序代码(dead code elimination)

REPL

我们可以创造一个简单的 PERL,就像这样

</>复制代码

  1. def repl():
  2. print("Hit CTRL+D or type "exit" to quit.")
  3. while True:
  4. try:
  5. source = raw_input("> ")
  6. code = list(parse(source))
  7. code = constant_fold(code)
  8. Machine(code).run()
  9. except (RuntimeError, IndexError) as e:
  10. print("IndexError: %s" % e)
  11. except KeyboardInterrupt:
  12. print("
  13. KeyboardInterrupt")

用一些简单的程序来测试我们的 REPL

</>复制代码

  1. > 2 3 + 4 * println
  2. Constant-folded 2+3 to 5
  3. Constant-folded 5*4 to 20
  4. 20
  5. > 12 dup * println
  6. 144
  7. > "Hello, world!" dup println println
  8. Hello, world!
  9. Hello, world!
  10. 你可以看到,常量折叠看起来运转正常。在第一个例子中,它把整个程序优化成这样 20 println。
下一步

当你添加完 callreturn 之后,你便可以让使用者定义自己的函数了。在Forth 中函数被称为 words,他们以冒号开头紧接着是名字然后以分号结束。例如,一个整数平方的 word 是长这样滴

</>复制代码

  1. : square dup * ;

实际上,你可以试试把这一段放在程序中,比如 Gforth

</>复制代码

  1. $ gforth
  2. Gforth 0.7.3, Copyright (C) 1995-2008 Free Software Foundation, Inc.
  3. Gforth comes with ABSOLUTELY NO WARRANTY; for details type `license"
  4. Type `bye" to exit
  5. : square dup * ; ok
  6. 12 square . 144 ok

你可以在解析器中通过发现 : 来支持这一点。一旦你发现一个冒号,你必须记录下它的名字及其地址(比如:在程序中的位置)然后把他们插入到符号表(symbol table)中。简单起见,你甚至可以把整个函数的代码(包括分号)放在字典中,譬如:

</>复制代码

  1. symbol_table = {
  2. "square": ["dup", "*"]
  3. # ...
  4. }

当你完成了解析的工作,你可以连接你的程序:遍历整个主程序并且在符号表中寻找自定义函数的地方。一旦你找到一个并且它没有在主程序的后面出现,那么你可以把它附加到主程序的后面。然后用

call 替换掉 square,这里的
是函数插入的地址。

为了保证程序能正常执行,你应该考虑剔除 jmp 操作。否则的话,你不得不解析它们。它确实能执行,但是你得按照用户编写程序的顺序保存它们。举例来说,你想在子例程之间移动,你要格外小心。你可能需要添加 exit 函数用于停止程序(可能需要告诉操作系统返回值),这样主程序就不会继续执行以至于跑到子例程中。

实际上,一个好的程序空间布局很有可能把主程序当成一个名为 main 的子例程。或者由你决定搞成什么样子。

如您所见,这一切都是很有趣的,而且通过这一过程你也学会了很多关于代码生成、链接、程序空间布局相关的知识。

更多能做的事儿

你可以使用 Python 字节码生成库来尝试将虚拟机代码为原生的 Python 字节码。或者用 Java 实现运行在 JVM 上面,这样你就可以自由使用 JITing。

同样的,你也可以尝试下register machine。你可以尝试用栈帧(stack frames)实现调用栈(call stack),并基于此建立调用会话。

最后,如果你不喜欢类似 Forth 这样的语言,你可以创造运行于这个虚拟机之上的自定义语言。譬如,你可以把类似 (2+3)*4 这样的中缀表达式转化成 2 3 + 4 * 然后生成代码。你也可以允许 C 风格的代码块 { ... } 这样的话,语句 if ( test ) { ... } else { ... } 将会被翻译成

</>复制代码

  1. if
  2. jmp
  3. jmp
  4. jmp

例子,

</>复制代码

  1. Address Code
  2. ------- ----
  3. 0 2 3 >
  4. 3 7 # Address of true-block
  5. 4 11 # Address of false-block
  6. 5 if
  7. 6 jmp # Conditional jump based on test
  8. # True-block
  9. 7 "Two is greater than three."
  10. 8 println
  11. 9 15 # Continue main program
  12. 10 jmp
  13. # False-block ("else { ... }")
  14. 11 "Two is less than three."
  15. 12 println
  16. 13 15 # Continue main program
  17. 14 jmp
  18. # If-statement finished, main program continues here
  19. 15 ...

对了,你还需要添加比较操作符 != < <= > >=

我已经在我的 C++ stack machine 实现了这些东东,你可以参考下。

我已经把这里呈现出来的代码搞成了个项目 Crianza,它使用了更多的优化和实验性质的模型来吧程序编译成 Python 字节码。

祝好运!

完整的代码

下面是全部的代码,兼容 Python 2 和 Python 3

你可以通过 这里 得到它。

</>复制代码

  1. #!/usr/bin/env python
  2. # coding: utf-8
  3. """
  4. A simple VM interpreter.
  5. Code from the post at http://csl.name/post/vm/
  6. This version should work on both Python 2 and 3.
  7. """
  8. from __future__ import print_function
  9. from collections import deque
  10. from io import StringIO
  11. import sys
  12. import tokenize
  13. def get_input(*args, **kw):
  14. """Read a string from standard input."""
  15. if sys.version[0] == "2":
  16. return raw_input(*args, **kw)
  17. else:
  18. return input(*args, **kw)
  19. class Stack(deque):
  20. push = deque.append
  21. def top(self):
  22. return self[-1]
  23. class Machine:
  24. def __init__(self, code):
  25. self.data_stack = Stack()
  26. self.return_stack = Stack()
  27. self.instruction_pointer = 0
  28. self.code = code
  29. def pop(self):
  30. return self.data_stack.pop()
  31. def push(self, value):
  32. self.data_stack.push(value)
  33. def top(self):
  34. return self.data_stack.top()
  35. def run(self):
  36. while self.instruction_pointer < len(self.code):
  37. opcode = self.code[self.instruction_pointer]
  38. self.instruction_pointer += 1
  39. self.dispatch(opcode)
  40. def dispatch(self, op):
  41. dispatch_map = {
  42. "%": self.mod,
  43. "*": self.mul,
  44. "+": self.plus,
  45. "-": self.minus,
  46. "/": self.div,
  47. "==": self.eq,
  48. "cast_int": self.cast_int,
  49. "cast_str": self.cast_str,
  50. "drop": self.drop,
  51. "dup": self.dup,
  52. "exit": self.exit,
  53. "if": self.if_stmt,
  54. "jmp": self.jmp,
  55. "over": self.over,
  56. "print": self.print,
  57. "println": self.println,
  58. "read": self.read,
  59. "stack": self.dump_stack,
  60. "swap": self.swap,
  61. }
  62. if op in dispatch_map:
  63. dispatch_map[op]()
  64. elif isinstance(op, int):
  65. self.push(op) # push numbers on stack
  66. elif isinstance(op, str) and op[0]==op[-1]==""":
  67. self.push(op[1:-1]) # push quoted strings on stack
  68. else:
  69. raise RuntimeError("Unknown opcode: "%s"" % op)
  70. # OPERATIONS FOLLOW:
  71. def plus(self):
  72. self.push(self.pop() + self.pop())
  73. def exit(self):
  74. sys.exit(0)
  75. def minus(self):
  76. last = self.pop()
  77. self.push(self.pop() - last)
  78. def mul(self):
  79. self.push(self.pop() * self.pop())
  80. def div(self):
  81. last = self.pop()
  82. self.push(self.pop() / last)
  83. def mod(self):
  84. last = self.pop()
  85. self.push(self.pop() % last)
  86. def dup(self):
  87. self.push(self.top())
  88. def over(self):
  89. b = self.pop()
  90. a = self.pop()
  91. self.push(a)
  92. self.push(b)
  93. self.push(a)
  94. def drop(self):
  95. self.pop()
  96. def swap(self):
  97. b = self.pop()
  98. a = self.pop()
  99. self.push(b)
  100. self.push(a)
  101. def print(self):
  102. sys.stdout.write(str(self.pop()))
  103. sys.stdout.flush()
  104. def println(self):
  105. sys.stdout.write("%s
  106. " % self.pop())
  107. sys.stdout.flush()
  108. def read(self):
  109. self.push(get_input())
  110. def cast_int(self):
  111. self.push(int(self.pop()))
  112. def cast_str(self):
  113. self.push(str(self.pop()))
  114. def eq(self):
  115. self.push(self.pop() == self.pop())
  116. def if_stmt(self):
  117. false_clause = self.pop()
  118. true_clause = self.pop()
  119. test = self.pop()
  120. self.push(true_clause if test else false_clause)
  121. def jmp(self):
  122. addr = self.pop()
  123. if isinstance(addr, int) and 0 <= addr < len(self.code):
  124. self.instruction_pointer = addr
  125. else:
  126. raise RuntimeError("JMP address must be a valid integer.")
  127. def dump_stack(self):
  128. print("Data stack (top first):")
  129. for v in reversed(self.data_stack):
  130. print(" - type %s, value "%s"" % (type(v), v))
  131. def parse(text):
  132. # Note that the tokenizer module is intended for parsing Python source
  133. # code, so if you"re going to expand on the parser, you may have to use
  134. # another tokenizer.
  135. if sys.version[0] == "2":
  136. stream = StringIO(unicode(text))
  137. else:
  138. stream = StringIO(text)
  139. tokens = tokenize.generate_tokens(stream.readline)
  140. for toknum, tokval, _, _, _ in tokens:
  141. if toknum == tokenize.NUMBER:
  142. yield int(tokval)
  143. elif toknum in [tokenize.OP, tokenize.STRING, tokenize.NAME]:
  144. yield tokval
  145. elif toknum == tokenize.ENDMARKER:
  146. break
  147. else:
  148. raise RuntimeError("Unknown token %s: "%s"" %
  149. (tokenize.tok_name[toknum], tokval))
  150. def constant_fold(code):
  151. """Constant-folds simple mathematical expressions like 2 3 + to 5."""
  152. while True:
  153. # Find two consecutive numbers and an arithmetic operator
  154. for i, (a, b, op) in enumerate(zip(code, code[1:], code[2:])):
  155. if isinstance(a, int) and isinstance(b, int)
  156. and op in {"+", "-", "*", "/"}:
  157. m = Machine((a, b, op))
  158. m.run()
  159. code[i:i+3] = [m.top()]
  160. print("Constant-folded %s%s%s to %s" % (a,op,b,m.top()))
  161. break
  162. else:
  163. break
  164. return code
  165. def repl():
  166. print("Hit CTRL+D or type "exit" to quit.")
  167. while True:
  168. try:
  169. source = get_input("> ")
  170. code = list(parse(source))
  171. code = constant_fold(code)
  172. Machine(code).run()
  173. except (RuntimeError, IndexError) as e:
  174. print("IndexError: %s" % e)
  175. except KeyboardInterrupt:
  176. print("
  177. KeyboardInterrupt")
  178. def test(code = [2, 3, "+", 5, "*", "println"]):
  179. print("Code before optimization: %s" % str(code))
  180. optimized = constant_fold(code)
  181. print("Code after optimization: %s" % str(optimized))
  182. print("Stack after running original program:")
  183. a = Machine(code)
  184. a.run()
  185. a.dump_stack()
  186. print("Stack after running optimized program:")
  187. b = Machine(optimized)
  188. b.run()
  189. b.dump_stack()
  190. result = a.data_stack == b.data_stack
  191. print("Result: %s" % ("OK" if result else "FAIL"))
  192. return result
  193. def examples():
  194. print("** Program 1: Runs the code for `print((2+3)*4)`")
  195. Machine([2, 3, "+", 4, "*", "println"]).run()
  196. print("
  197. ** Program 2: Ask for numbers, computes sum and product.")
  198. Machine([
  199. ""Enter a number: "", "print", "read", "cast_int",
  200. ""Enter another number: "", "print", "read", "cast_int",
  201. "over", "over",
  202. ""Their sum is: "", "print", "+", "println",
  203. ""Their product is: "", "print", "*", "println"
  204. ]).run()
  205. print("
  206. ** Program 3: Shows branching and looping (use CTRL+D to exit).")
  207. Machine([
  208. ""Enter a number: "", "print", "read", "cast_int",
  209. ""The number "", "print", "dup", "print", "" is "", "print",
  210. 2, "%", 0, "==", ""even."", ""odd."", "if", "println",
  211. 0, "jmp" # loop forever!
  212. ]).run()
  213. if __name__ == "__main__":
  214. try:
  215. if len(sys.argv) > 1:
  216. cmd = sys.argv[1]
  217. if cmd == "repl":
  218. repl()
  219. elif cmd == "test":
  220. test()
  221. examples()
  222. else:
  223. print("Commands: repl, test")
  224. else:
  225. repl()
  226. except EOFError:
  227. print("")

本文系 OneAPM 工程师编译整理。想阅读更多技术文章,请访问 OneAPM 官方技术博客。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/45373.html

相关文章

  • 初识Java(

    摘要:图片含义如下源代码程序编译器编译在执行字节码编译器会将源代码编译成字节码在虚拟机上执行字节码。字节码只能在上执行。的构成要素的构成如下图所示每一栏分别的含义如下源程序字节码编译调试程序等源代码由开发者编写。 源自Javaの道日语技术社区原文地址译者 梦梦的幻想乡見てくれてありがとうござい!!! はじめてのJava 初识Java 本章将会对Java的执行顺序、Java的构成要素、Java...

    qqlcbb 评论0 收藏0
  • 第2章:软件构建的过程和工具 2.2软件构建的过程,系统和工具

    摘要:建模语言建模语言是可用于表达信息或知识或系统的任何人造语言,该结构由一组一致的规则定义,目标是可视化,推理,验证和传达系统设计。将这些文件安排到不同的地方称为源代码树。源代码树的结构通常反映了软件的体系结构。 大纲 软件构建的一般过程: 编程/重构 审查和静态代码分析 调试(倾倒和记录)和测试 动态代码分析/分析 软件构建的狭义过程(Build): 构建系统:组件和过程 构建变体...

    godiscoder 评论0 收藏0

发表评论

0条评论

trilever

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<