198с. Mini-project: a CAN dashboard — RPM, speed and temperature from the car's bus
The scanner from 198р asks the engine control unit and waits for a reply. But RPM, speed and temperature are on the car's bus anyway: the units broadcast them on their own, cyclically, dozens of times a second, and the instrument cluster simply listens to them. Here we build a second cluster: an STM32F407 silently listens to the bus, decodes the frames using a real DBC file (19е), draws an RPM needle and temperature and fuel bars on an ILI9341 colour display, and lights the warnings: overheating, the shift light, low fuel. If there are no known frames on the bus, the device switches to OBD-II polling by itself, like the 198р scanner. The third source is a race car on a rusEFI ECU (19з): it has its own broadcast.
There was no car at hand. The core (dash_core.c) was built with gcc on a computer
and run on three synthetic, but DBC-correct, frame streams:
VW MQB for 60 s with CRC errors, lost frames and a missing message,
rusEFI for 20 s, and a car that stays silent, where the device falls back to OBD polling.
The firmware for the STM32F407 was built with arm-none-eabi-gcc; it has not been run
on a board or in a car.
Sources: RM0090 (bxCAN chapter: Silent mode, 32.5.1; the CAN_BTR fields, table
184; DMA2, the request mapping table; CCM) from the Keil STM32F4xx_DFP pack
2.17.1, and RM0440 (FDCAN: Bus monitoring mode, the CCCR.MON bit) from
STM32G4xx_DFP 1.6.0. Signals: opendbc/dbc/vw_mqb.dbc; the CRC algorithm and its
constants: opendbc/car/volkswagen/mqbcan.py; real frames: the opendbc
tests; the rusEFI broadcast: can_verbose.cpp and rusEFI_CAN_verbose.dbc
(commit 79ceeb3); ILI9341 commands: the qp_ili9341.c driver from QMK; OBD
formulas: python-obd. Licences: opendbc is MIT (Comma.ai), and the
16-byte CRC constant of the ESP_21 message is taken from it; rusEFI is GPL-3.0, QMK is
GPL-2.0-or-later, python-obd is GPL-2.0: their code is not copied, only
formats and numbers were checked against them. DBC parsing is in 19е, OBD-II in 19г and 198р, rusEFI in 19з,
the transceiver and terminators in 19.2, input protection in 173.
Terms used in the documentation
Term Meaning Silent mode (bxCAN), Bus monitoring mode (FDCAN), listen-only a "listen only" mode: receives, but drives only recessive bits onto the bus acknowledge (ACK) slot the acknowledge field: any receiver writes a dominant bit into it broadcast / cyclic message a frame that a unit sends on its own, cyclically identifier list mode, 16-bit scale a 16-bit list filter: 4 exact identifiers per bank rolling counter, CRC a frame counter and a checksum inside the frame itself (19е.4) instrument cluster (Kombi) the dashboard instrument panel shift light a "time to change gear" lamp exponential moving average (EMA) exponential smoothing dirty rectangle a rectangle that needs to be redrawn CASET, PASET, RAMWR ILI9341 commands: column window, row window, memory write
198с.1 What the device does and what it is made of
The device asks nothing as long as it can avoid it. Frames arrive through the transceiver into bxCAN, the hardware filter passes the seven needed identifiers, the core decodes them by the DBC, checks the CRC and the counter, smooths the values and 25 times a second hands the display a list of rectangles to redraw. The display receives them over SPI via DMA in 8 KB chunks.
The parts list is approximate; prices are not given:
| What | Which | Why |
|---|---|---|
| board | STM32F407 (or STM32G474 with FDCAN, 198с.2) | bxCAN, SPI with DMA, 128 KB of DMA-accessible RAM, FPU for the needle geometry |
| transceiver | an SN65HVD230 module | 3.3 V supply, same as the microcontroller (19.2) |
| display | a 2.4…2.8″ ILI9341 module, 320 × 240, SPI | 16 bits per pixel |
| protection and power | fuse, diode, TVS clamp, switching regulator | as in 198р.2 and 173.4 |
| connection | an OBD-II plug or a tap on the CAN wires behind the cluster | 198с.2 |
STM32F407 pins: CAN1 on PB8 (RX) and PB9 (TX), function AF9; SPI1 on PA5 (SCK) and PA7 (MOSI), AF5; display chip select on PA4, data/command on PA3, reset on PA2. Clock: an 8 MHz crystal, 168 MHz core, APB1 42 MHz (bxCAN), APB2 84 MHz (SPI1).
198с.2 Where to connect and why only listen
Where to get the bus. The easiest place is the OBD-II connector: pins 6 and 14 (CAN-H and
CAN-L), 16 is battery plus, 4 and 5 are ground (pinout from 198р.2). But on
many cars the connector is separated by a gateway (common knowledge; not checked on a car), and the broadcast frames of the powertrain
bus do not reach it: only replies to diagnostic requests are visible. For
VW this can be seen in the DBC too: for ESP_21 in vw_mqb.dbc the sender is Gateway_MQB, that
is, in the described network the frame is sent by the gateway, not by the ESP unit itself. In that case you connect to
the CAN wires behind the instrument cluster: everything the cluster needs is guaranteed to be there.
Which frames reach the connector of a particular car cannot be checked
without the car, and that is why the device has a fallback path through OBD (198с.4).
No terminator. Like the 198р scanner, the dashboard is a stub on a bus that already has two 120 Ω terminators at its ends (19.2).
Why only listen. The car's bus belongs to someone else. Any node in normal mode drives the bus even if it transmits nothing itself: it acknowledges every frame with a dominant bit in the ACK field and sends an error frame if it does not like something. A device with the wrong bus speed in normal mode will destroy other nodes' frames with its own error frames.
In Silent mode (RM0090, 32.5.1) bxCAN receives valid frames, but
drives only recessive bits onto the bus and cannot start a transmission itself.
If it needs to send a dominant bit (an acknowledge, an overload flag,
an active error flag), the bit is looped back internally into its own receiver, and
the bus stays recessive. The mode is enabled by the SILM bit (bit 31 of CAN_BTR); it
can be changed only in initialization mode. In the core the same idea is carried
above the hardware: while the device is listening, dash_poll() simply has no path to
transmission, and in the VW and rusEFI runs zero frames were transmitted.
FDCAN on the STM32G474 has the same idea: Bus monitoring mode, the MON bit (bit 5)
of the FDCAN_CCCR register. According to RM0440 it can be set only when
both the CCE and INIT bits are set; the behaviour is the same: it receives valid frames,
cannot start a transmission, and loops dominant bits back internally. The firmware
of this section is for the F407; FDCAN is covered in 19а.
198с.3 bxCAN: 500 kbit/s from 42 MHz, SILM and a list filter
Bit rate. The powertrain bus runs at 500 kbit/s; this is the same assumption as in
198р, and it has to be checked on a particular car. From APB1 at 42 MHz exactly
500 kbit/s is given by 33 combinations of the fields, and 5 of them have a sample point from 80 to
90 % (search by the dash_model.py model):
| Prescaler | Quanta per bit | TS1 + TS2 | Sample point |
|---|---|---|---|
| 4 | 21 | 16 + 4 | 81.0 % |
| 6 | 14 | 11 + 2 | 85.7 % |
| 7 | 12 | 9 + 2 | 83.3 % |
| 12 | 7 | 5 + 1 | 85.7 % |
| 14 | 6 | 4 + 1 | 83.3 % |
The second one is used: a quantum of 142.9 ns, SJW of 2 quanta. The register value is 0x011A0005, and with
the SILM bit it is 0x811A0005. The mode is switched through initialization:
static void can_mode(int active) /* 0 — SILM (только слушать), 1 — обычный */
{
CAN1->MCR |= CAN_MCR_INRQ;
while (!(CAN1->MSR & CAN_MSR_INAK)) {
}
CAN1->BTR = CAN_BTR_VAL | (active ? 0u : CAN_BTR_SILM);
CAN1->MCR &= ~CAN_MCR_INRQ;
while (CAN1->MSR & CAN_MSR_INAK) {
}
}
Filter. We need exact identifiers, not a range as in the scanner (198р.3): four VW frames, two rusEFI frames and the OBD reply 0x7E8, seven in total. In 16-bit list mode one bank holds four identifiers: STID in bits 15…5, RTR in bit 4, IDE in bit 3. Two banks give eight slots; the last one is taken by the same 0x7E8:
fr[0] = F16(VW_ESP_21) << 16 | F16(VW_MOTOR_12); /* банк 0, FR1 */
fr[1] = F16(VW_KOMBI_03) << 16 | F16(VW_MOTOR_07); /* банк 0, FR2 */
fr[2] = F16(RE_BASE3) << 16 | F16(RE_BASE1); /* банк 1, FR1 */
fr[3] = F16(OBD_RESP_ID) << 16 | F16(OBD_RESP_ID); /* банк 1, FR2: ответ OBD дважды */
The result is 1FA01500, D700C800, 40604020, FD00FD00. The test ran all 2048 standard identifiers
through a model of this filter: exactly 7 passed:
0x0A8, 0x0FD, 0x201, 0x203, 0x640, 0x6B8, 0x7E8; not a single remote frame or
29-bit frame. In the VW model, out of 57849 frames
9849 reached the core: the other 48000 (sixteen foreign messages every 20 ms)
were dropped by the hardware, and the interrupt does not wake up for them.
198с.4 Signals: VW MQB by the DBC, the rusEFI broadcast and the OBD fallback
VW MQB. Four messages from vw_mqb.dbc, all Intel (@1, 19е.2):
| Frame | Message | Signal | Bits | Scale | Service fields |
|---|---|---|---|---|---|
| 0x0A8 | Motor_12 | MO_Drehzahl_01 |
48|16 | × 0.25 rpm | CRC (byte 0), counter Motor_12_BZ 8|4 |
| 0x0FD | ESP_21 | ESP_v_Signal |
32|16 | × 0.01 km/h | CHECKSUM 0|8, COUNTER 8|4 |
| 0x640 | Motor_07 | MO_Kuehlmittel_Temp |
24|8 | × 0.75 − 48 °C | — |
| 0x6B8 | Kombi_03 | KBI_Tankfuellstand_Prozent |
18|7 | × 1 % | 126 "Init", 127 "Fehler" |
The decoding is written by hand, with shifts and masks like the 19е.5 generator, and the CRC is checked before the value reaches the display:
case VW_MOTOR_12:
case VW_ESP_21: {
int k = f->id == VW_ESP_21;
if (vw_mqb_crc(f->id, d) != d[0]) {
dash.crc_err++;
return;
}
if (!counter_ok(k, d[1] & 0x0Fu))
return;
lock_src(SRC_VW);
if (k)
set_val(SIG_SPEED, (int32_t)(d[4] | d[5] << 8), now); /* 32|16, ×0,01 км/ч */
else
set_val(SIG_RPM, (int32_t)(d[6] | d[7] << 8) * 25, now); /* 48|16, ×0,25 об/мин */
break;
}
The CRC is the same CRC-8 with polynomial 0x2F and a "secret" byte selected by the counter as in 19е.4. The opendbc constants table has 37 messages; ESP_21 is there, but Motor_12 (0xA8) is not. For it the opendbc function computes the CRC over bytes 1…7 only, and the same is done here; whether this matches a real engine control unit has not been checked. On 16 real ESP_21 frames from the opendbc tests the core accepted all 16 (counters 0…15 in a row, 0 losses) and showed speeds from 0.00 to 16.16 km/h; the first frame with one bit flipped gave CRC 0xCB instead of 0x66 and was dropped.
The counter catches two problems: a gap (a difference greater than 1) and a "stuck" transmitter
(the same counter once more: the frame is dropped). A value of 126 or 127 in KBI_Tankfuellstand_Prozent is not
a percentage but "not ready yet" and "error" (VAL_ 1720 in the DBC): such frames
are dropped.
rusEFI. Its own broadcast is enabled by the enableVerboseCanTx flag ("CAN
broadcast using custom rusEFI protocol"): sendCanVerbose() in
can_verbose.cpp sends 12 frames in a row, from the base to the base + 11. The default
base is CAN_DEFAULT_BASE 0x200, the period is canSleepPeriodMs, by
default 50 ms. A frame is a C structure copied as is (least significant byte
first), and rusEFI_CAN_verbose.dbc describes it the same way:
| Frame | Structure | Signal | Bits | Scale |
|---|---|---|---|---|
| 0x201 | Speeds |
RPM |
0|16 | × 1 rpm |
| 0x201 | Speeds |
VehicleSpeed |
48|8 | × 1 km/h |
| 0x203 | Sensors1 |
CoolantTemp |
16|8 | × 1 − 40 °C |
| 0x203 | Sensors1 |
FuelLevel |
56|8 | × 0.5 % |
There is neither a counter nor a CRC here: all you can do is watch whether a frame has gone missing.
OBD fallback. If no known frame has arrived within 1 s, the core
raises the active flag, the firmware switches bxCAN from SILM to normal mode, and
from then on it polls, as in 198р.5: a 25 ms slot, a table of eight slots, RPM
in every other slot, a 150 ms reply timeout. PIDs 0C, 0D, 05 use the formulas from 198р.4;
the fuel level is PID 2F, A · 100 / 255 % (python-obd, FUEL_LEVEL,
the percent decoder). Unit discovery, PID maps and multi-frame replies are not
repeated here; they are in 198р. The dashboard asks at the functional address 0x7DF and listens
only to 0x7E8. This is the only case where the device drives the bus, and
it makes sense to enable it only where the car is otherwise silent.
198с.5 Display: needle, smoothing, warnings and SPI via DMA
What the full screen costs. 320 × 240 pixels at 16 bits is 153600 bytes. SPI1 runs from APB2 at 84 MHz with a prescaler of 4, which gives 21 MHz. The full screen takes 58.51 ms, that is, no more than 17.1 frames per second; at 42 MHz it takes 29.26 ms (this section did not check the allowed write clock against the ILI9341 datasheet). There is nowhere to keep a whole frame: the RAM that DMA can see is 128 KB (131072 bytes in the linker script), and DMA cannot see the 64 KB of CCM (RM0090: "accessible only by the CPU"). Hence the design of the output:
- Redraw only what has changed. On every tick (40 ms) the core compares what is drawn with the new state: the needle tip, the speed number, the bar lengths, the warning flags, and hands over a list of rectangles. For the needle this is a box around the pivot, the old tip and the new tip.
- Draw in chunks.
dash_render()recomputes the colour of every pixel of the rectangle, 4096 pixels (8192 bytes) at a time. The DMANDTRcounter goes up to 65535, so a chunk fits with room to spare. - Two buffers. While DMA2 (stream 3, channel 3, which is SPI1_TX according to the RM0090 table) sends one chunk, the processor draws the next one:
for (int y = 0; y < r[i].h; y += rows) {
int h = y + rows > r[i].h ? r[i].h - y : rows;
dash_render(&r[i], y, h, lcd_buf[k]); /* рисуем, пока DMA шлёт прошлый кусок */
dma_wait();
lcd_dma(lcd_buf[k], (uint32_t)(h * r[i].w * 2));
k ^= 1;
}
Each rectangle is preceded by a window: 2A (columns, 4 bytes), 2B
(rows, 4 bytes), 2C (write), 11 bytes of commands in total. The commands, 16 bits per
pixel (3A with 0x05) and the 90° rotation (36 with 0x68: BGR, MX, MV) are taken from
the QMK driver qp_ili9341.c.
Smoothing. The raw value jumps: rounding, sensor jitter, fuel sloshing in the tank. The needle uses an exponential average: on every tick the display covers a fraction α of the remaining distance, and the last step goes straight to the target:
int32_t dash_ema(int32_t shown, int32_t target, unsigned shift)
{
int32_t step = (target - shown) / (int32_t)(1u << shift);
return step ? shown + step : target; /* последний шаг — сразу в цель */
}
| Signal | α | Ticks to 90 % of a step | Time | Time constant |
|---|---|---|---|---|
| RPM | 1/2 | 4 | 160 ms | 57.7 ms |
| speed, temperature | 1/4 | 9 | 360 ms | 139.0 ms |
| fuel | 1/16 | 36 | 1440 ms | 619.8 ms |
The number of ticks was computed both by the core and by the Python model using the formula ⌈ln 0.1 / ln (1 − α)⌉, and they matched.
Warnings use the raw value. The shift light, overheating and fuel look at the last received value, not the smoothed one, and switch on at the moment a frame is received, not on a display tick. Each threshold has a gap between switching on and off, so that the lamp does not flicker at the boundary:
| Warning | On | Off |
|---|---|---|
| shift light | from 6000 rpm | below 5800 rpm |
| overheating | from 105 °C | below 100 °C |
| low fuel | below 10 % | from 12 % |
A signal that has not been updated for 500 ms is considered lost: a dash is shown instead of the number, and its warnings go off.
198с.6 Testing on a computer: three cars
The sim_dash.c test builds frames by the DBC (scale, counter, CRC) and
passes them through the filter model and the core; the display is "drawn" for real,
in chunks into a buffer, and the SPI bytes are counted. The VW frame periods in the model are
ours: the VW DBC does not have them (19е.1). Car A: Motor_12 every 10 ms, ESP_21
every 20 ms, Motor_07 every 100 ms, Kombi_03 every 200 ms, plus 16 foreign messages.
Car A, VW MQB, 60 s. Engine start at 2 s, acceleration to 6400 rpm by 15 s, a gear change (a drop to 3500 rpm), acceleration to 120 km/h, cruise, overheating to 110 °C and cooling down, accelerated fuel consumption from 14 to 8 %. Faults: five Motor_12 frames corrupted after the CRC was computed (20.00 s), three ESP_21 frames lost (22 s), ESP_21 disappears for 1 s (35 s), a Motor_12 frame repeated with an old counter (45 s), and for the first second the cluster sends "Init".
| What | Run |
|---|---|
| frames per second reaching the core | 0x0A8: 100.0, 0x0FD: 49.1, 0x640: 10.0, 0x6B8: 5.0 |
| dropped on CRC | 5 |
| "stuck" counter | 1 |
| "Init" dropped | 5 |
| transmitted to the bus | 0 |
| shift light | RPM crossed 6000 at 14.286 s, the lamp at 14.290 s (4 ms later) |
| the same lamp on the smoothed value | 14.360 s, 74 ms later |
| step 6400 → 3500 at 15 s | the needle covered 90 % by 15.120 s |
| lag during acceleration (10 s) | 23 rpm |
| overheating | on at 37.407 s (105.00 °C), off at 46.607 s (99.75 °C) |
| low fuel | 45.013 s (9 %) |
| ESP_21 loss | last frame 34.985 s, dash at 35.520 s (535 ms later), number back at 36.040 s |
The RPM step covered 90 % in 120 ms, not 160: the frame arrived right at a tick, and the first of the four steps was taken immediately. The dash appears after 535 ms: 500 ms of timeout plus the wait for the next display tick.
The loss counter showed 10: five Motor_12 frames with a bad CRC (the sender did count them), three lost ESP_21 frames and two for the one-second loss, while 50 frames went missing. A four-bit counter sees losses only modulo 16: 50 − 3 · 16 = 2. A long gap is caught not by the counter but by the 500 ms timer.
A screen snapshot at the peak of the overheating, verbatim from the run:
show overheat t=39000 src=1 rpm=3000 spd=110 clt=107.65 fuel=10.00 warn=-H- stale=0000 needle=63,64
Car B, rusEFI, 20 s. All 12 broadcast frames every 50 ms; of these, 0x201 and 0x203 reach the core, 20.0 per second each, and the remaining 4000 frames are dropped by the filter. The shift light came on 10 ms after RPM crossed 6000: the delay is until the next frame. Overheating at 17.003 s.
Car C, no known frames, 10 s. For the first second the device is silent (0 frames transmitted), at 1.000 s it switches to OBD. After that there are 361 requests and 360 replies (the last request is at the very end of the test), 0 timeouts; all 360 values matched what the unit encoded; RPM 180 times, at least every 50 ms; fuel shown as 54.90 % (A = 140).
Display. The heaviest tick for VW is 24146 bytes, 9.198 ms of SPI out of a 40 ms tick; over 60 s SPI is busy 2.69 % of the time. With rusEFI the RPM rises faster and the needle is redrawn more often: the heaviest tick is 12.087 ms, 6.65 % on average. The first tick is the full screen, 58.51 ms, once at power-up.
STM32F407 firmware (dash_f407.c: clocks, bxCAN in SILM with filters,
receive interrupt, SPI1 with DMA2, ILI9341, SysTick; the same core) built with
arm-none-eabi-gcc 12.2.1 -Os -Wall -Wextra -Werror -pedantic for
Cortex-M4 with FPU: 5224 bytes of flash and 17036 bytes of RAM, of which
16384 are the two display buffers and 512 the queue of 32 frames. The core itself is
3632 bytes of code in the object file. _Static_assert checks in the firmware: 168/42/84 MHz,
exactly 500 kbit/s, SPI 21 MHz, chunk within NDTR, SILM is bit 31.
198с.7 Full firmware code
The firmware is five files of our own: the hardware-free core dash_core.h and
dash_core.c, the F407 register code dash_f407.c, the startup start_f407.c
and the linker script f407.ld, plus the font font5x7.h, which is the same file byte
for byte as in 198р.7, so it is not repeated here. All five are given below in full:
dash_core.c and dash_f407.c are split into consecutive chunks with nothing left out;
joined with a blank line between them, they give the files byte for byte. The snippets from 198с.3,
198с.4 and 198с.5 are repeated inside their functions. The only third-party code is the
16-byte CRC constant of the ESP_21 message from opendbc (MIT). The computer test sim_dash.c and the model dash_model.py are the test bench,
not part of the device, and they are not included here.
Building uses the same commands as the check. CMSIS_F4 is the
Drivers/CMSIS/Device/ST/STM32F4xx/Include directory of the Keil STM32F4xx_DFP 2.17.1 pack
(it contains stm32f4xx.h), CMSIS_CORE is CMSIS/Core/Include from CMSIS 5.9.0
(github.com/ARM-software/CMSIS_5):
for f in start_f407 dash_f407 dash_core; do
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -Os \
-Wall -Wextra -Werror -pedantic -std=c11 -ffreestanding -ffunction-sections -fdata-sections \
-fsingle-precision-constant -DSTM32F407xx -I. -I$CMSIS_F4 -I$CMSIS_CORE -c $f.c -o $f.o
done
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
start_f407.o dash_f407.o dash_core.o -T f407.ld -nostdlib -Wl,--gc-sections -o fw.elf -lgcc
Core header. The frame, DBC addresses and fields, timeouts, warning thresholds, the display and the dashboard state:
/* dash_core.h — раздел 198с: ядро приборной панели по CAN, без привязки к железу.
* Одно и то же ядро собирается gcc на ПК (опыт sim_dash.c) и arm-none-eabi-gcc для STM32F407 (dash_f407.c). */
#ifndef DASH_CORE_H
#define DASH_CORE_H
#include <stdint.h>
typedef struct {
uint32_t id; /* 11 бит; 29-битные кадры панели не нужны */
uint8_t dlc;
uint8_t d[8];
} dash_frame;
enum { SIG_RPM, SIG_SPEED, SIG_CLT, SIG_FUEL, SIG_N };
enum { SRC_NONE, SRC_VW, SRC_RUSEFI, SRC_OBD };
/* VW MQB, opendbc/dbc/vw_mqb.dbc */
#define VW_MOTOR_12 0x0A8u /* MO_Drehzahl_01 48|16@1+ (0.25,0), CRC и счётчик */
#define VW_ESP_21 0x0FDu /* ESP_v_Signal 32|16@1+ (0.01,0), CRC и счётчик */
#define VW_MOTOR_07 0x640u /* MO_Kuehlmittel_Temp 24|8@1+ (0.75,-48) */
#define VW_KOMBI_03 0x6B8u /* KBI_Tankfuellstand_Prozent 18|7@1+ (1,0), 126 Init, 127 Fehler */
/* rusEFI, rusEFI_CAN_verbose.dbc: база CAN_DEFAULT_BASE 0x200 */
#define RE_BASE1 0x201u /* RPM 0|16@1+ (1,0), VehicleSpeed 48|8@1+ (1,0) */
#define RE_BASE3 0x203u /* CoolantTemp 16|8@1+ (1,-40), FuelLevel 56|8@1+ (0.5,0) */
/* OBD-II (разделы 19г и 198р) */
#define OBD_REQ_ID 0x7DFu
#define OBD_RESP_ID 0x7E8u
#define DASH_TICK_MS 40u /* экран обновляется 25 раз в секунду */
#define DASH_STALE_MS 500u /* сигнал старше — прочерк */
#define DASH_FALLBACK_MS 1000u /* столько слушаем молча, прежде чем спрашивать по OBD */
#define DASH_OBD_SLOT_MS 25u
#define DASH_OBD_P2_MS 150u
/* пороги предупреждений, в сотых долях единицы; включение и выключение разнесены */
#define WARN_CLT_ON 10500 /* 105 °C */
#define WARN_CLT_OFF 10000 /* 100 °C */
#define WARN_SHIFT_ON 600000 /* 6000 об/мин */
#define WARN_SHIFT_OFF 580000 /* 5800 об/мин */
#define WARN_FUEL_ON 1000 /* меньше 10 % */
#define WARN_FUEL_OFF 1200 /* 12 % и больше */
enum { WARN_HOT = 1, WARN_SHIFT = 2, WARN_FUEL = 4 };
/* экран 320 × 240 (ILI9341, поворот на 90°), 16 бит на точку */
#define LCD_W 320
#define LCD_H 240
#define DASH_CHUNK_PX 4096u /* кусок, который рисуется и уходит одной передачей DMA */
typedef struct {
int16_t x, y, w, h;
} dash_rect;
typedef struct {
int32_t val[SIG_N]; /* последнее значение ×100 */
int32_t shown[SIG_N]; /* сглаженное значение на экране ×100 */
uint32_t t_upd[SIG_N]; /* когда пришло */
uint8_t have[SIG_N];
uint8_t stale[SIG_N];
uint8_t src; /* откуда данные */
uint8_t active; /* 0 — молчим (режим SILM), 1 — можно передавать (OBD) */
uint8_t warn;
uint8_t last_cnt[2]; /* счётчики Motor_12 и ESP_21 */
uint8_t cnt_ok[2];
uint32_t crc_err, cnt_lost, cnt_frozen, bad_dlc, invalid;
uint32_t rx_known, obd_tx, obd_rx, obd_timeouts;
uint32_t t_start, t_obd_sent, next_slot;
uint8_t obd_wait, obd_pid, poll_i;
/* что сейчас нарисовано */
int16_t nx, ny; /* конец стрелки */
int16_t speed_txt, bar_clt, bar_fuel;
uint8_t warn_drawn, full;
} dash_t;
extern dash_t dash;
uint8_t vw_mqb_crc(uint32_t id, const uint8_t *d);
void dash_filter_regs(uint32_t fr[4]); /* два банка bxCAN, 16-битный список */
int dash_filter_accept(const uint32_t fr[4], uint32_t id, int ide, int rtr);
void dash_init(uint32_t now);
void dash_rx(const dash_frame *f, uint32_t now);
int dash_poll(uint32_t now, dash_frame *req); /* 1 — отправить запрос OBD */
int dash_tick(uint32_t now, dash_rect *r, int max); /* число прямоугольников для перерисовки */
void dash_render(const dash_rect *r, int row0, int rows, uint16_t *buf);
int32_t dash_ema(int32_t shown, int32_t target, unsigned shift);
void dash_needle(int32_t rpm100, int16_t *x, int16_t *y);
#endif
VW CRC. CRC-8 with polynomial 0x2F over bytes 1…7; for ESP_21, at the end, a byte from the table selected by the counter:
/* dash_core.c — раздел 198с: разбор широковещательных кадров, запасной опрос OBD, сглаживание,
* предупреждения и перерисовка экрана кусками. Железа не касается: кадры приходят в dash_rx(),
* запрос OBD отдаёт dash_poll(), точки для экрана даёт dash_render(). */
#include "dash_core.h"
#include "font5x7.h"
dash_t dash;
/* ── CRC-8 0x2F VW MQB (AUTOSAR E2E Profile 2), как volkswagen_mqb_meb_checksum() в opendbc ── */
static const uint8_t magic_fd[16] = {0xB4, 0xEF, 0xF8, 0x49, 0x1E, 0xE5, 0xC2, 0xC0,
0x97, 0x19, 0x3C, 0xC9, 0xF1, 0x98, 0xD6, 0x61}; /* ESP_21 */
static uint8_t crc8_2f(uint8_t crc, uint8_t b)
{
crc ^= b;
for (int i = 0; i < 8; i++)
crc = (uint8_t)((crc & 0x80u) ? (unsigned)(crc << 1) ^ 0x2Fu : (unsigned)(crc << 1));
return crc;
}
uint8_t vw_mqb_crc(uint32_t id, const uint8_t *d)
{
uint8_t crc = 0xFF;
for (int i = 1; i < 8; i++)
crc = crc8_2f(crc, d[i]);
if (id == VW_ESP_21) /* у 0xA8 в таблице opendbc «секретного» байта нет */
crc = crc8_2f(crc, magic_fd[d[1] & 0x0Fu]);
return (uint8_t)(crc ^ 0xFFu);
}
List filter. The four registers of two bxCAN banks and the same check on the computer:
/* ── фильтры bxCAN: 16-битный список, 4 идентификатора на банк (STID в битах 15…5, RTR 4, IDE 3) ── */
#define F16(id) ((uint32_t)(id) << 5)
void dash_filter_regs(uint32_t fr[4])
{
fr[0] = F16(VW_ESP_21) << 16 | F16(VW_MOTOR_12); /* банк 0, FR1 */
fr[1] = F16(VW_KOMBI_03) << 16 | F16(VW_MOTOR_07); /* банк 0, FR2 */
fr[2] = F16(RE_BASE3) << 16 | F16(RE_BASE1); /* банк 1, FR1 */
fr[3] = F16(OBD_RESP_ID) << 16 | F16(OBD_RESP_ID); /* банк 1, FR2: ответ OBD дважды */
}
int dash_filter_accept(const uint32_t fr[4], uint32_t id, int ide, int rtr)
{
uint32_t v = ide ? ((id >> 18) << 5 | 1u << 3 | ((id >> 15) & 7u)) : F16(id);
v |= rtr ? 1u << 4 : 0u;
for (int i = 0; i < 4; i++)
if ((fr[i] & 0xFFFFu) == v || (fr[i] >> 16) == v)
return 1;
return 0;
}
Values. Smoothing, warnings with separated thresholds, the frame counter and the initial state:
/* ── значения и предупреждения ── */
static const uint8_t EMA_SHIFT[SIG_N] = {1, 2, 2, 4}; /* α = 1/2, 1/4, 1/4, 1/16 */
int32_t dash_ema(int32_t shown, int32_t target, unsigned shift)
{
int32_t step = (target - shown) / (int32_t)(1u << shift);
return step ? shown + step : target; /* последний шаг — сразу в цель */
}
static void warn_update(int s)
{
int32_t v = dash.val[s];
if (s == SIG_CLT) {
if (v >= WARN_CLT_ON) dash.warn |= WARN_HOT;
else if (v < WARN_CLT_OFF) dash.warn &= (uint8_t)~WARN_HOT;
} else if (s == SIG_RPM) {
if (v >= WARN_SHIFT_ON) dash.warn |= WARN_SHIFT;
else if (v < WARN_SHIFT_OFF) dash.warn &= (uint8_t)~WARN_SHIFT;
} else if (s == SIG_FUEL) {
if (v < WARN_FUEL_ON) dash.warn |= WARN_FUEL;
else if (v >= WARN_FUEL_OFF) dash.warn &= (uint8_t)~WARN_FUEL;
}
}
static void set_val(int s, int32_t v, uint32_t now)
{
if (!dash.have[s])
dash.shown[s] = v;
dash.val[s] = v;
dash.have[s] = 1;
dash.stale[s] = 0;
dash.t_upd[s] = now;
warn_update(s);
}
static int counter_ok(int k, uint8_t c)
{
if (dash.cnt_ok[k]) {
if (c == dash.last_cnt[k]) { /* счётчик не растёт — передатчик завис */
dash.cnt_frozen++;
return 0;
}
dash.cnt_lost += (uint8_t)(c - dash.last_cnt[k] - 1u) & 0x0Fu;
}
dash.last_cnt[k] = c;
dash.cnt_ok[k] = 1;
return 1;
}
static void lock_src(uint8_t s)
{
if (dash.src == SRC_NONE)
dash.src = s;
dash.rx_known++;
}
void dash_init(uint32_t now)
{
volatile uint8_t *p = (volatile uint8_t *)‐ /* без memset: прошивка собирается без libc */
for (unsigned i = 0; i < sizeof dash; i++)
p[i] = 0;
dash.t_start = now;
dash.full = 1;
dash.speed_txt = -1;
dash.bar_clt = -1;
dash.bar_fuel = -1;
}
Frame decoding. VW with CRC and counter, rusEFI, OBD replies:
void dash_rx(const dash_frame *f, uint32_t now)
{
const uint8_t *d = f->d;
if (f->dlc != 8u) {
dash.bad_dlc++;
return;
}
switch (f->id) {
case VW_MOTOR_12:
case VW_ESP_21: {
int k = f->id == VW_ESP_21;
if (vw_mqb_crc(f->id, d) != d[0]) {
dash.crc_err++;
return;
}
if (!counter_ok(k, d[1] & 0x0Fu))
return;
lock_src(SRC_VW);
if (k)
set_val(SIG_SPEED, (int32_t)(d[4] | d[5] << 8), now); /* 32|16, ×0,01 км/ч */
else
set_val(SIG_RPM, (int32_t)(d[6] | d[7] << 8) * 25, now); /* 48|16, ×0,25 об/мин */
break;
}
case VW_MOTOR_07:
lock_src(SRC_VW);
set_val(SIG_CLT, (int32_t)d[3] * 75 - 4800, now); /* 24|8, ×0,75 − 48 °C */
break;
case VW_KOMBI_03: {
uint32_t raw = (uint32_t)(d[2] >> 2) | (uint32_t)(d[3] & 1u) << 6; /* 18|7 */
lock_src(SRC_VW);
if (raw > 100u) { /* 126 Init, 127 Fehler */
dash.invalid++;
return;
}
set_val(SIG_FUEL, (int32_t)raw * 100, now);
break;
}
case RE_BASE1:
lock_src(SRC_RUSEFI);
set_val(SIG_RPM, (int32_t)(d[0] | d[1] << 8) * 100, now);
set_val(SIG_SPEED, (int32_t)d[6] * 100, now);
break;
case RE_BASE3:
lock_src(SRC_RUSEFI);
set_val(SIG_CLT, ((int32_t)d[2] - 40) * 100, now);
set_val(SIG_FUEL, (int32_t)d[7] * 50, now);
break;
case OBD_RESP_ID:
if (!dash.active || !dash.obd_wait || d[1] != 0x41u || d[2] != dash.obd_pid)
return;
dash.obd_wait = 0;
dash.obd_rx++;
if (dash.obd_pid == 0x0Cu)
set_val(SIG_RPM, (int32_t)(d[3] << 8 | d[4]) * 25, now); /* (256A + B) / 4 */
else if (dash.obd_pid == 0x0Du)
set_val(SIG_SPEED, (int32_t)d[3] * 100, now);
else if (dash.obd_pid == 0x05u)
set_val(SIG_CLT, ((int32_t)d[3] - 40) * 100, now);
else
set_val(SIG_FUEL, (int32_t)d[3] * 10000 / 255, now); /* A · 100 / 255 */
break;
default:
break;
}
}
OBD fallback polling. After 1 s of silence, requests in rotation in 25 ms slots with a 150 ms reply timeout:
/* ── запасной путь: машина молчит на знакомых адресах — спрашиваем по OBD ── */
static const uint8_t POLL[8] = {0x0C, 0x0D, 0x0C, 0x05, 0x0C, 0x0D, 0x0C, 0x2F};
int dash_poll(uint32_t now, dash_frame *req)
{
if (!dash.active) {
if (dash.src == SRC_NONE && now - dash.t_start >= DASH_FALLBACK_MS) {
dash.active = 1; /* платформа выключит SILM, увидев active */
dash.src = SRC_OBD;
dash.next_slot = now;
}
return 0; /* молча слушаем: передавать нечего и нечем */
}
if (dash.obd_wait && now - dash.t_obd_sent >= DASH_OBD_P2_MS) {
dash.obd_wait = 0;
dash.obd_timeouts++;
}
if (dash.obd_wait || (int32_t)(now - dash.next_slot) < 0)
return 0;
dash.obd_pid = POLL[dash.poll_i++ & 7u];
dash.next_slot += DASH_OBD_SLOT_MS;
req->id = OBD_REQ_ID;
req->dlc = 8;
req->d[0] = 2;
req->d[1] = 0x01;
req->d[2] = dash.obd_pid;
for (int i = 3; i < 8; i++)
req->d[i] = 0xAA;
dash.obd_wait = 1;
dash.t_obd_sent = now;
dash.obd_tx++;
return 1;
}
Geometry and what to redraw. Sine without libm (series up to x⁹), the needle, the bars and the rectangles that changed during a tick:
/* ── геометрия экрана 320 × 240 ── */
#define CX 110
#define CY 135
#define NEEDLE 85
#define TICK_IN 88
#define TICK_OUT 100
#define RPM_MAX 800000 /* 8000 об/мин ×100 — вся шкала */
#define SPD_X 215
#define SPD_Y 40
#define CLT_Y 125
#define FUEL_Y 165
#define BAR_X 215
#define BAR_W 100
#define BAR_H 14
#define WARN_Y 195
static float fsin(float x) /* без libm: приведение к [−π/2; π/2] и ряд до x⁹ */
{
const float pi = 3.14159265f;
while (x > pi) x -= 2 * pi;
while (x < -pi) x += 2 * pi;
if (x > pi / 2) x = pi - x;
if (x < -pi / 2) x = -pi - x;
float x2 = x * x;
return x * (1 - x2 / 6 * (1 - x2 / 20 * (1 - x2 / 42 * (1 - x2 / 72))));
}
static void polar(int32_t rpm100, int r, float *x, float *y)
{
if (rpm100 < 0) rpm100 = 0;
if (rpm100 > RPM_MAX) rpm100 = RPM_MAX;
float a = (225.0f - 270.0f * (float)rpm100 / (float)RPM_MAX) * 3.14159265f / 180.0f;
*x = CX + r * fsin(a + 3.14159265f / 2);
*y = CY - r * fsin(a);
}
void dash_needle(int32_t rpm100, int16_t *x, int16_t *y)
{
float fx, fy;
polar(rpm100, NEEDLE, &fx, &fy);
*x = (int16_t)(fx + 0.5f);
*y = (int16_t)(fy + 0.5f);
}
static int bar_px(int s)
{
int32_t v = dash.shown[s], lo = s == SIG_CLT ? 4000 : 0, hi = s == SIG_CLT ? 13000 : 10000;
if (!dash.have[s] || dash.stale[s]) return 0;
if (v < lo) v = lo;
if (v > hi) v = hi;
return (int)((v - lo) * BAR_W / (hi - lo));
}
static int add_rect(dash_rect *r, int n, int max, int x0, int y0, int x1, int y1)
{
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > LCD_W) x1 = LCD_W;
if (y1 > LCD_H) y1 = LCD_H;
if (n < max && x1 > x0 && y1 > y0)
r[n++] = (dash_rect){(int16_t)x0, (int16_t)y0, (int16_t)(x1 - x0), (int16_t)(y1 - y0)};
return n;
}
static int mn(int a, int b) { return a < b ? a : b; }
static int mx(int a, int b) { return a > b ? a : b; }
int dash_tick(uint32_t now, dash_rect *r, int max)
{
int n = 0;
for (int s = 0; s < SIG_N; s++) {
if (dash.have[s] && now - dash.t_upd[s] > DASH_STALE_MS && !dash.stale[s]) {
dash.stale[s] = 1;
dash.warn &= (uint8_t)~(s == SIG_CLT ? WARN_HOT : s == SIG_RPM ? WARN_SHIFT : s == SIG_FUEL ? WARN_FUEL : 0);
}
if (dash.have[s] && !dash.stale[s])
dash.shown[s] = dash_ema(dash.shown[s], dash.val[s], EMA_SHIFT[s]);
}
int16_t nx, ny;
dash_needle(dash.have[SIG_RPM] && !dash.stale[SIG_RPM] ? dash.shown[SIG_RPM] : 0, &nx, &ny);
int spd = dash.have[SIG_SPEED] && !dash.stale[SIG_SPEED] ? (int)((dash.shown[SIG_SPEED] + 50) / 100) : -2;
int bc = bar_px(SIG_CLT), bf = bar_px(SIG_FUEL);
if (dash.full) {
dash.full = 0;
n = add_rect(r, n, max, 0, 0, LCD_W, LCD_H);
} else {
if ((dash.warn ^ dash.warn_drawn) & WARN_SHIFT)
n = add_rect(r, n, max, 0, 0, LCD_W, 16);
if (nx != dash.nx || ny != dash.ny)
n = add_rect(r, n, max, mn(CX, mn(nx, dash.nx)) - 2, mn(CY, mn(ny, dash.ny)) - 2,
mx(CX, mx(nx, dash.nx)) + 3, mx(CY, mx(ny, dash.ny)) + 3);
if (spd != dash.speed_txt)
n = add_rect(r, n, max, SPD_X, SPD_Y, SPD_X + 90, SPD_Y + 40);
if (bc != dash.bar_clt)
n = add_rect(r, n, max, BAR_X, CLT_Y, BAR_X + BAR_W, CLT_Y + BAR_H);
if (bf != dash.bar_fuel)
n = add_rect(r, n, max, BAR_X, FUEL_Y, BAR_X + BAR_W, FUEL_Y + BAR_H);
if ((dash.warn ^ dash.warn_drawn) & (WARN_HOT | WARN_FUEL))
n = add_rect(r, n, max, BAR_X, WARN_Y, BAR_X + 101, WARN_Y + 24);
}
dash.nx = nx;
dash.ny = ny;
dash.speed_txt = (int16_t)spd;
dash.bar_clt = (int16_t)bc;
dash.bar_fuel = (int16_t)bf;
dash.warn_drawn = dash.warn;
return n;
}
Drawing. The colour of every pixel is computed anew, and the chunk goes into the buffer most significant byte first:
/* ── рисование: каждая точка прямоугольника считается заново, буфер — один кусок ── */
#define C_BG 0x0000u
#define C_WHITE 0xFFFFu
#define C_RED 0xF800u
#define C_GREEN 0x07E0u
#define C_AMBER 0xFD20u
#define C_GRAY 0x4208u
#define C_DARK 0x2104u
static int near_seg(float px, float py, float ax, float ay, float bx, float by, float w)
{
float dx = bx - ax, dy = by - ay, t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy);
if (t < 0) t = 0;
if (t > 1) t = 1;
float ex = ax + t * dx - px, ey = ay + t * dy - py;
return ex * ex + ey * ey <= w * w;
}
static int text_px(int x, int y, int x0, int y0, int sc, const char *s)
{
if (y < y0 || y >= y0 + 7 * sc || x < x0) return 0;
int col = (x - x0) / sc, ch = col / 6, cx = col % 6;
for (int i = 0; i < ch; i++)
if (!s[i]) return 0;
if (!s[ch] || cx >= 5) return 0;
char c = s[ch];
if (c < ' ' || c > 'Z') return 0;
return (FONT5X7[c - ' '][cx] >> ((y - y0) / sc)) & 1u;
}
static uint16_t pixel(int x, int y, const char *spd)
{
if (y < 16)
return (dash.warn_drawn & WARN_SHIFT) ? C_RED : C_DARK;
if (x < 212) { /* круглая шкала оборотов */
float fx = (float)x, fy = (float)y, ax, ay, bx, by;
float dx = fx - CX, dy = fy - CY;
if (dx * dx + dy * dy <= 36.0f) return C_GRAY; /* ось стрелки */
if (near_seg(fx, fy, CX, CY, dash.nx, dash.ny, 1.5f)) return C_AMBER;
if (dx * dx + dy * dy >= (TICK_IN - 1) * (TICK_IN - 1)) {
for (int k = 0; k <= 8; k++) {
polar(k * 100000, TICK_IN, &ax, &ay);
polar(k * 100000, TICK_OUT, &bx, &by);
if (near_seg(fx, fy, ax, ay, bx, by, 1.2f)) return k >= 6 ? C_RED : C_WHITE;
}
}
return C_BG;
}
if (text_px(x, y, SPD_X, SPD_Y, 5, spd)) return C_WHITE;
if (text_px(x, y, SPD_X + 36, SPD_Y + 42, 2, "KM/H")) return C_GRAY;
if (text_px(x, y, BAR_X, CLT_Y - 17, 2, "TEMP")) return C_GRAY;
if (text_px(x, y, BAR_X, FUEL_Y - 17, 2, "FUEL")) return C_GRAY;
if (x >= BAR_X && x < BAR_X + BAR_W && y >= CLT_Y && y < CLT_Y + BAR_H)
return x - BAR_X < dash.bar_clt ? ((dash.warn_drawn & WARN_HOT) ? C_RED : C_GREEN) : C_DARK;
if (x >= BAR_X && x < BAR_X + BAR_W && y >= FUEL_Y && y < FUEL_Y + BAR_H)
return x - BAR_X < dash.bar_fuel ? ((dash.warn_drawn & WARN_FUEL) ? C_AMBER : C_GREEN) : C_DARK;
if (y >= WARN_Y && y < WARN_Y + 24 && x >= BAR_X && x < BAR_X + 48)
return text_px(x, y, BAR_X + 7, WARN_Y + 5, 2, "HOT") ? C_BG : (dash.warn_drawn & WARN_HOT) ? C_RED : C_DARK;
if (y >= WARN_Y && y < WARN_Y + 24 && x >= BAR_X + 53 && x < BAR_X + 101)
return text_px(x, y, BAR_X + 55, WARN_Y + 5, 2, "FUEL") ? C_BG : (dash.warn_drawn & WARN_FUEL) ? C_AMBER : C_DARK;
return C_BG;
}
void dash_render(const dash_rect *r, int row0, int rows, uint16_t *buf)
{
char spd[4] = "---";
if (dash.speed_txt >= 0) {
int v = dash.speed_txt > 999 ? 999 : dash.speed_txt;
spd[0] = v >= 100 ? (char)('0' + v / 100) : ' ';
spd[1] = v >= 10 ? (char)('0' + v / 10 % 10) : ' ';
spd[2] = (char)('0' + v % 10);
}
for (int y = row0; y < row0 + rows; y++)
for (int x = r->x; x < r->x + r->w; x++) {
uint16_t c = pixel(x, r->y + y, spd);
*buf++ = (uint16_t)(c >> 8 | c << 8); /* ILI9341 ждёт старший байт первым */
}
}
Firmware: clocks and reception. Clock and CAN constants with build-time checks, a queue of 32 frames, the FIFO 0 interrupt:
/* dash_f407.c — раздел 198с: прошивка приборной панели для STM32F407 (регистры CMSIS, без HAL).
* Такты: HSE 8 МГц, SYSCLK 168 МГц, APB1 42 МГц (bxCAN), APB2 84 МГц (SPI1).
* bxCAN1: PB8 RX, PB9 TX (AF9), 500 кбит/с, режим SILM — только слушать. Два банка фильтров, 16-битный список.
* ILI9341: SPI1 PA5 SCK, PA7 MOSI (AF5), PA4 CS, PA3 DC, PA2 RESET; SPI1_TX — DMA2 поток 3, канал 3. */
#include "stm32f4xx.h"
#include "dash_core.h"
#define HSE_HZ 8000000u
#define PLL_M 8u
#define PLL_N 336u
#define PLL_P 2u
#define PLL_Q 7u
#define SYSCLK_HZ (HSE_HZ / PLL_M * PLL_N / PLL_P)
#define PCLK1_HZ (SYSCLK_HZ / 4u)
#define PCLK2_HZ (SYSCLK_HZ / 2u)
#define CAN_BRP 6u
#define CAN_TS1 11u
#define CAN_TS2 2u
#define CAN_SJW 2u
#define SPI_DIV 4u /* BR = 001: fPCLK / 4 */
_Static_assert(SYSCLK_HZ == 168000000u && PCLK1_HZ == 42000000u && PCLK2_HZ == 84000000u, "такты 168/42/84 МГц");
_Static_assert(PCLK1_HZ / (CAN_BRP * (1u + CAN_TS1 + CAN_TS2)) == 500000u, "CAN 500 кбит/с ровно");
_Static_assert(CAN_TS1 <= 16u && CAN_TS2 <= 8u && CAN_BRP <= 1024u && CAN_SJW <= 4u, "поля CAN_BTR: TS1 4 бита, TS2 3, BRP 10, SJW 2");
_Static_assert(PCLK2_HZ / SPI_DIV == 21000000u, "SPI1 21 МГц");
_Static_assert(DASH_CHUNK_PX * 2u <= 65535u, "кусок влезает в NDTR (до 65535)");
_Static_assert(CAN_BTR_SILM == 0x80000000u, "SILM — бит 31 CAN_BTR");
#define CAN_BTR_VAL ((CAN_SJW - 1u) << 24 | (CAN_TS2 - 1u) << 20 | (CAN_TS1 - 1u) << 16 | (CAN_BRP - 1u))
static volatile uint32_t ms;
static dash_frame rxq[32];
static volatile uint8_t rxh, rxt;
static volatile uint32_t rx_lost;
static uint16_t lcd_buf[2][DASH_CHUNK_PX];
void SysTick_Handler(void) { ms++; }
void CAN1_RX0_IRQHandler(void)
{
while (CAN1->RF0R & CAN_RF0R_FMP0) {
CAN_FIFOMailBox_TypeDef *m = &CAN1->sFIFOMailBox[0];
uint8_t n = (uint8_t)((rxh + 1u) & 31u);
if (n != rxt) {
dash_frame *f = &rxq[rxh];
uint32_t lo = m->RDLR, hi = m->RDHR;
f->id = m->RIR >> 21;
f->dlc = (uint8_t)(m->RDTR & 15u);
for (int i = 0; i < 4; i++) {
f->d[i] = (uint8_t)(lo >> (8 * i));
f->d[4 + i] = (uint8_t)(hi >> (8 * i));
}
rxh = n;
} else {
rx_lost++;
}
CAN1->RF0R |= CAN_RF0R_RFOM0;
}
}
Clocks and pins. HSE 8 MHz → PLL 168 MHz, APB1 42 MHz, APB2 84 MHz:
static void clock_init(void)
{
RCC->CR |= RCC_CR_HSEON;
while (!(RCC->CR & RCC_CR_HSERDY)) {
}
FLASH->ACR = FLASH_ACR_ICEN | FLASH_ACR_DCEN | FLASH_ACR_PRFTEN | FLASH_ACR_LATENCY_5WS;
RCC->CFGR = RCC_CFGR_PPRE1_DIV4 | RCC_CFGR_PPRE2_DIV2;
RCC->PLLCFGR = PLL_M | PLL_N << RCC_PLLCFGR_PLLN_Pos | (PLL_P / 2u - 1u) << RCC_PLLCFGR_PLLP_Pos
| RCC_PLLCFGR_PLLSRC_HSE | PLL_Q << RCC_PLLCFGR_PLLQ_Pos;
RCC->CR |= RCC_CR_PLLON;
while (!(RCC->CR & RCC_CR_PLLRDY)) {
}
RCC->CFGR |= RCC_CFGR_SW_PLL;
while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
}
SysTick->LOAD = SYSCLK_HZ / 1000u - 1u;
SysTick->VAL = 0;
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
}
static void gpio_af(GPIO_TypeDef *g, unsigned pin, unsigned af)
{
g->MODER = (g->MODER & ~(3u << (2 * pin))) | 2u << (2 * pin);
g->OSPEEDR |= 3u << (2 * pin);
g->AFR[pin >> 3] = (g->AFR[pin >> 3] & ~(15u << (4 * (pin & 7u)))) | af << (4 * (pin & 7u));
}
static void gpio_out(GPIO_TypeDef *g, unsigned pin)
{
g->MODER = (g->MODER & ~(3u << (2 * pin))) | 1u << (2 * pin);
g->BSRR = 1u << pin;
}
bxCAN. SILM mode and back, filters, sending an OBD request:
/* ── bxCAN ── */
static void can_mode(int active) /* 0 — SILM (только слушать), 1 — обычный */
{
CAN1->MCR |= CAN_MCR_INRQ;
while (!(CAN1->MSR & CAN_MSR_INAK)) {
}
CAN1->BTR = CAN_BTR_VAL | (active ? 0u : CAN_BTR_SILM);
CAN1->MCR &= ~CAN_MCR_INRQ;
while (CAN1->MSR & CAN_MSR_INAK) {
}
}
static void can_init(void)
{
uint32_t fr[4];
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;
RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
gpio_af(GPIOB, 8, 9);
gpio_af(GPIOB, 9, 9);
CAN1->MCR &= ~CAN_MCR_SLEEP;
dash_filter_regs(fr);
CAN1->FMR |= CAN_FMR_FINIT;
CAN1->FM1R |= 3u; /* банки 0 и 1 — список */
CAN1->FS1R &= ~3u; /* 16 бит: 4 идентификатора в банке */
CAN1->FFA1R &= ~3u; /* оба — в FIFO 0 */
CAN1->sFilterRegister[0].FR1 = fr[0];
CAN1->sFilterRegister[0].FR2 = fr[1];
CAN1->sFilterRegister[1].FR1 = fr[2];
CAN1->sFilterRegister[1].FR2 = fr[3];
CAN1->FA1R |= 3u;
CAN1->FMR &= ~CAN_FMR_FINIT;
CAN1->IER = CAN_IER_FMPIE0;
NVIC_EnableIRQ(CAN1_RX0_IRQn);
can_mode(0);
}
static void can_send(const dash_frame *f)
{
if (!(CAN1->TSR & CAN_TSR_TME0))
return;
CAN1->sTxMailBox[0].TDTR = f->dlc;
CAN1->sTxMailBox[0].TDLR = (uint32_t)f->d[0] | (uint32_t)f->d[1] << 8 | (uint32_t)f->d[2] << 16 | (uint32_t)f->d[3] << 24;
CAN1->sTxMailBox[0].TDHR = (uint32_t)f->d[4] | (uint32_t)f->d[5] << 8 | (uint32_t)f->d[6] << 16 | (uint32_t)f->d[7] << 24;
CAN1->sTxMailBox[0].TIR = f->id << 21 | CAN_TI0R_TXRQ;
}
ILI9341 over SPI1 and DMA2. Commands, the window, sending a chunk on stream 3, display start-up:
/* ── ILI9341 по SPI1 + DMA2 поток 3 ── */
#define CS_LO() (GPIOA->BSRR = 1u << (4 + 16))
#define CS_HI() (GPIOA->BSRR = 1u << 4)
#define DC_CMD() (GPIOA->BSRR = 1u << (3 + 16))
#define DC_DATA() (GPIOA->BSRR = 1u << 3)
static void delay_ms(uint32_t d)
{
uint32_t t0 = ms;
while (ms - t0 < d) {
}
}
static void spi_wait(void)
{
while (!(SPI1->SR & SPI_SR_TXE)) {
}
while (SPI1->SR & SPI_SR_BSY) {
}
}
static void dma_wait(void)
{
if (DMA2_Stream3->CR & DMA_SxCR_EN) {
while (!(DMA2->LISR & DMA_LISR_TCIF3)) {
}
}
DMA2->LIFCR = DMA_LIFCR_CTCIF3 | DMA_LIFCR_CHTIF3 | DMA_LIFCR_CTEIF3 | DMA_LIFCR_CDMEIF3 | DMA_LIFCR_CFEIF3;
spi_wait();
}
static void lcd_cmd(uint8_t c, const uint8_t *p, unsigned n)
{
DC_CMD();
CS_LO();
*(volatile uint8_t *)&SPI1->DR = c;
spi_wait();
DC_DATA();
for (unsigned i = 0; i < n; i++) {
while (!(SPI1->SR & SPI_SR_TXE)) {
}
*(volatile uint8_t *)&SPI1->DR = p[i];
}
spi_wait();
CS_HI();
}
static void lcd_window(const dash_rect *r)
{
unsigned x1 = (unsigned)(r->x + r->w - 1), y1 = (unsigned)(r->y + r->h - 1);
uint8_t ca[4] = {(uint8_t)(r->x >> 8), (uint8_t)r->x, (uint8_t)(x1 >> 8), (uint8_t)x1};
uint8_t pa[4] = {(uint8_t)(r->y >> 8), (uint8_t)r->y, (uint8_t)(y1 >> 8), (uint8_t)y1};
lcd_cmd(0x2A, ca, 4); /* столбцы */
lcd_cmd(0x2B, pa, 4); /* строки */
DC_CMD();
CS_LO();
*(volatile uint8_t *)&SPI1->DR = 0x2C; /* запись в память; дальше — точки */
spi_wait();
DC_DATA();
}
static void lcd_dma(const uint16_t *p, uint32_t bytes)
{
DMA2_Stream3->CR = 0;
DMA2_Stream3->PAR = (uint32_t)&SPI1->DR;
DMA2_Stream3->M0AR = (uint32_t)p;
DMA2_Stream3->NDTR = bytes;
DMA2_Stream3->CR = 3u << DMA_SxCR_CHSEL_Pos | DMA_SxCR_MINC | DMA_SxCR_DIR_0 | DMA_SxCR_EN;
}
static void lcd_init(void)
{
static const uint8_t pix = 0x05, madctl = 0x68; /* 16 бит на точку; поворот на 90° (BGR|MX|MV), как в QMK */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_DMA2EN;
RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
gpio_af(GPIOA, 5, 5);
gpio_af(GPIOA, 7, 5);
gpio_out(GPIOA, 4);
gpio_out(GPIOA, 3);
gpio_out(GPIOA, 2);
SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_BR_0 | SPI_CR1_SSM | SPI_CR1_SSI;
SPI1->CR2 = SPI_CR2_TXDMAEN;
SPI1->CR1 |= SPI_CR1_SPE;
GPIOA->BSRR = 1u << (2 + 16);
delay_ms(10);
GPIOA->BSRR = 1u << 2;
delay_ms(120);
lcd_cmd(0x01, 0, 0); /* программный сброс */
delay_ms(120);
lcd_cmd(0x11, 0, 0); /* выход из сна */
delay_ms(5);
lcd_cmd(0x3A, &pix, 1);
lcd_cmd(0x36, &madctl, 1);
lcd_cmd(0x29, 0, 0); /* включить */
delay_ms(20);
}
Redraw and the main loop. Two buffers: while DMA sends one chunk, the core draws the next one:
static void draw(uint32_t now)
{
dash_rect r[8];
int n = dash_tick(now, r, 8), k = 0;
for (int i = 0; i < n; i++) {
int rows = (int)(DASH_CHUNK_PX / (unsigned)r[i].w);
dma_wait();
CS_HI();
lcd_window(&r[i]);
for (int y = 0; y < r[i].h; y += rows) {
int h = y + rows > r[i].h ? r[i].h - y : rows;
dash_render(&r[i], y, h, lcd_buf[k]); /* рисуем, пока DMA шлёт прошлый кусок */
dma_wait();
lcd_dma(lcd_buf[k], (uint32_t)(h * r[i].w * 2));
k ^= 1;
}
}
dma_wait();
CS_HI();
}
int main(void)
{
uint32_t last = 0;
int hw_active = 0;
clock_init();
dash_init(0);
lcd_init();
can_init();
for (;;) {
while (rxt != rxh) {
dash_rx(&rxq[rxt], ms);
rxt = (uint8_t)((rxt + 1u) & 31u);
}
dash_frame q;
int send = dash_poll(ms, &q);
if (dash.active && !hw_active) { /* машина молчит: выходим из SILM и спрашиваем по OBD */
can_mode(1);
hw_active = 1;
}
if (send && hw_active)
can_send(&q);
if (ms - last >= DASH_TICK_MS) {
last += DASH_TICK_MS;
draw(ms);
}
}
}
Startup. Copying .data, zeroing .bss, enabling the FPU, and the vector table
with SysTick and CAN1_RX0:
/* start_f407.c — раздел 198с: старт STM32F407: таблица векторов (SysTick и CAN1_RX0), .data, .bss, FPU, main. */
#include <stdint.h>
extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;
int main(void);
void SysTick_Handler(void);
void CAN1_RX0_IRQHandler(void);
void Reset_Handler(void)
{
uint32_t *s = &_sidata, *d = &_sdata;
*(volatile uint32_t *)0xE000ED88u |= 0xFu << 20; /* CPACR: сопроцессоры CP10 и CP11 — FPU */
while (d < &_edata)
*d++ = *s++;
for (d = &_sbss; d < &_ebss;)
*d++ = 0;
main();
for (;;) {
}
}
static void Fault_Handler(void)
{
for (;;) {
}
}
typedef void (*vec_t)(void);
__attribute__((section(".isr_vector"), used))
const vec_t vectors[16 + 21] = {
[0] = (vec_t)(uintptr_t)&_estack, [1] = Reset_Handler, [2] = Fault_Handler, [3] = Fault_Handler,
[15] = SysTick_Handler,
[16 + 20] = CAN1_RX0_IRQHandler, /* CAN1_RX0_IRQn = 20 */
};
The linker script f407.ld: the 128 KB of RAM that DMA can see:
/* Раздел 198с: STM32F407 — 1 МБ флеш-памяти, 128 КБ ОЗУ (SRAM1 + SRAM2, доступны DMA; CCM 64 КБ DMA не видит). */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector : { KEEP(*(.isr_vector)) } > FLASH
.text : { *(.text .text.*) *(.rodata .rodata.*) . = ALIGN(4); } > FLASH
.ARM.exidx : { *(.ARM.exidx*) } > FLASH
.data : { _sdata = .; *(.data .data.*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH
_sidata = LOADADDR(.data);
.bss (NOLOAD) : { _sbss = .; *(.bss .bss.*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
end = _ebss;
}
198с.8 Pitfalls
On the bench the transmitter repeats the same frame over and over. One transmitter and
one dashboard in SILM: there is never an ACK on the bus, because the dashboard does not
drive it. For the transmitter this is an error, and by default bxCAN (NART = 0,
RM0090) retransmits the frame until it goes out successfully. In a car the other
units acknowledge; on the bench you need a third node in normal mode.
The dashboard breaks the bus. Normal mode and the wrong bit rate: every frame of
the units is destroyed by an error frame. Listen only in SILM (bxCAN) or MON
(FDCAN); go to normal mode only for OBD and only after silence.
Silence on the OBD connector. The gateway does not let broadcast frames out to the
diagnostic connector (in the DBC, ESP_21 is sent by Gateway_MQB). Connect behind
the cluster or live on the OBD fallback.
Speed is there, but RPM is frozen. A frame with a bad CRC is dropped, correctly. But Motor_12 has no "secret" byte in the opendbc table, and if the real unit uses one, all its frames will be dropped. The CRC error counter on the service screen shows this immediately.
50 frames lost, but the counter says 2. Four bits mean losses modulo 16. A missing message is caught by the timer, not by the counter.
The shift light is late. If you drive it from the smoothed RPM, it is 74 ms instead of 4 in the test. Warnings must use the raw value.
Fuel shows 126 %. That is the cluster's "Init", not a percentage (VAL_ 1720).
The display slows down reception. A full frame is 58.51 ms at 21 MHz, longer than the 40 ms tick. Redraw rectangles, via DMA and in chunks, and keep reception in an interrupt with a queue.
DMA sends zeros. The buffer is in CCM (0x1000 0000): DMA cannot see it. The display buffers go only in SRAM1/SRAM2.
A frame larger than 65535 bytes. NDTR is 16-bit, so 153600 bytes cannot be
sent in one go; use chunks of 8192 bytes.
198с.9 Summary
- The device listens to the bus silently: bxCAN in SILM (bit 31 of
CAN_BTR, from 42 MHz BRP 6, TS1 11, TS2 2, 85.7 %,0x811A0005); on the G474,FDCAN_CCCR.MON. In the VW and rusEFI tests 0 frames were transmitted. - Two filter banks, 16-bit list: of 2048 identifiers 7 pass; in the VW model 9849 frames out of 57849 reached the core.
- VW MQB by
vw_mqb.dbc: 0xA8 RPM × 0.25, 0xFD speed × 0.01, 0x640 temperature × 0.75 − 48, 0x6B8 fuel in %; CRC-8 0x2F on 16 of 16 real ESP_21 frames; 5 corrupted frames dropped. - rusEFI: base 0x200, 0x201 and 0x203 every 50 ms, no CRC and no counter; with no known frames, OBD after 1 s: 360 replies, 0 mismatches.
- Smoothing α = 1/2, 1/4, 1/16: 160, 360 and 1440 ms to 90 %. Warnings on the raw value: the lamp after 4 ms; on the smoothed value it would be 74 ms.
- ILI9341: the full screen is 58.51 ms at 21 MHz, so redraw by rectangles, in 8 KB chunks via DMA; the heaviest tick is 9.198 ms out of 40.
- F407 firmware: 5224 bytes of flash and 17036 bytes of RAM.
Next: section 198т "Mini-project: a CAN bus logger to an SD card — recording a trip and analysing it on a computer": the same bus, but into a file instead of onto a display: record a whole drive and analyse it later.