The more I learn about computers, the more I realize you can’t really understand much without understanding memory and the C programming language. As someone who grew up on garbage collected interpreted languages like python, I have very little experience in this area and need to improve.
So, while I don’t plan on becoming a C programmer, I’ve started working on some from-basics handmade-style projects to learn more about how the system works and develop a better intuition about memory and performance.
For my first project, I decided to build the classic snake game I played on my parent’s Nokia phones during my childhood. This is my first learning project using C (or graphics programming) and I used only SDL2 library and C for this implementation.
Thinking about memory on the stack and heap ¶
I had created a struct to initialize the game state, looking something like this:
struct Game {
int window_w;
int window_h;
int game_w;
//...
enum State state;
SDL_Rect game_rect;
SDL_Rect fruit;
SDL_Window* window;
SDL_Renderer* win_ren;
SDL_Rect *body; // array representing the snake's body
};
This struct was initialized in main
, just before the event loop. The snake body was represented as an array of SDL_Rect
’s, and I pre-defined the max array size during compile time, thinking that reserving the space on the main stack frame during compile time meant I wouldn’t have to malloc
(heap allocate) at all.
SDL_Rect* body[MAX_SNAKE_LEN];
This worked great, and I wrote most of the game like this. Eventually I decided I wanted a reset
key, to start my game again without quitting. So I moved all the init code to a struct Game init()
function, and set up a trigger in my event loop to call it when i pushed r
.
struct Game init(void){
SDL_Rect* body[MAX_SNAKE_LEN];
//...
struct Game game = {.window_w=WINDOW_W,
.window_h=WINDOW_H,
// ...
.head_x=GAME_SIZE/2,
.head_y=GAME_SIZE/4,
.body=body,
};
SDL_CreateWindowAndRenderer(WINDOW_W, WINDOW_H, SDL_WINDOW_RESIZABLE, &game.window, &game.win_ren);
if (!game.window || !game.win_ren) {
printf("Failed to create window or renderer. Error: %s", SDL_GetError());
exit(EXIT_FAILURE);
}
SDL_SetWindowTitle(game.window, "snake");
game.fruit = make_fruit(&game);
return game;
}
And right away, I started getting segfaults or nonsense shapes on the screen. This was a great learning experience, and required the use of a debugger (lldb
) to understand what was happening.
In short, my Game
struct stored a pointer to the head of the snake array, which was pre-defined and allocated on the current stack frame. This was no problem in the original code, because I was on the main
stack frame, and the memory remained valid for the life of the program. However, when I moved the definition to init()
, the pointer no longer pointed to valid memory once init
returned the game
and the stack frame variables went out of scope.
While I’m sure there is a way to address this without allocating, I ended up changing my definition to heap allocate the snake instead:
SDL_Rect* body = malloc(MAX_SNAKE_LEN * sizeof(SDL_Rect));
if (body == NULL){
puts("Failed to allocate snake");
exit(EXIT_FAILURE);
}
body[0] = (SDL_Rect){.x=GAME_SIZE/2, .y=GAME_SIZE/4, .w=STEP_SIZE, .h=STEP_SIZE};
// ...
Now the pointer to the snake body will remain valid as long as I like.
Debuggers are great! ¶
I underestimated the value of even the command line interface of debuggers. When I was trying to figure out the array memory corruption problem, I don’t think I would have gotten anywhere without using lldb
. By compiling with the -g
flag and breaking on my init
function, I was able to step through the code and see a correctly allocated snake body in init, then nonsense as soon as I stepped out of the frame! That let me narrow my search and learn about how to properly do this in C.
All I had to do was:
#clang `pkg-config sdl2 --libs --cflags` -Wall -g -O0 -pedantic -std=gnu11 main.c -o snake
lldb snake
break set init
// step with s
frame variable body
//inspect the addresses and values
step out
// Oh no!
This was much easier than adding my debug statements using %p
everywhere.
While I’d like to use gdb
in the future, lldb
is much easier to use on MacOS and works by default so I stuck with that.
Arrays and pointers ¶
The most challenging part of C for me so far is groking arrays.
While I can read the definition, and convinced myself that the array[i]
syntax is really *(array + i)
since array
itself is a pointer to the 0ith element of the array by working through the following:
#include <stdio.h>
int main(){
int array[3] = {1,2,3};
printf("Address of array is %p\n", &array);
printf("Address of array[0] is %p\n", &array[0]);
printf("Address of array+1 is %p\n", (array+1));
printf("Address of array[1] is %p\n", &array[1]);
printf("Content of array+1 is %d\n", *(array+1));
printf("Content of array[1] is %d\n", array[1]);
}
// Address of array is 0x7ff7b4fb606c
// Address of array[0] is 0x7ff7b4fb606c
// Address of array+1 is 0x7ff7b4fb6070
// Address of array[1] is 0x7ff7b4fb6070
// Content of array+1 is 2
// Content of array[1] is 2
Using them in practice requires more thought and serious consideration of where things are stored and what you are actually doing, as I learned with my init
function.
I have a lot to learn ¶
Obviously, there is a reason why many of us learn and use higher level languages. There are a lot of great features that make programming safer and nicer than C, and we’ve learned a lot since the creation of C.
However, there is also a lot of junk and bulk and unnecessary complexity. By better understanding these lower levels, I hope to be a better judge of the advantages and disadvantages of the tools I use, and learn how to create more resilient and higher performance software.
You can find my first attempt at making a snake here: https://github.com/nkicg6/csnake