This is a detailed look at the complete closed loop fuel control routine. Read the high level overview first if you haven’t already!
Before we get into the details, a brief outline of what this code does might be helpful. I’ll assume you understand the general idea behind closed loop fueling, and just concentrate on how the code achieves that.
We can break this into stages:
The size of the correction step used varies based on whether the current condition is lean or not lean, whether it has just changed, and also by rpm and load.
In step #1, the rpm/load dependent step values are retrieved from three different maps. In step #2, these values are modified and combined according to the control strategy. The gist of that strategy is like this: if the measured condition just changed to or from a lean condition, then we make a very big correction step in the opposite direction. But if the latest measured condition is the same as the previous one, then we only add a small correction step. The correction accumulates until the condition switches.
In step #3, various threshold conditions are allowed to temporarily block lambda correction. These are
In step 4 the final correction is limited to between 0.7x and 1.14x.
Finally, step 5 applies the correction according to certain timer-based rules. In order for a correction to be applied, the condition it’s correcting for has to exist for a certain minimum time, which is measured by the timer variable 3Eh. See DME software timers for more details on how this timer works.
There are 2 possible actions that can happen in this final application:
We’ll call the latter situation the neutral condition.
In all cases, the relevant AFR condition must hold continuously for the whole timer period. If the condition changes before the timer expires, then the timer gets reset.
And the timer periods are different for actions #1 and #2! For a rich/lean condition, the timer period is short around 103ms, and for the neutral condition, it’s longer (around 760ms). That is, in order for the correction to be neutralized, the AFR must remain neither rich nor lean for a long time. But in order for a correction to take place, the AFR need only be rich or lean for a short time.
While the timer is counting down, whichever action was happening before simply continues, with any new addition or subtraction based on the control strategy.
But note also that when the correction step is being calculated, no distinction is made between a rich condition, and a neutral condition. It’s simply lean or not-lean. Therefore a neutral condition will generally provoke a correction in the lean direction, and so it’s unlikely that the timer for neutralizing the correction will go for long without being reset.
Now we’ll get into those stages one by one.
The looking up of the lambda correction maps starts at the end of the fuel adjustment routine:
X1de0: mov 4eh,r6
mov 4fh,r7
ljmp X1053
This 1053 jump just calls 1B39 where we check 20h.7, which is our master flag for closed loop fuel control, set only for appropriately equipped cars:
X1b39:
jnb 20h.7,X1b3f
ljmp X1d15
The code at 1D15 seems to be a replacment for the 0A5E (which is unreachable). It just expands on the conditions that select between Maps 62 and 65, although for out purposes this is irrelevant. Once the map is selected, we jump to 0A65 where the rest of the lookups/map selections happen:
X1d15:
mov r2,#3eh
jb 25h.6,X1d1f ; 25h.6=1 means RoW, so 20h.7 would not be set (see 1BEE)
jnb t0,X1d1f
mov r2,#41h
X1d1f:
ljmp X0a65
...
X0a5e: ;UNREACHEABLE
mov r2,#3eh ;Map 62
jnb t0,X0a65
mov r2,#41h ;Use Map 65 if code plug set
X0a65:
lcall X051d
mov 18h,a ;18h, for unchanged
lcall X051d ;Map 63 or 66
mov 19h,a ;19h, for changed/lean
lcall X051d ;Map 64 or 67
mov 1ah,a ;1A, for changed/not-lean
ret
;
The above code only looks up the maps based on rpm and load, and stores the values for later.
The beginning of the actual adjustment routine is called a little later, from 0419 in the post-load calculation routine, via the acall to 1B30:
X1b30: jnb 20h.7,X1b36
ljmp X0a75
The first part of this routine is concerned with updating the current lambda correction value based on the values we just retrieved from the maps, and the current and previous states of the O2 sensor circuit.
The previous correction is loaded from 1B:1C into r1:r0. Then we update r1:r0 and store it back into 1B:1C. Later when we actually apply the correction, we use r1:r0. If we end up neutralizing the correction, we overwrite 1B:1C with the neturalized version.
X0a75:
jnb 23h.4,X0a7b
mov 1bh,#80h
X0a7b:
mov r0,1ch ;1Ch, low byte of final correction
mov r1,1bh ;1Bh, high byte of final correction
mov c,p1.7 ;1=lean, according to opendme
cpl c
mov 20h.2,c ;so now 20h.2=0 means lean
jc X0a94 ;jump if not lean
jb 24h.6,X0a8e ;24h.6 = previous 20h.2
lcall X0bcd ;unchanged/lean
sjmp X0aa1
;Changed from not-lean->lean:
X0a8e:
clr a ; call 0BD4 with a=0
lcall X0bd4
sjmp X0aa1
;Not lean:
X0a94:
jnb 24h.6,X0a9c
lcall X0bcd ; unchanged/not-lean
sjmp X0aa1
;Changed from lean->not-lean
X0a9c:
mov a,1ah ; call 0BD4 with a=1Ah, Map 64
lcall X0bd4
X0aa1:
mov 1ch,r0 ;1Ch
mov 1bh,r1 ;1Bh
mov c,20h.2
mov 24h.6,c ;store new 20h.2 value for next time
ljmp X105c ;jumps to 1CB7, so the code immediately after this is an unreachable version
;
Here’s the logic of the above section, in pseudo-code:
if cranking:
1B = 1
r1:r0 = 1B:1C
if currently lean:
if previously lean:
r1:r0 = call 0BCD
else if not previously lean:
r1:r0 = call 0BD4 (with a=0)
else if currently not-lean:
if previously lean:
r1:r0 = call 0BD4 (with a=1A, the value from Map 64)
else if not previously lean:
r1:r0 = call 0BCD
1B:1C = r1:r0
previously lean = currently lean
The 0BCD/0BD4 routines are really one routine with two different entry points - here’s the whole thing:
; Called when lambda is unchanged
X0bcd:
mov a,18h ; 18h, from Map 62, lambda adjustment for unchanged state
mov b,#1
sjmp X0bdd
;
; Called when lambda has changed from/to lean.
; This is called with either a=0 or a=1Ah (from Map 64)
; if transition was LEAN->NOT-LEAN, then a=1Ah
; if transition was NOT LEAN->LEAN, then a=0
X0bd4:
cpl a
inc a
add a,19h ; 19h; this subtracts a from 19h
mov b,#10h ; we'll be multiplying by 16, vs by 1 in the unchanged case
clr 24h.4
;
; Arguments: A, B, R1:R0
; Returns: R1:R0
;
; common path, called either way
X0bdd:
mul ab
jnb b.7,X0be4
mov b,#7fh ; clamp b to max 127
X0be4:
jnb 20h.2,X0bec ; jump if lean - if not lean, we complement b:a and c below
cpl c
cpl a
xrl b,#0ffh ; xor, effectively complements b in place (cpl only works on a)
X0bec:
addc a,r0 ; get here if lean or not lean, but b:a is complemented if not lean
mov r0,a
mov a,b
addc a,r1
mov r1,a
clr a
jb 20h.2,X0bf8 ; jump if not lean
cpl c
cpl a
X0bf8:
jc X0bfc ; add overflowed
mov r0,a ; a=0 if not lean, 255 if lean
mov r1,a
X0bfc:
ret
And here’s a pseudo code summary of that same routine. Note that b:a is used as a 2’s complement signed number. So before complementing, we must clamp to the middle value (high byte 127). After complementing into a negative number if necessary, we add it r1:r0, in which case we have to check for overflow and clamp to 0 or 65356 as appropriate.
if unchanged:
b:a = 18h (from Map 62)
else if lean:
b:a = (19h - 1Ah) * 16 (19h from Map 63, 1Ah from Map 64)
else if not-lean:
b:a = 19h * 16
if b >= 127 then b=127 (clamp b:a to roughly 32767)
if lean:
r1:r0 = r1:r0 + b:a
if r1:r0 > MAX:
r1:r0 = MAX
else:
r1:r0 = r1:r0 - b:a
if r1:r0 < MIN:
r1:r0 = MIN
The next section handles threshold conditions for lambda control, including temperature. Flag 24h.5 (not to be confused with 25h.4!) is a local master switch for all threshold conditions. If it’s clear, lambda control is disabled:
X1cb7:
jb 23h.2,X1d0b ; cranking -> clear 24h.5, causing no correction later
jb 23h.1,X1d0b ; WOT -> clear 24h.5, causing no correction later
jb 23h.5,X1d0b ; coasting fuel cutoff -> clear 24h.5, causing no correction later
nop
org 1cc3h
mov a,#4bh ; 1160 + 4Bh = 11AB, contains 8Eh=166, so 6640rpm
movc a,@a+dptr
cjne a,37h,X1cc9
X1cc9:
jc X1d0b ; clears 24h.5, causing no correction later
mov a,#4ch ; 11AC, contains 66h or 102 decimal
movc a,@a+dptr
cjne a,49h,X1cd1
X1cd1:
jnc X1ce8 ; c=1 if a < 49h, i.e. if 49h > 102, so jnc jumps if 49h <= 102
jnb 24h.5,X1d0b ; clear 24h.5, no correction if load > 102
; (this could probably happen at mid-range rpm/high throttle)
jb 24h.1,X1ce2 ; check the 3F timer if we were previously in this condition (load > 102)
setb 24h.1 ; we just hit load > 102 for the first time, this flag starts the timer
clr 24h.2 ; doesn't appear to be set by any reachable code
mov a,#4dh ; 11AD, contains 6
movc a,@a+dptr
mov 3fh,a ; 3F <- 6
X1ce2:
mov a,3fh
jz X1d0b ; if the timer expired, clear 24h.5, so no correction
sjmp X1cea ; the timer hasn't expired yet, so don't cancel correction just yet
;
X1ce8:
clr 24h.1 ; jumped here from the load comparison - if load dropped below 102,
; clear 24h.1 (results in the timer being reset next time)
X1cea:
jnb 25h.4,X1cf1 ; patched-in condition: if 25h.4 is clear, use normal temp threshold logic
mov a,#6eh ; patched-in alternate constant/table offset
sjmp X1cf8
;
X1cf1:
mov a,#49h
jb 23h.0,X1cf8
mov a,#48h ; 48h and 49h (11A8 and 11A9) both contain 58h/88 dec., roughly 16.7C
X1cf8:
movc a,@a+dptr
mov r2,a
jb 24h.5,X1d01 ; if correction is already enabled somehow, don't add the other temp value
mov a,#4ah ; 11AA, contains 7 which is equivalent to an addition of around 4.6C
movc a,@a+dptr
add a,r2 ; roughly 21C
X1d01:
clr c
subb a,13h ; 13h = coolant temperature
mov 24h.5,c ; set 24h.5 if coolant temperature is above the threshold
lcall X0b50 ; clamping routine for r1:r0
sjmp X1d0d ; jumps to 0AFB (see below)
In pseudo code:
if cranking or WOT or coasting:
clear 24h.5
if rpm >= 6640:
clear 24h.5
if load > 102:
if 24h.1: # means we had previously entered this condition
if 3F = 0: # check the timer
clear 24h.5
else: # we just entered this condition, set the timer
set 24h.1
clear 24h.2 # unused
3F = 6 # timer value loaded from 11AD
else:
clear 24h.1 # load went below 102 without the timer expiring, so cancel the whole business
if 25h.4: # if this is set, it means NTCII was < ~15C during cranking
threshold = 50C # 11CE, contains 8Ch/140
else:
threshold = ~17C
if not 24h.5: # lambda is currently blocked by some threshold
threshold = threshold + 5C # hysterisis
if 13h > threshold:
set 24h.5
else:
clear 24h.5
call clamping routine
call main adjustment routine
A few notes about this:
The flags 25h.4 and 25h.5 are both set during the cranking phase. Each one corresponds to an engine temperature threshold, and is set if the engine temperature was below that threshold during cranking. But both thresholds are set to the same value, around 15C. Only 25h.4 is used here. The way that 25h.4 is used here means that there are two different temperature thresholds for enabling lambda control. If the temperature was below ~15C while cranking, then lambda control is only enabled above 55C. Otherwise it’s enabled from around 22C.
Also note the hysterisis logic: once lambda control is activated based on the initial temp threshold, it won’t get deactivated unless the temp falls to around 5C below that temperature - this prevents it from switching on and off constantly if the temperature happens to be hovering around the threshold.
The comments in the pseudo code above note that 24h.2 is unused; more on that later.
The next section is where the correction logic is actually applied.
X0afb:
lcall X105f ; 105F->1E36 (monitoring/debugging)
jnb p1.6,X0b18 ; Lambda: jump if rich
jb p1.7,X0b18 ; Lambda: jump if lean
jb 24h.3,X0b10
; lambda is ok
setb 24h.3 ; this gets cleared when we go rich/lean at 0B18, so this one probably means lambda is OK
clr 24h.4 ; this gets set the first time we enter the rich/lean code at 0B18, clearing it means the timer will reset next time we go rich/lean
mov a,#43h
movc a,@a+dptr
mov 3eh,a ; lambda timer
X0b10:
mov a,3eh ; lambda timer
jnz X0b2e ; lambda is ok and the timer has NOT expired so keep doing the previous correction (?)
clr 24h.7 ; lambda is ok and the timer HAS expired, so we can stop correcting now
sjmp X0b2e
;
; rich or lean
X0b18:
jb 24h.4,X0b28 ; 24h.4 seems to indicate that we *just* went rich/lean, so set up some flags and reset the lambda timer
setb 24h.4 ; presumably this block only needs to run the first time we go rich or lean
clr 24h.3 ; lambda not OK any more
clr 24h.2 ; doesn't appear to be set by any reachable code
setb 24h.0
mov a,#44h
movc a,@a+dptr
mov 3eh,a ; reset the lambda timer
X0b28:
mov a,3eh ; lambda timer
jnz X0b2e
setb 24h.7 ; timer reached zero, so clear lambda-OK flag, otherwise 24h.7=0 will inhibit rich/lean correction below
X0b2e:
mov c,24h.5 ; we could get here with the timer expired or not expired
anl c,24h.7
anl c,24h.0
mov 24h.5,c ; 24h.5 = master switch for correction 1=do correction 0=don't
jc X0b42 ; if ALL flags are set, do a correction, otherwise the code below wipes it out to 1
mov r0,#0
mov r1,#80h
mov 1ch,r0 ; 1Ch, low byte of lambda correction
mov 1bh,r1 ; 1Bh, high byte of lambda correction
clr 24h.2
X0b42:
ljmp X1062 ; this just jumps to 0B45, below
;
X0b45:
lcall X0455 ; 16x16 bit multiply
mov a,#1
lcall X0509 ; left-shift 24-bit value
ljmp X1065
;
And here’s the logic:
if rich or lean:
if not 24h.4: # 24h.4=1 means the rich/lean timer has already been started
set 24h.4
clear 24h.3
clear 24h.2 # unused
set 24h.0
3E = 9 # lambda timer, loaded with 11A4, which contains 9
if timer = 0:
set 24h.7
if 24h.5 and 24h.7 and 24h.0:
apply the change in r1:r0
else:
r1:r0 = 1B:1C = 32767
clear 24h.2 (unused)
else: # lambda ok (not rich or lean)
if not 24h.3: #24h.3=1 means the OK timer has already started
set 24h.3
clear 24h.4
3E = 66 # lambda timer, loaded with 11A3, containing 24h/66
if timer = 0:
clear 24h.7
if 24h.5 and 24h.7 and 24h.0: # this will now fail because 24h.7=0
apply the change in r1:r0
else:
r1:r0 = 1B:1C = 32767, i.e 1
clear 24h.2 (unused)
apply the correction to the r7:r6 fuel value # via 16x16 mul routine
multiply by 2
So 24h.4 is the flag that indicates we’re in the rich/lean timer phase. And 24h.3 indicates that we’re in the lambda neutral timer phase. To see how these flags work, let’s consider a a few hypothetical examples.
Let’s say there was previously a correction for a rich or lean condition, and 24h.7 is set. Suppose that we have entered the above code with the lambda condition having just changed to neutral. Then 24h.3 will get set, and the timer 3F will get initialized with the value 66. But until the timer reaches zero, 24h.7 remains set, so the jc X0b42 instruction will run and the correction will still happen, with whatever update was calculated earlier. Now suppose the neutral condition persists long enough for the timer to reach zero. Then 24h.7 gets cleared, and the correction will get neutralized at 0B38.
But conversely, let’s say the neutral condition doesn’t last that long, and instead gets interrupted by a transition back to rich or lean. We don’t care which at this point, since the direction of the correction was handled by the signed calculation earlier. But now we’ll take the path at 0B18, which clears 24h.3. In this particular example, 24h.7 was still set, because the netural timer never expired, so the correction can start happening immediately. But in the case where the neutral timer had expired, and 24h.7 had been cleared, then it would only get set again after timer expires at 0B28.
Note that three flags all have to be set in order for the correction to proceed: 24h.5, 24h.7 and 24h.0. If any of these are clear, then the correction is neutralized at 0B38. We have already seen that 24h.5 is the thresholds flag from earlier. 24h.7 is the rich/lean vs neutral indicator. But so far 24h.0 is unexplained. In fact it’s not used - it’s related to 24h.2, and both are part of a watchdog routine that is deactivated in the final version of the code. We’ll see the details a little later. For now, just trust that 24h.0 is always set.
Finally we multiply r7:r6 (the existing fuel adjustment) by r1:r0, and then multiply by 2. Recall that our lambda adjustment value is a signed 2’s complement number, so the max value is half the 16-bit range, with higher values representing negative numbers. Multiplying it by 2 restores the scale to match the existing 16-bit fuel correction r7:r6.
The clamping routine is called earlier, at the end of the threshold section. I mentioned earlier that there is an unused watchdog algorithm that involves 24h.2 and 24h.0. The implementation of that routine can be seen here, but it’s not used because the cjne a,#0ffh,X0b7f instruction uses the value 11A5, which is 255, so the jump to 0B7F will never happen. The code at 0B7F is the only place that 24h.2 ever gets set.
Since the upper limit here is high byte=147, that gives a maximum lambda adjustment of around 1.15x. The lower limit is 90, giving around 0.7x.
; This routine short circuits if r1 is between 90 and 147.
; If outside that range, then it's clamped to that range.
; So this is the lambda clamping routine, and the limits are presumably
; 90/128 and 147/128, i.e. 0.7x - 1.14x
; Arguments: R1:R0
X0b50:
mov a,#46h ; 11A6, contains 93h/147 dec.
movc a,@a+dptr
mov r3,a ; r3 <- 147
clr c
mov a,r1 ; r1 is the high byte of the current lambda correction factor
subb a,r3
jc X0b5d ; jump if r1 < 147 (probably 147/128, i.e. 1.14x)
mov r0,#0 ; r0 is the low byte, set to zero if r1 >= 147
sjmp X0b67 ; set 24h.0, check timer, clr 24h.0 if expired, return
;
X0b5d:
mov a,#47h ; 11A7, contains 60h, 90 dec. (presumably 90/128, i.e. 0.7x)
movc a,@a+dptr
mov r3,a
clr c
subb a,r1
jc X0b89 ; jump if r1 > 90, this returns
mov r0,#0ffh ; set low byte to 255
; if we get here then either r1 > 147 or r1 < 90
X0b67:
mov a,r3 ; a <- 147 or 90 depending on how we jumped here
mov r1,a
mov 1ch,r0 ; 1Ch, low byte of lambda correction
mov 1bh,r1 ; 1Bh, high byte of lambda correction
jb 24h.1,X0b89 ; return. 24h.1 was previously noted as flagging that lambda correction is load-inhibited (49h > 102)
jb 24h.2,X0b83 ; check timer, clr 24h.0 if timer=0, return
mov a,#45h ; 11A5, contains FFh, 255
movc a,@a+dptr
cjne a,#0ffh,X0b7f ; this will never jump since 11A5 does contain 255
clr 24h.2
setb 24h.0
sjmp X0b89
;
X0b7f:
setb 24h.2 ; unreachable?
mov 3fh,a
X0b83:
mov a,3fh
jnz X0b89
clr 24h.0
X0b89:
ret
Here’s the logic that actually happens here - it could hardly be simpler:
if r1 < 147 and r1 > 90:
return
else if r1 >= 147:
1C = r0 = 0
1B = r1 = 147
else if r1 <=90:
1C = r0 = 255
1B = r1 = 90
return
For completeness here’s a brief explantion of the unused watchdog logic too:
if 24h.1=1: # this indicates that timer 3F is busy with load threshold
return
if 11A5 != 255:
set 24h.2
3F = 11A5
else:
clear 24h.2
if 3F = 0:
clear 24h.0 # this neutralizes lambda correction (see above)
First note that the timer, 3F, is the same one used for the load threshold earlier. If its currently being used for that, we skip this watchdog logic entirely.
Next we check the constant 11A5, and if it’s anything other than 255, then we use it as a timer value for 3F. We set 24h.2 so that indicates that we’re in the watchdog countdown mode.
But recall that 24h.2 is cleared earlier when the lambda state transitions. So this logic, if active, would detect when the correction is pegged to the rails for a certain time, and neutralize the lambda correction in that case.
Everything the lambda routines touch — the map lookups (0A65), the correction update (0A75/0BCD/0BD4), the threshold section (1CB7), the application logic (0AFB) and the clamping routine (0B50). See the memory map for the master list.
| Location | Purpose |
|---|---|
| 13h | coolant temperature (NTC II), compared against the enable threshold |
| 18h | correction step for an unchanged condition, from Map 62 or 65 |
| 19h | correction step for a changed-to-lean condition, from Map 63 or 66 |
| 1Ah | correction step for a changed-to-not-lean condition, from Map 64 or 67 |
| 1Bh | high byte of the lambda correction factor |
| 1Ch | low byte of the lambda correction factor |
| 37h | engine speed (rpm/40), checked against the upper rpm threshold |
| 49h | load, checked against the load threshold |
| 3Eh | lambda timer (rich/lean and neutral phases) |
| 3Fh | load threshold timer, shared with the disabled watchdog logic |
| Flag | Purpose |
|---|---|
| 20h.2 | current lambda condition (0 = lean, 1 = not lean) |
| 20h.7 | master enable for closed loop fuel control |
| 23h.0 | TPS at idle — selects which temperature threshold constant is used |
| 23h.1 | WOT — blocks correction |
| 23h.2 | cranking — blocks correction, and resets 1Bh via 23h.4 |
| 23h.4 | startup — resets the correction to 80h |
| 23h.5 | coasting fuel cutoff — blocks correction |
| 24h.0 | watchdog enable; always set in the production code |
| 24h.1 | load threshold timer is running |
| 24h.2 | watchdog countdown active; never set by reachable code |
| 24h.3 | the lambda-neutral timer phase is running |
| 24h.4 | the rich/lean timer phase is running |
| 24h.5 | all threshold conditions are met (1 = correction permitted) |
| 24h.6 | previous value of 20h.2 |
| 24h.7 | the current condition has persisted long enough to act on |
| 25h.4 | NTC II was below ~15C during cranking — selects the higher temp threshold |
| 25h.6 | region coding (1 = RoW) |
| p1.6 | O2 sensor comparator, rich |
| p1.7 | O2 sensor comparator, lean |
| t0 | code plug input, selects Maps 62-64 vs 65-67 |
| Reference | Address | Purpose |
|---|---|---|
| Map 62 (3Eh) / 65 (41h) | 1659h / 1659h | correction step, condition unchanged (by rpm and load) |
| Map 63 (3Fh) / 66 (42h) | 1694h / 1694h | correction step, changed to lean |
| Map 64 (40h) / 67 (43h) | 16BAh / 18ECh | correction step, changed to not-lean |
| 1160+43h | 11A3h | 3Eh reload for the neutral phase (66, i.e. ~760ms) |
| 1160+44h | 11A4h | 3Eh reload for the rich/lean phase (9, i.e. ~103ms) |
| 1160+45h | 11A5h | watchdog timer value (255, which disables the watchdog) |
| 1160+46h | 11A6h | upper clamp on the correction high byte (147, ~1.15x) |
| 1160+47h | 11A7h | lower clamp on the correction high byte (90, ~0.7x) |
| 1160+48h | 11A8h | temperature threshold, not at idle (88, ~16.7C) |
| 1160+49h | 11A9h | temperature threshold, at idle (88, ~16.7C) |
| 1160+4Ah | 11AAh | hysteresis added to the temperature threshold (7, ~4.6C) |
| 1160+4Bh | 11ABh | upper rpm threshold (166, i.e. 6640rpm) |
| 1160+4Ch | 11ACh | load threshold (102) |
| 1160+4Dh | 11ADh | 3Fh reload for the load threshold timer (6, i.e. ~2s) |
| 1160+6Eh | 11CEh | alternate temperature threshold used when 25h.4 is set (140, ~50C) |