Linux Socket 编程入门
关于计算机网络以及Socket编程相关的概念性知识,可以参考相关资料,这里只列举了常见函数的用法,以及两个简单的例子。
Socket 相关函数
socket 函数
创建socket(套接字)
1 |
|
参数:
family:协议族,通常取AF_INET(IPv4) 或AF_INET6(IPv6)type:套接字类型,通常取SOCK_STREAM(TCP) 或SOCK_DGRAM(UDP)protocol:协议,可以取IPPROTO_TCP、IPPTOTO_UDP,但是更建议直接取0,表示自动使用默认的协议
返回值:
- 创建成功:返回一个新的套接字文件描述符
sockfd - 创建失败:返回 -1,通过
errno获取错误信息
因此最常见的两种用法以及错误处理如下
1 | // TCP |
sockaddr 结构体
在socket网络通讯中,需要定义一个通用的sockaddr结构体来保存对方的地址信息,sockaddr定义如下
1 |
|
但是上述结构体太通用了,实际上我们对于IPv4使用如下更具体的结构体
1 |
|
其中:
sin_family:地址族,通常取AF_INET(IPv4)或AF_INET6(IPv6)sin_port:端口号,通常取大于1024且小于65536的整数,过小的端口号会被系统自动分配,或者需要sudo权限。如果将端口设置为0,表示使用随机端口,一般不推荐sin_addr.s_addr:IPv4 地址,例如可以取127.0.0.1(本地地址),0.0.0.0(任意地址,可以使用INADDR_ANY),或者某个具体的IPv4地址sin_zero:填充字节,以保证和sockaddr大小一致,不需要设置
对于IPv6来说,需要的结构体略有不同,这里不再赘述。在传递给bind和connect等API时,通常会将sockaddr_in强制转换成sockaddr类型。
在创建socketaddr_in结构体时,通常需要使用大小端转换和IP地址的格式转换,下面提供几个常见的例子。
服务端使用,指定任意 IP 的 PORT 端口
1 |
|
客户端使用,指定 SERVER_IP 的 PORT 端口
1 |
|
bind 函数
bind 可以用于绑定套接字到指定的地址(IP 地址和端口号),用于服务器端的监听(包括TCP和UDP的服务端)。
1 |
|
参数:
sockfd:套接字描述符addr:指向sockaddr结构体 的指针,通常由sockaddr_in结构体转换addrlen:addr的长度,一般用sizeof获取
返回值:
- 成功:返回 0
- 失败:返回 -1,通过
errno获取错误信息
使用以及错误处理例如
1 | if (bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) { |
listen 函数
对于TCP服务端,在bind之后可以使用listen函数开启监听,也就是将套接字从CLOSE状态转换为LISTEN状态。
1 |
|
参数:
sockfd:要操作的socketbacklog:为服务器处于LISTEN状态下维护的已完成连接队列长度的最大值,例如5或者10(当然在系统中也存在一个上限)
返回值:
- 成功:返回 0
- 失败:返回 -1,通过
errno获取错误信息
使用以及错误处理例如
1 | if (listen(sockfd, 5) == -1) { |
connect 函数
connect 函数用于客户端主动连接到服务器,通常在TCP客户端的连接过程中使用(UDP也可以使用,但是主要适用于TCP)
1 |
|
参数:
sockfd:套接字描述符addr:指向sockaddr结构体 的指针,通常由sockaddr_in结构体转换addrlen:addr的长度,一般用sizeof获取
返回值:
- 成功:返回 0
- 失败:返回 -1,通过
errno获取错误信息
使用以及错误处理例如
1 | if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { |
accept 函数
当TCP服务端调用 bind 和 listen 之后,服务器会在 accept 处阻塞,等待客户端发起连接,在连接成功后返回新的 socket 用于通信,并且可以通过输出参数获取客户端连接信息。(因为accept存在阻塞行为,TCP服务器可以使用fork多进程的方式来实现同时处理多个客户端连接,但是UDP服务器是不需要的)
1 |
|
参数:
sockfd:套接字描述符addr:(输出参数)指向sockaddr结构体 的指针,通常由sockaddr_in结构体转换,可填 NULL,表示不获取addrlen:(输出参数)指向addr长度的指针,可填 NULL,表示不获取
返回值:
- 成功:返回新的socket(注意这里socket也需要手动关闭)
- 失败:返回 -1,通过
errno获取错误信息
使用以及错误处理例如
1 | struct sockaddr_in client_addr; |
不获取客户端信息时,可以更加简单
1 | int client_fd = accept(server_fd, NULL, NULL); |
close 函数
close函数可以用于关闭套接字,就像文件关闭一样。
1 |
|
注意:
- 由于socket允许复制,close操作只是减少socket的引用计数,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
- 由于异常退出可能造成资源泄露,我们可能需要捕获
ctrl+c等触发的异常信号,以便及时释放资源。
Socket IO 函数
网络IO操作有下面几组函数:
write/readwritev/readvsend/recvsendto/recvfromsendmsg/recvmsg
前两组是通用的IO操作,可以用于文件读写等,第二组是适合多个缓冲区的IO操作,自动针对多个缓冲区进行,多个缓冲区按照顺序连接。
这些操作也支持socket的读写操作,但是通常不适用于UDP,这里不做讨论。
我们重点关注后三组函数。
需要说明的是, 考虑到缓冲区大小限制和网络原样,发送和接收数据的时候,无法保证不会一次性发送和接收完全部数据,可能需要套一层循环,但是这里简化讨论,不考虑这个问题。
send 和 recv 函数
这两个函数是最基础的IO函数,通常只用于TCP,因为TCP是有连接的。
1 |
|
参数:
sockfd:套接字描述符buf:发送数据缓冲区len:发送数据缓冲区长度flags:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*标志位
返回值:
- 成功:返回实际发送的字节数
- 连接关闭:返回 0
- 发送失败:返回 -1,通过
errno获取错误信息
使用示例
1 | ssize_t sent_bytes = send(sockfd, buffer, strlen(buffer), 0); |
1 |
|
参数:
sockfd:套接字描述符buf:(输出参数)接收数据缓冲区len:接收数据缓冲区长度(最大可接收的字节数)flags:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*标志位
返回值:
- 成功:返回实际接收的字节数(可能小于
len) - 连接关闭:返回 0
- 发送失败:返回 -1,通过
errno获取错误信息
使用示例
1 | ssize_t recv_bytes = recv(sockfd, buffer, sizeof(buffer) - 1, 0); |
sendto 和 recvfrom 函数
这两个函数只用于UDP,由于UDP通常是无连接的,在读写中都加入了dest_addr和addrlen。
1 |
|
参数:
sockfd:套接字描述符buf:发送数据缓冲区len:发送数据缓冲区长度flags:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*标志位dest_addr:接收方地址addrlen:接收方地址长度
返回值:
- 成功:返回实际发送的字节数
- 发送失败:返回 -1,通过
errno获取错误信息
使用例如
1 | ssize_t sent_len = sendto(sockfd, message, strlen(message), 0, |
1 |
|
参数:
sockfd:套接字描述符buf:(输出参数)接收数据缓冲区len:接收数据缓冲区长度(最大可接收的字节数)flags:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*标志位src_addr:(输出参数)发送方地址addrlen:(输出参数)发送方地址长度
返回值:
- 成功:返回实际接收的字节数(可能小于
len) - 连接关闭:返回 0
- 发送失败:返回 -1,通过
errno获取错误信息
使用示例
1 | ssize_t recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE, 0, |
sendmsg 和 recvmsg 函数
最后的这两个函数更具有一般性,行为更加灵活,实际上可以完全替代前两组函数,因为它引入了struct msghdr这个结构体参数。
1 |
|
参数:
sockfd:套接字描述符msg:消息结构体,打包了所有信息flags:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*标志位
返回值:
- 成功:返回实际发送的字节数
- 连接关闭:返回 0
- 失败:返回 -1,通过
errno获取错误信息
1 |
|
参数:
sockfd:套接字描述符msg:(输出参数)消息结构体,打包了所有信息flags:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*标志位
返回值:
- 成功:返回实际接收的字节数
- 连接关闭:返回 0
- 失败:返回 -1,通过
errno获取错误信息
这里我们需要关注 iovec 和 msghdr 结构体的具体内容,iovec 结构体定义如下
1 |
|
使用例如
1 | // 发送数据准备 |
msghdr 结构体定义如下
1 |
|
其中的很多成员都是可选的,TCP只需要关注核心的msg_iov以及msg_iovlen即可,UDP还需要关注msg_name和msg_namelen。
TCP使用例如
1 | // 发送准备 |
UDP使用例如
1 | // 发送准备 |
实例——echo服务器
我们考虑实现一个最简单的echo服务器,分别通过TCP和UDP实现。
echo服务器的逻辑很简单,将收到的信息原样发送回去,这里我们顺便在发送信息时附带了时间,在返回信息中加上了双方的地址信息。
由于TCP的accpet函数存在阻塞,使用fork多进程的方式来实现同时处理多个客户端连接。
TCP 服务端
1 |
|
UDP 服务端
1 |
|
客户端
使用 TCP 和 UDP 的客户端代码非常相似,因此写在了一起,使用USE_TCP宏来区分
1 |
|
实例——极简 http 服务器
这个极简的http服务器的内容很简单:
- GET 请求:
- 主页 (/):显示欢迎信息,并提供一个跳转到 /ask 页面的链接。
- 问答页 (/ask):包含一个 HTML 表单,允许用户输入内容并提交。
- 404 处理:对于未知路径,服务器返回 404 Not Found。
- 来自问答页的 POST 处理:用户提交的内容将被服务器接收,并显示在返回的 HTML 页面中。
http服务器就不需要客户端了,直接使用浏览器访问即可。
1 |
|
这里还有一个稍微复杂一点的http服务器:Tinyhttpd,
这是J. David Blackstone在1999年写的一个不到 500 行的超轻量型 Http Server,读一遍可以更好地理解http服务器的原理。
它除了可以根据请求返回本地目录中的文件内容,还支持 CGI:从请求中提取参数并传递给脚本执行,脚本计算并生成html返回。
补充
大小端数值转换
首先,不同的机器上对于多字节变量的字节存储顺序是不同的,有大端字节序和小端字节序两种,目前常见的机器都是小端序,但是在网络编程中统一使用大端序,也成为网络字节序。
在Linux中,提供了四个用于主机字节序和网络字节序之间相互转换的函数:
1 |
|
IP地址格式转换
IP地址一共有两种格式:
- Presentation(表达格式):也就是我们能看得懂的格式,例如”192.168.19.12”这样的字符串
- Numeric(数值格式):可以存入套接字地址结构体的格式,数据类型为整型
Linux 提供了两个函数用于IP地址格式的相互转换的函数:
inet_pton():将IP地址从表达格式转换为数值格式inet_ntop():将IP地址从数值格式转换为表达格式
1 |
|
参数:
family:地址族,通常取AF_INET(IPv4)或AF_INET6(IPv6)。strptr:(输入参数,表达格式)指向以点分隔的十进制(IPv4)或冒号分隔(IPv6)的 IP 地址字符串的字符指针。addrptr:(输出参数,数值格式)指向struct in_addr(IPv4)或struct in6_addr(IPv6)的通用指针,用于存储转换后的二进制 IP 地址。
返回值:
- 如果转换成功,则返回 1。
- 如果表达格式的 IP 地址格式有误,则返回 0。
- 如果转换出错则返回 -1,并设置相应的
errno。
1 |
|
参数:
family:地址族,AF_INET(IPv4)或AF_INET6(IPv6)。addrptr:(输入参数,数值格式)指向struct in_addr(IPv4)或struct in6_addr(IPv6)的通用指针。strptr:(输出参数,表达格式)用于存储转换后的 IP 地址字符串的缓冲区字符指针。len:strptr的缓冲区长度。
返回值:
- 如果转换成功,则返回
strptr指针。 - 如果出错,则返回
NULL,并设置相应的errno。
输出缓冲区的大小建议设置为如下值
1 |
Python 简易聊天服务器
基于 Socket 编程就可以实现简单的聊天服务器,为了使用 GUI,这里选择使用 Python tkinter 实现,原理都是类似的。
1 | #!/usr/bin/env python3 |
