- 编程语言
- 语言运行时
- 标准库
- 编译器
- 并发编程
【免费下载链接】otp
Erlang/OTP
Port(端口)是 Erlang/OTP 内置的三大互操作机制之一,它以字节流为接口,让 Erlang 进程能够与运行在独立 OS 进程中的外部程序(通常是 C 程序)通信。本文以官方互操作教程 Ports 章节 为主体,围绕“在 Erlang 中调用 C 函数”这一经典场景,完整讲解端口的创建、消息协议、C 端通信代码的写法与端到端运行流程。读完本文,你将掌握open_port/2的用法、{packet,2}长度前缀协议、C 程序在 stdin/stdout 上的读写约定,以及端口失败检测与优雅关闭的完整套路。
端口机制概览:字节导向的外部世界接口
从 Erlang 的视角看,端口提供了与外部世界通信的基本机制。端口对外部程序呈现**字节导向(byte-oriented)**的接口:创建端口后,Erlang 与外部程序之间交换的是字节列表或二进制,而不是 Erlang 项(terms)。因此,程序员往往需要自行设计一套编码/解码(encoding/decoding)方案,把 Erlang 项转成字节序列,再在另一端还原。这一点在官方 Interoperability Tutorial 概述 中有着明确说明。
端口的底层实现依赖平台:在 UNIX 上使用管道(pipes),外部程序默认从标准输入(文件描述符 0)读取、向标准输出(文件描述符 1)写入。只要外部程序能够处理这种进程间通信机制,它可以用任何语言编写——本文以 C 为例。
在 Erlang/OTP 中,与端口并列的内建互操作机制还有分布式 Erlang(用于 Erlang 节点间通信)和NIF(把 C 代码直接链接进运行时系统)。端口适合“Erlang 程序与外部程序运行在同一台机器”的各种互操作场景,编程相对直白;而 NIF 由于直接链接进模拟器进程,调用开销最小但崩溃风险最高——一个出错的 NIF 会让整个运行时系统泄漏内存、挂起或崩溃,因此官方建议在开销可接受时优先使用外部端口。这一取舍在 overview.md 中有详细论述。
原文档用一张流程图概括了端口通信的参与方:
图中的关键点有两个:一是端口位于 ERTS 内部,作为 Erlang 进程与外部 OS 进程之间的桥梁;二是 Erlang 侧必须有一个**connected process(连接进程)**负责与端口交互——所有进出端口的数据都必须经过它。
问题场景:为什么需要端口
本教程要解决的经典互操作问题是:把一段求解复杂问题的 C 代码集成进 Erlang 程序。假设我们有如下两个 C 函数,希望从 Erlang 中调用(见 example.md 与配套的 complex.c):
/* complex.c */ int foo(int x) { return x+1; } int bar(int y) { return y*2; }这两个函数被刻意保持得尽可能简单,以便读者把注意力集中在互操作机制本身。从 Erlang 的角度,理想情况是不必关心foo、bar是 C 函数,直接调用即可:
% Erlang code ... Res = complex:foo(X), ...这里的诀窍在于:C 通信的细节被封装在complex.erl的实现内部。后续章节将展示如何用端口(以及教程中的 NIF 等其他机制)实现这个模块。本教程将用端口方案实现一个complex1模块来完成任务。
Erlang 端:创建端口、封装调用
创建端口:open_port/2与连接进程
Erlang 与 C 之间的一切通信,都必须从创建端口开始。创建端口使用 BIFopen_port/2,第一个参数传{spawn, ExtPrg}——字符串ExtPrg是外部程序的名字,可以包含命令行参数;第二个参数是选项列表,本例中只有一个{packet, 2}。
{packet, 2}的含义是:在 Erlang 与 C 之间传输的数据前,附加一个2 字节的长度指示符,以简化双端通信。Erlang 侧会自动加上这个长度指示符,而 C 侧必须显式地构造和解析它。
创建端口的 Erlang 进程被称为该端口的connected process(连接进程)。所有到端口的通信和来自端口的数据都必须经过该进程;如果连接进程终止,端口也随之终止(只要外部程序写得正确,它也会随之终止)。同时,进程被设置为trap_exit,以便检测外部程序的失败:
-module(complex1). -export([start/1, init/1]). start(ExtPrg) -> spawn(?MODULE, init, [ExtPrg]). init(ExtPrg) -> register(complex, self()), process_flag(trap_exit, true), Port = open_port({spawn, ExtPrg}, [{packet, 2}]), loop(Port).这里start/1会派生一个独立的init/1进程:它把自己的 PID 注册成全局名complex(这样其他进程可以通过名字发送消息),设置trap_exit,然后创建端口并进入主循环。注意:连接进程是init/1所在进程,而不是调用start/1的进程。
从 Ports and Port Drivers 参考手册 可以进一步确认端口 BIF 的细节:
PortName通常是{spawn, Command}元组,Command为外部程序名字符串;外部程序运行在 Erlang 工作区之外,除非找到了同名端口驱动(linked-in driver),此时启动的是驱动。PortSettings是端口设置(选项)列表,通常至少包含{packet, N},指定传输数据前附加N 字节长度指示符,N 的合法取值为 1、2 或 4。若希望用二进制(binary)而非字节列表通信,则需加上binary选项。- 端口标识(port identifier)的使用方式与 PID 类似:可以像 PID 一样收发消息,也可以用
link/1链接,或用register/2注册名字。
对外 API:foo/1与bar/1
现在实现complex1:foo/1和complex1:bar/1。两者都把请求发给complex进程,然后等待回复:
foo(X) -> call_port({foo, X}). bar(Y) -> call_port({bar, Y}). call_port(Msg) -> complex ! {call, self(), Msg}, receive {complex, Result} -> Result end.调用者进程把{call, self(), Msg}消息发给注册名complex,然后阻塞等待{complex, Result}回复并返回结果。这样,从调用者角度看foo、bar与普通 Erlang 函数无异。
主循环:编码、发送、等待、解码、回传
complex进程的主循环承担五步工作:
- 把消息编码成字节序列;
- 发送到端口;
- 等待回复;
- 解码回复;
- 把结果回传给调用者。
loop(Port) -> receive {call, Caller, Msg} -> Port ! {self(), {command, encode(Msg)}}, receive {Port, {data, Data}} -> Caller ! {complex, decode(Data)} end, loop(Port) end.这里使用的两条端口消息协议是(更完整的协议列表见 ports.md):
{Pid, {command, Data}}— 把Data发送给端口(Data必须是 I/O 列表,即二进制,或 0~255 整数组成的(可嵌套)列表);{Port, {data, Data}}— 从外部程序收到的数据,发给端口所有者(connected process)。
此外,参考手册还列举了{Pid, close}(关闭端口,端口在缓冲区刷完后回复{Port, closed})和{Pid, {connect, NewPid}}(转移端口所有权,回复{Port, connected})等消息,并提醒:发往端口的消息是异步投递的(这一行为自 Erlang/OTP 16 起生效,更早版本是同步投递)。
简单的编码/解码方案
假设 C 函数的参数和返回值都小于 256,教程采用了一套极简编码方案:foo用字节 1 表示,bar用字节 2 表示,参数/结果各用一个字节表示:
encode({foo, X}) -> [1, X]; encode({bar, Y}) -> [2, Y]. decode([Int]) -> Int.这套方案的代价是显而易见的——它限定了参数与结果必须在 0~255 之间;实际项目中若数据范围更大,要么改用多字节编码,要么配合binary选项 +term_to_binary/1走 Erlang 外部项格式(见 overview.md 中关于 Erl_Interface 与term_to_binary/1、binary_to_term/1的讨论)。
完整 Erlang 程序:停止与失败检测
完整的complex1模块(仓库中的 complex1.erl)还包含停止端口和检测端口失败的逻辑:
-module(complex1). -export([start/1, stop/0, init/1]). -export([foo/1, bar/1]). start(ExtPrg) -> spawn(?MODULE, init, [ExtPrg]). stop() -> complex ! stop. foo(X) -> call_port({foo, X}). bar(Y) -> call_port({bar, Y}). call_port(Msg) -> complex ! {call, self(), Msg}, receive {complex, Result} -> Result end. init(ExtPrg) -> register(complex, self()), process_flag(trap_exit, true), Port = open_port({spawn, ExtPrg}, [{packet, 2}]), loop(Port). loop(Port) -> receive {call, Caller, Msg} -> Port ! {self(), {command, encode(Msg)}}, receive {Port, {data, Data}} -> Caller ! {complex, decode(Data)} end, loop(Port); stop -> Port ! {self(), close}, receive {Port, closed} -> exit(normal) end; {'EXIT', Port, Reason} -> exit(port_terminated) end. encode({foo, X}) -> [1, X]; encode({bar, Y}) -> [2, Y]. decode([Int]) -> Int.主循环现在处理三种消息:
{call, Caller, Msg}— 正常调用流程,如上所述;stop— 向端口发送{self(), close},等待{Port, closed}确认后以exit(normal)正常退出;{'EXIT', Port, Reason}— 端口异常终止(如外部程序崩溃),此时进程以exit(port_terminated)退出。
后两种分支正是“连接进程 trap 端口退出信号”的实际应用:因为init/1进程设置了process_flag(trap_exit, true),端口终止时发送的{'EXIT', Port, Reason}会作为普通消息进入信箱,而不是直接把进程杀死,从而让进程有机会做清理并给出明确的失败语义。
C 端:在 stdin/stdout 上实现长度前缀协议
字节级读写:read_exact与write_exact
在 C 侧,需要编写从 Erlang 接收数据、向 Erlang 发送数据的函数,且要处理2 字节长度指示符。默认情况下,C 程序从标准输入(文件描述符 0)读取、向标准输出(文件描述符 1)写入(对应仓库中的 erl_comm.c):
/* erl_comm.c */ #include <stdio.h> #include <unistd.h> typedef unsigned char byte; int read_exact(byte *buf, int len) { int i, got=0; do { if ((i = read(0, buf+got, len-got)) <= 0){ return(i); } got += i; } while (got<len); return(len); } int write_exact(byte *buf, int len) { int i, wrote = 0; do { if ((i = write(1, buf+wrote, len-wrote)) <= 0) return (i); wrote += i; } while (wrote<len); return (len); } int read_cmd(byte *buf) { int len; if (read_exact(buf, 2) != 2) return(-1); len = (buf[0] << 8) | buf[1]; return read_exact(buf, len); } int write_cmd(byte *buf, int len) { byte li; li = (len >> 8) & 0xff; write_exact(&li, 1); li = len & 0xff; write_exact(&li, 1); return write_exact(buf, len); }几个值得注意的实现细节:
read_exact/write_exact用循环确保读满/写完指定字节数。管道上的read/write可能因为信号中断或缓冲区限制而“短读/短写”,循环重试正是生产级代码的基本功;read_cmd先读 2 字节长度前缀(大端序,(buf[0] << 8) | buf[1]),再按该长度读取完整数据;write_cmd则先写出 2 字节长度前缀,再写正文;- 教程特别提醒:
stdin/stdout是带缓冲的输入/输出流,绝不能用于与 Erlang 的通信。必须直接使用文件描述符 0/1 上的read/write系统调用(这也是头文件引入<unistd.h>的原因),否则缓冲机制会破坏协议同步。
主程序:分发函数调用并回传结果
main函数监听来自 Erlang 的消息,按照约定的编码方案,用第一个字节决定调用哪个函数、第二个字节作为参数,然后把函数结果回传给 Erlang(对应仓库中的 port.c):
/* port.c */ typedef unsigned char byte; int main() { int fn, arg, res; byte buf[100]; while (read_cmd(buf) > 0) { fn = buf[0]; arg = buf[1]; if (fn == 1) { res = foo(arg); } else if (fn == 2) { res = bar(arg); } buf[0] = res; write_cmd(buf, 1); } }注意:C 程序运行在一个while循环中,并检查read_cmd/1的返回值——这是为了检测端口何时关闭并自行终止。当 Erlang 侧关闭端口(或连接进程退出导致端口终止)时,管道写端关闭,read_cmd返回 ≤ 0,循环退出、进程自然结束。这正是 Erlang 文档中所说的“外部程序,只要写得正确,会随端口一起终止”的落地实现。
端到端运行:三步跑通示例
仓库的 tutorial 目录 下提供了全部示例源码:complex.c、erl_comm.c、port.c与complex1.erl,可以直接照此操作。
Step 1. 编译 C 代码
$ gcc -o extprg complex.c erl_comm.c port.c这里把三个 C 文件链接成一个可执行文件extprg。编译命令在 UNIX 环境下运行,需要系统具备 gcc 工具链。
Step 2. 启动 Erlang 并编译 Erlang 代码
$ erl Erlang/OTP 26 [erts-14.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns] Eshell V14.2 (press Ctrl+G to abort, type help(). for help) 1> c(complex1). {ok,complex1}(上面的版本 banner 是教程撰写时 Erlang/OTP 26 环境的真实输出,实际以你本地安装的 Erlang/OTP 版本为准。)
Step 3. 运行示例
2> complex1:start("./extprg"). <0.34.0> 3> complex1:foo(3). 4 4> complex1:bar(5). 10 5> complex1:stop(). stop执行结果验证了整条链路:foo(3)返回 4(C 侧x+1),bar(5)返回 10(C 侧y*2)。调用stop()后,complex1进程向端口发送close消息,等待{Port, closed}确认后正常退出;与此同时,extprg进程因为读不到新数据而退出while循环并终止——Erlang 进程、端口、外部 OS 进程三者的生命周期就此完整收束。
端口协议速查与生命周期要点
结合 ports.md 与本文示例,可以把端口通信的协议与生命周期归纳如下:
发送给端口的消息(Data须为 I/O 列表):
| 消息 | 含义 |
|---|---|
{Pid, {command, Data}} | 把Data发送给端口 |
{Pid, close} | 关闭端口;端口在缓冲区刷完后回复{Port, closed} |
{Pid, {connect, NewPid}} | 把端口所有权转移给NewPid,回复{Port, connected} |
从端口接收的消息:
| 消息 | 含义 |
|---|---|
{Port, {data, Data}} | 从外部程序收到数据 |
{Port, closed} | 对close的确认 |
{Port, connected} | 对connect的确认 |
{'EXIT', Port, Reason} | 端口已终止(若未 trap 退出信号,该消息会直接终止连接进程) |
生命周期关键规则:
- 端口由
open_port({spawn, ExtPrg}, [{packet, N}])创建,N取 1、2 或 4;长度前缀自动附加在 Erlang 侧,须由 C 侧显式处理; - 创建端口的进程是 connected process(端口所有者),所有通信必须经过它;它终止,端口随之终止;
- 端口消息异步投递(Erlang/OTP 16 之前为同步);
- 外部程序通过检测
read返回 ≤ 0 来感知端口关闭并自行退出; - 若需在 C 侧使用 Erlang 项而非自定义字节编码,可结合
binary选项与term_to_binary/1/binary_to_term/1,或改用 Erl_Interface 库(见 overview.md)。
延伸阅读
- Interoperability Tutorial 目录:互操作机制的总体介绍与前置知识;
- 问题示例:本文所解问题的原始定义;
- NIFs 章节:同一问题用 NIF 的解法,便于对比端口与 NIF 的取舍(NIF 更快但崩溃风险更高,官方建议优先考虑外部端口);
- C Nodes 章节 与 Erl_Interface 章节:把 C 程序包装成分布式节点的另一条路径;
- Ports and Port Drivers 参考手册:端口 BIF、消息协议与端口驱动的权威参考;
- 配套源码:complex1.erl、complex.c、erl_comm.c、port.c,均可直接编译运行验证本文全部内容。
- 编程语言
- 语言运行时
- 标准库
- 编译器
- 并发编程
【免费下载链接】otp
Erlang/OTP
相关推荐
Erlang/OTP 互操作实战:用 linked-in Port Driver 在 Erlang 中调用 C 代码(c_portdriver 教程详解)
Erlang/OTP 互操作实战:用 linked in Port Driver 在 Erlang 中调用 C 代码(c_portdriver 教程详解) 本指
编程语言语言运行时标准库编译器并发编程使用 vmctl 将 Grafana Mimir 历史指标迁移到 VictoriaMetrics:remote-read 与对象存储双模式实战指南
使用 vmctl 将 Grafana Mimir 历史指标迁移到 VictoriaMetrics:remote read 与对象存储双模式实战指南 Grafan
编程语言语言运行时标准库编译器并发编程3 步批量解除 PDF 复制打印限制:PDF 补丁丁实操指南
3 步批量解除 PDF 复制打印限制:PDF 补丁丁实操指南 想把 PDF 里的一段话复制出来,阅读器却弹出"权限不足";想打印,按钮是灰的。这类锁由文档自身的
编程语言语言运行时标准库编译器并发编程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考