Before a ping program can send a single byte it has to get past the kernel. ICMP is not TCP or UDP, so there is no unprivileged port to bind — you need root, a capability, or a sysctl that most tutorials never mention. Get that wrong and the program fails at socket(), four lines in, with an error that does not explain itself.
This guide covers how an ICMP echo request is built, a complete implementation for Linux and macOS that compiles clean under -Wall -Wextra -pedantic, the four ways to obtain permission to run it, and what the Windows version needs that the POSIX one does not. The POSIX program was compiled and run on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 using -std=c11 -Wall -Wextra -pedantic, zero warnings, clean under AddressSanitizer and UndefinedBehaviorSanitizer. The Windows program was compiled and run on Windows 11 with MSVC 19.44.35229, also zero warnings. Both live in a GitHub repository where every commit rebuilds them on GCC, Clang and MSVC with warnings treated as errors. Every output block and error below is captured verbatim.
What Is an ICMP Ping?
A ping sends an ICMP echo request (type 8) to a host and waits for an echo reply (type 0), measuring the round trip. ICMP is a control protocol that sits directly on IP rather than on TCP or UDP, which means it has no port numbers — so a program cannot simply bind an unprivileged socket to send one. On Linux and macOS you open either a raw socket (SOCK_RAW, requiring root or CAP_NET_RAW) or, on Linux only, an unprivileged datagram socket (SOCK_DGRAM with IPPROTO_ICMP) when the net.ipv4.ping_group_range sysctl permits it. On Windows you open a raw socket through Winsock and run as Administrator. Everything here is ICMP over IPv4; IPv6 uses ICMPv6, with different header definitions and a different protocol number.
That permission question is the first thing to solve and the thing most implementations handle worst, so it comes before the code.
CAP_NET_RAW; Linux also offers an unprivileged SOCK_DGRAM path controlled by ping_group_range, which most tutorials never mention. Try both and report both failures — they have different causes and different fixes.
Permission: The Four Routes
Run a ping program as an ordinary user with stock settings and both socket types fail — with different errors, pointing at different fixes:
Could not open an ICMP socket.
SOCK_DGRAM: Permission denied
SOCK_RAW : Operation not permitted
EACCES on the datagram socket means your group is outside ping_group_range. EPERM on the raw socket means you lack CAP_NET_RAW. A program that only tries SOCK_RAW reports the second and sends the user to sudo when they might not have needed it.
Route 1 — run as root. What every tutorial assumes, and the least good option for a program you intend to use.
Route 2 — grant the capability to the binary. The file carries the privilege, so any user can run it without sudo:
sudo setcap cap_net_raw+ep ./ping
CAP_NET_RAW lets the binary craft and read arbitrary packets on the local network, so grant it only to executables you built and control.
PING 127.0.0.1 (127.0.0.1) 56 bytes of data [raw socket]
64 bytes from 127.0.0.1: icmp_seq=0 time=0.09 ms
Route 3 — Linux’s unprivileged ICMP socket. This is the modern answer, and the one most ping examples skip. SOCK_DGRAM with IPPROTO_ICMP lets an ordinary user send echo requests, controlled by a sysctl holding a range of group IDs:
cat /proc/sys/net/ipv4/ping_group_range
1 0
1 0 is an empty range — start above end — so nobody qualifies, Check yours before assuming either way — the value varies. Open it up and the same binary works with no root and no capability:
sudo sysctl -w net.ipv4.ping_group_range="0 2147483647"
PING 127.0.0.1 (127.0.0.1) 56 bytes of data [unprivileged socket]
64 bytes from 127.0.0.1: icmp_seq=0 time=0.04 ms
64 bytes from 127.0.0.1: icmp_seq=1 time=0.05 ms
--- 127.0.0.1 statistics ---
4 sent, 4 received, 0% loss
That is the same executable, the same unprivileged user, and no elevation at any point.
Route 4 — Windows. There is no capability model and no unprivileged ICMP socket. Microsoft documents raw sockets as requiring Administrator, and a refused socket returns WSA error 10013 (WSAEACCES). Assume you need to run elevated.
One observation is worth recording without over-reading it. Testing for this article on Windows 11, the raw socket opened from a Developer Command Prompt that had not been launched with “Run as administrator”, and whoami showed a UAC-filtered token — the Administrators group present but marked Group used for deny only, and none of the administrative privileges enabled. It pinged successfully.
That is not the same as testing from a standard user account. The machine was logged in under an administrator account, and a filtered admin token is not identical to a token that never had those rights — whether Windows’ raw-socket check treats them the same is exactly the question, and it was not tested. Published projects disagree in the same way: one maintained cross-platform tracing library states that “on Windows we always need privileges to send ICMP packets”, another documents “Windows: Raw ICMP works without admin privileges”. Both current.
What to do in code: attempt the socket and, if it is refused, report WSAEACCES and tell the user to run elevated. That is correct whichever way the check actually works, which is why the implementation below prints the WSA error rather than asserting a cause.
One consequence for your code: try the unprivileged socket first and fall back to raw. Most users on a modern Linux desktop will succeed on the first attempt.
Building the Echo Request
An ICMP echo request is eight bytes of header followed by whatever payload you choose. The echo message format is the one RFC 792 defined in 1981:
| Field | Size | Value for an echo request |
|---|---|---|
| Type | 1 byte | 8 (ICMP_ECHO); replies come back as 0 |
| Code | 1 byte | 0 |
| Checksum | 2 bytes | 16-bit one’s complement sum of the whole packet |
| Identifier | 2 bytes | Yours to choose — usually the process ID |
| Sequence | 2 bytes | Incremented per packet |
The checksum covers the header and the payload, and must be computed with the checksum field itself set to zero. That ordering catches people out: fill the packet, zero the checksum field, compute, then write the result back. The algorithm is the standard internet checksum from RFC 1071 — the same one TCP, UDP and IPv4 use, so the routine below is worth keeping.
The Implementation
Roughly 200 lines. This is a Linux implementation using IPv4 — AF_INET throughout, so ICMPv6 is out of scope — and a separate Windows version follows further down.
On macOS and the BSDs it will not compile as written, and it is worth knowing exactly why rather than being told it is “untested”. The code uses struct icmphdr, which is glibc’s spelling; macOS provides only the BSD struct icmp, where the same field is hdr.icmp_id rather than hdr.un.echo.id. Porting is mechanical — swap the structure, the field names, and the ICMP_ECHO reply constant — but it is a port, not a recompile.
/* ping.c - minimal ICMP echo client for Linux.
Build: cc -std=c11 -Wall -Wextra -pedantic ping.c -o ping */
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define DEFAULT_PAYLOAD 56
#define MAX_PAYLOAD 1024
#define RECV_BUFFER 2048
#define PING_COUNT 4
/* 16-bit one's complement sum, as RFC 1071 describes it. */
static unsigned short checksum(const void *data, size_t len)
{
const unsigned char *p = data;
unsigned long sum = 0;
while (len > 1) {
unsigned short word;
memcpy(&word, p, sizeof word); /* no aliasing games */
sum += word;
p += 2;
len -= 2;
}
if (len == 1)
sum += *p;
sum = (sum >> 16) + (sum & 0xffff);
sum += (sum >> 16);
return (unsigned short)~sum;
}
static double now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1000.0 + ts.tv_nsec / 1000000.0;
}
/* Try the unprivileged socket first, fall back to the raw one.
*is_raw tells the caller whether replies will carry an IP header. */
static int open_icmp_socket(int *is_raw)
{
int fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);
if (fd >= 0) {
*is_raw = 0;
return fd;
}
const int dgram_errno = errno;
fd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (fd >= 0) {
*is_raw = 1;
return fd;
}
fprintf(stderr,
"Could not open an ICMP socket.\n"
" SOCK_DGRAM: %s\n"
" SOCK_RAW : %s\n"
"Either run as root, grant CAP_NET_RAW with\n"
" sudo setcap cap_net_raw+ep ./ping\n"
"or allow unprivileged ICMP for your group with\n"
" sudo sysctl -w net.ipv4.ping_group_range=\"0 2147483647\"\n",
strerror(dgram_errno), strerror(errno));
return -1;
}
int main(int argc, char **argv)
{
if (argc < 2 || argc > 3) {
fprintf(stderr, "usage: %s <host> [payload-bytes]\n", argv[0]);
return 2;
}
long payload = DEFAULT_PAYLOAD;
if (argc == 3) {
char *end;
payload = strtol(argv[2], &end, 10);
if (*end != '\0' || payload < 0 || payload > MAX_PAYLOAD) {
fprintf(stderr, "payload must be between 0 and %d bytes\n", MAX_PAYLOAD);
return 2;
}
}
struct addrinfo hints;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_RAW;
struct addrinfo *res = NULL;
int rc = getaddrinfo(argv[1], NULL, &hints, &res);
if (rc != 0) {
fprintf(stderr, "%s: %s\n", argv[1], gai_strerror(rc));
return 1;
}
struct sockaddr_in dest = *(struct sockaddr_in *)res->ai_addr;
freeaddrinfo(res);
char dotted[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &dest.sin_addr, dotted, sizeof dotted);
int is_raw = 0;
int fd = open_icmp_socket(&is_raw);
if (fd < 0)
return 1;
struct timeval tv = { 1, 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
printf("PING %s (%s) %ld bytes of data [%s socket]\n",
argv[1], dotted, payload, is_raw ? "raw" : "unprivileged");
const size_t packet_len = sizeof(struct icmphdr) + (size_t)payload;
unsigned char packet[sizeof(struct icmphdr) + MAX_PAYLOAD];
unsigned char reply[RECV_BUFFER];
const unsigned short ident = (unsigned short)getpid();
int received = 0;
for (int seq = 0; seq < PING_COUNT; ++seq) {
memset(packet, 'E', packet_len);
struct icmphdr hdr;
memset(&hdr, 0, sizeof hdr);
hdr.type = ICMP_ECHO;
hdr.code = 0;
hdr.un.echo.id = htons(ident);
hdr.un.echo.sequence = htons((unsigned short)seq);
memcpy(packet, &hdr, sizeof hdr);
hdr.checksum = checksum(packet, packet_len);
memcpy(packet, &hdr, sizeof hdr);
const double sent_at = now_ms();
if (sendto(fd, packet, packet_len, 0,
(struct sockaddr *)&dest, sizeof dest) < 0) {
fprintf(stderr, "sendto: %s\n", strerror(errno));
break;
}
/* A raw socket on loopback also receives our own outgoing echo
request, so keep reading until the reply we asked for turns up
or the timeout expires. */
int got_reply = 0;
while (!got_reply) {
struct sockaddr_in from;
socklen_t fromlen = sizeof from;
ssize_t n = recvfrom(fd, reply, sizeof reply, 0,
(struct sockaddr *)&from, &fromlen);
if (n < 0) {
if (errno == EINTR)
continue; /* a signal, not a timeout */
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("seq=%d timeout\n", seq);
break;
}
fprintf(stderr, "recvfrom: %s\n", strerror(errno));
break;
}
/* A raw socket hands back the IP header; SOCK_DGRAM does not. */
size_t offset = 0;
if (is_raw) {
if ((size_t)n < sizeof(struct iphdr)) continue;
const struct iphdr *ip = (const struct iphdr *)reply;
offset = (size_t)ip->ihl * 4;
}
if ((size_t)n < offset + sizeof(struct icmphdr))
continue;
struct icmphdr in;
memcpy(&in, reply + offset, sizeof in);
if (in.type != ICMP_ECHOREPLY)
continue; /* our own request, or something else */
/* The kernel manages the id on an unprivileged ping socket, so
only a raw socket can match on it. */
if (is_raw && ntohs(in.un.echo.id) != ident)
continue; /* somebody else's ping */
/* Match the sequence we are waiting for, so a late reply to an
earlier request is not counted twice. */
if (ntohs(in.un.echo.sequence) != (unsigned short)seq)
continue;
char fromdot[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &from.sin_addr, fromdot, sizeof fromdot);
printf("%zd bytes from %s: icmp_seq=%u time=%.2f ms\n",
n - (ssize_t)offset, fromdot,
ntohs(in.un.echo.sequence), now_ms() - sent_at);
++received;
got_reply = 1;
}
if (seq + 1 < PING_COUNT)
sleep(1);
}
printf("--- %s statistics ---\n%d sent, %d received, %d%% loss\n",
dotted, PING_COUNT, received,
(PING_COUNT - received) * 100 / PING_COUNT);
close(fd);
return received > 0 ? 0 : 1;
}
Output:
PING localhost (127.0.0.1) 32 bytes of data [raw socket]
40 bytes from 127.0.0.1: icmp_seq=0 time=0.04 ms
40 bytes from 127.0.0.1: icmp_seq=1 time=0.05 ms
40 bytes from 127.0.0.1: icmp_seq=2 time=0.05 ms
40 bytes from 127.0.0.1: icmp_seq=3 time=0.05 ms
--- 127.0.0.1 statistics ---
4 sent, 4 received, 0% loss
The 56 bytes of data is the payload you asked for; the 64 bytes from is what recvfrom returned, which includes the eight-byte ICMP header. On a raw socket the IP header is in there too, which is why the code skips ip->ihl * 4 bytes before reading the ICMP header.
Five Details That Matter
Your raw socket receives your own outgoing packets. This one cost me a debugging session. On loopback, a SOCK_RAW ICMP socket sees the echo request it just sent as well as the reply. My first version read exactly one packet per ping, saw type 8, reported “non-echo reply” and moved on — losing half the replies and reporting 50% packet loss on localhost. The fix is the inner loop: keep reading until the reply you asked for arrives or the timeout fires.
A raw socket gives you the IP header; a datagram socket does not. With SOCK_RAW, recvfrom hands back the full IP packet and you must skip ip->ihl * 4 bytes to reach the ICMP header. With SOCK_DGRAM, the kernel strips it and you start at the ICMP header. Code that assumes one will misparse the other.
The kernel manages the identifier on a Linux ping socket. It uses the id field for its own demultiplexing, so the value you set is not the value that comes back. Matching on the identifier only works on a raw socket, which is why that check is conditional.
Validate the payload size against your buffer. The check here is payload < 0 || payload > MAX_PAYLOAD. Without it, a size taken straight from the command line and written into a fixed buffer is a heap overflow — a memset of 5,000 bytes into a 1,024-byte allocation is what AddressSanitizer reports as:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 5000 at 0x519000000480 thread T0
Use getaddrinfo, not gethostbyname. The old resolver is not thread-safe, is IPv4-only in practice, and is deprecated on every platform including Windows. Likewise inet_ntop rather than inet_ntoa, which returns a pointer to a static buffer.
The Windows Version
The POSIX file does not compile on Windows — the first include fails:
ping.c(6): fatal error C1083: Cannot open include file: 'arpa/inet.h': No such file or directory
Winsock needs its own file. The differences are mechanical but there are a lot of them:
| POSIX | Winsock |
|---|---|
| — | WSAStartup / WSACleanup around everything |
socket() | WSASocketW(...) with WSA_FLAG_OVERLAPPED if you want timeouts |
int fd, close() | SOCKET, closesocket() |
errno / strerror | WSAGetLastError() |
struct icmphdr, struct iphdr | Define them yourself, packed — Winsock has neither |
SO_RCVTIMEO takes a struct timeval | SO_RCVTIMEO takes a DWORD of milliseconds |
clock_gettime(CLOCK_MONOTONIC) | QueryPerformanceCounter |
sleep(1) | Sleep(1000) |
Root or CAP_NET_RAW | Administrator token |
Two of those are easy to get wrong. WSA_FLAG_OVERLAPPED is not optional if you want a timeout — without it a socket from WSASocket is synchronous and non-overlapped, the internal wait code never runs, SO_RCVTIMEO is ignored, and a lost reply blocks forever. And SO_RCVTIMEO takes a plain DWORD on Windows, not the struct timeval the POSIX API expects; pass the wrong one and the call fails quietly.
Define the two headers with #pragma pack(1) and fixed-width types:
#pragma pack(push, 1)
typedef struct icmp_header {
uint8_t type;
uint8_t code;
uint16_t checksum;
uint16_t id;
uint16_t sequence;
} icmp_header;
#pragma pack(pop)
Use uint32_t rather than unsigned long in the IP header. On Windows long is four bytes so either works, but the struct has to be exactly 20 bytes and fixed-width types make that true everywhere — the same file with unsigned long produces a 28-byte header on a 64-bit Linux model.
The full Windows source is in the repository alongside the POSIX one. Build it from a Developer Command Prompt:
cl /nologo /TC /W4 /WX src\ping-windows.c /link Ws2_32.lib
/TC compiles the file as C rather than letting the extension decide. Note there is no /EHsc — that switch configures C++ exception handling and has nothing to do with a C translation unit.
Output:
PING 127.0.0.1 (127.0.0.1) 56 bytes of data [raw socket]
64 bytes from 127.0.0.1: icmp_seq=0 time=0.32 ms
64 bytes from 127.0.0.1: icmp_seq=1 time=0.28 ms
64 bytes from 127.0.0.1: icmp_seq=2 time=0.45 ms
64 bytes from 127.0.0.1: icmp_seq=3 time=0.20 ms
--- 127.0.0.1 statistics ---
4 sent, 4 received, 0% loss
/WX turns warnings into errors, so that build is warning-free rather than warning-tolerant. Against a real host:
PING www.google.com (142.251.155.119) 56 bytes of data [raw socket]
64 bytes from 142.251.155.119: icmp_seq=0 time=78 ms
64 bytes from 142.251.155.119: icmp_seq=1 time=78 ms
--- 142.251.155.119 statistics ---
4 sent, 4 received, 0% loss
One difference worth knowing if you write your own: GetTickCount64 is the obvious timer and it is too coarse for this. It resolves to roughly 15 milliseconds, so every loopback reply comes back as time=0 ms. QueryPerformanceCounter is sub-microsecond and gives the figures above.
The Code, and What the Build Checks
Both implementations live in the MYCPLUS C examples repository, under networking/ping:
networking/ping/
├── README.md
└── src/
├── ping.c POSIX - Linux and macOS
└── ping-windows.c Winsock 2
Two files rather than one, because the platforms disagree about almost everything below the algorithm: header definitions, socket types, timeout options, timer APIs and error reporting. Trying to bridge that with #ifdef produces a file that is harder to read than either half.
Every push rebuilds both on three toolchains with warnings treated as errors — -Werror for GCC and Clang, /WX for MSVC — so a warning fails the build rather than scrolling past:
| Job | Compiler | Platform |
|---|---|---|
| GCC | gcc -std=c11 -Wall -Wextra -pedantic -Werror | Ubuntu |
| Clang | clang -std=c11 -Wall -Wextra -pedantic -Werror | Ubuntu |
| MSVC | cl /TC /W4 /WX | Windows |
Be clear about what that badge does not mean. The build compiles both programs and runs an argument-validation check — no arguments must produce the usage message and exit status 2 — and stops there. It never sends a packet. GitHub-hosted runners have neither reliable raw-socket privileges nor predictable ICMP egress, so a network test there would be flaky rather than informative. A green badge tells you the code compiles clean on three compilers and rejects bad input. The ping results on this page come from real machines, and that is the only way to get them.
Why Your Ping Might Not Get a Reply
Not every silence is a bug in your code:
- The host is filtering ICMP. Plenty of servers and most cloud security groups drop echo requests.
ping google.comworking whileping yourserver.comtimes out usually means policy, not code. - You are behind a NAT that does not track ICMP. Some do not, and replies never make it back.
- The identifier does not match. On a raw socket you will see every ICMP packet the host receives, including other processes’ pings. Filter on your own id.
- Corporate networks block it outright. ICMP is a common casualty of default-deny egress rules.
A timeout is a result, not an error. The implementation above reports it and continues.
Key Takeaways
- ICMP has no ports, so sending a ping needs privilege: root,
CAP_NET_RAW, or a permissiveping_group_rangeon Linux. - Try
SOCK_DGRAMwithIPPROTO_ICMPfirst, then fall back toSOCK_RAW. On a modern Linux desktop the first one often succeeds without any elevation. - Report both failures.
EACCESandEPERMpoint at different fixes, and a program that only mentionssudosends people down the wrong path. - A raw socket also receives your own outgoing packets on loopback. Read in a loop until you get the reply you want.
- Raw gives you the IP header, datagram does not — skip
ip->ihl * 4bytes only in the raw case. - The kernel rewrites the ICMP id on an unprivileged socket, so only match on it when using a raw socket.
- Validate the payload size against your buffer. Taking it straight from
argvand writing it into a fixed allocation is a heap overflow. - Use
getaddrinfoandinet_ntop, notgethostbynameandinet_ntoa.
Frequently Asked Questions
Conclusion
The interesting part of a ping program is not the protocol. Eight bytes of header, a checksum, and a reply to match up — that is an afternoon. The interesting part is everything around it: which socket the kernel will let you open, what it hands back when it does, and why the packet you just sent is sitting in your own receive queue.
That pattern holds for most network programming. The protocol is documented and finite; the platform is neither. Our other C programming guides take the same approach, and if you are working at this layer the Windows sockets programming guide covers the Winsock side in more detail, while the TCP client and server and UDP sender and receiver examples show the same socket API at the transport layer.
What I Could Not Verify
Everything here was compiled and run on one machine: an Ubuntu 24.04 container with GCC 13.3 and Clang 18.1.3, running as root with the ability to change sysctls. macOS is not supported by this file and is not tested. The code uses glibc’s struct icmphdr, which the BSDs do not define, so it will not compile there — the section above says what a port involves. The code uses only POSIX interfaces and netinet/ip_icmp.h, which macOS provides, but I have no Mac and the BSD ICMP header layout differs in field names — treat “Linux and macOS” as “Linux, tested, and macOS, expected”. The Windows program was compiled and run, on one Windows 11 machine with MSVC 19.44.35229. All pings were to loopback. The container’s egress is restricted, so nothing here exercised a real network path, a filtering host, a NAT or a timeout against a live remote — the timeout path is reached in code but was not triggered by a real unresponsive host.



