Assembly
The program runs on Windows, Linux and macOS on an x86-64 processor. Built for any other architecture, it stops with an
#Error; the AArch64 version is the next lesson, ARM assembly.Every function ends up as machine code: a list of processor instructions. Normally the compiler writes that list for you. Once in a while you need to write it yourself — for an instruction the language has no way to express, or for exact control over what runs. An asm func is a function whose body is processor instructions instead of statements. The compiler emits them as written and does not look inside.
That is the point, and also the danger. Nothing in the body is checked. Get a register wrong and the function returns the wrong answer without any warning. Read this lesson as a look under the floor, not as a way to write everyday code.
A body made of instructions
// Win64: the first two integers arrive in rcx and rdx. The result goes back in rax.
#Abi(.Win64)
asm func AddWin64(a: int64, b: int64) -> int64 {
mov rax, rcx
add rax, rdx
ret
}
The signature is ordinary Rux: two int64 parameters, an int64 result. But a and b are not names inside the body. The body works on registers — the processor's own handful of 64-bit storage slots, named rax, rcx, rdx and so on — and the calling convention decides which registers the arguments arrive in. Under Win64, a is in rcx, b is in rdx, and the caller will look for the result in rax.
The instructions are written destination first:
| Instruction | Means |
|---|---|
mov rax, rcx | copy rcx into rax — so rax is now a |
add rax, rdx | rax = rax + rdx — so rax is now a + b |
ret | return to the caller, which reads rax |
Nothing is added around the body: no setup, no clean-up, not even the ret. Leave out the ret and the function still builds — and the processor carries on into whatever bytes happen to follow it.
The body is written against a convention
The convention decides which registers the arguments arrive in, so every body here carries an #Abi naming the one it was written for. Here is the same addition for System V, the convention of Linux, macOS and FreeBSD:
// The same addition for System V, whose first two integers arrive in rdi and rsi.
#Abi(.SysV)
asm func AddSysV(a: int64, b: int64) -> int64 {
mov rax, rdi
add rax, rsi
ret
}
Pinned like that, the same body works on Windows, Linux and macOS alike: the compiler adapts each call to the function's convention, so on Windows it calls AddSysV with the arguments in rdi and rsi. That is why the program can call both versions and get 42 twice. Without an #Abi, an asm func uses the target's C convention — Win64 on Windows, System V elsewhere — and a body that reads rcx would read the wrong register as soon as it was built for Linux.
A loop is a label and a jump
There is no while in assembly. A loop is made of the pieces while is built from: a label to jump back to, a test, and a conditional jump:
// A loop is a label and a conditional jump. These are the pieces `while` is built from.
#Abi(.Win64)
asm func SumTo(n: int64) -> int64 {
xor rax, rax
next:
test rcx, rcx
jle done
add rax, rcx
dec rcx
jmp next
done:
ret
}
xor rax, rax sets the total to zero. test rcx, rcx compares n with zero, and jle done leaves the loop when it is zero or less. Otherwise n is added to the total, dec takes one off n, and jmp next goes round again. Drawn out, it is the while loop you would have written in Rux:
flowchart LR
start(["total = 0<br/>xor rax, rax"]) --> next{"next:<br/>n ≤ 0?<br/>test, jle"}
next -- "no" --> body["total += n<br/>n -= 1<br/>add, dec"]
body -- "jmp next" --> next
next -- "yes" --> done(["done:<br/>return total<br/>ret"])One architecture, chosen with when
Assembly belongs to one processor family. These instructions mean nothing to an ARM processor, so the functions sit inside a when on the target's architecture:
when #target.arch {
.X86_64 => {
// … the three asm functions …
},
else => #Error("This lesson is x86-64 assembly; the AArch64 version is the AsmArm lesson")
}
rux build --target linux-aarch64 stops with that message. The assembler also knows only a chosen subset of x86-64 — enough for stubs, system calls and arithmetic, but not every instruction the processor has.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// Every function ends up as machine code. Once in a while you need to write that code yourself,
// for an instruction the language has no way to express, or for exact control over what runs.
//
// An `asm func` is a function whose body is processor instructions instead of statements. The
// compiler emits them as written and does not look inside. That is the point, and also the
// danger: nothing in the body is checked. Get a register wrong and the function returns the
// wrong answer without any warning.
//
// The body is written against a calling convention, because the convention decides which
// registers the arguments arrive in and where the result must go. So every x86-64 body here
// carries an `#Abi` naming the one it was written for. Pinned like that, the same body works
// on Windows, Linux and macOS alike, and the compiler adapts each call to it.
//
// Assembly also belongs to one architecture. This lesson is x86-64 only, and `when` stops the
// build anywhere else. The AArch64 version is the next lesson.
import Core::{ #Error, #target };
import Io::PrintLine;
when #target.arch {
.X86_64 => {
// Win64: the first two integers arrive in rcx and rdx. The result goes back in rax.
#Abi(.Win64)
asm func AddWin64(a: int64, b: int64) -> int64 {
mov rax, rcx
add rax, rdx
ret
}
// The same addition for System V, whose first two integers arrive in rdi and rsi.
#Abi(.SysV)
asm func AddSysV(a: int64, b: int64) -> int64 {
mov rax, rdi
add rax, rsi
ret
}
// A loop is a label and a conditional jump. These are the pieces `while` is built from.
#Abi(.Win64)
asm func SumTo(n: int64) -> int64 {
xor rax, rax
next:
test rcx, rcx
jle done
add rax, rcx
dec rcx
jmp next
done:
ret
}
},
else => #Error("This lesson is x86-64 assembly; the AArch64 version is the AsmArm lesson")
}
func Main() -> int {
// Nothing at the call site says these functions are unusual.
PrintLine("AddWin64(20, 22) {}", AddWin64(20, 22));
PrintLine("AddSysV(20, 22) {}", AddSysV(20, 22));
PrintLine("SumTo(10) {}", SumTo(10));
PrintLine("SumTo(100) {}", SumTo(100));
return 0;
}
Besides Io, its Rux.toml lists Core under [Dependencies].
Run it
cd Examples/Platform/Asm
rux run
AddWin64(20, 22) 42
AddSysV(20, 22) 42
SumTo(10) 55
SumTo(100) 5050
Common mistakes
Mark
AddSysV with #Abi(.Win64) and it still builds, but the caller now puts the arguments in rcx and rdx while the body adds rdi and rsi. On Windows the call then returned 0 instead of 42. Always pin the convention the body was written for.popcnt rax, rdx fails with error: instruction 'popcnt' is recognized for target 'windows-x86_64' but is not implemented by its assembler. Stick to the subset in the Reference's assembler page.The parameters are not names inside an
asm func; the arguments exist only in their registers. add rax, b fails with error: unsupported operands for 'add' — write add rax, rdx.Try it yourself
- Write
Subtract(a: int64, b: int64) -> int64for Win64, usingsubin place ofadd. Check thatSubtract(50, 8)is 42. - Write
Negate(n: int64) -> int64withneg, which flips the sign of a register. - Write a System V version of
SumTo, readingnfromrdi, and call it on Windows. - Write
Max(a: int64, b: int64) -> int64for Win64: copyrcxtorax, comparercxwithrdx, jump to the end withjgewhenrcxis already the larger, and otherwise copyrdxtorax.
Learn more
- Assembler functions in the Rux Reference — operands, labels and the supported instructions
- Abi — pinning the convention a body is written for
- ABI — calling conventions from the Rux side
- ARM assembly — the same functions for AArch64