JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

位址、連接埠與位元組順序

第 1 篇給了你通訊端的概念。現在我們補上一條真實連線所需要的三個小卻不容差錯的細節:哪一台機器(IP 位址)、上面的哪一支程式(連接埠),以及多位元組的數字在線路上必須怎麼擺放(網路位元組順序)。把這些一次弄對,你接下來寫的客戶端與伺服器就會直接動起來。

位址點名一台機器;連接埠點名一支程式

從第 1 篇你已經知道通訊端是一個端點,也知道 TCP/IP 模型把責任分層堆疊起來。現在我們把這個端點精確地點名。兩個數字就做到了:一個IP 位址,從一個網路上所有可達的機器裡挑出一台;以及一個連接埠,從那台機器上跑著的眾多程式裡挑出一支。把這個 位址與連接埠想成一個郵政地址再加一個門牌號碼:街道地址讓信送到正確的大樓,而門牌號碼讓它送到裡頭正確的那扇門。單獨任何一個都不夠——一個只帶著 IP 的封包會抵達那台機器,卻不知道哪一支程式該收下它。

一個 IPv4 位址是 32 位元——四個位元組——通常寫成像 93.184.216.34 這樣的點分十進位形式,其中四個部分各是一個範圍 0 到 255 的位元組。所以 93.184.216.34 其實就是四位元組的序列 0x5d 0xb8 0xd8 0x22。一個連接埠是一個 16 位元的數字,一個從 0 到 65535 的無號值,這正是為什麼你把它存進一個 uint16_t、也是為什麼這個型別是無號的。1024 以下的低位連接埠(也就是 HTTP 的 80、ssh 的 22、HTTPS 的 443)是保留給標準服務的眾所周知連接埠,通常需要權限才能 bind;高位連接埠則任你自己的程式自由取用。

為什麼位元組順序會存在

這裡是每個初學者都會被絆倒一次的陷阱。一個像 0x1234 的連接埠塞不進單一個位元組;它是一個兩位元組的數字,而記憶體是一個扁平的位元組陣列,所以機器必須決定哪個位元組排在前面。這個選擇叫做位元組順序(endianness),你早在資料表示那個章節就認識了這個概念。一台小端序(little-endian)機器(x86、如今多數的 ARM)把最低有效位元組排在前面,所以 0x1234 落在記憶體裡是位元組 0x34 接著 0x12。一台大端序(big-endian)機器把最高有效位元組排在前面:0x12 接著 0x34。同一個數字,兩種不同的位元組排法。

在一台機器內部這從來不要緊——每一次讀和寫都用同一套慣例,所以這個排法對你是看不見的。但網路把可能彼此不一致的機器連了起來。如果一台小端序的筆電把連接埠 0x1234 的原始兩個位元組以 0x34 0x12 送出,而一台大端序的伺服器以它自己的順序讀那同樣兩個位元組,它看到的是 0x3412——一個完全不同的連接埠。兩台各自獨立運作都完美無缺的電腦會講不上話,不是因為哪一台有臭蟲,而是因為它們從未對位元組順序達成共識。線路需要一套固定的慣例,讓雙方無論自己內部的習慣為何都遵守。

網路位元組順序,以及帶你抵達那裡的四個函式

達成共識的慣例很簡單:網路位元組順序是大端序。每一個在 IP 或 TCP 標頭裡旅行的多位元組值——位址、連接埠、長度——依定義都以最高有效位元組為先送出。你機器自己的排法叫做主機位元組順序,在 x86 上是小端序、因此與網路不一致。所以你必須在邊界上轉換:內部用主機順序,線路上用網路順序。標準函式庫剛好給你四個轉換函式,用一套整齊的命名法——h 代表 host(主機)、n 代表 network(網路)、s 代表 short(16 位元)、l 代表 long(32 位元):

htons(x)   host  -> network,  16-bit   (use for a PORT)
htonl(x)   host  -> network,  32-bit   (use for an IPv4 ADDRESS)
ntohs(x)   network -> host,    16-bit   (a port you just received)
ntohl(x)   network -> host,    32-bit   (an address you just received)

/* read the name left to right: h-to-n-s = host to network, short */
四個位元組順序函式。在一台大端序主機上它們什麼都不做(主機已等於網路);在一台小端序主機上它們交換位元組。關鍵是,無論哪一種情況你的程式碼都一樣——這正是呼叫它們的重點。

一旦你看懂了,這套紀律就是機械式的。任何你放進一個 sockaddr 結構的連接埠或位址,先經過 htons() 或 htonl();任何你從一個收自網路的結構裡讀出來的連接埠或位址,經過 ntohs() 或 ntohl()。在一個你忘了轉換的連接埠上呼叫 htons(),是初學者最常見的單一網路臭蟲:連線被拒、或落到了錯的服務上,而其他一切看起來都沒問題。在你寫下指派的當下就把這個呼叫寫好,你就永遠不必為它除錯。

一步步填好一個 sockaddr_in

這一切在一個小結構裡匯聚起來。對 IPv4 而言,核心要的是一個 struct sockaddr_in,它把三樣東西捆在一起:位址族、連接埠(網路順序),以及 32 位元的 IPv4 位址(同樣是網路順序)。你把它填好,然後把它的位址交給伺服器端的 bind() 或客戶端的 connect()。這個 通訊端位址就是這個結構:位址族欄位說「這是 IPv4」,而另外兩個欄位就是我們一直在朝它建構的「在哪裡」與「是哪個」。

  1. 先把整個結構歸零:memset(&addr, 0, sizeof(addr))。這會清掉填補位元組與任何你沒明確設定的欄位,使得沒有殘留的舊位元組外洩進核心。
  2. 設定位址族:addr.sin_family = AF_INET。這就是告訴核心把其餘部分當成一個 IPv4 位址、而非 IPv6 或 Unix 通訊端來讀。
  3. 透過 htons 設定連接埠:addr.sin_port = htons(8080)。在這裡忘了 htons 就是那個經典臭蟲——在 x86 上你的連接埠會悄悄變成位元組被交換過的。
  4. 設定位址:用 inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) 把一個點分十進位字串解析進那個以網路順序排列的欄位,並檢查它回傳 1。

兩個方便的捷徑值得點名。位址 0.0.0.0,寫成常數 INADDR_ANY,意思是「綁定到這台機器擁有的每一個網路介面」——伺服器用它來接受抵達它任何一個位址的連線。而 127.0.0.1 是回送位址,一個虛擬介面,直接繞回同一台機器、不碰任何真實的網路硬體;這就是你在一台筆電上同時跑一個客戶端與伺服器來測試的方式,而那正是接下來兩篇要做的事。INADDR_ANY 本身是一個數字,當你指派它時,仍然要經過 htonl() 才能在每一台主機上都正確。

名字、數字,以及接下來是什麼

還剩一個誠實的缺口:人類打的是像 example.com 的名字,不是像 93.184.216.34 的數字。從名字到位址的翻譯由 DNS(網域名稱系統)完成,那是一個網路替你查詢的分散式目錄。在程式碼裡你透過 getaddrinfo() 觸及它,它接收一個主機名稱與一個服務(一個連接埠、或像 "http" 的名字),回交給你一個現成的 sockaddr,替你做好 IPv4 或 IPv6 的判斷與位元組順序的工作。它是現代、推薦的正門;我們剛剛手填的那個 sockaddr_in,正是 getaddrinfo() 在底下產出的東西,而親手見過它一次,正是為什麼那個自動版本永遠不會讓你覺得像魔法。

注意這一切都不專屬於 TCP。無論你開的是一條可靠的串流、還是發射出去的資料包,都需要一個位址、一個連接埠與網路位元組順序——TCP 相對於 UDP的選擇改變的是位元組怎麼流動,而非你怎麼點名端點。所以本篇裡的一切都是基礎工,你在第 5 篇的 UDP 範例裡會重用它,就跟在緊接著的 TCP 工作裡一樣多。定址這一層坐落在傳輸選擇的底下。

你現在有了一條連線據以建構的詞彙:一個 IP 位址與連接埠來點名一個端點、位元組順序的問題以及為什麼網路位元組順序解決了它、htons/htonl/ntohs/ntohl 這個四重奏,還有一個填好、可以交給核心的 sockaddr_in。第 3 篇〈寫一個 TCP 客戶端〉把這個結構直接帶進 connect()、開啟你第一條真正的連線;第 4 篇把它反過來變成一個聆聽的伺服器。那個困難、又容易漏掉的細節——位元組順序——現在已經被你拋在身後,所以那幾篇可以聚焦在資料的流動上,而不是某一個數字的擺放上。