Skip to content

socket编程简介

套接字概念

Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

既然是文件,那么理所当然的我们可以使用文件描述符引用套接字。与管道类似,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。

套接字的内核实现较为复杂,不宜在学习初期深入学习。

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

套接字通信原理如下图所示:

**在网络通信中,套接字一定是成对出现的。**一端的发送缓冲区对应另一端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。

TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API。本章的主要内容是socket API,主要介绍TCP协议的函数接口,最后介绍UDP协议和UNIX Domain Socket的函数接口。网络编程接口如下图所示:

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址

TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。例如上一节的UDP段格式,地址0-1是16位的源端口号,如果这个端口号是1000(0x3e8),则地址0是0x03,地址1是0xe8,也就是先发0x03,再发0xe8,这16位在发送主机的缓冲区中也应该是低地址存0x03,高地址存0xe8。但是,如果发送主机是小端字节序的,这16位被解释成0xe803,而不是1000。因此,发送主机把1000填到发送缓冲区之前需要做字节序的转换。同样地,接收主机如果是小端字节序的,接到16位的源端口号也要做字节序的转换。如果主机是大端字节序的,发送和接收都不需要做转换。同理,32位的IP地址也要考虑网络字节序和主机字节序的问题。

大端和小端

大端字节序(高端字节序)(网络字节序): 低位地址存放高位数据, 高位地址存放低位数据

小端字节序(低端字节序): 低位地址存放低位数据, 高位地址存放高位数据

小端是是正常的,网络上传输使用的是大端、接受需要转换。

大端和小端只是对数据类型长度是两个及以上的,如int short 对于单字节没限制,在网络中经常需要考虑大端和小端的是IP和端口。

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做**网络字节序和主机字节序的转换**。

c
#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
// h表示host,n表示network,l表示32位长整数,s表示16位短整数。
// printf用的是小端 sockaddr用的是大端

如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

转换示例

cpp
#include <arpa/inet.h>
#include <stdio.h>

int main(int argc, char const *argv[]) {
    unsigned short s = 0x1234;
    printf("s=%x\n", s);
    printf("htons(s)=%x\n", htons(s));

    unsigned int i = 0x12345678;
    printf("i=%x\n", i);
    printf("htonl(i)=%x\n", htonl(i));

    return 0;
}

程序输出:

shell
$ gcc htons.c -o a
$ ./a
s=1234
htons(s)=3412
i=12345678
htonl(i)=78563412

IP地址转换函数

早期:

c
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
  • 只能处理IPv4的ip地址

  • 不可重入函数

:参数是struct in_addr

现在:

c
#include <arpa/inet.h>

int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
  • p:表示点分十进制的字符串形式
  • to:到
  • n:表示network网络
cpp
#include <arpa/inet.h>

int inet_pton(int af, const char *src, void *dst);

函数说明

将字符串形式的点分十进制IP转换为大端模式的网络IP(整形4字节数)

支持IPv4和IPv6,可重入函数,其中inet_ptoninet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr。因此函数接口是void *addrptr

参数说明

  • afAF_INET
  • src: 字符串形式的点分十进制的IP地址
  • dst: 存放转换后的变量的地址

例如: inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);

手工也可以计算: 如192.168.232.145, 先将4个正数分别转换为16进制数,

192--->0xC0 168--->0xA8 232--->0xE8 145--->0x91

最后按照大端字节序存放: 0x91E8A8C0, 这个就是4字节的整形值

cpp
#include <arpa/inet.h>

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

函数说明

网络IP转换为字符串形式的点分十进制的IP

参数说明

  • afAF_INET
  • src: 网络的整形的IP地址
  • dst: 转换后的IP地址,一般为字符串数组
  • size: dst的长度

函数返回值

  • 成功--返回指向dst的指针
  • 失败--返回NULL,并设置errno

例如: IP地址为010aa8c0, 转换为点分十进制的格式:

01---->1 0a---->10 a8---->168 c0---->192

由于从网络中的IP地址是高端模式, 所以转换为点分十进制后应该为: 192.168.10.1

sockaddr数据结构

strcut sockaddr 很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体,为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数,至于这个函数是sockaddr_in还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。

可参看 man 7 ip。

sockaddr数据结构

c
struct sockaddr {
  sa_family_t sa_family; /* address family, AF_xxx */
  char sa_data[14];/* 14 bytes of protocol address */
};

使用 sudo grep -r "struct sockaddr_in {" /usr 命令可查看到struct sockaddr_in结构体的定义。一般其默认的存储位置:/usr/include/linux/in.h 文件中。

c
struct sockaddr_in {
  __kernel_sa_family_t sin_family; /* Address family */  //地址结构类型
  __be16 sin_port; /* Port number */  //端口号
  struct in_addr sin_addr;/* Internet address */  //IP地址
  /* Pad to size of `struct sockaddr'. */
  unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
  sizeof(unsigned short int) - sizeof(struct in_addr)];
};

struct in_addr {/* Internet address. */
  __be32 s_addr;
};

struct sockaddr_in6 {
  unsigned short int sin6_family; /* AF_INET6 */
  __be16 sin6_port; /* Transport layer port # */
  __be32 sin6_flowinfo; /* IPv6 flow information */
  struct in6_addr sin6_addr;/* IPv6 address */
  __u32 sin6_scope_id; /* scope id (new in RFC2553) */
};

struct in6_addr {
  union {
    __u8 u6_addr8[16];
    __be16 u6_addr16[8];
    __be32 u6_addr32[4];
  } in6_u;

  #define s6_addr in6_u.u6_addr8
  #define s6_addr16 in6_u.u6_addr16
  #define s6_addr32 in6_u.u6_addr32
};

#define UNIX_PATH_MAX 108
struct sockaddr_un {
  __kernel_sa_family_t sun_family; /* AF_UNIX */
  char sun_path[UNIX_PATH_MAX]; /* pathname */
};

Pv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位端口号和32位IP地址,IPv6地址用sockaddr_in6结构体表示,包括16位端口号、128位IP地址和一些控制字段。UNIX Domain Socket的地址格式定义在sys/un.h中,用sock-addr_un结构体表示。各种socket地址结构体的开头都是相同的,前16位表示整个结构体的长度(并不是所有UNIX的实现都有长度字段,如Linux就没有),后16位表示地址类型。IPv4、IPv6和Unix Domain Socket的地址类型分别定义为常数AF_INETAF_INET6AF_UNIX。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bindacceptconnect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如:

c
struct sockaddr_in servaddr;
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));/* initialize servaddr */

获取远程主机的信息

当使用ping www.baidu.com命令的时间,会先查询本地hosts文件中有没有相应域名的记录,如果没有再查询DNS。

既然可以使用系统命令得到ip地址,那么也可以使用代码来获得。借助库函数``即可实现。

函数原型

cpp
#include <netdb.h>
extern int h_errno;

struct hostent *gethostbyname(const char *name);

#include <sys/socket.h>       /* for AF_INET */

struct hostent *gethostbyaddr(const void *addr, socklen_t len, int type);

函数说明

这个库函数底层是使用了DNS的,所以使用的时间要有网络。函数返回一个struct hostent *类型的结构体指针,该结构体的定义为,可以在<netdb.h>中查看到

cpp
struct hostent {
    char  *h_name;            /* 官方名称 */
    char **h_aliases;         /* 别名列表,最后一个元素为NULL */
    int    h_addrtype;        /* ip地址的类型,IPv4或IPv6 */
    int    h_length;          /* ip地址的长度 */
    char **h_addr_list;       /* ip地址列表,最后一个元素为NULL */
}
#define h_addr h_addr_list[0] /* for backward compatibility */

使用示例

cpp
#include <arpa/inet.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char const *argv[]) {
    if (argc != 2) {
        printf("Args error.\n");
        return -1;
    }

    struct hostent *pHost = gethostbyname(argv[1]);
    if (pHost == NULL) {
        fprintf(stderr, "gethostbyname: %s\n", hstrerror(h_errno));
        return -1;
    }

    printf("Host real name is %s\n", pHost->h_name);
    for (int i = 0; pHost->h_aliases[i] != NULL; ++i) {
        printf("    alias name is %s\n", pHost->h_aliases[i]);
    }
    printf("addrtype is %d\n", pHost->h_addrtype);
    printf("addrlength is %d\n", pHost->h_length);
    for (int i = 0; pHost->h_addr_list[i] != NULL; ++i) {
        char buf[1024];
        memset(buf, 0x0, sizeof(buf));
        inet_ntop(pHost->h_addrtype, pHost->h_addr_list[i], buf, sizeof(buf));
        printf("    addr is %s\n", buf);
    }

    return 0;
}

程序输出:

shell
$ gcc gethostbyname.c -o a
$ ./a www.baidu.com
Host real name is www.a.shifen.com
    alias name is www.baidu.com
addrtype is 2
addrlength is 4
    addr is 183.2.172.42
    addr is 183.2.172.185
$ ./a www.jd.com
Host real name is wwwv6.jcloudimg.com
    alias name is www.jd.com
    alias name is www.jd.com.gslb.qianxun.com
    alias name is www.jd.com.s.galileo.jcloud-cdn.com
addrtype is 2
addrlength is 4
    addr is 36.99.50.131