C without the integer promotion, for 8/16 bit MCUs - Page 1
Forum section: electronics / general

author: peter-h
author_rank: Super Contributor
timestamp: November 20, 2024, 02:21:31 pm
content: I recall from a project I worked on (hardware + low level asm, Z180) in the 1980s that this (IAR C - another guy was doing that) resulted in much worse code than one could do in hand crafted asm. Basically no C compiler would allow arithmetic in 8 bits. Has this changed / is there a way to do 8 bit maths on int8_t or uint8_t?ld a, whateverld b, whateveradd a, bversusld c, whateverld b, 0ld l, whateverld h, oadd hl, bcI may be missing something obvious.Obviously on arm32 the above doesn't apply anymore because int is 32 bits anyway and is done in 1 clock, so asm helps only in very special cases, or where you don't want the compiler to optimise the code.Edit: the above uses "int" as 16-bit, so everything is done with BC DE or HL (or IX IY if being more clever). This was typical of all Z80/180 C compilers I saw e.g. Hitech, IAR.

author: ejeffrey
author_rank: Super Contributor
timestamp: November 20, 2024, 03:55:20 pm
content: C isn't really ideal for 8 bit processors. It originated on 16-bitters and 8 bit CPUs often have other quirks like limited stacks and restricted register use that make building a good compiler difficult. There are still C compilers for platforms like PIC but they are not generally very efficient.That said, while C has integer promotion rules that promote all expressions to at least int, there is nothing keeping a compiler from using shorter instructions when possible. Adding two 8 bit numbers into an 8 bit result can perfectly be done using 8 bit instructions. If your compiler doesn't, that just means the developers didn't find this worth while.If you have compound expressions the compiler won't usually be able to calculate bounds on the intermediate values, and this may force it to use larger instructions. That may not matter too much if the intermediates all stay in registers, but you can solve this by only using single binary expressions and assigning the results to an explicitly sized temporary variable. That will allow a smart compiler to use the narrow arithmetic instructions.

author: Nominal Animal
author_rank: Super Contributor
timestamp: November 20, 2024, 04:24:21 pm
content: You can use casts like((type)(expression))in compound expressions to limit the precision and range. With GCC, if you enable optimizations (-O1, -O2, -Os, -Og, etc.) for example for the AVR architecture, 8-bit arithmetic is used throughout; seehere for an example(godbolt.org).It is a pity C isn't defined in terms of explicit-sized binary types (intN_tanduintN_t), with type promotion rules defined by the target architecture. The current C standard requires that theinttype can represent all integers between -32767 and +32767, inclusive, which makes 8-bit architecture support very difficult. For example, if you look at the assembly above, the function call ABI used places the three parameters in even registersr24,r22,r20instead of using consecutive ones, because of this "at least 16 bits" requirement being reflected in the AVR function call ABI.Indeed, I personally could do without type promotion in C. It would complicatestdargprocessing somewhat, and break a crapton of existing C code, so it would essentially be a new programming language. And at that point, one could easily add the crucial bits missing from C as discussed many times here and elsewhere.For some reason, I really like ATmega32U4 and AT90USB1286 AVR MCUs with native USB 2.0 (low/full speed only, max. 12 Mbit/s). When I write code for these, I accept the ABI inefficiencies, and use proper casts extensively (testing crucial expressions at godbolt.org for GCC 5.4.0 and later, or SDCC 4.0 if the target is supported). It isn't perfect, but nothing is; this suffices for me.

author: Dave92F1
author_rank: Contributor
timestamp: November 20, 2024, 06:52:20 pm
content: FWIW, I think Microchip's C for the PIC18 (an 8 bitter) has custom stuff that lets you do 8 bit math.

author: Nominal Animal
author_rank: Super Contributor
timestamp: November 20, 2024, 08:10:46 pm
content: Again, 8-bit math is not a problem in C: just cast the intermediate results touint8_torint8_t. The casts limit the range (and precision) to that of the specified type, and allows the optimizer to eliminate any extra operations.For example,c+= (uint8_t)(a*b);on AVR compiles to three instructions: onemul, oneadd, and clearingr1(asr1:r0is used for the multiplication result, andr1is defined to be zero in AVR ABI). On Z80, SDCC compiles to a loop with 8-bit additions.I've verified that GCC and SDCC Do The Right Thing, when any optimizations are enabled.

author: SiliconWizard
author_rank: Super Contributor
timestamp: November 20, 2024, 08:44:45 pm
content: Yes, just explicitely cast.The "integer" situation in C is pretty bad IMO, but that's a matter of perspective. Many other languages have chosen to have some "abstract" integer with no explicit width. If you add to that the fact it's not strongly-typed, you inevitably end up with those integer promotion rules, one way or another.Using "abstract" integers are rarely a good idea for low-level code, but it can make higher-level code more "portable". Or at least... make the limitations of the integer type evolve with the target. This can also have some benefits.IMO the less risky approach in terms of arithmetics is making everything explicit, and thus strongly-typed languages. Which C isn't. So yes, in C you need to cast an awful lot of times to get to the same result reliably.But I somehow wish we would all use Ada instead.

author: golden_labels
author_rank: Super Contributor
timestamp: November 21, 2024, 04:58:57 am
content: Integer promotions determine the meaning of the calculations, not how they’re implemented in machine code. Keep in mind C is an extremely abstract language, So don’t assume an integer promotion makes the implementation switch to wider and more expensive operations. As long as semantics are unaffected by using narrower operations, any sane modern C compiler is going to use them.(1)As people mentioned above, coercing the results to 8 bits makes the compiler aware all the upper bits are irrelevant.(2)But this doesn’t mean you need to do(unsigned char)casts each operation either. Compilers go great lengths to write even complex and long, multi-statement code as something simple and performant. You’d be surprised how far they can go and how complicated patterns they manage to “understand”. So compile and check the actual output first. Only if it’s not good enough, worry about such details.The response would be incomplete without mentioning there are limits to what a compiler can do. Compilers must weight compilation times against benefits. Going deep in call trees becomes exponentially less productive. Compilers must also weight costs and gains of a particular solution: there is no simple “always better” option. AFAIK no currently available compiler does very deep analysis and most are not fond of excessive code duplication. So there are some common cases, where wider operations are often used despite the compiler is aware they’re wasteful. Function calls are an example.____(1)If yours doesn’t, consider this to be the problem. It will be lacking in other situations too. Of course I’m aware one works with what one has at their disposition. Sometimes compiler-specific tricks need to be employed. But this will fall into… well, compiler-specific, not general C.(2)Assuming they really are. Given complex semantics in C it’s not hard to fall into a trap.

author: brucehoult
author_rank: Super Contributor
timestamp: November 21, 2024, 05:37:15 am
content: Something gcc and llvm can be depended on to do well is "needed bits" analysis. If certain bits of a value van't affect the final result then they don't load them from memory, or don't calculate them, or whatever. And they do that right down an expressionSimple example, using AVR:Code:uint8_t foo(uint32_t *p) {return *p>>24 ^ *p>>8;}givesCode:foo:movw r30,r24ldd r24,Z+1ldd r25,Z+3eor r24,r25retFor those who don't know AVR, the firstmovwis because only several register pairs can be used as pointers andZ(r30-r31) is one of them. Register pairr24-r25is both the function argument and the result. Note that it loads only the bytes from*pthat are needed, and that it doesn't care that the upper halfr25of the result has a junk value left in it.

author: Siwastaja
author_rank: Super Contributor
timestamp: November 21, 2024, 08:30:12 am
content: So TLDR, only worry about functional correctness of expressions and concentrate your care against C pitfalls there. If it is functionally correct, any usable compiler will produce optimal machine code, too.If higher bits affect the visible result (of the program, not just single piece of expression!), then compiler generates the instructions, but you should not worry about the instructions, you should be worrying about the mismatch of your expected functionality vs. realized functionality, i.e. different results.You should be probably constraining values e.g. by if statements (input validation), or if modulo-2^nwrap around is desirable, by casting into uintn_t.

author: peter-h
author_rank: Super Contributor
timestamp: November 21, 2024, 09:03:41 am
content: Sounds like Z80 compilers are much better today than 30+ years ago.But I wonder if that's really true. What is the incentive for compiler devs to put in the time? Formodern8/16 bit CPUs, yes. But for Z80, Z180, H8/300? The IAR C code was 10x slower than even casually written asm.Of course there is the argument that some 99% of a program is not perf critical so this doesn't matter...

author: brucehoult
author_rank: Super Contributor
timestamp: November 21, 2024, 09:41:49 am
content: Quote from: peter-h on November 21, 2024, 09:03:41 amz80 and 6502 and other 8 bit CPUs of that era were extremely unfriendly to implement C or Pascal semantics on. The only one passably decent was 6809.But still 10x is awful, if it was actually generating native code. Especially because that probably means it was 10 times bigger as well. I don't recall Turbo Pascal 1.0 -- now 41 years ago -- being *that* bad. And it was only $50, a miracle at the time.With that kind of speed factor to play with you should simply implement a much nicer virtual machine, with much more compact code: you can do very compact bytecode with around 10:1 slow down (at least for manipulating 16 or 32 bit values), or make everything simply sequence of JSRs for three times bigger code but still half the size of native code and only a 2:1 slow down, or make each "instruction" be the 2-byte address of the virtual machine instruction, with a simple loop to load and call them, with both size and speed midway between the other two options.

author: SiliconWizard
author_rank: Super Contributor
timestamp: November 21, 2024, 10:46:14 pm
content: SDCC is still actively maintained, and while the 8051 and Z80 were not that great for C, it works rather well in spite of this.I have used it in commercial projects with zero issue.The use case was the Cypress FX1 & FX2, which are 8051-based. There are still some even recent chips that are 8051-based (such as some chinese MCUs). Yes, with some (very few actually) timing-critical portions in assembly, but most in C. (And that was fortunate - I wouldn't have enjoyed implementing the USB part in assembly.)The alternative was Keil, but it was rather expensive and SDCC was doing just as well, at least in my use case.For the Z80, there must not be a big market anymore, although there are still calculators (TI I think?) which use eZ80 chips? They need compilers too. Granted, in the old days, firmware for calculators was all written in assembly, but these days, not so much. Developing a whole TI84 in pure assembly would probably not be very practical.

author: peter-h
author_rank: Super Contributor
timestamp: November 22, 2024, 03:23:36 pm
content: QuoteTo be precise, the 10x comparison was IAR C (using "int" which is 16 bit) against asm (and performing an algorithm in 8 bit signed numbers). Then 10x is fairly easy to achieve. Especially if you engage the brain a bit.I also had some very good code, developed by much smarter people than myself, for e.g. 8x8=16 multiply, 16/8 divide, and then some really great code for 32x32 multiply and 32/32 divide which used the alternate register set, but that (along with a huge raft of other stuff) is off topic for integer promotion.You might say: How much maths can really be done in 8 bits? Well, it depends. If you know exactly the range of values and know how to guard against over/underflow, then it's OK. But you must understand the application, especially with integer maths. One can lose precision with int16, or with int32...

author: Nominal Animal
author_rank: Super Contributor
timestamp: November 22, 2024, 03:41:48 pm
content: If you write C that runs on wildly different architectures, then usinguint_fastN_tandint_fastN_ttypes for local variables and function parameters is often very effective. One must realize that the corresponding types areat leastNbits, but may be larger; so for some expressions or sub-expressions you'll likely still want to limit the range and precision to a specific width via casting touintN_t/intN_t.

author: brucehoult
author_rank: Super Contributor
timestamp: November 23, 2024, 01:22:33 am
content: Quote from: Nominal Animal on November 22, 2024, 03:41:48 pmIf you want specific widths, then replacingexpwith(exp & 0xFF)or(exp & 0xFFFF)is the way to both ensure you get the byte/half instructions on the machines that have them, and get correct results on the machines that have only 32/64 bit arithmetic.It's the same for, for example, the varying semantics of shift by greater than the register size. Write what you actually want i.e.(s > 31 ? 0 : (v<<s))or(v << (s & 31))and you'll get just the native instruction when it does what you want, and the correct result when it doesn't.Or at least you should get just the native instruction, when possible. Weirdly ARM gcc and clang don't optimise the first idiom when s is a variable, but do when s is a constant.The RISC-V compiler correctly generates just the native instruction for the 2nd idiom, even when s is a variable.https://godbolt.org/z/saG7s8T43

author: spth
author_rank: Regular Contributor
timestamp: March 16, 2025, 05:01:29 pm
content: Quote from: Nominal Animal on November 20, 2024, 04:24:21 pmC has explicitly-sized integer types that do not get promoted: ISO C23 _BitInt(N) and unsigned _BitInt(N) is well-supported by current SDCC, GCC and clang.

author: SiliconWizard
author_rank: Super Contributor
timestamp: March 16, 2025, 07:03:04 pm
content: Quote from: spth on March 16, 2025, 05:01:29 pmThat's one significant addition in C23.One can use any 'N' from 1 to BITINT_MAXWIDTH (which is implementation-defined).Note that while the integer promotion rule doesn't apply with these, it has its own promotion rule: in arithmetic and logic expressions mixing _BitInt variables of different sizes, the smaller sizes get promoted to the largest size in the expression. That's something that makes sense and is much closer to what developers (especially low-level ones) expect.Second note is that it does *not* do any kind of packing in terms of storage. If you define an "unsigned _BitInt(24)", for instance, operations on it will be on 24 bits only, but it will be stored in the smallest "native" width supported, that is usually 32-bit, except possibly on odd architectures that support 24-bit natively. So, the benefit is that it spares you from having to deal with integer promotion and from having to explicitely use masks, but it doesn't pack anything.Third note is that GCC (as of 14.2) still doesn't support _BitInt for the ARM 32-bit targets, neither for the RISC-V targets (both 32 & 64-bit), but it does for x86_64 and ARM 64-bit.Also note that for as of GCC 14.2, you'll have to pass the "-std=c23" option to the compiler, as C23 is partially supported in it, but it's not default. I think it will be default in GCC 15.So, on MCUs, it'll be a little while before you can use this. Should be in GCC 15, but I don't know when it's going to be released.

author: Nominal Animal
author_rank: Super Contributor
timestamp: March 17, 2025, 03:12:23 am
content: Yup, one morefor C23. I stayed with C99 for the longest time due to unhappiness with C11 and C17, but I think C is back on the right standards track now. Lots to improve, of course, but steps in the right direction for sure.(I still think we could replace pointers with arrays and get compile-time memory safety that way, though; but that will never be included in standard C.)

author: SiliconWizard
author_rank: Super Contributor
timestamp: March 17, 2025, 01:07:39 pm
content: From C11 I mostly use _Generic, occasionally though. What was there in C11 to dislike in particular?I wrote a small example for _BitInt so people not knowing it yet can have a look and toy with it:https://godbolt.org/z/4fs59xMdx