4 Months of C on Weekends (It's Less Forgiving Than You Think)
I spent weekends for 4 months writing C. Hash tables, HTTP servers, JSON parsers. It's hard. It's good. I keep coming back.
Introduction
Four months of weekends. That's how long I've been writing C. Saturday mornings, Sunday afternoons, the occasional "let me just fix this one bug" that turns into 3 AM.
And I gotta say: it's less forgiving than I thought. Way less.
I came into this thinking "I know programming. I'll pick up the syntax and be fine." And the syntax IS fine. The syntax is the easy part. It's everything else that beats you up.
But it's good. It's really good. The pain teaches you things. I'll explain.
"C on weekends is like going to the gym. You dread it. You hate it while you're doing it. But afterwards you feel like you could fight a bear."
Project 1: The Hash Table
I wanted to understand how hash tables actually work. Not the Python dict I use daily. The actual internals.
The API
HashTable* ht_create(size_t initial_capacity);
void ht_destroy(HashTable* ht);
void ht_insert(HashTable* ht, const char* key, void* value);
void* ht_get(HashTable* ht, const char* key);
void ht_remove(HashTable* ht, const char* key);Things That Went Wrong
- First version had no collision handling. Insert two items with the same hash? Second one silently overwrites the first. Cool. Cool cool cool.
- Fixed with linear probing. Then deletion broke the probe chains. Had to add tombstones. Took two weekends to realize why deleted entries were breaking lookups.
- Forgot to resize the table. Inserted 1000 items. Performance went from "instant" to "let me get coffee while this finishes."
- Memory leaks everywhere. Every insert allocated a copy of the key. Never freed it on delete. The program worked fine for small tests. Ran it for an hour? OOM.
// A real bug I had:
void ht_remove(HashTable* ht, const char* key) {
size_t index = ht_find_index(ht, key);
if (index == HT_NOT_FOUND) return;
free(ht->entries[index]->value);
// FORGOT: free(ht->entries[index]->key);
ht->entries[index]->tombstone = true;
ht->count--;
}One missing line. Memory leak that grows with every deletion. Took me an hour with valgrind to find it.
This is the thing about C. It doesn't tell you when you're wrong. It just silently degrades until you notice something's off and go hunting.
"valgrind is not my friend. valgrind is my dentist. It finds the cavities I didn't know I had and charges me hours of my life to fix them."
Project 2: The HTTP Server
I wanted to build something that actually does a thing. So I wrote an HTTP server from scratch. Socket, bind, listen, accept, the whole deal.
Version 1: One Connection, One Response
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(server_fd, (struct sockaddr*)&address, sizeof(address));
listen(server_fd, 10);
int client_fd = accept(server_fd, NULL, NULL);
char buffer[4096] = {0};
read(client_fd, buffer, 4096);
printf("Request:\n%s\n", buffer);
const char* response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello!";
write(client_fd, response, strlen(response));
close(client_fd);It worked. One connection. One response. Felt incredible. Then I tried a second connection and realized the server had already exited.
Version 2: The Loop
Added a while loop. Accept, respond, close, repeat. But if a client opened a connection and didn't send data, the server just sat there. Blocked. Couldn't handle anything else.
Version 3: select()
Discovered select(). Monitors multiple file descriptors at once. New connections AND existing connections in a single loop. It worked. The code was ugly as sin. Lots of FD_SET and FD_ISSET macros. But it worked.
The bugs along the way:
- Forgot to reset
fd_setevery iteration. After the first client connected,select()returned immediately because the old FD was still set. Spent a weekend debugging that one. - Buffer overflows when a request was larger than 4096 bytes. The request just got truncated. Silently. Because C doesn't check array bounds.
- Forgot to set
SO_REUSEADDRon the socket. Crashed on restart because the port was still in TIME_WAIT. Had to wait 60 seconds or change ports. Learned aboutsetsockopt().
"The HTTP server taught me one thing: everything you rely on a framework to handle — connection pooling, request parsing, error handling — is a problem YOU have to solve in C. Every. Single. One."
Project 3: The JSON Parser
I wrote a recursive descent JSON parser. Not because we need another one. Because I wanted to feel the pain of parsing without regex or libraries.
What Broke
- Escape sequences (
\n,\t,\\). My parser saw a backslash and messed up. Took an entire weekend to handle them properly. - Numbers with exponents (
1.5e10). Infinite loop because I didn't advance past thee. C doesn't throw an exception for infinite loops. It just hangs. Forever. Until you kill it. - Unicode escapes (
\u0041). I gave up. ASCII only. I am not strong enough for Unicode parsing in C.
What Worked
When it worked, it was fast. Parsed a 10MB JSON file in 0.03s. That's the C payoff. When you get it right, nothing touches you.
The Real Feeling
Four months in, here's where I am:
C is hard. Not "I don't understand pointers" hard (pointers are fine). It's hard because:
- The compiler won't save you. No borrow checker. No garbage collector. No type system that catches null dereferences. Just you and your mistakes.
- The errors are cryptic. "Segmentation fault" doesn't tell you where or why. You learn to love
gdbbecause it's the only thing that talks back. - The standard library is bare bones. Need a dynamic array? Write it. Need a string split? Write it. Need a dictionary? Write it. Python gives you all of this for free. C gives you
printfand a prayer. - Undefined behavior means your program can work perfectly for months and then crash because of a compiler optimization you didn't know existed.
But it's good. There's something honest about C. When your program works, it works because you made it work. Not because a framework caught your mistake. Not because a garbage collector cleaned up after you. Because you managed the memory correctly. Because you handled the edge case. Because you understood exactly what the computer is doing.
And the skills transfer. I understand my Node.js code better now. I know what Buffer.alloc actually does. I know why connection pooling matters. I know what the garbage collector is doing under the hood. C doesn't just teach you C. It teaches you what every other language is hiding from you.
"C makes you a better programmer in every language. Because it shows you the machinery. Once you've seen the machinery, you can never unsee it."
What I'd Say to Someone Thinking About C
Do it on weekends. Don't quit your day job. Don't try to build production software. Build things that break.
Write a hash table. Watch it leak memory. Fix it. Write an HTTP server. Watch it hang on the second connection. Fix it. Write a parser. Watch it infinite loop on an exponent. Fix it.
Every bug you fix in C teaches you something that sticks forever. Because you can't hand-wave C bugs. You have to understand them.
valgrindfor memory leaksgdbfor segfaultsgcc -Wall -Wextra -Wpedanticfor the compiler yelling at youstraceto see what your program is actually asking the OS to do- Patience. Lots of patience.
Conclusion
Four months of weekends. Three projects. Dozens of segfaults. More memory leaks than I can count.
C is less forgiving than I thought. It is hard. It will humble you. It will make you spend three hours debugging a missing free() call. It will crash at 2 AM for reasons you won't understand until 4 AM.
But it's good. It's really good. The feeling of making a C program work is different from any other language. Because you earned it. Nobody helped you. No framework saved you. No runtime caught your mistake. Just you, the compiler, and a program that finally does what you told it to do.
And now if you'll excuse me, I have a segfault to debug. Something about an uninitialized pointer. Again.
"C on weekends. It hurts. It's worth it. I keep coming back."
