Blog post
Week 5: GSoC Project Progress
26 Jun 2026
Hours this week: 18
Total hours: 86
At the end of the fourth week, I had the following tasks to work upon:
- Since x86 backend infrastructure is setup in
certik/mlirrepository, now I am focusing on expanding the x86 backend implementation for all tests in the repository. - Using the LLVM Dialect path, to use the custom backend implementations.
This week, I worked upon the first task, to expand the x86 backend implementation for all tests in the repository. And by the time of writing this blog, I have expanded the x86-64 backend to support 220 tests out of 226 in the certik/mlir repository. Before this work, only 125 tests were working with the x86-64 backend. This effort also involved understanding the x86 backend tests, as well as the new fixes which came along the way.
This PR is created here: PR: 217
In this blog, I would like to discuss the work done during this week, specifically for this PR, to make all the tests work with the x86-64 backend.
Commit 1: Short-circuit correctness for llvm.cond_br on the ELF backend
Commit: fix: register short_circuit.tc for the elf backend
Problem: In C, && and || do not run every part of the condition. For example, in if ((n != 0) && (100 / n > 0)), when n is 0, the program must not evaluate 100 / n. tinyC lowers this into llvm.cond_br branches in MLIR. Some branches pass extra values into the next block (like a phi node). Our old x64 ELF backend copied those values for both the true and false branch before it even checked the condition. So the “inner” part of the condition could still run when it should have been skipped.
Fixes implemented:
First, we changed the order of work inside llvm.cond_br. The backend now loads the condition, tests it, and only then decides where to go. For example, writing if (a) in C and only running the then block when a is true. For if ((n != 0) && (100 / n > 0)), when n == 0 the code jumps straight to the false path and never touches the inner check. When n != 0, it goes to the true path and only then runs the next piece. This matches how && and || behave in normal C compilers. Second, when a branch edge carries block arguments, we added a small “landing pad”: copy the values with emit_edge(), then jump to the real block. When the edge has no arguments, we branch directly with no extra pad. Example: a simple if (x) with no values passed into the next block stays a plain jump; a chained && that passes a value into the inner test uses the pad only on the path that actually needs it. A helper called patch_rel32_at() fixes up jump targets for these pads (same idea as the branch patch loop at the end of each function).
Commit 2: Function pointers and indirect calls on the ELF backend
Commit: fix: add support for function pointer based tests on elf backend
Problem: Function pointers did not work on the ELF backend. For the code: int add(int a, int b) { return a + b; } and in main, int (*fp)(int,int) = &add; fp(2, 3);. Three things were broken. (1) Getting the address &add failed because the backend only knew how to load data globals (like int x), not function symbols. (2) Calling through fp failed because the backend only supported call @add(...) with a fixed name, not call through a register containing function pointer. (3) The function add didn’t emit into the ELF file because main never called @add directly.
Fixes implemented:
First, we fixed how addressof works in load_val(). If the name is a data global (e.g. addressof @my_counter), we still use lea_rip_data like before. If it is not in the data map for example addressof @add or addressof @printf, we use lea_rip_func, which emits lea reg, [rip + @symbol] with a text-section relocation. Few helpers encode the information and we use them to resolve the function pointer into function address.
Second, we added indirect call support in llvm.call lowering. A direct call like call @add(2, 3) still uses emit_direct_call() which translatates to one E8 rel32 instruction. An indirect call has no callee name; operand 0 is the function pointer. We load that pointer into r11, skip it when setting up arguments (arg_base = 1), run the same as a normal call (reg args in rdi/rsi/..., stack args pushed, etc.), then emit call *r11. Example: fp(2, 3) becomes load fp -> write 2 and 3 in arguments -> jump through the pointer.
Third, we extended reachability so address-taken functions get emitted. The backend already scans from main and follows direct calls like call @printf. We add: when scanning a reachable function, if we see llvm.mlir.addressof @add and @add is not a data global, we add add to the this function set. So, while emitting functions, we emit indirect calls through function pointers as well.
Commit 3: Pointer-global initialization on the ELF backend
Commit: fix: initialize pointer globals from addressof regions on elf backend
Problem: The x64 ELF backend could only initialize globals from a plain value attribute: integers, floats, and string bytes embedded directly in the data storage. Many pointer globals do not use that form. When tinyC emits something like char *greeting = "Hi", it lowers to two globals: a string literal (e.g. @.str.0) with a value attribute, and a pointer global (@greeting) whose initializer is a region, not a value which is essentially addressof @.str.0; return.
Fix: For a pointer global like char *greeting = "Hi", the backend must store the address of the string, not leave the slot as zero. We do that once at program startup, before main runs. The same idea is used in aarch backend as well in filling in pointer globals before user code starts. We scan globals during collection: plain values (numbers, strings) are written into the data section as before and pointer globals that mean “point at @some_symbol” are recorded as pending symbols. After all globals are collected, we resolve each pending entry to a slot offset (this also handles forward references, where the target global appears later in the file). In synthesised _start, we emit a short sequence per pointer: load the target address, load the pointer slot address, store so greeting already points at "Hi" when main calls printf. This fixed the globals test on ELF; global_ptr_array and global_ptr_ptr benefit from the same change.
Commit 4: Support for sqrt() on the ELF backend
Commit: fix: add support for sqrt() on the ELF Backend
We also added a small fix for sqrt() on the ELF backend: tinyC lowers sqrt(x) to llvm.intr.sqrt, which the x64 backend did not recognize and reported as an unsupported op. In lower_fp_op() we now handle it like other unary float ops. We load the operand into an XMM register, emit sqrtss for f32 or sqrtsd for f64, and store the result back. This enables us to calculate the square root of numbers and print the result.
Commit 5: malloc / free on the ELF backend
Commit: fix: fix malloc and free calls on the ELF Backend
Functions with malloc and free calls linked on ELF but crashed on the first malloc(). This was because platform_heap_grow failed in buddy.c as the heap was never set up. This was because of missing platform_init() call in _start for linux platform.h file. We need to call platform_init() from _start so that the heap is set up before the first malloc() call. This was fixed by calling platform_init() from _start in synth_start() function. This function is called once at program startup, before main runs.
Commit 6: Rename test entry to app_main on ELF backend
Commit: fix: rename main to app_main for elf backends
On native, wasm, and macho backends the tests use #define main app_main so the test body keeps a stable symbol name while the platform supplies the real entry point. ELF was missing that rename, so func_macro printed main from __func__ instead of the expected app_main. This commit applies the same pattern in run_tinyc_tests.py: ELF unity now renames the test to app_main and adds a thin main() wrapper that calls it (platform init stays in synthesised _start), then registered func_macro on the elf target in tests.toml.
Remaining six tests (220 / 226 on ELF)
The six tests that do not compile on ELF are not gaps in the x64 backend. They test wasm or wasm to Mach-O behaviour and are omitted from the elf target (or skipped on macho for the same reason):
wasm_import_attr,wasm32_long_is_i32— WASI imports (fd_writefromwasi_snapshot_preview1); run on wasm and the default macho (wasm) backend; skipped on native (native_skip) and direct macho-llvm (macho_llvm_skip).builtin_wasm_memory—__builtin_wasm_memory_size/memory.grow; wasm-only (native_skip).static_func_collision— multi-TU wasm link (link_separately); wasm-only.libc_qsort— needs hostqsortso skipped on wasm and macho too (wasm_skip); not in corec-stdlib on ELF.define_malloc_free— redefinesmalloc/freein the test itself; skipped in the unity runner (single_tu_skip; alsowasm_skipon macho/wasm).
Together with these commits, we support 220 passing tests out of 226 in the tinyc test runner for the ELF backend.
Next Steps:
- Start working on the benchmarking speed of execution of the mlir backend, by comparing the performance of tinyc compiler with the new backend with Clang.
- Implement the optimizations like elimination of string comparisions, malloc etc for improving the speed of execution of the mlir backend.
- Once these steps are completed, I shall be working on the integration of the new mlir backend with the lfortran compiler.
Acknowledgments
A huge thanks to my mentors, Ondřej Čertík, Pranav Goswami, Harshita Kalani & other contributors for helping me to work on this project.