View unanswered posts | View active topics It is currently Tue Jun 25, 2019 4:22 pm



Reply to topic  [ 113 posts ]  Go to page Previous  1, 2, 3, 4, 5, 6, 7, 8  Next
 74xx based CPU (yet another) 
Author Message

Joined: Sat Feb 02, 2013 9:40 am
Posts: 881
Location: Canada
It looks like the compiler is well on its way. It seems to generate pretty good code. I got confused for a moment by the use of r0. I’m used to seeing it used to hold the constant zero. I’m also used to seeing the target to the left and sources to the right. But it works either way.

_________________
Robert Finch http://www.finitron.ca


Tue Apr 16, 2019 3:49 am
Profile WWW
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
Hi Robfinch,

Thanks for your input.

robfinch wrote:
It looks like the compiler is well on its way. It seems to generate pretty good code. I got confused for a moment by the use of r0. I’m used to seeing it used to hold the constant zero. I’m also used to seeing the target to the left and sources to the right. But it works either way.


My processor does not have a specific 'Zero' register because it's relatively cheap to load small constants (including zero) to any register, and even perform basic operations with small constants such as compare, add and subtract, through instruction embedded constant fields. So I chose to have all 8 registers fully available for computations considering that it's a 74xx based CPU and there will be limited hardware resources.

Indeed, it's kind of weird that different processors adopted totally opposite operand order, and there was no criteria consistency among them. It seems that most current processors adopt your preferred order (destination on the left), but there's still one processor (the MSP430) that has it on the right. I could have certainly chosen any of them, and I still can change that at any time, but I am kind of biased to the 'right destination' approach for historical reasons and because, in my mind, the execution flow of data in an assembly file goes from top to bottom and from left to right. But that's just a personal preference, of course.

About optimisations, I found a particular one that caught me totally off-ward. I initially thought that the compiler was buggy, but then realised it was correct code. Consider the following C source code:

Code:
struct A
{
  unsigned  char l[2];
  unsigned  char m;
  unsigned  char n;
};

struct B
{
  unsigned  char w[2];
  unsigned  char y;
  unsigned  char z;
};

struct B convert( struct A a )
{
  struct B b;
  b.w[0] = a.l[0];
  b.w[1] = a.l[1];
  b.y = a.m;
  b.z = a.n;
  return b;
}


This gets compiled for the CPU74 (and others) like this:

Code:
convert:                                ; @convert
; %bb.0:                                ; %entry
   ret   


So it's just a "ret" instruction with nothing else to do?. Yes, and this is correct because both structs share the same pattern. Struct A a is passed in registers r0 and r1 as it takes 32 bits. The compiler avoids completely the explicit assignations and it just returns the same r0, r1 registers, which already contain the correct data, but now are an alias of a Struct B variable. Isn't that captivating?.

And now, an even more interesting example. I made a small change to struct B and to the function.

Code:
struct A
{
  unsigned  char l[2];
  unsigned  char m;
  unsigned  char n;
};

struct B
{
  unsigned  char w[4];
  unsigned  char y;
  unsigned  char z;
};

struct B convert( struct A a )
{
  struct B b;
  b.y = a.m;
  b.z = a.n;
  return b;
}


The assembly code now is this:

Code:
convert:                                ; @convert
; %bb.0:                                ; %entry
   mov   r1, r2                  ; encoding: [0x00,0x0a]
   mov   #0, r0                  ; encoding: [0x40,0x00]
   mov   r0, r1                  ; encoding: [0x00,0x01]
   ret     


So the compiler takes r1 as the 16 bit register it is, and moves the a.n and a.m fields into b.y and b.z in a single instruction (this is the first move). Then sets the 4 byte array b.w to all zeros with the next two instructions. The compiler performed the assignations in a particular order that gets the result in the correct return registers without any need of extra register moves.

For the Thumb architecture, since the registers are 32 bits, the optimisation is even more amazing. See this:

Code:
_convert:
@ %bb.0:                                @ %entry
   lsrs   r1, r0, #16
   movs   r0, #0
   bx   lr


In this case the 6 byte long returning struct can be held in two 32 bit registers, so it's just a 16 bit shift right and a 32 bit move. As before, the assignation is performed in reverse order to prevent additional register moves.

In fact, the compiler does things like that all the time and sometimes quite aggressively, which to me is a joy to watch.

Architectures with instruction sets performing too many implicit register updates (such as Status Register updates) do not benefit that much of such optimisations, because the compiler is not able to safely reorder instructions without incurring on undesired side effects. The VAX-11 for example was not particularly good at that. The ARM on the contrary has specific ALU instructions that do not affect the SR, which enable compilers to perform more aggressive instruction reordering (i.e the "adds" instruction will set condition flags, while "add" instruction will not). Compilers (including LLVM) are even able to take into account the pipelining behaviour of processors, thus optimising even further the way instructions are reordered. On the LLVM this is referred as Instruction Scheduling and Instruction Itineraries. The LLVM-ARM backend has plenty of code to support that, but this is way over my head, and nothing that I will ever need on my simple 74xx based computer.


Tue Apr 16, 2019 8:04 am
Profile
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
The subject of compiler optimisations is quite an amazing thing.

I am now starting to look at the compiler intermediate code in order to understand how to produce conditional code, and just tried the following:
Code:
int compare( int a )
{
  return a > 4 ? 5 : 6;
}


The above is compiled like this in the intermediate LLVM representation:
Code:
entry:
  %cmp = icmp sgt i16 %a, 4
  %cond = select i1 %cmp, i16 5, i16 6
  ret i16 %cond


This compares 'a' with the constant 4 for the signed-greater-than condition, and then selects constant 5 or 6 depending on whether the comparison was true or false.
So far so good, it does exactly what's expected.

Then, I replaced the C code by this one:
Code:
int compare( int a )
{
  return a > 4 ? 4 : a;
}


This gets compiled like this:
Code:
entry:
  %0 = icmp slt i16 %a, 4
  %cond = select i1 %0, i16 %a, i16 4
  ret i16 %cond


Interestingly, the compiler replaced the 'signed-greater-than' by a 'signed-lower-than', and reversed the select instruction. At first glance the assembly output looks WRONG, because the 'greater-than' condition should have normally been reversed by a 'lower-than-or-equal' condition, not just a 'lower-than' condition. In fact, this appears to be wrong because it is as if the original C code would have been this:

Code:
return a < 4 ? a : 4;


which is markedly different than the original one!

For a while I struggled to understand why the compiler could do that. I mean, this is the direct output of the Clang front-end compiler, no backend implementation is involved at that stage, and this compiler is a top class product that is regularly used to successfully compile millions of lines of code. So it COULD NOT create wrong intermediate code!.

And indeed, the output is not wrong. In spite that the condition has been replaced by an apparently incorrect one, when you think about it, the code actually produces exactly the same results. The edge condition where 'a' equals 4 produces the same result (four) on both cases. In one case it would return 'a' and in the other case '4', but both are the same value, right?.

So why, the compiler hacked (?) the code by (sort of) reversing the condition?. I really don't have a clue. But the important thing is that the compiled code still produces the correct results.


Tue Apr 16, 2019 11:57 am
Profile

Joined: Sat Feb 02, 2013 9:40 am
Posts: 881
Location: Canada
I wonder the compiler is trying to keep the constant to the right? Immediate constants tend to be represented as the rightmost operand in instructions. It maybe for conditional move instructions with immediates. Also, some architectures have a set-less-than instruction but not a set-greater-than instruction (RiSCV). (Less than and greater than are the same thing except with swapped operands). It is a curiosity.

_________________
Robert Finch http://www.finitron.ca


Wed Apr 17, 2019 3:52 am
Profile WWW
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
robfinch wrote:
I wonder the compiler is trying to keep the constant to the right? Immediate constants tend to be represented as the rightmost operand in instructions. It maybe for conditional move instructions with immediates. Also, some architectures have a set-less-than instruction but not a set-greater-than instruction (RiSCV). (Less than and greater than are the same thing except with swapped operands). It is a curiosity.

Indeed the compiler seems to always move the constant to the right operand, but unfortunately this is not documented anywhere and I am unable to see that either from the complexity of the front-end source code. I have implemented explicit code to swap the comparison operands in case a constant comes on the left, but so far I have never managed to get it called. I mean, the constant comes always on the right whatever is the C source code. Still, the backend implementations that I looked at into some detail do NOT assume that the constant is always on the right, i.e they implement some sort of condition and operands swap or take into account that the constant may be on the left, but I didn't get that code executed either. Maybe the earlier versions of the compiler would eventually place constants on the left, so code had to be implemented on the backends to account for that?. Then the compiler was updated to force constants always on the right?. I don't know. So I think the safest bet is to leave the swapping condition code, even if it could not be tested.
About the generation of less-than or greater-than conditions, actually the compiler can generate both as shown in my previous post, but for some reason it replaced greater-than by less-than in at least one case without any clear reason.


Wed Apr 17, 2019 1:41 pm
Profile
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
I am now starting to generate actual target code for comparisons and I began by testing negations and the C ternary operator.
Code:
int compare( int a )
{
  return !a;
}


For that, the compiler creates a compare equal to zero, followed by a zero extenstion from 1 bit to 16 bits.

LLVM
Code:
%tobool = icmp eq i16 %a, 0
  %lnot.ext = zext i1 %tobool to i16
  ret i16 %lnot.ext


Ok, very clever, but not all the processors have the luxury of cheap zero extensions from 1 bit to 16 bits. This in turn has to be coded for most processors in terms of status register conditions, which the intermediate representation doesn't event have.

So depending on the processor, several approaches are used.

The MSP430 has direct access to the status register (it's register r2), so the strategy here, is to copy r2 into a general register, then move the zero bit to the least significant bit, then perform an and to get the correct result.

MSP430
Code:
compare:                                ; @compare
; %bb.0:                                ; %entry
   cmp.w   #0, r12
   mov.w   r2, r12
   rra.w   r12
   and.w   #1, r12
   ret

Ok, fair enough, but I can't do that because my processor doesn't have an accessible Status Register.

The general approach is of course using a normal comparison and a conditional jump, so the code (for the CPU74) would look like that:

CPU74
Code:
compare:                                ; @compare
; %bb.0:                                ; %entry
   mov   r0, r1                  ; encoding: [0x00,0x01]
   mov   #1, r0                  ; encoding: [0x40,0x08]
   cmp   r1, #0                  ; encoding: [0x48,0x01]
   breq   &.LBB0_2                ; encoding: [0xa0,0x00]
; %bb.1:                                ; %entry
   mov   #0, r0                  ; encoding: [0x40,0x00]
.LBB0_2:                                ; %entry
   ret                             ; encoding: [0x02,0x00]

This is correct code, it sets r0 to 1 if it was zero, or sets it to 0 otherwise.

The ARM-Thumb on the other hand, does a better thing. See this:
Code:
_compare:
@ %bb.0:                                @ %entry
   ldr   r1, LCPI0_0    ; ignore
   ands   r1, r0       ; ignore
   movs   r0, #0
   subs   r0, r0, r1
   adcs   r0, r1
   bx   lr

Ignore the first two instructions because they are only there to truncate a 32 bit register into a 16 bit value.
The trick in this case is to subtract 'a' from 0, (i,e computing its negated value) which would result in a 'borrow' condition if 'a' was different than zero. Then perform an add with carry of 'a' with its negated value. 'a' added to its negated value produces zero, which is added to the 'carry' flag to create a Zero or One result as desired. The "borrow" is interpreted as a negated "carry" flag in the status register, so the carry will correctly set the result to Zero if 'a' was not zero, and to One if 'a' was zero.

So that's what I implemented too for my architecture, finally resulting in the following assembly code for the original C code:

CPU74
Code:
compare:                                ; @compare
; %bb.0:                                ; %entry
   mov   #0, r1                  ; encoding: [0x40,0x01]
   sub   r1, r0, r1              ; encoding: [0x24,0x41]
   addc   r1, r0, r0              ; encoding: [0x22,0x40]
   ret   


Remember that the assembly language for the MSP430 and the CPU74 have the destination operand on the right, whereas the ARM has it on the left, so it's important to not forget that when comparing those codes.


Wed Apr 17, 2019 2:54 pm
Profile
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
The most generic code for most conditional situations remains to be a 'compare' instruction followed by a conditional jump. For example:
Code:
int compare( int a )
{
  return a < 3 ? 4 : 5;
}

This gets generically compiled like this on all architectures, as they all share similar backend code, which I also adopted:

CPU74
Code:
compare:                                ; @compare
; %bb.0:                                ; %entry
   mov   r0, r1                  ; encoding: [0x00,0x01]
   mov   #4, r0                  ; encoding: [0x40,0x20]
   cmp   r1, #3                  ; encoding: [0x48,0x19]
   brlt   &.LBB0_2                ; encoding: [0xa0,0x00]
; %bb.1:                                ; %entry
   mov   #5, r0                  ; encoding: [0x40,0x28]
.LBB0_2:                                ; %entry
   ret     


However, while playing with it, I found several cases where the compiler attempts to create 'clever' code that causes curious things.
Consider the following C code:

Code:
int compare( int a )
{
  return a < 0;
}


For the above code, the compiler creates this as the intermediate form:

LLVM
Code:
entry:
  %a.lobit = lshr i16 %a, 15
  ret i16 %a.lobit


As before, quite cleverly, the compiler just moves the sign bit to the least significant one. But unfortunately, this is not cheaper to do on most architectures.

The ARM has the luxury to implement fast multiple constant shift instructions that are just used for that.

ARM-Thumb
Code:
_compare:
@ %bb.0:                                @ %entry
   lsrs   r0, r0, #15
   bx   lr

Well, it just follows what the intermediate dictates.

The MSP430 produces the same, but it does not have any 15 bit shift instruction, so the result is this oddity:
Code:
compare:                                ; @compare
; %bb.0:                                ; %entry
   clrc
   rrc.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   rra.w   r12
   ret

Yes, it is a 15 bit right shift and it's correct code, but you can have an opinion on that.

The CPU74 is slightly better thanks to the 'swap' instruction trick that I described on some earlier post, but still kind of weird.
Code:
compare:                                ; @compare
; %bb.0:                                ; %entry
   swapb   r0, r0                  ; encoding: [0x01,0xc0]
   zext   r0, r0                  ; encoding: [0x00,0x80]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   lsr   r0, r0                  ; encoding: [0x01,0x00]
   ret                             ; encoding: [0x02,0x00]


Instead of the 15 single bit instructions on the MSP430 the CPU74 will only use 7, because the initial 8 bits are custom shifted with the swap+sext instructions.

So, this puts me now on a dilema of what to do

(1) Leave it as is?
(2) Incorporate additional instructions to help cases like that, such as a "rotate left" instruction, which would ultimately move the sign bit to the least significant bit on a single instruction, which then can be followed by an 'and' to trim the undesired bits? Problem is that I already have all two register instruction slots used in the ISA.
(3) Other ideas?


Wed Apr 17, 2019 3:41 pm
Profile

Joined: Wed Jan 09, 2013 6:54 pm
Posts: 1192
So, if the compiler is a given, then the long-distance right-shift is what you need to implement. How in your machine might you do that? In a machine with carry, you can add a left-justified 1 bit to pop the sign bit into the carry, and add carry to zero to put the carry bit into the LSB. But I think you said you don't have a status register...


Wed Apr 17, 2019 4:37 pm
Profile

Joined: Sat Feb 02, 2013 9:40 am
Posts: 881
Location: Canada
So, are you saying that even if there is a greater-than operation defined, the compiler won’t use it? What if there's no less-than operation available, does the compiler croak then?

_________________
Robert Finch http://www.finitron.ca


Thu Apr 18, 2019 2:53 am
Profile WWW

Joined: Sat Feb 02, 2013 9:40 am
Posts: 881
Location: Canada
It looks like a reduction 'not' operation could be handy. I have a reduction 'or' operation in FT64 just for such things. It takes any bit set in the source and moves it to bit zero.
It doesn't get used that often, but as you've demonstrated the alternative is pretty messy.

_________________
Robert Finch http://www.finitron.ca


Thu Apr 18, 2019 3:02 am
Profile WWW
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
BigEd wrote:
So, if the compiler is a given, then the long-distance right-shift is what you need to implement. How in your machine might you do that? In a machine with carry, you can add a left-justified 1 bit to pop the sign bit into the carry, and add carry to zero to put the carry bit into the LSB. But I think you said you don't have a status register...

Thank you for your suggestion. I also accept suggestions about other compilers, but I don't think this would make a difference. I assume all compilers would treat less-than-zero conditions in a special way because ultimately this is testing for the sign bit, as you pointed out.

I DO have a status register, but I have no explicit instruction to move it into a general purpose register, as I initially though this was not necessary, because all the uses of the SR are implicit in instructions (such as add with carry, or conditional jumps). However, the LLVM intermediate code representation doesn't even have a SR.


Last edited by joanlluch on Thu Apr 18, 2019 7:11 am, edited 1 time in total.



Thu Apr 18, 2019 6:04 am
Profile
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
robfinch wrote:
So, are you saying that even if there is a greater-than operation defined, the compiler won’t use it? What if there's no less-than operation available, does the compiler croak then?

Hi Rob, I'm not sure if I fully understand your question. We must distinguish between the LLVM intermediate code and the specific backend implementations. The LLVM intermediate code has a lot of features that may or may not be available on a particular target processor. The LLVM code has virtually no restrictions. It is target independent code, and all optimisations are target independent. After that, it's the responsibility of the particular backend implementations to translate the LLVM code into actual target-specific assembly code. Since not all features of the LLVM intermediate representation are available on all processors, this requires some work on the backend, such as for example replacing condition codes, in case the compiler generated one that is not available on a particular processor.


Thu Apr 18, 2019 6:25 am
Profile
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
robfinch wrote:
It looks like a reduction 'not' operation could be handy. I have a reduction 'or' operation in FT64 just for such things. It takes any bit set in the source and moves it to bit zero.
It doesn't get used that often, but as you've demonstrated the alternative is pretty messy.


I see what you mean, and I can think on at least a couple of circumstances where such operator can be useful. I wonder if you have multiple shift instructions too like the ARM, (possibly implemented around a barrel shifter in hardware?)

I think one of the shortcomings of my current architecture is that I have not provided any instruction to explicitly read the SR as a register. The MSP430 on the contrary, can get the SR moved into a general purpose register, which can then be modified with shift instructions to get the actual condition and avoid conditional branching in conditional expressions.

Another approach that appeals me are the "setxx" instructions of the X86. They are a range of instructions that move a boolean condition (0 or 1) into a register depending on given conditions in the SR. For example "setgt" would set 1 to a register when a greater-than condition is present in the SR to set a register. It's similar to conditional branching, but instead of branching, it sets a register. I can imagine that the hardware implementation is similar to conditional branching, except that the SR condition is set to a register instead of used to eventually perform a jump.


Thu Apr 18, 2019 7:01 am
Profile

Joined: Sat Feb 02, 2013 9:40 am
Posts: 881
Location: Canada
Quote:
I wonder if you have multiple shift instructions too like the ARM, (possibly implemented around a barrel shifter in hardware?)
Yup. Just implemented using the Verilog '>>' and '<<' operators. There's room in the ISA for long-form (48-bit opcode) shift instructions that also perform a second operation on the output of the shift. But I've not implemented them yet.
Quote:
I think one of the shortcomings of my current architecture is that I have not provided any instruction to explicitly read the SR as a register.
I think at some point there will be a need to be able to read the SR or other control and status registers. (CSR's). I liked the way RiSC-V implements CSR's and the same type of instruction is in the FT64 ISA.
Frequently associated with processing cores are timers. Many embedded processors have built in timer registers. These would be accessible via CSR instructions. If you want to expand the architecture at some point, I think it would be good to a least reserve a spot in the opcode space for reading/writing CSR's. There's very few machines that don't have some sort of accessible special purpose registers.
However, for building a cpu out of TTL parts it might be a bit much to implement.
Quote:
Another approach that appeals me are the "setxx" instructions of the X86.

Set instructions are great, but they use a lot of opcode space compared to a simple compare instruction.

_________________
Robert Finch http://www.finitron.ca


Sat Apr 20, 2019 3:24 am
Profile WWW
User avatar

Joined: Fri Mar 22, 2019 8:03 am
Posts: 98
Location: Girona-Catalonia
I miss a "like" feature on this forums. I think it would be useful sometimes to just let someone know that you have read her/his post and implicitly say that you agree with what has been posted, or that the content is useful to you. It's a pity in my opinion that a 'like' button was not implemented in this forums. I miss it.

Anyway, regarding the recent posts on conditional expressions and the 'not' operator. I decided to get a bit bold and implemented both 'select' and 'set' instructions on my architecture. I managed to do so without affecting the encoding of the remaining instructions, as I embedded all the new coding in the existing slot for the 'conditional branch' instruction. To achieve that, I reduced the previous 4 bit long condition code field, to just 3 bits. The spare bit is now used to determine whether it's a conditional branch or a set/select instruction. As I only need 9 bits for the three register operands of a 'select' instruction (as opposed to the 10 bit relative address field of the branch instruction), there's an additional bit that I use to determine whether it's a 'select' or a 'set'.

A SETcc, Rd instruction will set 1 or 0 to a register depending on an existing condition on the SR (possibly generated by a previous CMP instruction)
A SELECTcc Rn, Rs, Rd instruction will move one of two source registers into a destination register depending on an existing condition on the SR
A BRcc reladdr instruction will conditionally branch the program execution depending on an existing condition on the SR

These are the new instruction encodings:

Attachment:
Opcodes.png
Opcodes.png [ 223.96 KiB | Viewed 2229 times ]


The reduction from 4 to 3 bits for the condition code field means that there are a couple of condition codes that are not directly supported by hardware

Attachment:
ConditionCodes.png
ConditionCodes.png [ 154.51 KiB | Viewed 2229 times ]


The unsupported hardware conditions are 'less than or equal' and 'unsigned less than of equal', but it's easy enough for the compiler to generate code that overcomes that limitation without incurring in any performance penalty. For Branch and Set instructions with constant comparisons I just add 1 to the constant at compile time and replace the unsupported conditions by 'less than' or 'unsigned less than' respectively, which are both supported. In case of register to register comparison, I swap the registers in the compare instruction and use the complementary condition on the 'branch' or 'set' instruction. The case of the 'select' instruction is easier as I just have to swap the source operands and use the reverse condition in the 'select' instruction.

To help the compiler to reuse conditions already present in the SR, I also decided to use a separated set of condition codes for arithmetic operations. So now the SR is the general Status Regiter, and the ASR is the Arithmetic Status Register. The SR will store conditions that are essentially generated by the 'cmp' instruction, whereas all other instructions (generally arithmetic) will affect the ASR instead. Instructions such as ADDC and similar will now use the ASR instead of the SR. This helps creating shorter and more efficient code through the avoidance of redundant 'cmp' instructions: The compiler can now freely insert arithmetic instructions between a 'cmp' and a 'set' or 'select' instruction, if that would help code generation. So this is the updated register table:

Attachment:
Registers.png
Registers.png [ 129.14 KiB | Viewed 2229 times ]


The resulting assembly code after such changes is much cleaner and shorter. The compiler front end is clever enough to produce 'select', 'set', or 'branch' instructions in a optimal way while still fully adhering to the C language conditional and logical expression specifications, which obviously helps enormously to achieve these results. My previous backend implementation reduced all these instructions to conditional branches, which was the only possible thing to do as 'select' and 'set' were not available, but that's no longer the case. I will post some code examples later today.


Sat Apr 20, 2019 10:01 am
Profile
Display posts from previous:  Sort by  
Reply to topic   [ 113 posts ]  Go to page Previous  1, 2, 3, 4, 5, 6, 7, 8  Next

Who is online

Users browsing this forum: No registered users and 1 guest


You cannot post new topics in this forum
You cannot reply to topics in this forum
You cannot edit your posts in this forum
You cannot delete your posts in this forum
You cannot post attachments in this forum

Search for:
Jump to:  
cron
Powered by phpBB® Forum Software © phpBB Group
Designed by ST Software