C0nvR3 Lab

Search workspace

Keyboard shortcuts

Ctrl + K
Open search
Esc
Close menu or dialog

How2pwn\0-0-computer_structure\index.md

0-0 计算机组成初步

Table of contents

从 C 语言到汇编:二进制安全需要的计算机组成基础

我们平时编写 C 语言程序时,习惯用变量、循环、判断和函数来描述程序逻辑:

int add(int a, int b) {
    return a + b;
}

但是 CPU 并不认识 intreturn 或函数名。源代码经过编译器处理后,会被翻译成机器指令;机器指令本质上是一串二进制数据。汇编语言则为这些机器指令提供了便于人类阅读的写法。上面的函数可能对应如下汇编逻辑:

add:
    lea eax, [rdi + rsi]
    ret

对于普通软件开发,我们未必需要关心翻译后的结果;但在逆向工程、漏洞分析和恶意代码分析中,拿到的往往只有编译后的程序。此时,我们必须从寄存器、内存和汇编指令出发,重新理解程序做了什么。

本文不会完整讲授数字电路、流水线或缓存一致性等计算机组成原理课程内容,而是围绕二进制安全所需的主线展开:

硬件如何协作 → CPU 如何保存数据 → 汇编指令如何操作数据和改变控制流 → 函数如何使用栈与寄存器。

文中的汇编代码采用 x86-64 Intel 语法,运行环境默认为 Linux 用户态,函数调用遵循 System V AMD64 ABI。示例以讲清原理为第一目标,实际编译结果会随着编译器版本、优化等级和源代码写法发生变化。

计算机由哪些主要硬件组成

一台计算机包含很多部件。对于分析普通用户态程序,我们首先需要认识 CPU、内存、外部存储、总线和输入输出设备。

部件主要作用与二进制分析的关系
CPU解释并执行机器指令,完成运算和控制汇编指令最终在 CPU 中执行
内存(RAM)临时保存正在运行的程序、代码和数据代码、栈、堆和全局变量都位于进程的虚拟地址空间中
硬盘或固态硬盘长期保存程序和文件,断电后数据仍存在ELF 可执行文件最初存放在这里,运行时再由系统装入内存
总线在 CPU、内存和设备之间传输地址、数据和控制信息决定各硬件如何交换信息,但初学阶段不必深入电气细节
输入输出设备让计算机与用户和外界交换数据程序通常通过操作系统提供的接口访问这些设备

1. CPU 内部有什么

为了便于理解,可以把 CPU 内部的重要部分概括为:

  • 控制单元:取得指令、解释指令,并协调其他部件完成操作。
  • 运算单元:执行加减、比较、位运算等操作,也常被称为算术逻辑单元(ALU)。
  • 寄存器:CPU 内部容量很小但速度很快的存储位置,用来保存当前正在处理的数据、地址和状态。
  • 缓存:位于 CPU 与内存之间,用来保存近期可能再次使用的指令和数据,减少等待内存的时间。

真实 CPU 的结构远比上述描述复杂,还包含多级流水线、分支预测、乱序执行等机制。不过从程序可见的角度看,我们暂时可以把它理解为一台不断读取并执行指令的机器。

2. 取指、译码与执行

CPU 执行程序时,会不断重复一个基本过程:

  1. 取指:根据指令指针指向的地址,从内存中取得下一条机器指令。
  2. 译码:判断这条指令要做什么、操作数来自哪里。
  3. 执行:完成数据传输、运算、访存或跳转,并更新相关寄存器。
  4. 继续:如果没有发生跳转,就执行地址上的下一条指令;如果发生跳转,则从新的地址继续。

例如,CPU 执行 add eax, ebx 时,需要读取 EAXEBX,完成加法,把结果写回 EAX,同时更新部分状态标志。

3. 为什么需要不同层次的存储

寄存器很快,却只有少量;内存容量大一些,但访问更慢;硬盘容量更大,也能长期保存数据,但速度远低于内存。计算机通过分层存储在速度、容量和成本之间取得平衡。

大致可以按照下面的顺序理解:

速度快、容量小
    寄存器
    CPU 缓存
    内存(RAM)
    SSD / HDD
速度慢、容量大

需要注意,“变量存放在哪里”不是永远固定的。编译器可能把一个变量放在寄存器里,也可能放在栈上;开启优化后,一个没有实际用途的变量甚至可能被直接删除。逆向分析时,我们关注的是数据在当前指令中的真实位置,而不是源代码中的名字。

x86-64 寄存器

寄存器位于 CPU 内部。汇编代码经常直接读写寄存器,因此认识寄存器是阅读反汇编的第一步。

1. 通用寄存器

x86-64 提供 16 个主要的 64 位通用寄存器:

寄存器常见约定用途
RAX函数返回值,部分算术指令的隐含操作数
RBX通用数据;在 System V 调用约定中由被调用者保存
RCX通用数据、函数第 4 个整数参数、部分循环或字符串操作
RDX通用数据、函数第 3 个整数参数、部分乘除法操作
RSI通用数据、函数第 2 个整数参数
RDI通用数据、函数第 1 个整数参数
RBP可作为栈帧基址,也可以被编译器当作普通通用寄存器
RSP栈顶指针
R8R9通用数据、函数第 5、6 个整数参数
R10R11通用数据,由调用者保存
R12R15通用数据,由被调用者保存

表中的用途大多来自指令规则或调用约定,并不代表寄存器只能这样使用。例如,RAX 除了保存返回值,也完全可以用于普通加法。分析程序时,应先看指令如何使用它,再结合上下文判断含义。

2. 特殊寄存器

  • RIP:指令指针,表示下一条将要执行的指令位置。普通 mov 指令不能随意直接修改它;跳转、调用和返回等控制流指令会改变它。
  • RSP:栈顶指针,保存当前栈顶地址。函数创建局部空间、保存数据或恢复现场时都会修改它。
  • RBP:常用作栈帧基址,使函数能够通过固定偏移访问参数和局部变量;开启优化后经常被省略。
  • RFLAGS:保存运算状态和部分控制标志。条件跳转主要读取其中的零标志、符号标志、进位标志和溢出标志。

本文不展开浮点、SIMD、控制寄存器和内核态寄存器。这些内容在遇到相关程序时再学习即可。

3. 子寄存器

同一个寄存器可以用不同名称访问不同宽度。以 RAX 为例:

63                                      0
+---------------------------------------+
|                  RAX                  | 64 位
+-------------------+-------------------+
                    |        EAX        | 32 位
                    +---------+---------+
                              |   AX    | 16 位
                              +----+----+
                              | AH | AL | 各 8 位
                              +----+----+

RBX/EBX/BX/BLRCX/ECX/CX/CL 等名称也遵循类似规律。R8R15 则使用 R8DR8WR8B 这样的后缀表示 32、16、8 位部分。

在 64 位模式下,有一条非常重要的规则:

写入 32 位子寄存器会自动把对应 64 位寄存器的高 32 位清零;写入 8 位或 16 位子寄存器不会自动清除其余位。

例如:

mov rax, 0xffffffffffffffff
mov eax, 1

执行后,RAX 的值是 0x0000000000000001,而不是 0xffffffff00000001。理解这一点能够避免很多寄存器追踪错误。

汇编语言的基本写法

1. 指令和操作数

一条汇编指令通常由“指令助记符 + 操作数”组成:

mov eax, 10
add eax, ebx

本文使用 Intel 语法,基本格式是:

指令 目标操作数, 源操作数

因此 mov eax, ebx 表示把 EBX 的值复制到 EAX,不会反过来修改 EBX

常见操作数包括:

  • 立即数:直接写在指令中的常量,如 100x20
  • 寄存器操作数:数据位于寄存器中,如 rax
  • 内存操作数:方括号内给出要访问的内存地址,如 [rbx][rbp-8]

例如:

mov eax, 10          ; EAX = 10
mov ebx, eax         ; EBX = EAX
mov ecx, [rdi]       ; 从 RDI 指向的内存读取 4 字节到 ECX
mov [rsi + 4], ecx   ; 把 ECX 的 4 字节写入 RSI+4 指向的内存

寄存器宽度通常暗示访问的数据大小:AL 是 1 字节,AX 是 2 字节,EAX 是 4 字节,RAX 是 8 字节。某些场景需要显式写出 byte ptrdword ptrqword ptr,以免数据宽度产生歧义。

x86 的普通 mov 通常不能同时把源和目标都写成内存操作数。若要把一处内存的数据复制到另一处,一般先经过寄存器:

mov eax, [rdi]
mov [rsi], eax

2. 内存寻址

x86-64 常见的内存地址形式可以写成:

[基址寄存器 + 索引寄存器 * 比例 + 偏移]

其中比例只能是 1248。例如,一个 int 占 4 字节,读取数组的第 i 个元素可能写成:

mov eax, [rdi + rsi*4]

RDI 保存数组首地址、RSI 保存下标 i,这条指令就近似对应:

eax = ((int *)rdi)[rsi];

方括号表示“访问该地址处的内存”。没有方括号时,操作的是地址值或寄存器值本身。

3. 数据宽度与小端序

内存可以看成一串按地址编号的字节。一个地址通常对应一个字节,而 int、指针等数据会连续占用多个字节。x86-64 使用小端序保存多字节整数,也就是把数值的低位字节放在较低的内存地址。

假设从地址 0x1000 开始保存 32 位数值 0x11223344,内存布局是:

地址      内容
0x1000    44
0x1001    33
0x1002    22
0x1003    11

这并不表示数值被反转了,只是规定了各字节在内存中的排列顺序。CPU 用 32 位宽度读取 0x1000 时,得到的仍是 0x11223344。但在调试器或十六进制编辑器中逐字节观察时,通常会先看到 44 33 22 11

数据宽度同样重要。若 RDI = 0x1000

mov al, [rdi]       ; 读取 1 字节,得到 0x44
mov ax, [rdi]       ; 读取 2 字节,得到 0x3344
mov eax, [rdi]      ; 读取 4 字节,得到 0x11223344

逆向分析时,同一个地址可能被按不同宽度访问。访问宽度有助于推测数据类型和结构布局,也是判断缓冲区边界是否正确的重要依据。

常见的数据传输指令

1. mov:复制数据

mov 是最常见的数据传输指令:

mov rax, rbx        ; RAX = RBX
mov eax, [rdi]      ; 从内存读取 4 字节
mov [rdi], eax      ; 向内存写入 4 字节

它是“复制”而不是“移动后清空”。执行 mov rax, rbx 后,RBX 保持不变。

2. movzxmovsx:扩展较小的数据

把 8 位或 16 位数据放入更宽的寄存器时,需要决定高位如何填写:

  • movzx:零扩展,高位补 0,适合无符号数。
  • movsx:符号扩展,根据源数据的最高位填充高位,适合有符号数。
mov al, 0xff
movzx ecx, al       ; ECX = 255
movsx edx, al       ; EDX = 0xffffffff,即有符号数 -1

相同的位模式 0xff 可以表示无符号数 255,也可以表示 8 位有符号数 -1。CPU 不会给寄存器贴上“有符号”或“无符号”标签,具体含义取决于后续使用的指令。

3. lea:计算有效地址

lea 的原意是“加载有效地址”。它计算方括号中的地址表达式,但不会读取该地址处的内存:

lea rax, [rdi + 8]  ; RAX = RDI + 8
mov rax, [rdi + 8]  ; RAX = 内存[RDI + 8]

二者看起来相似,含义却完全不同。因为 lea 可以完成加法和有限倍率乘法,编译器也常把它用于普通算术:

int f(int x) {
    return x * 5 + 3;
}

一种可能的汇编实现是:

f:
    lea eax, [rdi + rdi*4 + 3]
    ret

这里没有访问内存,只是计算 x + x*4 + 3

4. pushpop:在栈上保存和取回数据

在 x86-64 中,可以把下面两条指令近似理解为:

push rax
; 等价效果:RSP = RSP - 8;内存[RSP] = RAX

pop rbx
; 等价效果:RBX = 内存[RSP];RSP = RSP + 8

push 先降低 RSP 再写入,pop 先读取再提高 RSP。二者必须配合栈的布局来理解,后文会详细说明。

运算、比较与标志位

1. 基本运算

add eax, ebx        ; EAX = EAX + EBX
sub eax, 4          ; EAX = EAX - 4
xor eax, eax        ; EAX = 0,同时清零 RAX 的高 32 位

xor eax, eax 常用于将寄存器清零。它并不神秘:任何值与自身逐位异或,结果都是 0。

2. 常见状态标志

许多算术和逻辑指令会更新 RFLAGS 中的状态。阅读条件跳转时,最常见的是:

标志名称常见含义
ZFZero Flag结果是否为 0
SFSign Flag结果最高位是否为 1
CFCarry Flag无符号运算是否产生进位或借位
OFOverflow Flag有符号运算是否溢出

3. cmp:比较两个值

cmp eax, ebx

cmp 在内部按照 EAX - EBX 更新标志位,但不保存减法结果,也不会改变 EAXEBX。紧随其后的条件跳转会读取这些标志。

有符号数和无符号数使用不同的条件跳转:

含义有符号跳转无符号跳转
相等jeje
不相等jnejne
大于jgja
大于等于jgejae
小于jljb
小于等于jlejbe

这里的 g/l 可以理解为 signed greater/less,a/b 可以理解为 unsigned above/below。相同的位模式在有符号和无符号解释下大小关系可能完全不同,因此选择哪组跳转非常重要。

4. test:按位测试

test 对两个操作数执行按位与,更新标志位,但不保存结果。最常见的用法是判断一个值是否为 0:

test eax, eax
je .is_zero

EAX 为 0,EAX & EAX 的结果也是 0,ZF 被置为 1,je 就会跳转。

控制流指令

程序默认按地址顺序执行。控制流指令可以改变下一条指令的位置,从而实现判断、循环和函数调用。

1. jmp:无条件跳转

jmp .done

执行后,无论标志位如何,都会从 .done 标签处继续。标签只是汇编代码中对地址的命名,生成机器码后通常会变成目标地址或相对偏移。

2. 条件跳转与 if/else

下面的 C 函数返回两个有符号整数中的较大值:

int max_value(int a, int b) {
    if (a > b) {
        return a;
    }
    return b;
}

一种直观的汇编写法是:

max_value:
    cmp edi, esi          ; 比较 a 和 b
    jle .use_b            ; 如果 a <= b,跳到 .use_b
    mov eax, edi          ; 返回 a
    ret
.use_b:
    mov eax, esi          ; 返回 b
    ret

根据 System V AMD64 调用约定,前两个 int 参数位于 EDIESI,返回值写入 EAX。由于 int 是有符号类型,这里使用 jle,而不是用于无符号比较的 jbe

3. 条件跳转与循环

int sum_to_n(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}

一种便于教学的汇编实现是:

sum_to_n:
    xor eax, eax          ; sum = 0
    mov ecx, 1            ; i = 1
.loop:
    cmp ecx, edi
    jg .done              ; i > n 时结束
    add eax, ecx          ; sum += i
    add ecx, 1            ; i++
    jmp .loop
.done:
    ret

逆向时,循环不一定保留 forwhile 的外观。我们通常通过“向前的条件跳转 + 跳回较低地址的跳转”识别循环结构,再分析初始化、循环条件和变量更新。

4. callret

call target 主要完成两件事:

  1. call 后面那条指令的地址压入栈中,作为返回地址。
  2. 把控制流转移到 target

ret 则从栈顶取出返回地址,并跳回该地址。可以近似理解为:

call target:push 下一条指令地址;跳转到 target
ret:        pop RIP

这就是函数执行结束后能够回到调用位置的基本原因。如果栈上的返回地址被错误覆盖,ret 就可能跳到错误的位置;经典的栈缓冲区溢出利用正是由此展开。

栈与栈帧

1. 栈是什么

栈是进程内存中的一片区域,按照“后进先出”的方式使用。x86-64 用户态程序的栈通常向低地址增长:压入数据时 RSP 变小,弹出数据时 RSP 变大。

假设 RSP 最初为 0x1000RAX0x1122334455667788

push rax

执行后:

高地址
0x1000  ← 压栈前的 RSP
0x0ff8  0x1122334455667788  ← 压栈后的 RSP
低地址

随后执行 pop rbxRBX 得到该值,RSP 恢复为 0x1000

栈常用于保存:

  • 函数返回地址;
  • 无法全部放入寄存器的参数;
  • 局部变量和临时数据;
  • 需要跨函数调用保持的寄存器值;
  • 对齐所需的填充空间。

2. 一个典型栈帧

在未优化或保留帧指针的代码中,函数开头常见:

push rbp
mov rbp, rsp
sub rsp, 32

含义依次是:保存调用者的 RBP,让当前 RBP 指向本函数栈帧的固定基准,再为局部变量预留 32 字节空间。此时可通过 [rbp-4][rbp-8] 等地址访问局部数据。

函数结束时可能使用:

mov rsp, rbp
pop rbp
ret

也可能用一条 leave 代替前两条。一个简化的栈帧布局如下:

高地址
+-------------------------+
| 栈上传递的额外参数       |
+-------------------------+
| 返回地址                 |  由 call 压入
+-------------------------+
| 调用者原来的 RBP         |  ← RBP
+-------------------------+
| 局部变量、临时数据        |
| 保存的寄存器、填充空间    |  ← RSP
+-------------------------+
低地址

这只是常见形式,并不是每个函数都必须如此。优化后的编译器可以省略 RBP 栈帧,直接相对 RSP 访问数据;若所有局部变量都能放入寄存器,函数甚至无需分配栈空间。逆向时不要依赖固定模板,而要追踪 RSP 的实际变化。

3. 调用过程中栈如何变化

假设调用者即将执行:

call worker

若调用前 RSP = 0x1000,则 call 会把返回地址压入栈,进入 workerRSP = 0x0ff8。若 worker 随后执行 push rbpRSP 变为 0x0ff0。函数结束时:

  1. pop rbp 使 RSP0x0ff0 回到 0x0ff8
  2. ret 取出返回地址,使 RSP 回到 0x1000
  3. CPU 从 call 后面的指令继续执行。

只要压入和弹出的数据不匹配,返回地址就可能被当成普通数据弹走,或者 ret 取到错误地址。因此,分析函数时应特别关注每一处对 RSP 的修改。

Linux x86-64 函数调用约定

如果每个函数都随意选择寄存器传参,分别编译的代码将无法可靠协作。调用约定规定了参数放在哪里、返回值放在哪里、哪些寄存器需要保持,以及栈应如何对齐。

本文讨论 Linux 等类 Unix 系统常用的 System V AMD64 ABI。这里只介绍普通整数和指针参数;浮点参数、结构体返回和可变参数还有额外规则。

1. 参数与返回值

前六个整数或指针参数依次使用:

参数序号64 位寄存器传递 int 时常见部分
第 1 个RDIEDI
第 2 个RSIESI
第 3 个RDXEDX
第 4 个RCXECX
第 5 个R8R8D
第 6 个R9R9D

更多整数参数通过栈传递。整数或指针返回值通常位于 RAX;返回 int 时常看到写入 EAX

例如:

long add_three(long a, long b, long c) {
    return a + b + c;
}

可以写成:

add_three:
    lea rax, [rdi + rsi]
    add rax, rdx
    ret

2. 调用者保存与被调用者保存

函数调用可能覆盖一部分寄存器。为了让调用双方能够配合,ABI 把通用寄存器大致分为两类:

类型寄存器规则
调用者保存RAXRCXRDXRSIRDIR8R11调用者若想在 call 后继续使用旧值,应在调用前自行保存
被调用者保存RBXRBPR12R15被调用者若要修改,必须先保存,并在返回前恢复

RSP 也必须在函数返回时恢复到调用约定要求的位置,否则调用者无法正确继续执行。

“保存”不代表每次都要把所有相关寄存器压栈。函数只需处理自己真正修改且必须保持的寄存器。编译器也可能选择把跨调用仍需使用的值放进 RBX 等被调用者保存寄存器,或暂存在栈上。

3. 栈对齐

System V AMD64 ABI 要求函数调用边界满足 16 字节对齐。便于初学者记忆的方式是:

  • 调用者执行 call 前,通常让 RSP 是 16 的倍数;
  • call 压入 8 字节返回地址,因此被调用函数刚进入时通常有 RSP % 16 == 8
  • 被调用函数若还要调用其他函数,需要在下一次 call 前重新调整好对齐。

这也是代码中有时会出现看似“多余”的 sub rsp, 8 或更大填充空间的原因。某些 SIMD 指令和库函数依赖正确对齐,破坏约定可能造成崩溃。

4. Red zone

System V AMD64 ABI 在当前 RSP 下方保留了 128 字节的 red zone(红区)。用户态的信号或中断处理过程不会破坏这一区域,因此不再调用其他函数的叶子函数可以把临时数据放在这里,而无需先执行 sub rsp, ...

但是,普通的函数调用会让被调用函数使用更低地址的栈空间,所以需要跨越 call 保存的数据不能依赖红区。内核代码通常也不会使用这一规则。初学阶段只需做到:看到叶子函数直接访问 [rsp-8] 时,不要立刻认为它越过了栈顶或写错了内存。

综合示例:从 C 函数到汇编

假设有如下代码:

int helper(int x);

int adjust(int value, int limit) {
    int doubled = value * 2;

    if (doubled > limit) {
        return doubled - limit;
    }

    return helper(doubled);
}

下面是一份符合前述规则、为教学简化过的汇编实现:

adjust:
    push rbp
    mov rbp, rsp
    push rbx
    sub rsp, 8

    lea ebx, [rdi + rdi]  ; EBX = value * 2
    cmp ebx, esi          ; doubled 与 limit 比较
    jle .call_helper      ; doubled <= limit 时调用 helper

    mov eax, ebx
    sub eax, esi          ; 返回 doubled - limit
    jmp .done

.call_helper:
    mov edi, ebx          ; helper 的第一个参数
    call helper           ; 返回值已经位于 EAX

.done:
    add rsp, 8
    pop rbx
    pop rbp
    ret

逐步分析:

  1. valuelimit 是前两个 int 参数,分别位于 EDIESI
  2. lea ebx, [rdi+rdi] 算出两倍的 value。写入 EBX 也会清除 RBX 的高 32 位。
  3. doubled 在调用 helper 时仍要使用。这里把它放入被调用者保存寄存器 RBX,所以函数开头保存旧 RBX,返回前再恢复。
  4. cmp ebx, esi 按照 doubled-limit 更新标志;源代码使用有符号 int,所以用 jle
  5. 大于分支在 EAX 中计算返回值;小于等于分支把参数放入 EDI 后调用 helper
  6. helper 的返回值已经在 EAX,因此无需再次搬运。
  7. 进入 adjust 时,返回地址已经占 8 字节;push rbppush rbxsub rsp,8 共同让 RSP 在调用 helper 前满足 16 字节对齐。
  8. 两条返回路径最终都到达 .done,按相反顺序撤销栈空间并恢复寄存器。

真实编译器可能使用条件移动、重新排列分支、内联 helper,或采取完全不同的寄存器分配。逆向分析的目标不是背下一种固定模板,而是根据数据流和控制流还原等价逻辑。

简单练习题

练习 1:追踪子寄存器

执行下面的代码后,RAX 是多少?

mov rax, 0xffffffffffffffff
mov eax, 0xffffffff
add rax, 1
查看答案

mov eax, 0xffffffff 会清除 RAX 的高 32 位,因此此时 RAX = 0x00000000ffffffff。再加 1 后:

RAX = 0x0000000100000000

练习 2:判断跳转

EAX = 5EBX = 8,下面的 jl 是否跳转?

cmp eax, ebx
jl .smaller
查看答案

会跳转。cmp eax, ebx 按照 5-8 设置标志,jl 按有符号数判断 EAX < EBX,条件成立。

练习 3:追踪栈指针

假设开始时 RSP = 0x1000,依次执行:

push rax
call target

进入 targetRSP 是多少?若 target 立即执行 ret,随后调用者执行 pop rax,最终 RSP 又是多少?

查看答案

push rax 压入 8 字节,RSP 变为 0x0ff8call 再压入 8 字节返回地址,进入 targetRSP = 0x0ff0

ret 弹出返回地址后,RSP 回到 0x0ff8pop rax 再弹出先前保存的值,最终 RSP = 0x1000

练习 4:还原伪代码

尝试把下面的汇编还原成 C 风格伪代码:

calc:
    xor eax, eax
    xor ecx, ecx
.loop:
    cmp ecx, edi
    jge .done
    add eax, ecx
    add ecx, 1
    jmp .loop
.done:
    ret
查看答案

EDI 是参数,EAX 是累加结果,ECX 是从 0 开始的循环变量。等价逻辑可以写成:

int calc(int n) {
    int result = 0;
    for (int i = 0; i < n; i++) {
        result += i;
    }
    return result;
}

n <= 0 时,第一次比较就满足 i >= n,函数返回 0。

Terminal

C0nvR3 Lab terminal ready. Type help for commands.