1. 项目概述:为什么“Jack语言”是计算机系统课里最被低估的硬核一环
很多人看到“从0到1构建计算机”这个系列标题,第一反应是数字电路、ALU设计、指令译码这些硬件层内容——确实,前几讲的逻辑门搭CPU、内存寻址、微程序控制已经够烧脑了。但到了第8讲“高级语言:Jack”,不少学习者反而松了口气,觉得“终于不用画真值表了,写代码多轻松”。我带过三届某高校计算机系统导论实验班,几乎每届都有学生在这一讲交出一份能编译、能运行、但完全不符合Jack语言语义规范的“伪Jack程序”,最后卡在虚拟机层调试三天毫无进展。问题不在语法糖,而在于Jack不是Python,不是Java,它是一把专为Nand2Tetris教学体系锻造的手术刀:没有垃圾回收,没有动态类型,没有标准库封装,甚至不支持浮点数——但它强制你直面“高级语言如何落地为机器指令”这个根本命题。它用极简的语法结构(仅7种语句、5种表达式)、确定性的内存模型(静态区/堆/栈严格分离)和可预测的编译输出(每行Jack代码对应固定范围的VM指令),把抽象语法树(AST)生成、作用域管理、符号表构建、栈帧布局、函数调用约定这些黑箱,变成你能一行行跟踪、逐字节验证的白盒过程。关键词“Jack语言”背后,实际是编译原理+运行时系统+硬件接口三重能力的交汇点。适合谁?不是只想调API的初学者,而是想搞懂“为什么C语言要手动free”“为什么Java方法调用比C慢一个数量级”“为什么Rust能零成本抽象”的进阶学习者。它不教你怎么写出优雅的业务代码,而是教你亲手锻造一把能切开所有高级语言外壳的解剖刀。
2. Jack语言核心设计哲学与底层约束解析
2.1 为什么放弃“现代语言舒适区”?三个不可妥协的设计铁律
Jack语言的语法看起来像Java的简化版:类、方法、字段、局部变量、if-while等控制流。但它的每个设计选择都带着明确的硬件映射意图,绝非随意删减。我拆解过Nand2Tetris官方编译器源码,其核心约束可归为三条铁律:
第一,零抽象泄漏原则。
Jack不提供任何隐藏内存操作的语法。比如Array a = new Array(10)这行代码,表面看是创建数组,实则编译器必须生成明确的内存分配指令(push constant 10,call Memory.alloc 1)和基地址存储操作。对比Java的new int[10],JVM会自动处理对象头、GC标记位、数组长度字段等细节,而Jack要求你亲手管理每一个字节。这种“裸露”不是为了刁难,而是让你看清:所谓“对象”,不过是内存中一段连续空间+几个预设偏移量的字段;所谓“new”,本质就是向堆管理器申请一块内存并返回首地址。我在某次实验中让学生用Jack实现一个简易链表,当他们被迫手动计算this.next在对象内存布局中的偏移量(field next声明在类定义第二位,每个字段占16位,故偏移=1*2=2字)时,才真正理解“指针算术”在底层是如何被编译器翻译成pop pointer 1这样的VM指令的。
第二,确定性执行模型。
Jack禁止所有可能导致运行时行为不可预测的特性。没有异常机制(try-catch),没有反射(reflection),没有动态加载(classloader)。所有方法调用必须在编译期确定目标地址——这意味着不支持虚函数或多态分派。官方文档明确指出:“Jack编译器不生成vtable或dynamic dispatch code”。取而代之的是严格的静态绑定:obj.method()调用被直接翻译为call ClassName.method 2(2为参数个数),连方法签名都不需要运行时解析。这种“笨办法”牺牲了灵活性,却换来绝对的可追溯性:你在VM层看到的每一行call指令,都能100%反向定位到Jack源码的哪一行、哪个类、哪个方法。我曾帮一位学生排查一个栈溢出bug,他写的递归斐波那契函数在Jack中无限调用,VM监控显示function Main.fib 0指令反复压栈。由于Jack不支持尾递归优化,我们直接在编译器输出的VM文件里数push argument指令的嵌套深度,3秒就定位到是fib(n-1)+fib(n-2)导致两次递归调用未被合并——这种调试效率,在有JIT和GC的现代语言里根本无法想象。
第三,内存模型显式化。
Jack将内存划分为四个严格隔离的区域:
- 静态区(static):类级别变量,生命周期=整个程序运行期,地址由编译器静态分配;
- 堆(heap):
new操作分配,需手动Memory.deAlloc释放,无GC; - 栈(stack):方法调用时自动分配局部变量和参数,方法返回即自动回收;
- 常量区(constant):字符串字面量、数字常量,编译期固化。
关键在于,每个区域的访问方式被语法强制绑定:static字段只能用static关键字声明,访问时生成pop static n指令;field字段(对象实例变量)必须属于某个类,访问时生成pop this n(n为字段在对象内存中的索引);local变量只能在方法内声明,访问时生成pop local n。这种绑定不是约定,而是编译器语法检查的硬规则。我见过最典型的错误是学生试图在static方法里访问field变量,编译器直接报错Cannot access field from static method——因为field的访问指令pop this n依赖this指针,而static方法根本没有this上下文。这种“编译期报错”比“运行时NullPointerException”更有教育价值:它逼你思考“this指针从哪来?为什么static方法没有?”。
2.2 语法糖的真相:那些看似简单的语句,背后藏着多少VM指令?
Jack的语法简洁到只有7种语句(let, if, while, do, return, var, class),但每一种都是精心设计的“指令翻译模板”。以最常用的let语句为例,它表面是赋值,实则是内存搬运的完整流水线。考虑这段代码:
class Main { function void main() { var int a, b; let a = 5; let b = a * 2 + 1; } }编译后生成的VM指令并非简单对应,而是分层展开:
let a = 5→push constant 5+pop local 0(a是第一个local变量,索引0)let b = a * 2 + 1→ 先计算右值:push local 0(取a)→push constant 2→call Math.multiply 2(注意:Jack不内置乘法,需调用Math库)→push constant 1→call Math.add 2→ 最后pop local 1(存b)
这里暴露两个关键细节:
- 运算符必须转为函数调用:Jack语法层的
*和+是纯语法糖,编译器将其替换为对Math.multiply和Math.add的调用,参数个数固定为2。这意味着你无法重载运算符,也无法自定义+的行为——它永远是Math.add的别名。 - 临时值必须显式入栈:
a * 2 + 1的计算过程,中间结果(a*2)必须先push到栈顶,再作为Math.add的第一个参数。这直接映射了VM的基于栈架构:所有计算都在栈上进行,没有寄存器概念。
再看if语句的“陷阱”:
if (x > 0) { let y = x; } else { let y = -x; }编译器生成的VM指令包含标签跳转:先计算x>0(生成push local 0,push constant 0,gt),然后not取反,若为真则goto ELSE_START,否则执行then块。关键点在于,if的条件表达式必须产生一个布尔值(0或-1),而gt指令的结果正是-1(true)或0(false),无需额外转换。这种设计让条件判断与底层比较指令一一对应,消除了“布尔类型如何表示”这个抽象层。我在指导学生时强调:当你写出if (a == b),脑子里不该想“相等判断”,而该想“push a; push b; eq这三条VM指令”。
3. 从Jack源码到VM字节码:编译器工作流全链路实操
3.1 编译器四阶段拆解:词法分析→语法分析→语义分析→代码生成
Nand2Tetris的Jack编译器是一个典型的自顶向下递归下降解析器,其工作流清晰分为四个阶段,每个阶段输出都是下一阶段的输入。我以Main.jack文件为例,展示真实编译过程:
阶段一:词法分析(Tokenizer)
输入:原始Jack文本
输出:Token流(类型+值)
示例:let a = 5;→[KEYWORD:let, IDENTIFIER:a, SYMBOL:=, INTEGER_CONSTANT:5, SYMBOL:;]
提示:Jack的标识符规则极简——仅字母、数字、下划线,且必须以字母或下划线开头。我曾见学生用
let 123var = 5;导致tokenizer直接崩溃,因为123var被识别为INTEGER_CONSTANT:123+IDENTIFIER:var,中间缺少分隔符。编译器不会尝试修复,而是报错Expected identifier, got integer。
阶段二:语法分析(Parser)
输入:Token流
输出:抽象语法树(AST)
核心动作:根据Jack语法BNF规则,递归构建树节点。例如let语句的解析函数compileLet()会:
- 消耗
let关键字; - 消耗左值(identifier或array access);
- 消耗
=符号; - 调用
compileExpression()解析右值; - 消耗
;。
最终生成AST节点:LetStatement(left=a, right=ConstantExpression(value=5))。
注意:
compileExpression()是递归核心,它按运算符优先级(括号>一元>乘除>加减>关系>逻辑)构建子树。a * 2 + 1会被解析为AddExpression(left=MulExpression(left=VarRef(a), right=Const(2)), right=Const(1)),确保乘法先于加法计算。
阶段三:语义分析(SymbolTable Builder)
输入:AST + 当前作用域信息
输出:符号表(SymbolTable)
这是最容易被忽略却最关键的阶段。编译器为每个作用域(类、方法)维护独立符号表,记录:
- 名称(name)
- 类型(type)
- 种类(kind:static/field/arg/local)
- 索引(index:在该种类内存区的序号)
例如var int a, b;在main()方法中,符号表添加:
| name | type | kind | index |
|------|------|--------|--------|
| a | int | local | 0 |
| b | int | local | 1 |
当后续遇到let b = a * 2;,编译器查表得a的kind=local, index=0,于是生成push local 0;b的kind=local, index=1,生成pop local 1。索引不是随机分配,而是按声明顺序严格递增——这保证了栈帧布局的可预测性。
阶段四:代码生成(CodeWriter)
输入:AST + 符号表
输出:.vm文件
这是将AST节点翻译为VM指令的机械过程。以LetStatement为例,codeWriter.writeLet()函数:
- 调用
compileExpression()生成右值的VM指令(如push constant 5); - 根据左值种类调用不同指令:
- 若左值是
local x→pop local index; - 若左值是
field y→pop this index; - 若左值是
array[i]→ 先计算array基地址和i,相加得元素地址,再pop that 0(that段用于临时存储)。
关键技巧:array[i]的翻译是push array; push i; add; pop pointer 1; push that 0,其中pointer 1指向that段,that 0即数组元素。这个过程暴露了Jack数组的本质:array是一个指向首地址的指针,i是偏移量,add完成地址计算。
- 若左值是
3.2 手动模拟编译:用纸笔走通一个完整函数
为彻底掌握流程,我建议新手用纸笔模拟编译以下Square类的draw方法:
class Square { field int x, y, size; method void draw() { var int i; let i = 0; while (i < size) { do Screen.drawRectangle(x, y + i, x + size, y + i); let i = i + 1; } return; } }步骤1:构建类符号表
- 类名:Square
- 字段:x(field, index=0), y(field, index=1), size(field, index=2)
步骤2:构建draw方法符号表
- 参数:无(无arg)
- 局部变量:i(local, index=0)
步骤3:逐行生成VM指令
let i = 0→push constant 0; pop local 0while (i < size):- 循环开始标签:
label WHILE_EXP0 - 计算条件:
push local 0(i)→push this 2(size)→lt→not(因while是“当条件为真时执行”,VM中if-goto跳转到false分支) - 条件假则跳转:
if-goto WHILE_END0
- 循环开始标签:
do Screen.drawRectangle(...):- 压入4个参数:
push this 0(x)→push this 1(y)→push local 0(i)→add(y+i)→push this 0(x)→push this 2(size)→add(x+size)→push this 1(y)→push local 0(i)→add(y+i) - 调用:
call Screen.drawRectangle 4
- 压入4个参数:
let i = i + 1:push local 0→push constant 1→add→pop local 0
return:push constant 0; return(Jack方法必须返回值,void方法返回0)- 循环结束:
goto WHILE_EXP0; label WHILE_END0
全程共生成约25行VM指令。当你亲手写下每一行,并对照this段内存布局(x@0, y@1, size@2)时,“面向对象”就不再是概念,而是内存地址的精确计算。
4. 实战避坑指南:90%学习者栽在的5类典型错误
4.1 符号表管理错误:作用域混淆与索引错位
这是最高频的错误类型,占我答疑记录的43%。核心问题在于混淆“类作用域”和“方法作用域”的符号表层级。
错误案例1:在static方法中访问field
class Test { field int count; static function void bad() { let count = 10; // 编译器报错!count是field,不能在static方法中访问 } }根因:field变量存储在this段(对象实例内存),而static方法没有this指针,符号表查询count时,在当前方法符号表(空)和类符号表中找到count,但其kind=field,编译器拒绝生成pop this n指令。
解决方案:要么将count改为static,要么将方法改为method(非static)。
错误案例2:局部变量索引覆盖
method void test() { var int a; var boolean b; var int c; let a = 1; let b = true; let c = 2; }你以为索引是a@0, b@1, c@2?错!Jack中boolean和int都占1个word(16位),但索引按声明顺序分配,与类型无关。所以a@0, b@1, c@2正确。但若写成:
var int a; var int arr[10]; // 数组声明! var int c;则arr作为数组,其index仍是2,但arr[0]访问的是this 2,arr[1]是this 3...arr[9]是this 11。如果后续c的索引被误算为3,就会覆盖arr[1]。
实操心得:永远用
SymbolTable.indexOf(name)查索引,不要手算。我在编译器调试时,会在codeWriter中加入日志:System.out.println("Writing " + name + " at index " + symbolTable.indexOf(name));,一目了然。
4.2 内存段误用:this/that/argument/local的生死线
VM指令的segment参数选错,会导致程序静默崩溃(数据写到错误内存区)或诡异行为(如屏幕乱画)。以下是各段的黄金法则:
| 段名 | 存储内容 | 访问指令 | 关键约束 |
|---|---|---|---|
| static | 类级别变量 | static n | n由编译器全局分配,跨对象共享 |
| field | 对象实例变量 | this n | n为字段在对象内存中的偏移,this指针由调用者传入 |
| static/field | — | — | 绝不混用!this只能用于field,static只能用于static变量 |
| local | 方法局部变量 | local n | n为局部变量索引,方法返回即失效 |
| argument | 方法参数 | argument n | n为参数索引,argument 0是第一个参数 |
| this/that | 临时指针 | pointer 0/1 | pointer 0=this,pointer 1=that,用于间接寻址 |
致命错误:用pop argument 0给this赋值。
在构造函数constructor Square new(int ax, int ay, int asize)中,参数ax, ay, asize存于argument 0/1/2,但this指针(新对象地址)由Memory.alloc返回后,必须用pop pointer 0存入this段,而非pop argument 0。后者会覆盖第一个参数,导致ax值丢失。
4.3 表达式求值陷阱:运算符优先级与临时栈管理
Jack表达式求值严格遵循栈规则:左结合、从左到右压栈,运算符触发计算。a + b * c的求值顺序是:
push a→ 栈:[a]push b→ 栈:[a, b]push c→ 栈:[a, b, c]call Math.multiply 2→ 弹出b,c,压入bc → 栈:[a, bc]call Math.add 2→ 弹出a,bc,压入a+bc → 栈:[a+b*c]
常见误区:认为*优先级高就该先生成multiply指令。错!指令生成顺序与压栈顺序一致,multiply指令总在c之后、add之前生成。
避坑技巧:复杂表达式用括号显式分组。a + (b * c)和a + b * c生成相同VM指令,但前者可读性高,且避免因优先级记忆错误导致逻辑偏差。
4.4 方法调用失配:参数个数与this指针的隐形契约
Jack方法调用有两条铁律:
- 非static方法调用:
call ClassName.method n,其中n= 参数个数 + 1(+1是隐式的this指针) - static方法调用:
call ClassName.method n,其中n= 参数个数
错误案例:调用Screen.drawRectangle(x, y+i, x+size, y+i)时,传入4个参数,但drawRectangle是static方法,应call Screen.drawRectangle 4。若误写为call Screen.drawRectangle 5,VM会从栈顶弹出5个值,导致this指针被当作参数弹出,后续pop argument 0等指令全部错位。
实操心得:在
codeWriter.writeCall()中,我强制加入断言:if (isMethod && !isConstructor) n++;,并在日志中打印Calling+ methodName +with+ n +args,调用前一眼确认。
4.5 字符串与内存泄漏:new/delete的硬编码责任
Jack不提供字符串类,字符串字面量(如"Hello")被编译器自动转换为String.new(5)+ 5次String.appendChar。但所有new操作都必须配对deAlloc,否则内存泄漏。
典型泄漏场景:在循环中创建临时字符串:
while (i < 10) { let s = String.new(1); do s.appendChar('A'); // 忘记 do Memory.deAlloc(s); let i = i + 1; }10次循环后,堆内存被10个1字节字符串碎片占据,Memory.alloc可能失败。
解决方案:Jack标准库的String类提供dispose()方法,本质是call Memory.deAlloc 1。务必养成习惯:var String s; let s = String.new(1); ... do s.dispose();。我在所有涉及字符串的Demo中,都用try-finally模式(虽Jack无try,但用do序列模拟)确保dispose执行。
5. Jack语言的延伸价值:超越课堂的工程思维训练
5.1 从Jack到真实编译器:LLVM IR与AST的镜像关系
学完Jack编译器,你会惊讶地发现,工业级编译器(如Clang/LLVM)的核心流程与之惊人相似。LLVM的IR(Intermediate Representation)就像Jack VM指令的超集:%1 = add i32 %0, 1对应push local 0; push constant 1; add;call void @printf对应call Output.printString 1。区别在于规模——LLVM IR有数百种指令,而Jack VM仅8条(add/sub/neg/eq/gt/lt/and/or/not)。但抽象层次完全一致:前端(词法/语法分析)生成AST,中端(优化)遍历AST做变换,后端(代码生成)将AST节点映射为IR指令。我曾带学生用Python重写Jack编译器,当他们把compileExpression()函数改造成遍历AST生成LLVM IR时,只用了2小时——因为思维模型已固化:AST节点→内存操作→指令序列。这种能力迁移,远超一门课的范畴。
5.2 运行时系统设计启示:为什么Go的goroutine比Java线程轻量?
Jack的function调用模型,是理解现代运行时的关键钥匙。Jack中每次call都会:
- 将返回地址压栈;
- 将
this指针(若为method)压栈; - 为局部变量分配栈空间(
function ClassName.method n中的n); - 跳转到函数入口。
这正是栈帧(Stack Frame)的雏形。Java线程的栈帧包含局部变量表、操作数栈、动态链接、方法出口等,而Go的goroutine栈初始仅2KB,按需增长。差异根源在于:Java栈帧大小在编译期固定(因JVM需预分配),而Go运行时动态管理栈。Jack的function指令明确指定局部变量个数n,强制编译期确定栈帧大小,这解释了为何C/Java线程切换开销大——每次切换都要保存/恢复整个固定大小的栈帧。而goroutine的栈是分段的,切换只需保存少量寄存器。当你用Jack写一个递归深度1000的函数,VM监控显示栈指针SP从256一路涨到256+1000*2=2256,就直观理解了“栈空间消耗”与“递归深度”的线性关系。
5.3 硬件协同设计思维:为什么RISC-V要精简指令集?
Jack VM指令集仅有8条算术/逻辑指令,却能支撑完整高级语言。这正是RISC(Reduced Instruction Set Computing)哲学的体现:用大量简单指令组合,替代一条复杂指令。a = b * c + d在CISC(如x86)中可能有imul指令直接计算,而在RISC-V中必须分解为mul t0, b, c+add a, t0, d。Jack同理:没有multiply指令,必须call Math.multiply。这种“分解”不是缺陷,而是将复杂性从硬件转移到软件,让硬件设计更简单(晶体管少、功耗低、频率高),而软件(编译器)可以做更智能的优化(如常量折叠、死代码消除)。我在某次芯片设计讲座中,用Jack编译器生成的VM指令对比RISC-V汇编,学生立刻明白:所谓“指令集”,本质是软硬件的契约接口——Jack定义了VM层的契约,RISC-V定义了硬件层的契约,而编译器是签署契约的公证人。
6. 工具链实战:用VS Code高效开发Jack程序的配置秘籍
6.1 环境搭建:零配置启动Jack开发
官方Nand2Tetris工具包已足够,但默认配置体验不佳。我推荐以下VS Code插件组合:
- Jack Syntax Highlighter:为
.jack文件提供语法高亮(关键词/字符串/注释区分); - Auto Rename Tag:重命名类名时,自动同步修改
class XXX {和}内的名称; - Prettier:配置
.prettierrc为{"tabWidth": 4, "semi": false},适配Jack无分号风格。
关键配置:在VS Code设置中搜索files.associations,添加"*.jack": "java",让Java语法高亮引擎接管Jack文件(因语法相似度高,效果极佳)。
6.2 调试技巧:VM层单步追踪的黄金三步法
Jack调试的核心是VM指令级追踪,而非源码级。我的三步法:
- 编译时生成详细日志:修改
JackCompiler.java,在codeWriter.writePush()等函数中加入System.err.println("PUSH " + segment + " " + index),重定向到文件; - VM模拟器开启步进模式:运行
VMEmulator.sh,加载.vm文件后,点击Step按钮,每按一次执行一条VM指令; - 内存视图联动观察:在VM模拟器中,勾选
Show RAM,重点关注0-255(栈)、2048-32767(堆)、16384-20479(屏幕)区域。当执行pop local 0时,观察RAM窗口中local 0地址(通常是256)的值是否更新。
实操心得:在
while循环中,我习惯在循环体第一行插入do Output.printInt(i),这样每次迭代都在终端打印i值,比在VM中数goto指令更直观。虽然多两行代码,但节省90%调试时间。
6.3 性能优化实战:如何让Jack程序快10倍?
Jack本身无性能概念,但编译器输出可优化。以Array操作为例:
低效写法:
let i = 0; while (i < len) { let val = arr[i]; // 每次都计算arr+i地址 do process(val); let i = i + 1; }优化后:
let base = arr; // 提取基地址 let i = 0; while (i < len) { let addr = base + i; // 地址计算在循环内 let val = Memory.peek(addr); // 直接读内存 do process(val); let i = i + 1; }优化原理:arr[i]编译为push arr; push i; add; pop pointer 1; push that 0(5条指令),而Memory.peek(addr)编译为push addr; call Memory.peek 1(2条指令)。实测1000次循环,指令数从5000降至2000,速度提升2.5倍。这揭示了底层真相:减少内存寻址次数,永远是性能优化的第一法则。我在所有图形渲染Demo中,都预先计算Screen内存基地址16384,用Memory.poke(base + offset, value)替代Screen.pixelOn(x,y),帧率从15fps升至38fps。
7. 终极挑战:用Jack实现一个可交互的俄罗斯方块
7.1 架构设计:如何用Jack的限制倒逼出优雅方案
俄罗斯方块有四大模块:
- 游戏状态(board二维数组、currentPiece、score)
- 输入处理(键盘扫描)
- 物理引擎(碰撞检测、重力下落)
- 渲染(逐像素绘制)
Jack的限制(无二维数组、无浮点、无事件驱动)迫使我们创新:
- board模拟:用一维数组
field int board[200](10x20),board[y*10+x]模拟二维; - 键盘输入:轮询
Keyboard.keyPressed(),非中断; - 碰撞检测:对piece每个方块,检查
board[y*10+x] != 0; - 渲染优化:只重绘变化区域,用
Screen.drawPixel(x,y)而非清屏重绘。
核心突破点:用static方法管理全局状态。Game类声明static int board[200], score, level,所有方法为static,避免this指针开销,使主循环while(true) { do Game.update(); do Game.render(); }极致轻量。
7.2 关键算法实现:旋转矩阵与边界检查的Jack化
俄罗斯方块旋转基于坐标变换。标准I型方块坐标为[(0,0),(1,0),(2,0),(3,0)],顺时针旋转90度后为[(0,0),(0,1),(0,2),(0,3)]。Jack中无法用二维数组存坐标,我们用位运算编码:
- 每个方块用16位整数表示,bit0-3为x,bit4-7为y,如
(0,0)=0x00,(1,0)=0x01; - 旋转函数
rotatePiece(int piece):对每个bit位置,提取x,y,应用旋转公式x' = y, y' = 3-x,再组合新坐标。
边界检查更巧妙:
function boolean canMove(int dx, int dy) { var int i, x, y, nx, ny; let i = 0; while (i < 4) { // piece有4个方块 let x = getPieceX(i); // 从piece编码中提取x let y = getPieceY(i); // 提取y let nx = x + dx; let ny = y + dy; if (nx < 0 | nx >= 10 | ny < 0 | ny >= 20) { // 超出board边界 return true; // true表示不能移动 } if (Game.board[ny*10+nx] != 0) { // 与已有方块碰撞 return true; } let i = i + 1; } return false; }这里|是Jack的位或运算符(非逻辑或),因||在Jack中不存在,所有逻辑运算必须用位运算模拟。nx < 0 | nx >= 10编译为`push nx; push constant 0; lt; push nx; push constant 10; ge;