IP 地址可以标识网络中的主机,协议类型(TCP或UDP)加端口号可以表示主机上的进程。
基本原理
文件类型
Linux 中有七种类型的文件,这些文件类型可以使用一些基本的函数,例如 read、write:
- 普通文件
- 目录
- 链接文件
- 字符设备
- 块设备
- 管道:pipe匿名管道,fifo有名管道
- 套接字:socket
套接字是全双工的,虽然只有一个文件描述符,但是在 Linux 内核中读写操作分别对应不同的缓冲区。相比之下,管道则是半双工的,读写操作对应同一个缓冲区。
字节序
TCP/IP 协议规定,使用大端字节序。 即低地址放高字节。
常见字节序
处理器可以支持两种字节序:
- 大端(big-endian):最低有效字节在高地址出现
- 小端(little-endian):最低有效字节在低地址出现
例如,对于十六进制数据 0x12345678,总共占4个字节。大端存储时,最低地址存的是 0x12,小端则是0x78。
地址(假设从1001开始) | 大端 | 小端 |
---|---|---|
1004 | 78 | 12 |
1003 | 56 | 34 |
1002 | 34 | 56 |
1001 | 12 | 78 |
可以通过代码查看平台的字节序:
#include <stdio.h>
union u{
char c;
int i;
};
int main(void) {
union u test;
test.i = 0x12345678;
printf("%x\n", test.c);
return 0;
}
x86平台是小端存储,ARM则可以自由选择。
字节序转换
为使应用程序有更好的可移植性,需要在处理器字节序和网络字节序之间进行转换。常用的函数有4个,入参和返回值都是整数形式的 IP 地址或端口号:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostint32); // 返回网络字节序表示的32位整数
uint16_t htons(uint16_t hostint16);// 返回网络字节序表示的16位整数
uint32_t ntohl(uint32_t netint32); // 返回主机字节序表示的32位整数
uint16_t ntohs(uint16_t netint16); // 返回主机字节序表示的16位整数
IP地址是32位,端口号是16位。
地址格式
最开始所有的 socket 代码都是针对 IPv4 编写的,后来才兼容 IPv6 和 Unix 域。
sockaddr 结构体
socket 相关函数中,直接使用这个结构体。为了防止编译时报错,都需要强制转成这种结构体才能使用:
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
};
sockaddr_in 、sockaddr_in6、sockaddr_un 结构体
因特网地址定义在 <netinet/in.h> 头文件中。
- sockaddr_in:IPv4 地址。
- sockaddr_in6:IPv6 地址。
- sockaddr_un:UNIX 域地址。
struct in_addr {
in_addr_t s_addr; /* IPv4 address*/
};
struct sockaddr_in {
sa_family sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
};
地址格式转换
常用的 IP 地址格式是点分十进制字符串(例如192.168.1.1),计算机中用的是 32 位的 unsigned int 类型,并在通信前转成合适的字节序。这些常用的步骤已经被封装成立标准函数。
#include <arpa/inet.h>
// 返回地址字符串格式
const char *inet_ntop(int domain, const void *restrict addr, char *restrict str, socklen_t size);
// 成功返回1,格式无效返回0,失败返回-1
int inet_pton(int domain, const char *src, void *dst);
上面两个函数的参数 domain:只支持 AF_INET 和 AF_INET6。
示例:
unsigned char buf[sizeof(struct in6_addr)];
int domain = AF_INET;
s= inet_pton(domain, argv[2], buf);
if (s <= 0) {
if (s == 0)
fprintf(stderr, "Not in presentation format");
else
perror("inet_pton");
exit(EXIT_FAILURE);
}
if (inet_ntop(domain, buf, str, INET6_ADDRSTRLEN) == NULL) {
perror("inet_ntop");
exit(EXIT_FAILURE);
}
常用函数
socket 创建套接字
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数:
- domain:通信方式,AF_INET 表示 IPv4,AF_INET6 表示 IPv6,AF_UNIX 表示 unix 域
- type:套接字类型,SOCK_STREAM 表示 TCP,SOCK_DGRAM 表示 UDP
- protocol:协议,通常设为 0 表示使用默认协议。
返回值:
- 出错返回 -1,否则返回套接字的描述符
bind 绑定地址和端口号
对于客户端,可以使用自动分配的端口号,不需要调用该函数。但是服务器通常工作在指定端口,必须使用 bind 来绑定。
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:通过 socket 函数创建的套接字对应的描述符
- addr:套接字地址。
struct sockaddr
类型通常是通过类型强制转换得到,来源有:- struct sockaddr_in:IPv4 地址
- struct sockaddr_in6:IPv6 地址
- struct sockaddr_un:UNIX 域地址
- addrlen:套接字结构体长度
返回值:失败返回 -1,成功返回 0。
套接字地址结构体的定义在不同的系统中有差异,例如,Linux 中定义如下:
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
};
struct in_addr {
in_addr_t s_addr; /* IPv4 address */
};
struct sockaddr_in {
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[8];
};
listen 激活socket,指定用于建立连接的队列大小
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
参数:
- sockfd:套接字描述符
- backlog:等待连接的队列大小。如果等待建立连接的客户端数量超过此限制,服务器会忽略后面的请求。
accept 监听客户端连接
accept 每次监听到一个客户端的连接,就会创建一个新的指向该客户端的套接字,同时改写入参保存客户端套接字信息。仅用于 SOCK_STREAM, SOCK_SEQPACKET 类型的套接字。
#in clude <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
- struct sockaddr:套接字地址结构体,跟具体通信类型有关
- addrlen:结构体的大小
accept 函数会阻塞执行,直到有客户端请求。此时 accept 会创建一个新的套接字返回,并修改入参的 addr 和 addrlen 保存客户端信息。
connect 建立连接
需要把具体的socket地址类型强转为 struct sockaddr 类型。
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
服务器代码示例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <ctype.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERV_IP "127.0.0.1" /* 服务器可以用 htonl(INADDR_ANY) 自动选择可用IP */
#define SERV_PORT 8888
#define MAX_CONN 100 /* 连接队列最多可以有 100 个客户端在排队等待建立连接 */
int main() {
char buf[1024];
int n;
int sockfd, clientfd;
struct sockaddr_in serv_addr, client_addr;
socklen_t socklen;
sockfd = socket(AF_INET, SOCK_STREAM, 0); /* IPv4 协议, TCP 协议*/
if (sockfd == -1) {
perror("socket");
return -1;
}
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT); /* 指定端口号 */
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 自动选择可用IP */
if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
return -1;
}
if (listen(sockfd, MAX_CONN) < 0) {
perror("listen");
return -1;
}
socklen = sizeof(client_addr);
clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &socklen);
if (clientfd < 0) {
perror("accept");
return -1;
}
inet_ntop(AF_INET, &client_addr, buf, INET_ADDRSTRLEN);
printf("client IP is: %s, client port is: %d\n", buf, ntohs(client_addr.sin_port));
while(1) {
n = read(clientfd, buf, sizeof(buf));
for (int i = 0; i < n; i++) {
buf[i] = toupper(buf[i]);
}
write(clientfd, buf, n);
}
close(clientfd);
close(sockfd);
return 0;
}
客户端代码示例
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define error_exit(msg) \
do {perror(msg); exit(EXIT_FAILURE);} while(0)
#define SERV_IP "127.0.0.1"
#define SERV_PORT 8888
int main(int argc, char *argv[]) {
int sockfd, ret;
int n;
char buf[BUFSIZ];
struct sockaddr_in serv_addr;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
error_exit("socket");
memset(&serv_addr, 0, sizeof(serv_addr));
ret = inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr);
if (ret <= 0) {
if (ret == 0)
error_exit("format error");
else
error_exit("inet_pton");
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
ret = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if (ret == -1) {
error_exit("connect");
}
while(1) {
fgets(buf, sizeof(buf), stdin);
write(sockfd, buf, strlen(buf));
n = read(sockfd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, n);
}
close(sockfd);
return 0;
}
错误处理
每个函数都可能发生错误,但是如果为每个函数都写错误处理代码,会导致逻辑很混乱。常用的有两种方式来处理错误。
定义统一的错误处理函数
针对函数返回值进行判断,出错时调用错误处理函数。
#define error_exit(msg) do {perror(msg); exit(EXIT_FAILURE);} while(0)
优点是实现方式统一,缺点是不够灵活。
重写所有会导致错误的函数,内部封装错误处理
可用把方法重新写为大写字母开头,这样在 vim 编辑器中仍然可以方便的查看 man 手册(大写 K,即 shift + k)。
例如 redis 源码封装了 anet.c 文件,将所有套接字相关函数重写:
static int anetGenericAccept(char *err, int s, struct sockaddr *sa, socklen_t *len) {
int fd;
while(1) {
fd = accept(s,sa,len);
if (fd == -1) {
if (errno == EINTR)
continue;
else {
anetSetError(err, "accept: %s", strerror(errno));
return ANET_ERR;
}
}
break;
}
return fd;
}
int anetTcpAccept(char *err, int s, char *ip, size_t ip_len, int *port) {
int fd;
struct sockaddr_storage sa;
socklen_t salen = sizeof(sa);
if ((fd = anetGenericAccept(err,s,(struct sockaddr*)&sa,&salen)) == -1)
return ANET_ERR;
if (sa.ss_family == AF_INET) {
struct sockaddr_in *s = (struct sockaddr_in *)&sa;
if (ip) inet_ntop(AF_INET,(void*)&(s->sin_addr),ip,ip_len);
if (port) *port = ntohs(s->sin_port);
} else {
struct sockaddr_in6 *s = (struct sockaddr_in6 *)&sa;
if (ip) inet_ntop(AF_INET6,(void*)&(s->sin6_addr),ip,ip_len);
if (port) *port = ntohs(s->sin6_port);
}
return fd;
}