RISC-V Assembler: Arithmetic
projectf.io
projectf.io
I did a bit of x86 as stuff 20 years ago but hated it, now I wanted to teach my daughter some c and assembler and was thinking between arm and riscv, but riscv is just a joy to teach (I made a riscv assembler boardgame to help with the task https://punkx.org/overflow/)
Recently I was rewatching Hackers(1995) and I also got excited about the same quote:
> “RISC architecture is going to change everything.” — Acid Burn
After spending some time with esp32s and riscv assembler, I think its more true than before :)
If you havent given it a try yet, there are many articles like the one, and also projects like https://luplab.gitlab.io/rvcodecjs/ or https://riscv.vercel.app/ where you can play with it, or even make your own emulator by learning from other emulators like https://github.com/OpenMachine-ai/tinyfive/blob/main/machine...
this cheatsheet is also very useful: https://www.cl.cam.ac.uk/teaching/1617/ECAD+Arch/files/docs/...
You can tell because sbreak/scall. They are ebreak/ecall in the final version.
AIUI, the reason the name was changed is that they're not necessarily calls to the supervisor. E.g. they could be calls to machine mode, or the hypervisor. And some chips only implement M mode.
RISC-V I started playing with more recently (early 2023, thanks to VisionFive 2), and it feels like my old favorite (MIPS), without the baggage MIPS carried.
It is a pleasure to work with this amount of GPRs and the comfortable alternative names for them that the official abi offers. I am loving it so far.
I expect to have RVA22+V hardware soon (Milk-V Oasis). Very much looking forward to playing with the vector extension on that.
I have been coding kind of a lot x64 recently, the limitation of 16 GPRs has been painful. I am sure that when I will crank up on rv64 assembly programming, those 32GPRs will feel like fresh air.
In the other hand, I am not fond of the ABI register names, and the pseudo-instructions involving mini-compilation. I'll stick to xNUMBER register names and won't use pseudo-instructions. Like I will avoid any abuse of the preprocessor.
Why? They're simple substitution, and very helpful with following ABI.
>and the pseudo-instructions involving mini-compilation.
Again, why? These aren't specific to the assembler used, but rather, defined in the specification itself. This means they are reliable, and will always be there for as long as you use a RISC-V compliant assembler.
They are thus also the register names you will see in disassembler output, debuggers and other tools.
Also, you might be interested in this new RVA22+V board[0].
0. https://forum.banana-pi.org/t/leading-the-future-of-computin...
For example `li` gets expanded by the assembler into `liu` and `addi` which on larger RISC-V cores get recognised and fused back into a single op. Using `xori` instead of `addi` would have had the same result but wouldn't get fused.
Next, some idioms get recognised and automatically assembled into "compressed" 16-bit instructions to save space. For example "mv rd,rs" and "addi rd, rs, 0" both get assembled into "c.mv rd,rs". And on a larger RISC-V core, "c.mv" could be only a register rename in the decoder, thus taking 0 cycles.
I am currently writting my own rv64 on x64 virtual machine process, to code my programs in rv64 and not anymore in x64 in order to be "real hardware ready".
BTW, anybody knows a EU based distributor of milk-v duo boards with the cv1800 SOC (the one without ARM cores and a rv64 MCU) which I can contact using my domestic email server?
I love the idea that someone could get to this page and not already know that!
Also this nicely highlights my pet peeve with assembly:
add rd, rs1, rs2 # rd = rs1 + rs2
It's very difficult to remember which parameter is the destination etc. IMO it would be much nicer if assembly had just a little more syntax for that sort of thing. E.g. rd = add rs1, rs2
t0 = li 5
Just so the destination register is obvious. Ah well, nobody's going to do that. Assembly parsing is a total mess; there's no official grammar or anything - it's just "what GCC/LLVM do".Might be worth noting because it is different in other assembly languages. In e.g. 6502 and MC680x0 asm, you'd first prefix with # to differentiate between an address (to load from and/or store to) and an "immediate" constant number, and then $ to denote hexadecimal. In x86 assembly (Intel style), you instead suffix the number with the letter h.
> rd = add rs1, rs2
There are assembly languages for a few architectures that did/have that form or a similar one. Itanium assembly had instead the form:
add r1 = r2, r3But at some point doing simple C makes more sense, than adding syntactic sugar to Assembly.
Most architectures have something. Sparc had one (I still have the manual), PPC, 68k. I would even say x86 does as well but you can’t force its adoption, what AT&T and GNU wants to do on their own can’t be prevented. AT&T I suppose had the goal of making it all consistent, but I’m not sure if that was an improvement. Though I know of its vocal defendants.
RISC-V might be the exception more than anything, but they have a defacto syntax used throughout the spec.
Analog Devices DSPs and Itanium are examples with the = token.
So there's a lot of times where I'm staring at x86 assembly and going "wait, which version is this? the one that does destination first or destination second?"
ITYM AT&T :). The idea is that the basic grammar is common across architectures to help compiler backend authors. The historical reason for the ordering is because that’s how it was on the PDP-11, the “mother” assembly. And all AT&T/GNU versions preserve this ordering regardless of the vendor format.
> The other is the syntax most commonly used in practice
It didn’t always used to be this way. In the dark ages before NASM, MASM was a top warez.
And depending on what you’re doing I don’t think Intel syntax is uncommon, gas will even accept it for the most part these days.
> "wait, which version is this? the one that does destination first or destination second?"
There must be some mnemonic to associate sigil vomit with destination last. Shitty sigils come out the ass?
True, I think. I mean, that certainly was the case for PDP-11 and VAX asm. 68000 too (pretty much a 32 bit, 16 register PDP-11). Whether that was the actual reason is more debatable.
> And all AT&T/GNU versions preserve this ordering regardless of the vendor format.
False. GNU `as` puts the destination register first for all of Arm32, Arm64, MIPS, PowerPC, RISC-V. Every RISC ISA, as far as I know. Except for store instructions, where the source register is first.
As far as the operand ordering with the x86, the thing is they did flip it from the Intel syntax and the beginnings of this port date back to Version 7 (1978), a few years before GNU. So what was the reason other than it looked like the incumbent/precedent PDP-11? I haven't heard it being much more than circumstance.
If you want equal signs, use C
> Assembly is meant to be 1:1 with machine code,
And that is nonsense as well. RISC-V is a perfect example as it has plenty of pseudo ops. (Or do you actually think that the literal machine code bytes of an add instruction are 0x41 0x44 0x44? - they’re not)
Equal signs or not, macro assemblers have plenty of ergonomic conveniences layered above machine code.
How does an equal sign break any concordance with assembly and the underlying machine code anyway?
Itanium fringe? You’re clueless and have no credibility.
We’re talking about an = instead of a , … you’re needlessly bringing up “crazy ASTs”, so much for being argumentative.
And defensive?
You seem mixed up, I’m not the one even advocating for the damn things. But your argument is ignorant and foolish.
> Assembly is meant to map directly to the way the machine code is written
This is just false.
https://github.com/netwide-assembler/nasm/blob/master/asm/as... Simple?
Very fringe. A huge market failure. Hardware discontinued. Support removed in LLVM (2.6), and the Linux kernel (6.7). Still seems to be hanging on in GCC, though it was initially going to be deprecated in GCC 10.
One of the very few ISAs I've never actually seen a real machine of, let alone used. And I've worked professionally on i960 (Stratus fault-tolerant computer), which not many people can say. Not to mention of course PA-RISC and Alpha and Pr1me and DG Nova/Eclipse (and an M88000 PC) as well as common-as-mud (and which I own examples of) SPARC and MIPS.
Absolutely correct that mega-millions or billions went into it, both hardware and software, and that it FUD'ed many other ISAs to premature extinction.
It's not that hard to get an ISA into gcc and Linux -- you just need shipping hardware (or a convincing story that there will be) and someone to do the work. For example Andes (Taiwan) and C-Sky (China) both got their proprietary ISAs (nds32, c-sky) into GCC and the Linux kernel. And both switched to RISC-V not long after :_)
Also assembly mnemonics aren't even 1:1 with instructions. Pseudoinstructions do pretty much anything, and even something like `add` can assemble to two different instructions depending on the arguments.
As for writing an assembler being "extremely easy"... yeah no. There's no formal grammar so you're going to be reverse engineering LLVM and GCC's hilariously messy assemblers. Or more realistically, guessing and building an enormous test suite. Not easy at all.
With a '=' all OP is asking for is the wee-est bit of grammar (no more than in some addressing modes in CISC assemblers) and to change the order of the registers, which isn't crazy since immediates are already split.
https://en.wikipedia.org/wiki/RISC-V#Design
PS- while the instruction formats look crazy in isolation, there is a nice symmetry between them and if you start thinking about instruction decoding in discrete logic, they are actually quite an elegant design.
PPS- there is also “High Level Assembly” https://en.m.wikipedia.org/wiki/High_Level_Assembly
It is an Assembly with equal signs.
> rd = add rs1, rs2
I dunno, how about rd = rs1 + rs2- signed add
- unsigned add
- add and carry
There are a few others in other ISA', then there are the operand sizes (byte, half-word, word, long word etc) and the «+» operator does not capture the operand size nor the specifics whereas
- rd = rs1 addu8 rs2
makes the intention clear: «add the lower 8 bits from rs2 to rs1, don't set the sign bit when overflown and store the product in rd».
Moreover, the «+» operation is commutative and the CPU instructions are not, e.g.
- rd = rs + #10
and
- rd = #10 + rs
mean two completely different things for the CPU and the latter does not even have an encoding for it. The assembly processor is not the right place to place the smarts in to figure out the programmer's intention, either, as it is a very straightforward 1:1 assembly syntax to the ISA encoding translator.
rd = rs1 addu8 rs2
use rd = rs1 +u8 rs2
etc. The + stands out, more clearly indicating addition. (to me anyway)As for commutativity of +, it's not necessarily true. It depends entirely on what underlying operation + denotes. It's perfectly reasonable to use it for string concatenation, and that clearly isn't commutative. But if it's not in the case of an ISA, that's fine, just have the assembler reject it.
rd = rs1 * rs2 + rs3
It's no different from parsing if/then/else. Provided one assignment maps to one opcode, it's unambiguous.Even C functions are pre-fix. [function_name arg1, arg2 ...] If schools were teaching pre-fix, we would be in better position to appreciate Intel syntax.
Higher maths uses pre-fix, for example: y=f(x) unfortenately, above line is not pure prefix... = y f(x) would be pure pre-fix.
x++
is a postfix kind-of function a ? b : c
is ternary infixEdit: arguably
/* comment! */
is a function that takes an unquoted string. It's a no-op function so the compiler removes it at compile time (giggle)AMD 29k (its descendants are still alive) has two further ADD operations:
– ADDU – IF unsigned overflow THEN trap (out of range), and
– ADDCS – IF signed overflow THEN trap (out of range).
The ADD instruction family in the HP PA-RISC 2.0 is, of course, one of the best ones out there:
ADD,cmplt,carry,cond r1,r2,t
Purpose: To do 64-bit integer addition and conditionally nullify the following instruction.General register r1 and general register r2 are added. If no trap occurs, the result is placed in general register 1. The variable «carry_borrows» in the operation section captures the 4-bit carries resulting from the add operation. The completer, «complt», specifies whether the carry/ borrow bits in the PSW (the processor status word) are updated and whether a trap is taken on signed overflow. The completer, «carry», specifies whether the addition is done with carry in.
So, under a certain set of conditions, a PA-RISC «add» operation, other than yielding an add product, can:
– Can result in a trap on a signed overflow.
– Can nullify the following instruction.
An instruction mnemonic would like this:
ADD,DC,TSV,C,<=,N r1, r2 – «add where r1 is less than or equal to negative of r2, and trap if specified conditions (DC,TSV) were met and nullify the following instruction»
HP PA-RISC 2.0 also has an «add and branch» instruction, naturally, embellished with branch conditions, «cond»: ADDB,cond,n r1, r2, target
If «n» flag is set, the «add and branch» will also nullify the following instruction, e.g. ADDB,*<=,N r1, r1, label_1
There are also an «add immediate left» instruction (a left shift and add a constant) and «halfword parallel add» (adds multiple halfwords in parallel with optional saturation).How does one encode all of that with a «+» operator?
What about adding two decimals? The decimals are not used in modern CPU architectures, but they used to be a commonplace and were encoded by completely separate instructions.
The bottom line is: the assembly language is a slightly more human friendly (e.g. «add r1, r2, r3») interface into the bit code (e.g. a fictional opcode of «0xf500010203» for «add r1, r2, r3») and, consequently, into the internal CPU machinery and its state, and is not a high programming language nor a testamenet to the laws of mathematics.
> What about adding two decimals?
rx = ry +bcd rz
I'm really thinking of simple, simple changes. As you point out some situations aren't appropriate for it, in other cases maybe it is. Or maybe I'm just plain wrong and it never is appropriate. But it's just a suggestion and worth considering, no?And this is the problem I have been trying to point out. «+» comes from math, and – in the world of math – there are no bytes, no half-words, no overflows, no carry overs, no branches and no traps, and the imaginary «register» size is infinite – an addition always succeeds however large is the number. There are no decimals, no floating point numbers, either – in math, there is no distinction. There is just a lone exception being the handling of the explicit infinity values (-∞ and +∞). «Add and branch» or «add and trap» simply do not exist in math – math is abstract, and computing is concrete.
That is not the case in computing. The semantics of «add» varies across different CPU architectures, as the assembly language exposes the internal machinery and the internal state of a given CPU which may or may not be appropriate for another CPU. For example, RISC-V does not support the integer overflow flag, and most other CPU's do. Assembly is a 1:1 representation of the binary code for a given CPU, and that is where it stops.
In fact, I do find the charm in your proposal being along the lines of «r3 = r1 +.u8 r2», «r3 = r1 +.c r2 or branch label1», «r3 = r1 +.c r2 or trap overflow» etc. It will not be assembly though, more of a meta-assembly, which is fine. The real trouble is that the grammar will be able to handle just one ISA and might quickly become complex and unwieldy if ported to another ISA that has a rich set of addition operations that do not fit in the narrow constraints of such a design.
I fully understand your criticism, entirely correct, that machine arithmetic does not behave like mathematical arithmetic. I diverged from you where you say that '+' is mathematical therefore has a predetermined meaning. It has a conventional mathematical interpretation, often related to number addition, but it is a convention only and can be bent as far as you like provided you're clear about it (and a bit of common sense too; defining '+' as a square root operation would be pretty bloody stupid). No symbol in mathematics has any intrinsic meaning, including '+'.
Thanks for interesting discussion!
Another alternative is to mark the output in the operand position. Say, `add rd!, rs1, rs2`. I relate this to x86 `LEA` which is effectively an arithmetic operation but encoded like a load operation, hence a weird syntax `lea rax, [rbx + 42]` even though no memory access happens. Maybe that might be useful in the other way around.
Indeed, a 3-operand ADD is one of the extensions planned for Intel APX. But unless you need the output flags, an ordinary LEA suffices for adding two registers and storing the result in a third.