C to Z80 (version 0.1 beta)

Chris Williams <abbrev@gmail.com>

Contents

Introduction

In this text, I will explain how statements in a high-level language like C translate to the Z80 assembly language, which is used in the TI-8x calculators. I will cover the topics of global and local variables, function arguments, function return values, expressions, pointers, and flow structures (if/else, for, do/while, and while constructs). I will also discuss some optimization tips and techniques where applicable.

I include snippets of both C and the translated Z80 assembly to illustrate how to translate between the two languages. This text is not for beginners: having some knowledge and experience with C will be very helpful, but you might be able to get by if you know another high-level language like BASIC or Pascal. Also, you should also be familiar with the Z80 assembly language well enough to understand what each statement does.

For the most part, this text is sequential, as concepts build upon earlier and simpler concepts. Of course, you can skip over anything you already know and read back only when it is referenced.

As this is currently just a draft, please look over it and send any feedback to me on how it can be improved. I fully expect some errors in this text, so please point them out to me.

Global Variables

Global variables are used to store data which is global. By that I mean the data is accessed by many functions in a program, and it would be inconvenient or difficult to pass it as an argument to each function.

Among variables, accessing global variables in Z80 assembly probably is by far the simplest and most straight-forward. Assume the variable mychar is a global variable, defined with "char mychar;" outside any function definitions. The C statement

	mychar = 'A';
translates to Z80 assembly as
	ld	a,'A'
	ld	(mychar),a	; put 'A' in mychar

Alternatively, if the address of mychar is already loaded in the HL register (such as from a pointer, covered later), the assembly code would instead look like this:

	; HL contains the address of mychar
	ld	(hl),'A'	; put 'A' in mychar

Of course, the value to store in mychar could be in any other register already, so this code shows how it can be used:

	; HL contains the address of mychar
	; B contains the value to store in mychar
	ld	(hl),b

This can be done for any of the registers A, B, C, D, E, H, or L, though H and L likely will not contain any useful value if they're used for the effective address.

Now consider a global integer variable, that is type int in C. Let's call this integer myint (how creative!). It would be defined in C by "int myint;" outside all function definitions. The C statement

	myint = 42;
then translates to assembly as
	ld	hl,42
	ld	(myint),hl	; put 42 in myint

Of course, if the address of myint is loaded already in the HL register, it becomes

	ld	(hl),42%256	; low byte of 42
	inc	hl
	ld	(hl),42/256	; high byte of 42
and then HL contains the address of myint plus one after this executes. You could put a "dec hl" at the end if you still need to have the address of myint in HL.

The code above shows one important thing to remember: the Z80 stores 16-bit values in memory in "little-endian" byte order. That is, the least-significant byte is stored first at the lower address, and the most-significant byte is stored last at the higher address. "Big-endian" means the opposite, as one might expect. The M68000, which is in the TI-9x graphing calculators, stores multi-byte data in big-endian byte order, for example. This text is not about the 68k assembly language, so that's all I'll say about it for now.

Let's look at the final case, when HL has the address of myint and DE has the value. It actually is similar to the last case above. Here's how it's done in assembly:

	ld	(hl),e		; low byte of DE
	inc	hl
	ld	(hl),d		; high byte of DE

All of this should be familiar to you already, but if it isn't, you ought to read other texts before reading this.

Local Variables

Local variables are variables which are local to a function, or more generally, to a "stack frame". A stack frame is defined by both functions and by statement blocks, which in C is any statement(s) surrounded by braces. Usually local variables are stored either in the stack frame or in registers. On processors like the Z80 which don't have many general-purpose registers, local variables are most often restricted to the stack.

To create a local variable on the stack, space must first be allocated for it. On the Z80, there are a few ways to do this. Take the following C code:

foo()
{
	int bar, baz;
	/* do stuff... */
}

The function foo has two local variables named bar and baz. This code might translate to

foo:
	push	bc	; make space for bar
	push	bc	; make space for baz
	; do stuff...
	pop	bc	; destroy baz (free the space)
	pop	bc	; destroy bar
	ret

However, this is not the only way to reserve space for local variables. The Z80 has instructions to increment and decrement the stack pointer as well as to put the value in HL into the stack pointer. The increment and decrement instructions are good only for allocating and freeing space for byte-sized data, as seen here:

	dec	sp	; allocate one byte for data
	; do stuff...
	inc	sp	; free the space

That is equivalent to this C code:

{
	char c;
	/* do stuff... */
}

When the space needed by local variables exceeds eight bytes (FIXME: is it really 8 bytes?), loading directly into the stack pointer takes fewer bytes of code than using push. For example, a local array variable may take up many bytes. In C, we may be given

{
	int myarray[50];
	int i;
	/* whatever... */
}
and we would write that as
	push	ix
	ld	ix,0
	add	ix,sp		; put the stack pointer in IX
	
	ld	hl,-102		; 102 == sizeof(myarrary) + sizeof(i)
	add	hl,sp		; "subtract" from the stack pointer
	ld	sp,hl		; reserve space for myarray and i
	; whatever...
	
	ld	sp,ix 		; restore the stack pointer
	pop	ix

The value -102 is computed from the sizes of the variables: myarray is an array of 50 integers and is 100 bytes, and i is 2 bytes (an int is 2 bytes). Notice I used the IX register to save the stack pointer. I will show you why in much more detail soon.

All of these examples so far show two important things to keep in mind: 1) whatever you allocate on the stack you must also free, and 2) the stack builds downward in memory; push decrements the stack pointer by 2 then stores the value, and pop gets the value then increments the stack pointer by 2.

Accessing Local Variables

We now have the task of allocating space for local variables out of the way. Now we need a way to get the contents of and store values to them. This is trickier than accessing global variables. For one thing, you must know exactly where the variable is stored. As we don't have any label for the variables, we need another way to obtain their addresses.

I used the IX register above to save the stack pointer. This was not only to restore the stack pointer; I use IX as the "frame pointer", because it points to the top of the stack frame. The top of the stack frame holds the old value of IX, or the address of (a pointer to) the previous frame pointer; that is, the top of the previous stack frame. As the code above (the last snippet in the last sub-section) stands, IX points at the top of the stack frame.

The frame pointer IX can be used to access local variables. To access i, for example, you must subtract 102 from IX to get the address of i. Since IX is an index register, this can be done quite easily. For example, the C code (located at the 'whatever...' comment)

	myint = i;
would translate to
	ld	l,(ix-102)	; low byte of i
	ld	h,(ix-101)	; high byte of i
	ld	(myint),hl
in assembly. The value 102 is the offset from the top of the stack frame of the variable i. This is because we have 100 bytes above it for myarray, and i takes 2 bytes more. What is stored at IX + 0? As stated earlier, that's where we saved the old value of IX. It is the top of our stack frame and contains the address of the top of the previous stack frame. We saved it there so we can restore it later.

Take a look at this illustration of a stack frame (each row is two bytes):

   : ...               :
   +---------+---------+
   | old value of IX   | IX+0
   +---------+---------+
   | myarray[49]       | IX-2
   +---------+---------+
   | myarray[48]       | IX-4
   +---------+---------+
   / ...               / ...
   +---------+---------+
   | myarray[0]        | IX-100
   +---------+---------+
SP | i                 | IX-102
   +---------+---------+

Using IX comes with a price, though: instructions which refer to IX in any way are both longer and slower than instructions which refer to HL. On the other hand, using HL in place of IX would typically require more instructions, and HL wouldn't be available as it is in the code above. There are some situations where using IX is smaller and faster than using HL, and there are other situations where using HL is more preferable. Which one you use depends on your experience and ability to decide which is the best to use in any given situation.

Also, please note that the offset of IX is limited to the range [-128,127], which means that local variables are restricted to 126 bytes or 63 integers (unless you shift the base address of IX, which is just beyond the scope of this text). For the rest of this text, though, I'll assume we use the IX register for accessing variables in a stack frame.

Functions

Function Arguments

Believe it or not, function arguments are very similar to local variables. Yes, they really are very similar. In fact, they even have function-level scope in C. They therefore can be accessed in the same way as local variables too. Take this C code:

int cheese(int x, int y)
{
	/* useless comment */
}

The function cheese takes two arguments, x and y. Both arguments are int variables. Translating it to assembly yields

cheese:
	push	ix		; save frame pointer
	ld	ix,0
	add	ix,sp		; IX = top of stack frame
	; useless comment
	ld	sp,ix
	pop	ix		; restore frame pointer
	ret

Actually, most of that really is useless. All this function needs is a single ret instruction, as that's all it really does. It's not useless when we need to access the function arguments, though.

To access the argument x, such as to store it in myint as in this code (where the 'useless comment' is)

	myint = x;
we would translate it to this:
	ld	l,(ix+4)	; low byte of x
	ld	h,(ix+5)	; high byte of x
	ld	(myint),hl	; store it in myint

As you can hopefully see, x is stored at the address IX + 4. The y argument is stored at IX + 6. This is almost identical to the example in the "Accessing Local Variables" sub-section; the only difference is the offsets to IX.

Remember a little earlier I said the index of IX is limited to the range [-128,127]? This limits function arguments to 124 bytes or 62 integers (this almost never is a problem :-). What is stored at IX + 2? IX+2 points to the return address of the function cheese. It is stored there by the processor automatically when we called a function. We need to save that so we can return to the function that called us.

Look at this pretty diagram of a stack frame:

   : ...               :
   +---------+---------+
   | y (arg 2)         | IX+6
   +---------+---------+
   | x (arg 1)         | IX+4
   +---------+---------+
   | return address    | IX+2
   +---------+---------+
SP | old value of IX   | IX+0
   +---------+---------+

That is what the stack looks like immediately after we push IX onto it. Let's add some local variables to the function cheese:

int cheese(int x, int y)
{
	int foo, blah;
	/* useless comment */
}

Then we'll translate that to assembly:

cheese:
	push	ix
	ld	ix,0
	add	ix,sp
	push	bc		; space for foo
	push	bc		; space for blah
	; useless comment
	ld	sp,ix
	pop	ix
	ret

Finally, here's the stack frame for this function:

   : ...               :
   +---------+---------+
   | y (arg 2)         | IX+6
   +---------+---------+
   | x (arg 1)         | IX+4
   +---------+---------+
   | return address    | IX+2
   +---------+---------+
   | old value of IX   | IX+0
   +---------+---------+
   | foo               | IX-2
   +---------+---------+
SP | blah              | IX-4
   +---------+---------+

It's simple to access any of the function arguments or local variables just by referencing IX plus an offset.

Calling Functions

How did the arguments get on the stack in the first place? Simple: the caller (the function that called cheese) pushed them on the stack in reverse order before calling cheese. This can be demonstrated with more code snippets.

We'll call cheese with the arguments 1 and 2. In C that looks like this:

	cheese(1, 2);

Doing that in Z80 assembly:

	ld	hl,2		; second argument
	push	hl
	ld	hl,1		; first arg
	push	hl
	call	cheese
	pop	bc		; discard 1
	pop	bc		; discard 2

This also shows that the caller must clean up the stack after itself. Technically, it could clean up the stack after several consecutive function calls, but for this text we assume it cleans up after every function.

Function Return Values

To be most useful, a function usually returns a value, called simply its "return value". This possibly is the simplest topic to cover. To return a value to its caller, a function needs only to load the return value in the primary register; that is, HL. Therefore, for the function zero to return 0, as in the following:

int zero()
{
	return 0;
}
it just needs to load 0 into HL, as shown in assembly here:
zero:
	ld	hl,0	; *** return value ***
	ret

Not too difficult, eh? The only difficult part, if there is one, is to ensure that HL contains the return value when the function returns. If that's not a problem, then it is very easy to return a value from a function.

Note: in the above assembly code, I omitted the frame pointer, because it would only add bloat in this example. In fact, this can be done by many C compilers with a flag such as '-fomit-frame-pointer' for the GNU Compiler Collection (gcc). If I had included the frame pointer it would look like this:

zero:
	push	ix	; save previous frame pointer
	ld	ix,0
	add	ix,sp	; set this frame's pointer
	
	ld	hl,0	; *** return value ***
	
	ld	sp,ix	; restore stack pointer
	pop	ix	; restore previous frame pointer
	ret

Notice the extra instructions which do effectively nothing. If a function can do its job without using a frame pointer, then leave it out. It's not necessary.

Expressions

What is an expression? In the most general sense, it is anything that results in a value. Is that vague enough for you? An expression can be anything from a single variable to a long and complicated math computation. Take, for example, the following C code:

	myint = 42 * myint + 1;

This expression (all C expressions, in fact) is in what's known as "algebraic notation" or "infix notation", because the operators (* and +) are between the operands (42, myint, and 1). This is the normal way we humans write math expressions, and it's easy for us to evaluate it. Computers, on the other hand, need a simpler way to evaluate expressions. They generally use what is known as "postfix notation", or more commonly "Reverse Polish Notation" or simply "RPN". Read about RPN elsewhere if you're unfamiliar with it. (FIXME: should I explain RPN here?)

In RPN the above expression would be written as

	42 myint * 1 + &myint ->
where &myint is the address of myint and -> means store the value (put the value at the address of myint). If we use the HL register as the top of our expression stack and DE as the second to top (and using the hardware stack for additional space), this can be translated to assembly language relatively easily, thus:
	ld	de,42
	ld	hl,(myint)
	call	_mul		; 42 myint *
	push	hl
	ld	hl,1
	pop	de
	add	hl,de		; 1 +
	push	hl
	ld	hl,myint	; &myint
	pop	de
	call	_pint		; -> (store HL in (DE))

Of course, we can optimize this like so:

	ld	de,42
	ld	hl,(myint)
	call	_mul		; 42 myint *
	inc	hl		; 1 +
	ld	(myint),hl	; &myint ->
because the Z80 allows us to increment HL by 1 directly and to store the value in HL to myint directly as shown here. Usually an RPN expression can be "fudged" a little, as the "1 +" and "&myint ->" were, to make it smaller and faster in assembly. It does not always need to be translated literally. In fact, since we know the value 42 is constant and can fit in one byte, we could optimize this code even further:
	ld	a,42
	ld	hl,(myint)
	call	mulhlbya	; 42 myint *
	inc	hl		; 1 +
	ld	(myint),hl	; &myint ->

This assumes we have a function named mulhlbya which multiplies registers A and HL and returns the value in HL. Going even further, we could expand the multiplication operation in-line to get faster but somewhat larger code:

	ld	hl,(myint)
	ld	d,h
	ld	e,l
	add	hl,hl		; myint * 2
	add	hl,hl		; myint * 4
	add	hl,de		; myint * 5
	add	hl,hl		; myint * 10
	add	hl,hl		; myint * 20
	add	hl,de		; myint * 21
	add	hl,hl		; myint * 42
	inc	hl		; myint * 42 + 1
	ld	(myint),hl	; &myint ->

How does that work? It just expands the shift-and-add multiplication algorithm for the constant value 42. This can be done for any value in place of 42.

Essentially, if you can write an expression in RPN, it's fairly simple and straight-forward to translate it to assembly.

Before you get carried away on micro-optimizing as shown here, remember the saying: "Premature optimization is the root of all evil." Basically that means to save all of your optimizing efforts until the design of your program is very solid. If you ever needed to change or remove something which had been prematurely optimized, you will have wasted your efforts spent on optimizing and possibly made the code more difficult to maintain as well. Once you have a working design, focus on optimizing globally first (e.g., find better algorithms), then work down to more local optimizations last (e.g., which registers should hold what). Learn from my experiences: I've worked hard on optimizing a little section of code, then later I didn't need much of that code when I optimized globally. DON'T LET THIS HAPPEN TO YOU! :)

Also, don't reinvent the wheel. If someone has written a routine that does what you need to do, don't write your own version of it unless you REALLY know what you're doing (or if you're not permitted to use the routine for some reason). Most likely the routine was written by someone who knew what she (or he) was doing. If you can improve it, go ahead, but don't write your own from scratch just for the sake of writing your own.

Pointers

In C, a pointer is nothing more than an address to another data object. The same is true in assembly. To access the object pointed to by a pointer, we do what is called "dereferencing". Dereferencing, in its simplest forms, is just following an address to find the object, which may be an integer, a character, a data structure, or even another address. Take a look at this C code:

int *myptr = &myint;

	/* in some function: */
	cheese(*myptr, 5);

Here we store the address of myint in myptr, which is a pointer to type int. Then we call the function cheese with the arguments *myptr (we dereference myptr) and 5. This translates to assembly as

	ld	bc,5
	push	bc		; second argument
	ld	hl,(myptr)
	call	LD_HL_MHL	; *myptr
	push	hl		; first argument
	call	cheese		; cheese(*myptr, 5)
	pop	bc
	pop	bc		; clean up the stack
;...
myptr:	.dw myint		; int *myptr = &myint

"What's this LD_HL_MHL thing?", you might ask. It's a short routine to dereference the address in HL and put the result in HL. The "MHL" in it refers to "iMmediate" (FIXME: is this correct?), because it loads the immediate value in HL into HL. Here's all it contains:

LD_HL_MHL:
	ld	a,(hl)
	inc	hl
	ld	h,(hl)
	ld	l,a
	ret

And of course, this function can easily be made in-line for speed (at the price of size):

	ld	bc,5
	push	bc		; second argument
	ld	hl,(myptr)
	ld	a,(hl)
	inc	hl
	ld	h,(hl)
	ld	l,a		; dereference myptr
	push	hl		; first argument
	call	cheese
	pop	bc
	pop	bc

Actually, this shows us how we can optimize this code. For one thing, we're not limited to pushing only HL; we can push BC or DE as well. We can change the in-line dereferencing code to use DE:

	ld	bc,5
	push	bc
	ld	hl,(myptr)
	ld	e,(hl)
	inc	hl
	ld	d,(hl)
	push	de
	call	cheese
	pop	bc
	pop	bc

There! We just saved a byte from the previous code. It's now the same size as the code which uses "call LD_HL_MHL", but it's faster, as we don't make an extra call.

Let's look at another task: calling a function through a function pointer. This can be done with the following C code: int (*funcptr)(int, int) = &cheese; /* the '&' is optional in C */

	/* in a function */
	funcptr(1, 2);

But how is it done in assembly? It's easy, at least for me (I should shut up, right? :). Let's think for a while how it would be done. We have the address of the function we are calling (funcptr), and we (should) know that the call instruction pushes on the stack the address of the following instruction before jumping to the function. If you came up with something like this:

	; the function's address is in HL
	call	JP_MHL
	; ...

JP_MHL:	jp	(hl)
then you'd be correct! Don't worry if you did it completely differently; I know of a couple other ways to call a function whose address is in HL. Another way to do it is
	ld	bc,$+5	;$  return address is after the jp (hl)
	push	bc	;$+3
	jp	(hl)	;$+4
			;$+5

I actually used this method in my multi-threading system for the TI-86. I thought it was the smallest and fastest method, but I was wrong. Here's yet another, less obvious, way:

	ld	bc,.retaddr
	push	bc
	push	hl
	ret
.retaddr

I don't recommend this or the previous method, though, because they're unnecessarily large and slow. For the rest of this section I'll stick with the first method, as it is the smallest and fastest of the three I gave here. To finish with the C code given above, here's the complete translation to assembly:

	ld	hl,2
	push	hl		; second arg
	ld	hl,1
	push	hl		; first arg
	ld	hl,(funcptr)
	call	JP_MHL		; funcptr(1, 2)
	pop	bc
	pop	bc		; clean up the stack

Control Structures

The basic program control structures, if/else, do/while, while, and for, all affect the flow of a program based on conditions. A condition is nothing more than an expression, but its value is used to determine which path to take.

Note: These sub-sections are mostly written but incomplete. Would you like to finish them? :-D

If/Else

The if/else structure is the simplest control structure, so we'll start here first. Conceptually, an "if" statement in pseudo-code looks like this:

	if <condition> is false, goto endif
		<statement>
	endif

Similarly, an "if/else" statement looks like this:

	if <condition> is false, goto else
		<statement>
		goto endif
	else
		<statement>
	endif

Notice I didn't cover the "if/else if" or "if/else if/else" statements? The reason is simple: "if" statements can be nested, so the "else if" or "else if/else" is simply part of the "else" statement. Now let's look at an example:

	if (myint > 2)
		cheese(1, 2);
	else
		foo();

This would look like this in assembly:

	ld	de,(myint)
	ld	hl,2
	call	_gt		; greater than 2?
	jp	c,.else1	; FIXME: is this condition correct?
	ld	hl,2
	push	hl
	ld	hl,1
	push	hl
	call	cheese		; cheese(1, 2)
	pop	bc
	pop	bc
	jp	.endif1
.else1
	call	foo		; foo()
.endif1

FIXME: finish this sub-section

Do/While

A do/while statement is just about as simple as an if statement. A C statement like

	do
		foo();
	while (myint < 42);
would translate to assembly as
.stmt1
	call	foo		; foo()
	ld	de,(myint)	; while (...)
	ld	hl,42
	call	_lt		; less than 42?
	jp	c,.stmt1	; FIXME: is this condition correct?

FIXME: finish this

While

A while statement is very similar to a do/while statement. In fact it translates almost identically as a do/while statement, with only one minor difference. As you should know, a 'while' statement evaluates the condition before the executing the statement, instead of after the statement like the 'do/while' statement does. Therefore, we need to jump over the statement to the condition, and the C code

	while (myint < 42)
		foo();
translates like this:
	jp	.cond1
.stmt1
	call	foo		; foo()
.cond1
	ld	de,(myint)	; while (...)
	ld	hl,42
	call	_lt		; less than 42?
	jp	c,.stmt1	; FIXME: is this condition correct?

Notice that the only difference between this and the example given for the 'do/while' is the first jp (jump) instruction and of course the label. That's because we need to do the condition first and execute the statement only if the condition is true.

FIXME: finish this

For

A for statement is the most involved control structure. Conceptually a statement like

	for (myint = 0; myint < 42; ++myint)
		cheese(3, 4);
is equivalent to
	myint = 0;
	while (myint < 42) {
		cheese(3, 4);
		
		++myint;
	}

So the most obvious approach to translating this to assembly would be correct:

	ld	hl,0
	ld	(myint),0	; myint = 0
	jp	.cond1
.stmt1
	ld	hl,4
	push	hl
	ld	hl,3
	push	hl
	call	cheese		; cheese(3, 4)
	pop	bc
	pop	bc
	ld	hl,myint
	inc	(hl)		; ++myint
.cond1
	ld	de,(myint)	; while (...)
	ld	hl,42
	call	_lt		; less than 42?
	jp	c,.stmt1	; FIXME: is this condition correct?

FIXME: finish this too

Summary

This text explained how to convert statements in a high-level language such as C into Z80 assembly language. It discussed how to access global and local variables, pass arguments to functions, return a value from a function, write expressions, dereference pointers, and write flow structures in Z80 assembly language. It also discussed some ways to optimize code.

Future Directions

If I ever write another version of this document (based on my time and on demand for it), I plan to explain data structures and possibly some C standard library functions which might come in handy for Z80 assembly programmers. I also plan to include some complete assembly routines that I mentioned in this text but omitted.


This document Copyright 2005 Chris Williams.

$Id: index.html,v 1.4 2005/09/23 23:08:26 chris Exp $