Assembly (x86-64, AArch64, RISC-V) Module 2 – Bits, bytes and numbers
Two's complement and sign extension
Negate by inverting and adding 1, tell carry from signed overflow, and widen bytes by zero or sign extension on x86-64, AArch64 and RISC-V, with recorded runs.
What you will learn
- Represent a negative integer in two's complement and state the range of an n-bit register
- Predict whether an addition overflows as an unsigned sum, as a signed sum, or as both
- Distinguish zero extension from sign extension and choose the load that does each on x86-64, AArch64 and RISC-V
- Explain why the same byte prints as 200 on one platform and as −56 on another
Before you start
What you’ll learn · Before you start
What you will learn
- Represent a negative integer in two's complement and state the range of an n-bit register
- Predict whether an addition overflows as an unsigned sum, as a signed sum, or as both
- Distinguish zero extension from sign extension and choose the load that does each on x86-64, AArch64 and RISC-V
- Explain why the same byte prints as 200 on one platform and as −56 on another
Before you start
On this page
Store 200 in a C char on a Mac with Apple silicon and print it as a number: the program prints −56, while the
compiler for Linux on AArch64 loads the same byte, 0xc8, as 200. Only the rule that reads it differs. two's complement: The way x86-64, AArch64 and RISC-V store signed integers: the top bit of an n-bit value is worth minus 2 to the power n − 1 and every other bit keeps its usual weight, so the byte 0xc8 is −56. A value is negated by inverting every bit and adding 1. is the rule that gives a pattern of bits a
negative reading: the top bit counts as minus its usual weight, so 0xc8 is −128 + 64 + 8 = −56, and a number is
negated by inverting every bit and adding 1. 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. keeps that reading
when a value moves into a wider register, by copying the top bit into every new bit above it.
Below, you negate numbers by hand and with programs you can change, tell an unsigned overflow from a signed one,
widen a byte both ways, read the extending loads of x86-64, AArch64 and RISC-V beside a recorded run, and see why the
sign of plain char is a choice each ABI makes. Sizes and register names come from
bits, bytes, words and hexadecimal.
Negating a number means inverting every bit and adding 1
Take 56, which is 0011 1000 in eight bits. Its negative has to be the 8-bit pattern that, added to 56, leaves 0 in
the register. Inverting every bit gives 1100 0111, which is 199, and 56 + 199 = 255: every bit set, one short of
256. Adding 1 gives 1100 1000, which is 200 as an unsigned integer: An integer type without negative values: every bit has its positive binary weight, so n bits hold 0 to 2 to the power n, minus 1: 0 to 255 for a byte., and
56 + 200 = 256, a 1 followed by eight zeros. An 8-bit register has no ninth bit to keep that 1 in, so it keeps
0000 0000. In eight bits, then, 200 behaves exactly like −56, and two’s complement makes that the official
reading of a signed integer: An integer type whose values can be negative. On x86-64, AArch64 and RISC-V it is stored in two's complement, so n bits hold −2 to the power n − 1 up to 2 to the power n − 1, minus 1: −128 to 127 for a byte.: bit 7 is worth −128 instead of +128, and every other
bit keeps its weight. Step through the two moves and the two checks:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Start from 56. 56 is 32 + 16 + 8, so its 8 bits are 0011 1000. Its negative has to be the 8-bit pattern that adds to 56 and leaves zero in the register.
- Flip every bit. Inverting gives 1100 0111, which is 199. Added to 56 it makes 1111 1111: every bit set, one short of 256.
- Add 1. Adding 1 gives 1100 1000, 0xc8, which reads as 200 unsigned. 56 + 200 = 256 = 2 to the power 8, the first number an 8-bit register cannot hold.
- Check by adding. 0011 1000 + 1100 1000 = 1 0000 0000. The carry out of bit 7 has no bit to go into, so the register keeps 0000 0000: x plus the new pattern is zero.
- Read it with the weights. In two's complement bit 7 is worth −128 instead of +128. 0xc8 has bits 7, 6 and 3 set: −128 + 64 + 8 = −56, the negative of 56.
The same rule fixes the range of every width. With the top bit worth , an n-bit register
holds signed values from to , and unsigned ones from 0 to
. In words: half of the patterns, those with the top bit set, are negative, and the other half
are zero and the positive values, so the negative side always has one value more. For eight bits that is −128 to
127 against 0 to 255. The program negates 56 the way the scene did, prints the result in both readings and lists
the four register widths. Press Edit, set x to another value from 0 to 255 and run it again:
# Two's complement in an 8-bit register: negate by inverting every bit and adding 1.
BITS = 8
MASK = (1 << BITS) - 1 # 0xff: keeps the low 8 bits, as an 8-bit register does
def pattern(n):
"""The 8 bits of n, with a space between the two nibbles."""
b = format(n & MASK, "08b")
return b[:4] + " " + b[4:]
x = 56 # change this to any value from 0 to 255
inverted = ~x & MASK
negated = (inverted + 1) & MASK
signed = int.from_bytes(bytes([negated]), "big", signed=True)
print(f"x {pattern(x)} {x}")
print(f"inverted {pattern(inverted)} {inverted}")
print(f"plus 1 {pattern(negated)} {negated} = 0x{negated:02x}")
print(f"x + that {pattern(x + negated)} the carry out of bit 7 is lost")
print(f"0x{negated:02x} read as a signed byte: {signed}")
edge = 0x80
print(f"negating 0x80 gives 0x{(~edge + 1) & MASK:02x} again: 8 bits hold -128 but not +128")
print()
print("bits unsigned range signed range")
for n in (8, 16, 32, 64):
top = 2**n - 1
low, high = -(2 ** (n - 1)), 2 ** (n - 1) - 1
print(f"{n:>4} 0 to {top:<30,} {low:,} to {high:,}") Output
x 0011 1000 56 inverted 1100 0111 199 plus 1 1100 1000 200 = 0xc8 x + that 0000 0000 the carry out of bit 7 is lost 0xc8 read as a signed byte: -56 negating 0x80 gives 0x80 again: 8 bits hold -128 but not +128 bits unsigned range signed range 8 0 to 255 -128 to 127 16 0 to 65,535 -32,768 to 32,767 32 0 to 4,294,967,295 -2,147,483,648 to 2,147,483,647 64 0 to 18,446,744,073,709,551,615 -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 negate.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 run confirms each step: 199 after inverting, 200, or 0xc8, after adding 1, and a sum of 0 once the ninth bit
is gone. It also shows the one value without a positive partner: negating 0x80 gives 0x80 again, because +128
does not fit in eight bits.
Inverting without adding 1
Flipping the bits of 56 gives 1100 0111, which two’s complement reads as −57, not −56: the inverted pattern is
always one below the negative. Add the 1, then check the result by adding it to the original, because only the
true negative leaves 0 in the register.
Every ISA in this track stores signed integers this way. Intel’s manual defines the signed integers of x86-64 as two’s complement values with the sign in bit 7, 15, 31 or 63, and since the edition known as C23 the C standard defines every signed integer type the same way, where earlier editions also allowed other representations.
Key idea
In two’s complement the top bit is worth minus its usual weight, so −x is the pattern that adds to x and leaves zero: invert every bit, then add 1. Eight bits hold −128 to 127, and every width has one more negative value than positive ones.
Checkpoint
Check: Negating in two's complement
One adder serves signed and unsigned values; only the overflow test differs
Add 0x64 to 0x64 in an 8-bit register and the result is 0xc8. Read as unsigned numbers, 100 + 100 = 200, which
is right. Read as signed numbers, 100 + 100 = −56, which is wrong, because 200 does not fit below 128. The adder did
one thing, a binary addition that drops a ninth bit, and both readings come from the same 8 bits. That is why
x86-64, AArch64 and RISC-V each have one add instruction for both kinds of integer: Intel’s manual lists ADD and SUB
among the instructions that work on unsigned and signed operands alike. What differs between the readings is the
test for an integer overflow: An arithmetic result that does not fit in the bits that hold it, so the register keeps only its low bits. For unsigned numbers it shows as a carry out of the top bit; for signed ones, as two inputs of one sign giving a result of the other sign., a result that did not fit:
- An unsigned sum overflows when it needs a ninth bit, which leaves the adder as a carry out of bit 7: 200 + 100 is 300, and the register keeps 44.
- A signed sum overflows when it falls outside −128 to 127, which shows as two inputs of the same sign giving a result of the other sign: 100 + 100 gives −56.
The program adds five pairs with one 8-bit adder, prints both readings and both tests, then checks every one of the
65,536 pairs of bytes. Add your own pairs to PAIRS and run it again:
# One 8-bit adder, two readings of the same result: the carry speaks for the unsigned
# reading, the overflow for the signed one.
MASK = 0xFF
def signed(b):
"""The signed (two's complement) reading of an 8-bit pattern."""
return b - 256 if b & 0x80 else b
PAIRS = [(20, 30), (100, 100), (200, 100), (0xC8, 0x38), (0x80, 0xFF)]
print("a b sum unsigned signed carry overflow")
for a, b in PAIRS:
total = a + b
s = total & MASK # the 8 bits the register keeps
carry = total > MASK # the unsigned sum needed a ninth bit
overflow = signed(a) + signed(b) != signed(s) # the signed sum does not fit
u_text = f"{a}+{b}={s}"
s_text = f"{signed(a)}+{signed(b)}={signed(s)}"
print(f"0x{a:02x} 0x{b:02x} 0x{s:02x} {u_text:<14} {s_text:<16} {'yes' if carry else 'no':<6} {'yes' if overflow else 'no'}")
# Over every pair of bytes: how often each reading goes wrong, and the top-bit rule
carries = overflows = rule_agrees = 0
for a in range(256):
for b in range(256):
s = (a + b) & MASK
over = signed(a) + signed(b) != signed(s)
carries += a + b > MASK
overflows += over
# rule: both inputs have the same top bit, and the result's top bit differs from it
rule = ((a ^ s) & (b ^ s) & 0x80) != 0
rule_agrees += rule == over
print()
print(f"pairs with a carry out (unsigned overflow): {carries:,} of 65,536")
print(f"pairs with signed overflow: {overflows:,} of 65,536")
print(f"top-bit rule agrees with the signed sum: {rule_agrees:,} of 65,536") Output
a b sum unsigned signed carry overflow 0x14 0x1e 0x32 20+30=50 20+30=50 no no 0x64 0x64 0xc8 100+100=200 100+100=-56 no yes 0xc8 0x64 0x2c 200+100=44 -56+100=44 yes no 0xc8 0x38 0x00 200+56=0 -56+56=0 yes no 0x80 0xff 0x7f 128+255=127 -128+-1=127 yes yes pairs with a carry out (unsigned overflow): 32,640 of 65,536 pairs with signed overflow: 16,384 of 65,536 top-bit rule agrees with the signed sum: 65,536 of 65,536
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_adder.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
Each reading fails in its own cases. 0xc8 + 0x64 carries out and is still right as a signed sum, −56 + 100 = 44;
0x64 + 0x64 carries nothing and is wrong as a signed sum; 0x80 + 0xff is wrong both ways. Across all 65,536
pairs, 32,640 carry out and 16,384 overflow as signed sums, and the sign rule agrees with the exact signed sum in all
65,536 of them. The three ISAs report the two tests in different ways:
| ISA | Unsigned overflow | Signed overflow | Which instructions record them |
|---|---|---|---|
| x86-64 | carry flag, CF | overflow flag, OF | add, sub and the other arithmetic instructions |
| AArch64 | carry flag, C | overflow flag, V | only the flag-setting forms, such as adds, subs and cmp |
| RISC-V | no flag: bltu compares the sum with an input |
no flag: a few compares and a branch | none |
Intel’s manual describes CF as the overflow condition of unsigned arithmetic and OF as that of signed arithmetic.
Arm’s A64 guide explains that an s at the end of a data-processing instruction makes it update the N, Z, C and V
flags. The RISC-V manual leaves overflow checks out of the instruction set and shows them as branches: add t0, t1, t2
followed by bltu t0, t1, overflow for an unsigned sum. MySmartCoPilot’s programmer calculator
works at a width and a signedness you choose: set it to signed 8-bit and type 100 + 100, and it shows −56 with a
note that 200 does not fit.
Key idea
One adder computes both readings. A carry out of the top bit says the unsigned sum did not fit; two inputs of one sign giving a result of the other sign say the signed sum did not fit.
Checkpoint
Check: Carry and signed overflow from one adder
Zero extension keeps the unsigned value, sign extension keeps the signed one
A byte loaded into a 64-bit register needs 56 more bits above it, and what goes into them decides which reading of
the byte survives. Take 0xc8 again. zero extension: Widening a value by filling every new bit above it with 0, so an unsigned number keeps its value: the byte 0xc8 (200) becomes 0x000000c8 in 32 bits. movzx, ldrb and lbu zero-extend. fills the new bits with 0 and
gives 0x00000000000000c8, which is 200 under both readings: the unsigned value is kept and the −56 is lost. Sign
extension fills them with copies of bit 7 and gives 0xffffffffffffffc8, which is −56 as a signed 64-bit number:
the signed value is kept, and read as unsigned the same bits are 18,446,744,073,709,551,560. A byte whose bit 7 is 0,
such as 0x38 or 0x7f, comes out the same both ways. Step through 0xc8 widened to 32 bits:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- One byte, two readings. The byte 0xc8 is 1100 1000. Read unsigned it is 200; read in two's complement it is −56. A 32-bit register needs 24 more bits above it.
- Fill with zeros. Zero extension puts 0 in bits 31 to 8: 0x000000c8. The unsigned reading, 200, survives, and the signed reading is now 200 as well.
- Fill with copies of bit 7. Sign extension copies bit 7, a 1, into bits 31 to 8: 0xffffffc8. The signed reading, −56, survives; read unsigned, the same 32 bits are 4,294,967,240.
- Both keep the byte. Cut either result back to 8 bits and 0xc8 comes back. Extension never touches the byte itself: it chooses which reading the wider value keeps.
Neither way changes the byte itself; each picks the reading that the wider value keeps. The program widens four
bytes both ways, prints the two 64-bit forms of 0xc8 and checks a way to sign-extend with arithmetic alone. Put your
own bytes in BYTES and run it again:
# Widening one byte to 32 bits: zero extension keeps the unsigned value,
# sign extension keeps the signed value, and both keep the byte itself.
BYTES = [0x38, 0x7F, 0x80, 0xC8] # try your own bytes, from 0x00 to 0xff
print("byte zero-extended sign-extended")
for byte in BYTES:
raw = bytes([byte])
u = int.from_bytes(raw, "little", signed=False) # as a zero-extending load reads it
s = int.from_bytes(raw, "little", signed=True) # as a sign-extending load reads it
print(f"0x{byte:02x} 0x{u & 0xFFFFFFFF:08x} = {u:<4} 0x{s & 0xFFFFFFFF:08x} = {s}")
b = 0xC8
s = int.from_bytes(bytes([b]), "little", signed=True)
print()
print(f"0x{b:02x} widened to 64 bits: 0x{b:016x} or 0x{s & (2**64 - 1):016x}")
print(f"low 8 bits of both: 0x{b & 0xFF:02x} and 0x{s & 0xFF:02x}")
# Sign extension without a library: flip bit 7, then subtract its weight
same = all((x ^ 0x80) - 0x80 == int.from_bytes(bytes([x]), "little", signed=True) for x in range(256))
print(f"(x ^ 0x80) - 0x80 equals the signed reading for all 256 bytes: {same}") Output
byte zero-extended sign-extended 0x38 0x00000038 = 56 0x00000038 = 56 0x7f 0x0000007f = 127 0x0000007f = 127 0x80 0x00000080 = 128 0xffffff80 = -128 0xc8 0x000000c8 = 200 0xffffffc8 = -56 0xc8 widened to 64 bits: 0x00000000000000c8 or 0xffffffffffffffc8 low 8 bits of both: 0xc8 and 0xc8 (x ^ 0x80) - 0x80 equals the signed reading for all 256 bytes: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extend.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
0x80 becomes 128 or −128 and 0xc8 becomes 200 or −56, while 0x38 and 0x7f stay 56 and 127 either way. The
last line is a check worth keeping: flipping bit 7 with ^ 0x80 and then subtracting 128 gives the signed value of
each of the 256 bytes, which is sign extension written as arithmetic. Python’s int.from_bytes does the same with
signed=True, and its documentation says that this argument chooses two’s complement.
Zero-extending a signed value
Widening −56 from a byte with zero extension puts 200 in the wider register: a positive number with the same low
byte. A test such as x < 0 then fails, and any sum with it is 256 too large. Widen a signed value with sign
extension and an unsigned one with zero extension, and choose by the type of the value, not by the value you
expect it to hold.
Checkpoint
Check: Zero extension and sign extension
Each ISA has a zero-extending and a sign-extending load
The hardware widens as part of a load, or in one instruction between registers, and each ISA names both choices.
The table gives the forms for a byte and for a 32-bit value; the loads read the byte at the address in rdi, x0
or a0:
| Widening | x86-64 | AArch64 | RV64 |
|---|---|---|---|
| byte, zero extension | movzx eax, byte ptr [rdi] |
ldrb w0, [x0] |
lbu a0, 0(a0) |
| byte, sign extension | movsx rax, byte ptr [rdi] |
ldrsb x0, [x0] |
lb a0, 0(a0) |
| 32 to 64 bits, zero extension | mov eax, edi |
mov w0, w0 |
lwu from memory, or two shifts |
| 32 to 64 bits, sign extension | movsxd rax, edi |
sxtw x0, w0 |
lw from memory, or sext.w a0, a0 |
The AArch64 column runs on the Mac that recorded this page. Each function in widen.s widens the byte 0xc8 one
way, and the C program prints all 64 bits of x0 after each:
S · asm/widen.s
// Four ways to widen the byte at [x0], and one 32-bit write, on Apple arm64.
// Every function leaves its result in x0, and the C program prints all 64 bits of it.
.text
.globl _load_ldrb_w, _load_ldrsb_w, _load_ldrsb_x, _sxtw_x, _write_w
.p2align 2
_load_ldrb_w:
ldrb w0, [x0] // zero-extend the byte into w0
ret
_load_ldrsb_w:
ldrsb w0, [x0] // sign-extend the byte to 32 bits, into w0
ret
_load_ldrsb_x:
ldrsb x0, [x0] // sign-extend the byte to all 64 bits
ret
_sxtw_x:
sxtw x0, w0 // copy bit 31 of w0 into bits 63 to 32 of x0
ret
_write_w:
mov w0, w0 // a 32-bit write of the low half to itself
ret C · asm/widen_main.c
/* Calls each function of widen.s on the byte 0xc8 and prints the whole 64-bit x0 it left. */
#include <stdint.h>
#include <stdio.h>
uint64_t load_ldrb_w(const uint8_t *p);
uint64_t load_ldrsb_w(const uint8_t *p);
uint64_t load_ldrsb_x(const uint8_t *p);
uint64_t sxtw_x(uint64_t x);
uint64_t write_w(uint64_t x);
static void show(const char *what, uint64_t x0) {
printf("%-16s x0 = 0x%016llx = %lld\n", what, (unsigned long long)x0, (long long)x0);
}
int main(void) {
const uint8_t byte = 0xc8; /* the byte that (char)200 stores */
uint64_t wide = load_ldrsb_x(&byte);
show("ldrb w0, [x0]", load_ldrb_w(&byte));
show("ldrsb w0, [x0]", load_ldrsb_w(&byte));
show("ldrsb x0, [x0]", wide);
show("sxtw x0, w0", sxtw_x(load_ldrsb_w(&byte)));
show("mov w0, w0", write_w(wide));
return 0;
} """Four ways to widen the byte 0xc8 on AArch64, and one 32-bit write, run natively; each line prints all of x0."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# Assemble widen.s with the C program that calls each function on the byte 0xc8, then run it
clang -o widen asm/widen_main.c asm/widen.s
./widen; echo "exit=$?"
""") Output
# Assemble widen.s with the C program that calls each function on the byte 0xc8, then run it $ clang -o widen asm/widen_main.c asm/widen.s $ ./widen; echo "exit=$?" ldrb w0, [x0] x0 = 0x00000000000000c8 = 200 ldrsb w0, [x0] x0 = 0x00000000ffffffc8 = 4294967240 ldrsb x0, [x0] x0 = 0xffffffffffffffc8 = -56 sxtw x0, w0 x0 = 0xffffffffffffffc8 = -56 mov w0, w0 x0 = 0x00000000ffffffc8 = 4294967240 exit=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 widen_session.py
ldrb gave 200 and ldrsb x0 gave −56, as the table promises, and sxtw turned the 32-bit −56 into the 64-bit
one. ldrsb w0 is the line to look at twice: it sign-extended the byte only up to bit 31, 0xffffffc8, and the write
to w0 then cleared bits 63 to 32, so x0 holds 0x00000000ffffffc8, which is 4,294,967,240 as a 64-bit number.
Arm’s A64 guide states both rules: an s in the load sign-extends as far as the size of the destination register,
and every write to a w register clears the top half of the x register. mov w0, w0 uses the second rule alone
to zero-extend a 32-bit value.
Sign-extending to 32 bits when the value is used as 64
ldrsb w1, [x0] followed by add x2, x2, x1 adds 4,294,967,240 for the byte 0xc8, not −56, because x1 was
only sign-extended to 32 bits. Load with ldrsb x1, [x0], or follow the 32-bit load with sxtw x1, w1, whenever
the value takes part in 64-bit arithmetic such as an address.
x86-64 and RV64 follow the same pattern. On x86-64 a write to a 32-bit register clears the upper half, as Intel’s
manual says, so movsx eax, byte ptr [rdi] stops at bit 31 just as ldrsb w0 does, and movsxd sign-extends 32
bits to 64. RV64 keeps every 32-bit value sign-extended in its 64-bit registers, even an unsigned one: its manual
makes lw and the W instructions, such as addw, copy bit 31 upwards, and gives lwu for a 32-bit value that must
stay zero-extended. sext.w is another name for addiw a0, a0, 0. The same functions for those two ISAs are Linux
sources, shown here and not run:
ASM · asm/widen_x86.asm
; The same widenings on x86-64 Linux, for NASM (shown on the page, not run on it).
; Each function leaves its 64-bit result in rax.
section .text
global load_movzx, load_movsx_32, load_movsx_64, sext_32_to_64, load_mov_al
load_movzx:
movzx eax, byte [rdi] ; zero-extend the byte; writing eax also zeroes bits 63 to 32
ret
load_movsx_32:
movsx eax, byte [rdi] ; sign-extend to 32 bits; writing eax zeroes bits 63 to 32
ret
load_movsx_64:
movsx rax, byte [rdi] ; sign-extend the byte to all 64 bits
ret
sext_32_to_64:
movsxd rax, edi ; copy bit 31 of edi into bits 63 to 32 of rax
ret
load_mov_al:
mov al, byte [rdi] ; an 8-bit write: bits 63 to 8 of rax keep whatever they held
ret S · asm/widen_rv64.s
# The same widenings on RV64 Linux, for GNU as (shown on the page, not run on it).
# Each function leaves its 64-bit result in a0.
.text
.globl load_lbu, load_lb, load_lwu, load_lw, sext_32_to_64
load_lbu:
lbu a0, 0(a0) # zero-extend the byte to 64 bits
ret
load_lb:
lb a0, 0(a0) # sign-extend the byte to 64 bits
ret
load_lwu:
lwu a0, 0(a0) # zero-extend a 32-bit word to 64 bits
ret
load_lw:
lw a0, 0(a0) # sign-extend a 32-bit word to 64 bits
ret
sext_32_to_64:
sext.w a0, a0 # addiw a0, a0, 0: copy bit 31 into bits 63 to 32
ret Key idea
Choose the extending load by the type of the value, zero-extending for unsigned and sign-extending for signed, and check how far it extends: on AArch64 and x86-64 a 32-bit destination stops the extension at bit 31 and clears the rest.
Checkpoint
Check: The extending loads of each ISA
Plain char is signed on some ABIs and unsigned on others
C has three character types, and the standard leaves the sign of plain char to each implementation: section 6.2.5
of C23 says it behaves like either signed char or unsigned char. Each ABI then picks one. The x86-64 psABI lists
char as a signed byte, and Apple’s ARM64 document says that on its platforms char is a signed type. AAPCS64, the
procedure call standard that Linux on AArch64 follows, maps char to an unsigned byte, and the RISC-V psABI makes it
unsigned too. The compiler picks the load that matches. The session asks clang which instruction widens one plain
char for four targets, compiling only, then builds and runs (char)200 twice, the second time with
-funsigned-char:
C (char_load.c) · asm/char_load.c
/* Widen one plain char to int: the compiler must pick a zero- or a sign-extending load. */
int widen(const char *p) { return *p; } C (char200.c) · asm/char200.c
/* Store 200 in a plain char and print it as an int. */
#include <stdio.h>
int main(void) {
char c = (char)200; /* the byte 0xc8 */
printf("(char)200 = %d\n", c);
return 0;
} """Which load each compiler picks for a plain char, for four ABIs, and what (char)200 prints on the host."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run(r"""
# Compile widen() for four targets and keep only its load instruction (nothing runs yet)
clang --target=arm64-apple-macos -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' '
clang --target=aarch64-linux-gnu -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' '
clang --target=x86_64-linux-gnu -masm=intel -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' '
clang-23 --target=riscv64-linux-gnu -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' '
# Build and run (char)200 for the host, then again with char made unsigned
clang -o char200 asm/char200.c
./char200
clang -funsigned-char -o char200u asm/char200.c
./char200u
""") Output
# Compile widen() for four targets and keep only its load instruction (nothing runs yet) $ clang --target=arm64-apple-macos -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' ' ldrsb w0, [x0] $ clang --target=aarch64-linux-gnu -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' ' ldrb w0, [x0] $ clang --target=x86_64-linux-gnu -masm=intel -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' ' movsx eax, byte ptr [rdi] $ clang-23 --target=riscv64-linux-gnu -O2 -S -o - asm/char_load.c | grep -E '^[[:space:]]+(ldr|mov|lb)' | tr -s '\t' ' ' lbu a0, 0(a0) # Build and run (char)200 for the host, then again with char made unsigned $ clang -o char200 asm/char200.c $ ./char200 (char)200 = -56 $ clang -funsigned-char -o char200u asm/char200.c $ ./char200u (char)200 = 200
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 loads_session.py
| Platform | Plain char is | Load the compiler picked |
|---|---|---|
| macOS on Apple silicon | signed | ldrsb w0, [x0] |
| Linux on x86-64 | signed | movsx eax, byte ptr [rdi] |
| Linux on AArch64 | unsigned | ldrb w0, [x0] |
| Linux on RISC-V | unsigned | lbu a0, 0(a0) |
The first two loads sign-extend, so 0xc8 becomes −56; the last two zero-extend, so the same byte becomes 200. On
the Mac the program printed −56, and with -funsigned-char, an option that clang documents for exactly this choice,
it printed 200. The byte and the processor stayed the same; the type of char changed.
Where the sign matters, say which one you mean. Code that treats bytes as numbers, such as a checksum, a hash or a
table indexed by a byte, should use unsigned char or uint8_t. The program adds up the bytes of the UTF-8 word
café both ways; Python’s struct module reads a byte as a signed char with the format b and as an unsigned char
with B:
# One byte, two ABIs: plain char is a signed byte on some platforms and an unsigned byte
# on others. struct reads a byte as signed char ("b") or as unsigned char ("B").
import struct
byte = b"\xc8" # the byte that (char)200 stores
print("read as signed char: ", struct.unpack("b", byte)[0])
print("read as unsigned char:", struct.unpack("B", byte)[0])
# The same choice inside a checksum that adds up the bytes of a text
text = "café".encode("utf-8")
signed_sum = sum(struct.unpack(f"{len(text)}b", text))
unsigned_sum = sum(struct.unpack(f"{len(text)}B", text))
high = sum(1 for x in text if x >= 0x80)
print()
print("bytes of café:", text.hex(" "))
print("sum as signed chars: ", signed_sum)
print("sum as unsigned chars:", unsigned_sum)
print(f"difference: {unsigned_sum - signed_sum} = 256 x {high} bytes from 0x80 up") Output
read as signed char: -56 read as unsigned char: 200 bytes of café: 63 61 66 c3 a9 sum as signed chars: 150 sum as unsigned chars: 662 difference: 512 = 256 x 2 bytes from 0x80 up
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 char_byte.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 two bytes of é, c3 and a9, are above 0x7f, so the signed sum is 150 and the unsigned sum is 662: 512 apart,
256 for each of them. A checksum written with plain char gives one of those answers on macOS and the other on Linux
on AArch64, for the same file.
Checkpoint
Check: The sign of plain char
Practise
The exercise asks for both widenings as Python functions on whole 64-bit patterns, the way a register holds them.
Its sample tests try the bytes 0x00, 0x7f, 0x80 and 0xff, inputs wider than a byte, and both ends of the
64-bit signed range:
Exercise · Easy · Python
Widen a byte the way the load instructions do
A byte load takes the low byte of a value in memory and fills a 64-bit register with it. Write three Python functions that give the register's 64 bits the way the hardware does, as a whole number from 0 to 2⁶⁴ − 1:
zext8(x): what a zero-extending load leaves (ldrbon AArch64,lbuon RISC-V,movzxon x86-64). Only the low 8 bits ofxcount; every bit above them is 0.sext8(x): what a sign-extending load to 64 bits leaves (ldrsb x0,lb,movsx rax). Only the low 8 bits ofxcount, and bit 7 of that byte is copied into bits 63 to 8.as_signed64(r): the value of a 64-bit register patternrread as a signed (two's complement) integer, from −2⁶³ to 2⁶³ − 1.
So sext8(0xc8) is 0xffffffffffffffc8, as_signed64(sext8(0xc8)) is −56 and as_signed64(zext8(0xc8)) is 200.
Use integer arithmetic and comparisons only: no int.from_bytes or struct, so that each step is one you could write as an instruction. The sample tests try the bytes 0x00, 0x7f, 0x80 and 0xff, values wider than a byte, and the edges of the 64-bit signed range.
Starter code · widen.py
def zext8(x):
"""The 64-bit register after a zero-extending byte load of the low byte of x."""
return 0 # replace with your code
def sext8(x):
"""The 64-bit register after a sign-extending byte load of the low byte of x."""
return 0 # replace with your code
def as_signed64(r):
"""The 64-bit pattern r read as a two's complement integer."""
return 0 # replace with your code The sample tests · test_widen.py
"""Sample tests: the four bytes of the track's harness, wider inputs and the edges of the signed range."""
from widen import as_signed64, sext8, zext8
ONES_ABOVE_THE_BYTE = 0xFFFF_FFFF_FFFF_FF00
def test_zext8_keeps_the_unsigned_value():
assert zext8(0x00) == 0x00
assert zext8(0x7F) == 0x7F
assert zext8(0x80) == 0x80
assert zext8(0xFF) == 0xFF
def test_zext8_reads_only_the_low_byte():
assert zext8(0x1C8) == 0xC8
assert zext8(0xFFFF_FFFF_FFFF_FFC8) == 0xC8
def test_sext8_copies_bit_7_upwards():
assert sext8(0x00) == 0x00
assert sext8(0x7F) == 0x7F
assert sext8(0x80) == ONES_ABOVE_THE_BYTE | 0x80
assert sext8(0xFF) == 0xFFFF_FFFF_FFFF_FFFF
def test_sext8_reads_only_the_low_byte():
assert sext8(0x1C8) == ONES_ABOVE_THE_BYTE | 0xC8
assert sext8(0x37F) == 0x7F
def test_as_signed64_reads_bit_63_as_minus_2_to_the_63():
assert as_signed64(0) == 0
assert as_signed64(0x7FFF_FFFF_FFFF_FFFF) == 2**63 - 1
assert as_signed64(0x8000_0000_0000_0000) == -(2**63)
assert as_signed64(0xFFFF_FFFF_FFFF_FFC8) == -56
assert as_signed64(0x0000_0000_FFFF_FFC8) == 4_294_967_240
def test_the_byte_0xc8_both_ways():
assert as_signed64(sext8(0xC8)) == -56
assert as_signed64(zext8(0xC8)) == 200 A hint
Start every function by keeping only the bits the load reads: x & 0xff is the byte. For sext8, look at bit 7 of that byte: when it is 0 the result is the byte itself, and when it is 1 the 56 bits above it are all 1s, which is the same as adding 0xffffffffffffff00. For as_signed64, a pattern with bit 63 set stands for the pattern minus 2⁶⁴.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Fifteen self-check questions follow, from the weight of bit 7 to a pointer moved by a byte offset. They start with the idea you have answered least well, and each one is explained the moment you check it:
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: the byte 0x80 goes into a 64-bit register once by zero extension and once by sign extension. What does each leave, and which reading of the byte does each keep?
Zero extension leaves 0x0000000000000080, which is 128 whether the register is read as signed or as unsigned, so
it keeps the unsigned reading of the byte. Sign extension copies bit 7, a 1, into all 56 new bits and leaves
0xffffffffffffff80, which is −128 as a signed 64-bit number, so it keeps the signed reading; read as unsigned, the
same bits are 18,446,744,073,709,551,488. The low byte is 0x80 in both, because extension never touches the bits
it starts from. A strong answer also names the loads, movzx or movsx on x86-64, ldrb or ldrsb on AArch64 and
lbu or lb on RISC-V, and says that the choice follows the type of the value, unsigned char or signed char,
not the number the programmer hopes to see.
The module’s bank adds two questions that put these rules into working code: a byte-copy loop whose end test depends
on the sign of char, and an overflow check that the compiler removes. Open a row for the first step and the
follow-up questions:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A small C tool copies its input with
char c; while ((c = getchar()) != EOF) putchar(c);. Built for Linux on AArch64 it never stops; built for macOS it stops early on some files. Explain both failures from the code each compiler generates, and give the fix. -
Mid to senior
A teammate guards a signed
intaddition in C withif (a + b < a) return ERR_OVERFLOW;. At -O2 the guard never fires for two large positive inputs, and the disassembly shows no comparison of the sum, only a test of the sign bit of b. What did the compiler do, and how should the check be written?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do you negate a number in two's complement?
Invert every bit, then add 1. The result is the pattern that adds to the original and leaves zero in the register.
-
What is the byte 0xc8 as a signed number, and why?
-56. In two's complement the top bit of an n-bit value is worth minus 2 to the power n-1, so 0xc8 is -128 + 72 = -56. Read as unsigned, the same byte is 200.
-
Why does negating -128 in an 8-bit register give -128 again?
Eight bits hold -128 to 127: the negatives outnumber the positives by one, so +128 has no pattern. Inverting 0x80 and adding 1 lands on 0x80 again.
-
One adder gives the sum for signed and unsigned numbers. How do you tell that each reading did not fit?
For unsigned numbers, a carry out of the top bit. For signed numbers, two inputs of the same sign giving a result of the other sign. x86-64 records them in CF and OF, AArch64's flag-setting instructions in C and V, and RISC-V code checks them with branches.
-
The byte 0x80 is widened to 64 bits by zero extension and by sign extension. What does each give?
Zero extension gives 0x0000000000000080, which is 128 and keeps the unsigned reading. Sign extension copies bit 7 into every new bit and gives 0xffffffffffffff80, which is -128 and keeps the signed reading.
-
Which loads zero-extend and which sign-extend a byte on each ISA?
movzx and movsx on x86-64, ldrb and ldrsb on AArch64, lbu and lb on RISC-V. Pick by the type of the value, and remember that a 32-bit destination on AArch64 and x86-64 stops the extension at bit 31 and clears the rest.
-
Is plain char signed or unsigned?
The ABI decides. It is signed on x86-64 and Apple silicon but unsigned on Linux for AArch64 and RISC-V, so code that treats bytes as numbers should say unsigned char or uint8_t.
Key takeaways
- Two’s complement gives the top bit of an n-bit value the weight minus 2 to the power n − 1, so
0xc8is −56 in eight bits, and a value is negated by inverting every bit and adding 1. - An n-bit register holds one more negative value than positive ones, −128 to 127 for a byte, so negating the most negative value gives it back unchanged.
- One adder serves both readings: a carry out of the top bit means the unsigned sum did not fit, and two inputs of one sign giving a result of the other sign mean the signed sum did not fit, which x86-64 records in CF and OF, AArch64’s flag-setting instructions in C and V, and RISC-V code checks with branches.
- Zero extension fills the new bits with 0 and keeps the unsigned value, sign extension copies the top bit and keeps
the signed value, and each ISA has a load for each, such as
ldrbandldrsb, with a 32-bit destination stopping the extension at bit 31. - The sign of plain
charis the ABI’s choice, signed on x86-64 and Apple silicon and unsigned on Linux AArch64 and RISC-V, so code that treats bytes as numbers should declare themunsigned charoruint8_t.
Terms in this lesson
- integer overflow
- An arithmetic result that does not fit in the bits that hold it, so the register keeps only its low bits. For unsigned numbers it shows as a carry out of the top bit; for signed ones, as two inputs of one sign giving a result of the other sign.
- 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.
- signed integer
- An integer type whose values can be negative. On x86-64, AArch64 and RISC-V it is stored in two's complement, so n bits hold −2 to the power n − 1 up to 2 to the power n − 1, minus 1: −128 to 127 for a byte.
- two's complement
- The way x86-64, AArch64 and RISC-V store signed integers: the top bit of an n-bit value is worth minus 2 to the power n − 1 and every other bit keeps its usual weight, so the byte 0xc8 is −56. A value is negated by inverting every bit and adding 1.
- unsigned integer
- An integer type without negative values: every bit has its positive binary weight, so n bits hold 0 to 2 to the power n, minus 1: 0 to 255 for a byte.
- zero extension
- Widening a value by filling every new bit above it with 0, so an unsigned number keeps its value: the byte 0xc8 (200) becomes 0x000000c8 in 32 bits. movzx, ldrb and lbu zero-extend.
References
- Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 1 (sections 3.4.1.1, 3.4.3.1, 4.2.1, 7.3.1.6 and 7.3.1.7) (Intel Corporation)
- Learn the architecture: A64 Instruction Set Architecture Guide (zero and sign extension, condition codes) (Arm Limited)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): fundamental data types (Arm Limited)
- Writing ARM64 code for Apple platforms (Apple Inc.)
- The RISC-V Instruction Set Manual, Volume I: RV64I Base Integer Instruction Set (RISC-V International)
- The RISC-V Instruction Set Manual, Volume I: RV32I Base Integer Instruction Set (overflow checks) (RISC-V International)
- RISC-V ELF psABI: calling convention and C type details (RISC-V International)
- System V Application Binary Interface, AMD64 Architecture Processor Supplement (x86-64 psABI project)
- N3220, working draft of ISO/IEC 9899:2024 (C23), 6.2.5 Types and 6.2.6.2 Integer types (ISO/IEC JTC1/SC22/WG14)
- Clang command line argument reference: -fsigned-char and -funsigned-char (The LLVM Project)
- The Python Standard Library: Built-in Types (int.from_bytes) (Python Software Foundation)
- The Python Standard Library: struct, interpret bytes as packed binary data (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress