
In part 5, we joined a WiFi network, and used ‘ping’ to contact another unit on that network, but this was achieved by setting the IP address manually, which is generally known as using a ‘static’ IP.
The alternative is to use a ‘dynamic’ IP, that a central server (such as the WiFi Access Point) allocates from a pool of available addresses, using Dynamic Host Configuration Protocol (DHCP); this also provides other information such as a netmask & router address, to allow our unit to communicate with the wider Internet.
IP addresses and routing
So far, I’ve just said that an IP address consists of 4 bytes, that are usually expressed as decimal values with dotted notation, e.g. 192.168.1.2, but there is some extra complication.
Firstly it is important to note I’m using version 4 of the protocol (IPv4); there is a newer version (IPv6) with a much wider address range, but the older version is sufficient for our purposes, and easier to implement.
Next it is important to distinguish between a public and private IP address.
- Public: an address that is accessible from the Internet, generally assigned by an Internet Service Provider (ISP)
- Private: an address used locally within an organisation, that is not unique; generally assigned from the blocks 192.168.x.x, 172.16.x.x or 10.x.x.x
The address we’ll be getting from the DHCP server is probably private; if we are accessing the Internet, there will be one or more network devices (‘routers’) that perform public-to-private translation, and also security functions (‘firewalls’) to block malicious data.
If our unit has an IP address it wishes to contact, how does it know what to do? It just has to determine if the target address is local or remote by applying a netmask. For example if our unit is given the address 192.168.1.1 with netmask 255.255.255.0, then a logical AND of the two values means that our local network (known as a ‘subnet’) is 192.168.1. If the unit we’re contacting is on that subnet (i.e. the address begins with 192.168.1) then we just send out a local ARP request to convert their IP address into a MAC address, and start communicating.
If the target address isn’t on the same subnet (e.g. 192.168.2.1, 11.22.33.44, or anything else) then our unit contacts a router (using the address given in the DHCP response) and relies on the router to forward the data appropriately.

In the diagram above, there are networks with public addresses 11.22.33.44 and 22.33.44.55, and they both have private addresses in 192.168.1.x subnetworks; the job of the router is to move the data between these subnetworks by performing Network Address Translation (NAT) between them.
If unit 192.168.1.3 wants to contact 22.33.44.55 it will check the netmask, and because the target isn’t on the same subnetwork, the data will be sent to the router 192.168.1.1, which will forward it over the Internet.
If 192.168.1.3 wants to contact 192.168.1.2, ANDing with the netmask will show that they are both on the same subnet, so the data will be sent directly, bypassing using the router.
However, if 192.168.1.3 wants to send the data to 192.168.1.1 on the remote network, how does the router know what to do? The simple answer is “it doesn’t”, as addresses on the 192.168.1.x subnet aren’t unique, and there will be thousands (or millions!) of units with that same address around the world. Also the netmask clearly indicates that 192.168.1.1 must be on the same subnet as 192.168.1.3, so the data will be sent locally to 192.168.1.1, whether it exists or not; if it doesn’t exist, that’ll be flagged up by the ARP request failing.
There are various workarounds for this ‘NAT traversal’ problem, for example 192.168.1.3 sends the data to the router 22.33.44.55, which is configured to copy incoming data to 192.168.1.1, but there are major security risks associated with opening up a system to unfiltered Internet traffic, so for the purposes of this blog, I’m assuming that our unit will only be communicating with other units on the same subnetwork, or publicly-available systems on the Internet.
The above example assumes there is a single router for all outgoing traffic, and this is generally the case on a WiFi network, where the Access Point also acts as a router. However, on more complex networks there can be multiple routers to provide alternative routes to other networks or the Internet.
Client and server
The most common model for communication between two systems is client-server. The server runs continuously, waiting for a client to get in contact. The client uses a specific communications format (a ‘protocol’) to establish a link (‘connection’) to the server. The connection persists for as long as is needed to exchange the data, then it is closed by both sides.
Simpler protocols can dispense with the connection, but still retain the client-server model; for example, to fetch the time with Network Time Protocol (NTP) you just send a single message to a time server, and get a single message back with the time. This ‘connectionless’ approach means that a single ‘stateless’ server can handle very large numbers of clients, since it doesn’t have to track the state of its clients; an incoming request has all the information needed to send the response.
UDP message format
So there are two distinct ways for a client to communicate with a server; one creates a persistent connection, with both sides tracking the flow of data, and re-sending any data that is lost in transit: this is Transmission Control Protocol (TCP). The other way is User Datagram Protocol (UDP), which has no such tracking, or error correction; just send a block of data and hope it arrives.
This uncertainty means that, if faced with a choice, many programmers reject UDP as being too unreliable, however it does have a very important place in the suite of TCP/IP protocols, not least because it is used for DHCP.
A DHCP transmission consists of the following:
- Ethernet header
- IP header
- UDP header
- DHCP header
- DHCP option data
We’ve already used the Ethernet and IP headers when sending an ICMP (ping) message, this time we’re stacking on a UDP header.
/* ***** UDP (User Datagram Protocol) header ***** */
typedef struct udph
{
WORD sport, /* Source port */
dport, /* Destination port */
len, /* Length of datagram + this header */
check; /* Checksum of data, header + pseudoheader */
} UDPHDR;
There is a 16-bit length, which shows the total length of the header plus any data that follows, and a 16-bit checksum, which is calculated in an unusual manner; it incorporates the UDP header, parts of the IP header, and all the data that follows. The way this is calculated is to create a pseudo-header containing the relevant IP parts:
/* ***** Pseudo-header for UDP or TCP checksum calculation ***** */
/* The integers must be in hi-lo byte order for checksum */
typedef struct /* Pseudo-header... */
{
IPADDR sip, /* Source IP address */
dip; /* Destination IP address */
BYTE z, /* Zero */
pcol; /* Protocol byte */
WORD len; /* UDP length field */
} PHDR;
So the UDP code has to prepare two headers, though the pseudo-header is only used for checksum calculation, and can be discarded after that is done.
// Add UDP header to buffer, return byte count
int ip_add_udp(BYTE *buff, WORD sport, WORD dport, void *data, int dlen)
{
UDPHDR *udp=(UDPHDR *)buff;
IPHDR *ip=(IPHDR *)(buff-sizeof(IPHDR));
WORD len=sizeof(UDPHDR), check;
PHDR ph;
udp->sport = htons(sport);
udp->dport = htons(dport);
udp->len = htons(sizeof(UDPHDR) + dlen);
udp->check = 0;
len += ip_add_data(&buff[sizeof(UDPHDR)], data, dlen);
check = add_csum(0, udp, len);
IP_CPY(ph.sip, ip->sip);
IP_CPY(ph.dip, ip->dip);
ph.z = 0;
ph.pcol = PUDP;
ph.len = udp->len;
udp->check = 0xffff ^ add_csum(check, &ph, sizeof(PHDR));
return(len);
}
Port numbers
Another notable feature of the UDP header is the source & destination port numbers, and these deserve some explanation.
A port number can identify a specific service on a server; for example port 80 identifies an HTTP web server, and 67 is a DHCP server. These are ‘well-known’ port numbers and are in the range 0 to 1023. Ports numbered 1024 to 49151 are also used for specific server functionality that isn’t part of the original set, so are known as ‘registered’. The remaining numbers 49152 to 65535 are ‘dynamic’ ports, that are used temporarily by client applications.
When a client wishes to communicate with a server, it will obtain a dynamic port from its operating system, and use that port for the duration of a transaction, releasing it when the transaction is complete. In contrast, a server will generally monopolise a well-known or registered port on a permanent basis, though some servers additionally open up a dynamic port on a short-term basis to handle a specific interaction with the client, such as a file transfer.
Unusually, the DHCP server & client are both assigned well-known numbers, namely UDP 67 and 68. You may see these identified as BOOTP ports, since DHCP is based on the older BOOTP protocol, with some additions.
DHCP message format
DHCP is a 4-step process:
- Discover: the unit broadcasts a request asking for network parameters, such as an IP address it can use, also a router address, and subnet mask.
- Offer: the server responds with some proposed values, that the unit can accept or reject.
- Request: the unit signifies its acceptance of the proposed values
- ACK: the server acknowledges the request, indicating that the parameters have been assigned to the unit.
Once the parameters have been assigned, the server will generally attempt to keep them unchanged, such that every time the unit boots, it will get the same IP address. However, this is not guaranteed, and a busy server with a lot of temporary clients will be forced to re-use addresses from units that haven’t been active for a while.
The message format is based on the older protocol BOOTP:
typedef struct {
BYTE opcode; /* Message opcode/type. */
BYTE htype; /* Hardware addr type (net/if_types.h). */
BYTE hlen; /* Hardware addr length. */
BYTE hops; /* Number of relay agent hops from client. */
DWORD trans; /* Transaction ID. */
WORD secs; /* Seconds since client started looking. */
WORD flags; /* Flag bits. */
IPADDR ciaddr, /* Client IP address (if already in use). */
yiaddr, /* Client IP address. */
siaddr, /* Server IP address */
giaddr; /* Relay agent IP address. */
BYTE chaddr [16]; /* Client hardware address. */
char sname[SNAME_LEN]; /* Server name. */
char bname[BOOTF_LEN]; /* Boot filename. */
BYTE cookie[DHCP_COOKIE_LEN]; /* Magic cookie */
} DHCPHDR;
When making the initial discovery request, many of these values are unused; the ‘cookie’ is filled in with a specific 4-byte value (99, 130, 83, 99) that signal this is a DHCP request, not BOOTP. Then there is a data field with ‘option’ values; each entry has one byte indicating the option type, one byte indicating data length, and that number of data bytes. The options I use in the discovery request are a byte value of 1, indicating it is a discovery message, and 4 parameter values, indicating what should be provided by the server (1 for subnet mask, 3 for router address, 6 for nameserver address and 15 for network name).
// DHCP message options
typedef struct {
BYTE typ1, len1, opt;
BYTE typ2, len2, data[4];
BYTE end;
} DHCP_MSG_OPTS;
// DHCP discover options
DHCP_MSG_OPTS dhcp_disco_opts =
{53, 1, 1, // Msg len 1 type 1: discover
55, 4, {1, 3, 6, 15}, // Param len 4: mask, router, DNS, name
255}; // End
The resulting offer from the server probably includes much more than we asked for; this is what my server returns:
Option: (53) DHCP Message Type (Offer)
Option: (54) DHCP Server Identifier (192.168.1.254)
Option: (51) IP Address Lease Time (7 days)
Option: (58) Renewal Time Value (3 days, 12 hours)
Option: (59) Rebinding Time Value (6 days, 3 hours)
Option: (1) Subnet Mask (255.255.255.0)
Option: (28) Broadcast Address (192.168.1.255)
Option: (15) Domain Name ("home")
Option: (6) Domain Name Server (192.168.1.254)
Option: (3) Router (192.168.1.254)
Option: (255) End
You’ll see that the Access Point 192.168.1.254 is acting as a router and nameserver; we’ll be looking at the Domain Name System (DNS) in the next part of this blog.
If the unit wants to accept these proposed settings, it must send a request containing the proposed IP address. This can have the same format as the discovery, with a byte value of 3, indicating it is a request message, and a the 4-byte address value:
// DHCP request options
DHCP_MSG_OPTS dhcp_req_opts =
{53, 1, 3, // Msg len 1 type 3: request
50, 4, {0, 0, 0, 0}, // Address len 4 (copied from offer)
255}; // End
Assuming all is OK, the ACK response from the server will be similar to the offer, maybe with more values added (such as vendor-specific information), so an important part of the receiver code is the scanning of the parameters to find the values that are needed.
State machine
If we were in a multi-tasking environment, the DHCP process might basically consist of a sequence of 4 function calls, each function stopping (‘blocking’) until it is complete:
send_discovery() receive_offer() send_request() receive_ack()
Since we don’t currently have multi-tasking, we can’t adopt this approach, as it would block any other code from running, and in the event of an error, one of these functions might stall indefinitely. Instead, we have to adopt a ‘polled’ approach, where we keep on re-visiting this process to see what (if anything) has changed. The key to this is to have a single ‘state’ variable that reflects what has happened, e.g. it has a value of 1 when we have sent the discovery, 2 when we have received an offer, and so on.
// Poll DHCP state machine
void dhcp_poll(void)
{
static uint32_t dhcp_ticks=0;
if (dhcp_state == 0 || // Send DHCP Discover
(dhcp_state != DHCPT_ACK && ustimeout(&dhcp_ticks, DHCP_TIMEOUT)))
{
ustimeout(&dhcp_ticks, 0);
IP_ZERO(my_ip);
ip_tx_dhcp(bcast_mac, bcast_ip, DHCP_REQUEST,
&dhcp_disco_opts, sizeof(dhcp_disco_opts));
dhcp_state = DHCPT_DISCOVER;
}
else if (dhcp_state == DHCPT_OFFER) // Received Offer, send Request
{
ustimeout(&dhcp_ticks, 0);
IP_CPY(dhcp_req_opts.data, offered_ip);
ip_tx_dhcp(host_mac, bcast_ip, DHCP_REQUEST,
&dhcp_req_opts, sizeof(dhcp_req_opts));
dhcp_state = DHCPT_REQUEST;
}
}
The polling of the DHCP state also incorporates a timeout, that is triggered in the event of an error; with a simple 4-step protocol like this, we can just restart the process from the beginning, rather than trying to work out where the error occurred.
Example program
There is one example program dhcp.c that fetches IP addresses and netmask from a DHCP server, and prints the result:
Joining network
Joined network
Tx DHCP DISCOVER
Rx DHCP OFFER 192.168.1.240
Tx DHCP REQUEST
Rx DHCP OFFER 192.168.1.240
Rx DHCP OFFER 192.168.1.240
Rx DHCP ACK 192.168.1.240 mask 255.255.255.0 router 192.168.1.254 DNS 192.168.1.254
DHCP complete, IP address 192.168.1.240 router 192.168.1.254
192.168.1.254->192.168.1.240 ARP request
192.168.1.240->192.168.1.254 ARP response
The display mode is set to include DHCP:
set_display_mode(DISP_INFO|DISP_JOIN|DISP_ARP|DISP_DHCP);
This allows you to see the message-passing; it isn’t unusual to receive duplicate messages, and in the DHCP OFFER above. The ARP display is also enabled so you can see the router using ARP to check the newly-assigned address.
It will be necessary to change the default SSID and PASSWD to match your network; for details on how to build & load the application, see the introduction.
Project links | |
---|---|
Introduction | Project overview |
Part 1 | Low-level interface; hardware & software |
Part 2 | Initialisation; CYW43xxx chip setup |
Part 3 | IOCTLs and events; driver communication |
Part 4 | Scan and join a network; WPA security |
Part 5 | ARP, IP and ICMP; IP addressing, and ping |
Part 6 | DHCP; fetching IP configuration from server |
Part 7 | DNS; domain name lookup |
Part 8 | UDP server socket |
Part 9 | TCP Web server |
Part 10 | Web camera |
Source code | Full C source code |
Copyright (c) Jeremy P Bentham 2022. Please credit this blog if you use the information or software in it.