Last time, I covered the basics of doing ARM assembly programming on the Raspberry Pi Pico’s RP2040 microcontroller. Now it’s time to get to grips with the dozens of instructions to which the RP2040’s Cortex-M0+ cores respond.
Each instruction is just a number, encoded to indicate the operation itself, what registers it uses, and any other relevant data. But because encoding numerical instructions is hard, assembly programming instead represents instructions in a textual shorthand. Because these codes are easier to memorise than are the numbers they represent, they’re called ‘mnemonics’—pronounced ‘nemonix’. The assembler converts the mnemonic ‘operator’ plus a value derived from its various ‘operands‘ into a numeric value—and that’s the machine code that the MCU executes.
op operand_1, operand_2, operand_3
Operators, or ‘ops’, can be grouped by type. The ones you’ll use most frequently involve loading numbers into register from memory, moving numbers into registers from other registers or as specified values, performing arithmetic on registers’ contents, and writing the results back to memory again. These are the groups of ops I’ll cover here—in future installments, I’ll go over branch ops, logical ops and the rest.
Let’s look at the instructions you’ll use to get data into registers. You can get the full details from ARM’s documentation, such as this handy reference card, so I’ve focused on the key points.
This fills one register with one or more bytes from memory. The address of that memory is stored in another register—it’s used as a pointer. This is called “indirect addressing”, and it’s indicated by placing square brackets around the pointer register and any optional offset to its value. The basic form is:
LDR<type> Rd, [Rn, #<offset>]
This means load the destination register, Rd, with the value stored at the x bytes whose first address is the value stored in Rn plus offset. If offset is zero, you can omit it:
| |
If Rd and Rn are different, offset is a 5-bit value; if they are the same, it’s an 8-bit value. To complicate matters, the offset actually applied is four times that value, because word addresses have to be four-byte aligned, ie. divisible by four. The maximum 5-bit value is 31, which gives us an actual offset range of 0-124 (4 x 31). The actual 8-bit range is 0-1020 (4 x 255). The assembler assumes you’re giving an actual value, not one in the base range.
The offset can also be a value held in another register, which is handy if you need a calculated offset or an offset larger than an immediate offset will give you:
| |
Let’s talk about <type>. It’s a character indicating the loaded value’s type. Omit it if you’re loading an unsigned 32-bit value, but add H for an unsigned 16-bit value (Halfword), or B for an unsigned 8-bit value (Byte). If the number is signed, prefix <type> with S; use SW for a signed 32-bit number.
| |
You can use SP and PC in place of R13 and R15 to make working with those registers more explicit—this works for most ops. You can also use labels:
LDR R0, data_address
This is considered a special case which will be converted by the assembler into a PC-relative instruction:
| |
With a single op, LDM, you can fill two or more low registers (R0-7) with the contents of the memory pointed to by another register:
| |
Include the ! to increment Rn with the number of bytes loaded, handy for loop counters. For example, if three registers are specified in the list, that’s 12 bytes (3 x 4 bytes), so Rn += 12. If Rn is included in the list of low registersd, it won’t be updated with the increment, even if you’ve added the !.
The registers will be loaded in number order, not in the order you list them.
Store ops write the contents of a register to memory by specifying the first byte of memory. Like loads, stores primarily use indirect addressing. They use the same <type> definitions and immediate offset rules. The basic form is:
| |