通用的BEAM指令集
通用指令集(Generic):是官方标准,编译器和虚拟机都用它,相对稳定,只在大版本间变化;
特定指令集(Specific):是内部优化实现,可能随时改变,不应该基于它开发工具。
要想开发编译器、虚拟机或分析工具,必须基于通用指令集,这样才能保证跨版本兼容性,特定指令集只是BEAM的内部实现细节。
7.1 指令定义
通用指令集的名称和操作码都定义在 lib/compiler/src/genop.tab.
genop.tab 文件被 beam_makeops(一个Perl脚本)用作输入,从中生成各种代码。
这个生成器(beam_makeops)会为编译器生成Erlang代码(beam_opcodes.hrl 和 beam_opcodes.erl),为模拟器生成C代码(beam_opcodes.c 和 beam_opcodes.h;对于传统BEAM解释器还会生成 beam_hot.h、beam_warm.h、beam_cold.h;对于BeamAsm会生成 beamasm_emit.h 和 beamasm_protos.h)。
文件中以"#"开头的行是注释会被忽略,文件还可以包含定义来转换成Perl脚本中的绑定。
genop.tab 文件的主要内容是操作码定义,
格式是 OPNUM: [-]NAME/ARITY,其中OPNUM和ARITY是整数,NAME是以小写字母开头的标识符,冒号、减号和斜杠是字面符号。
比如 1: label/1 就表示操作码1是label函数,参数数量为1。减号表示该函数已弃用,弃用函数会保留其操作码以便向后兼容,加载器会识别这些弃用指令但拒绝加载包含它们的代码。
7.2 BEAM码列表
给个 S 参数给编译器编译就可以得到 人可读和机器可读格式的 .S文件
-module(beamexample1).
-export([id/1]).
id(I) when is_integer(I) -> I.When compiled with erlc -S beamexample1.erl we get the following beamexmaple1.S file:
%% Module Declaration
{module, beamexample1}. %% version = 0
%% Exported Functions
{exports, [{id,1}, {module_info,0}, {module_info,1}]}.
%% Attributes
{attributes, []}.
%% Labels Count
{labels, 7}.
%% ============================================================================
%% Function: id/1
%% ============================================================================
{function, id, 1, 2}.
{label, 1}. % Error handling label
{line, [{location, "beamexample1.erl", 5}]}.
{func_info, {atom,beamexample1}, {atom,id}, 1}.
{label, 2}. % Function entry point
{test, is_integer, {f,1}, [{x,0}]}. % Test if x0 is integer
return. % Return x0
%% ============================================================================
%% Function: module_info/0 (auto-generated)
%% ============================================================================
{function, module_info, 0, 4}.
{label, 3}. % Error handling label
{line, []}.
{func_info, {atom,beamexample1}, {atom,module_info}, 0}.
{label, 4}. % Function entry point
{move, {atom,beamexample1}, {x,0}}. % Move module name to x0
{line, []}.
{call_ext_only, 1, {extfunc, erlang, get_module_info, 1}}.
%% ============================================================================
%% Function: module_info/1 (auto-generated)
%% ============================================================================
{function, module_info, 1, 6}.
{label, 5}. % Error handling label
{line, []}.
{func_info, {atom,beamexample1}, {atom,module_info}, 1}.
{label, 6}. % Function entry point
{move, {x,0}, {x,1}}. % Move arg0 to arg1
{move, {atom,beamexample1}, {x,0}}. % Move module name to x0
{line, []}.
{call_ext_only, 2, {extfunc, erlang, get_module_info, 2}}.第一行 {module, beamexample1}. %% version = 0 告诉我们模块名是beamexample1,指令集版本号是0。接着是导出函数列表 id/1, module_info/0, module_info/1,可以看出编译器自动添加了两个生成的函数,这两个函数只是对通用模块信息BIFs(erlang:module_info/1 和 erlang:module_info/2)的分发器,并将模块名作为第一个参数添加。{attributes, []} 列出所有编译属性,我们的案例中没有。{labels, 7} 说明模块中标签数量少于7个,这样做便于一次性加载代码。最后一种元指令是函数指令,格式为 {function, Name, Arity, StartLabel},可以看到id函数的开始标签实际上是函数代码中的第二个标签。
{label, N} 指令并不是真正的指令,加载到内存时不占用空间,它只是为代码中的某个位置起个本地名称或编号。每个标签可能标记一个基本块的开始,因为它是跳转的潜在目标。第一个标签后的前两条指令实际上是错误生成代码,添加行号、模块、函数和参数数量信息并抛出异常,这些是 line 和 func_info 指令。函数的核心在 {label,2} 后,指令 {test,is_integer,{f,1},[{x,0}]} 测试其参数(列表末尾的 {x,0} 变量)是否满足测试条件(在本例中是is_integer)。如果测试成功就执行下一条指令 return,否则函数失败跳转到标签1({f,1}),即执行到标签1处抛出函数子句异常。
文件中的另外两个函数是自动生成的。看第二个函数,指令 {move,{x,0},{x,1}} 将x0寄存器中的参数移到第二个参数寄存器x1,然后 {move,{atom,beamexample1},{x,0}} 将模块名原子移到第一个参数寄存器x0,最后对 erlang:get_module_info/2 进行尾调用({call_ext_only,2,{extfunc,erlang,get_module_info,2}})。如后续章节所示,有多种不同的调用指令。
7.3 Calls
第八章又说道erlang的几种调用类型,为了区分指令集的本地calls和远程calls,远程calls有 _ext 在指令集名称里面,本地calls只是在模块的代码上有一个标签,同时远程calls还是这种格式的{extfunc, Module, Function, Arity}.
为了区分普通调用(构建栈)和尾递归调用,后者在名称中包含 _only 或 _last。带 _last 的变体还会释放由最后一个参数指定的栈槽数量。
另外还有 call_fun Arity 指令,用于调用存储在寄存器 {x, Arity} 中的闭包。参数存储在 x0 到 {x, Arity-1} 中。
7.4 堆栈管理
两类函数:
叶函数(Leaf Function) - 不调用其他函数
- 无需保存返回地址
- 无需管理栈帧
- 直接执行后return返回
非叶函数(Non-leaf Function) - 会调用其他函数
- 需要保存返回地址(CP)到栈
- 用 allocate 指令创建栈帧
- 用 deallocate 指令销毁栈帧
关键指令:
allocate StackNeed Live - 分配栈空间
- StackNeed:分配多少个字的额外栈空间给局部变量
- Live:垃圾回收时需要保存多少个X寄存器(比如Live=2就保存x0和x1)
deallocate StackNeed - 释放栈空间
- 恢复栈指针
- 从栈读取返回地址
- 必须与allocate成对使用
简单说: allocate和deallocate就像C语言的函数栈帧管理,push返回地址和局部变量,pop释放它们。Live参数是个优化,告诉垃圾回收器哪些寄存器有活跃数据,不用扫描其他的。
7.5 消息推送
核心是 loop_rec(检查)、remove_message(移除)、loop_rec_end(推进指针)、wait(挂起)这几个指令配合,Ref技巧是为了避免在邮箱很满时进行低效的线性扫描
发送消息很简单:
使用 send 指令,无需参数。假设x0是目标进程,x1是消息内容,执行后消息也会被放在x0中。
接收消息比较复杂,因为涉及选择性接收和模式匹配。
7.5.1 最小接收循环 - 接收任意消息
{loop_rec, {f,2}, {x,0}}. % 检查消息队列,无消息跳到L2
remove_message. % 移除消息
{jump, {f,3}}. % 继续处理
{wait, {f,1}}. % 无消息时进程挂起
流程:检查→有消息就处理→没消息就挂起等待。
7.5.2 选择性接收循环 - 只接收特定类型的消息
{loop_rec, {f,3}, {x,0}}. % 检查消息
{test, is_nil, {f,2}, [{x,0}]}. % 模式匹配
remove_message. % 匹配则移除
{loop_rec_end, {f,1}}. % 不匹配则推进指针到下一条消息
{wait, {f,1}}. % 无新消息则挂起
流程:逐条扫描消息队列→匹配就处理→不匹配就推进指针继续扫描。
7.5.3 带超时的接收循环 - 接收消息或超时
{wait_timeout, {f,1}, {integer,1000}}. % 设置1000ms超时
timeout. % 超时被触发时执行
流程:设置定时器→进程挂起→要么新消息到达→要么超时触发。
7.5.4 Ref技巧 - 优化深邮箱的扫描
问题:如果邮箱有100条旧消息,选择性接收需要扫描所有100条才能找到1条新响应,太慢。
解决方案:使用新创建的ref标记RPC消息
Ref = make_ref(),
Counter ! {self(), inc, Ref},
receive {Ref, Count} -> Count end.
BEAM代码用 recv_mark 和 recv_set 指令:
- recv_mark - 标记make_ref前的消息队列末尾
- recv_set - 接收时只扫描mark后的新消息
性能提升:从O(N)(扫描所有旧消息)变成O(1)(只扫描新消息)。这对于高吞吐量的RPC模式很重要。
注意:如果Ref不是发送给自己的进程的情况下,这种优化是不行的,编译的时候不会用recv_mark和recv_set,
这里的问题是:
- 你创建的ref被发给了别的进程
- 别的进程处理后才会发消息回来
- 在发送和接收之间,你的邮箱可能积累了其他消息
- recv_set时无法确定"哪些消息是ref创建之前的"
- 所以优化无法应用,还是得线性扫描
ref技巧的前提:
- Ref必须是本进程创建的
- Ref必须在make_ref之后立即(或很快)被发送出去
- receive必须紧跟在send之后
- 期间没有其他receive操作
简单说:ref技巧是为了优化本进程发起的"同步RPC"模式(发送请求→等待响应),如果Ref的流向更复杂,优化就失效了。
补充:
Erlang 核心底层优化:同步消息的“指针对跳” (Recv Marker Optimization)
- 核心原理
当 Erlang 编译器识别出一种特殊的“请求-响应”模式时,它会生成两根特殊的 BEAM 指令,将信箱扫描从O(N)O(N)优化为O(1)O(1):
•recv_mark:在发送请求前,记录当前信箱的末尾指针(Save Pointer)。
•recv_set:在执行receive时,直接跳过recv_mark之前的所有消息,从记录的位置开始扫描。 - 标准优化模板
这种优化通常由make_ref/0或monitor/2触发:
codeErlang
foo(Pid) -> Ref = make_ref(), %% [指令: recv_mark] 记录当前信箱末尾 Pid ! {self(), Ref, query}, receive {Ref, Reply} -> Reply %% [指令: recv_set] 直接从记录点开始看,无视旧消息 end. - 优化失效的“三大杀手”(精准触发条件)
编译器非常保守,只要它不能在静态编译阶段“证明”这个 Ref 是绝对安全的,它就会放弃生成recv_mark。
失效场景:
跨函数调用 (Scope)
编译器只做函数内
分析。如果make_ref在另一个函数内执行,编译器无法保证调用该函数前信箱里没有同名 Ref。
R = get_ref(), gen_server:call(P, R)
变量逃逸 (Escape)
如果 Ref 被传给了非内置(BIF)函数,编译器认为变量“逃逸”了,因为该函数可能产生副作用(如递归接收)。
Ref = make_ref(), logger:info("~p", [Ref]), receive...
中间干扰 (Intervening)
在make_ref和目标receive之间如果存在另一个receive块,会重置 PCB 中的扫描位移。
Ref = make_ref(), receive timeout -> ok after 0 -> ok end, receive...
非直接构造
Ref 必须是刚生成的。如果 Ref 是从函数参数传进来的,优化绝对不会触发。
handle_msg(Ref) -> receive {Ref, _} -> ok end. - 高阶补充:不仅仅是make_ref
在现代 OTP 版本中,以下操作同样会触发recv_mark:
•monitor(process, Pid):监控进程并等待'DOWN'消息。
•spawn_monitor/1/3:派生并监控。
•编译器知道:在这些指令执行之前,信箱里绝不可能已经存在对应的Ref或'DOWN'消息。 - 性能影响
•生效时:即使进程信箱有 100,000 条消息,匹配Ref的延迟也是0 毫秒。
•失效时:每收到一个回复,都要线性扫描一遍 100,000 条消息,CPU 会瞬间因为Selective Receive Cleanup飙升。