☰
Erlang/OTP 端口(Ports)与端口驱动(Port Drivers)完全指南:从消息协议到 C 语言实战
2026/9/25 6:01:15 网站建设 项目流程
  • 编程语言
  • 语言运行时
  • 标准库
  • 编译器
  • 并发编程

【免费下载链接】otp

Erlang/OTP

项目地址:https://gitcode.com/gh_mirrors/ot/otp
点击查看免费下载

Ports 是 Erlang 与外部世界通信的基础机制,它为 Erlang 进程提供了一条字节导向(byte-oriented)的通道,用于与运行在独立 OS 进程中的外部程序交互;而 Port Drivers 则是将用 C 编写的驱动动态链接进运行时系统,从 Erlang 侧看起来与普通端口无异。本文以 Erlang/OTP 参考手册中 ports.md 为骨架,完整梳理端口的创建、消息协议、BIF 调用方式,并引入 Interoperability Tutorial 中的完整可运行示例,结合 ERTS 的erl_driver、driver_entry接口与erl_ddll内核模块源码,深入讲解端口驱动的工作原理。读完本文,你将能够独立编写 Erlang 侧端口封装模块,并实现一个用 C 编写的外部程序或链接式端口驱动。

Ports:Erlang 视角下的外部世界接口

从 Erlang 的角度看,Ports(端口)提供了与外部世界通信的基本机制。一个端口向外部程序提供一个字节导向的接口:当端口被创建后,Erlang 可以通过发送和接收字节列表(list of bytes),以及二进制数据(binaries),来与它通信。

端口有几个关键特性:

  • 端口所有者(Port Owner):创建端口的 Erlang 进程被称为该端口的port owner,或称connected process(连接进程)。所有进出端口的通信必须经过端口所有者。
  • 生命周期绑定:如果端口所有者终止,端口也随之终止(外部程序若编写正确,也会随之终止)。
  • 独立 OS 进程:外部程序运行在另一个操作系统进程中。默认情况下,它从标准输入(文件描述符 0)读取数据,向标准输出(文件描述符 1)写入数据。外部程序应在端口关闭时终止自身。

这一模型在 c_port.md 中用一个 mermaid 流程图做了直观示意:Erlang 进程(Connected process)与 Port 双向通信,Port 再与外部 OS 进程双向通信,外部程序始终位于 ERTS 之外。

创建端口:open_port/2与端口选项

创建端口的入口是 BIFopen_port(PortName, PortSettings),它返回一个端口标识符Port。端口标识符的使用方式与 PID 类似:

  • 可以像对 PID 一样向端口标识符发送和接收消息;
  • 可以用link/1与端口建立链接;
  • 可以用register/2为端口注册名字。

PortName:外部程序名

PortName通常是一个元组{spawn, Command},其中字符串Command是外部程序的名字(可以包含命令行参数)。外部程序运行在 Erlang 工作区(workspace)之外,除非运行时系统中恰好存在一个名为Command的端口驱动——如果找到同名驱动,则启动的是该驱动而非外部 OS 进程。这正是普通端口与端口驱动在创建方式上统一的原因:二者都用{spawn, Name}。

PortSettings:端口选项列表

PortSettings是端口的选项(settings)列表,典型配置至少包含一个{packet, N}元组:

选项含义
{packet, N}指定端口与外部程序之间传输的数据前附加一个 N 字节的长度指示符(length indicator),N 的合法取值为1、2、4。Erlang 侧会自动添加长度指示符,但外部 C 程序需要自行显式处理它
binary若希望以二进制(binary)而非字节列表来收发数据,必须包含该选项

从源码角度看,{packet, N}这类选项直接影响运行时系统对端口数据流的封帧处理;在教程示例 complex1.erl 中采用{packet, 2},即用 2 字节大端序长度指示符简化 C 与 Erlang 之间的消息切分。

端口消息协议:发送与接收

端口所有者进程Pid通过发送和接收消息与端口Port通信。事实上,任何进程都可以向端口发送消息,但消息中必须标明端口所有者的身份(即消息必须形如{Pid, ...})。

发送给端口的消息

在下列示例中,Data必须是 I/O 列表(I/O list),即一个二进制,或一个(可能深度嵌套的)由二进制或 0~255 范围内整数组成的列表。可以向端口发送的消息有:

  • {Pid, {command, Data}}—— 向端口发送Data。
  • {Pid, close}—— 关闭端口。除非端口已经关闭,否则端口会在所有缓冲区排空、端口真正关闭后,回复{Port, closed}。
  • {Pid, {connect, NewPid}}—— 将端口Port的所有者设置为NewPid。除非端口已经关闭,否则端口会向旧所有者回复{Port, connected}。注意:旧端口所有者仍然与端口保持链接(linked),而新的端口所有者不会被自动链接到端口。

从端口接收的消息

端口将以下消息发送给拥有它的进程:

  • {Port, {data, Data}}—— 从外部程序接收到Data。
  • {Port, closed}—— 对Port ! {Pid, close}的应答。
  • {Port, connected}—— 对Port ! {Pid, {connect, NewPid}}的应答。
  • {'EXIT', Port, Reason}—— 端口因某种原因终止时发送。

投递语义:异步(OTP 16 起的变更)

发送给端口的消息是异步投递的。参考手册特别标注了一处历史变更:在 Erlang/OTP 16 之前,发送给端口的消息是同步投递的。理解这一点对排查遗留代码的时序问题很重要——现代 OTP 中Port ! {self(), {command, Data}}之后不能假设数据已经到达驱动侧。

用 BIF 而非消息与端口通信

除了消息传递,Erlang 还提供一组 BIF 完成同样的操作,性能上通常更受青睐:

BIF功能
port_command(Port, Data)向端口发送Data,等价于消息{Pid, {command, Data}}
port_close(Port)关闭端口,等价于消息{Pid, close}
port_connect(Port, NewPid)将端口Port的所有者设置为NewPid。旧所有者Pid仍与端口保持链接,如不希望如此,必须自行调用unlink(Port)
erlang:port_info(Port, Item)按Item返回端口的指定信息
erlang:ports()返回当前节点上所有端口的列表

此外还有两个仅适用于端口驱动的 BIF:

  • port_control/3—— 向端口驱动发送一个控制命令;
  • erlang:port_call/3—— 同步调用端口驱动中的回调(与port_control类似,但是同步调用)。

Port Drivers:链接进虚拟机的 C 驱动

除了启动外部 OS 进程,Erlang 还允许按照特定规则用 C 编写驱动,并动态链接到 Erlang 运行时系统。这种链接式驱动(linked-in driver)从 Erlang 程序员的角度看就是一个端口,因此被称为port driver(端口驱动)。

警告:一个错误的端口驱动会导致整个 Erlang 运行时系统内存泄漏、挂起或崩溃。原因在于驱动回调是作为 VM 原生代码的直接扩展执行的,运行环境并不安全——没有抢占式调度、没有内存保护。驱动代码一旦崩溃,整个 VM 都会崩溃。

端口驱动的核心文档位于 ERTS 中:

  • erl_driver —— Erlang 驱动的 API 函数说明;
  • driver_entry —— 驱动入口结构定义;
  • erl_ddll —— Kernel 中的动态加载/卸载驱动模块。

驱动的加载:erl_ddll

端口驱动是一个共享库(UNIX 下为.so,Windows 下为.dll),带有特殊入口点。在创建端口之前,必须先用erl_ddll:load_driver/2加载驱动。从 erl_ddll.erl 源码看,该模块提供了两组语义不同的接口:

  • load_driver/2与unload_driver/1(以及reload_driver/2):当驱动仍有端口打开时,卸载会被视为错误——最后持有驱动的用户卸载驱动或最后一个持有驱动的进程消亡时,仍然打开的端口会以driver_unloaded原因被杀死;
  • load/2与reload/2:用于等最后一个打开端口关闭后再进行代码替换,配合驱动监视器使用更安全。

需要驱动代码热替换时,推荐由单个进程专职管理驱动生命周期:进程启动时加载,需要替换时 reload,进程退出时卸载。由于一个驱动不能同时以 "old" 和 "new" 两份代码存在,所有用户(打开的端口)必须先关闭旧代码才能卸载并加载新代码,且整个卸载/加载过程是原子操作,期间会阻塞系统内其他进程对该驱动的使用。

实战示例一:外部 OS 进程端口(complex1 + C)

下面完整复现 c_port.md 中的示例,演示 Erlang 与 C 程序通过端口通信。

Erlang 侧封装

所有 Erlang 与 C 的通信都从创建端口开始。创建端口的进程即为connected process;示例同时设置了trap_exit,以便检测外部程序的失败:

-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.

这里的编解码方案非常简单(假定参数和结果都小于 256):foo用字节 1 表示,bar用字节 2 表示,参数/结果各用一个字节。open_port的{packet, 2}选项让 Erlang 侧自动附加 2 字节长度指示符,而 C 侧必须显式处理它。

C 侧通信函数(erl_comm.c)

C 侧需要自己实现带 2 字节长度指示符的读写函数。默认情况下,C 程序从标准输入(fd 0)读取、向标准输出(fd 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); }

注意:C 侧的stdin/stdout是带缓冲的,绝不能用于与 Erlang 通信,必须直接使用底层read/write系统调用。

C 侧主循环(port.c)

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的返回值——这正是它检测端口关闭并自行终止的方式。

编译与运行

Step 1.编译 C 代码(complex.c定义foo/bar函数,见 complex.c):

$ gcc -o extprg complex.c erl_comm.c port.c

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}

Step 3.运行示例:

2> complex1:start("./extprg"). <0.34.0> 3> complex1:foo(3). 4 4> complex1:bar(5). 10 5> complex1:stop(). stop

实战示例二:链接式端口驱动(complex5 + port_driver.c)

下面复现 c_portdriver.md 中的示例。端口驱动是动态链接进仿真器进程的共享库,调用 C 函数无需上下文切换,因此是从 Erlang 调用 C 代码最快的方式;但也是最不安全的方式——驱动中的崩溃会直接带崩整个仿真器。

Erlang 侧封装

与普通端口不同,创建端口前必须先加载驱动:

-module(complex5). -export([start/1, stop/0, init/1]). -export([foo/1, bar/1]). start(SharedLib) -> case erl_ddll:load_driver(".", SharedLib) of ok -> ok; {error, already_loaded} -> ok; _ -> exit({error, could_not_load_driver}) end, spawn(?MODULE, init, [SharedLib]). init(SharedLib) -> register(complex, self()), Port = open_port({spawn, SharedLib}, []), loop(Port). 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. 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} -> io:format("~p ~n", [Reason]), exit(port_terminated) end. encode({foo, X}) -> [1, X]; encode({bar, Y}) -> [2, Y]. decode([Int]) -> Int.

关键点:open_port({spawn, SharedLib}, [])中的SharedLib是驱动名(即驱动入口结构中声明的driver_name),而不是文件路径;文件路径在erl_ddll:load_driver/2中给出。由于同名驱动可能被多个进程创建,加载时需容忍already_loaded。

C 驱动(port_driver.c)

驱动是一个编译成共享库的模块,包含头文件erl_driver.h,通过DRIVER_INIT(<driver_name>)宏声明的特殊入口点返回驱动结构。完整实现见 port_driver.c:

/* port_driver.c */ #include <stdio.h> #include "erl_driver.h" typedef struct { ErlDrvPort port; } example_data; static ErlDrvData example_drv_start(ErlDrvPort port, char *buff) { example_data* d = (example_data*)driver_alloc(sizeof(example_data)); d->port = port; return (ErlDrvData)d; } static void example_drv_stop(ErlDrvData handle) { driver_free((char*)handle); } static void example_drv_output(ErlDrvData handle, char *buff, ErlDrvSizeT bufflen) { example_data* d = (example_data*)handle; char fn = buff[0], arg = buff[1], res; if (fn == 1) { res = foo(arg); } else if (fn == 2) { res = bar(arg); } driver_output(d->port, &res, 1); } ErlDrvEntry example_driver_entry = { NULL, /* F_PTR init, called when driver is loaded */ example_drv_start, /* L_PTR start, called when port is opened */ example_drv_stop, /* F_PTR stop, called when port is closed */ example_drv_output, /* F_PTR output, called when erlang has sent */ NULL, /* F_PTR ready_input, called when input descriptor ready */ NULL, /* F_PTR ready_output, called when output descriptor ready */ "example_drv", /* char *driver_name, the argument to open_port */ NULL, /* F_PTR finish, called when unloaded */ NULL, /* void *handle, Reserved by VM */ NULL, /* F_PTR control, port_command callback */ NULL, /* F_PTR timeout, reserved */ NULL, /* F_PTR outputv, reserved */ NULL, /* F_PTR ready_async, only for async drivers */ NULL, /* F_PTR flush, called when port is about to be closed, but there is data in driver queue */ NULL, /* F_PTR call, much like control, sync call to driver */ NULL, /* unused */ ERL_DRV_EXTENDED_MARKER, /* int extended marker, Should always be set to indicate driver versioning */ ERL_DRV_EXTENDED_MAJOR_VERSION, /* int major_version, should always be set to this value */ ERL_DRV_EXTENDED_MINOR_VERSION, /* int minor_version, should always be set to this value */ 0, /* int driver_flags, see documentation */ NULL, /* void *handle2, reserved for VM use */ NULL, /* F_PTR process_exit, called when a monitored process dies */ NULL /* F_PTR stop_select, called to close an event object */ }; DRIVER_INIT(example_drv) /* must match name in driver_entry */ { return &example_driver_entry; }

几个值得注意的实现细节:

  • 驱动实例状态:example_drv_start是唯一收到端口实例句柄(ErlDrvPort)的回调,必须把它保存下来。示例用driver_alloc分配一个驱动自定义结构并回传指针,不要用全局变量保存实例状态——因为驱动可能被多个 Erlang 进程同时 spawn,驱动结构需要实例化多份;
  • 数据回发:example_drv_output收到 Erlang 发来的数据后,用driver_output把结果发回 Erlang;
  • 版本管理:ErlDrvEntry结构中必须设置ERL_DRV_EXTENDED_MARKER与主/次扩展版本宏。依据 erl_driver.md,ERTS 5.5.3 起引入扩展接口与版本管理;ERTS 5.9 起旧驱动必须重新编译并改用扩展接口,且需适配 64 位驱动接口;
  • 未用回调置 NULL:驱动是共享模块而非程序,没有main函数;本例未用到的函数指针全部设为 NULL。

编译与运行

Step 1.编译 C 代码(不同平台命令不同):

unix> gcc -o example_drv.so -fpic -shared complex.c port_driver.c windows> cl -LD -MD -Fe example_drv.dll complex.c port_driver.c

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(complex5). {ok,complex5}

Step 3.运行示例:

2> complex5:start("example_drv"). <0.34.0> 3> complex5:foo(3). 4 4> complex5:bar(5). 10 5> complex5:stop(). stop

深入原理:erl_driver回调模型与并发安全

结合 erl_driver.md,可以从源码级理解驱动为何"危险且强大":

  • 回调即 VM 扩展:驱动回调是 VM 原生代码的直接扩展,执行时不处于安全环境。驱动回调崩溃会崩溃整个 VM;实现不当的回调还可能造成 VM 内部状态不一致,导致调用之后任意时刻的崩溃或异常行为;回调内长时间工作(lengthy work)会降低 VM 响应性,可能引发内存使用极端化和调度器负载不均。
  • 锁模型:驱动在驱动级(driver level)或端口级(port level)上加锁。默认使用驱动级锁,即同一时刻只有一个仿真器线程执行驱动代码;若在driver_entry中设置ERL_DRV_FLAG_USE_PORT_LOCKING标志,则可启用端口级锁,允许多个仿真器线程同时执行驱动代码(但同一端口对应的回调同一时刻仍只有一个线程调用),此时驱动编写者必须自行同步各端口共享的数据。SMP 支持出现之前编写的大多数驱动,在使用驱动级锁时无需改写即可在 SMP 运行时中运行。
  • 减少拷贝:驱动 API 中ErlDrvBinary类型的驱动二进制由调用者分配和释放,直接使用二进制可避免一次额外的数据拷贝;多数输出函数带 "header buffer"(hbuf/hlen参数),在二进制(或列表)之前先发送一个头部列表,便于在接收端匹配消息,与二进制语法配合可以高效地匹配消息头部。

小结

端口与端口驱动构成了 Erlang 与外部世界互操作的两条主线:

  • Ports(外部进程):隔离性最好,外部程序运行在独立 OS 进程中,崩溃不会波及 VM;通信基于字节流(配合{packet, N}长度帧或自行设计协议),通过消息或port_command/port_close等 BIF 交互;
  • Port Drivers(链接式驱动):性能最高(无上下文切换),但安全风险最大,需要严格遵循erl_driver.h的 API 约定、正确处理实例状态与版本标记,并通过erl_ddll管理加载/卸载与代码替换。

无论选择哪种方式,都需牢记端口所有权模型:所有通信经由端口所有者进程,所有者消亡则端口消亡。更多进阶示例(NIF、C Nodes、erl_interface 等互操作手段)可继续阅读 Interoperability Tutorial 中的 c_port.md、c_portdriver.md、nif.md 等章节,BIF 的完整语义以 ERTS 参考文档 及erlang模块文档为准。

  • 编程语言
  • 语言运行时
  • 标准库
  • 编译器
  • 并发编程

【免费下载链接】otp

Erlang/OTP

项目地址:https://gitcode.com/gh_mirrors/ot/otp
点击查看免费下载

相关推荐

上一篇:Android Advanced RecyclerView 教程
下一篇:5步构建企业级远程监控管理平台:Tactical RMM深度解析与实践指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询