Reverse Engineering lead acid charger firmware
Work-in-progress notes.
Debug Header
Beware: the control board has a 6-bin debug header J3 but this is not the standard ISP pinout. From the top side, it looks like:
+-----+
MISO |1 | 2| SCK
+--/ |
MOSI |3 4| nRESET
| |
VCC |5 6| GND
+-----+
The control board is fine with VCC as 5V. This is what it gets from the main board. With it removed from the main charger, you can feasibly power it up from your ISP adapter for pulling firmware out.
Fuses
The fuses read out along with the entire program flash and EEPROM. There seems to be no attempt to lock the firmware down from readout, which is convenient.
fuses_lo = 0x24
fuses_hi = 0x91
lock_byte = 0xff
These translate as:
RSTDISBL 1: PC6 is still nRST
WDTON 0: WDT always on
SPIEN 0: SPI programming enabled
EESAVE 0: EEPROM retainted during chip erase
BOOTSZ 00: Boot flash size 1024 bytes
BOOTRST 1: Interrupt vector lives in application
BODLEVEL 0
BODEN 0
Brown-out detection enabled at 4 V
CKOPT 1
CKSEL 0100 (4) 8 MHz
SUT1 10 (2)
8 MHz internal RC clock.
Start-up time 6 clocks plus 65 ms
Microcontroller pinout
FIXME get markdown tables rendering to HTML :)
|Pin| Special | Port | Connection |
|--:| ------- | ---- | ------------------------- |
| 1 | INT0 | PD3 | Push button |
| 2 | XCK | PD4 | LED10: Analysis |
| 3 | GND | | GND |
| 4 | VCC | | +5V |
| 5 | GND | | GND |
| 6 | VCC | | +5V |
| 7 | XTAL1 | PB6 | Main board high-side FET |
| 8 | XTAL2 | PB7 | LED 11: Boost |
| 9 | T1 | PD5 | LED 3: Gel |
|10 | AIN0 | PD6 | LED 4: Pb Acid |
|11 | AIN1 | PD7 | LED 5: Calcium |
|12 | ICP1 | PB0 | LED 12: Maintain |
|13 | OC1A | PB1 | Conn pin 7 via RC filters |
|14 | SS | PB2 | LED 2: Fault |
|15 | MOSI | PB3 | J3 pin 3 |
|16 | MISO | PB4 | J3 pin 1 |
|17 | SCK | PB5 | J3 pin 2 |
|18 | AVCC | | +5V |
|19 | ADC6 | | LM324 out3 via 10k |
|20 | AREF | | |
|21 | GND | | GND |
|22 | ADC7 | | Thermistor |
|23 | ADC0 | PC0 | 10 to 15% battery voltage |
|24 | ADC1 | PC1 | Q -> 3k OC -> conn pin 6 |
|25 | ADC2 | PC2 | Q -> 6k8 OC -> conn pin 6 |
|26 | ADC3 | PC3 | LED 9: Absorb |
|27 | ADC4 | PC4 | LED 6: Desulfation |
|28 | ADC5 | PC5 | LED 7: Soft start |
|29 | nRESET | PC6 | J3 pin 4 |
|30 | RXD | PD0 | LED 8: Bulk |
|31 | TXD | PD1 | Buzzer NPN |
|32 | INT0 | PD2 | Reverse voltage sensing |
Main connector pinout
This connects the control board to the charger board. Pin 1 is nearest the buzzer on the control board. I/O marked from the perspective of the charger board.
|Pin| Dir. | Description |
|==:| ---- | ------------------------- |
| 1 | Out | out3 from mainboard LM324 |
| 2 | | 5 VDC / VCC |
| 3 | | 0 V / GND |
| 4 | In | Charge enable |
| 5 | Out | Battery positive |
| 6 | In | Some stepped ref. voltage |
| 7 | In | Some smooth ref. voltage |
Globals
These are stored in data space and potentially modified during program execution.
- 0x90: Chemistry prime. Used in single ISR. 1: Pb Acid; 2: Gel; 3: Calcium
- 0x91: Current chemistry: 0: Gel; 1: Pb Acid; 2: Calcium
- ...
- 0xab: Looks unused (initialised and never referenced).
- 0xac: Looks unused (initialised and never referenced).
- ...
- 0xb0: Unsure as yet. Presume paired with 0xb1. Only used in PWM routine
- 0xb1: Unsure as yet. Presume paired with 0xb0. Only used in PWM routine
- ...
- 0xb4: Unsure as yet. Presume paired with 0xb5.
- 0xb5: Unsure as yet. Presume paired with 0xb4. b4:b5 set differently for each chemistry.
- 0xb6: Unsure as yet. Presume paired with 0xb7
- 0xb7: Unsure as yet. Presume paired with 0xb6. b6:b7 set differently for gel.
- 0xb8: ADC LSB
- 0xb9: ADC MSB
- ...
- 0xc2: Unsure as yet. Presume paired with c3 to form a counter/timer?
- 0xc3: Unsure as yet. Presume paired with c2 to form a counter/timer?
- 0xc8: Unsure as yet. Presume paired with 0xc9.
- 0xc9: Unsure as yet. Presume paired with 0xc8. c8:c9 set differently for each chemistry.
- ...
- 0xcb: Looks unused. Initialised to 0x01.
- 0xcc: Looks unused. Initialised to 0x60.
- 0xcd: Initialised to 0x01. XREF in
_start
, 0x504 - 0xce: Initialised to 0x29. XREF in
_start
, 0x504 - 0xcf: Initialised to 0x01. XREF in
_start
, 0x504 - 0xd0: Initialised to 0x10. XREF in
_start
, 0x504 - 0xd1: Initialised to 0x00. XREF in
_start
, 0x504 - 0xd2: Initialised to 0xf0. XREF in
_start
, 0x504 - 0xd3: XREF in
_start
, 0x504 - 0xd4: Looks unused. Only ever set during absorb stage to 0x3c.
- 0xd5: Looks unused. Only ever set during absorb stage to 0x05.
Interrupts
The only interrupts in the vector are:
- INT0, low level trigger. Fires while battery is reverse connected.
- INT1, falling edge trigger. Fires when user presses the button to change battery chemistry.
Let's elaborate on these in pseudocode.
IRQ0 Handler (low level, battery connected in reverse)
This is a low-level-triggered interrupt. The core of the ISR is to buzz the buzzer and flash the LED for the user's selected chemistry. Somewhat interestingly the fault LED isn't lit in this routine. Presumably this LED was left for indicating a faulty battery rather than a faulty connection.
Also interesting is the cheap debouncing performed throughout the routing, really making sure the battery is reverse connected. It makes the code quite hard to express cleanly in C-like pseudo code.
ISR(INT0_vect) {
if (battery reverse connected) {
delay_ms(5);
if (battery reverse connected) {
disconnect_charger();
led_off_desulfation;
led_off_soft_start;
led_off_bulk;
led_off_absorb;
led_off_analysis;
led_off_boost;
led_off_maintain;
do {
do {
led_on_current_chemistry()
buzzer_on
delay_ms(500)
led_off_all_chemistry
buzzer_off
delay_ms(500)
} while battery reverse connected
delay_ms(10)
} while battery reverse connected
led_light_current_chemistry()
}
}
}
IRQ1 Handler (falling edge, button press)
Edge triggered, this ISR is called when the user presses the single button on the front of the charger to change the chemistry of the battery. Each press advances the state machine one state, with wrap-around at the end:
- lead acid ⇒ calcium
- calcium ⇒ gel
- gel ⇒ lead acid
Again there's some cheap debouncing done by sleeping around 10 milliseconds and re-checking the level of the button input to make sure it's really pressed.
What's really interesting/odd about this ISR is that it introduces a completely new global variable which stores the current chemistry in a completely new variable at 0x90 (which I've named chemistry_prime
), with completely different meaning to the one at 0x91. This seems odd, since the rest of the program uses 0x91 happily.
I can't think of a reason to have a completely separate variable here, not especially with the meaning it has. Being 1-based, the variable gets 1 subtracted from it everywhere it's used, including before the switch-case jump table. The more I read this code the more it looks like plain old unstructured spaghetti produced by a human rather than some esoteric compiler.
ISR(INT1_vect) {
delay_ms(10);
if (button still pressed) {
switch (chemistry_prime - 1) {
case 0:
// was lead acid, new chemistry calcium
chemistry_prime = 3;
chemistry = 2;
led_off_gel;
led_off_lead_acid;
led_on_calcium;
some_globalb4, some_globalb5 = 0x03, 0xb4;
some_globalb6, some_globalb7 = 0x03, 0x47;
some_globalc8, some_globalc9 = 0x03, 0xd1;
some_globalc2, some_globalc3 = 0x00, 0x00;
break
case 1:
// was gel, new chemistry lead acid
chemistry_prime = 1;
chemistry = 1;
led_off_gel;
led_on_lead_acid;
led_off_calcium;
some_globalb4, some_globalb5 = 0x03, 0x86;
some_globalb6, some_globalb7 = 0x03, 0x47;
some_globalc8, some_globalc9 = 0x03, 0x9d;
some_globalc2, some_globalc3 = 0x00, 0x00;
break;
case 2:
// was calcium, new chemistry gel
chemistry_prime = 2;
chemistry = 0;
led_on_gel;
led_off_lead_acid;
led_off_calcium;
some_globalb4, some_globalb5 = 0x03, 0x67;
some_globalb6, some_globalb7 = 0x03, 0x29;
some_globalc8, some_globalc9 = 0x03, 0x7f;
some_globalc2, some_globalc3 = 0x00, 0x00;
break;
}
delay_ms(300);
watchdog_reset;
interrupt_enable;
}
_start
Or at least that's what I'll call it.
This routine appears written in assembler and is fairly flat looking.
It sets up various peripherals and global settings such as stack pointer, ports, interrupts, core power state, timers.
SP := 0x45f ; top of SRAM
OSCCAL := program[0x1c00] ; 154 decimal. This should put RC osc. at ~6 MHz
MCUCR := 0x08 ; Awake, idle, INT1 on falling edge, INT0 on low level
DDRB := 0x17 ; 0001 0111: PB4, PB2, PB1, PB0 output
; extra hting in here ;
DDRC := 0xfe ; 1111 1110: PC7, PC6, PC5, PC4, PC3, PC2, PC1 output
DDRD := 0xf3 ; 1111 0011: PD7, PD6, PD5, PD4, PD1, PD0 output
data[0x90] := 1
data[0x91] := 1
data[0xcb] := 1
data[0xcc] := 0x60 ; 96
data[0xcd] := 1
data[0xce] := 0x29 ; 41
data[0xcf] := 1
data[0xd0] := 0x10 ; 16
data[0xd1] := 0
data[0xd2] := 0xf0 ; 240
TCNT1 := 0x0
It then runs through a quick LED strobe which takes about 1 second. It lights the battery chemistry and fault LEDs for half a second, then it switches to lighting only the charge state LEDs. Doesn't seem to be a reason for this other than perhaps a sanity test a user can make that all LEDs are working. Perhaps it also gives the charger board it's controlling some time to become stable.
PORTC4 := 1 ; off: Desulfation
PORTC5 := 1 ; off: Soft start
PORTD0 := 1 ; off: Bulk
PORTC3 := 1 ; off: Absorb
PORTD4 := 1 ; off: Analysis
PORTB7 := 1 ; off: Boost
PORTB0 := 1 ; off: Maintain
PORTD5 := 0 ; on: Gel
PORTD6 := 0 ; on: Pb acid
PORTD7 := 0 ; on: Calcium
PORTB2 := 0 ; on: Fault
delay_ticks(0xfa) ; about 500 ms
PORTC4 := 0 ; on: Desulfation
PORTC5 := 0 ; on: Soft start
PORTD0 := 0 ; on: Bulk
PORTC3 := 0 ; on: Absorb
PORTD4 := 0 ; on: Analysis
PORTB7 := 0 ; on: Boost
PORTB0 := 0 ; on: Maintain
PORTD5 := 1 ; off: Gel
PORTD6 := 1 ; off: Pb acid
PORTD7 := 1 ; off: Calcium
PORTB2 := 1 ; off: Fault
delay_ticks(0xfa) ; about 500 ms
More memory initialisation stuff happens after this
data[0xac] := 0
data[0xab] := 0
data[0xb4] := 3 ; FIXME these look like words
data[0xb5] := 0x86 ; Preload 0xb4–0xb7 with Pb Acid values
data[0xb6] := 3
data[0xb7] := 0x47
data[0xc8] := 3 ; Preload 0xc8–0xc9 with Pb Acid values
data[0xc9] := 0x9d
data[0x91] := 1 ; Preload chemistry with Pb Acid
data[0xb0] := 1
data[0xb1] := 0x91
Followed by timer 1 setup for PWM.
OCR1A := 0x0191 ; out compare A for timer1 is 0x191
ICR1 := 0x07d0 ; preloading input compare why?
TCCR1B := 0x19 ; WGM13:WGM12 is 11
; No prescaler.
TCCR1A := 0x82 ; WGM11:WGM10 is 10 => fast PWM
; Top ICR1, TOV1 set at top, OCR1x updated at bottom
; OC1A cleared on compare match, OC1B disconnected
GICR := 0xc1 ; Begin IVSEL set
GICR := 0xc1 ; Enable INT1, INT0
; Also invalidates any IVSEL set, since IVCE still 1. FIXME check cycles with nops below
nop
nop
LEDs are changed again
PORTB2 := 1 ; off: Fault
PORTD6 := 0 ; on: Pb Acid
Interrupts enabled and watchdog patted:
sei
wdr
uint16_t adc_read(uint8_t)
This function appears to read the ADC based on a ADMUX value provided as a direct argument. It leaves the low and high bytes of the read value stored to adc_val_hi:adc_val_lo
, as well as r27:r26
.
It's kind of interesting they chose to insert a fixed delay rather than polling on the ADSC bit in the ADCSRA register. Given the clock division involved (only /8), the conversion will happen well within the fixed delay in the code, but it's still a bit sloppy. In addition the ADSC bit in ADCSRA is cleared after the conversion value is read which is an interesting quirk adding to the evidence that this code was produced by hand-crafted assembly. The datasheet says that this has no effect.
uint16_t adc_read(uint8_t admux) {
ADMUX = admux;
ADCSRA = 0xc3; // Enable ADC, clock /8 start conversion
delay_ms(5);
adc_val_lo = ADCL;
adc_val_hi = ADCH;
adc_disable;
adc_int_disable;
adc_clear_conversion_start;
return ADCH:ADCL;
}
void delay_ticks(uint8_t)
Not a very interesting function. Weirdest thing is some of the assembler that is essentially a no-op (maybe hangover from dev/debug?), e.g.:
in r16, TIMSK
ori r16, 0
out TIMSK, r16
The datasheet doesn't say anything is triggered when writing this register in this way.
#define delay_ms(x) delay_ticks(x/2)
void delay_ticks(uint8_t tick_count) {
TCCR1 = 3; // prescale /64 => overflow every 2–3 ms
TCNT0 = 5; // preload non-zero to account for fixed overhead
do {
do {
watchdog_reset
} until timer overflow
tick_count = tick_count - 1
} while tick_count != 0
}