Пост

Пишем ping на Си

Видите это довольное лицо? Это Майк Муусс - автор, наверное самой часто используемой утилиты ping (недаром он на фотке так радуется).

Некоторое время назад мне самому довелось написать ping, но в виде отдельной функции.

И на мой взгляд, для си-программистов, которые только начинают работать с сетью, это очень полезная утилита для самостоятельной разработки. Почему? Потому, что для разработки этой утилиты нужно научится делать, казалось бы, самые примитивные действия - отправлять и принимать пакет.

ICMP

Давайте сначала вкратце определим требования конкретно для нашей функции ping. Функция нужна нам для проверки целостности и качества соединения между хостами. И для этого нам достаточно отправить ICMP-эхопакет с запросом целевому хосту и получить от нее ответ. Для оценки качества соединения будем использовать время между запросом и получением ответа.

Системные вызовы

Операционная система (в нашем случае Linux) позволяет получать доступ к сетевым устройствам посредством системных вызовов. Для ping мне потребовались следующие вызовы:

  • socket - используется для создания сокета
  • select - в нашем случае используется для проверки состояния сокета
  • sendto - для отправки данных
  • recvfrom - для получения данных
  • inet_aton - для преобразования ip-aдреса в строковом виде в структуру sockaddr_in
  • и другие

Реализация

Функция для получения текущего времени

Для начала напишем вспомогательную функцию для получения текущего времени в миллисекундах.

1
2
3
4
5
6
7
static ulong get_cur_time_ms()
{
    struct timespec time;
    clock_gettime(CLOCK_MONOTONIC, &time);
    ulong time_ms = time.tv_sec * 1000 + (time.tv_nsec / 1000000);
    return time_ms;
}

Данные

Далее нужно определиться с тем, какие данные будем отправлять. Объявим структуру, который будет иметь icmp заголовок и поле данных. Эта структура будет служить нашим пакетом.

1
2
3
4
typedef struct {
    struct icmp hdr;   // заголовок
    char data[PING_PKT_DATA_SZ]; // данные
} ping_pkt_t;

Напишем функцию для заполнения нашей структуры.

1
2
3
4
5
6
7
8
9
10
static void prepare_icmp_pkt(ping_pkt_t *ping_pkt)
{
    memset(ping_pkt, 0, sizeof(ping_pkt_t));
    srand(time(NULL));
    const short random_id = rand();
    ping_pkt->hdr.icmp_hun.ih_idseq.icd_id = random_id; // некоторый id-пакета
    ping_pkt->hdr.icmp_type = ICMP_ECHO;                // говорим, что это эхо-пакет
    ping_pkt->hdr.icmp_hun.ih_idseq.icd_seq = 0;        // номер запроса (у нас всегда 0)
    ping_pkt->hdr.icmp_cksum = checksum(ping_pkt, sizeof(ping_pkt_t)); // контрольная сумма
}

Функция ping

Сигнатура нашей основной функции будет следующим.

1
int ping(const char* ip, const ulong timeout, ulong* time)

На входе мы получаем ip-адрес пингуемого хоста и таймаут ожидания. На выходе результат выполнения и третий аргумент time куда запишем время пинга хоста.

Тут же, с помощью inet_aton, преобразуем ip в строковом виде в структуру sockaddr_in.

1
2
3
4
5
struct sockaddr_in to_addr;
to_addr.sin_family = AF_INET;
if (!inet_aton(ip, (struct in_addr*)&to_addr.sin_addr.s_addr)) {
    perror("inet_aton");
}

Сокет

Далее нам нужно создать сокет путем вызова соответствующей функции.

1
2
3
4
int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (sock < 0) {
    perror("socket");
}

Директива SOCK_RAW говорит о том, что мы отправляем “сырой” пакет без использования транспортного уровня (UDP/TCP). С помощью директивы IPPROTO_ICMP говорим, что мы будем использовать протокол ICMP. Функция socket() возвращает нам файловый дескриптор, который будем использовать в последующем.

Отправляем пакет

Перед тем как отправить пакет фиксируем время.

1
const ulong start_time_ms = get_cur_time_ms();

И отправляем пакет.

1
2
3
4
int res = sendto(sock, &ping_pkt, sizeof(ping_pkt_t), 0, &to_addr, sizeof(to_addr));
if (res <= 0) {
    perror("error");
}

Получаем ответ

Для получения ответа нужно считать принятые данные из сокета. Данные будут записаны в структуру ip_pkt.

1
2
3
4
5
6
7
ip_pkt_t ip_pkt;
struct sockaddr_in from_addr;
socklen_t socklen = sizeof(struct sockaddr_in);
int res = recvfrom(sock, &ip_pkt, sizeof(ip_pkt_t), 0, &from_addr, &socklen);
if (res <= 0) {
    perror("error");
}

Таймауты

Далее на мой взгляд идет самая сложная часть. Делать вызов recvfrom, то есть принимать данные из сети, нам нужно только при наличии данных. Для этого нам поможет системный вызов select, который используется для отслеживания состояния сокета. А именно:

  • Я взвожу select на требуемый таймаут, после чего происходит блокировка на этой функции до момента пока не появятся данные или не истечет время
  • Если select разблокировался по таймауту, значит время истекло и мы выходим из цикла
  • Если select разблокировался из-за появления данных, то вызываем recvfrom и записываем данные в структуру ip_pkt
  • Если данные адресованы не нам, взводим select заново, ведь у нас еще осталось время
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct timeval tv;
tv.tv_sec = timeout / 1000;
tv.tv_usec = (timeout % 1000) * 1000;

for (;;) {
    fd_set rfd;
    FD_ZERO(&rfd);
    FD_SET(sock, &rfd);

    // блокируемся пока не получим данные или не истечет время
    int n = select(sock + 1, &rfd, 0, 0, &tv);

    if (n == 0) { break; } // выходим из цикла по таймауту
    if (n < 0)  { break; } // произошла ошибка

    // значит у нас есть данные, фиксируем время
    const ulong elapsed_time = (get_cur_time_ms() - start_time_ms);

    if (FD_ISSET(sock, &rfd)) {
        ip_pkt_t ip_pkt; // сюда будут записаны данные
        struct sockaddr_in from_addr; // а здесь адрес от кого получили данные
        socklen_t socklen = sizeof(struct sockaddr_in);
        if (recvfrom(sock, &ip_pkt, sizeof(ip_pkt_t), 0, &from_addr, &socklen) <= 0) {
            break;
        }
        if (to_addr.sin_addr.s_addr == from_addr.sin_addr.s_addr) { // проверка ip-адреса
            if (reply_id == ip_pkt.ping_pkt.hdr.icmp_hun.ih_idseq.icd_id) {
                // если пакет от того кому отправили и ID запроса и ответа совпадают
                // то пакет наш, фиксируем время и выходим из цикла
                *reply_time = elapsed_time;
                break;
            }
        }
    }
    // Данные не наши, поэтому
    // задаем новый таймаут по оставшемуся времени и возвращаемся в начало цикла
    const int new_timeout = timeout - elapsed_time;
    tv.tv_sec = new_timeout / 1000;
    tv.tv_usec = (new_timeout % 1000) * 1000;
}

Запуск

Компилируем, запускаем ping и видим, что все работает.

1
2
3
4
5
6
$ sudo ./ping 8.8.8.8 1000
Ping host=8.8.8.8 timeout=1000 time=47 ms 
$ sudo ./ping 8.8.8.8 1000
Ping host=8.8.8.8 timeout=1000 time=52 ms 
$ sudo ./ping 5.5.5.5 1000
Timeout.

Стоп! Но почему для запуска требуется sudo? А потому, что мы используем сырые пакеты. Помните директиву SOCK_RAW?

Тогда почему стандартная утилита ping не требует sudo? Ничего страшного. Пара нехитрых действий и наш ping тоже запускается без sudo.

1
2
3
4
$ sudo chown root:root ./ping
$ sudo chmod u+s ./ping
$ ./ping 8.8.8.8 1000     # технически он запускается из под рута
Ping host=8.8.8.8 timeout=1000 time=56 ms 

Ссылка на репозиторий с исходниками - https://github.com/a-khakimov/ping.

Если есть вопросы или замечания - пишите! Я обязательно постараюсь ответить. Спасибо!

Авторский пост защищен лицензией CC BY 4.0 .