How my application programmer instincts failed when debugging assembler
I’ve had a smidge of extra time with my recent unemployment, so to stay sharp and learn a few new things I followed Seiya Nuta’s guide to building an Operating System in 1,000 Lines.
I’m not an OS programmer, my life is normally spent at high-level application programming. (The closest I come to the CPU is the week I spent trying to internalize the flow of those crazy speculative execution hacks.) Assembler is easy enough to write, that wasn’t the problem. The problem was when I encountered problems. My years of debugging application-level code has led to a pile of instincts that just failed me when debugging assembler-level bugs.
These are the three places I had the biggest problems debugging.
Big error #1 – I forgot a ret in a naked assembler function#
I forgot the ret in a naked assembler function. It didn’t return to its caller.
What happened next is both fun and obvious—but only when you know that you missed a ret.
That function—let’s call it the first function—didn’t return to its caller, so execution just went to the next function in the file. The input arguments were whatever happened to be in the a0 and a1 registers. And when that second function returned, it used the caller information that was still available in the ra register, and it returned to where the first function was called from.
This is something that just doesn’t happen in application programming, which meant that I had a heck of a time debugging it.
It was even harder to debug because those two functions were related. They were next to each other in the file, of course they were related. I saw that the second function was doing strange stuff, and I was expecting it to be called around that time, so I focused on that error.
My application-programmer brain went like this: Why was it failing? It was sometimes being called with junk parameters, and it was being called more often than it should be. Why? Look at the caller. Why? Investigate the calling site. Investigate any loops. Move up the calling tree. Repeat. Repeat. Repeat. Which sent me nowhere near the problem. Everything went nowhere until I read the compiled assembler and started manually tracing execution.
Lesson 1: Application code is (mostly) about logical abstractions. OS code isn’t (always) about that. Debugging problems in OS code may be about just looking at adjacent assembler code.
(Addendum: This was around the process-creation code, which made things even weirder.)
Big error #2 – incorrect types in a packed struct#
Another error was an incorrect type inside a packed struct. It only needed 16 bits, but I was copying and pasting a previous line and gave it 32 bits.
In application programming, the size of the variable really doesn’t matter much to me, it’s almost entirely abstracted away in dynamic languages. I’ve spent a long time in the mindset that the size of types is on the other side of a certain abstraction, and that abstraction will nicely fail to compile if I make a mistake. I don’t think about it.
Types in C code are a lot more about how much space the variable takes up, with a bit of semantics on top. There’s no abstraction.
Even with one struct member having too much space allocated to it, the whole thing still compiled correctly, and all my tests in the C code showed it working.
But the struct was also being accessed in assembler. In assembler I was manually calculating the offsets from the struct location, using the sizes in the tutorial, and I didn’t make any silly mistakes while copying and pasting code here, which meant that suddenly that incorrect type caused a failure.
It was easy to printf and see that the values of the structs were correct, but that was C’s view of the struct.
Lesson 2 Lesson 1, again: There is no abstraction.
(Addendum: One thing I’ve learned about assembler code is that it just “goes forward” in a way that other languages don’t. In any pile of Rust code I have so many defined types and conversions and error handlers that errors are noted and bubble up right away. The nature of a good abstraction.)
#3 (a smaller one): the __attribute__ typo that compiled#
I also learned how forgiving C parsing can be: __attribute((foo)) compiled and ran, even though the correct syntax is __attribute__((foo)). I got no compilation failure to tell me that anything went wrong.
In Sum#
Abstractions. They don’t exist in assembler. Memory is read from registers and the stack and written to registers and the stack.
It’s something that I know in my rational brain, and I was happily coding with that in mind. But when problems came up, I never realized how much I run on instinct and past patterns. I’ve been pretty good at debugging applications in my career, it’s what I’ve done most of. But my application-coded debugging brain kept looking at abstractions like they would provide all the answers. I rationally knew that the abstractions wouldn’t help, but my instincts hadn’t gotten the message.
I’m not an OS programmer or a low-level programmer. I don’t know if I’m sad about that, I like application-level programming. But it felt powerful to handle data on the stack directly.
It’s not that I love all levels of abstraction. Debugging a pile of assembler code is about reading the assembler code, which is nice. I enjoy that a lot more than the super-abstraction of Java Spring Boot, debugging a problem there looks a more like magic than programming (and eventually requires knowing a man named Will and texting him. Everyone should know a Will.)
But for everyone like me–the curious, the application programmers, and the unemployed–go ahead and do the Operating System in 1,000 Lines tutorial.
(Final note: ChatGPT was good at answering questions about RISC-V, but it was not good at finding bugs in code. It seemed to follow the logical-abstraction model of an application programmer and failed to help me with any of the above problems. But it was good at explaining the problems after I solved them.)
(Final final note: This post was written without ChatGPT, but for fun I fed my initial rough notes into ChatGPT and gave it some instructions to write a blog post. Here’s what it produced: Debugging Below the Abstraction Line (written by ChatGPT). It has a way better hero image.)