As others have mentioned, this is best done in assembly. Here is some code to try:
void delay_sub(unsigned char i)
{
// convert 20, 21, 22 etc to count in R7 of 1, 2, 3 (extra cycle added if i is odd)
; cycles
rrc A ; 1 c = 1 if odd
jnc even ; 2 or 4 extra 2 cycles if branch taken (spoils cache)
nop ; 1 delete if using lcall's instead of acall's
nop ; 1 same
clc ; 1 in either case carry is clear prior to subb
even:
subb A,#9 ; 1
mov R7,A ; 1 R7 now = (i / 2) - 9
//while (i--);
loop:
djnz R7, loop ; 2 loop address should be in cache, so no extra cycles needed
ret ; 6
}
timing calculation (assuming acall's)
if i even:
5+7+R7*2+6 = minimum of 20 22 24 ... => R7 = 1, 2, 3 ...
if i odd:
5+8+R7*2+6 = minimum of 21 23 25 ... => R7 = 1, 2, 3 ...
It assumes a call is made like ACALL(nn), where nn is a constant or a variable in a byte variable, so that the parameter can be passed using a one cycle MOV A,#n instruction for example. The minimum timing you can do is 20 clocks, as you asked for.
mov A,#n ; 1
acall ; 4
There is no check that the parameter is greater than or equal to 20, any values less than 20 will give incorrect timing.
The mov instruction and acall will take 5 cycles. First off, the count (i) is divided by two to account for the DJNZ instruction taking two cycles. Then the count is adjusted to add a cycle if i is odd. Finally a fixed value is subtracted so the value in the register to be decremented (R7) is in the range 1, 2, 3 ... R7 is then decremented in a tight loop (two cycles per count). There is a fixed cycle count of 6 for the return.
If you have to use a LCALL instead of a ACALL, the minimum timing you can do will be 21 clocks instead of 20, and you will need to delete the two nop's after the jnc instruction. You have to use either all ACALL's or LCALL's, you can't mix them.
I would avoid using C to call the function unless you can guarantee the compiler doesn't add extra overhead. Also, I'm using R7 as a scratch register; your compiler manual will tell you what registers can be used inside an assembler function without having to save them (if any).
This also doesn't account for disabling and re-enabling interrupts, if necessary, to guarantee the timing routine will not be interrupted.
The behavior of the jump instructions are based on the datasheet for the C8051F38x as I understand it (in terms of when the instruction cache is spoiled or not). This may be different for other versions of the 8051.
Finally, I haven't shown the syntax for jumping into in-line assembly and back out again. The subroutine could also be put into a separate file and assembled.