miller
发布于

(转)使用底层的 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 错误码。新创建的套接字可以使用其他系统调用进行配置,例如 bindconnectlistenacceptsendtorecvfrom 等等。这些系统调用在 Go 语言中都有相应的定义,Go 包含两套系统, 一个是标准库的 syscall, 如 syscall.Socket[1], 一个是 unix.Socket[2],如果你是在做 Linux 网络程序开发,其实都是一样的,所以本文中以syscall.Socket库为例介绍。

domain定义了套接字的地址族,比如我们常用的AF_INET, AF_PACKET代表数据链路层的 raw socket。type类型常用的有SOCK_STREAMSOCK_DGRAMSOCK_RAW等。protocol指常用的协议,根据前面的参数设置的不同,这个参数有不同的设置,比如第二个参数是SOCK_STREAMSOCK_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 创建出来的文件描述符,我们把ReadWriteAcceptClose等系统调用都封装好,后面就方便使用了:

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_INETsyscall.SOCK_DGRAMsyscall.IPPROTO_UDP。UDP 我们通过SendtoRecvfrom进行读写:

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协议很简单,它的前四个字节分别是字母CHAO, 它没有 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_PACKETsyscall.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。

参考文档

  1. https://man7.org/linux/man-pages/man2/socket.2.html

  2. https://www.halolinux.us/kernel-reference/the-socket-system-call.html

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

浏览 (401)
点赞
收藏
评论