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

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.