Socket 是透過作業系統進行網路通訊的標準方式,可想成一條連線的端點,像總機接線員交換機上的插孔。

Socket 抽象了 OSI 的繁瑣細節#

對程式設計師而言,socket 就是拿來在網路上收送資料的東西——它把上一節那些 OSI 細節都打理好了。資料在會議層(第 5 層)傳輸,其下的較低層由作業系統處理路由。

依傳輸層(第 4 層)結構的不同,socket 有幾種型別,最常見的是兩種:

型別比喻協定特性
串流 socket(stream socket)打電話TCP可靠的雙向通訊,封包無誤且依序抵達,並即時確認送達
資料報 socket(datagram socket)寄信UDP單向、不可靠,無法保證順序或送達

網頁伺服器、郵件伺服器及其用戶端都用 TCP 與串流 socket。UDP(User Datagram Protocol)則很基本輕量、幾乎沒有內建保障——它不是真正的連線,只是把資料從一點送到另一點的基本方法。若程式需要確認對方收到封包,對方就得自行回傳確認封包。網路遊戲與串流媒體常用資料報 socket 與 UDP,因為開發者能省去 TCP 的內建開銷、按需求量身打造通訊。

Socket 函式#

在 C 中,socket 的行為很像檔案——它們也用檔案描述元識別自己,甚至可以直接用 read()write() 收送資料。但也有專為 socket 設計的函式(原型定義於 /usr/include/sys/sockets.h):

函式作用
socket(domain, type, protocol)建立新 socket,回傳檔案描述元、錯誤回傳 -1
connect(fd, remote_host, addr_length)把 socket 連到遠端主機
bind(fd, local_addr, addr_length)把 socket 綁到本地位址以監聽連入連線
listen(fd, backlog_queue_size)監聽連入連線,將請求排入 backlog 佇列
accept(fd, remote_host, addr_length)接受已綁定 socket 上的連入連線,回傳新的 socket 檔案描述元
send(fd, buffer, n, flags)從 buffer 送出 n 位元組
recv(fd, buffer, n, flags)接收 n 位元組進 buffer

建立 socket 時要指定三個參數:

  • domain(網域):socket 的協定家族。定義於 bits/socket.h,如 PF_INET(IP 協定家族)、PF_INET6(IPv6)、PF_AX25(業餘無線電)等。
  • type(型別):如 SOCK_STREAM(串流)、SOCK_DGRAM(資料報)。
  • protocol(協定)幾乎總是 0——多數協定家族只有一個協定,本書所有例子都設 0。

Socket 位址#

許多 socket 函式引用 sockaddr 結構來傳遞定義主機的位址資訊。

struct sockaddr {
   __SOCKADDR_COMMON (sa_); /* 位址家族與長度 */
   char sa_data[14];        /* 位址資料 */
};

由於 socket 能用多種協定家族溝通、每種家族定義端點位址的方式不同,位址的定義也必須隨位址家族變動。因此有若干大小相同的位址結構——它們可互相轉型,socket() 只需接受一個 sockaddr 指標,實際可指向 IPv4、IPv6 或 X.25 的位址結構。

本書使用 IPv4,即協定家族 PF_INET、位址家族 AF_INET,對應的位址結構是 sockaddr_in

struct sockaddr_in {
   __SOCKADDR_COMMON (sin_);  /* 位址家族 */
   in_port_t sin_port;        /* 連接埠號(16 位元 short) */
   struct in_addr sin_addr;   /* 網際網路位址(32 位元) */
   unsigned char sin_zero[8]; /* 填補到與 sockaddr 同大小 */
};

結尾的 8 位元組填補不做任何事,但必須保留,好讓這些結構能互相轉型。

圖 0x400-2:sockaddr 與 sockaddr_in 位址結構大小相同,可互相轉型

網路位元組順序#

函式作用
htonl(value)Host-to-Network Long:32 位元整數,主機序 → 網路序
htons(value)Host-to-Network Short:16 位元整數,主機序 → 網路序
ntohl(value)Network-to-Host Long
ntohs(value)Network-to-Host Short

網際網路位址轉換#

12.110.110.204 這種點分數字表示法,有函式可與網路位元組順序的 32 位元整數互轉(定義於 arpa/inet.h):

  • inet_aton()(ASCII to Network):把點分格式的字串轉成 in_addr 結構。
  • inet_ntoa()(Network to ASCII):反向轉換,回傳點分格式字串。此字串存於函式內靜態配置的緩衝區,會在下次呼叫時被覆寫。

一個簡單的伺服器#

以下伺服器在連接埠 7890 監聽 TCP 連線,客戶端連上後送出 Hello, world!,再持續接收資料直到連線關閉:

// simple_server.c 核心流程
if ((sockfd = socket(PF_INET, SOCK_STREAM, 0)) == -1)  // 建立 socket
   fatal("in socket");

// SO_REUSEADDR:允許重用位址綁定,即使該埠看似仍被占用
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int));

host_addr.sin_family = AF_INET;
host_addr.sin_port = htons(PORT);   // 轉成網路位元組順序
host_addr.sin_addr.s_addr = 0;      // 自動填入本機 IP
memset(&(host_addr.sin_zero), '\0', 8);

bind(sockfd, (struct sockaddr *)&host_addr, sizeof(struct sockaddr));
listen(sockfd, 5);                  // backlog 佇列上限 5

while(1) {   // 接受迴圈
   new_sockfd = accept(sockfd, (struct sockaddr *)&client_addr, &sin_size);
   printf("server: got connection from %s port %d\n",
          inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
   send(new_sockfd, "Hello, world!\n", 13, 0);
   recv_length = recv(new_sockfd, &buffer, 1024, 0);
   while(recv_length > 0) {
      printf("RECV: %d bytes\n", recv_length);
      dump(buffer, recv_length);
      recv_length = recv(new_sockfd, &buffer, 1024, 0);
   }
   close(new_sockfd);
}

用 telnet 當通用 TCP 用戶端即可連上測試:伺服器送出 Hello, world!,並印出收到的每個資料封包(含 telnet 每行結尾送出的 0x0D 0x0A)。

一個 Web 用戶端#

telnet 已能當我們伺服器的用戶端,但世上有數千種接受標準 TCP/IP 連線的伺服器。每次用瀏覽器,它都用 HTTP 連上某個網頁伺服器(預設埠 80,列於 /etc/services)。

HTTP 位於 OSI 模型最頂的應用層。此層的網路細節都已由低層處理完畢,所以 HTTP 用純文字構成——POP3、SMTP、IMAP、FTP 控制通道等許多應用層協定也是。既然是標準協定,它們都有完整文件、容易研究。知道語法後,你就能手動與說同種語言的程式交談。

HTTP 中,請求用 GET 命令加資源路徑與協定版本(如 GET / HTTP/1.0)。用 HEAD 取代 GET 則只回傳標頭、不回內容。用 telnet 手動連上埠 80 就能取得這些標頭:

reader@hacking:~/booksrc $ telnet www.internic.net 80
HEAD / HTTP/1.0

HTTP/1.1 200 OK
Date: Fri, 14 Sep 2007 05:34:14 GMT
Server: Apache/2.0.52 (CentOS)
...

這揭露了伺服器是 Apache 2.0.52、主機跑 CentOS——對於刺探(profiling)目標很有用。這正是可以自動化的手動流程。

由於接下來的程式要收送大量資料,而標準 socket 函式不太友善,書中把 send_string()recv_line() 兩個輔助函式放進 hacking-network.h

  • send() 回傳的實際寫出位元組數未必等於你想送的量,send_string()strlen() 確保整個字串都送出。
  • HTTP 與 telnet 一樣,預期每行以 0x0D(歸位 \r)與 0x0A(換行 \n)結尾。