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.