Chapter 88: Raw Socket Implementation in ESP-IDF

Chapter Objectives

After completing this chapter, students will be able to:

  • Understand the concept of raw sockets and their purpose in network programming.
  • Differentiate raw sockets from standard TCP and UDP sockets.
  • Identify use cases where raw sockets are necessary or beneficial.
  • Learn how to create, configure, and use raw sockets in ESP-IDF with LwIP.
  • Understand the basic structure of an IPv4 header and an ICMP header.
  • Implement applications that send custom IP packets (e.g., ICMP Echo Requests) using raw sockets.
  • Implement applications that receive and parse raw IP packets.
  • Be aware of the IP_HDRINCL socket option and its implications.
  • Understand the importance of checksum calculations in IP and ICMP headers.
  • Recognize the need for specific LwIP configurations (LWIP_RAW) to enable raw socket functionality.
  • Appreciate the low-level control and responsibilities that come with using raw sockets.

Introduction

In the preceding chapters, we’ve explored network programming using TCP and UDP sockets. These protocols operate at the transport layer, providing convenient abstractions like reliable streams (TCP) or connectionless datagrams (UDP), handling much of the underlying complexity of packet formatting, addressing, and (for TCP) reliability. However, there are scenarios where developers need finer-grained control over the network packets being sent or need to interact with protocols that operate directly above the IP layer, without the mediation of TCP or UDP.

This is where raw sockets come into play. Raw sockets provide an interface to send and receive IP datagrams directly, bypassing the usual transport layer processing. This allows applications to construct or inspect entire IP packets, including their headers, offering a powerful tool for network diagnostics, implementing custom protocols, or for educational purposes to understand network interactions at a lower level.

In this chapter, we will delve into the world of raw socket programming on ESP32 devices using ESP-IDF and the LwIP TCP/IP stack. We’ll cover the theory behind raw sockets, how to construct and parse IP and ICMP headers, and provide practical examples, such as building a simple ping utility.

Theory

1. What are Raw Sockets?

A raw socket is a type of network socket that allows an application to directly send and receive packets at the Internet Protocol (IP) layer (Layer 3 of the OSI model). Unlike TCP or UDP sockets, which process data at the transport layer (Layer 4), raw sockets provide access to the underlying network protocol data units.

Analogy:

Think of TCP and UDP sockets as full-service postal options. With TCP, you give your message, and the service guarantees it’s packaged correctly, addressed, sent, tracked, and its delivery confirmed. With UDP, you give your message, it’s packaged and addressed, and sent off quickly, but with no delivery guarantee.

Raw sockets, on the other hand, are like having direct access to the mail sorting room and the delivery trucks. You can craft your own envelopes (IP headers), put whatever you want inside (payload, which could be ICMP, a custom protocol, or even malformed TCP/UDP segments for testing), and hand it directly to the IP layer for transmission. Similarly, you can receive entire, unopened envelopes directly from the IP layer.

2. Key Characteristics of Raw Sockets

  • Direct IP Layer Access: Raw sockets operate directly on top of the IP layer. This means the application is responsible for handling protocol headers that TCP/UDP sockets would normally manage.
  • Protocol Flexibility: Applications can send and receive packets for any protocol riding on top of IP (e.g., ICMP, IGMP, OSPF, or even custom experimental protocols). You specify the protocol number when creating the raw socket.
  • Header Construction/Parsing:
    • Sending: The application may need to construct the entire IP header itself if the IP_HDRINCL (IP Header Include) socket option is set. If not, the kernel (or LwIP in our case) will typically prepend an IP header, but the application still provides the payload for the specified IP protocol.
    • Receiving: The application receives the full IP datagram, including the IP header and its payload. It’s the application’s responsibility to parse these headers.
  • Privileges: On traditional operating systems (Linux, Windows, macOS), creating and using raw sockets typically requires administrator or root privileges due to their potential for network abuse (e.g., packet spoofing, denial-of-service attacks) and their ability to see all traffic for a given protocol. On ESP32 with LwIP, this “privilege” is controlled by enabling raw socket support in the LwIP configuration (LWIP_RAW).
  • No Port Abstraction (Typically): While IP headers contain source and destination IP addresses, raw sockets don’t inherently use the concept of “ports” in the same way TCP/UDP do for demultiplexing, as they operate below that layer. Demultiplexing for received raw packets is primarily based on the protocol field in the IP header.
Feature TCP Sockets (SOCK_STREAM) UDP Sockets (SOCK_DGRAM) Raw Sockets (SOCK_RAW)
OSI Layer Transport Layer (Layer 4) Transport Layer (Layer 4) Network/Internet Layer (Layer 3)
Protocol Abstraction Provides reliable, ordered byte stream. Handles TCP headers, ACKs, flow control, congestion control. Provides connectionless datagram service. Handles UDP headers. Direct access to IP datagrams. Application handles IP payload (e.g., ICMP, custom) and potentially IP header itself.
Connection Model Connection-oriented (requires connect(), accept()). Connectionless (though connect() can be used to set a default peer). Connectionless. No concept of a “connection” at the IP layer.
Header Handling Kernel/Stack manages TCP and IP headers. Application deals with payload data only. Kernel/Stack manages UDP and IP headers. Application deals with payload data only. Application receives full IP datagram (IP header + payload). May need to construct IP header for sending (if IP_HDRINCL is set).
Reliability High (guaranteed delivery, ordering). Low (unreliable, unordered). None provided by the socket; depends on the encapsulated protocol (e.g., ICMP has no reliability) or IP itself.
Port Numbers Uses port numbers for application demultiplexing. Uses port numbers for application demultiplexing. Typically does not use port numbers for demultiplexing. Relies on the IP protocol field in the IP header.
Typical Use Cases Web (HTTP/S), Email (SMTP), File Transfer (FTP). DNS, DHCP, VoIP, Online Gaming, Streaming. Ping, Traceroute, custom IP protocols, network monitoring, security testing.
Privileges (Traditional OS) Generally no special privileges required. Generally no special privileges required. Typically requires administrator/root privileges. (On ESP32, controlled by LWIP_RAW config).
Data Unit Stream of bytes. Datagrams (messages). IP Packets/Datagrams.

3. Use Cases for Raw Sockets

  • Network Diagnostic Tools:
    • Ping: Sends ICMP Echo Request packets and listens for ICMP Echo Reply packets to test host reachability and round-trip time. ping is a classic raw socket application.
    • Traceroute: Discovers the network path to a destination host by sending packets with incrementally increasing Time-To-Live (TTL) values and listening for ICMP Time Exceeded messages.
  • Implementing Custom Network Protocols: If you need to develop a new protocol that operates directly over IP and doesn’t fit the TCP or UDP models.
  • Network Monitoring and Packet Sniffing: Capturing and analyzing raw network traffic for a specific protocol (though dedicated packet capture libraries like libpcap are more common for general sniffing on PCs).
  • Security Testing: Crafting specific packets to test firewall rules or intrusion detection systems.
  • Educational Purposes: Learning the internals of IP, ICMP, and other network protocols by constructing and dissecting packets.

4. IPv4 Header Format

When working with raw sockets, especially if you intend to build the IP header yourself (IP_HDRINCL), understanding the IPv4 header structure is crucial. The header is typically 20 bytes long (without options).

---
title: IPv4 Header Format - Block Diagram
---
block-beta
    columns 8
    
    A["Version<br/>4 bits"]:1
    B["IHL<br/>4 bits"]:1
    C["Type of Service<br/>8 bits"]:2
    D["Total Length<br/>16 bits"]:4
    
    E["Identification<br/>16 bits"]:4
    F["Flags<br/>3 bits"]:1
    G["Fragment Offset<br/>13 bits"]:3
    
    H["TTL<br/>8 bits"]:2
    I["Protocol<br/>8 bits"]:2
    J["Header Checksum<br/>16 bits"]:4
    
    K["Source IP Address<br/>32 bits"]:8
    
    L["Destination IP Address<br/>32 bits"]:8
    
    M["Options (if IHL > 5)<br/>Variable"]:6
    N["Padding<br/>Variable"]:2
    
    O["Data Payload<br/>(ICMP, TCP, UDP)"]:8
    
    style A fill:#EDE9FE,stroke:#5B21B6
    style B fill:#EDE9FE,stroke:#5B21B6
    style C fill:#EDE9FE,stroke:#5B21B6
    style D fill:#EDE9FE,stroke:#5B21B6
    style E fill:#EDE9FE,stroke:#5B21B6
    style F fill:#EDE9FE,stroke:#5B21B6
    style G fill:#EDE9FE,stroke:#5B21B6
    style H fill:#EDE9FE,stroke:#5B21B6
    style I fill:#EDE9FE,stroke:#5B21B6
    style J fill:#EDE9FE,stroke:#5B21B6
    style K fill:#EDE9FE,stroke:#5B21B6
    style L fill:#EDE9FE,stroke:#5B21B6
    style M fill:#DBEAFE,stroke:#2563EB
    style N fill:#DBEAFE,stroke:#2563EB
    style O fill:#F3F4F6,stroke:#9CA3AF

Key Fields:

  • Version (4 bits): IP version number (e.g., 4 for IPv4).
  • IHL (Internet Header Length) (4 bits): Length of the IP header in 32-bit words. Minimum value is 5 (for a 20-byte header).
  • Type of Service (TOS) / Differentiated Services Code Point (DSCP) (8 bits): Specifies quality of service parameters.
  • Total Length (16 bits): Total length of the IP datagram (header + data) in bytes.
  • Identification (16 bits): Used for uniquely identifying fragments of an original IP datagram.
  • Flags (3 bits): Control fragmentation (e.g., Don’t Fragment (DF), More Fragments (MF)).
  • Fragment Offset (13 bits): Indicates where a particular fragment belongs in the original IP datagram.
  • Time to Live (TTL) (8 bits): Limits the lifespan of a datagram to prevent it from circulating indefinitely. Decremented by each router.
  • Protocol (8 bits): Identifies the next-level protocol encapsulated in the IP datagram’s data payload (e.g., 1 for ICMP, 6 for TCP, 17 for UDP). This is key for raw socket demultiplexing.
  • Header Checksum (16 bits): A checksum calculated over the IP header fields only, used for error detection in the header.
  • Source IP Address (32 bits): The sender’s IP address.
  • Destination IP Address (32 bits): The intended recipient’s IP address.
  • Options (variable length): Optional fields, not commonly used in basic scenarios. If present, IHL will be > 5.
Field Name Size (Bits) Description Common Values / Notes
Version 4 IP protocol version number. Always 4 for IPv4.
IHL (Internet Header Length) 4 Length of the IP header in 32-bit words. Minimum 5 (for a 20-byte header). Max 15 (for a 60-byte header with options).
Type of Service (TOS) / DSCP 8 Specifies quality of service parameters or differentiated services. Often 0 for default service. Used for QoS marking.
Total Length 16 Total length of the IP datagram (header + data) in bytes. Min 20 (header only, no data). Max 65,535 bytes.
Identification 16 Used to uniquely identify fragments of an original IP datagram if fragmentation occurs. Set by sender; copied to all fragments.
Flags 3 Control fragmentation. Bit 0: Reserved (must be 0). Bit 1: DF (Don’t Fragment). Bit 2: MF (More Fragments). DF=1 prevents fragmentation. MF=1 means this is a fragment and more follow. MF=0 means last/only fragment.
Fragment Offset 13 Indicates where this fragment belongs in the original IP datagram, in units of 8 bytes. 0 for unfragmented packets or the first fragment.
Time to Live (TTL) 8 Limits the lifespan of a datagram. Decremented by each router. Prevents indefinite circulation. Typically 64, 128, or 255 initially. Datagram discarded if TTL reaches 0.
Protocol 8 Identifies the next-level protocol in the data payload. 1 (ICMP), 6 (TCP), 17 (UDP). Key for raw socket demultiplexing.
Header Checksum 16 Error-checking checksum calculated over the IP header fields only. Recalculated by routers as TTL changes.
Source IP Address 32 The sender’s IPv4 address. Must be a valid IP address of the sending interface.
Destination IP Address 32 The intended recipient’s IPv4 address. Can be unicast, multicast, or broadcast.
Options Variable Optional fields for control, debugging, or security. Not commonly used. If present, IHL will be > 5. Header must be padded to a multiple of 32 bits.

Important Note on Byte Order: All multi-byte fields in IP headers (and other network protocol headers like ICMP, TCP, UDP) must be in network byte order (big-endian). Functions like htons() (host to network short) and htonl() (host to network long) are used to convert values from the host’s byte order to network byte order before placing them into the header. ntohs() and ntohl() are used for the reverse conversion.

5. ICMP (Internet Control Message Protocol)

ICMP is a companion protocol to IP, defined in RFC 792. It’s used by network devices, like routers, to send error messages and operational information (e.g., a requested service is not available, or a host or router could not be reached). Ping utilities use ICMP Echo Request and Echo Reply messages.

Basic ICMP Header Structure (after the IP header):

Key ICMP Fields (common for Echo Request/Reply):

  • Type (8 bits): Identifies the ICMP message type.
    • 8: Echo Request (ping request)
    • 0: Echo Reply (ping reply)
    • 3: Destination Unreachable
    • 11: Time Exceeded
  • Code (8 bits): Provides further details for the message type (e.g., for Destination Unreachable, code can specify “Network Unreachable,” “Host Unreachable,” “Port Unreachable”). For Echo Request/Reply, it’s usually 0.
  • Checksum (16 bits): A checksum calculated over the entire ICMP message (header + data).
  • Identifier (16 bits) & Sequence Number (16 bits): Used in Echo Request/Reply messages to match replies with requests. Typically, a ping process will use its process ID as the identifier and an incrementing sequence number for each ping sent.

6. Key Socket API Functions for Raw Sockets

The standard Berkeley Sockets API is used, with specific parameters for raw sockets.

  • socket(domain, type, protocol):
    • domain: AF_INET (for IPv4) or AF_INET6 (for IPv6).
    • type: SOCK_RAW.
    • protocol: Specifies the IP protocol number that this socket will send or receive (e.g., IPPROTO_ICMP for ICMP, IPPROTO_UDP for UDP packets including their headers, IPPROTO_TCP for TCP, or even a custom protocol number). If you specify IPPROTO_RAW (or IPPROTO_IP on some systems), you might receive all IP packets, or you might need to use IP_HDRINCL to build the entire IP packet including the protocol field. LwIP’s behavior for IPPROTO_RAW should be checked. For sending, this protocol number will be placed in the IP header’s protocol field if the kernel/LwIP constructs the IP header.
  • bind(sockfd, addr, addrlen):
    • Optional for raw sockets. If used, it can associate the raw socket with a specific local IP address. When receiving, the socket will then only receive packets destined for that local IP address. If not bound, it might receive packets destined for any local IP address matching the socket’s protocol.
  • sendto(sockfd, buf, len, flags, dest_addr, addrlen):
    • Sends a raw packet.
    • buf: Pointer to the buffer containing the packet data. If IP_HDRINCL is set, this buffer must start with a complete IP header, followed by the payload (e.g., ICMP header and data). If IP_HDRINCL is not set, buf contains only the payload for the protocol specified when the socket was created (e.g., just the ICMP header and data), and LwIP will prepend an IP header.
    • dest_addr: A struct sockaddr_in (for IPv4) containing the destination IP address. The port number in sockaddr_in is usually ignored for raw IP sending.
  • recvfrom(sockfd, buf, len, flags, src_addr, addrlen):
    • Receives a raw IP packet.
    • buf: Buffer to store the incoming packet (which will include the IP header).
    • src_addr: Populated with the source IP address of the received packet.
  • setsockopt(sockfd, level, optname, optval, optlen):
    • Used to set socket options.
    • IP_HDRINCL:
      • level: IPPROTO_IP
      • optname: IP_HDRINCL
      • optval: Pointer to an integer (1 to enable, 0 to disable).
      • When enabled, the application must provide the IP header for outgoing packets.
  • close(sockfd): Closes the raw socket.
Function Purpose for Raw Sockets Key Parameters for Raw Sockets Common Usage
socket() Creates a new raw socket endpoint. domain: AF_INET (or AF_INET6)
type: SOCK_RAW
protocol: IPPROTO_ICMP, IPPROTO_RAW, or other IP protocol number.
Called once at the beginning to obtain a raw socket descriptor. Requires LWIP_RAW enabled in LwIP.
bind() Optional. Associates the raw socket with a specific local IP address. sockfd: Socket descriptor.
addr: struct sockaddr_in with local IP. Port is usually ignored.
If bound, socket only receives packets destined for that local IP. If not, may receive packets for any local IP matching the socket’s protocol.
sendto() Sends a raw IP packet (or payload if IP_HDRINCL is not set). sockfd: Socket descriptor.
buf: Data buffer (may include IP header if IP_HDRINCL).
len: Length of data.
dest_addr: struct sockaddr_in with destination IP. Port often ignored.
Used to transmit custom-crafted packets. Application is responsible for most header content.
recvfrom() Receives an entire raw IP packet, including its IP header. sockfd: Socket descriptor.
buf: Buffer to store received IP packet.
len: Max size of buffer.
src_addr: struct sockaddr_storage to store sender’s IP address.
Used to capture and inspect incoming IP packets matching the socket’s protocol.
setsockopt() Configures socket options. Crucial for IP_HDRINCL. level: IPPROTO_IP
optname: IP_HDRINCL
optval: Pointer to int (1 to enable).
Set IP_HDRINCL to 1 if the application will provide the complete IP header for outgoing packets.
close() Closes the raw socket and releases associated system resources. sockfd: Socket descriptor. Called when communication is finished.

Send Flow:

graph TD
    %% Styles
    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef ioNode fill:#D1FAE5,stroke:#059669,stroke-width:1px,color:#065F46;
    classDef endNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;
    classDef prepNode fill:#FFF7ED,stroke:#A1887F,stroke-width:1px,color:#6D4C41;

    A["Start: Create Raw Socket <br> socket(AF_INET, SOCK_RAW, protocol)"]:::startNode
    A --> B{IP_HDRINCL set?}:::decisionNode

    B -- Yes --> C["Application Constructs Full IP Header <br> (Version, IHL, TTL, Protocol, Src/Dest IP, IP Checksum*, etc.)"]:::prepNode
    C --> D["Application Constructs Payload <br> (e.g., ICMP Header + Data, Custom Protocol Header + Data)"]:::prepNode
    D --> E["Combine IP Header and Payload into Buffer"]:::prepNode
    E --> F["Calculate Payload Checksum <br> (e.g., ICMP Checksum)"]:::prepNode
    F --> G["Call sendto(sock, buffer_with_IP_hdr, len, 0, &dest_addr, ...)"]:::ioNode

    B -- No (LwIP builds IP Header) --> H["Application Constructs Payload Only <br> (e.g., ICMP Header + Data for IPPROTO_ICMP socket)"]:::prepNode
    H --> I["Calculate Payload Checksum <br> (e.g., ICMP Checksum)"]:::prepNode
    I --> J["Call sendto(sock, payload_buffer, len, 0, &dest_addr, ...)"]:::ioNode
    
    G --> K["Packet Sent (or error)"]:::endNode
    J --> K

    subgraph "IP_HDRINCL Path"
        C
        D
        E
        F
        G
    end
    subgraph "No IP_HDRINCL Path"
        H
        I
        J
    end

    

Receive Flow:

graph TD
    %% Styles
    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef ioNode fill:#D1FAE5,stroke:#059669,stroke-width:1px,color:#065F46;
    classDef endNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;
    classDef parseNode fill:#FFF7ED,stroke:#A1887F,stroke-width:1px,color:#6D4C41;

    A["Start: Create & Optionally Bind Raw Socket <br> socket(AF_INET, SOCK_RAW, protocol)"]
    B["Call recvfrom(sock, rx_buffer, buf_len, 0, &src_addr, &addr_len)"]
    C{"Packet Received? (len > 0)"}
    D["Handle Error / Timeout <br> (e.g., log errno, retry)"]
    E["Received Full IP Datagram in rx_buffer <br> (IP Header + IP Payload)"]
    F["Parse IP Header from rx_buffer <br> (Check Version, IHL, Protocol, Src/Dest IP, etc.)"]
    G["Calculate IP Payload Start Offset <br> (typically IP Header Length = IHL * 4 bytes)"]
    H["Parse IP Payload based on IP Header's Protocol field <br> (e.g., ICMP Header, Custom Protocol Header)"]
    I["Verify Payload Checksum if applicable <br> (e.g., ICMP Checksum)"]
    J["Process Data / Respond as Needed"]

    A --> B
    B --> C
    C -- No (Error or Timeout) --> D
    D --> B
    C -- Yes --> E
    E --> F
    F --> G
    G --> H
    H --> I
    I --> J
    J --> B

    class A startNode;
    class B ioNode;
    class C decisionNode;
    class D endNode;
    class E,F,G,H,I parseNode;
    class J processNode;

    subgraph PacketProcessing
        E
        F
        G
        H
        I
        J
    end

7. Checksum Calculation

Both IP headers and ICMP messages (and TCP/UDP) include checksum fields for error detection. The algorithm is standard:

  1. The checksum field itself is set to zero.
  2. The data to be checksummed (e.g., IP header, or ICMP header + ICMP data) is treated as a sequence of 16-bit integers.
  3. These 16-bit integers are added together using one’s complement arithmetic. If the total length is odd, a byte of zeros is padded at the end for checksum calculation purposes (but not transmitted).
  4. The one’s complement of the sum is taken (i.e., flip all bits). This 16-bit result is the checksum.

LwIP might automatically calculate the IP header checksum for outgoing packets even if IP_HDRINCL is set, provided the checksum field in the user-supplied IP header is zero. For ICMP, the application is typically responsible for calculating the ICMP checksum.

8. LwIP Configuration for Raw Sockets

To use raw sockets in ESP-IDF, LwIP must be configured to support them:

  • In menuconfig (or sdkconfig file):
    • Component config ---> LWIP ---> Enable per-interface loopback packets (LWIP_NETIF_LOOPBACK) (often enabled by default or with raw sockets)
    • Component config ---> LWIP ---> Enable RAW API (LWIP_RAW) must be enabled (y).

Without LWIP_RAW enabled, attempts to create SOCK_RAW sockets will fail.

LwIP Configuration Option Menuconfig Path (Typical) Purpose & Importance for Raw Sockets Default State
LWIP_RAW Component config —> LWIP —> Enable RAW API This is the primary switch to enable raw socket functionality in LwIP. If not enabled, attempts to create SOCK_RAW sockets will fail (e.g., with EPROTONOSUPPORT or EACCES). Often disabled by default in minimal configurations; must be explicitly enabled for raw socket programming.
LWIP_NETIF_LOOPBACK Component config —> LWIP —> Enable per-interface loopback packets Allows packets sent by an interface to be looped back and received by the same interface if the destination IP matches one of the interface’s own IPs. Can be useful for testing raw socket applications locally. May be enabled or disabled by default. Check your ESP-IDF version’s defaults.
LWIP_SO_RCVTIMEO Component config —> LWIP —> Enable SO_RCVTIMEO Enables the SO_RCVTIMEO socket option, allowing receive timeouts to be set on sockets, including raw sockets. This is crucial to prevent recvfrom() from blocking indefinitely. Usually enabled by default in recent ESP-IDF versions.
IP_SOF_BROADCAST_RECV
(and similar broadcast/multicast options)
Component config —> LWIP —> Various IP layer options While not strictly raw socket options, if your raw socket application needs to receive broadcast or multicast IP packets, relevant LwIP IP layer options for handling these must be enabled. Varies. Check specific options if dealing with broadcast/multicast.

Practical Examples

Let’s implement a simple ping client that sends ICMP Echo Requests and listens for Echo Replies.

Prerequisite: LwIP Configuration

Ensure LWIP_RAW is enabled in your project’s sdkconfig:

  1. Run idf.py menuconfig.
  2. Navigate to Component config ---> LWIP --->.
  3. Ensure Enable RAW API (LWIP_RAW) is checked ([*]).
  4. Save and exit. Rebuild your project if you changed this setting (idf.py build).

Example 1: Simple Ping Client (ICMP Echo Request)

This example will send an ICMP Echo Request to a specified destination IP.

We will let LwIP construct the IP header, so we only need to build the ICMP packet.

C
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
#include <lwip/netdb.h> // For gethostbyname

// Define PING target IP address
#define PING_TARGET_IP "192.168.1.1" // Replace with a reachable IP on your network (e.g., your router)
#define PING_COUNT 4
#define PING_TIMEOUT_SEC 1 // Timeout for recvfrom in seconds

static const char *TAG = "raw_ping";

// ICMP packet structure (for Echo Request/Reply)
struct icmp_echo_hdr {
    uint8_t type;
    uint8_t code;
    uint16_t checksum;
    uint16_t id;
    uint16_t seqno;
    // char data[]; // Optional data
};

// Calculate checksum for a block of data
// Standard algorithm: sum 16-bit words, fold carries, 1's complement
static uint16_t calculate_checksum(void *data, int len) {
    uint32_t sum = 0;
    uint16_t *ptr = (uint16_t *)data;

    while (len > 1) {
        sum += *ptr++;
        len -= 2;
    }

    // Add leftover byte, if any
    if (len > 0) {
        sum += *(uint8_t *)ptr;
    }

    // Fold 32-bit sum to 16 bits
    while (sum >> 16) {
        sum = (sum & 0xFFFF) + (sum >> 16);
    }
    return (uint16_t)~sum;
}

void ping_task(void *pvParameters) {
    struct sockaddr_in dest_addr;
    dest_addr.sin_family = AF_INET;
    // Resolve hostname if needed, or use inet_addr for IP string
    // For simplicity, using inet_addr directly with PING_TARGET_IP
    if (inet_pton(AF_INET, PING_TARGET_IP, &dest_addr.sin_addr) <= 0) {
        ESP_LOGE(TAG, "inet_pton failed for IP: %s", PING_TARGET_IP);
        vTaskDelete(NULL);
        return;
    }
    // Port is not used by ICMP at this level, but sockaddr_in needs it.
    // LwIP will ignore it for raw ICMP.
    dest_addr.sin_port = 0; 

    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sock < 0) {
        ESP_LOGE(TAG, "Unable to create raw socket: errno %d. Check LWIP_RAW is enabled.", errno);
        vTaskDelete(NULL);
        return;
    }
    ESP_LOGI(TAG, "Raw ICMP socket created.");

    // Set receive timeout
    struct timeval timeout;
    timeout.tv_sec = PING_TIMEOUT_SEC;
    timeout.tv_usec = 0;
    if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) {
        ESP_LOGE(TAG, "Failed to set SO_RCVTIMEO: errno %d", errno);
        // Continue without timeout or handle error
    }

    // ICMP Echo Request packet
    struct icmp_echo_hdr icmp_req;
    uint16_t ping_id = 0x1234; // Arbitrary ID
    char ping_payload[] = "ESP32 Ping Data"; // Optional payload

    ESP_LOGI(TAG, "Pinging %s:", PING_TARGET_IP);

    for (int seq = 0; seq < PING_COUNT; seq++) {
        // Prepare ICMP packet
        icmp_req.type = 8; // ICMP Echo Request
        icmp_req.code = 0;
        icmp_req.checksum = 0; // Zero out checksum field before calculation
        icmp_req.id = htons(ping_id);
        icmp_req.seqno = htons(seq);

        // Create buffer for ICMP header + payload
        char packet_buffer[sizeof(struct icmp_echo_hdr) + sizeof(ping_payload)];
        memcpy(packet_buffer, &icmp_req, sizeof(struct icmp_echo_hdr));
        memcpy(packet_buffer + sizeof(struct icmp_echo_hdr), ping_payload, sizeof(ping_payload));
        
        // Calculate ICMP checksum
        ((struct icmp_echo_hdr*)packet_buffer)->checksum = calculate_checksum(packet_buffer, sizeof(packet_buffer));

        // Send the ICMP packet (LwIP will add IP header)
        int err = sendto(sock, packet_buffer, sizeof(packet_buffer), 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
        if (err < 0) {
            ESP_LOGE(TAG, "Error sending ICMP Echo Request: errno %d", errno);
            vTaskDelay(pdMS_TO_TICKS(1000)); // Wait before retrying or next ping
            continue;
        }

        // Receive ICMP Echo Reply
        char rx_buffer[128]; // Buffer to receive IP packet (IP header + ICMP reply)
        struct sockaddr_storage source_addr;
        socklen_t socklen = sizeof(source_addr);
        
        int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer), 0, (struct sockaddr *)&source_addr, &socklen);

        if (len < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                 ESP_LOGW(TAG, "Ping timeout for seq %d", seq);
            } else {
                ESP_LOGE(TAG, "recvfrom failed: errno %d", errno);
            }
        } else {
            // Packet received, parse it (IP header is present)
            // IP header is typically 20 bytes if no options
            // For simplicity, we assume IPv4 and no IP options.
            // A robust parser would check IP version and IHL.
            if (len >= (20 + sizeof(struct icmp_echo_hdr))) {
                struct ip_hdr *ip_header = (struct ip_hdr *)rx_buffer;
                struct icmp_echo_hdr *icmp_reply = (struct icmp_echo_hdr *)(rx_buffer + (ip_header->v_hl & 0x0F) * 4); // Calculate offset using IHL

                char src_ip_str[INET_ADDRSTRLEN];
                inet_ntop(AF_INET, &((struct sockaddr_in *)&source_addr)->sin_addr, src_ip_str, sizeof(src_ip_str));

                if (icmp_reply->type == 0 && icmp_reply->code == 0 && ntohs(icmp_reply->id) == ping_id && ntohs(icmp_reply->seqno) == seq) {
                     ESP_LOGI(TAG, "Received %d bytes from %s: icmp_seq=%d, ttl=%d", 
                             len, src_ip_str, ntohs(icmp_reply->seqno), ip_header->_ttl);
                } else {
                     ESP_LOGW(TAG, "Received unexpected ICMP packet from %s: type=%d, code=%d, id=%u, seq=%u", 
                             src_ip_str, icmp_reply->type, icmp_reply->code, ntohs(icmp_reply->id), ntohs(icmp_reply->seqno));
                }
            } else {
                ESP_LOGW(TAG, "Received packet too short (%d bytes) to be a valid ICMP Echo Reply.", len);
            }
        }
        vTaskDelay(pdMS_TO_TICKS(1000)); // Wait 1 second between pings
    }

    ESP_LOGI(TAG, "Ping task finished.");
    close(sock);
    vTaskDelete(NULL);
}

// In your app_main:
// ESP_ERROR_CHECK(nvs_flash_init());
// ESP_ERROR_CHECK(esp_netif_init());
// ESP_ERROR_CHECK(esp_event_loop_create_default());
// ESP_ERROR_CHECK(example_connect()); // Your Wi-Fi connection function
// xTaskCreate(ping_task, "ping_task", 4096, NULL, 5, NULL);

Explanation:

  1. Includes and Defines: Standard headers, plus lwip/netdb.h. PING_TARGET_IP should be changed to an IP on your local network.
  2. icmp_echo_hdr struct: Defines the structure of an ICMP echo packet.
  3. calculate_checksum(): A standard checksum calculation function.
  4. ping_task():
    • Sets up dest_addr for the target IP.
    • Creates a raw socket for IPPROTO_ICMP. This tells LwIP we want to send/receive ICMP packets.
    • Sets a receive timeout (SO_RCVTIMEO) for recvfrom.
    • Loops PING_COUNT times:
      • Constructs an icmp_echo_hdr packet for an Echo Request (type 8, code 0).
      • Sets an identifier and sequence number (converted to network byte order).
      • Copies the ICMP header and optional payload into packet_buffer.
      • Calculates the ICMP checksum over the ICMP header and payload and places it in the packet.
      • sendto(): Sends the ICMP packet. LwIP will prepend the necessary IP header because IP_HDRINCL is not set.
      • recvfrom(): Waits for a reply.
      • If a packet is received (len > 0):
        • It’s an entire IP packet. The first part is the IP header, followed by the ICMP message.
        • ip_header->v_hl & 0x0F) * 4 calculates the IP header length to find the start of the ICMP reply.
        • It checks if the received ICMP packet is an Echo Reply (type 0, code 0) and if the ID and sequence number match the sent request.
        • Logs relevant information.
  5. The task closes the socket and deletes itself after the pings.

Note on IP Header Structure in recvfrom:

The struct ip_hdr is typically defined in LwIP’s ip4_hdr.h (or similar, may need to include lwip/ip_addr.h, lwip/ip4.h). If you don’t have it directly available, you’d define a minimal version or access fields by offsets. The example above assumes a struct ip_hdr like:

C
// Minimal struct ip_hdr for parsing (ensure fields match LwIP's actual struct or access by offset)
struct ip_hdr {
    uint8_t  v_hl;        /* version / header length */
    uint8_t  tos;         /* type of service */
    uint16_t len;         /* total length */
    uint16_t id;          /* identification */
    uint16_t off;         /* fragment offset field */
    uint8_t  ttl;         /* time to live */
    uint8_t  proto;       /* protocol */
    uint16_t chksum;      /* checksum */
    uint32_t src;         /* source and dest address */
    uint32_t dest;
};

You might need to include lwip/ip4_hdr.h or define the necessary parts of struct ip_hdr if it’s not directly accessible. For the example, ip_header->_ttl was used as a placeholder; you should use the correct field name from LwIP’s struct ip_hdr (e.g., ip_header->ttl or IPH_TTL(ip_header) macro if available). The example uses (ip_header->v_hl & 0x0F) * 4 which is a common way to get IP header length using the IHL field.

Build Instructions

  1. Create Project: Standard ESP-IDF project setup.
  2. Add Code: Place the ping example code in your main.c or a new C file.
  3. Configure LwIP: Ensure LWIP_RAW is enabled in menuconfig as described previously.
  4. Include Wi-Fi/Network Setup: Ensure your app_main initializes NVS, netif, event loop, and connects to your Wi-Fi network. Then, create the ping_task.
  5. Build: idf.py build
  6. Flash: idf.py -p (PORT) flash
  7. Monitor: idf.py -p (PORT) monitor

Run/Flash/Observe Steps

  1. Modify PING_TARGET_IP in the code to a valid IP address on your network that is expected to respond to pings (e.g., your computer, your router, or another ESP32).
  2. Flash and run the application on your ESP32.
  3. Observe the serial monitor output. You should see:
    • “Raw ICMP socket created.”
    • “Pinging <TARGET_IP>:”
    • For each successful ping, a line like: “Received XX bytes from <TARGET_IP>: icmp_seq=Y, ttl=Z”
    • Or “Ping timeout for seq Y” if no reply is received within PING_TIMEOUT_SEC.
  4. Wireshark (Optional but Recommended):
    • Run Wireshark on a computer on the same network.
    • Use a display filter like icmp or ip.addr == <ESP32_IP_ADDRESS>.
    • You should see the ICMP Echo Request packets sent by the ESP32 and the corresponding Echo Reply packets from the target. Examine their structure.

Example 2: Using IP_HDRINCL (Conceptual)

If you wanted to construct the IP header yourself, you would:

  1. Set the IP_HDRINCL socket option:int enable_hdrincl = 1; if (setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &enable_hdrincl, sizeof(enable_hdrincl)) < 0) { ESP_LOGE(TAG, "Failed to set IP_HDRINCL: errno %d", errno); // ... handle error ... }
  2. Your packet_buffer for sendto() would need to start with a fully populated struct ip_hdr, followed by your ICMP header and payload.
  3. You would be responsible for filling all necessary IP header fields: version, IHL, total length (IP header + ICMP packet), TTL, protocol (set to IPPROTO_ICMP), source IP (ESP32’s IP), destination IP.
  4. IP Header Checksum: LwIP might still calculate the IP header checksum for you if you set the checksum field in your custom IP header to 0. If you calculate it yourself, ensure it’s correct. The ICMP checksum still needs to be calculated over the ICMP part.

This approach gives more control but also more responsibility. For sending standard ICMP, letting LwIP handle the IP header is often simpler and less error-prone.

Variant Notes

The core LwIP raw socket API and its behavior are generally consistent across ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, and ESP32-H2 variants, provided LWIP_RAW is enabled in the ESP-IDF LwIP configuration.

  • LWIP_RAW Configuration: This is the primary prerequisite and applies to all variants. Without it, socket(AF_INET, SOCK_RAW, ...) will fail.
  • RAM and CPU Resources:
    • Raw socket operations, especially if receiving many packets or crafting complex ones, can consume RAM for packet buffers (pbufs in LwIP) and CPU for header processing and checksum calculations.
    • Variants with more RAM (e.g., ESP32-S3, or ESP32 with PSRAM) and faster CPUs might handle very high raw packet rates more effectively, but for typical use cases like ping or occasional custom packet sending, all variants should be capable.
  • Network Interface Performance: The underlying performance of the Wi-Fi or Ethernet interface on the specific variant will naturally affect how quickly raw packets can be sent or received.
  • Checksum Offloading: Some more advanced network interface controllers (NICs) on desktop systems offer hardware checksum offloading. On ESP32, checksum calculations for IP and ICMP are typically done in software by LwIP or the application.

In essence, the software API for raw sockets is uniform. Practical performance limits will be dictated by the hardware resources of the chosen ESP32 variant and the LwIP configuration.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
LWIP_RAW Not Enabled socket(AF_INET, SOCK_RAW, …) fails, often with errno EPROTONOSUPPORT or EACCES. Fix: Ensure Component config —> LWIP —> Enable RAW API (LWIP_RAW) is checked in menuconfig. Save configuration and rebuild the project.
Incorrect Header Construction (IP/ICMP) Packets not reaching destination, ignored by peer, or malformed packets seen in Wireshark. Incorrect protocol behavior. Fix: Meticulously check RFCs for IP and the specific protocol (e.g., ICMP). Verify all fields: lengths, byte order (htons/htonl), protocol numbers, source/destination IPs (especially if IP_HDRINCL is used). Use Wireshark to compare sent packets with known good ones.
Checksum Errors Packets dropped by recipient or intermediate routers. ICMP error messages might be generated. Fix: Implement a standard checksum algorithm correctly. Ensure the checksum field itself is zeroed out before calculation. For IP header checksum with IP_HDRINCL, LwIP might calculate it if the field is 0; verify this behavior. Application must calculate ICMP checksum.
IP_HDRINCL Misunderstanding Sending only payload when IP_HDRINCL is set, or providing IP header when it’s not set (and LwIP expects to build it). Fix: If IP_HDRINCL is enabled, application provides the full IP header. If disabled (default for IPPROTO_ICMP raw sockets), LwIP builds IP header; application provides only IP payload (e.g., ICMP packet).
Insufficient Buffer Size for recvfrom() Received packets appear truncated; recvfrom() might return a smaller length than the actual packet on the wire if buffer is too small. Potential for misinterpreting data. Fix: Ensure receive buffer is large enough for the network’s MTU (e.g., ~1500 bytes for Ethernet) or at least the largest expected IP packet.
Firewall/Network Issues Packets sent by ESP32 don’t arrive at destination, or replies don’t come back. Ping might fail to specific targets. Fix: Check firewalls on ESP32’s host PC, target machine, and routers. ICMP traffic is sometimes filtered. Ensure target is configured to respond (e.g., accept pings).
Incorrect Byte Order for Multi-byte Fields Header fields (lengths, IDs, checksums, IPs, ports within payload) are misinterpreted by recipient or when parsing. Fix: Always use htons(), htonl() when constructing headers, and ntohs(), ntohl() when parsing received multi-byte fields.
Parsing Received IP Packet Incorrectly Accessing payload (e.g., ICMP data) at wrong offset in receive buffer; misinterpreting IP header fields. Fix: Remember recvfrom() on a raw socket returns the full IP packet. Correctly parse the IP header first (especially IHL to find header length) to locate the start of the IP payload (e.g., ICMP header).

Tip: Wireshark is your best friend when debugging raw socket issues. It allows you to see exactly what’s being sent and received on the network.

Exercises

  1. Custom ICMP Data Payload:
    • Modify the provided ping example to include a custom string (e.g., “My ESP32 Ping Test!”) as the data payload of the ICMP Echo Request.
    • When an Echo Reply is received, verify that the payload in the reply matches the payload sent.
    • Log the received payload.
  2. IP Packet Inspector (Basic):
    • Create a raw socket to receive all ICMP packets (IPPROTO_ICMP) or, for a broader scope (if LwIP allows easily for IPPROTO_IP or similar to get all IP packets – check LwIP behavior), try to receive multiple IP protocols.
    • In the receive loop, parse and print the following fields from the IPv4 header of each received packet:
      • Source IP Address
      • Destination IP Address
      • Protocol number
      • Time To Live (TTL)
      • Total Length
    • Generate different types of IP traffic towards your ESP32’s IP address (e.g., ping it, try a UDP connection to a closed port) and observe the output.
  3. IP_HDRINCL Ping:
    • Modify the ping example to use the IP_HDRINCL socket option.
    • Your application must now construct the entire IPv4 header in addition to the ICMP Echo Request packet.
    • You will need to:
      • Determine the ESP32’s own IP address to use as the source IP.
      • Fill in all necessary IP header fields (Version, IHL, TOS, Total Length, ID, Flags, Fragment Offset, TTL, Protocol (IPPROTO_ICMP), Source IP, Destination IP).
      • Calculate the IP header checksum (or set it to 0 and see if LwIP calculates it).
      • The ICMP checksum calculation remains the same.
    • Test if your IP_HDRINCL ping works. This is more complex but provides deeper insight.

Summary

  • Raw sockets offer low-level network access, allowing applications to send/receive IP datagrams directly, bypassing transport layer (TCP/UDP) processing.
  • They are created using socket(AF_INET, SOCK_RAW, protocol), where protocol specifies the IP protocol number (e.g., IPPROTO_ICMP).
  • The IP_HDRINCL socket option, if set, requires the application to build the entire IP header for outgoing packets. Otherwise, LwIP typically prepends the IP header.
  • Applications using raw sockets are responsible for constructing and parsing relevant protocol headers (e.g., ICMP) and calculating checksums.
  • LwIP requires LWIP_RAW to be enabled in menuconfig for raw socket functionality.
  • Common use cases include network diagnostics (ping, traceroute), implementing custom protocols, and educational exploration of network layers.
  • Raw sockets provide great power and flexibility but also demand careful implementation and understanding of network protocols.

Further Reading

  • ESP-IDF Programming Guide:
  • LwIP Project Documentation:
    • LwIP Wiki: http://lwip.wikia.com/wiki/LwIP_Wiki
    • LwIP rawapi.txt or similar documentation within the LwIP source code for details on its raw API implementation.
    • LwIP header files (e.g., lwip/raw.h, lwip/ip4_hdr.h, lwip/icmp.h).
  • RFCs (Request for Comments):
    • RFC 791: Internet Protocol (IP)
    • RFC 792: Internet Control Message Protocol (ICMP)
  • Books:
    • “TCP/IP Illustrated, Vol. 1: The Protocols” by W. Richard Stevens – An indispensable reference for understanding TCP/IP protocols in depth.
    • “Unix Network Programming, Vol. 1: The Sockets Networking API” by W. Richard Stevens, Bill Fenner, and Andrew M. Rudoff – A classic text on socket programming.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top