NFS(Network File System)是一个经典的分布式文件系统协议,它的底层依赖Sun RPC(远程过程调用)框架。大多数开发者接触NFS都是通过系统自带的mount命令,很少有机会深入了解客户端与服务器之间到底交换了哪些数据。本文将用Ruby实现一个最小化的NFS客户端,覆盖RPC调用、端口映射查询、挂载协议以及基本的文件读取操作,帮助你从协议层面理解NFS的工作方式。

一、NFS协议架构与RPC基础
NFS v3实际上由两个独立的RPC程序组成:Mount协议(程序号100005)负责将服务器导出的目录转换为一个「文件句柄」(file handle),而NFS协议本身(程序号2049)则使用这个句柄执行LOOKUP、READ、READDIR等文件操作。两者配合的流程是:客户端先向服务器查询导出列表,选择要挂载的目录,Mount服务返回根句柄,之后所有NFS请求都携带这个句柄来定位文件。
RPC调用的核心是XDR(External Data Representation)编码。XDR定义了一套跨平台的类型序列化规则:整数使用大端4字节,字符串前带4字节长度,可变长数据需要补齐到4字节倍数,opaque数据同理。理解XDR是手写NFS客户端的关键,因为Ruby标准库并没有现成的NFS绑定,所有报文都要自己拼。
另一个必须处理的细节是RPC程序监听的端口号。NFS相关的服务通常注册在动态端口上,客户端需要先访问111端口上的portmapper(或rpcbind),通过GETPORT调用查询目标程序当前监听的端口。下面这段代码实现了XDR编码和RPC报文构造的基础工具:
require 'socket'
module XDR
def self.uint32(n)
[n].pack('N')
end
def self.uint64(n)
[n].pack('Q>')
end
def self.string(s)
data = s.dup.force_encoding('BINARY')
len = data.bytesize
padding = (4 - (len % 4)) % 4
uint32(len) + data + ("\x00" * padding)
end
def self.opaque(data)
string(data)
end
def self.fh(data) # 文件句柄:定长opaque,长度显式给出
uint32(data.bytesize) + data
end
end
class RpcClient
def initialize(host, port, prog, ver)
@host, @port = host, port
@prog, @ver = prog, ver
@xid = rand(1 << 31)
end
def call(proc_num, payload)
# RPC call 报文头:xid + msg_type + rpcvers + prog + vers + proc + cred + verf
header = XDR.uint32(@xid += 1) + XDR.uint32(0) +
XDR.uint32(2) + XDR.uint32(@prog) +
XDR.uint32(@ver) + XDR.uint32(proc_num) +
XDR.uint32(0) + XDR.uint32(0) + XDR.uint32(0) +
XDR.uint32(0) + XDR.uint32(0)
packet = header + payload
sock = TCPSocket.new(@host, @port)
# TCP上的RPC报文需要4字节长度前缀(记录标记)
sock.write(XDR.uint32(packet.bytesize) + packet)
read_reply(sock)
ensure
sock&.close
end
def read_reply(sock)
len = sock.read(4).unpack1('N')
data = sock.read(len)
xid, msg_type, reply_stat = data[0, 12].unpack('NNN')
raise "bad xid" unless xid == @xid
raise "rpc denied" unless reply_stat == 0
accept_stat = data[20, 4].unpack1('N')
raise "rpc accept error: #{accept_stat}" unless accept_stat == 0
data[24..] # 去掉头部的verified data部分
end
end这段代码中有几个容易踩坑的地方。第一,TCP传输的RPC报文必须加4字节的记录长度前缀,而UDP则不需要,这是新手最常犯的错误之一。第二,认证字段(auth_unix或auth_null)在NFS v3中通常可以用全零的空认证,但如果服务器开了更严格的安全策略,就会返回accept_stat为1的错误。第三,read_reply里对头部偏移的计算要严格对照RFC 5531的报文格式,错一个字节后续解析就全乱了。
二、通过portmapper查询端口并完成挂载
有了RPC基础类,下一步是查询Mount服务监听的端口。portmapper本身也是RPC程序(程序号100000,版本2),它的GETPORT过程接受目标程序号、版本号和协议号作为参数,返回对应的TCP或UDP端口。整个过程如下:
def query_nfs_port(host)
pm = RpcClient.new(host, 111, 100000, 2)
payload = XDR.uint32(100005) + # mount程序号
XDR.uint32(3) + # mount版本 v3
XDR.uint32(6) + # IPPROTO_TCP
XDR.uint32(0) # 占位
reply = pm.call(3, payload) # GETPORT过程号是3
port = reply.unpack1('N')
raise "port not registered" if port == 0
port
end
def do_mount(host, port, export_path)
mount = RpcClient.new(host, port, 100005, 3)
# MOUNT过程:入参为目录路径 + 保留字段
payload = XDR.string(export_path) + XDR.string('')
# 提前插入空认证占位(auth_null 已在头部处理)
reply = mount.call(1, payload) # MNT过程号是1
status = reply[0, 4].unpack1('N')
raise "mount failed, status=#{status}" unless status == 0
# 成功时紧跟着根文件句柄,格式为opaque<>
fh_len = reply[4, 4].unpack1('N')
reply[8, fh_len]
end
host = '192.168.0.1'
root_fh = do_mount(host, query_nfs_port(host), '/export/data')
puts "拿到根句柄,长度 #{root_fh.bytesize} 字节"挂载成功后返回的根句柄是整个会话的锚点。NFS v3的句柄是不透明数据,客户端不需要理解其内部结构,只需在每次请求中原样带回。需要注意的是,如果export路径配置了访问控制(如/etc/exports中的IP白名单),客户端IP不在名单内时会收到status为13的ACCES错误,这在调试阶段非常常见。
另外一个实用建议:在实现时把每一步的原始报文用String#unpack('H*')打印出来,与Wireshark抓包结果逐字节对照。NFS报文解析是排查协议实现问题最有效的手段,尤其是XDR补齐字节的计算,肉眼很容易出错。
三、执行LOOKUP与READ读取文件内容
拿到根句柄后就可以调用NFS程序(2049)的过程了。以读取/export/data/hello.txt为例,需要先对每一级路径执行LOOKUP:输入是父目录句柄和文件名字符串,输出是新的文件句柄和属性。LOOKUP到最终文件后,再用READ过程读取数据。
def lookup(nfs, dir_fh, name)
payload = XDR.fh(dir_fh) + XDR.string(name)
reply = nfs.call(3, payload) # LOOKUP过程号是3
status = reply[0, 4].unpack1('N')
raise "lookup #{name} failed: #{status}" unless status == 0
fh_len = reply[4, 4].unpack1('N')
reply[8, fh_len]
end
def read_file(nfs, fh, size = 8192)
offset = 0
result = +''.b
loop do
# READ过程:句柄 + offset + count
payload = XDR.fh(fh) + XDR.uint64(offset) + XDR.uint32(size)
reply = nfs.call(6, payload) # READ过程号是6
status = reply[0, 4].unpack1('N')
raise "read failed: #{status}" unless status == 0
eof_flag = reply[4, 4].unpack1('N')
# 跳过READ3resok中的file_attributes(变长fattr3,这里简单跳过)
data, next_off = extract_read_data(reply)
result << data
offset += data.bytesize
break if eof_flag == 1 || data.empty?
end
result
end
nfs = RpcClient.new(host, query_port(host, 2049), 2049, 3)
txt_fh = lookup(nfs, root_fh, 'hello.txt')
puts read_file(nfs, txt_fh)READ结果中的file_attributes字段是变长的fattr3结构,包含类型、模式、UID、大小等21个字段,解析时建议按RFC 1813的表逐个字段偏移写一个专门的解码器,或者更省事的做法是利用紧随其后的count和data字段:先读count,再读count字节的数据,属性部分直接跳过。循环读取时务必依赖EOF标志而非读到空数据才停止,因为NFS允许返回不足count的数据。
常见错误码值得记几个:2表示不存在(NOENT),13表示权限不足(ACCES),70表示远程I/O错误(IO)。如果LOOKUP每级都成功但READ报错,多半是文件属性解析的偏移出了问题,而不是服务器问题。通过这样一个手写的客户端,你会发现NFS并没有想象中神秘,它本质上就是一组基于XDR编码的RPC过程调用,掌握这个思路后,再看其他基于ONC RPC的协议也会轻松许多。
Ruby NFS客户端RPC调用文件句柄修改时间:2026-09-13 08:56:36