Apollo 13 - Tao Liu's blog
Failure is not an option.
Saturday, September 5, 2026
Speaker for Oxidize 2026 and Rustlab 2026
Monday, August 17, 2026
EGraph for RISC-V Assembly: Making Optimization More Declarative
- Structural matching, so equivalent instructions can be recognized.
- A small cost model, so cheaper forms like add-immediate or add can be preferred appropriately.
- Display logic, so extracted results can be rendered back into readable assembly.
- rewriting multiplication by powers of two into shifts,
- simplifying instructions with neutral elements,
- handling conditional rewrites when analysis proves a property,
- generating dynamic rewrites based on matched values.
Tuesday, August 4, 2026
Bringing the RISC-V Simulator into VS Code
One of the goals of my RiscV toolchain project has always been more than simply generating RISC-V assembly.
I want to build a complete toolchain that makes writing,
compiling, and debugging RISC-V programs feel natural. This week, I reached
another milestone: my RISC-V simulator can now debug programs directly
inside Visual Studio Code.
Seeing breakpoints, registers, and single-step execution
inside VS Code is surprisingly satisfying. It makes the project feel much
closer to a real development environment.
Why a VS Code Debugger?
When developing a compiler, debugging generated assembly is
just as important as generating it.
Previously, my workflow looked something like this:
- Compile
C source code.
- Generate
RISC-V assembly.
- Run
the simulator.
- Print
register values.
- Repeat.
That works for small programs, but it quickly becomes
painful as the compiler grows.
Having an interactive debugger completely changes the
experience.
Instead of inserting print statements everywhere, I can now:
- set
breakpoints
- single-step
through instructions
- inspect
registers
- follow
the execution flow
- verify
generated assembly interactively
It dramatically shortens the feedback loop when tracking
down compiler bugs.
Using the Debug Adapter Protocol
Rather than inventing my own debugger UI, I decided to
integrate with Visual Studio Code's Debug Adapter Protocol (DAP).
This means the extension can take advantage of the debugger
interface that developers already know.
Currently the debugger supports:
- launching
a RISC-V program
- breakpoints
- stepping
through instructions
- viewing
all integer registers
- inspecting
the current execution point
- displaying
the call stack
Because the simulator acts as the debugging backend, every
instruction executed is under complete control of the debugger.
Connecting the Pieces
This project has gradually evolved into several independent
components that now work together:
C Source
│
▼
C Compiler
│
▼
RISC-V Assembly
│
▼
Simulator
│
▼
VS Code Debugger
Each component was originally developed independently.
Connecting them together has been one of the most rewarding
parts of the project.
When I hit F5 in VS Code, the debugger launches the
simulator, loads the executable, and immediately allows me to step through the
generated instructions.
That is a much nicer workflow than running individual
command-line tools.
The Screenshot
The screenshot below shows the debugger paused on an
instruction.
On the left, VS Code displays the complete register set.
On the right, the current instruction is highlighted while
breakpoints are shown directly in the editor.
Watching the register values change after each instruction
has already helped me identify several code-generation issues that would have
been much harder to diagnose otherwise.
RisvV C Compiler Dev Journal - 20260804
72 Test Cases, typedef, and Function Pointers
Today I reached another milestone on my RISC-V C compiler project with Rust. The regression suite has now grown to 72 executable test cases, and this milestone adds support for two important C language features: typedef and function pointers.
I've been following a simple rule throughout this project: every new language feature must come with at least one regression test. This gives me confidence to continue improving the compiler without worrying about breaking functionality that was implemented earlier.
The compiler has gradually evolved from handling simple expressions into supporting a substantial subset of the C language. So far it includes:
variables and arithmetic expressions
if,while, andforfunctions and recursion
pointers and pointer arithmetic
arrays
structures
unionenumtypedeffunction pointers
Adding typedef
Although typedef does not introduce a new type, it is an important C language feature that allows programmers to create aliases for existing types.
Supporting typedef required extending the compiler's symbol table and type system so that type aliases could be correctly distinguished from ordinary identifiers and resolved during semantic analysis.
With typedef in place, complex declarations become much easier to read. This is especially important for function pointers, where the original C syntax can quickly become difficult to understand.
For example:
typedef int (*BinOp)(int, int);
is much cleaner than repeatedly writing the full function pointer declaration throughout a program.
Why Function Pointers?
Function pointers are one of the features that make C both flexible and powerful. They enable callbacks, dispatch tables, state machines, and many programming patterns commonly found in embedded software, operating systems, and low-level libraries.
Supporting function pointers required more than just parsing a complicated declaration. The compiler now needs to correctly handle:
function pointer type resolution
assignments between functions and function pointer variables
indirect function calls
passing function pointers as parameters
returning function pointers from functions
function pointers stored inside arrays and structures
Test Case #72
To validate both typedef and function pointer support, I created a regression test that combines several real-world usage patterns into a single program.
The test covers:
a
typedeffor a function pointer typemultiple arithmetic callback functions
an array of function pointers
passing function pointers as parameters
returning a function pointer from another function
storing function pointers inside a structure
indirect function calls through variables, arrays, and structure members
The test program defines several operations (add, sub, mul, and negate_via) and exercises different ways of invoking them through function pointers.
The expected final result is 42, which becomes the regression check for the entire feature set.
This type of test is especially valuable because it validates the interaction between multiple compiler components:
parsing
type resolution
semantic analysis
function call generation
memory layout handling
RISC-V code generation
A More Stable Foundation
One thing I've noticed during recent development is that segmentation faults have become much less common.
Earlier in the project, adding a new language feature often exposed issues deep inside the compiler. These problems usually came from assumptions that were valid when the compiler was smaller but became incorrect as more language features were introduced.
Recently, the development process has changed. Most of my effort is now spent adding new capabilities and expanding test coverage rather than debugging compiler crashes.
I believe this is a sign that the compiler framework itself is becoming more stable. The internal architecture, type system, and regression framework are mature enough to support continued growth.
There are still many bugs to discover, but it is encouraging to see the project moving from "building the foundation" toward "expanding capabilities."
Growing the Regression Suite
The compiler now includes 72 executable regression tests, and this number has become more meaningful than simply counting implemented features.
Every bug I fix becomes a permanent regression test.
Every new language feature adds another executable example.
Over time, the regression suite is becoming a living specification of what the compiler supports.
This approach also makes future optimization work safer because I can quickly verify that improvements do not break existing functionality.
What's Next?
With many core C language features now implemented, including struct, union, enum, typedef, and function pointers, the next phase of development will focus on improving compiler maturity in two areas.
The first area is expanding floating-point support. The compiler already has basic floating-point functionality, and the next step is to add more comprehensive regression tests covering more complex scenarios, including floating-point expressions, conversions, function calls, and interactions between integer and floating-point types.
The goal is not only to make floating-point programs compile, but also to build confidence that the generated RISC-V code behaves correctly across a wider range of real-world cases.
The second area is optimization. Up to this point, the primary focus has been correctness, language coverage, and compiler stability. With the framework becoming more mature and the regression suite growing, the next challenge is improving the quality of generated code.
Future optimization work will explore areas such as:
improving generated instruction sequences
reducing unnecessary operations
peephole optimization
intermediate representation improvements
exploring more advanced compiler optimization techniques
This marks an important transition for the project: moving from:
"Can the compiler correctly compile C programs?"
toward:
"Can the compiler generate efficient RISC-V code?"
I'm looking forward to this next stage of the journey.
Thursday, May 7, 2026
SelfNote: Register Spillover and Replacement
MoveAway(setA, setB, available) -> (replacement, spillover)if setA ∩ setB = φ { (φ, φ ) }elseoverlap = setA ∩ setBreplacement = overlap zip available //this will generate vec<(reg name0, reg name1)>spillover = overlap - available
MoveAway(setA, setB, available) -> (replacement, spillover)overlap = setA ∩ setBreplacement = overlap zip available //this will generate vec<(reg name0, reg name1)>spillover = overlap - available
Monday, April 20, 2026
Building My First RISC-V VSCode Extension (and Everything That Went Wrong)
I set out to build a simple VSCode extension for RISC-V assembly. The idea sounded straightforward: define a new language, hook up a Rust-based parser through LSP, and get syntax highlighting plus diagnostics. I’ve built compilers before—how hard could this be? It turns out, the hard part wasn’t the compiler at all. It was everything around it.
The Setup
On paper, the architecture was clean. A TypeScript extension runs inside VSCode, launches a Rust-based language server, and that server parses .rv.s files, reports errors, and sends back semantic tokens for highlighting. In development mode, everything worked beautifully. I could open a file and immediately see highlighting, introduce an error and watch diagnostics appear exactly where I expected. It felt solid, and at that point I thought I was basically done. I wasn’t.
The VSIX Reality Check
The moment I packaged everything into a .vsix and installed it, things broke—quietly. There was no highlighting, no diagnostics, and no logs. The extension just sat there stuck on “Activating…”. That’s when it really clicked: dev mode hides problems, but packaging reveals them.
The First Wall: Missing Dependencies
The first real clue came from an error saying it couldn’t find vscode-languageclient/node. That was confusing because it was clearly listed in my package.json. The issue, as it turned out, was entirely self-inflicted. VSCode extensions don’t bundle dependencies automatically, and I had explicitly excluded node_modules in .vscodeignore. In other words, I built an extension and then removed part of it before shipping. Once I included node_modules again, that problem disappeared. Simple in hindsight, but not obvious when you’re in the middle of it.
The Silent Failure: LSP Not Starting
After fixing dependencies, the extension still didn’t work. This time there wasn’t even an error—just silence. That silence made it harder to debug than an actual crash. The issue turned out to be the path to the Rust binary. In development, I was using a relative path like server/target/release/rust_keyword_lsp_server.exe, which worked because everything ran inside my workspace. But once installed, VSCode runs the extension from a completely different location, so that path no longer pointed to anything valid. The fix was to resolve paths using context.extensionPath. Once I did that, the server finally started.
The Subtle Killer: stdout
Then came one of the most subtle bugs in the entire process. My parser used println! to print errors, which is perfectly normal in Rust. But in an LSP setup, stdout is not for logging—it’s the protocol itself. Every time I printed something, I was corrupting the JSON stream between the server and VSCode. The client would silently disconnect, making it look like the server never started. There was no obvious error pointing to this. The fix was simply to use eprintln! instead, sending logs to stderr. One small change, but it made a massive difference.
The Debugging Breakthrough: Developer Tools
At one point, I realized I was essentially debugging blind. There were no logs, no clear signals, just a stuck extension. That changed when I discovered the developer tools in VSCode through “Help → Toggle Developer Tools”. This was a turning point. Suddenly I could see activation errors, inspect console logs, catch missing modules immediately, and verify runtime paths. Before this, I was guessing. After this, I was actually debugging. If you’re building a VSCode extension and not using this, you’re making things much harder than they need to be.
Packaging Is a Minefield
Even after getting everything working, packaging still had its own set of traps. I had to carefully tune .vscodeignore to exclude Rust source code and large build folders, include only the release binary, and still keep all required Node dependencies. A single mistake here could break the extension again in ways that looked completely unrelated. It became clear that packaging isn’t just cleanup—it’s part of the system itself.
What I Learned
The biggest surprise in all of this was where the complexity actually lives. It’s not in parsing, not in Rust, and not even in the LSP protocol. It’s in the boundaries: the differences between development and packaged environments, stdout versus stderr, relative paths versus resolved ones, and what exists locally versus what actually gets shipped. Each of these seems small on its own, but together they create a system where things can fail silently in ways that are hard to reason about.
Where It Ended Up
After working through all of that, I ended up with a VSIX that installs cleanly, a Rust LSP that starts reliably, working diagnostics, and semantic highlighting for RISC-V assembly. More importantly, I now understand the real challenges of building tooling inside VSCode, and they’re not where I initially expected them to be.
Closing Thought
If you’re building an LSP-based extension, expect the bugs to come from the edges, not the core. Your parser will probably work, and your design will probably make sense. But the thing that breaks everything might be something as small as a single println!, and you won’t see it coming.
Eventually, I got instruction and registers highlight works.
