Build Systems & Toolchain
Level 5 — Mastery (cross-domain: systems, math, coding) Time limit: 90 minutes Total marks: 60
Instructions: Answer all three questions. Show reasoning, derive complexity/cost estimates where requested, and justify each toolchain claim with reference to the underlying mechanism. Assume Linux/ELF, GCC/Clang, GNU Make and CMake unless stated.
Question 1 — Symbol Resolution, Linking Cost & PIC (22 marks)
You are building a numerical solver split across translation units. Given:
main.oreferences symbolssolve,norm.libsolve.a= archive ofsolve.o(definessolve, referencesnorm,dot) andextra.o(definesunused).libmath.a= archive ofmath.o(definesnorm,dot).
(a) [4] Explain what the linker does in each of the four compilation stages for main.c, and state precisely at which stage an "undefined reference to solve" error is emitted versus a #include-related error.
(b) [6] The command gcc main.o -lsolve -lmath succeeds, but gcc main.o -lmath -lsolve fails with undefined reference to 'norm'. Explain the archive-member pull-in mechanism and the single-pass left-to-right resolution rule that causes this, referencing the linker's undefined symbol set. State one flag-based fix.
(c) [6] A shared library libsolve.so is built with -fPIC. Explain what Position-Independent Code changes about how the symbol norm is accessed at runtime (GOT/PLT), and derive the number of extra memory indirections on the first vs. subsequent calls to an external function under lazy binding.
(d) [6] Suppose the solver's inner loop calls one external PIC function dot() exactly times. Let the cost of a direct (non-PIC static) call be cycles, and each GOT indirection add cycles, with a one-time PLT lazy-resolution cost of cycles on first call. Derive a closed-form expression for total call overhead relative to the static case, and compute the asymptotic per-call overhead ratio as . Given , , , find the smallest for which average per-call overhead of the PIC version is within of the static cost.
Question 2 — Makefile Correctness, CMake & Cross-Compilation (20 marks)
(a) [8] Consider this Makefile:
CC = gcc
CFLAGS = -O2 -Wall
OBJ = main.o solve.o math.o
app: $(OBJ)
$(CC) $(CFLAGS) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) app(i) Explain the meaning of $^, $<, $@ in the two recipes. (ii) The build is silently incorrect when a header solve.h (included by main.c and solve.c) changes. State why, and rewrite/add the minimal rule(s) to fix header-dependency tracking. (iii) clean sometimes fails if a file named clean exists — give the one-line fix and name the mechanism.
(b) [6] Write a minimal CMakeLists.txt that: builds a static library solve from solve.c math.c; builds an executable app from main.c linking solve; sets C standard 17; and enables -fsanitize=address only for the Debug build type. Explain how CMAKE_BUILD_TYPE selects flag sets and what target_link_libraries propagates.
(c) [6] You must cross-compile app for an ARM64 target from an x86-64 host. Explain the roles of the toolchain (aarch64-linux-gnu-gcc) and the sysroot. Given that the target uses a shared libmath.so, explain why linking against the host's libmath.so is wrong and what mechanism (--sysroot) ensures the correct headers/libs are used. State how the resulting binary's ELF machine field differs.
Question 3 — Sanitizers, UB Proof & Disassembly (18 marks)
(a) [8] Given:
int sum(int *a, int n) {
int s = 0;
for (int i = 0; i <= n; i++) // note: <=
s += a[i];
return s;
}(i) Identify the bug and prove it is a heap-buffer-overflow read of exactly one element past the array when called with a size-n array. (ii) Explain what redzone ASan places and how the runtime detects the access. (iii) If instead s overflows INT_MAX, which sanitizer (ASan/UBSan/TSan) catches it and why is this undefined behavior by the standard?
(b) [6] Two threads both execute counter++ on a shared int counter with no synchronization. (i) Explain why counter++ is not atomic by decomposing it into its load/add/store machine steps. (ii) State the maximum and minimum final value of counter if each thread increments times (starting from 0), and prove the minimum. (iii) Which sanitizer flags this and what is its detection principle?
(c) [4] Given this objdump -d fragment for a function f:
0000000000001129 <f>:
1129: 8d 04 3f lea (%rdi,%rdi,1),%eax
112c: c3 ret
Determine what f(x) computes as a function of its integer argument x (passed in %edi), and justify from the addressing mode.
Answer keyMark scheme & solutions
Question 1
(a) [4]
- Preprocessing (1):
#include/macro expansion; a missingsolve.h(bad#include) fails here (fatal error: solve.h: No such file). - Compilation (1): C → assembly; type/syntax errors here.
solveused but undeclared → compile-time error (implicit decl warning in C). - Assembly (1): asm →
main.oobject withsolveas an undefined symbol in symbol table (UND entry + relocation). - Linking (1): resolves
solve. "undefined reference tosolve" is emitted here, not earlier — the compiler trusts the declaration; only the linker knows no definition exists.
(b) [6]
- Linker processes inputs left to right, maintaining a set of currently-undefined symbols (2). When it meets an archive, it pulls in only those members that satisfy a currently-unresolved symbol (1).
main.o -lsolve -lmath: aftermain.o, undefined = {solve, norm}.-lsolvepullssolve.o(defines solve; addsdotto undefined) → undefined = {norm, dot}.-lmathpullsmath.o→ resolves norm, dot. ✓ (1)main.o -lmath -lsolve: aftermain.o, undefined={solve,norm}.-lmathprovides norm/dot, butdotisn't yet needed (solve not pulled), somath.osatisfiesnorm(pulled). Then-lsolvepullssolve.owhich newly referencesnorm,dot— butlibmathwas already passed and won't be re-scanned →undefined reference to 'norm'. ✗ (1)- Fix (1):
gcc main.o -lsolve -lmath -lmath(repeat) OR wrap in-Wl,--start-group -lsolve -lmath -Wl,--end-group.
(c) [6]
- PIC accesses
normnot by absolute address but via the GOT (Global Offset Table): code loads the function address from a GOT slot; calls to external functions go through a PLT stub (2). - Lazy binding: first call jumps to PLT stub → GOT still points back into PLT → invokes dynamic linker (
_dl_runtime_resolve) which resolves the real address, patches GOT (1). - Indirections (3): First call = PLT stub + GOT load (points to resolver) + resolver work → the address is resolved once (extra resolver cost). Subsequent calls = 1 extra memory indirection (GOT load) vs direct call. So: first call ≈ resolver overhead; steady state = 1 extra GOT indirection per call.
(d) [6]
- Static total = . PIC total: one call pays resolver (once), every call pays : (2). (Accept .)
- Overhead relative to static (1).
- Per-call ratio asymptote: as (1). So steady-state overhead ratio (50% over static, from the GOT indirection).
- "Within 60%": need (1). With : . Smallest (1).
Question 2
(a) [8]
- (i) [3]
$^= all prerequisites (main.o solve.o math.o);$<= first prerequisite (the.c);$@= target name (appor the.o). - (ii) [3] Object rule depends only on
%.c, notsolve.h; changingsolve.hdoes not trigger recompilation → stale object with old header → silently wrong. Fix: add header prerequisites, ideally auto-generated:
CFLAGS += -MMD -MP
-include $(OBJ:.o=.d)(Accept explicit main.o solve.o: solve.h.)
- (iii) [2] Add
.PHONY: clean. Mechanism: phony targets tell Make the target is not a file, so it always runs regardless of a same-named file/timestamp.
(b) [6]
cmake_minimum_required(VERSION 3.10)
project(app C)
set(CMAKE_C_STANDARD 17)
add_library(solve STATIC solve.c math.c)
add_executable(app main.c)
target_link_libraries(app PRIVATE solve)
target_compile_options(solve PRIVATE $<$<CONFIG:Debug>:-fsanitize=address>)
target_link_options(app PRIVATE $<$<CONFIG:Debug>:-fsanitize=address>)(4 for correct structure/lib/link/standard, 1 for generator-expression config guard.)
Explanation (1): CMAKE_BUILD_TYPE=Debug selects CMAKE_C_FLAGS_DEBUG (-g), Release selects -O3 -DNDEBUG; $<CONFIG:Debug> applies flags only in that config. target_link_libraries(... PRIVATE) propagates usage requirements (include dirs, defines) of solve to app but not transitively past app.
(c) [6]
- Toolchain [2]:
aarch64-linux-gnu-gccis a cross-compiler — runs on x86-64 but emits AArch64 machine code + uses the ARM64 assembler/linker. - Sysroot [2]:
--sysroot=/path/to/arm64/rootredirects header (/usr/include) and library (/usr/lib) searches to the target's filesystem, not the host's. - Why host lib wrong [1]: host
libmath.socontains x86-64 code — link/load would fail (wrong ELF machine, wrong ABI/symbol versions).--sysrootguarantees ARM64libmath.so+ matching headers. - ELF field [1]:
e_machine=EM_AARCH64(0xB7) vs hostEM_X86_64(0x3E).
Question 3
(a) [8]
- (i) [3] Loop condition
i <= niteratesi = 0..n, i.e.n+1accesses. Valid indices of a size-narray are0..n-1. Access ati=nreadsa[n], exactly one element (sizeof(int)bytes) past the end → out-of-bounds read. Proof: last legal indexn-1 < n, soa[n]is beyond allocation. - (ii) [3] ASan surrounds each heap allocation with redzones (poisoned bytes) tracked in shadow memory (1 shadow byte per 8 app bytes). Every load/store is instrumented to check the shadow; accessing
a[n]lands in the redzone → shadow marks it poisoned → runtime reports heap-buffer-overflow READ of size 4. - (iii) [2] UBSan (
-fsanitize=signed-integer-overflow). Signed integer overflow is undefined behavior per the C standard (§6.5/5) because signed representation/overflow wraparound is not defined; compilers may assume it never happens (enabling optimizations), so it must be caught at runtime, not by ASan (memory) or TSan (data races).
(b) [6]
- (i) [2]
counter++decomposes into: load counter → register; add 1; store register → counter. Non-atomic: another thread can interleave between load and store, reading the stale value. - (ii) [3] Max = (perfectly interleaved without lost updates). Min = (with ). Proof of min: thread A reads 0, is preempted; thread B does all increments (counter = ); A finishes its remaining then writes back 1 (its stale load+adds accumulate to 1 on its held register only for the first, but the classic lost-update lower bound): the minimum is 2 — one thread reads 0 early and eventually stores 1; the other stores 1 last; every intermediate update from the interleaving-loser is overwritten. (Full marks for identifying max , min with the lost-update argument.)
- (iii) [1] TSan (Thread Sanitizer). Principle: tracks a happens-before relation via vector clocks / shadow memory of memory accesses; two accesses to the same location, at least one a write, with no ordering → reported data race.
(c) [4]
lea (%rdi,%rdi,1),%eax: effective address =%rdi + %rdi*1 = 2*%rdi, stored (as computed value, not a load) into%eax(2).leacomputes the address arithmetic without dereferencing (1).- Therefore
f(x) = 2*x(returns double the argument) (1).
[
{"claim":"Q1d smallest N within 60% overhead is 1000","code":"c,g,r=2,1,200; import sympy as sp; N=sp.symbols('N',positive=True); sol=sp.solve(sp.Eq((r+N*(c+g)),sp.Rational(16,10)*N*c),N); Nmin=sp.ceiling(sol[0]); result=(Nmin==1000)"},
{"claim":"Q1d asymptotic per-call overhead ratio is 3/2","code":"c,g=2,1; import sympy as sp; N=sp.symbols('N',positive=True); ratio=sp.limit((200+N*(c+g))/(N*c),N,sp.oo); result=(ratio==sp.Rational(3,2))"},
{"claim":"Q3a off-by-one: i<=n accesses n+1 elements, index n out of bounds for size n","code":"n=5; accesses=[i for i in range(0,n+1)]; result=(len(accesses)==n+1 and max(accesses)==n and n not in range(0,n))"},
{"claim":"Q3c lea (rdi,rdi,1) computes 2*x","code":"x=sp.symbols('x'); val=x + x*1; result=(sp.simplify(val-2*x)==0)"}
]