Computer Systems
Thirteen modules on what really happens when a C program runs: bits, machine code, pipelines, caches, linking, processes, virtual memory, I/O, sockets and threads. Toggle bits, step through a function call, run a cache, schedule two threads by hand, then check yourself with concept questions and practice problems.
Follows the chapter order of R. E. Bryant and D. R. O'Hallaron, Computer Systems: A Programmer's Perspective, 2nd ed. (Prentice Hall, 2011), Chapters 2–12. Chapter 1, the book's overview tour, has no module of its own. Chapter numbers refer to that book; the questions and examples here are written for this page.
Integers: one pattern, two readings
A -bit word is only a pattern. Read it as unsigned and you get one number; read it as two's complement and, if the top bit is set, you get a number exactly smaller. The adder does not care which reading you meant, so the same addition can overflow in one reading and be exact in the other.
The idea
Draw the bit patterns around a clock. Adding moves you around the clock. The unsigned reading breaks at the top, between and ; the signed reading breaks at the bottom, between and . Overflow means your path crossed the break for the reading you care about.
int and unsigned, the signed operand is converted to unsigned. So -1 < 0u is false: becomes .Explore
Worked example
A 16-bit sensor value is stored as short s = -300;. The program then does
unsigned short u = s; /* same 16 bits, unsigned reading */
int i = u; /* widen an unsigned value */
int j = s; /* widen a signed value */
signed char k = (signed char) s; /* keep the low 8 bits */
Find the bits and the value of every variable.
What are the 16 bits of
s?is
0x012C. Its negation in 16 bits is , which is0xFED4. (Check: flip the bits of0x012Cto get0xFED3, then add 1.)What value does
uhold?The same bits read as unsigned: .
Widening to 32 bits: what are
iandj?An unsigned value is zero-extended:
iis0x0000FED4. A signed value is sign-extended (the top bit is copied into the new bits):jis0xFFFFFED4. Each extension keeps the value of its own reading.What is
k?Truncation keeps the low byte
0xD4. Its top bit is 1, so as a signed char it is . Truncation is reduction mod : .
Check your understanding
For a 32-bit int x with , is -x always different from x?
- Yes: a nonzero number never equals its negative.That's true for integers, but
intarithmetic is mod . Try the most negative value. - No: for ,
-xequalsx. - No:
-xoverflows for every negativex.Every negative value except one has a positive partner in range.
1000).int x = -1; unsigned u = 1; What does x < u evaluate to?
- 1 (true), since .C does not compare the mathematical values here. Which conversion happens first?
- 0 (false)
- It depends on the compiler.The conversion rule is fixed by the C standard.
x is converted to unsigned: its bits are all ones, which reads as , and that is not less than 1.You add two -bit two's-complement numbers of opposite sign. Can the result overflow?
- Yes, when one of them is .Try , the most extreme case. Is it in range?
- Never.
- Yes, whenever the carry flag is set.The carry flag is about the unsigned reading. Try 4 bits: sets the carry but is exact as signed.
Practice
Sign extension keeps the valueA one-line proof
Show that copying the top bit into a new bit position does not change the two's-complement value: . Conclude that sign-extending by any number of bits is safe.
Hint
Only the two top bits differ in weight. Compare with .
Solution
The low bits contribute the same in both. In the longer word, the top two bits contribute , which is exactly what the single top bit contributes in the shorter word. Induction on the number of added bits gives the general case.
Shifting is not dividingRounding direction
In 8 bits, let x = -7. Compute x >> 1 with an arithmetic shift and compare it with C's x / 2. Then find a value b such that (x + b) >> 1 equals x / 2 for negative x.
Hint
An arithmetic right shift by computes . C's division truncates toward zero.
Solution
is 0xF9 = 11111001; shifting right and copying the sign bit gives 11111100 = . C gives -7/2 = -3. Adding the bias first fixes it: . Compilers emit exactly this bias for signed division by a power of two, applied only when .
More practice: Bryant–O'Hallaron, Practice Problems 2.17, 2.21 and 2.29; Homework 2.81.
Floating point: sign, exponent, fraction
IEEE floating point is binary scientific notation with a fixed number of digits. That one decision explains everything odd about it: the gaps between numbers double at each power of two, most decimals are stored only approximately, and adding numbers in a different order can give a different answer.
The idea
float, ; for double, .float, is a tie between and and rounds to . So (16777216.0f + 1.0f) - 16777216.0f is 0, while 16777216.0f + (1.0f - 16777216.0f) is 1. Compilers therefore don't reorder floating-point sums for you.Explore
Worked example
Encode in the 8-bit format above (1 sign bit, , , bias 7).
Write in binary and normalize it.
. So and the digits after the point are
1011.What goes in the exponent field?
=
1001. It is neither 0 nor 15, so the number is normalized.There are four fraction digits but only three bits. Round.
1.101|1: the dropped part is exactly half of the last kept place, a tie. Round to even:1.101ends in 1, so round up to1.110. The stored value is .Give the bit pattern, then check it in the explorer.
1 1001 110=0xCE. Type-6.75above: the explorer shows the same bits, stored value , and an error of , half the gap of between neighbours in .What are the smallest positive value and the largest finite value of this format?
Smallest: the denormal
0 0000 001. Largest:0 1110 111.
Check your understanding
Why do denormalized numbers use rather than ?
- So that the largest denormal and the smallest normal are one gap apart, with no jump in spacing.
- To make room for NaN.NaNs use the all-ones exponent field, not the all-zeros one.
- Because the hidden bit would otherwise be 1.The hidden bit is 0 for denormals by definition; the question is which power of two multiplies it.
What is the smallest positive integer that a float cannot represent exactly?
- Don't forget the hidden leading 1: a normalized float has 24 significant bits.
- A power of two needs only one significant bit, so it is exact.
float x is finite. Can x + 1.0f == x be true?
- No: adding 1 always changes a number.Only if the result can be stored. What is the gap between floats near ?
- Yes, for example at , where the gap between floats is 2.
- Only for denormalized
x.Near zero the gaps are tiny, so adding 1 certainly changes the value.
for (float f = 0; f != 2e7f; f += 1) never ends.Practice
Decode a floatFrom hex to value
Which number does the float bit pattern 0x41200000 represent?
Hint
Write out the first 12 bits: the sign is bit 31, the exponent is bits 30–23.
Solution
0100 0001 0010 0000 …: , 10000010, so ; the fraction is 010…0 , so . The value is . Check it by toggling the bits in float mode.
Counting a formatHow many values are there?
For the 8-bit format (, ), how many bit patterns represent positive finite numbers, and how many of those are denormalized?
Solution
With : exponent field 0 gives zero and 7 denormals; fields 1 to 14 give normalized values; field 15 gives and 7 NaNs. So there are positive finite values, 7 of them denormalized.
More practice: Bryant–O'Hallaron, Practice Problems 2.45, 2.47 and 2.52; Homework 2.84 and 2.88.
Procedures and the run-time stack
A function call is three cooperating conventions: call leaves a return address on the stack, the arguments and the result travel in agreed registers, and each function promises to restore certain registers. Step through a recursive call one instruction at a time and watch the stack grow and shrink.
The idea
The book's main examples are IA32; it then extends them to x86-64 (Section 3.13). We use x86-64, where the first six integer arguments travel in registers.
%rsp holds the address of its top element. pushq S subtracts 8 from %rsp and stores there; popq D loads from (%rsp) into and adds 8.call f pushes the address of the instruction after the call (the return address) and jumps to f. ret pops an address and jumps there.%rdi, %rsi, %rdx, %rcx, %r8, %r9; result in %rax. Callee-saved: %rbx, %rbp, %r12–%r15 must hold the same values on return, so a function that wants one pushes it first and pops it at the end. All other registers are caller-saved: the caller cannot expect them to survive a call.long fact(long n) {
if (n <= 1)
return 1;
return n * fact(n - 1);
}
Below, n arrives in %rdi. The function needs n after the recursive call, and %rdi won't survive it, so it parks n in the callee-saved %rbx, which means saving the caller's %rbx first.
Explore
%rip). Addresses are on the left.%rbx · brackets show each call's frameWorked example
Here a local variable must live on the stack:
void incr(long *p, long d) { *p += d; }
long twice(long x) {
long v = x;
incr(&v, 10);
return v * 2;
}Why can't
vstay in a register?incrneeds a memory address for it:&v. Registers have no addresses, sotwicemust reserve a stack slot forv.Write plausible x86-64 code for
twice.twice: subq $24, %rsp # room for v (plus padding) movq %rdi, 8(%rsp) # v = x leaq 8(%rsp), %rdi # 1st argument: &v movl $10, %esi # 2nd argument: 10 call incr movq 8(%rsp), %rax # reload v: incr changed it addq %rax, %rax # v * 2 addq $24, %rsp # free the frame retWhy 24 bytes, when
vneeds only 8?The x86-64 ABI requires
%rspto be a multiple of 16 at everycall. On entry totwice, the caller'scallhas just pushed 8 bytes, so%rspis 8 more than a multiple of 16. Subtracting 24 restores the alignment and leaves room forv.What does
incrcompile to? Does it need a frame?addq %rsi, (%rdi)followed byret. It calls nothing and keeps nothing, so beyond the return address it needs no stack at all.What goes wrong if
twicereturned&vinstead?The slot is freed by
addq $24, %rsp. The next call made by the caller reuses those bytes for its own frame, so the returned pointer would point at whatever that call stores there.
Check your understanding
Just after call fact runs, before the first instruction of fact, what is stored at (%rsp)?
- The argument
n.On x86-64 the first argument travels in%rdi, not on the stack. - The address of the instruction right after the
call. - The caller's
%rbp.Only if the callee pushes it.callitself pushes exactly one thing.
ret will pop. Step once past a call in the explorer: a blue slot appears holding the address of the next line in the caller.fact pushes %rbx before using it, but never saves %rdi although it overwrites it. Why is that correct?
%rbxis callee-saved, so the caller may rely on it;%rdiis caller-saved, so the caller may not.%rdiholds the argument, which belongs tofact.That is part of it, but the real rule covers every register: who is responsible for preserving it?- It is a bug that happens not to matter here.It is the convention working as designed.
fact copies n into %rbx.For fact(4) called from main, how many return addresses are on the stack at the deepest point?
- 3Count the call from
maintoo. - 4
- 7That adds the saved copies of
%rbx, which aren't return addresses.
fact(4) to fact(1), each with its return address. There are also three saved %rbx values, because the base case returns without touching %rbx. Set n = 4 and run to the deepest point to see them.Practice
How deep does it go?Stack usage of recursion
How many bytes below main's %rsp does the stack reach during fact(n), for , with the code above?
Solution
Each non-base call uses 16 bytes (return address and saved %rbx); the base case uses 8 (return address only). Total bytes. For that is 40 bytes: compare the lowest %rsp in the explorer with the starting one.
Overwriting a frameWhy buffer overflows are dangerous
A function starts with subq $24, %rsp and keeps char name[16] at (%rsp). It calls strcpy(name, s) where s is a 39-character string. Which bytes of the stack get overwritten, and what happens at ret?
Hint
Draw the frame: the array, 8 bytes of padding, then the return address pushed by the caller. strcpy also writes the terminating zero byte.
Solution
strcpy writes 40 bytes, offsets 0–39 from %rsp: name (0–15), the padding (16–23), the return address (24–31), and 8 bytes of the caller's frame (32–39). After addq $24, %rsp, ret pops characters 24–31 of the string as an address and jumps there. Whoever chooses the string chooses where the program goes.
More practice: Bryant–O'Hallaron, Practice Problems 3.32, 3.33, 3.51 and 3.52.
Pipelining and its hazards
A pipelined processor works on five instructions at once, each in a different stage, like an assembly line. It runs at one instruction per cycle until an instruction needs a result that is still on the line. Then the hardware must either wait or pass the value along a shortcut.
The idea
With instructions and bubbles the pipeline takes cycles (four to fill it). So the cycles per instruction approach . Branches and returns add control hazards on top: a mispredicted branch costs 2 bubbles in the book's design and a ret costs 3.
Explore
Worked example
A block of combinational logic has a total delay of 240 ps. Each pipeline register adds 15 ps. Measure throughput in GIPS (billions of instructions per second; a period of ps gives GIPS).
What is the throughput without pipelining?
One register at the end: period ps, so GIPS, and latency 255 ps.
Split into three equal stages of 80 ps. Throughput and latency?
Period ps: GIPS, about 2.7 times faster. Latency grows to ps.
Suppose the logic only splits as 60, 120, 60 ps. Now?
The slowest stage sets the clock: period ps, GIPS. The two short stages sit idle for half of every cycle.
Go deeper: six equal stages of 40 ps.
Period ps, GIPS, latency ps. Doubling the stages from three to six gave only a 1.73 times speedup, because the 15 ps of register delay is now of every cycle.
Check your understanding
With forwarding, mrmovl 0(%esi),%eax followed directly by addl %eax,%ebx still needs one bubble. Why?
- Forwarding only works for ALU results.The pipeline can forward the loaded value too, from the Memory stage. The problem is timing.
- When
addlis in Decode, the load is only in Execute: memory hasn't been read yet. - Memory is slower than registers, so every load stalls.A load followed by an unrelated instruction causes no bubble at all.
addl in Decode for one cycle lines it up with that moment, and then the value is forwarded. Pick "Load then use" in the explorer.Which reordering of mrmovl 0(%esi),%eax, addl %eax,%ebx, irmovl $4,%ecx gives the same result with no bubble (forwarding on)?
- Move the
irmovlbetween the load and the add. - Move the
addlbefore the load.Thenaddlwould use the old%eax: a different program. - Nothing helps: the load/use bubble is unavoidable.It is unavoidable only when the use comes immediately after the load.
irmovl touches neither %eax nor %ebx, so it can fill the slot the bubble would have taken. Compilers schedule code this way. Compare "Load then use" with "Loads hoisted" in the explorer.Without forwarding, how many bubbles does an instruction need when it reads a register written by the instruction directly before it?
- 1That's the load/use cost with forwarding.
- 2Count from the writer's Decode stage to its Write-back stage.
- 3
Practice
Counting bubblesCheck your answer in the explorer
For the program below, count the bubbles and the total number of cycles, first with stalling only, then with forwarding.
irmovl $1, %eax
addl %eax, %ecx
mrmovl 8(%ecx), %edx
addl %edx, %eax
Hint
Each of the last three instructions reads a register written by the one just before it. Only one of those writers is a load.
Solution
Stalling only: each of the three adjacent dependences costs 3 bubbles, 9 in total, so cycles. Forwarding: only the load followed by its use stalls, 1 bubble, cycles. (The last addl also reads %eax from the first instruction, which is long finished.) The explorer's "Mixed" program is this one.
The cost of mispredictionsCPI from frequencies
In some loop, one instruction in six is a conditional jump, and the predictor is wrong a quarter of the time. Each misprediction costs 2 bubbles. Ignoring other hazards, what is the CPI?
Solution
Bubbles per instruction: . So : about 8% slower than the ideal.
More practice: Bryant–O'Hallaron, Practice Problems 4.26, 4.27 and 4.41.
Latency, throughput and parallel accumulators
A modern core can start a multiply every cycle, yet a simple product loop runs several times slower than that. The reason is a chain of dependences: each multiply waits for the one before. Break the chain into several independent ones and the hardware's parallelism comes back.
The idea
long prod(long *a, long n) {
long acc = 1;
for (long i = 0; i < n; i++)
acc = acc * a[i]; /* needs the previous acc */
return acc;
}
long prod2(long *a, long n) { /* k = 2 accumulators */
long acc0 = 1, acc1 = 1, i;
for (i = 0; i + 1 < n; i += 2) {
acc0 = acc0 * a[i];
acc1 = acc1 * a[i+1]; /* independent of acc0 */
}
for (; i < n; i++)
acc0 = acc0 * a[i];
return acc0 * acc1;
}
prod2 multiplies in a different order. That is harmless for integers (arithmetic mod is associative) but changes rounding for floating point, so a compiler won't make this change to a floating-point loop unless told it may.Explore
Worked example
A loop computes a product of doubles with a single accumulator. The multiplier has latency and is fully pipelined. Profiling shows the loop takes 30% of the program's time.
What CPE do you expect, and why?
One chain of dependent multiplies: each starts only when the previous result is ready, so .
With accumulators and one unit, what is the CPE?
. It improves until , where a new multiply starts every cycle; more accumulators cannot help a single unit.
With units, how many accumulators are needed to reach the throughput bound?
We need , so . Enough chains have to be in flight to keep units each busy for cycles: with , . Try L = 3, u = 2 in the explorer: the CPE stops improving at .
Is the rewrite allowed?
Only if a slightly different rounding is acceptable. The products are mathematically equal, but floating-point multiplication is not associative, so the last bits can differ.
The loop speeds up from CPE 4 to CPE 1. How much faster is the whole program?
Amdahl with , : . Even an infinitely fast loop would give only .
Check your understanding
, two fully pipelined units, accumulators. What CPE does the model give for large ?
- 0.5That's the throughput bound . Is the latency bound also met?
- 1
- 2Four independent chains, each doing one operation every 4 cycles, give 4 operations per 4 cycles.
Can the compiler load *f once, before the loop, in this function?
void scale(long *a, long n, long *f) {
for (long i = 0; i < n; i++)
a[i] = a[i] * *f;
}
- Yes,
*fis never assigned in the loop.Not by name. But what iffpoints intoa? - No:
fmight point to an element ofa, so a store toa[i]could change*f. - No, because loads can never be moved out of loops.They can be, when the compiler can prove nothing in the loop changes them.
f == &a[0], the first iteration changes *f and later iterations must see the new value. If you know they never overlap, copy *f into a local variable yourself.A program spends 80% of its time in one function. You make that function 10 times faster. The overall speedup is about…
- 8That would need the remaining 20% to vanish as well.
- 3.6
- 10Only the 80% got faster.
Practice
Parallelism with a serial partAmdahl's law
A job spends 25% of its time reading input (which can't be sped up) and 75% computing. You spread the computation perfectly over 8 cores. What is the speedup? What is the limit with unlimited cores?
Solution
. The limit is .
Unrolling is not enoughWhere the parentheses go
Two ways to unroll prod by 2 with a single accumulator: (a) acc = (acc * a[i]) * a[i+1]; (b) acc = acc * (a[i] * a[i+1]);. Give the CPE of each in terms of , assuming a unit with enough throughput.
Hint
Find the chain of operations that each depend on acc from the previous iteration.
Solution
(a) Both multiplies sit on the chain through acc: cycles per 2 elements, CPE . Unrolling alone removed loop overhead but not the dependence. (b) a[i] * a[i+1] doesn't involve acc and can run ahead; only one multiply per 2 elements is on the chain, so CPE . This "reassociation" is the single-accumulator version of the case in the explorer.
More practice: Bryant–O'Hallaron, Practice Problems 5.4, 5.8 and 5.13; Homework 5.17 and 5.18.
Caches and locality
A cache is a small, fast copy of the memory you used recently. Whether it helps depends on two things you control: the order in which your loops visit memory, and how your data is laid out. Run the same sum two ways through a simulated cache and compare.
The idea
int a[8][8]; /* row i starts 32 bytes after row i-1 */
for (i = 0; i < 8; i++) /* row by row: stride 1 */
for (j = 0; j < 8; j++) s += a[i][j];
for (j = 0; j < 8; j++) /* column by column: stride 8 */
for (i = 0; i < 8; i++) s += a[i][j];
Explore
Worked example
A direct-mapped cache holds 128 bytes in 16-byte blocks; addresses have 12 bits. A program sums int v[64], which starts at address 0, twice in a row.
Find , , , , and split the address
0x1A4., so , , .
0x1A4=00011 010 0100: tag 3, set 2, offset 4. Check:0x1A4.What is the miss rate of the first pass?
A block holds 4 ints. The first access to each block misses and the next three hit: 25% misses, all of them cold.
And of the second pass?
The array is 256 bytes, twice the cache. By the end of the first pass,
v[32..63]has replacedv[0..31]in every set ( and are 128 bytes apart, so they share a set). The second pass starts on evicted blocks and again misses 25%, now capacity misses.Reorganize the work: do both passes over
v[0..31], then both overv[32..63]. Miss rate?Each half is exactly 128 bytes, so it stays in the cache during its second pass, which now hits every time. Overall: 16 misses in 128 accesses, 12.5%. Working on a cache-sized piece at a time is the idea behind blocking.
Check your understanding
You double the block size and halve , so the capacity stays the same. What happens to the miss rate of the row-by-row sum?
- It roughly halves: each miss now brings in twice as many neighbours.
- No change: the same elements are read.The number of elements is the same, but how many share each block changed.
- It rises because there are fewer sets.In a stride-1 pass, each block is used up before it could be evicted, so the number of sets doesn't matter here.
Why do caches take the set index from the middle bits of the address rather than the top bits?
- So that consecutive blocks go to different sets and a contiguous array can fill the whole cache.
- Because the top bits are needed for the offset.The offset is the lowest bits.
- It makes the tag comparison faster.The comparison costs the same either way. Think about which sets a long array would use.
int a[8][8] summed column by column, with , , (a 128-byte cache, half the array). What is the hit rate?
- 75%That would need each block to survive until its other three ints are read, a whole column later.
- 50%Look at which rows share a set.
- 0%
Practice
Cache geometryFrom size to address bits
A 32 KB, 8-way set-associative cache has 64-byte blocks, and addresses are 48 bits. Find , , and .
Solution
, so ; ; .
Layout mattersArray of structs vs. struct of arrays
struct particle { double x, y, z, mass; } p[4096]; A loop sums p[i].mass for all i. With 64-byte blocks and a cache much smaller than the array, what is the miss rate? What if the masses were kept in their own array double mass[4096]?
Solution
A struct is 32 bytes, so a block holds two of them, but the loop uses only 8 bytes of each struct. One miss per two accesses: 50%. With a separate array, a block holds 8 masses: one miss per 8 accesses, 12.5%. The loop reads 4 times fewer bytes from memory.
More practice: Bryant–O'Hallaron, Practice Problems 6.10, 6.13–6.17 and 6.18; Homework 6.37.
Symbols and how the linker resolves them
Each .c file is compiled on its own, so a name like x used in two files means nothing until the linker decides which definition every reference gets. The rules are short. Some of their outcomes are bugs that compile and link without complaint.
The idea
.c becomes a relocatable object file .o) and finally the linker, which combines object files into an executable. The linker does two jobs: symbol resolution (match each reference with one definition) and relocation (give every section and symbol an address and patch the references).static functions and global variables), external ones it only references (marked undefined, UND), and local ones (static functions and variables), which are visible only inside that file. Ordinary local variables live on the stack and are not symbols at all.int x;) are weak.COMMON block. Since gcc 10 the default is -fno-common: int x; is then an ordinary definition in .bss, so cases (2) and (3) with two definitions become link errors. The explorer lets you switch between the two.The linker matches names only. It does not know types, so if one file defines double x and another uses int x, both silently refer to the same bytes.
Explore
gcc main.c other.c.x, and the variable each file's uses of x end up at: main.c other.cWorked example
main.o calls fmt_time, which is defined in libfmt.a. fmt_time calls pad, defined in libutil.a. The linker scans files left to right, keeping a set of names referenced but not yet defined. It takes a member out of a static library only if that member defines something in at the moment the library is scanned.
Trace
gcc main.o libutil.a libfmt.a.After
main.o: . Scanninglibutil.a: no member definesfmt_time, so nothing is taken. Scanninglibfmt.a: its member definingfmt_timeis added, and . End of the command line with :undefined reference to `pad'.Fix the order.
gcc main.o libfmt.a libutil.a. Whenlibutil.ais scanned,padis already in . The rule: a library must come after every file that uses it.Now
padalso callsfmt_num, which lives inlibfmt.a. What happens with the fixed command?The member with
padaddsfmt_numto , butlibfmt.awas already scanned: undefined reference tofmt_num. (Unlessfmt_numhappens to sit in the same member asfmt_time, which was already taken in whole.)How do you link two libraries that depend on each other?
Name one of them twice:
gcc main.o libfmt.a libutil.a libfmt.a. (Alternatively, merge them into one library.)
Check your understanding
Which of these does not get an entry in the symbol table of foo.o?
- A
static int counter = 0;at file scope.That's a local symbol: listed, but invisible to other files. - A function
barthatfoo.ccalls but doesn't define.It appears as an undefined (UND) symbol for the linker to resolve. - A variable
int i;declared inside a function.
static variable inside a function, by contrast, does get a local symbol.a.c has int n = 3; and b.c has int n = 5; (both at file scope). What happens when you link them?
- The linker picks one of them.That is rule 3, for weak symbols. Are these weak?
- A link error:
nis defined twice. - Each file keeps its own
n.That happens only withstatic.
a.c defines long buf[2] = {1, 2}; and b.c declares extern long *buf; and reads buf[0]. What happens?
- A link error: the types don't match.Object files carry no type information for the linker to compare.
- It links.
b.ctakes the array's first 8 bytes (the value 1) as a pointer and dereferences address 1, which crashes. - It works: an array and a pointer are the same thing.An array name converts to a pointer in expressions, but a pointer variable is stored differently from an array.
buf resolves to the address of the array. Code compiled for extern long *buf loads 8 bytes from that address and treats them as a pointer. The linker resolved the name correctly; the declarations disagree.Practice
Who wins?Weak, strong and static
a.c: int count; void bump(void); int main(void) { bump(); printf("%d\n", count); }. b.c: int count = 10; void bump(void) { count++; }. What does it print under the book's rules? With gcc 10? What if b.c declares static int count = 10; instead?
Solution
Book's rules: a.c's weak count resolves to b.c's strong one; bump makes it 11, and it prints 11. With gcc 10's -fno-common, both are definitions: a link error. With static in b.c there are two different variables; a.c's is the only global one, zero-initialized, and the program prints 0 under either compiler.
Patching a callPC-relative relocation
A call instruction is 5 bytes: opcode e8 followed by a 4-byte displacement relative to the address of the next instruction. It is placed at address 0x401020, and the linker puts the called function at 0x401100. Which 4 bytes does the linker write after e8?
Solution
The next instruction starts at 0x401025, so the displacement is 0x401100 - 0x401025 = 0xDB. As a little-endian 32-bit value: db 00 00 00. If the target were below the call, the displacement would be negative, in two's complement.
More practice: Bryant–O'Hallaron, Practice Problems 7.1, 7.2, 7.3 and 7.4; Homework 7.8 and 7.10.
Processes, fork and wait
fork is called once and returns twice. After it, two processes run the same code with separate memory, and the kernel interleaves them as it pleases. The only guarantees about the order of their output are the ones you build in. Draw them as a graph and count the outputs that can really happen.
The idea
fork() creates a child with a copy of the parent's address space and its open files; it returns 0 in the child and the child's PID in the parent. exit(s) ends a process. waitpid(pid, &status, 0) (or wait(&status) for any child) blocks until a child has terminated, then reaps it. A terminated child that hasn't been reaped is a zombie. execve replaces the running program with a new one and, if it succeeds, never returns.fork to the child's first event, and from a child's exit to the parent's wait that reaps it. Any ordering of the events that respects every edge is a possible execution, and no other ordering is.printf ends in \n and output goes to a terminal, so each line is written immediately. If text is still in the stdio buffer when fork runs, both processes inherit it and it can be printed twice.Explore
Worked example
int main(void) {
int x = 5;
pid_t pid = fork();
if (pid == 0) { /* child */
x = x * 2;
printf("child: %d\n", x);
exit(0);
}
waitpid(pid, NULL, 0); /* parent */
x = x + 1;
printf("parent: %d\n", x);
return 0;
}What does this print?
child: 10thenparent: 6.Why doesn't the child's
x = 10show up in the parent?After
fork, each process has its own copy ofxat the same virtual address. Changes in one are invisible to the other. (The kernel shares the physical pages until one side writes, but that's an implementation detail.)Is the order of the two lines guaranteed?
Yes. The parent's
printfcomes afterwaitpid, which returns only after the child has exited, and the child prints before it exits.Delete the
waitpidline. Now?Both orders are possible. Nothing connects the parent's print to the child's.
Restore
waitpidbut delete the child'sexit(0). Now?The child falls through. It calls
waitpid(0, NULL, 0), which waits for any child in its process group; it has no children, so the call fails at once. It printsparent: 11and exits. The real parent then reaps it and printsparent: 6. Output:child: 10,parent: 11,parent: 6, in that order.
Check your understanding
for (int i = 0; i < 3; i++) fork(); printf("x\n"); How many lines are printed?
- 3The children also run the rest of the loop.
- 4Each process alive at a
forkbecomes two. - 8
After fork, the child sets a global variable g = 1. Does the parent see g == 1?
- Yes, globals are shared.Globals are shared between threads. Processes have separate address spaces.
- No. The parent has its own copy of
g. - Only after the child exits.Exiting doesn't copy anything back.
waitpid collects.A parent forks a child and never calls wait. The child exits. Until the parent ends, the child is…
- Gone: its memory and its entry in the process table are freed at once.The kernel keeps a small record, the exit status, until someone collects it.
- An orphan.An orphan is a child whose parent died first. It is then adopted by
init. - A zombie: terminated but not reaped.
SIGCHLD handler, or the zombies pile up.Practice
All the outputsCheck with "if / else" in the explorer
printf("A\n");
if (fork() == 0)
printf("B\n");
else
printf("C\n");
printf("D\n");
List every possible output, written as a string of letters.
Hint
A comes first. The child prints B then D, the parent C then D. Interleave the two pairs.
Solution
There are interleavings, but the two Ds look alike, so only 4 distinct outputs: ABDCD, ABCDD, ACBDD, ACDBD.
Printed twicestdio buffers and fork
Change the first line above to printf("A"); (no newline) and run the program as ./prog > out.txt. How many As end up in the file? Why?
Solution
Two. Output to a file is fully buffered, so "A" is still in the stdio buffer in user memory when fork copies the address space. Both processes flush their copy when they exit. Calling fflush(stdout) before fork prevents it.
More practice: Bryant–O'Hallaron, Practice Problems 8.2, 8.3 and 8.4; Homework 8.11–8.15.
Virtual memory and address translation
Every address your program uses is virtual. On each access the hardware splits it into a page number and an offset, looks the page number up (first in the TLB, then in the page table) and swaps it for a physical page number. Translate a few addresses yourself and watch hits, misses and page faults.
The idea
The explorer uses a toy machine: 16-bit virtual addresses, 12-bit physical addresses, 256-byte pages, a 2-way TLB with 4 sets, and 6 physical frames for this process.
Explore
Worked example
A 32-bit machine has 4 KB pages and 4-byte PTEs.
How big is a single flat page table per process?
, so the VPN has 20 bits: PTEs 4 bytes = 4 MB, for every process, even one that uses a few pages.
Use two levels instead: the VPN splits into 10 + 10 bits. What does each level hold?
The level-1 table has entries (4 KB); each points to a level-2 table, also 4 KB, that maps pages MB of virtual memory. Level-2 tables exist only for regions in use.
A process uses 8 MB at the bottom of its address space and 1 MB of stack at the top. How much page-table memory does it need now?
Two level-2 tables for the 8 MB, one for the stack, plus the level-1 table: KB = 16 KB instead of 4 MB.
Split the virtual address
0x00403A1Cinto VPN1, VPN2 and VPO.VPO is the low 12 bits:
0xA1C. The VPN is0x00403A1C >> 12 = 0x403; VPN10x403 >> 10and VPN20x403 & 0x3FF. So: entry 1 of the level-1 table, entry 3 of that level-2 table, byte0xA1Cof the page.
Check your understanding
With 4 KB pages, the virtual address 0x12345678 is on a page mapped to PPN 0x9A. What is the physical address?
0x9A6780x9A5678The offset has bits: three hex digits, not four.0x1234569AThe page number is replaced; the offset stays in the low bits.
0x678, and the PPN goes on top: 0x9A followed by 678.A load touches a page that is allocated but currently on disk. What happens?
- The process is killed with a segmentation fault.That happens for unallocated addresses or forbidden accesses, not for pages that are merely on disk.
- The MMU fetches the page from disk by itself.Page faults are handled in software, by the kernel.
- A page fault: the kernel loads the page, updates the PTE, and the load runs again.
Processes A and B both store to virtual address 0x601000, in a private data page. What happens?
- They overwrite each other's data.Virtual addresses are meaningful only together with a page table, and each process has its own.
- They write different physical bytes: each page table maps that page to its own frame.
- The second store faults.Nothing is wrong with either access.
Practice
Field widthsSizes from parameters
Virtual addresses are 39 bits, physical addresses 32 bits, pages 8 KB. How many bits are in the VPO, VPN and PPN? How many PTEs would a flat page table have?
Solution
: VPO 13 bits, VPN bits, PPN bits. A flat table would have entries.
TLB reachWhy huge pages exist
A TLB has 64 entries. How much memory can it map with 4 KB pages, and with 2 MB pages? A program reads random elements of a 64 MB array. Which page size gives far fewer TLB misses?
Solution
KB versus MB. With 4 KB pages the array spans 16384 pages, so almost every random access misses the TLB. With 2 MB pages it spans 32 pages, which all fit, so after the first touches there are no TLB misses.
More practice: Bryant–O'Hallaron, Practice Problems 9.2, 9.3 and 9.4; Homework 9.11–9.13.
Dynamic memory allocation
malloc and free manage one long run of bytes, the heap, using nothing but a few bits stored inside it. Allocate and free blocks in a small heap, switch the placement policy, and find out how a heap with plenty of free space can still fail a request.
The idea
malloc(n) returns a pointer to at least bytes, aligned (here to 8 bytes), or NULL. free(p) must be given a pointer that malloc returned. The allocator can't move allocated blocks, and must answer requests in the order they arrive.0x19. Walking from header to header visits every block, free or not.free find the previous block's size in constant time, so it can merge (coalesce) a freed block with free neighbours on both sides. With header and footer, a block needs at least 16 bytes: .Real allocators keep explicit lists of the free blocks only, often split by size class, so they don't walk past allocated ones. The explorer uses the simple implicit list on a 256-byte heap: 4 bytes of padding, then blocks, then a 4-byte end marker.
Explore
Worked example
The heap holds one free 64-byte block. With 4-byte headers and footers, 8-byte alignment and a 16-byte minimum block, run p = malloc(10); q = malloc(20); free(p); free(q);.
What size is
p's block, and what is its header?, rounded up to 24. Header
0x19. Splitting leaves a free block of 40 bytes, header0x28.What happens for
q = malloc(20)?It needs bytes. The leftover would be bytes, too small for a block, so
qgets the whole 40-byte block: header0x29.How much internal fragmentation does
q's block have?The program asked for 20 bytes in a 40-byte block: 20 bytes are overhead (8 for header and footer, 12 unused).
free(p): does it coalesce?No. Its only neighbour,
q, is allocated. The header becomes0x18.free(q): what is the heap now?Its previous neighbour is free: the footer just before
q's header says "24, free". The two merge into one free 64-byte block, header0x40. Check it in the explorer starting from an empty heap: the steps look the same, only in a larger heap.
Check your understanding
Why does an implicit-list allocator need a footer to coalesce with the previous block in constant time?
- A header tells you where the next block starts, but not where the previous one starts. The footer sits just before your header.
- Because
freemust check that the pointer is valid.The allocator generally doesn't check. The question is about finding neighbours. - To store the requested size .The footer is a copy of the header: block size and allocated bit.
The heap has 120 free bytes in total, yet malloc(48) fails. What is this called?
- Internal fragmentation.That's wasted space inside allocated blocks.
- External fragmentation.
- A memory leak.A leak is memory the program no longer uses but never freed. These bytes are free.
Which is true of best fit compared with first fit?
- It usually wastes less memory, but each request must search the whole free list.
- It is always faster.It can stop early only on an exact fit.
- It never needs to split blocks.The best-fitting block can still be larger than the request.
Practice
Headers by handBlock sizes
With the rules above, give the block size and header value for malloc(1), malloc(13) and malloc(16).
Solution
: header 0x11. : 0x19. : 0x19. Note that malloc(1) costs 16 bytes.
Make malloc failDesign the fragmentation
In the explorer's 256-byte heap (248 bytes for blocks), find a sequence of malloc and free calls after which at least 100 bytes are free but malloc(48) returns NULL.
Hint
Allocate equal blocks until the heap is full, then free every other one.
Solution
Six malloc(32) calls: five 40-byte blocks, and a sixth that takes the last 48 bytes whole (a 40-byte block would leave 8, too small to split off). Free the 1st, 3rd and 5th: 120 bytes free in three 40-byte pieces separated by allocated blocks, so nothing can coalesce. malloc(48) needs a 56-byte block and fails.
More practice: Bryant–O'Hallaron, Practice Problems 9.6, 9.7 and 9.10; Homework 9.15–9.17.
Unix I/O: descriptors and sharing
A file descriptor is a small integer, but the kernel connects it to the file through two more tables, and the file position lives in the middle one. That one detail decides what fork, dup2 and opening a file twice do. Step through four short programs and watch the tables.
The idea
open returns the smallest descriptor not in use; a process starts with 0, 1 and 2 (standard input, output and error). read(fd, buf, n) copies up to bytes from the current file position and advances it; it returns the number of bytes read, 0 at end of file, or on error.read and write may move fewer bytes than asked, near end of file, on terminals, and constantly on network sockets. Robust code loops until it has what it needs.open creates a new open file entry, so two opens of the same file have independent positions. fork copies the descriptor table, so parent and child point to the same entries and share positions. dup2(old, new) closes new if needed and makes it point to old's entry.Redirection in a shell (prog > out.txt) is just dup2 of a freshly opened file onto descriptor 1, done in the child before execve.
Explore
notes.txt holds the six bytes kernel.Worked example
How does a shell run sort < in.txt? Sketch the system calls and the descriptor tables.
Which process should open
in.txt: the shell or a child?A child. The shell calls
fork, and the child changes its own standard input. The shell's descriptor 0 must stay connected to the terminal.The child calls
fd = open("in.txt", O_RDONLY). Which descriptor does it get?The smallest unused one: 3, since 0, 1 and 2 are open.
How does it make descriptor 0 refer to the file?
dup2(fd, 0)closes the child's old descriptor 0 (the terminal's open file entry loses one reference; the shell still holds it) and points 0 atin.txt's entry. Thenclose(fd), since descriptor 3 is no longer needed.The child calls
execve("/usr/bin/sort", ...). Why doessortread from the file?execvereplaces the code and data but keeps the descriptor table.sortreads descriptor 0 like always and has no idea it is a file.
Check your understanding
A process has descriptors 0, 1 and 2 open. It calls close(0) and then open("a.txt", O_RDONLY). Which descriptor is returned?
- 3Is 3 the smallest descriptor not in use?
- 0
- It depends on the file system.The rule is fixed: the lowest unused descriptor.
close(0), 0 is the lowest free slot. Older shells redirected input exactly this way, before dup2 existed.A file of 8 bytes is opened, then the process forks. Parent and child each call read(fd, buf, 4) once. Do they read the same bytes?
- Yes, each has its own copy of everything.The descriptor table is copied, but the entries it points to are not.
- No: they share one file position, so one gets bytes 0–3 and the other bytes 4–7.
- Only the parent can read; the child's descriptor is closed.Children inherit open descriptors.
read(sockfd, buf, 4096) on a network socket returns 100. What does that mean?
- An error: 4096 bytes were requested.Errors are reported as .
- The connection is closed.A closed connection reads as 0, end of file.
- A short count: 100 bytes had arrived so far. Read again for more.
read returns what is there. The book's Rio functions wrap the necessary loop.Practice
Order of redirectionsTwo dup2 calls
In a shell, cmd > out.txt 2>&1 sends both output and errors to the file, but cmd 2>&1 > out.txt sends errors to the terminal. With f = open("out.txt", ...), write each one as dup2 calls and explain the difference.
Hint
2>&1 means dup2(1, 2): copy what descriptor 1 points to now.
Solution
First form: dup2(f, 1); dup2(1, 2);. Descriptor 1 already points to the file when 2 copies it, so both go to the file. Second form: dup2(1, 2); dup2(f, 1);. Descriptor 2 copies 1 while 1 still points to the terminal; changing 1 afterwards doesn't affect 2.
Redirection without dup2The lowest-descriptor rule
Using only close and open, make printf write to log.txt. Why is dup2 still the better choice in a multithreaded program?
Solution
close(1); open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); The new file gets descriptor 1, the lowest free one. But between the close and the open, another thread could open something and take descriptor 1. dup2 does the replacement in one atomic step.
More practice: Bryant–O'Hallaron, Practice Problems 10.1–10.5; Homework 10.6.
Sockets and the client–server model
To a program, a network connection is a pair of file descriptors that happen to be on different machines. Setting one up takes a few calls in a fixed order, and almost every bug in between comes from byte order or from assuming one read returns a whole message.
The idea
192.0.2.10). Internet protocols send multi-byte numbers big-endian (network byte order), so a little-endian host converts with htonl/htons and back with ntohl/ntohs. Domain names are mapped to addresses by DNS.socket, then connect to the server's address. Server: socket, bind to a port, listen, then loop on accept, which blocks until a client connects and returns a new connected descriptor. The listening descriptor stays open for further clients. Once connected, both sides use plain read, write and close.int listenfd = open_listenfd(port); /* socket + bind + listen */
while (1) {
int connfd = accept(listenfd, NULL, NULL); /* waits for a client */
handle(connfd); /* read request, write reply */
close(connfd);
}
GET /index.html HTTP/1.1, header lines such as Host: www.example.com, then an empty line. Each line ends in \r\n. The response starts with a status line (HTTP/1.1 200 OK), headers, an empty line, then the content.The book builds addresses with gethostbyname and friends; current code uses getaddrinfo, which also handles IPv6. The ideas below are the same either way.
Worked example
A client on host 192.0.2.10 fetches a page from a web server at 198.51.100.7, port 80. The client machine is little-endian (x86).
Write the server's address as a 32-bit hex number.
One byte per field:
C6,33,64,07. The address is0xC6336407.In network byte order, in what order do those bytes sit in memory? What value would the x86 client see if it read them as a
uint32_twithoutntohl?Big-endian, so most significant first:
C6 33 64 07. Read little-endian, those bytes are0x076433C6.ntohlswaps them back.The port: what bytes does
htons(80)put in memory, and what port would a server listen on if you stored plain80on x86?0x0050.htons(80)gives the bytes00 50. Stored without conversion on x86, the bytes are50 00, which the network reads as0x5000= port 20480.The client's kernel picks port 50123. What is the socket pair? Could a second tab in the same browser use the same pair?
(192.0.2.10:50123, 198.51.100.7:80). No: a second connection gets a different ephemeral port, and the differing pair is how both kernels tell the two connections apart.On the server, which calls block, and which descriptors exist after
acceptreturns?acceptblocks until a connection arrives (andreadblocks until data does). Afterwards there are two:listenfd, still open for the next client, andconnfd, connected to this client only.The client sends the request below in one
write. How does the server know the headers have ended?GET /index.html HTTP/1.1\r\n Host: www.example.com\r\n \r\nBy the empty line:
\r\nright after a\r\n. Since areadmay return only part of the request, the server keeps reading, line by line, until it sees that empty line. That is the short-count lesson from the previous module.
Check your understanding
A server's listening descriptor is 3, and accept returns 5. Which descriptor does it read the client's request from?
- 3The listening descriptor only produces new connections; no data flows through it.
- 5
- Either one.They play different roles.
Two programs on one laptop connect to the same server, port 80. How does the server's kernel deliver each packet to the right connection?
- By the client's IP address.Both connections come from the same address.
- By the full socket pair: the client ports differ.
- It can't; the second connection is refused.A server port accepts many connections at once.
Why does the server need the empty line at the end of an HTTP request, when TCP already delivers the bytes reliably?
- TCP is a byte stream with no message boundaries, so the protocol has to mark where the request ends.
- To give the network time to deliver the last packet.The empty line is data, not a delay.
- For compatibility with old browsers only.Every HTTP/1.x server relies on it to find the end of the headers.
Content-Length header for a body) tell it where one message stops.Practice
Address conversionsDotted decimal and hex
Convert 172.16.254.1 to hex, and 0xC0A80064 to dotted decimal.
Solution
AC, 10, FE, 01: 0xAC10FE01. And C0, A8, 00, 64: 192.168.0.100.
The missing bytesReading from a socket
A client sends a 2000-byte message with one write. The server calls read(connfd, buf, 8192) once and gets 1448 bytes. Is something broken? How should the server read a message whose length it knows in advance?
Solution
Nothing is broken: the rest is in a later packet. Loop: keep calling read on the remaining space until 2000 bytes have arrived, stopping on 0 (the client closed early) or (an error, retrying if it was only interrupted by a signal).
More practice: Bryant–O'Hallaron, Practice Problems 11.1, 11.2 and 11.3.
Threads, races and semaphores
Two threads that each add 1 to a shared counter can leave it with only one of the additions. Be the scheduler: choose which thread runs each instruction, and watch the counter, the registers and the path you trace through a progress graph. Then add a lock and see the paths that remain.
The idea
select to react to whichever descriptor is ready), and threads (several flows of control in one process).static variables and open files are shared by all of them.volatile long cnt = 0; /* shared */
void *worker(void *arg) {
for (long i = 0; i < NITERS; i++)
cnt++; /* load cnt into %rdx; add 1; store %rdx to cnt */
return NULL;
}
Explore
cnt++) and T (i++). unsafe region forbidden region (with the mutex) your pathWorked example
A bank moves money between accounts, each with its own mutex:
void transfer(struct account *from, struct account *to, long amt) {
P(&from->lock);
P(&to->lock);
from->balance -= amt;
to->balance += amt;
V(&to->lock);
V(&from->lock);
}
Thread 1 calls transfer(&A, &B, 10) while thread 2 calls transfer(&B, &A, 5).
Find a schedule that never finishes.
Thread 1 does
P(&A.lock), then thread 2 doesP(&B.lock). Now thread 1 waits for B's lock, which thread 2 holds, and thread 2 waits for A's lock, which thread 1 holds. Neither can proceed: deadlock.In a progress graph, where is the trouble?
There are two forbidden regions, one per lock, and they overlap in a way that leaves a pocket below and to the left of their overlap. A path that enters the pocket can only move right into one forbidden region or up into the other: it is stuck. That pocket is the deadlock region.
Fix
transferwithout a global lock.Lock in a fixed order, for example by account number: lock
min(from, to)first, thenmax(from, to); unlock in either order. Both threads now try A first, so whichever gets A will also get B.Does the fix still work with many threads and many accounts?
Yes. If everyone acquires locks in increasing order, a cycle of waiting threads is impossible: the thread holding the highest-numbered lock in such a cycle could only be waiting for an even higher lock, which no one in the cycle holds.
Check your understanding
Starting from cnt = 0, two threads each run cnt++ once, with no lock. Which final values are possible?
- Only 2.Try running thread 1's load, then all of thread 2.
- 1 or 2.
- 0, 1 or 2.Whatever happens, some thread stores a value it computed as (a value it loaded) + 1.
Which of these is shared by all threads running worker?
long i, declared insideworker.Each thread has its own stack, so each call has its owni.static long calls, declared insideworker.- The parameter
arg.Each thread gets its own copy of the pointer. What it points to may be shared.
static local has a single instance for the whole program, stored with the globals, even though only worker can name it.for (long i = 0; i < 4; i++) pthread_create(&tid[i], NULL, work, &i); Each thread starts by reading *(long *)arg. What can a thread see?
- Its own index, 0 to 3.That needs the thread to read
ibefore the loop changes it. Nothing guarantees that. - Whatever
iholds when it reads: two threads may see the same value, and a thread may even see 4. - Always 4.A thread may well run before the loop finishes.
i++ and each thread's read. Pass each thread its own copy instead: a separate malloc'd long, or the value cast to a pointer.Practice
Counting interleavingsCheck with "Run 1000 random schedules"
Each thread runs cnt++ once as load, add, store. How many interleavings of the six instructions are there, and how many of them leave cnt = 2?
Solution
interleavings: choose which 3 of the 6 slots belong to thread 1. Only 2 are safe: all of thread 1 before thread 2, or the reverse. Any other interleaving has a load by one thread between the other thread's load and store, and loses an update. The explorer's random schedules are not uniform over interleavings: they also run each thread's loop test H and increment T, and flip a coin at every step. Under that scheduler 3 runs in 8 give 2, so expect about 375 of 1000, not 100.
A one-slot bufferSemaphores for ordering
One producer and one consumer share int buf. Use semaphores slots (initially 1) and items (initially 0) so the consumer gets every value exactly once. Why is no separate mutex needed?
Solution
Producer: P(&slots); buf = x; V(&items); Consumer: P(&items); y = buf; V(&slots);. Since slots + items is at most 1, the producer can write only after the consumer has taken the previous value, and the consumer can read only after a new value is written. The two never touch buf at the same time. Even several producers and consumers would be safe here, since P(&slots) lets only one producer at a time near the single slot; a buffer of several slots, with shared indices, is where a mutex becomes necessary.
More practice: Bryant–O'Hallaron, Practice Problems 12.7, 12.8 and 12.15; Homework 12.18 and 12.29.