分配IP地址和端口號
IP是Internet Protocol(網絡協議)的簡寫,是為收發網絡數據而分配給計算機的值。端口號並非賦予計算機的值,而是為區分程序中創建的套接字而分配給套接字的序號
網絡地址(Internet Address)
為使計算機連接到網絡並收發數據,必須向其分配IP地址。IP地址分為兩類:
- IPv4(Internet Protocol version 4):4字節地址族
- IPv6(Internet Protocol version 6):16字節地址族
IPv4和IPv6的差別主要是IP地址所用的字節數,目前通用的地址族為IPv4,IPv6是為了應對2010年前后IP地址耗盡的問題而提出的標准,即便如此,現在還是主要使用IPv4,IPv6的普及需要更長的時間
IPv4標准的4字節IP地址分為網絡地址和主機(計算機)地址,且分為A、B、C、D、E等類型,圖1-1展示了IPv4地址族,一般不會使用已被預約的E類地址,故省略

圖1-1 IPv4地址族
網絡地址(網絡ID)是為區分網絡而設置的一部分IP地址。假設向WWW.SEMI.COM公司傳輸數據,該公司內部構建了局域網,把所有計算機連起來。因此,首先應向SEMI.COM網絡傳輸數據,也就是說,並非一開始就瀏覽所有4字節IP地址,進而找到目標主機;而是僅瀏覽4字節IP地址的網絡地址,先把數據送到SEMI.COM網絡,SEMI.COM網絡(構成網絡的路由器)接收到數據后,瀏覽傳輸數據的主機地址(主機ID)並將數據傳給目標主機。圖1-2展示了數據傳輸過程

圖1-2 基於IP地址的數據傳輸過程
某主機向203.211.172.103和203.211.217.202傳輸數據,其中203.211.172和203.211.217為該網絡的網絡地址。所以,“向相應網絡傳輸數據”實際上是向構成網絡的路由器(Route)或交換機(Switch)傳遞數據,由接收數據的路由器根據數據中的主機地址向目標主機傳遞數據
若想構建網絡,需要一種物理設備完成外網與本網主機之間的數據交換,這種設備便是路由器或交換機。它們實際上也是一種計算機,只不過是為特殊目的而設計運行的,因此有了別名。所以,如果在我們使用的計算機上安裝適當的軟件,也可以將其作為交換機。另外,交換機比路由器功能要簡單一些,但實際用途差別不大
網絡地址分類與主機地址邊界
只需通過IP地址的第一個字節即可判斷網絡地址占用的字節數,因為我們根據IP地址的邊界區分網絡地址,如下所示:
- A類地址的首字節范圍:0~127
- B類地址的首字節范圍:128~191
- C類地址的首字節范圍:192~223
還有如下這種表述方式:
- A類地址的首位以0開始,0000 0000為0,0111 111為127,與上面的0~127對應
- B類地址的前2位以10開始,1000 0000為128,1011 1111為191,與上面的128~191對應
- C類地址的前3位以110開始,1100 0000為192,1101 1111為223,與上面的192~223對應
正因如此,通過套接字收發數據時,數據傳到網絡后即可輕松找到正確主機
用於區分套接字的端口號
IP用於區分計算機,只要有IP地址就能找到目標主機,但僅憑這些無法傳輸給目標主機中的應用程序,畢竟處理數據靠的還是目標主機中的程序。假設用戶在上網的同時,一邊欣賞視頻,一邊瀏覽網頁,這里至少需要兩個套接字,一個接收視頻數據,一個接收網頁數據,那么問題來了,怎么區分這兩個套接字呢?或者說,怎么區分到達的數據是正在觀看的視頻,還是正在瀏覽的網頁呢?這里就需要用到端口號了
計算機中一般配有NIC(Network Interface Card,網絡接口卡)數據傳輸設備。通過NIC向計算機內部傳輸數據時會用到IP,操作系統負責把傳遞到內部的數據適配給套接字,這時就要利用端口號了。也就是說,通過NIC接收的數據內有端口號,操作系統正是參考此端口號把數據傳輸給相應端口的套接字,如圖1-3所示

圖1-3 數據分配過程
端口號就是同一操作系統內區分不同套接字而設置的,因此無法將一個端口號分配給不同套接字。另外,端口號由16位構成,可分配的端口號范圍是0~65535。但0~1023是知名端口(Well-known PORT),一般分配給特定應用程序,所以應當分配此范圍之外的端口。另外,雖然端口號不能重復,但TCP套接字和UDP套接字不會共用端口號,所以允許重復。例如:如果某TCP套接字使用8500號端口,則其他TCP套接字就無法使用該端口號,但UDP套接字可以使用
總之,數據傳輸目標地址同時包含IP地址和端口號,只有這樣,數據才會被傳輸到最終的目的應用程序
地址信息的表示
應用程序中使用IP地址和端口號以結構體的形式給出了定義。這里將以IPv4為中心,圍繞此結構體討論目標地址的表示方法
struct sockaddr_in
{
short sin_family; //協議族Address family
unsigned short sin_port; //16位TCP/UDP端口號
struct in_addr sin_addr; //32位IP地址
unsigned char sin_zero[8]; //沒有實際意義,只是為了跟SOCKADDR結構在內存中對齊
};
該結構體中提到另一結構體in_addr定義如下,它用來存放32位IP地址
struct in_addr
{
in_addr_t s_addr; //32位IPv4地址
};
講解以上兩個結構體前觀察一些數據類型。uint16_t、int_addr_t等類型可以參考POSIX(Portable Operating System Interface,可移植操作系統接口)。POSIX是為Unix系列操作系統設立的標准,它定義了一些其他數據類型,如表1-1
| 數據類型名稱 | 數據類型說明 | 聲明的頭文件 |
| int8_t | signed 8-bit int | sys/types.h |
| uint8_t | unsigned 8-bit int(unsigned char) | |
| int16_t | signed 16-bit int | |
| uint16_t | unsigned 16-bit int(unsigned short) | |
| int32_t | signed 32-bit int | |
| uint32_t | unsigned 32-bit int(unsigned long) | |
| sa_family_t | 地址族(address family) | sys/socket.h |
| socklen_t | 長度(length of struct) | |
| in_addr_t | IP地址,聲明為uint32_t | netinet/in.h |
| in_port_t | 端口號,聲明為uint16_t |
從這些數據類型聲明也可掌握之前結構體的含義,那為什么需要額外定義這些數據類型呢?這是考慮到擴展性的結果。如果使用int32_t類型的數據,就能保證在任何時候都占用4個字節,即時將來使用64位表示int類型也是如此
結構體sockaddr_in的成員分析
成員sin_family:
每種協議適用的地址族均不同。比如,IPv4使用4字節地址族,IPv6使用16字節地址族,可以參考表1-2保存的sin_family地址信息
| 地址族(Adddress Family) | 含義 |
| AF_INET | IPv4網絡協議中使用的地址族 |
| AF_INET6 | IPv6網絡協議中使用的地址族 |
| AF_LOCAL | 本地通信中采用的Unix協議的地址族 |
AF_LOCAL只是為了說明具有多種地址族而添加的
成員sin_port:
該成員保存16位端口號,且以網絡字節序保存(后續還會說明何為網絡字節序)
成員sin_addr:
該成員保存32位IP地址信息,且也以網絡字節序保存。為理解好該成員,應同時觀察結構體in_addr。但結構體in_addr聲明為uint32_t,因此只需當做32位整數即可
成員sin_zero:
無特殊含義,只是為了結構體sockaddr_in的大小與sockaddr結構體保持一致而插入的成員。必須填充為0,否則無法得到想要的結果,后續還會介紹sockaddr
從之前介紹的代碼也可看出,sockaddr_in結構體變量地址將以如下方式傳遞給bind函數,后續還會介紹到bind函數,現在來看下下面參數傳遞和類型轉換的代碼
struct sockaddr_in serv_addr;
……
if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");
……
此處重要的是第二個參數的傳遞,實際上,bind函數的第二個參數期望得到sockaddr結構體變量地址值,包括地址族、端口號、IP地址等。從下列代碼也可看出,直接向sockaddr結構體填充這些信息會帶來麻煩
struct sockaddr
{
unsigned short sa_family; //地址族(Address Family)
char sa_data[14]; //地址信息
};
此結構體成員要求sa_data保存的信息需包含IP地址和端口號,剩余部分應填充0,這也是bind函數要求的。而這對於包含地址信息來講非常麻煩,繼而就有了新的結構體sockaddr_in。若按照之前的講解填寫sockaddr_in結構體,則將生成符合bind函數要求的字節流。最后轉換為sockaddr型的結構體變量,再傳遞給bind函數即可
sockaddr_in是保存IPv4地址信息的結構體,那為何還需要通過sin_family單獨指定地址族信息呢?這還是與sockaddr結構體有關,結構體sockaddr並非只為IPv4設計,這從保存地址信息的數組sa_data長度為14字節也可看出。因此,結構體sockaddr要求在sin_family中指定地址族信息,是為了與sockaddr保持一致,sockaddr_in結構體中也有地址族信息
網絡字節序與地址變換
不同CPU中,4字節整數值1在內存空間的保存方式是不同的。4字節整數型值1可用二進制表示如下:
00000000 00000000 00000000 00000001
有些CPU以這種順序保存到內存,另一些CPU則以倒序保存
00000001 00000000 00000000 00000000
若不考慮這些就收發數據則會發生問題,因為保存順序的不同意味着對接收數據的解析順序也不同
字節序(Order)與網絡字節序
CPU向內存保存數據的方式有兩種,這意味着CPU解析數據的方式也有兩種:
- 大端序:高位字節存放到低位地址
- 小端序:高位字節存放到高位地址
用下面的例子進行說明,假設在0x20號開始的地址中保存4字節int類型數0x12345678,大端序CPU保存如圖1-4所示:

圖1-4 大端序字節表示
整數0x12345678,0x12是最高位字節,0x78是最低位字節。因此,大端序先保存最高位字節0x12(最高位字節0x12存放到低位地址),小端序保存方式如圖1-5所示:

圖1-5 小端序字節表示
先保存的是最低位字節0x78,從以上分析可以看出,每種CPU的數據保存方式均不同。因此,代表CPU數據保存方式的主機字節序(Host Byte Order)在不同CPU中也各不相同。目前主流的Intel系列CPU以小端序方式保存數據。那么,如果兩台字節序不同的計算機之間交換數據,勢必會出現這樣的問題,大端序計算機傳輸數據0x1234時未考慮字節序問題,直接以0x12、0x34的順序發送,結果接收端以小端序方式保存數據,因此小端序接收到的數據則變為0x3412,而非0x1234。正因如此,在通過網絡傳輸數據時約定統一使用大端序傳輸數據
字節序轉換
既然我們明白了在填充sockaddr_in結構體前將數據轉換成網絡字節序。接下來,我們就來了解一下關於轉換字節序的函數:
- unsigned short htons(unsigned short);
- unsigned short ntohs(unsigned short);
- unsigned long htonl(unsigned long);
- unsigned long ntohl(unsigned long);
htons中的h代表主機(host)字節序,n代表網絡(network)字節序。另外,s指的是short,l指的是long(Linux中long類型占用4個字節)。因此,htons是h、to、n、s的組合,也可以解釋為“把short型數據從主機字節序轉化為網絡字節序”。而ntohs可以解釋為“把short類型數據從網絡字節序轉化為主機字節序”。通常,以s作為后綴的函數中,s代表兩個字節short,因此用於端口號轉換,以l作為后綴的函數中,1代表4個字節,因此用於IP地址轉換
下面通過示例說明以上函數的調用過程
endian_conv.c
#include <stdio.h>
#include <arpa/inet.h>
int main(int argc, char *argv[])
{
unsigned short host_port = 0x1234;
unsigned short net_port;
unsigned long host_addr = 0x12345678;
unsigned long net_addr;
net_port = htons(host_port);
net_addr = htonl(host_addr);
printf("Host ordered port:%#x \n", host_port);
printf("Network ordered port:%#x \n", net_port);
printf("Host ordered address:%#lx \n", host_addr);
printf("Network ordered address:%#lx \n", net_addr);
return 0;
}
- 第6、8行:各保存2個字節、4個字節的數據。當然,若運行的CPU不同,則保存的字節序也不同
- 第11、12行:變量host_port、host_addr中的數據轉化為網絡字節序。若運行環境為小端序CPU,則改變之后的字節序保存
編譯並運行endian_conv.c
# gcc endian_conv.c -o endian_conv # ./endian_conv Host ordered port:0x1234 Network ordered port:0x3412 Host ordered address:0x12345678 Network ordered address:0x78563412
這就是小端序CPU中運行的結果。如果在大端序CPU中運行,則變量值不會改變
網絡地址的初始化與分配
前面已討論過網絡字節序列,接下來介紹bind函數為代表的結構體的應用
將字符串信息轉換為網絡字節序的整型
sockaddr_in中保存地址信息的成員為32位整數型,因此,為了分配IP地址,需要將其表示為32位整數型數據。這對於只熟悉字符串信息的我們並非易事,對於IP的表示,我們熟悉點分十進制法,而非整數型數據表示法。幸運的是,有個函數會幫我們將字符串形式的IP轉換為32位整數型數據
#include <arpa/inet.h> in_addr_t inet_addr(const char* strptr);//成功時返回32位大端序整數型值,失敗時返回INADDR_NONE
如果向該函數傳遞“211.214.107.99”的點分十進制格式的字符串,它會將其轉換為32位整數型數據並返回。當然,該整數型值滿足網絡字節序。另外,該函數的返回值類型in_addr_t在內部聲明為32位整數型。下面示例表示該函數的調用過程
inet_addr.c
#include <stdio.h>
#include <arpa/inet.h>
int main(int argc, char *argv[])
{
char *addr1 = "1.2.3.4";
char *addr2 = "1.2.3.256";
unsigned long conv_addr = inet_addr(addr1);
if (conv_addr == INADDR_NONE)
printf("Error occured!\n");
else
printf("Network ordered integer addr:%#lx\n", conv_addr);
conv_addr = inet_addr(addr2);
if (conv_addr == INADDR_NONE)
printf("Error occured!\n");
else
printf("Network ordered integer addr:%#lx\n", conv_addr);
return 0;
}
- 第6行:一個字節能表示的最大整數為255,也就是說,它是錯誤的IP地址。利用該錯誤地址檢驗inet_addr函數的錯誤檢測能力
- 第8、13行:通過運行結果驗證第8行的函數能正常調用,而第13行的函數調用出現異常
編譯並運行inet_addr.c
# ./inet_addr Network ordered integer addr:0x4030201 Error occured!
從運行結果上來看,inet_addr函數不僅可以把IP地址轉換為32位整數型,而且可以檢測無效的IP地址。另外,從輸出結果可以驗證確實轉換為網絡字節序
inet_aton函數與inet_addr函數在功能上完全相同,也將字符串形式IP地址轉換為32位網絡字節序整數並返回,只不過該函數利用了in_addr結構體
#include <arpa/inet.h> int inet_aton(const char *string, struct in_addr*addr);//成功時返回1,失敗時返回0
- string:含有需轉換的IP地址信息的字符串地址
- addr:將保存轉件結果的in_addr結構體變量的地址值
實際編程中若要調用inet_addr函數,需將轉換后的IP地址信息代入sockaddr_in結構體中聲明的in_addr結構體變量。而inet_aton函數則不需此過程,原因在於,若傳遞in_addr結構體變量地址值,函數會自動把結果填入該結構體變量。通過示例了解一下inet_aton函數調用過程
inet_aton.c
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
void error_handling(char *message);
int main(int argc, char *argv[])
{
char *addr = "127.232.124.79";
struct sockaddr_in addr_inet;
if (!inet_aton(addr, &addr_inet.sin_addr))
error_handling("Conversion error");
else
printf("Network ordered integer addr:%#x\n", addr_inet.sin_addr.s_addr);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
- 第9、10行:轉換后的IP地址信息需保存到sockaddr_in的in_addr型變量才有意義。因此,inet_aton函數的第二個參數要求得到in_addr型的變量地址值。這就省去了手動保存IP地址信息的過程
編譯並運行inet_aton.c
# gcc inet_aton.c -o inet_aton # ./inet_aton Network ordered integer addr:0x4f7ce87f
最后再介紹一個與inet_aton函數相反的函數,此函數可以把網絡字節序整數型IP轉換成我們熟悉的字符串形式
#include <arpa/inet.h> char *inet_ntoa(struct in_addr in);//成功時返回轉換的字符串地址值,失敗時返回-1
該函數將通過參數傳入的整數型IP地址轉換為字符串格式並返回。但調用時需小心 ,返回值類型為char指針,返回字符串地址意味着字符串已保存到內存空間了,但該函數未向程序員要求分配內存,而是在其函數內部申請內存並保存字符串。也就是說,調用完函數后,應立即將字符串復制到其他的內存空間。因為,若再次調用inet_ntoa函數,則有可能覆蓋之前保存的字符串信息。總之,再次調用inet_ntoa函數前返回的字符串地址值是有效的,若需長期保存,則應將字符串復制到其他內存空間。下面給出該函數調用示例:
inet_ntoa.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
int main(int argc, char *argv[])
{
struct sockaddr_in addr1, addr2;
char *str_ptr;
char str_arr[20];
addr1.sin_addr.s_addr = htonl(0x1020304);
addr2.sin_addr.s_addr = htonl(0x1010101);
str_ptr = inet_ntoa(addr1.sin_addr);
strcpy(str_arr, str_ptr);
printf("Dotted-Decimal notation1:%s\n", str_ptr);
inet_ntoa(addr2.sin_addr);
printf("Dotted-Decimal notation2:%s\n", str_ptr);
printf("Dotted-Decimal notation3:%s\n", str_arr);
return 0;
}
- 第15行:向inet_ntoa函數傳遞結構體變量addr1中的IP地址信息並調用該函數,返回字符串形式的IP地址
- 第16行:瀏覽並復制第15行中返回的IP地址信息
- 第19、20行:再次調用inet_ntoa函數。由此得出,第15行中返回的地址已覆蓋了新的IP地址字符串,可通過第20行的輸出結果進行驗證
- 第21行:第16行中復制了字符串,因此可以正確輸出第15行中返回的IP地址字符串
編譯運行inet_ntoa.c
# gcc inet_ntoa.c -o inet_ntoa # ./inet_ntoa Dotted-Decimal notation1:1.2.3.4 Dotted-Decimal notation2:1.1.1.1 Dotted-Decimal notation3:1.2.3.4
網絡地址初始化
結合前面所學的內容,現在介紹創建套接字過程中常見的網絡地址信息初始化方法
struct sockaddr_in addr; char *serv_ip = "211.217.168.13"; //聲明IP地址字符串 char *serv_port = "8500"; //聲明端口號字符串 memset(&addr, 0, sizeof(addr)); //結構體變量addr的所有成員初始化為0 addr.sin_family = AF_INET; //指定地址族 addr.sin_addr.s_addr = inet_addr(serv_ip); //基於字符串的IP地址初始化 addr.sin_port = htons(atoi(serv_port)); //基於字符串的端口號初始化
上述代碼中,memset函數將每一個字節初始化為同一個值:第一個參數為結構體變量addr的地址值,即初始化為addr,第二個參數為0,因此初始化為0;最后一個參數中傳入addr的長度。因此addr的所有字節均初始化為0。這么做是為了將sockaddr_in結構體成員sin_zero初始化為0,另外,最后一行代碼調用atoi函數把字符串類型的值轉換成整數型。總之,上面的代碼利用字符串格式的IP地址和端口號初始化了sockaddr_in結構體變量
另外,代碼中的IP地址和端口號采用了硬編碼,這並不是一個好的寫法,因為運行環境改變就得更改代碼。因此,我們運行示例main函數時傳入IP地址和端口號
請求方法不同意味着調用的函數也不同,服務器端的准備工作通過bind函數完成,而客戶端則通過connect函數完成。因此,函數調用前需要的地址值類型也不同,服務端聲明sockaddr_in結構體變量,將其初始化為賦予服務器端IP和套接字的端口號,然后調用bind函數;而客戶端則聲明sockaddr_in結構體,並初始化為要與之連接的服務器端套接字的IP和端口號,然后調用connect函數
INADDR_ANY
每次創建服務器端套接字都要輸入IP地址會有些繁瑣,此時可如下初始化地址信息
struct sockaddr_in addr; char *serv_port = "8500"; //聲明端口號字符串 memset(&addr, 0, sizeof(addr)); //結構體變量addr的所有成員初始化為0 addr.sin_family = AF_INET; //指定地址族 addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(atoi(serv_port)); //基於字符串的端口號初始化
與之前方式最大的區別在於,利用常數INADDR_ANY分配服務器端的IP地址。若是采用這種方式,則可自動獲取運行服務端的計算機IP地址,不必親自輸入。而且,若同一計算機中已分配多個IP地址(多宿主計算機),則只要端口號一致,就可以從不同IP地址接收數據。因此,服務端中優先考慮這種方式
向套接字分配網絡地址
既然已討論了sockaddr_in結構體的初始化方法,接下來就把初始化的地址信息分配給套接字,bind函數負責這項操作:
#include <sys/socket.h> int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);
- sockfd:要分配地址信息(IP地址和端口號)的套接字文件描述符
- myaddr:存有地址信息的結構體變量地址值
- addrlen:第二個結構體變量的長度
如果此函數調用成功,則將第二個參數指定的地址信息分配給第一個參數中的相應套接字。下面給出服務器端常見的套接字初始化過程:
int serv_sock; struct sockaddr_in serv_addr; char *serv_port = "9190"; /*創建服務器端套接字(監聽套接字)*/ serv_sock = socket(PF_INET, SOCK_STREAM, 0); /*地址信息初始化*/ memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADRR_ANY); serv_addr.sin_port = htons(atoi(serv_port)); /*分配地址信息*/ bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
服務端代碼結構以上,當然還有未顯示的異常處理代碼
