My time at Seneca College has been a complex experience, marked by both challenges and profound learning opportunities. The journey wasn’t without its difficulties. Being at Seneca International Academy gave me a hard time with teachers who don’t have experience in the field and don’t want to teach us. This experience was undoubtedly frustrating and highlighted some institutional problems. (Guess I’ll write another blog post about it…)
However, despite these challenges, I found incredible value in the relationships with dedicated educators who genuinely cared about student learning. Professors like Professor Chris Tyler from the Software Portability & Optimization (SPO600) course, Professor David Humphrey from the Cloud Computing for Programmer (CCP 555) course, and Professor Cameron Gray from the Introduction To Programming with C (IPC 144) course transformed potential disappointments into meaningful educational experiences, demonstrating that the quality of education often transcends institutional structures.
But I’m here not to talk about problems in Seneca. This post is to wrap up what have I done so far in the SPO600 course, and what I’ll do with the “GCC Compiler Pass Development” blog posts.
SPO600: Start of Studies
My journey in SPO600 has been an exciting adventure, starting with the basics of assembly language and moving on to compiler development. We began by learning about the 6502 processor, which was used in famous computers like the Apple II. We dove into its assembly language and then explored other architectures like AArch64 and x86_32, each revealing how modern computers work.
This progression was more than just a technical exercise; it was about understanding how computers operate from the ground up—starting with simple machine instructions and progressing to complex systems. We weren’t just coding; we were learning how computers “speak.”
SPO600: GCC Compiler Pass Development, Beginning
After that, the SPO600 course represented a remarkable deep dive into compiler infrastructure, specifically focusing on GCC tree pass development. We began by exploring how to build the GCC compiler on our machines. With the successful setup of the compiler, we could start experimenting with custom passes.
Through a series of blog posts, I delved into the intricate world of compiler intermediate representations, particularly GIMPLE, and developed custom compiler passes that offered unique insights into code analysis and optimization. My experimental passes started with the diagnostic-focused `tree-ctyler.cc` and evolved into the more sophisticated `tree-prune.cc`. These projects were not merely academic exercises; they were genuine explorations of compiler technology.
Throughout this experience, I had the opportunity to:
Develop basic function analysis and pruning strategies
Gain hands-on experience with compiler infrastructure modification
SPO600: GCC Compiler Pass Development, Achievements and Learning Outcomes
So, by developing these compiler passes, I achieved several significant milestones:
Successfully integrated custom passes into the GCC compilation pipeline
Created mechanisms to analyze function statements
Developed a basic approach to identifying potentially redundant or similar functions
Gained profound insights into compiler internals
Had fun doing it! (Which, in my opinion, is the most important thing)
SPO600: Continuing the Journey
As I conclude this academic chapter, the question naturally arises: Will I continue exploring compiler development?
The answer is yes! The SPO600 course has not been an endpoint but a launching pad. The skills acquired – understanding complex software infrastructure, developing low-level analysis tools, and navigating large codebases – are universally applicable across software engineering domains.
With that, my immediate goals for the near future include:
Refining the function analysis techniques developed in these passes
Exploring more sophisticated optimization strategies
Potentially contributing to GCC or other open-source compiler projects
SPO600: Final Thoughts
This journey through compiler pass development and learning assembly language has been more than an academic course. It’s been a testament to the power of curiosity, persistent learning, and the joy of understanding complex systems from the inside out.
Also, I am truly grateful to my professor, Chris Tyler, for illustrating that compiler development and things that work on our computer are far more than just a theoretical concept or the number of bytes in a system. It is a nuanced craft that requires a deep understanding of code structure, transformation techniques, and performance implications.
To future students and enthusiasts: embrace these opportunities. Each line of code, each exploration into system internals, is a step toward deeper technological understanding.
At last, thank you, Professor Chris, for an incredible learning experience.
Hi there! How is it going everyone who checked this post?
Following up on my previous exploration of GCC’s compilation process, I’ve been developing custom compiler passes that interact with GCC’s intermediate representation (IR). This post will describe my journey of creating two experimental passes in the GCC compiler infrastructure.
Before we start, here are the links to code that will be discussed in this post:
Initial Exploration: Defining the tree-ctyler.cc Pass
My first step was to get a simple diagnostic pass, that would iterate through all functions and dump it in the file. Fortunately, we were provided by our professor with a default pass that we can expand on. As expected, it was designed to do a basic traversal of all functions in the compilation unit. Let’s break it down.
First, include headers:
#define INCLUDE_MEMORY
#include "config.h" /* One of the main headers that are loaded
* into GCC source files.
* Contains system-specific configuration details
* determined during the build process */
#include "system.h" /* Provides a standardized way to include
* sys and std headers */
#include "core types.h" /* This one contains fundamental type definitions and macros. */
#include "backend.h" /* Main header for compiler's backend infrastructure. */
#include "tree.h" /* Defines tree data structure, a GCC's primary internal representation
* of program structure.
* This is one of our main tools for seeing and
* using GCC representation and transformations of source code. */
#include "gimple.h" /* Defines GIMPLE intermediate representations.
* We will use it with the previous header to prune our code later. */
#include "tree-pass.h" /* A header that defines infrastructure for compiler passes.
* Meaning, that this is our core header */
#include "ssa.h" /* A header that implements the Static Single Assignment (SSA) form.
* It may contain something usefull for later iterations */
#include "gimple-iterator.h" /* Iterators for GIMPLE statements.
* Safe and convenient iteration over basic blocks and statements */
#include "gimple-walk.h" /* Tools for traversing and analyzing GIMPLE representation.
* It might be useful in later iterations */
#include "internal-fn.h" /* Defines compiler-specific functions? */
#include "gimple-pretty-print.h" /* Functions for printing GIMPLE representations in a human-readable format. */
These headers collectively form the core infrastructure for GCC’s internal code representation, transformation, and optimization processes. They abstract away platform-specific details and provide a consistent, powerful framework for compiler development.
Next, our pass data structure. As told in tree-pass.h:
Metadata for a pass, non-varying across all instances of a pass.
const pass_data pass_data_ctyler = {
GIMPLE_PASS, /* type of our pass, cloud be anything from opt_tree_pass enum in tree-pass.h*/
"ctyler", /* name of our pass. if it starts with star (*) it will not print dump */
OPTGROUP_NONE, /* optinfo_flags - optimization group of our pass. enum optgroup_flag in dumpfile.h*/
TV_NONE, /* tv_id - idk what this is and why we use it */
/*
PROP_cfg, /* properties_required */
0, /* properties_provided - */
0, /* properties_destroyed */
/* next has something to do with TODO structure */
0, /* todo_flags_start */
0, /* todo_flags_finish */
};
Basically, this is the configuration of what we want to do with our pass.
Then, we have our main pass_ctyler class, which expands gimple_opt_pass base class:
class pass_ctyler : public gimple_opt_pass
{
public:
/* Default constructor for our class */
pass_ctyler (gcc::context *ctxt)
: gimple_opt_pass (pass_data_ctyler, ctxt)
{}
/* opt_pass methods: */
/* function that checks if this pass should be executed */
/* by default returning 1 in gimple_opt_pass, but we set it to be sure */
bool gate (function *) final override {
return 1; // always execute pass
}
/* Function that will be called on each function of source code */
unsigned int execute (function *) final override;
}; // class pass_ctyler
Next, we have our execute function – code that will be executed on each function during the tree pass. This is a main piece of code that we will change later. As of now, it simply counts and lists functions:
unsigned int pass_ctyler::execute (function *)
{
struct cgraph_node *node;
int func_cnt = 0;
FOR_EACH_FUNCTION (node)
{
if (dump_file)
{
fprintf (dump_file, "=== Function %d Name '%s' ===\n", ++func_cnt, node->name() );
}
}
return 0;
}
With that, we need to define the factory function that will create our class. That’s why we created this function, called make_ctyler_pass:
This is the requirement for the next piece of GCC’s source code that we will modify.
Initial Exploration: Modifications to Run Pass
To actually include our pass into the GCC compiler, we need to modify some of the existing source code. Here are the steps:
First, we have to modify Makefile.in:
This is a file that will help us build everything during the compilation phase of our source code.
You need to add tree-ctyler.o into the OBJS array – doesn’t matter where exactly. I personally put it after this line: “tree-complex.o \”.
Next, we need to modify passes.def file:
This is where we define the steps of our pass and after which tree pass another pass should execute.
Our pass is supposed to check everything after most of the optimizations, but shouldn’t be too close to the end. Therefore, I added “NEXT_PASS (pass_ctyler)” after “NEXT_PASS (pass_nrv)”
Lastly, we need to add our pass to the tree-pass.h:
This is where our factory method (make_pass_ctyler) comes in.
It doesn’t matter where we put it, but I put this line after make_pass_unused_result
With that, we can reconfigure the pass and rebuild it, and it’s good to go! You can check how to do the configuration process in my previous post.
Initial Exploration: Produced Dump File
Welp, sorry guys, this pesky rat ate all of my dump files (definitely not me doing “rm -rf ./”). I will add example dump files to the folder with pass-ctyler.cc later.
That is why, I encourage you to try building GCC on your own – it’s fun!
Advancing to tree-prune.cc: A More Advanced Pass
The next iteration is tree-prune.cc, which builds on the first pass with a more sophisticated analysis:
Counts total statements in each function
Creates a dynamic buffer to store statement codes
Generates a diagnostic string of statement types that can be used later as a naive approach to pruning our functions.
I will mostly concentrate on modifications to execute the function.
First, creation of dump code array:
FOR_EACH_BB_FN(bb, fun) {
gimple_stmt_iterator gsi;
for (gsi = gsi_start_bb(bb); !gsi_end_p(gsi); gsi_next(&gsi)) {
total_stmt_count++;
}
}
stmt_buffer_ints = new int[total_stmt_count];
if (!stmt_buffer_ints) {
return 0;
}
memset(stmt_buffer_ints, 0, (total_stmt_count) * sizeof(int));
First, using “FOR_EACH_BB_FN”, we count each gimple statement. Then, using “new”, we create a dynamic array of integers to write our codes in it. Lastly, we set all fields in this array to 0. Later, this array will be dumped in “dump_file”.
With “FOR_EACH_BB_FN” we are getting basic blocks of code (aka line-by-line code after all modifications). Then, via “gsi_start_bb(bb)” we get a GIMPLE iterator that can get us a GIMPLE statement later on. With this, we get a code for this GIMPLE line, and we dump it to our stmt_buffer_ints array.
Lastly, we make a dump into the file:
if (dump_file) {
fprintf(dump_file, "Number of statements: %d\n", total_stmt_count);
fprintf(dump_file, "Statement count string:\n");
for (iter_stmt = 0; iter_stmt < total_stmt_count; iter_stmt++) {
fprintf(dump_file, "%d", stmt_buffer_ints[iter_stmt]);
}
fprintf(dump_file,
"\n\n#### tree_prune.cc: end of prune diagnostics, start "
"regular dump of current gimple ####\n\n\n");
}
delete[] stmt_buffer_ints;
return 0;
We just check if we have dump_file open, and dump all the data that we want into this file. Also, we shouldn’t forget to free previously allocated resources.
Advancing to tree-prune.cc: Analyzing Dump File
Let’s analyze the dump files generated by our tree-prune.cc pass. You can find them here.
Dump File Characteristic:
Looking at the output, we can observe some interesting patterns:
We have several functions in the dump:
scale_samples (default and popcnt variants)
scale_samples.resolver
sum_sample
main
Number of Statements:
scale_samples (both variants): 165 statements
sum_sample: 32 statements
scale_samples.resolver: 5 statements
main: 59 statements
Statement Count Strings:
A key observation is the statement count strings for different functions:
With dumped functions inside the file, we see that everything except variable names is the same, but what if we assign different variables in different order? This approach might mark that these functions are identical. That is why my approach is naive.
In this particular case, the scale_samples clones (default and popcnt) appear identical in terms of statement count, statement type sequence, and dump of source code with objdump. However, this is a surface-level analysis that will work to some extent.
Next Steps and Limitations
Our current method is a basic approach to function comparison.
To truly understand function differences, we’ll need more sophisticated analysis techniques.
The identical statement strings suggest the functions might be very similar, but we can’t conclusively say they are exactly the same.
Interesting Observations
It is important to remember, that we need to reconfigure everything (aka “~/gcc/configure”) and recompile GCC, once we modify Makefile.in or any autogenerated files in the GCC folder. Otherwise, we will get strange errors and won’t be able to compile our code.
Subsequential compilations after the first compilation of GCC are much faster (for instance, 1st compilation was for 34 mins, and 2nd compilation, after modification to tree-prune.cc, was 1 minute). A cool feature of the Make tool!
It was interesting to see GCC’s “poisoning” of malloc(). To allocate memory, I had to use either new or their xmalloc(). If anyone knows the reason for having xmalloc and not transferring to new – hit me up on LinkedIn!
In the beginning, I planned to wrap the current “FOR_EACH_BB_FN” with “FOR_EACH_FUNCTION(node)”, which was used in tree-ctyler.cc. However, this approach gave me an “irange::range” error from the ssa.h library. Same as previous, if someone knows the reason – hit me up on LinkedIn!
Next Steps and Future Work
The current implementation provides a foundation for more advanced compiler pass development. Some potential future directions include:
Implementing more sophisticated code analysis
Exploring function clones to prune them later
Further improving on debugging of GCC tree passes and checking more libraries within GCC that can help with that
Conclusion
Developing custom GCC passes is a complex but rewarding process. It requires a deep understanding of the compiler’s internal representation and careful navigation of its infrastructure. Each pass provides a unique window into how compilers transform and optimize code.
Stay tuned for our next exploration into the depths of compiler technology!
I’m continuing the chain of posts about my SPO600 course at Seneca College this fall with a new entry! Today, I’m starting an exciting project to revolutionize how compilers work. As part of the course, we’re implementing Automatic Function Multi-Versioning (AFMV) in the GNU Compiler Collection (GCC). But before we dive into the deep end, let’s start with the basics.
The Big Picture: Automatic Function Multi-Versioning (AFMV)
Imagine a compiler so smart that it could automatically create multiple versions of a function, each optimized for different hardware capabilities, without any extra work from the programmer. That’s the main functionality of AFMV. It builds upon existing technologies like IFUNC (indirect functions) and FMV (Function Multi-Versioning), but takes them to the next level by making the process automatic.
With AFMV, the compiler would:
Clone all functions
Optimize each clone for different architectural variants
Intelligently prune back to a single implementation when clones are fundamentally the same
This could lead to significant performance improvements across various hardware platforms, all without requiring developers to manually optimize their code.
Why Build GCC from Source?
Now, you might be wondering, “Why start by building GCC from source?” Well, this is the first crucial step towards contributing to the AFMV implementation. By compiling GCC on different platforms, we’re not just flexing that we know something – we’re gaining intimate knowledge of the compiler’s inner workings. This understanding is essential for making meaningful contributions to such a complex project.
In this blog post, I’ll share my experiences building GCC on three different systems:
AArch64 (MacAir with M2 processor – MacOS)
x86_64 (College’s Server – Fedora Linux)
x86_64 (Personal Lenovo IdeaPad – Arch Linux)
Each platform presents its own set of challenges and insights, which will prove invaluable as we progress towards our AFMV goal.
A Special Note on Apple Silicon
As we go deeper into this project, it’s worth highlighting that support for Apple Silicon (like the M2 processor in my MacAir) is still not fully integrated into the main GCC repository. This presents both a challenge and an opportunity – our work might contribute not only to AFMV but also to improving GCC’s support for these cutting-edge processors.
So, grab your favourite caffeinated beverage and join me on this thrilling expedition into the heart of GCC. By the end of this post, you’ll have a solid understanding of the GCC build process across different platforms—our first step towards the grand vision of AFMV.
Let the compilation begin!
Building on x86_64 (Fedora and Arch Linux)
Before building, you need to ensure that you have the following things installed:
Compiler – GCC or Clang will work
Git – to clone GCC repository
So, for both systems, I used a similar build process as described below:
1. Cloning the GCC repository to the gcc-snapshot directory:
The –disable-multilib option was necessary because my Arch Linux system lacked the headers for the 32-bit version. This is something I plan to explore further in future blog posts.
3. Building GCC:
time make -j 20 | tee build.log
The -j 20 flag allows the make utility to run up to 20 parallel jobs, which can significantly speed up the build process on multi-core systems. However, for IdeaPad, which has only two cores, the maximum number of jobs I was able to allocate for it to work efficiently was 5:
time make -j 4 | tee build.log
In general, the number that is passed to -j should be in the range of (number of cores + 1) to (number of cores * 2 + 1) for it to work efficiently (as suggested by our professor).
4. Installing GCC:
make install
This command will install everything in the directory specified in step 2 (–prefix argument).
After the installation, I put my newly built GCC (version 15.0.0) through its paces by compiling the classic “Hello World!” program in both C (using stdio.h) and C++ (using iostream). It was a small but satisfying victory to see these programs compile and run without a hitch.
Building on AArch64 (MacOS)
THIS BUILD PROCESS IS SPECIFICALLY FOR MACOS WITH M-SERIES PROCESSORS! The previous build process for Linux on ARM64 machines should work as is.
Before diving into the build process, I had to gather a few essential tools and libraries. Here’s what you’ll need if you’re following along at home:
XCode – IDE from Apple but we need only two things from it:
Clang/Clang++ – the default C and C++ compilers for MacOS, comes with the default installation of XCode.
MacOS SDK – comes with the default installation of XCode.
IMPORTANT NOTE: I ran into the problem by using the default repository of GCC: “darwin-23.0.5 platform is not recognized…”.
As mentioned by Iain Sandoe at GCC mail, the support for the Apple Silicon processors is not upstreamed in the main mirror. So, instead of using the standard GCC repository (as in x86_64), we need an experimental branch that supports ARM-based Macs.
2. Creating a build directory:
mkdir gcc-build-make
cd gcc-build-make
3. Configuring the Build – the configuration step required specifying the locations of our Homebrew-installed libraries and the MacOS SDK, as it’s not specified in PATH:
Processor Power Matters: The high-end Intel i9 and Apple M2 processors significantly outperformed the older i5, demonstrating the importance of raw processing power for compilation tasks.
Parallel Processing Efficiency: The ability to utilize more cores (24 jobs on the server, 12 on the M2) greatly accelerated the build process compared to the 5 jobs on the IdeaPad.
Architecture Differences: Despite the architectural differences between x86_64 and AArch64, the M2 chip matched the performance of the high-end Intel processor, showcasing Apple’s impressive silicon design.
OS and Configuration Impact: While not as significant as hardware differences, the variations in operating systems and configurations (like the need to –disable-multilib on Arch Linux) can affect build times and processes.
Conclusion
Embarking on this journey of building GCC from source across multiple platforms has been a great experience, providing valuable insights into cross-platform development, building systems, and the intricacies of compiling a complex software project like GCC.
The next step after this preparation stage will be checking the source of the GCC, figuring out how to add things to the compilation process, accessing the intermediate representation of the code, and figuring out the right arguments to pass to gcc to access different types of code dumps (like -fdump-tree-all or -fdump-tree-pass).
Therefore, stay tuned for more and may force be with you!
For this lab, I dove into the intricacies of assembly programming on two very different architectures: x86_64 and AArch64. My journey began with the AArch64 platform, followed by the x86_64 architecture. Below, I’ll describe my experiences, challenges, and the key differences between these two platforms, drawing from both my practical work and insights from comparing them to the simpler 6502 architecture.
Everything was executed on the College’s Servers, which have x86_64 and AArch64 CPUs.
You can find the reference provided by our professor here:
I began with AArch64 assembly on one of our lab servers, then moved to x86_64 on another machine. The goal was to create increasingly complex loop programs that would:
Print a simple message in a loop
Display loop counter values (0-9)
Handle two-digit numbers (00-30)
Suppress leading zeros for a cleaner output
Starting with AArch64
I started with an AArch64 assembly code that does nothing but loops, and then progressively enhanced it. Here is the full code (available through Google Drive to download) by our professor Chris Tyler:
.text
.globl _start
min = 0 /* starting value for the loop index; **note that this is a symbol (constant)**, not a variable */
max = 10 /* loop exits when the index hits this number (loop condition is i<max) */
_start
mov x19, min /* copy 0 in x19 */
loop:
/* ... body of the loop ... do something useful here ... */
add x19, x19, 1 /* add 1 to x19 */
cmp x19, max /* compare x19 to 10 */
b.ne loop /* loop if it's not equal */
mov x0, 0 /* status -> 0 */
mov x8, 93 /* exit is syscall #93 */
svc 0 /* invoke syscall */
So, what do we have here?
Constans and heading: this is basically where we declare our constants (like min and max), and starting point of our code (.global _start).
“_start:”: a label that states the main start for our code. A “loop” is just a label.
This code does not have any output, but we can say that it will run 10 times before exiting.
Hello Loop!
The first thing was to integrate this code with the hello-world code to print the loop:
So, here is how I integrated it into the existing code:
.text
.globl _start
min = 0 /* starting value for the loop index; **note that this is a symbol (constant)**, not a variable */
max = 10 /* loop exits when the index hits this number (loop condition is i<max) */
_start:
mov x19, min
loop:
mov x0, 1 /* file descriptor: 1 is stdout */
adr x1, msg /* message location (memory address) */
mov x2, len /* message length (bytes) */
mov x8, 64 /* write is syscall #64 */
svc 0 /* invoke syscall */
add x19, x19, 1
cmp x19, max
b.ne loop
mov x0, 0 /* status -> 0 */
mov x8, 93 /* exit is syscall #93 */
svc 0 /* invoke syscall */
.data
msg: .ascii "Loop\n"
len= . - msg
Changes I made:
Printing the message: first, I loaded the descriptor to have stdout (standard output), and then loaded length and message location, to execute syscall (system call) in order to print the message
“.data” section: I added the “.data” section, which has the message content and length of the message.
And the output, 10 prints of “Loop”:
Loop
Loop
Loop
Loop
Loop
Loop
Loop
Loop
Loop
Loop
Displaying numbers 0 to 9
Next, I had to put the numbers into the text:
.text
.globl _start
min = 0 /* starting value for the loop index */
max = 10 /* loop exits when the index hits this number */
_start:
mov x19, min /* Initialize loop counter */
loop:
/* Prepare the message */
adr x0, msg /* Load address of the message */
mov x1, x19 /* Move loop counter to x1 */
add x1, x1, 48 /* Convert to ASCII by adding 48 */
strb w1, [x0, 5] /* Store the ASCII digit in the 6th position of msg */
/* Print the message */
mov x0, 1 /* file descriptor: 1 is stdout */
adr x1, msg /* message location (memory address) */
mov x2, len /* message length (bytes) */
mov x8, 64 /* write is syscall #64 */
svc 0 /* invoke syscall */
/* Increment and check loop condition */
add x19, x19, 1 /* Increment loop counter */
cmp x19, max /* Compare with max value */
b.ne loop /* If not equal, continue loop */
/* Exit program */
mov x0, 0 /* status -> 0 */
mov x8, 93 /* exit is syscall #93 */
svc 0 /* invoke syscall */
.data
msg: .ascii "Loop 0\n" /* Message template with placeholder for digit */
len = . - msg /* Length of the message */
Here is what I did:
Modified the message template to include a placeholder for the digit: msg: .ascii “Loop 0\n”
Used register manipulation to convert the loop counter (x19) into an ASCII character:
Moved the loop counter to x1: mov x1, x19
Added 48 (ASCII code for ‘0’) to convert the number to its ASCII representation: add x1, x1, 48
Stored the ASCII character into the message:
Used byte-specific storage with strb w1, [x0, 5] to write only one byte at position 5 in the message (where the ‘0’ placeholder was)
This approach ensured that each number (0-9) would be correctly displayed as its ASCII character equivalent. And now we can display the text “Loop 0” up to “Loop 9”:
Next, I had to show the numbers from 0 to 30 (just key changes, full code in loop-print-loop-0-30.s):
loop:
/* Prepare the message */
adr x0, msg /* Load address of the message */
mov x1, 10 /* Store 10 at x1 for later calculations */
udiv x2, x19, x1 /* Divide x1 by 10 and store num of 10th of curr idx */
msub x3, x2, x1, x19 /* x3 = x1 - (x2 * x1) - getting reminder */
add x2, x2, 48 /* Convert to ASCII by adding 48 */
add x3, x3, 48 /* Convert to ASCII by adding 48 */
cmp x2, 48
b.ne continue
add x2, x2, -16
continue:
strb w2, [x0, 5] /* Store the ASCII digit of 10th in the 6th position of msg */
strb w3, [x0, 6] /* Store the ASCII digit of reminder in the 7th position of msg */
/* Print the message code here... */
.data
msg: .ascii "Loop 00\n" /* Message template with placeholder for digits */
len = . - msg /* Length of the message */
So what are these chagnes?
Changed the message template to accommodate two digits: msg: .ascii “Loop 00\n”
Implemented division to split the number into tens and ones:
Used mov x1, 10 to set up the divisor
Used udiv x2, x19, x1 to get the quotient (tens digit)
Used msub x3, x2, x1, x19 to get the remainder (ones digit) by calculating x19 – (x2 * x1)
Converted both digits to ASCII:
Added 48 to both quotient and remainder: add x2, x2, 48 and add x3, x3, 48
Handled leading zero suppression:
Compared the tens digit with ASCII ‘0’ (48): cmp x2, 48
If it was zero (indicating a single-digit number), replace it with a space: add x2, x2, -16
Stored both digits in the message:
Used strb w2, [x0, 5] for the tens position
Used strb w2, [x0, 6] for the ones position
This implementation allowed for a clean display of numbers from 0 to 30 (and above!), with proper spacing for single-digit numbers and correct positioning of both digits for two-digit numbers.
So with this, I was done with the AArch64 portion and most of the code logic, which I can reuse for the x86_64 version!
Thoughts on AArch64
But before that, here are a few interesting observations that stood out during AArch64 development:
ASCII Conversion: While working on converting numbers to ASCII, I realized that values above 30 in my loop would output characters like A, B, and C—direct results of how the ASCII table assigns codes to letters after 9. This was a reminder of the importance of correctly handling and limiting ranges when working with ASCII conversions.
Division Operations: One of the key parts of this lab was figuring out how to divide numbers and calculate remainders. In AArch64, I used the udiv instruction to divide and the msub instruction to compute the remainder. This was much simpler than what I encountered in 6502, where the division had to be manually implemented due to a lack of dedicated division instructions.
Byte-Specific Storage: One of the more subtle but significant learnings was when I needed to write only one byte into memory using strb. If I had used str instead, it would have written an entire quadword, potentially overwriting memory beyond what I intended. (Iva explained this part to me, but it was an interesting concept to grasp).
Overall, AArch64 felt more intuitive in terms of its register handling, especially with register names like w2 and x2—indicating 32-bit or 64-bit access.
Transitioning to x86_64
And here is the code for x86_64 to print 0 to 30:
.text
.globl _start
min = 0 /* starting value for the loop index; **note that this is a symbol (constant)**, not a variable */
max = 30 /* loop exits when the index hits this number (loop condition is i<max) */
stdout = 1
_start:
mov $min,%r15 /* loop index */
loop:
mov $10,%r10 /* Move 10 to r10 */
mov %r15,%rax /* copy loop index to rax */
mov $0, %rdx /* set rdx to 0 */
div %r10 /* divide rax by 10. quotient in rax, reminder in rdx */
mov %rax,%r11 /* store quotinent in r11 */
mov %rdx,%r12 /* store reminder in r12 */
mov $msg,%r10 /* load msg location into r10 */
add $48,%r11 /* convert val at r11 into ASCII */
add $48,%r12 /* convert val at r12 into ASCII */
cmp $48,%r11
jne continue
add $-16,%r11
continue:
mov %r11b,5(%r10) /* set first 0 */
mov %r12b,6(%r10) /* set second 0 */
mov $len,%rdx /* message length */
mov $msg,%rsi /* message location */
mov $stdout,%rdi /* file descriptor stdout */
mov $1,%rax /* syscall sys_write */
syscall
inc %r15 /* increment index */
cmp $max,%r15 /* see if we're done */
jne loop /* loop if we're not */
mov $0,%rdi /* exit status */
mov $60,%rax /* syscall sys_exit */
syscall
.data
msg: .ascii "Loop 00\n"
.set len , . - msg
Switching to x86_64, I encountered some familiar concepts but with notable differences in execution:
Division in x86_64: Unlike in AArch64, where division and remainders are straightforward, x86_64 requires careful use of multiple registers. I had to store the quotient in the %rax register and the remainder in the %rdx register after each div operation. This was slightly more complex, but I appreciated how x86_64 assigned dedicated registers for division.
Message Writing: When writing specific bytes of the message, x86_64 required moving values to particular locations in memory using instructions like *mov %r11b, 5(%r10)* to write the quotient and remainder into specific slots of the message buffer.
Syntax Preferences: While I found x86_64’s register syntax to be a bit cryptic (e.g., *%r11b* for an 8-bit portion of a register), I appreciated the simplicity of *mov* in x86_64 over the *strb* and *ldrb* instructions in AArch64.
Reflections on Register Conventions and Code Organization
So, I wanted to reflect on the code, that I’ve written on AArch64 and x86_64. And differences between them became clearer as I progressed:
Register Naming: AArch64 has a clean and straightforward naming system. Register x19, for example, is easy to follow. In contrast, x86_64’s naming conventions can feel convoluted with %rax, %rdx, and %r11b needing more careful management.
Instruction Syntax: Both architectures have strict yet different syntactical rules. AArch64 felt slightly more flexible without the need for prefixes like % for registers.
Code Organization: Compared to 6502, both modern architectures show remarkable improvements in code structuring, especially with the introduction of .data and .rodata sections. These sections clearly separate code from data, which made the experience of organizing larger programs much more efficient than the simpler 6502 days.
Debugging Experience
Throughout this lab, I relied heavily on gdb to debug both platforms. Setting breakpoints and tracking register changes helped immensely when something didn’t work. I also referred to StackOverflow for specific issues, such as ensuring correct syscall usage on both platforms. GNU tools such as GNU Assembler (as) and GNU Linker (ld) were crucial to assembling and linking the programs.
Final Thoughts
This lab gave me hands-on experience with how assembly languages differ between architectures, both in terms of programming and debugging. While AArch64 felt more intuitive with register usage and instructions, x86_64 offered deeper control with its dedicated registers for specific tasks. Both, however, provided more functionality and flexibility than the 6502, whose simplicity was once state-of-the-art for machines like the Atari 2600 and Apple I.
Having worked on all three, I appreciate the architectural advances that have made coding in assembly a more organized and powerful process 🙂