Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Notes

2026-08-22

  • Collect all markdown docs from claude and from the laptop and put them into knowledgebase
  • Books on compilers, links on iPad Firefox

Wiki

mdbook Setup Guide

Tutorial: Reading objdump Output, Line by Line

We’ll write a tiny C program, compile it, and disassemble it — then go through the actual x86-64 assembly the compiler produced, instruction by instruction.

The program

#include <stdio.h>
#include <limits.h>

void print_binary(unsigned int x)
{
    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
        putchar((x >> i) & 1 ? '1' : '0');
    putchar('\n');
}

int main(void)
{
    int x = 45;
    printf("%d\n", x);
    print_binary((unsigned int) x);
    return 0;
}

Save this as example.c.

Compiling with debug info, no optimization

gcc -g -O0 -o example example.c

Two flags matter a lot here:

  • -g embeds debug information (source file names, line numbers, variable info) into the binary — this is what lets objdump -S interleave your original C source alongside the assembly.
  • -O0 disables optimization. This is deliberate for learning: an optimized build (-O2) would reorder, merge, and eliminate instructions aggressively, making the assembly much harder to map back to your source line-by-line. -O0 gives you a close-to-literal, unoptimized translation — useful for understanding, not for shipping.

Disassembling

objdump -dS --disassemble=main example
objdump -dS --disassemble=print_binary example
  • -d disassembles executable sections.
  • -S interleaves original source lines (requires -g at compile time).
  • --disassemble=<function> restricts output to just that one function, instead of dumping the entire binary.

Part 1: main, line by line

00000000000011be <main>:

The label. main starts at address 0x11be within this binary.

Function prologue

int main(void)
{
    11be:	f3 0f 1e fa          	endbr64
    11c2:	55                   	push   %rbp
    11c3:	48 89 e5             	mov    %rsp,%rbp
    11c6:	48 83 ec 10          	sub    $0x10,%rsp

This block is boilerplate that appears at the start of nearly every function compiled with -O0 — the function prologue. It sets up this function’s own private workspace (its stack frame).

  • endbr64 — a CPU security feature (Intel CET / “Control-flow Enforcement Technology”), marking this as a valid landing point for indirect jumps/calls. Not something you write yourself; the compiler inserts it automatically as a defense against certain exploit techniques. Safe to mentally skip past for understanding program logic.
  • push %rbp — saves the caller’s base pointer (%rbp) onto the stack, so it can be restored when this function returns. %rbp conventionally points at the base of the current function’s stack frame — this instruction preserves whoever was using it before us.
  • mov %rsp,%rbp — copies the current stack pointer (%rsp) into %rbp, establishing this function’s own frame base. From here on, local variables are addressed as offsets from %rbp.
  • sub $0x10,%rsp — moves the stack pointer down by 16 bytes, reserving 16 bytes of scratch space on the stack for this function’s local variables (here, just the one int x, though the compiler reserved more than the strict minimum — normal at -O0, and often related to stack alignment requirements).

This is the concrete, physical version of the abstract “stack frame” / “activation record” concept from earlier in this series of questions — you’re looking at the actual instructions that create one.

int x = 45;

    int x = 45;
    11ca:	c7 45 fc 2d 00 00 00 	movl   $0x2d,-0x4(%rbp)
  • -0x4(%rbp) is the memory location the compiler chose for the local variable x — 4 bytes below the frame base. This is x’s actual address in memory for the lifetime of this function call — the same kind of address you’ve been taking with &x at the source level.
  • $0x2d is 45 in hex (0x2d = 45). movl (“move long word”) writes this 4-byte immediate value directly into that memory location — this one instruction is x = 45.

printf("%d\n", x);

    printf("%d\n", x);
    11d1:	8b 45 fc             	mov    -0x4(%rbp),%eax
    11d4:	89 c6                	mov    %eax,%esi
    11d6:	48 8d 05 27 0e 00 00 	lea    0xe27(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    11dd:	48 89 c7             	mov    %rax,%rdi
    11e0:	b8 00 00 00 00       	mov    $0x0,%eax
    11e5:	e8 86 fe ff ff       	call   1070 <printf@plt>

This block is entirely about the x86-64 calling convention — the rule for how arguments get passed to a function. On Linux x86-64, the first several integer/pointer arguments go in specific registers, in order: %rdi, %rsi, %rdx, %rcx, %r8, %r9.

  • mov -0x4(%rbp),%eax — loads x’s value (45) from memory back into the %eax register. (The compiler stored it to memory a moment ago and now has to reload it — this kind of redundant memory round-trip is exactly the sort of thing -O2 optimization would eliminate; at -O0 the compiler doesn’t bother.)
  • mov %eax,%esi — moves x’s value into %esi, which is the register for the second argument to printf. (printf’s arguments are the format string, then x — so x is argument #2.)
  • lea 0xe27(%rip),%raxlea = “load effective address.” This computes the address of the string literal "%d\n" (stored elsewhere in the binary, referenced here relative to the current instruction pointer, %rip) and puts that address into %rax. The comment # 2004 <_IO_stdin_used+0x4> is objdump helpfully telling you the resolved absolute address and what section it falls in.
  • mov %rax,%rdi — moves that string address into %rdi, the register for the first argument (the format string).
  • mov $0x0,%eax — sets %eax to 0. This looks unrelated to your code, but it’s part of the x86-64 calling convention specifically for variadic functions like printf: %eax must hold the number of vector/floating-point registers used for the call (0 here, since we’re passing plain integers, no floats).
  • call 1070 <printf@plt> — the actual call. <printf@plt> means this jumps through the PLT (Procedure Linkage Table) — a layer of indirection used because printf lives in the dynamically-linked C library (libc), not in this binary itself; the PLT resolves the real address of printf at load time (or on first call).
    print_binary((unsigned int) x);
    11ea:	8b 45 fc             	mov    -0x4(%rbp),%eax
    11ed:	89 c7                	mov    %eax,%edi
    11ef:	e8 75 ff ff ff       	call   1169 <print_binary>
  • Reload x from memory into %eax.
  • Move it into %edi — the register for the first argument. Note: the (unsigned int) cast in your source doesn’t generate any actual instruction here — reinterpreting a 4-byte int as a 4-byte unsigned int involves no bit-pattern change at all, only a change in how later instructions interpret the same bits (recall the earlier discussion on reinterpreting bytes via pointer casts — this is the same idea: the cast is purely a compile-time typing decision, with zero runtime cost here).
  • call 1169 <print_binary> — this time, no @pltprint_binary is defined right here in this same binary, so the call target is a direct, known address (0x1169), no dynamic-linking indirection needed.

return 0; and epilogue

    return 0;
    11f4:	b8 00 00 00 00       	mov    $0x0,%eax
}
    11f9:	c9                   	leave
    11fa:	c3                   	ret
  • mov $0x0,%eax — the x86-64 convention for returning an integer value is: put it in %eax. This is exactly how main’s return 0 becomes the process’s exit status.
  • leave — a shorthand instruction that undoes the prologue in one step: restores %rsp from %rbp (deallocating the stack frame) and pops the saved %rbp back (undoing the very first push %rbp). This is the function epilogue.
  • ret — pops the return address (pushed automatically by the call instruction that got us here) and jumps back to the caller.

Part 2: print_binary, line by line

This is the more interesting one — it’s a real loop with a real branch, so you get to see how C control flow actually becomes machine code.

0000000000001169 <print_binary>:
void print_binary(unsigned int x)
{
    1169:	f3 0f 1e fa          	endbr64
    116d:	55                   	push   %rbp
    116e:	48 89 e5             	mov    %rsp,%rbp
    1171:	48 83 ec 20          	sub    $0x20,%rsp
    1175:	89 7d ec             	mov    %edi,-0x14(%rbp)

Same prologue pattern as before, plus one new instruction:

  • mov %edi,-0x14(%rbp)x arrived in %edi per the calling convention (it’s the argument register). This instruction immediately copies it out into x’s own stack slot, at -0x14(%rbp). This is standard -O0 behavior: even a parameter that arrived in a register gets spilled to memory right away, purely so the rest of the function can treat it uniformly like any other local variable.

The for loop setup

    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
    1178:	c7 45 fc 1f 00 00 00 	movl   $0x1f,-0x4(%rbp)
    117f:	eb 2a                	jmp    11ab <print_binary+0x42>
  • movl $0x1f,-0x4(%rbp) — this is i = sizeof(x) * CHAR_BIT - 1, fully computed at compile time. 0x1f = 31 decimal. The compiler already knows sizeof(unsigned int) is 4 and CHAR_BIT is 8, so 4 * 8 - 1 = 31 — no runtime multiplication ever happens; the compiler folded the whole expression into a single constant. i lives at -0x4(%rbp), a separate stack slot from x’s -0x14(%rbp).
  • jmp 11ab — jumps straight down to the loop’s condition check (address 0x11ab, shown further below), skipping the loop body on this first pass through. This is a very characteristic pattern for compiled for loops: check the condition before the first iteration by jumping to the check first, then looping back up to the body only if the check passes. It restructures your for(init; cond; incr) body into something closer to “init; goto check; body: …; incr; check: if (cond) goto body;” — logically identical to your source, just rearranged so the condition test is a single piece of code reused both for the “should I run at all” question and the “should I loop again” question.

The loop body: putchar((x >> i) & 1 ? '1' : '0');

        putchar((x >> i) & 1 ? '1' : '0');
    1181:	8b 45 fc             	mov    -0x4(%rbp),%eax
    1184:	8b 55 ec             	mov    -0x14(%rbp),%edx
    1187:	89 c1                	mov    %eax,%ecx
    1189:	d3 ea                	shr    %cl,%edx
    118b:	89 d0                	mov    %edx,%eax
    118d:	83 e0 01             	and    $0x1,%eax
    1190:	85 c0                	test   %eax,%eax
    1192:	74 07                	je     119b <print_binary+0x32>
    1194:	b8 31 00 00 00       	mov    $0x31,%eax
    1199:	eb 05                	jmp    11a0 <print_binary+0x37>
    119b:	b8 30 00 00 00       	mov    $0x30,%eax
    11a0:	89 c7                	mov    %eax,%edi
    11a2:	e8 b9 fe ff ff       	call   1060 <putchar@plt>

This is the whole expression (x >> i) & 1 ? '1' : '0', broken into its real, individual pieces — a great example of how much machinery one line of C can expand into:

  • mov -0x4(%rbp),%eax — load i into %eax.
  • mov -0x14(%rbp),%edx — load x into %edx.
  • mov %eax,%ecx — copy i into %ecx. This looks redundant, but it isn’t: x86’s shift instructions have a hardware quirk where the shift amount, when it’s a variable (not a compile-time constant), must be supplied specifically in the %cl register (the low byte of %ecx) — there’s no shift-by-any-register form. The compiler is forced to shuffle i into %ecx purely to satisfy this instruction encoding rule.
  • shr %cl,%edx — the actual shift! shr = shift right, logical (zero-fill) — matches unsigned int x, confirming exactly the logical-vs-arithmetic distinction from your earlier question: because x is unsigned, the compiler emits shr, not sar. This computes x >> i, in place, in %edx.
  • mov %edx,%eax — copy the shift result into %eax.
  • and $0x1,%eax — the & 1 — masks off every bit except the lowest one. This is the literal machine-level version of the bitmask extraction technique from earlier in this conversation.
  • test %eax,%eax — a common idiom for “is this value zero?” — it ANDs %eax with itself (without storing the result anywhere) purely to set the CPU’s internal flags based on the value.
  • je 119b — “jump if equal” (i.e., jump if the last test found zero) — jump down to the '0' branch. This is the compiled form of your ternary’s false path.
  • mov $0x31,%eax / jmp 11a00x31 is ASCII '1' (decimal 49). This is the ternary’s true branch: load the character '1', then jump past the other branch.
  • mov $0x30,%eax (at label 119b) — 0x30 is ASCII '0' (decimal 48) — the false branch’s value.
  • mov %eax,%edi / call 1060 <putchar@plt> — same calling convention pattern as before: put the argument (whichever character was selected) into %edi, call putchar through the PLT (dynamically linked, like printf).

Loop increment and condition check

    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
    11a7:	83 6d fc 01          	subl   $0x1,-0x4(%rbp)
    11ab:	83 7d fc 00          	cmpl   $0x0,-0x4(%rbp)
    11af:	79 d0                	jns    1181 <print_binary+0x18>
  • subl $0x1,-0x4(%rbp)i--, done directly in memory (subtract 1 from i’s stack slot).
  • cmpl $0x0,-0x4(%rbp) — compares i against 0 (sets flags based on i - 0, without storing the subtraction result anywhere — same “compute flags only” idea as test above).
  • jns 1181 — “jump if not sign” — i.e., jump back up to the loop body (address 0x1181) if the comparison result was non-negative, meaning i >= 0 still holds. This single instruction is the entire compiled form of your i >= 0 loop condition. Once i becomes -1, this jump is no longer taken, and execution falls through to what’s next.

putchar('\n'); and epilogue

    putchar('\n');
    11b1:	bf 0a 00 00 00       	mov    $0xa,%edi
    11b6:	e8 a5 fe ff ff       	call   1060 <putchar@plt>
}
    11bb:	90                   	nop
    11bc:	c9                   	leave
    11bd:	c3                   	ret
  • mov $0xa,%edi0x0a is ASCII newline (\n). Loaded directly as a constant — no memory lookup needed, since '\n' is a literal known at compile time.
  • call 1060 <putchar@plt> — same putchar call pattern as inside the loop.
  • nop — “no operation” — does literally nothing. Compilers sometimes insert these for instruction alignment purposes (certain addresses are faster for the CPU to fetch/decode from); not meaningful to your program’s logic.
  • leave / ret — same epilogue pattern as main.

Things worth noticing across both functions

  • Every local variable got its own stack slot, addressed as a negative offset from %rbp — this is the concrete reality behind the abstract idea of “a variable’s address,” which you’ve been reasoning about symbolically (&x) all through this conversation. Here you can see the actual offset the compiler picked.
  • Constant expressions get folded at compile time. sizeof(x) * CHAR_BIT - 1 never executes as a multiplication and subtraction at runtime — it’s baked into a single movl $0x1f, ... instruction. The compiler evaluates anything it can determine ahead of time, once, rather than making the CPU redo it on every run.
  • Function calls follow a strict, mechanical calling convention — arguments in %rdi, %rsi, %rdx, … in order, return value in %eax/%rax. This is exactly what makes it possible for main (in this binary) to call printf (compiled separately, in glibc, decades ago) and have it just work — both sides agree on this convention without ever having seen each other’s source code.
  • shr (not sar) appears because x is declared unsigned — direct, physical confirmation of the logical-vs-arithmetic shift discussion: change unsigned int x to int x in the function signature and recompile, and you’d see sar emitted instead for the exact same source-level >> operator.
  • -O0 is verbose and redundant on purpose — notice how often a value gets stored to memory and immediately reloaded (e.g. x is loaded into %eax, stored, then reloaded again just a few instructions later). A real optimizing build (-O2) would keep such values in registers throughout and eliminate most of this — but that would also make the assembly much harder to map back to individual source lines, which is exactly why -O0 is the right choice for a tutorial like this one, and -O2 is what you’d actually ship.

Try it yourself

# See the difference optimization makes:
gcc -g -O2 -o example_opt example.c
objdump -dS --disassemble=print_binary example_opt

Compare this against the -O0 version above — at -O2, the compiler will likely eliminate most of the memory round-trips, possibly unroll part of the loop, and generally produce something much less line-by-line traceable back to source — a good next step once this version makes sense.

Bitwise Operators Cheatsheet (C)

The operators, one bit at a time

OpNameRule (per bit pair a,b)
&AND1 only if both are 1
|OR1 if either is 1
^XOR1 if they differ
~NOTflips the single bit
<<left shiftslides bits toward higher value (×2 per shift)
>>right shiftslides bits toward lower value (÷2 per shift)

What each operator does when combined with a mask

This is the part worth memorizing — what a 1-bit vs a 0-bit in your mask means, per operator:

Opmask bit = 1 means…mask bit = 0 means…
x & maskkeep that bit of x unchangedforce to 0
x | maskforce to 1keep that bit of x unchanged
x ^ maskflip that bit of xkeep that bit of x unchanged
x & ~maskforce to 0keep that bit of x unchanged

Everything below is a consequence of just this table.


Common idioms

x & mask        // TEST/EXTRACT — isolate specific bits, zero the rest
x | mask        // SET — force specific bits to 1, leave rest alone
x & ~mask       // CLEAR — force specific bits to 0, leave rest alone
x ^ mask        // TOGGLE — flip specific bits, leave rest alone
~x              // flip EVERY bit
~x ^ mask       // flip everything, then flip mask's 1-bits back
                //   → net effect: bits under mask=1 stay original,
                //     bits under mask=0 end up flipped
x ^ ~mask       // identical result to ~x ^ mask (see Identities below)
(x >> i) & 1    // test whether bit i specifically is set (0 or 1)
x & (x - 1)     // clear the lowest set bit
x & -x          // isolate the lowest set bit (two's complement trick)
x | (1 << i)    // set bit i
x & ~(1 << i)   // clear bit i
x ^ (1 << i)    // toggle bit i

Masks by width — remember the implicit zero-padding

A literal like 0xFF is widened to match the operand’s width by padding with zeros on the left before the operation runs:

0xFF  on a 32-bit int  →  0x000000FF

That’s why x & 0xFF keeps only the last byte: the mask is 1-bits for the byte you want, and implicit 0-bits for everything above it.

Want to isolateMask
last byte0xFF
last 2 bytes0xFFFF
last nibble (4 bits)0xF
bit i only1 << i
everything except the last byte~0xFF

Shifts

x << k     // multiply by 2^k. Fills k zeros on the right. Drops top k bits.
x >> k     // divide by 2^k (roughly).
  • Right shift on unsigned: always logical — fills with 0s on the left. Well-defined, no ambiguity.
  • Right shift on signed, negative x: implementation-defined — in practice almost universally arithmetic (sign-extends, fills with 1s) on real hardware/compilers, but not guaranteed by the standard.
  • Undefined behavior: shifting by a negative amount, or by an amount >= the width of the type (x << 32 on a 32-bit int). Always guard:
    if (k >= 0 && k < (int)(sizeof(x) * CHAR_BIT)) y = x << k;
    

Useful identities

~x ^ x        == all 1s   (a bit and its complement always differ)
~a ^ b        == a ^ ~b   (complementing either single operand of XOR
                            gives the same result)
a ^ b ^ b     == a         (XOR with the same value twice cancels —
                            this is the basis of the XOR swap)
a & a         == a
a | a         == a
a & ~a        == 0
a | ~a        == all 1s
~(~x)         == x
(x & ~y) | (~x & y) == x ^ y

Set-operation view (bit vectors as sets)

If a bit vector encodes a set (bit i set ⟺ element i is in the set):

a & b     // intersection
a | b     // union
a ^ b     // symmetric difference (elements in exactly one of the two)
~a        // complement (relative to the full universe of bits)
a & ~b    // set difference (A minus B)

Gotchas

  • Operator precedence: &, |, ^ bind looser than ==/!= and comparisons. if (x & mask == 1) parses as x & (mask == 1) — almost always a bug. Always parenthesize: if ((x & mask) == 1).
  • &&/|| vs &/|: the double-character versions are logical operators (short-circuiting, operate on “truthy/falsy” as a whole, return 0/1) — completely different from the single-character bitwise versions. Easy to typo one for the other.
  • Signed shift of negative numbers: right-shifting a negative signed int is implementation-defined, not guaranteed zero-fill. Cast to unsigned first if you need guaranteed logical-shift behavior.
  • Mask width mismatches: x & 0xFF on a 64-bit x still only keeps the last byte — the mask pads with zeros to match, which is usually what you want, but worth double-checking when mixing types of different widths in one expression.

Quick self-test

Given x = 0x87654321 (32-bit):

ExpressionResultWhy
x & 0xFF0x00000021keep last byte, zero rest
x | 0xFF0x876543FFforce last byte to all 1s
x ^ 0xFF0x876543DEflip only the last byte
~x0x789ABCDEflip everything
~x ^ 0xFF0x789ABC21flip everything, then un-flip last byte
x << 40x76543210shift left 4, drop top nibble, zero-fill right
x >> 4 (unsigned)0x08765432shift right 4, zero-fill left

Documentation

C, Pointers, and Binary — Session Notes

Working notes from a deep-dive that started with GNU basename.c’s remove_suffix() function and ended up covering pointers, memory layout, endianness, bit vectors, and bitmasking.


The starting code

static void
remove_suffix (char *name, char const *suffix, idx_t suffix_len)
{
  char *np = name + strlen (name);
  char const *sp = suffix + suffix_len;

  while (np > name && sp > suffix)
    if (*--np != *--sp)
      return;
  if (np > name)
    *np = '\0';
}

Removes suffix from the end of name, unless name consists entirely of suffix (in which case it’s left untouched).

  • np starts one past the last character of name (at the '\0').
  • sp starts one past the last character of suffix.
  • The while loop walks both pointers backward, comparing characters from the end inward. *--np decrements first, then dereferences.
  • If characters mismatch, return immediately — no truncation.
  • If the loop exits because sp ran out first (np > name still true), the suffix was fully matched with room to spare → truncate at np.
  • If np also reached name (equal, not greater), the whole string was consumed matching the suffix → name is the suffix → don’t touch it.

name is char *, suffix is char const * — why?

  • name is written to (*np = '\0'), so it can’t be const.
  • suffix is only ever read, so marking it const is a promise not to modify it — lets you pass string literals or existing const char * values in without complaint.

What static means here

  • On a function: internal linkage — only visible/callable within this .c file (this translation unit). Not part of the file’s public API, and lets the compiler optimize more aggressively (e.g. inlining).
  • (Different from static on a local variable, which instead makes that variable persist across calls instead of living on the stack.)

char *np = name + strlen(name); — pointer arithmetic

  • strlen(name) counts bytes until it hits '\0'.
  • Pointer arithmetic (ptr + N) advances by N * sizeof(pointee_type), not raw bytes — except for char*, where sizeof(char) == 1, so it happens to coincide with byte offsets.
  • name + strlen(name) lands exactly on the string’s null terminator — one past the last real character. This is well-defined (“one past the end” is allowed); going further is undefined behavior.

Comparing pointers: np > name

  • Pointer comparisons (<, >) are only well-defined when both pointers point into (or one past the end of) the same array/object.
  • Here it’s asking “have I walked back to the start of name yet, or is there still room left?” — a way to distinguish why the loop stopped.

“Where does a string start/end” — low-level view

  • Start of a C string = just the pointer, free.
  • End = not free — has to be found by scanning for '\0' (what strlen does). C strings carry no built-in length; it’s purely a scanning convention.

Learning by reading code vs. reading a book

  • A valid, real learning style (sometimes called “Feynman-style” or interrogation-driven learning) — finds your actual gaps in real time instead of guessing at them in advance.
  • Trade-off: chaotic, not comprehensive — some sequential topics (e.g. fork/exec/wait semantics in TLPI) genuinely benefit from a linear build-up that ad-hoc code reading won’t give you. Both approaches can complement each other.

*--np — increment or something else?

  • --np is pre-decrement: np = np - 1, moving back one element (one char = one byte here), then the expression evaluates to the new pointer.
  • * then dereferences that new position.
  • *--np = “move back one, then read.” Contrast *np-- = “read, then move back” (post-decrement) — same eventual pointer position, different value used in that expression.
  • Pointer arithmetic scales by sizeof(pointee_type) — 1 byte for char*, typically 4 for int*, 8 for double*. Never “by bit” — the smallest addressable unit in C is a byte.

Concrete trace of np > name

  • "foo.tmp" vs suffix "tmp": after 3 matches, sp reaches suffix (exhausted) while np is still at index 4, > name (index 0) → suffix fully matched, name has more before it → truncate → "foo.".
  • "tmp" vs suffix "tmp": after 3 matches, both np and sp reach their respective starts simultaneously → np == name, not > → the whole string was consumed by the match → leave untouched.
  • General rule: np > name after the loop means the suffix ran out first (real truncatable match); np == name means name itself was fully consumed (nothing to truncate).
  • Section 2.1.4, “Representing Strings” (in Ch. 2, “Representing and Manipulating Information”) — short section, byte-level walkthrough of null-terminated ASCII strings.
  • Read alongside 2.1.1–2.1.3 (hex notation, data sizes, addressing and byte ordering) for the addressing foundation.
  • Chapter 1’s “hello world” walkthrough is a good concrete anchor for “a string is just bytes in memory.”

Non-ASCII characters in string literals

  • char is just a byte-sized integer type — nothing ASCII-specific about it. ASCII is a convention that fits in 7 bits.
  • On UTF-8 source files (standard on Linux), ASCII characters stay one byte; non-ASCII characters take 2–4 bytes per codepoint.
  • "café" → 6 bytes total (c,a,f, 2 bytes for é, \0) — strlen returns 5, not 4.
  • Real danger: byte-oriented code (like remove_suffix) doesn’t know bytes can belong together as one multi-byte character. Truncating mid-sequence produces invalid UTF-8.
  • Options: treat as opaque bytes (fine if you never split mid-sequence), wchar_t/char16_t/char32_t for fixed-width Unicode (with portability caveats), or a UTF-8-aware library (utf8proc, ICU) for anything that needs to reason about actual characters.

Tracing base_name through the coreutils/gnulib source

  • Not declared in any header directly #included in basename.c — it comes in transitively via system.hdirname.h.
  • gnulib is a shared pool of portable utility code vendored into projects like coreutils, not a linked external library — hence code living in a lib/ subdirectory pulled in by the build system.
  • base_name (not basename()) exists because the real POSIX/glibc basename() has inconsistent behavior across platforms; gnulib provides a consistent version under a different name.
  • Practical ways to trace this: grep -rn "symbol" . across the whole tree (not just one file), ctags/cscope/clangd “go to definition,” or read the project’s own catch-all internal header (system.h) to see what it pulls in. In GNU projects, if a symbol isn’t in src/, check lib/ next.

Setting up ctags / cscope / clangd in vim

ctags (sudo apt install universal-ctags):

ctags -R .
set tags=./tags;,tags;

Ctrl-] jumps to definition, Ctrl-t back. Purely textual/regex-based — finds a match, not necessarily the semantically correct one.

cscope (sudo apt install cscope):

cscope -Rb
if filereadable("cscope.out")
    cs add cscope.out
endif
set cscopetag
set cscopetagorder=0   " note: numeric options need '=', not a space

:cs find g <symbol> (definition), :cs find c <symbol> (find callers — something ctags can’t do at all).

clangd (sudo apt install clangd) — actual semantic understanding, correctly resolves symbols behind macros/#ifdef guards. Needs compile_commands.json (see below). Wire into vim via vim-lsp or similar LSP client plugin:

nnoremap gd :LspDefinition<CR>
nnoremap gD :vsp<CR>:LspDefinition<CR>

Building coreutils from a git clone

A git clone lacks generated build machinery (no configure script, no gnulib sources) that a release tarball would already have:

cd coreutils
./bootstrap                       # clones gnulib submodule, runs autotools
sudo apt install autoconf automake autopoint gperf texinfo  # if needed
./configure
sudo apt install bear
bear -- make                      # generates compile_commands.json
  • bear intercepts every compiler invocation and records the actual flags/includes used — critical for clangd to resolve conditional macros (like #if GNULIB_DIRNAME) correctly.
  • This does NOT install anything on the system./configure and make build binaries only inside the source tree itself. Only sudo make install would copy anything to /usr/local/bin and affect what runs on the actual $PATH. Not needed just to read source and generate compile_commands.json.

Build error: -Werror=useless-cast

Newer GCC than what coreutils’ maintainer build profile expects can trip -Werror on warnings gnulib’s warning list didn’t anticipate. Fix:

make clean
./configure --disable-gcc-warnings
bear -- make

Fallback if warnings still leak through: add CFLAGS="-Wno-error".

C code organization: gnulib’s granular module philosophy

  • lib/basename.c containing only base_name isn’t “one function per file” as a hard rule — it reflects gnulib’s design: small, independent, individually-importable modules, since projects copy files piecemeal rather than linking a monolithic library.
  • Rule of thumb: reusable, standalone primitives → their own file. Tightly-coupled, program-specific logic (like src/basename.c’s usage(), remove_suffix(), perform_basename(), main()) → bundled together.
  • .h files declare (what exists + its signature); .c files define (the actual implementation). Callers only need the cheap .h declaration; only the actual implementation file needs to be compiled.

Vim navigation after :LspDefinition

  • Jump list: Ctrl-o (back to previous position), Ctrl-i (forward) — works for any “big” cursor movement, not just LSP jumps. :jumps shows the full list.
  • Splits: :LspDefinition can be made to open in a split explicitly:
    nnoremap gD :vsp<CR>:LspDefinition<CR>
    
    Navigate between splits with Ctrl-w w (cycle), Ctrl-w h/j/k/l (directional), Ctrl-w q (close current split).

#define proper_name(name) ... — macros with no visible body

  • Function-like macros are pure text substitution performed by the preprocessor before compilation — there’s no executable code “at” the macro itself.
  • If the macro expands to a call to a real function (e.g. proper_name_lite(...)), that’s where to look next for actual implementation.
  • If it’s genuinely just a bare substitution with no further function call, that is the entire definition — nothing more exists to find.
  • Practical context: this is gnulib’s propername module, used to localize author names in --version output (append translated name, handle non-ASCII authors’ names without requiring translators to type special characters).
  • Why “go to definition” tooling dead-ends on macros: macros have no call site, no stack frame, and no symbol in the compiled output — they’re erased by the preprocessor before the compiler’s symbol-table step ever runs. (“Call site” needs a small caveat: there is a textual invocation point in source, it just never becomes a real function-call instruction in the compiled binary.)

What a symbol table actually is

  • Compiler’s internal table (during compilation): tracks every identifier’s type/scope, used for type-checking. Exists only in the compiler’s memory.
  • Object file’s symbol table (persistent, inspectable via nm): name → address/type/attributes. T/t = defined here (upper/lowercase = global/local — static produces lowercase t, i.e. internal linkage). U = undefined here, needs to be supplied by another object file.
  • Linking: the linker matches U entries in one file against T/t entries in others. Failure to find a match anywhere → the classic “undefined reference to X” error.
  • Final binary: still carries a symbol table (unless stripped), which is what lets debuggers show function names instead of raw addresses.
  • Macros never appear at any of these stages — they’re erased before the compiler even starts building its internal table.

Disassemblers available on Debian

Already included via binutils (near-universal dependency):

objdump -d src/basename        # disassemble
nm src/basename                 # symbol table
readelf -a src/basename         # full ELF structure
objdump -dS src/basename        # disassembly interleaved with source (needs -g)

More capable/interactive options: gdb (has a disassemble command, good for interactive stepping), radare2 (sudo apt install radare2 — much more powerful, steeper learning curve).

Checking endianness

lscpu | grep -i endian

x86-64 (virtually all modern laptops) → little-endian. Or verify at the byte level directly:

unsigned int x = 1;
unsigned char *p = (unsigned char *) &x;
printf ("%s\n", p[0] == 1 ? "Little endian" : "Big endian");

Array element order vs. endianness — two separate things

  • Array order (my_array[0], [1], [2], …) is guaranteed by the C standard to be sequential, increasing addresses — true on every architecture, regardless of endianness.
  • Endianness only governs byte order within a single multi-byte value (e.g. the 4 bytes composing one int element) — not the order of elements relative to each other.
  • char is always 1 byte — no internal byte order to speak of, which is exactly why byte-oriented string code is endian-agnostic.

Getting the address of an array element

int my_array[3] = {1, 2, 3};
int *p = &my_array[0];   // explicit
int *p2 = my_array;       // equivalent — array name decays to pointer to first element
int *p3 = my_array + 1;   // == &my_array[1], via pointer arithmetic

%p and the (void *) cast

  • printf is variadic — the compiler can’t type-check variadic arguments against the format string the way it does normal parameters.
  • %p specifically expects void * per the C standard. Passing e.g. int * without casting is technically undefined behavior (even though it works in practice on platforms where all pointer types share the same representation) — the cast makes it correct per the standard, not just “happens to work here.”

Reinterpreting bytes: unsigned char *bytes = (unsigned char *) p;

  • Nothing about the underlying memory changes or gets split — the cast only changes how the compiler is told to read the same bytes at the same address.
  • Dereferencing through the new pointer type changes both what a single dereference gives you (4 bytes assembled into one int vs. one raw byte) and how pointer arithmetic scales (+1 jumps 4 bytes vs. 1 byte).
  • A type’s “N-byte-ness” is a convention for interpretation, not a physical fusing of bytes — the bytes are always just individual bytes underneath.

radare2 build failure: stale absolute path

Error: No rule to make target '/home/user/Downloads/radare2.../r_util.h' while everything else pointed to /opt/radare2....

  • Cause: the tree was originally built (or partially configured) at one location, then moved — some generated build artifact still has the old absolute path baked in.
  • Fix: git clean -xdf (or re-extract/re-clone fresh) directly at the final location, then build there without building elsewhere first.
  • If a full clean isn’t feasible: grep -rl "old/stale/path" . to find and remove the specific stale generated files so they regenerate.

Converting a number’s byte representation back to decimal

  • Each byte in memory has place value 256^i, where i is its position (little-endian: position 0 = lowest address = first byte).
  • value = Σ byte_i × 256^i
  • Important not to mix this up with converting individual hex digits using 16^i — a byte is two hex digits, and the two schemes happen to agree only because of that 1-byte-equals-2-hex-digits relationship; reasoning byte-by-byte with 256^i is the version that generalizes correctly and matches actual memory layout.
  • This only works for integers. Floats (IEEE 754) split their bits into sign/exponent/mantissa fields with different meanings — summing byte_i × 256^i on a float’s raw bytes gives a meaningless number.

Why 256^i (and not something else)

  • Same logic as decimal place value: a base-b positional system uses b^i for position i, because each “digit” can hold exactly b distinct values (0 through b−1).
  • A byte holds 256 values (0–255) → it’s a “digit” in a base-256 system → position i’s weight is 256^i.
  • 256 = 2⁸ specifically because a byte is 8 bits by convention.

Where 2⁸ = 256 comes from, combinatorially

  • Not “combinations” (order-independent selection) — this is permutations with repetition: 8 independent positions, each choosing from 2 values, order matters. Formula: n^k (here 2^8).
  • By the multiplication principle: 8 independent binary choices multiply together: 2×2×2×2×2×2×2×2 = 256.
  • Same logic gives hex digits 16 possible values (2^4, one nibble), and explains why one byte always converts to exactly two hex digits: 2^8 = 256 = 16 × 16.

Printing floats as hex: %a / %A

printf ("%a\n", 12345.678);   // 0x1.81c6b851eb852p+13
  • C99 standard specifier. Format: 0x1.<mantissa fraction>p<exponent> — exponent is a power of 2 (p, not e), exact representation, no decimal rounding error.
  • Different from a raw byte dump: %a shows the interpreted mantissa/exponent structure; (unsigned char*)&f byte-dumping shows the raw bytes before interpretation.

Why byte-dumping a float doesn’t match its decimal value

  • Floats use IEEE 754: 1 sign bit + exponent bits (biased) + mantissa bits (with an implicit leading 1.) — three fields with different jobs, not one uniform place-value integer like an int.
  • The Σ byte_i × 256^i trick only applies to integers; applying it to a float’s raw bytes produces a meaningless number.
  • To manually decode: split into sign/exponent/mantissa per the format’s bit widths, subtract the exponent bias, reconstruct (-1)^sign × 1.mantissa × 2^(exponent−bias).

size_t vs int comparison warnings

for (int i = 0; i < len; i++)   // len is size_t → warning
  • size_t is unsigned; comparing signed int to it triggers an implicit conversion of the signed value to unsigned.
  • Real danger: a negative signed value converted to unsigned doesn’t become negative — it wraps to a huge positive value (e.g. SIZE_MAX), silently breaking loop logic.
  • Correct fix: match the loop variable’s type —
    for (size_t i = 0; i < len; i++)
    
  • If genuinely need signed arithmetic: use ptrdiff_t with an explicit cast at the comparison, not casting len down.
  • GNU/gnulib code (per idx_t in remove_suffix) uses a custom signed- but-guaranteed-wide-enough type for this exact reason.

(int) len cast — why it’s hacky, not just inelegant

  • Silences the warning by making both operands int, but doesn’t fix the actual issue — it relocates it.
  • size_t can hold values far larger than int can represent. If len exceeds INT_MAX, (int) len overflows — signed integer overflow is undefined behavior, commonly manifesting as wraparound to a garbage (possibly negative) value.
  • Correct fix widens/matches toward the type that can safely hold every possible value (size_t for the loop variable) rather than narrowing the wider one down.

Printing numbers in binary

  • No portable %b before C23; C23 added it (printf("%b", 42), compiler-dependent availability).
  • Portable approach — bit by bit, most significant first:
    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
        putchar ((x >> i) & 1 ? '1' : '0');
    
  • For a float: can’t meaningfully treat it as an integer bit pattern via a raw pointer cast (violates strict aliasing → UB). Use memcpy to safely reinterpret the bytes into an unsigned integer of the same size, then run the same bit-printing loop on that.

sizeof(x) * CHAR_BIT - 1 explained

  • sizeof(x) = size in bytes. CHAR_BIT (from <limits.h>) = bits per byte on this platform (portable stand-in for “8” — not guaranteed by the standard to always be 8, though it almost always is).
  • sizeof(x) * CHAR_BIT = total bit count (e.g. 32 for a 4-byte int).
  • - 1 converts that count into the highest valid index — identical off-by-one logic to array indexing (length items → valid indices run 0 to length−1). Shifting by an amount equal to or exceeding the type’s width (e.g. x >> 32 on a 32-bit type) is itself undefined behavior.

putchar vs printf for single characters

  • putchar(int c) writes exactly one character — no format-string parsing, no variadic argument handling.
  • printf("%c", c) works too, but pays for format-string parsing and variadic dispatch machinery it doesn’t need for a single fixed character, on every one of the 32/64 loop iterations.
  • General idiom: use the narrowest function that does exactly the job; reach for printf when you actually need its formatting features (multiple values, padding, grouping, etc.).

Bit vectors representing sets

  • Encode A ⊆ {0, ..., w−1} as bits [a_{w−1}, ..., a_1, a_0], where a_i = 1 iff i ∈ A.
  • By convention: rightmost bit is a_0, positions increase going left — same convention as ordinary place-value numbers (rightmost digit = lowest place value).
  • Example: a = 01101001 → positions with 1: 0, 3, 5, 6 → A = {0, 3, 5, 6}.
  • This is the same operation as (x >> i) & 1 from the binary-printing loop — “is bit i set” is exactly “is element i in the set.”

Bit-vector notation is independent of endianness

  • Endianness = byte order in memory for a multi-byte value — a hardware/memory-layout question.
  • Bit-vector position numbering = a notational/mathematical convention for reading one value’s bits on paper — has nothing to do with memory addresses.
  • A byte is the atomic unit for endianness (no internal byte order to reorder); bit-position-0-is-rightmost is a fixed, universal convention applying uniformly regardless of how a value happens to be stored.

Bitmasking — direct application of bit vectors

flags = flags | mask;    // OR: set a bit to 1
flags = flags & ~mask;   // AND-NOT: clear a bit to 0
flags = flags ^ mask;    // XOR: toggle a bit
if (flags & mask) ...    // AND: test a bit
  • Set union = a | b; set intersection = a & b, applied to two bit vectors — a single machine instruction instead of iterating a data structure.
  • Masks are conventionally written in hex (0x01, 0x02, 0x04, …), each isolating exactly one bit position, and combined with named constants (FLAG_READ | FLAG_WRITE) rather than raw literals.

Do professional C programmers convert binary/hex in their heads?

  • Almost never as live arithmetic. What they have is pattern recognition for a handful of very common values (0x0F, 0xFF, single-bit masks, small hex digits) — recognition, not computation.
  • In practice: reach for a calculator/shell (printf "%x\n" 233), reason via named constants/masks rather than raw bit patterns, or use debugger tooling (gdb’s p/x, p/t).
  • The actual valuable skill is structural intuition — understanding why a byte factors into two hex digits, why bit 0 is the low end, why endianness affects bytes but not bits, what each bitwise operator does — not raw conversion speed.

XOR swap (inplace_swap)

void inplace_swap (int *x, int *y)
{
  *y = *x ^ *y;   /* Step 1 */
  *x = *x ^ *y;   /* Step 2 */
  *y = *x ^ *y;   /* Step 3 */
}
  • *x/*y dereference the pointers — read/write the caller’s actual variables, not local copies.
  • Works via XOR’s algebra: X ^ X = 0, X ^ 0 = X, and XOR is its own inverse ((A ^ B) ^ B = A). Step 1 combines both originals into *y; steps 2–3 successively extract each original back out.
  • Swaps values with no temporary variable, by mutating the caller’s memory directly through the pointers — same mechanism as remove_suffix’s *np = '\0'.

& and * as inverses — tying it together

  • &x — “address of x” (value → address).
  • *p — “value at this address” (address → value).
  • Caller producing a location to write into (scanf("%d", &x), inplace_swap(&a, &b)) uses &.
  • Callee (or scanf internally) that received a pointer uses * to actually read/write the thing it points to.
  • scanf("%d", &x) writes directly into x’s address — same as writing into x itself; an address isn’t a separate location “about” a variable, it is where the variable lives.

Double pointers: **x

int value = 42;
int *p = &value;    // pointer to int
int **pp = &p;        // pointer to pointer to int
  • *ppp’s content → value’s address (still a pointer).
  • **pp → dereference again → 42.
  • Each * peels back exactly one layer of indirection — same single rule, applied repeatedly.
  • Practical use: a function needs to reassign the caller’s pointer itself (not just what it currently points to) — e.g. handing back freshly malloc’d memory:
    void allocate (int **out)
    {
      *out = malloc (sizeof (int));
      **out = 42;
    }
    
  • argv in main(int argc, char **argv) is a real-world example: pointer to an array of char * strings.

Bit-printing order vs. bit numbering — resolving the apparent contradiction

  • Bit position 0 = rightmost = least significant — a fixed labeling convention, unrelated to print order.
  • The bit-printing loop (i = 31 down to 0) prints highest index first (leftmost) — matching how ordinary place-value numbers are written (345: hundreds digit first, on the left), not “position 0 first.”
  • These are two different axes: which position holds which index (fixed convention) vs. the order you emit them in for display (chosen to match human place-value reading habits, high-to-low).
  • Contrast with array printing (my_array[0] printed first, leftmost) — arrays and binary numbers deliberately use opposite print-order conventions relative to index; mixing the two up is an easy, natural trap.

Why leading zeros appear where they do (e.g. int x = 0x45)

  • A fixed-width container (32-bit int) always has exactly 32 bit positions regardless of the value stored — most of them may legitimately be 0 for a small value.
  • These aren’t padding tacked onto the end of “the real number” — the high-order (leftmost) positions genuinely represent large powers of 2 that this particular value doesn’t need, exactly like writing 345 as zero-padded 00000345: the leading zeros are the genuinely-zero high place-value digits, and the meaningful digits occupy the low place-value positions, which print last (rightmost) — same rule, no exception, just often invisible in unpadded decimal.
  • Bit-printing shows the entire container’s contents — every physical bit position — not an abstracted “value” the way printf("%d", x) does. The container itself has no concept of “where the meaningful part starts” — that’s purely a human interpretation layered on top via the place-value formula.

Inspect Assembly Code

C Code

#include <stdio.h>

int is_equal(int x, int y) { return !(x & ~y); }

int main(void) {
  int x1 = 0x45;
  int x2 = 0x55;
  int y1 = 0x45;
  int y2 = 0x66;

  /* test with == first */
  if (x1 == y1) {
    printf("TRUE: %#010x\n", x1 == y1);
  }
  printf("is_equal: %#010x\n", is_equal(x1, y1));
  printf("is_equal: %#010x\n", is_equal(x2, y2));
  return 0;
}

Assembly

	.file	"isequal.c"
	.text
	.globl	is_equal
	.type	is_equal, @function
is_equal:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	%edi, -4(%rbp)
	movl	%esi, -8(%rbp)
	movl	-8(%rbp), %eax
	notl	%eax
	andl	-4(%rbp), %eax
	testl	%eax, %eax
	sete	%al
	movzbl	%al, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	is_equal, .-is_equal
	.section	.rodata
.LC0:
	.string	"TRUE: %#010x\n"
.LC1:
	.string	"is_equal: %#010x\n"
	.text
	.globl	main
	.type	main, @function
main:
.LFB1:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp
	movl	$69, -4(%rbp)
	movl	$85, -8(%rbp)
	movl	$69, -12(%rbp)
	movl	$102, -16(%rbp)
	movl	-4(%rbp), %eax
	cmpl	-12(%rbp), %eax
	jne	.L4
	movl	-4(%rbp), %eax
	cmpl	-12(%rbp), %eax
	sete	%al
	movzbl	%al, %eax
	movl	%eax, %esi
	leaq	.LC0(%rip), %rax
	movq	%rax, %rdi
	movl	$0, %eax
	call	printf@PLT
.L4:
	movl	-12(%rbp), %edx
	movl	-4(%rbp), %eax
	movl	%edx, %esi
	movl	%eax, %edi
	call	is_equal
	movl	%eax, %esi
	leaq	.LC1(%rip), %rax
	movq	%rax, %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	-16(%rbp), %edx
	movl	-8(%rbp), %eax
	movl	%edx, %esi
	movl	%eax, %edi
	call	is_equal
	movl	%eax, %esi
	leaq	.LC1(%rip), %rax
	movq	%rax, %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE1:
	.size	main, .-main
	.ident	"GCC: (Debian 14.2.0-19) 14.2.0"
	.section	.note.GNU-stack,"",@progbits