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 helloglobal _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
syscallThe
'(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_statusglobal _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
syscallThe 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_numberglobal _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
syscallSixteen 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 localsglobal _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
syscallThe 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 kindsglobal _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
syscallA 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 divideglobal _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
syscallWriting 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 underflowglobal _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
syscallFortran'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 branchglobal _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
syscallThe 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
print — end 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 countedglobal _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
syscallThe 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 indexingglobal _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
syscallThe
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_majorglobal _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
syscallRead 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_referenceglobal _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
syscallThe 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 manglingglobal _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
syscallThis 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 descriptorsglobal _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
syscallThe 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 interopglobal _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
syscallCompare 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 boundsglobal _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
syscallOne 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, r12–r15) 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_savedglobal _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
syscallReading
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.