eBPF Program Insights

Β· 4017 words Β· 19 minute read

Introduction πŸ”—

In this article we will see different ways to inspect an eBPF program in order to understand more about eBPF.

This article is intended for developers who already have basic familiarity with eBPF programs and want to explore how to inspect compiled .o files using different tools. Some knowledge of Go, C, and tools for working with eBPF in Go (such as bpf2go) will be helpful.

The program includes everything we need to inspect it and understand its various components:

  • A function
  • A global variable
  • An eBPF map
  • Some BPF helpers
  • The license

Key Considerations πŸ”—

An eBPF program, when compiled, becomes an ELF (Executable and Linkable Format) object file (.o), which contains not only bytecode, but also maps, global variables, metadata, and other sections.

We are using the cilium/ebpf library, which utilizes bpf2go, as described in the cilium/ebpf GitHub repo: cmd/bpf2go: allows compiling and embedding eBPF programs written in C within Go code. As well as compiling the C code, it auto-generates Go code for loading and manipulating the eBPF program and map objects.

The .o files generation are influenced by a specific set of Clang flags:

  • -O2 ensures the code is optimized, improving its chances of passing the eBPF verifier by simplifying control flow and removing dead code.
  • -mcpu=v1 guarantees compatibility with older kernels by avoiding newer eBPF instructions that might not be supported.
  • -g enables debug information, which is required to generate BTF (BPF Type Format) metadata.

Symbol stripping is performed by default unless explicitly disabled. This affects tools like readelf and llvm-objdump, as debug and symbol information may be missing.

These defaults ensure portability and compatibility with tooling, but they also mean that a .o file compiled using bpf2go might differ from one compiled manually, especially in terms of section layout and available metadata. We can inspect the default compilation behavior here.

For instance, if we manually compile our XDP program using clang -g -O2 -Wall --target=bpf -I../../headers -c xdp.c -o xdp.o, and compare the output of readelf -S xdp.o with readelf -S bpf_bpfel.o (the bpf_bpfel.o file is created using bpf2go), we will notice differences in the presence of .debug_* sections and symbol metadata.

By default, bpf2go strips the debug information using cmd = exec.Command(args.Strip, "-g", args.Dest) (here) producing smaller and cleaner ELF files optimized for loading into the kernel. However, if we want to explore the ELF structure in more detail (e.g., for analysis or learning), we can preserve this information by passing the -no-strip flag to bpf2go in the .go file: //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -no-strip bpf xdp.c -- -I../../headers. This way, all debug symbols and metadata are retained for inspection.

Anyway, here we are using the stripped version, i.e., the default one.

Tools πŸ”—

To inspect and read an eBPF .o file, we can use tools like bpftool, readelf and llvm-objdump.

Before we dive into the tools, here’s a quick reminder of some common ELF sections we’ll see in eBPF .o files:

  • .text: Contains the actual eBPF instructions.
  • .rodata: Read-only data, like constants defined in the C program.
  • .bss: Uninitialized global/static variables, typically zero-initialized at runtime.
  • .data: Initialized global/static variables.
  • .maps: Definitions of eBPF maps used by the program.

Understanding these will help make sense of what each tool reveals.

bpftool allows for inspection and simple modification of BPF objects on the system (this is the most common definition). We can use it to do many powerful things, such as inspect loaded eBPF programs, map contents, BTF information, and even pin or detach programs from the kernel.

The BPF and XDP Reference Guide from Cilium provides valuable insights into the BPF instruction set and can help interpret bpftool outputs. The following points are quoted from the official documentation and describe the purpose of the BPF registers:

  • Register r10 is the only register which is read-only and contains the frame pointer address in order to access the BPF stack space. The remaining r0 - r9 registers are general purpose and of read/write nature.
  • r0 contains the return value of a helper function call.
  • r1 - r5 hold arguments from the BPF program to the kernel helper function.
  • r6 - r9 are callee saved registers that will be preserved on helper function call.

Using readelf πŸ”—

readelf is a command-line tool for displaying information about ELF files, such as section headers, symbols, and relocation entries, making it especially useful when analyzing compiled eBPF object files. In brief readelf helps examining ELF headers and section names. We will focus on two commands.

The readelf -S program.o command displays all sections, including: .text (code), maps, license and so on.

The readelf -s program.o command lists function names and symbols defined inside the object file.

Using llvm-objdump (For eBPF bytecode) πŸ”—

Because objdump may not support eBPF instructions, llvm-objdump (LLVM’s object file dumper) is usually a better choice. It prints the contents of object files and final linked images named on the command line. We will see only the llvm-objdump -S program.o command which shows the disassembled code alongside the corresponding source lines.

Example πŸ”—

The example program is a simple XDP probe that counts the number of IPv4 packets on the loopback interface. It checks if it is Ethernet, IPv4 and not malformed. The controlplane reads the value from an eBPF array map. The counter of IPv4 packets is stored in a eBPF map and also in a global variable.

How to build it:

cd ebpf-samples
make build TARGET=program_insights/xdp

How to run it:

sudo ./program_insights/xdp/bin/xdp

bpftool πŸ”—

Let’s see the bpftool prog command syntax:

sudo bpftool prog help
Usage: bpftool prog { show | list } [PROG]
       bpftool prog dump xlated PROG [{ file FILE | [opcodes] [linum] [visual] }]
       bpftool prog dump jited  PROG [{ file FILE | [opcodes] [linum] }]
PROG := { id PROG_ID | pinned FILE | tag PROG_TAG | name PROG_NAME }

In particular we will use sudo bpftool prog show which shows information about loaded programs and bpftool prog dump xlated PROG which dumps the translated version of our eBPF program. It shows a human-readable, disassembled form of the BPF instructions, as seen by the verifier, i.e., after the kernel has applied its own rewrites. This is not the raw BPF bytecode, nor is it JIT-compiled machine code.

Note: we can use tag of the eBPF program that is automatically generated based on its instructions; this helps us uniquely identify and compare programs, even across systems. Also because the program name alone isn’t guaranteed to be unique or preserved when loading into the kernel.

For our example, we have:

sudo bpftool prog show
...
326: xdp  name xdp_func  tag 16fbe55eb9bf1e18  gpl
	loaded_at 2025-05-31T16:05:29+0200  uid 0
	xlated 384B  jited 233B  memlock 4096B  map_ids 62,63,64
	btf_id 298

And here is the output of the dump xlated command, with some comments on the right-hand side:

sudo bpftool prog dump xlated tag 16fbe55eb9bf1e18

int xdp_func(struct xdp_md *ctx):
; int xdp_func(struct xdp_md *ctx) {
   0: (b7) r0 = 2   --> set default return value (XDP_PASS) as defined in common.h

; void *data_end = (void*)(long)ctx->data_end;
   1: (79) r2 = *(u64 *)(r1 +8)   --> load data_end from ctx->data_end

; void *data = (void*)(long)ctx->data;
   2: (79) r1 = *(u64 *)(r1 +0)   --> load data from ctx->data

; if ((void *)(eth + 1) > data_end) {
   3: (bf) r3 = r1    --> r3 = data (start of packet)
   4: (07) r3 += 14   --> r3 = data + sizeof(struct ethhdr)

; if ((void *)(eth + 1) > data_end)
   5: (2d) if r3 > r2 goto pc+41    --> drop if out-of-bounds

; if (eth->h_proto != bpf_htons(ETH_P_IP))
   6: (69) r3 = *(u16 *)(r1 +12)    --> load eth->h_proto (EtherType)

; if (eth->h_proto != bpf_htons(ETH_P_IP))
   7: (55) if r3 != 0x8 goto pc+39    --> check for IPv4 (0x0800 in network order is 0x0008)
   8: (07) r1 += 34   --> advance to IP header (14 for struct ethhdr + 20 offset for struct iphdr)
   9: (2d) if r1 > r2 goto pc+37    --> bounds check again
  10: (b7) r1 = 0   --> clear the r1 register

; u32 key = 0;
  11: (63) *(u32 *)(r10 -4) = r1    --> r10 is pointing to the top of the stack; 
                                    r10 - 4 means that we are reserving 4 bytes; r1 was 0; 
                                    so we are storing a 32 bit value 0 from r1 at the 
                                    memory location r10 - 4 in the ebpf stack

  12: (b7) r6 = 1   --> r6 = 1 (initial value)

; u64 initval = 1, *valp;
  13: (7b) *(u64 *)(r10 -16) = r6   --> we're using the stack to store initval (value = 1)

  14: (bf) r2 = r10   --> prepare pointer (we’ll soon point to key, not initval yet!)
  15: (07) r2 += -4   --> r2 now points to key (stored at r10 - 4)

; valp = bpf_map_lookup_elem(&counter, &key); --> remember that the map used is a BPF_MAP_TYPE_ARRAY
                                              --> afaiu, the bpf verifier decides it can do zero-cost 
                                              lookup instead of calling the helper;
                                              that's because we are using an array as map. 

  16: (18) r1 = map[id:98]          --> load map pointer (counter map)
  18: (07) r1 += 336                --> this should be like an internal map offset; 
                                    so the array actually start after this 336
  19: (61) r0 = *(u32 *)(r2 +0)     --> remember line 15; now we are loading the key value (0)
  20: (35) if r0 >= 0x1 goto pc+3   --> check bounds; for an array map of size 1; 
                                    if key >= 1 jumps ahead and return NULL; 
                                    here key is 0, so it's safe and we can continue
  21: (67) r0 <<= 3   --> r0 = r0 * sizeof(u64)
  22: (0f) r0 += r1   --> r0 now points to value area + key offset
  23: (05) goto pc+1    --> unconditional jump; skip instruction 24 when the key was valid
  24: (b7) r0 = 0   --> r0 = NULL (fallback case)

; if (!valp)    --> valp contains the result of the bpf_map_lookup_elem
  25: (55) if r0 != 0x0 goto pc+10    --> r0 has the points to the value returned by the bpf_map_lookup_elem;
                                      if r0 != 0x0 it means that the lookup succeeded and valp has a valid 
                                      value so we jump 10 instructions ahead
                                      if r0 == 0x0 it means the that key was not found in the map, 
                                      so the program continues and insert an initial value


  26: (bf) r2 = r10   --> top of the stack

; bpf_map_update_elem(&counter, &key, &initval, BPF_ANY);
  27: (07) r2 += -4   --> r2 now points to key (stored at r10 - 4)
  28: (bf) r3 = r10   --> top of the stack
  29: (07) r3 += -16    --> r3 points to &initval (1)
  30: (18) r1 = map[id:98]    --> r1 = counter map pointer
  32: (b7) r4 = 0   --> r4 = BPF_ANY (insert or update)
  33: (85) call array_map_update_elem#320752 --> update or insert new entry
  34: (b7) r0 = 0   --> return value is set to 0
  35: (05) goto pc+11   --> skip atomic add (we just inserted)

; __sync_fetch_and_add(valp, 1);  --> this function is used to do an atomic increment of 
                                      the value pointed by valp (taken from the map)
  36: (b7) r1 = 1   --> simple r1 is set to 1
  37: (db) lock *(u64 *)(r0 +0) += r1   --> atomic increment 

; __sync_lock_test_and_set_8(&global_counter,1);  --> this is a lock-test-and-set using an atomic64_xchg, 
                                                      which swaps the contents of *global_counter with 1 atomically;
                                                      it sets the value to 1, but also reads the old value
  38: (18) r1 = map[id:99][0]+0   --> load pointer to global_counter[0]
  40: (db) lock *(u64 *)(r1 +0) += r6   --> atomic add: *global_counter += 1

; bpf_printk("global_counter value %u\n", global_counter);
  41: (79) r3 = *(u64 *)(r1 +0)   --> load global_counter value
  42: (18) r1 = map[id:100][0]+0    --> load pointer to fmt string
  44: (b7) r2 = 25    --> size of format string
  45: (85) call bpf_trace_printk#-112608    --> print trace

  46: (b7) r0 = 2   --> set default return value (XDP_PASS) as defined in common.h
; }
  47: (95) exit   --> exit program

Let’s also take a look at the maps:

sudo bpftool map show
...
62: array  name counter  flags 0x0
	key 4B  value 8B  max_entries 1  memlock 392B
	btf_id 295
63: array  name .bss  flags 0x0
	key 4B  value 8B  max_entries 1  memlock 392B
	btf_id 296
64: array  name .rodata  flags 0x80
	key 4B  value 25B  max_entries 1  memlock 416B
	btf_id 297  frozen

There are more maps than expected. In the code we have only one eBPF map called counter but there’s also a global variable.

Let’s understand what is happening.

Well, as stated in the eBPF docs:

In eBPF we have no heap, only the stack and pointers into kernel space. To work around the issue, we use array maps. The compiler will place global data into the .data, .rodata, and .bss ELF sections. The loader will take these sections and turn them into array maps with a single key (max_entries set to 1) and its value the same size as the binary blob in the ELF section.

Let’s see one by one.

The following map represents the counter of type BPF_MAP_TYPE_ARRAY, and as we can see, there is only one element with key = 0 and value = 40. This means that the eBPF program has analyzed 40 IPv4 packets.

sudo bpftool map dump id 62
[{
        "key": 0,
        "value": 40
    }
]

The following map represents the .bss section of the BPF program’s data and it contains uninitialized global variables at load time. As we can see the value o the global_counter is 40, indeed we increment it when we increment the map counter value.

sudo bpftool map dump id 63
[{
        "value": {
            ".bss": [{
                    "global_counter": 40
                }
            ]
        }
    }
]

The following map represents the .rodata section, which holds read-only data, often including format strings used in ‘bpf_printk’. The format string shown as ASCII is: “global_counter value %u\n\0”

sudo bpftool map dump id 64
[{
        "value": {
            ".rodata": [{
                    "xdp_func.____fmt": [103,108,111,98,97,108,95,99,111,117,110,116,101,114,32,118,97,108,117,101,32,37,117,10,0
                    ]
                }
            ]
        }
    }
]

As for the use of bpftool, that’s all for now, but as mentioned earlier, bpftool can be used for many other purposes.

readelf πŸ”—

First let’s use readelf -s bpf_bpfel.o that shows symbols like function names and map references.

Here’s some information about the columns before the output:

  • Num: Symbol index in the symbol table.
  • Value: Address or offset of the symbol.
  • Size: Size of the symbol in bytes.
  • Type: Type of the symbol
    • FUNC: Function symbol. Entry point of the eBPF program
    • OBJECT: Data object live variable, constant, map, etc..
    • NOTYPE: No specific type. Often are internal compiler labels. (I need to go deeper here)
    • SECTION: It is used to mark the presence of a section like .rodata, .text, etc.. There is no actual data or code, indeed the Size is 0.
  • Bind: Binding type of the symbol:
    • LOCAL: Symbol is local to the object file.
    • GLOBAL: Symbol is visible to other object files (can be used across modules).
  • Vis: Visibility (usually DEFAULT for symbols visible to all parts of the program).
  • Ndx: Index of the section in which the symbol is located.
  • Name: Name of the symbol.

Note also that the bpf_bpfel.o file is generated using the command make generate TARGET=program_insights/xdp, which runs go generate on the .c file.

readelf -s bpf_bpfel.o

Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND                    --> it is something not defined in the object file but something external
     1: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 xdp                --> xdp section; it says where xdp_func is defined (section 3)
     2: 0000000000000148     0 NOTYPE  LOCAL  DEFAULT    3 LBB0_6             --> label used in the program 
     3: 00000000000000f0     0 NOTYPE  LOCAL  DEFAULT    3 LBB0_5             --> label used in the program 
     4: 0000000000000000    25 OBJECT  LOCAL  DEFAULT    8 xdp_func.____fmt   --> format string used in bpf_printk, stored in .rodata (section 8)
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 .rodata            --> read only data section (section 8)
     6: 0000000000000000   336 FUNC    GLOBAL DEFAULT    3 xdp_func           --> entry point for the eBPF program (section 3 xdp)
     7: 0000000000000000    32 OBJECT  GLOBAL DEFAULT    7 counter            --> map definition (section 7 .maps) Why is 32 bytes? Explained later.
     8: 0000000000000000     8 OBJECT  GLOBAL DEFAULT    6 global_counter     --> global variable stored in .bss
     9: 0000000000000000    13 OBJECT  GLOBAL DEFAULT    5 __license          --> license stored in .data

To fully understand the previous output and the comments on the right part we need to take a look to the sections (-S flag) output described later.

Note: LBB0_6 and LBB0_5 are internal markers used by the compiler for different parts of the eBPF program.

Now, let’s use readelf -S bpf_bpfel.o, which shows the sections headers, i.e., BPF maps and program sections:

readelf -S bpf_bpfel.o

There are 15 section headers, starting at offset 0xc58:

Section Headers:
  [Nr] Name              Type             Address           Offset    Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000  0000000000000000  0000000000000000           0     0     0
  [ 1] .strtab           STRTAB           0000000000000000  00000bbd  0000000000000097  0000000000000000           0     0     1
  [ 2] .text             PROGBITS         0000000000000000  00000040  0000000000000000  0000000000000000  AX       0     0     4
  [ 3] xdp               PROGBITS         0000000000000000  00000040  0000000000000150  0000000000000000  AX       0     0     8
  [ 4] .relxdp           REL              0000000000000000  00000a18  0000000000000040  0000000000000010   I      14     3     8
  [ 5] license           PROGBITS         0000000000000000  00000190  000000000000000d  0000000000000000  WA       0     0     1
  [ 6] .bss              NOBITS           0000000000000000  000001a0  0000000000000008  0000000000000000  WA       0     0     8
  [ 7] .maps             PROGBITS         0000000000000000  000001a0  0000000000000020  0000000000000000  WA       0     0     8
  [ 8] .rodata           PROGBITS         0000000000000000  000001c0  0000000000000019  0000000000000000   A       0     0     1
  [ 9] .BTF              PROGBITS         0000000000000000  000001dc  00000000000005f9  0000000000000000           0     0     4
  [10] .rel.BTF          REL              0000000000000000  00000a58  0000000000000040  0000000000000010   I      14     9     8
  [11] .BTF.ext          PROGBITS         0000000000000000  000007d8  0000000000000150  0000000000000000           0     0     4
  [12] .rel.BTF.ext      REL              0000000000000000  00000a98  0000000000000120  0000000000000010   I      14    11     8
  [13] .llvm_addrsig     LOOS+0xfff4c03   0000000000000000  00000bb8  0000000000000005  0000000000000000   E       0     0     1
  [14] .symtab           SYMTAB           0000000000000000  00000928  00000000000000f0  0000000000000018           1     6     8
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), p (processor specific)

Colums:

  • Name: Name of the section
  • Type: Type of the section
    • PROGBITS: Holds actual program or data content (like bytecode or variables).
    • REL: Relocation section that contains information to adjust addresses or references.
    • STRTAB: String table that holds strings used in other sections.
    • SYMTAB: Symbol table containing information about program symbols (functions, variables).
    • NOBITS: Doesn’t have physical data but describes memory regions that will be allocated during execution (e.g., uninitialized variables).
    • LOOS: OS-specific section (in this case, used by LLVM for address signature information).
  • Address: It is the virtual address where the section would reside when the program will be loaded in the kernel. As we can see is 0 because the address is determined during linking.
  • Offset: It is the offset where the section starts
  • Size: Byte size of the section
  • EntSize: Entry size of each item in the section
  • Flags:
    • W (write): The section is writable.
    • A (alloc): The section is allocated in memory when loaded.
    • X (execute): The section is executable.
    • S (strings): The section contains string data.
    • I (info): Information for processing.
    • M (merge): The section can be merged with others of the same type.
  • Link: Section index to which this section is linked, if applicable. For relocation sections (REL), the Link column points to the symbol table (.symtab) that the relocations refer to.
  • Info: Additional info like index into other sections or a ref to a symbol table.
  • Align: Alignment requirement for the section. For example, .text is aligned to 4-byte boundaries, and .maps is aligned to 8-byte boundaries.

Interesting things πŸ”—

.text is a section that contains program data like the code for the eBPF program.

.relxdp contains information to update addresses or offsets within the xdp section or other sections when linking the object file. It has Link=14 (section 14, i.e., the .symtab symbol table) and it has Info=3 that means this relocation section contains entries that apply to section number 3, which is the XDP section (i.e., the eBPF code).

.bss represents uninitialized data (like global variables that are not explicitly initialized). Its size is 8 bytes because we have a global variable __s64 global_counter that is 8 bytes.

.maps contains only metadata describing the maps that need to be created by the eBPF loader. It does not contain the actual contents of the maps at runtime (those live in kernel space after map creation).

Small tl;dr about .maps: why is 32 bytes? πŸ”—

In the eBPF code, we defined:

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);   --> 1 byte
    __uint(max_entries, 1);             --> 1 byte
    __type(key, __u32);                 --> 4 bytes
    __type(value, __u64);               --> 8 bytes
} counter SEC(".maps");

So why is it 32 bytes? Let’s take a look at BTF Style Maps. In particular we can see:

#define __uint(name, val) int (*name)[val]  // pointer to array of int[val]
#define __type(name, val) typeof(val) *name // pointer to a value of type 'val'

So our eBPF map becomes:

struct {
    int (*type)[1];          // pointer to int[1]  --> 8 bytes
    int (*max_entries)[1];   // pointer to int[1]  --> 8 bytes
    __u32 *key;              // pointer to __u32   --> 8 bytes
    __u64 *value;            // pointer to __u64   --> 8 bytes
};

On a 64-bit system, each field is a pointer, which means: 8 bytes per field and 4 fields x 8 bytes = 32 bytes total. That fully explains why the .maps section shows Size: 32.

Moreover, the BTF Style Maps solves a problem of the Legacy Maps which is that the key an value type information is lost.

As double check, we can do the following test.

Modify the eBPF code to use the Legacy Style map (which is currently commented out), and comment out the BTF Style map.

// Legacy Maps. More info here https://docs.ebpf.io/linux/concepts/maps/#legacy-maps
struct bpf_map_def SEC("maps") counter = {
    .type = BPF_MAP_TYPE_ARRAY,
    .key_size = sizeof(__u32),
    .value_size = sizeof(__u64),
    .max_entries = 1
} ;

Where bpf_map_def definition is this:

struct bpf_map_def {
	unsigned int type;
	unsigned int key_size;
	unsigned int value_size;
	unsigned int max_entries;
	unsigned int map_flags;
};

Regenerate the code with go generate . and then use readelf -s bpf_bpfel.o, whose output shows:

Num:    Value          Size Type    Bind   Vis      Ndx Name
...
7: 0000000000000000    20   OBJECT  GLOBAL DEFAULT    7 counter

or readelf -S bpf_bpfel.o, whose output shows:

[Nr] Name              Type             Address           Offset       Size              EntSize          Flags  Link  Info  Alig
...
[ 7] maps              PROGBITS         0000000000000000  000001a0     0000000000000014  0000000000000000  WA       0     0     4

The size is 0x14 in hexadecimal, which is 20 bytes. The function that does this job is bpf_object__init_internal_map.

As for the use of readlef, that’s all for now even if it can be used to do many other things.

llvm-objdump πŸ”—

For completeness, below we can see the output of the command llvm-objdump -S bpf_bpfel.o, unlike the output from the command bpftool prog dump xlated, this output works on the .o file itself and not on the program loaded in the kernel.

llvm-objdump -S bpf_bpfel.o

bpf_bpfel.o:	file format elf64-bpf

Disassembly of section xdp:

0000000000000000 <xdp_func>:
; int xdp_func(struct xdp_md *ctx) {
       0:	b7 00 00 00 02 00 00 00	r0 = 0x2
;     void *data_end = (void*)(long)ctx->data_end; 
       1:	61 12 04 00 00 00 00 00	r2 = *(u32 *)(r1 + 0x4)
;     void *data = (void*)(long)ctx->data;
...

Notes:

  • -S shows the disassembled code alongside each line of source code.
  • -d disassemble all executable sections found in the input files.
  • -D disassembles all sections found in the input files.

Summary πŸ”—

We learned how to use bpftool, readelf, and llvm-objdump to inspect an eBPF ELF file and gain a better understanding of its structure. Although this article is not exhaustive regarding these tools, it helped us grasp how to leverage them to uncover hidden details like maps (personally, my favorite part), variables, and instruction flow.

It’s my first time writing a blog post like this, so I hope it was both enjoyable and understandable.

Special thanks to Leonardo Di Giovanna and Francesco Monaco for taking the time to review my article.

References πŸ”—