C Language
C as the language of kernels, HALs, and Android native code; interview depth on memory, pointers, UB, and the compilation model.
- C is close to the machine: you own layout, lifetime, and every byte. That is why kernels, libc/bionic, HALs and firmware are still written in it.
- A program is translation units compiled to object files, then linked. Headers declare; sources define. Mixing that up is how you get duplicate or missing symbols.
- Integer promotions, usual arithmetic conversions, signed overflow (UB) vs unsigned wrap, and
charsignedness are the classic trap cluster. - Pointers are addresses plus a type. Arrays decay.
void*, function pointers,const,restrictand pointer-to-pointer are everyday interview material. - Undefined behaviour is not "a crash": the compiler may assume it never happens and delete your checks. Data races are UB.
volatileis not a lock. - On Android, native code is NDK + bionic + JNI. The Java side of JNI lives on the Java page; kernel C lives on Linux kernel & BSP.
Why C still matters
C is the language you use when the next layer down is hardware or an ABI that must stay stable for decades. The Linux kernel, bootloaders, libc (glibc on GNU/Linux, bionic on Android), vendor HALs, radio firmware, and most NDK libraries are C or a thin C-callable shell around C++. Interviews for platform, BSP, modem and native Android roles treat C as assumed fluency, not as a "nice to have".
Higher-level languages are like booking a hotel: someone else owns the building, the plumbing and the fire exits. C is like being handed the keys to the plant room. You can run a tighter, cheaper building, but if you leave a valve open (a dangling pointer) or mix two incompatible pipe sizes (wrong type punning), the whole floor floods. The kernel, HAL and firmware are the plant rooms of a phone; that is why they are still C.
App (Java / Kotlin) see java.html
| Binder / JNI
Framework + native daemons see android-frameworks.html, binder-aidl.html
| C ABI / HIDL / AIDL / ioctl
Vendor HAL (often C or C++)
| syscall / ioctl
Linux kernel (C) see linux-kernel-bsp.html
| MMIO / IRQ / DMA
Firmware, bootloader, libc C, sometimes assembly
Kernel
The kernel is C by policy: a small, predictable language with a stable ABI to assembly and a culture of explicit lifetime. No libc, no exceptions, small stacks. Details belong on the kernel & BSP page; this page covers the language those files are written in.
libc / bionic
Every malloc, open, pthread_create and printf is a C function in the C library. Android uses bionic, not glibc: smaller, stricter, missing some POSIX, plus fdsan and a different dynamic linker.
HALs
Treble HALs started as C structs of function pointers (hw_module_t / hw_device_t). Newer ones are AIDL (often C++ NDK), but the wire format and many vendor libraries are still C. See Binder & AIDL.
Firmware
Boot ROM, first-stage loaders, PMIC, modem and DSP images are almost always C plus assembly. No OS, no heap you can trust, and every byte of RAM is accounted for.
What an interviewer is really testing
They are not testing whether you remember the exact wording of the standard. They are testing whether you can ship a HAL or a native daemon without leaking, racing, overflowing, or invoking UB that only appears at -O2 on the device. C++ adds RAII and the STL (see C++); algorithms and Big-O live on the DSA page. Here the subject is the C abstract machine and how it maps onto Linux and Android.
.c file?". A strong answer names ABI stability, predictable codegen, no hidden allocations or exceptions, and the existing corpus. Then you should be able to walk preprocess → compile → assemble → link without waving your hands.Compilation model
A C program is not "the source files". It is one or more translation units that the compiler turns into object files, which the linker combines with libraries into an executable or a shared object. Interviews fail people who treat #include as "copy the implementation" or who cannot explain why a function in a header became a multiple-definition error.
Think of a book assembled at a print shop. Each chapter (a .c file plus the headers it includes) is typeset on its own (compile) into a printed signature (the .o). The bindery (the linker) collates signatures, pulls in stock pages from other books (static archives or shared libraries), and stamps page numbers so cross-references resolve (relocations). A header is the table of contents and the chapter titles, not the chapter text. If two chapters both contain the full text of the same appendix, the bindery complains: multiple definition.
The four stages
- Preprocess
cpp/clang -Eexpands#include,#define,#ifdef. The output is still C text, one translation unit. Line markers remember the original file for diagnostics. - Compile The compiler front end parses that text, type-checks it, and the back end emits assembly for the target (
aarch64,x86_64, …). - Assemble
asturns assembly into an object file: machine code, a symbol table, and relocation records. Nothing has been "wired up" to other files yet. - Link The linker assigns final addresses, patches relocations, pulls in
.amembers or records DT_NEEDED for.sofiles, and writes the ELF (or PE) image.
# See each stage (names vary; the idea does not)
clang -E foo.c -o foo.i # preprocessed C
clang -S foo.c -o foo.s # assembly
clang -c foo.c -o foo.o # object file
clang foo.o bar.o -o prog # link
clang -shared -fPIC foo.o -o libfoo.so
| Artifact | What it is | Interview phrase |
|---|---|---|
| Translation unit | One .c after preprocessing (includes expanded) | The compiler never sees your other .c files |
Object file (.o) | Code + data + unresolved symbols | Relocations wait for the linker |
Static library (.a) | Archive of .o files | Linker copies only the members it needs |
Shared library (.so) | Separately loaded image, position-independent | One copy in memory; resolved at load / first call |
Header (.h) | Declarations, macros, static inline | Must be safe to include from many units |
Headers vs sources
A header is included textually. Anything with external linkage that you define in a header (a non-inline function body, a non-const object) is copied into every unit that includes it, and the linker then sees many definitions. Put declarations in headers and definitions in exactly one .c, unless you use static (internal linkage) or C99 inline with a single external definition.
Belongs in a header
- Function prototypes
struct/enum/typedeftypesexterndata declarations- Macros and
static inlinehelpers - Include guards
Belongs in a source
- Function bodies with external linkage
- The one definition of a global
- File-scope
statichelpers and tables - The
inlinefunction's external copy
int g_count; in a header and including it from two .c files. With tentative-definition rules this used to "work" on some toolchains and then break under -fno-common (the default on modern Clang/GCC). Declare extern int g_count; in the header and define int g_count; in one .c.-c stops after the object file. Incremental builds and static libraries are just collections of those objects. Shared libraries need -fPIC so the code can run at any load address (GOT/PLT; see the Android / tooling questions).Types, promotions and conversions
C's integer rules are older than most of the codebases you will work in, and they still surprise people. Almost every "why did this comparison go wrong?" or "why is this shift UB?" question bottoms out in integer promotions, usual arithmetic conversions, signedness, and whether overflow is defined.
Imagine a shop that only has two sizes of measuring jug: a small cup (narrow types: char, short) and a litre jug (int, or unsigned int if the cup cannot fit). Before mixing two liquids (a binary operator), the shop pours every cup into a litre jug (integer promotions). If the two litre jugs are still different kinds (signed vs unsigned, or different ranks), it pours both into a common jug (usual arithmetic conversions). The surprise is that a "small unsigned cup" often becomes a signed litre, and a signed litre mixed with an unsigned litre becomes unsigned — so a negative number suddenly looks huge.
Integer promotions
In most expression contexts, types narrower than int (char, signed char, unsigned char, short, bit-fields, enums of small range) are promoted: if int can represent every value of the original type, the result is int; otherwise unsigned int. This happens to operands of arithmetic, to function arguments without a prototype (old style), and to the integer operand of some operators. It does not happen when you store into an object — assignment converts to the target type.
unsigned char a = 1, b = 2;
int s = a - b; /* promotions to int: result is -1, not 255 */
unsigned char u = a - b; /* then convert: implementation-defined / wrap to 255 */
if (a - b > 0) { } /* -1 > 0 is false — a common loop/unsigned-char bug */
Usual arithmetic conversions
For a binary operator, after promotions, C picks a common real type. Roughly: if either side is floating, both become that floating type; else both become the type with the higher conversion rank; if ranks are equal but signedness differs, a signed type converts to the unsigned type of the same rank. That last rule is why if (len < 0) is nonsense when len is size_t, and why -1 < (size_t)1 is false: -1 becomes a huge unsigned value.
| Type | Role in interviews |
|---|---|
size_t | Unsigned size of objects; return of sizeof, strlen, malloc argument. Never mix casually with signed loop indexes. |
ptrdiff_t | Signed difference of two pointers in the same array. |
intptr_t / uintptr_t | Integer that can hold a pointer. Needed for tagged pointers, hashing addresses, and some JNI / Binder handles. Not a licence to do arithmetic and cast back without care. |
int32_t / uint32_t | Fixed width from <stdint.h>. Prefer these at ABI and protocol boundaries, not "plain int". |
int_fast32_t etc. | Fastest type with at least N bits. Rarely what you want for an ABI. |
Signed overflow vs unsigned wrap
Unsigned integers wrap modulo 2n. That is defined. Signed overflow (and signed division overflow, and shifting into or past the sign bit in ways the standard forbids) is undefined behaviour. Compilers exploit "signed overflow never happens" to delete bounds checks and to rewrite loops. -fwrapv makes signed overflow wrap, but that is a dialect, not portable C, and kernel/Android code should not depend on it unless the build actually passes the flag.
int n = INT_MAX;
n = n + 1; /* UB. Do not "know" it becomes INT_MIN. */
unsigned u = UINT_MAX;
u = u + 1u; /* defined: 0 */
size_t bytes = (size_t)count * elem; /* may wrap; then malloc(bytes) is too small */
char signedness
Plain char is either signed or unsigned; the implementation chooses. On many ARM Android ABIs it is unsigned; on x86 it is often signed. If you write char c = getbyte(); if (c == 0xFF) you may be comparing -1 to 255. For bytes, use unsigned char or uint8_t. For text, be explicit. This is a favourite "works on my laptop, fails on the phone" question.
unsigned char subtraction, or size_t compared with -1, on the whiteboard and ask what prints. Narrate promotions first, then usual arithmetic conversions, then the comparison. Mention that signed overflow is UB, not two's complement wrap, unless they have specified the flag or the platform dialect.Memory: stack, heap, data, bss, text
A running C program (userspace) has a virtual address space the OS backs with pages. Interviewers want the segments you reason about, not a full MMU lecture (that is on Linux kernel & BSP).
A process is a workshop. The text segment is the laminated instruction posters on the wall (code, usually read-only and shareable). rodata is the printed lookup tables. data is the labelled drawers that already have tools in them (initialised globals). bss is the empty drawers reserved with a label but not filled until you open the shop (zeroed at load). The stack is the workbench for the job you are doing right now: frames appear and vanish as functions are called. The heap is the storeroom where you request shelves (malloc) and must return them (free) or the storeroom fills up.
high addresses argv / env stack (grows down) locals, return addresses, spilled args ... mmap / shared libs heap (grows up) malloc / new bss uninitialised / zero globals data initialised globals rodata string literals, const text machine code low addresses
| Region | Lifetime | Who frees | Typical bugs |
|---|---|---|---|
| Text / rodata | Process | OS on exit | Writing to a string literal (UB) |
| Data / bss | Process | OS on exit | Unsynchronised global mutation; init order |
| Stack | Function activation | Automatic | Returning a pointer to a local; overflow; huge VLAs |
| Heap | Until free | You | Leak, double-free, use-after-free, wrong size |
malloc, calloc, realloc, free
malloc(n)— n bytes, alignment suitable for any standard type (at leastmax_align_t). Contents indeterminate.malloc(0)is implementation-defined (NULL or a unique pointer you must still free).calloc(nmemb, size)— zeroed; watch overflow innmemb * size.realloc(p, n)— may extend in place or move. On failure,pis still valid; the classic leak isp = realloc(p, n)when the result is NULL.realloc(NULL, n)ismalloc(n).realloc(p, 0)is a mess across C versions and libraries — avoid it.free(p)—free(NULL)is a no-op. Anything else twice, or a pointer not from this allocator, is UB.
void *p = malloc(n);
if (!p) return -ENOMEM;
void *q = realloc(p, n2);
if (!q) { free(p); return -ENOMEM; } /* p still valid on failure */
p = q;
Alignment, padding, endian
The CPU and the ABI require that a type sit at an address divisible by its alignment (often its size). The compiler inserts padding inside structs so every member is aligned, and often trailing padding so an array of the struct stays aligned. offsetof and sizeof tell the truth; guessing does not.
Endianness is the byte order of a multi-byte integer in memory. Little-endian (ARM Android, x86) stores the least significant byte at the lowest address. Big-endian is the opposite. The host endian is not the wire endian. Protocols and filesystems specify one; you convert with explicit shifts or htole32-style helpers, not by taking the address of an int.
Pointer vs array
An array is a sequence of objects. In most expressions, an array decays to a pointer to its first element. Exceptions: sizeof, _Alignof (C11), address-of &arr (type is pointer-to-array), and string-literal initialisation of an array. A function parameter void f(int a[10]) is a lie: it is int *a. sizeof a inside f is the size of a pointer.
int a[3] = {1, 2, 3};
int *p = a; /* decay */
size_t n = sizeof a; /* 3 * sizeof(int) */
size_t m = sizeof p; /* sizeof(int *) */
void f(int b[3]) {
/* sizeof b == sizeof(int *), always */
}
sizeof to compute a count. Keep a length next to every pointer that is not a NUL-terminated string you truly treat as a string.Pointers in depth
A pointer value is an address plus a type that says how to load, store and step. Almost all hard C bugs are pointer bugs: wrong type, wrong lifetime, wrong provenance, or arithmetic that walks off the object.
A pointer is a labelled envelope with a street address. The label (the type) says what kind of building you expect and how many house-numbers you skip when you "go to the next one" (pointer arithmetic is in units of the pointee, not bytes — except char *). void * is an envelope with the address but no building type: you can deliver it, but you cannot open it until you stamp a type. A function pointer is an envelope that holds the address of a procedure, not a house. const is a "do not rearrange the furniture" sticker. restrict is a promise that this envelope is the only one you will use to enter that house during the operation, so the compiler can skip "did someone else move the sofa?" checks.
Pointer arithmetic
If p points to an element of an array (or one-past-the-last), p + k moves by k * sizeof(*p) bytes. Arithmetic on pointers that do not belong to the same array object is UB. One-past-the-end is a valid pointer value but must not be dereferenced. NULL arithmetic is UB. Subtracting two pointers yields ptrdiff_t and is only defined within the same array.
Because a[i] is defined as *((a) + (i)), i[a] is the same expression. That is a trivia question; the useful point is that the array and the index are symmetric in the abstract machine.
void*
void * is the generic data pointer. You may convert to and from any object pointer without a cast in C (C++ requires a cast; see C++). You may not dereference it or do pointer arithmetic on it (GCC has a byte-sized void * extension; do not use it in interview-portable code). Function pointers are not safely portable through void * in standard C, though POSIX and Android practice often do it; the honest answer is "object pointers yes, function pointers only with a platform ABI".
Function pointers
The type includes the return type and parameter types. Use a typedef so callbacks stay readable. HALs, qsort, pthread start routines and JNI native method tables are function-pointer tables.
typedef int (*cmp_fn)(const void *a, const void *b);
void qsort(void *base, size_t n, size_t size, cmp_fn cmp);
int start(void *ctx);
pthread_t t;
pthread_create(&t, NULL, start, ctx); /* start must match the ABI */
const correctness
| Declaration | What is const |
|---|---|
const T *p / T const *p | The T objects. You may retarget p. |
T * const p | The pointer. You may write *p but not p = …. |
const T * const p | Both. |
Casting away const and then writing is UB if the object was defined const (string literals, const globals). It is a safety hole even when the language does not catch it. APIs that only read should take const T * so callers can pass const data and so the compiler can help.
restrict
C99 restrict on a pointer means: for the lifetime of that pointer, every access to the object it points to will go through a pointer based on it (or through a pointer to a const-qualified-compatible type in some cases — the standard wording is fussy). memcpy is restrict; memmove is not. If you pass overlapping buffers to a restrict function, that is UB and the vectoriser may produce a wrong result. Interview translation: "I promise these buffers do not alias."
Pointer to pointer
You need T ** when a function must change the caller's pointer (allocate and hand back, or walk a list by rewriting head), or when you have an array of pointers (argv, JNI jobjectArray internals, a table of strings). void ** is not a generic "pointer to any pointer" in a type-safe way; it is a pointer to void *.
int alloc_buf(uint8_t **out, size_t n)
{
uint8_t *p = malloc(n);
if (!p) return -1;
*out = p; /* rewrite caller's pointer */
return 0;
}
int *(*fp)(void) is "fp is a pointer to a function returning pointer to int". Prefer typedefs over impressing anyone.Strings and buffers
A C string is a contiguous sequence of char ending in a '\0'. There is no stored length. A buffer is storage that may or may not contain a string. Interviews hammer the gap between length (how many payload bytes you mean), capacity (how many bytes the allocation can hold, including the NUL if it is a string), and size of the pointer (useless).
A C string is a freight train with a red caboose (the NUL). The railway does not write down how many cars there are; you walk the train until you see the caboose (strlen). Capacity is the length of the siding you parked on. If you add cars without leaving room for the caboose, the next train (the next stack frame or heap chunk) gets hit. snprintf is a dispatcher that refuses to send more cars than the siding, and still tries to attach a caboose if there is room for even one byte.
| Function | NUL-aware? | Bounds? | Notes |
|---|---|---|---|
strlen | Yes | No | Reads until NUL; UB if missing |
strcpy | Yes | No | Do not use on untrusted or unknown sizes |
strncpy | Partial | Count | May omit NUL; pads with zeros — usually the wrong tool |
strcat | Yes | No | Must already contain a string; easy overflow |
snprintf | Yes | Yes | Always NUL-terminates if size > 0; return is the untruncated length |
memcpy | No | You pass n | Raw bytes; restrict; no overlap |
memmove | No | You pass n | Overlap-safe |
memcmp | No | You pass n | Not a string compare; use strcmp / strncmp for text |
snprintf is the default formatting tool
char buf[32];
int n = snprintf(buf, sizeof buf, "%s:%d", name, port);
if (n < 0) { /* encoding / output error */ }
if ((size_t)n >= sizeof buf) { /* truncated; buf still terminated */ }
The return value is the number of characters that would have been written, excluding the NUL — or a negative error. That is how you detect truncation and how you size a second buffer. sprintf has no bound. strncpy is not "safe strcpy".
Common bugs
- Forgetting the NUL when you build a string with
memcpy. - Using
sizeof(ptr)as a buffer size. - Off-by-one: looping
i <= non a buffer ofnbytes. - Assuming
charis unsigned when scanning bytes. - Modifying a string literal (
char *s = "hi"; s[0] = 'H';) — UB; usechar s[] = "hi";. gets— removed; if you see it, reject the code.
strlen, strcpy, memcpy, reverse a string in place, remove duplicates, or parse a path. They want NUL handling, restrict / overlap awareness, and an ownership story, not a clever algorithm (those are on DSA).Structs, unions, bitfields, flexible arrays, packed layouts
These types are how C describes memory that more than one field shares or occupies. HAL ioctls, Binder parcels (at a lower level), file headers and register maps are all "a struct plus rules about padding".
A struct is a row of labelled lockers in a fixed order, with empty lockers (padding) inserted so each real locker starts on a tidy boundary. A union is one locker that several labels claim: only one tenant at a time, same street address. A bitfield is taping paper dividers inside a locker to store flags. A flexible array member is a locker at the end that is "as long as the annex you built when you allocated the building". Packed is ripping out the empty lockers so the row is shorter — cheaper to ship, but people now trip on the uneven floor (unaligned access).
Structs and padding
Members are laid out in order. The compiler may add padding between members and at the end. Reordering members can shrink the struct. There is no portable "insert a field in the middle and keep the old ABI" without a versioning plan. Do not memcmp two structs for equality if they have padding — padding bytes can be garbage.
struct item {
uint8_t id; /* offset 0 */
/* 3 bytes padding on a 4-byte-align ABI */
uint32_t flags; /* offset 4 */
uint16_t len; /* offset 8 */
/* 2 bytes tail padding so sizeof is 12 */
};
Unions
Size is the size of the largest member, plus any trailing padding the ABI wants. Reading a member other than the one last written is implementation-defined in C (type punning via union is a common dialect and is what GCC/Clang document); the strictly portable pun is memcpy into an object of the other type. Do not use unions to "extend lifetime" of pointers.
Bitfields
Syntax: unsigned flags : 3;. Allocation unit, order within the unit, and whether they can straddle storage units are implementation-defined. They are fine for in-memory flags on one compiler/ABI. They are a bad choice for a wire format or a shared-memory protocol unless both sides were built with the same toolchain and you have tests. Atomic bitfields are not a thing you should invent; use atomic_uint and masks.
Flexible array members (C99)
The last member may be T data[]; — not a pointer, not a fixed array. sizeof(struct) does not include the flexible part. You allocate offsetof(struct s, data) + n * sizeof(T) (or equivalent). Access s->data[i] for i < n. The old data[1] and GNU data[0] hacks exist in kernels; say so, then prefer FAM in new userspace C.
struct pkt {
uint32_t len;
uint8_t data[];
};
struct pkt *p = malloc(offsetof(struct pkt, data) + n);
if (!p) return NULL;
p->len = (uint32_t)n;
Packed structs
__attribute__((packed)) (or a pragma) removes padding. On ARM, an unaligned uint32_t load can be slow or, on some older/strict configs, fault. The compiler will emit bytewise accesses if it knows the pointer is unaligned, but a cast of a packed member's address to uint32_t * can re-introduce a badly aligned load. Prefer explicit serialisation: read bytes, shift, or memcpy into an aligned local.
sizeof(struct) and a raw dump to another process, another architecture, or a Java layer. Padding, endian and bitfields will disagree. Define a protocol. Binder / AIDL exist so you do not invent a new one every time; see Binder & AIDL.Preprocessor
The preprocessor is a token-based text engine that runs before the compiler proper. It is not scoped, not typed, and not debuggable in the way functions are. Use it for include guards, feature tests and a few typed wrappers; do not build a second language out of macros.
The preprocessor is a mail-merge system. #include pastes another letter into this one. #define is a find-and-replace rule that does not understand grammar. Token pasting (##) glues two words into a new identifier; stringizing (#) turns a parameter into a quoted string. Conditional compilation is "only print this paragraph if the campaign is Android". Include guards stop the same letter being pasted twice into one envelope (one translation unit). They do not stop two envelopes from each having a copy.
Include guards
#ifndef FOO_H
#define FOO_H
/* declarations */
#endif
The name must be unique. #pragma once is widely supported and fine in practice; in an interview, still know guards, because they are standard and they are what you will see in kernel and AOSP headers. Guards are per translation unit, not per process.
Macros vs inline
| Macro | static inline function | |
|---|---|---|
| Type checking | None | Yes |
| Evaluates args once | No — MAX(i++, j) is a bug | Yes |
| Can take address | No | Yes (if not fully inlined away) |
| Debug stepping | Painful | Normal |
| Still needed for | Guards, #ifdef, generics-by-token, container_of | Small helpers |
#define MAX(a, b) ((a) > (b) ? (a) : (b)) /* double-evaluates */
#define STR(x) #x
#define JOIN(a, b) a##b
int JOIN(foo, Bar) = 1; /* fooBar */
const char *s = STR(fooBar); /* "fooBar" */
Include paths and quotes
#include "foo.h" searches the including file's directory first, then the include path. #include <foo.h> searches the system / -I path. Order of -I flags matters: the first hit wins. A wrong order is how you compile against a stale generated AIDL header or the wrong bionic header.
Conditional compilation
#if, #ifdef, #ifndef, #elif, #else, #endif. Use #if defined(FOO) && FOO when the value matters. Nested #ifdef forests are how Android HALs grow untestable variants. Prefer putting differences behind small functions and a single config header. Never close a brace in only one arm of an #ifdef — the translation unit becomes different C.
clang -E -dM - < /dev/null (or the driver equivalent) dumps predefined macros: __ANDROID__, __aarch64__, __SIZEOF_POINTER__. Knowing that those exist is enough; do not memorise every builtin.Linkage: static, extern, tentative definitions, inline
Linkage answers "when I write the name foo in two places, is it the same object or function?". Storage duration answers "how long does it live?". They are different axes. Interviews mix them on purpose.
External linkage is a public listed phone number: anyone who knows the name can call that one line. Internal linkage (static at file scope) is an extension that only rings inside this building (this translation unit). No linkage (a local variable, or a typedef) is not a phone line at all. A tentative definition is putting your name on the office door without bringing furniture; if nobody else furnishes the office, the building manager puts in empty furniture (zero-initialised storage) at the end of the day. Two furnished offices with the same public number is a multiple definition.
| Keyword / form | Linkage | Duration | Notes |
|---|---|---|---|
File-scope int x; | External | Static | Tentative definition if no initialiser |
File-scope int x = 1; | External | Static | Full definition |
extern int x; | External | Static | Declaration; not a definition (unless it initialises) |
File-scope static int x; | Internal | Static | One per translation unit |
Local int x; | None | Automatic | Stack (or register) |
Local static int x; | None | Static | One instance; initialised once |
Function static void f(void) | Internal | — | Not exported to the linker |
Tentative definitions
At file scope, int g; without extern and without an initialiser is a tentative definition. If the translation unit never sees a real definition, the compiler emits a zeroed g. Historically many units could each have int g; and the linker merged them as "common" symbols. Modern toolchains default to -fno-common: two tentative definitions in two units are a multiple-definition error. Write extern in headers and one definition in one .c.
C99 inline
This is not C++ inline. In C99:
- An
inlinedefinition in a header may be used for inlining. - If the function is also used as an out-of-line function (its address is taken, or the compiler chooses not to inline), there must be exactly one external definition.
- The usual pattern:
inline int foo(int x) { … }in the header, andextern inline int foo(int x);or a copy in exactly one.c(toolchain-specific preferred form; GCC'sextern inlinehistory is messy). Many codebases just usestatic inlinein headers and accept a copy per unit if the compiler does not inline.
C++ inline (see C++) is a different ODR rule. Do not mix the stories.
One-definition issues
C does not use the C++ term ODR, but the linker still wants one definition for each external symbol (plus weak / common exceptions). Two non-static functions with the same name in two .c files fail at link. Two static functions with the same name are fine: they are different functions. A non-inline function defined in a header is the usual way to get the first failure.
static mean?" has three correct answers depending on where it is written: internal linkage (file-scope function or object), static storage duration (local), or (in C++) a class member that is not bound to an instance. Lead with "where is the keyword?" then answer.Undefined, unspecified and implementation-defined behaviour
This is the section that separates people who have been burned from people who have only read a blog. The compiler is a theorem prover that assumes your program is free of undefined behaviour. If you lie, it may delete your if, reorder stores, or format your disk — more realistically: miscompile a bounds check that only fails on the device at -O2.
The C standard is a contract with a shipping company. Defined behaviour is the published timetable. Implementation-defined is "this railway chooses left-hand or right-hand running and must publish which" (char signedness, sizeof(long), the result of shifting a negative number in older rules — they must document it). Unspecified is "the train will use one of these two tracks; we will not tell you which and it may change" (argument evaluation order). Undefined is "if you walk onto the live rail, the contract is void": the company may do anything, including pretending you never walked there and optimising the station away. Sequence points / sequenced-before are the signs that say which events must happen before which; without them, two writes to the same ticket machine are a riot (UB).
| Class | Meaning | Example |
|---|---|---|
| Defined | The standard says what happens | Unsigned wrap; free(NULL) |
| Implementation-defined | Must be documented by the compiler/ABI | Plain char signedness; size of long; #pragma effects |
| Unspecified | One of several correct outcomes; no documentation required | Order of evaluating f() + g(); padding bit values |
| Undefined | No requirements | Signed overflow; use-after-free; data race; out-of-bounds store |
Sequence points (older teaching) vs sequenced-before (C11/C17)
C17 describes an expression as a partial order: A is sequenced before B, or they are unsequenced, or they are indeterminately sequenced. A sequence point (the C89/C99 classroom word) is a place where all prior side effects are complete and no subsequent ones have started — the semicolon, the comma operator, && / || / ?:, function call (after arguments, before the body), and a few others.
If a scalar object is modified twice, or modified and read for a different reason, with those accesses unsequenced relative to each other, the behaviour is undefined. That is why i = i++, a[i] = i++, and printf("%d %d", i++, i++) are rejected in interviews. C17 did not make them defined; it just changed the vocabulary.
i = i++; /* UB */
a[i++] = i; /* UB */
i = i + 1; /* fine */
i += 1; /* fine */
j = i++; /* fine: one modify, the extra read is the value computation of i++ */
volatile — what it is and is not
volatile tells the compiler: every read or write in the abstract machine must appear as a real access to that lvalue, and volatile accesses are not reordered with each other. Use it for memory-mapped registers, for sig_atomic_t flags set by a signal handler, and for values a debugger might poke.
It is not atomicity (a 64-bit store may tear). It is not a compiler barrier for surrounding non-volatile accesses in the way people hope. It is not a CPU memory barrier. It does not make a data race defined. For threads, use C11 atomics or a mutex. For device registers in a driver, the kernel uses readl/writel and READ_ONCE/WRITE_ONCE — see kernel & BSP.
restrict and data races
restrict is a promise about aliasing in a single thread of execution of that function. A data race is two threads accessing the same memory, at least one a write, without synchronisation. C11 says a data race is undefined behaviour — even a "plain byte store that looks atomic on this CPU". "I only write a flag" is not a defence unless that flag is atomic or protected by a happens-before edge (unlock/lock, release/acquire, etc.).
if (p != NULL) then using p after another thread might have freed it, or using volatile int ready as a publication flag without a release barrier on the data stores. Sanitizers exist because reviewers miss this.-fsanitize=undefined,address,thread, and code review of lifetimes.I/O, FILE*, file descriptors and errno
Userspace C has two I/O layers that interviews expect you to keep straight. The kernel's objects are file descriptors (small integers in the process fd table). The C library's portable API is FILE* streams that buffer on top of an fd. Android adds fdsan and a slightly different buffering story in bionic.
A file descriptor is a coat-check ticket: the kernel holds the open file, you hold a number. FILE* is a clipboard the library keeps so it can write a paragraph at a time instead of walking to the coat check for every letter (buffering). errno is a sticky note on your thread that the last library/syscall helper left when it failed — not a field inside the FILE, and not a global in the old single-threaded sense. Mixing read and fread on the same fd without coordination is two people using the ticket and the clipboard without telling each other where they are on the page.
| File descriptor | FILE* | |
|---|---|---|
| Type | int | opaque pointer |
| API | open, read, write, close, ioctl, mmap | fopen, fread, fprintf, fclose |
| Buffering | None in libc (kernel page cache still exists) | Full / line / none |
| Bridge | fileno(fp) | fdopen(fd, mode) |
errno
On failure, many POSIX functions return -1 (or NULL) and set errno to a positive E* constant. Success does not set errno to 0. You must look at errno only when the function said it failed, unless the man page says otherwise. errno is thread-local in modern libc (a macro that expands to a function). strerror is not reliably thread-safe; prefer strerror_r where it exists. Some functions return the error code directly (pthread, many C11/C23 APIs) and leave errno alone.
int fd = open(path, O_RDONLY);
if (fd < 0) {
/* errno is valid here */
return -errno;
}
FILE *fp = fdopen(fd, "r");
if (!fp) { close(fd); return -errno; }
/* fclose will close fd; do not close it again */
printf in a log line) before you save it. Save int saved = errno; first. On Android, closing the wrong fd can abort via fdsan if the tag does not match.Make, gdb, sanitizers and valgrind
Platform interviews expect you to build and debug C without an IDE doing it for you. You do not need to be a Make guru; you need the mental model and the sanitizer names.
Make is a recipe book that only recooks a dish if an ingredient file is newer than the plated result. gdb is a freeze-ray and a magnifying glass: stop the process, look at memory, step one instruction or one line. Sanitizers are inspectors who walk next to the program and shout when you step off the path (ASan), do illegal arithmetic (UBSan), or leak a bucket (LSan). Valgrind is a slower inspector who interprets the whole run in software, so it sees some classes of bug without a special rebuild, at a steep speed cost. On Android devices you usually bring the sanitizer build, not Valgrind.
Make at interview level
CC ?= clang
CFLAGS ?= -Wall -Wextra -O2
objs := main.o util.o
prog: $(objs)
$(CC) $(CFLAGS) -o $@ $(objs)
%.o: %.c util.h
$(CC) $(CFLAGS) -c -o $@ $<
.PHONY: clean
clean:
rm -f $(objs) prog
A rule is target: dependencies plus a recipe. Make compares timestamps. .PHONY means "this name is not a file". Variables: $@ target, $< first prerequisite, $^ all prerequisites. Android.bp / Soong is what AOSP actually uses; saying "Make is the model, Soong is the Android generator" is enough. Do not claim you ship product with a 20-line Makefile unless you do.
gdb (and lldb)
break foo,break file.c:42,run,continue,next,step,finish.bt/backtrace— the first thing after a crash.print expr,x/16xb ptr(examine bytes),info locals,info registers.watch *ptr— stop when that location changes (hardware watchpoint if available).- Need
-g(and preferably not a fully stripped binary). Optimised code will not match source line-for-line; that is expected.
Android: lldb from the NDK, or gdbserver / lldb-server on device. Tombstones already have a backtrace; use that before attaching.
Sanitizers vs Valgrind
| Tool | Catches | Cost | Notes |
|---|---|---|---|
| ASan | Heap/stack/global OOB, use-after-free, some leaks (with LSan) | ~2x memory, ~2x CPU | Shadow memory; compile and link with -fsanitize=address |
| UBSan | Signed overflow, bad shifts, misaligned, etc. | Low | -fsanitize=undefined; can trap or log |
| LSan | Unfreed heap at exit | Low extra on ASan | Misses still-reachable caches; not a substitute for thinking |
| TSan | Data races | High memory | Needs all code instrumented; hard on huge processes |
| Valgrind (memcheck) | UAF, OOB, uninit, leaks | 10–30x slower | No recompile; weak on Android user builds; great on host |
Concurrency in C: atomics and pthreads
C did not have a thread memory model until C11. Before that, pthreads was a library contract on top of a language that pretended threads did not exist. You should still treat any unsynchronised shared write as UB. This section is the practical subset; it is not a full memory-model course.
Two threads are two cooks sharing one counter. A mutex is a single kitchen pass: only one cook behind it. A condition variable is a bell: you sleep until someone rings that the sauce is ready, then you re-check the sauce (the predicate). An atomic is a labelled jar that the building code says you may take or replace in one move. memory_order_relaxed is "the jar is atomic but I said nothing about when the rest of the pantry becomes visible". Release / acquire is "when I put the finished plate on the pass (release), the waiter who picks it up (acquire) sees every ingredient I already prepared". seq_cst is a single global ticket machine: more expensive, easier to reason about. A data race is two cooks stirring the same pot with no pass and no atomic jar — the health inspector (the standard) declares the kitchen undefined, not "probably fine on ARM".
pthreads (POSIX, including Android)
pthread_create/pthread_join/pthread_detach.pthread_mutex_t— default is normal mutex; recursive is opt-in. Do not unlock from another thread.pthread_cond_t— always wait in a loop on the predicate; the mutex must be held. Spurious wakeups happen.pthread_rwlock_t,pthread_once, thread-specific data / TLS (_Thread_localin C11).- Mutex unlock synchronises-with the next lock of the same mutex: a full happens-before for data you touched while holding it.
C11 atomics at a practical level
#include <stdatomic.h>
atomic_int ready = 0;
atomic_store_explicit(&ready, 1, memory_order_release);
int r = atomic_load_explicit(&ready, memory_order_acquire);
| Order | When you use it |
|---|---|
relaxed | Counters, statistics; no publish of other data |
release store / acquire load | Publish a buffer: write data, then release-store a flag; reader acquire-loads the flag, then reads data |
acq_rel | Read-modify-write that both takes and publishes (e.g. unlock-like RMW) |
seq_cst | Default if you do not want to think; total order of all seq_cst ops |
volatile is not on this table. A mutex is still the default for anything with more than one word of invariant. Atomics shine for flags, reference counts and simple queues you can actually prove.
relaxed to publish a pointer to a freshly written struct. The reader can see the new pointer and the old struct body. That is the classic "it works on x86" bug: x86 TSO hides a lot of missing barriers; ARM will not.ready; acquire load of ready, then loads of payload. Name pthreads as the everyday API on Android. If they push harder, say seq_cst is a conservative default and relaxed is for independent counters.C on Android: NDK, JNI, bionic, HALs
Android native code is still C's world: the NDK toolchain, bionic, JNI bridges, and HAL processes. The Java object model, GC and JNI from Java belong on the Java page. Kernel-side C belongs on Linux kernel & BSP. Framework services that call into native are on Android frameworks. This section is the C side of those boundaries.
The NDK is a guest workshop on the Java estate. JNI is the hatch between the manor (managed objects, GC) and the workshop (pointers, malloc). JNIEnv is the speaking tube for this thread — you cannot walk over to another bench and use their tube. Local refs are visitor badges that expire when the native method returns; global refs are employee badges you must revoke. Bionic is the workshop's tool crib: familiar tools, fewer drawers than glibc, and a clerk (fdsan) who shouts if you hang up someone else's coat-check ticket. A HAL is a locked annex that the manor talks to through Binder, not by wandering in with a raw pointer.
NDK
The NDK is Clang, a sysroot of bionic headers and libs, and build files (Android.mk legacy, CMake, or Soong cc_library). You produce .so files loaded by System.loadLibrary or packaged in the vendor / system image. ABI: arm64-v8a is the default interview answer for phones; 32-bit is dying. Compile with -fPIC, hidden visibility by default in modern NDK, and do not ship debug symbols in the unsigned path without a plan.
JNI from the C side
Every JNI call goes through a JNIEnv * (a table of function pointers). It is per-thread. A native thread must AttachCurrentThread before using JNI and DetachCurrentThread before exit. Caching JNIEnv in a global is a bug.
- FindClass from a native thread uses the system class loader, not the app loader. You will not find app classes unless you cached a
jclass(global ref) from a method that ran with the right loader, or you use a cachedClassLoaderobject. - Local refs are freed when the native method returns, or when you
DeleteLocalRef/PopLocalFrame. Tight loops thatNewObjectwithout deleting will overflow the local ref table (default a few hundred). - Global refs (
NewGlobalRef) survive; you mustDeleteGlobalRef. Weak global refs do not keep the object alive. - Strings: Java is UTF-16.
GetStringUTFCharsgives modified UTF-8, not always standard UTF-8 (surrogates / U+0000). For binary data usejbyteArray, not strings.ReleaseStringUTFCharsmust be paired. Check for NULL: the VM may throwOutOfMemoryError. - After any JNI call that can throw, check
ExceptionCheckbefore continuing; otherwise the next JNI call is undefined from the VM's point of view.
Java-side native methods, RegisterNatives from Java, and GC interaction are covered on Java.
bionic vs glibc
| Topic | bionic (Android) | glibc (typical GNU/Linux) |
|---|---|---|
| Scope | Smaller POSIX; no full glibc extensions | Huge surface, NSS, iconv, … |
| Dynamic linker | linker64, namespaces, DT_RUNPATH rules, sphal | ld-linux, rpath, ldconfig |
| Threads | pthreads on the kernel clone; no glibc POSIX timers everywhere | NPTL |
| Memory | jemalloc-derived / scudo in various releases | ptmalloc (historically) |
| FDs | fdsan tags fds and aborts on use-after-close / wrong owner | No fdsan |
| Locale / iconv | Limited; do not assume glibc locale behaviour | Full |
fdsan (file descriptor sanitizer) is always-on in modern Android. Closing an fd that libc thinks another owner still holds aborts. The fix is ownership: do not close an fd you passed to fdopen without knowing who closes it; do not close fds the framework still owns.
HAL C APIs
Legacy HALs export HAL_MODULE_INFO_SYM, a hw_module_t with open() that returns an hw_device_t whose first fields are a vtable. Calls look like device->ops->foo(device, …). Newer HALs are AIDL interfaces in a vendor process; the C/C++ NDK stubs still feel like "struct of function pointers" plus Binder. Do not invent a new ioctl ABI if AIDL exists. See Binder & AIDL and frameworks.
Interview coding patterns in C
This is not the DSA page. They will not ask you to invent Dijkstra in C. They will ask you to write the functions that make Dijkstra possible without invoking UB: strings, bytes, bits, ownership, and small structs. Write boring, correct C. Comment who owns each pointer.
DSA problems are chess tactics. C interview coding is kitchen hygiene: wash the knife (NUL-terminate), do not use the same cutting board for raw and cooked (overlap / restrict), write the name on the leftover box (ownership), and count the seats before you invite guests (capacity vs length). A fancy algorithm on a dirty board still sends people to the hospital (security bugs).
What they actually ask
- Implement
strlen,strcpy,strcmp,memcpy,memset,memmove. - Reverse a string in place; reverse words; is-palindrome on bytes.
- Bit tricks: count bits, isolate lowest set bit (
x & -x), swap without a temp (they still ask; mention XOR and also "just use a temp"), endian swap, test power-of-two (x && !(x & (x-1))). - Manual
atoiwith overflow detection using unsigned or wider types. - In-place remove of a character; compress runs; circular buffer of bytes.
- Intrusive linked list (the kernel style:
next/previn the struct) — language, not graph theory.
Ownership comments
/* out: malloc'd, caller frees. Returns 0 or -errno. */
int read_all(int fd, uint8_t **out, size_t *len);
/* borrows name; does not free it. Copies into an internal table. */
int registry_add(const char *name);
/* takes ownership of buf; will free() it. buf may be NULL. */
void packet_set_payload(struct packet *p, uint8_t *buf, size_t n);
C has no unique_ptr. The comment is the type system. Interviewers read the comment before they read the loop. If you allocate twice and only free once on the error path, say the cleanup label out loud (goto cleanup is idiomatic C, not a sin).
A correct-enough memcpy
void *my_memcpy(void *restrict dst, const void *restrict src, size_t n)
{
unsigned char *d = dst;
const unsigned char *s = src;
while (n--)
*d++ = *s++;
return dst;
}
Say: bytewise is correct; real libc aligns and copies words; overlap is UB because of restrict — use memmove (copy backwards if d is after s inside the same object). For strlen, walk until '\0', return the count, and mention you must not read past a missing terminator if the buffer length is known — then you wanted memchr.
char if they asked for a byte API. Then code. Then a 30-second complexity line: O(n) time, O(1) extra space.Quick revision
- C remains the language of kernels, bootloaders, libc/bionic, HALs and firmware because the ABI is stable and nothing is implicit.
- Preprocess → compile → assemble → link; the compiler sees one translation unit at a time.
- Headers declare; exactly one
.cdefines each external object or function (unlessstaticor a carefulinline). - Static
.acopies used object files into the image; shared.sois loaded at runtime and needs PIC. - Integer promotions lift narrow types to
int(orunsigned int) before most arithmetic. - Usual arithmetic conversions pick a common type; signed vs unsigned of the same rank becomes unsigned.
- Signed overflow is UB; unsigned wrap is defined modulo 2n.
size_tis unsigned; never writeif (len < 0)on asize_t.intptr_t/uintptr_thold a pointer as an integer; they are not a free pass for provenance games.- Plain
charsignedness is implementation-defined; useunsigned charfor bytes. - Text = code; rodata = literals; data = initialised globals; bss = zero globals; stack = frames; heap = malloc.
reallocfailure leaves the old pointer valid; do not overwrite it with NULL.- Alignment drives struct padding;
sizeofincludes tail padding. - Endian is a property of how integers are stored; the wire format is not automatically the host format.
- Arrays decay to pointers except for
sizeof,&arrand a few other cases; function parameters are already pointers. - Pointer arithmetic is in elements, only within the same array (or one-past-end, not dereferenced).
void *is a generic object pointer; do not dereference it; function pointers are a separate story.const T *vsT * const: pointee vs pointer.restrictmeans "these buffers do not alias"; overlappingmemcpyis UB.- A C string is bytes plus a NUL; capacity must include that NUL if you store a string.
snprintfbounds the write, NUL-terminates if size > 0, and returns the untruncated length.strncpyis not safestrcpy: it may omit the NUL and it zero-pads.memcpyisrestrictand forbids overlap;memmoveallows overlap.- Do not
memcmpstructs for equality when they contain padding. - Union type punning is a documented compiler dialect; portable punning is
memcpy. - Bitfield allocation order and straddling are implementation-defined; do not use them as a wire format.
- A flexible array member is
T data[]at the end;sizeofexcludes it; allocateoffsetof + n * sizeof(T). - Packed structs drop padding and can cause unaligned accesses on ARM; serialise with bytes or
memcpy. - Include guards (or
#pragma once) are per translation unit; they do not give you one definition across the program. - Prefer
static inlineover function-like macros: types, single evaluation, debuggable. #stringizes,##pastes tokens; both happen in the preprocessor, not the type system.#include "…"searches the includer's directory first;<…>uses the include path; first-Ihit wins.staticat file scope = internal linkage; on a local = static duration; the word is not one concept.- Tentative definition: file-scope
int g;with no initialiser. Modern-fno-commonforbids one per unit. - C99
inlineis not C++ inline; you still need one external definition if the function is used out of line. - Undefined = no requirements; unspecified = one of several; implementation-defined = documented choice.
- Unsequenced modify + modify (or modify + extra read) of the same scalar is UB:
i = i++. volatileforces accesses to that lvalue; it is not atomic, not a mutex, not a CPU barrier.- A data race is UB in C11 even if the store "looks atomic" on the CPU.
- File descriptors are kernel
ints;FILE*is a buffered libc stream;fileno/fdopenbridge them. - Read
errnoonly after a documented failure; it is thread-local; save it before you log. - Make rebuilds a target when a dependency is newer;
.PHONYis a name that is not a file. - After a crash: backtrace first. gdb/lldb need
-g; optimised code will not match source perfectly. - ASan = spatial/temporal memory; UBSan = language UB; LSan = leaks; TSan = races; Valgrind = interpreter, slow, host-friendly.
- Mutex unlock happens-before the next lock; condvar waits must loop on the predicate.
- Publish with payload stores then a release store; consume with an acquire load then payload loads.
memory_order_relaxedis for independent counters, not for publishing a pointer to new data.- Android native = NDK/Clang + bionic, not glibc. Kernel C is a different world (no libc).
JNIEnvis per-thread; attach native threads; never cache another thread's env.FindClasson a native thread uses the system class loader — cachejclassfrom the right context.- Local JNI refs die when the native method returns; delete in tight loops; global refs must be deleted.
- JNI "UTF" is modified UTF-8; Java strings are UTF-16; binary data belongs in byte arrays.
- fdsan aborts on close of an fd the runtime thinks someone else owns.
- HALs are C vtables historically and AIDL/Binder now; do not invent a new ioctl protocol if AIDL exists.
- C has no unique_ptr: ownership is a comment plus one
freeon every path, often viagoto cleanup. - Whiteboard C is
strlen/memcpy/bits/buffers, not graph algorithms (those live on the DSA page). - Check overflow before
malloc(count * size); a wrapped size is a security bug. - Writing a string literal is UB; use an array if you need to mutate.
- One-past-the-end pointers may be computed, not dereferenced.
- LTO and
-O2make latent UB visible; "it works in debug" is not a proof of definedness.
Glossary
- ABI
- Application Binary Interface: sizes, alignments, calling convention and symbol names that compiled objects must agree on.
- Alignment
- Requirement that an object's address is a multiple of N (often the type's size). Misalignment can be slow or fault.
- ASan
- AddressSanitizer: compiler instrumentation plus shadow memory that catches out-of-bounds and use-after-free.
- bionic
- Android's C library and dynamic linker, smaller than glibc and with extras such as fdsan.
- Bitfield
- A struct member occupying a specified number of bits; layout is implementation-defined.
- BSS
- Segment for zero-initialised (or uninitialised) static-duration objects; the file stores a size, not the zeros.
- calloc
- Heap allocation that zeros memory; the element-count times size product can overflow.
- const
- Type qualifier meaning the object will not be modified through this lvalue. Casting it away and writing can be UB.
- Data race
- Two threads touch the same memory, at least one writes, with no happens-before. UB in C11.
- Declaration
- Introduces a name and type without necessarily reserving storage or providing a body.
- Definition
- The unique place that reserves storage or provides a function body (plus C's tentative-definition rules).
- Effective type
- The type C uses for aliasing rules: how an object may be read. Strict aliasing is the informal name.
- Endianness
- Byte order of a multi-byte integer in memory or on the wire. Host and protocol may differ.
- errno
- Thread-local error code set by many failing libc/POSIX calls. Inspect it only after a documented failure.
- extern
- Specifies external linkage, or (with an initialiser) a definition. In headers, usually a declaration only.
- FAM
- Flexible array member: last struct member
T data[], sized by the allocation, not bysizeof. - fdsan
- Android file-descriptor sanitizer: tags fds and aborts on use-after-close or mismatched owner.
- FILE
- Opaque libc stream with buffering, sitting on top of a file descriptor.
- Function pointer
- Pointer to a function; type includes return and parameter types. Not an object pointer.
- GOT / PLT
- Global Offset Table and Procedure Linkage Table: indirection used by position-independent code to reach data and functions.
- HAL
- Hardware Abstraction Layer: vendor code behind a stable interface (legacy C vtable or AIDL).
- Implementation-defined
- The implementation must choose and document a behaviour (for example plain
charsignedness). - Include guard
#ifndef/#define/#endifwrapper so a header's body is pasted at most once per translation unit.- Integer promotion
- Conversion of narrow integer types to
intorunsigned intbefore most arithmetic. - intptr_t
- Signed integer type guaranteed to hold a pointer. Pair is
uintptr_t. - JNI
- Java Native Interface: the C API that talks to the VM. Java-side details belong on the Java page.
- JNIEnv
- Per-thread table of JNI function pointers. Must not be shared across threads.
- Linkage
- Whether two declarations of the same name denote the same object or function (external, internal, or none).
- LSan
- LeakSanitizer: reports heap that is not freed at process exit (often bundled with ASan).
- lvalue
- An expression that designates an object (something that can appear on the left of assignment, roughly).
- malloc
- Uninitialised heap allocation aligned for any standard type. Pair with
free. - memcpy
- Copy
nbytes between non-overlapping (restrict) buffers. Overlap is UB. - memmove
- Copy
nbytes; overlap is allowed. - memory_order
- C11 atomic constraint: relaxed, acquire, release, acq_rel, seq_cst — how much reordering is forbidden.
- NDK
- Native Development Kit: Clang, bionic sysroot and build support for Android native code.
- Object file
- Compiler output (
.o) containing machine code, data and relocation records, not yet linked. - Padding
- Unused bytes inserted so members and the whole struct meet alignment. Contents are not meaningful.
- PIC / PIE
- Position-independent code / executable: can run at any load address, used for shared libraries and hardened binaries.
- Pointer provenance
- The idea that a pointer is not only an address: it is tied to the object it was derived from. Casting through integers can lose that.
- pthread
- POSIX threads API: create, join, mutex, condvar. The everyday threading API on Android userspace.
- realloc
- Resize a heap block; may move it. On failure the original pointer remains valid.
- Relocation
- A linker (or loader) patch that fills in an address that was unknown at compile time.
- restrict
- Promise that a pointer is the only way the function will access that object, enabling non-aliasing optimisations.
- Sequence point
- Older term for a place where previous side effects are complete. C11+ uses sequenced-before.
- Sequenced-before
- C11/C17 partial order on evaluations. Unsequenced conflicting accesses to a scalar are UB.
- Shared library
- Dynamically loaded image (
.so) with its own text/data, resolved by the dynamic linker. - size_t
- Unsigned type of object sizes; result of
sizeofand the usual malloc/strlen type. - snprintf
- Bounded formatted write that NUL-terminates when the size is not zero; return is the untruncated length.
- static
- Keyword meaning internal linkage (file scope) or static storage duration (block scope), depending on where it appears.
- Static library
- Archive (
.a) of object files. The linker copies only the members it needs. - Storage duration
- How long an object lives: automatic (stack), static (process), or allocated (heap until free).
- Strict aliasing
- Informal name for effective-type rules: accessing an object as the wrong type is UB (with exceptions such as
char *). - Tentative definition
- File-scope declaration that may become a zeroed definition if no real definition appears in the unit.
- Text segment
- The executable code portion of the image, usually mapped read-only and shareable.
- Translation unit
- A source file after preprocessing: what the compiler actually compiles.
- Trap representation
- A bit pattern that is not a valid value of the type. Reading it can be UB (more relevant historically for some integers).
- TSan
- ThreadSanitizer: detects data races in instrumented builds.
- UBSan
- UndefinedBehaviorSanitizer: runtime checks for many kinds of language UB.
- Undefined behaviour
- The standard imposes no requirements. Compilers may assume it never happens and optimise accordingly.
- Unspecified behaviour
- The standard gives a set of allowed outcomes and does not require the choice to be documented.
- Usual arithmetic conversions
- Rules that convert both operands of a binary operator to a common type after promotions.
- Valgrind
- Dynamic analysis suite; memcheck interprets the binary and finds many memory errors without a special compile.
- void*
- Generic object pointer. Convertible to other object pointers in C; not dereferenceable; not a portable function-pointer box.
- volatile
- Qualifier requiring that accesses to that lvalue are not elided. Not a concurrency tool.
- Weak symbol
- A symbol the linker may override with a strong definition, used for interposable defaults.
Interview questions
Fundamentals
Why is C still the language of kernels, HALs and firmware?
Those layers need a stable ABI to assembly and to other languages, predictable codegen, no hidden allocations or exceptions, and a culture of explicit lifetime. The existing corpus (Linux, bionic, bootloaders, radio firmware) is C. C++ can wrap it (see C++) but the kernel and most firmware stay C by policy. Interviews want that layered picture: app/Java on top, native/HAL in the middle, kernel C below — see Linux kernel & BSP.
What happens when you compile a C program? Walk through preprocess, compile, assemble and link.
The preprocessor expands #include, macros and conditionals into one translation unit of C text. The compiler turns that into assembly for the target. The assembler emits an object file: machine code, symbols and relocations. The linker assigns addresses, patches relocations, pulls in static archives or records shared-library dependencies, and writes the executable or .so. The compiler never sees your other .c files; only the linker does.
What is a translation unit?
A source file after preprocessing: every included header is pasted in, macros are expanded, and inactive #ifdef arms are gone. That blob is what the compiler type-checks and compiles to one .o. Two translation units communicate only through the linker (external symbols) and through the ABI of the types they share via headers.
What belongs in a header versus a source file?
Headers: prototypes, struct/enum/typedef, extern data declarations, macros, static inline helpers, include guards. Sources: function bodies with external linkage, the single definition of each global, file-scope static helpers. A non-inline function defined in a header is copied into every unit and usually becomes a multiple-definition error.
What is the difference between a declaration and a definition?
A declaration introduces a name and a type. A definition is the declaration that reserves storage or provides the function body. extern int n; declares; int n = 3; defines. A prototype declares a function; the body defines it. You may declare many times; you define an external object or function once (C also has tentative definitions for uninitialised file-scope objects).
Static library vs shared library?
A static library (.a) is an archive of object files. The linker copies the members it needs into your image; after that there is no runtime dependency on the archive. A shared library (.so) is a separate PIC image the dynamic linker loads; many processes can share the text. Shared libs give smaller binaries and updatable implementations; they add load-time cost, versioning and DT_NEEDED headaches. Android vendor HALs are typically shared objects.
What is an object file?
Compiler/assembler output (.o / ELF relocatable): machine code, static data, a symbol table, and relocation records that say "patch this offset with the final address of foo". It is not runnable. Linking (static or dynamic) resolves those relocations.
What are integer promotions?
In most expression contexts, integer types narrower than int (char, short, small bit-fields) are converted to int if int can represent every value of the original type, otherwise to unsigned int. So unsigned char a = 1, b = 2; a - b is int -1, not 255. Assignment into a narrow object converts back afterwards.
What are the usual arithmetic conversions?
After promotions, a binary operator converts both operands to a common type: floating types win if present; otherwise the higher conversion rank wins; if ranks match but signedness differs, the signed operand converts to the unsigned type of that rank. That is why -1 < (size_t)1 is false: -1 becomes a huge unsigned value.
Why is signed overflow undefined but unsigned wrap defined?
The standard defines unsigned arithmetic modulo 2n. Signed overflow is UB so compilers may assume it never happens: they delete "impossible" checks and rewrite loops. Two's-complement wrap is what the CPU does, but C does not give you that unless the implementation documents a dialect (-fwrapv). Write overflow-safe code with unsigned types, wider types, or explicit checks.
What is size_t and when do you use it?
size_t is the unsigned type of object sizes: sizeof, strlen, malloc's argument, array counts in libc. Use it for sizes. Do not use it as a general integer: it cannot be negative, so a mistaken < 0 check is dead code, and mixing it with signed values triggers usual arithmetic conversions.
What are intptr_t and uintptr_t?
Integer types that can hold an object pointer. Used for tagged pointers, hashing addresses, and some handle encodings. Casting a pointer to an integer and back is allowed for void * via these types, but pointer provenance and alignment still matter; they are not a licence to fabricate pointers from arbitrary integers.
Is char signed or unsigned? Why does it matter?
Plain char is either; the ABI chooses. Many ARM Android targets use unsigned char; many x86 hosts use signed. char c = 0xFF; if (c == 0xFF) may compare -1 with 255. For bytes use unsigned char or uint8_t. This is a classic "works on my laptop" bug.
Stack vs heap vs data vs bss vs text.
Text is code (usually RX, shareable). rodata is const data and string literals. Data is initialised static-duration objects. BSS is zeroed static-duration objects (size in the file, not the zeros). Stack holds frames: locals, return addresses; automatic lifetime. Heap is malloc/free. Returning a pointer to a local, writing a string literal, or leaking heap are the usual mix-ups.
How do malloc, calloc, realloc and free work, and what are the traps?
malloc(n) returns n uninitialised bytes or NULL. calloc zeros and can overflow nmemb * size. realloc(p, n) may move the block; on failure p is still valid — never write p = realloc(p, n) without a temporary. free(NULL) is safe; double-free and freeing a non-heap pointer are UB. malloc(0) is implementation-defined.
What is alignment? What is struct padding?
A type's alignment is the divisor required of its address. The compiler inserts unused bytes so each member (and the struct as a whole, for arrays) meets its alignment. offsetof and sizeof are authoritative. Padding is why you cannot treat a struct as a portable wire format and why memcmp of structs can see stale pad bytes.
Big-endian vs little-endian?
Little-endian stores the least significant byte at the lowest address (ARM Android, x86). Big-endian stores the most significant byte first. Endianness is about integers in memory or on the wire, not about bitfields magically becoming portable. Convert explicitly at protocol boundaries.
How does a pointer differ from an array?
An array is an object that contains N elements. In most expressions it decays to a pointer to the first element. sizeof on an array is the whole object; sizeof on a pointer is the pointer. A function parameter declared as an array is a pointer. &arr has type pointer-to-array, not pointer-to-element.
Explain pointer arithmetic.
p + k advances by k * sizeof(*p) bytes, and is defined only for pointers into the same array object (or one-past-the-last). Dereferencing one-past-the-end is UB. Subtracting two pointers yields ptrdiff_t and has the same restriction. NULL + 1 is UB. char * arithmetic is in bytes.
What is void* and what can you do with it?
The generic object pointer. In C you may convert to and from any object pointer without a cast. You may not dereference it or (portably) do arithmetic on it. It is the type of malloc's return and of untyped callbacks' context. Function pointers are not portably stored in void * even though POSIX code often does it.
What is a function pointer? Give a use case.
A pointer whose type is a function type: return type plus parameter types. Used for qsort comparators, pthread start routines, HAL vtables, and JNI native method tables. Typedef the type. Calling through NULL is UB. You cannot portably perform data-pointer arithmetic on them.
Explain const correctness for pointers.
const T *p (same as T const *p): you may not write *p; you may retarget p. T * const p: you may write *p but not change p. Both: const T * const p. APIs that only read should take const T *. Writing through a cast-away const is UB if the object was defined const (including string literals).
What does restrict mean?
A C99 promise: for the lifetime of that pointer, accesses to the pointed-to object go through pointers based on it. The compiler may assume no aliasing and vectorise copies. memcpy is restrict; passing overlapping regions is UB. memmove has no restrict and is the overlap-safe copy.
When do you need a pointer to a pointer?
When a callee must change the caller's pointer (allocate and hand back), when you walk a list by rewriting head, or when you have an array of pointers (argv). void ** is a pointer to void *, not a magic "pointer to any pointer".
How are C strings stored? What is length vs capacity?
A string is contiguous chars plus a terminating '\0'. There is no stored length: strlen walks until NUL. Length is how many payload characters you mean; capacity is how many bytes the buffer can hold (including the NUL if you store a string). Confusing capacity with sizeof(pointer) is a standard overflow.
Why is strcpy unsafe? How does snprintf help?
strcpy writes until the source NUL with no destination bound. snprintf(buf, sizeof buf, "%s", src) will not write more than the size, NUL-terminates if size > 0, and returns the length that would have been written so you can detect truncation. strncpy is usually the wrong substitute (may omit NUL, zero-pads).
memcpy vs memmove vs strcpy?
strcpy copies a C string (stops at NUL, no bound). memcpy copies n raw bytes and forbids overlap (restrict). memmove copies n bytes and allows overlap by copying forward or backward as needed. For unknown overlap, use memmove. For strings of unknown size, use a bounded formatter or an explicit length.
What is a struct? What is a union?
A struct lays members out in order (plus padding). A union overlays members in the same storage; size is the largest member (plus ABI tail padding). Reading a union member other than the one last written is implementation-defined in the standard; compilers you use document type punning. Do not memcmp structs with padding and expect a stable result.
What is a bitfield?
A struct member with a bit width, e.g. unsigned ready : 1;. Useful for in-memory flags on one toolchain. Allocation unit, bit order and straddling are implementation-defined, so they are a bad wire format. They are not atomic; use atomics or a mutex for shared flags.
What is a flexible array member?
C99 last member T data[];. It is not a pointer and not a fixed array. sizeof does not include the flexible part. Allocate offsetof(struct s, data) + n * sizeof(T) and use s->data[i]. Prefer this over the old data[1] / GNU data[0] hacks in new userspace C.
What is a packed struct and when is it dangerous?
Packed layout removes padding so the in-memory image is compact. Members can be unaligned. On ARM an unaligned word load can be slow or fault. Taking the address of a packed member and treating it as a normally aligned pointer is a common bug. Prefer explicit serialisation for protocols.
What are include guards? Is #pragma once enough?
Guards are #ifndef FOO_H / #define FOO_H / #endif so a header's body is pasted once per translation unit. #pragma once is widely supported and fine in practice; interviews still expect guards because they are standard and ubiquitous in kernel/AOSP headers. Guards do not prevent multiple definitions across units.
Macro vs inline function?
Macros are untyped token substitution: arguments can be evaluated twice (MAX(i++, j)), errors are late, and you cannot take the address. static inline functions are typed, evaluate arguments once, and step in a debugger. Keep macros for guards, #ifdef, token pasting and a few patterns like container_of.
What does static mean? What does extern mean?
Ask where it is written. File-scope static on a function or object: internal linkage (not exported to the linker). Block-scope static: static storage duration, one instance, initialised once. extern: external linkage; extern int x; is a declaration. C++ adds a third meaning for class members (see C++).
Undefined vs unspecified vs implementation-defined behaviour?
Undefined: no requirements (signed overflow, UAF, data race). Unspecified: one of several allowed outcomes, need not be documented (argument evaluation order). Implementation-defined: the implementation chooses and documents (plain char signedness, sizeof(long)). Interviews want examples and the optimisation consequence of UB.
What does volatile actually do?
It forces the compiler to emit a real access for each abstract-machine read or write of that lvalue, and not to reorder those volatile accesses with each other. Use it for MMIO and signal-handler flags (sig_atomic_t). It does not provide atomicity, a CPU barrier, or defined behaviour for data races. For threads use C11 atomics or a mutex.
Going deeper
What is a tentative definition?
A file-scope declaration of an object with no initialiser and without extern, e.g. int g;. If the translation unit never provides a real definition, the compiler emits a zeroed g. Historically multiple units could each have int g; and the linker merged "common" symbols. Modern Clang/GCC default to -fno-common, so that becomes a multiple-definition error. Declare extern int g; in the header and define it in one .c.
How does C99 inline linkage work?
An inline definition in a header may be used for inlining. If the function is used as a real function (address taken, or the compiler does not inline), there must be exactly one external definition in the program. The portable habit in many codebases is static inline in the header (possible duplicate copies). This is not the C++ ODR-inline rule; do not mix the stories (see C++).
What is a one-definition problem in C?
Each external symbol should have one definition at link time. Two non-static functions named foo in two .c files fail. A function body in a header included by two units is the usual cause. Two static functions with the same name are different functions. Weak symbols and (historically) common symbols are the exceptions.
What are token pasting and stringizing?
In a function-like macro, #param turns the argument's tokens into a string literal. a##b concatenates tokens into one token (e.g. a new identifier). They run in the preprocessor. Pasting must produce a valid token; stringizing does not evaluate the argument. Useful for test harnesses and enum-to-string tables; easy to make unreadable.
How do include paths work? Quotes vs angle brackets?
#include "foo.h" searches the directory of the including file first, then the include path. #include <foo.h> searches the system / -I path. The first file found wins, so -I order can silently pick a stale generated header. That shows up in Android when generated AIDL headers fight checked-in copies.
Name pitfalls of conditional compilation.
Closing a brace in only one #ifdef arm makes two different programs. Nested feature forests are untestable. #if FOO when FOO is undefined is 0; #ifdef FOO only tests whether it is defined. Prefer a single config header and small functions over scattering __ANDROID__ through logic.
Why is i = i++ undefined?
The scalar i is modified twice (the increment and the assignment) with those side effects unsequenced relative to each other. C17's sequenced-before relation does not order them. The compiler may produce anything, including deleting nearby code. Write i += 1; or i = i + 1;. The same rule kills a[i] = i++ and printf("%d %d", i++, i++).
Sequence points (C17) vs sequenced-before?
C89/C99 teaching used sequence points (semicolon, comma operator, &&/||/?:, after argument evaluation before a call). C11/C17 describe a sequenced-before partial order on evaluations. The rule you need: unsequenced conflicting accesses to the same scalar (two writes, or a write and an extra read) are UB. The vocabulary changed; the interview examples did not become defined.
Give five common sources of undefined behaviour in C.
- Out-of-bounds store or load, including missing NUL on a "string".
- Use-after-free, double-free, or using an uninitialised pointer.
- Signed integer overflow; bad shifts (negative, or shift ≥ width).
- Strict-aliasing / effective-type violations; overlapping
memcpy. - Data races; returning a pointer to a local; writing a string literal.
Why is a data race undefined behaviour?
C11's memory model says a race (conflicting accesses, at least one a write, no happens-before) has no defined result. The compiler may keep a copy in a register forever, tear a store, or invent loads. "I only write a byte flag" is not enough unless that object is atomic or you used a lock. x86 TSO hides many missing barriers; ARM will not. Use TSan in tests.
Array decaying and sizeof pitfalls?
Pass an array to a function and it is a pointer: sizeof inside the function is the pointer size. Macros like #define COUNT(a) (sizeof(a)/sizeof (a)[0]) are only valid on a real array object, not on a parameter. Keep an explicit length. sizeof on a VLA is evaluated at runtime; on other types it is a constant.
Why is a[i] the same as i[a]?
The standard defines a[i] as *((a)+(i)). Addition commutes, so i[a] is the same if one operand is a pointer and the other an integer. It is trivia. The useful lesson is that subscripting is pointer arithmetic, which is why a decaying array and an index are symmetric in the abstract machine.
How do you read a complex pointer declaration (spiral rule)?
Start at the identifier, go right as far as you can (arrays, function params), then left (pointers, const), then out through parentheses. int *(*fp)(void) is "fp is a pointer to a function taking void returning pointer to int". Prefer a typedef. Interviewers use this to see if you panic or methodically decode.
const T* vs T* const vs const T* const?
Read it backwards from the star: const T * is pointer to const T (pointee frozen). T * const is const pointer to T (pointer frozen). Both frozen: const T * const. A function that only reads should take the first so callers can pass const data and literals.
Effective type / strict aliasing in plain language?
An object's effective type (how it was created or last written, roughly) is the type you may use to read it. Reading an int through a float * is UB; the compiler assumes they cannot alias and reorders loads. Exceptions include character types (you may inspect any object's bytes via unsigned char *) and unions in the compiler dialect you actually use. Portable punning is memcpy into an object of the other type.
What alignment does malloc provide? What about over-aligned types?
C11 malloc is aligned for any object whose alignment is not greater than max_align_t (typically 8 or 16). SIMD types, some hardware descriptors and cache-line aligned structs need aligned_alloc, posix_memalign, or a compiler-aligned allocator. Free with the matching API. Over-aligned new in C++ is a different chapter.
What is the realloc failure leak pattern?
p = realloc(p, n) if realloc returns NULL: you overwrite the only pointer to the old block and leak it. Keep the old pointer, check the result, then assign. On success the old pointer is invalid if the block moved — do not use both.
What does snprintf return, and how do you detect truncation?
On success it returns the number of characters that would have been written excluding the NUL. If that value is negative, there was an encoding/output error. If (size_t)n >= size, the output was truncated (the buffer is still NUL-terminated when size > 0). That return is also how you size a correctly large second buffer.
Name common string and buffer bugs.
- Missing NUL after
memcpyof text. sizeof(ptr)as a count.- Off-by-one:
i <= non an n-byte buffer. - Modifying a string literal.
- Using
strncpyas "safe strcpy". - Assuming
charis unsigned when scanning bytes.
Why can you not blit a struct over the wire as sizeof(struct) bytes?
Padding, endianness, bitfield layout and the size of long are ABI-specific. The receiver may be another architecture, a Java layer, or a different compiler. Define a protocol: fixed-width types, explicit endian conversion, no hidden pads (or a documented packed format you serialise carefully). Binder/AIDL exist so you do not invent this every time — see Binder & AIDL.
Union type punning: what is allowed?
The standard says reading a member other than the one last stored is implementation-defined (except you may read a common initial sequence of structs in a union). GCC and Clang document that union punning works as people expect. The strictly portable approach is memcpy between objects. Never use a union to keep a pointer alive after free.
FILE* vs file descriptor: when do you mix them?
The fd is the kernel object; FILE* buffers in libc. fileno and fdopen bridge them. Mixing read and fread without fflush/lseek coordination desynchronises the buffer. If you fdopen, fclose closes the fd — do not close it again (fdsan will punish you on Android). Use raw fds for ioctl, mmap, poll loops and Binder-adjacent code.
How does errno work? Why is it not a plain global?
Failing POSIX functions typically return -1 or NULL and set a positive E* code. Success does not clear errno. Modern libc makes errno a macro that refers to thread-local storage so two threads do not clobber each other. Look at it only when the function said it failed. Save it before you log, because logging can clobber it. strerror is not reliably thread-safe; prefer strerror_r.
Explain Make at interview level: targets, dependencies, phony, variables.
A rule is target: prerequisites plus a recipe. Make rebuilds the target if it is missing or older than a prerequisite. .PHONY marks a name that is not a file (clean). $@ is the target, $< the first prerequisite, $^ all of them. AOSP uses Soong/Android.bp; Make is still the mental model and appears in small native projects.
What gdb commands do you actually use after a crash?
bt first. Then frame N, info locals, print, x/16xb ptr to dump memory, info registers. watch *addr for who-writes-this. You need -g and preferably an unstripped binary. Optimised builds skip around; that is normal. On Android start with the tombstone backtrace; attach lldb/gdbserver if you need live state.
ASan vs UBSan vs LSan vs Valgrind — when do you pick each?
ASan: OOB and UAF, needs a rebuilt binary, ~2× memory. UBSan: language UB (overflow, bad shift), cheap, rebuild. LSan: leaks at exit, often with ASan. TSan: races, heavy, all code instrumented. Valgrind memcheck: no rebuild, very slow, excellent on host, awkward on Android user builds. Kernel memory bugs need KASAN, not ASan — see kernel & BSP.
What is a pthread mutex vs a condition variable?
A mutex gives mutual exclusion: one holder, unlock happens-before the next lock, so data touched under the lock is visible. A condvar lets a thread wait for a condition: always wait in a while (!pred) loop with the mutex held; the signaler changes the pred under the same mutex and then signals. Spurious wakeups happen. Do not use a condvar as a semaphore without a predicate.
memory_order_relaxed vs acquire/release — practical rule?
Relaxed: atomicity of that location only; no publication of other memory. Use for independent counters. Release store: "all my earlier writes become visible to someone who acquire-loads this flag." Acquire load: "I see the writer's payload." Default seq_cst if you do not want to think. Never publish a pointer to a struct with a relaxed store of the pointer.
Name three differences between bionic and glibc.
Bionic is smaller: not the full POSIX/glibc surface (locale, NSS, some pthread extras). The dynamic linker is Android's linker64 with library namespaces, not ld-linux. Android has fdsan; glibc does not. Allocators differ (jemalloc-derived / scudo vs historical ptmalloc). Do not assume glibc-only functions exist on the device.
What is fdsan?
Android's file-descriptor sanitizer. libc tags fds with an owner. Closing an fd the runtime believes another owner still holds, or using a closed fd, aborts the process. It catches "I closed the fd I passed to fdopen" and "I closed an fd the framework still owns". Fix ownership, do not disable fdsan to hide the bug.
What is JNIEnv and why can you not cache it across threads?
JNIEnv is a per-thread pointer to the JNI function table (and VM thread state). Another thread has a different env. Caching it in a global and using it from a worker is a crash. A thread you created must AttachCurrentThread before JNI and DetachCurrentThread before it exits. The Java-side native story is on the Java page.
Why does FindClass fail from a native worker thread?
From a thread attached natively, FindClass uses the system class loader, which cannot see app classes. Cache a global jclass (or a ClassLoader object) while you are still in a JNI call that arrived from Java with the app loader. Also check exceptions: a failed FindClass leaves a pending exception you must handle before more JNI.
Advanced
What is pointer provenance, and why can two pointers with the same address not be interchangeable?
In the C abstract machine a pointer is tied to the object it was derived from, not just to an integer address. Using a pointer invented from an integer, or one that came from a different object, to access another object can be UB even if the bits match. Compilers use this to assume that a pointer derived from a cannot reach b. Interview answer: "address equality is not a full story; do not manufacture pointers; uintptr_t round-trips are for tagging, not for forging access to a sibling object."
What is a trap representation?
A bit pattern that is not a valid value of the type. Loading it can be UB. Modern two's-complement int usually has no trap representations; the idea still matters for some padding bits and for "I memcpy'd garbage into an enum / pointer." Uninitialised automatic objects can behave as if they had a trap or an unstable value. Initialise, or copy bytes via unsigned char.
Why is overlapping memcpy undefined?
memcpy is specified with restrict: the implementation may read all of the source and write all of the destination as if they were distinct. If they overlap, those assumptions fail (torn copies, vectorised wrong direction). memmove is specified to handle overlap. If you do not know, call memmove.
Flexible array vs trailing [0] vs [1] — what do you say in an interview?
C99 FAM T data[] is the language feature: sizeof excludes it, allocate with offsetof. GNU data[0] is an extension used in older kernels. data[1] is the pre-C99 hack and overstates sizeof by one element, which people then subtract. New userspace C: FAM. Kernel C: you will still see all three — language history, not a new algorithm (kernel internals stay on kernel & BSP).
Bitfield layout, endian and portability?
Which end of the storage unit gets bit 0, whether fields straddle units, and how they interact with endianness are implementation-defined. Two compilers or -fshort-enums-style flags can disagree. Fine for private flags. For a register map or a modem payload, use explicit shifts and masks on a uint32_t you serialise yourself.
Packed structs and unaligned loads on ARM?
Packed members need not be naturally aligned. The compiler emits safe accesses if it knows the pointer came from a packed struct. If you take &s->unaligned_u32 and pass it as uint32_t * to a function compiled as aligned, ARM may fault or tear. Fix: memcpy into an aligned local, or read bytes. This is a common "works on x86" HAL bug.
Why is volatile not a memory barrier and not atomic?
volatile constrains the compiler's treatment of that lvalue only. A 64-bit store can still tear. Surrounding non-volatile writes can still move relative to it in ways that surprise you (more so at the CPU). Another thread racing on a plain volatile int is still a data race. Use atomic_store_explicit / mutexes. Leave volatile for MMIO and signals.
What does the compiler assume about restrict, and how do you break it?
It assumes that stores through one restrict pointer are invisible to loads through another restrict pointer (and through unrelated pointers) during that invocation. Overlapping buffers, or writing the same object via a global and a restrict parameter, let it generate a wrong vectorised copy. The bug is silent. Mark only true non-aliasing parameters; otherwise omit restrict.
What are setjmp/longjmp, and why are they dangerous?
They implement a non-local jump: setjmp saves a stack context, longjmp restores it. Locals not marked volatile can be stale after a jump back. You must not longjmp into a function that has already returned. There is no automatic cleanup — in C++ that skips destructors (see C++). Prefer explicit error returns and goto cleanup in C APIs.
What may a signal handler do? What is async-signal-safe?
A handler can interrupt almost any code, including malloc internals. The safe subset is small: write to an fd, sig_atomic_t / C11 lock-free atomic flags, a few listed POSIX functions. Do not call printf, malloc, or locks. The usual pattern is "set a flag, return"; the main loop handles the work. Android also delivers some signals for crashes (debuggerd) — do not install clever handlers that allocate.
PIC, PIE, GOT and PLT at interview depth?
Position-independent code uses relative addressing so a .so (and a PIE executable) can load at any address. Global data goes through the GOT (a table of addresses the loader fills). External calls often go through the PLT (a trampoline that resolves on first call, then jumps). -fPIC is required for shared objects. This is the cost of dynamic linking and of ASLR, not a DSA topic.
What are weak symbols and interposition?
A weak definition can be overridden by a strong symbol of the same name at link or load time. Used for default implementations you expect a vendor library to replace, and for hooks. Interposition (LD_PRELOAD, linker namespaces) can replace strong symbols too, which is why hidden visibility and symbol versioning exist on Android. Do not rely on weak aliases as a security boundary.
How does AddressSanitizer's shadow memory work?
ASan maps a compact shadow byte for every 8 bytes of application memory. Poisoned shadow means "this is redzones, freed, or out of bounds." Each load/store is instrumented to check the shadow. Freed heap is poisoned and often put on a quarantine so UAF still hits poison. That is why ASan needs extra RAM and a rebuild, and why it can miss bugs in uninstrumented assembly or a different allocator.
Use-after-free vs double-free — how do sanitizers find them?
UAF: the slot is poisoned after free; a later access trips ASan/Valgrind. Double-free: the allocator or ASan sees a free of a pointer already on the freelist / not marked allocated. Both are UB even if they "sometimes work" because the bytes still look intact. Debug with the sanitizer stack of the free and the use; gdb watchpoints if you cannot rebuild.
memory_order_seq_cst vs acq_rel: when do you pay?
seq_cst puts all sequentially-consistent operations in a single total order, which is easier to reason about and can require extra fences (especially on ARM for stores). acq_rel on an RMW only pairs with that location's acquire/release story; it does not automatically order unrelated seq_cst-free atomics the way people hope. Use seq_cst as a default for small flags; drop to acq_rel/release-acquire when you have measured and can draw the happens-before edges.
What is false sharing?
Two threads write different variables that live on the same cache line. The line ping-pongs between cores even though there is no data race on the same object. Fix: align/pad hot atomics to the cache-line size (typically 64 bytes) or keep writers on separate lines. This is a performance bug that looks like "atomics are slow" in a profiler.
How would you implement a SPSC ring buffer with C11 atomics?
One producer, one consumer, power-of-two size. Producer writes the slot, then release-stores the write index. Consumer acquire-loads the write index, reads the slot, then relaxed/release-stores the read index. Do not let the two indices live on one cache line if this is hot. No mutex. If you need MPMC, do not invent it on a whiteboard — use a mutex or a known algorithm and state the hazards (ABA).
JNI local vs global vs weak global references?
Local refs are valid until the native method returns (or you pop a frame / delete). They overflow if you allocate in a loop. Global refs keep the object alive and must be DeleteGlobalRef'd. Weak global refs do not keep it alive; you must promote carefully and handle a cleared ref. Never store a local ref in a C global for later. Java GC details: Java.
GetStringUTFChars vs NewStringUTF — encoding traps?
Java strings are UTF-16. JNI "UTF" is modified UTF-8: U+0000 and supplementary characters are encoded differently from standard UTF-8. Round-tripping arbitrary Unicode or binary through these APIs corrupts data. For bytes use jbyteArray. Always pair Get with Release, check NULL, and check exceptions. Prefer GetStringCritical only for short, no-JNI, no-blocking regions — it can pin or copy and has strict rules.
Why can kernel C not use libc?
The kernel is not a POSIX process: no user-space loader, no heap that malloc knows about, no errno, small stacks, and a rule against floating point in many contexts. It has its own allocators (kmalloc), printing (printk) and string helpers. That is policy and environment, not "C is different in the kernel." Full story: Linux kernel & BSP.
Legacy HAL C APIs vs AIDL — how do you talk about them in C?
Legacy: a shared object exports HAL_MODULE_INFO_SYM, hw_module_t.open returns an hw_device_t whose ops are a C function-pointer table. Calls are in-process or through an old wrapper. Treble: the HAL is a Binder server; you implement an AIDL interface (C++ NDK stubs are still "struct of methods" plus parcels). Prefer AIDL over a new ioctl ABI. IPC details: Binder & AIDL; framework callers: Android frameworks.
How do you document ownership in a C API?
Every pointer in the prototype needs a sentence: caller allocates / callee allocates; who frees; may be NULL; borrowed vs stolen. Use names (out_, _owned) plus a comment. Error paths must free what they allocated — goto cleanup is idiomatic. C has no unique_ptr; the comment is the contract. Interviewers read it before they read the loop.
Write strlen on the whiteboard. What do you say while coding?
Walk unsigned char or char until '\0', return the count as size_t. State that a missing terminator is UB if you do not have a bound — then the real API is memchr(s, 0, cap). libc may read a word at a time; you write the obvious loop. NULL input is UB in the C standard for strlen; ask whether they want a defensive check.
size_t my_strlen(const char *s)
{
const char *p = s;
while (*p) p++;
return (size_t)(p - s);
}Write memcpy. What must you mention besides the loop?
Prototype with restrict. Copy bytes via unsigned char *. Return dst. Overlap is UB — send them to memmove (if d is after s in the same object, copy backwards). Real libc aligns and copies words; do not pretend your loop is that. n == 0 is a no-op. NULL with n > 0 is UB.
void *my_memcpy(void *restrict d, const void *restrict s, size_t n)
{
unsigned char *cd = d;
const unsigned char *cs = s;
while (n--) *cd++ = *cs++;
return d;
}How do you allocate count * size without overflowing?
If size != 0 && count > SIZE_MAX / size, fail before calling malloc. A wrapped product makes a small allocation and a later write smashes the heap — a security bug. calloc is supposed to check, but you still think about it. The same check applies to realloc and to FAM sizing: offsetof + count * elem.
What is va_list, and why can you not portably inspect the stack?
stdarg.h macros walk the argument area in an ABI-specific way. You must have a prototype with an ellipsis, start with va_start on the last named parameter, and va_end. You cannot assume arguments are on the stack in order (registers, spilling). Passing the wrong type after default promotions is UB. Prefer explicit counted arrays over varargs in new HAL APIs.
Why does enabling LTO or -O2 suddenly break "working" code?
Link-time optimisation inlines across units and applies the same UB assumptions more aggressively. Signed overflow checks, NULL checks after a dereference, and type-pun loads that "worked" at -O0 get deleted. The program was always undefined; the optimiser just started believing the standard. Reproduce with -O2 -flto, then UBSan/ASan, then fix the UB — do not add volatile as a superstition.
Why is strerror not thread-safe, and what do you use instead?
Classic strerror writes a shared buffer. Two threads can interleave and see a garbled or swapped message. errno itself is thread-local, but the string helper may not be. Use strerror_r (POSIX or GNU variant — check which) or format the integer. On Android, prefer the bionic-documented thread-safe form and still save errno before any other call.
Scenario & debugging
The program crashes only after a free. How do you debug it?
Classic UAF: something still holds the pointer. Rebuild with ASan; the report gives the use stack and the free stack. If you cannot rebuild, gdb watch on the chunk or a debug malloc that paints freed memory (0xDD). Check all error paths for a free that the success path also does. On Android, also check that you did not free a buffer still owned by a HAL or a Binder parcel.
It works with -O0 and crashes with -O2. What do you suspect?
Undefined behaviour that the optimiser exploited: signed overflow, strict aliasing, an uninitialised variable, a missing sequence, a NULL check after dereference, or a race that only appears when the compiler keeps a register copy. Rebuild with UBSan and ASan at -O2, read the warning set (-Wall -Wextra), and look at the crashing expression, not at "the optimiser is buggy".
Intermittent crash on device, never on the host. Where do you start?
List the deltas: ARM vs x86 (unaligned access, char signedness, weaker memory model), bionic vs glibc, fdsan, 32-vs-64 if any, and real concurrency on a big.LITTLE phone. Enable ASan/UBSan in an NDK debug build, capture the tombstone, and check for missing acquire/release on a flag. Host is TSO and often signed char; the device is not your laptop.
How do you hunt a native leak on Android?
LSan/ASan on a debug build for leaks that survive until exit. For long-running processes (system_server, a HAL), use heap snapshots, malloc debug (libc.debug.malloc / heapprofd / Perfetto), and ask whether the "leak" is a cache. Check JNI global refs: they leak Java objects and native peer structs together. Do not confuse still-reachable singleton buffers with a true leak.
How do you tell stack overflow from a heap smash?
Stack overflow: huge locals or deep recursion; tombstone often in a guard page just below the stack; frames look smashed near the current function. Heap smash: ASan redzone, corrupted malloc metadata, crash in malloc/free, or a wild pointer far from the stack. ulimit -s / thread stack size matters on native threads you created. VLAs and alloca of untrusted sizes are suspects.
JNI reports "local reference table overflow". What did you do?
You created local refs in a loop (NewObject, FindClass, GetObjectArrayElement) and never deleted them or popped a frame. The default table is small (a few hundred). DeleteLocalRef each iteration or PushLocalFrame/PopLocalFrame. Returning to Java clears the table; a long-lived native loop never does. This is a C-side bug even though the table is in the VM.
FindClass returns NULL in a worker thread you attached. What next?
Assume the system class loader and a pending exception. Check ExceptionDescribe/ExceptionClear. Cache a global jclass from a JNI entry that ran on a Java thread with the app loader, or cache the app ClassLoader and call loadClass. Also verify the class name uses slashes (com/foo/Bar) not dots. Java-side registration: Java.
A Java string became garbage in C, or C bytes became garbage in Java. Why?
Most often modified UTF-8 vs real UTF-8 vs UTF-16, or treating binary as a string. Use jbyteArray for bytes. Check that you called the matching Release function and that you did not write through a pointer after Release. Embedded NUL in modified UTF-8 is a special encoding; strlen on JNI UTF is the wrong length API.
The process aborted with an fdsan message. How do you fix it?
Someone closed an fd the runtime tagged as owned by someone else: double close, close after fdopen (let fclose own it), or closing an fd you handed to another component. The abort includes the owner tag and a stack. Fix the ownership contract; do not close fds you do not own. This is Android-specific; glibc would have silently reused the number and corrupted a later client.
ASan or TSan printed a report. How do you read it?
ASan: the access type, the address, the allocation stack, the free stack (for UAF), and the offending thread. TSan: two stacks that raced and the location. Symbolise with llvm-symbolizer / ndk-stack if you only have PCs. Fix the lifetime or add the missing lock/atomic; do not "make the race smaller" with volatile.
The linker says "multiple definition of foo". What did you do?
Two non-static definitions of the same external symbol: a function body in a header, int g; in a header under -fno-common, or linking the same object twice. Fix: declarations in the header, one definition in one .c, or static inline / internal linkage. Check you did not add the same source to two libraries that then both link into the binary.
The linker says "undefined reference to foo". What did you miss?
A declaration without a definition, a missing object or library on the link line, C++ name mangling if one side is C++ without extern "C" (see C++), or a static function you tried to call from another unit. Link order can matter with static archives: the archive must come after the objects that need it. On Android, also check the shared-lib is in the right linker namespace.
You have an include cycle or a mysterious missing type. How do you structure headers?
Forward-declare struct foo; when you only need a pointer. Include the full header only where you need the layout. Guards stop double-paste, not cycles of incomplete types. Split "public API" headers from "impl" headers. If two headers each need the other's size, the types are too entangled — introduce an opaque pointer.
A log line was truncated and a later parser broke. snprintf?
Check the return value against the buffer size. Truncation still NUL-terminates (if size > 0) but the semantic payload is incomplete. Size the buffer from the return, or fail closed. Do not use sprintf. If you chained two snprintf calls, the second must use the remaining space, not sizeof buf again.
malloc(n * size) succeeded but the later write smashed the heap.
The multiplication wrapped: you allocated a tiny block and wrote n * size bytes. Check n > SIZE_MAX / size (when size != 0) before allocating. Treat this as a security defect. Same bug exists in FAM size expressions and in realloc growth: new_cap = old_cap * 2 can wrap.
You get SIGBUS on ARM when reading a protocol header.
Likely an unaligned load: a packed struct, a cast of a byte pointer to uint32_t *, or a DMA buffer with a misaligned offset. x86 would have tolerated it. memcpy into an aligned local, or read bytes and shift. Check the ABI alignment of the type and whether the buffer came from the network or a file with no padding.
Host and device disagree on a packed protocol struct.
List padding (did both sides pack?), endian, char signedness, enum size, and bitfield order. Dump sizeof and offsetof on both. Then stop using a raw struct as the protocol: write a serialiser with uint8_t / uint32_t and explicit endian helpers. If this crosses Binder, you wanted AIDL — Binder & AIDL.
A vendor HAL process dies and the framework sees a death notification. Where do you start?
Tombstone / logcat in the HAL process first: native crash, fdsan abort, SELinux deny, or an assert. Then the last Binder transaction (onTransact / AIDL method). Check thread-pool exhaustion and TransactionTooLarge. Framework-side death is a symptom; the C/C++ HAL is the patient. How death is delivered: Binder & AIDL. Who called: Android frameworks.
In gdb, how do you find who freed this pointer?
If you can rebuild: ASan already has the free stack. If not: a watchpoint on the first word of the chunk, or a breakpoint on free with a condition on the argument. Debug allocators log a stack at free. After the fact, a tombstone plus maps only tells you it was heap; you need a reproducer. Do not guess from "it looks like a libc address".
Valgrind is not available on the device. What do you use instead?
NDK ASan/UBSan/LSan builds, heapprofd / malloc debug, tombstones, and host-side Valgrind on a unit-test binary compiled against a host libc (knowing bionic differences). Kernel issues: KASAN, kmemleak — kernel & BSP. "We only have logcat" is not a strategy; ask for a sanitizer build flavour.
The interviewer asks you to write strlen and memcpy on a whiteboard. How do you run the room?
Write prototypes first. List edge cases out loud: n == 0, overlap (memcpy vs memmove), NULL (standard says UB), signed char if they asked for a byte API, and a missing NUL if the buffer is bounded. Code the obvious loops in unsigned char. State O(n) / O(1). Mention real libc does word-wise copies. Do not wander into graph algorithms — those are on DSA.
You are designing a C API for a HAL. How do you talk about ownership and errors?
Every pointer is borrowed, copied, or stolen — write it in the comment. Return 0 / -errno or an explicit status enum; do not mix errno-on-success. No hidden threads unless documented. Prefer AIDL types over raw structs across processes. Cleanup with goto so every allocate has one free. Version the ABI. If the framework will call you, stay off the Binder thread for long work — Binder & AIDL.