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)結尾。