Пишем 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.
Если есть вопросы или замечания - пишите! Я обязательно постараюсь ответить. Спасибо!