Level 5 — MasteryC Programming

C Programming

90 minutes60 marksprintable — key stays hidden on paper

Chapter: 5.1 C Programming Level: 5 — Mastery (cross-domain: math + physics + coding, build/prove) Time limit: 90 minutes Total marks: 60

Instructions: Answer all three questions. You may write real, compilable C99/C11. State any assumptions about platform (word size, endianness) explicitly. Partial credit is awarded for correct reasoning even where code has minor slips.


Question 1 — Fixed-point physics integrator (20 marks)

A microcontroller has no FPU. You must integrate the 1-D equation of motion of a falling body under gravity g=9.81 m/s2g = 9.81\ \mathrm{m/s^2} using Q16.16 fixed-point arithmetic stored in an int32_t (16 integer bits, 16 fractional bits, two's complement).

The semi-implicit (symplectic) Euler update with timestep hh is: vn+1=vn+gh,xn+1=xn+vn+1h.v_{n+1} = v_n + g\,h, \qquad x_{n+1} = x_n + v_{n+1}\,h.

(a) [3] Give the typedef for a Q16.16 type using stdint.h, and write two macros TO_FIX(d) and FROM_FIX(q) converting a double to Q16.16 and back. State the resolution (smallest representable positive value) and the representable range as a decimal.

(b) [5] Write a function

q16_16 fix_mul(q16_16 a, q16_16 b);

that multiplies two Q16.16 numbers correctly. Explain why an intermediate 64-bit type is required, and what undefined behaviour would occur if you multiplied in 32 bits and the mathematical product overflowed int32_t. Reference the relevant UB category.

(c) [4] Using h=0.5 sh = 0.5\ \mathrm{s}, v0=0v_0 = 0, x0=0x_0 = 0, hand-compute (in ordinary decimal, then as the stored int32_t hex value) v1v_1, x1x_1, v2v_2, x2x_2. Show the exact Q16.16 stored integers.

(d) [4] The physically exact free-fall position after time tt is x(t)=12gt2x(t)=\tfrac12 g t^2. Compute x(1.0s)x(1.0\,\mathrm{s}) exactly and compare to your x2x_2 from part (c). Explain the sign and origin of the discrepancy in terms of the integrator, not rounding.

(e) [4] Explain why q16_16 must not be declared volatile merely to "stop the compiler optimising the loop away," and give the one legitimate situation in this embedded program where volatile is required.


Question 2 — Prove a bit-manipulation identity and implement it (20 marks)

(a) [6] For any uint32_t x, define pop(x)\text{pop}(x) = number of set bits. Prove the "parallel bit-count" reduction is correct for the first step, i.e. prove that x((x1) & 0x55555555)x - \big((x \gg 1)\ \&\ \texttt{0x55555555}\big) computes, within each adjacent 2-bit field, the population count of that field. (Hint: treat x=2a+bx = 2a + b per 2-bit field with a,b{0,1}a,b\in\{0,1\} and evaluate.)

(b) [5] Implement int popcount(uint32_t x); using the full parallel algorithm (masks 0x55555555, 0x33333333, 0x0F0F0F0F, then a multiply-and-shift by 0x01010101). Explain why the final >> 24 extracts the answer.

(c) [4] Using a struct with a 3-bit unsigned bit-field flags, show what value is read back after assigning it the integer 13, and explain the mechanism. State whether the overflow here is undefined behaviour and justify.

(d) [5] A colleague writes x << 32 for a uint32_t x "to clear it." Explain precisely why this is undefined behaviour (cite the shift rule), contrast with the well-defined behaviour of x << 31, and give a correct portable expression producing 00.


Question 3 — Callback dispatch, memory safety, and a leak proof (20 marks)

You are building a numerical library. A generic reducer applies a callback across a dynamically allocated array of double:

typedef double (*binop)(double acc, double x);
double reduce(const double *a, size_t n, double init, binop f);

(a) [4] Write reduce. Then define a binop named add and use it to sum an array. Explain what "the array name decays to a pointer" means for the parameter a, and why sizeof a inside reduce does not give the array size.

(b) [5] Write double *make_series(size_t n) that mallocs n doubles and fills ai=1/(i+1)a_i = 1/(i+1) (the harmonic series). Show the exact malloc argument expression, and explain why using calloc(n, sizeof(double)) here would be wasteful and why writing malloc(n * 8) is a portability bug.

(c) [4] The following caller has exactly two distinct memory errors. Identify each by its standard name, state the line, and give the fix.

double *p = make_series(5);
double s  = reduce(p, 5, 0.0, add);
free(p);
double t  = reduce(p, 5, 0.0, add);   // (L4)
free(p);                              // (L5)

(d) [3] Show the exact Valgrind (memcheck) error kind you would expect for the L4 and L5 problems, and name the memory segment (text/data/bss/heap/stack) each object lives in: the array from make_series, the pointer p, the function add.

(e) [4] Prove that summing the harmonic series in the natural order (index 00 up) loses more accuracy than summing in reverse order for large nn, in IEEE-754 double. Give the mathematical reason (magnitude of running sum vs. added term) and relate it to the unit roundoff u=253u = 2^{-53}.

Answer keyMark scheme & solutions

Question 1

(a) [3]

#include <stdint.h>
typedef int32_t q16_16;
#define TO_FIX(d)   ((q16_16)((d) * 65536.0 + ((d) < 0 ? -0.5 : 0.5)))
#define FROM_FIX(q) ((double)(q) / 65536.0)
  • Scale factor 216=655362^{16}=65536. (1 mark for typedef + scale)
  • Resolution =216=1/655361.52587890625×105= 2^{-16} = 1/65536 \approx 1.52587890625\times10^{-5}. (1)
  • Range: [215,215216]=[32768,+32767.9999847...][-2^{15}, 2^{15}-2^{-16}] = [-32768, +32767.9999847...]. (1)

(b) [5]

q16_16 fix_mul(q16_16 a, q16_16 b) {
    int64_t t = (int64_t)a * (int64_t)b;   /* full 64-bit product */
    return (q16_16)(t >> 16);              /* rescale by 2^-16 */
}
  • Two Q16.16 values each up to ~2312^{31}; product is a Q32.32 quantity needing up to ~2622^{62} → cannot fit in 32 bits. (2)
  • Must cast before multiply; (int64_t)(a*b) would already overflow. (1)
  • Signed integer overflow of int32_t is undefined behaviour (not modular wraparound; UB category: overflow of a signed integer arithmetic operation). Compiler may assume it never happens → arbitrary result. (2)

(c) [4] g=9.81g=9.81, h=0.5h=0.5, gh=4.905gh = 4.905.

step value (decimal) Q16.16 integer hex
v1=0+4.905v_1 = 0 + 4.905 4.9054.905 4.905×65536=321461.763214624.905\times65536 = 321461.76 \to 321462 0x0004E7B6
x1=0+v1h=2.4525x_1 = 0 + v_1 h = 2.4525 2.45252.4525 160727.04160727160727.04 \to 160727 0x00027397
v2=4.905+4.905=9.81v_2 = 4.905+4.905 = 9.81 9.819.81 642908.16642908642908.16 \to 642908 0x0009CF5C
x2=x1+v2h=2.4525+4.905=7.3575x_2 = x_1 + v_2 h = 2.4525+4.905 = 7.3575 7.35757.3575 482181.12482181482181.12 \to 482181 0x000EBB45

(1 mark per correctly computed pair; rounding to nearest integer accepted.)

(d) [4] Exact: x(1)=12(9.81)(1)2=4.905 mx(1)=\tfrac12(9.81)(1)^2 = 4.905\ \mathrm{m}. Computed x2=7.3575 mx_2 = 7.3575\ \mathrm{m}.

  • Discrepancy =7.35754.905=2.4525 m= 7.3575 - 4.905 = 2.4525\ \mathrm{m}, i.e. computed is too large. (1)

  • Origin: semi-implicit Euler uses the updated velocity vn+1v_{n+1} in the position update, overshooting because velocity is taken at the end of the interval; over two steps it accumulates x=h2g(1+2)=...x = h^2 g(1+2)=... giving \sum larger than the 12gt2\tfrac12 g t^2 area. It is truncation/discretisation error (O(h)O(h) local), NOT fixed-point rounding. (3)

    (Numeric check: semi-implicit gives xN=gh2k=1Nk=gh2N(N+1)/2x_N = g h^2 \sum_{k=1}^N k = g h^2 N(N+1)/2; with N=2,h=0.5N=2,h=0.5: 9.810.253=7.35759.81\cdot0.25\cdot3 = 7.3575. ✓)

(e) [4]

  • volatile forces every access to hit memory and forbids the optimiser from eliding reads/writes; using it just to defeat dead-code elimination is a misuse that destroys performance and does not make the algorithm correct — the loop isn't "dead" if its result is actually consumed. (2)
  • Legitimate use: a variable aliased to a memory-mapped hardware register (e.g. reading the timer/ADC that supplies real acceleration, or a status flag set by an ISR) must be volatile so the compiler re-reads it each iteration instead of caching a stale value. (2)

Question 2

(a) [6] Per 2-bit field write the value as 2a+b2a+b, a,b{0,1}a,b\in\{0,1\}; popcount of the field is a+ba+b.

  • x1x\gg1 shifts aa into the low position of the field (value aa there, and the high bit receives the low bit of the neighbouring field).
  • Masking with 0x55...5 (binary 0101...) keeps only the low bit of each 2-bit field, isolating aa.
  • Then xax - a within the field =(2a+b)a=a+b= (2a+b) - a = a+b = popcount. ∎ (2 for setup, 2 for shift+mask isolating aa, 2 for subtraction giving a+ba+b with no borrow across fields because result 2\le 2 fits in 2 bits.)

(b) [5]

int popcount(uint32_t x) {
    x = x - ((x >> 1) & 0x55555555u);
    x = (x & 0x33333333u) + ((x >> 2) & 0x33333333u);
    x = (x + (x >> 4)) & 0x0F0F0F0Fu;
    return (int)((x * 0x01010101u) >> 24);
}
  • After the 0x0F step, each byte holds the popcount of that byte (0–8). (2)
  • Multiplying by 0x01010101 sums the four byte-counts into the top byte (each 01 broadcasts/adds the lower bytes upward; total 32\le 32 fits in 8 bits, no carry out of the top byte matters). The top byte sits at bits 24–31, so >> 24 extracts the total. (3)

(c) [4]

struct S { unsigned flags : 3; } s;
s.flags = 13;         /* 13 = 0b1101, only low 3 bits stored */
  • Read back value = 13mod8=513 \bmod 8 = 5 (0b101). (2)
  • Mechanism: an unsigned bit-field of width 3 holds values 0..70..7; the value is reduced modulo 232^3 on store (implementation truncates to the low 3 bits). (1)
  • This is not undefined behaviour: conversion to an unsigned type is well-defined modular reduction. (1)

(d) [5]

  • x << 32 where x is 32-bit uint32_t: shift count \ge width of the promoted type ⇒ undefined behaviour (C rule: shift amount must be << width of the (promoted) left operand type). (2)
  • x << 31 is well-defined: the count 31 is <32< 32; result is x's bit 0 moved to bit 31, everything else shifted out — a defined value. (1)
  • Portable "produce 0": simply 0u, or (uint32_t)0, or x & 0u. If a variable shift is desired, use (uint64_t)x << 32 then truncate — the 64-bit shift by 32 is defined. (2)

Question 3

(a) [4]

double reduce(const double *a, size_t n, double init, binop f) {
    double acc = init;
    for (size_t i = 0; i < n; i++) acc = f(acc, a[i]);
    return acc;
}
double add(double acc, double x) { return acc + x; }
/* usage: double s = reduce(arr, N, 0.0, add); */
  • "Array name decays to pointer": when an array is passed as an argument, the parameter is adjusted to a pointer to its first element; the size information is lost. (2)
  • sizeof a inside reduce yields sizeof(const double *) (typically 8), the pointer size — not n * sizeof(double); hence n must be passed explicitly. (2)

(b) [5]

double *make_series(size_t n) {
    double *a = malloc(n * sizeof *a);   /* == n * sizeof(double) */
    if (!a) return NULL;
    for (size_t i = 0; i < n; i++) a[i] = 1.0 / (double)(i + 1);
    return a;
}
  • sizeof *a is portable regardless of double's size. (2)
  • calloc zero-initialises every byte, but we overwrite all n entries immediately, so the zeroing is wasted work. (1.5)
  • malloc(n * 8) hard-codes sizeof(double) == 8; on a platform where double differs (or if n * 8 overflows size_t) it under-allocates → portability/overflow bug. (1.5)

(c) [4]

  • L4 reduce(p, 5, ...) after free(p): use-after-free (dangling pointer dereference). Fix: don't free before this use; or re-fetch/reallocate. (2)
  • L5 second free(p): double free. Fix: free exactly once and set p = NULL after freeing (freeing NULL is a no-op). (2)

(d) [3]

  • L4 → Valgrind reports "Invalid read of size 8" with "Address ... inside a block of size 40 free'd" (use-after-free). (1)
  • L5 → "Invalid free() / delete / delete[]" (double free). (1)
  • Segments: heap array from make_seriesheap; the pointer p (local) → stack; function add (code) → text. (1)

(e) [4]

  • Floating-point addition error per step is bounded by urunning sumu\cdot|\text{running sum}| with u=253u = 2^{-53}. In natural order the running sum grows toward 1/(i+1)lnn\sum 1/(i+1)\approx\ln n while the newly added terms 1/(i+1)1/(i+1) become tiny; adding a tiny term to a large sum means the small term's low bits are lost (absorbed below the ULP of the big accumulator). (2)
  • Reverse order adds the smallest terms first, so partial sums stay small while the tiny terms accumulate into a comparable magnitude before the large terms dominate; fewer low bits are discarded. (1)
  • Formal: total rounding error is ukSk\lesssim u\sum_k |S_k| where SkS_k are the partial sums; forward order maximises the SkS_k at the moment small terms are added, hence larger error. Reverse order minimises Sk\sum|S_k| weighted against the small addends. (1)
[
  {"claim":"Q16.16 stored value of v1=4.905 rounds to 321462", "code":"result = round(4.905*65536)==321462"},
  {"claim":"semi-implicit Euler x2 with N=2,h=0.5,g=9.81 equals 7.3575", "code":"g