Welcome back. Last lesson established how to inspect an ARM64 ELF executable: sections describe link-time organization, program headers describe the runtime image, and the interpreter plus NEEDED entries form its deployment contract.
This lesson turns those observations into a controlled comparison. You will build the same small gateway-oriented program in several forms, then measure what changes in the ELF layout, which target-side files it requires, and what relocation work remains for the dynamic loader. The crucial framing is that static versus dynamic describes how libraries are linked, while PIC versus PIE describes whether generated code can tolerate a changing load address. These are related choices, but they are not interchangeable.
Two independent design decisions
Consider two questions when producing an executable:
-
Where does library implementation code come from at runtime?
- A statically linked executable incorporates required code from static archives during its final link.
- A dynamically linked executable records dependencies on shared objects, which the target dynamic loader must find and bind at startup.
-
Can the program itself be loaded at a varying virtual address?
- Position-dependent code assumes a fixed link-time location for the main executable.
- Position-independent code avoids such assumptions and can be placed at an address selected at load time.
- A PIE is an executable built from position-independent code and linked so that it can be relocated as a complete program image.
- PIC is generally code intended to live in a shared library, which must coexist with many independently loaded processes and libraries.
The terms therefore form a matrix rather than a simple list.
| Artifact | Typical compiler and linker flags | ELF type commonly shown by readelf -h | Dynamic loader required? | Primary purpose |
|---|---|---|---|---|
| Static executable | -static -fno-PIE -no-pie | EXEC | No | Self-contained deployment artifact |
| Dynamic, non-PIE executable | -fno-PIE -no-pie | EXEC | Yes | Traditional fixed-base executable using shared libraries |
| Dynamic PIE executable | -fPIE -pie | DYN | Yes | Main executable eligible for address-space randomization |
| Shared library | -fPIC -shared | DYN | No PT_INTERP in the library itself | Reusable code loaded by dynamic executables |
Two details prevent common misdiagnoses:
Type: DYNdoes not automatically mean “shared library.” A PIE executable also normally hasType: DYN. Look for thePT_INTERPprogram header andfileoutput to distinguish an executable from a library.- Static does not simply mean “secure” or “better for embedded.” It removes the ELF loader and shared-library deployment contract, but it usually increases artifact size and makes a libc security update require rebuilding and redeploying every statically linked program that includes that code.
For an industrial gateway, these are product decisions. A small recovery tool in an initramfs may reasonably favor a static build when its limited function must run in an unusual recovery environment. A gateway service normally benefits from dynamically linked, PIE-enabled builds because system libraries can be patched as packages and the application participates in standard hardening policies.
Static and Dynamic Linking on Linux with gcc
Watch “Static and Dynamic Linking on Linux with gcc” by embeddedarmdev for a concise visual model of what becomes part of a static executable and what remains external in a dynamic executable.
Watch linking model to distinguish link-time symbol resolution from resolution performed when a process starts. Then watch deployment tradeoffs for the file-size, memory-sharing, and runtime-dependency consequences. The examples use a host system, but the same ELF concepts apply to your ARM64 target.
PIC, PIE, and the addresses that must be fixed up
A linked program contains many references to addresses: global data, string literals, internal functions, and external library functions. If a program has a known fixed base address, the linker can embed address assumptions directly into the final executable. If the program may be placed elsewhere, those references must instead be expressed in a relocatable form or adjusted by the loader.
PIC, or position-independent code, is conventionally used for shared libraries. A shared library can be mapped into different processes and placed at different addresses, so its code should not require rewriting executable instruction pages merely because its load address differs. GCC generates PIC with -fpic or -fPIC.
On AArch64, prefer -fPIC for normal shared-library work. GCC documents a GOT-size constraint for lowercase -fpic on AArch64; uppercase -fPIC avoids that limit. For a small library both often produce equivalent practical results, but -fPIC avoids a scaling surprise in a real product library.
A PIE, or position-independent executable, applies the same principle to the main executable. It is normally built with -fPIE during compilation and -pie during linking. The executable’s ELF type becomes DYN, allowing the kernel and dynamic loader to choose a load base rather than using a fixed executable base.
Code Gen Options (Using the GNU Compiler Collection (GCC))
Read the relevant GCC documentation to separate the compiler’s PIC and PIE code-generation flags from the linker’s role. This is the authoritative description of the flags you will use in the lab.
In the “Code Gen Options” page, locate the entries beginning with -fpic, -fPIC, and -fpie. Read the PIC size guidance, noting the AArch64 GOT limit for lowercase -fpic. Next read the paragraph beginning the -fPIC option. Finally, read the entry beginning the PIE distinction, including the following sentence that names the -pie linker option.
The Global Offset Table, or GOT, supports indirection for addresses whose final values are not known during linking. For externally provided functions, the Procedure Linkage Table, or PLT, commonly provides a callable stub. A PLT entry consults an associated GOT entry.

The exact binding policy depends on linker options and loader settings:
- With conventional lazy binding, the first call to an imported function may trigger resolution through the PLT.
- The loader records the resolved target in the GOT.
- Later calls can use the resolved location.
- With eager binding, the loader resolves applicable imports at startup instead. The ELF still has a dynamic-linking contract; only the timing changes.
The prior lesson’s readelf -rW command is how you inspect the evidence rather than assuming the policy.
Build a controlled ARM64 comparison set
Use the same Yocto SDK environment and target-aware binutils as in the previous lesson. Do not replace the SDK compiler with Ubuntu’s native gcc: these artifacts must remain AArch64 builds that use target headers and libraries.
Create a new private lab directory:
cd ~/gateway-private
mkdir -p labs/03-linkage-layout
cd labs/03-linkage-layout
printf 'CC=%s\n' "$CC"
printf 'Target sysroot=%s\n' "$SDKTARGETSYSROOT"
READELF="$(${CC} -print-prog-name=readelf)"
OBJDUMP="$(${CC} -print-prog-name=objdump)"
SIZE="$(${CC} -print-prog-name=size)"
Create a deliberately small shared library and an application that consumes it:
cat > telemetry.h <<'EOF'
#ifndef TELEMETRY_H
#define TELEMETRY_H
int telemetry_score(int sample);
#endif
EOF
cat > telemetry.c <<'EOF'
#include "telemetry.h"
int telemetry_bias = 7;
int telemetry_score(int sample)
{
return sample + telemetry_bias;
}
EOF
cat > gateway_main.c <<'EOF'
#include <stdio.h>
#include "telemetry.h"
int main(void)
{
printf("gateway telemetry score: %d\n", telemetry_score(42));
return 0;
}
EOF
First compile the library source as PIC and link it into a versioned shared object:
${CC} -Wall -Wextra -O0 -g -fPIC -c telemetry.c -o telemetry.pic.o
${CC} -shared \
-Wl,-soname,libtelemetry.so.1 \
-o libtelemetry.so.1.0 telemetry.pic.o
ln -sf libtelemetry.so.1.0 libtelemetry.so.1
ln -sf libtelemetry.so.1 libtelemetry.so
The three names serve different purposes:
| Name | Role | Needed at runtime? |
|---|---|---|
libtelemetry.so.1.0 | Real file containing the library implementation | Yes |
libtelemetry.so.1 | SONAME symlink selected by runtime dependency resolution | Yes |
libtelemetry.so | Linker name used when the build specifies -ltelemetry | Usually no |
Verify the library’s identity before building the executables:
file libtelemetry.so.1.0
"$READELF" -hW libtelemetry.so.1.0 | grep 'Type:'
"$READELF" -dW libtelemetry.so.1.0 | grep -E 'SONAME|NEEDED'
"$READELF" -lW libtelemetry.so.1.0 | grep 'Requesting program interpreter' || true
You should observe Type: DYN and a SONAME of libtelemetry.so.1. A shared library normally does not request a program interpreter, because it is not independently launched by the kernel as a normal program.
Now build the dynamic, non-PIE executable explicitly. Modern Linux toolchains commonly default to PIE, so both compile and link options below are intentional:
${CC} -Wall -Wextra -O0 -g \
-fno-PIE -no-pie \
gateway_main.c \
-L. -ltelemetry \
-Wl,--enable-new-dtags,-rpath,'$ORIGIN' \
-o gateway-dyn-exec
The $ORIGIN RUNPATH is a laboratory convenience: it tells the target loader to search the executable’s directory for libtelemetry.so.1. It allows the executable and the library to be deployed together for a controlled test. Do not treat an unrestricted runtime search path as a production default; the directory and its ownership must be controlled.
Next build the dynamic PIE executable:
${CC} -Wall -Wextra -O0 -g \
-fPIE -pie \
gateway_main.c \
-L. -ltelemetry \
-Wl,--enable-new-dtags,-rpath,'$ORIGIN' \
-o gateway-dyn-pie
Both dynamic executables need the same custom library and target libc. The difference is in the code generation and loadability of the main executable itself.
Finally, attempt the static executable:
find "$SDKTARGETSYSROOT" -name libc.a -print
${CC} -Wall -Wextra -O0 -g \
-static -fno-PIE -no-pie \
gateway_main.c telemetry.c \
-o gateway-static
A static link requires static versions of the C library and other toolchain runtime components. If the command succeeds, you have a directly comparable ARM64 static artifact.
If it fails with an error such as cannot find -lc, do not silently substitute the Ubuntu host compiler. Record this as an SDK-content finding: your current target SDK supplies shared runtime artifacts but not the required static development archives. The static comparison below still tells you what evidence to expect; later, a deliberately configured SDK can include static development packages when the product genuinely requires them.
Measure the file layout and deployment contract
Start with the total on-storage size:
stat -c '%n %s bytes' \
gateway-dyn-exec \
gateway-dyn-pie \
libtelemetry.so.1.0
if [ -f gateway-static ]; then
stat -c '%n %s bytes' gateway-static
fi
Then inspect section-size totals. The -A form makes size list individual sections rather than only an aggregate:
"$SIZE" -A gateway-dyn-exec
"$SIZE" -A gateway-dyn-pie
"$SIZE" -A libtelemetry.so.1.0
if [ -f gateway-static ]; then
"$SIZE" -A gateway-static
fi
Do not focus on one exact byte count. Toolchain release, debug information, optimization level, linker implementation, build IDs, and C library versions all affect the result. Instead, compare the structure:
- The dynamic executables should contain sections such as
.interp,.dynamic,.dynsym,.dynstr,.plt,.got,.rela.dyn, or.rela.plt. - The static executable should be substantially larger once static libc code is incorporated, while lacking the normal dynamic-loader contract.
- The shared library holds
telemetry_score()andtelemetry_bias; the dynamic executables contain references to them instead of their implementation. - Debug sections can dominate a tiny example compiled with
-g. They are development metadata, not runtime mapped code.
Capture key ELF evidence for each artifact:
for artifact in gateway-dyn-exec gateway-dyn-pie libtelemetry.so.1.0; do
printf '\n========== %s ==========\n' "$artifact"
file "$artifact"
printf '\nELF type:\n'
"$READELF" -hW "$artifact" | grep -E 'Type:|Machine:|Entry point'
printf '\nInterpreter:\n'
"$READELF" -lW "$artifact" \
| grep 'Requesting program interpreter' || true
printf '\nDynamic contract:\n'
"$READELF" -dW "$artifact" \
| grep -E 'NEEDED|SONAME|RPATH|RUNPATH' || true
printf '\nSelected layout sections:\n'
"$READELF" -SW "$artifact" \
| grep -E '\.(interp|text|plt|got|rela\.dyn|rela\.plt|dynamic|dynsym)' \
|| true
printf '\nDynamic relocations:\n'
"$READELF" -rW "$artifact" | grep 'R_AARCH64' || true
done
If gateway-static was built, inspect it separately:
printf '\n========== gateway-static ==========\n'
file gateway-static
"$READELF" -hW gateway-static | grep -E 'Type:|Machine:|Entry point'
printf '\nInterpreter, if any:\n'
"$READELF" -lW gateway-static \
| grep 'Requesting program interpreter' || true
printf '\nDynamic section entries, if any:\n'
"$READELF" -dW gateway-static || true
printf '\nRelocations, if any:\n'
"$READELF" -rW gateway-static || true
A normal fully static, non-PIE executable has no PT_INTERP and no NEEDED entries because no ELF dynamic loader is required to locate shared objects before main() can run. If readelf -rW reports relocation information, inspect the section name before drawing conclusions: not every relocation record implies loader work at process startup. The important distinction is whether a runtime dynamic-linking contract exists.
Interpret the relocation evidence
Your dynamic AArch64 builds may show several relocation types. Exact records vary with GCC, linker, optimization, and hardening settings, so use their role rather than treating a particular count as a pass criterion.
| Relocation evidence | Meaning in this lab |
|---|---|
R_AARCH64_JUMP_SLOT | A GOT or PLT entry must receive the resolved address of an imported function such as printf or telemetry_score. |
R_AARCH64_GLOB_DAT | A GOT entry must receive an address for a global symbol or data reference. |
R_AARCH64_RELATIVE | The loader adjusts an internal address using the actual runtime base address. This is particularly characteristic of position-independent images. |
.rela.plt | Dynamic relocations associated with imported function-call stubs. |
.rela.dyn | Other dynamic relocation records, commonly including relative and data-address work. |
Inspect the call site in each executable:
"$OBJDUMP" -d --disassemble=main gateway-dyn-exec | less
"$OBJDUMP" -d --disassemble=main gateway-dyn-pie | less
In the dynamic variants, main should call telemetry_score@plt or an equivalent generated entry. The executable does not contain the implementation of telemetry_score; it contains a route through the dynamic-linking mechanism. Contrast that with a successful static build:
"$OBJDUMP" -d --disassemble=main gateway-static | less
"$OBJDUMP" -d --disassemble=telemetry_score gateway-static | less
In the static artifact, both main and the implementation of telemetry_score are present in the executable. The final image has incorporated code from telemetry.c and, much more significantly, from the static C runtime.
Finally, compare the program-header virtual addresses:
"$READELF" -lW gateway-dyn-exec | sed -n '/Program Headers:/,/Section to Segment mapping:/p'
"$READELF" -lW gateway-dyn-pie | sed -n '/Program Headers:/,/Section to Segment mapping:/p'
A non-PIE executable normally has virtual addresses linked around a fixed nonzero base. A PIE typically presents load segments with relative virtual addresses beginning near zero; the loader chooses the actual base when the process starts. This ELF property enables address-space layout randomization for the main program, but whether addresses are randomized during execution also depends on kernel configuration and process settings.
At this stage, do not use ldd on the workstation to validate these ARM64 binaries. It cannot perform normal ARM64 target resolution on an x86-64 host, and ELF metadata inspection is sufficient for this comparison.
A concise evidence record
Create a private comparison note:
cat > linkage-comparison.md <<'EOF'
# ARM64 linkage comparison
## Artifacts
- `gateway-static`:
- `gateway-dyn-exec`:
- `gateway-dyn-pie`:
- `libtelemetry.so.1.0`:
## Measured evidence
For each artifact, record:
- `file` classification and byte size
- ELF `Type` and load-segment virtual-address pattern
- requested interpreter, if present
- `NEEDED`, `SONAME`, and `RUNPATH` entries
- relevant `.plt`, `.got`, `.dynamic`, and relocation sections
- observed AArch64 relocation types
## Deployment conclusion
State exactly which files must be installed beside each executable
and which are expected to come from the target root filesystem.
EOF
For the eventual public showcase, publish only a sanitized table of conclusions and generic ELF excerpts. Keep complete binaries, full build metadata, internal paths, and any proprietary library names in the private repository.
Key takeaways
Static and dynamic linking answer where required library code comes from. A static executable incorporates it at link time; a dynamic executable declares dependencies that the target dynamic loader must satisfy.
PIC and PIE answer whether generated code can tolerate relocation of its containing image:
- Build reusable shared libraries with
-fPIC -shared. - Build a PIE executable with
-fPIE -pie. - Use
-fno-PIE -no-pieonly when you deliberately need a non-PIE comparison or have a justified platform constraint. - Verify the result through ELF evidence, not by trusting build flags alone.
Your essential measurement tools remain:
statfor file size;size -Aandreadelf -SWfor layout;readelf -lWandreadelf -dWfor the runtime loader and dependency contract;readelf -rWfor deferred relocation work;objdump -dfor connecting PLT-mediated calls to generated instructions.
Next, you will use this runtime-dependency understanding to assemble a minimal BusyBox root filesystem. The key practical question will become: which directories, loader files, libraries, device support, and init components must exist for an ARM64 program to boot and run successfully?
Can't find a good explanation? Sign up and we'll make it for you
Sign up