C Programming
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 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 is:
(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 , , , hand-compute (in ordinary
decimal, then as the stored int32_t hex value) , , , . Show the exact
Q16.16 stored integers.
(d) [4] The physically exact free-fall position after time is . Compute exactly and compare to your 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 = number of set bits. Prove the
"parallel bit-count" reduction is correct for the first step, i.e. prove that
computes, within each adjacent 2-bit field, the population count of that field.
(Hint: treat per 2-bit field with 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 .
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
(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 up) loses more accuracy than summing in reverse order for large , in IEEE-754 double. Give the mathematical reason (magnitude of running sum vs. added term) and relate it to the unit roundoff .
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 . (1 mark for typedef + scale)
- Resolution . (1)
- Range: . (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 ~; product is a Q32.32 quantity needing up to ~ → cannot fit in 32 bits. (2)
- Must cast before multiply;
(int64_t)(a*b)would already overflow. (1) - Signed integer overflow of
int32_tis 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] , , .
| step | value (decimal) | Q16.16 integer | hex |
|---|---|---|---|
0x0004E7B6 |
|||
0x00027397 |
|||
0x0009CF5C |
|||
0x000EBB45 |
(1 mark per correctly computed pair; rounding to nearest integer accepted.)
(d) [4] Exact: . Computed .
-
Discrepancy , i.e. computed is too large. (1)
-
Origin: semi-implicit Euler uses the updated velocity in the position update, overshooting because velocity is taken at the end of the interval; over two steps it accumulates giving larger than the area. It is truncation/discretisation error ( local), NOT fixed-point rounding. (3)
(Numeric check: semi-implicit gives ; with : . ✓)
(e) [4]
volatileforces 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
volatileso 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 , ; popcount of the field is .
- shifts into the low position of the field (value there, and the high bit receives the low bit of the neighbouring field).
- Masking with
0x55...5(binary0101...) keeps only the low bit of each 2-bit field, isolating . - Then within the field = popcount. ∎ (2 for setup, 2 for shift+mask isolating , 2 for subtraction giving with no borrow across fields because result 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
0x01010101sums the four byte-counts into the top byte (each01broadcasts/adds the lower bytes upward; total fits in 8 bits, no carry out of the top byte matters). The top byte sits at bits 24–31, so>> 24extracts the total. (3)
(c) [4]
struct S { unsigned flags : 3; } s;
s.flags = 13; /* 13 = 0b1101, only low 3 bits stored */- Read back value = (
0b101). (2) - Mechanism: an unsigned bit-field of width 3 holds values ; the value is reduced modulo 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 << 32wherexis 32-bituint32_t: shift count width of the promoted type ⇒ undefined behaviour (C rule: shift amount must be width of the (promoted) left operand type). (2)x << 31is well-defined: the count 31 is ; result isx's bit 0 moved to bit 31, everything else shifted out — a defined value. (1)- Portable "produce 0": simply
0u, or(uint32_t)0, orx & 0u. If a variable shift is desired, use(uint64_t)x << 32then 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 ainsidereduceyieldssizeof(const double *)(typically 8), the pointer size — notn * sizeof(double); hencenmust 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 *ais portable regardless ofdouble's size. (2)calloczero-initialises every byte, but we overwrite allnentries immediately, so the zeroing is wasted work. (1.5)malloc(n * 8)hard-codessizeof(double) == 8; on a platform wheredoublediffers (or ifn * 8overflowssize_t) it under-allocates → portability/overflow bug. (1.5)
(c) [4]
- L4
reduce(p, 5, ...)afterfree(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 setp = NULLafter 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_series→ heap; the pointerp(local) → stack; functionadd(code) → text. (1)
(e) [4]
- Floating-point addition error per step is bounded by with . In natural order the running sum grows toward while the newly added terms 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 where are the partial sums; forward order maximises the at the moment small terms are added, hence larger error. Reverse order minimises 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