Let's look at PWM first of all - this is how we keep the lights on. The basic idea is this - persistence of vision gives us the illusion that an LED is light either dimly or brightly, when in fact it's flickering on and off so quickly that we can't see it flashing. The more time it spends on, the brighter it appears:
This is how most people imagine PWM working - in a "serial" manner (one instruction after another, with a suitable delay built in). For an 80% duty cycle, for example, the code might go something like this:
LED_on
Wait 1ms
LED_on
Wait 1ms
LED_on
Wait 1ms
Wait 1ms
LED_on
Wait 1ms
Wait 1ms
LED_off
Wait 1ms
Or even, more simply
LED_on
Wait 4ms
LED_off
Wait 1ms
Wait 1ms
Or even, more simply
LED_on
Wait 4ms
LED_off
Wait 1ms
and just repeat/loop.
In fact, a lot of servo controllers work this way - which is where the theoretical limit of 8 serially controlled servos per board comes from. A lot of times you see this for pwm - the code exactly matches the timing graph: send a pin high, wait the appropriate of time, send it low, wait out the remainder of the total cycle time.
And it works. But it's not the best way to control stuff via PWM. Let's say we've got some other stuff going on (like reading an analogue input, writing to a character display and so on). What happens now?
LED_on
Wait 4ms
LED_off
Wait 1ms
Check the analogue input
Write to the display
Loop
The problem is that doing all the other stuff, not just flickering the LEDS, takes up a lot of time. So our LEDs are on for 4ms and off for 1ms, and on for 4 out of 5 milliseconds gives us a duty cycle of 80% over 5ms. But if all this other stuff takes a few milliseconds to complete too:
On for 4ms
Off for 1ms
Stays off for 20ms while we check the inputs, update the LCD display and so on
Our 80% duty cycle is now actually on for 4ms, off for 1ms, stays off for 20ms, so on for 4 out of 25ms gives us a duty cycle of just 16%. And, because we've increased the total amount of time between each cycle, the flickering of the LEDs, on and off, becomes noticeable to the eye.
What we need is a way of getting the LEDs to update themselves, while we dedicate as much mcu power and time to the other tasks as necessary. What we need, in short, is a timer interrupt!
In our UV controller we're going to use our timer to do two things. Firstly it'll keep track of time (since we have a countdown timer from switching on the UV LEDs so that we don't overexpose any screens/boards). But secondly, we'll use it to as a "clock" for our PWM.
Instead of saying "turn on, do nothing while a set time elapses, turn off, do nothing while more time passes" we're going to use an interrupt, which is more akin to: "turn on the LEDs, then I'm off over here to do something else - give me a shout when that time is up". After the "on" part of the PWM time has elapsed "turn off the LEDs, then give me a shout when it's time to turn them on again".
In fact, we're going to use a slightly hybrid version of the two approaches. We're using an interrupt to run some code every 1 millisecond. At this point we'll ask "should the LEDs be on of off?" and act accordingly. We'll also keep a counter running and increase it by one; when the counter hits 1000, we know that exactly one second has passed, and adjust our countdown time by one second. Here's how we set up Timer1 in Oshonsoft:
'create a timer to fire every 1m/s
'(we use this for the countdown AND to control the LEDs)
T1CON = 0x00
T1CON.T1OSCEN = 1
T1CON.TMR1CS = 0
T1CON.TMR1ON = 0
Gosub preload_timer
'enable global and peripheral interrupts
INTCON.GIE = 1
INTCON.PEIE = 1
PIE1.TMR1IE = 1
'clear the timer-has-tripped interrupt flag
PIR1.TMR1IF = 0
'(we use this for the countdown AND to control the LEDs)
T1CON = 0x00
T1CON.T1OSCEN = 1
T1CON.TMR1CS = 0
T1CON.TMR1ON = 0
Gosub preload_timer
'enable global and peripheral interrupts
INTCON.GIE = 1
INTCON.PEIE = 1
PIE1.TMR1IE = 1
'clear the timer-has-tripped interrupt flag
PIR1.TMR1IF = 0
Our sub-routine preload_timer simply sets a value so that we get the timer interrupt to fire every millisecond. This can take a bit of thinking about, and there are "make-it-easy" calculators all over the 'net but they can sometimes just confuse the matter. Simply put, Timer1 is actually a 16-bit counter. Every clock instruction it increases by one. When it reaches 65,535 and rolls over to zero, the Timer1 interrupt is called. That's basically it.
What we need to do is work out what number to start counting from, so that when the mcu hits 65535 limit and rolls over to zero, exactly the right number of clock instructions have been carried out to indicate one second has elapsed. The number we need to count up to is critically dependent on the speed we're running the mcu at (the crystal value we're running it from). In our case, we're using a 4Mhz crystal.
A 4Mhz crystal means 1 million instructions are carried out every second. So 1000 instruction cycles (one instruction takes 4 clock cycles remember!) occur every millisecond. If we set our Timer1 value so that it only counts up to 1,000 instead of 65,535 before firing the interrupt, we should get a 1ms interrupt. Instead of starting at zero, we need to preload our Timer1 value with 65535-1000 = 64535. In hex, 64535 is 0xFC17
preload_timer:
'at 4mhz (crystal) we have 1m clock cycles per second
'if we count up to 1,000 that's 1 millisecond
'so let's preload timer1 with 65,535 - 1,000 = 64,535
'(which in hex is 0xFC17)
TMR1H = 0xfc
TMR1L = 0x17
Return
preload_timer:
'at 4mhz (crystal) we have 1m clock cycles per second
'if we count up to 1,000 that's 1 millisecond
'so let's preload timer1 with 65,535 - 1,000 = 64,535
'(which in hex is 0xFC17)
TMR1H = 0xfc
TMR1L = 0x17
Return
Now, every millisecond, wherever we are in our program, and whatever we're doing, everything will get parked and code execution jumps to the interrupt routine. When we're done here, we'll jump back to exactly where we were in the code, before we were so rudely interrupted. The interrupt routine needs to keep track of the number of milliseconds passed, and update the LEDs as necessary:
On Interrupt
Save System
'If it's Timer1 that caused the interrupt:
If PIR1.TMR1IF = 1 Then
'set the new timer value
Gosub preload_timer
'clear the interrupt flag
PIR1.TMR1IF = 0
'increase our (milli)second timer
seccount = seccount + 1
'if one whole second has passed, update the clock
If seccount >= 1000 Then
seccount = 0
Gosub decrease_time_one_sec
redrawtimer = 1
Endif
'increase our LED PWM wave
led_counter = led_counter + 1
If led_counter > 30 Then led_counter = 0
'decide whether the LED(s) should be on or off
Select Case state
Case 4
If led_counter >= brightwhite And brightwhite < 30 Then
Low white_led_pin
Else
High white_led_pin
Endif
Case 5
If led_counter >= brightuv And brightuv < 30 Then
Gosub turn_uv_off
Else
Gosub turn_uv_on
Endif
EndSelect
Endif
Resume
In this way, we still have PWM controlled LEDs - they are on for part of a time and off for a specific set time - but instead of just hanging around doing nothing between the on and off phases, we're freed up to run other code and the LEDs will just "look after themselves".
As well as a millisecond counter, we'll just keep an "LED brightness counter" which also increases every millisecond - but only counts up to 30 then resets to zero instead of 1,000 (as our clock counter does). In our code, we're using a brightness level from zero to 30. All we do is see if the current LED brightness counter is less than the required LED brightness and if it is, turn the LEDs on, otherwise turn them off.
This means that one PWM cycle lasts 30 milliseconds (we don't want to make this too large else visible flickering will appear on the LEDs). We don't have a fine degree of control over the "duty cycle". We can't, for example, set it to 1%, but we have up to 30 different values we can use which equate to
1/30 = about 3%
2/30 = about 7%
3/30 = about 10%
4/30 = about 13%
and so on.
Enough with the theory - let's get this coded up and some new firmware burned onto our almost-working hardware!
No comments:
Post a Comment