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:
-gembeds debug information (source file names, line numbers, variable info) into the binary — this is what letsobjdump -Sinterleave your original C source alongside the assembly.-O0disables 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.-O0gives 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
-ddisassembles executable sections.-Sinterleaves original source lines (requires-gat 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.%rbpconventionally 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 oneint 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 variablex— 4 bytes below the frame base. This isx’s actual address in memory for the lifetime of this function call — the same kind of address you’ve been taking with&xat the source level.$0x2dis45in hex (0x2d = 45).movl(“move long word”) writes this 4-byte immediate value directly into that memory location — this one instruction isx = 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— loadsx’s value (45) from memory back into the%eaxregister. (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-O2optimization would eliminate; at-O0the compiler doesn’t bother.)mov %eax,%esi— movesx’s value into%esi, which is the register for the second argument toprintf. (printf’s arguments are the format string, thenx— soxis argument #2.)lea 0xe27(%rip),%rax—lea= “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>isobjdumphelpfully 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%eaxto0. This looks unrelated to your code, but it’s part of the x86-64 calling convention specifically for variadic functions likeprintf:%eaxmust hold the number of vector/floating-point registers used for the call (0here, 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 becauseprintflives in the dynamically-linked C library (libc), not in this binary itself; the PLT resolves the real address ofprintfat load time (or on first call).
print_binary((unsigned int) x);
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
xfrom 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-byteintas a 4-byteunsigned intinvolves 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@plt—print_binaryis 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 howmain’sreturn 0becomes the process’s exit status.leave— a shorthand instruction that undoes the prologue in one step: restores%rspfrom%rbp(deallocating the stack frame) and pops the saved%rbpback (undoing the very firstpush %rbp). This is the function epilogue.ret— pops the return address (pushed automatically by thecallinstruction 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)—xarrived in%ediper the calling convention (it’s the argument register). This instruction immediately copies it out intox’s own stack slot, at-0x14(%rbp). This is standard-O0behavior: 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 isi = sizeof(x) * CHAR_BIT - 1, fully computed at compile time.0x1f=31decimal. The compiler already knowssizeof(unsigned int)is 4 andCHAR_BITis 8, so4 * 8 - 1 = 31— no runtime multiplication ever happens; the compiler folded the whole expression into a single constant.ilives at-0x4(%rbp), a separate stack slot fromx’s-0x14(%rbp).jmp 11ab— jumps straight down to the loop’s condition check (address0x11ab, shown further below), skipping the loop body on this first pass through. This is a very characteristic pattern for compiledforloops: 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 yourfor(init; cond; incr) bodyinto 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— loadiinto%eax.mov -0x14(%rbp),%edx— loadxinto%edx.mov %eax,%ecx— copyiinto%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%clregister (the low byte of%ecx) — there’s no shift-by-any-register form. The compiler is forced to shuffleiinto%ecxpurely to satisfy this instruction encoding rule.shr %cl,%edx— the actual shift!shr= shift right, logical (zero-fill) — matchesunsigned int x, confirming exactly the logical-vs-arithmetic distinction from your earlier question: becausexisunsigned, the compiler emitsshr, notsar. This computesx >> 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%eaxwith 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 lasttestfound zero) — jump down to the'0'branch. This is the compiled form of your ternary’sfalsepath.mov $0x31,%eax/jmp 11a0—0x31is ASCII'1'(decimal 49). This is the ternary’struebranch: load the character'1', then jump past the other branch.mov $0x30,%eax(at label119b) —0x30is ASCII'0'(decimal 48) — thefalsebranch’s value.mov %eax,%edi/call 1060 <putchar@plt>— same calling convention pattern as before: put the argument (whichever character was selected) into%edi, callputcharthrough the PLT (dynamically linked, likeprintf).
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 fromi’s stack slot).cmpl $0x0,-0x4(%rbp)— comparesiagainst0(sets flags based oni - 0, without storing the subtraction result anywhere — same “compute flags only” idea astestabove).jns 1181— “jump if not sign” — i.e., jump back up to the loop body (address0x1181) if the comparison result was non-negative, meaningi >= 0still holds. This single instruction is the entire compiled form of youri >= 0loop condition. Onceibecomes-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,%edi—0x0ais 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>— sameputcharcall 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 asmain.
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 - 1never executes as a multiplication and subtraction at runtime — it’s baked into a singlemovl $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 formain(in this binary) to callprintf(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(notsar) appears becausexis declaredunsigned— direct, physical confirmation of the logical-vs-arithmetic shift discussion: changeunsigned int xtoint xin the function signature and recompile, and you’d seesaremitted instead for the exact same source-level>>operator.-O0is verbose and redundant on purpose — notice how often a value gets stored to memory and immediately reloaded (e.g.xis 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-O0is the right choice for a tutorial like this one, and-O2is 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
| Op | Name | Rule (per bit pair a,b) |
|---|---|---|
& | AND | 1 only if both are 1 |
| | OR | 1 if either is 1 |
^ | XOR | 1 if they differ |
~ | NOT | flips the single bit |
<< | left shift | slides bits toward higher value (×2 per shift) |
>> | right shift | slides 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:
| Op | mask bit = 1 means… | mask bit = 0 means… |
|---|---|---|
x & mask | keep that bit of x unchanged | force to 0 |
x | mask | force to 1 | keep that bit of x unchanged |
x ^ mask | flip that bit of x | keep that bit of x unchanged |
x & ~mask | force to 0 | keep 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 isolate | Mask |
|---|---|
| last byte | 0xFF |
| last 2 bytes | 0xFFFF |
| last nibble (4 bits) | 0xF |
bit i only | 1 << 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 with0s on the left. Well-defined, no ambiguity. - Right shift on signed, negative
x: implementation-defined — in practice almost universally arithmetic (sign-extends, fills with1s) 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 << 32on a 32-bitint). 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 asx & (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, return0/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
intis implementation-defined, not guaranteed zero-fill. Cast tounsignedfirst if you need guaranteed logical-shift behavior. - Mask width mismatches:
x & 0xFFon a 64-bitxstill 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):
| Expression | Result | Why |
|---|---|---|
x & 0xFF | 0x00000021 | keep last byte, zero rest |
x | 0xFF | 0x876543FF | force last byte to all 1s |
x ^ 0xFF | 0x876543DE | flip only the last byte |
~x | 0x789ABCDE | flip everything |
~x ^ 0xFF | 0x789ABC21 | flip everything, then un-flip last byte |
x << 4 | 0x76543210 | shift left 4, drop top nibble, zero-fill right |
x >> 4 (unsigned) | 0x08765432 | shift 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).
npstarts one past the last character ofname(at the'\0').spstarts one past the last character ofsuffix.- The
whileloop walks both pointers backward, comparing characters from the end inward.*--npdecrements first, then dereferences. - If characters mismatch,
returnimmediately — no truncation. - If the loop exits because
spran out first (np > namestill true), the suffix was fully matched with room to spare → truncate atnp. - If
npalso reachedname(equal, not greater), the whole string was consumed matching the suffix →nameis the suffix → don’t touch it.
name is char *, suffix is char const * — why?
nameis written to (*np = '\0'), so it can’t beconst.suffixis only ever read, so marking itconstis a promise not to modify it — lets you pass string literals or existingconst char *values in without complaint.
What static means here
- On a function: internal linkage — only visible/callable within this
.cfile (this translation unit). Not part of the file’s public API, and lets the compiler optimize more aggressively (e.g. inlining). - (Different from
staticon 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 byN * sizeof(pointee_type), not raw bytes — except forchar*, wheresizeof(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
nameyet, 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'(whatstrlendoes). 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?
--npis pre-decrement:np = np - 1, moving back one element (onechar= 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 forchar*, typically 4 forint*, 8 fordouble*. 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,spreachessuffix(exhausted) whilenpis still at index 4,> name(index 0) → suffix fully matched,namehas more before it → truncate →"foo."."tmp"vs suffix"tmp": after 3 matches, bothnpandspreach their respective starts simultaneously →np == name, not>→ the whole string was consumed by the match → leave untouched.- General rule:
np > nameafter the loop means the suffix ran out first (real truncatable match);np == namemeansnameitself was fully consumed (nothing to truncate).
Recommended reading in Computer Systems: A Programmer’s Perspective
- 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
charis 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) —strlenreturns 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_tfor 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 inbasename.c— it comes in transitively viasystem.h→dirname.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(notbasename()) exists because the real POSIX/glibcbasename()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 insrc/, checklib/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
bearintercepts 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 —
./configureandmakebuild binaries only inside the source tree itself. Onlysudo make installwould copy anything to/usr/local/binand affect what runs on the actual$PATH. Not needed just to read source and generatecompile_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.ccontaining onlybase_nameisn’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’susage(),remove_suffix(),perform_basename(),main()) → bundled together. .hfiles declare (what exists + its signature);.cfiles define (the actual implementation). Callers only need the cheap.hdeclaration; 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.:jumpsshows the full list. - Splits:
:LspDefinitioncan be made to open in a split explicitly:
Navigate between splits withnnoremap gD :vsp<CR>:LspDefinition<CR>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
propernamemodule, used to localize author names in--versionoutput (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 —staticproduces lowercaset, i.e. internal linkage).U= undefined here, needs to be supplied by another object file. - Linking: the linker matches
Uentries in one file againstT/tentries 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
intelement) — not the order of elements relative to each other. charis 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
printfis variadic — the compiler can’t type-check variadic arguments against the format string the way it does normal parameters.%pspecifically expectsvoid *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
intvs. one raw byte) and how pointer arithmetic scales (+1jumps 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, whereiis 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 with256^iis 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^ion a float’s raw bytes gives a meaningless number.
Why 256^i (and not something else)
- Same logic as decimal place value: a base-
bpositional system usesb^ifor positioni, because each “digit” can hold exactlybdistinct 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 is256^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(here2^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, note), exact representation, no decimal rounding error. - Different from a raw byte dump:
%ashows the interpreted mantissa/exponent structure;(unsigned char*)&fbyte-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 anint. - The
Σ byte_i × 256^itrick 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_tis unsigned; comparing signedintto 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_twith an explicit cast at the comparison, not castinglendown. - GNU/gnulib code (per
idx_tinremove_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_tcan hold values far larger thanintcan represent. IflenexceedsINT_MAX,(int) lenoverflows — 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_tfor the loop variable) rather than narrowing the wider one down.
Printing numbers in binary
- No portable
%bbefore 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
memcpyto 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).- 1converts that count into the highest valid index — identical off-by-one logic to array indexing (lengthitems → valid indices run0tolength−1). Shifting by an amount equal to or exceeding the type’s width (e.g.x >> 32on 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
printfwhen 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], wherea_i = 1iffi ∈ 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 with1: 0, 3, 5, 6 →A = {0, 3, 5, 6}. - This is the same operation as
(x >> i) & 1from the binary-printing loop — “is bitiset” is exactly “is elementiin 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’sp/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/*ydereference 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 ofx” (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
scanfinternally) that received a pointer uses*to actually read/write the thing it points to. scanf("%d", &x)writes directly intox’s address — same as writing intoxitself; 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
*pp→p’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; } argvinmain(int argc, char **argv)is a real-world example: pointer to an array ofchar *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 = 31down to0) 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 be0for 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
345as zero-padded00000345: 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