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!
Following up on my previous post about building GCC from source, we’ll explore some of the compiler’s more intricate features – specifically, the dump flags and optimization levels. This knowledge will be crucial for implementing Automatic Function Multi-Versioning (AFMV).
Setting Up Our Experimental Environment
Before we dive in, I wanted to make sure we’re using our experimental GCC build instead of the system’s default compiler. We can do this by updating our PATH:
PATH=$HOME/path-to-gcc-directory/bin:$PATH
To verify we’re using the right version, run the following:
gcc -v
You should see something like: “gcc version 15.0.0 20241016 (experimental) (GCC)”. This confirms we’re working with our custom-built compiler.
Building and Analyzing Dumps
After the previous steps, I wanted to test the compiler and dive deeper into the dump files with two simple programs written in C programming language and compiled with my locally installed GCC. Here is the code for them:
// main1.c
// prints some random text
#include <stdio.h>
int main() {
printf("This simple program writes text!");
return 0;
}
// main2.c
// prints some text and
// then has a loop to print
// the loop counter
#include <stdio.h>
int main() {
printf("This program will print a loop!\n");
int n = 4;
for (int i = 0; i <= n; i++)
printf("Loop cntr: %d\n", i);
return 0;
}
The first program prints the string “This simple program writes text!” to the screen and returns. The second program also prints a similar line to the screen, loops over a variable initially set to 4, prints the string “Loop cntr: index” in each iteration, and then returns.
We can compile our two programs with the following commands:
Note that we are using the -O3 on main2.c to check if the compiler will have more passes, and if there will be any noticeable differences.
Understanding Compilation Flags
The flags that were used were -fdump-tree-all and -O3. These flags will be used to tell the compiler about the level of optimization and which dump it should generate during the compiling process.
The -fdump-tree-all flag was used to tell the compiler to create dumps for all passes during the compilation process. These dumps are stored in files called intermediate representation (IR) and their names have basic information that tells in which pass they were generated and what that pass aimed to accomplish.
For instance, one of the files we will analyze is called main1.c.006t.gimple. Here is how to break down what each word means:
main1.c – source code file, for which this pass was done
.006t – on which compilation pass this step was done (this operation was done on the 6th pass of the compilation process)
.gimple – short description of what was done during this tree pass (here, the compiler generated the GIMPLE code)
In addition, according to the GCC wiki, we can specify which specific passes we want to get. For example, instead of -fdump-tree-all, we can use the -fdump-tree-gimple compilation flag to get only the GIMPLE intermediate representation.
Going back to another compilation flag, the -O3 flag specifies the level of optimization the compiler should apply to the code, if none is specified, the compiler will use the -O0 optimization level. Here is a brief explanation of what each level of optimization does:
-O0: No optimization (default) – Best for debugging as it maintains a straightforward relationship between source code and machine instructions
-O1: Basic optimizations that don’t require much compilation time
-O2: More aggressive optimizations without space-speed trade-offs
-O3: Maximum optimization, potentially using optimization techniques that might increase the file size.
These flags are inclusive of the previous flag option, so on -O2, all flags from -O1 will also be active, plus additional ones.
I found out that there are also other optimization flags which are used for more specific needs such as -Osfor optimizations for size, or -Og for optimizations for debugging. If you want to have a comprehensive list of optimizations enabled at each level, you can refer to the GCC documentation.
Analyzing Program Structure with -fdump-tree-all
Simple print into the console:
Let’s start by examining our first program (main1.c) using GCC’s dump flags:
#include <stdio.h>
int main() {
printf("This simple program writes text!");
return 0;
}
When we compile this with -fdump-tree-all, GCC generates various intermediate representation (IR) files. Let’s look at two particularly interesting ones:
1. The GIMPLE representation (main1.c.006t.gimple):
int main () {
int D.3403; // initialize integer variable
// will be used as the return val
printf ("This simple program writes text!");
D.3403 = 0; // set return value to 0
return D.3403; // return value and exit app
D.3403 = 0; // same as previous
return D.3403; // therefore, my "return 0;"
// statement is redundant
}
This GIMPLE representation shows how GCC breaks down our code into simpler statements. Notice how it introduces a temporary variable (D.3403) for the return value and actually generates redundant return statements as the return statement for the main function is optional and will always be included.
2. The debug representation (main1.c.363t.debug) is currently empty in our case, as we’re using -O0 which maintains a simple mapping between source and machine code. However, it is worth mentioning that debug IR is one of the last passes in the compilation process (the 363rd pass). We can only imagine how many steps our compiler performs in order to give us ready code…
Optimized Loop Counter
Things get more interesting when we compile our second program (main2.c) with higher optimization levels:
#include <stdio.h>
int main() {
printf("This program will print a loop!\n");
int n = 4;
for (int i = 0; i <= n; i++)
printf("Loop cntr: %d\n", i);
return 0;
}
When compiled with -O3, we see many more IR files, from main2.c.005t.original all the way to main2.c.363t.debug (with much fewer passes missing). The loop-specific passes are particularly interesting:
main2.c.158t.fix_loops: Prepares loops for optimization
main2.c.159t.loop: Performs general loop optimizations
main2.c.161t.unswitch: Attempts to move loop-unrelated conditions out of loops
These passes show how GCC progressively transforms and optimizes our code, especially when dealing with loops.
The Impact of Optimization Levels on Compilation Passes
When we examine the compilation outputs more closely, we can see a dramatic difference in the number and types of passes performed at different optimization levels. Let’s compare the compilation results:
Basic Compilation (main1.c with -fdump-tree-all)
Our simple text-printing program generated approximately 30 intermediate files. These represent the basic passes that GCC performs even without optimization, including:
Initial parsing (.005t.original)
GIMPLE generation (.006t.gimple)
Basic lowering and cleanup passes (.009t.omplower, .010t.lower)
Exception handling setup (.012t.ehopt, .013t.eh)
Final debug information (.363t.debug)
Loop Program Without Optimization (main2.c with -fdump-tree-all)
Before generating the optimized code for main2.c, I tried to do default compilation. The program with a loop generated roughly the same number of passes (~30) when compiled without optimization flags. This shows that without explicit optimization requests, GCC performs only the necessary passes to generate correct code, regardless of the program’s complexity.
Loop Program With -O3 (main2.c with -fdump-tree-all -O3)
Here’s where things get interesting! With -O3, the number of intermediate files exploded to over 150 passes. This demonstrates the extensive optimization work GCC performs at higher optimization levels. Some notable additional passes include:
Dead code elimination (.142t.dce3), appears in the -O2 optimizations list (-dce, -ftree-dce)
Loop-specific optimizations:
Loop initialization (.160t.loopinit)
Loop unswitching (.161t.unswitch), appears in the -O3 optimizations list (-funswitch-loops)
Loop versioning (.164t.lversion), appears in the -O3 optimizations list (possibly -fversion-loops-for-strides, but I’m not sure)
This dramatic increase in optimization passes shows how -O3 triggers GCC to perform extensive analysis and transformation of the code. As well as performing all of the optimizations from -O1 and -O2.
The presence of these additional passes doesn’t guarantee that all optimizations will be applied to our simple program, but it shows the comprehensive approach GCC takes when asked to perform maximum optimization. Each pass builds upon the results of previous passes, potentially creating new opportunities for subsequent optimizations.
[placeholder for the talk about FMV and how we can do it, mb other post?]
Conclusion
This deep dive into GCC’s dump flags and optimization levels has revealed the intricate nature of the compilation process. Through our experiments with two simple programs, we’ve uncovered several key insights:
Dump Flag Analysis
The -fdump-tree-all flag provides valuable visibility into GCC’s internal compilation process
Each dump file represents a specific transformation or analysis step
Understanding these dumps is crucial for compiler development and optimization work
Impact of Optimization Levels
Without optimization flags, GCC performs about 30 basic passes
Adding -O3 dramatically increases the number of passes to over 150
Higher optimization levels trigger sophisticated transformations like loop optimizations and dead code elimination
Practical Implications
Different programs may benefit from different optimization levels
The same optimization flag can trigger vastly different numbers of passes depending on code complexity
Understanding these passes helps us make informed decisions about compilation flags
This exploration marks another step toward our goal of implementing Automatic Function Multi-Versioning (AFMV). In our next post, I’ll explore Register Transfer Language (RTL) dumps using -fdump-rtl-all, which will give us insight into how GCC handles machine-specific optimizations. Understanding both tree and RTL dumps will be essential for making informed decisions about function cloning and optimization in our AFMV implementation.
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 🙂
Welcome back to my blog series on the Software Portability and Optimization course. In this post, I’ll share my journey through the math and graphics Lab, where we delved deeper into 6502 assembly language. This time, we’re not just filling screens – we’re making things move!
Everything was executed with web 6502 Assembler Emulator, hosted on our College’s wiki.
You can find the reference sheet that I used here.
In this lab, we created a bouncing graphic on the 6502 emulator’s bitmapped display. We started with a basic animation and then progressively enhanced it to achieve a bouncing effect. Here is the full code (link) by our professor Chris Tyler, but we will mainly concentrate on modifying this part of the code:
;
; draw-image-subroutine.6502
;
; This is a routine that can place an arbitrary
; rectangular image on to the screen at given
; coordinates.
;
; Chris Tyler 2024-09-17
; Licensed under GPLv2+
;
;
; The subroutine is below starting at the
; label "DRAW:"
;
; Test code for our subroutine
; Moves an image diagonally across the screen
; Zero-page variables
define XPOS $20
define YPOS $21
; Set up the data structure
; The syntax #<LABEL returns the low byte of LABEL
; The syntax #>LABEL returns the high byte of LABEL
LDA #<G_X ; POINTER TO GRAPHIC
STA $10
LDA #>G_X
STA $11
LDA #$05
STA $12 ; IMAGE WIDTH
STA $13 ; IMAGE HEIGHT
; Set initial position X=Y=0
LDA #$00
STA XPOS
STA YPOS
; Main loop for diagonal animation
MAINLOOP:
; Set pointer to the image
; Use G_O or G_X as desired
LDA #<G_O
STA $10
LDA #>G_O
STA $11
; Place the image on the screen
LDA #$10 ; Address in zeropage of the data structure
LDX XPOS ; X position
LDY YPOS ; Y position
JSR DRAW ; Call the subroutine
; Delay to show the image
LDY #$00
LDX #$50
DELAY:
DEY
BNE DELAY
DEX
BNE DELAY
; Set pointer to the blank graphic
LDA #<G_BLANK
STA $10
LDA #>G_BLANK
STA $11
; Draw the blank graphic to clear the old image
LDA #$10 ; LOCATION OF DATA STRUCTURE
LDX XPOS
LDY YPOS
JSR DRAW
; Increment the position
INC XPOS
INC YPOS
; Continue for 29 frames of animation
LDA #28
CMP XPOS
BNE MAINLOOP
; Repeat infinitely
JMP $0600
As we run a code, the 5×5 pixels O symbol jumps from the left corner of the bitmapped display to the left (as shown in the following gif):
Choosing a Starting Location
Next, we needed to modify our starting position of drawing our symbol. Here’s the relevant code snippet:
; some set-up code before that
STA $13 ; IMAGE HEIGHT
; setting up X and Y locations
LDA #$10 ; 16 from HEX
STA XPOS ; load into XPOS location
LDA #$01 ; 1 from HEX
STA YPOS ; load into YPOS location
; other code
This sets our initial X position to 16 and Y position to 1. However, it’s worth noting that due to our statically defined boundary check of 27, there’s a potential issue here. Our bitmapped display uses pages $02-$05, while our code and data reside on pages $06-$EF. With these settings, our animation could potentially overwrite our code if it runs long enough!
Implementing Movement Increment
This is where things got interesting. Now we need to make some steps towards making the O symbol bounce how we want.
First, I implemented X and Y increments/decrements to move our graphics more programmatically (with ‘variables’ and stuff). Here’s a snippet of how I approached this (link to code):
; set up code here...
STA $13 ; IMAGE HEIGHT
LDA #$00 ; starting at X=Y=0
STA $22 ; bool flag = false
STA XPOS
STA YPOS
; main loop code here...
DELAY:
; delay code here...
JSR DRAW
LDA #01
CMP $22
BNE INCREMENT_POS
JMP DECREMENT_POS
INCREMENT_POS:
INC XPOS
INC YPOS
LDA #27
CMP XPOS
BNE MAINLOOP
INC $22
JMP MAINLOOP
DECREMENT_POS:
DEC XPOS
DEC YPOS
LDA #00
CMP XPOS
BNE MAINLOOP
DEC $22
JMP MAINLOOP
; rest of the code...
How does this work? Before returning to the Main loop after the delay, we check our boolean flag that shows if we will increment x and y, or decrease them. Based on that, we check if 0≤XPOS≤27, and switch a boolean flag to reverse increasing/decreasing.
However, after switching the positions for Y, where the boundary check for X is lower or equal to 27, the code breaks.
Splitting code into two
To confront these issues, I defined two new memory locations, XINCRM and YINCRM, to store our movement increments. Initially, I set both to 1, giving us diagonal movement. Also, XMAXVAL and YMAXVAL to store the maximum pixels for our display with 5×5 image (initial value of 27):
define XPOS $20
define YPOS $21
define XINCRM $22
define YINCRM $23
define XMAXVAL 27
define YMAXVAL 27
; SET UP STRUCTURE IN MEMORY
LDA #<G_X ; POINTER TO GRAPHIC
STA $10
LDA #>G_X
STA $11
LDA #$05
STA $12 ; IMAGE WIDTH
STA $13 ; IMAGE HEIGHT
LDA #$01
STA XINCRM
STA YINCRM
LDA #$00
STA XPOS
LDA #$00
STA YPOS
; rest of the code
The real challenge came in implementing the logic for changing direction. I explored (link) using additions of signed and unsigned digits in 6502 assembly but ultimately found that using INC (increment) and DEC (decrement) instructions was more straightforward. Here’s how I handled the X-axis movement:
define XPOS $20
define YPOS $21
define XINCRM $22
define YINCRM $23
define XMAXVAL 27
define YMAXVAL 27
; SET UP STRUCTURE IN MEMORY
LDA #<G_X ; POINTER TO GRAPHIC
STA $10
LDA #>G_X
STA $11
LDA #$05
STA $12 ; IMAGE WIDTH
STA $13 ; IMAGE HEIGHT
LDA #$01 ; flag to increment X axis
STA XINCRM
STA YINCRM
LDA #$00
STA XPOS
LDA #$00
STA YPOS
; main loop is the same
DELAY:
; JSR DRAW here
JSR CHECK_X_BOUNDARY
JSR DO_X
JMP MAINLOOP ; repeat
; CHECK_X_BOUNDARY and DO_X here
Implementing the Bounce
After successfully implementing the X-axis movement and bouncing, I duplicated the logic for the Y-axis. The Y-axis code follows the same pattern:
Only thing you need to add is the CHECK_Y_BOUNDARY and DO_Y. And to change direction of the move from the start, you need to change bool flag for XINCRM or YINCRM.
Here is the full code (link), and here is how it works:
Conclusion
This lab was a significant step up from our previous one, pushing us to think more deeply about program flow and efficient implementation in assembly. It required a different way of thinking compared to high-level languages. Refactoring the logic from our initial animation to the bouncing version was also tricky, as we had to carefully consider how each change would affect the overall flow of the program. It’s fascinating to see how a relatively simple concept like a bouncing graphic requires careful consideration of memory usage, instruction choice, and logical flow when working at such a low level.
I’m excited to see what challenges the next lab will bring! As always, if you have any questions or want to share your own experiences with the 6502 assembly, feel free to leave a comment below.
(or anyone else who stumbled upon my humble blog post)
As a part of my last semester course in the Computer Programming and Analysis program, I took the Software Portability and Optimization course as my professional course. Thus, I will document the work I will be doing during this semester and this course.
In this post, I would like to show my results of the first lab we had. In this lab, we saw the basics of 6502 Assembly Language and were tasked to make execution time improvements to the code and challenge ourselves with interesting coding tasks.
Everything was executed with web 6502 Assembler Emulator, hosted on our College’s wiki.
To start, we were given the following piece of code. Knowing that most people are not familiar with assembly language (and even fear to look at it), I will include comments on what each line of code does. If you want to check all the instructions for the 6502, you can find them here.
LDA #$00 ; load accumulator with 0
STA $40 ; store accumulator at memory address
; $0040
LDA #$02 ; load accumulator with 2
STA $41 ; store accumulator at memory addr
; $0041
LDA #$07 ; store the colour we want to show
; (yellow) to accumulator
LDY #$00 ; load 0 to Y register
LOOP: STA ($40), Y ; set pixel colour at the address (pointer)+Y
INY ; increment Y register by 1
BNE LOOP ; exit if 0 flag is true
INC $41 ; increment by 1 at $0041
LDX $41 ; load from $0041 to register X
CPX #$06 ; compare value in X to 6
BNE LOOP ; exit if 0 flag is true
TL;DR What this code does is fill the emulator’s bitmapped display with yellow colour as you can see below
Having the code up and running, we had to analyze the code and see how long it takes to run it by assuming a CPU running at 1MHz (Megahertz) clock speed.
Calculating Runtime
To calculate the runtime of this program we can follow these steps:
List all operations performed in the application.
Check how many Cycles each operation runs.
Calculate the Cycle Count (how many times a particular operation will be performed/repeated)
Discover if there is an Alternate Cycle, and how many Cycles it will take to run.
See how many times the Alternate Cycle will run (Alternate Cycle Count)
Calculate the Operation Total Cycle per operation (Cycles x Cycles Count + Alt. Cycles * Alt. Cycles Count)
Calculate the Program’s Total Cycles by adding all Operation Cycles together.
Calculate uS (nanosecond) speed with the following formula: 1.000.000 / ( CPU Speed * 1.000.000)
Calculate Execution Time in uS with the formula: Program’s Total Cycles * uS per clock.
Transform the time from uS to mS (microsecond) and S (second), with respective formulas: mS = Execution Time in uS / 1000 | S = Execution time in mS / 1000
You can see all the steps being performed in the screenshot below. If you want to check it in your browser or download a copy of the sheet with the formulas, you can follow this link.
Looking at the analysis, the program will take 0.011328 seconds to run and fill the emulator’s bitmapped display with the yellow colour we chose. Weight is 25 bytes.
Code Improvements
After calculations, we were given a task to improve our program’s execution time. We have to modify the code that will fill the code as fast as possible.
The main portion of the time took our nested loop. We have 1024 pixels to fill in our bitmapped display (corresponding to the bytes in memory address $02-$05). Our Y register can store numbers up to 256 until the 0 flag is turned on, and we will exit the loop. As 1024 is divided by 256 to 4, we can simply use the write function to write the colour that we want four times on four different locations and only then increase the counter instead of having nested loops and using external storage. This will decrease our time of execution by removing nested loops, decrease our program’s size by having fewer instructions, and remove the need to use extra variables.
LDA #$07
LDY #$00
LOOP: STA $0200, Y
STA $0300, Y
STA $0400, Y
STA $0500, Y
INY
BNE LOOP
Now, let’s see if it works as intended!
Calculating Runtime for Improved Code
Wow, that’s fast! At least faster than what we had before. Now, let’s do an analysis, and see how this code performs on paper. It’s also available here.
Results Comparison
Not only did we achieve almost two times decrease in execution time (0.011328 -> 0.006403), but our code weight went down from 25 bytes to 19 bytes! That’s great results in my opinion!
Modifying the Code
The next step in our lab is to make some code modifications to see how it behaves. There were 3 suggested modifications to do, so we could try different parameters and experiment with each of them. Here they are:
Change the code to fill the display with light blue instead of yellow. This modification can be made by changing the colour code from yellow to blue.
This one is not too hard, we just need to find the number that corresponds to blue colour. In our case it’s 06.
LDA #$07 ; Change this line
LDA #$06 ; to this
Here is the result:
Change the code to fill the display with different colours on each page (one-quarter of the bitmapped display).
To make this modification, I changed the first iteration of the code. After we pass the inner loop, I can add to the accumulator, so that the following loop will fill the next one-quarter of the display with different colors. Here’s the code:
LDA #$00
STA $40
LDA #$02
STA $41
LDA #$07
LDY #$00
LOOP: STA ($40), Y
INY
BNE LOOP
ADC #$01 ; Adding 1 to the accumulator.
; This will change the colour.
INC $41
LDX $41
CPX #$06
BNE LOOP
Make each pixel a random colour.
In memory address $00fe there is a pseudo-random number generator, that will give us different numbers on each read operation. Therefore, I can load into the accumulator value from this location inside the inner loop that will look like this in the original code:
LDA #$00
STA $40
LDA #$02
STA $41
; no need to load accumulator with initial colour
LDY #$00
LOOP: LDA $fe ; load accumulator with
; pseudo-random colour
STA ($40), Y
INY
BNE LOOP
INC $41
LDX $41
CPX #$06
BNE LOOP
As you can see below, the colours will differ each time you run a program (I ran the code 3 times).
Additional Experiments
There were some additional optional experiments in the lab instructions, so I decided to pick one of them and explain them. Here is the task:
Add the TYA instruction after the LOOP: label and before the STA ($40), Y. What visual effect does this cause, and how many colours are on the screen and why?
So, here is the modified 1st iteration code:
LDA #$00
STA $40
LDA #$02
STA $41
LDA #$07
LDY #$00
LOOP: TYA ; here is TYA instruction
STA ($40), Y
INY
BNE LOOP
INC $41
LDX $41
CPX #$06
BNE LOOP
And here is what the display looks like after we run the code:
We see 16 distinct colours filling the 32-bit display (32 / 16 = 2, so we fill the display with the same 16 colours 2 times). But what exactly happens?
If we check the colour codes on the 6502 Emulator page, we will see that the code for black, our first colour, is $0. The TYA instruction transfers value from register Y to the accumulator, which holds 0 at the beginning of the loop. Hence, we have our first colour black. Next, the value in the accumulator will be stored in a memory location of the bitmapped display, and the Y register will be increased by 1. Therefore, after we repeat the loop, we will store the value in Y to the accumulator, and once we copy the value from the accumulator to the memory location of the bitmapped display, we will see white (which has a colour code of $1). This will repeat until the register Y has a value of $F in the second bit (code for light gray, our last colour on display). Then, everything will repeat from the colour black, as if we increment our register Y which has $0F in it, it will become $10, which after we remove 1st bit, we will be left with $0 – a colour code of black. Then everything repeats until we fill the display.
Conclusion
This lab was a great introduction to 6502 assembly, offering a glimpse into how small optimizations can make a significant impact. From improving execution time to reducing code size, it’s clear how important efficiency is at the hardware level—especially in an environment as limited as the 6502. The experiments were fun and eye-opening, especially seeing how small changes can yield drastically different visual outcomes.
I’ve already learned a lot from this first exercise, and I’m excited to dive deeper into the world of software optimization as the course progresses. Keep an eye on this space for more updates, where I’ll be tackling even more intriguing optimizations and coding challenges in the coming weeks!
Thanks for reading, and if you have any tips or want to share your own experiences with assembly or anything else, feel free to drop a comment below!
Hello there! My name is Vlad – an aspiring software dev student at Seneca, who just thought about making my personal blog (why not lol?). I will mainly write about software I make and interesting things I saw during my developer career, so be tuned for more in next posts 😉