通用的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 堆栈管理

两类函数:

  1. 叶函数(Leaf Function) - 不调用其他函数

    • 无需保存返回地址
    • 无需管理栈帧
    • 直接执行后return返回
  2. 非叶函数(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)

  1. 核心原理
    当 Erlang 编译器识别出一种特殊的“请求-响应”模式时,它会生成两根特殊的 BEAM 指令,将信箱扫描从O(N)O(N)优化为O(1)O(1):
    •recv_mark:在发送请求前,记录当前信箱的末尾指针(Save Pointer)。
    •recv_set:在执行receive时,直接跳过recv_mark之前的所有消息,从记录的位置开始扫描。
  2. 标准优化模板
    这种优化通常由make_ref/0或monitor/2触发:
    codeErlang
    foo(Pid) -> Ref = make_ref(), %% [指令: recv_mark] 记录当前信箱末尾 Pid ! {self(), Ref, query}, receive {Ref, Reply} -> Reply %% [指令: recv_set] 直接从记录点开始看,无视旧消息 end.
  3. 优化失效的“三大杀手”(精准触发条件)
    编译器非常保守,只要它不能在静态编译阶段“证明”这个 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.
  4. 高阶补充:不仅仅是make_ref
    在现代 OTP 版本中,以下操作同样会触发recv_mark:
    •monitor(process, Pid):监控进程并等待'DOWN'消息。
    •spawn_monitor/1/3:派生并监控。
    •编译器知道:在这些指令执行之前,信箱里绝不可能已经存在对应的Ref或'DOWN'消息。
  5. 性能影响
    •生效时:即使进程信箱有 100,000 条消息,匹配Ref的延迟也是0 毫秒。
    •失效时:每收到一个回复,都要线性扫描一遍 100,000 条消息,CPU 会瞬间因为Selective Receive Cleanup飙升。