Skip to content

Lab 33: Beyond the Assembler: Hand-Encoding an Instruction

Time: ~55 minutes | Prerequisites: Lab 32 | Hardware: Pico 2

The assembler is not the instruction set

Echo waving welcome Your chip implements an instruction MicroPython refuses to write. So we look it up in the manual and build the machine code ourselves.

What You'll Build

An instruction MicroPython refuses to write, encoded by hand from the ARM manual and executed successfully.

Learning Objectives

  • Derive an instruction encoding from the reference manual
  • Emit raw machine words with the data() directive
  • Verify a hand-encoded instruction on known values
  • Explain why the payoff is small and why that matters

Concepts Introduced

ID Concept
541 Instruction Encoding
542 Opcode
543 Encoding Bit Field
544 Encoding Table
545 Halfword
546 Thumb-2 Encoding
547 Data Directive
548 Raw Machine Word
549 Fused Multiply Add
550 VFMA Instruction
551 Fused Rounding
552 Assembler Limitation
553 ISA Versus Toolchain
554 Encoding Verification

Procedure

Open 33-hand-encoding.py and work through it section by section:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# Lab 33: Beyond the Assembler -- Hand-Encoding an Instruction
#
# Your Cortex-M33 implements VFMA.F32 -- fused multiply-add. It computes
# a = a + b*c as a SINGLE instruction with a single rounding.
#
# MicroPython's assembler refuses to write it:
#
#     >>> vfma(s0, s1, s2)
#     unsupported Thumb instruction 'vfma' with 3 arguments
#
# The silicon can do it. The toolchain cannot express it. That gap is the
# subject of this lab, and the point is bigger than one instruction:
#
#     THE ASSEMBLER IS NOT THE INSTRUCTION SET.
#
# So we look up the encoding in the ARM manual, build the machine word
# ourselves, and emit it as raw data.

import machine
import micropython
from array import array
from uctypes import addressof

machine.mem32[0xE000EDFC] = machine.mem32[0xE000EDFC] | (1 << 24)
machine.mem32[0xE0001000] = machine.mem32[0xE0001000] | 1


def rd():
    return machine.mem32[0xE0001004]


# =========================================================================
# PART 1 -- confirm the assembler really refuses
# =========================================================================
print("=== PART 1: the assembler says no ===")
try:
    exec("@micropython.asm_thumb\ndef _f():\n    vfma(s0, s1, s2)\n",
         {"micropython": micropython})
    print("vfma assembled -- your MicroPython supports it directly!")
except Exception as e:
    print("vfma(s0, s1, s2) ->", e)
print()
print("The instruction exists in the chip. Only the assembler is missing.")


# =========================================================================
# PART 2 -- read the encoding out of the manual
# =========================================================================
print()
print("=== PART 2: building the instruction by hand ===")
print()
print("From the VFP data-processing format (ARM Cortex-M33 user guide,")
print("section 3.12), VFMA.F32 <Sd>, <Sn>, <Sm> has base word 0xEEA00A00.")
print()
print("Single-precision registers are SPLIT across two fields each:")
print("    Sd -> Vd = Sd>>1 at bits 15:12,   D = Sd&1 at bit 22")
print("    Sn -> Vn = Sn>>1 at bits 19:16,   N = Sn&1 at bit 7")
print("    Sm -> Vm = Sm>>1 at bits  3:0,    M = Sm&1 at bit 5")
print()


def encode_vfma(sd, sn, sm, subtract=False):
    """Return the two Thumb-2 halfwords for VFMA/VFMS.F32 sd, sn, sm."""
    word = 0xEEA00A00
    if subtract:
        word |= 1 << 6                      # VFMS is VFMA with the op bit set
    word |= (sd >> 1) << 12
    word |= (sd & 1) << 22
    word |= (sn >> 1) << 16
    word |= (sn & 1) << 7
    word |= (sm >> 1)
    word |= (sm & 1) << 5
    return (word >> 16) & 0xFFFF, word & 0xFFFF


print("Worked example -- VFMA.F32 s0, s1, s2  (s0 += s1*s2):")
print("    Sd=0 -> Vd=0, D=0")
print("    Sn=1 -> Vn=0, N=1  -> bit 7 set  -> 0x80")
print("    Sm=2 -> Vm=1, M=0  -> 0x1")
print("    0xEEA00A00 | 0x80 | 0x1 = 0xEEA00A81")
hi, lo = encode_vfma(0, 1, 2)
print("    encode_vfma(0,1,2) = %s %s" % (hex(hi), hex(lo)))
print()
print("A 32-bit Thumb-2 instruction is stored as two halfwords, high first.")


# =========================================================================
# PART 3 -- emit it and check it works
# =========================================================================
print()
print("=== PART 3: does it actually run? ===")
print()


@micropython.asm_thumb
def try_vfma(r0):
    # buf = [a, b, c, result]     computes a + b*c
    vldr(s0, [r0, 0])
    vldr(s1, [r0, 4])
    vldr(s2, [r0, 8])
    data(2, 0xEEA0, 0x0A81)         # vfma.f32 s0, s1, s2
    vstr(s0, [r0, 12])


buf = array("f", [1.0, 2.0, 3.0, 0.0])
try_vfma(addressof(buf))
print("1.0 + 2.0*3.0 = %.1f   (expect 7.0)" % buf[3])
print()
if abs(buf[3] - 7.0) < 1e-6:
    print("It ran. You just executed an instruction your assembler cannot")
    print("write, by constructing the machine code yourself.")
else:
    print("Wrong result -- check the encoding.")


# =========================================================================
# PART 4 -- the trap that costs an afternoon
# =========================================================================
print()
print("=== PART 4: a trap worth knowing about ===")
print()
print("Encode VFMA.F32 s7, s1, s4 and look carefully:")
hi7, lo7 = encode_vfma(7, 1, 4)
hi6, lo6 = encode_vfma(6, 1, 5, subtract=True)
print("    vfma.f32 s7, s1, s4  ->  %s %s" % (hex(hi7), hex(lo7)))
print("    vfms.f32 s6, s1, s5  ->  %s %s" % (hex(hi6), hex(lo6)))
print()
print("The HIGH halfwords differ: 0xEEE0 versus 0xEEA0.")
print()
print("Sd=7 is odd, so the D bit is set -- and D lives at bit 22, which is")
print("in the FIRST halfword. Get that wrong and the instruction still")
print("assembles, still runs, and quietly writes to the wrong register.")
print()
print("No error. No crash. Just a spectrum that is subtly wrong.")
print()
print("This exact mistake was made while building this course. Which is")
print("why the next section exists.")


# =========================================================================
# PART 5 -- verify every encoding before trusting it
# =========================================================================
print()
print("=== PART 5: verify, do not assume ===")
print()


@micropython.asm_thumb
def check_pair(r0):
    # buf = [10.0, 2.0, 3.0, out_fms, out_fma]
    vldr(s6, [r0, 0])           # 10.0
    vldr(s7, [r0, 0])           # 10.0
    vldr(s1, [r0, 4])           # 2.0
    vldr(s5, [r0, 8])           # 3.0
    vldr(s4, [r0, 8])           # 3.0
    data(2, 0xEEA0, 0x3AE2)     # vfms.f32 s6, s1, s5  -> 10 - 2*3 = 4
    data(2, 0xEEE0, 0x3A82)     # vfma.f32 s7, s1, s4  -> 10 + 2*3 = 16
    vstr(s6, [r0, 12])
    vstr(s7, [r0, 16])


b = array("f", [10.0, 2.0, 3.0, 0.0, 0.0])
check_pair(addressof(b))
print("vfms: 10 - 2*3 = %.1f  (expect 4.0)   %s"
      % (b[3], "ok" if abs(b[3] - 4.0) < 1e-6 else "WRONG"))
print("vfma: 10 + 2*3 = %.1f  (expect 16.0)  %s"
      % (b[4], "ok" if abs(b[4] - 16.0) < 1e-6 else "WRONG"))
print()
print("Two lines of arithmetic with a known answer. Always do this before")
print("a hand-encoded instruction goes anywhere near real data.")


# =========================================================================
# PART 6 -- what does it actually buy?
# =========================================================================
print()
print("=== PART 6: was it worth it? ===")
print()
import gc
import v0_baseline
import v7_vfma_raw

N = 512
import math
signal = [math.sin(2 * math.pi * 40 * i / N) for i in range(N)]

v0 = v0_baseline.Variant(N)
v7 = v7_vfma_raw.Variant(N)
gc.collect()


def bench(v, trials=15):
    re, im = v.make_buffers()

    def once():
        for i in range(N):
            re[i] = signal[i]
            im[i] = 0.0
        s = rd()
        v.run(re, im)
        return (rd() - s) & 0xFFFFFFFF

    once()
    return min(once() for _ in range(trials))


base = bench(v0)
vfma = bench(v7)

print("%-26s %12s %10s" % ("variant", "cycles", "speedup"))
print("%-26s %12d %9.3fx" % ("v0 baseline", base, 1.0))
print("%-26s %12d %9.3fx" % ("v7 hand-encoded VFMA", vfma, base / vfma))
print()
print("The butterfly's two multiply-then-add sequences drop from three")
print("instructions to two. That removes 2 of about 28 instructions -- a")
print("ceiling of roughly 7%%, and the measurement lands below even that.")
print()
print("=== The real lesson ===")
print()
print("This variant is the most technically demanding in the course and")
print("very nearly the least effective. That is not a failure -- it is the")
print("finding.")
print()
print("Lab 24 showed the FFT's cost is dominated by loop control, address")
print("arithmetic and memory access, NOT by the multiplies. Optimising")
print("arithmetic in a loop that is not arithmetic-bound barely registers.")
print()
print("What you actually gained: the ability to reach any instruction your")
print("chip implements, whether or not your toolchain has heard of it.")
print("Some day you will need an instruction nobody exposed, and now you")
print("know it is a lookup in a manual rather than a dead end.")

Each part builds on the last, and the comments in the file explain the reasoning as you go. Run it, read it, then change something and run it again.

Predict before you measure

Echo offering a tip Wherever this lab reports a speedup, write your guess down before you run it. Every quantitative prediction made while building this course turned out to be optimistic — being wrong on paper is how you find out what the machine really does.

Troubleshooting

Symptom Likely cause Fix
unsupported Thumb instruction The assembler lacks that mnemonic Check Lab 28's probe; see Lab 33 for the workaround
Assembly returns nonsense Wrong argument order Arguments arrive in r0, r1, r2, r3
Results differ between runs No warm-up, or heap state Discard a warm-up; build objects before measuring (Labs 26, 32)
Variant looks slower than baseline Measurement artifact Re-run with everything allocated up front
MemoryError Too many variants alive gc.collect() between sections

Check Your Understanding

  1. What does this lab measure, and what does it deliberately exclude?
  2. Which result surprised you most against your prediction, and why?
  3. What would you change to make the effect larger?
  4. Where would this technique NOT be worth the complexity?

Onward

Echo celebrating You can now reach any instruction your chip implements, whether or not your toolchain has heard of it.


Next: Lab 34 | Previous: Lab 32