This page is for anyone facing a C round, from a first job to a senior systems role. Most C interviews start with pointers and arrays, move to malloc and free, stack and heap, then test storage classes, const, structs and unions, bit tricks and macros. Strong rounds end on undefined behaviour and a short coding task done by hand, like reversing a string. Senior rounds add a debugging story and a judgement call. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Pointer: a variable whose value is the address of another object, with a type that says what lives there.
The ampersand: &x gives the address of the object x.
The star: in a declaration it makes a pointer type; in an expression *p follows the address to the object.
"A pointer is just a variable that holds an address instead of a normal value, and its type tells the compiler what kind of object sits at that address. The ampersand gives me the address of something, so if I write int *p = &x, p now holds where x lives in memory. The star does two jobs. In a declaration it means this is a pointer. In an expression it dereferences, so *p means the int that p points to, and writing *p = 20 changes x itself. The pointer's type matters because it tells the compiler how many bytes to read and how far to move if I do arithmetic on it. The main rule I keep in mind is that a pointer must point at something valid before I dereference it."
int x = 10;
int *p = &x; // p holds the address of x
*p = 20; // write through p: x is now 20
printf("%d %d\n", x, *p); // prints 20 20
Mixing up the pointer and the value it points to, for example saying p is 20 after *p = 20.
Scaling: adding 1 moves by sizeof the pointed-to type, so p + 1 is the next int.
Indexing: a[i] is defined as *(a + i).
Subtraction: p - q gives a count of elements, only valid inside the same array.
Limits: only within the array or one past its end.
"Pointer arithmetic counts in elements, not bytes. If p is an int pointer and an int is four bytes on my machine, p + 1 is four bytes further on, which is exactly the next int in an array. That's how the compiler turns a[i] into *(a + i): it scales i by the size of the element for me. Subtracting two pointers works the same way, so if p points at a[5] and q at a[2], p minus q is 3, not 12. There are limits, though. Arithmetic is only defined while the pointer stays inside the same array, or points one past the end, which is what makes loops with an end pointer legal. Going further, or subtracting pointers from two different arrays, is undefined behaviour. And you can't do standard arithmetic on a void pointer, because void has no size."
int a[5] = {10, 20, 30, 40, 50};
int *p = a; // points at a[0]
p = p + 2; // moves 2 * sizeof(int) bytes: now at a[2]
printf("%d\n", *p); // 30
printf("%td\n", p - a); // 2: a count of elements, not bytes
Saying p + 1 always moves one byte, or that you can freely walk a pointer before the start of an array.
Decay: in most expressions an array name turns into a pointer to its first element.
Exceptions: not with sizeof, not with unary &, and not for a string used to initialise a char array.
Differences: an array owns its storage and can't be reassigned; a pointer is a separate variable.
Parameters: int a[] in a parameter list is really int *a.
"It's only half true. An array is a block of storage, and a pointer is a variable that holds an address. The confusion comes from decay: in most expressions an array name is converted to a pointer to its first element, which is why I can pass an array to a function or index a pointer. But there are exceptions. sizeof on an array gives the size of the whole array, and &arr gives a pointer to the whole array, with a different type from a pointer to the first element. An array also can't be assigned to or pointed somewhere else. The classic bug is inside a function: a parameter written as int a[] is really int *a, so sizeof a gives the size of a pointer, not the array. That's why C functions that take arrays also take a length."
#include <stdio.h>
void show(int a[]) { // really: int *a
printf("%zu\n", sizeof a); // size of a pointer
}
int main(void) {
int arr[10];
printf("%zu\n", sizeof arr); // 10 * sizeof(int)
show(arr); // arr decays to &arr[0]
return 0;
}
Saying arrays and pointers are identical, or using sizeof on an array parameter to get its length.
Null: deliberately points nowhere; check before dereferencing.
Dangling: points at memory that was freed or went out of scope.
Wild: never initialised, so it holds a random address.
Habits: initialise every pointer, set it to NULL after free, never return a local's address.
"A null pointer is one I've set to NULL on purpose to say it points at nothing. Dereferencing it is undefined behaviour, which on most systems means a crash, so I check for NULL, especially after malloc or fopen. A dangling pointer used to point at something valid, but that memory is gone: I freed it, or it was a local variable in a function that has returned. The pointer still holds the old address, so the bug can stay hidden until the memory gets reused. A wild pointer was never initialised at all, so it holds whatever was on the stack. My habits are simple. I initialise every pointer, either to a real address or to NULL. After free I set the pointer to NULL if it's still in scope. And I never return the address of a local variable."
Believing that free sets the pointer to NULL, or that a dangling pointer always crashes straight away.
Declaration: int (*op)(int, int) is a pointer to a function taking two ints and returning an int.
Uses: callbacks, comparators for qsort, tables of handlers instead of long switch statements.
Typedef: a typedef makes the declarations readable.
"A function pointer holds the address of a function, so I can decide at run time which function to call. The syntax is the tricky part: int (*op)(int, int) is a pointer to a function that takes two ints and returns an int. The brackets around *op matter, because without them it declares a function that returns a pointer. The most common place I've used one is qsort. The standard library doesn't know how to compare my data, so I hand it a comparator function. I've also used arrays of function pointers as a dispatch table, where a command code indexes straight into the handler to call, which is cleaner than a long switch. In bigger code I put a typedef on the function type so the declarations stay readable."
#include <stdlib.h>
static int cmp_int(const void *a, const void *b) {
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y); // avoids overflow that x - y could cause
}
void sort_ints(int *arr, size_t n) {
qsort(arr, n, sizeof arr[0], cmp_int);
}
Writing int *op(int, int) and calling it a function pointer, or never having used one outside a textbook.
By value: a function gets a copy of the pointer, so assigning to it changes nothing outside.
Fix: pass the address of the pointer, then write through it.
Other uses: argv, arrays of strings, functions that allocate memory for the caller.
"C passes every argument by value, including pointers. If I pass a list's head pointer to a function and assign a new node to it inside, I only change the function's copy, and the caller's head stays the same. So when a function must change which address the caller's pointer holds, I pass a pointer to that pointer. For pushing onto the front of a list, I allocate the node, point its next at the current head, and then write the new node into *head, which updates the caller's variable. The same pattern shows up when a function allocates a buffer and hands it back through a parameter, and in argv, which is an array of char pointers. The call site makes it visible, because the caller writes push with an ampersand in front of the list."
#include <stdlib.h>
struct node { int val; struct node *next; };
int push(struct node **head, int val) {
struct node *n = malloc(sizeof *n);
if (n == NULL) return -1;
n->val = val;
n->next = *head;
*head = n; // changes the caller's pointer
return 0;
}
// usage: struct node *list = NULL; push(&list, 5);
Passing the head by value and wondering why the list stays empty, or forgetting to check what malloc returned.
malloc: a block of the given size, contents not initialised.
calloc: count and size, memory set to all-bits zero.
realloc: grows or shrinks a block, may move it and copies the old contents.
free: gives the block back; every allocation needs exactly one free.
"All three allocate on the heap and return NULL if they fail, so I always check. malloc takes a size in bytes and gives me memory with whatever garbage was there, so I use it when I'm about to fill the memory myself. calloc takes a count and an element size and returns memory set to zero. I use it when I want a clean array or struct, and because the count and size come in separately, a good library checks that multiplying them doesn't overflow. realloc resizes an existing block. It may extend it in place or move it to a new address and copy the old contents over, so any other pointers into the old block become invalid. free returns the block to the allocator. The rule is one free for every successful allocation, and never touch the memory afterwards."
Assuming malloc returns zeroed memory, or never checking for NULL.
The bug: if realloc fails it returns NULL and you have just lost your only pointer to the old block.
Safe pattern: assign to a temporary, check it, then replace the original.
Moves: on success the block may move, so every other pointer into it is stale.
Growth: grow by a factor, not by one, to avoid copying on every insert.
"If realloc can't find the memory, it returns NULL but leaves the original block untouched. With p = realloc(p, n), that NULL overwrites my only pointer to the old block, so I've leaked it and lost the data in one line. The safe pattern is to assign to a temporary first, check it for NULL, and only then assign it back. If it failed, the old buffer is still valid, so I can report the error or free it cleanly. Two other things matter. On success the block may have moved, so any pointers I kept into the middle of it are now dangling; I keep indexes instead of pointers if I need them. And when growing a dynamic array I usually double the capacity rather than add one element at a time, so the copying cost stays low overall."
int grow(int **arr, size_t *cap) {
size_t new_cap = *cap ? *cap * 2 : 16;
int *tmp = realloc(*arr, new_cap * sizeof **arr);
if (tmp == NULL) {
return -1; // *arr is still valid and still owned by the caller
}
*arr = tmp;
*cap = new_cap;
return 0;
}
Writing p = realloc(p, n) without seeing the leak, or keeping raw pointers into a buffer that gets reallocated.
Stack: local variables, freed automatically when the function returns; fast but limited in size.
Heap: malloc memory, lives until you free it; bigger, but you own the cleanup.
The bug: a local's lifetime ends at return, so its address dangles.
Fixes: caller passes a buffer, return malloc memory, or use static with care.
"Local variables have automatic storage, which in practice means the stack. They're created when the function is entered and gone when it returns, so they cost almost nothing but they can't outlive the call, and the stack is fairly small, so big arrays don't belong there. Heap memory from malloc lives until I call free, which suits data whose size I only know at run time or that has to outlive the function. So if I return the address of a local array, the caller gets a pointer to memory whose lifetime has ended. It may even seem to work until the next function call overwrites that stack space. I have three fixes. My favourite is to let the caller pass in a buffer and its size. Or I return malloc memory and document that the caller frees it. A static local also works, but every call then shares one buffer, which isn't thread-safe."
Saying the returned local address is fine because the value is still there when you print it.
What free does: returns the block to the allocator; the pointer keeps the old address.
Double free: corrupts the allocator's bookkeeping; undefined behaviour, often a crash far away.
Use after free: reads or writes memory that may now belong to someone else.
Defences: clear ownership, NULL after free, sanitizers in testing.
"free hands the block back to the allocator, and that's all. It doesn't change my pointer, so the pointer still holds the old address, and it doesn't clear the memory. free on a NULL pointer is safe and does nothing. A double free is undefined behaviour. In practice the allocator keeps its own bookkeeping in and around freed blocks, so freeing twice can corrupt that and crash much later in unrelated code. Use after free is worse because it's quiet. The block may already be handed out to another part of the program, so my write silently changes someone else's data, and attackers have used exactly this to take over programs. My defences are a clear owner for every allocation, setting the pointer to NULL right after free so a second free is harmless, and running tests under AddressSanitizer or Valgrind, which report these at the exact line."
Thinking free sets the pointer to NULL, or that a use after free is harmless if nothing crashes.
Confirm: watch memory over time and tie the growth to a kind of traffic or event.
Reproduce: replay that traffic in a test setup and run the service under a leak detector.
Other causes: caches with no limit, fragmentation, lists that are never cleared.
Fix and prevent: fix the owner of the lost allocation, add a leak check to CI.
"First I'd confirm it's really a leak. I'd graph the process memory over days and look for what it tracks: request count, a certain kind of request, errors or reconnects. Growth that only climbs on error paths is a strong hint, because error paths are where frees get skipped. Then I'd reproduce it outside production by replaying that traffic against a build running under Valgrind or with LeakSanitizer, which reports each allocation that's never freed along with the stack that made it. The stack usually points straight at the owner. I'd also keep an open mind, because not all growth is a leak. It can be a cache with no size limit, a list that's never trimmed, or heap fragmentation. Once fixed, I'd add a leak check to the test run and keep the memory graph with an alert, so the restart stops being the fix."
Accepting the scheduled restart as the solution, or guessing at code changes without measuring.
auto: the default for locals; lives for the block.
register: a hint to keep it in a register; you can't take its address; compilers mostly ignore it.
static: a local keeps its value between calls; at file scope it hides the name from other files.
extern: declares something defined elsewhere, usually in another file.
"Storage classes decide how long a variable lives and who can see it. auto is the default for local variables: created when the block starts, gone when it ends, and nobody writes it out. register was a hint to keep the variable in a CPU register. You can't take its address, and modern compilers make their own choices, so it's mostly historical. static has two meanings. On a local variable it gives static lifetime, so it's initialised once and keeps its value between calls, which is handy for a counter. On a global variable or function it gives internal linkage, so the name is private to that source file. extern declares a variable or function that's defined somewhere else, usually in another file, without creating storage. Functions are extern by default."
int next_id(void) {
static int id = 0; // initialised once, keeps its value between calls
return ++id;
}
static int cache_hits; // file scope: invisible to other source files
Saying static at file scope means the value never changes, or mixing up lifetime and visibility.
Definition: exactly one .c file creates the variable, with an initialiser if needed.
Declaration: the header says extern, which promises it exists without creating storage.
Linker: each file compiles alone; the linker ties uses to the one definition.
"I'd put the definition in exactly one source file, say config.c, where I write int log_level = 1. That's the line that creates storage. In config.h I write extern int log_level, which is only a declaration: it tells every file that includes the header that the variable exists and what type it is, without creating another copy. Each source file compiles on its own, and the linker then connects every use to the one definition. If I put the definition itself in the header, every file that includes it gets its own definition, and the build typically fails with a multiple definition error at link time. If nobody defines it, I get an undefined reference instead. I'd also ask whether it needs to be global at all; often a static variable with a getter and setter function is easier to reason about."
/* config.h */
extern int log_level; /* declaration: no storage */
/* config.c */
#include "config.h"
int log_level = 1; /* the one definition */
/* main.c */
#include "config.h" /* uses log_level; the linker finds it */
Putting the initialised definition in the header, or not knowing the difference between a compile error and a link error.
Pointer to const: const int *p can move, but can't change the value through it.
Const pointer: int *const p can change the value, but can't point elsewhere.
Both: const int *const p locks the pointer and the value.
Parameters: const on the pointed-to data tells callers the function only reads it.
"I read it right to left from the name. const int *p is a pointer to a const int, so I can move p to point somewhere else, but I can't change the value through p. int *const p is a const pointer to an int, so the value can change but p is fixed to one address. const int *const p locks both. For function parameters the first form is the useful one. A signature like size_t count(const char *s) promises the caller the function only reads the string, and the compiler enforces it. Putting const on the pointer itself in a parameter matters less, because the pointer is a copy anyway. One thing I'd add: in C a const variable isn't a true constant expression, so I can't use a const int as a case label; I'd use an enum or a define there."
Mixing up which part is constant, or thinking const makes the data read-only for every pointer that can reach it.
Alignment: each type likes to start at an address that is a multiple of its alignment.
Padding: the compiler adds gaps between members and at the end so arrays of the struct stay aligned.
Shrink: order members from largest alignment to smallest.
Packing: compiler-specific, can slow access; not a portable file format.
"Most CPUs access data fastest, or only safely, when it starts at an address that's a multiple of its size, like a four-byte int on a four-byte boundary. So the compiler inserts padding. If I have a char, then an int, then a char, on a typical platform with four-byte ints the int needs three bytes of padding before it, and the struct gets three more at the end so that in an array the next element's int is aligned too. That's twelve bytes for six bytes of data. Reordering so the int comes first and the two chars follow brings it down to eight. Compilers also offer packing pragmas or attributes, but they're not standard, and unaligned access can be slower or even fault on some CPUs. And because padding bytes hold unspecified values, I don't compare structs with memcmp or write them raw to a file."
struct loose { char a; int n; char b; }; // typically 12 bytes
struct tight { int n; char a; char b; }; // typically 8 bytes
Saying sizeof a struct always equals the sum of its members, or reaching for packing as the default fix.
Struct: every member has its own storage; size is at least the sum.
Union: all members share the same storage; size is at least the largest member.
Use: a tagged union, where an enum field records which member is live.
"In a struct every member gets its own space, one after another, so I can use all of them at once and the size is at least the sum of the members. In a union every member starts at the same address and shares the same bytes, so the size is roughly the largest member, and only one member holds a meaningful value at a time. Writing one member overwrites the others. The real use is saving memory when a value can be one of several types. The common pattern is a tagged union: a struct with an enum that says what kind of value this is, plus a union holding an int, a double or a string pointer. I used that for a small config parser, where each setting could be a number, a flag or text. The code always checks the tag before reading the union."
Saying a union stores all its members at once, or using one without any tag to know which member is valid.
Mask: 1u shifted left by n selects bit n.
Set, clear, toggle: OR with the mask, AND with the inverted mask, XOR with the mask.
Test: shift right by n and AND with 1.
Safety: use unsigned; shifting by the type's width or more is undefined.
"Everything starts from a mask, which is 1u shifted left by n, a number with only bit n set. To set the bit I OR the value with the mask. To clear it I AND with the inverted mask, so every other bit is kept and bit n becomes zero. To toggle it I XOR with the mask. To test it I shift the value right by n and AND with 1, which gives 1 or 0. I use unsigned types and write 1u, not plain 1, because shifting a 1 into the sign bit of a signed int is undefined behaviour. Shifting by the full width of the type or more is also undefined, so if n can come from outside, I check it's in range first."
unsigned int flags = 0;
unsigned int n = 3;
flags |= (1u << n); // set bit 3
flags &= ~(1u << n); // clear bit 3
flags ^= (1u << n); // toggle bit 3
unsigned int on = (flags >> n) & 1u; // test bit 3: 0 or 1
Using logical operators like && instead of bitwise ones, or clearing a bit with XOR.
Simple: test the lowest bit and shift, once per bit of the type.
Better: n & (n - 1) clears the lowest set bit, so loop once per 1 bit.
In practice: compilers offer a population count builtin that maps to one instruction on many CPUs.
"The simple version checks the lowest bit with n & 1, adds it to a count, and shifts n right until it's zero. That loops once per bit position. The better version uses the fact that n & (n - 1) clears the lowest set bit. Subtracting one flips the lowest 1 to 0 and all the zeros below it to 1s, so ANDing with the original wipes out just that one bit. If I loop until n is zero, I run once per set bit instead of once per bit position, which helps when numbers are sparse. The same trick checks for a power of two: n is non-zero and n & (n - 1) is zero. In production code I'd use the compiler's population count builtin, which many CPUs run as a single instruction, and keep this loop as the portable fallback."
unsigned int count_bits(unsigned int n) {
unsigned int count = 0;
while (n != 0) {
n &= n - 1; // clears the lowest set bit
count++;
}
return count;
}
Writing a loop on a signed int that shifts right and never ends for negative numbers.
XOR swap: a ^= b, b ^= a, a ^= b.
Arithmetic swap: a = a + b and so on, which can overflow a signed int.
Trap: if both pointers refer to the same variable, XOR swap sets it to zero.
Real code: a temporary is clearer and the compiler makes it just as fast.
"The usual answer is the XOR swap: a gets a XOR b, then b gets b XOR a, which gives back the original a, then a gets a XOR b, which gives the original b. There's also an add and subtract version, but with signed ints the addition can overflow, and signed overflow is undefined behaviour, so I'd avoid it. The XOR version has its own trap. If I write it as a function taking two pointers and both point at the same variable, the first step turns it into zero and it stays zero. So I'd check for that. Then I'd tell the interviewer I wouldn't use it in real code. A temporary variable is clearer, has no traps, and the optimiser usually produces the same or better machine code. It's a nice puzzle, not a performance trick."
void xor_swap(int *a, int *b) {
if (a == b) return; // same object: XOR would zero it
*a ^= *b;
*b ^= *a;
*a ^= *b;
}
Claiming the XOR swap is faster in modern code, or missing the same-address case.
Precedence: text substitution, so SQUARE(a + 1) becomes a + 1*a + 1; parenthesise everything.
Double evaluation: SQUARE(i++) evaluates i++ twice, which is undefined behaviour.
Inline: type-checked, arguments evaluated once, visible in the debugger.
Macros still win: include guards, conditional builds, file and line in logs, token pasting.
"A macro is pure text substitution before the compiler sees the code. So SQUARE(a + 1) expands to a + 1 * a + 1, which is 2a + 1, not the square. Wrapping the parameter and the whole body in brackets fixes that, but not the second problem: SQUARE(i++) expands to i++ times i++, which modifies i twice without sequencing, and that's undefined behaviour. Macros also have no type checking and don't show up nicely in a debugger. A static inline function fixes all three: the argument is evaluated once, the types are checked, and the compiler can still inline it for speed. I still use macros where only the preprocessor can do the job: include guards, conditional compilation for different platforms, logging that captures the file and line, and stringizing or token pasting. For multi-statement macros I wrap the body in do while zero so they behave like one statement."
#define SQUARE_BAD(x) x * x
#define SQUARE(x) ((x) * (x))
static inline int square(int x) { return x * x; }
// SQUARE_BAD(a + 1) -> a + 1 * a + 1 (wrong)
// SQUARE(i++) -> ((i++) * (i++)) (undefined behaviour)
// square(i++) -> argument evaluated once: fine
Saying macros are always faster than functions, or not knowing that SQUARE(i++) is undefined behaviour.
Approach: one index from each end, swap, move inward until they meet.
Edge cases: empty string, one character, even and odd lengths.
Cost: O(n) time, O(1) extra space.
Trap: the caller must pass writable memory, not a string literal.
"I find the length with strlen, then keep two indexes, one at the start and one at the last character. I swap those two characters and move both inward until they meet or cross. That's O(n) time and no extra memory. The edge case that bites people is the empty string. If the length is zero and I compute length minus one with size_t, it wraps around to a huge number, so I return early when the length is below two. For tests I'd use an empty string, one character, an even length like abcd, an odd length like abc, and a string with spaces. I'd also mention to the interviewer that the caller must pass a writable char array. Passing a string literal would compile but modifying it is undefined behaviour and usually crashes."
#include <string.h>
void reverse(char *s) {
size_t len = strlen(s);
if (len < 2) return;
for (size_t i = 0, j = len - 1; i < j; i++, j--) {
char t = s[i];
s[i] = s[j];
s[j] = t;
}
}
Computing len - 1 on an empty string, or testing the function on a string literal.
Array: a writable copy of the characters, including the terminating zero, owned by the variable.
Pointer: points at the string literal itself, which you must not modify.
Crash: literals are often kept in read-only memory, so the write faults.
Habit: declare literal pointers as const char *.
"With char s[] = hello, the compiler creates an array of six chars, the five letters plus the terminating zero, and copies the literal into it. That array belongs to me, so s[0] = 'H' is perfectly fine, and sizeof s is six. With char *s = hello, s is just a pointer to the string literal itself. Modifying a string literal is undefined behaviour, and on most systems literals sit in a read-only section, so writing s[0] gives a segmentation fault. sizeof s here is the size of a pointer. C lets me assign a literal to a plain char pointer for historical reasons, which is why this compiles without complaint. My habit is to write const char * for anything that points at a literal, so the compiler catches the bad write for me."
Saying both forms create a modifiable copy, or not knowing that string literals may be read-only.
Open: fopen with the right mode; check for NULL and report why with perror.
Loop: loop on fgets returning non-NULL, not on feof.
Why feof fails: it only turns true after a read has already failed, so the last line is handled twice.
Finish: check ferror, then fclose.
"I open the file with fopen in read mode and check for NULL straight away, printing the reason with perror, because a missing file or a permissions problem is normal and shouldn't crash. Then I loop on fgets itself: while fgets returns non-NULL, I have a line. fgets stops at the newline or when the buffer is full, and it keeps the newline, so a very long line comes back in pieces. After the loop I check ferror to tell a read error from a normal end of file, and I always fclose. The feof loop is a classic bug because feof doesn't look ahead. It only becomes true after a read has already hit the end and failed. So the loop runs one extra time, and that failed read's leftover buffer usually gets processed twice, which shows up as a duplicated last line."
#include <stdio.h>
int print_file(const char *path) {
FILE *fp = fopen(path, "r");
if (fp == NULL) {
perror(path);
return -1;
}
char line[256];
while (fgets(line, sizeof line, fp) != NULL) {
fputs(line, stdout);
}
int failed = ferror(fp);
fclose(fp);
return failed ? -1 : 0;
}
Not checking fopen for NULL, or driving the loop with feof.
Risk: input you don't control can be any length; an overflow corrupts memory and can be exploited.
Fix: check the length, or use snprintf with the buffer size; know that strncpy may not add the terminator.
Tone: explain the risk with a concrete input, suggest the change, don't just block.
"I wouldn't approve it. The author may be right about normal users, but the input comes from outside, so an attacker or a buggy client can send anything. strcpy copies until it finds a zero byte and knows nothing about the buffer size, so a long name writes past the end, corrupts whatever sits next to it, and in the worst case lets someone control the program. I'd suggest checking the length first and rejecting names that are too long, or copying with snprintf using sizeof the buffer, which always terminates the string and tells you if it was cut short. I'd mention that strncpy isn't a drop-in fix, because it doesn't add the terminating zero when the source fills the buffer. I'd leave the comment with a concrete example input so it's clearly not a style point, and offer to help with the change."
Accepting it because names are usually short, or recommending strncpy with no mention of the missing terminator.
Meaning: the standard sets no requirements at all on what happens.
Examples: signed overflow, out-of-bounds access, null dereference, use after free, i = i++.
Why it bites: the optimiser assumes it never happens and reasons from that.
Related terms: implementation-defined and unspecified behaviour are different, and tamer.
"Undefined behaviour means the C standard puts no requirements on what the program does. It might crash, give the expected answer, or do something odd far away. Common examples are signed integer overflow, reading past the end of an array, dereferencing a null or freed pointer, reading an uninitialised local, and modifying a variable twice without sequencing, like i = i++. The reason it can work today and break tomorrow is that the optimiser is allowed to assume it never happens. So if I write a loop that relies on a signed int wrapping to negative, the compiler may decide the loop can never end, or it may delete a null check that comes after a dereference. A new compiler version or a higher optimisation level changes the result. It's different from implementation-defined behaviour, like the size of an int, which each compiler must document. To catch it I use warnings and the undefined behaviour sanitizer in tests."
Saying undefined behaviour just means you get a random value, or that it is safe if the tests pass.
Conversion: when int meets an unsigned int of the same rank, the int is converted to unsigned.
Result: -1 becomes the largest unsigned value, so -1 < 1u is false.
sizeof: it returns size_t, which is unsigned, so the same trap applies.
Defence: turn on sign-compare warnings and keep counters and sizes in one type.
"When you mix a signed int and an unsigned int in one expression, C converts the int to unsigned before comparing. So -1 turns into the largest unsigned value, and -1 < 1u is false. sizeof returns size_t, which is unsigned, so if (-1 < sizeof(int)) is false too, which surprises people. The loop version is counting down with an unsigned index: for size_t i = n - 1; i >= 0; i--. An unsigned value is always zero or more, so the condition is always true. When i reaches zero and decrements, it wraps to a huge number instead of stopping, and the loop reads far outside the array. Note that unsigned wraparound itself is well defined; the bug is in the logic. My defences are compiling with -Wall and -Wextra, which turns on the sign-compare warning in C, and writing the countdown as for i = n; i-- > 0."
int x = -1;
unsigned int y = 1;
if (x < y) puts("less");
else puts("not less"); // this one prints
for (size_t i = n - 1; i >= 0; i--) { /* never stops */ }
for (size_t i = n; i-- > 0; ) { /* correct countdown */ }
Saying the compiler compares the real numeric values, or fixing the loop by casting without understanding the conversion.
Triage: group the warnings by type; some point to real undefined behaviour.
Deadline: fix the dangerous ones now, record the rest instead of hiding them.
Options: ship on the old compiler, or silence narrowly with a tracked ticket.
Afterwards: get to zero warnings, then treat new ones as errors.
"I wouldn't switch them off wholesale, because in C a new warning is often the compiler pointing at a real bug, but I also wouldn't block the release to fix dozens of lines in one night. I'd spend an hour sorting them by type. Things like uninitialised variables, sign comparisons, format string mismatches and possible buffer overruns I'd look at properly, since those can be real undefined behaviour. Style warnings like unused parameters can wait. If we're short on time, the safer choice is often to ship with the compiler version we tested on and do the upgrade right after the release. If we must ship on the new one, I'd silence only the specific low-risk warnings, not all of them, and log a ticket to clean them up. Once we reach zero warnings, I'd make new ones fail the build so they don't pile up again."
Adding a flag that silences all warnings, or insisting every warning be fixed before the release without triage.
Symptom: what failed, and why it was hard to reproduce.
Tools: debugger backtrace, core dump, sanitizer or Valgrind, a smaller reproduction.
Cause and fix: the actual line at fault, the fix and the test you added.
"In my final-year project we built a small log parser in C, and it crashed now and then with a segmentation fault inside free, which made no sense at first because the free call itself looked fine. The backtrace in gdb only showed where the damage was noticed, not where it happened. So I rebuilt with AddressSanitizer and ran the same input files. It stopped on the first bad write, a heap buffer overflow in the code that copied a field into a fixed buffer. We'd sized it for the field plus nothing, forgetting the terminating zero, so every field of exactly maximum length wrote one byte past the end and damaged the allocator's data. The fix was one character in the size, plus a check on the length. I added the long-field case as a test, and from then on we ran the sanitizer build in every test run."
A story where the fix was adding a NULL check or making a buffer bigger without ever finding the line that caused it.
Symptom: what changed between builds and how you noticed.
Diagnosis: narrowing it to one function, reading warnings, using sanitizers.
Root cause: the undefined behaviour the optimiser was entitled to exploit.
Fix: correct the code, keep optimisation on, add a guard against repeats.
"At my last company, a checksum routine gave different answers in the release build than in the debug build. The first reaction in the team was that the compiler had a bug, but that's almost never true. I cut it down to one function and built it with the undefined behaviour sanitizer, which flagged signed integer overflow. The code added bytes into an int accumulator and relied on it wrapping around. In the debug build the machine just wrapped. With optimisation on, the compiler assumed signed overflow can't happen and rearranged the arithmetic, so the result changed. The fix was to make the accumulator an unsigned type of a fixed width, where wraparound is defined, which is what the algorithm wanted all along. We kept full optimisation, and I added the sanitizer build to CI so the next one would show up in tests, not in the field."
Blaming the compiler and turning optimisation off, with no idea what the code did wrong.
Problem: unclear ownership causing leaks, double frees or mismatched allocators.
Decision: the rule you chose, such as caller-provided buffers or paired create and destroy functions.
Making it stick: naming, comments in the header, tests and review.
"At my last company we had a parsing library used by two other teams, and the header didn't say who owned the strings it returned. One team freed them, the other didn't, so we had a leak on one side and a double free waiting to happen on the other. I proposed one rule for the whole library: anything the library allocates, the library frees. Every object got a create function and a matching destroy function, and functions that return text either write into a buffer the caller passes with its size, or return a pointer that stays valid until the owning object is destroyed. I wrote the ownership rule at the top of the header and next to each function, and we added leak checks with Valgrind to both teams' test runs. The double free reports stopped, and new functions followed the same pattern without argument."
Saying ownership was obvious and needed no documentation, or that each caller should just be careful.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.