首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> C語言 >> 關於C語言

從零實現 Ping 程式:ICMP 協議、原始套接字與 Linux 系統程式設計實戰

     編輯:關於C語言

前言:Ping 背後的網路探測原理

ping 是網路診斷最基礎工具,原理簡單卻蘊含核心網路知識:發送 ICMP 回顯請求 (Type 8),目標主機回應 ICMP 回顯應答 (Type 0)。測量往返時間 (RTT)、觀察 TTL、統計丟包率。本文完整實現一個極簡版 myping,涵蓋原始套接字、校驗和計算、時間戳、訊號處理等關鍵技術。

協議基礎:ICMP over IP

封裝層次

乙太網幀頭
    IP 頭 (20位元組)          ← 核心欄位:版本、IHL、TTL、協議(1=ICMP)、來源/目的IP、檢查和
        ICMP 頭 (8位元組)       ← 類型、代碼、檢查和、識別碼、序號
            ICMP 資料 (可變)     ← 時間戳 (struct timeval) 用於 RTT 計算

關鍵結構體 (Linux <netinet/ip.h>, <netinet/ip_icmp.h>)

/* IP 頭關鍵欄位 */
struct ip {
    uint8_t  ip_hl:4, ip_v:4;   // 頭長(4位元組單位)、版本
    uint8_t  ip_tos;            // 服務類型
    uint16_t ip_len;            // 總長度
    uint16_t ip_id;             // 識別碼
    uint16_t ip_off;            // 片偏移/標誌
    uint8_t  ip_ttl;            // 生存時間 (TTL)
    uint8_t  ip_p;              // 協議 (IPPROTO_ICMP = 1)
    uint16_t ip_sum;            // 頭檢查和
    struct in_addr ip_src, ip_dst; // 來源/目的 IP
};

/* ICMP 頭關鍵欄位 (回顯請求/應答) */
struct icmp {
    uint8_t  icmp_type;         // 類型: 8=請求, 0=應答
    uint8_t  icmp_code;         // 代碼: 0
    uint16_t icmp_cksum;        // 檢查和
    uint16_t icmp_id;           // 識別碼 (通常用 PID)
    uint16_t icmp_seq;          // 序號
    // 後接資料:struct timeval 發送時間戳
};

核心算法:網際檢查和 (RFC 1071)

將資料視為 16 位整數序列累加,溢出回卷,最後取反碼:

unsigned short cal_chksum(unsigned short *addr, int len) {
    int nleft = len;
    int sum = 0;
    unsigned short *w = addr;
    unsigned short answer = 0;

    while (nleft > 1) {         // 以 16 位為單位累加
        sum += *w++;
        nleft -= 2;
    }
    if (nleft == 1) {           // 奇數位元組:高位補 0
        *(unsigned char*)(&answer) = *(unsigned char*)w;
        sum += answer;
    }
    sum = (sum >> 16) + (sum & 0xFFFF); // 回卷進位
    sum += (sum >> 16);         // 再次回卷
    answer = ~sum;              // 取反碼
    return answer;
}

完整實作:myping.c

/***********************************************************
 * myping.c - 極簡 Ping 實現
 * 編譯: gcc -o myping myping.c
 * 執行: sudo ./myping <主機名/IP>  (需 root 權限建立原始套接字)
 ***********************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <errno.h>

#define PACKET_SIZE   4096
#define MAX_WAIT_TIME 5
#define MAX_PACKETS   3
#define DATA_LEN      56

char send_pkt[PACKET_SIZE];
char recv_pkt[PACKET_SIZE];
int sockfd;
int nsent = 0, nrecv = 0;
struct sockaddr_in dest_addr;
pid_t pid;
struct sockaddr_in from;
struct timeval tv_recv;

void statistics(int signo);
unsigned short cal_chksum(unsigned short *addr, int len);
int pack(int pkt_no);
void send_packet(void);
void recv_packet(void);
int unpack(char *buf, int len);
void tv_sub(struct timeval *out, struct timeval *in);

int main(int argc, char *argv[]) {
    struct hostent *host;
    struct protoent *proto;
    unsigned long inaddr = 0;
    int size = 50 * 1024;

    if (argc < 2) {
        fprintf(stderr, "Usage: %s <hostname/IP>\n", argv[0]);
        exit(1);
    }

    /* 1. 取得 ICMP 協議號 */
    if ((proto = getprotobyname("icmp")) == NULL) {
        perror("getprotobyname");
        exit(1);
    }

    /* 2. 建立原始套接字 (需 root) */
    if ((sockfd = socket(AF_INET, SOCK_RAW, proto->p_proto)) < 0) {
        perror("socket");
        exit(1);
    }

    /* 3. 降權:捨棄 root 權限,保留建立的 socket */
    setuid(getuid());

    /* 4. 擴大接收緩衝區,防止廣播風暴溢出 */
    setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));

    /* 5. 解析目標地址 */
    memset(&dest_addr, 0, sizeof(dest_addr));
    dest_addr.sin_family = AF_INET;
    if ((inaddr = inet_addr(argv[1])) == INADDR_NONE) {
        if ((host = gethostbyname(argv[1])) == NULL) {
            perror("gethostbyname");
            exit(1);
        }
        memcpy(&dest_addr.sin_addr, host->h_addr, host->h_length);
    } else {
        memcpy(&dest_addr.sin_addr, &inaddr, sizeof(inaddr));
    }

    pid = getpid();  // 用 PID 作為 ICMP 識別碼
    printf("PING %s (%s): %d bytes data in ICMP packets.\n",
           argv[1], inet_ntoa(dest_addr.sin_addr), DATA_LEN);

    send_packet();      // 發送階段
    recv_packet();      // 接收階段
    statistics(SIGALRM); // 統計輸出
    return 0;
}

/* 封裝 ICMP 回顯請求封包 */
int pack(int pkt_no) {
    struct icmp *icmp = (struct icmp*)send_pkt;
    struct timeval *tval = (struct timeval*)icmp->icmp_data;

    icmp->icmp_type = ICMP_ECHO;      // 8
    icmp->icmp_code = 0;
    icmp->icmp_cksum = 0;             // 先歸零計算
    icmp->icmp_seq = pkt_no;
    icmp->icmp_id = pid;

    gettimeofday(tval, NULL);         // 記錄發送時間戳

    int packsize = 8 + DATA_LEN;      // ICMP頭(8) + 資料
    icmp->icmp_cksum = cal_chksum((unsigned short*)icmp, packsize);
    return packsize;
}

/* 發送多個封包,間隔 1 秒 */
void send_packet() {
    while (nsent < MAX_PACKETS) {
        nsent++;
        int pktsize = pack(nsent);
        if (sendto(sockfd, send_pkt, pktsize, 0,
                   (struct sockaddr*)&dest_addr, sizeof(dest_addr)) < 0) {
            perror("sendto");
            continue;
        }
        sleep(1);
    }
}

/* 接收並處理回應 */
void recv_packet() {
    int n, fromlen = sizeof(from);
    signal(SIGALRM, statistics);  // 超時處理

    while (nrecv < nsent) {
        alarm(MAX_WAIT_TIME);
        if ((n = recvfrom(sockfd, recv_pkt, sizeof(recv_pkt), 0,
                          (struct sockaddr*)&from, &fromlen)) < 0) {
            if (errno == EINTR) continue;  // alarm 中斷
            perror("recvfrom");
            continue;
        }
        gettimeofday(&tv_recv, NULL);  // 記錄接收時間
        if (unpack(recv_pkt, n) == -1) continue;
        nrecv++;
    }
}

/* 解析接收封包:剝離 IP 頭,驗證 ICMP 回顯應答 */
int unpack(char *buf, int len) {
    struct ip *ip = (struct ip*)buf;
    int ip_hdr_len = ip->ip_hl << 2;  // IHL * 4
    struct icmp *icmp = (struct icmp*)(buf + ip_hdr_len);
    len -= ip_hdr_len;

    if (len < 8) {  // ICMP 頭至少 8 位元組
        fprintf(stderr, "ICMP packet too short\n");
        return -1;
    }

    /* 驗證:類型=0(應答) 且 ID=我們的 PID */
    if (icmp->icmp_type == ICMP_ECHOREPLY && icmp->icmp_id == pid) {
        struct timeval *tv_send = (struct timeval*)icmp->icmp_data;
        tv_sub(&tv_recv, tv_send);  // 計算時間差

        double rtt = tv_recv.tv_sec * 1000.0 + tv_recv.tv_usec / 1000.0; // 毫秒
        printf("%d byte from %s: icmp_seq=%u ttl=%d rtt=%.3f ms\n",
               len, inet_ntoa(from.sin_addr), icmp->icmp_seq, ip->ip_ttl, rtt);
        return 0;
    }
    return -1;  // 非我們的回應
}

/* 統計輸出 */
void statistics(int signo) {
    printf("\n--- PING statistics ---\n");
    printf("%d packets transmitted, %d received, %d%% lost\n",
           nsent, nrecv, nsent ? (nsent - nrecv) * 100 / nsent : 0);
    close(sockfd);
    exit(0);
}

/* timeval 減法:out -= in */
void tv_sub(struct timeval *out, struct timeval *in) {
    if ((out->tv_usec -= in->tv_usec) < 0) {
        --out->tv_sec;
        out->tv_usec += 1000000;
    }
    out->tv_sec -= in->tv_sec;
}

關鍵技術點深度解析

1. 原始套接字 (SOCK_RAW) 與權限模型

  • socket(AF_INET, SOCK_RAW, IPPROTO_ICMP) 繞過傳輸層,直接收發 IP 負載。
  • 必須 root 權限建立(CAP_NET_RAW),但建立後可 setuid(getuid()) 降權。
  • 接收端會收到所有 ICMP 封包(含錯誤訊息),需靠 icmp_id 過濾屬於本程式的回應。

2. ICMP 識別碼與序號設計

  • icmp_id = getpid():進程唯一標識,多進程並發不衝突。
  • icmp_seq = 1,2,3...:單調遞增,對應請求/回應配對。
  • 回應封包中 icmp_id 和 icmp_seq 會原樣返回。

3. 時間戳與 RTT 計算

  • 發送前 gettimeofday() 寫入 ICMP 資料區 (struct timeval 8 位元組)。
  • 接收時再次 gettimeofday() 取當前時間。
  • tv_sub() 處理微秒借位,精確到微秒級。

4. 訊號驅動的超時機制

  • alarm(MAX_WAIT_TIME) 設定定時器,到期送 SIGALRM。
  • recvfrom() 被中斷返回 EINTR,循環繼續。
  • 全部發送完成後進入接收循環,若超時觸發 statistics() 輸出統計並退出。

5. 檢查和計算細節

  • 計算時 icmp_cksum 必須先歸零。
  • ICMP 頭 + 資料總長度必須為偶數位元組(奇數補 0)。
  • IP 層也有獨立檢查和 (ip_sum),由核心自動填寫/驗證,用戶態無需處理。

編譯與執行實戰

$ gcc -o myping myping.c
$ sudo chown root:root myping
$ sudo chmod u+s myping   # 設定 SUID,普通用戶也能執行
$ ./myping www.google.com
PING www.google.com (142.250.190.4): 56 bytes data in ICMP packets.
64 byte from 142.250.190.4: icmp_seq=1 ttl=115 rtt=12.345 ms
64 byte from 142.250.190.4: icmp_seq=2 ttl=115 rtt=11.876 ms
64 byte from 142.250.190.4: icmp_seq=3 ttl=115 rtt=12.001 ms

--- PING statistics ---
3 packets transmitted, 3 received, 0% lost

擴展方向與生產級考量

  1. IPv6 支援:AF_INET6, IPPROTO_ICMPV6, struct icmp6_hdr。
  2. 非阻塞 I/O + epoll/kqueue:替代 alarm/signal,支援高併發。
  3. 時間戳選項:IP 選項或 ICMP 擴展獲取更精確路徑延遲。
  4. 統計增強:最小/平均/最大/標準差、抖動計算、直方圖。
  5. 權限最小化:Linux Capabilities (cap_net_raw+ep) 替代 SUID。
  6. 錯誤處理完善:ICMP 目的不可達、超時、重定向等錯誤類型解析顯示。

總結

實現 ping 是學習 Linux 系統網路程式設計的最佳切入點:

  • 掌握 原始套接字 程式模型
  • 理解 ICMP/IP 協議細節 與封包結構
  • 實作 檢查和算法、時間測量、訊號處理
  • 體會 特權操作與降權 的安全模式

這 100 行代碼串聯了網路協議棧從應用層到驅動層的關鍵路徑,是網路工程師必修的實戰課題。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved