使用底层的syscall.Socket实现网络编程

socket 函数是一个系统调用,用于在操作系统内核中创建一个新的网络套接字。套接字是一种在网络上进行通信的抽象对象,通过套接字,应用程序可以使用不同的网络协议进行通信,如 TCP、UDP 等,甚至我们可以实现自定义的协议。

syscall.Socket

可以很多介绍Go标准库的epoll方式的文章,但是介绍Go底层怎么创建TCP、UDP连接的文章还不是太多,Go最底层也是通过系统调用Socket的方式创建套接字,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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 函数的原型定义如下:

1
2
3
#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,一个是unix.Socket,如果你是在做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等系统调用都封装好,后面就方便使用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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和端口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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函数,把真个创建、监听、接收连接、处理请求都串起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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的页面。这个函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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:8080, 应该能看到hello world的页面。

通过这个程序,你可以看到使用syscall.Socket的方式也并不复杂,只需调用相应的系统调用就行,你知道了也就会了,使用的时候查一下文档或者例子就可以了。

使用syscall.Socket实现访问一个网页

上面的例子是使用syscall.Socket提供一个http server的例子,其实就是实现了一个tcp server。那么如果要实现一个访问网页的client,该如何使用syscall.Socket实现呢?

TCP socket需要调用系统调用Connect连接到服务器,连接后就可以通过Read/Write进行读写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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进行读写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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进行数据帧的发送和接收。

首先看一下服务端的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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协议的数据,就把请求原封不动的写回回去。

上面是服务端的代码,接下来是客户端的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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等协议,限于文章的篇幅,我就不一一列举了,感兴趣的同学可以在原文的注释中讨论。

参考文档

  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