PONYλM2Modula-2

Fortran.CodeCompared.To/Assembly

An interactive executable cheatsheet comparing Fortran and Assembly

Fortran 2018 (GCC 16.2) x86-64 (NASM 3.01)
Output & Running It
Hello, World
A Fortran program is a named block with implicit none at the top; execution starts there and the runtime has already set up I/O before your first statement. An assembly program has no runtime at all — the linker looks for _start and that is where the process begins. db declares bytes, 10 is a newline, and equ $ - message computes the length at assemble time.
program hello implicit none write(*, '(A)') "Hello, World!" end program hello
global _start section .data message: db "Hello, World!", 10 length: equ $ - message section .text _start: mov rax, 1 ; syscall 1 = write mov rdi, 1 ; fd 1 = stdout mov rsi, message ; the address of the bytes mov rdx, length ; how many bytes to write syscall mov rax, 60 ; syscall 60 = exit xor rdi, rdi ; status 0 syscall
The '(A)' format is doing real work here: print *, "Hello, World!" would emit leading whitespace, because list-directed output is free to lay the record out as it likes. That single detail is a fair summary of what the Fortran runtime is for — it owns record structure, unit numbers and formatting, none of which exists in the right-hand column, where a write is four registers and an instruction.
stop Sets the Exit Status
stop with an integer ends the program with that status, after the runtime has flushed its buffers and closed its units. In assembly the status is whatever is in rdi when the exit syscall runs, and nothing is flushed because nothing was buffered.
program halt_with_status implicit none write(*, '(A)') "about to stop with 3" stop 3 end program halt_with_status
global _start section .data message: db "about to stop with 3", 10 length: equ $ - message section .text _start: mov rax, 1 mov rdi, 1 mov rsi, message mov rdx, length syscall mov rax, 60 ; exit mov rdi, 3 ; ...with status 3 syscall
The flushing is the part worth noticing. A Fortran program that writes to a unit and then crashes may lose the last record; a program that stops cleanly will not. The assembly column has no such concern because write is a syscall — the bytes are handed to the kernel immediately, and there is no user-space buffer left holding anything.
What write Does to an Integer
This is the only row that spells number-to-text conversion out in full; later rows print one or two digits so the conversion stays three lines. div rcx divides rdx:rax by rcx, leaving the quotient in rax and the remainder in rdx, so rdx must be zeroed first. Digits come out least-significant first, which is why the buffer fills backwards. The I0 edit descriptor means "as many digits as it takes, no padding".
program write_number implicit none integer :: value value = 31415 write(*, '(I0)') value end program write_number
global _start section .bss buffer: resb 20 section .text _start: mov rax, 31415 lea rsi, [buffer + 20] ; one past the end — we fill backwards mov rcx, 10 dec rsi ; make room for the newline first mov byte [rsi], 10 convert: xor rdx, rdx ; clear the high half of the dividend div rcx ; rax = rax / 10, rdx = rax % 10 add dl, '0' dec rsi mov [rsi], dl test rax, rax jnz convert lea rdx, [buffer + 20] sub rdx, rsi ; length = end - start mov rax, 1 mov rdi, 1 syscall mov rax, 60 xor rdi, rdi syscall
Sixteen instructions and one division per digit. The I0 descriptor is the interesting comparison: I5 would right-justify in five columns and I2 would fill the field with asterisks rather than truncate, because a Fortran field that cannot hold its value is an error you can see. The assembly column has no field width at all — it writes exactly the bytes it produced.
Variables Are Registers
A Declaration Becomes a Register
Fortran declares every variable with a type before any executable statement, and implicit none makes that mandatory rather than optional. Assembly has sixteen general-purpose registers with fixed names and no types — remembering which register holds which declaration is your job.
program locals implicit none integer :: first, second, total first = 10 second = 32 total = first + second write(*, '(I0)') total end program locals
global _start section .bss output: resb 3 section .text _start: mov rax, 10 ; first = 10 mov rbx, 32 ; second = 32 add rax, rbx ; total = first + second xor rdx, rdx mov rcx, 10 div rcx ; rax = 4, rdx = 2 add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
The reason implicit none is the first line of every serious Fortran program is visible in the right-hand column: without a declaration there is nothing to check a name against, and Fortran's historical default typed any undeclared variable by its first letter. A typo then became a new variable rather than an error — the same failure mode as mistyping a register name here, except the assembler at least rejects a register that does not exist.
Kinds Are Widths
Fortran's integer(kind=1) through kind=8 select storage size in bytes. Assembly has one register with four names: rax is all 64 bits, eax the low 32, ax the low 16, al the low 8 — four windows onto the same storage, not four registers.
program kinds implicit none integer(kind=8) :: wide integer(kind=1) :: narrow wide = 7 wide = wide + 1 narrow = int(wide, kind=1) write(*, '(I0)') narrow end program kinds
global _start section .bss output: resb 2 section .text _start: mov rax, 7 add rax, 1 ; rax = 8 ; int(wide, kind=1) is not a computation and costs nothing. ; AL *is* the low byte of RAX — the same storage, read narrower. add al, '0' mov [output], al mov byte [output + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 2 syscall mov rax, 60 xor rdi, rdi syscall
A narrowing conversion is free for the same reason in both columns: the bits were already there and the narrower name stops looking at the rest. The direction that costs an instruction is widening a signed value, where the sign bit must be smeared across the new high bits — that is movsx. Note that Fortran's kind numbers are not required by the standard to be byte counts, which is why selected_int_kind exists and why portable code uses it.
Arithmetic & The Flags Register
Division and mod Are One Instruction
Fortran's / on two integers truncates, and mod gives the remainder. One div instruction produces both at once — quotient in rax, remainder in rdx — and rdx must be zeroed first because it supplies the high half of the dividend.
program divide implicit none integer :: quotient, remainder quotient = 17 / 5 remainder = mod(17, 5) write(*, '(I0)') quotient write(*, '(I0)') remainder end program divide
global _start section .bss output: resb 4 section .text _start: mov rax, 17 xor rdx, rdx mov rcx, 5 div rcx ; ONE instruction: rax = 3, rdx = 2 add al, '0' mov [output], al mov byte [output + 1], 10 add dl, '0' mov [output + 2], dl mov byte [output + 3], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 4 syscall mov rax, 60 xor rdi, rdi syscall
Writing the division and the remainder as separate expressions asks for the same division twice unless the compiler notices they are identical, which gfortran does. Note also the trap this row sits next to: 1/2 in Fortran is 0, not 0.5, because both operands are integers — the same truncation the div instruction performs, surfacing as one of the commonest sources of silently wrong numerical results.
The Flags Nobody Declares
Every arithmetic instruction quietly updates a flags register that nothing in the source mentions. sub sets the carry flag when the subtraction borrowed, and jc jumps if it did — the pair is a range check performed at machine level.
program underflow implicit none integer :: smaller, larger smaller = 3 larger = 10 if (smaller < larger) then write(*, '(A)') "underflow" else write(*, '(I0)') smaller - larger end if end program underflow
global _start section .data underflow_message: db "underflow", 10 underflow_length: equ $ - underflow_message section .text _start: mov rax, 3 sub rax, 10 ; borrows, so CF = 1 — and nothing declared CF jc report_underflow ; the else branch would print rax here mov rax, 60 xor rdi, rdi syscall report_underflow: mov rax, 1 mov rdi, 1 mov rsi, underflow_message mov rdx, underflow_length syscall mov rax, 60 xor rdi, rdi syscall
Fortran's if (smaller < larger) compiles to a compare and a jump reading exactly this flag. The flag is invisible, unnamed, and overwritten by the very next arithmetic instruction, which is why the jump must immediately follow the sub — inserting an unrelated add between them silently breaks the test with nothing to warn you.
Control Flow: cmp and jump
if / then / else
cmp is a subtraction that discards the result and keeps only the flags; the conditional jump after it reads them. The two are one thought split across two instructions, and the jump is named for the comparison you meant rather than for the flag it tests.
program branch implicit none integer :: value value = 7 if (value > 5) then write(*, '(A)') "big" else write(*, '(A)') "small" end if end program branch
global _start section .data big_message: db "big", 10 big_length: equ $ - big_message small_message: db "small", 10 small_length: equ $ - small_message section .text _start: mov rax, 7 cmp rax, 5 jle print_small ; jump if NOT greater — the condition is inverted mov rsi, big_message mov rdx, big_length jmp print print_small: mov rsi, small_message mov rdx, small_length print: mov rax, 1 mov rdi, 1 syscall mov rax, 60 xor rdi, rdi syscall
The condition is inverted, which is the commonest confusion when reading compiler output: the source says "if this is true, do the block" and the machine says "if this is false, skip the block". Note also that the two arms had to be arranged so control rejoins at printend if is a single exit point in Fortran, and here you build that out of a jump.
A do Loop
A Fortran do loop evaluates its bounds once, before the first iteration, and the trip count is fixed from then on. Down here there is a register, an increment, and a backwards jump. rbx holds the counter because syscall destroys rcx, and this loop writes on every pass.
program counted implicit none integer :: index do index = 0, 4 write(*, '(I0)') index end do end program counted
global _start section .bss digit: resb 2 section .text _start: xor rbx, rbx ; index = 0 next: mov rax, rbx add al, '0' mov [digit], al mov byte [digit + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, digit mov rdx, 2 syscall ; destroys rcx and r11 — rbx survives inc rbx cmp rbx, 5 jl next mov rax, 60 xor rdi, rdi syscall
The trip count being fixed up front is a real guarantee and not a detail: it is what lets the compiler know how many iterations there will be before the loop starts, which is the precondition for unrolling it or splitting it across threads. Modifying the loop variable inside the body is forbidden for the same reason. The choice of rbx over rcx here is the sort of thing a register allocator decides silently — get it wrong by hand and the write in the middle destroys the counter.
Arrays, Column-Major & 1-Based
1-Based Indexing Costs Nothing
Fortran arrays start at 1 by default, and may declare any lower bound. The machine only knows offsets from a base address, so the compiler subtracts the lower bound — an index of 1 becomes an offset of 0. [rsi + rcx * 8] multiplies the index by 8 as part of the instruction.
program indexing implicit none integer(kind=8) :: numbers(3) numbers = [10_8, 20_8, 30_8] write(*, '(I0)') numbers(2) end program indexing
global _start section .data numbers: dq 10, 20, 30 section .bss output: resb 3 section .text _start: lea rsi, [numbers] mov rcx, 2 ; the Fortran index dec rcx ; MINUS the lower bound — this is the whole trick mov rax, [rsi + rcx * 8] xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
The dec rcx is free in practice: the compiler folds the subtraction into the addressing mode's displacement, so a 1-based array and a 0-based one generate identical code. That answers the old claim that 1-based indexing must be slower — the lower bound is a compile-time constant, and constants cost nothing. What it buys is that the declared range is checkable, which -fcheck=bounds turns into a real test.
Why The First Subscript Is Innermost
Column-Major, In Stride Constants
This is the row that matters most for performance work. Fortran stores a 2-D array column by column, so moving along the FIRST subscript moves one element in memory while moving along the second jumps a whole column. The assembly shows both addresses computed by hand: the difference is which subscript gets multiplied.
program column_major implicit none integer(kind=8) :: grid(2, 3) ! Column-major: this fills column 1, then column 2, then column 3. grid = reshape([11_8, 21_8, 12_8, 22_8, 13_8, 23_8], [2, 3]) write(*, '(I0)') grid(2, 1) write(*, '(I0)') grid(1, 2) end program column_major
global _start section .data ; grid(2,3) laid out COLUMN BY COLUMN, exactly as Fortran stores it: ; grid(1,1) grid(2,1) grid(1,2) grid(2,2) grid(1,3) grid(2,3) grid: dq 11, 21, 12, 22, 13, 23 rows: equ 2 section .bss output: resb 6 section .text _start: lea rsi, [grid] ; grid(2,1): offset = (row-1) + (col-1)*rows = 1 + 0 = 1 mov rax, [rsi + 1 * 8] xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 ; grid(1,2): offset = 0 + 1*rows = 2 — a whole COLUMN further on lea rsi, [grid] mov rax, [rsi + 2 * 8] xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output + 3], al mov [output + 4], dl mov byte [output + 5], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 6 syscall mov rax, 60 xor rdi, rdi syscall
Read the two offsets: stepping the first subscript moved one slot, stepping the second moved rows slots. That is the whole reason a Fortran loop nest should have the first subscript innermost — consecutive iterations then touch consecutive addresses, which is what a cache line is for. C stores rows contiguously instead, so the identical nest wants the opposite order, and getting it backwards is the classic silent performance bug when porting between the two.
Everything Goes By Reference
Arguments Are Addresses, Not Values
Fortran's classic calling convention passes everything by reference: a subroutine receives the address of its argument, never a copy. Square brackets mean "the contents of", so add [rdi], rsi adds into the eight bytes at the address in rdi — which is what an intent(inout) argument compiles to.
program by_reference implicit none integer(kind=8) :: total total = 40 call add_to(total, 2_8) write(*, '(I0)') total contains subroutine add_to(target, amount) integer(kind=8), intent(inout) :: target integer(kind=8), intent(in) :: amount target = target + amount end subroutine add_to end program by_reference
global _start section .data total: dq 40 amount: dq 2 section .bss output: resb 3 section .text ; add_to(target ADDRESS in rdi, amount ADDRESS in rsi) ; Both are addresses — that is the classic Fortran convention. add_to: mov rax, [rsi] ; load the amount THROUGH its address add [rdi], rax ; target = target + amount ret _start: lea rdi, [total] ; the address of total lea rsi, [amount] ; the address of amount — even for intent(in) call add_to mov rax, [total] ; 42 xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
The second lea is the surprise: even intent(in) is passed by address, because intent is a promise the compiler checks rather than a change to the convention. That is why passing a literal to an old-style Fortran routine was historically dangerous — the caller had to materialize the constant somewhere so its address could be taken, and a routine that wrote through that address could corrupt the constant itself.
The Trailing Underscore
work Becomes work_
gfortran appends an underscore to the symbol name of every module-free external procedure, and lowercases it — so subroutine Work becomes the symbol work_. The assembly column defines exactly that symbol, since a label is just a name for an address.
program mangling implicit none integer(kind=8) :: result ! The symbol emitted for this is "double_it_" — lowercased, underscored. ! Check for yourself with: gfortran -c file.f90 && nm file.o call double_it(21_8, result) write(*, '(I0)') result contains subroutine double_it(value, answer) integer(kind=8), intent(in) :: value integer(kind=8), intent(out) :: answer answer = value * 2 end subroutine double_it end program mangling
global _start section .data value: dq 21 section .bss answer: resq 1 output: resb 3 section .text ; The name gfortran would emit for subroutine double_it. ; A label is just a name for an address; the underscore is convention. double_it_: mov rax, [rdi] ; value, by reference imul rax, 2 mov [rsi], rax ; answer, by reference ret _start: lea rdi, [value] lea rsi, [answer] call double_it_ mov rax, [answer] xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
This convention is why linking Fortran and C by hand means writing double_it_ on the C side, and why the underscore count differed between compilers historically — g77 used two for names already containing one. A procedure inside a module is mangled far more elaborately still (__modulename_MOD_procname), which is why bind(C) exists and is the subject of a later row.
An Assumed-Shape Array Is a Descriptor
A Descriptor, Not an Address
An assumed-shape dummy argument — declared values(:) — does not receive a bare address. It receives a descriptor: a small struct holding the data pointer, the bounds and the stride, which is how size(values) can work inside the callee. The assembly shows a hand-built two-field version of that struct.
program descriptors implicit none integer(kind=8) :: numbers(3) numbers = [10_8, 20_8, 12_8] ! An explicit interface is REQUIRED for an assumed-shape argument, ! which the 'contains' section provides here. write(*, '(I0)') total_of(numbers) contains function total_of(values) result(total) integer(kind=8), intent(in) :: values(:) integer(kind=8) :: total integer :: index total = 0 ! size() works because the descriptor carried the bounds along. do index = 1, size(values) total = total + values(index) end do end function total_of end program descriptors
global _start section .data numbers: dq 10, 20, 12 ; A descriptor, by hand: where the data is, and how many elements. ; A real gfortran descriptor also carries strides and bounds per rank. descriptor: dq numbers ; + 0 the data pointer dq 3 ; + 8 the element count section .bss output: resb 3 section .text ; total_of(descriptor address in rdi) -> rax total_of: mov rsi, [rdi] ; the data pointer, OUT of the descriptor mov r8, [rdi + 8] ; the count, also out of the descriptor xor rax, rax xor rcx, rcx accumulate: cmp rcx, r8 jge total_done add rax, [rsi + rcx * 8] inc rcx jmp accumulate total_done: ret _start: lea rdi, [descriptor] call total_of xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
The descriptor is why an explicit interface is mandatory for an assumed-shape argument: the caller has to know to build one, and without an interface it would pass a bare address that the callee then reads as a struct — reliably wrong, and reliably confusing. It is also why passing a Fortran assumed-shape array to C does not work: C expects the address, so the interface must use an explicit-shape or assumed-size dummy, or bind(C).
bind(C) Gives All Of This Up
bind(C) Gives All Of This Up
bind(C) asks for a plain, unmangled symbol and C's conventions — so no trailing underscore, and value attributes give by-value arguments in registers rather than addresses. The assembly is correspondingly simpler: the numbers arrive directly.
program interop implicit none integer(kind=8) :: answer answer = combine(2_8, 5_8, 8_8) write(*, '(I0)') answer contains ! bind(C) means the symbol is exactly "combine", and 'value' means the ! arguments arrive in registers rather than as addresses. function combine(first, second, third) result(answer) bind(C, name="combine") use iso_c_binding, only: c_int64_t integer(c_int64_t), value :: first, second, third integer(c_int64_t) :: answer answer = first + second * third end function combine end program interop
global _start section .bss output: resb 3 section .text ; No trailing underscore, and the values arrive IN the registers. ; This is an ordinary C-convention function. combine: mov rax, rsi imul rax, rdx add rax, rdi ret _start: mov rdi, 2 ; by value, not by address mov rsi, 5 mov rdx, 8 call combine ; 2 + 5 * 8 = 42 xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
Compare this against the by-reference row: three leas became three movs, and the symbol lost its underscore. That is the whole of what bind(C) buys, and the whole of what it costs — you give up assumed-shape arrays, optional arguments and Fortran's own name resolution in exchange for a symbol any linker can find. The iso_c_binding kinds exist so the widths provably agree rather than agreeing by luck.
Gotchas For Fortran Developers
Nothing Checks the Bounds
gfortran can insert a bounds test on every array access with -fcheck=bounds, and it reports which subscript went out of range. Assembly has no such option — the check is a comparison you write, or it does not happen.
program bounds implicit none integer(kind=8) :: numbers(3) integer :: index numbers = [10_8, 20_8, 30_8] index = 5 if (index < 1 .or. index > 3) then write(*, '(A)') "out of range" else write(*, '(I0)') numbers(index) end if end program bounds
global _start section .data numbers: dq 10, 20, 30 out_of_range_message: db "out of range", 10 out_of_range_length: equ $ - out_of_range_message section .text _start: mov rbx, 5 dec rbx ; index minus the lower bound cmp rbx, 3 jae out_of_range ; unsigned: catches too-large AND "negative" ; the in-range path would read [numbers + rbx * 8] here mov rax, 60 xor rdi, rdi syscall out_of_range: mov rax, 1 mov rdi, 1 mov rsi, out_of_range_message mov rdx, out_of_range_length syscall mov rax, 60 xor rdi, rdi syscall
One instruction, jae, does the work of both halves of the Fortran test: after subtracting the lower bound, an index below the range wraps to an enormous unsigned value and fails the same upper comparison. That trick is what -fcheck=bounds emits, which is why bounds checking costs two instructions per access rather than four — and why leaving it on in a debug build is usually the right trade.
A Syscall Destroys Registers
The syscall instruction always destroys rcx and r11 — the processor uses them to remember how to return. Callee-saved registers (rbx, rbp, r12r15) survive. Fortran has no way to express this, which is precisely the point: the compiler obeys the convention so you never meet it.
program callee_saved implicit none integer(kind=8) :: counter counter = 42 write(*, '(A)') "writing" ! counter survives because the compiler knows the ABI write(*, '(I0)') counter end program callee_saved
global _start section .data message: db "writing", 10 length: equ $ - message section .bss output: resb 3 section .text _start: mov rbx, 42 ; rbx is callee-saved — it will survive mov rcx, 42 ; rcx will NOT mov rax, 1 mov rdi, 1 mov rsi, message mov rdx, length syscall ; rcx is now garbage; rbx is untouched mov rax, rbx ; read the one that survived xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
Reading rcx after that syscall would print whatever the kernel left behind — no crash, no complaint, just a wrong answer that changes with the kernel version. This is the concrete shape of what a calling convention is: an agreement about which registers survive a call, obeyed by everything the compiler emits, and enforced by nothing at all.