Show all solutions
Hide all solutions
Test 2
Notes
Assume a Linux operating system running on the x86-64 architecture unless
otherwise stated. On this architecture, sizeof(int) == 4, sizeof(long) == sizeof(void*) == 8, and the maximum alignment required by any type is 16.
Some figures and code samples can be pinned to your browser so you can keep
them visible as you scroll through the test. Push the 📌 and then drag the
pinned figure where you want it.
Some questions concern Problem Set
1’s m61 allocators and
tests; an
m61 test is a complete program that indicates success by exiting with status
0 and failure by terminating abnormally (e.g., by failing an assertion).
print_object(x) prints the address of object x, followed by the
hexadecimal values of its bytes in increasing address order, 16 bytes per
line. print_bytes(ptr, sz) prints in a similar format the sz bytes of
memory starting at ptr.
1. Bytes of a record (15 points)
This program was compiled and run on x86-64 Linux.
struct record {
short id;
char tag;
int count;
const char* name;
unsigned char flags;
};
int main() {
const char* buf = "alpha";
record r = { 61, 'Q', 10000, buf, 5 };
print_object(r);
}
It printed:
7bc3'53bf'0040 3d 00 51 00 10 27 00 00 80 01 b8 4a 88 55 00 00
7bc3'53bf'0050 05 00 00 00 00 00 00 00
QUESTION 1A. Which byte offsets within r hold padding? List them all.
(The first byte of r has offset 0.)
2pt Offset 3 (between tag and count, so that count is 4-byte
aligned) and offsets 17–23 (after flags, so that sizeof(record) is a
multiple of 8).
QUESTION 1B. What is sizeof(record)?
1pt 24. The print_object call printed 24 bytes. You can also compute
it from the layout: id at offset 0, tag at 2, count at 4 (after 1 byte
of padding), name at 8, flags at 16, and 7 bytes of end padding so the
size is a multiple of the struct’s alignment, 8.
QUESTION 1C. What is the value of r.count in hexadecimal?
2pt Offsets 4–7 hold the bytes 10 27 00 00. x86-64 is little-endian,
so the least significant byte comes first and the value is 0x2710, which
is 10000.
QUESTION 1D. These lines are added after the call to print_object(r)
(though not necessarily in this order):
print_bytes(buf, sizeof(buf));
print_bytes(buf, strlen(buf));
print_object(buf);
print_object(r.name);
Assuming that print_object(r) printed the exact addresses and bytes shown
above, which of these outputs might be generated by each line of code?
List all that apply.
7bc3'53bf'0020 80 01 b8 4a 88 55 00 00
7bc3'53bf'0048 80 01 b8 4a 88 55 00 00
5588'4ab8'0180 61 6c 70 68 61
5588'4ab8'0180 61 6c 70 68 61 00
5588'4ab8'0180 61 6c 70 68 61 00 00 00
- Sanitizer error: signed integer overflow
- Sanitizer error: global buffer overflow
4pt Line 1—E or G; line 2—C; line
3—A; line 4—B.
- Line 1:
buf is a pointer, so sizeof(buf) is 8, but the string
literal "alpha" occupies only 6 bytes (including its terminating null).
Reading 8 bytes starting at buf runs 2 bytes past the end of the
literal, which is undefined behavior. A sanitizer may report a global
buffer overflow (G), but sanitizers don’t catch every error; if the read
goes unreported, the output is most likely E, though the two extra bytes
are whatever happens to follow the literal in the executable’s read-only
data.
- Line 2:
strlen(buf) is 5, so this prints the 5 bytes of alpha at the
literal’s address, 0x5588'4ab8'0180 (C).
- Line 3:
buf is a local pointer object, so print_object(buf) prints
its 8 bytes (the little-endian address of the literal) at buf’s own
address on the stack. Line A is possible: 0x7bc3'53bf'0020 is a
plausible stack address near r. Line B is impossible, because
0x7bc3'53bf'0048 is inside r, and distinct objects cannot overlap.
- Line 4:
r.name is at offset 8 of r, so its address is
0x7bc3'53bf'0040 + 8 = 0x7bc3'53bf'0048, and it contains the same 8
bytes as buf (B).
No line causes signed integer overflow, so F never appears.
QUESTION 1E. buf is declared before r. How might its address relate to
the address of r? List all that apply and explain briefly.
- The address of
buf must be less than the address of r
- The address of
buf must equal the address of r.name
- The address of
buf must be greater than the address of r
- None of the above
2pt 4, none of the above. The abstract machine requires that distinct
live objects occupy disjoint addresses, and it fixes the layout of arrays
and (simple) structs, but it says nothing about the relative positions of
separate local variables. The compiler can place them in any order.
Option 2 confuses value with address: buf and r.name hold the same
value (the address of the string literal), but they are distinct objects,
so their own addresses differ.
QUESTION 1F. What is the minimum size of any structure with the same
members as record (but not necessarily in the same order)?
2pt 16 bytes. Any order that avoids interior padding works, such as:
struct record {
const char* name;
int count;
short id;
char tag;
unsigned char flags;
};
The members have sizes 8 + 4 + 2 + 1 + 1 = 16, each is naturally aligned
at its offset, and 16 is a multiple of the struct’s alignment (8), so no
padding is required.
QUESTION 1G. Some of these expressions always evaluate to the same numeric
value. List all pairs (or greater subsets) that always have the same value.
r.name + 5 - 2
&r.id
(long) r.count % 2
(buf + strlen(buf)) - r.name
&r
nullptr
r.flags
&r.name[3]
2pt Four pairs: A/H, B/E, C/F, and D/G.
- B/E:
&r.id and &r have the same value, the address of r
(0x7bc3'53bf'0040 in this run), since the first member of a struct is
at offset 0. They have different types, short* and record*, but the
same numeric value.
- A/H:
r.name + 5 - 2 and &r.name[3] are both r.name + 3, the address
of the h in alpha (0x5588'4ab8'0183 here). Pointer arithmetic on a
const char* moves one byte per unit. There is no undefined behavior:
r.name + 5 points at the string’s terminating null, which exists.
- D/G:
(buf + strlen(buf)) - r.name subtracts two pointers into the same
array and yields the index difference, 5; r.flags is also 5.
- C/F:
(long) r.count % 2 is 10000 % 2, which is 0, and nullptr is
the null pointer, whose numeric value is 0.
No larger subsets exist: the four pairs have the values 0x7bc3'53bf'0040,
0x5588'4ab8'0183, 5, and 0, all distinct.
2. Break that allocator (18 points)
Each part of this question shows a fragment of an m61 allocator with a bug.
Your job is to design an m61 test that exposes that bug (but doesn’t
falsely accuse any correct allocator). You may describe the test, but ideally
write code: a few lines each is enough. You may assume all allocator code not
shown is correct.
All the fragments share these declarations. The buffer is 8 MiB and comes
from malloc, so it is 16-byte aligned; fresh space is handed out
sequentially from buffer.
constexpr size_t buffer_size = 8 << 20; // allocated size of buffer
static char* buffer; // next unused byte of the buffer
static char* end_buffer; // one past the end of the buffer
static std::map<char*, size_t> active; // active payload -> requested size
static std::map<char*, size_t> freed; // free block -> block size
QUESTION 2A. This m61_malloc stores a 32-byte header before each
payload; it doesn’t support memory reuse yet.
void* m61_malloc(size_t sz) {
size_t padding = sz % 16 ? 16 - sz % 16 : 0;
size_t bsz = 32 + sz + padding; // header plus payload plus alignment padding
if (bsz > size_t(end_buffer - buffer)) {
return nullptr;
}
char* ptr = buffer + 32;
// ... initialize header, insert `ptr` into `active` ...
buffer += bsz;
return ptr;
}
Design a test that fails for this allocator. Your test should not require memory reuse.
4pt
int main() {
void* p = m61_malloc(SIZE_MAX);
assert(p == nullptr);
}
No allocator can satisfy a request for 264−1 bytes, so a
correct m61_malloc returns nullptr. In this fragment, SIZE_MAX gets 1
byte of padding, and 32 + SIZE_MAX + 1 wraps around to 32, which is
smaller than the space remaining, so line 4 does not fail. The function
returns a bogus pointer to a “successful” allocation and advances buffer
by only 32 bytes. Any sz ≥ SIZE_MAX − 46 triggers the bug (bsz wraps to
0, 16, or 32); SIZE_MAX works without knowing the header size.
QUESTION 2B. This m61_malloc uses purely external metadata; it doesn’t
support wild-write detection yet.
void* m61_malloc(size_t sz) {
if (sz > buffer_size) {
return nullptr;
}
// ... maybe reuse a free block ...
size_t bsz = sz % 16 ? (sz | 15) + 1 : sz; // round up to multiple of 16
if (bsz > size_t(end_buffer - buffer)) {
return nullptr;
}
active.insert({ buffer, sz });
char* ptr = buffer;
buffer += sz;
return ptr;
}
Design a test that fails for this allocator. Your test should not involve a
wild write.
4pt Line 6 computes a rounded block size, but line 12 advances
buffer by the requested size sz. Two kinds of test notice.
An alignment test: after an allocation whose size is not a multiple of 16,
the next allocation starts at an unaligned address.
int main() {
void* p = m61_malloc(1);
void* q = m61_malloc(1);
assert(p && q);
assert((uintptr_t) q % 16 == 0); // fails: q == (char*) p + 1
}
A zero-size test: m61_malloc(0) advances buffer by 0, so two such
calls return the same pointer, but the specification says distinct active
allocations must have distinct addresses.
int main() {
void* p = m61_malloc(0);
void* q = m61_malloc(0);
assert(p && q && p != q);
}
(The fix is buffer += bsz on line 12,
together with rounding 0 up to 16 as the later fragments do.)
QUESTION 2C. This allocator detects trailing wild writes with a canary
byte stored immediately after each allocation.
void* m61_malloc(size_t sz) {
// ... check `sz`, maybe reuse a free block ...
size_t bsz = !sz || sz % 16 ? (sz | 15) + 1 : sz; // round up to multiple of 16
if (bsz > size_t(end_buffer - buffer)) {
return nullptr;
}
active.insert({ buffer, sz });
char* ptr = buffer;
buffer[sz] = 0xFE; // canary value
buffer += bsz;
return ptr;
}
void m61_free(void* ptr) {
char* cptr = reinterpret_cast<char*>(ptr);
// ... return if !ptr, detect invalid and double frees ...
size_t sz = active[cptr];
assert((unsigned char) cptr[sz] == 0xFE); // check that canary value is unchanged
// ... free the block ...
}
Design a wild-write subtest that demonstrates a problem with this
allocator. A correct allocator would report the wild write when the allocation
was freed (so the subtest is expected to abort); this allocator, given the
right test, will not detect the wild write (so the subtest would not abort).
Hint: Our subtest starts like this:
void ww_subtest() {
char* p = (char*) m61_malloc(16);
char* q = (char*) m61_malloc(1);
assert(p && q);
p[16] = 0; // wild write: one zero byte just past the allocation
5pt
void ww_subtest() {
char* p = (char*) m61_malloc(16);
char* q = (char*) m61_malloc(1);
assert(p && q);
p[16] = 0; // wild write: one zero byte just past the allocation
q[0] = 0xFE; // in this allocator, q[0] *is* p[16]: this restores the canary
m61_free(p); // a correct allocator reports the wild write here and aborts
}
When sz is a multiple of 16, bsz == sz, so the canary written on line 9
lands at buffer[sz], which is the first byte of the next block (line 10
advances buffer by exactly sz). The canary therefore lives inside
whatever is allocated next, rather than in space reserved for it. The wild
write p[16] = 0 by itself would be caught by line 18, so the test
allocates q, whose first byte is the same byte as p[16], and writes
0xFE there using an ordinary in-bounds write. Line 18 then sees an intact
canary and the wild write goes unreported. A correct allocator keeps its
canary in space the user can never legitimately write, so q[0] = 0xFE
cannot repair it, and m61_free(p) reports the error. Any multiple of 16
works as p’s size. (The fix is to reserve the canary byte, for instance
by computing bsz from sz + 1.)
The sequence of allocator calls is what matters. A complete test would run
ww_subtest in a subprocess (for instance with run_subtest from
utils.hh) and check that the subprocess aborted, but that wrapper is not
required for credit.
Partial credit: a subtest that evades detection with a different wild
write, such as p[17] = 0 or p[-1] = 0, shows an understanding that a
canary protects only the bytes it occupies, but not of this allocator’s
particular bug. Such writes are also not ones every correct allocator must
catch, so the test would falsely accuse some correct allocators.
QUESTION 2D. This allocator reuses memory. Assume m61_malloc correctly
serves requests from freed blocks when possible, with correct splitting.
Here is m61_free.
void m61_free(void* ptr) {
char* cptr = reinterpret_cast<char*>(ptr);
// ... return if !ptr, check for invalid and double frees ...
size_t sz = active[cptr];
size_t bsz = !sz || sz % 16 ? (sz | 15) + 1 : sz; // round up to multiple of 16
active.erase(cptr); // free allocation
auto next = freed.find(cptr + bsz); // coalesce with following free block, if any
if (next != freed.end()) {
bsz += next->second;
freed.erase(next);
}
freed.insert({ cptr, bsz });
}
Design a test that fails for this allocator. Your test should not involve wild
writes. Hint: Our test starts like this:
int main() {
char* a = (char*) m61_malloc(1 << 20);
char* b = (char*) m61_malloc(1 << 20);
char* c = (char*) m61_malloc((6 << 20) - 128);
assert(a && b && c);
5pt
int main() {
char* a = (char*) m61_malloc(1 << 20);
char* b = (char*) m61_malloc(1 << 20);
char* c = (char*) m61_malloc((6 << 20) - 128);
assert(a && b && c);
m61_free(a); // lower-addressed block first!
m61_free(b);
void* big = m61_malloc(2 << 20);
assert(big); // fails: `a` and `b` never merged
}
m61_free only merges a block with the free block after it (line 7),
never with the free block before it. a and b are adjacent, since
fresh space is sequential and 1 MiB is a multiple of 16, so b == a + (1 << 20). Freeing a first finds b still active, so no merge happens; freeing
b then finds c, still active, so again no merge. The 2 MiB request fits
neither 1 MiB block, and after c only 128 bytes of fresh space remain, so
m61_malloc returns nullptr. A correct allocator coalesces a and b
into a block of at least 2 MiB and succeeds.
The free order is essential: freeing b before a would let line 7 merge
a with b, and the test would pass. (Like the torture tests, this test
assumes that consecutive fresh allocations are adjacent in memory.)
3. Could have compiled (12 points)
Each part of this question shows x86-64 assembly produced by an optimizing
compiler, followed by several C++ functions. Which functions could a
correct compiler have translated into that assembly? A function qualifies if
the assembly behaves as the C++ abstract machine requires on every call that
does not execute undefined behavior. Assume assertions are enabled.
QUESTION 3A.
<some_f>:
xorl %eax, %eax
testl %edi, %edi
jne .L2
incl %eax
.L2:
ret
Which of these functions could compile to that assembly? List all that
apply or say “none of the above”.
-
int f1(int x) {
return x == 0;
}
int f2(long y) {
return y == 0;
}
int f3(int z) {
assert(false);
}
-
int f4(int m) {
return !m;
}
int f5(int q) {
if (q & q == q) {
return true;
}
return false;
}
long f6(unsigned u) {
return u == 0;
}
-
int f7(int a, int b) {
return a == 0;
}
4pt f1, f4, f6, and f7.
f1 and f4 are the same function: !m is 1 if m == 0 and 0 otherwise.
f2: testl examines only the low 32 bits of the argument. For y == 0x1'0000'0000, the source returns 0 but the assembly returns 1. The
compiler would use testq %rdi, %rdi.
f3: The assertion always fails, so a correct compilation must call the
assertion failure routine (__assert_fail), which never returns. This
assembly returns normally.
f5: == has higher precedence than &, so the condition is q & (q == q), which is q & 1. The function returns 1 for odd q, so it would
compile to something like movl %edi, %eax; andl $1, %eax.
f6: Comparing an unsigned with 0 uses the same testl. The return value
is a long, so all of %rax matters, but 32-bit operations zero the
upper half of their destination: xorl %eax, %eax clears all 64 bits of
%rax, and incl %eax leaves it exactly 1.
f7: The second argument, in %esi, is simply ignored.
QUESTION 3B.
<some_g>:
movl $1, %eax
ret
Which of these functions could compile to that assembly? List all that
apply or say “none of the above”.
-
bool g1() {
return true;
}
int g2(int x) {
return x == x;
}
int g3(int x) {
return x != 0;
}
int g4(int x) {
return x + 1 > x;
}
-
int g5(unsigned x) {
return x + 1 > x;
}
int g6(int x) {
return x / x == 1;
}
int awesome[4] = {0, 1, 2, 3};
bool g7(int v) {
for (int i = 0; i <= 4; ++i) {
if (awesome[i] == v) {
return true;
}
}
return false;
}
4pt g1, g2, g4, g6, and g7.
g1 and g2 always return true.
g3 returns 0 for x == 0.
g4: The result could differ only when x + 1 overflows, that is, when
x == INT_MAX. But signed integer overflow is undefined behavior, so the
compiler may assume it never happens and return 1.
g5: Unsigned arithmetic wraps around. When x == UINT_MAX, x + 1 == 0,
and the function must return 0.
g6: x / x is 1 for every nonzero x. When x == 0, the division is
undefined behavior, so the compiler may assume x != 0.
g7: When v is not 0, 1, 2, or 3, the loop reaches i == 4 and reads
awesome[4], which is out of bounds and therefore undefined behavior.
So every call that does not execute undefined behavior returns true, and
GCC compiles it that way.
QUESTION 3C.
<some_h>:
movl (%rsi), %eax
leal (%rdi,%rax,4), %eax
ret
Which of these functions could compile to that assembly? List all that
apply or say “none of the above”.
-
int h1(int a, const int* b) {
return a + 4 * *b;
}
int* h2(int* a, const int* b) {
return a + *b;
}
-
int h3(const int* a, int b) {
return *a + 4 * b;
}
int h4(int a, const long* b) {
return a + 4 * *b;
}
-
int h5(int a, const short* b) {
return a + 4 * *b;
}
4pt h1 and h4.
The first instruction loads the 4 bytes at the address in %rsi, the
second argument, which must therefore be a pointer. The leal then
computes %edi + 4 * %eax without touching memory, so the first argument
is a plain integer used by value. The result is 32 bits.
h1: Exactly a + 4 * (*b).
h2: Pointer arithmetic on int* produces a 64-bit address, so the result
needs leaq into %rax and b’s value must be sign-extended first. GCC
produces movslq (%rsi), %rax; leaq (%rdi,%rax,4), %rax.
h3: The roles are swapped: here the first argument is the pointer, so
the load would be from (%rdi) and the scaled register would be %rsi.
GCC produces movl (%rdi), %eax; leal (%rax,%rsi,4), %eax.
h4: Yes, surprisingly. *b is 8 bytes, and GCC’s own output is movq (%rsi), %rax; leal (%rdi,%rax,4), %eax. But the result is an int, so
only the low 32 bits of a + 4 * (*b) matter, and those depend only on
the low 32 bits of *b. On little-endian x86-64, the low 32 bits of the
long at b are exactly the 4 bytes at address b, which is what movl (%rsi), %eax loads. The assembly is a correct translation.
h5: *b is 2 bytes. The compiler must load 2 bytes and sign-extend them
(movswl (%rsi), %eax). movl reads 4 bytes, including 2 bytes past the
end of the short, and uses them as the high half of the value.