TCP連接中客戶端的端口號是如何確定的?

在 TCP 連接中,客戶端在發起連接請求前會先確定一個客戶端端口,然后用這個端口去和服務器端進行握手建立連接。那么在 Linux 上,客戶端的端口到底是如何被確定下來的呢?

事實上很多我們平時遇到的問題都和這個端口選擇過程相關,如果能深度理解這個過程,將有助于我們對這些問題的深刻理解。

  • Cannot assign requested address 報錯是怎么回事?
  • 一個客戶端端口可以同時用在兩條 TCP 連接上嗎?

還是讓我們借助一段簡單到只有兩句的代碼,從這個來講起!

int?main(){
?fd?=?socket(AF_INET,SOCK_STREAM,?0);
?connect(fd,?...);
?...
}

一、創建 socket

客戶端在發起連接的時候,需要事先創建一個 socket。在 c 語言中,就是調用 socket 函數,例如 socket(AF_INET,SOCK_STREAM, 0) 這句。

socket 函數執行完畢后,在用戶層視角我們是看到返回了一個文件描述符 fd。但在內核中其實是一套內核對象組合,大體結構如下。

從上圖我們看到,socket 在內核里并不是一個內核對象。而是包含 file、socket、sock 等多個相關內核對象構成,每個內核對象還定義了 ops 操作函數集合。在后面的內核源碼執行過程中,我們需要時不時回頭來看這些內核對象,這里先簡單了解一下就行。

這些內核對象都是在 socket 系統調用執行過程中創建出來的。為了避免喧賓奪主,這里只列出入口代碼,詳細過程就不展開介紹了。

//file:?net/socket.c
SYSCALL_DEFINE3(socket,?int,?family,?int,?type,?int,?protocol)
{
?//創建?socket、sock?等內核對象,并初始化
?sock_create(family,?type,?protocol,?&sock);

?//創建?file?內核對象,申請?fd
?sock_map_fd(sock,?flags?&?(O_CLOEXEC?|?O_NONBLOCK));

?......
}

二、connect 發起連接

接下來我們就進入到 connect 函數的執行過程中來。由于這個過程比較長,所以我們分成幾個小節來進行討論。

2.1 connect 調用鏈展開

當我們在客戶端機上調用 connect 函數的時候,事實上會進入到內核的系統調用源碼中進行執行。

//file:?net/socket.c
SYSCALL_DEFINE3(connect,?int,?fd,?struct?sockaddr?__user?*,?uservaddr,
??int,?addrlen)
{
?struct?socket?*sock;

?//根據用戶?fd?查找內核中的?socket?對象
?sock?=?sockfd_lookup_light(fd,?&err,?&fput_needed);

?//進行?connect
?err?=?sock->ops->connect(sock,?(struct?sockaddr?*)&address,?addrlen,
?????sock->file->f_flags);
?...
}

這段代碼首先根據用戶傳入的 fd(文件描述符)來查詢對應的 socket 內核對象。在第一節中我們看了 socket 內核對象結構,據此可以知道接下來 sock->ops->connect 其實調用的是 inet_stream_connect 函數。

//file:?ipv4/af_inet.c
int?inet_stream_connect(struct?socket?*sock,?...){?
?...
?__inet_stream_connect(sock,?uaddr,?addr_len,?flags);
}

int?__inet_stream_connect(struct?socket?*sock,?...)
{
?struct?sock?*sk?=?sock->sk;

?switch?(sock->state)?{
??case?SS_UNCONNECTED:
???err?=?sk->sk_prot->connect(sk,?uaddr,?addr_len);
???sock->state?=?SS_CONNECTING;
???break;
?}
?...
}

剛創建完畢的 socket 的狀態就是 SS_UNCONNECTED,所以在 __inet_stream_connect 中的 switch 判斷會進入到 case SS_UNCONNECTED 的處理邏輯中。

上述代碼中 sk 取的是 sock 對象。繼續回顧第一節中 socket 的內核數據結構圖,可以得知 sk->sk_prot->connect 實際上對應的是 tcp_v4_connect 方法。

我們來看 tcp_v4_connect 的代碼,它位于 net/ipv4/tcp_ipv4.c。

//file:?net/ipv4/tcp_ipv4.c
int?tcp_v4_connect(struct?sock?*sk,?struct?sockaddr?*uaddr,?int?addr_len){
?//設置?socket?狀態為?TCP_SYN_SENT
?tcp_set_state(sk,?TCP_SYN_SENT);

?//動態選擇一個端口
?err?=?inet_hash_connect(&tcp_death_row,?sk);

?//函數用來根據 sk 中的信息,構建一個完成的 syn 報文,并將它發送出去。
?err?=?tcp_connect(sk);
}

在 tcp_v4_connect 中我們終于看到了選擇端口的函數,那就是 inet_hash_connect。

2.2 選擇可用端口

我們找到 inet_hash_connect 的源碼,我們來看看到底端口是如何選擇出來的。

//file:net/ipv4/inet_hashtables.c
int?inet_hash_connect(struct?inet_timewait_death_row?*death_row,????????struct?sock?*sk){
?return?__inet_hash_connect(death_row,?sk,?inet_sk_port_offset(sk),
???__inet_check_established,?__inet_hash_nolisten);
}

這里需要提一下在調用 __inet_hash_connect 時傳入的兩個重要參數。

  • inet_sk_port_offset(sk):這個函數是根據要連接的目的 IP 和端口等信息生成一個隨機數。
  • __inet_check_established:檢查是否和現有 ESTABLISH 的連接是否沖突的時候用的函數

了解了這兩個參數后,讓我們進入 __inet_hash_connect。這個函數比較長,為了方便理解,我們先看前面這一段。

//file:net/ipv4/inet_hashtables.c
int?__inet_hash_connect(...)
{
?//是否綁定過端口
?const?unsigned?short?snum?=?inet_sk(sk)->inet_num;

?//獲取本地端口配置
?inet_get_local_port_range(&low,?&high);
??remaining?=?(high?-?low)?+?1;

?if?(!snum)?{
??//遍歷查找
??for?(i?=?1;?i?<=?remaining;?i++)?{
???port?=?low?+?(i?+?offset)?%?remaining;
???...
??}
?}
}

在這個函數中首先判斷了 inet_sk(sk)->inet_num,如果我們調用過 bind,那么這個函數會選擇好端口并設置在 inet_num 上。這個我們后面專門分一小節介紹。這里我們假設沒有調用過 bind,所以 snum 為 0。

接著調用 inet_get_local_port_range,這個函數讀取的是 net.ipv4.ip_local_port_range 這個內核參數。來讀取管理員配置的可用的端口范圍。

該參數的默認值是 32768 61000,意味著端口總可用的數量是 61000 - 32768 = 28232 個。如果你覺得這個數字不夠用,那就修改你的 net.ipv4.ip_local_port_range 內核參數。

接下來進入到了 for 循環中。其中offset 是我們前面說的,通過 inet_sk_port_offset(sk) 計算出的隨機數。那這段循環的作用就是從某個隨機數開始,把整個可用端口范圍來遍歷一遍。直到找到可用的端口后停止。

那么我們接著來看,如何來確定一個端口是否可以使用呢?

//file:net/ipv4/inet_hashtables.c
int?__inet_hash_connect(...)
{
?for?(i?=?1;?i?<=?remaining;?i++)?{
??port?=?low?+?(i?+?offset)?%?remaining;

??//查看是否是保留端口,是則跳過
??if?(inet_is_reserved_local_port(port))
???continue;

??//?查找和遍歷已經使用的端口的哈希鏈表
??head?=?&hinfo->bhash[inet_bhashfn(net,?port,
????hinfo->bhash_size)];
??inet_bind_bucket_for_each(tb,?&head->chain)?{

???//如果端口已經被使用
???if?(net_eq(ib_net(tb),?net)?&&
???????tb->port?==?port)?{

????????????????//通過?check_established?繼續檢查是否可用
????if?(!check_established(death_row,?sk,
???????port,?&tw))
?????goto?ok;
???}
??}

??//未使用的話,直接?ok
??goto?ok;
?}

?return?-EADDRNOTAVAIL;
ok:?
?...??
}

首先判斷的是 inet_is_reserved_local_port,這個很簡單就是判斷要選擇的端口是否在 net.ipv4.ip_local_reserved_ports 中,在的話就不能用。

如果你因為某種原因不希望某些端口被內核使用,那么就把它們寫到 ip_local_reserved_ports 這個內核參數中就行了。

整個系統中會維護一個所有使用過的端口的哈希表,它就是 hinfo->bhash。接下來的代碼就會在這里進行查找。如果在哈希表中沒有找到,那么說明這個端口是可用的。至此端口就算是找到了。

遍歷完所有端口都沒找到合適的,就返回 -EADDRNOTAVAIL,你在用戶程序上看到的就是 Cannot assign requested address 這個錯誤。怎么樣,是不是很眼熟,你見過它的對吧,哈哈!

/*?Cannot?assign?requested?address?*/
#define?EADDRNOTAVAIL?99?

以后當你再遇到 Cannot assign requested address 錯誤,你應該想到去查一下 net.ipv4.ip_local_port_range 中設置的可用端口的范圍是不是太小了。

2.3 端口被使用過怎么辦

回顧剛才的 __inet_hash_connect, 為了描述簡單我們之前跳過了已經在 bhash 中存在時候的判斷。這是由于其一這個過程比較長,其二這段邏輯很有價值,所以飛哥單獨拉一小節出來。

//file:net/ipv4/inet_hashtables.c
int?__inet_hash_connect(...)
{
?for?(i?=?1;?i?<=?remaining;?i++)?{
??port?=?low?+?(i?+?offset)?%?remaining;

??...
??//如果端口已經被使用
??if?(net_eq(ib_net(tb),?net)?&&
???????tb->port?==?port)?{
???//通過?check_established?繼續檢查是否可用
???if?(!check_established(death_row,?sk,?port,?&tw))
????goto?ok;
??}
?}
}

port 已經在 bhash 中如果已經存在,就表示有其它的連接使用過該端口了。請注意,如果 check_established 返回 0,該端口仍然可以接著使用!

這里可能會讓很多同學困惑了,一個端口怎么可以被用多次呢?

回憶下四元組的概念,兩對兒四元組中只要任意一個元素不同,都算是兩條不同的連接。以下的兩條 TCP 連接完全可以同時存在(假設 192.168.1.101 是客戶端,192.168.1.100 是服務端)

  • 連接1:192.168.1.101 5000 192.168.1.100 8090
  • 連接2:192.168.1.101 5000 192.168.1.100 8091

check_established 作用就是檢測現有的 TCP 連接中是否四元組和要建立的連接四元素完全一致。如果不完全一致,那么該端口仍然可用!!!

這個 check_established 是由調用方傳入的,實際上使用的是 __inet_check_established。我們來看它的源碼。

//file:?net/ipv4/inet_hashtables.c
static?int?__inet_check_established(struct?inet_timewait_death_row?*death_row,
????????struct?sock?*sk,?__u16?lport,
????????struct?inet_timewait_sock?**twp)
{
?//找到hash桶
?struct?inet_ehash_bucket?*head?=?inet_ehash_bucket(hinfo,?hash);

?//遍歷看看有沒有四元組一樣的,一樣的話就報錯
?sk_nulls_for_each(sk2,?node,?&head->chain)?{
??if?(sk2->sk_hash?!=?hash)
???continue;
??if?(likely(INET_MATCH(sk2,?net,?acookie,
??????????saddr,?daddr,?ports,?dif)))
???goto?not_unique;
?}

unique:
?//要用了,記錄,返回?0?(成功)
?return?0;
not_unique:
?return?-EADDRNOTAVAIL;?
}

該函數首先找到 inet_ehash_bucket,這個和 bhash 類似,只不過是所有 ESTABLISH 狀態的 socket 組成的哈希表。然后遍歷這個哈希表,使用 INET_MATCH 來判斷是否可用。

這里 INET_MATCH 源碼如下:

//?include/net/inet_hashtables.h
#define?INET_MATCH(__sk,?__net,?__cookie,?__saddr,?__daddr,?__ports,?__dif)?\
?((inet_sk(__sk)->inet_portpair?==?(__ports))?&&??\
??(inet_sk(__sk)->inet_daddr?==?(__saddr))?&&??\
??(inet_sk(__sk)->inet_rcv_saddr?==?(__daddr))?&&??\
??(!(__sk)->sk_bound_dev_if?||????\
????((__sk)->sk_bound_dev_if?==?(__dif)))??&&??\??net_eq(sock_net(__sk),?(__net)))

在 INET_MATCH 中將 __saddr、__daddr、__ports 都進行了比較。當然除了 ip 和端口,INET_MATCH還比較了其它一些東東,所以 TCP 連接還有五元組、七元組之類的說法。為了統一,咱們還沿用四元組的說法。

如果 MATCH,就是說就四元組完全一致的連接,所以這個端口不可用。也返回 -EADDRNOTAVAIL。

如果不 MATCH,哪怕四元組中有一個元素不一樣,例如服務器的端口號不一樣,那么就 return 0,表示該端口仍然可用于建立新連接。

所以一臺客戶端機最大能建立的連接數并不是 65535。只要 server 足夠多,單機發出百萬條連接沒有任何問題。

2.4 發起 syn 請求

再回到 tcp_v4_connect,這時我們的 inet_hash_connect 已經返回了一個可用端口了。接下來就進入到 tcp_connect,如下源碼所示。

//file:?net/ipv4/tcp_ipv4.c
int?tcp_v4_connect(struct?sock?*sk,?struct?sockaddr?*uaddr,?int?addr_len){
?......

?//動態選擇一個端口
?err?=?inet_hash_connect(&tcp_death_row,?sk);

?//函數用來根據 sk 中的信息,構建一個完成的 syn 報文,并將它發送出去。
?err?=?tcp_connect(sk);
}

到這里其實就和本文要討論的主題沒有關系了,所以我們只是簡單看一下。

//file:net/ipv4/tcp_output.c
int?tcp_connect(struct?sock?*sk){
?//申請并設置?skb
?buff?=?alloc_skb_fclone(MAX_TCP_HEADER?+?15,?sk->sk_allocation);
?tcp_init_nondata_skb(buff,?tp->write_seq++,?TCPHDR_SYN);

?//添加到發送隊列?sk_write_queue?上
?tcp_connect_queue_skb(sk,?buff);

?//實際發出?syn
?err?=?tp->fastopen_req???tcp_send_syn_data(sk,?buff)?:
???????tcp_transmit_skb(sk,?buff,?1,?sk->sk_allocation);

?//啟動重傳定時器
?inet_csk_reset_xmit_timer(sk,?ICSK_TIME_RETRANS,
??????inet_csk(sk)->icsk_rto,?TCP_RTO_MAX);
}

tcp_connect 一口氣做了這么幾件事

  • 申請一個 skb,并將其設置為 SYN 包
  • 添加到發送隊列上
  • 調用 tcp_transmit_skb 將該包發出
  • 啟動一個重傳定時器,超時會重發

三、bind 時端口如何選擇

在 2.2 小節中,我們看到 connect 選擇端口之前先判斷了 inet_sk(sk)->inet_num 有沒有值。如果有的話就直接用這個,而會跳過端口選擇過程。

那么這個值是從哪兒來的呢?不賣關子,它就是在對 socket 使用 bind 時設置的。

不只是服務器端,哪怕是對于客戶端,也可以對 socket 使用 bind 來綁定 IP 或者端口。如果使用了 bind,那么在 bind 的時候就會確定好端口,并設置到 inet_num 變量中。

一般非常不推薦在客戶端角色下使用 bind。因為這會打亂 connect 里的端口選擇過程。

bind 的時候,如果傳了端口,那么 bind 就會嘗試使用該端口。如果端口號傳的是 0 ,那么 bind 有一套獨立的選擇端口號的邏輯。

//file:?net/ipv4/af_inet.c
int?inet_bind(struct?socket?*sock,?struct?sockaddr?*uaddr,?int?addr_len){
?struct?sock?*sk?=?sock->sk;
?...

?//用戶傳入的端口號
?snum?=?ntohs(addr->sin_port);

?//不允許綁定?1024?以下的端口
?if?(snum?&&?snum?<?PROT_SOCK?&&
?????!ns_capable(net->user_ns,?CAP_NET_BIND_SERVICE))
??goto?out;

?//嘗試確定端口號
?if?(sk->sk_prot->get_port(sk,?snum))?{
??inet->inet_saddr?=?inet->inet_rcv_saddr?=?0;
??err?=?-EADDRINUSE;
??goto?out_release_sock;
?}

根據第一節中的 socket 內核對象,能找到 sk->sk_prot->get_port 實際調用的是 inet_csk_get_port。該函數來嘗試確定端口號,如果嘗試失敗,返回 EADDRINUSE。你的應用程序將會顯示一條錯誤信息 “Address already in use”。

#define?EADDRINUSE?226?/*?Address?already?in?use?*/

我們簡單看一下如果用戶沒有傳入端口(傳入的為 0),bind 是怎么選擇端口的。

//file:?net/ipv4/inet_connection_sock.c
int?inet_csk_get_port(struct?sock?*sk,?unsigned?short?snum){
?...

?if?(!snum)?{
??inet_get_local_port_range(&low,?&high);
??remaining?=?(high?-?low)?+?1;
??smallest_rover?=?rover?=?net_random()?%?remaining?+?low;

??do?{
???if?(inet_is_reserved_local_port(rover))
????goto?next_nolock;

???head?=?&hashinfo->bhash[inet_bhashfn(net,?rover,
??????hashinfo->bhash_size)];
???inet_bind_bucket_for_each(tb,?&head->chain)

????//?沖突檢測
????if?(!inet_csk(sk)->icsk_af_ops->bind_conflict(sk,?tb,?false))?{
?????snum?=?rover;
?????goto?tb_found;
????}

??}?while?(--remaining?>?0);
?}
}

這段邏輯和 connect 很像,通過 net_random 來從 net.ipv4.ip_local_port_range 指定的端口范圍內一個隨機位置開始遍歷。也會跳開 ip_local_reserved_ports 保留端口配置。通過 inet_csk(sk)->icsk_af_ops->bind_conflict 進行沖突檢測。

inet_csk_bind_conflict 這個函數整體比較復雜,不過我們只需要了解一點就好,該函數和 connect 中端口選擇邏輯不同的是,并不會到 ESTABLISH 的哈希表進行可用檢測,只在 bind 狀態的 socket 里查。所以默認情況下,只要端口用過一次就不會再次使用

四、結論

客戶端建立連接前需要確定一個端口,該端口會在兩個位置進行確定。

第一個位置,也是最主要的確定時機是 connect 系統調用執行過程。在 connect 的時候,會隨機地從 ip_local_port_range 選擇一個位置開始循環判斷。找到可用端口后,發出 syn 握手包。如果端口查找失敗,會報錯 “Cannot assign requested address”。這個時候你應該首先想到去檢查一下服務器上的 net.ipv4.ip_local_port_range 參數,是不是可以再放的多一些。

如果你因為某種原因不希望某些端口被使用到,那么就把它們寫到 ip_local_reserved_ports 這個內核參數中就行了,內核在選擇的時候會跳過這些端口。

另外注意即使是一個端口是可以被用于多條 TCP 連接的。所以一臺客戶端機最大能建立的連接數并不是 65535。只要 server 足夠多,單機發出百萬條連接沒有任何問題。

我給大伙兒貼一下我實驗時候在客戶機上實驗時的實際截圖,來實際看一下一個端口號確實是被用在了多條連接上了。

截圖中左邊的 192 是客戶端,右邊的 119 是服務器的 ip。可以看到客戶端的 10000 這個端口號是用在了多條連接上了的。

第二個位置,如果在 connect 之前使用了 bind,將會使得 connect 時的端口選擇方式無效。轉而使用 bind 時確定的端口。bind 時如果傳入了端口號,會嘗試首先使用該端口號,如果傳入了 0 ,也會自動選擇一個。但默認情況下一個端口只會被使用一次。所以對于客戶端角色的 socket,不建議使用 bind !

最后我再想多說一句,上面的選擇端口的都是從 ip_local_port_range 范圍中的某一個隨機位置開始循環的。如果可用端口很充足,則能快一點找到可用端口,那循環很快就能退出。假設實際中 ip_local_port_range 中的端口快被用光了,這時候內核就大概率得把循環多執行很多輪才能找到,這會導致 connect 系統調用的 CPU 開銷的上漲。

所以,最好不要等到端口不夠用了才加大 ip_local_port_range 的范圍,而是事先就應該保持一個充足的范圍。

喜歡的話別忘了?分享、點贊、收藏?三連~

歡迎關注公眾號?前端進階體驗?收獲更多優質文章~

本文使用 文章同步助手 同步

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容