I know that the Z80 and the 6502 are very different, but I was wondering if there are any languages on a higher level than assembly which can generate compact and efficient 8-bit machine code by design, and how this was achieved?
Well, a prime candidate would be Ada.
It was a specific design goal for Ada to produce good code for tiny and 'odd' microprocessors (*1). Two basic approaches enabled this:
- the language itself was as non-assuming as possible, while at the same time
- offering tools to specify certain workings as detailed as possible - where needed -
- separating this to a great degree from generic code.
The high abstraction separates it from 'lower' languages like C or FORTH which are both built around certain assumptions about how a processor works and what functions it offers. In fact, C and Forth are great examples of two major pitfalls:
- Expecting a certain low-level behaviour of a CPU and
- ignoring high-level functions offered by a CPU
C for example is built on pointers and the assumption that everything has an address and is a series of bytes which can be iterated over (and may be structured further, but that can be ignored at will). CPUs with multiple address spaces or object storage or different understanding of data handling will inherently end up with less than desirable code.
The /370 is a great example here. While (register-based) pointers are an essential feature, the memory pointed to is handled as a block (or structure) with sub-blocks (fields) that can be manipulated with single instructions, not loops (*2). C-code forcing iteration onto a /370 can easy degrade (local) performance by a factor of 100 and more (*3).
Forth on the other hand is at its core built around the idea of a stack (or multiple stacks) and the ability for threaded code. Effective (stack) pointer handling and fast (and simple) moves to and from the stack are essential for performance. Both issues that 8-bit CPUs aren't inherently good at. The 6502 may have 128 pointers, but handling them is ugly. Indirect jumps, such as those needed for threaded code, are non-existent. Thus, fast implementations rely on self-modifying code. Then again, it is only marginally better on an 8080/Z80 as they have only one memory pointer (HL) and a backup (DE) which in turn is needed almost all the time.
Like C, Forth ignores higher-level function offerings, or has a hard time using them. Unlike C, it's a bit more open to changes in low-level behaviour.
Both languages are maybe higher than assemblers can operate on a more abstract level - if used carefully - but are not inherently abstract. They assume certain workings. If these are not basic machine instructions, performance will suffer.
A 'real' high-level language should not make such assumptions. Here, Pascal is a better candidate, as it assumes next to nothing. As a result, there are compilers for either line, 6502 and 8080/Z80, producing quite good code. I guess Turbo-Pascal for CP/M doesn't need any further introduction. On the 6502 side (Apple, Atari, Commodore) Kyan Pascal was considered a great way to work in high-level languages (*4).
Which brings us back to the original question, how to achieve good code performance on a wide range of machines:
- Don't expose any low-level working to the programmer.
- Have the compiler cover it.
- Have the programmer define the intended result, not the way it is achieved.
Essentially the goals set for Ada :)
... on a higher level than assembly ...
Serious? That statement feels quite offending :)
Assembly can and often is already on a higher level than some other languages. Assembly is the essential prototype of an extensible language. Everything can be done and nothing is impossible.
*1 - Note the 'produce' clause, having the compiler run on such machines is a different story.
*2 - It's always helpful to keep in mind that the /370 may have spearheaded many modern concepts, but it was designed with a punch card in mind. A punch card is a record, maybe pointed to by a register, holding information (fields) at fixed offset with fixed length. The whole instruction set for character (byte) manipulation is built to fit. No need to loop over two fields to move, compare, translate, pack or even search within, the most basic instructions (MVC, CLC, TR, PACK, TRT) already take care to handle fields at once.
*3 - This was a huge problem when C first became requested by users and implemented. By now compilers have evolved, and more importantly, CPU designers have added quite some 'changes' to cover up for the inefficiency of C.
*4 - Its only fault was its late 'birth' - too late to make a major impact.