19з. rusEFI: an open engine control unit on STM32 — sensors, CAN and diagnostics
Sections 19г–19ж looked at the car from the outside: a scanner queries a unit, an adapter listens to the bus, a DBC file decodes other people's frames. Here is the view from the inside. rusEFI is open firmware for an engine control unit (ECU): it catches the crankshaft teeth, counts revolutions, reads sensors, decides how much fuel to inject and when to ignite the mixture, and itself answers OBD requests over CAN. It runs on STM32F4, F7 and H7.
This is a tour of real code, not a retelling. Which boards it runs on, how the 60-2 wheel decoder is built, how a thermistor turns into degrees, what goes out on CAN and how rusEFI answers the scanner from section 19г. Three pieces of the firmware — the decoder, OBD-II and the thermistor — were built on a PC without changes and run on a synthetic wheel.
Prerequisites: the rusEFI sources — github.com/rusefi/rusefi (commit
79ceeb32). License: GPL-3.0 with three caveats
in license.txt (the firmware is not for public roads, does not meet
emission standards, is not for manned aviation). The code here is retold,
quotes are a few lines each with the path to the file in the repository. The firmware as a whole
was not built here (without the ChibiOS, googletest and libfirmware submodules):
three of its parts were built on a computer — the decoder, OBD-II and the thermistor — with stubs
in place of the rest.
Terms used in the documentation
Term Meaning ECU (engine control unit) the engine control unit trigger wheel, crank / cam the toothed wheel on the crankshaft / camshaft 60-2, 36-1 a wheel with 60 (36) positions, 2 (1) teeth removed sync gap ratio the ratio of the durations of neighbouring teeth, the "gap" instant RPM revolutions over the last 90° VE (volumetric efficiency) table the cylinder filling table speed density air calculation from intake pressure and temperature ignition advance the spark advance angle CLT, IAT coolant temperature, intake air temperature MAP, TPS intake pressure, throttle position wideband O2, lambda the wideband oxygen sensor, λ rev limiter, limp mode the rev limiter, the emergency mode tuning software (TunerStudio) the program for tuning the unit
19з.1 What it is and which STM32 it runs on
The firmware code is in firmware/. The main things for us:
| Directory | What is there |
|---|---|
firmware/config/boards/ |
boards: pins, MCU type, their own settings |
firmware/hw_layer/ports/stm32/ |
the STM32 port: stm32f4, stm32f7, stm32h7, ADC, CAN, timers |
firmware/controllers/trigger/ |
the wheel decoder, revolutions |
firmware/controllers/sensors/ |
sensors and conversion of voltage into quantities |
firmware/controllers/algo/ |
fuel, ignition, default settings |
firmware/controllers/can/ |
CAN: dashboards, the λ module, OBD-II, ISO-TP |
firmware/console/binary/ |
communication with TunerStudio |
Each buildable board is described by a meta-info*.env file with the field
PROJECT_CPU. There are 47 such working descriptions (disabled ones with disabled
in the name are not counted): 26 on STM32F4, 17 on STM32F7, 4 on STM32H7.
There are development boards too: f407-discovery, nucleo_f767,
nucleo_h743. The F4/F7 families share a common mcuconf_common_f4_f7.h.
Underneath the firmware is ChibiOS: the submodule firmware/ChibiOS (branch
stable_21.11_…rusefi… in .gitmodules). Threads, CAN queues and peripheral
drivers come from there. The firmware keeps time in its own ticks: in
hw_layer/ports/stm32/port_mpu_util.h US_TO_NT_MULTIPLIER is set to 4,
that is, 4 ticks per microsecond. A comment there explains the choice: 4 MHz is
a common divisor of the TIM5 timer frequencies on the supported MCUs (84, 108 and 200 MHz).
19з.2 The engine in five minutes: the wheel, revolutions, fuel, ignition
To read the code, four notions are enough.
The wheel. On the crankshaft there is a toothed wheel, and a sensor (inductive or Hall) gives a pulse for every tooth. A 60-2 wheel is 60 positions of 6°, of which two in a row have no tooth. This gap is the mark: the first tooth after it is the reference point of the revolution. On 36-1 the step is 10°, and one tooth is removed.
Revolutions. While the wheel turns evenly, the time between teeth is the same.
rusEFI calculates the average revolutions once per engine cycle (720°, two crankshaft
revolutions) in firmware/controllers/engine_cycle/rpm_calculator.cpp:
60 * mult / periodSeconds, where mult = 720 / 360 = 2. The instantaneous ones — over
the last 90° in instant_rpm_calculator.cpp: the angle in degrees, the time
in 4 MHz ticks.
Fuel. By default the air in the cylinder is estimated from the intake pressure
(MAP), the air temperature and the filling table
(controllers/algo/airmass/speed_density_airmass.cpp: "ideal-gas law").
The fuel mass comes from the air mass and the required λ, the injector pulse
duration from getInjectionDuration() in controllers/algo/fuel/injector_model.cpp:
the time by the injector flow rate plus the dead time of opening.
Ignition. The advance angle comes from the ignitionTable table by revolutions
and load (getRunningAdvance() in controllers/algo/ignition/ignition_state.cpp),
then corrections by temperature.
19з.3 The wheel decoder: the state machine in trigger_decoder.cpp
The heart is TriggerDecoderBase::decodeTriggerEvent() in
firmware/controllers/trigger/trigger_decoder.cpp. On every edge it:
- Calculates the tooth duration — the difference from the previous edge, in ticks;
keeps the last durations in
toothDurations[]. - If the wheel has a mark, divides the current duration by the previous one — this is
triggerSyncGapRatio— and callsisSyncPoint(). - At the synchronisation point compares the number of teeth with the expected one. If it matches — "synchronised", index set to 0. If it does not match and synchronisation was already there — an error, the error counter +1, synchronisation reset.
- If there are more teeth than in a revolution and no mark — a reset too.
- If more than a second has passed since the previous edge — the engine was standing, there is no synchronisation.
isSyncPoint() checks several recent gaps at once, each in its own
window, and does it without division:
// firmware/controllers/trigger/trigger_decoder.cpp (rusEFI, GPL-3.0)
bool isGapCondition =
(toothDurations[i] > toothDurations[i + 1] * from
&& toothDurations[i] < toothDurations[i + 1] * to);
The windows are set by the wheel description. initializeSkippedToothTrigger() in
decoders/trigger_universal.cpp for a wheel with a gap sets the first gap
to skippedCount + 1 (on 60-2 — 3, on 36-1 — 2), the second to 1, and
TRIGGER_GAP_DEVIATION 0.25f from trigger_structure.h widens each by
±25 %. The 60-2 and 36-1 wheels are declared with SyncEdge::RiseOnly: only
rising edges are taken into account.
A run. The bench built trigger_decoder.cpp with g++ 12.2 in stm-sys:1 together
with the real trigger_decoder.h and two state files from
live_data_generated/. The wheel is filled in by the rules above, the edges are fed
with a step of 4 MHz ticks, the start is from the tenth tooth.
| Wheel | Events in the description | Rising edges | Gap windows |
|---|---|---|---|
| 60-2 | 116 | 58 | [2.25; 3.75] and [0.75; 1.25] |
| 36-1 | 70 | 35 | [1.5; 2.5] and [0.75; 1.25] |
| Wheel, rpm | First synchronisation | Gap | Revolutions per cycle | Instantaneous |
|---|---|---|---|---|
| 60-2, 150 | 49th tooth | 3.000 | 150.00 | 150.00 |
| 60-2, 800 | 49th tooth | 3.000 | 800.00 | 800.00 |
| 60-2, 3000 | 49th tooth | 3.001 | 3000.00 | 3000.00 |
| 60-2, 6500 | 49th tooth | 2.997…3.003 | 6500.01 | 6499.84 |
| 36-1, 3000 | 31st tooth | 1.999 | 3000.00 | 3000.00 |
The 49th tooth is the 48 teeth before the gap plus the first after it: before the mark the decoder does not know where it is. Over seven revolutions — 7 synchronisations and not a single error. The index after synchronisation grows by 2 (a rising edge and a skipped falling edge) and reaches 114. The deviation of the gap and of hundredths of a revolution at 6500 rpm is the rounding of the tooth time (154 µs) to a tick of 0.25 µs.
Failures, 60-2 at 3000 rpm:
- A tooth is lost (event 300): at the nearest mark (event 339) there are 57 teeth instead of 58 — an error, error counter 1, synchronisation reset. The next mark (397) — synchronised again.
- Noise in the middle of a tooth (event 300): an extra edge shifts the count, the index reaches the end of the revolution before the mark — at event 338 an error and a reset, and the mark at 339 synchronises again right away.
- Acceleration 1000 → 7000 rpm in 1 s: the gap went to 2.977…3.004, 70 synchronisations, 0 errors.
- The engine stopped — a pause of 1.5 s: the very first tooth after it comes without synchronisation.
The "more teeth than needed" error is counted only if the revolutions are no longer 0: the code says so — "let's not show a warning if we are just starting to spin". During cranking a failure only resets synchronisation.
19з.4 Sensors: the thermistor, voltage and failure
Temperature sensors are negative-coefficient thermistors
in a divider with a pull-up resistor. The conversion chain is built from two
functions (controllers/sensors/converters/):
ResistanceFunc(resistance_func.cpp) — voltage to resistance: below 0.05 V — a short (Low), above 98 % of the supply — an open circuit (High), otherwiseR = R_pull / (U_supply / U − 1).ThermistorFunc(thermistor_func.cpp) — the Steinhart–Hart equation1/T = A + B·ln R + C·(ln R)³.configure()calculates the coefficients A, B, C from three "temperature — resistance" points in the settings; below −50 °C and above 250 °C (CLT_UPPER_VALIDATION_LIMIT) — a failure.
The default coolant sensor
(controllers/algo/engine_configuration.cpp): 0, 23.8889 and 48.8889 °C
at 9500, 2100 and 1000 Ω, pull-up 1500 Ω. A run of the same two files
on a PC, divider supply 5 V:
| Input | Resistance | Temperature |
|---|---|---|
| — | 9500 Ω | 0.00 °C |
| — | 2100 Ω | 23.89 °C |
| — | 1000 Ω | 48.89 °C |
| — | 5000 Ω | 6.39 °C |
| 1.00 V | 375.0 Ω | 103.10 °C |
| 2.50 V | 1500.0 Ω | 33.90 °C |
| 4.50 V | 13500.0 Ω | −1.33 °C |
| 0.02 V | — | failure Low (short) |
| 4.95 V | — | failure High (open circuit) |
The coefficients came out as A = −1.592602·10⁻³, B = 8.205510·10⁻⁴,
C = −2.943859·10⁻⁶. The three reference points are reproduced exactly; everything
hotter than 48.9 °C is extrapolation: 300 Ω gives 120.34 °C, 150 Ω 192.28 °C.
The air sensor (setCommonNTCSensorParameters(): −20, 23.8889 and 120 °C at
18000, 2100 and 100 Ω) relies on wider points; its three points on the bench
gave −20.00, 23.89 and 120.00 °C.
MAP and the throttle are linear sensors (linear_func.cpp: two
"voltage — quantity" points). λ is taken over CAN from a separate module (19з.5).
A sensor failure is not a number but a code (UnexpectedCode); then the emergency
mode itself decides what to do with it (19з.7).
19з.5 CAN: dashboards, the λ module and TunerStudio
All work with CAN is in firmware/controllers/can/.
Transmission. can_tx.cpp goes through a loop every 5 ms and chooses what
it is time to send: intervals of 10, 20, 50, 100, 200, 250, 500 and 1000 ms. The data
for the instrument cluster is updateDash() in can_dash.cpp: the protocol is selected by
the canNbcType setting, besides "none" — 13 variants: BMW E46 and E90, VAG and
VAG MQB, Fiat, Mazda RX-8, Mercedes W202, Nissan VQ, Hyundai Genesis Coupe,
Honda K, Haltech, AiM dashboards and a simple broadcast in MegaSquirt format. rusEFI's own
detailed set of frames is described in rusEFI_CAN_verbose.dbc — it is
read with the tools from section 19е.
Reception. processCanRxMessage() in can_rx.cpp hands the frame out to
listeners: OBD, UDS, Lua scripts, the λ module. The rusEFI wideband λ module
sends on standard identifiers: for the first sensor 0x190 is
data, 0x191 diagnostics (AemXSeriesLambda.cpp, "0th sensor is 0x190
and 0x191"). λ arrives as an integer with a step of 0.0001. AEM X-Series sensors are
on extended identifiers starting from 0x180, with the same step of 0.0001.
UDS. uds.cpp is only 28 lines: on 0x7E0 the unit understands one service, 22,
with identifier F1A4 and answers on 0x7E8 with four letters rEFI; for
a wrong length — the refusal 7F 22 13. This is a business card for programs, not
the full UDS from section 19д.
TunerStudio. The tuning program talks to the unit over UART, USB (CDC)
or CAN. The protocol is single-byte commands (S — greeting, O —
current values, R — read, C — write a piece of the settings, B —
save to Flash) and CRC32 at the end of the packet (tunerstudio_io.cpp). Over CAN
the same stream goes through ISO-TP (section 19в): controllers/can/isotp/isotp.h
knows four frame types — single, first, consecutive, flow
control; the identifiers are CAN_ECU_SERIAL_RX_ID 0x710 and
CAN_ECU_SERIAL_TX_ID 0x720 (the f407-discovery board).
19з.6 OBD-II in rusEFI: comparison with section 19г
obd2.cpp is 227 lines: the unit listens to the common request 0x7DF and answers from 0x7E8,
in one 8-byte frame — length, 0x40 + service, PID, data. The bench built
the file without changes, substituted sensor values and ran requests through
obdOnCanPacketRx():
| Request | rusEFI answer | By the 19г formula | In the sensor |
|---|---|---|---|
| 01 0C | 04 41 0C 2E E0 | (256·0x2E + 0xE0)/4 = 3000 rpm | 3000 |
| 01 05 | 03 41 05 82 | 0x82 − 40 = 90 °C | 90 |
| 01 0F | 03 41 0F 39 | 0x39 − 40 = 17 °C | 17 |
| 01 0B | 03 41 0B 26 | 38 kPa | 38 |
| 01 0D | 03 41 0D 3C | 60 km/h | 60 |
| 01 11 | 03 41 11 2B | 0x2B·100/255 = 16.863 % | 16.863 |
| 01 10 | 04 41 10 18 1F | 0x181F/100 = 61.75 g/s | 61.75 |
| 01 42 | 04 41 42 39 4B | 0x394B/1000 = 14.667 V | 14.667 |
| 01 04 | 03 41 04 80 | 0x80·100/255 = 50.196 % | 50 |
| 01 0E | 03 41 0E 94 | 0x94/2 − 64 = 10° | 10° |
The formulas match section 19г. Rounding is efiRound() to an integer, then
clamping to 0…255 or 0…65535: 16 000 rpm went out as FA 00, 20 000 as
FF FF (16383.75), −50 °C as 00 (−40 °C).
Support maps. To 0100 the unit answered BD 3E 80 00 — 12 PIDs:
01 03 04 05 06 08 0B 0C 0D 0E 0F 11. To 0120 — 10 00 00 00 (only 0x24,
the λ ratio), to 0140 — 40 00 40 14 (0x42, 0x52, 0x5C, 0x5E). In total
the three maps hold 17 PIDs. Two oddities are visible at once:
- The least significant bit of the
0100map (PID 0x20 — "ask for the next map") is not set, nor is it in0120. A scanner that follows the chain of maps, as in 19г, will not go beyond0100and will not learn about the voltage and the oil. - The unit answers PID 0x10 (air flow), but it is not in the map.
To 012F and 01A6 — silence, as described in 19г. Services 03 and 07 are also
silent: handleDtcRequest() is empty, and the code says "TODO: this appears to be
unfinished?". rusEFI shows fault codes in TunerStudio, not to a scanner.
19з.7 Pitfalls
The firmware from a copy does not build. The ChibiOS, libfirmware,
googletest submodules and the λ module firmware are empty in a copy: .gitmodules is there,
the directories are not. You need git submodule update --init; without a network all that remains is
to build individual files with stubs, as here.
The wheel does not synchronise on a VR sensor. An inductive sensor gives both
edges, and the 60-2 wheel is declared as RiseOnly. If falling edges reach the
decoder, it will consider them "unimportant" and shift the index — the revolution will not
add up. The edges are filtered one level up (trigger_central.cpp); in the bench
only rising edges are fed.
Synchronisation is lost during cranking, but there are 0 errors. An extra tooth at zero revolutions is not counted as an error — it only resets synchronisation. You have to look at both the error counter and the number of synchronisation losses.
The thermistor lies when hot. The three default CLT points end at 48.9 °C; at 90 °C the equation is already extrapolating. The points should be taken from the datasheet of your own sensor over the whole working range.
The scanner sees only some of the PIDs. See 19з.6: without bit 0x20 in 0100
the chain of maps breaks. For your own unit the support map is built with the
next-map bit.
The limiter and the emergency mode. limp_manager.cpp decides whether
fuel may be injected and spark given. By default (default_base_engine.cpp)
the cut-off is at 7000 rpm, the return 50 rpm lower, and both fuel
and spark are cut. There are 22 reasons for a ban (ClearReason in limp_manager.h): the cut-off,
oil pressure, a stuck throttle (then the limit is 1500 rpm), a fatal
error (limit 0), no engine phase and others. The reason code is shown
in TunerStudio — start the search for "why it does not start" with it.
19з.8 Summary
- rusEFI is an ECU on STM32 under ChibiOS, GPL-3.0: 47 working board descriptions, of them 26 on F4, 17 on F7 and 4 on H7; time is in 4 MHz ticks.
- The decoder is a state machine with two states: the 60-2 wheel has 116 events, 58 rising edges, gap windows [2.25; 3.75] and [0.75; 1.25]. At 150, 800, 3000 and 6500 rpm synchronisation at the 49th tooth from the start, 0 errors; revolutions per cycle 6500.01, instantaneous 6499.84.
- A lost tooth gives 1 error and one revolution without synchronisation; noise gives a reset and synchronisation at the next mark; acceleration 1000 → 7000 rpm in 1 s — 0 errors; a pause longer than 1 s — no synchronisation.
- The thermistor is Steinhart–Hart over three points; the default CLT: 9500, 2100, 1000 Ω → 0.00, 23.89, 48.89 °C; below 0.05 V — a short, above 98 % of the supply — an open circuit.
- CAN: 13 dashboard protocols, λ on 0x190/0x191 with a step of 0.0001, TunerStudio over ISO-TP on 0x710/0x720, UDS — one service 22 F1A4.
- OBD-II: the formulas match 19г (3000 rpm →
2E E0); the three maps hold 17 PIDs, there is no next-map bit, services 03 and 07 are silent. - The default cut-off is 7000 rpm, 22 reasons for banning fuel and spark.
Next: section 19и "J1939: the CAN of trucks and special machinery — PGN, SPN and DM1 diagnostics" — how the same CAN bus works on trucks and special machinery: 29-bit addresses, PGNs and DM1 codes.