Brightness, reading palette, text size, comments and reading sound.Adds page background, hover cards, button corners, code colours, width and layout.Everything, including diagram styling, motion, inline code, depth and fine details.
Reading sound Off
Sound starts off. Play when you want it.
Original sounds, released into the public domain under CC0.
Margin comments
Hide author and reader comments without deleting them.
Page brightness
Lesson text size
Reading palette
More options
Reader finish
Modern adds semantic colours and refined controls. Soft depth adds frost and shadows.
Page background
Hover cards
Definitions, examples, tips, and lesson summaries that open over words and links. Off also removes the underlines.
Chat button
Which corner the button for asking questions about the book sits in. Top corners stop short of the lesson list and contents; at the bottom right it sits under the Notes button. It takes the colour of the chapter you are reading.
Notes button
Where the 📝 Notes button sits on lessons. Hidden leaves a thin tab on the edge; Alt + N brings it back.
Settings panel
Popover opens under the button. Side sheet slides in from the right with a dimmed page behind it.
Sidebar and header contrast
Original keeps the dark lesson list and header. Unified tints them to match the page and palette.
Reading width
How wide the lesson text column is. Standard is about 70 characters a line.
Table of contents
Light or dark for the “On this page” panel on the right, whatever the page is.
Lesson spacing
Code brightness
Syntax colours
Moving effects
System respects reduced motion while still advancing steps. On Play enables moving effects when you start a diagram. Off keeps manual steps.
Fine-tuning
Depth
Flat is the original. Soft adds faint shadows to diagram boxes, cards and buttons and lifts them slightly on hover.
Inline code
Classic keeps the badge look. Soft quiets the background and border for paragraphs full of keywords.
Floating buttons
Docked shows the Notes and chat buttons normally. Minimal makes them small icons that fade until you point at them.
Gradients
Use flat colours for headings, cards and diagram backgrounds.
Diagram background
Changes diagram surfaces; labels and role colours remain readable.
Diagram boxes
Heading rectangles
Diagram label backgrounds
Diagram frames
Diagram size
Page grid
Time per step
Semantic colours
Ligatures
What the colours mean
Terms, inputs, and data
Code and processing
Results and tips
Cautions
These colours follow your theme; their meanings stay the same. Data tables and diagram explanations use the information colour; code panels use processing. The lesson header and navigation identify its chapter. Correct answers are green; errors are red.
Alt + T palette · Alt + D page · Alt + C code · Alt + S syntax · Alt + H semantic · Alt + L ligatures · Alt + N lesson list · Alt + O on this page
13. Physical Memory, Machines, and Hardware — print chapter
Game Hacking Academy
Chapter 13: Physical Memory, Machines, and Hardware
The kernel boundary also determines how devices reach memory. A process sees
virtual addresses; a device moves bytes through a physical-memory path with
its own mappings and access permissions. Understanding both maps makes an offline capture
interpretable without assuming that a game address is a RAM offset.
DMA means direct memory access. A capable device can transfer data without asking the CPU to copy each byte. Storage, networking, graphics, and other high-throughput devices use DMA because involving the CPU in every tiny move would waste time.
Here DMA refers to device transfers. It is different from dynamically
allocating memory in a program, where the program asks its allocator for space
and receives an address. The shared word memory does not make those the same
operation.
DMA is not automatically a cheat or an attack. It is a hardware feature. Security problems appear when a device can reach memory it should not reach, or when software trusts a device too much.
This chapter uses ordinary offline capture files from an owned machine, virtual machine, or synthetic test. It does not configure DMA hardware, install a kernel driver, disable an IOMMU, access a live game, or bypass anti-cheat.
That describes the CPU’s complete decision. The small offline walker in
Lesson 13.2 answers a narrower question: where do the recorded, present
entries lead? It checks presence and file bounds but does not emulate every
read, write, execute, and user/supervisor permission check the CPU would make.
A physical result alone is therefore not proof that a live process could read
or write the address.
Two processes may both use virtual address 0x1000 because they supply different
page-table roots. The access type matters too: a mapping may permit a read but reject
a write or instruction fetch. A virtual address by itself therefore does not name
a location: the address space and the mapping’s permissions are part of its meaning.
The same virtual address in two processesBoth processes use the number 0x1000. Which memory that means depends on the page-table root in use.
1Both processes use 0x1000
Process A
Virtual address
0x1000
Process B
Virtual address
0x1000
The numbers are identical.
2Each has its own page-table root
Process A
Virtual address
0x1000
↓ translated with
Page-table root
root A
Process B
Virtual address
0x1000
↓ translated with
Page-table root
root B
The CPU translates each address with the root of the running process.
3The same number reaches different physical memory
Process A
Virtual address
0x1000
↓ translated with
Page-table root
root A
↓ lands in
Physical memory
physical page A
Process B
Virtual address
0x1000
↓ translated with
Page-table root
root B
↓ lands in
Physical memory
physical page B
Physical page A and physical page B are different places, labelled symbolically.
All 3 steps shown
Both processes use the number 0x1000. Which memory that means depends on the page-table root in use.
It is like asking for apartment 1000 without naming the building. The apartment
number is useful only after the building is chosen; likewise, 0x1000 is
useful only with the correct page-table root. The analogy is limited: page
tables can remap or remove a page while a program runs, and they separately
record whether a read, write, or instruction fetch is allowed. The diagram
below shows the same virtual number reaching two different physical places.
An offline physical capture contains bytes arranged by physical address. A debugger’s pointer such as 0x00007FF6_12341000 is virtual. To connect them, the reader needs the correct page-table root for that address space.
The idea transfers to a guest virtual machine in Lesson 13.3: a guest’s
virtual address first needs its guest page tables, and the resulting guest
physical address needs a second translation through the hypervisor’s map.
Neither stage can be skipped just because both addresses are written as
numbers.
The address-space root changes the answer. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
select the address-space root
split virtual page and byte offset
look up the page in that root’s map
reject if the entry is absent
physical = frame * 4096 + offset
report the mapping and its owner
Result: root A maps the virtual address to 0x701A. Choosing another valid root can give a different valid answer.
Before translating one page, identify the architecture and address-space
configuration. Microsoft’s default 32-bit Windows map
gives a 32-bit process on 32-bit Windows a lower 2 GiB user range
(0x00000000–0x7FFFFFFF) and an upper 2 GiB system range
(0x80000000–0xFFFFFFFF). Those are address ranges, not amounts of installed
or resident RAM. Modules, heaps, stacks, mapped files, and free holes occupy
parts of the user range rather than filling it uniformly.
Where a 32-bit Windows program’s addresses goHeights are shares of the 4 GiB (232 byte) address space, lowest address at the top. Point at a part, or tab to it, to read what it is.
The ordinary 32-bit Windows layout.
0x00000000
0x00010000
Placed inside it, positions illustrative:
0x7FFF0000
0x80000000
0xFFFFFFFF
Reserved low 64 KiB0x00000000 to 0x0000FFFF
The first 64 KiB are never usable. A null pointer, or a small offset from one, lands here and causes an access violation. Windows reports 0x00010000 as the lowest address a program may use. Size: 0x0000FFFF − 0x00000000 + 1 = 65,536 bytes (64 KiB).
User space0x00010000 to 0x7FFEFFFF
Everything the program itself can map: its image, DLLs, heaps, stacks and mapped files, with free holes between them. This is an address range, not an amount of RAM in use. Size: 0x7FFEFFFF − 0x00010000 + 1 = 2,147,352,576 bytes (2 GiB − 128 KiB).
Reserved top 64 KiB0x7FFF0000 to 0x7FFFFFFF
The last 64 KiB below the boundary stay inaccessible too. Windows reports the address just before it as the highest address the program may use. Size: 0x7FFFFFFF − 0x7FFF0000 + 1 = 65,536 bytes (64 KiB).
System space0x80000000 to 0xFFFFFFFF
Reserved for the operating system: the kernel, drivers and kernel data. User-mode code cannot read or write here, and a try is an access violation. Size: 0xFFFFFFFF − 0x80000000 + 1 = 2,147,483,648 bytes (2 GiB).
Program imageposition illustrative
The .exe is mapped here. 0x00400000 is the linker’s default preferred base for a 32-bit executable; an image built with /DYNAMICBASE can be placed elsewhere by ASLR.
Heapsposition illustrative
Memory from the process heap and from VirtualAlloc comes from whatever free addresses exist, so heaps are scattered and their addresses change from run to run.
Thread stacksposition illustrative
Every thread has its own stack, commonly 1 MiB reserved by default. It is placed when the thread starts and grows toward lower addresses.
DLLsposition illustrative
Each loaded DLL, such as kernel32.dll, is mapped into user space. With ASLR, Windows chooses the base, and it can differ between boots.
TEB and PEBposition illustrative
The Thread Environment Block and Process Environment Block are small system structures mapped into user space. On 32-bit x86 the FS segment register points at the current thread’s TEB.
On 32-bit Windows booted with the 4-gigabyte-tuning option, a program built as large-address-aware may use 3 GiB.
0x00000000
0x00010000
Placed inside it, positions illustrative:
0xBFFF0000
0xC0000000
0xFFFFFFFF
Reserved low 64 KiB0x00000000 to 0x0000FFFF
The first 64 KiB are never usable. A null pointer, or a small offset from one, lands here and causes an access violation. Windows reports 0x00010000 as the lowest address a program may use. Size: 0x0000FFFF − 0x00000000 + 1 = 65,536 bytes (64 KiB).
User space0x00010000 to 0xBFFEFFFF
Everything the program itself can map: its image, DLLs, heaps, stacks and mapped files, with free holes between them. This is an address range, not an amount of RAM in use. Size: 0xBFFEFFFF − 0x00010000 + 1 = 3,221,094,400 bytes (3 GiB − 128 KiB).
Reserved top 64 KiB0xBFFF0000 to 0xBFFFFFFF
The last 64 KiB below the boundary stay inaccessible too. Windows reports the address just before it as the highest address the program may use. Size: 0xBFFFFFFF − 0xBFFF0000 + 1 = 65,536 bytes (64 KiB).
System space0xC0000000 to 0xFFFFFFFF
Reserved for the operating system: the kernel, drivers and kernel data. User-mode code cannot read or write here, and a try is an access violation. Size: 0xFFFFFFFF − 0xC0000000 + 1 = 1,073,741,824 bytes (1 GiB).
Program imageposition illustrative
The .exe is mapped here. 0x00400000 is the linker’s default preferred base for a 32-bit executable; an image built with /DYNAMICBASE can be placed elsewhere by ASLR.
Heapsposition illustrative
Memory from the process heap and from VirtualAlloc comes from whatever free addresses exist, so heaps are scattered and their addresses change from run to run.
Thread stacksposition illustrative
Every thread has its own stack, commonly 1 MiB reserved by default. It is placed when the thread starts and grows toward lower addresses.
DLLsposition illustrative
Each loaded DLL, such as kernel32.dll, is mapped into user space. With ASLR, Windows chooses the base, and it can differ between boots.
TEB and PEBposition illustrative
The Thread Environment Block and Process Environment Block are small system structures mapped into user space. On 32-bit x86 the FS segment register points at the current thread’s TEB.
The 32-bit program sees all 4 GiB; the 64-bit kernel lives elsewhere, outside this 32-bit view.
0x00000000
0x00010000
Placed inside it, positions illustrative:
0xFFFF0000
0xFFFFFFFF
Reserved low 64 KiB0x00000000 to 0x0000FFFF
The first 64 KiB are never usable. A null pointer, or a small offset from one, lands here and causes an access violation. Windows reports 0x00010000 as the lowest address a program may use. Size: 0x0000FFFF − 0x00000000 + 1 = 65,536 bytes (64 KiB).
User space0x00010000 to 0xFFFEFFFF
Everything the program itself can map: its image, DLLs, heaps, stacks and mapped files, with free holes between them. This is an address range, not an amount of RAM in use. Size: 0xFFFEFFFF − 0x00010000 + 1 = 4,294,836,224 bytes (4 GiB − 128 KiB).
Reserved top 64 KiB0xFFFF0000 to 0xFFFFFFFF
The last 64 KiB below the boundary stay inaccessible too. Windows reports the address just before it as the highest address the program may use. Size: 0xFFFFFFFF − 0xFFFF0000 + 1 = 65,536 bytes (64 KiB).
Program imageposition illustrative
The .exe is mapped here. 0x00400000 is the linker’s default preferred base for a 32-bit executable; an image built with /DYNAMICBASE can be placed elsewhere by ASLR.
Heapsposition illustrative
Memory from the process heap and from VirtualAlloc comes from whatever free addresses exist, so heaps are scattered and their addresses change from run to run.
Thread stacksposition illustrative
Every thread has its own stack, commonly 1 MiB reserved by default. It is placed when the thread starts and grows toward lower addresses.
DLLsposition illustrative
Each loaded DLL, such as kernel32.dll, is mapped into user space. With ASLR, Windows chooses the base, and it can differ between boots.
TEB and PEBposition illustrative
The Thread Environment Block and Process Environment Block are small system structures mapped into user space. On 32-bit x86 the FS segment register points at the current thread’s TEB.
The two big boundaries are real and drawn to scale; the 64 KiB reserved ends are drawn wider than their true share so they stay visible. Where the image, heaps, stacks and DLLs actually sit changes with ASLR and allocation history, so they are not placed. A native 64-bit process has a different map.
ASLR and allocation history can move those user regions; Microsoft describes
the image-randomization behavior under DYNAMICBASE.
The limits table
also gives the exceptions: 4GT plus a large-address-aware image can extend a
32-bit process to 3 GiB on 32-bit Windows; on 64-bit Windows, a large-address-aware
32-bit process can have 4 GiB. A native 64-bit process has a different map.
Do not apply the diagram’s split to this lesson’s x86-64 capture merely because
both use Windows.
A simplified upward-growing segment has a base and an inclusive limit.
For a one-byte access, check the offset against the limit, then add it to the
base to form a linear address. Paging divides that linear address space
into fixed-size pages and maps each page to a physical frame, with access
permissions. Adjacent virtual pages need not reach adjacent physical frames.
Two ways to turn a program’s address into a real oneTwo independent examples, not consecutive steps of one address. Each one starts from a number the program uses and ends in a number the hardware uses, or in a fault.
Paging: equal 4 KiB pages, looked up by number
page number · 3 bits4
offset · 12 bits0x234
0x4234 in binary, 15 bits: 100 001000110100
Page table (8 pages). The present bit says whether the page has a frame.
Page
Present
Frame
Frame starts at
0
1
0x0A2
0x000A2000
1
1
0x1F0
0x001F0000
2
0
none
none
3
1
0x011
0x00011000
4
1
0x1A0
0x001A0000
5
0
none
none
6
1
0x0B7
0x000B7000
7
1
0x05C
0x0005C000
Split 0x4234 into a 3-bit page number and a 12-bit offset: page 4 (the top bits), offset 0x234 (the low 12 bits).
Row 4 of the page table: present bit 1, frame 0x1A0.
Segmentation: variable-size pieces with a base and a limit
segment numberS0
offset0x0234
Segment table. The limit is the last valid offset, so the segment holds limit + 1 bytes.
Segment
Base
Limit
Size
S0 code
0x00400000
0x1FFF
8,192 bytes
S1 data
0x00500000
0x0FFF
4,096 bytes
S2 stack
0x00700000
0x2FFF
12,288 bytes
S3 unused
none
none
not in use
Row 0 of the segment table: base 0x00400000, limit 0x1FFF (the last valid offset).
Check: offset 0x0234 is not greater than limit 0x1FFF, so the check passes.
Linear address = base 0x00400000 + offset 0x0234 = 0x00400234.
Linear address 0x00400234
Four worked translations
Method
Input
Result
Paging
0x4234
Physical address 0x001A0234
Paging
0x2FFF
Page fault: page 2 is not present
Segmentation
S0 : 0x0234
Linear address 0x00400234
Segmentation
S0 : 0x2000
Trap: offset 0x2000 is past the limit 0x1FFF
Piece size
Paging: every page is the same size (here 4 KiB), so any free frame fits any page. Segmentation: each segment has its own size, which is why every row has its own limit.
What is checked
Paging: the page’s present bit (and, on real CPUs, access permissions). Segmentation: that the offset is not past the limit.
What goes wrong
Paging: a page fault when the page has no frame. Segmentation: a trap when the offset is past the limit or the segment is not in use.
Windows today
Windows normally uses flat segments (one big segment starting at zero) and does the real work with paging, so the page tables decide what each address means.
Optional exploration
Compare a segment with a page mapping
Use the same small number as a segment offset and, independently, a virtual address. The segment has base 0x00400000 and an inclusive limit. Paging models one selected 4 KiB page entry; changing its frame moves that page. Large pages and read/write privilege checks are outside this model.
Play starts only when chosen. Step works one action at a time.
4 / 4: form each result
Segment: 0x00401234. Paging: 0x001A0234. These are independent address examples.
number supplied to each example0x00001234
Conceptual segment
inclusive limit 0x00001FFFoffset fits
↓
base 0x00400000 + offset0x00401234
4 KiB paging
page | byte offset1 | 0x00000234
↓
selected page-table entryframe base 0x001A0000
↓
frame base + byte offset0x001A0234
Segment offset / virtual address
0x00001234
Virtual page and offset
1 / 0x00000234
Segment result
0x00401234
Physical result
0x001A0234
Complete default worked example and listing
This reference keeps the default values. The live picture above follows your input and selected step.
Action
Worked state
choose the input
Use 0x00001234 as a segment offset in one example and a virtual address in the other.
check the limit and split the page
Offset 0x00001234 passes limit 0x00001FFF. Paging splits it into page 1 and offset 0x00000234.
read the selected page entry
The entry for virtual page 1 is present and selects frame 416.
form each result
Segment: 0x00401234. Paging: 0x001A0234. These are independent address examples.
segment: if offset > inclusive_limit, fault
otherwise linear = base + offset
paging: virtual_page = floor(address / 4096)
byte_offset = address % 4096
if selected entry is absent, fault
otherwise physical = frame * 4096 + byte_offset
The conceptual segment result is a linear address; the paging result is a physical address. Segment-limit failure and a not-present page are separate decisions. Modern Windows normally uses flat application segments plus paging.
Intel’s memory-management guide
describes how the mechanisms combine. Windows normally uses a flat
application address model rather than separate variable-size application
segments; Microsoft’s x86 overview
states that model. Page tables supply the process mappings and page permissions.
In 64-bit mode, most traditional segment bases are ignored, while FS/GS still
have special uses. A segment register is therefore not an alternative process
page table. The next section follows the paging structures used by the capture.
On ordinary x86-64 systems using four-level paging, the CPU’s CR3 register identifies the physical base of the top table. The virtual address supplies four nine-bit indices and a twelve-bit offset:
Virtual-address bits
Use
47–39
PML4 index
38–30
page-directory-pointer index
29–21
page-directory index
20–12
page-table index
11–0
byte offset inside a 4 KiB page
sign ext.bits 63–48
PML4bits 47–39
PDPTbits 38–30
PDbits 29–21
PTbits 20–12
offsetbits 11–0
A 64-bit virtual address in this x86-64 four-level paging model. Four 9-bit table indices and a 12-bit page offset use bits 47–0; the top 16 bits must copy bit 47 to make a canonical address. Five-level paging uses a different split.
Each table has 512 eight-byte entries. A normal translation reads one entry at each level, checks its present bit, takes the next table’s physical address, and finally adds the page offset.
Large pages can finish earlier: a page-directory-pointer entry may map 1 GiB, and a page-directory entry may map 2 MiB.
With the common 48-bit virtual-address form, bits 63–48 must repeat bit 47. An address that breaks this rule is noncanonical. The lab rejects it before reading the capture:
Optional: practise the sign-extension check for this four-level, 48-bit address model.
An IOMMU is an input/output memory management unit. It does for devices what the page tables above do for processes: it sits between a device and physical memory and translates the addresses the device asks for, so the operating system can limit a device to approved physical regions instead of letting it reach all RAM.
Windows exposes protections including Kernel DMA Protection on supported hardware. Microsoft explains that it uses the IOMMU to isolate capable peripherals and protect against malicious DMA devices. Read the current Microsoft overview for requirements and behavior.
If a protected machine blocks DMA, that is the system working correctly. Keep the protections enabled and use the synthetic capture created by the tests or an offline hypervisor capture.
some virtual pages may be absent, swapped, compressed, or intentionally omitted;
you need the correct CR3 and architecture details;
device memory and CPU caches may not be represented;
the capture may contain private data unrelated to the lesson.
A capture holds whatever was in RAM, including private data unrelated to the lesson, so it is stored securely and deleted when the lab no longer needs it. It also cannot be interpreted on its own. Its origin, capture time, build, architecture, capture method, and a cryptographic hash travel with the file, because a CR3 value or a structure layout means something only for the machine state it came from, and the hash is what shows the bytes have not changed since.
The useful computer-science problem is translating and validating captured memory. The complete implementation:
opens a normal file read-only;
accepts a physical CR3 value and virtual address;
walks x86-64 page tables inside that file;
checks present bits and bounds;
supports 4 KiB, 2 MiB, and 1 GiB mappings while leaving live CPU access
permissions as a separate question;
prints a bounded hexdump.
No hardware-specific SDK is needed. This keeps the lesson reproducible and focuses attention on address translation rather than stealth, firmware, or bypasses.
Lesson 13.1 left us with a concrete mismatch: a debugger reports a game’s
virtual address, while an offline RAM capture is indexed by physical
address. We need the captured page tables to connect the two. If we mix them
up, a read may return plausible bytes from the wrong place, so the types and
bounds in this tool are part of the explanation, not just coding style.
The error includes the failed level and physical entry address. That context is far more useful than “read failed.”
Here is one real entry: the first PML4 entry of the synthetic capture used in the tests later in this lesson, stored at physical address 0x1000:
0x100001
0x100120
0x100200
0x100300
0x100400
0x100500
0x100600
0x100700
present bit set
bit 13 set
higher bits, all 0
one little-endian u64, value 0x0000_0000_0000_2001
Little-endian means the lowest byte comes first, so byte 0x01 is bits 0–7 and byte 0x20 is bits 8–15. 0x20 is binary 0010 0000, bit 5 of its byte, which is bit 8 + 5 = 13 of the entry, worth 2^13 = 8,192 = 0x2000. So the entry is 0x2000 + 1 = 0x2001: entry & 1 is 1, so it is present, and clearing the flag bits leaves 0x2000, the physical address of the next table.
Before reading the code, it pays to see where its constants come from, because
none of them are arbitrary. A 64-bit virtual address is cut into six fields, and
two sizes fix every boundary:
A page is 4,096 bytes, and 4,096 = 2^12, so the lowest 12 bits, 0–11, pick a
byte inside the page.
A table holds 512 entries, and 512 = 2^9, so each table index takes 9 bits.
Stacking four indices above the offset puts the page-table index at bits
12–20 (the next field starts at 12 + 9 = 21), the page-directory index at
21–29, the PDPT index at 30–38, and the PML4 index at 39–47.
That accounts for 12 + 4 × 9 = 48 bits. The remaining 16 bits, 48–63, carry
no information of their own: they must repeat bit 47, which is what makes an
address canonical.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
Take the address from the page-crossing example later in this lesson,
0x0000_7FF6_1234_0FF8. Every hex digit is exactly four bits, so its low twelve
digits spell out bits 47 down to 0:
Cutting those 48 bits at the boundaries above, working from the right (12 bits,
then four groups of 9), gives the six fields:
bits 63–480000 0000 0000 0000
bits 47–390 1111 1111
bits 38–301 1101 1000
bits 29–210 1001 0001
bits 20–121 0100 0000
bits 11–01111 1111 1000
sign extension
PML4 index
PDPT index
PD index
PT index
page offset
all 0, like bit 47
0x0FF, 255
0x1D8, 472
0x091, 145
0x140, 320
0xFF8, 4,088
0x0000_7FF6_1234_0FF8 split into its fields. Each field is written in groups of four bits counted from the right, so it reads straight off as hex.
Converting each field to decimal is ordinary place value:
0x0FF = 15 × 16 + 15 = 255; 0x1D8 = 1 × 256 + 13 × 16 + 8 = 472; 0x091 = 9 × 16 + 1 = 145;
0x140 = 1 × 256 + 4 × 16 = 320; and 0xFF8 = 15 × 256 + 15 × 16 + 8 = 4,088.
This address therefore uses PML4 entry 255, PDPT entry 472, page-directory
entry 145, page-table entry 320, and byte 4,088 of the final page.
The same layout accounts for every mask in the walk below:
The shifts of 39, 30, 21, and 12 slide each index field down to bit zero
so it can be used as a table index. For the example address,
(value >> 39) & 0x1FF is exactly the PML4 field, 255.
& 0x1FF keeps nine bits, because 0x1FF is 511 and each table holds
512 entries. Nine bits is not a coincidence: 512 entries of eight bytes each
comes to exactly 4,096 bytes, so every page table is itself exactly one page.
& 0xFFF keeps twelve bits — the offset within a 4 KiB page, since
0xFFF is 4,095.
& 0x000F_FFFF_FFFF_F000 keeps bits 12 through 51 of an entry. The low
twelve bits are cleared because an entry stores flags there (present,
writable, user-accessible, large-page), and the high bits are cleared because
they hold further flags rather than address. What remains is the physical
address of the next table — which is page-aligned, and therefore always had
twelve spare zero bits for those flags to live in.
The complete implementation masks flag bits from each next-table address:
The real file also checks the large-page bit, bit 7 of an entry (1 << 7 = 0x80), at the PDPT and page-directory levels. When it is set, the walk stops early, and the index fields it skipped become part of the offset. A 1 GiB page is 2^30 bytes, so its offset is the low 30 bits, value & 0x3FFF_FFFF. A 2 MiB page is 2^21 bytes, so its offset is the low 21 bits, value & 0x1F_FFFF. Each mask is one less than its page size: 0x4000_0000 = 4 × 16^7 = 2^2 × 2^28 = 2^30, and 0x20_0000 = 2 × 16^5 = 2 × 2^20 = 2^21.
Every entry in the chart is read through read_physical, so an entry address beyond the end of the file stops the walk with OutOfRange at whichever level it occurs.
Translating only the starting address is a subtle bug. Virtual memory looks
contiguous; the physical memory behind it is not. Two virtual pages sitting
side by side can be backed by physical pages nowhere near each other.
Suppose a 32-byte read begins 8 bytes before the end of a page:
requested: 32 bytes at virtual 0x0000_7FF6_1234_0FF8
bytes 1..8 live in the page at virtual ...0000 -> physical 0x0512_3000
bytes 9..32 live in the page at virtual ...1000 -> physical 0x0091_A000
This is the worked address from the walk above. Its page offset is 0xFF8, and
the page ends at offset 0x1000, so 0x1000 − 0xFF8 = 4,096 − 4,088 = 8 bytes
remain in the first page. Byte 9 sits at 0x0000_7FF6_1234_1000, where the
page-table index is 0x141 = 321 instead of 320 and the offset is 0. The two
halves of the read are translated by neighbouring entries of the same page
table, at byte offsets 320 × 8 = 2,560 and 321 × 8 = 2,568, and nothing
requires those two entries to hold neighbouring physical addresses.
Nothing warns you when this happens. Translating once and then reading 32
consecutive physical bytes quietly returns 24 bytes belonging to whatever
follows the first page in physical memory — a completely unrelated allocation,
another process’s data, or nothing mapped at all. The bytes come back looking
perfectly ordinary.
read_virtual therefore loops:
translate the current virtual address;
calculate bytes remaining in its page;
copy the smaller of that amount and the requested remainder;
translate again after crossing the boundary.
This is why the public method returns a new byte vector rather than a borrowed slice of one physical region.
A read that crosses a page boundaryNeighbouring virtual pages can sit in unrelated physical pages, so one read may need two copies.
Virtual read, physical pieces, one buffer
What the program asks for
32 bytes starting at 0x00007FF612340FF8
page offset 0xFF8page boundary crossed
8 bytesfirst page
24 bytesnext virtual page
Where the bytes really are
↓ translate each page
Physical memory for this lesson’s mapping
8 bytes at 0x05123FF8
24 bytes at 0x0091A000
What the reader returns
↓ copy and join
One owned buffer
8 + 24 = 32 bytes
The read starts 8 bytes before the page ends, so 24 bytes come from the next virtual page, which maps to a different physical page. The reader joins both copies.
The fixture is 0x7000 bytes: 7 × 0x1000 = 7 × 4,096 = 28,672 bytes, or seven
4 KiB pages. The lab’s capture format has no header. File offset N holds
physical address N, so each page of the file is one physical page:
0x0000zeros
0x1000PML4 table
0x2000PDPT
0x3000page directory
0x4000page table
0x5000data page
0x6000zeros
entry 0 holds 0x2001
entry 0 holds 0x3001
entry 0 holds 0x4001
entry 0 holds 0x5001
GHA DMA at 0x5123
The synthetic capture, one box per 4 KiB page. Virtual 0x0123 is less than 0x1000, so bits 12–47 are all 0 and every table index is 0. The walk therefore reads entry 0 of each table, which is the table's first 8 bytes, and follows 0x2000, 0x3000, 0x4000, then 0x5000. The offset 0x123 then selects physical address 0x5000 + 0x123 = 0x5123.
With CR3 = 0x1000, virtual address 0x0123 should translate to physical 0x5123. Tests also cover a missing present bit and a noncanonical virtual address.
Watch it happenWalking four page tables from a virtual address to a physical one
A virtual address, 0x0123, is split into four zero table indices and an offset of 0x123. Starting from CR3 = 0x1000, each table’s entry 0 is read: 0x2001, 0x3001, 0x4001, 0x5001. Clearing the flag bits gives the next table base each time, ending at the data page 0x5000. Adding the offset gives the physical address 0x5123, where GHA DMA is stored.
Read the steps
CR3 supplies the physical address of the top table, 0x1000. The virtual address 0x0123 has four table indices, all 0, and a page offset of 0x123.
Level 1, the PML4: read eight bytes at 0x1000, entry 0. It holds 0x2001. The lowest bit is the present flag and is set; clearing the flag bits leaves 0x2000, the base of the next table.
Level 2, the PDPT, at 0x2000: entry 0 holds 0x3001. Its large-page bit is clear, so the walk continues; the next table is at 0x3000.
Level 3, the page directory, at 0x3000: entry 0 holds 0x4001. Again not a large page, so the next table, the page table, is at 0x4000.
Level 4, the page table, at 0x4000: entry 0 holds 0x5001, present. Clearing the flags leaves 0x5000, the base of the data page. The table walk has the page but not yet the byte.
Combine the aligned page base 0x5000 with the offset 0x123: physical address 0x5123. That is where the capture stores GHA DMA. A real read must still check that its whole range fits inside the file.
Each step depends on a valid eight-byte entry: an out-of-range address or a clear present bit stops the walk at that level. This fixture uses four levels and 4 KiB pages.
Synthetic tests are powerful because every byte is known. A failure means the translator is wrong, not that an unknown capture changed.
The checked implementation is in advanced-memory-labs/src/dma.rs; the command-line wrapper is src/bin/dma_capture.rs.
Terminal window
cargo test --manifest-path advanced-memory-labs/Cargo.toml dma
cargo run --manifest-path advanced-memory-labs/Cargo.toml --bin dma_capture -- `
capture.bin 0x10000x00007FF61234100064
Arguments are:
path to an offline physical capture with recorded provenance;
physical CR3 value in hexadecimal;
virtual address in hexadecimal;
byte count from 1 through 4096.
The maximum is deliberately small. A learning tool should make unexpectedly huge reads impossible by default.
The output says which bytes an offline page-table mapping points to. It does
not certify that a live user-mode read or write would pass CPU and Windows
permission checks. The present bit and the file bounds are necessary for this
walk; effective access also depends on flags at multiple table levels and on
the kind of access requested.
wrong address space, stale pointer, or wrong build model
Never “fix” an error by masking more bits until output appears. Compare the entry format with the architecture manual, confirm the capture layout, and keep the failure visible.
The next lesson asks a harder question than “did translation return bytes?”:
do those bytes belong to the intended process, object, build, and capture
moment? That requires provenance and independent checks, not another mask.
Lesson 13.3
Hypervisors and Virtual Machines
The earlier lessons followed authority from a game process through Windows,
driver requests, and physical-memory mappings. The rest of this chapter widens
the view to the machine itself: virtual machines, console hardware, chip-level debug
interfaces, and emulators. Each gives software a different view of the hardware
and a different path from software requests to device effects.
Lesson 1.7 set up a Windows virtual machine for this book’s
labs and promised that a snapshot could undo a bad experiment. It did not say
how one computer can pretend to be several, or what a snapshot actually
stores. A guest network-card write, a RAM address, and a snapshot will make
those mechanisms visible. The last section returns to the driver trust
boundary from Chapter 12 and follows Windows’ use of the same machinery to
protect its own kernel.
The next lesson translates one address through both stages
digit by digit and shows how software can tell that it is virtualized.
A virtual machine (VM) is a complete computer made of software: virtual
processors, virtual RAM, a virtual disk, a virtual network card, and a virtual
screen, convincing enough that an unmodified operating system installs and runs
on it. The operating system inside is the guest, and the real computer is
the host. The software that creates virtual machines and shares the real
hardware among them is the hypervisor, also called a virtual machine
monitor.
This is a different kind of virtual machine from the one in
Lesson 11.9. Lua’s virtual machine is a program that carries
out Lua bytecode, an instruction set that no processor runs in silicon. A
hypervisor’s virtual machine offers the same instruction set as the real
processor, x86-64, so the guest’s instructions run directly on the real
processor, and the hypervisor steps in only when the guest touches something
that must stay under the host’s control.
In the figure below, each arrow points from a piece of software to whatever it
runs on:
A type 1 hypervisor runs directly on the hardware, with no operating system
underneath it. Every operating system on the machine runs above it, including
the one that manages the others. Hyper-V is type 1: when it is turned on, the
hypervisor starts first at boot, and your Windows becomes the root partition,
a privileged guest that manages the other guests. Xen and VMware ESXi are also
type 1.
A type 2 hypervisor is an application that runs on an ordinary host
operating system and relies on it for scheduling, memory, and devices.
VirtualBox and VMware Workstation are type 2. Even they need ring 0 for the
processor’s virtualization instructions, so each installs a kernel driver
alongside its application.
The line blurs in practice. KVM turns the Linux kernel itself into a
hypervisor. And on a Windows PC where the Windows hypervisor is already running,
as it is whenever memory integrity is on (the last section explains why),
VirtualBox and VMware run their guests through Windows’ own hypervisor instead
of directly on the processor.
A guest’s kernel expects to run in ring 0, but ring 0 on the real processor
already belongs to the hypervisor or the host’s kernel. Running the guest’s
kernel in ring 3 instead did not work on x86 for many years, because a few
instructions that change the processor’s state quietly behave differently in
ring 3 instead of faulting, so the hypervisor never saw them.
Lesson 13.4 shows
the classic problem instruction and the software workarounds that came first.
In 2005 and 2006, Intel and AMD added hardware support, called Intel VT-x
and AMD-V. Both add a separate processor mode for guests. Intel calls it
VMX non-root operation, and the hypervisor runs in VMX root operation.
Inside the guest mode, the guest gets its own complete set of rings 0 to 3, so
the guest kernel runs in its own ring 0, unmodified, and every instruction
behaves exactly as the guest kernel expects.
The hypervisor keeps one control structure for each virtual processor, called
the VMCS on Intel and the VMCB on AMD. It holds three things:
the guest’s saved state: every register, including the instruction pointer
and cr3;
the host’s state, reloaded whenever the guest stops;
the exit controls: the list of guest actions that must hand control to the
hypervisor.
The hypervisor starts or resumes a guest with a VM entry (vmlaunch or
vmresume on Intel, vmrun on AMD). The processor loads the guest’s state and
runs the guest’s instructions natively, at full speed, until one of the listed
actions happens.
A VM exit is the processor stopping a guest and handing control to the
hypervisor. The processor saves the guest’s state into the control structure,
loads the host’s state, and jumps to the hypervisor’s exit handler, along with
a number that says why, the exit reason, and details such as the address
involved. The hypervisor deals with the cause, adjusts the guest’s saved state
(usually moving its instruction pointer past the instruction it just handled),
and enters the guest again. From inside the guest, the instruction simply seems
to have taken effect.
Typical causes of an exit:
cpuid, the instruction that describes the processor. On Intel it always
exits, so the hypervisor decides what the guest is told about its processor.
Port I/O to a virtual device, such as the in and out instructions from
Lesson 12.7.
An access to a guest-physical address that has no usable mapping in the
second stage of translation, described in the next section. That is how the
hypervisor catches accesses to a virtual device’s memory-mapped registers.
hlt, when a virtual processor has nothing to do and the hypervisor can give
the real core to other work.
Interrupts from real hardware, which belong to the host.
Here is one exit, caused by a guest’s network driver writing to a register of
its virtual network card:
A virtual device is backed by host workThe guest’s network driver writes 1 to its virtual card’s START register.
GuestCPUHypervisor
1
Requestwrite 1 to START
Step 1: Guest to CPU
The register sits on a guest-physical page of the virtual card.
2
ControlVM exit: no stage-2 mapping
Step 2: CPU to Hypervisor
Guest state is saved; the exit reason and address go along.
3
Alonesend the packet on the real card
Step 3: Hypervisor (alone)
It updates its emulated card and uses the host’s network card.
4
ReplyVM entry after the store
Step 4: Hypervisor to Guest
The guest runs on; to it, the store simply took effect.
All 4 steps shown
The guest’s network driver writes 1 to its virtual card’s START register.
Exits are expensive. The round trip of exit, handling, and entry takes hundreds
to thousands of processor cycles, where an ordinary instruction takes about
one. So hypervisors keep exits rare. The guest’s ordinary instructions, its own
system calls, and its own page faults never leave the guest, and modern guests
use paravirtual devices, virtual devices designed to need few exits. It is
the idea Lesson 12.5 called limited direct execution, one
level further down: run natively, and trap only what must stay under control.
Each guest believes it owns physical memory starting at address 0. It cannot:
the host’s real memory also starts at 0, and several guests share it. So a
guest’s “physical” addresses are really guest physical addresses, and they
must be translated once more into host physical addresses, the real
addresses of RAM.
With second-level address translation (SLAT), the processor does both
translations in hardware. Intel calls its version extended page tables (EPT);
AMD calls its version nested page tables (NPT). The two stages have different
owners:
Stage 1 turns a guest virtual address into a guest physical address,
through the guest’s own page tables. The guest kernel writes these tables and
switches between them by loading its own cr3, exactly as it would on a real
machine.
Stage 2 turns a guest physical address into a host physical address,
through tables that only the hypervisor writes. The guest cannot see them,
name them, or change them.
Both stages work a page at a time. The low bits of an address, which pick a
byte within its page, pass through unchanged, and each stage replaces only the
page number above them.
Lesson 13.4 follows one address through
both stages digit by digit and counts what the extra stage costs.
Each stage can also refuse. If the guest’s own tables have no entry for a
virtual page, the processor raises an ordinary page fault inside the guest,
and the guest kernel handles it as it always would. If the hypervisor’s tables
have no entry for a guest physical page, as for the network card’s register
page in the previous section, the result is a VM exit. Stage-2 entries also
carry their own read, write, and execute permissions, and an access succeeds
only if both stages allow it.
The second stage is where a guest’s isolation comes from. A guest can write
anything it likes into its own page tables, but every address they produce is
only a guest physical address, and only the pages the hypervisor maps in stage
2 exist for that guest. Memory that belongs to the host or to another guest has
no guest physical address at all, so no instruction the guest can execute is
able to name it.
Lesson 1.7 took snapshots before any experiment. A snapshot can capture a whole
running computer because everything that makes up a guest is something the
hypervisor owns:
The disk. A guest’s disk is an ordinary file on the host. Taking a
snapshot freezes that file and starts a new differencing file, the delta,
which receives every later write. A read checks the delta first and falls
back to the frozen base.
The memory. The guest’s RAM is host memory that the stage-2 tables map.
Because the hypervisor knows exactly which host pages those are, it can write
all of them to a file, and a snapshot of a running VM includes that file.
The processors and devices. Each virtual processor’s saved state from its
control structure, every register including the instruction pointer, plus the
state of every emulated device.
block 0base
block 1delta
block 2base
block 3base
block 4delta
block 5base
A virtual disk after a snapshot. Blocks 1 and 4 were written since, so their new contents live in the delta; the others are still read from the frozen base. Restoring the snapshot discards the delta.
Restore all three, and the guest continues from exactly the instruction it had
reached, with its disk exactly as it was. Restoring is quick because nothing is
copied back: the hypervisor throws the delta away. Deleting a snapshot is the
slow direction, because the delta’s blocks must then be merged into the base.
A snapshot cannot put back anything that left the virtual machine: a file
copied to the host through a shared folder, a message already sent over the
network (the other end still has it), a change to an online account, or a
change to the host itself. It also relies on the hypervisor’s isolation
holding. A bug in the hypervisor that lets guest code run on the host, called a
VM escape, would put an attacker beyond any snapshot’s reach. Such bugs
usually sit in the code that emulates devices; they are rare and valuable, and
they are patched quickly. That is why Lesson 1.7 keeps shared folders, the
shared clipboard, and USB passthrough off until they are needed, and why the
host’s hypervisor deserves updates as promptly as Windows does.
That is the case for running this book’s Windows labs in a VM. A bug check in
the guest’s kernel or a bad driver in the guest (Lesson 12.4,
Lesson 12.7) stops only the guest. Debuggers and memory tools, which need broad
rights inside the guest, get none outside it. And a restore returns a whole
machine, not just one program, to a known state.
A guest is not invisible, though. Software inside it can tell that it is
running in a virtual machine, and some anti-cheat systems refuse to run a
protected game there;
Lesson 13.4
shows how a program finds out.
Restore the state that makes a running guest. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
checkpoint = registers 6, RAM 4, disk 2, device Idle
guest continues and changes each state
select the checkpoint contents to restore
restore the selected components
compare every component with the checkpoint
report whether the running state is complete
Result: all illustrated guest state matches the checkpoint.
Final values: contents = full; register = 6; ram = 4; disk = 2; device = idle; complete = true.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
contents = full.
Step 2, line 1. All numbers are illustrative state values, not file sizes.
Lesson 12.4 ended with an uncomfortable fact: kernel code can do anything, so a
single exploited driver bug hands an attacker the whole kernel.
Virtualization-based security (VBS) answers with the machinery from this
lesson. At boot, the Windows hypervisor starts first, and Windows runs above it
in two virtual trust levels:
VTL0, the normal world: the Windows kernel, drivers, and every program,
in their usual rings 0 and 3;
VTL1, the secure world: a small secure kernel and a few isolated
processes, such as the one that guards login secrets when Credential Guard is
on.
The hypervisor keeps separate stage-2 permissions for each trust level. VTL1
can ask the hypervisor to change VTL0’s permissions. Nothing in VTL0, not even
ring-0 kernel code, can change them, because the stage-2 tables cannot be
reached from inside VTL0 at all.
Memory integrity, also called HVCI (hypervisor-protected code
integrity), is the VBS feature that uses this arrangement for the kernel’s own
code. The code-integrity checks that decide whether a driver is properly signed
run in VTL1, where a compromised VTL0 kernel cannot tamper with them. A kernel
page becomes executable in stage 2 only after its code passes those checks, and
an executable page is never writable.
Suppose an attacker finds a driver bug that lets them write anywhere in kernel
memory, the worst case from Lesson 12.4. They can still corrupt data. They
cannot write new instructions into a code page, because code pages are not
writable in stage 2, and they cannot turn a data page into code, because only
VTL1 grants execute permission and it grants it only to signed code. The
vulnerable driver blocklist from Lesson 12.7 is enforced whenever memory
integrity is on.
Memory integrity is on by default on clean installs of Windows 11 on compatible
hardware. It needs the processor’s virtualization support and SLAT, which is
one more reason to leave virtualization enabled in the firmware settings. On
such a PC, VirtualBox and VMware run their guests through the Windows
hypervisor instead of directly on the processor, which can make a lab VM
somewhat slower. That is not a reason to turn memory integrity off:
Lesson 12.9 lists it among the protections worth keeping.
Lesson 13.3 described what a hypervisor does: it runs a
guest’s instructions natively, takes control back through VM exits, and
translates every guest address a second time. This lesson looks underneath. It
shows why x86 needed new hardware before a guest’s kernel could run unmodified,
follows one address through both stages of translation digit by digit, counts
what the second stage costs, and ends with the ways software inside a guest can
tell that it is there.
The obvious way to build a hypervisor is to run the guest’s kernel in ring 3,
where every privileged instruction it tries causes a fault, and have the
hypervisor catch each fault and do to the virtual machine what the
instruction would have done to a real one. This is called trap and
emulate: the guest’s ordinary instructions run at full speed, and only the
sensitive ones cost extra.
In 1974 Gerald Popek and Robert Goldberg stated the condition for trap and
emulate to work: every instruction that reads or changes the machine’s control
state must fault when it runs outside the most privileged level. For many years
x86 did not meet it. The classic example is popf, which loads the flags
register, including the interrupt flag that switches interrupts on and off.
In ring 0, popf changes the interrupt flag. In ring 3 it silently leaves the
flag alone and raises no fault. A guest kernel running in ring 3 that used
popf to switch interrupts back on would carry on believing they were on, and
the hypervisor would never find out, because nothing trapped.
Early x86 hypervisors worked around the problem. VMware, from 1999, examined
the guest kernel’s code as it ran and rewrote the problem instructions into
safe sequences, a technique called binary translation. Xen, from 2003,
modified the guest kernel’s source code so that it asked the hypervisor
directly instead of running those instructions, which is called
paravirtualization. Both worked, and both were complicated. The hardware
support from Lesson 13.3, VT-x and AMD-V, made both workarounds unnecessary for
running an unmodified kernel: it gives the guest its own ring 0, where popf
behaves exactly as the guest kernel expects.
Lesson 13.3 split a guest’s address translation into two stages. The guest’s
own page tables turn a guest virtual address into a guest physical address,
and the hypervisor’s tables turn that into a host physical address, the real
location in RAM. Here is one address going through both:
Pages are 4 KiB, which is 4 × 1,024 = 4,096 bytes. Since 2¹² = 4,096, the
lowest 12 bits of an address choose a byte within its page, the offset, and
all the bits above them name the page. Each hex digit stands for 4 bits, so the
12 offset bits are exactly the last three hex digits;
Lesson 6.5 made the same split. Take the guest virtual
address 0x0040_3A2C:
0
0
4
0
3
A
2
C
guest virtual page 0x00403
offset 0xA2C
The guest virtual address 0x0040_3A2C, split before its last three hex digits.
The offset is 0xA2C = 10 × 256 + 2 × 16 + 12 = 2,560 + 32 + 12 = 2,604,
safely less than 4,096. The page number is 0x403 = 4 × 256 + 3 = 1,027. As a
check, page 1,027 starts at byte 1,027 × 4,096 = 4,206,592, and 4,206,592 +
2,604 = 4,209,196, which is 0x0040_3A2C written in decimal.
Stage 1. The guest kernel’s page tables map guest virtual page 0x403 to
guest physical frame 0x1F7. A frame is a page-sized block of physical
memory. Translation replaces the page number with the frame number and keeps
the offset.
Multiplying by 0x1000, which is 4,096, shifts a number left by three hex
digits, so 0x1F7 × 0x1000 = 0x1F_7000, and adding the offset gives
0x1F_7A2C. On a real machine the translation would end here, with a RAM
address. In a guest, 0x001F_7A2C is only the guest’s idea of one.
Stage 2. The hypervisor’s tables map guest physical frame 0x1F7 to host
physical frame 0x52E3.
0x52E3 × 0x1000 = 0x52E_3000, and adding 0xA2C gives 0x052E_3A2C, where
the byte really is in RAM. The offset rode through both stages untouched; each
stage only swapped the number above it.
Predict first
The guest’s tables also map page 0x404 to guest frame 0x2A0. The hypervisor’s tables map frame 0x1F7 to host frame 0x52E3 and have no entry for frame 0x2A0.
The guest reads 0x0040_4010, one page above the lesson’s address. What happens?
Answer: Stage 2 has no entry, so the processor makes a VM exit. Stage 1 works: page 0x404 becomes frame 0x2A0, so the guest physical address is 0x2A0 × 0x1000 + 0x010 = 0x002A_0010. Stage 2 finds no entry for 0x2A0 and hands control to the hypervisor.
Translate a guest address twiceGuest tables: page 0x403 → frame 0x1F7, page 0x404 → frame 0x2A0. Hypervisor tables: frame 0x1F7 → host frame 0x52E3, and no entry for 0x2A0.
Two lookups, one offset
Split the address
page 0x403 (orange) · offset 0xA2C (blue)
0
0
4
0
3
A
2
C
Stage 1: the guest’s tables
Lookup
page 0x403
→ frame 0x1F7
↓ keep the offset
Guest physical
0x001F_7A2C0x1F7 × 0x1000 + 0xA2C
Stage 2: the hypervisor’s tables
Lookup
frame 0x1F7
→ host frame 0x52E3
↓ keep the offset
Host physical (real RAM)
0x052E_3A2C0x52E3 × 0x1000 + 0xA2C
Offset 0xA2C is the last three hex digits and rides through unchanged. Stage 1 swaps page 0x403 for frame 0x1F7; stage 2 swaps that for host frame 0x52E3, giving 0x052E_3A2C.
Keep the page offset through two mappings. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
guest_virtual_page = 0x403
split the selected 12-bit offset
guest table maps page0x403 to frame 0x17
hypervisor maps frame 0x17 to host frame
combine host frame and unchanged offset
report all three addresses
Real translation is not one table lookup per stage but a four-level walk
(Lesson 13.1), and with two stages the walks multiply. The
guest’s page tables live in guest physical memory, so each of the four entries
the processor reads during the guest’s walk sits at a guest physical address
that must first go through a four-level stage-2 walk. Each guest level
therefore costs 4 reads to find its table plus 1 read of the entry itself,
4 + 1 = 5 reads, and 4 × 5 = 20 reads for the whole stage-1 walk. The guest
physical address that comes out needs one more four-level stage-2 walk:
20 + 4 = 24 memory reads to translate one address, where a machine without
virtualization needs 4.
The processor avoids paying that on every access with its translation
lookaside buffer (TLB), a small cache of recent translations. The TLB keeps
the final guest-virtual-to-host-physical answer, so a program that touches the
same pages again, as programs mostly do, pays for the walk once.
A guest runs real instructions on a real processor, so most of what it does
looks identical inside and outside a VM. A program that wants to know can
still find out, in a few ways:
The processor says so. Hypervisors announce themselves on purpose. When a
program runs cpuid with input 1, bit 31 of the result in ecx is the
hypervisor present bit: 0 on bare hardware, set by a hypervisor. Bit 31 is
worth 2³¹ = 2,147,483,648, which is 0x8000_0000, because the top hex digit
8 is binary 1000. A second input, 0x4000_0000, returns the hypervisor’s
name as twelve ASCII characters, such as Microsoft Hv. This is a feature,
not a leak: guests use it to find out which faster, hypervisor-aware drivers
they can load.
The hardware looks virtual. Virtual disks, graphics adapters, and network
cards report model names from the hypervisor’s vendor, and virtual network
cards get addresses from ranges assigned to those vendors.
Some instructions are slow. Anything that causes a VM exit takes far
longer than it would on bare hardware, and careful timing can notice.
Guest tools ask so they can install the right drivers. Licensing software asks.
Malware asks so it can behave innocently inside the sandboxes that analysts use
to study it. Some anti-cheat systems ask, and refuse to run a protected game in
a VM, because a hypervisor can see and change everything a guest does. That is
one more reason this book’s targets are offline, single-player builds running
in your own VM.
The first check is weaker than it looks. On a Windows 11 PC with memory
integrity on, Windows itself runs on the Windows hypervisor, so the
hypervisor-present bit is set on an ordinary desktop too.
Lesson 13.3
explains why.
A controller can recognise a button press before your game opens. An SSD can
manage its stored blocks before a Windows driver asks for one. A console can
check its boot software before its main operating system starts. In each case,
some software belongs to the device itself. That software is firmware.
Writing your own firmware means taking responsibility for work that a desktop
application normally delegates: starting the processor correctly, choosing a
memory layout, configuring hardware, and deciding what happens when something
fails. We will make those responsibilities concrete with a small button-and-lamp
controller, build its Rust code into a firmware image, and run that image on an
emulated microcontroller.
Firmware is software that operates a device, usually stored in nonvolatile
memory: memory that keeps its contents without power. Flash is a common
example and can be erased and programmed again. The word does not mean that
the code is permanently unchangeable, nor that it must be written in assembly.
Consider a USB controller plugged into a PC:
Software
Where it runs
What it does
Game application
The PC’s processor
Turns a recognised button action into a game action
Device driver
The PC’s processor, as part of its OS
Exchanges requests and reports with the controller
Controller firmware
The controller’s own processor
Samples its buttons and prepares the reports
The driver and firmware cooperate across a device boundary. Updating the game
does not necessarily update either one. A firmware update replaces code in the
device; its format and programming procedure belong to that particular device.
Firmware can include a real-time operating system, or RTOS, which schedules
tasks and provides services suited to embedded devices. It can also use no OS
at all. The latter arrangement is called bare-metal programming, and it is
the arrangement used by this lesson. An embedded Linux application is another
possibility; being physically inside a device does not make every program
bare-metal firmware.
A microcontroller combines a processor core, memory, and peripheral devices
on one chip. Peripherals include timers, serial interfaces, and GPIO
(general-purpose input/output) pins. A GPIO pin can sense a button’s electrical
level or drive an output, with the appropriate circuit and configuration.
Our target is QEMU’s model of the LM3S6965EVB evaluation board. QEMU is an
emulator: a program that reproduces a machine’s behaviour so its software can
run on another computer. This model has a Cortex-M3 core, 256 KiB of flash, and
64 KiB of SRAM. A KiB is 1,024 bytes; SRAM is the chip’s working RAM,
whose contents are not retained without power. These capacities are listed in
the QEMU board documentation.
The address map is shown in the
Cortex-M memory-layout walkthrough.
Region in this model
Address range
Purpose
Flash, 256 KiB
0x00000000–0x0003FFFF
Startup information, instructions, and stored constants
SRAM, 64 KiB
0x20000000–0x2000FFFF
Writable state and the call stack
Peripheral registers
Device-specific addresses
Configure hardware and exchange input/output
These are addresses in the emulated device, not addresses in the PC process
running QEMU. The model can load an image into its flash region, but that does
not reproduce the electrical procedure for programming a physical flash chip.
For the lamp controller, the instructions survive a power cycle in flash. The
current lamp state is working state: the program starts it again at each boot.
Retaining the last setting across power loss would require an explicit storage
design. Writing flash every time the main loop runs would be a poor substitute:
flash has erase/program constraints and finite endurance that RAM does not.
On this Cortex-M system, reset does not mean “call whichever function happens
to be named main.” The core obtains startup values from a vector table.
Its first entry contains the initial stack pointer; its second contains the
reset-handler address. Later entries describe exception and interrupt handlers.
The table contains values and addresses, rather than a sequence of application
instructions. The Cortex-M memory-layout walkthrough
shows how these entries fit into an image.
0x00000000initial SP: 0x20010000
0x00000004reset-handler address
0x00000008next handler address
The beginning of this image's Cortex-M vector table. The initial stack pointer is just above the 64 KiB RAM region; the stack grows toward lower addresses. The reset address is supplied by the runtime, rather than guessed in application code.
The reset handler runs startup code. Among its jobs is preparing writable
static data so Rust can safely use it. The linker groups program contents into
sections. Placement in the image happens before execution; reset-time
initialization then prepares particular writable ranges:
Section
What belongs there
Who arranges it
.vector_table
Initial stack value and handler addresses
The linker places it; the loader or programmer supplies the image
.text
Machine instructions
The linker places executable code in flash for this image
.rodata
Read-only data, such as message strings
The linker places stored constants in flash
.data
Writable statics with stored initial values
The linker describes both ranges; reset startup copies initial contents from flash to RAM
.bss
Writable statics initially filled with zero
The linker reserves the range; reset startup fills it with zero
For example, a writable counter that must start at 7 needs both a working
location in RAM and an initial value in the image. Startup copies the 7 before
the application uses the counter. A counter starting at zero needs a reserved
RAM range, but the image need not carry a long run of zero bytes: startup can
write the zeros. The linker and runtime agree on where these ranges begin and
end. Local variables are separate; a compiler can keep them in registers or on
the stack as the program executes.
What startup does before Rust uses its staticsOn reset the RAM is not initialised. Startup copies .data from flash and zero-fills .bss.
1Startup copies .data from flash
Flash, 256 KiB
0x000000000x0003FFFF
code and vectors
.data initial value: 7
↓ copy from flash
SRAM, 64 KiB
0x200000000x2000FFFF
.data counter 7
.bss counter ??
After reset SRAM holds unknown bytes (??). Startup first copies the initial value 7 from flash into the .data counter.
2Startup zero-fills .bss: now Rust may use both
↓ fill with zero
SRAM, 64 KiB
0x200000000x2000FFFF
.data counter 7
.bss counter 0
The .bss counter is filled with zero, so both statics hold their starting values before main runs.
All 2 steps shown
On reset the RAM is not initialised. Startup copies .data from flash and zero-fills .bss.
The cortex-m-rt runtime
provides the Cortex-M startup and vector arrangement used here. Our application
supplies its memory map and Rust entry function. The runtime prepares static
data, then calls that function. These responsibilities are why a source file
containing fn main() alone is not a complete firmware image.
Startup data and button edges have different jobs. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
startup copies flash data into RAM
startup zeroes BSS
lamp = off; previous = released
for each sample: pressed = held and not previous
toggle on pressed; save previous
report initialized memory and final lamp
Result: RAM data 7, BSS zero, 2 presses and lamp off. Holding a button does not create repeated edges.
Final values: data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 2; held = false; pressed = false.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
data = 7; samples = two.
Step 2, line 2. The startup routine copies initialized data, then clears zero-initialized storage.
data = 7; samples = two; ram_data = 7; bss = 0.
Step 3, line 3. Rust starts after these memory regions are initialized.
Watch it happenInitialize memory, then detect new button presses
Reset copies the initial stack pointer 0x20010000 and follows the reset handler. Startup copies counter 7 into .data and zeroes .bss before entering Rust. Six button samples are false, true, true, false, true, false. Only new presses toggle the lamp, producing Off, On, On, On, Off, Off, which is reported to QEMU.
Read the steps
Reset reads the vector table. Copy its initial stack pointer into SP, then follow the reset-handler address supplied by the runtime. The stack starts just above the 64 KiB RAM region.
Startup copies the stored counter value 7 from flash into the .data working range. It writes zeros into .bss. The image needs stored bytes for 7, but no stored run of zero bytes.
Only after initialization does the runtime call the Rust entry function. The lamp controller begins with lamp_on false and was_pressed false.
Sample 1 is released, so the lamp stays off. Sample 2 changes false to true: a new press toggles the lamp on. Remember that the button is now pressed.
Sample 3 is still pressed. Since the previous sample was already true, it is not a new edge. Sample 4 releases the button. Neither sample toggles the lamp, which stays on.
Sample 5 is a second false-to-true edge, so it toggles the lamp off. Sample 6 releases the button and leaves it off. Holding and releasing are different from a new press.
The application reports Off, On, On, On, Off, Off through semihosting to QEMU, then requests an emulator exit. This reporting can happen only after startup and application execution.
The six inputs and outputs are the lesson’s trace. The button is a logical stable press; physical switch bounce needs a separate filter.
A boot ROM is startup software stored in read-only memory on a chip. A
bootloader is software whose job is to select, check, or load the application
that will run next. It may support updates and recovery. A vendor boot ROM can
start a bootloader; a bootloader can then start an application image.
Those stages are not mandatory in every design. Our simple image starts through
its Cortex-M reset vectors and runtime without a separate bootloader. A console
uses a more elaborate chain, including signature checks, as
console architecture explains next.
A bootloader’s work and the Rust runtime’s initialization work remain distinct,
even if a particular system coordinates them closely.
Write the control rule separately from the hardware
Our rule is: a new button press toggles the lamp once. Keeping the button
held must not toggle it on every sample. The input is a logical pressed value;
the output is whether the lamp should now be on.
The logical change false → true is a rising edge. We detect it by
remembering the previous sample:
if pressed now AND not pressed previously:
toggle the lamp
remember the current pressed state
report the lamp state
Here is that complete control rule in Rust. It requires neither an OS nor a
hardware register address:
pubstructLampController {
lamp_on: bool, // Output state that survives between samples.
was_pressed: bool, // The previous sample, needed to detect a new press.
}
implLampController {
pubconstfnnew() -> Self {
Self { lamp_on: false, was_pressed: false }
}
pubfnsample(&mutself, pressed: bool) -> bool {
// Holding the button is not a new edge.
ifpressed && !self.was_pressed {
self.lamp_on = !self.lamp_on;
}
self.was_pressed = pressed;
self.lamp_on
}
}
&mut self lets the method update this controller’s remembered state. !
negates a boolean: it switches false to true or true to false.
The last expression returns the new lamp state. No heap allocation is needed;
the controller stores just its two boolean fields.
The input deliberately means a recognised, stable press. A physical switch can
bounce, briefly making and breaking contact several times. Rising-edge
detection alone does not remove that bounce. Physical firmware would first use
a suitable debounce policy, often based on several stable samples over time,
then pass the resulting logical state to this controller.
Sample
Input pressed?
New press?
Lamp after the sample
1
false
No
Off
2
true
Yes
On
3
true
No: still held
On
4
false
No: released
On
5
true
Yes
Off
6
false
No
Off
This separation lets us test the rule on the PC and then reuse it inside a
firmware image. It also keeps a logic bug distinguishable from a pin-wiring
or clock-configuration bug.
The firmware-labs project combines this rule
with a target, memory map, runtime, and entry function. The full image
follows; the build reference keeps its exact
files and reproduction steps.
The target and the layout answer different questions
The thumbv7m-none-eabi target
selects Armv7-M instructions, a bare-metal environment, and a calling
convention for Cortex-M3. It does not select board pins or a memory map.
memory.x declares the regions for the runtime’s
link.x: ORIGIN gives the starting address; LENGTH gives the capacity.
#![no_std]
uses Rust’s core without the OS-oriented standard library. Arithmetic,
structs, borrowing, arrays, slices, Option, and Result still work; OS
files, threads, and terminal printing do not appear automatically. Vec
needs allocation support; this controller does not.
#![no_main] removes the normal application entry arrangement. The runtime’s
#[entry] marks the function called after startup. -> ! means it never
returns: there is no desktop caller waiting.
Semihosting asks a supporting debugger or emulator to perform host-side
operations. Here cortex-m-semihosting
sends text to QEMU’s terminal, without a guest OS or emulated UART. This is
the full src/main.rs:
#![no_std] // Use core without an operating system's standard library.#![no_main] // Let cortex-m-rt supply the reset/startup entry arrangement.use core::{fmt, fmt::Write, panic::PanicInfo};use cortex_m_rt::entry;use cortex_m_semihosting::{debug, hio};use gha_firmware_labs::LampController;// These samples stand in for a button; they are not GPIO reads.fn run_trace() -> Result<(), fmt::Error> { let mut output = hio::hstdout().map_err(|_| fmt::Error)?; let mut lamp = LampController::new(); for (index, pressed) in [false, true, true, false, true, false] .into_iter() .enumerate() { let on = lamp.sample(pressed); writeln!(output, "sample {}: pressed={pressed}, lamp={on}", index + 1)?; } Ok(())}#[entry]fn main() -> ! { // Tell QEMU whether the trace completed; no guest OS exits this program. let status = match run_trace() { Ok(()) => debug::EXIT_SUCCESS, Err(_) => debug::EXIT_FAILURE, }; debug::exit(status); // QEMU-only: replace this in physical-board firmware. // The entry point must never return, even if the exit request returns. loop { core::hint::spin_loop(); }}#[panic_handler]fn panic(_information: &PanicInfo) -> ! { debug::exit(debug::EXIT_FAILURE); // A panic makes this emulator run fail. loop { core::hint::spin_loop(); }}
#![no_std] // Use core without an OS standard library.
#![no_main] // The runtime supplies reset/startup.
The build reference contains the exact
installation and run commands. Linking produces an ELF image with
sections and addresses, at target/thumbv7m-none-eabi/debug/lamp-firmware.
Debug metadata can enlarge that file without occupying flash. Its output is:
sample 1: pressed=false, lamp=false
sample 2: pressed=true, lamp=true
sample 3: pressed=true, lamp=true
sample 4: pressed=false, lamp=true
sample 5: pressed=true, lamp=false
sample 6: pressed=false, lamp=false
The held press stays on. QEMU exits successfully; the computer’s OS runs
the tools while the loaded firmware has no guest OS.
A HAL (hardware abstraction layer) supplies operations such as reading
pins while peripheral-access code reaches hardware registers. Choose the
exact board’s documented example; the physical-board reference
keeps the complete configuration, polarity, and programming order.
Replace fixed inputs with debounced observations and QEMU exits with a
board-appropriate failure policy. Polling repeatedly checks input;
an interrupt runs a handler for a configured event. Shared controller
state needs synchronization; this example has one owner. A watchdog
can reset a stalled device unless firmware services it, requiring an
explicit recovery policy.
The library checks the logical rule; QEMU exercises the compiled ARM image
and startup. Neither proves physical wiring or electrical behavior.
Breakpoints stop the processor while external devices can continue.
Timers, messages, and watchdogs may behave differently; printing also
slows loops. Semihosting output does not measure normal execution speed.
Compare timing with logging disabled. The troubleshooting reference
keeps the observation checklist and transport tradeoffs.
Use these references to reproduce the emulated run or adapt the rule to
physical hardware. The complete working firmware remains above.
Optional build reference: project files, QEMU runner, and commands
Emulator (QEMU)
The complete firmware-labs project contains these
files. Keep their names and directory arrangement, including the hidden
.cargo directory:
Directoryfirmware-labs/
Cargo.toml
Cargo.lock
Directory.cargo/
config.toml
memory.x
build.rs
Directorysrc/
lib.rs
main.rs
src/lib.rs contains the controller and its tests.
Cargo.toml declares the project and its runtime
dependencies; Cargo.lock pins the full dependency
set. build.rs is a small helper that makes the memory
map available to the linker. It runs on your computer during the build, so its
use of std does not require an OS on the microcontroller.
#![no_std]/// A lamp that toggles once per new button press, not once per held sample./// Inputs are logical pressed/released states, already debounced by the caller.pub struct LampController { lamp_on: bool, was_pressed: bool,}impl LampController { /// The lamp and previous button state both start off at each new boot. #[must_use] pub const fn new() -> Self { Self { lamp_on: false, was_pressed: false, } } /// Accept one sample and return the output the lamp should now use. pub fn sample(&mut self, pressed: bool) -> bool { // A rising edge is a change from released to pressed. if pressed && !self.was_pressed { self.lamp_on = !self.lamp_on; } self.was_pressed = pressed; self.lamp_on }}impl Default for LampController { fn default() -> Self { Self::new() }}#[cfg(test)]mod tests { use super::LampController; #[test] fn holding_and_releasing_do_not_create_extra_toggles() { let mut lamp = LampController::new(); let samples = [false, true, true, false, true, false]; let expected = [false, true, true, true, false, false]; for (pressed, wanted) in samples.into_iter().zip(expected) { assert_eq!(lamp.sample(pressed), wanted); } } #[test] fn a_new_controller_does_not_retain_the_last_boots_state() { let mut first_boot = LampController::new(); assert!(first_boot.sample(true)); let mut second_boot = LampController::new(); assert!(!second_boot.sample(false)); assert!(second_boot.sample(true)); }}
You can obtain the source files from those links and the configuration link
below, or use the
authored project folder.
The project uses Rust 2024 and requires Rust 1.85 or later.
The project’s .cargo/config.toml selects
the target, passes -Tlink.x to the linker, and tells Cargo how to run the image:
[build]
target = "thumbv7m-none-eabi"
[target.thumbv7m-none-eabi]
rustflags = ["-C", "link-arg=-Tlink.x"]
runner = [
"qemu-system-arm",
"-cpu", "cortex-m3",
"-machine", "lm3s6965evb",
"-nographic",
"-semihosting-config", "enable=on,target=native",
"-kernel",
]
Cargo appends the built executable’s path after -kernel. QEMU creates the
selected machine and loads that image. -nographic uses terminal output;
the semihosting option enables the reporting mechanism described next. This
configuration follows the
Embedded Rust QEMU setup.
Install stable Rust with rustup and an ARM system build of QEMU. The
QEMU downloads page provides platform-specific
installation options. On Windows, make sure the folder containing
qemu-system-arm.exe is on PATH.
Open a terminal inside firmware-labs/, so Cargo reads this project’s
configuration, then run:
Terminal window
# Install Rust's libraries for the Cortex-M3 target.
rustuptargetaddthumbv7m-none-eabi
# Compile and link the image using the supplied dependency versions.
cargobuild--locked--binlamp-firmware
# Start the same image using the QEMU runner in .cargo/config.toml.
The Rust target is thumbv7m-none-eabi. It describes the Armv7-M architecture,
bare-metal environment, and calling convention suitable for this Cortex-M3.
It does not identify which pins the board wires to an LED or where a different
chip keeps its flash. The
Rust target documentation
describes the CPU requirements.
ORIGIN is a region’s starting address; LENGTH is its capacity. The linker
places the program into these regions using the runtime’s link.x script.
A matching CPU instruction set with the wrong memory map still produces the
wrong firmware for a board.
The binary begins with #![no_std] and #![no_main].
no_std
uses Rust’s core library without automatically linking the OS-oriented
standard library. Arithmetic, structs, borrowing, arrays, slices, Option, and
Result still work. File access, OS threads, and ordinary terminal printing
do not appear automatically. Dynamic collections such as Vec require
additional allocation support; our controller does not need them.
no_main disables the normal application entry arrangement. The
#[entry] attribute from cortex-m-rt marks the function that startup calls.
Its -> ! means it never returns: there is no desktop caller waiting for a
return value.
Semihosting lets target code ask a supporting debugger or emulator to
perform host-side operations. Here it sends text to QEMU’s terminal. That is
neither an emulated UART nor Windows providing println! to a guest process.
The cortex-m-semihosting interface
provides the output stream used in the complete main file above.
fmt::Write supplies the writing operation used by writeln!.
Opening the stream or writing can fail, so run_trace returns a Result and
? propagates an error. The entry function converts that result into an
emulator success or failure request. Formatting uses the fixed samples and
controller state; it needs no growing string buffer.
A panic_handler
defines what this no_std application does if it panics. This one makes the
emulator run fail. Physical firmware needs its own failure policy, such as
placing outputs into a safe state and halting or restarting. Both exit calls
in this file are QEMU test instrumentation, not a physical-device shutdown
procedure.
The image is an ELF executable at
target/thumbv7m-none-eabi/debug/lamp-firmware. ELF carries sections, addresses,
and other information a loader or debugger can use. The file’s total size is
not the flash usage: debug metadata can occupy space in the file without being
loaded into the microcontroller’s flash.
QEMU then exits successfully. The two consecutive true inputs leave the lamp
on, which is the essential difference from toggling on every held sample.
Some QEMU versions also print Timer with period zero, disabling; that model
message is separate from the firmware trace.
The computer’s OS runs Cargo and QEMU. The emulated firmware has no guest
OS. Its startup, control logic, and output requests all execute as part of
the loaded image.
Optional build reference: adapt to an exact physical board
The control rule is reusable; the board configuration is specific. Start with
the exact development board’s documented Rust board-support or HAL
(hardware abstraction layer) example. A HAL presents operations such as
reading a pin or configuring a timer while handling many register details.
Underneath it, peripheral-access code ultimately reads and writes hardware
registers, the mechanism introduced in
device registers.
Adapt the project in this order:
Identify the actual chip and board. Select their CPU target, memory map,
pin assignments, and supported programming interface. Two Cortex-M boards
can require different targets, flash addresses, clocks, and peripherals.
Initialize the hardware. Configure clocks, an input pin with the right
pull resistor, and a suitable output. A pull-up or pull-down resistor gives
an unpressed input a defined electrical level instead of leaving it floating.
Convert high/low into logical pressed/released meaning: active-low means
a low electrical level represents a press. Its logical false → true press
can therefore correspond to an electrical high-to-low transition.
Replace the trace with real observations. Debounce the input, call
sample, and apply its result to the output. A timer can set the sampling
cadence. A real loop continues working rather than ending after six inputs.
Replace emulator-only behaviour. Remove the QEMU runner and both
semihosting exit calls. Choose board output and a panic policy that work
without QEMU. Logging through a debugger is an optional development aid.
Rebuild, program, verify, reset, and observe. Use the board’s supported
bootloader or a compatible debug probe. A flashing tool erases/programs the
required regions; verification checks the written image. Reset tests the
startup path, and the real output tests pin configuration and behaviour.
probe-rs is one programming and debugging
tool for supported chips and probes. A board can instead document a vendor
tool, a USB bootloader, or OpenOCD. Use its exact target configuration; the
QEMU build above is not an image for arbitrary boards.
Polling checks an input repeatedly; an interrupt runs a handler when a
configured event occurs. Either design can work. If an interrupt and the main
loop both touch controller state, they need an explicit synchronization policy.
The simple controller above has one owner and introduces no such shared access.
A watchdog is a timer that can reset a stalled device unless firmware
services it correctly; its configuration and recovery policy matter on real
hardware, rather than being a cure for an unexplained bug.
Optional build reference: hardware observations and logging transports
Firmware debugging has two places to inspect: the rule and its hardware
integration. The library tests in the project check that holding a press does
not toggle repeatedly and that a fresh controller starts with fresh state.
They do not establish that a physical button is wired correctly. The QEMU run
tests the compiled ARM image and its startup, but does not test a real board’s
electrical behaviour.
When a real device behaves differently, record the chip, board revision,
toolchain, image version, and input conditions. Compare the intended state
transition with the observed one. Then check the boundary responsible for that
input or output: pin polarity, debounce timing, timer rate, or register setup.
Changing the control rule to compensate for a wiring mistake hides the cause.
Breakpoints stop the processor, while external devices can keep running;
timers, incoming messages, and watchdogs may therefore behave differently.
Printing can also slow a loop. Semihosting is convenient for this short trace
but unsuitable as a measurement of normal execution speed. Other transports
have different requirements:
A serial peripheral sends bytes through configured pins to a compatible receiver or adapter
RTT (Real Time Transfer)
The target writes a RAM buffer that a supporting debug probe reads
ITM/SWO tracing
A supported Cortex-M Instrumentation Trace Macrocell emits trace events through Serial Wire Output to a compatible probe; CMSIS documents this debug path
RTT’s buffer policy matters too: a full blocking
buffer can pause the program, while a nonblocking mode can discard output.
Buffering or deferring text formatting reduces work on a time-sensitive path
but does not make it free. Record only the events needed
to distinguish the competing explanations, and compare behaviour with logging
disabled when investigating a timing problem.
The image joins reset, memory, and control logic. Console architecture
extends those startup responsibilities into a larger boot chain.
Lesson 13.6
How Game Consoles Are Built
Open a current game console and you find parts a PC builder would recognise: a
processor, a graphics processor, memory, an SSD, and a network adapter. The
PlayStation 5 and the Xbox Series X even run the same x86-64 instruction set as
most PCs. Yet a PC runs whatever its owner installs, while a console runs only
what its maker has approved. Both facts come from design choices, and this
lesson follows them from the chip up.
Firmware and Bare-Metal Rust established how device code starts
and uses its memory. A console adds a larger software stack and checks which
images are allowed to run at each stage.
Two things in that table differ from a typical gaming PC.
First, the CPU cores and the GPU sit on the same piece of silicon. A chip that
holds most of a computer’s parts is called a system on a chip, or SoC.
Second, the word “shared” in the memory column. A gaming PC gives the CPU
system RAM on the motherboard and gives the graphics card its own video RAM, so
anything the GPU needs, such as the textures and vertex buffers of
Chapter 7, is copied across the PCI Express bus. In a console,
the CPU and the GPU use one pool of memory: the CPU writes a buffer, and the GPU
reads the very same bytes. This design is called unified memory.
PC: system RAM, a copy, then video RAMThe CPU and the GPU have separate memories, so data has to be copied from one to the other.
1The CPU fills a buffer in system RAM
CPU side, then GPU side
buffersystem RAM
emptyvideo RAM
The data starts on the CPU’s side.
2It is copied to video RAM; the GPU reads that copy
↓ copy
CPU side, then GPU side
buffersystem RAM
copy of the buffervideo RAM · GPU reads here
The copy costs time, and changes the CPU makes afterwards are not seen until the next copy.
All 2 steps shown
The CPU and the GPU have separate memories, so data has to be copied from one to the other.
Console: CPU and GPU share one memoryOn one SoC both processors use the same memory, so one buffer serves both.
1The CPU writes the shared buffer
One SoC
CPUwrites
GPU
shared bufferone memory
There is one buffer, in memory both processors can reach.
2The GPU reads the same buffer
One SoC
CPU
GPUreads
shared bufferno copy
No copy is needed, because both use the same bytes.
All 2 steps shown
On one SoC both processors use the same memory, so one buffer serves both.
The shared pool has to satisfy its hungriest reader, the GPU, so consoles build
it from graphics memory. The PlayStation 5’s memory bus is 256 bits wide, one
wire per bit, and each wire carries 14 billion bits per second. That is
256 × 14 = 3,584 billion bits per second, and dividing by 8 bits per byte gives
448 GB every second, for the CPU and GPU alike.
The third difference is not in the table because it is the table: every
PlayStation 5 has the same chip, the same memory, and the same clock speeds. A
PC game has to run on thousands of combinations of parts; a console game is
tuned for exactly one. At 60 frames per second, each frame gets
1,000 ms ÷ 60 ≈ 16.7 ms for all of its work, from game logic and physics to
rendering (Lesson 4.1). A measurement on one console holds for
every console, so developers can spend that budget almost to the last
millisecond. They can even compile their shaders ahead of time for the one GPU
and ship the results on the disc, a luxury that emulators lack, as
Lesson 13.9 shows.
Sharing the PC’s instruction set does not make a console game a PC game,
though. Its machine code calls the console’s own operating system and graphics
interfaces, such as a console version of Direct3D 12 on Xbox and Sony’s own
interfaces on PlayStation, which is why moving a console game to PC is still
porting work.
The software stack: a hypervisor and two partitions
When Microsoft introduced the Xbox One in 2013, it described the console as
running three operating systems at once: a hypervisor that owns the hardware, a
game partition that runs the current game, and a Windows-based partition for
the home screen and apps. Other consoles differ in the details, but the same
shape keeps appearing:
A partition here is a virtual machine in the sense of
Lesson 13.3: an operating system that sees only the cores,
memory, and devices the hypervisor gives it. The split pays for itself three
ways:
Predictable timing. The game partition has CPU cores and memory reserved
for it, so a background download cannot steal time from a frame. With 16.7 ms
per frame, a background task that takes 2 ms at the wrong moment is a visible
stutter.
Containment. A bug in a game, such as a crash caused by a malformed save
file, stays inside the game partition. The system partition’s memory, where
account tokens and purchase records live, is not even mapped into the game’s
address space.
Independent updates. The system software can change without touching the
game partition that thousands of shipped games depend on.
Most console chips also contain a small, separate security processor. It
starts before the main cores, holds the console’s keys, and performs the checks
described next. The main cores never see those keys.
When a console powers on, which code runs first, and why should anyone trust it?
Secure boot answers with a chain of trust: each stage checks the digital
signature (Lesson 10.8) of the next stage before running it, and
refuses if the check fails.
An authentic image can still be too old. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
read the selected inputs
evaluate each stated condition
combine the required conditions
perform the effect only when accepted
otherwise retain the old state
report the result and its limits
Result: this image passes both illustrated boot checks.
Watch it happenVerify each image before transferring execution
Factory boot ROM checks an image key against fused trust, verifies the signed header and code, and accepts security version 3 against minimum 3. Code enters the bootloader only after those checks. The loader checks privileged code, kernels check system components, and system services check a game signature and licence before decrypting and starting the game.
Read the steps
Power on begins in factory-written boot ROM. The bootloader image is stored but not executing. Its example key and signature each occupy 2,048 divided by 8, or 256 bytes.
Hash the public key supplied with the image. Compare that result with the unchangeable fused key hash. A different key would stop the boot; a matching hash allows signature verification to continue.
Hash the signed header and code, then check the signature with the trusted key. Check security version 3 against the three set fuses: 3 is at least the minimum 3. Both checks must pass before any jump.
Only now does the ROM enter the verified bootloader. The loader reads the next privileged image, hashes its signed contents, and verifies its signature before starting the hypervisor and kernels.
Enter the verified privileged code. The hypervisor and kernels check the system components they load. The next code image remains data until its check succeeds.
The verified system services begin. Before starting the game, they check the package signature and licence, then decrypt the accepted package. A valid signature alone does not supply a licence.
The accepted game code enters the execution path. Every later stage ran only after an earlier trusted stage checked it. A failed check would have taken the refusal route instead.
The image sizes and fuse values are the lesson’s example. Code blocks move into the execution path only after their checks pass.
The first link is the boot ROM, a small program written into the chip when
it is manufactured, which no software, update, or owner can change. It is the
root of trust: every later stage is trusted only because an earlier stage
checked it, and the chain ends at the one piece of code that needs no check
because nobody can alter it.
Each check is a digital signature, the same mechanism Windows uses on programs:
the console maker signs every stage with a private key kept in its own
buildings, and the console checks each signature with the matching public key,
which can check signatures but cannot make them
(Lesson 10.8 walks through
how). The boot ROM needs that public key to check the first signature, and the
key must be as unchangeable as the ROM. Chips can keep such values in
eFuses: physical bits set once during manufacturing and then locked against
ordinary software writes. Lesson 13.7
returns to how those bits can also lock a hardware debug port. A 2,048-bit RSA
public key would need 2,048
of them. Chips store a SHA-256 hash of the key instead, which is 256 bits and so
256 fuses, and the key itself travels in flash storage alongside the bootloader:
security version3
code length65,536
public key256 bytes
code65,536 bytes
signature256 bytes
header
its hash must match the fuses
the next stage
signs the header and code
A boot image in flash. With RSA-2048, the key and the signature are each 2,048 bits, which is 2,048 ÷ 8 = 256 bytes.
Before it runs a single instruction of the bootloader, the boot ROM:
hashes the public key that came with the image and compares the result with
the fused hash, since a mismatch means someone supplied their own key;
hashes the header and the code;
checks the signature against that hash using the now-trusted key;
checks the security version, described in the next section;
only then jumps to the code.
Each later stage repeats the pattern for the stage after it, with keys that the
earlier, already-checked stage vouches for. A bug in any later stage can
therefore be fixed by an update, because the stage before it checks the
replacement’s signature like any other. A bug in the boot ROM cannot be fixed on
consoles already sold: nothing checks the ROM and nothing can rewrite it, so
only a new revision of the chip fixes it. This has happened to at least one
console family, whose early units remain modifiable for that reason alone.
A signature proves who made the code, not that it is the latest version. If
version 4 of the bootloader fixes a security hole in version 3, version 3 is
still genuinely signed, and without another check anyone could reinstall it and
use its hole.
Consoles stop this with anti-rollback, a security version counter kept in
fuses. Each boot image carries a security version, and the boot code refuses any
image whose version is below the number of blown fuses:
fuse 01
fuse 11
fuse 21
fuse 30
fuse 40
fuse 50
fuse 60
fuse 70
A bank of eight anti-rollback fuses. Three are blown, 1 + 1 + 1 = 3, so the minimum accepted security version is 3.
When an update fixes a security hole, it raises the security version to 4 and
blows fuse 3, and from then on a version-3 image is refused. A fuse cannot be
unblown, so the minimum only ever rises. And because a bank of eight fuses can
count only to 8, makers raise the security version only for updates that fix a
security flaw, not for every update: each increase spends a fuse forever.
The chain does not stop at boot. Everything a console runs, including system
updates, apps, and every game, must carry the platform holder’s signature, which
the system software checks before launching it. Unsigned code simply does not
run. On Windows, by contrast, an Authenticode signature
(Lesson 10.8) is optional for ordinary programs, and an unsigned
program runs after a warning, because the owner decides.
Game packages are also encrypted, and the two protections do different jobs
(Lesson 10.10). The signature proves that the package came,
unchanged, from the platform holder. The encryption hides the contents, so the
code cannot be read, or copied to run elsewhere, outside a console holding the
right keys. Those keys stay inside the security processor, which decrypts
content for the game without ever handing the keys to the game or to the system
software. Digital purchases add a licence check before anything is decrypted.
Signing everything has a consequence for memory. Code that a program writes for
itself at run time was never signed, so consoles generally do not let ordinary
games mark memory as both writable and executable, the combination that
Lesson 6.5 treats as a place where data can become code. That
rule returns in Lesson 13.9,
because an emulator’s fastest technique needs exactly that combination.
firmware whose Secure Boot keys the owner can change or switch off
a boot ROM and a fused key hash that nobody can change
Can the owner debug a running game?
yes, as Chapters 2 and 5 do
no; that needs a development kit
Who holds the storage keys?
the owner, if the disk is encrypted at all
the security processor, never software
Can old system software be reinstalled?
usually
no; anti-rollback fuses refuse it
The deeper difference is whose interests the protections serve. A PC’s security
defends its owner against other people: malware, other users, network
attackers. The owner is the administrator and may switch any of it off. A
console’s security also defends the platform holder and game publishers against
the owner: against copying games, against cheating in online play, and against
running unapproved software. So a console is built so that its owner cannot
switch the protections off, down to debug ports locked with fuses
(Lesson 13.7).
Game developers still need to debug. Licensed studios receive development
kits: consoles with debugging enabled and different keys, which run unsigned
test builds. A retail console lacks those permissions by design.
An online service decides whether to let a console into matchmaking, the store,
and cloud saves. It cannot look inside the console, so it needs the console to
prove what software it booted. That proof is called remote attestation:
As each boot stage runs, it records a hash of the next stage before starting it.
Those hashes, called measurements, describe exactly what booted. The
security processor signs the service’s nonce together with the measurements,
using a key unique to that console, fused at the factory, that no software can
read. The service checks the signature and compares the measurements with the
values genuine software produces. Console makers do not publish their exact
protocols, but PCs with a TPM security chip use the same idea, called measured
boot.
A console running modified software cannot produce a valid report. The modified
software cannot reach the key, and it cannot make the measurements match
software it is not. Such a console can run cheats in matches with other people,
run pirated games, and send messages that genuine software never would, so the
service trusts nothing it says and may ban it. Attestation is still only one
layer: no check on a client is perfect, so the server goes on checking every
action. Lesson 15.5
follows the ban as one enforcement decision among the other layers.
Homebrew is software written by hobbyists for a platform without the
platform holder’s licence: games, tools, media players, and emulators. Because
a console runs only signed code, unofficial homebrew depends on a flaw somewhere
in the chain of trust, and its legal position is contested. Running pirated
games is copyright infringement however the console was modified. This book
stays out of that territory: none of its labs modify a console. Lesson 13.9 studies console
software from the other side, with an emulator running on your own PC and
programs you are allowed to run.
Optional reference: official homebrew, circumvention law, and the arguments on each side
Homebrew’s legal position is worth describing precisely rather than taking a
side.
Platform holders have sometimes opened official doors. In the late 1990s Sony
sold the Net Yaroze, a black PlayStation that hobbyists could program. The
PlayStation 3’s “Other OS” feature let owners install Linux until a 2010 system
update removed it. Microsoft’s Developer Mode turns a retail Xbox into a limited
development kit for apps, for anyone with a developer account.
Platform holders treat exploiting a flaw in the chain of trust as
circumvention. They patch it, ban consoles detected online running modified
software, and have sued, and in some cases seen prosecuted, people who sold
modification devices commercially. Many countries have anti-circumvention laws,
such as section 1201 of the US Digital Millennium Copyright Act and Article 6 of
the EU’s 2001 Copyright Directive. These make it unlawful to break technical
measures that protect copyrighted works, with exemptions that vary by country
and by kind of device.
On the other side, homebrew developers and some researchers argue that owners
should be able to run their own code on hardware they bought, and that
preserving old software depends on it. Where the line falls depends on the
country, the device, and exactly what is done, and this lesson is not legal
advice.
Lesson 13.7
Hardware Debugging with JTAG
Every debugger in this book so far has been a program asking other software for
permission. The debugger of Lesson 6.10 asks Windows to pause a
thread, and Windows agrees because the debugger holds the right access.
Lesson 12.4 showed the processor enforcing a boundary between
user mode and the kernel, and Lesson 13.3 put a hypervisor
underneath the kernel. This lesson goes below all of them, to a debugger that
asks no software at all, because it is wired into the chip.
A console’s boot and signing rules can restrict software tools. A physical
debug port crosses a different boundary, which is why shipped devices usually
lock it too. To see how the port gained such reach, start with its original
job: finding bad solder joints.
A circuit board is a sheet of fibreglass with copper wires, called traces,
printed on it. Chips are soldered on top, and every pin of every chip must make
a good electrical joint with its trace. A factory building thousands of boards a
day always produces some bad joints: a blob of solder that bridges two pins, or
a pin that never touches its pad. Every connection has to be checked before a
board ships.
For decades the check was a bed of nails: a fixture of spring-loaded metal
pins pressed onto test points all over the board, so a tester could drive a
signal into one end of a trace and measure it at the other. That approach
struggled in the 1980s as surface-mounted chips packed their pins closer
together, and it became hopeless once packages hid their connections
underneath. A ball grid array, for example, joins the board through a grid of
solder balls on its underside, and once it is soldered down no probe can reach
them.
In 1985 a group of electronics companies formed the Joint Test Action Group to
solve the problem. Their design was standardised in 1990 as IEEE 1149.1, and
everyone calls it JTAG after the group. The idea was to stop probing the
board from outside and build the probes into the chips.
A chip that supports JTAG places a small circuit called a boundary-scan cell
between each pin and the chip’s internal logic. In normal operation the cell is
transparent: signals pass straight through, and the chip behaves as though the
cell were not there. In test mode the cell can do two other things:
capture: record the level currently on its pin, 1 or 0;
drive: put a chosen level onto the pin, overriding the chip’s own logic.
The cells are wired one after another into a shift register: a row of
one-bit cells in which, on every clock tick, each cell takes its neighbour’s
value, so a new bit enters at one end and the bit at the far end falls out. The
captured levels can therefore leave one at a time through a single wire while a
new pattern arrives through another. This row is the chip’s boundary-scan
register, and Lesson 13.8
shifts a four-pin chip’s register one clock at a time.
Chips on the same board are linked the same way, the output wire of one chip
feeding the input wire of the next, so the whole board forms one long shift
register called a scan chain.
The JTAG connection on each chip is called the Test Access Port, or TAP.
It uses four signals:
Signal
Name
Direction
Job
TCK
Test Clock
into the chip
every action happens on a tick of this clock
TMS
Test Mode Select
into the chip
steers the port from one state to the next
TDI
Test Data In
into the chip
bits enter the chain here
TDO
Test Data Out
out of the chip
bits leave the chain here
An optional fifth signal, TRST (Test Reset), resets the port directly.
Every chip receives the same TCK and TMS, while the data runs in series, from
the debugger’s TDI through each chip in turn and back from the last chip’s TDO:
Two chips on one JTAG chainThe clock and mode lines go to both chips at once. The data goes through them one after the other.
1Data runs through the chips in series
Data path, debugger TDI to debugger TDO
TDIdebugger
Chip A
Chip B
TDOdebugger
TDI feeds chip A, chip A feeds chip B, and chip B returns the data on TDO. Each clock moves every bit one place along the chain.
2TCK and TMS reach both chips in parallel
TCK and TMS, in parallel
Debugger
Chip A and Chip B at the same time
One clock and one mode line branch to every chip, so all of them step together.
All 2 steps shown
The clock and mode lines go to both chips at once. The data goes through them one after the other.
Testing the thick trace takes two passes. Load a 1 into the cell on chip A’s
pin and switch A’s cells to drive; switch chip B’s cells to capture; then shift
everything out and look at the cell on B’s pin. A good joint reads 1. Then repeat
with a 0, because a pin accidentally joined to the power supply would read 1
whatever A drove. The two patterns together catch a trace stuck at 1 and a trace
stuck at 0. Test software computes patterns that check every trace on a board at
once.
Captured pin bits leave through one wire. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
capture selected 4-bit pin pattern
out = register & 1
register = register >> 1
put incoming bit in position 3
repeat for four clocks
report outgoing bits and new register
Result: outgoing 1011, new register=0000. The original pattern left low bit first.
The debugger has one wire, TMS, for giving orders, yet it needs to say “capture
now”, “shift”, and “update”. The chip solves this with a small state
machine called the TAP controller: a circuit that is always in exactly one
of a fixed list of states. On every tick of TCK it moves to one of two next
states, and the level of TMS, 0 or 1, picks which, so a sequence of levels on
TMS walks the controller wherever the debugger needs it.
Reading or loading a register follows the same main path every time:
Capture loads the register from its inputs: for the boundary-scan
register, the levels on the pins.
Shift moves the register one place toward TDO per clock, so a 32-bit
register takes 32 clocks, its old contents leaving through TDO as new bits
arrive from TDI.
Update copies the new pattern to the register’s outputs in one step, so
the pins never see the in-between patterns the register passes through while
bits ripple along.
The path above exists twice: once for a data register, such as the
boundary-scan register, and once for the instruction register, described
next. Lesson 13.8 draws all
16 states, including the ones that choose between the two copies and pause a
scan, and follows them one clock at a time.
One more property makes the controller impossible to lose: from any state, five
clocks with TMS at 1 reach Test-Logic-Reset, as Lesson 13.8 checks state by
state. A debugger unsure where the controller is sends five 1s on TMS and starts
again from a known place.
Instructions choose which register sits in the chain
Scanning through the instruction register’s copy of the path loads each chip’s
instruction register, and the instruction decides which data register the data
copy works on. The standard requires a few instructions and leaves the rest to
each chip’s designer:
BYPASS selects a one-bit register. It shrinks a chip to a single bit in
the chain, so the debugger can reach other chips without shifting through this
one’s long registers.
EXTEST and SAMPLE/PRELOAD select the boundary-scan register for board
tests like the one above.
IDCODE is optional but nearly universal. It selects a 32-bit register
holding a number that identifies the chip: its manufacturer, its part number,
and its version. A chip that has one selects it automatically on reset.
Those two small registers let a debugger explore a board it knows nothing
about. Right after reset, one data scan reads the ID of every chip that has one.
And with every chip in BYPASS, each chip adds exactly one bit to the chain, so
timing a single 1 from TDI to TDO counts the chips.
Lesson 13.8 does
both on a two-chip board.
The standard left room for chip designers to add private instructions and data
registers, and designers used that room to connect the TAP to on-chip debug
logic. Through the same four wires, a debugger can then:
halt the processor core, stopping it between two instructions whatever it
is running, the kernel included;
single-step, running exactly one instruction and halting again;
read and write the core’s registers, including the program counter;
read and write memory, through a debug unit that issues reads and writes
on the chip’s internal bus exactly as the CPU does;
set hardware breakpoints and watchpoints, comparators that halt the core
when it fetches from, or accesses, a chosen address. They work like the x86
debug registers of Lesson 2.4, except that they are programmed
from outside the chip.
The difference from Chapter 5 is where the request goes. A Windows debugger asks
the operating system to pause one thread. A JTAG debugger stops the whole
processor, and the operating system has no say, because it is no longer running.
Arm’s Cortex-M processors, found in a great many microcontrollers, the
single-chip computers inside things like keyboards and game controllers, show
how ordinary the mechanism is: one memory write into a debug control register
stops the core before its next instruction.
Lesson 13.8 follows
how the debugger makes that write and builds its value bit by bit.
Four signals, or five with TRST, are a lot for a small microcontroller with
twenty pins. Arm designed Serial Wire Debug, SWD, which needs two:
SWCLK, a clock, and SWDIO, a single data wire that the debugger and the chip
take turns driving. Instead of walking a state machine, SWD sends packets.
Every transaction begins with an 8-bit request that names a register and says
whether it will be read or written. The wire then turns around, and the chip
answers with a 3-bit acknowledgement: OK, WAIT, or FAULT. A read then delivers
32 data bits and a parity bit, an extra bit chosen to make the number of 1s
even, so that a single bit damaged on the wire shows up as an odd count:
request8 bits
turnaround
ack3 bits
data32 bits
parity1 bit
turnaround
sent by the debugger
sent by the chip
One SWD read on the single data wire, in time order from left to right. During each turnaround neither side drives the wire, so it can change hands.
Many chips offer both protocols on shared pins, with SWDIO on the TMS pin and
SWCLK on the TCK pin, and a special bit sequence switches the port from one to
the other. Behind either protocol sits the same debug port, so halting,
stepping, and memory access work the same way.
Lesson 13.8 builds the first request a
debugger usually sends, bit by bit.
Everything above needs one thing: an open debug port. The debug logic does not
care what software the chip runs. A debugger that can halt the core and read
memory can read whatever that memory holds, such as encryption keys, a game
console’s decrypted code, or a phone owner’s data. It can also write memory,
so it can change code that secure boot
(Lesson 13.6) checked a moment
earlier. The protections of Chapters 11 and 12 — privilege levels, memory
protection, the kernel boundary — are all enforced by the processor that the
debugger has just stopped.
So production devices close the port, in layers from weakest to strongest:
Leave out the connector. Makers often omit the header and leave unmarked
pads. That only adds effort, because the traces are still there.
Turn debug off in early boot code. The first software to run disables the
debug logic. It protects only from that moment on, and only if nothing can run
earlier.
Blow a fuse. An eFuse is a one-time programmable bit: a tiny link on
the chip that the factory can change once, from 0 to 1, and that nothing can
change back. Hardware reads the fuses at power-on, before any software runs,
and a fuse that says “debug disabled” keeps the debug logic off. Many
microcontrollers offer levels: fully open, memory reads blocked while a
debugger is attached, and debug permanently disabled.
Require authentication. The port stays present but locked until the
debugger proves it is allowed in.
Secure debug authentication is a challenge and a signed answer:
Each part closes a specific gap. The nonce, a number used once
(Lesson 10.10), makes old answers useless, because a recorded
signature covers a different number. The chip ID makes each answer good for one
unit, so a signature leaked from a repair bench unlocks nothing else. The
private key never leaves the maker, so knowing exactly how the protocol works,
as you now do, does not help anyone forge an answer.
The lock follows the chip through its life. Chips usually leave the chip factory
open for manufacturing tests, are locked as the last step before a product
ships, and may enter a return-for-analysis state, reachable only through
authentication, if a unit comes back broken. Each step is a one-way fuse change.
The hardware that reads those fuses is built to fail closed: designers
assume someone will try to disturb the check itself, so any fuse reading other
than a clean “open” leaves debug off.
A product shipped with its debug port open is a well-known and often repeated
mistake. Fixing it costs almost nothing at the factory and is nearly impossible
once the devices are in customers’ hands. Development boards sold for learning
embedded programming are the opposite case: they leave SWD open on purpose and
document it, because their owners are the developers.
The JTAG lab is in Lesson 13.8: a
simulated two-chip scan chain that you drive one clock at a time.
Lesson 13.8
JTAG Scan Chains and Debug Access
Lesson 13.7 explained what JTAG is for and what each of its
parts does. This lesson drives those parts one clock at a time, from a four-bit
shift register to the packet that opens an SWD session, and ends with a lab that
simulates a two-chip scan chain.
TCK is a clock: a wire that switches from 0 to 1 and back, over and over. The
moment it rises from 0 to 1 is called the rising edge, and the moment it
falls back to 0 is the falling edge. A JTAG chip reads TMS and TDI on the
rising edge and changes TDO on the falling edge. Each wire is read on one edge
and changed on the other, so no wire is ever read at the moment it changes.
The standard also requires TMS and TDI to read as 1 when nothing drives them.
That small rule matters twice below.
JTAG’s shift registers always shift toward TDO: on each clock the cell next to
TDO, bit 0, leaves first, every other bit moves down one place, and the bit
arriving from TDI enters at the top.
Take a small chip with four pins and a 4-bit boundary-scan register, bits b3
to b0. The test captures the pins: pin 0 reads 1, pin 1 reads 0, pin 2 reads
1, and pin 3 reads 1. Each cell now holds its own pin’s level:
b3 · pin 31
b2 · pin 21
b1 · pin 10
b0 · pin 01
TDI enters at b3, TDO leaves from b0
After the capture, each cell holds its pin's level. b0 sits next to TDO, so pin 0's level will leave first.
Now the debugger runs four clocks, sending 1, 1, 0, 0 into TDI, first bit
first. On each clock, TDO receives b0, then b0 takes b1’s value, b1
takes b2’s, b2 takes b3’s, and b3 takes the bit from TDI:
Clock
TDI sends
TDO receives
b3 b2 b1 b0 after the clock
start
1 1 0 1
1
1
1, pin 0’s level
1 1 1 0
2
1
0, pin 1’s level
1 1 1 1
3
0
1, pin 2’s level
0 1 1 1
4
0
1, pin 3’s level
0 0 1 1
Check clock 1: TDO receives b0, which is 1. Then b0 takes b1’s 0, b1
takes b2’s 1, b2 takes b3’s 1, and b3 takes the incoming 1, giving
1 1 1 0. The other rows follow the same rule.
The same four clocks did two jobs. They read the pins out in order, pin 0 to
pin 3, and they loaded a new pattern. The first bit sent travelled furthest and
ended in b0; the last bit sent stopped in b3:
Clock a 4-bit shift registerOn each clock b0 leaves through TDO, every bit moves one place toward TDO, and the TDI bit enters at b3.
Captured pin levels, b3 to b0
After 4 clocks
TDI sent
In order
1clock 1
1clock 2
0clock 3
0clock 4
Shift register
b3 b2 b1 b0
TDI enters at b3b0 leaves to TDO
0b3 · from TDI
0b2 · from TDI
1b1 · from TDI
1b0 · from TDI
TDO received
In order
1pin 0
0pin 1
1pin 2
1pin 3
TDO received 1, 0, 1, 1: pin 0’s level first, because b0 sits next to TDO. The register holds 0 0 1 1 (b3 to b0): the sent 1, 1, 0, 0, with the first bit in b0.
Look at the register after clock 2: 1 1 1 1, a pattern nobody asked for. While
bits ripple through, the register passes through meaningless in-between values.
If the pins followed the register directly, they would flicker through every one
of them. So each cell has a second one-bit store, called a latch, that
actually drives the pin, and the latch copies the shifted value only once the
whole pattern is in place. That copy is the Update step of Lesson 13.7.
Lesson 13.7 followed the main path through the TAP controller: Capture, Shift,
and Update. Here is the whole machine. At every rising edge of TCK it moves to
one of two next states, chosen by whether TMS is 0 or 1. The diagram writes the
16 state names without hyphens; the standard spells them Test-Logic-Reset,
Run-Test/Idle, Select-DR-Scan, Capture-DR, and so on.
The machine has two matching columns. States ending in DR work on a data
register, such as the boundary-scan register; states ending in IR work on the
instruction register. Down each column:
Select chooses a column. With TMS at 0 it enters that column’s Capture
state; with TMS at 1 it moves on, from Select-DR-Scan to Select-IR-Scan, and
from Select-IR-Scan to Test-Logic-Reset.
Capture loads the register’s shift stage from its inputs: the pin levels,
for the boundary-scan register.
Shift moves one bit from TDI toward TDO on every clock, for as long as TMS
stays 0.
Exit1, Pause, and Exit2 leave the shifting, wait if the debugger
needs time to fetch more data, and resume without starting over.
Update copies the shifted-in value to the register’s outputs in one step.
Test-Logic-Reset is the state in which the chip ignores the test logic and works
normally, and Run-Test/Idle is a resting place between operations. Six states
can wait in place for as long as TMS holds steady: Test-Logic-Reset with TMS at
1, and Run-Test/Idle, Shift-DR, Pause-DR, Shift-IR, and Pause-IR with TMS at 0.
Every other state lasts exactly one clock.
Each state does its work on the rising edge that leaves it. The capture
happens on the clock that moves from Capture-DR to Shift-DR, and the last bit of
a scan is shifted by the same clock that raises TMS to leave Shift-DR for
Exit1-DR. To shift 64 bits, the debugger sends 63 clocks with TMS at 0 and then
the 64th with TMS at 1. Getting from a fresh reset to the first shift takes four
clocks:
Clock
TMS
From
To
1
0
Test-Logic-Reset
Run-Test/Idle
2
1
Run-Test/Idle
Select-DR-Scan
3
0
Select-DR-Scan
Capture-DR
4
0
Capture-DR, where the register loads
Shift-DR
Lesson 13.7 said that five clocks with TMS at 1 reach Test-Logic-Reset from any
state. Follow the arrows labelled TMS 1 from Shift-DR, one of the states
furthest away: Exit1-DR, Update-DR, Select-DR-Scan, Select-IR-Scan, and
Test-Logic-Reset, which is five clocks. The reset reference
below checks every other state the same way. Extra clocks do no harm, because
Test-Logic-Reset stays put while TMS is 1, so a debugger that is unsure of the
state sends five 1s on TMS and starts from a known place. And because an
undriven TMS reads as 1, a chip whose test port is not connected to anything
sits in Test-Logic-Reset permanently and works normally.
Optional reference: every state's path to Test-Logic-Reset
To check the rule for all 16 states, hold TMS at 1 and work outward from
Test-Logic-Reset, following the arrows labelled TMS 1:
Starting state
Path with TMS at 1
Clocks
Select-IR-Scan
straight to Test-Logic-Reset
1
Select-DR-Scan
Select-IR-Scan, then as above
1 + 1 = 2
Run-Test/Idle, Update-DR, or Update-IR
Select-DR-Scan, then as above
1 + 2 = 3
Exit1 or Exit2, in either column
that column’s Update, then as above
1 + 3 = 4
Capture, Shift, or Pause, in either column
Exit1 from Capture or Shift, Exit2 from Pause, then as above
1 + 4 = 5
The rows cover 1 + 1 + 3 + 4 + 6 = 15 states, and Test-Logic-Reset itself needs
0 clocks, so all 16 are accounted for and none needs more than five.
A few fixed rules about instruction codes shape how a debugger drives a chain.
BYPASS must be all ones, so a chip with a 4-bit instruction register uses
1111, and the one-bit register it selects always captures 0. A chip with an
IDCODE register selects IDCODE on reset, so no instruction scan is needed before
reading IDs.
Any code a chip does not define must behave as BYPASS. Together with the rule
that an undriven TDI reads as 1, this means a broken chain fails safe: a chip
whose TDI wire is cut receives nothing but ones, which select BYPASS, not a test
mode that drives the pins against the chip’s own outputs.
Instructions are bits in the chain like any others. In one instruction scan the
first bits shifted in travel furthest, so to give every chip its own
instruction, the debugger sends the instruction for the chip nearest TDO first
and the one for the chip nearest TDI last.
A test bit measures a chain of one-bit registers. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
each chip contributes one zero bit
send one 1 through TDI, then zeros
sample the old last cell at TDO
shift every chip from its old neighbor
count zero outputs before the pulse
report the serial chain delay
Result: 2 zero samples precede the pulse, showing 2 serial BYPASS cells in this sampling convention.
A debugger attached to an unfamiliar board can count its chips in two scans.
Reset, then shift plenty of ones through the instruction registers. The
debugger does not know how long each register is, and it does not need to:
every register ends up full of ones, which is BYPASS, and the spare ones fall
out of TDO.
Scan the data registers. Each chip now contributes one bypass bit, which
captured 0. Send a single 1 followed by 0s, and count the clocks until the 1
comes back.
Here is the second scan on a board with two chips, A nearest TDI and B nearest
TDO. On each clock, TDO receives B’s bit, B takes A’s old bit, and A takes the
bit from TDI:
Clock
TDI sends
TDO receives
A’s bit after
B’s bit after
start
0
0
1
1
0
1
0
2
0
0
0
1
3
0
1
0
0
TDI0
chip A0
chip B1
TDO0
the whole data chain: one bypass bit per chip
After clock 2, the 1 sent on clock 1 sits in chip B's bypass bit. On clock 3 it reaches TDO.
The 1 went in on clock 1 and came out on clock 3, so the chain held it for
3 − 1 = 2 clocks. Each one-bit register holds a bit for exactly one clock, so
there are 2 chips.
After reset, every chip with an IDCODE register has it selected, so one data
scan reads every ID on the chain. For two chips that is 32 + 32 = 64 bits. They
come out lowest bit first, and the chip nearest TDO answers first: the first 32
bits belong to chip B.
Bit 0 of every IDCODE is 1, and that is deliberate. A bypass register captures
0, so the first bit from each chip tells the debugger what kind of register it
is reading. A 1 means a 32-bit IDCODE follows; a 0 means a chip with no IDCODE,
sitting in BYPASS, which contributes that single bit and nothing more.
Chip A in this lesson’s lab contains Arm’s JTAG debug port, found in many
microcontrollers, and it answers 0x4BA00477. The 32 bits split into three
fields plus the fixed 1:
31–284
27–24B
23–20A
19–160
15–120
11–84
7–47
3–07
version
part number 0xBA00
manufacturer and the fixed 1
Each box is one hex digit, four bits, labelled with the bit numbers it covers.
Bits 31 to 28 are the first hex digit: version 4.
Bits 27 to 12 are the next four digits: part number 0xBA00.
Bits 11 to 0 are 0x477, which is 4 × 256 + 7 × 16 + 7 = 1,143. Its lowest
bit is the fixed 1. Removing that bit means subtracting 1 and halving:
(1,143 − 1) ÷ 2 = 571. In hex, 571 = 2 × 256 + 3 × 16 + 11 = 0x23B, the
manufacturer code that JEDEC, the industry body that hands out these numbers,
assigned to Arm.
Arm’s Cortex-M processors keep their debug controls at fixed addresses in the
memory map, and their debug logic includes a memory access port that
performs bus reads and writes for the debugger. So halting the core takes one
ordinary memory write through the debug port, and reading RAM afterwards takes
one memory read. The register the halt writes is DHCSR, the Debug
Halting Control and Status Register, at address 0xE000EDF0, described in
Arm’s public architecture manual. Its low three bits are:
Bit
Name
Value of the bit
Effect when written as 1
0
C_DEBUGEN
1
allow halting debug
1
C_HALT
2
stop the core
2
C_STEP
4
while C_HALT is 0, run one instruction and stop
Every write must also carry 0xA05F in the upper 16 bits, or the processor
ignores it. That key is printed in the public manual, so it is not a password.
It stops a stray write from buggy software halting the core by accident.
A 32-bit value has eight hex digits, four bits each, so its upper 16 bits are
the first four digits, and putting 0xA05F there gives 0xA05F0000. Adding bit
values to it:
halt: 0xA05F0000 + 2 + 1 = 0xA05F0003;
step one instruction while halted: 0xA05F0000 + 4 + 1 = 0xA05F0005;
resume: 0xA05F0000 + 1 = 0xA05F0001.
The write travels through that memory access port. The port has an
address register and a data register, called TAR and DRW in Arm’s
documentation: write an address to the first, then read or write the second, and
the port performs that bus access.
The second read fetches the first word of the chip’s RAM, which starts at
0x20000000 on Cortex-M. Core registers come through two more memory-mapped
registers, one to choose a register by number and one to carry its value.
Lesson 13.7 showed the shape of an SWD read: an 8-bit request, a turnaround, a
3-bit acknowledgement, 32 data bits, and a parity bit. The request’s eight bits,
in the order they are sent, are:
Start: always 1.
APnDP: 0 for a debug port register, 1 for an access port register.
RnW: 1 to read, 0 to write.
A2 and A3: two address bits that choose one of four registers.
Parity: makes the count of 1s among APnDP, RnW, A2, A3, and the parity bit
itself even.
Stop: always 0. Park: always 1.
The first request a debugger usually sends reads the debug port’s ID register,
register 0. That needs APnDP = 0, RnW = 1, A2 = 0, and A3 = 0. Those four bits
contain one 1, an odd count, so the parity bit is 1, which makes two:
Start1
APnDP0
RnW1
A20
A30
Parity1
Stop0
Park1
read debug port register 0
evens out the 1s
Sent left to right, one bit per SWCLK tick. As a byte with the first bit as bit 0, it is 1 + 4 + 32 + 128 = 165 = 0xA5.
The bits in the order sent are 1, 0, 1, 0, 0, 1, 0, 1. Treating the first as bit
0, the 1s sit in bits 0, 2, 5, and 7, worth 1, 4, 32, and 128, for a total of
165. In hex that is 10 × 16 + 5 = 0xA5.
The ID comes back in the same format as a JTAG IDCODE. For many Cortex-M chips
it is 0x2BA01477. Split the same way as above, that is version 2, part number
0xBA01 for the serial-wire version of the port, and the low three digits
0x477 once again, so the same Arm manufacturer code, 0x23B.
The portable lab jtag_tap_lab.rs
simulates the board from this lesson: chip A with Arm’s JTAG debug port, which
answers 0x4BA00477, and a made-up sensor as chip B. No hardware is involved.
The four wires are function arguments, and the board is a Vec of chips. Every
chip sees the same TCK and TMS, so their controllers move in lockstep and one
TapState describes them all.
//! JTAG TAP controller lab for Lesson 14.10.//!//! Everything here is simulated. The four JTAG wires are function arguments//! and return values, and the board is two chips wired into one scan chain://! chip A contains an Arm JTAG debug port, and chip B is a made-up sensor.//! The lab models the 16-state TAP controller, each chip's 4-bit instruction//! register, the 1-bit bypass register, and the 32-bit IDCODE register. It//! then drives them the way a debugger does: reset the chain, read every//! chip's ID, and count the chips by timing a single 1 through their bypass//! registers.use std::fmt;/// Every chip on this board has a 4-bit instruction register.const IR_LENGTH: usize = 4;/// Capture-IR always loads `01` into the two lowest bits of an instruction/// register. The other bits are the chip designer's choice; these chips/// capture zeros there.const IR_CAPTURE: u8 = 0b0001;/// The standard fixes BYPASS as all ones: `1111` in a 4-bit register.const BYPASS: u8 = 0b1111;/// The 16 states of the TAP controller, named as in IEEE 1149.1.#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum TapState { TestLogicReset, RunTestIdle, SelectDrScan, CaptureDr, ShiftDr, Exit1Dr, PauseDr, Exit2Dr, UpdateDr, SelectIrScan, CaptureIr, ShiftIr, Exit1Ir, PauseIr, Exit2Ir, UpdateIr,}impl TapState { const ALL: [Self; 16] = [ Self::TestLogicReset, Self::RunTestIdle, Self::SelectDrScan, Self::CaptureDr, Self::ShiftDr, Self::Exit1Dr, Self::PauseDr, Self::Exit2Dr, Self::UpdateDr, Self::SelectIrScan, Self::CaptureIr, Self::ShiftIr, Self::Exit1Ir, Self::PauseIr, Self::Exit2Ir, Self::UpdateIr, ]; /// The state after one rising edge of TCK, given the level of TMS. /// /// Each row gives the next state for TMS low, then for TMS high. Several /// rows share a pair, but one row per state keeps the table easy to /// check against the lesson's diagram. #[expect( clippy::match_same_arms, reason = "one row per state mirrors the state diagram" )] const fn next(self, tms: bool) -> Self { let (tms_low, tms_high) = match self { Self::TestLogicReset => (Self::RunTestIdle, Self::TestLogicReset), Self::RunTestIdle => (Self::RunTestIdle, Self::SelectDrScan), Self::SelectDrScan => (Self::CaptureDr, Self::SelectIrScan), Self::CaptureDr => (Self::ShiftDr, Self::Exit1Dr), Self::ShiftDr => (Self::ShiftDr, Self::Exit1Dr), Self::Exit1Dr => (Self::PauseDr, Self::UpdateDr), Self::PauseDr => (Self::PauseDr, Self::Exit2Dr), Self::Exit2Dr => (Self::ShiftDr, Self::UpdateDr), Self::UpdateDr => (Self::RunTestIdle, Self::SelectDrScan), Self::SelectIrScan => (Self::CaptureIr, Self::TestLogicReset), Self::CaptureIr => (Self::ShiftIr, Self::Exit1Ir), Self::ShiftIr => (Self::ShiftIr, Self::Exit1Ir), Self::Exit1Ir => (Self::PauseIr, Self::UpdateIr), Self::PauseIr => (Self::PauseIr, Self::Exit2Ir), Self::Exit2Ir => (Self::ShiftIr, Self::UpdateIr), Self::UpdateIr => (Self::RunTestIdle, Self::SelectDrScan), }; if tms { tms_high } else { tms_low } }}impl fmt::Display for TapState { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::TestLogicReset => "Test-Logic-Reset", Self::RunTestIdle => "Run-Test/Idle", Self::SelectDrScan => "Select-DR-Scan", Self::CaptureDr => "Capture-DR", Self::ShiftDr => "Shift-DR", Self::Exit1Dr => "Exit1-DR", Self::PauseDr => "Pause-DR", Self::Exit2Dr => "Exit2-DR", Self::UpdateDr => "Update-DR", Self::SelectIrScan => "Select-IR-Scan", Self::CaptureIr => "Capture-IR", Self::ShiftIr => "Shift-IR", Self::Exit1Ir => "Exit1-IR", Self::PauseIr => "Pause-IR", Self::Exit2Ir => "Exit2-IR", Self::UpdateIr => "Update-IR", }) }}/// How many clocks with TMS high it takes to reach Test-Logic-Reset from/// `start`. A walk that has not arrived after 16 clocks is going round a/// loop that never visits it, so the search stops there.fn clocks_to_reset(start: TapState) -> Option<u32> { let mut state = start; for clocks in 0..16 { if state == TapState::TestLogicReset { return Some(clocks); } state = state.next(true); } None}/// One chip's test logic, apart from its TAP controller, which the chain/// keeps for all chips at once.#[derive(Clone, Debug)]struct Chip { name: &'static str, idcode: u32, /// Which instruction selects IDCODE is the chip designer's choice. idcode_instruction: u8, /// The instruction in force. Only Update-IR and reset change it. instruction: u8, /// The instruction register's shift stage. ir_shift: u8, /// The shift stage of the data register the instruction selects. dr_shift: u32,}impl Chip { /// A chip as it comes out of reset, with IDCODE selected. const fn new(name: &'static str, idcode: u32, idcode_instruction: u8) -> Self { Self { name, idcode, idcode_instruction, instruction: idcode_instruction, ir_shift: 0, dr_shift: 0, } } /// IDCODE selects the 32-bit ID register. BYPASS, and every instruction /// the chip does not define, select the 1-bit bypass register. const fn selects_idcode(&self) -> bool { self.instruction == self.idcode_instruction } fn capture_dr(&mut self) { // The bypass register always captures 0. self.dr_shift = if self.selects_idcode() { self.idcode } else { 0 }; } /// Shifts one bit in from the TDI side and returns the bit that leaves on /// the TDO side: bit 0 leaves, every other bit moves down one place, and /// the new bit enters at the top. fn shift_dr(&mut self, bit_in: bool) -> bool { let top = if self.selects_idcode() { 31 } else { 0 }; let bit_out = (self.dr_shift & 1) == 1; self.dr_shift = (self.dr_shift >> 1) | (u32::from(bit_in) << top); bit_out } fn capture_ir(&mut self) { self.ir_shift = IR_CAPTURE; } fn shift_ir(&mut self, bit_in: bool) -> bool { let bit_out = (self.ir_shift & 1) == 1; self.ir_shift = (self.ir_shift >> 1) | (u8::from(bit_in) << (IR_LENGTH - 1)); bit_out } fn update_ir(&mut self) { self.instruction = self.ir_shift; } fn reset(&mut self) { self.instruction = self.idcode_instruction; }}/// Which set of registers a scan goes through.#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum Register { Instruction, Data,}/// A board's scan chain: TDI -> `chips[0]` -> `chips[1]` -> ... -> TDO.////// Every chip sees the same TCK and TMS, so their TAP controllers move in/// lockstep and one state describes them all.struct Chain { state: TapState, chips: Vec<Chip>,}impl Chain { const fn new(chips: Vec<Chip>) -> Self { Self { state: TapState::TestLogicReset, chips, } } /// One cycle of TCK. On the rising edge the current state's action runs /// (capture or shift) and the controller moves on. Update and reset act /// on the falling edge, in the state just entered. /// /// Returns the bit TDO carried at the rising edge, or `None` when no Shift /// state is active and nothing drives TDO. fn clock(&mut self, tms: bool, tdi: bool) -> Option<bool> { let bit_out = match self.state { TapState::CaptureDr => { self.chips.iter_mut().for_each(Chip::capture_dr); None } TapState::CaptureIr => { self.chips.iter_mut().for_each(Chip::capture_ir); None } TapState::ShiftDr => Some(self.shift_chain(tdi, Chip::shift_dr)), TapState::ShiftIr => Some(self.shift_chain(tdi, Chip::shift_ir)), _ => None, }; self.state = self.state.next(tms); match self.state { TapState::UpdateIr => self.chips.iter_mut().for_each(Chip::update_ir), TapState::TestLogicReset => self.chips.iter_mut().for_each(Chip::reset), _ => {} } bit_out } /// Moves every register in the chain one place toward TDO. `tdi` enters /// the first chip, each chip's outgoing bit enters the next chip, and the /// last chip's outgoing bit is the one that appears on TDO. fn shift_chain(&mut self, tdi: bool, shift: fn(&mut Chip, bool) -> bool) -> bool { self.chips .iter_mut() .fold(tdi, |bit, chip| shift(chip, bit)) } /// Five clocks with TMS high reach Test-Logic-Reset from any state. One /// more with TMS low parks the chain in Run-Test/Idle, ready to scan. fn reset(&mut self) { for _ in 0..5 { self.clock(true, false); } self.clock(false, false); } /// From Run-Test/Idle, shifts `bits_in` through the instruction or data /// registers and returns to Run-Test/Idle. Returns the bits that came out /// of TDO, first bit first. fn scan(&mut self, register: Register, bits_in: &[bool]) -> Vec<bool> { assert_eq!( self.state, TapState::RunTestIdle, "scans start in Run-Test/Idle" ); assert!(!bits_in.is_empty(), "a scan shifts at least one bit"); self.clock(true, false); // to Select-DR-Scan if register == Register::Instruction { self.clock(true, false); // to Select-IR-Scan } self.clock(false, false); // to Capture self.clock(false, false); // capture happens now; to Shift let mut bits_out = Vec::with_capacity(bits_in.len()); for (index, &bit) in bits_in.iter().enumerate() { // TMS rises with the last bit: that clock shifts it and leaves // for Exit1. let last = index + 1 == bits_in.len(); bits_out.push( self.clock(last, bit) .expect("TDO is driven in a Shift state"), ); } self.clock(true, false); // to Update self.clock(false, false); // to Run-Test/Idle bits_out }}/// `count` bits of `value`, lowest bit first: the order JTAG shifts them.fn bits_lsb_first(value: u32, count: usize) -> Vec<bool> { (0..count) .map(|index| ((value >> index) & 1) == 1) .collect()}/// Rebuilds a number from bits that arrived lowest bit first.fn word_lsb_first(bits: &[bool]) -> u32 { bits.iter() .rev() .fold(0, |word, &bit| (word << 1) | u32::from(bit))}/// The three fields of a 32-bit IDCODE, whose bit 0 is always 1.#[derive(Debug, PartialEq, Eq)]struct IdCode { version: u32, part: u32, manufacturer: u32,}/// Splits an IDCODE into its fields, or returns `None` when bit 0 is 0,/// which no IDCODE can have.fn decode_idcode(value: u32) -> Option<IdCode> { ((value & 1) == 1).then_some(IdCode { version: value >> 28, part: (value >> 12) & 0xFFFF, manufacturer: (value >> 1) & 0x7FF, })}/// Loads one instruction into every chip. `instructions` is in chain order,/// chip nearest TDI first. The first bits shifted in travel furthest, so the/// bits for the chip nearest TDO go in first.fn load_instructions(chain: &mut Chain, instructions: &[u8]) { let bits: Vec<bool> = instructions .iter() .rev() .flat_map(|&instruction| bits_lsb_first(u32::from(instruction), IR_LENGTH)) .collect(); chain.scan(Register::Instruction, &bits);}/// Counts the chips on a chain without knowing anything about them.////// BYPASS is all ones, so 64 ones leave every chip in BYPASS whatever its/// instruction register's length, as long as the lengths add up to 64 or/// less; the spare ones fall out of TDO. Each bypass register holds one bit/// and captures 0, so a single 1 fed in on the first clock comes out one/// clock later for every chip it passes through.fn count_chips(chain: &mut Chain, max_chips: usize) -> Option<usize> { chain.scan(Register::Instruction, &[true; 64]); let mut probe = vec![false; max_chips + 1]; probe[0] = true; chain .scan(Register::Data, &probe) .iter() .position(|&bit| bit)}/// The lesson's board: TDI -> chip A -> chip B -> TDO.fn board() -> Chain { Chain::new(vec![ Chip::new("chip A, an Arm JTAG debug port", 0x4BA0_0477, 0b1110), Chip::new("chip B, a made-up sensor", 0x15EE_D001, 0b0010), ])}fn main() { println!("1. Five clocks with TMS high reach Test-Logic-Reset from any state"); let mut state = TapState::ShiftDr; let mut path = vec![state.to_string()]; for _ in 0..5 { state = state.next(true); path.push(state.to_string()); } println!(" {}", path.join(" -> ")); let slowest = TapState::ALL .into_iter() .filter_map(clocks_to_reset) .max() .unwrap_or_default(); println!(" the slowest of the 16 states needs {slowest} clocks"); println!("\n2. Reset selects IDCODE in every chip, so 64 bits read both IDs"); let mut chain = board(); chain.reset(); let bits = chain.scan(Register::Data, &[false; 64]); // The chip nearest TDO answers first. for (chunk, chip) in bits.chunks(32).zip(chain.chips.iter().rev()) { let value = word_lsb_first(chunk); match decode_idcode(value) { Some(id) => println!( " {value:#010X} from {}: version {}, part {:#06X}, manufacturer {:#05X}", chip.name, id.version, id.part, id.manufacturer ), None => println!(" {value:#010X} from {}: not an IDCODE", chip.name), } } println!("\n3. Count the chips by timing one bit through BYPASS"); match count_chips(&mut chain, 8) { Some(chips) => println!( " the 1 went in on clock 1 and came out on clock {}: {chips} chips", chips + 1 ), None => println!(" the 1 never came out: more than 8 chips, or a broken chain"), } println!("\n4. The instruction picks the register: chip A on IDCODE, chip B on BYPASS"); chain.reset(); load_instructions(&mut chain, &[0b1110, BYPASS]); let bits = chain.scan(Register::Data, &[false; 33]); println!( " 32 + 1 = 33 bits: chip B's bypass bit ({}) first, then chip A's ID {:#010X}", u8::from(bits[0]), word_lsb_first(&bits[1..]) );}#[cfg(test)]mod tests { use super::*; #[test] fn five_clocks_with_tms_high_reset_every_state() { let clocks: Vec<u32> = TapState::ALL .into_iter() .map(|state| clocks_to_reset(state).unwrap()) .collect(); assert!(clocks.iter().all(|&count| count <= 5)); assert_eq!(clocks.iter().max(), Some(&5)); assert_eq!(clocks_to_reset(TapState::ShiftDr), Some(5)); assert_eq!(clocks_to_reset(TapState::RunTestIdle), Some(3)); } #[test] fn exactly_six_states_can_wait_in_place() { let waiting: Vec<(TapState, bool)> = TapState::ALL .into_iter() .flat_map(|state| [(state, false), (state, true)]) .filter(|&(state, tms)| state.next(tms) == state) .collect(); assert_eq!( waiting, [ (TapState::TestLogicReset, true), (TapState::RunTestIdle, false), (TapState::ShiftDr, false), (TapState::PauseDr, false), (TapState::ShiftIr, false), (TapState::PauseIr, false), ] ); } #[test] fn tdo_is_driven_only_in_shift_states() { let mut chain = board(); chain.reset(); assert_eq!(chain.clock(false, false), None); } #[test] fn idcodes_arrive_nearest_chip_first_and_lowest_bit_first() { let mut chain = board(); chain.reset(); let bits = chain.scan(Register::Data, &[false; 64]); assert!(bits[0], "bit 0 of every IDCODE is 1"); assert_eq!(word_lsb_first(&bits[..32]), 0x15EE_D001); assert_eq!(word_lsb_first(&bits[32..]), 0x4BA0_0477); } #[test] fn the_arm_idcode_splits_into_the_lesson_fields() { assert_eq!( decode_idcode(0x4BA0_0477), Some(IdCode { version: 4, part: 0xBA00, manufacturer: 0x23B, }) ); assert_eq!(decode_idcode(0x4BA0_0476), None); } #[test] fn instruction_capture_starts_every_register_with_01() { let mut chain = board(); chain.reset(); let bits = chain.scan(Register::Instruction, &[true; 8]); assert_eq!(bits, [true, false, false, false, true, false, false, false]); assert!(chain.chips.iter().all(|chip| chip.instruction == BYPASS)); } #[test] fn a_bit_is_delayed_one_clock_per_chip_in_bypass() { let mut chain = board(); chain.reset(); assert_eq!(count_chips(&mut chain, 8), Some(2)); let mut longer = board(); longer.chips.push(Chip::new( "chip C, another made-up chip", 0x0000_0001, 0b0011, )); longer.reset(); assert_eq!(count_chips(&mut longer, 8), Some(3)); } #[test] fn undefined_instructions_select_the_bypass_register() { let mut chain = board(); chain.reset(); load_instructions(&mut chain, &[0b0101, 0b0101]); assert!(chain.chips.iter().all(|chip| chip.instruction == 0b0101)); let probe = [true, false, false, false]; assert_eq!( chain.scan(Register::Data, &probe), [false, false, true, false] ); } #[test] fn instructions_land_in_chain_order() { let mut chain = board(); chain.reset(); load_instructions(&mut chain, &[0b1110, BYPASS]); assert_eq!(chain.chips[0].instruction, 0b1110); assert_eq!(chain.chips[1].instruction, BYPASS); // The data chain is now 32 + 1 = 33 bits long. Chip B's bypass bit // is nearest TDO, so its captured 0 leaves first. let bits = chain.scan(Register::Data, &[false; 33]); assert!(!bits[0]); assert_eq!(word_lsb_first(&bits[1..]), 0x4BA0_0477); } #[test] fn reset_restores_idcode_from_any_state() { let mut chain = board(); chain.reset(); load_instructions(&mut chain, &[BYPASS, BYPASS]); for tms in [true, true, false, true, false] { chain.clock(tms, true); } assert_eq!(chain.state, TapState::PauseIr); chain.reset(); assert_eq!(chain.state, TapState::RunTestIdle); assert!( chain .chips .iter() .all(|chip| chip.instruction == chip.idcode_instruction) ); }}
The controller is a match with one row per state, giving the next state for
TMS at 0 and at 1, exactly like the diagram. The shift register is five lines:
& 1 keeps only bit 0, the bit leaving toward TDO. >> 1 moves every bit down
one place. << top puts the incoming bit at the top: bit 31 for the IDCODE
register, or bit 0 for the one-bit bypass register, where the top and the bottom
are the same cell. The chain passes each chip’s outgoing bit into the next chip:
The lab walks from Shift-DR to Test-Logic-Reset in five clocks and confirms that
no state needs more. It reads both IDCODEs, chip B’s first because chip B is
nearest TDO, and splits Arm’s into the fields above. It counts the chips by
timing a single 1 through their bypass bits. Finally, it puts chip A on IDCODE
and chip B on BYPASS, which makes the data chain 32 + 1 = 33 bits long. The
tests check that every state resets within five clocks, that exactly six states
can wait in place, that undefined instructions behave as BYPASS, and that
instructions land in chain order.
Then add a third chip to board(). Before you run it, predict what
count_chips reports and how many bits the IDCODE scan in main now needs.
Lesson 13.9
How Emulators Work
Lesson 11.8 promised that an emulator is a program whose data is
the software of an entire other computer. This lesson keeps the promise. An
emulator is a program that imitates another computer’s hardware closely
enough that software written for that computer runs on it unchanged. The
imitated machine and its software are the guest; the computer running the
emulator is the host.
The console architecture from the preceding lessons gives the emulator a
concrete target. Processor instructions, devices, timing, and saved state have
to agree closely enough for the same game code to behave as expected.
Lesson 11.9 built a virtual machine for bytecode that was
designed to be interpreted. An emulator interprets machine code that was
designed for a real chip, and it has to reproduce whatever that chip does,
including its quirks.
Lesson 2.3 described the steps a CPU repeats: fetch the bytes at
the instruction pointer, decode them, execute, and move on. In a real CPU,
circuits do that. In an emulator, a loop does:
loop:
opcode = memory[pc] fetch the byte the program counter points at
pc = pc + 1 move past it
decode opcode work out which instruction it is
execute it change registers, memory, and flags
add its cycles keep time
The program counter, or PC, holds the address of the next byte to fetch; x86
calls the same register the instruction pointer. The last line counts
cycles, ticks of the guest CPU’s clock, so the emulator knows how long the
real chip would have taken. An emulator that runs this loop once per guest
instruction is an interpreter. Everything else in this lesson is either a
faster way to run the loop or a way to imitate the rest of the machine around
it.
To watch the loop work, take a made-up 8-bit console, the one
Lesson 13.10 emulates in full. Like the processors in 1980s
consoles, its CPU has a program counter and a few small registers, and it does
its arithmetic in one called A, the accumulator. Each instruction is one or
two bytes long. The first byte, the opcode, says which instruction it is;
some instructions carry a second byte, the operand.
The bytes 03 05 mean ADD #5: add the number 5 to A. Suppose they sit at
addresses 0x04 and 0x05, PC holds 0x04, and A holds 10:
PC0x04
A10
0x0403
0x0505
CPU registers
ADD #5 in memory
Before the loop runs: PC points at the opcode 03, and A holds 10.
One pass of the loop does four things:
Fetch: read the byte at PC, 03, and add 1 to PC, making it 0x05.
Decode: 03 is ADD, which takes one operand byte.
Execute: fetch the operand, 05, which adds 1 to PC again and makes it
0x06. Then set A = 10 + 5 = 15.
Keep time: this CPU spends one cycle on every byte it reads or writes in
memory. ADD #5 read two bytes, so it costs 2 cycles.
PC0x06
A15
0x0403
0x0505
CPU registers
ADD #5 in memory
After one pass: A holds 15, and PC = 0x04 + 2 = 0x06, the first byte of the next instruction.
Nothing in memory marks where one instruction ends and the next begins. The CPU
knows each instruction’s length from its opcode, and fetching the bytes moves PC
past them, so PC lands on the next instruction without any extra work.
The toy emulator fetches and runs ADD #5Two bytes in memory make one instruction. Fetching reads them; executing changes A and PC and spends cycles.
1Fetch the opcode 03 at PC 0x04
Memory bytes, then CPU registers
03at 0x04
05at 0x05
10A
0x04PC
0cycles
A starts at 10. Opcode 03 means ADD with an immediate operand.
2Fetch the operand 05 at 0x05
Memory bytes, then CPU registers
03at 0x04
05at 0x05
10A
0x06PC
1cycles
The operand byte follows the opcode.
3Execute: A = 10 + 5 = 15
Memory bytes, then CPU registers
03at 0x04
05at 0x05
15A
0x06PC
2cycles
PC has advanced to 0x06 and the instruction cost two cycles. The instruction bytes stay in memory.
All 3 steps shown
Two bytes in memory make one instruction. Fetching reads them; executing changes A and PC and spends cycles.
Lesson 13.10 lists all seven instructions of
this CPU, loads a 14-byte program, and follows it instruction by instruction.
An emulator performs one guest add instruction. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
PC = 0x04; A = 10
opcode = memory[PC]
operand = memory[PC + 1]
decode 0x03 as ADD immediate
A = (A + operand) modulo 256; PC += 2
report the accumulator and next fetch
Result: A=15, next fetch at 0x06. The result fits in the 8-bit register.
Final values: operand = 5; PC = 0x06; A = 15; opcode = 0x03; immediate = 5; operation = ADD; mathematical_sum = 15.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
operand = 5.
Step 2, line 1. Start at the opcode with accumulator 10.
operand = 5; PC = 0x04; A = 10.
Memory: 0x04 = 03; 0x05 = 05.
Step 3, line 2. Fetch the opcode byte.
operand = 5; PC = 0x04; A = 10; opcode = 0x03.
Memory: 0x04 = 03; 0x05 = 05.
Step 4, line 3. Fetch the immediate byte after the opcode.
operand = 5; PC = 0x04; A = 10; opcode = 0x03; immediate = 5.
Memory: 0x04 = 03; 0x05 = 05.
Step 5, line 4. The made-up instruction set defines 0x03 as an immediate add.
operand = 5; PC = 0x04; A = 10; opcode = 0x03; immediate = 5; operation = ADD.
Memory: 0x04 = 03; 0x05 = 05.
Step 6, line 5. Add in the 8-bit accumulator and advance past both instruction bytes.
An interpreter pays for fetching and decoding every time an instruction runs,
typically many host instructions for each guest instruction. Suppose a loop of
five guest instructions runs 1,000 times. The interpreter decodes
5 × 1,000 = 5,000 instructions, although only five different ones exist.
A dynamic recompiler, also called a JIT (just-in-time compiler), removes
most of that repetition. The first time guest code runs, it translates it into
host machine code and stores the translation; every later time, it runs the
stored host code directly. It translates a block, a run of instructions that
ends at a jump, because after a jump the next instruction could be anywhere:
With a recompiler, the 1,000 passes of that loop cost 5 decodes and 1,000 runs
of native code instead of 5,000 decodes. Consoles whose CPUs execute hundreds of
millions of instructions per second, such as the GameCube and the PlayStation 2,
are emulated with recompilers, as in the Dolphin and PCSX2 emulators, because an
interpreter cannot keep up.
The speed has two costs:
Self-modifying code. Code is data, so a guest program may overwrite its
own instructions. A stored translation then describes code that no longer
exists. The recompiler must notice writes to addresses it has translated and
throw those translations away.
Writable, then executable memory. A recompiler writes host machine code
into memory and then runs it.
Lesson 13.6 showed
that consoles, and some phone platforms, refuse ordinary programs exactly that
combination. Emulators on those platforms have to interpret.
The toy console’s PC is 8 bits wide, and eight bits can name 2⁸ = 256
addresses, 0x00 to 0xFF. Not all of them are RAM. The console’s memory
map says what answers at each address:
Addresses
What answers
0x00 to 0xEF
RAM: 0xF0 = 15 × 16 = 240 bytes
0xF0
the display: writing a byte here shows it on the screen
0xF1
the buttons: reading here returns which buttons are held
0xF2 to 0xFF
nothing: reads give 0, and writes vanish
A device that answers at an address this way uses memory-mapped I/O. To the
CPU, storing A at 0xF0 is an ordinary store instruction. The address alone
decides that the byte goes to the display instead of RAM. In hardware,
address-decoding circuits make that choice. In the emulator, a pair of
functions does, one for reads and one for writes, and every memory access the
CPU makes goes through them:
Real consoles have far bigger maps, with more RAM, a picture chip, a sound chip,
and controller ports, but an emulator handles them the same way.
Lesson 13.10 writes the toy console’s
map as code and looks at part of the NES’s.
Inside a real console, the CPU, the picture chip, and the sound chip all run at
the same time, each from its own clock. Older games depended on that timing
exactly. A game might change a picture-chip register partway down the screen to
split it into a scrolling playfield and a fixed status bar; if the emulated CPU
reaches that write too early or too late, the split lands on the wrong line. So
emulators count cycles and keep the parts in step: run the CPU for a slice of
cycles, advance the other chips by the same amount of time, and repeat.
Suppose the toy console’s clock runs at 1.2 MHz, which is 1,200,000 cycles per
second, and the screen redraws 60 times a second. Each frame then lasts
1,200,000 ÷ 60 = 20,000 cycles. The emulator’s frame loop runs instructions
until 20,000 cycles are used, draws the frame, and waits until 1/60 of a second
of real time has passed, so the game plays at its original speed on a host that
could run it thousands of times faster.
An instruction cannot stop halfway, so a frame can end slightly past its budget.
The emulator then takes the extra cycles off the next frame, which keeps the
average exact. Lesson 13.10 ends a
frame partway through a program and works out the numbers.
Consoles of the 8-bit and 16-bit eras had a dedicated picture chip that built
each frame line by line from tiles, small blocks of pixels, and sprites,
movable images, kept in video memory, in step with the television’s scanning
beam. An emulator imitates that chip’s rules, writes the resulting pixels into
an array called a framebuffer, and hands the array to the host’s GPU as a
texture, one of the objects Chapter 7 observes.
3D consoles are harder. Their GPUs run command lists and shaders written for
that GPU’s own design, so the emulator translates the guest’s commands into a
host graphics interface such as Vulkan or Direct3D, and the guest’s shaders into
host shaders. That translation happens while the game runs, the first time each
effect appears, which is why emulated games can stutter the first time they show
something new, and why emulators keep a cache of translated shaders. The
original console never had this problem, because its games shipped shaders
already compiled for its one GPU
(Lesson 13.6).
Sound works the same way in a different currency. The guest’s sound chip
produces a stream of samples, numbers that describe the sound wave, and a
typical host plays 48,000 of them per second. At 60 frames per second the
emulator must therefore produce 48,000 ÷ 60 = 800 samples every frame. Audio is
the least forgiving part of the machine. A late video frame just leaves the
previous picture up a moment longer, but if the samples run out, the speakers
click and crackle. Many emulators pace themselves by the audio device for that
reason.
A save state is everything the machine is, written out: every register, all
of RAM, the I/O registers, and the emulator’s own timing position. Loading one
overwrites all of it, so the machine carries on as though nothing had happened.
A game’s own save file, like the ones in Lesson 10.2, stores only
what the game chooses, in the game’s format. A save state works for any game, at
any instant, even in the middle of a frame.
The registers matter as much as RAM. Partway through a calculation, a new value
may exist only in a register, not yet stored to memory, so a snapshot of RAM
alone would lose it.
Lesson 13.10 catches the toy console
at exactly such a moment, writes its state out byte by byte, and checks the
bytes again when they are loaded back.
Chapters 1 and 2 needed scanners, debuggers, and pointer paths because Windows
keeps each process’s memory private and moves things around. An emulator
removes both obstacles.
The guest’s RAM is one array. The emulator can display it, search it, and
change it directly. A RAM search in an emulator is the scan of
Lesson 1.9, keeping the candidates whose value changed as
expected, run over the guest’s RAM: on an NES, 2 KiB, which is
2 × 1,024 = 2,048 bytes, instead of the gigabytes a PC game can occupy.
Addresses do not move. Old consoles have no virtual memory and no ASLR
(Lesson 2.7), and their games keep variables at fixed
addresses. Gold found at an address today is at the same address tomorrow, and
on every other copy of the same game, with no pointer path
(Lesson 2.8) needed.
The emulator is already a debugger. Every guest memory access passes
through the emulator’s read and write functions. A watchpoint on a variable is
one if in the write function that checks the address and records PC. That
is the answer the debug registers of Lesson 2.4 gave, with no
debug registers involved.
Fixed addresses are also what made commercial cheat devices possible. The Game
Genie sat between the cartridge and the console, and whenever the console read a
particular cartridge address, it substituted a different byte, patching the
game’s code or data as it was read. Action Replay and GameShark codes worked on
RAM instead, writing a chosen value to a chosen address once every frame. A
cheat code of that second kind is just an address and a value.
Such a code can lose a race with the game: a game that has already copied the
value into a register, or that recomputes it from other data, later stores its
own number over the cheat’s.
Lesson 13.10 shows the race
cycle by cycle.
All of this is for single-player games played alone. An emulator that plays
online with other people, or that submits scores to a shared leaderboard, is
part of a multiplayer service, and the same limits apply as everywhere else in
this book.
Writing and using an emulator is legal in many countries. In the United States,
a 2000 appeals court decision in Sony v. Connectix held that copying the
PlayStation’s BIOS during reverse engineering, in order to build a compatible
emulator, was fair use. A 1992 decision had already found the Game Genie lawful,
because it changed a game only while the owner played it.
What the emulator runs is a different matter. A console’s BIOS or system
firmware and its games are copyrighted, and downloading them from the internet
is generally copyright infringement. Emulator projects that shipped decryption
keys or firmware, or that were promoted for piracy, have been sued, and some
have shut down. Several emulators avoid needing the original firmware by
replacing it with their own code.
That leaves plenty to run legitimately:
homebrew games that their authors release for anyone to play;
public-domain or freely licensed programs;
test programs that emulator developers write to check CPU and video behaviour;
dumps you make yourself of games you own, where your country’s law allows
making them.
The toy console of Lesson 13.10 sidesteps the question
entirely: it emulates a machine that does not exist, running a program written
for this book.
Lesson 13.10
Emulator Timing and State
Lesson 13.9 explained how an emulator works and ran one
instruction of a made-up 8-bit console through the fetch–decode–execute loop.
This lesson builds the whole console, follows a complete program through it
cycle by cycle, and ends with a lab that runs it.
The CPU is made up, but it is built like the 8-bit processors in 1980s consoles.
It has four registers:
PC, 8 bits, the program counter, so the whole machine has the 256
addresses, 0x00 to 0xFF, of Lesson 13.9’s memory map.
A, 8 bits, the accumulator, where arithmetic happens.
X, 8 bits, a second register, used here as a loop counter.
Z, 1 bit, the zero flag: 1 when the last result was zero, 0 otherwise.
It understands seven instructions. Each begins with its opcode, and some carry
an operand byte:
The toy CPU’s instructions
Bytes
Written as
Length
What it does
Cycles
01 n
LDX #n
2
X = n; Z = 1 if X is 0
2
02 a
LDA a
2
A = the byte at address a; Z = 1 if A is 0
3
03 n
ADD #n
2
A = A + n, wrapping past 255 back to 0; Z = 1 if A is 0
2
04 a
STA a
2
store A at address a
3
05
DEX
1
X = X − 1, wrapping below 0 to 255; Z = 1 if X is 0
1
06 a
JNZ a
2
if Z is 0, jump: PC = a
2, or 3 if it jumps
FF
HLT
1
stop
1
A # marks an immediate: #5 means the number 5 itself, while an operand
without # is an address, and the instruction uses the byte stored there. STA
and JNZ leave Z alone. Every other byte value is not an instruction, and the
lab reports it as an error instead of guessing.
Every number in the last column comes from one rule: the CPU spends one cycle on
each memory access, meaning each instruction byte it fetches and each data byte
it reads or writes, plus one extra cycle when a jump is taken, to load the new
address into PC. So LDX #n and ADD #n each fetch 2 bytes, 2 cycles; LDA a
fetches 2 and reads 1, 3 cycles; STA a fetches 2 and writes 1, 3 cycles;
DEX and HLT each fetch 1, 1 cycle; and JNZ a fetches 2, plus 1 if it
jumps. Real 8-bit CPUs, such as the 6502 inside the NES, have similar costs for
similar reasons.
The program adds 5 gold twice and then shows the total on the console’s
display. Gold lives at address 0x80 and starts at 10:
address bytes instruction meaning
0x00 01 02 LDX #2 X = 2: go round the loop twice
0x02 02 80 LDA 0x80 A = gold
0x04 03 05 ADD #5 A = A + 5
0x06 04 80 STA 0x80 gold = A
0x08 05 DEX X = X - 1
0x09 06 02 JNZ 0x02 back to 0x02 while X is not 0
0x0B 04 F0 STA 0xF0 show A on the display
0x0D FF HLT stop
Each address is the one before it plus that instruction’s length:
0x00 + 2 = 0x02, and so on down to 0x08 + 1 = 0x09,
0x09 + 2 = 0x0B, and 0x0B + 2 = 0x0D. The last byte sits at 0x0D,
which is 13, so the program fills addresses 0 to 13, which is 14 bytes:
0x0001
02
0x0202
80
0x0403
05
0x0604
80
LDX #2
LDA 0x80
ADD #5
STA 0x80
Addresses 0x00 to 0x07, one byte per box: the first four instructions.
0x0805
0x0906
02
0x0B04
F0
0x0DFF
DEX
JNZ 0x02
STA 0xF0
HLT
Addresses 0x08 to 0x0D: the last four. The earlier ADD #5 at 0x04 adds five to the accumulator on each loop pass.
The program and its data share one memory, as they do in every
stored-program computer. At power-on the machine looks like this:
PC0x00
A0
X0
Z0
0x80 · gold10
CPU registers
RAM
Before step 1: every register is 0, and gold at 0x80 holds 10, which is 0x0A.
The first pass goes from power-on through the jump back:
Step
PC
Instruction
What happens
A
X
Z
Gold
Cycles
1
0x00
LDX #2
X = 2, not 0, so Z = 0
0
2
0
10
0 + 2 = 2
2
0x02
LDA 0x80
A = the byte at 0x80, 10
10
2
0
10
2 + 3 = 5
3
0x04
ADD #5
A = 10 + 5 = 15
15
2
0
10
5 + 2 = 7
4
0x06
STA 0x80
gold = A = 15
15
2
0
15
7 + 3 = 10
5
0x08
DEX
X = 2 − 1 = 1, not 0, so Z = 0
15
1
0
15
10 + 1 = 11
6
0x09
JNZ 0x02
Z is 0, so PC = 0x02
15
1
0
15
11 + 3 = 14
The second pass repeats the loop body, and this time the counter runs out:
Step
PC
Instruction
What happens
A
X
Z
Gold
Cycles
7
0x02
LDA 0x80
A = the byte at 0x80, 15
15
1
0
15
14 + 3 = 17
8
0x04
ADD #5
A = 15 + 5 = 20
20
1
0
15
17 + 2 = 19
9
0x06
STA 0x80
gold = A = 20
20
1
0
20
19 + 3 = 22
10
0x08
DEX
X = 1 − 1 = 0, so Z = 1
20
0
1
20
22 + 1 = 23
11
0x09
JNZ 0x02
Z is 1, no jump
20
0
1
20
23 + 2 = 25
12
0x0B
STA 0xF0
the display shows 20
20
0
1
20
25 + 3 = 28
13
0x0D
HLT
stop
20
0
1
20
28 + 1 = 29
In hex, 15 is 0x0F and 20 is 1 × 16 + 4 = 0x14. At step 11, fetching JNZ
and its operand has already moved PC from 0x09 to 0x09 + 2 = 0x0B, so not
jumping needs no extra work: the next instruction is simply the next one.
The whole run takes 29 cycles: 2 for LDX, then 3 + 2 + 3 + 1 + 3 = 12 for the
first pass, 3 + 2 + 3 + 1 + 2 = 11 for the second, and 3 + 1 = 4 for the ending.
2 + 12 + 11 + 4 = 29. When the machine stops, it looks like this:
Where 29 cycles goThe program loads X, loops twice and then shows gold. Each part has a cost in cycles.
1LDX and the first loop pass: 2 + 12 = 14
Cycles, drawn to scale
029 cycles
2
12
11
4
Loading X costs 2 cycles; the first pass costs 12 and makes gold 10 + 5 = 15.
2Second loop pass: 14 + 11 = 25
Cycles, drawn to scale
029 cycles
2
12
11
4
The second pass is one cycle cheaper and makes gold 15 + 5 = 20.
3Display and halt: 25 + 4 = 29
Cycles, drawn to scale
029 cycles
2
12
11
4
The display shows 20 and the program halts after 29 cycles in total.
All 3 steps shown
The program loads X, loops twice and then shows gold. Each part has a cost in cycles.
PC0x0E
A20
X0
Z1
0x80 · gold20
0xF0 · display20
CPU registers
RAM
I/O
After HLT. PC is 0x0E, one past the HLT at 0x0D, because fetching the opcode moved it on.
The lab’s step function is that trace, as code. It fetches the opcode,
decodes it, and runs one arm of a match. Each arm’s last value is the
instruction’s cost in cycles:
fetch reads the byte at PC and adds 1 to PC, so each call consumes one byte of
the instruction. Opcode::decode is a match on the byte that returns None
for anything that is not an instruction, and the ? turns that into an
IllegalOpcode error that names the address. A real CPU meeting an unknown
opcode might raise an exception, as x86 does, or do something undocumented. An
emulator that guessed would hide bugs in the guest program and in itself.
Watch it happenOne LDA instruction turns bytes into CPU state
Program bytes 02 80 sit at addresses 0x02 and 0x03. The CPU fetches 02 and advances PC to 0x03, decodes it as LDA, fetches the operand 80 and advances PC to 0x04, then reads RAM address 0x80, which holds 10, into register A. The cycle count goes from 2 to 5 and PC points at ADD #5.
Read the steps
Before the instruction: PC = 0x02, A = 0, X = 2, Z = 0, and 2 cycles have been spent. RAM address 0x80 holds the gold, 10. The next instruction’s bytes are 02 80.
Fetch the opcode: read the byte at PC = 0x02, which is 02, and advance PC to 0x03. This byte names the operation; it has not loaded the gold yet.
Decode: the opcode 02 maps to LDA. The LDA arm needs one address operand and then a data read. An unknown opcode would stop here with an error.
Fetch the address operand: read the byte at PC = 0x03, which is 80, and advance PC to 0x04. The operand names RAM address 0x80.
Read RAM[0x80]: the byte there is 10, so A becomes 10. Because A is not zero, Z stays 0. X is untouched, and RAM still holds 10, because LDA only reads it.
Finish: LDA costs 3 cycles, one for the opcode, one for the operand, one for the data read. The total is 2 + 3 = 5, and PC = 0x04 now selects ADD #5.
An instruction can use several emulated cycles. A saved register and a saved memory byte are separate copies, so they can disagree.
The same instruction can load a different value. Change the memory byte below,
then step through the fetches and the data read. Try zero: the loaded value
also changes the zero flag. Each memory read costs one cycle in this toy CPU.
Try a different memory byteOne instruction, four actions
Text
PC points to the opcode at 0x02
opcode = program[PC]; PC = 0x03
decode opcode 02 as LDA
address = program[PC]; PC = 0x04
A = RAM[address]; Z = (A == 0)
Registers and values
PC
0x02
A
0
X
2
Z
0
cycles
2
RAM[0x80]
10
buffer
—
Snapshot 1, source line 1: Starting state. PC selects program address 0x02. Fetch the opcode next.
Initial recorded state.
Focus a register name to mark its matching source and inspection references.
Read every snapshot of the lesson’s example
Snapshot 1, line 1. Starting state. PC selects program address 0x02. Fetch the opcode next.
PC = 0x02; A = 0; X = 2; Z = 0; cycles = 2; RAM[0x80] = 10; buffer = —.
Snapshot 2, line 2. Fetched opcode 02 from program address 0x02. PC advances to 0x03. One memory read adds one cycle.
PC = 0x03; A = 0; X = 2; Z = 0; cycles = 3; RAM[0x80] = 10; buffer = 02.
Snapshot 3, line 3. Decoded 02 as LDA. Decoding reads no memory, so the cycle count stays at 3.
PC = 0x03; A = 0; X = 2; Z = 0; cycles = 3; RAM[0x80] = 10; buffer = 02.
Snapshot 4, line 4. Fetched operand 80 from program address 0x03. PC advances to 0x04. This read adds one cycle.
PC = 0x04; A = 0; X = 2; Z = 0; cycles = 4; RAM[0x80] = 10; buffer = 80; address = 0x80.
Snapshot 5, line 5. Read RAM[0x80] into A. A becomes 10; Z is 0 because A is not zero. RAM still holds 10. Three reads cost three cycles: 2 + 3 = 5.
PC = 0x04; A = 10; X = 2; Z = 0; cycles = 5; RAM[0x80] = 10; buffer = 10; address = 0x80.
Fetch opcode
Decode
Fetch address
Read RAM into A
Underlined action: what Step does next. Outlined byte: the read source. Filled register: written by this action.
PC is 0x02. A is 0. Change RAM, then fetch the opcode.
Read all four instruction actions
Action
PC
A
Cycles
What happened
Starting state
0x02
0
2
RAM[0x80] holds 10.
Fetch opcode
0x03
0
3
Read 02 from program address 0x02.
Decode
0x03
0
3
02 means LDA. Decoding adds no memory read.
Fetch address
0x04
0
4
Read operand 80 from program address 0x03.
Read RAM into A
0x04
10
5
A becomes 10; Z stays 0; RAM still holds 10.
What does a rising clock edge do?
A register captures data on a rising clock edge
Data can change between clock edges. The stored value changes only when the clock goes from 0 to 1.
At time 1, the rising edge captures 1. At time 2, data drops to 0 while the stored value stays 1. The next rising edge captures 0.
Read the timing example
Time
Clock
Data
Stored value
Reason
0
0
0
0
Initial state
1
1
1
1
Rising edge captures data
2
0
0
1
Falling edge keeps the stored value
3
1
0
0
Rising edge captures data
Because step decodes every instruction each time it runs, the loop body from
0x02 to 0x0A is decoded afresh on both passes. That repetition is what the
dynamic recompilers of
Lesson 13.9 remove.
The toy console maps RAM from 0x00 to 0xEF, the display at 0xF0, the
buttons at 0xF1, and nothing from 0xF2 to 0xFF. In
the lab, the map is two functions. Every read the CPU makes, including every
fetch, goes through read, and every store goes through write:
_ => {} // the buttons are read-only, and 0xF2 to 0xFF go nowhere
}
}
0x00..=0xEF is Rust’s range from 0x00 to 0xEF with both ends included.
That is 0xF0 = 15 × 16 = 240 addresses, exactly the length of the ram
array. DISPLAY and BUTTONS are constants holding 0xF0 and 0xF1. So step
12’s STA 0xF0 calls write with address 0xF0 and value 20, the second arm
stores 20 in the display register, and nothing in step has to know that
0xF0 is not RAM.
Real consoles work the same way on a larger scale. The NES has 2 KiB of RAM at
0x0000 to 0x07FF; 0x0800 is 8 × 256 = 2,048 bytes. Its address decoder
ignores the top address bits for RAM, so the same 2,048 bytes answer again at
0x0800, 0x1000, and 0x1800: the range up to 0x2000, which is
2 × 16³ = 2 × 4,096 = 8,192 bytes, holds 8,192 ÷ 2,048 = 4 copies. This is
called mirroring. The picture chip’s registers start at 0x2000, and the
controllers answer at 0x4016 and 0x4017. An NES emulator’s read is a
bigger version of the one above.
A real frame’s budget, like the one in Lesson 13.9, runs to thousands of
cycles, while the whole traced program takes only 29. So the lab uses a tiny
budget of 18 cycles, which ends a frame partway through the program. Its run
loop steps whole instructions until the budget is used or the CPU halts:
The check used < budget happens before each instruction, never during one,
because an instruction cannot stop halfway. After step 7 the run has used 17
cycles, which is less than 18, so it runs step 8, an ADD worth 2 cycles, and
reaches 19. That is not less than 18, so the frame ends. It overshot by
19 − 18 = 1 cycle, and the emulator gives the next frame 18 − 1 = 17 cycles,
which keeps the long-run average at exactly 18 per frame. The overshoot can
never exceed 2: the frame’s last instruction starts with at most 17 cycles used
and costs at most 3, and 17 + 3 = 20 = 18 + 2.
Here is the machine at that frame boundary:
PC0x06
A20
X1
Z0
0x80 · gold15
CPU registers
RAM
Where the 18-cycle budget stops. The new total, 20, exists only in A; gold in RAM still says 15 until STA at 0x06 runs.
At that frame boundary the new gold total exists only in A, so a snapshot of
RAM alone would restore gold = 15 and lose the 20. The lab’s SaveState copies
every part of the machine and converts it to a row of bytes:
PC0x06
A20
X1
Z0
halted0
cycles19
CPU registers
emulator bookkeeping
The first 13 bytes. PC, A, X, Z, and the halted flag take 1 byte each; the cycle count is a 64-bit number, and 64 ÷ 8 = 8 bytes. So 5 + 8 = 13.
RAM240 bytes
display0
buttons0
all of RAM
I/O registers
The other 242 bytes: 240 for RAM and 1 each for the two I/O registers. In all, 13 + 240 + 2 = 255 bytes.
The halted flag records whether HLT has run, and the cycle count is the
emulator’s position in time, so a loaded state resumes at the same point in the
frame. Reading a state back is parsing input from outside the program, so it is
checked the way Lesson 10.2 checks a save file: the lab refuses
anything that is not exactly 255 bytes long, and refuses a Z or halted byte
that holds anything other than 0 or 1, because no real run of the machine could
have produced it. Real emulators also store a version number, because the
layout changes as the emulator does, and a state saved by one version may not
load in another.
It returns the overshoot, which the caller takes off the next frame’s budget.
saturating_sub subtracts but stops at 0 instead of wrapping around, so a frame
that ends early because the CPU halted reports no overshoot.
Give it the 18-cycle frames and one cheat: write 99 to gold. Frame 1 runs steps 1
to 8, and then the cheat writes 99 to 0x80. But the game had already copied
gold into A and added 5. Frame 2, with its budget of 17, starts with step 9,
which stores A’s 20 over the 99, and steps 9 to 13 finish the program in
3 + 1 + 2 + 3 + 1 = 10 cycles, well inside the budget. Step 12 shows 20 on the
display, and at the end of frame 2 the cheat writes 99 again:
A20
0x80 · gold99
0xF0 · display20
CPU register
RAM
I/O
After frame 2. RAM holds the cheat's 99, but the game stored and displayed the 20 it had already computed in A.
The portable lab
toy_emulator_lab.rs is the console
from this lesson: the CPU, the 240 bytes of RAM, the display and buttons
registers, the step interpreter, a run loop with a cycle budget, frames with
constant-write cheats, and a SaveState that copies the registers and memory and
converts them to and from 255 bytes.
//! Toy emulator lab for Lesson 14.11.//!//! A made-up 8-bit console: a CPU with a program counter, registers `A` and//! `X`, and a zero flag; 240 bytes of RAM; and two memory-mapped I/O//! registers. The emulator is an interpreter. Each step fetches the byte at//! the program counter, decodes it, executes it, and counts the cycles the//! real chip would have spent. On top of that sit a run loop with a cycle//! budget, video frames with constant-write cheats, and save states.use std::error::Error;use std::fmt;/// RAM fills addresses 0x00 to 0xEF, which is 0xF0 = 15 x 16 = 240 bytes.const RAM_SIZE: usize = 0xF0;/// Writing here changes the number on the console's display.const DISPLAY: u8 = 0xF0;/// Reading here returns the buttons held down, one bit per button.const BUTTONS: u8 = 0xF1;/// Where the lesson's program keeps the player's gold.const GOLD: u8 = 0x80;/// The small frame budget the lesson uses to stop the program part way.const FRAME_BUDGET: u64 = 18;/// The lesson's program: add 5 gold twice, then show the total.const PROGRAM: [u8; 14] = [ 0x01, 0x02, // 0x00 LDX #2 X = 2 0x02, 0x80, // 0x02 LDA 0x80 A = gold 0x03, 0x05, // 0x04 ADD #5 A = A + 5 0x04, 0x80, // 0x06 STA 0x80 gold = A 0x05, // 0x08 DEX X = X - 1 0x06, 0x02, // 0x09 JNZ 0x02 back to 0x02 while X is not 0 0x04, 0xF0, // 0x0B STA 0xF0 show A on the display 0xFF, // 0x0D HLT];/// The instructions this CPU understands. Operands are fetched while the/// instruction executes.#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum Opcode { /// `0x01 n`: X = n. Ldx, /// `0x02 a`: A = the byte at address a. Lda, /// `0x03 n`: A = A + n, wrapping past 255 back to 0. Add, /// `0x04 a`: store A at address a. Sta, /// `0x05`: X = X - 1, wrapping below 0 to 255. Dex, /// `0x06 a`: jump to address a if the zero flag is clear. Jnz, /// `0xFF`: stop. Hlt,}impl Opcode { const fn decode(byte: u8) -> Option<Self> { match byte { 0x01 => Some(Self::Ldx), 0x02 => Some(Self::Lda), 0x03 => Some(Self::Add), 0x04 => Some(Self::Sta), 0x05 => Some(Self::Dex), 0x06 => Some(Self::Jnz), 0xFF => Some(Self::Hlt), _ => None, } }}/// The CPU's registers, plus two facts the emulator tracks about it: whether/// it has halted, and how many cycles it has run since power-on.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]struct Cpu { pc: u8, a: u8, x: u8, zero: bool, halted: bool, cycles: u64,}#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum EmuError { /// The byte at `at` is not an instruction this CPU knows. IllegalOpcode { at: u8, opcode: u8 }, /// The program does not fit in RAM. ProgramTooLarge { length: usize }, /// A save state has the wrong length or an impossible flag byte. BadSaveState,}impl fmt::Display for EmuError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::IllegalOpcode { at, opcode } => { write!( formatter, "byte {opcode:#04X} at {at:#04X} is not an instruction" ) } Self::ProgramTooLarge { length } => write!( formatter, "a {length}-byte program does not fit in {RAM_SIZE} bytes of RAM" ), Self::BadSaveState => { formatter.write_str("the save state has the wrong length or an impossible flag") } } }}impl Error for EmuError {}/// A constant-write cheat code: after every frame, write `value` at `address`.#[derive(Clone, Copy, Debug)]struct Cheat { address: u8, value: u8,}/// The whole console: CPU, RAM, and the two I/O registers.#[derive(Clone, Debug, PartialEq, Eq)]struct Console { cpu: Cpu, ram: [u8; RAM_SIZE], display: u8, buttons: u8,}impl Console { /// A console just powered on, with `program` copied to address 0x00. fn with_program(program: &[u8]) -> Result<Self, EmuError> { let mut ram = [0; RAM_SIZE]; ram.get_mut(..program.len()) .ok_or(EmuError::ProgramTooLarge { length: program.len(), })? .copy_from_slice(program); Ok(Self { cpu: Cpu::default(), ram, display: 0, buttons: 0, }) } /// The memory map. Every read the CPU makes comes through here. fn read(&self, address: u8) -> u8 { match address { 0x00..=0xEF => self.ram[usize::from(address)], DISPLAY => self.display, BUTTONS => self.buttons, _ => 0, // 0xF2 to 0xFF: nothing is wired there } } /// Every write the CPU makes comes through here too. fn write(&mut self, address: u8, value: u8) { match address { 0x00..=0xEF => self.ram[usize::from(address)] = value, DISPLAY => self.display = value, _ => {} // the buttons are read-only, and 0xF2 to 0xFF go nowhere } } /// Reads the byte at the program counter and moves the counter past it. fn fetch(&mut self) -> u8 { let byte = self.read(self.cpu.pc); self.cpu.pc = self.cpu.pc.wrapping_add(1); byte } /// Runs one instruction and returns the cycles it took. /// /// The cycle rule: one cycle for each byte fetched, one for each data /// byte read or written, and one more when a jump is taken. fn step(&mut self) -> Result<u8, EmuError> { if self.cpu.halted { return Ok(0); } let at = self.cpu.pc; let opcode = self.fetch(); let instruction = Opcode::decode(opcode).ok_or(EmuError::IllegalOpcode { at, opcode })?; let cycles = match instruction { Opcode::Ldx => { self.cpu.x = self.fetch(); self.cpu.zero = self.cpu.x == 0; 2 } Opcode::Lda => { let address = self.fetch(); self.cpu.a = self.read(address); self.cpu.zero = self.cpu.a == 0; 3 } Opcode::Add => { let value = self.fetch(); self.cpu.a = self.cpu.a.wrapping_add(value); self.cpu.zero = self.cpu.a == 0; 2 } Opcode::Sta => { let address = self.fetch(); self.write(address, self.cpu.a); 3 } Opcode::Dex => { self.cpu.x = self.cpu.x.wrapping_sub(1); self.cpu.zero = self.cpu.x == 0; 1 } Opcode::Jnz => { let target = self.fetch(); if self.cpu.zero { 2 } else { self.cpu.pc = target; 3 } } Opcode::Hlt => { self.cpu.halted = true; 1 } }; self.cpu.cycles += u64::from(cycles); Ok(cycles) } /// Runs whole instructions until `budget` cycles have been used or the /// CPU halts, and returns the cycles used. An instruction is never split, /// so the result can pass the budget by up to two cycles. fn run(&mut self, budget: u64) -> Result<u64, EmuError> { let mut used = 0; while used < budget && !self.cpu.halted { used += u64::from(self.step()?); } Ok(used) } /// One video frame: run for the frame's budget, then apply every cheat, /// the way a cheat cartridge writes its values once per frame. Returns /// the overshoot, which the caller takes off the next frame's budget. fn run_frame(&mut self, budget: u64, cheats: &[Cheat]) -> Result<u64, EmuError> { let used = self.run(budget)?; for cheat in cheats { self.write(cheat.address, cheat.value); } Ok(used.saturating_sub(budget)) } /// Copies everything the console is. fn save_state(&self) -> SaveState { SaveState { cpu: self.cpu, ram: self.ram, display: self.display, buttons: self.buttons, } } /// Puts the console back exactly where the save state was taken. fn load_state(&mut self, state: &SaveState) { self.cpu = state.cpu; self.ram = state.ram; self.display = state.display; self.buttons = state.buttons; }}/// Everything the console is, copied out: registers, all of RAM, and the I/O/// registers. Loading it puts the machine back exactly where it was.#[derive(Clone, Debug, PartialEq, Eq)]struct SaveState { cpu: Cpu, ram: [u8; RAM_SIZE], display: u8, buttons: u8,}impl SaveState { /// PC, A, X, Z, and halted (5 bytes), the cycle count (8 bytes), RAM /// (240 bytes), display and buttons (2 bytes): 5 + 8 + 240 + 2 = 255. const LENGTH: usize = 5 + 8 + RAM_SIZE + 2; fn to_bytes(&self) -> Vec<u8> { let cpu = self.cpu; let mut bytes = Vec::with_capacity(Self::LENGTH); bytes.extend([ cpu.pc, cpu.a, cpu.x, u8::from(cpu.zero), u8::from(cpu.halted), ]); bytes.extend(cpu.cycles.to_le_bytes()); bytes.extend(self.ram); bytes.extend([self.display, self.buttons]); bytes } /// Reads a save state back, refusing anything that is not exactly /// `LENGTH` bytes long or has a flag byte other than 0 or 1. fn from_bytes(bytes: &[u8]) -> Result<Self, EmuError> { let bad = EmuError::BadSaveState; let (&[pc, a, x, zero, halted], rest) = bytes.split_first_chunk::<5>().ok_or(bad)?; let (cycles, rest) = rest.split_first_chunk::<8>().ok_or(bad)?; let (ram, rest) = rest.split_first_chunk::<RAM_SIZE>().ok_or(bad)?; let &[display, buttons] = rest else { return Err(bad); }; let flag = |byte| match byte { 0 => Ok(false), 1 => Ok(true), _ => Err(bad), }; Ok(Self { cpu: Cpu { pc, a, x, zero: flag(zero)?, halted: flag(halted)?, cycles: u64::from_le_bytes(*cycles), }, ram: *ram, display, buttons, }) }}/// A console with the lesson's program loaded and 10 gold at 0x80.fn lesson_console() -> Result<Console, EmuError> { let mut console = Console::with_program(&PROGRAM)?; console.write(GOLD, 10); Ok(console)}fn main() -> Result<(), Box<dyn Error>> { println!("1. The traced program, one instruction at a time"); let mut console = lesson_console()?; println!(" step at A X Z gold cycles"); let mut step = 0; while !console.cpu.halted { let at = console.cpu.pc; console.step()?; step += 1; let cpu = console.cpu; println!( " {step:>4} {at:#04X} {:>3} {:>3} {} {:>4} {:>6}", cpu.a, cpu.x, u8::from(cpu.zero), console.read(GOLD), cpu.cycles ); } println!(" the display shows {}", console.display); println!("\n2. A budget of {FRAME_BUDGET} cycles stops the run between two instructions"); let mut console = lesson_console()?; let used = console.run(FRAME_BUDGET)?; println!( " used {used} cycles: PC = {:#04X}, A = {}, X = {}, gold in RAM = {}", console.cpu.pc, console.cpu.a, console.cpu.x, console.read(GOLD) ); let saved = console.save_state(); console.run(1_000)?; let first_ending = console.clone(); console.load_state(&saved); console.run(1_000)?; println!( " loaded the save state and ran on again: same final machine = {}", console == first_ending ); let bytes = saved.to_bytes(); let read_back = SaveState::from_bytes(&bytes)?; println!( " the save state is {} bytes, and reads back unchanged = {}", bytes.len(), read_back == saved ); println!("\n3. A constant-write cheat puts 99 in gold after every frame"); let mut console = lesson_console()?; let cheats = [Cheat { address: GOLD, value: 99, }]; let mut budget = FRAME_BUDGET; for frame in 1..=2 { let overshoot = console.run_frame(budget, &cheats)?; budget = FRAME_BUDGET - overshoot; println!( " after frame {frame}: gold = {}, A = {}, display = {}", console.read(GOLD), console.cpu.a, console.display ); } Ok(())}#[cfg(test)]mod tests { use super::*; fn fresh() -> Console { lesson_console().unwrap() } /// After each step: (address of the instruction, A, X, Z, gold, total /// cycles), exactly as the lesson's trace tables list them. const TRACE: [(u8, u8, u8, bool, u8, u64); 13] = [ (0x00, 0, 2, false, 10, 2), // LDX #2 (0x02, 10, 2, false, 10, 5), // LDA 0x80 (0x04, 15, 2, false, 10, 7), // ADD #5 (0x06, 15, 2, false, 15, 10), // STA 0x80 (0x08, 15, 1, false, 15, 11), // DEX (0x09, 15, 1, false, 15, 14), // JNZ 0x02 jumps (0x02, 15, 1, false, 15, 17), // LDA 0x80 (0x04, 20, 1, false, 15, 19), // ADD #5 (0x06, 20, 1, false, 20, 22), // STA 0x80 (0x08, 20, 0, true, 20, 23), // DEX (0x09, 20, 0, true, 20, 25), // JNZ 0x02 falls through (0x0B, 20, 0, true, 20, 28), // STA 0xF0 (0x0D, 20, 0, true, 20, 29), // HLT ]; #[test] fn every_step_matches_the_lesson_trace() { let mut console = fresh(); for (step, &(at, a, x, zero, gold, cycles)) in (1..).zip(TRACE.iter()) { assert_eq!( console.cpu.pc, at, "step {step} starts at the wrong address" ); console.step().unwrap(); let cpu = console.cpu; assert_eq!( (cpu.a, cpu.x, cpu.zero, console.read(GOLD), cpu.cycles), (a, x, zero, gold, cycles), "after step {step}" ); } assert!(console.cpu.halted); assert_eq!(console.display, 20); } #[test] fn a_jump_costs_one_more_cycle_when_it_is_taken() { let mut console = Console::with_program(&[0x06, 0x05]).unwrap(); assert_eq!(console.step(), Ok(3)); assert_eq!(console.cpu.pc, 0x05); let mut console = Console::with_program(&[0x06, 0x05]).unwrap(); console.cpu.zero = true; assert_eq!(console.step(), Ok(2)); assert_eq!(console.cpu.pc, 0x02); } #[test] fn addition_wraps_past_255_and_sets_the_zero_flag() { let mut console = Console::with_program(&[0x03, 0x06]).unwrap(); console.cpu.a = 250; console.step().unwrap(); assert_eq!(console.cpu.a, 0); assert!(console.cpu.zero); } #[test] fn a_budget_stops_between_instructions_never_inside_one() { let mut console = fresh(); assert_eq!(console.run(FRAME_BUDGET), Ok(19)); assert_eq!( (console.cpu.pc, console.cpu.a, console.cpu.x), (0x06, 20, 1) ); assert_eq!(console.read(GOLD), 15, "the new total exists only in A"); } #[test] fn loading_a_save_state_replays_the_same_future() { let mut console = fresh(); console.run(FRAME_BUDGET).unwrap(); let saved = console.save_state(); console.run(1_000).unwrap(); let first_ending = console.clone(); assert!(first_ending.cpu.halted); console.write(GOLD, 0); console.load_state(&saved); assert_eq!(console.save_state(), saved); console.run(1_000).unwrap(); assert_eq!(console, first_ending); } #[test] fn a_save_state_round_trips_through_255_bytes() { let mut console = fresh(); console.run(FRAME_BUDGET).unwrap(); let saved = console.save_state(); let bytes = saved.to_bytes(); assert_eq!(bytes.len(), 255); assert_eq!(bytes[..5], [0x06, 20, 1, 0, 0]); assert_eq!(SaveState::from_bytes(&bytes), Ok(saved)); } #[test] fn a_damaged_save_state_is_rejected() { let bytes = fresh().save_state().to_bytes(); assert_eq!( SaveState::from_bytes(&bytes[..254]), Err(EmuError::BadSaveState) ); let mut damaged = bytes; damaged[3] = 2; // the zero flag can only be 0 or 1 assert_eq!(SaveState::from_bytes(&damaged), Err(EmuError::BadSaveState)); } #[test] fn unknown_bytes_are_an_error_not_a_guess() { let mut console = Console::with_program(&[]).unwrap(); assert_eq!( console.step(), Err(EmuError::IllegalOpcode { at: 0, opcode: 0 }) ); } #[test] fn a_program_larger_than_ram_is_rejected() { assert_eq!( Console::with_program(&[0; 241]).unwrap_err(), EmuError::ProgramTooLarge { length: 241 } ); } #[test] fn io_addresses_are_not_ram() { let mut console = fresh(); console.write(DISPLAY, 42); assert_eq!(console.read(DISPLAY), 42); console.buttons = 0b0000_0101; console.write(BUTTONS, 0xFF); assert_eq!( console.read(BUTTONS), 0b0000_0101, "the buttons are read-only" ); console.write(0xF5, 7); assert_eq!(console.read(0xF5), 0, "nothing is wired at 0xF5"); } #[test] fn a_cheat_is_rewritten_every_frame_but_loses_to_a_register_copy() { let mut console = fresh(); let cheats = [Cheat { address: GOLD, value: 99, }]; assert_eq!(console.run_frame(FRAME_BUDGET, &cheats), Ok(1)); assert_eq!(console.read(GOLD), 99); assert_eq!(console.run_frame(FRAME_BUDGET - 1, &cheats), Ok(0)); assert!(console.cpu.halted); assert_eq!(console.read(GOLD), 99); assert_eq!( console.display, 20, "the game copied gold into A before the cheat wrote it" ); }}
Build it, then run it from the rust-labs folder:
Terminal window
cargo run --bin toy_emulator_lab
The lab first runs the traced program one instruction at a time and prints the
same numbers as the tables above. Then it stops a run at the 18-cycle boundary,
takes a save state, runs to the end, loads the state, and runs again, arriving
at an identical machine. Finally, it runs two frames with the gold cheat and
shows RAM at 99 while the display says 20. The tests check every one of the 13
trace steps, both costs of JNZ, wrapping at 255, the budget stop, the save
state’s round trip through 255 bytes, the rejection of damaged save states and
unknown opcodes, and the cheat’s race with register A.
Then give the console a watchpoint: a watch: Option<u8> field, and an if in
write that records self.cpu.pc whenever the watched address is written.
Before you run the traced program with gold watched, predict which PC values it
records and why they are not 0x06, the address of the STA.