Embedded HandbookApps
All sections › III. Peripherals
STM32F407, STM32F767, STM32H743 · level advanced · 15 min read
Open in the interactive handbook

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.

Top left — a 60-2 wheel unrolled into a line: teeth follow with a step of 6 degrees, a gap in place of two positions, the first tooth after the gap marked as the synchronisation point; next to it an explanation that the gap is the ratio of the current tooth's duration to the previous one, for an ordinary tooth it is 1, for the first after the gap 3, the rusEFI window for 60-2 is from 2.25 to 3.75 and the second window from 0.75 to 1.25, for 36-1 from 1.5 to 2.5. Below — the decoder state machine with two states, no synchronisation and synchronised, the transition on a gap within the window and back on a wrong tooth count, an extra tooth or a pause longer than a second. Further below — the chain of the unit on STM32 under ChibiOS: the CLT, IAT, MAP, TPS and lambda sensors, revolutions and phase from the teeth, the fuel and advance tables, the injectors and coils with the 7000 rpm limiter and the emergency mode. On the right — the CAN bus: rusEFI sends dashboard data in 13 protocols, receives lambda from the module on 0x190 and 0x191, answers the scanner on 0x7DF with the frame 0x7E8 04 41 0C 2E E0, that is 3000 rpm, and talks to TunerStudio over ISO-TP on 0x710 and 0x720


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:

  1. Calculates the tooth duration — the difference from the previous edge, in ticks; keeps the last durations in toothDurations[].
  2. If the wheel has a mark, divides the current duration by the previous one — this is triggerSyncGapRatio — and calls isSyncPoint().
  3. 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.
  4. If there are more teeth than in a revolution and no mark — a reset too.
  5. 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:

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/):

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:

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


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.

Open in the interactive handbook