Here's the video walkthrough, if you'd rather watch it:

Why I Started Using This Tool

After the UDPTx episode my DE10-Nano was broadcasting its frame every second, and I could watch it in Python and in Wireshark. But it was a one-way conversation. The whole reason the frame has a control register is so the PC can tell the board what to do, and nothing on the board was listening yet. I switch between a desktop and a laptop on my bench, so I wanted any of my PCs to be able to send a command without recompiling the board. I also wanted those commands addressed to the board itself, not broadcast to everything on the network. When I sat down to write the receive side, one question stopped me: how do I stop a thread that is asleep waiting for data that may never come?

What It Does

udprx.h / udprx.c is a fourth task in the same Start / loop / Stop style as SW alive, HW alive, and UDPTx. It:

  1. Binds a UDP socket to port 5001, the same FRAME_UDP_PORT the TX task broadcasts on.
  2. Blocks in recvfrom(). The thread sleeps inside the kernel and uses no CPU until a datagram arrives.
  3. Accepts frames from any PC, and notes who sent each one. The PC should send straight to the board's IP address (unicast), not broadcast.
  4. Checks what it is. It must be 16 bytes, start with 0xDE10, and be a WRITE frame.
  5. Updates the shared frame's control_reg with the get, change, set pattern from episode #13.
  6. Calls TODO hook functions with the new control register, so the next step only has to fill them in.

On the board you'll see:

[UDPRx] listening on UDP port 5001, accepting WRITE frames from any PC
[UDPRx] from 192.168.1.100 control 0x00000000 -> 0x00000001
[UDPRx] TODO: write 0x00000001 to FPGA
[UDPRx] from 192.168.1.101 control 0x00000001 -> 0x000000A5
[UDPRx] TODO: write 0x000000A5 to FPGA
...
[UDPRx] stopped, 3 frames applied, 61 dropped, 0 errors, uptime 60 s

Don't worry about the 61 dropped frames. We'll see exactly where they come from.

Sending vs listening: bind()

In episode #14 I said the TX socket didn't need bind(), because the kernel picks any free local port for a socket that only sends. A receiving socket is different. The PC sends to "board IP, port 5001", so our socket must claim port 5001, or the kernel has nowhere to deliver the datagram and just throws it away. That's what bind() does: it ties the socket to a local address and port.

We bind to INADDR_ANY, which means "any of my network interfaces". The board doesn't need to know its own IP address, and that's handy, because DHCP can change it.

Blocking recvfrom

recvfrom() is the receiving mirror of sendto():

ssize_t recvfrom(int fd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • It blocks by default. If no datagram is waiting, the thread is put to sleep until one arrives. That's the whole point of giving RX its own thread: it can wait as long as it likes while main, SW alive, HW alive, and UDPTx keep running.
  • It returns the number of bytes in the datagram, or -1 with the reason in errno.
  • It fills in src_addr with the sender's IP and port. That's how we know who sent it, so we can log it, and later reply to it.
  • addrlen goes in and comes out. You set it to sizeof(from) before every call, and the kernel writes back how much it used. Forgetting to reset it inside the loop is a classic bug.

With UDP, one recvfrom returns exactly one datagram. If the PC sends 16 bytes, we get those 16 bytes in one piece, never half a frame.

Any PC can send, but send to the board's IP

UDP has no login and no connection. Any PC that can reach port 5001 can send us a frame, and for this project that's exactly what I want. I don't want to recompile the board every time I switch laptops. So there's no sender filter. We just record the sender's address from recvfrom and print it next to every command, so the log always shows who changed what.

What we do ask of the PC is to send to the board's own IP address, for example 192.168.1.50:5001. That's called unicast: one sender, one receiver. The PC could broadcast its command to 255.255.255.255, and because we bind to INADDR_ANY the board would still receive it. But then every device on the network gets a copy, and if you ever have two boards on the bench, a broadcast would switch both of them. Unicast means "this command is for this board".

Here's the surprise that explains those dropped frames: the board hears its own broadcasts. UDPTx sends to 255.255.255.255:5001, and our RX socket is listening on port 5001 of the same machine, so every second a status frame arrives from the board itself. We don't need an IP check to get rid of it. It's a READ frame, and RX only acts on WRITE frames, so the control-code check drops it. That's also why we count those drops quietly instead of printing a line for each one. Printing every second would bury the log.

Be honest with yourself about what "any PC" means, though: anyone on your network can switch your board. On a bench network that's fine. Anywhere else you'd want something like a shared key and a checksum.

ntohl: the mirror of htonl

The PC sends every field big-endian (network order), just like our TX task does. The ARM core is little-endian, so we convert each field we read with ntohl(), "network to host long". It's the exact mirror of htonl(). Skip it, and a control value of 1 arrives as 0x01000000.

udprx.h

#ifndef UDPRX_H
#define UDPRX_H

#include <pthread.h>
#include <time.h>
#include <netinet/in.h>
#include "mytype.h"

typedef struct udprx_s
{
    pthread_t           thread_id;
    volatile BOOLEAN    running;
    INT32               sock_fd;
    time_t              start_time;
    UINT32              rx_count;
    UINT32              drop_count;
    UINT32              error_count;
} UDPRx;

INT32 UDPRx_start(void);
void  UDPRx_stop(void);

#endif /* UDPRX_H */

Compared with UDPTx:

  • No dest, and no IP address at all. TX needed to know where to send. RX doesn't need to know anyone in advance, because recvfrom tells it who sent each frame.
  • No interval_sec. RX doesn't run on a timer. It runs when data arrives.
  • Three counters. rx_count counts frames we actually applied, drop_count counts frames we rejected, and error_count counts real socket errors. When something goes wrong, those three numbers tell you where to look.

udprx.c

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "frame.h"
#include "udprx.h"

static UDPRx g_udprx = { .sock_fd = -1 };

/* Seconds from a clock that only ever moves forward. */
static time_t now_sec(void)
{
    struct timespec ts;

    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec;
}

static void udprx_close(UDPRx *task)
{
    if (task->sock_fd >= 0)
    {
        close(task->sock_fd);
        task->sock_fd = -1;
    }
}

/* ---- TODO hooks: called when new control data arrives ---- */

/* control_reg is what the PC wants written into the FPGA. */
static void udprx_todo_fpga_write(UINT32 control_reg)
{
    /* TODO: write control_reg into the FPGA. */
    printf("[UDPRx] TODO: write 0x%08X to FPGA\n", (unsigned int)control_reg);
}

/* One place to handle new control data. More hooks can be added here later. */
static void udprx_on_control(UINT32 control_reg)
{
    udprx_todo_fpga_write(control_reg);
}

static void *udprx_thread(void *arg)
{
    UDPRx               *task = (UDPRx *)arg;
    Frame               rx;
    Frame               frame;
    struct sockaddr_in  from;
    socklen_t           from_len;
    ssize_t             got;
    UINT32              old_reg;
    char                ip[INET_ADDRSTRLEN];

    while (task->running)
    {
        /* Blocks here until a datagram arrives, or until Stop wakes us up. */
        from_len = sizeof(from);
        got = recvfrom(task->sock_fd, &rx, sizeof(rx), 0,
                       (struct sockaddr *)&from, &from_len);

        if (!task->running)
        {
            break;      /* woken up by UDPRx_stop */
        }

        if (got < 0)
        {
            if (errno != EINTR)
            {
                task->error_count++;
                fprintf(stderr, "[UDPRx] recvfrom failed: %s\n", strerror(errno));
            }
            continue;
        }

        /* Any PC may send. Keep its address as text for the log. */
        inet_ntop(AF_INET, &from.sin_addr, ip, sizeof(ip));

        /* 1. Exactly one frame, with our header. */
        if (got != (ssize_t)sizeof(Frame) || ntohl(rx.header) != FRAME_HEADER)
        {
            task->drop_count++;
            printf("[UDPRx] bad frame from %s (%ld bytes)\n", ip, (long)got);
            continue;
        }

        /* 2. Only WRITE frames carry new control data.
              This also quietly drops our own READ broadcasts. */
        if (ntohl(rx.ctrl_code) != FRAME_CTRL_WRITE)
        {
            task->drop_count++;
            continue;
        }

        /* Good frame: get, change, set. */
        Frame_get(&frame);
        old_reg           = frame.control_reg;
        frame.control_reg = ntohl(rx.control_reg);
        Frame_set(&frame);
        task->rx_count++;

        printf("[UDPRx] from %s control 0x%08X -> 0x%08X\n", ip,
               (unsigned int)old_reg, (unsigned int)frame.control_reg);

        udprx_on_control(frame.control_reg);
    }

    return NULL;
}

INT32 UDPRx_start(void)
{
    INT32               rc;
    int                 on = 1;
    struct sockaddr_in  local;

    if (g_udprx.running)
    {
        return 0;   /* already started, nothing to do */
    }

    g_udprx.sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (g_udprx.sock_fd < 0)
    {
        rc = errno;
        fprintf(stderr, "[UDPRx] socket failed: %s\n", strerror(rc));
        return rc;
    }

    /* Let us restart the app straight away without "Address already in use". */
    if (setsockopt(g_udprx.sock_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
    {
        rc = errno;
        fprintf(stderr, "[UDPRx] SO_REUSEADDR failed: %s\n", strerror(rc));
        udprx_close(&g_udprx);
        return rc;
    }

    /* Claim port 5001 on every interface. */
    memset(&local, 0, sizeof(local));
    local.sin_family      = AF_INET;
    local.sin_port        = htons(FRAME_UDP_PORT);
    local.sin_addr.s_addr = htonl(INADDR_ANY);
    if (bind(g_udprx.sock_fd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        rc = errno;
        fprintf(stderr, "[UDPRx] bind to port %u failed: %s\n",
                (unsigned int)FRAME_UDP_PORT, strerror(rc));
        udprx_close(&g_udprx);
        return rc;
    }

    g_udprx.rx_count    = 0;
    g_udprx.drop_count  = 0;
    g_udprx.error_count = 0;
    g_udprx.start_time  = now_sec();
    g_udprx.running     = TRUE;

    printf("[UDPRx] listening on UDP port %u, accepting WRITE frames from any PC\n",
           (unsigned int)FRAME_UDP_PORT);

    rc = pthread_create(&g_udprx.thread_id, NULL, udprx_thread, &g_udprx);
    if (rc != 0)
    {
        g_udprx.running = FALSE;
        udprx_close(&g_udprx);
        fprintf(stderr, "[UDPRx] pthread_create failed: %s\n", strerror(rc));
        return rc;
    }

    return 0;
}

void UDPRx_stop(void)
{
    if (!g_udprx.running)
    {
        return;     /* never started, or already stopped */
    }

    g_udprx.running = FALSE;

    /* The thread is probably asleep in recvfrom. Wake it up. */
    shutdown(g_udprx.sock_fd, SHUT_RDWR);

    pthread_join(g_udprx.thread_id, NULL);
    udprx_close(&g_udprx);

    printf("[UDPRx] stopped, %u frames applied, %u dropped, %u errors, uptime %ld s\n",
           (unsigned int)g_udprx.rx_count,
           (unsigned int)g_udprx.drop_count,
           (unsigned int)g_udprx.error_count,
           (long)(now_sec() - g_udprx.start_time));
}

UDPRx_start, step by step

The same "fail early, undo only what's done" pattern as UDPTx_start:

  1. Already running? Return 0.
  2. socket(AF_INET, SOCK_DGRAM, 0) gives us an IPv4 UDP socket.
  3. SO_REUSEADDR. Without it, if you stop and restart the app quickly, bind can sometimes fail with Address already in use. It's a one-line quality-of-life fix.
  4. bind() to INADDR_ANY:5001. Note htons for the port and htonl for the address. Both go into the struct in network order. If another program is already holding port 5001, this is where you find out.
  5. Reset the counters, set running = TRUE, then pthread_create. Same order as TX: the flag is set before the thread exists.

The thread loop

Each time round the loop:

  1. Reset from_len and call recvfrom. The thread sleeps here until a datagram arrives.
  2. Check running first. If Stop woke us up, leave straight away.
  3. got < 0 is a real error. Count it and go round again. EINTR just means a signal interrupted the wait, so it's not counted.
  4. Note who sent it. There's no sender check, because any PC is welcome. inet_ntop turns the sender's 4-byte address into text like 192.168.1.100, only so we can print it.
  5. Filter 1, size and header. It must be exactly 16 bytes and start with 0xDE10. Anything else is someone else's traffic, or a bug in our sender.
  6. Filter 2, the control code. Only FRAME_CTRL_WRITE frames change the control register. Every READ frame is ignored, and that includes the board's own broadcast, which arrives here once a second.
  7. Get, change, set. Copy the shared frame out, remember the old control_reg (only for the log line), put in the new one, and write the whole frame back. We deliberately don't copy the PC's status_reg. Status belongs to the board, and the PC doesn't get to write it.
  8. Call udprx_on_control(new).

The TODO hooks: control goes to the FPGA, status comes from it

The user LED is already handled by the HW alive task, so the control register isn't for the LED. The DE10-Nano is an ARM plus an FPGA on one chip, and our two registers map onto it like this:

Register Direction Job
control_reg PC โ†’ board โ†’ FPGA A value the PC wants written into the FPGA
status_reg FPGA โ†’ board โ†’ PC A value read back from the FPGA and broadcast by UDPTx

This episode only builds the receive half of that path:

  • udprx_on_control(control_reg) is the one place new control data arrives. Today it just calls one hook. When there's more to do with the value later, it gets added here and the receive loop stays the same.
  • udprx_todo_fpga_write(control_reg) is the stub. For now it only prints what it would write. It's called for every accepted WRITE frame, even if the value didn't change. That way, sending the same value again re-writes the FPGA, which is handy if the FPGA was reloaded.
  • status_reg isn't touched by RX at all. Filling it with values read from the FPGA is a separate job. Whatever does it will use the same Frame_get / change status_reg / Frame_set pattern that main.c uses today, and UDPTx will broadcast it with no changes.

Right now the hook only prints a TODO line, and that's on purpose. We can test the whole receive path, header check, byte order, and shared frame, before touching the FPGA. When the FPGA write is filled in, none of the networking code has to change.

One thing to remember: the hooks run on the RX thread. If a hook blocks for a long time, no new frames get received until it returns. Keep them quick.

UDPRx_stop and the blocking problem

Here's the catch with a blocking recvfrom. In the other tasks, Stop sets running = FALSE, and within a second the thread wakes from sleep(1), sees the flag, and exits. The RX thread isn't sleeping on a timer. It's sleeping inside recvfrom, waiting for a datagram that might never come. If we only clear the flag, pthread_join would wait forever and the app would hang on exit.

So after clearing the flag we call shutdown(sock_fd, SHUT_RDWR). On Linux this marks the socket as shut down and wakes up any thread blocked on it. recvfrom returns straight away, the loop sees running == FALSE, and the thread exits. On an unconnected UDP socket, shutdown itself returns -1 with ENOTCONN, but it still does the wake-up, so we ignore the return value.

Then it's the same order as every task before it: join first, then close the socket, then print the totals. Why not just close() the socket to wake the thread? Because closing a file descriptor while another thread is still using it is a race. The number can get reused by the next open() somewhere else in the program.

The other common answer is a receive timeout: setsockopt(..., SO_RCVTIMEO, ...) with one second makes recvfrom give up every second, just like sleep(1) in the other tasks. It works, but the thread then wakes up every second for nothing. With shutdown the thread stays truly asleep until there's real work.

Wiring it into main.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "frame.h"
#include "swalive.h"
#include "hwalive.h"
#include "udptx.h"
#include "udprx.h"

int main(void)
{
    Frame frame;

    setvbuf(stdout, NULL, _IOLBF, 0);

    if (SWAlive_start() != 0)
    {
        fprintf(stderr, "could not start SW alive task\n");
        return EXIT_FAILURE;
    }

    if (HWAlive_start() != 0)
    {
        fprintf(stderr, "could not start HW alive task\n");
        SWAlive_stop();
        return EXIT_FAILURE;
    }

    /* Status bit 0 = "I'm running". */
    Frame_get(&frame);
    frame.status_reg = 0x1u;
    Frame_set(&frame);

    if (UDPTx_start() != 0)
    {
        fprintf(stderr, "could not start UDP TX task\n");
        HWAlive_stop();
        SWAlive_stop();
        return EXIT_FAILURE;
    }

    if (UDPRx_start() != 0)
    {
        fprintf(stderr, "could not start UDP RX task\n");
        UDPTx_stop();
        HWAlive_stop();
        SWAlive_stop();
        return EXIT_FAILURE;
    }

    printf("Main running for 60 s.\n");
    sleep(60);

    UDPRx_stop();
    UDPTx_stop();
    HWAlive_stop();
    SWAlive_stop();
    return EXIT_SUCCESS;
}
  • Start order, stop order reversed. RX starts last and stops first, and every failure path stops only what already started.
  • 60 seconds instead of 20, so you have time to send a few commands from the PC.
  • The Makefile needs no changes. $(wildcard src/*.c) picks up udprx.c.

A nice side effect: control_reg lives in the shared frame, and UDPTx broadcasts that frame every second. So once the PC writes a new value, it shows up in listen.py and in Wireshark within a second. That's a free "command received" confirmation. Sending that value from the PC with a small send.py is the next post.

Final Verdict

The part that taught me the most wasn't receiving data. It was stopping. My first version just cleared the flag like the other tasks, and the app hung on exit because the thread was still waiting in recvfrom. shutdown() fixed that cleanly, and it's worth knowing that just clearing a flag doesn't work for any thread that blocks. The second lesson was seeing my own broadcasts show up as dropped frames. It looked like a bug until I understood it, and now the drop counter is one of my favourite debugging tools. I also like stopping at TODO hooks. I could test the whole receive path from my PC before touching the FPGA, and the FPGA write only has to be filled into one function. To sum up: bind to INADDR_ANY:5001, reset from_len before every recvfrom, filter on what the frame is rather than who sent it, ntohl what you receive, and wake a blocked thread with shutdown() before you join it.