Linux socket API

佟暉發表於2023-11-14

socket是程式通訊機制的一種,與PIPE、FIFO不同的是,socket即可以在同一臺主機通訊(unix domain),也可以透過網路在不同主機上的程式間通訊(如:ipv4、ipv6),例如因特網,應用層透過呼叫socket API來與核心TCP/IP協議棧的通訊,透過網路位元組實現不用主機之間的資料傳輸。

前置條件

位元組序

對於多位元組的資料,不同處理器儲存位元組的順序稱為位元組序,主要有大端序(big-endian)和小端序(little-endian),位元組序的收發不統一就會導致值被解析錯誤。

大端序

高位位元組存低位記憶體

大端序是最高位位元組儲存在最低位記憶體地址處。例如一段資料0x0A0B0C0D,0x0A是最高位位元組,0x0D是最地位位元組,記憶體地址最低位a、最高位a+3,在大端序中儲存方式如下

  • 8bit儲存方式:記憶體地址從低到高0x0A -> 0x0B -> 0x0C -> 0x0D
  • 16bit儲存方式:記憶體地址從低到高0x0A0B -> 0x0C0D

小端序

低位位元組存低位記憶體

小端序是最低位位元組儲存在最低位記憶體地址處。例如一段資料0x0A0B0C0D,0x0A是最高位位元組,0x0D是最地位位元組,記憶體地址最低位a、最高位a+3,在小端序儲存方式如下

  • 8bit儲存方式:記憶體地址從低到高0x0D->0X0C->0X0B->0X0A
  • 16bit儲存方式:記憶體地址從低到高0X0C0D->0X0A0B

主機通常使用小端序,因為計算機先處理小端序的位元組效率更高。透過上面的結構不難看出,大端序更易讀,所以網路和儲存等採用了大端序,那麼網路通訊的時候就需要將網路位元組的大端序轉換為主機位元組的小端序。好在這些都有系統呼叫可以保證~
判斷主機的位元組序:

#include <iostream>
using namespace std;
void byteorder() {
  union {
    short value;
    char union_bytes[sizeof(short)];
  } test;
  test.value = 0x0102;
  if ((test.union_bytes[0] == 0x01) && (test.union_bytes[1] == 0x02)) {
    cout << "big endian" << endl;  // [0x01, 0x02]
  } else if ((test.union_bytes[0] == 0x02) && (test.union_bytes[1] == 0x01)) {
    cout << "little endian" << endl;  // [0x02, 0x01]
  } else {
    cout << "unknow~" << endl;
  }
}

int main() { byteorder(); }

位元組序轉換

#include<netinet/in.h>
// long型主機位元組序轉換為long型網路位元組序, host to network
unsigned long int htonl(unsigned long int hostlong);
// short型
unsigned short int htons(unsigned short int hostshort);
// long型網路位元組序轉換為long型主機位元組序, network to host
unsigned long int ntohl(unsigned long int netlong);
// short型
unsigned short int ntohs(unsigned short int netshort);

比方轉換主機的埠

int main(int argc, char *argv[]){
    int port = atoi(argv[1]); // 主機序
    server_address.sin_port = htons(port); // 網路序
}

地址

通用地址

地址我們標識通訊的端點,通用的地址格式為

#include<bits/socket.h>
struct sockaddr
{
    sa_family_t sa_family; // 協議型別,例如 ipv4 AF_INET、unix AF_UNIX
    char sa_data[14]; // unix域存放檔案路徑,ip域存放ip地址和埠號
}

sa_data只能容納14位元組地址資料,如果是unix域路徑長度可以達到108位元組放不下,所以linux定義了新的地址

#include<bits/socket.h>
struct sockaddr_storage
{
    sa_family_t sa_family;
    unsigned long int__ss_align; // 作用是記憶體對齊
    char__ss_padding[128-sizeof(__ss_align)];
}

專有地址

專有地址在bind、accept、connect等需要用到的函式中需要強制轉換為通用地址,例如:(struct sockaddr *)&server_address

顧名思義專門為ipv4、unix、ipv6設計的不同socket地址結構,以ipv4為例

struct sockaddr_in
{
    sa_family_t sin_family; // AF_INET
    u_int16_t sin_port; // 網路位元組序的埠號
    struct in_addr sin_addr; // IP地址
};
struct in_addr
{
    u_int32_t s_addr; // 網路位元組序的IP地址
};

具體這樣用:

int main(int argc, char *argv[]) {
    const char *ip = argv[1]; // 主機序ip地址
    int port = atoi(argv[2]); // 主機序埠
    struct sockaddr_in address; // ipv4專有地址
    // 設定專有地址的成員
    address.sin_family = AF_INET;
    address.sin_port = htons(port);
	// 將點分10進位制的ip字串轉換為網路位元組序整形表示的ip地址,存入sin_addr
    inet_pton(AF_INET, ip, &address.sin_addr);
  	int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 建立socket
    // 繫結埠,要強制轉換為通用地址 (struct sockaddr *)&address
  	int ret = bind(sockfd, (struct sockaddr *)&address, sizeof(address));
}

建立連線

建立socket

Linux一切皆檔案,所以socket建立好之後就是一個檔案描述符,對該fd讀寫關閉、屬性控制。

以ipv4為例

#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  • 第一個引數domain指定協議族,AF_INET、AF_UNIX、AF_INET6
  • 第二個引數type指定socket型別,TCP\UDP分別使用流式SOCK_STREAM和資料包式SOCK_DGRAM
  • 第三個引數protocal指定協議,有IPPROTO_TCP、IPPROTO_ICMP、IPPROTO_UDP等。通常使用預設的0。例如domain為AF_INET,type為SOCK_STREAM,那麼就意味著ipv4 TCP型別的socket,protocal設定為0即可。

標識socket:bind

標識該socket,對於ipv4用ip地址和埠作為端點的表示

int ret = bind(sockfd, (struct sockaddr *)&address, sizeof(address));

成功返回0,失敗返回-1並設定errno,例如errno

  • EACCES:沒有許可權繫結該埠
  • EADDRINUSE:繫結一個沒有釋放的埠和地址,通常被處於TIME_WAIT的連線使用,需要使用SO_REUSEADDR來複用處於TIME_WAIT連線的埠和地址

監聽socket:listen

開始監聽,並指定連線數

#include<sys/socket.h>
int listen(int sockfd,int backlog);

ret = listen(sock, 5);
  • backlog參數列示處於ESTABLISHED狀態的連線數(我的ubuntu20.4測試為backlog+1),超過該值客戶端收到ECONNREFUSED或者客戶端TIMEOUT

接受連線:accept

從listen佇列中拿連線過來,不管該連線是ESTABLISED還是CLOSE_WAIT的狀態。

int connfd = accept(sockfd, (struct sockaddr *)&client, &client_addrlength);

發起連線:connect

connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address))

成功返回0,失敗返回-1並設定errno

  • ECONNREFUSED:目標埠不存在或連線被拒絕
  • ETIMEOUT:連線超時

關閉連線

close

關閉socket fd,預設情況下:如果是多程式,fork後會將fd引用計數加1,如果要關閉該socket,父子程式都需要close,而且是同時關閉讀和寫。可以透過setsockopt的SO_LINGER控制close的行為

#include<sys/socket.h>
struct linger
{
	int l_onoff; // 關閉控制
	int l_linger; // 控制時間
}

close可能會有三種行為:

  1. l_onoff:關閉時(值為0),close預設行為,傳送緩衝區所有資料後關閉連線
  2. l_onoff:開啟時(值大於0),若l_linger為0,close系統呼叫立即返回,緩衝區資料被丟棄,給對端傳送RST報文
  3. l_onoff:開啟時(值大於0),若l_linger大於0:
    1. 阻塞型socket,close等待l_linger的時間,直到傳送完緩衝區資料並收到對端的ACK,如果這段時間沒有傳送完緩衝區資料並收到確認,close將返回-1並設定errno為EWOULDBLOCK。
    2. 非阻塞型socket,立即返回,根據返回值和errno來判斷殘留資料是否傳送完畢

shutdown

#include<sys/socket.h>
int shutdown(int sockfd,int howto);

不引用計數直接關閉,howto引數:

  • SHUT_RD:程式不能再對socketfd做讀操作,接收緩衝區資料被丟掉
  • SHUT_WR:關閉socketfd寫,緩衝區資料會在關閉前傳送出去,寫操作不可執行(半關閉狀態)
  • SHUT_RDWR:同時關閉

資料讀寫

除了預設對檔案描述符的read、write操作之外,socket提供了專門的讀寫資料函式

TCP讀寫(recv & send)

#include<sys/socket.h>
// recv成功時返回讀取到的長度,實際長度可能小於len
// 發生錯誤返回-1設定errno,返回0表示連線關閉
ssize_t recv(int sockfd, void*buf, size_t len, int flags);

// 成功時返回寫入的資料的長度,失敗返回-1這是errno
ssize_t send(int sockfd, const void*buf, size_t len, int flags);

flags提供了一些選項設定:

  • MSG_OOB(recv&send):傳送或接收緊急資料,也叫帶外資料,在傳輸層的七七八八中首部資訊中有說,在URG標誌位1時該欄位有效,seq + Urgen Pointer - 1的這一個位元組是緊急資料(緊急資料只有一個位元組),例如:
char buffer[1024];
memset(buffer, '\0', 1024);

// 傳送端傳送帶外資料hello
const char *oob_data = "hello";
send(sockfd, oob_data, strlen(oob_data), MSG_OOB);

ret = recv(connfd, buffer, BUFESIZE - 1, 0);
// 接收到hell
ret = recv(connfd, buffer, BUFESIZE - 1, MSG_OOB); // 接收端接收帶外資料
// 接收到o

hell為正常資料,o為帶外資料,只有最後一個位元組會被認為是帶外資料,前面的是正常資料。正常資料的接收會被帶外資料截斷。

  • int sockatmark(int sockfd);可以判斷下一個資料是不是帶外資料,1為是,此時可以利用MSG_OOB標誌的recv呼叫來接收帶外資料。
  • 透過SIGUSR訊號觸發對帶外資料的處理
  • MSG_DONTWAIT(recv&send):對socket的此次send或recv是非阻塞操作(相當於使用O_NONBLOCK)
  • MSG_WAITALL(recv):一直讀取到請求的資料全部返回後recv函式返回

UDP讀寫(recvfrom & sendto)

通常這兩個函式用於無連線的套接字,如果用於有連線的讀寫可以把後兩位置為NULL

#include <sys/socket.h>
// 可以接收UDP,也可以接收TCP(後兩個引數置位NULL,因為TCP是面向連線的)
ssize_t recvfrom(int sockfd,void* buf,size_t len,int flags,
				struct sockaddr* src_addr,socklen_t* addrlen);
// 可以接收UDP,也可以接收TCP(後兩個引數置位NULL,因為TCP是面向連線的)
ssize_t sendto(int sockfd,const void* buf,size_t len,int flags,
				const struct sockaddr* dest_addr,socklen_t addrlen);

更高階的讀寫(recvmsg & sendmsg)

使用sendmsg可以將多個緩衝區的資料合併傳送
使用recvmsg可以將接收的資料送入多個緩衝區,或者接收輔助資料

#include<sys/socket.h>
ssize_t recvmsg(int sockfd,struct msghdr* msg,int flags);
ssize_t sendmsg(int sockfd,struct msghdr* msg,int flags);

msghdr結構

struct msghdr
{
    void* msg_name; // socket地址,如果是流資料,設定為NULL
    socklen_t msg_namelen; // 地址長度
    struct iovec* msg_iov; // I/O快取區陣列,分散的緩衝區
    int msg_iovlen; // I/O快取區陣列元素數量
    void* msg_control; // 輔助資料起始位置
    socklen_t msg_controllen; // 輔助資料位元組數
    int msg_flags; // 等於recvmsg和sendmsg的flags引數,在呼叫過程中更新
};

輔助函式

獲取地址

#include<sys/socket.h>
// 獲取socketfd本端的地址資訊,存到address,如果address長度大於address_len,將被截斷
int getsockname(int sockfd,struct sockaddr*address,socklen_t*address_len);

// 獲取socketfd遠端的地址資訊
int getpeername(int sockfd,struct sockaddr*address,socklen_t*address_len);

成功返回0,失敗返回-1設定errno

socketfd屬性設定,option

#include<sys/socket.h>
int getsockopt(int sockfd,int level,int option_name,
				void*option_value,socklen_t*restrict option_len);
int setsockopt(int sockfd,int level,int option_name,
				const void*option_value,socklen_t option_len);

成功返回0,失敗返回-1設定errno,記錄一下option_name,後面用到結合具體例項分析

gethostbyname & gethostbyaddr

根據主機名稱獲取主機的完整資訊、根據地址獲取主機的完整資訊,資訊返回結構如下:

#include<netdb.h>
struct hostent
{
    char* h_name; /*主機名*/
    char** h_aliases; /*主機別名列表,可能有多個*/
    int h_addrtype; /*地址型別(地址族)*/
    int h_length; /*地址長度*/
    char** h_addr_list /*按網路位元組序列出的主機IP地址列表*/
};

getservbyname & getservbyport

根據服務名稱或埠號獲取服務資訊,從/etc/services獲取資訊,該檔案中存放的是知名埠號和協議等資訊。返回結構體如下:

#include<netdb.h>
struct servent
{
    char* s_name; /*服務名稱*/
    char** s_aliases; /*服務的別名列表,可能有多個*/
    int s_port; /*埠號*/
    char* s_proto; /*服務型別,通常是tcp或者udp*/
};

getaddrinfo

可以認為是呼叫了gethostbyname和getservbyname

#include<netdb.h>
// hostname:可以是主機名或IP地址字串
// service:可以接收服務名,也可以接收十進位制埠號
// result指向返回結果的連結串列,結構為addrinfo
int getaddrinfo(const char* hostname,const char* service,const
struct addrinfo* hints,struct addrinfo** result);

addrinfo結構體:

struct addrinfo
{
int ai_flags; /*大部分設定hints引數*/
int ai_family; /*地址族*/
int ai_socktype; /*服務型別,SOCK_STREAM或SOCK_DGRAM*/
int ai_protocol; /*通常設定為0*/
socklen_t ai_addrlen; /*socket地址ai_addr的長度*/
char* ai_canonname; /*主機的別名*/
struct sockaddr* ai_addr; /*指向socket地址*/
struct addrinfo* ai_next; /*指向下一個sockinfo結構的物件*/
};

getaddrinfo結束後,釋放result分配的堆記憶體

void freeaddrinfo(struct addrinfo* res);

getnameinfo

可以認為是呼叫了gethostbyaddr和getservbyport

#include<netdb.h>
// 返回的主機名儲存在host,服務名儲存在serv
int getnameinfo(const struct sockaddr *sockaddr,socklen_t addrlen,
	char* host,socklen_t hostlen,char *serv,socklen_t servlen,int flags);
gai_strerror

轉換getnameinfo和getaddrinfo返回的錯誤碼為可讀的字串

#include<netdb.h>
const char* gai_strerror(int error);

getaddrinfo和getnameinfo返回的錯誤碼如下:

簡單示例

testserver.cc,testserver 0.0.0.0 8889

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

#include <cassert>
#include <cstdio>
#include <iostream>
using namespace std;

int main(int argc, char *argv[]) {
  if (argc <= 2) {
    cout << "usage:" << argv[0] << " ip_address port_number" << endl;
    return 0;
  }

  const char *ip = argv[1];
  int port = atoi(argv[2]);
  struct sockaddr_in address, client_addr;
  address.sin_family = AF_INET;
  address.sin_port = htons(port);
  inet_pton(AF_INET, ip, &address.sin_addr);
  int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  int ret = bind(sockfd, (struct sockaddr *)&address, sizeof(address));
  assert(ret != -1);
  ret = listen(sockfd, 2);
  assert(ret != -1);
  socklen_t client_addr_length = sizeof(client_addr);
  int conn =
      accept(sockfd, (struct sockaddr *)&client_addr, &client_addr_length);
  if (conn < 0)
    cout << "connect error: " << errno << endl;
  else {
    string hello = "hello client";
    send(conn, hello.data(), sizeof(hello), 0);
    close(conn);
  }
  close(sockfd);
  return 0;
}

testclient.cc,/etc/hosts加入server的地址和主機名,testclient myserver

#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cassert>
#include <iostream>

using namespace std;

int main(int argc, char* argv[]) {
  if (argc != 2) {
    cout << "usage: " << argv[0] << " hostname" << endl;
    return 0;
  }
  char* hostname = argv[1];
  // 獲取主機資訊
  struct hostent* hostinfo = gethostbyname(hostname);
  assert(hostinfo);

  /*
    獲取server返回資訊,自定義一個服務,
    編輯/etc/services, my	        8889/tcp
  */
  struct servent* servinfo = getservbyname("my", "tcp");
  assert(servinfo);
  cout << "myserver port is " << ntohs(servinfo->s_port) << endl;
  struct sockaddr_in address;
  address.sin_family = AF_INET;
  address.sin_port = servinfo->s_port;

  address.sin_addr = *(struct in_addr*)*hostinfo->h_addr_list;
  int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  int result = connect(sockfd, (struct sockaddr*)&address, sizeof(address));
  assert(result != -1);
  char buffer[128];
  result = recv(sockfd, buffer, sizeof(buffer), 0);
  cout << "resceived: " << result << endl;
  assert(result > 0);
  buffer[result] = '\0';
  cout << "server's message: " << buffer << endl;
  close(sockfd);
  return 0;
}

學習自:
《Linux高效能伺服器程式設計》
《UNIX環境高階程式設計》
《UNIX系統程式設計》

相關文章