An open API service indexing awesome lists of open source software.

https://github.com/cpq/embedded-network-programming-guide

A complete guide for network programming on microcontroller devices
https://github.com/cpq/embedded-network-programming-guide

Last synced: about 2 months ago
JSON representation

A complete guide for network programming on microcontroller devices

Awesome Lists containing this project

README

        

# Embedded network programming guide

[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://opensource.org/licenses/MIT)

This guide is written for embedded developers who work on connected products.
That includes microcontroller based systems that operate in bare metal or
RTOS mode, as well as microprocessor based systems which run embedded Linux.

## Prerequisites

All fundamental concepts are explained in details, so the guide should be
understood by a reader with no prior networking knowledge.

A reader is expected to be familiar with microcontroller C programming - for
that matter, I recommend reading my [bare metal programming
guide](https://github.com/cpq/bare-metal-programming-guide). I will be using
Ethernet-enabled Nucleo-H743ZI board throughout this guide. Examples for other
architectures are summarized in a table below - this list will expand with
time. Regardless, for the best experience I recommend Nucleo-H743ZI to get the
most from this guide: [buy it on
Mouser](https://www.mouser.ie/ProductDetail/STMicroelectronics/NUCLEO-H743ZI2?qs=lYGu3FyN48cfUB5JhJTnlw%3D%3D).

## Network stack explained

### Network frame structure

When any two devices communicate, they exchange discrete pieces of data called
frames. Frames can be sent over the wire (like Ethernet) or over the air (like
WiFi or Cellular). Frames differ in size, and typically range from couple of
dozen bytes to a 1.5Kb. Each frame consists of a sequence of protocol headers
followed by user data:

![Network Frame](media/frame.svg)

The purpose of the headers is as follows:

**MAC (Media Access Control) header** is only 3 fields: destination MAC
address, source MAC addresses, and an upper level protocol. MAC addresses are
6-byte unique addresses of the network cards, e.g. `42:ef:15:c8:29:a1`.
Protocol is usually 0x800, which means that the next header is IP. MAC header
handles addressing in the local network (LAN).

**IP (Internet Protocol) header** has many fields, but the most important are:
destination IP address, source IP address, and upper level protocol. IP
addresses are 4-bytes, e.g. `209.85.202.102`, and they identify a machine
on the Internet, so their purpose is similar to phone numbers. The upper level
protocol is usually 6 (TCP) or 17 (UDP). IP header handles global addressing.

**TCP or UDP header** has many fields, but the most important are destination
port and source ports. On one device, there can be many network applications,
for example, many open tabs in a browser. Port number identifies
an application.

**Application protocol** depends on the target application. For example, there
are servers on the Internet that can tell an accurate current time. If you want
to send data to those servers, the application protocol must SNTP (Simple
Network Time Protocol). If you want to talk to a web server, the protocol must
be HTTP. There are other protocols, like DNS, MQTT, etc, each having their own
headers, followed by the application data.

Install [Wireshark](https://www.wireshark.org/) tool to observe network
frames. It helps to identify issues quickly, and looks like this:

![Wireshark](https://www.wireshark.org/docs/wsug_html_chunked/images/ws-main.png)

The structure of a frame described above, makes it possible to accurately
deliver a frame to the correct device and application over the Internet. When a
frame arrives to a device, a software that handles that frame (a network
stack), is organised in four layers.

### Network stack architecture

![Network Stack](media/stack.svg)

Layer 1: **Driver layer**, only reads and writes frames from/to network hardware
Layer 2: **TCP/IP stack**, parses protocol headers and handles IP and TCP/UDP
Layer 3: **Network Library**, parses application protocols like DNS, MQTT, HTTP
Layer 4: **Application** - Web dashboard, smart sensor, etc

### DNS request example

Let's provide an example. In order to show your this guide on the Github, your
browser first needs to find out the IP address of the Github's machine. For
that, it should make a DNS (Domain Name System) request to one of the DNS
servers. Here's how your browser and the network stack work for that case:

![DNS request](media/dns.svg)

**1.** Your browser (an application) asks from the lower layer (library), "what
IP address `github.com` has?". The lower layer (layer 3, a library layer - in
this case, it is a C library) provides an API function `gethostbyname()`
that returns an IP address for a given host name. So everything said below,
essentially describes how `gethostbyname()` works.

**2.** The library layer gets the name `github.com` and creates a properly
formatted, binary DNS request: `struct dns_request`. Then it calls an API
function `sendto()` provided by the TCP/IP stack layer (layer 2), to send that
request over UDP to the DNS server. The IP of the DNS server is known to the library
from the workstation settings. The UDP port is also known - port 53, a standard
port for DNS.

**3.** The TCP/IP stack's `sendto()` function receives a chunk of data to send.
it contains DNS request, but `sendto()` does not know that and does not care
about that. All it knows is that this is the piece of user data that needs to
be delievered over UDP to a certain IP address (IP address of a DNS server) on
port 53. Hence TCP/IP stack prepends UDP, IP, and MAC headers
to the user data to form a frame. Then it calls API function `send_frame()`
provided by the driver layer, layer 1.

**4.** A driver's `send_frame()` function transmits a frame over the wire or
over the air, the frame travels to the destination DNS server. A chain of
Internet routers pass that frame from one to another, until a frame finally hits
DNS server's network card.

**5.** A network card on the DNS server gets a frame and generates a hardware
interrupt, invoking interrupt handler. It is part of a driver - layer 1. It
calls a function `recv_frame()` that reads a frame from the card, and passes
it up by calling `ethernet_input()` function provided by the TCP/IP stack

**6.** TCP/IP stack parses the frame, and finds out that it is for the UDP port
53, which is a DNS port number. TCP/IP stack finds an application that listens
on UDP port 53, which is a DNS server application, and wakes up its `recv()`
call. So, DNS server application that is blocked on a `recv()` call, receives
a chunk of data - which is a DNS request. A library routine parses that request
by extracting a host name, and passes that parsed DNS request to the application.

**7.** A DNS server application receives DNS request: "someone wants an
IP address for `github.com`". Then the application layer looks at its
configuration, figures out "Oh, it's me who is responsible for the github.com
domain, and this is the IP address I should respond with". The application
extracts an IP address from the configuration, and calls a library function
"get this IP, wrap into a DNS response, and send back". And the response
travels all the way back in the reverse order.

### BSD socket API

The communication between layers are done via a function calls. So, each
layer has its own API, which upper and lower levels can call. They are not
standardized, so each implementation provides their own set of functions.
However, on OSes like Windows/Mac/Linux/UNIX, a driver and TCP/IP layers are
implemented in kernel, and TCP/IP layer provides a standard API to the
userland which is called a "BSD socket API":

![BSD socket API](media/bsd.svg)

This is done becase kernel code does not implement application level protocols
like MQTT, HTTP, etc, - so it let's user application to implement them in
userland. So, a library layer and an application layer reside in userland.
Some library level routines are provided in C standard library, like
DNS resolution function `gethostbyname()`, but that DNS library functions
are probably the only ones that are provided by OS. For other protocols,
many libraries exist that provide HTTP, MQTT, Websocket, SSH, API. Some
applications don't use any external libraries: they use BSD socket API
directly and implement library layer manually. Usually that is done when
application decides to use some custom protocol.

Embedded systems very often use TCP/IP stacks that provide the same BSD API as
mainstream OSes do. For example, lwIP (LightWeight IP) TCP/IP stack, Keil's MDK
TCP/IP stack, Zephyr RTOS TCP/IP stack - all provide BSD socket API. Thus let's
review the most important BSD API stack functions:

- `socket(protocol)` - creates a connection descriptor and assigns an integer ID for it, a "socket"
- `bind(sock, addr)` - assigns a local IP:PORT for a listening socket
- `accept(sock, addr)` - creates a new socket, assigns local IP:PORT
and remote IP:PORT (incoming)
- `connect(sock, addr)` - assigns local IP:PORT and remote IP:PORT for
a socket (outgoing)
- `send(sock, buf, len)` - sends data
- `recv(sock, buf, len)` - receives data
- `close(sock)` - closes a socket

Some implementations do not implement BSD socket API, and there are perfectly
good reasons for that. Examples for such implementation is lwIP raw API,
and Mongoose Library.

### TCP echo server implemented with socket API

Let me demonstrate the two approaches (using socket and non-socket API) on a simple
TCP echo server example. TCP echo server is a simple application that
listens on a TCP port, receives data from clients that connect to that port,
and writes (echoes) that data back to the client. That means, this application
does not use any application protocol on top of TCP, thus it does not need
a library layer. Let's see how this application would look like written
with a BSD socket API. First, a TCP listener should bind to a port, and
for every connected client, spawn a new thread that would handle it. A thread
function that sends/receives data, looks something like this:

```c
void handle_new_connection(int sock) {
char buf[20];
for (;;) {
ssize_t len = recv(sock, buf, sizeof(buf), 0); // Receive data from remote
if (len <= 0) break; // Error! Exit the loop
send(sock, buf, len); // Success. Echo data back
}
close(sock); // Close socket, stop thread
}
```

Note that `recv()` function blocks until it receives some data from the client.
Then, `send()` also blocks until is sends requested data back to the client.
That means that this code cannot run in a bare metal implementation, because
`recv()` would block the whole firmware. For this to work, an RTOS is required.
A TCP/IP stack should run in a separate RTOS task, and both `send()` and
`recv()` functions are implemented using an RTOS queue API, providing a
blocking way to pass data from one task to another. Overall, this is how an
embedded receive path looks like with socket API:

![BSD socket API](media/socket.svg)

The `send()` part would work in the reverse direction. Note that this approach
requires TCP/IP stack implement data buffering for each socket, because
an application consumes received data not immediately, but after some time,
when RTOS queue delivers data. Note that using non-blocking sockets and
`select()/poll()` changes things that instead of many application tasks,
there is only one application task, but the mechanism stays the same.

Therefore this approach with socket API has
the following major characteristics:

1. It uses queues for exchanging data between TCP/IP stack and
application tasks, which consumes both RAM and time
2. TCP/IP stack buffers received and sent data for each socket. Note that
the app/library layer may also buffer data - for example, buffering a full
HTTP request before it can be processed. So the same data goes through
two buffering "zones" - TCP/IP stack, and library/app

That means, socket API implementation takes extra time for data to be processed,
and takes extra RAM for double-buffering in the TCP/IP stack.

### TCP echo server with non-socket (callback) API

Now let's see how the same approach works without BSD socket API. Several
implementations, including lwIP and Mongoose Library, provide callback API to
the TCP/IP stack. Here is how TCP echo server would look like written using
Mongoose API:

```c
// This callback function is called for various network events, MG_EV_*
void event_handler(struct mg_connection *c, int ev, void *ev_data, void *fn_data) {
if (ev == MG_EV_READ) {
// MG_EV_READ means that new data got buffered in the c->recv buffer
mg_send(c, c->recv.buf, c->recv.len); // Send back the data we received
c->recv.len = 0; // Discard received data
}
}
```

In this case, all functions are non-blocking, that means that data exchange
between TCP/IP stack and an app can be implemented via direct function calls.
This is how receive path looks like:

![Raw callback API](media/raw.svg)

As you can see, in this case TCP/IP stack provides a callback API which
a library or application layer can use to receive data directly. No need
to send it over a queue. A library/app layer can buffer data, and that's
the only place where buffering takes place. This approach wins for
memory usage and performance. A firmware developer should use
a proprietary callback API instead of BSD socket API.

lwIP TCP/IP stack, for example, provides both socket and non-socket (raw) API,
and raw API is more efficient in terms of RAM and performance. However
developers rarely use raw API, because it is not trivial to understand and use
compared to the socket API. The API of the Mongoose Library shown above
is designed to be simple and easy to understand. API design can make things
very easy or very difficult, so it is important to have a good API.

## Implementing layers 1,2,3 - making ping work

### Development environment and tools

Now let's make our hands dirty and implement a working network stack on
a microcontroller board. I will be using
[Mongoose Library](https://github.com/cesanta/mongoose) for all examples
further on, for the following reasons:

- Mongoose is very easy to integrate: just by copying two files,
[mongoose.c]() and [mongoose.h]()
- Mongoose has a built-in drivers, TCP/IP stack, HTTP/MQTT/Websocket library,
and TLS 1.3 all in one, so it does not need any other software to create
a network-enabled application
- Mongoose provides a simple, polished callback API designed specifically
for embedded developers

The diagram below shows Mongoose architecture. As you can see, Mongoose can
use external TCP/IP stack and TLS libraries, as well as built-in ones. In the
following example, we are going to use only a built-in functionality, so we
won't need any other software.

![Mongoose architecture](media/mongoose.svg)

All source code in this guide is MIT licensed, however Mongoose
is licensed under a dual GPLv2/commercial license.
I will be using a Nucleo board from ST Microelectronics, and there are several choices for the
development environment:
- Use Cube IDE provided by ST: [install Cube](https://www.st.com/en/development-tools/stm32cubeide.html)
- Use Keil from ARM: [install Keil](https://www.keil.com/)
- Use make + GCC compiler, no IDE: follow [this guide](https://mongoose.ws/documentation/tutorials/tools/)

Here, I am going to use Cube IDE. In the templates, however, both Keil and
make examples are provided, too. So, in order to proceed, install Cube IDE
on your workstation, and plug in Nucleo board to your workstation.

### Skeleton firmware

Note: this and the following sections has a Youtube helper video recorded:
https://www.youtube.com/watch?v=lKYM4b8TZts

The first step would be to create a minimal, skeleton firmware that does
nothing but logs messages to the serial console. Once we've done that, we'll
add networking functionality on top of it. The table below summarises
peripherals for various boards:

| Board | UART, TX, RX | Ethernet | LED |
| ---------------- | --------------- | ------------------------------------- | -------------- |
| STM32H747I-DISCO | USART1, A9, A10 | A1, A2, A7, C1, C4, C5, G12, G11, G13 | I12, I13, I14 |
| STM32H573I-DK | USART1, A9, A10 | A1, A2, A7, C1, C4, C5, G12, G11, G13 | I8, I9, F1 |
| Nucleo-H743ZI | USART3, D8, D9 | A1, A2, A7, C1, C4, C5, B13, G11, G13 | B0, E1, B14 |
| Nucleo-H723ZG | USART3, D8, D9 | A1, A2, A7, C1, C4, C5, B13, G11, G13 | B0, E1, B14 |
| Nucleo-H563ZI | USART3, D8, D9 | A1, A2, A7, C1, C4, C5, B15, G11, G13 | B0, F4, G4 |
| Nucleo-F746ZG | USART3, D8, D9 | A1, A2, A7, C1, C4, C5, B13, G11, G13 | B0, B7, B14 |
| Nucleo-F756ZG | USART3, D8, D9 | A1, A2, A7, C1, C4, C5, B13, G11, G13 | B0, B7, B14 |
| Nucleo-F429ZI | USART3, D8, D9 | A1, A2, A7, C1, C4, C5, B13, G11, G13 | B0, B7, B14 |

**Step 1.** Start Cube IDE. Choose File / New / STM32 project
**Step 2.** In the "part number" field, type the microcontroller name,
for example "H743ZI". That should narrow down
the MCU/MPU list selection in the bottom right corner to a single row.
Click on the row at the bottom right, then click on the Next button
**Step 3.** In the project name field, type any name, click Finish.
Answer "yes" if a pop-up dialog appears
**Step 4.** A configuration window appears. Click on Clock configuration tab.
Find a field with a system clock value. Type the maximum value, hit enter,
answer "yes" on auto-configuration question, wait until configured
**Step 5.** Switch to the Pinout tab, Connectivity, then enable the UART controller
and pins (see table above), choose "Asynchronous mode"
**Step 6.** Click on Connectivity / ETH, Choose Mode / RMII, verify that the
configured pins are like in the table above - if not, change pins
**Step 7.** Lookup the LED GPIO from the peripherals table, and configure it
for output. Click on the corresponding pin, select "GPIO output"
**Step 8.** Click Ctrl+S to save the configuration. This generates the code
and opens main.c file
**Step 9.** Navigate to the `main()` function and add some logging to the
`while` loop. Make sure to insert your code between the "USER CODE" comments,
because CubeIDE will preserve it during code regeneration:
```c
/* USER CODE BEGIN WHILE */
while (1)
{
printf("Tick: %lu\r\n", HAL_GetTick());
HAL_Delay(500);
```
**Step 10.** Redirect `printf()` to the UART. Note the UART global variable
generated by Cube at the beginning of `main.c` - typically it is
`UART_HandleTypeDef huart3;`. Copy it, open `syscalls.c`, find function
`_write()` and modify it the following way :
```c
#include "main.h"

__attribute__((weak)) int _write(int file, char *ptr, int len) {
if (file == 1 || file == 2) {
extern UART_HandleTypeDef huart3;
HAL_UART_Transmit(&huart3, (unsigned char *) ptr, len, 999);
}
return len;
}
```
**Step 11.** Click on "Run" button to flash this firmware to the board.
**Step 12.** Attach a serial monitor tool (e.g. putty on Windows, or
`cu -l COMPORT -s 115200` on Mac/Linux) and observe UART logs:
```
Tick: 90358
Tick: 90860
...
```
Our skeleton firmware is ready!

### Integrate Mongoose

Now it's time to implement a functional TCP/IP stack. We'll use Mongoose
Library for that. To integrate it, we need to copy two files into our source tree.

**Step 1**. Open https://github.com/cesanta/mongoose in your browser, click on "mongoose.h". Click on "Raw" button, and copy file contents into clipboard.
In the CubeIDE, right click on Core/Inc, choose New/File in the menu, type
"mongoose.h", paste the file content and save.
**Step 2**. Repeat for "mongoose.c". On Github, copy `mongoose.c` contents
to the clipboard. In the CubeIDE, right click on Core/Src, choose New/File
in the menu, type "mongoose.c", paste the file content and save.
**Step 3**. Right click on Core/Inc, choose New/File in the menu, type
"mongoose_custom.h", and paste the following contents:
```c
#pragma once

// See https://mongoose.ws/documentation/#build-options
#define MG_ARCH MG_ARCH_NEWLIB

#define MG_ENABLE_TCPIP 1 // Enables built-in TCP/IP stack
#define MG_ENABLE_CUSTOM_MILLIS 1 // We must implement mg_millis()
#define MG_ENABLE_DRIVER_STM32H 1 // On STM32Fxx series, use MG_ENABLE_DRIVER_STM32F
```
**Step 4**. Implement Layer 1 (driver), 2 (TCP/IP stack) and 3 (library) in
our code. Open `main.c`. Add `#include "mongoose.h"` at the top:
```c
/* USER CODE BEGIN Includes */
#include "mongoose.h"
/* USER CODE END Includes */
```
**Step 5**. Before `main()`, define function `mg_millis()` that returns
an uptime in milliseconds. It will be used by Mongoose Library for the time
keeping:
```c
/* USER CODE BEGIN 0 */
uint64_t mg_millis(void) {
return HAL_GetTick();
}
/* USER CODE END 0 */
```
**Step 6**. Navigate to `main()` function and change the code around `while`
loop this way:
```c
/* USER CODE BEGIN WHILE */
struct mg_mgr mgr;
mg_mgr_init(&mgr);
mg_log_set(MG_LL_DEBUG);

// On STM32Fxx, use _stm32f suffix instead of _stm32h
struct mg_tcpip_driver_stm32h_data driver_data = {.mdc_cr = 4};
struct mg_tcpip_if mif = {.mac = {2, 3, 4, 5, 6, 7},
// Uncomment below for static configuration:
// .ip = mg_htonl(MG_U32(192, 168, 0, 223)),
// .mask = mg_htonl(MG_U32(255, 255, 255, 0)),
// .gw = mg_htonl(MG_U32(192, 168, 0, 1)),
.driver = &mg_tcpip_driver_stm32h,
.driver_data = &driver_data};
NVIC_EnableIRQ(ETH_IRQn);
mg_tcpip_init(&mgr, &mif);

while (1) {
mg_mgr_poll(&mgr, 0);
/* USER CODE END WHILE */
```

**Step 7**. Connect your board to the Ethernet. Flash firmware. In the serial
log, you should see something like this:
```
bb8 3 mongoose.c:14914:mg_tcpip_driv Link is 100M full-duplex
bbd 1 mongoose.c:4676:onstatechange Link up
bc2 3 mongoose.c:4776:tx_dhcp_discov DHCP discover sent. Our MAC: 02:03:04:05:06:07
c0e 3 mongoose.c:4755:tx_dhcp_reques DHCP req sent
c13 2 mongoose.c:4882:rx_dhcp_client Lease: 86400 sec (86403)
c19 2 mongoose.c:4671:onstatechange READY, IP: 192.168.2.76
c1e 2 mongoose.c:4672:onstatechange GW: 192.168.2.1
c24 2 mongoose.c:4673:onstatechange MAC: 02:03:04:05:06:07
```
If you don't, and see DHCP requests message like this:
```
130b0 3 mongoose.c:4776:tx_dhcp_discov DHCP discover sent. Our MAC: 02:03:04:05:06:07
13498 3 mongoose.c:4776:tx_dhcp_discov DHCP discover sent. Our MAC: 02:03:04:05:06:07
...
```
The most common cause for this is you have your Ethernet pins wrong. Click
on the `.ioc` file, go to the Ethernet configuration, and double-check the
Ethernet pins against the table above.

**Step 8**. Open terminal/command prompt, and run a `ping` command against
the IP address of your board:
```sh
$ ping 192.168.2.76
PING 192.168.2.76 (192.168.2.76): 56 data bytes
64 bytes from 192.168.2.76: icmp_seq=0 ttl=64 time=9.515 ms
64 bytes from 192.168.2.76: icmp_seq=1 ttl=64 time=1.012 ms
```

Now, we have a functional network stack running on our board. Layers 1,2,3
are implemented. It's time to create an application - a simple web server,
hence implement layer 4.

## Implementing layer 4 - a simple web server

Let's add a very simple web server that responds "ok" to any HTTP request.

**Step 1**. After the `mg_tcpip_init()` call, add this line that creates HTTP listener
with `fn` event handler function:
```c
mg_http_listen(&mgr, "http://0.0.0.0:80", fn, NULL);
```
**Step 2**. Before the `mg_millis()` function, add the `fn` event handler function:
```c
static void fn(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = ev_data; // Parsed HTTP request
mg_http_reply(c, 200, "", "ok\r\n");
}
}
```
That's it! Flash the firmware. Open your browser, type board's IP address and
see the "ok" message.

Note that the [mg_http_reply()](https://mongoose.ws/documentation/#mg_http_reply)
function is very versatile: it cat create formatted output, like printf
on steroids. See [mg_snprintf()](https://mongoose.ws/documentation/#mg_snprintf-mg_vsnprintf)
for the supported format specifiers: most of them are standard printf, but
there are two non-standard: `%m` and `%M` that accept custom formatting
function - and this way, Mongoose's printf can print virtually anything.
For example, JSON strings. That said, with the aid of `mg_http_reply()`,
we can generate HTTP responses of arbitrary complexity.

So, how the whole flow works? Here is how. When a browser connects,
an Ethernet IRQ handler (layer 1) kicks in. It is defined by Mongoose, and activated by
the `#define MG_ENABLE_DRIVER_STM32H 1` line in the `mongoose_custom.h`: [ETH_IRQHandler](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/drivers/stm32h.c#L252). Other environments, like CubeIDE, implement `ETH_IRQHandler`
and activate it when you select "Enable Ethernet interrupt" in the Ethernet
configuration. To avoid clash with Cube, we did not activate Ethernet interrupt.

IRQ handler reads frame from the DMA, copies that frame to the Mongoose's
[receive queue](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/net_builtin.h#L30), and exits.
That receive queue is special, it is a thread-safe
single-producer-single-consumer non-blocking queue, so an IRQ handler, being
executed in any context, can safely write to it.

The `mg_poll()` function in the infinite `while()` loop constantly
verifies, whether we receive any data in the receive queue. When it detects
a frame in the receive queue, it extracts that frame, passes it on to the
[mg_tcp_rx()](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/net_builtin.c#L800) function - which is an etry point to the layer 2 TCP/IP stack.

That `mg_tcp_rx()` function parses headers, starting from Ethernet header,
and when it detects that a received frame belongs to one of the Mongoose
TCP or UDP connections, it copies frame payload to the connection's `c->recv`
buffer and [calls `MG_EV_READ` event](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/net_builtin.c#L687).

At this point, processing leaves layer 2 and enters layer 3 - a library layer.
Mongoose's HTTP event handlers catches `MG_EV_READ`, parses received data,
and when it detects that the full HTTP message is buffered, it [sends the
`MG_EV_HTTP_MSG` with parsed HTTP message](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/http.c#L1033) to the application - layer 4.

And this is where our event handler function `fn()` gets called. Our code is
simple - we catch `MG_EV_HTTP_MSG` event, and use Mongoose's API function
`mg_http_reply()` to craft a simple HTTP response:
```
HTTP/1.1 200 OK
Content-Length: 4

ok
```

This response goes to Mongoose's `c->send` output buffer, and `mg_mgr_poll()`
drains that data to the browser, [splitting the response by frames](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/net_builtin.c#L587-L588)
in layer 2, then passing to the layer 1. An Ethernet driver's output function [mg_tcpip_driver_stm32h_tx()](https://github.com/cesanta/mongoose/blob/68e2cd9b296733c9aea8b3401ab946dd25de9c0e/src/drivers/stm32h.c#L208) sends those frames back to the browser.

This is how Mongoose Library works.

Other implementations, like Zephyr, Amazon FreeRTOS-TCP, Azure, lwIP, work in
a similar way. They implement BSD socket layer so it is a bit more complicated
cause it includes an extra socket layer, but the principle is the same.

## Implementing Web UI

Using `mg_http_reply()` function is nice, but it's very good for creating
custom responses. It is not suitable for serving files. And the standard way
to build a web UI is to split it into two parts:
- a static part, which consists of directory with `index.html`, CSS,
JavaScript and image files,
- a dynamic part, which serves REST API

So instead of using `mg_http_reply()` and responding with "ok" to any request,
let's create a directory with `index.html` file and serve that directory.
Mongoose has API function `mg_http_serve_dir()` for that. Let's change the
event handler code to use that function:

```c
static void fn(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = ev_data; // Parsed HTTP request
struct mg_http_serve_opts opts = {.root_dir = "/web_root"};
mg_http_serve_dir(c, hm, &opts);
}
}
```

Build it and get build error "undefined reference to 'mkdir'". This is because
`mg_http_serve_dir()` function tries to use a default POSIX filesystem to
read files from directory `/web_root`, and our firmware does not have support
for the POSIX filesystem.

What are the possibilities here? First, we can implement POSIX filesystem,
by using an internal or external flash memory. Then we can copy our `web_root`
directory there, and our code will start to work. This is the hard way.

The easy way is to use a so-called embedded filesystem, by
transforming all files in the web directory into C arrays, and compiling them
into the firmware binary. This way, all UI files are simply hardcoded into the
firmware binary, and there is no need to implement a "real" filesystem:

**Step 1**. Tell `mg_http_serve_dir()` to use packed filesystem:
```c
struct mg_http_serve_opts opts = {.root_dir = "/web_root", .fs = &mg_fs_packed};
```
**Step 2**. Enable packed filesystem, and disable POSIX filesystem in `mongoose_custom.h`:
```c
#define MG_ENABLE_PACKED_FS 1
#define MG_ENABLE_POSIX_FS 0
```
**Step 3**. Create a new file `Core/Src/packed_fs.c`. Go to https://mongoose.ws/ui-pack/,
review UI files. Copy/paste the contents of generated `packed_fs.c`, save.

Build the firmware - and now it should build with no errors.

Let's review what that UI packer does. As you can see, it has 3 files, which
implement a very simple Web UI with LED control. The `index.html` file
loads `main.js` file, which defines a button click handler. When a button
gets clicked, it makes a request to the `api/led/toggle` URL, and when
than request completes, it makes another request to `api/led/get` URL,
and sets the status span element to the result of the request.

The tool has a preview window, and if any of the files are changed,
it automatically refreshes preview and regenerates packed_fs.c. The packed_fs.c
is a simple C file, which contains three C arrays, representing three files
we have, and two helper functions `mg_unlist()` and `mg_unpack()`, used by
Mongoose:
- the `mg_unlist()` function allows to scan the whole "filesystem" and get names of every file,
- the `mg_unpack()` function returns file contents, size, and modification time for a given file.

Mongoose provides a command line utility [pack.c](https://github.com/cesanta/mongoose/blob/master/test/pack.c)
to generate `packed_fs.c` automatically during the build. The example of that
is a [Makefile](https://github.com/cesanta/mongoose/blob/f883504d2d44d24cae1ca6c9f88ce780ab36f59b/examples/device-dashboard/Makefile#L38-L43)
for device dashboard example in Mongoose repository, which not only packs,
but also compresses files to minimise their size. But here, we'll use the
web tool because it is visual and makes it easy to understand the flow.

The static HTML is extremely simple. There 3 files: `index.html`, `style.css`
and `main.js`. The `index.html` references, or loads, the other two:

**index.html:**
```html


My Device


LED status:
0
Toggle LED

```

**style.css:**
```css
.main { margin: 1em; }
#status { display: inline-block; width: 2em; }
```

The Javascript code in the `main.js` file installs an event handler on button
click, so when a user clicks on a button, JS code makes HTTP requests -
I'll comment down below how it all works together:

**main.js:**
```javascript
var getStatus = ev => fetch('api/led/get')
.then(r => r.json())
.then(r => { document.getElementById('status').innerHTML = r; });

var toggle = ev => fetch('api/led/toggle')
.then(r => getStatus());

document.getElementById('btn').onclick = toggle;
```

Now, let's flash the firmware. Go to the IP address in the browser - and
now we see the Web UI with a button! Click on the button, and see that nothing
happens! The LED does not turn on and off. Open developer tools and see that
on every click, a browser makes "toggle" and "get" requests which return 404
error - not found. Let's implement those API calls.

Change the event handler in the following way:
```c
static void fn(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
if (mg_http_match_uri(hm, "/api/led/get")) {
mg_http_reply(c, 200, "", "%d\n", HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0));
} else if (mg_http_match_uri(hm, "/api/led/toggle")) {
HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); // Can be different on your board
mg_http_reply(c, 200, "", "true\n");
} else {
struct mg_http_serve_opts opts = {.root_dir = "/web_root", .fs = &mg_fs_packed};
mg_http_serve_dir(c, hm, &opts);
}
}
}
```

Note the `mg_http_match_uri()` checks. There, we are making different responses
to different URLs. On `/api/led/get` URL request, we're responding with
LED status, and on `/api/led/toggle` request, we're toggling the pin and
responding with `true`.

Build and flash this firmware. Refresh the page in the browser. Click on the
button - and now, LED toggle works! If we open developer tools in the browser,
we can see the sequence of the network requests made by the browser.

![Simple Web UI screenshot](media/simple_ui.webp)

Below is the diagram of the interaction between the browser and the device,
with explanations of every step:

![Web UI sequence flow](media/web_ui_flow.svg)

This is the flow for the Web UI of any complexity. Now, it is just a matter of
creating a professional UI interface using any suitable JS/CSS framework, and
extending the event handler function with the API calls that that UI invokes.
That's all it takes.

## Implementing Device Dashboard

Let me show you how to repeat everything we did in Cube - in the make + GCC
environment in one minute. Navigate to https://mongoose.ws/demo/?clear=1
This simple web tool creates a make project completely in your browser.
Choose the board, the "simple project". You can download the project to your
workstation and build manually. But we'll build in a browser - click on Build
button. That zips the projects and sends it to mongoose.ws site, which has
ARM GCC pre-installed. It simply runs `make`, creates firmware binary, and
sends that binary back to your browser. Now you can download that binary,
or flash it directly from your browser.

The "simple" project repeats what we've already done in Cube, with one
important difference - it also implements TLS. In other words, it can serve
both HTTP and HTTPS. Note that the binary size is less than 60 Kb! We will
cover TLS later, as it needs a separate discussion.

Now, let's click on "Start Over" button and build "Web UI Dashboard" project.
It follows absolutely the same flow as "simple" project, just the Web UI is
significantly more versatile, built with Preact JS framework and Tailwind CSS
framework. The event handler function moved into a separate file, `net.c`,
and supports many API calls required by Web UI - to show dashboard stats,
settings, and firmware update. By the way, the firmware update is completely
functional - but I won't cover it here, as it is a big topic on itself.
I won't cover the process of static UI creation in React, as there are tons
of very good tutorials on that. But if you want me to cover that, join our
Discord server and let me know.

What I'll do is to move that UI into the Cube project of ours.

**Step 1.** Copy net.c, net.h, packed_fs.c into the Cube project
**Step 2.** Add the following `include "net.h"` at the top of the main.c file
**Step 3.** Comment out `mg_http_listen(...)` call, add `web_init()` call
**Step 4.** Open net.h, modify HTTP_URL port 8000 to port 80

Rebuild, reflash, refresh your browser. We have a functional versatile
Web UI device dashboard reference running!

## Device management using MQTT protocol

## Enabling TLS

## Talking to AWS IoT and Microsoft Azure services

## About me

I am Sergey Lyubka, an engineer and entrepreneur. I hold a MSc in Physics from
Kyiv State University, Ukraine. I am a director and a co-founder at Cesanta - a
technology company based in Dublin, Ireland. Cesanta develops embedded
solutions:

- https://mongoose.ws - an open source HTTP/MQTT/Websocket network library
- https://vcon.io - a remote firmware update / serial monitor framework

You are welcome to register for
[my free webinar on embedded network programming](https://mongoose.ws/webinars/)