(转)使用底层的 syscall.Socket 实现网络编程
原文地址 mp.weixin.qq.com
感受下 golang 调用原生socket的魅力
socket 函数是一个系统调用,用于在操作系统内核中创建一个新的网络套接字。套接字是一种在网络上进行通信的抽象对象,通过套接字,应用程序可以使用不同的网络协议进行通信,如 TCP、UDP 等, 甚至我们可以实现自定义的协议。
syscall.Socket
可以很多介绍 Go 标准库的 epoll 方式的文章,但是介绍 Go 底层怎么创建 TCP、UDP 连接的文章还不是太多,Go 最底层也是通过系统调用 Socket 的方式创建套接字,比如
// descriptor as nonblocking and close-on-exec.
func sysSocket(family, sotype, proto int) (int, error) {
// See ../syscall/exec_unix.go for description of ForkLock.
syscall.ForkLock.RLock()
s, err := socketFunc(family, sotype, proto)
if err == nil {
syscall.CloseOnExec(s)
}
syscall.ForkLock.RUnlock()
if err != nil {
return -1, os.NewSyscallError("socket", err)
}
if err = syscall.SetNonblock(s, true); err != nil {
poll.CloseFunc(s)
return -1, os.NewSyscallError("setnonblock", err)
}
return s, nil
}
var (
testHookDialChannel = func() {} // for golang.org/issue/5349
testHookCanceledDial = func() {} // for golang.org/issue/16523
// Placeholders for socket system calls.
socketFunc func(int, int, int) (int, error) = syscall.Socket
connectFunc func(int, syscall.Sockaddr) error = syscall.Connect
listenFunc func(int, int) error = syscall.Listen
getsockoptIntFunc func(int, int, int) (int, error) = syscall.GetsockoptInt
)
可以看到最终的还是依赖syscall.Socket
创建套接字的,最后返回这个套接字的文件描述符。
在 Linux 操作系统中,socket 函数的原型定义如下:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
它的主要功能就是创建一个网络交流的端点,返回一个文件描述符,通过这个文件描述符就可以进行数据的读写,甚至 listen 和 accept。
该函数的参数含义如下:
-
domain:指定套接字的地址族,例如 AF_INET 表示 IPv4 地址族,AF_INET6 表示 IPv6 地址族,AF_UNIX 表示本地 Unix 域套接字。
-
type:指定套接字的类型,例如 SOCK_STREAM 表示面向连接的 TCP 套接字,SOCK_DGRAM 表示无连接的 UDP 套接字。
-
protocol:指定套接字使用的协议,通常可以设置为 0,表示使用默认协议。
socket 函数的返回值为新创建的套接字的文件描述符,如果出现错误则返回 -1,并设置 errno 错误码。新创建的套接字可以使用其他系统调用进行配置,例如 bind
、connect
、listen
、accept
、sendto
、recvfrom
等等。这些系统调用在 Go 语言中都有相应的定义,Go 包含两套系统, 一个是标准库的 syscall, 如 syscall.Socket[1], 一个是 unix.Socket[2],如果你是在做 Linux 网络程序开发,其实都是一样的,所以本文中以syscall.Socket
库为例介绍。
domain
定义了套接字的地址族,比如我们常用的AF_INET
, AF_PACKET
代表数据链路层的 raw socket。type
类型常用的有SOCK_STREAM
、SOCK_DGRAM
、SOCK_RAW
等。protocol
指常用的协议,根据前面的参数设置的不同,这个参数有不同的设置,比如第二个参数是SOCK_STREAM
、SOCK_DGRAM
时,我们常把 protocol 设置为 0,当第二个参数是SOCK_RAW
时,我们会根据协议的不同,把它设置为syscall.IPPROTO_XXX
或者syscall.ETH_P_ALL
。
既然 Go 标准库中已经提供了 TCP、UDP 甚至 IP 的编程库,为什么我们还要学习 syscall.Socket 呢?syscall.Socket 提供了底层的网络通讯方式,比标准库提供了更丰富的网络编程能力,新的协议,新的模式,而且能够提供更底层的网络控制能力。
在本文中,将使用大量的例子,演示syscall.Socket
不同的参数和不同的使用场景。
使用 syscall.Socket 实现一个最初级的 HTTP Server
首先第一个例子是使用syscall.Socket
创建一个 http server, 这个 http server 纯粹是演示使用,并没有考虑 tls、性能等方面,也没有考虑 HTTP 协议全特性支持,只是为了演示我们可以使用syscall.Socket
可以创建一个 TCP Server。
一开始,我们先定义类型netSocket
,它包含 Socket 创建出来的文件描述符,我们把Read
、Write
、Accept
和Close
等系统调用都封装好,后面就方便使用了:
type netSocket struct {
fd int
}
func (ns netSocket) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
n, err := syscall.Read(ns.fd, p)
if err != nil {
n = 0
}
return n, err
}
func (ns netSocket) Write(p []byte) (int, error) {
n, err := syscall.Write(ns.fd, p)
if err != nil {
n = 0
}
return n, err
}
func (ns *netSocket) Accept() (*netSocket, error) {
nfd, _, err := syscall.Accept(ns.fd)
if err == nil {
syscall.CloseOnExec(nfd)
}
if err != nil {
return nil, err
}
return &netSocket{nfd}, nil
}
func (ns *netSocket) Close() error {
return syscall.Close(ns.fd)
}
以上代码也很容易理解,通过系统调用,操作 socket 文件描述符,我们就可以实现通用的服务端的网络处理能力。
接下来要解决的问题就是怎么生成一个 Socket, 让它监听指定的 IP 和端口:
func newNetSocket(ip net.IP, port int) (*netSocket, error) {
// ForkLock 文档指明需要加锁
syscall.ForkLock.Lock()
// 这里第一个参数我们使用syscall.AF_INET, IPv4的地址族。
// 第二个参数指明是数据流方式,也就是TCP的方式。
// 第三个参数使用SOCK_STREAM默认协议。
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
return nil, os.NewSyscallError("socket", err)
}
syscall.ForkLock.Unlock()
// 建立了Socket,并且得到了文件描述符,我们可以设置一些选项,
// 比如可重用的地址
if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
syscall.Close(fd)
return nil, os.NewSyscallError("setsockopt", err)
}
// 绑定指定的地址和端口
sa := &syscall.SockaddrInet4{Port: port}
copy(sa.Addr[:], ip)
if err = syscall.Bind(fd, sa); err != nil {
return nil, os.NewSyscallError("bind", err)
}
// 开始监听客户端的连接请求
if err = syscall.Listen(fd, syscall.SOMAXCONN); err != nil {
return nil, os.NewSyscallError("listen", err)
}
return &netSocket{fd: fd}, nil
}
下面我们实现 main 函数,把整个创建、监听、接收连接、处理请求都串起来:
func main() {
ipFlag := flag.String("ip_addr", "127.0.0.1", "监听的地址")
portFlag := flag.Int("port", 8080, "监听的端口")
flag.Parse()
ip := net.ParseIP(*ipFlag)
socket, err := newNetSocket(ip, *portFlag)
if err != nil {
panic(err)
}
defer socket.Close()
log.Printf("http addr: http://%s:%d", ip, port)
for {
// 开始等待客户端的连接
rw, e := socket.Accept()
log.Printf("incoming connection")
if e != nil {
panic(e)
}
// Read request
log.Print("reading request")
req, err := parseRequest(rw)
log.Print("request: ", req)
if err != nil {
panic(err)
}
// Write response
log.Print("writing response")
io.WriteString(rw, "HTTP/1.1 200 OK\r\n"+
"Content-Type: text/html; charset=utf-8\r\n"+
"Content-Length: 20\r\n"+
"\r\n"+
"<h1>hello world</h1>")
if err != nil {
log.Print(err.Error())
continue
}
}
}
这里有一个parseRequest
函数我还没有提供,它负责解析客户端的请求,解析出 http request,实际这个例子也不使用这个解析出的请求,只是简单的返回一个 hello world 的页面。这个函数实现如下:
type request struct {
method string // GET, POST, etc.
header textproto.MIMEHeader
body []byte
uri string // The raw URI from the request
proto string // "HTTP/1.1"
}
func parseRequest(c *netSocket) (*request, error) {
b := bufio.NewReader(*c)
tp := textproto.NewReader(b)
req := new(request)
// First line: parse "GET /index.html HTTP/1.0"
var s string
s, _ = tp.ReadLine()
sp := strings.Split(s, " ")
req.method, req.uri, req.proto = sp[0], sp[1], sp[2]
// Parse headers
mimeHeader, _ := tp.ReadMIMEHeader()
req.header = mimeHeader
// Parse body
if req.method == "GET" || req.method == "HEAD" {
return req, nil
}
if len(req.header["Content-Length"]) == 0 {
return nil, errors.New("no content length")
}
length, err := strconv.Atoi(req.header["Content-Length"][0])
if err != nil {
return nil, err
}
body := make([]byte, length)
if _, err = io.ReadFull(b, body); err != nil {
return nil, err
}
req.body = body
return req, nil
}
你可以运行这个程序,然后在浏览器中访问 http://127.0.0.1 应该能看到 hello world 的页面。
通过这个程序,你可以看到使用 syscall.Socket 的方式也并不复杂,只需调用相应的系统调用就行,你知道了也就会了,使用的时候查一下文档或者例子就可以了。
使用 syscall.Socket 实现访问一个网页
上面的例子是使用 syscall.Socket 提供一个 http server 的例子,其实就是实现了一个 tcp server。那么如果要实现一个访问网页的 client, 该如何使用 syscall.Socket 实现呢?
TCP socket 需要调用系统调用 Connect 连接到服务器,连接后就可以通过 Read/Write 进行读写了:
package main
import (
"fmt"
"net"
"os"
"syscall"
)
func main() {
// 创建一个TCP socket
sockfd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
fmt.Println("socket creation failed:", err)
os.Exit(1)
}
defer syscall.Close(sockfd)
// 得到要访问的网页的地址和端口,也就是一个TCPAddr
serverAddr, err := net.ResolveTCPAddr("tcp", "bing.com:80")
if err != nil {
fmt.Println("address resolution failed:", err)
syscall.Close(sockfd)
os.Exit(1)
}
// 使用syscall.Connect和创建好的Socket连接这个地址
err = syscall.Connect(sockfd, &syscall.SockaddrInet4{
Port: serverAddr.Port,
Addr: [4]byte{serverAddr.IP[0], serverAddr.IP[1], serverAddr.IP[2], serverAddr.IP[3]},
})
if err != nil {
fmt.Println("connection failed:", err)
syscall.Close(sockfd)
os.Exit(1)
}
// 发送一个请求
request := "GET / HTTP/1.1\r\nHost: bing.com\r\n\r\n"
_, err = syscall.Write(sockfd, []byte(request))
if err != nil {
fmt.Println("write failed:", err)
syscall.Close(sockfd)
os.Exit(1)
}
// 处理返回的结果,这里并没有解析http response
response := make([]byte, 1024)
n, err := syscall.Read(sockfd, response)
if err != nil {
fmt.Println("read failed:", err)
syscall.Close(sockfd)
os.Exit(1)
}
// 输出返回的结果
fmt.Println(string(response[:n]))
}
这个例子中我们先创建了一个 Socket, 然后连接到 bing.com 网站,发送了一个简单的 http 请求并得到了返回结果。
使用 syscall.Socket 实现 UDP server
上面的例子我们实现了 TCP 的 Socket 方式,接下来我们看看如何使用 syscall.Socket 实现 UDP 的 server 和 client。
UDP 的实现更简单,没有其他的 Bind、Listen、Accept 等系统调用。
注意 Socket 的三个参数的设置:syscall.AF_INET
、syscall.SOCK_DGRAM
和syscall.IPPROTO_UDP
。UDP 我们通过Sendto
和Recvfrom
进行读写:
package main
import (
"fmt"
"net"
"syscall"
)
func main() {
// 创建UDP Socket
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_UDP)
if err != nil {
fmt.Println("socket failed:", err)
return
}
defer syscall.Close(fd)
// 绑定本地地址和端口
addr := syscall.SockaddrInet4{Port: 8080}
copy(addr.Addr[:], net.ParseIP("0.0.0.0").To4())
if err := syscall.Bind(fd, &addr); err != nil {
fmt.Println("bind failed:", err)
return
}
fmt.Println("UDP server is listening on port 8080...")
buf := make([]byte, 1024)
for {
// 读取客户端发送的数据
n, from, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
fmt.Println("recvfrom failed:", err)
continue
}
// 将数据返回给客户端
if err := syscall.Sendto(fd, buf[:n], 0, from); err != nil {
fmt.Println("sendto failed:", err)
}
}
}
这个例子我们实现了 echo 的功能,服务端收到什么,就把结果原封不动的返回给客户端。
使用 syscall.Socket 实现 UDP client
既然使用 syscall.Socket 实现了 UDP 服务端,自然我们也能实现 UDP 的客户端:
package main
import (
"fmt"
"net"
"syscall"
)
func main() {
// 创建UDP Socket
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_UDP)
if err != nil {
fmt.Println("socket failed:", err)
return
}
defer syscall.Close(fd)
// 设置目标地址和端口
addr := syscall.SockaddrInet4{Port: 8080}
copy(addr.Addr[:], net.ParseIP("127.0.0.1").To4())
// 发送数据到UDP服务器
message := "Hello, UDP server!"
if err := syscall.Sendto(fd, []byte(message), 0, &addr); err != nil {
fmt.Println("sendto failed:", err)
return
}
// 读取UDP服务器的响应
buf := make([]byte, 1024)
n, _, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
fmt.Println("recvfrom failed:", err)
return
}
fmt.Println("received response:", string(buf[:n]))
}
一开始也是创建一个 UDP 的 socket, 然后调用 Sendto 和 Recvfrom 进行数据的发送和接收。可以看到我们不需要处理 IP 的包头和 UDP 的包头。
实现自定义的协议
前面的 TCP 和 UDP 的例子,我们调用 syscall.Socket 的时候,第一个参数是syscall.AF_INET
, 后面根据 TCP 还是 UDP 分别设置数据流或者数据报的参数,这是 IP 层 + UDP/TCP 层的网络编程方式。其实我们可以使用 syscall.Socket 实现数据链路层的通讯,比如接下来的例子,我们在数据链路层实现一个CHAO
协议。CHAO
协议很简单,它的前四个字节分别是字母C
、H
、A
、O
, 它没有 IP 的数据,纯粹建立在数据链路层之上,通过 MAC Addr 进行数据帧的发送和接收。
首先看一下服务端的实现:
package main
import (
"fmt"
"net"
"syscall"
)
func main() {
// 创建raw socket
protocol := htons(syscall.ETH_P_ALL)
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(protocol))
if err != nil {
fmt.Println("socket failed:", err)
return
}
defer syscall.Close(fd)
// 绑定到指定的网络接口
ifi, err := net.InterfaceByName("eth0")
if err != nil {
fmt.Println("interfaceByName failed:", err)
return
}
addr := syscall.SockaddrLinklayer{
Protocol: protocol,
Ifindex: ifi.Index,
}
if err := syscall.Bind(fd, &addr); err != nil {
fmt.Println("bind failed:", err)
return
}
// 接收自定义协议数据包
buf := make([]byte, 1024)
for {
n, raddr, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
fmt.Println("recvfrom failed:", err)
return
}
if n < 4 || !(buf[0] == 'C' && buf[1] == 'H' && buf[2] == 'A' && buf[3] == 'O') {
continue
}
fmt.Printf("received request: %s\n", string(buf[4:n]))
// 回复自定义协议数据包
response := []byte("Hello, custom chao protocol!")
header := []byte{'C', 'H', 'A', 'O'} // 自定义协议头部, CHAO协议
packet := append(header, response...)
if err := syscall.Sendto(fd, packet, 0, raddr); err != nil {
fmt.Println("sendto failed:", err)
return
}
}
}
func htons(i uint16) uint16 {
return (i<<8)&0xff00 | i>>8
}
首先我们使用syscall.AF_PACKET
、 syscall.SOCK_RAW
, syscall.ETH_P_ALL
创建了一个 raw socket, 注意它和我们前面 TCP/UDP 例子不同,TCP/UDP 创建 socket 的时候第一个参数是 IPv4 的地址族,创建数据链路层的 raw socket, 我们使用的是syscall.AF_PACKET
, 而且第二个参数是syscall.SOCK_RAW
, 第三个参数是syscall.ETH_P_ALL
, 代表接收所有的协议的数据。
接下来将 socket 绑定到某个网卡上。我在测试的时候,发现 syscall.SockaddrLinklayer 需要通过 htons 把协议从主机字节序转换成网络字节序,但是调用syscall.Socket
的时候第三个参数转不转换好像都没有问题。
接下来就是调用Recvfrom
读取客户端的请求,这里检查如果不是 CHAO 协议的数据就忽略,如果是 CHAO 协议的数据,就把请求原封不动的写回回去。
上面是服务端的代码,接下来是客户端的代码:
package main
import (
"fmt"
"net"
"syscall"
)
func main() {
// 创建raw socket
protocol := htons(syscall.ETH_P_ALL)
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(protocol))
if err != nil {
fmt.Println("socket failed:", err)
return
}
defer syscall.Close(fd)
// 绑定到指定的网络接口
ifi, err := net.InterfaceByName("eth0")
if err != nil {
fmt.Println("interfaceByName failed:", err)
return
}
addr := syscall.SockaddrLinklayer{
Protocol: protocol,
Halen: 6,
Ifindex: ifi.Index,
}
copy(addr.Addr[:6], ifi.HardwareAddr)
if err := syscall.Bind(fd, &addr); err != nil {
fmt.Println("bind failed:", err)
return
}
// 发送自定义协议数据包
message := []byte("Hello, custom chao protocol!")
header := []byte{'C', 'H', 'A', 'O'} // 自定义协议头部, CHAO协议
packet := append(header, message...)
if err := syscall.Sendto(fd, packet, 0, &addr); err != nil {
fmt.Println("sendto failed:", err)
return
}
buf := make([]byte, 1024)
for {
n, _, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
fmt.Println("recvfrom failed:", err)
return
}
if n < 4 || !(buf[0] == 'C' && buf[1] == 'H' && buf[2] == 'A' && buf[3] == 'O') {
continue
}
fmt.Println("received response:", string(buf[4:n]))
break
}
}
func htons(i uint16) uint16 {
return (i<<8)&0xff00 | i>>8
}
和服务端的代码类似,也是创建 Socket 并绑定,这里我们是在同一台机器上进行测试的,所以绑定的地址都是本机的 eth0 网卡的地址。客户端先发送一个数据,然后等待服务端的返回。
其他
因为 syscall.Socket 是非常底层的网络编程的方式,你可以使用它做很多标准库没有办法提供的功能。比如你可以实现 arp、dhcp、icmp 等协议,限于文章的篇幅,我就不一一列举了,感兴趣的同学可以在原文的注释中讨论。
下一篇,我们将讨论 AF_XDP, 新的高效的 socket。
参考文档
-
https://www.halolinux.us/kernel-reference/the-socket-system-call.html
-
https://gist.github.com/jschaf/93f37aedb5327c54cb356b2f1f0427e3
参考资料
[1]
syscall.Socket: https://pkg.go.dev/syscall#Socket
[2]
unix.Socket: https://pkg.go.dev/golang.org/x/sys/unix#Socket