Assembly (x86-64, AArch64, RISC-V) Module 2 – Bits, bytes and numbers
Bits, bytes, words and hexadecimal
Read hexadecimal four bits at a time, convert by hand, and name the 8- to 64-bit sizes and register views of x86-64, AArch64 and RISC-V, with recorded runs.
What you will learn
- Convert between binary, hexadecimal and decimal by hand, and check the result with a programmer calculator
- Name the sizes that x86-64, AArch64 and RISC-V give to 16, 32 and 64 bits
- Identify how many bytes a load moves from its mnemonic, its size keyword or its encoding
- Predict what a write to a narrow register name leaves in the whole 64-bit register
Before you start
What you’ll learn · Before you start
What you will learn
- Convert between binary, hexadecimal and decimal by hand, and check the result with a programmer calculator
- Name the sizes that x86-64, AArch64 and RISC-V give to 16, 32 and 64 bits
- Identify how many bytes a load moves from its mnemonic, its size keyword or its encoding
- Predict what a write to a narrow register name leaves in the whole 64-bit register
Before you start
On this page
A disassembler prints ldr w0, [x1] beside the eight characters b9400020, and a run of the same load on this page
prints x0 = 0x0000000055667788: every number is hexadecimal, and the load read 4 bytes because w0 names 32 bits. A
bit: A single binary digit, 0 or 1, the smallest unit of data. Eight bits make a byte, and a 64-bit register holds 64 of them. is one binary digit, a byte: Eight bits, the smallest unit of memory with its own address on x86-64, AArch64 and RISC-V. A byte holds 256 patterns, written 0x00 to 0xFF. is 8 bits, one
hexadecimal: Base 16, written with the digits 0 to 9 and A to F. Each digit stands for four bits, so 0x2C is the byte 0010 1100, which is 44; the prefix 0x marks a hexadecimal number in C, Python and most assemblers. digit stands for exactly 4 bits, and the size called a
word: A size whose meaning depends on the manual: 16 bits in Intel's x86-64 manual, which keeps the register width of the 8086, but 32 bits on AArch64 and RISC-V. Give the size in bits or bytes next to the name. is 16 bits on x86-64 but 32 bits on AArch64 and RISC-V.
Below, you convert between binary, hexadecimal and decimal by hand and check every step with short Python programs you
can change and run, set the size names of the three ISAs side by side with the instructions that choose them, and
watch a recorded AArch64 run show what a write to a narrow register name does to the whole 64-bit register.
What assembly language is showed bytes such as 0x2a; this lesson makes them
quick to read.
One hexadecimal digit stands for four bits
Take the number 300. Written in binary it is 1 0010 1100: nine digits, each 0 or 1, worth 256, 128, 64 and so on
down to 1 from the left. Nine digits are already hard to read, and a 64-bit register holds 64 of them. Four bits can
form exactly 16 patterns, from 0000 to 1111, and hexadecimal has exactly 16 digits, 0 to 9 and then A to F
for 10 to 15. So each hexadecimal digit stands for one group of four bits, a nibble: A group of four bits, half a byte. One hexadecimal digit stands for exactly one nibble, so a byte is written with two hexadecimal digits., and
turning a binary number: A number written in base 2, with only the digits 0 and 1, each worth twice the digit to its right: 1 0010 1100 is 256 + 32 + 8 + 4 = 300. into hexadecimal takes no arithmetic beyond each group: cut the
bits into fours from the right, then replace every group with its digit. Step through 300 group by group:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 300 as nine bits. 300 is 256 + 32 + 8 + 4, so its binary form has a 1 in the places worth those four numbers and a 0 everywhere else: 1 0010 1100, nine bits.
- Groups of four from the right. Cut the bits into groups of four, starting at the right end: 1100, then 0010, then the single 1 that is left, padded with zeros to 0001. Zeros on the left never change a number.
- The lowest group is C. 1100 has ones in the places worth 8 and 4, so it is 12, written as the single digit C. A group of four bits is worth 0 to 15, exactly the range of one hexadecimal digit.
- The next group is 2. 0010 is 2. Its group sits four places higher, so each of its bits is worth 16 times as much: that is why it becomes the second hexadecimal digit.
- The top group is 1. 0001 is 1, the third digit. Nine bits needed only three hexadecimal digits, and no arithmetic beyond adding 8, 4, 2 and 1 inside each group.
- Read the digits top down. Reading the digits from the highest group to the lowest gives 0x12C. Check it the other way: 1 × 256 + 2 × 16 + 12 = 300.
- Two digits are one byte. The two lowest digits, 2C, are the lowest 8 bits, 0010 1100: the low byte. It is 44, the value an 8-bit register name keeps from 300.
The sixteen patterns are worth knowing on sight. The right half of the table is the left half with the bit worth 8 added:
| Bits | Hex | Bits | Hex |
|---|---|---|---|
| 0000 | 0 | 1000 | 8 |
| 0001 | 1 | 1001 | 9 |
| 0010 | 2 | 1010 | A |
| 0011 | 3 | 1011 | B |
| 0100 | 4 | 1100 | C |
| 0101 | 5 | 1101 | D |
| 0110 | 6 | 1110 | E |
| 0111 | 7 | 1111 | F |
Because a digit is always four bits, the length of a hexadecimal number gives its width: two digits fill a byte, four
fill 16 bits, eight fill 32 and sixteen fill 64. That is why disassemblers print 64-bit addresses with 16 digits, and
why the recorded x0 = 0x0000000055667788 keeps its eight leading zeros: they say that the register is 64 bits wide
and that its top 32 bits are 0. In C, Python and the GNU and LLVM assemblers the prefix 0x marks a hexadecimal
number, and Python writes a binary one with 0b.
The next program prints a few numbers in decimal, then in binary with every nibble above its digit, then in
hexadecimal. Press Edit, put your own whole numbers in VALUES and run it again:
"""One hexadecimal digit is four bits: each value in decimal, in hexadecimal and in binary cut into nibbles."""
VALUES = [44, 255, 300, 4096] # put your own whole numbers here and run it again
for n in VALUES:
digits = f"{n:X}" # hexadecimal digits, no prefix
bits = f"{n:0{4 * len(digits)}b}" # binary, padded to four bits for every hex digit
nibbles = [bits[i : i + 4] for i in range(0, len(bits), 4)]
print(f"{n:>5} binary {' '.join(nibbles)}")
print(f"{'':>5} hex {' '.join(f'{d:>4}' for d in digits)} 0x{digits}")
# Python reads all three spellings of 44, and the "_" format option groups binary and hex in fours.
print(0x2C, 0b0010_1100, int("2C", 16), int("101100", 2))
print(f"{300:_b}", f"{0x1122334455667788:_x}") Output
44 binary 0010 1100
hex 2 C 0x2C
255 binary 1111 1111
hex F F 0xFF
300 binary 0001 0010 1100
hex 1 2 C 0x12C
4096 binary 0001 0000 0000 0000
hex 1 0 0 0 0x1000
44 44 44 44
1_0010_1100 1122_3344_5566_7788
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hex_digits.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
300 comes out as 0001 0010 1100 above 1 2 C, and 4096 as 0001 0000 0000 0000 above 0x1000: a 1 followed by
twelve zero bits is a 1 followed by three zero digits. The last two lines show that Python reads 0x2C,
0b0010_1100 and int("2C", 16) as the same 44, and that a format with the _ option groups binary and hexadecimal
digits in fours, as Python’s format specification promises.
Key idea
Hexadecimal is binary written four bits at a time: one digit per nibble, two digits per byte and sixteen digits for a 64-bit register, so going between the two is a lookup, not a calculation.
Checkpoint
Check: One hex digit for every four bits
Converting by hand: divide by 16, then add place values
Hexadecimal is a positional system like decimal, with 16 in place of 10. The digits of 0x12C are worth 1 × 256,
2 × 16 and 12 × 1, and 256 + 32 + 12 = 300. That gives both directions. From hexadecimal to decimal, multiply each
digit by its power of 16 (1, 16, 256, 4,096 and so on from the right) and add. From decimal to hexadecimal, divide by
16 again and again: each remainder, 0 to 15, is one digit, the first remainder is the lowest digit, and the division
stops when the quotient reaches 0. The program does both for 300 the way you would on paper; change n and run it
again:
"""Decimal to hexadecimal by dividing by 16, then back again by place values, as you would on paper."""
DIGITS = "0123456789ABCDEF"
n = 300 # change it and run again
remainders = []
q = n
while q > 0:
q, r = divmod(q, 16)
remainders.append(r)
print(f"{q * 16 + r:>5} = {q:>4} × 16 + {r:>2} digit {DIGITS[r]}")
text = "".join(DIGITS[r] for r in reversed(remainders)) or "0"
print("remainders read from the bottom up: 0x" + text)
slip = "".join(DIGITS[r] for r in remainders) or "0"
print("read from the top down instead: 0x" + slip, "=", int(slip, 16))
total = 0
for place, d in enumerate(reversed(text)):
total += DIGITS.index(d) * 16**place
print(f"{d} × 16^{place} = {DIGITS.index(d) * 16**place}")
print("total", total, "| hex() says", hex(n), "| int() says", int(text, 16))
print("the last two hex digits are the low byte:", hex(n & 0xFF), "=", n & 0xFF) Output
300 = 18 × 16 + 12 digit C
18 = 1 × 16 + 2 digit 2
1 = 0 × 16 + 1 digit 1
remainders read from the bottom up: 0x12C
read from the top down instead: 0xC21 = 3105
C × 16^0 = 12
2 × 16^1 = 32
1 × 16^2 = 256
total 300 | hex() says 0x12c | int() says 300
the last two hex digits are the low byte: 0x2c = 44
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 by_hand.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The three divisions leave the remainders 12, 2 and 1. Read from the last one to the first, they are 1, 2 and C:
0x12C. The place values add back to 300, and Python’s own hex() and int() agree. The last line is the shortcut
that readers of disassembly use most: the two lowest hexadecimal digits are the low byte, here 0x2c, which is 44.
Reading the remainders in the order they came
The first remainder is the lowest digit. Writing the digits down as they appear gives 0xC21 for 300, which the
program shows is 3105, more than ten times too big. Read the remainders from the last to the first, then check the
result by adding the place values back.
From decimal to binary, go through hexadecimal: 300 is 0x12C, and writing each digit as its four bits gives
0001 0010 1100 without dividing by 2 nine times. From binary to decimal, take the same road in reverse.
To check a conversion without a program, MySmartCoPilot’s programmer calculator
shows a value in hexadecimal, decimal, octal and binary at once, at a width you choose from 8 to 128 bits, with a grid
of its bits; at 8 bits it wraps 200 + 100 to 44 and says that the sum overflowed. The
number base converter converts between other bases as well, fractions included.
GATE CS 2027
In the GATE 2027 syllabus for Computer Science and Information Technology, from IIT Madras, Section 2 (Digital Logic) names “number representation and arithmetic (fixed and floating point)”, and Section 3 (Computer Organization and Architecture) begins with “instruction set and addressing modes”. This module works through the first of them lesson by lesson, and the sizes and load forms below belong to the second.
Checkpoint
Check: Converting between hexadecimal and decimal
Thirty-two bits are a doubleword on x86-64 and a word on AArch64 and RISC-V
The same 4-byte load is mov eax, dword ptr [rsi] on x86-64, ldr w0, [x1] on AArch64 and lw a0, 0(a1) on RISC-V,
and the three manuals give that size two different names. A byte is 8 bits on all three. Intel’s manual defines a
word as 2 bytes, a doubleword: Twice a word in the manual you are reading: 32 bits on x86-64, where assemblers write it dword, and 64 bits on AArch64 and RISC-V. as 4, a quadword as 8 and a double quadword as 16. It traces
the family back to the 8086 of 1978, whose registers were 16 bits wide, and it still calls 16 bits a word. AArch64 and
RISC-V count from 32 bits instead: Arm’s procedure call standard, AAPCS64, lists a half-word as 2 bytes, a word as 4,
a double-word as 8 and a quad-word as 16, and the RISC-V specification defines a word of memory as 32 bits, with the
halfword, the doubleword and the quadword at 16, 64 and 128 bits. Intel’s words are therefore half the size of the
words in the other two manuals, and each size from 16 bits up has two names:
| Size | Intel x86-64 | AArch64 and RISC-V |
|---|---|---|
| 8 bits, 1 byte, 2 hex digits | byte | byte |
| 16 bits, 2 bytes, 4 hex digits | word | halfword |
| 32 bits, 4 bytes, 8 hex digits | doubleword | word |
| 64 bits, 8 bytes, 16 hex digits | quadword | doubleword |
| 128 bits, 16 bytes, 32 hex digits | double quadword | quadword |
The program builds the same table from the number of bits and adds the largest unsigned value of each size, a run of
F digits as long as the size. Run it, or add a row for 128 bits to SIZES:
"""Integer sizes: bits, bytes, hex digits, the largest unsigned value, and the name each ISA's manual gives the size."""
SIZES = [ # (bits, name in Intel's x86-64 manual, name in the AArch64 and RISC-V documents)
(8, "byte", "byte"),
(16, "word", "halfword"),
(32, "doubleword", "word"),
(64, "quadword", "doubleword"),
]
print(f"{'bits':>4} {'bytes':>5} {'digits':>6} {'largest unsigned':<19} {'x86-64':<11} AArch64 and RISC-V")
for bits, x86, arm_riscv in SIZES:
largest = 2**bits - 1
print(f"{bits:>4} {bits // 8:>5} {bits // 4:>6} {f'0x{largest:X}':<19} {x86:<11} {arm_riscv}") Output
bits bytes digits largest unsigned x86-64 AArch64 and RISC-V 8 1 2 0xFF byte byte 16 2 4 0xFFFF word halfword 32 4 8 0xFFFFFFFF doubleword word 64 8 16 0xFFFFFFFFFFFFFFFF quadword doubleword
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 size_names.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The largest 32-bit value is 0xFFFFFFFF, eight F digits, and the largest 64-bit value has sixteen. The x86 names are
also the assembler’s size keywords, word, dword and qword, while AArch64 and RISC-V put the size into the
mnemonic or the register name, as the next section shows.
Reading an Arm word with the x86 size
When an AArch64 or RISC-V manual says an instruction loads a word, it means 4 bytes. Read with the x86 meaning, 2
bytes, every offset into an array of words comes out halved: the third word of an array at x1 starts at
x1 + 8, not at x1 + 4. Turn a size name into bytes for the ISA whose manual you are reading before you use it.
Checkpoint
Check: The size names of each ISA
The instruction names how many bytes it moves
Each ISA spells the size of a memory access in the instruction itself. AArch64 uses the mnemonic and the register:
ldrb and ldrh read 1 and 2 bytes into a w register, and ldr reads 4 bytes into a w register and 8 into an
x register. RISC-V uses the mnemonic alone: lb, lh, lw and ld read a byte, a halfword, a word and a
doubleword, and lbu, lhu and lwu fill the bits above the value with zeros where the plain forms copy its top
bit. x86-64 takes the size from a register operand, and when the only operand with a size is in memory, a keyword
gives it: byte ptr, word ptr, dword ptr or qword ptr in GNU Intel syntax, or a suffix on the mnemonic in
AT&T syntax: The x86 notation that GNU as and objdump use by default: the source operand comes first, registers take a % sign and immediates a $ sign, memory is written disp(base, index, scale), and a suffix on the mnemonic (b, w, l or q) gives the operand size. More in “Assembler syntax: NASM, GNU as, Intel and AT&T”. Stores follow the same pattern:
| Bytes | x86-64, Intel syntax | AArch64 | RISC-V |
|---|---|---|---|
| 1 | byte ptr |
ldrb, strb |
lb, lbu, sb |
| 2 | word ptr |
ldrh, strh |
lh, lhu, sh |
| 4 | dword ptr |
ldr, str with w |
lw, lwu, sw |
| 8 | qword ptr |
ldr, str with x |
ld, sd |
The session below assembles the loads of the table for each ISA with LLVM and prints them back with its disassembler. It only encodes and decodes them: no x86-64 or RISC-V code runs in it. The three source files come first:
S (loads_a64.s) · asm/loads_a64.s
// Four loads that differ only in how many bytes they read from the address in x1 (AArch64, GNU/LLVM syntax).
ldrb w0, [x1] // 1 byte, zero-extended into w0
ldrh w0, [x1] // 2 bytes (a halfword)
ldr w0, [x1] // 4 bytes (a word)
ldr x0, [x1] // 8 bytes (a doubleword) S (loads_rv64.s) · asm/loads_rv64.s
# Seven RV64I loads from the address in a1; only the access size and the kind of extension differ.
lb a0, 0(a1) # 1 byte, sign-extended to 64 bits
lh a0, 0(a1) # 2 bytes (a halfword), sign-extended
lw a0, 0(a1) # 4 bytes (a word), sign-extended
ld a0, 0(a1) # 8 bytes (a doubleword)
lbu a0, 0(a1) # 1 byte, zero-extended
lhu a0, 0(a1) # 2 bytes, zero-extended
lwu a0, 0(a1) # 4 bytes, zero-extended S (loads_x64.s) · asm/loads_x64.s
# Four x86-64 loads from the address in rsi, in GNU Intel syntax; the ptr keyword names the size.
.intel_syntax noprefix
movzx eax, byte ptr [rsi] # 1 byte, zero-extended
movzx eax, word ptr [rsi] # 2 bytes (an x86 word), zero-extended
mov eax, dword ptr [rsi] # 4 bytes (a doubleword)
mov rax, qword ptr [rsi] # 8 bytes (a quadword) """The same loads on three ISAs, assembled by LLVM and printed back by its disassembler.
Nothing here runs the x86-64 or RISC-V code: llvm-mc only encodes the instructions and llvm-objdump only reads the
bytes, so the session works on any machine with LLVM.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# AArch64: every instruction is one 32-bit word
llvm-mc -triple=aarch64 -filetype=obj loads_a64.s -o loads_a64.o
llvm-objdump -d loads_a64.o | grep -E '^ +[0-9a-f]+:'
# RISC-V: also one 32-bit word each
llvm-mc -triple=riscv64 -filetype=obj loads_rv64.s -o loads_rv64.o
llvm-objdump -d loads_rv64.o | grep -E '^ +[0-9a-f]+:'
# x86-64: a string of bytes whose length depends on the instruction
llvm-mc -triple=x86_64 -filetype=obj loads_x64.s -o loads_x64.o
llvm-objdump -d -M intel loads_x64.o | grep -E '^ +[0-9a-f]+:'
""") Output
# AArch64: every instruction is one 32-bit word
$ llvm-mc -triple=aarch64 -filetype=obj loads_a64.s -o loads_a64.o
$ llvm-objdump -d loads_a64.o | grep -E '^ +[0-9a-f]+:'
0: 39400020 ldrb w0, [x1]
4: 79400020 ldrh w0, [x1]
8: b9400020 ldr w0, [x1]
c: f9400020 ldr x0, [x1]
# RISC-V: also one 32-bit word each
$ llvm-mc -triple=riscv64 -filetype=obj loads_rv64.s -o loads_rv64.o
$ llvm-objdump -d loads_rv64.o | grep -E '^ +[0-9a-f]+:'
0: 00058503 lb a0, 0x0(a1)
4: 00059503 lh a0, 0x0(a1)
8: 0005a503 lw a0, 0x0(a1)
c: 0005b503 ld a0, 0x0(a1)
10: 0005c503 lbu a0, 0x0(a1)
14: 0005d503 lhu a0, 0x0(a1)
18: 0005e503 lwu a0, 0x0(a1)
# x86-64: a string of bytes whose length depends on the instruction
$ llvm-mc -triple=x86_64 -filetype=obj loads_x64.s -o loads_x64.o
$ llvm-objdump -d -M intel loads_x64.o | grep -E '^ +[0-9a-f]+:'
0: 0f b6 06 movzx eax, byte ptr [rsi]
3: 0f b7 06 movzx eax, word ptr [rsi]
6: 8b 06 mov eax, dword ptr [rsi]
8: 48 8b 06 mov rax, qword ptr [rsi]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 encodings_session.py
The AArch64 and RISC-V loads are all 32-bit words, eight hexadecimal digits each, while the x86-64 loads are 3, 3, 2
and 3 bytes long, so an x86-64 listing has no fixed grid. The size is still in its bytes: Intel’s opcode tables list
0F B6 for movzx from a byte and 0F B7 from a word, and the extra 48 in front of 8b 06 is the REX.W prefix
that the table puts before the 64-bit form of mov. In the fixed-width ISAs the size sits in a few bits of the
instruction word, and finding them takes the nibble reading of the first section:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Two loads as eight digits each. The recorded session printed both loads as 32-bit words: b9400020 for ldr w0, [x1] on AArch64 and 0005a503 for lw a0, 0(a1) on RISC-V. Four bits per digit, so each word is eight digits.
- AArch64: the first digit. Bits 31 to 28 are the first digit, b, which is 1011. The four AArch64 loads of the session start with 3, 7, b and f, that is 0011, 0111, 1011 and 1111: only the top two bits differ.
- Those two bits are the size. LLVM's AArch64 instruction definitions call bits 31 and 30 of these loads the size field: 00 for ldrb, 01 for ldrh, 10 for ldr into a w register and 11 for ldr into an x register. Here they are 10.
- 10 means four bytes. Each step of the size field doubles the access: 1, 2, 4 and 8 bytes. 10 is the third step, 4 bytes, an AArch64 word, and the bytes land in w0.
- RISC-V: the fourth digit. On RISC-V, count digits from the right: 3, 0, 5, then a. The fourth digit holds bits 15 to 12, and a is 1010.
- The low three bits are funct3. Bit 15 is the lowest bit of the address register a1. Bits 14 to 12 are the funct3 field that picks the load, here 010. The RISC-V listing gives lb 000, lh 001, lw 010 and ld 011.
- 010 means a word. 010 is lw, a 4-byte word, sign-extended to 64 bits on RV64. With the top funct3 bit set the loads are lbu 100, lhu 101 and lwu 110, which is why the session shows c, d and e in that digit.
The four AArch64 loads start with 3, 7, b and f because only bits 31 and 30 change between them. The RISC-V loads differ in their fourth digit from the right, 8, 9, a and b, and then c, d and e for the unsigned forms. A disassembler does exactly this, field by field, for every word it prints.
Checkpoint
Check: How an instruction picks its access size
A narrow register name is a window on one 64-bit register
A 64-bit register often has more than one name, each for a different number of its low bits. On x86-64 the first
register is rax for all 64 bits, eax for the low 32, ax for the low 16 and al for the low 8, and Intel’s
register table gives every general-purpose register the same four levels, down to r8d, r8w and r8b for r8.
AArch64 has two names, x0 and w0, which Arm’s procedure call standard describes as one register used in a 64-bit
or in a 32-bit context. RISC-V has one name per register, and its width, called XLEN, is 32 bits on RV32 and 64 on
RV64.
Reading a narrow name always gives the low bits. Writing one is where the ISAs differ, and the next program shows it
on real AArch64 hardware. Each function in views_a64.s takes the address of the 8 bytes 0x1122334455667788, or
that value itself, and views.c prints what the function left in x0 or in memory:
S · asm/views_a64.s
// Seven small AArch64 functions for an Apple silicon Mac. The C caller in views.c passes an address or a value in x0
// (and a byte in w1), then prints in hexadecimal what each function left in x0 or in memory.
.text
.globl _load_byte
.globl _load_half
.globl _load_word
.globl _load_dword
.globl _keep_w
.globl _add_w
.globl _store_byte
.p2align 2
_load_byte: // x0 holds the address of 8 bytes
ldrb w0, [x0] // read 1 byte
ret
_load_half:
ldrh w0, [x0] // read 2 bytes
ret
_load_word:
ldr w0, [x0] // read 4 bytes
ret
_load_dword:
ldr x0, [x0] // read 8 bytes
ret
_keep_w: // x0 holds a 64-bit value
mov w0, w0 // write the 32-bit view w0 with its own low 32 bits
ret
_add_w:
add w0, w0, #1 // a 32-bit add: the result goes to w0
ret
_store_byte: // x0 holds an address, w1 a byte
strb w1, [x0] // write 1 byte; the other 7 bytes stay as they were
ret C · asm/views.c
// Calls the AArch64 functions of views_a64.s and prints, in hexadecimal, what each one left in x0 or in memory.
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
uint64_t load_byte(const uint64_t *p);
uint64_t load_half(const uint64_t *p);
uint64_t load_word(const uint64_t *p);
uint64_t load_dword(const uint64_t *p);
uint64_t keep_w(uint64_t x);
uint64_t add_w(uint64_t x);
void store_byte(uint64_t *p, uint8_t b);
int main(void) {
uint64_t memory = 0x1122334455667788; // 8 bytes in memory
printf("memory 0x%016" PRIx64 "\n", memory);
printf("ldrb w0, [x0] x0 = 0x%016" PRIx64 "\n", load_byte(&memory));
printf("ldrh w0, [x0] x0 = 0x%016" PRIx64 "\n", load_half(&memory));
printf("ldr w0, [x0] x0 = 0x%016" PRIx64 "\n", load_word(&memory));
printf("ldr x0, [x0] x0 = 0x%016" PRIx64 "\n", load_dword(&memory));
printf("mov w0, w0 x0 = 0x%016" PRIx64 "\n", keep_w(memory));
printf("add w0, w0, 1 x0 = 0x%016" PRIx64 "\n", add_w(memory));
store_byte(&memory, 0xFF);
printf("strb w1, [x0] memory = 0x%016" PRIx64 "\n", memory);
return 0;
} """Build views.c with views_a64.s on an Apple silicon Mac and run it: every line it prints comes from AArch64 code."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
uname -m
clang -o views views.c views_a64.s
./views; echo "exit=$?"
""") Output
$ uname -m arm64 $ clang -o views views.c views_a64.s $ ./views; echo "exit=$?" memory 0x1122334455667788 ldrb w0, [x0] x0 = 0x0000000000000088 ldrh w0, [x0] x0 = 0x0000000000007788 ldr w0, [x0] x0 = 0x0000000055667788 ldr x0, [x0] x0 = 0x1122334455667788 mov w0, w0 x0 = 0x0000000055667788 add w0, w0, 1 x0 = 0x0000000055667789 strb w1, [x0] memory = 0x11223344556677ff exit=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 views_session.py
ldrb, ldrh and ldr w0 returned 0x88, 0x7788 and 0x55667788, each with zeros above it in x0. They read
the lowest-addressed bytes, which on a little-endian machine hold the low end of the value, an order that
the endianness lesson explains. mov w0, w0 and add w0, w0, #1 wrote only
the 32-bit name, yet x0 came back as 0x0000000055667788 and 0x0000000055667789: on AArch64 a write to a w
register clears bits 63 to 32, a rule that Arm’s A64 guide states for every write to a w register and that
LLVM’s AArch64 code generator relies on. The store went the other way: strb changed one byte of memory and left the other seven alone,
0x11223344556677ff.
x86-64 clears the top half on a 32-bit write too, but not on narrower ones: Intel’s manual says that a 32-bit result
is zero-extended to 64 bits, while an 8-bit or 16-bit result leaves the upper 56 or 48 bits unchanged. RV64 does a
third thing. Its W instructions, such as addiw, addw and subw, ignore the upper 32 bits of their inputs and
write a 32-bit result
sign extension: Widening a value by copying its top bit into every new bit above it, so a two's complement number keeps its signed value: the byte 0xc8 (−56) becomes 0xffffffc8 in 32 bits. movsx, ldrsb and lb sign-extend. More in “Two's complement and sign extension”
to 64 bits, with bit 31 copied into every bit above it. The programs above ran on AArch64, so the
next program applies the two manuals’ rules to a 64-bit value in Python:
"""What a write to a narrow view leaves in the whole 64-bit register, under the x86-64 and RV64 rules."""
ALL = (1 << 64) - 1
def low(value, bits):
"""The low `bits` bits: the narrow view of the register."""
return value & ((1 << bits) - 1)
def x86_write(old, value, bits):
"""x86-64: a 32-bit write clears bits 63..32; an 8- or 16-bit write keeps every bit above the view."""
if bits == 32:
return low(value, 32)
return (old & ~((1 << bits) - 1) & ALL) | low(value, bits)
def rv64_w_result(value):
"""RV64 W instructions: the low 32 bits of the result, with bit 31 copied into bits 63..32."""
r = low(value, 32)
return r | (ALL ^ 0xFFFF_FFFF) if r & 0x8000_0000 else r
rax = 0x1122_3344_5566_7788 # change it and run again
print(f"rax before 0x{rax:016x}")
for name, bits in [("mov al, 0x01", 8), ("mov ax, 0x01", 16), ("mov eax, 0x01", 32)]:
print(f"{name:<18} 0x{x86_write(rax, 0x01, bits):016x}")
a0 = 0x1122_3344_89AB_CDEF # bit 31 of the low half is 1 here
print(f"a0 before 0x{a0:016x}")
print(f"addiw a0, a0, 0 0x{rv64_w_result(a0):016x}")
print(f"addiw a0, a0, 1 0x{rv64_w_result(a0 + 1):016x}") Output
rax before 0x1122334455667788 mov al, 0x01 0x1122334455667701 mov ax, 0x01 0x1122334455660001 mov eax, 0x01 0x0000000000000001 a0 before 0x1122334489abcdef addiw a0, a0, 0 0xffffffff89abcdef addiw a0, a0, 1 0xffffffff89abcdf0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 register_views.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
mov al changed the last two digits and mov ax the last four, while mov eax left 0x0000000000000001: the
11223344 above it is gone. addiw a0, a0, 0 turned 0x1122334489abcdef into 0xffffffff89abcdef, because the top
bit of the digit 8, which is bit 31, is 1. Step through the same writes on the three register files:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- One register, four names. On x86-64, rax is 64 bits, and eax, ax and al name its low 32, 16 and 8 bits. They are views of one register, not four registers. The model starts with rax = 0x1122334455667788.
- Write al: 8 bits change. mov al, 0x01 writes the low byte. Intel's manual says an 8-bit result leaves the upper 56 bits of the register as they were, so only the last two hex digits change: 0x1122334455667701.
- Write ax: 16 bits change. mov ax, 0x01 writes 16 bits and keeps the upper 48, so the last four digits change: 0x1122334455660001.
- Write eax: the top half clears. mov eax, 0x01 is different. In 64-bit mode a 32-bit result is zero-extended into the whole register, so rax becomes 0x0000000000000001 and the 11223344 above it is gone.
- AArch64: x0 and w0. AArch64 names each register twice: x0 for all 64 bits and w0 for the low 32. There is no 8-bit or 16-bit name; ldrb and ldrh load into w0 instead, as the recorded run showed.
- Write w0: the top half clears. The recorded mov w0, w0 wrote w0 with its own value, and x0 came back as 0x0000000055667788. Like eax on x86-64, a 32-bit write clears bits 63 to 32.
- RV64: one name, 64 bits. RISC-V gives each register one name, and on RV64 each is 64 bits wide. A W instruction works on the low 32 bits only. The model starts a0 at 0x1122334489abcdef, whose bit 31 is 1.
- addiw copies bit 31 up. addiw a0, a0, 0 keeps the low 32 bits, 89abcdef, and copies bit 31 into bits 63 to 32: 0xffffffff89abcdef. The 32-bit result is sign-extended, where x86-64 and AArch64 fill with zeros.
Copying bit 31 keeps a negative 32-bit number negative in 64 bits, and two’s complement shows why.
Key idea
A narrow name reads the low bits of one 64-bit register, so check the whole register after a narrow write: 32-bit writes clear the top half on x86-64 and AArch64, 8-bit and 16-bit x86 writes keep it, and RV64 W instructions fill it with copies of bit 31.
Checkpoint
Check: Narrow register names and the full register
Practise
Fifteen questions cover the five ideas of the lesson, from a single nibble to a whole register after a write. The session asks first about the idea you know least, explains every answer as soon as you check it, and brings a missed question back at the end:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up for freshers: is a word 16 bits or 32?
Both, depending on whose manual you read, because each family named its sizes from its own starting point. Intel’s x86-64 manual still calls 16 bits a word, the register width of the 8086 where its line began, so 32 bits is a doubleword and 64 bits a quadword, and assemblers spell those operand sizes word, dword and qword. AArch64 and RISC-V were defined with 32-bit words: Arm’s procedure call standard lists a word as 4 bytes and a double-word as 8, and the RISC-V specification defines a word of memory as 32 bits, a halfword as 16 and a doubleword as 64. A byte is 8 bits on all three. In an answer, give the size in bits or bytes next to the name and say whose naming you are using, because the same name in two manuals can differ by a factor of two.
Two harder questions in the module’s bank carry these sizes into real code: a 32-bit array index that changes from unsigned to signed, and a header field renamed while a tool moves from Windows to AArch64 Linux. Each row leads to its first step and its follow-ups:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A function returns
table[i], withia 32-bitunsigned int. After a refactor makesianint, the x86-64 code changes frommov eax, esitomovsxd rax, esiand the AArch64 load fromuxtw #2tosxtw #2. Why does a 32-bit index need widening, and for which values do the two versions read different elements? -
Mid to senior
A tool that reads a binary file header was written on Windows, whose documentation lists the fields as WORD and DWORD. Ported to Linux on AArch64, it declares each DWORD as
unsigned longand each WORD as an Arm word, and the header's fields come out with wrong values. Why, and how should the fields be declared?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How many bits does one hexadecimal digit stand for, and how many digits fill a 64-bit register?
One hex digit is exactly four bits, a nibble. Two digits make a byte and sixteen digits fill a 64-bit register, so turning binary into hex is a matter of cutting the bits into groups of four from the right.
-
How do you turn 300 into hexadecimal by hand, and how do you check the answer?
Divide by 16 repeatedly and read the remainders from last to first: 300 is 0x12C. Check by adding each digit times its power of 16: 1 x 256 + 2 x 16 + 12 = 300.
-
Is a word 16 bits or 32 bits?
It depends on the manual. Intel's x86-64 manual keeps the 8086 meaning, so a word is 16 bits, a doubleword 32 and a quadword 64. AArch64 and RISC-V call 32 bits a word and 64 bits a doubleword. Say the size in bits next to the name.
-
How does each ISA say how many bytes a load moves?
x86-64 writes byte, word, dword or qword ptr on the memory operand. AArch64 uses ldrb and ldrh, or ldr into a w or an x register. RISC-V has separate loads: lb, lh, lw and ld.
-
What happens to the upper bits of a 64-bit register when you write only a narrow part of it?
A 32-bit write (eax on x86-64, w0 on AArch64) clears the upper 32 bits. An 8-bit or 16-bit x86 write (al, ax) leaves them as they were. An RV64 W instruction fills them with copies of bit 31.
-
How many patterns does a byte hold, and how are they written in hexadecimal?
256 patterns, from 0x00 to 0xFF. A byte is 8 bits and the smallest unit of memory with its own address on x86-64, AArch64 and RISC-V.
Key takeaways
- One hexadecimal digit is exactly four bits, a nibble, so two digits make a byte and sixteen fill a 64-bit register; binary becomes hexadecimal by cutting the bits into fours from the right.
- Decimal becomes hexadecimal by dividing by 16 and reading the remainders from the last to the first, and
hexadecimal becomes decimal by adding each digit times its power of 16:
0x12Cis 256 + 32 + 12 = 300. - Intel’s x86-64 manual calls 16 bits a word, 32 bits a doubleword and 64 bits a quadword, while AArch64 and RISC-V call 32 bits a word and 64 bits a doubleword; a byte is 8 bits on all three.
- An instruction states its access size:
byte,word,dwordorqword ptron x86-64,ldrb,ldrhandldrinto awor anxregister on AArch64, andlb,lh,lwandldon RISC-V, and the fixed-width encodings carry that size in a few bits a reader can find digit by digit. - A narrow register name views the low bits of one 64-bit register: writing
eaxorw0clears the upper 32 bits, writingaloraxkeeps them, and an RV64 W instruction fills them with copies of bit 31.
Terms in this lesson
- binary number
- A number written in base 2, with only the digits 0 and 1, each worth twice the digit to its right: 1 0010 1100 is 256 + 32 + 8 + 4 = 300.
- bit
- A single binary digit, 0 or 1, the smallest unit of data. Eight bits make a byte, and a 64-bit register holds 64 of them.
- byte
- Eight bits, the smallest unit of memory with its own address on x86-64, AArch64 and RISC-V. A byte holds 256 patterns, written 0x00 to 0xFF.
- doubleword
- Twice a word in the manual you are reading: 32 bits on x86-64, where assemblers write it dword, and 64 bits on AArch64 and RISC-V.
- hexadecimal
- Base 16, written with the digits 0 to 9 and A to F. Each digit stands for four bits, so 0x2C is the byte 0010 1100, which is 44; the prefix 0x marks a hexadecimal number in C, Python and most assemblers.
- nibble
- A group of four bits, half a byte. One hexadecimal digit stands for exactly one nibble, so a byte is written with two hexadecimal digits.
- word
- A size whose meaning depends on the manual: 16 bits in Intel's x86-64 manual, which keeps the register width of the 8086, but 32 bits on AArch64 and RISC-V. Give the size in bits or bytes next to the name.
References
- Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 1: Basic Architecture (sections 2.1.1, 3.4.1.1 and 4.1) (Intel Corporation)
- Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 2B: MOV and MOVZX (Intel Corporation)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): fundamental data types and registers (Arm Limited)
- The RISC-V Instruction Set Manual, Volume I: Introduction (XLEN and memory sizes) (RISC-V International)
- The RISC-V Instruction Set Manual, Volume I: RV64I Base Integer Instruction Set (RISC-V International)
- The RISC-V Instruction Set Manual, Volume I: RV32/64G instruction set listings (RISC-V International)
- RISC-V Technical Specifications (ratified ISA manuals) (RISC-V International)
- Learn the architecture: A64 Instruction Set Architecture Guide (registers, loads and stores) (Arm Limited)
- LLVM AArch64 backend: AArch64InstrFormats.td, the load and store encodings (The LLVM Project)
- LLVM AArch64 backend: AArch64ISelLowering.cpp, 32-bit operations and the high half (The LLVM Project)
- GATE 2027 syllabus: Computer Science and Information Technology (CS) (Indian Institute of Technology Madras)
- The Python Standard Library: Format Specification Mini-Language (Python Software Foundation)
- The Python Standard Library: Built-in Functions (bin, hex and int) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress