Level 5 — MasteryBuild Systems & Toolchain

Build Systems & Toolchain

90 minutes60 marksprintable — key stays hidden on paper

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.o references symbols solve, norm.
  • libsolve.a = archive of solve.o (defines solve, references norm, dot) and extra.o (defines unused).
  • libmath.a = archive of math.o (defines norm, 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 NN times. Let the cost of a direct (non-PIC static) call be cc cycles, and each GOT indirection add gg cycles, with a one-time PLT lazy-resolution cost of rr cycles on first call. Derive a closed-form expression for total call overhead T(N)T(N) relative to the static case, and compute the asymptotic per-call overhead ratio as NN\to\infty. Given c=2c=2, g=1g=1, r=200r=200, find the smallest NN for which average per-call overhead of the PIC version is within 60%60\% 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 kk 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 missing solve.h (bad #include) fails here (fatal error: solve.h: No such file).
  • Compilation (1): C → assembly; type/syntax errors here. solve used but undeclared → compile-time error (implicit decl warning in C).
  • Assembly (1): asm → main.o object with solve as an undefined symbol in symbol table (UND entry + relocation).
  • Linking (1): resolves solve. "undefined reference to solve" 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: after main.o, undefined = {solve, norm}. -lsolve pulls solve.o (defines solve; adds dot to undefined) → undefined = {norm, dot}. -lmath pulls math.o → resolves norm, dot. ✓ (1)
  • main.o -lmath -lsolve: after main.o, undefined={solve,norm}. -lmath provides norm/dot, but dot isn't yet needed (solve not pulled), so math.o satisfies norm (pulled). Then -lsolve pulls solve.o which newly references norm, dot — but libmath was 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 norm not 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 = NcNc. PIC total: one call pays resolver rr (once), every call pays c+gc+g: TPIC(N)=r+N(c+g)T_{PIC}(N) = r + N(c+g) (2). (Accept r+(c+g)+(N1)(c+g)=r+N(c+g)r + (c+g) + (N-1)(c+g) = r+N(c+g).)
  • Overhead relative to static =TPICNc=r+Ng= T_{PIC} - Nc = r + Ng (1).
  • Per-call ratio asymptote: TPICNc=r+N(c+g)Ncc+gc=2+12=1.5\dfrac{T_{PIC}}{Nc} = \dfrac{r+N(c+g)}{Nc} \to \dfrac{c+g}{c} = \dfrac{2+1}{2} = 1.5 as NN\to\infty (1). So steady-state overhead ratio 1.5\to 1.5 (50% over static, from the GOT indirection).
  • "Within 60%": need r+N(c+g)Nc1.6\dfrac{r+N(c+g)}{Nc} \le 1.6 (1). With c=2,g=1,r=200c=2,g=1,r=200: 200+3N2N1.6200+3N3.2N2000.2NN1000\dfrac{200+3N}{2N}\le 1.6 \Rightarrow 200+3N \le 3.2N \Rightarrow 200 \le 0.2N \Rightarrow N \ge 1000. Smallest N=1000N = 1000 (1).

Question 2

(a) [8]

  • (i) [3] $^ = all prerequisites (main.o solve.o math.o); $< = first prerequisite (the .c); $@ = target name (app or the .o).
  • (ii) [3] Object rule depends only on %.c, not solve.h; changing solve.h does 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-gcc is a cross-compiler — runs on x86-64 but emits AArch64 machine code + uses the ARM64 assembler/linker.
  • Sysroot [2]: --sysroot=/path/to/arm64/root redirects header (/usr/include) and library (/usr/lib) searches to the target's filesystem, not the host's.
  • Why host lib wrong [1]: host libmath.so contains x86-64 code — link/load would fail (wrong ELF machine, wrong ABI/symbol versions). --sysroot guarantees ARM64 libmath.so + matching headers.
  • ELF field [1]: e_machine = EM_AARCH64 (0xB7) vs host EM_X86_64 (0x3E).

Question 3

(a) [8]

  • (i) [3] Loop condition i <= n iterates i = 0..n, i.e. n+1 accesses. Valid indices of a size-n array are 0..n-1. Access at i=n reads a[n], exactly one element (sizeof(int) bytes) past the end → out-of-bounds read. Proof: last legal index n-1 < n, so a[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 = 2k2k (perfectly interleaved without lost updates). Min = 22 (with k1k\ge1). Proof of min: thread A reads 0, is preempted; thread B does all kk increments (counter = kk); A finishes its remaining k1k-1 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 =2k=2k, min =2=2 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). lea computes 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)"}
]