In this series, we’ll build a small operating system for the ARM platform from the ground up. Along the way, we’ll
explore fundamental OS concepts and incrementally add components, turning abstract ideas into working code. Each
article will focus on a specific piece of the system, guiding you through the process step by step.
We’re going to use QEMU, an open-source emulator, so we can develop and test our code directly on a PC—no hardware
required (for now).
In Part 1, we’ll first discuss ARM, then move on to the following:
ARM, short for Advanced RISC Machine, is a family of Reduced Instruction Set Computing (RISC) architectures that power
billions of devices, from smartphones and tablets to embedded systems and IoT devices. Originally known as Acorn RISC
Machine, ARM has become a cornerstone of modern computing due to its energy efficiency and simplicity compared to
Complex Instruction Set Computing (CISC) architectures like x86. Designed around the RISC philosophy, ARM processors
use a small, highly optimized instruction set, enabling greater performance per watt and making them ideal for
low-power and mobile environments.
Why Emulation?
While ARM assembly is usually executed on physical devices, emulation tools like QEMU allow
you to:
Test code without requiring hardware.
Experiment with different ARM-based architectures and peripherals.
Debug programs more effectively using tools like GDB.
Supported ARM Hardware
Before we begin coding, let’s take a brief look at some popular ARM-based platforms:
Raspberry Pi: A widely used single-board computer.
BeagleBone Black: A powerful option for embedded projects.
STM32 Microcontrollers: Common in IoT and robotics applications.
Installing prerequisites
Before we begin, we need to setup our development and build environment. I’m using Manjaro so package
names might be slightly different for your distro of choice.
To build our software, we’ll install the arm-none-eabi toolchain, which provides the assembler (as), linker (ld),
and other essential utilities.
Now that our project structure is in place, we can begin writing our first piece of assembly code: the bootloader.
If we add bootstrap.s to the asm folder we can make a start on the bootloader.
.section .text
.global _start
_start:
LDR sp, =stack_top @ initialize the stack pointer
BL kernel_main @ jump to the kernel main loop
kernel_main:
1: B 1b @ infinite loop to keep the OS running
B . @ fallback loop
.section .bss
.align 4
stack_top:
.space 1024 @ allocate 1kb for the stack
This is a pretty basic module to begin with. At the start we define our code with a .text section and _start is a
global symbol:
.section .text
.global _start
Next, we setup our stack pointer sp by loading the address of our stack_top. The equal sign preceeding stack_top
tells the assembler to load the immediate value of the address. We have stack_top defined down a little further.
Then, we jump on to our kernel.
Interesting note, BL which is Branch with Link works very much like a branch (B) but it will store the address
from where we branched into the link register r14.
_start:
LDR sp, =stack_top @ initialize the stack pointer
BL kernel_main @ jump to the kernel main loop
Now we have two endless loops setup. The first one loops back to the 1: loop:
1: B 1b @ infinite loop to keep the OS running
If we do get an unexpected address sneak in for whatever reason, we’ve got a fallback loop that continually jumps to
itself using the shorthand ..
B . @ fallback loop
Finally, we complete the module by defining our stack with the .bss section. You’ll notice the stack_top label that
we referenced earlier.
.section .bss
.align 4
stack_top:
.space 1024 @ allocate 1kb for the stack
Build environment
We need to make this easy to build, so we create a Makefile in the root directory. The Makefile will use the
toolchain that we installed earlier, building our binaries into the build folder:
Our call out to our assembler is pretty straight forward, trading our .s files for .o object files. We use
-Ttext 0x0 to explicitly tell the linker that our program should start at address 0x0, which is necessary for
bare-metal environments.
Give it a build.
make
All going well you should see some output as follows:
-nographic runs qemu without a graphical user interface
If everything works, you won’t see much—your bootloader is running in an infinite loop, waiting for further development.
Debugging
Because we are running in a virtualised environment, we have a full debugger at our disposal. Having a debugger attached
to your code when things aren’t quite going to plan can be very valuable to understand what’s happening in the internals
of your program.
Using the -gdb option, you can instruct qemu to open a debugging port.
For deployment, we’ll use a Raspberry Pi as an example. This process is similar for other ARM-based boards.
Flashing
First, we need to convert the ELF file to a raw binary format suitable for booting:
arm-none-eabi-objcopy -O binary boot.elf boot.bin
Use a tool like dd to write the binary to an SD card:
Caution: Be very careful with the dd command! Double-check /dev/sdX before running it to avoid overwriting important data.
dd if=boot.bin of=/dev/sdX bs=512 seek=2048
Conclusion
In this post, we’ve built the foundation for armos. We’ve installed and configured the ARM toolchain, set up QEMU
to emulate our target board, organized our project directory for clarity and scalability, and even wrote a simple
bootloader to jumpstart our operating system. With these critical components in place, you’re now ready to embark on
the next steps—enhancing the bootloader, adding essential kernel functionalities, and ultimately constructing a
full-fledged minimalist OS.
In this update, we’re introducing first-class functions to our Lisp interpreter! This allows users to define and
use functions just like they would in a real Lisp environment.
Before this update, function definitions looked like this:
If define receives a list as the first argument, it assumes we’re defining a function.
The first element of that list (funcName) is the function name.
The remaining elements (params) are the parameter names.
The function body is stored as a lambda expression.
Example
With this change, both of these definitions are now equivalent:
;; Using explicit lambda(definesquare(lambda(x)(*xx)));; Using shorthand function definition(define(squarex)(*xx))(square4);; 16
This makes function definitions much cleaner and easier to read!
Fixing if Expressions
While making these changes, we discovered a bug in our if implementation.
Previously, ifdid not evaluate the then or else branch before returning it:
evalenv(List[Atom"if",condition,thenExpr,elseExpr])=doresult<-evalenvconditioncaseresultofBoolTrue->returnthenExprBoolFalse->returnelseExpr_->throwError$TypeMismatch"Expected boolean in if condition"result
This meant that calling:
(if(>105)(*22)(*33))
Would return(* 2 2) instead of 4!
The Fix
We simply added eval env to ensure the chosen branch is evaluated before being returned:
evalenv(List[Atom"if",condition,thenExpr,elseExpr])=doresult<-evalenvconditioncaseresultofBoolTrue->evalenvthenExprBoolFalse->evalenvelseExpr_->throwError$TypeMismatch"Expected boolean in if condition"result
Now, if expressions behave correctly:
(if(>105)(*22)(*33));; 4(if(<105)(*22)(*33));; 9
Recursion
With function definitions and the fixed if statement, we can now write recursive functions!
In this update, we extend our Lisp interpreter with floating point numbers and floating point math functions.
This brings us closer to full numeric support, allowing for a much richer mathematical capability.
If you’re following along, you can find the implementation for this article here.
Floating Point Type
Until now, our Lisp implementation only supported integers:
dataLispVal=AtomString|NumberInteger|BoolBool|StringString|FloatDouble-- Added floating point support|List[LispVal]|PairLispValLispVal|Lambda[String]LispValEnv|BuiltinFunc([LispVal]->ThrowsErrorLispVal)|BuiltinFuncIO([LispVal]->IOThrowsErrorLispVal)
We introduced a new constructor Float Double to represent floating point numbers.
Parsing
We needed to update our parser to recognize floating point literals. We do this in such a way to operate alongside the
current integer math that we currently support. We don’t want to disturb the work that we’ve already done, so we’ll use
that effort here.
parseInteger::ParserLispValparseInteger=dosign<-optionMaybe(char'-')-- Look for optional '-'digits<-many1digitletnumber=readdigitsreturn$Number$casesignofJust_->-numberNothing->numberparseFloat::ParserLispValparseFloat=dosign<-optionMaybe(char'-')-- Look for optional '-'whole<-many1digitchar'.'fractional<-many1digitletnumber=read(whole++"."++fractional)return$Float$casesignofJust_->-numberNothing->numberparseNumber::ParserLispValparseNumber=tryparseFloat<|>parseInteger
So, we changed the meaning of parseNumber from our original implementation. Instead of parseNumber handling only
integers, we split the logic into parseInteger and parseFloat, ensuring both number types are correctly parsed
This ensures that expressions like 3.14 are correctly interpreted as floating point numbers, while still maintaining
expressions like 3 being handled as integers.
One extra added feature here is handling negative numbers. We never observed the - symbol that can appear before
some numbers, so we’ve fixed this in the parser at the same time.
Numeric Coercion
Next, we needed to handle operations between integers and floats.
Our previous numeric functions assumed only integers, so we modified them to coerce integers to floats when
necessary. This means that we can use integers and floats together in our expressions.
This same logic was applied to subtraction, multiplication, and division.
Division Considerations
Division needed special attention because it must always return a float when dividing integers:
numericDiv[Numbera,Numberb]=ifb==0thenthrowError$TypeMismatch"Division by zero"(Numberb)elsereturn$Float(fromIntegrala/fromIntegralb)numericDiv[Floata,Floatb]=return$Float(a/b)numericDiv[Numbera,Floatb]=return$Float(fromIntegrala/b)numericDiv[Floata,Numberb]=return$Float(a/fromIntegralb)numericDivargs=throwError$TypeMismatch"Expected numbers"(Listargs)
This ensures that (/ 3 2) evaluates to 1.5 instead of performing integer division.
Adding Floating Point Math Functions
With float support in place, we introduced math functions like sin, cos, tan, exp, log, and sqrt:
importPreludehiding(log)numericSin,numericCos,numericTan,numericExp,numericLog,numericSqrt::[LispVal]->ThrowsErrorLispValnumericSin[Floata]=return$Float(sina)numericSin[Numbera]=return$Float(sin(fromIntegrala))numericSinargs=throwError$TypeMismatch"Expected a number"(Listargs)numericCos[Floata]=return$Float(cosa)numericCos[Numbera]=return$Float(cos(fromIntegrala))numericCosargs=throwError$TypeMismatch"Expected a number"(Listargs)numericTan[Floata]=return$Float(tana)numericTan[Numbera]=return$Float(tan(fromIntegrala))numericTanargs=throwError$TypeMismatch"Expected a number"(Listargs)numericExp[Floata]=return$Float(expa)numericExp[Numbera]=return$Float(exp(fromIntegrala))numericExpargs=throwError$TypeMismatch"Expected a number"(Listargs)numericLog[Floata]=ifa<=0thenthrowError$TypeMismatch"Logarithm domain error"(Floata)elsereturn$Float(loga)numericLog[Numbera]=ifa<=0thenthrowError$TypeMismatch"Logarithm domain error"(Numbera)elsereturn$Float(log(fromIntegrala))numericLogargs=throwError$TypeMismatch"Expected a positive number"(Listargs)numericSqrt[Floata]=ifa<0thenthrowError$TypeMismatch"Square root of negative number"(Floata)elsereturn$Float(sqrta)numericSqrt[Numbera]=ifa<0thenthrowError$TypeMismatch"Square root of negative number"(Numbera)elsereturn$Float(sqrt(fromIntegrala))numericSqrtargs=throwError$TypeMismatch"Expected a non-negative number"(Listargs)
We then added them to the built-in function table:
In our previous installment, we added
basic list operations to our Lisp interpreter, including cons, car, cdr, append, and reverse.
Now, we are expanding that functionality by introducing higher-order list functions:
map
filter
foldl and foldr
sort
Additionally, we’re introducing string manipulation functions that allow us to treat strings as lists of characters:
string->list
list->string
These changes bring our Lisp interpreter closer to standard Scheme-like behavior.
If you’re following along, you can find the updated implementation for this article here.
Lambda Expressions
A lambda function in computer programming is an anonymous function
that we use to pass to higher-order functions.
To support map, filter, and fold, we implemented lambda functions properly. Previously, our Lisp could
parse lambdas but couldn’t correctly apply them.
Adding Lambda Support
We modified eval to properly capture the current environment and store parameter names:
Now that lambda functions work, we can introduce list processing functions.
Map
map applies a function to each element in a list and returns a new list.
listMap::[LispVal]->IOThrowsErrorLispVallistMap[Lambdaparamsbodyenv,Listxs]=List<$>mapM(\x->apply(Lambdaparamsbodyenv)[x])xslistMapargs=throwError$TypeMismatch"Expected a function and a list"(Listargs)
Example
(map(lambda(x)(*x2))'(1234));; => (2 4 6 8)
Filter
filter removes elements that don’t satisfy a given predicate.
listFilter::[LispVal]->IOThrowsErrorLispVallistFilter[func@(Lambda___),Listxs]=dofiltered<-filterM(\x->doresult<-applyfunc[x]caseresultofBoolTrue->returnTrueBoolFalse->returnFalse_->throwError$TypeMismatch"Expected boolean return"result)xsreturn$ListfilteredlistFilterargs=throwError$TypeMismatch"Expected a function and a list"(Listargs)
Example
(filter(lambda(x)(>x5))'(2468));; => (6 8)
Fold (Reduce)
foldl and foldr accumulate values from left to right or right to left.
listFoldL::[LispVal]->IOThrowsErrorLispVallistFoldL[Lambdaparamsbodyenv,initial,Listxs]=foldM(\accx->apply(Lambdaparamsbodyenv)[acc,x])initialxslistFoldLargs=throwError$TypeMismatch"Expected a function, initial value, and a list"(Listargs)
listFoldR::[LispVal]->IOThrowsErrorLispVallistFoldR[Lambdaparamsbodyenv,initial,Listxs]=foldM(\accx->apply(Lambdaparamsbodyenv)[x,acc])initial(reversexs)listFoldRargs=throwError$TypeMismatch"Expected a function, initial value, and a list"(Listargs)
Example
(foldl(lambda(accx)(+accx))0'(12345));; => 15
This works like (((((0 + 1) + 2) + 3) + 4) + 5).
Whilst swapping out foldr for foldl in this scenario gives us the same result, it’s very different in execution.
It ends up working like (1 + (2 + (3 + (4 + (5 + 0))))).
Sort
sort Sorts a list in ascending order by default or with a custom comparator.
listSort::[LispVal]->ThrowsErrorLispVallistSort[Listxs]=casexsof[]->return$List[](Number_:_)->return$List(sortBycompareNumbersxs)(String_:_)->return$List(sortBycompareStringsxs)_->throwError$TypeMismatch"Cannot sort mixed types"(Listxs)wherecompareNumbers(Numbera)(Numberb)=compareabcompareStrings(Stringa)(Stringb)=compareablistSort[Lambdaparamsbodyclosure,Listxs]=casexsof[]->return$List[]_->throwError$TypeMismatch"Custom sorting requires ThrowsErrorIO"(Listxs)-- If you later want custom sorting, you'd need `ThrowsErrorIO`listSortargs=throwError$TypeMismatch"Expected a list (optionally with a comparator function)"(Listargs)
Example
(sort'(53814));; => (1 3 4 5 8)
Optionally, the implementation allows you to specify a predicate to control the sort.
String Manipulation
Strings are now treated as lists of characters in our Lisp. This can sometimes make things easier to work with when
dealing with strings.
Example
We can convert a string into a list (and therefore operate on it like it’s a list) by using string->list.
listToString::[LispVal]->ThrowsErrorLispVallistToString[Listchars]=casemapMextractCharcharsofRightstrList->return$String(concatstrList)Lefterr->throwErrorerrwhereextractChar(String[c])=Right[c]extractCharinvalid=Left$TypeMismatch"Expected a list of single-character strings"invalidlistToStringargs=throwError$NumArgs1args
(list->string'("h""e""l""l""o"));; => "hello"
Conclusion
We now how have higher order functions and lambda functions controlling our list processing. Stay tuned for further
updates as we add more features to our Lisp.
In the previous post we added
conditionals to our basic Lisp interpreter. Now, it’s time to introduce list manipulation – one of Lisp’s most
fundamental features.
This update brings support for:
Pairs (cons), first element (car), and rest (cdr)
List predicates (null?)
Common list operations (append, length, reverse)
Proper parsing of quoted lists ('(...))
Pairs and Lists
In Lisp, lists are built from pairs (cons cells). Each pair contains a head (car) and a tail (cdr). We’ll add Pair
into our LispVal data type:
(car and cdr)[https://en.wikipedia.org/wiki/CAR_and_CDR] are list primitives that allow you to return the first or
second component of a pair.
The expression (car (cons x y)) evaluates to x, and (cdr (const x y)) evaluates to y.
car::[LispVal]->ThrowsErrorLispValcar[List(x:_)]=returnxcar[Pairx_]=returnxcar[List[]]=throwError$TypeMismatch"Cannot take car of empty list"(List[])car[arg]=throwError$TypeMismatch"Expected a pair or list"argcarargs=throwError$NumArgs1argscdr::[LispVal]->ThrowsErrorLispValcdr[List(_:xs)]=return$Listxscdr[Pair_y]=returnycdr[List[]]=throwError$TypeMismatch"Cannot take cdr of empty list"(List[])cdr[arg]=throwError$TypeMismatch"Expected a pair or list"argcdrargs=throwError$NumArgs1args
We can now uwe these functions to work with our lists and pairs:
Going a little bit further now, we can easily implement append, length, and reverse.
listAppend::[LispVal]->ThrowsErrorLispVallistAppend[Listxs,Listys]=return$List(xs++ys)listAppend[Listxs,y]=return$List(xs++[y])listAppend[x,Listys]=return$List([x]++ys)listAppendargs=throwError$TypeMismatch"Expected two lists or a list and an element"(Listargs)listLength::[LispVal]->ThrowsErrorLispVallistLength[Listxs]=return$Number(toInteger(lengthxs))listLength[arg]=throwError$TypeMismatch"Expected a list"arglistLengthargs=throwError$NumArgs1argslistReverse::[LispVal]->ThrowsErrorLispVallistReverse[Listxs]=return$List(reversexs)listReverse[arg]=throwError$TypeMismatch"Expected a list"arglistReverseargs=throwError$NumArgs1args
These functions allow us to perform some more interesting processing of our lists:
(append'(12)'(34));; (1 2 3 4)(append'(ab)'c);; (a b c)(append'a'(bc));; (a b c)(length'(12345));; 5(length'());; 0(reverse'(12345));; (5 4 3 2 1)(reverse'());; ()
Quoted Lists
Finally, Lisp allows shorthand notation for quoting lists. For example, '(1 2 3) is equivalent to (quote (1 2 3)).
Our Lisp interpreter is now becoming a little more sophisticated. List processing is so fundamental to how Lisp operates
that we needed to get this implemented as soon as possible. The code for this particular article is available up on GitHub
to pull down and take a look at.