Embedded HandbookПриложения
Все разделы › III. Периферия
STM32F407 · уровень продвинутый · 56 мин чтения
Открыть в интерактивном справочнике

19м. rusEFI изнутри: искра и впрыск по углу коленвала — накопление, опережение и очередь событий на одном таймере

В разделе 19з декодер колеса 60-2 превратил фронты датчика в фазу цикла и обороты. Здесь — что блок делает дальше: как угол «искра за 35° до верхней мёртвой точки» становится моментом времени, когда включить катушку, чтобы она успела набрать ток, когда открыть форсунку, как всё это успевает один аппаратный таймер и что ломается, когда двигатель разгоняется или сбрасывает обороты между зубьями.

Рядом с кодом rusEFI — свой планировщик на C того же устройства и модель коленвала с меняющимися оборотами: на ней сравниваются три способа ставить события. Стенд прогнан на компьютере и тем же исходником в QEMU на Cortex-M4F; прошивка для STM32F407 на TIM5 собрана и измерена, на плате не запускалась.

Опоры: исходники rusEFI — github.com/rusefi/rusefi (коммит 79ceeb32), лицензия GPL-3.0 с тремя оговорками в license.txt (не для дорог общего пользования, не по экологическим нормам, не для пилотируемой авиации); цитаты — по нескольку строк с путём к файлу. Свой код написан заново: он повторяет устройство rusEFI, а не его текст. Документы: RM0090 (векторы прерываний и тактирование таймеров STM32F407), DS8626 (альтернативные функции выводов).

Как это называется в документации

Оригинал Перевод
TDC, BTDC, ATDC верхняя мёртвая точка (ВМТ), до ВМТ, после ВМТ
engine cycle, engine phase цикл двигателя (720°), фаза — угол в цикле
ignition timing, advance момент зажигания, угол опережения (УОЗ)
dwell, overdwell накопление (катушка под током перед искрой), перезаряд
injection phase, offset; timing mode End / Start / Center фаза впрыска: угол конца / начала / середины импульса
injector pulse width длительность импульса форсунки
event queue, scheduler, executor очередь событий по времени, планировщик
output compare сравнение: прерывание, когда счётчик таймера дошёл до числа
late delay, spin wait задержка очереди: близкое событие ждать в цикле
angle-based event, trigger scheduler событие по углу: ждёт «своего» зуба
rev limiter, hysteresis отсечка, возврат ниже порога

19м.1 От зуба к искре: что делает блок после декодера

Каждый зуб приходит в mainTriggerCallback() (firmware/controllers/engine_cycle/main_trigger_callback.cpp): время фронта в тактах 4 МГц, фаза этого зуба (currentPhase) и следующего (nextPhase). Если оборотов ещё нет (rpm == 0), функция выходит. Иначе — три шага:

// firmware/controllers/engine_cycle/main_trigger_callback.cpp (rusEFI, GPL-3.0)
    handleFuel(edgeTimestamp, currentPhase, nextPhase);

    engine->module<TriggerScheduler>()->scheduleEventsUntilNextTriggerTooth(
        rpm, edgeTimestamp, currentPhase, nextPhase);

    /**
     * For spark we schedule both start of coil charge and actual spark based on trigger angle
     */
    onTriggerEventSparkLogic(rpm, edgeTimestamp, currentPhase, nextPhase);

Топливо — форсунки, чьё открытие попадает между этим и следующим зубом; затем события, которые ждали этого зуба; затем катушки, чьё накопление начинается до следующего зуба. Главная мысль: на зубе ставится только то, что случится до следующего зуба. От зуба до угла остаётся меньше шага колеса (6° у 60-2, 18° на пропуске), и только этот остаток переводится во время по оборотам. Ошибка оборотов умножается на несколько градусов, а не на весь цикл.

Сверху — отрезок колеса 60-2 у цилиндра 1 на 3000 оборотах в минуту: накопление с 613 градусов ставится на зубе 612 через 1 градус, искра на 685 градусах ждёт зуба 684 и тоже ставится через 1 градус, страж через 6 миллисекунд не даст сжечь катушку. Посередине — цепочка блока на STM32F407 с одним таймером TIM5 на 4 мегагерца: захват зуба, декодер, план, события по углу, очередь по времени, сравнение, выходы на катушки и форсунки. Внизу — ошибка искры на разгоне с 1000 до 7000 оборотов за секунду: от метки до 249,1 градуса, от зуба до 2,10, по мгновенным оборотам до 0,144

Двигатель стенда: четыре цилиндра, порядок работы 1-3-4-2, ВМТ сжатия — 0, 180, 360 и 540° цикла, катушка и форсунка на каждый цилиндр; фаза цикла считается известной, как с датчиком распредвала.


19м.2 План искры: опережение и накопление

Угол опережения rusEFI берёт из таблицы ignitionTable по оборотам и нагрузке. Таблицу по умолчанию строит buildTimingMap(35) из controllers/algo/defaults/default_ignition.cpp: getAdvanceForRpm() даёт 10° ниже 600 об/мин, 35° с 3000 и прямую между ними, getInitialAdvance() добавляет 0.1 * (100 - map) — при 100 кПа во впуске ноль. Эту прямую повторяет advance_deg() стенда.

Накопление — время: по умолчанию setConstantDwell(4), 4 мс на всех оборотах; угол накопления — время, делённое на время градуса (ignition_state.cpp). Углы цилиндра считает prepareCylinderIgnitionSchedule() в spark_logic.cpp:

// firmware/controllers/engine_cycle/spark_logic.cpp (rusEFI, GPL-3.0)
    angle_t sparkAngle =
        // Negate because timing *before* TDC, and we schedule *after* TDC
        - finalIgnitionTiming
        // Offset by this cylinder's position in the cycle
        + getPerCylinderFiringOrderOffset(event->cylinderIndex, coilIndex);

и dwellStartAngle = sparkAngle - dwellAngleDuration. У стенда:

/* План искры: угол искры = ВМТ − опережение, накопление — заданное время,
 * переведённое в градусы по оборотам на момент плана. */
static void plan_ign(ign_ev *e, float rpm)
{
    e->spark_angle = wrap720(e->tdc - advance_deg(rpm));
    e->dwell_angle = wrap720(e->spark_angle - C.dwell_ms / ms_per_deg(rpm));
}
Обороты, об/мин Опережение Накопление 4 мс Впрыск 3 мс Зуб 6°
600 10,0° 14,40° 10,80° 1666,7 мкс
1000 14,2° 24,00° 18,00° 1000,0 мкс
2000 24,6° 48,00° 36,00° 500,0 мкс
3000 35,0° 72,00° 54,00° 333,3 мкс
6000 35,0° 144,00° 108,00° 166,7 мкс
7000 35,0° 168,00° 126,00° 142,9 мкс

На 7000 об/мин накопление — 168°, 28 шагов колеса: катушка начинает заряжаться задолго до искры, мимо проходят десятки зубьев. План пересчитывается после каждой искры: fireSparkAndPrepareNextSchedule() гасит катушку и зовёт prepareCylinderIgnitionSchedule() для следующего цикла; у стенда — fire().


19м.3 Очередь по времени на одном таймере

Все события сходятся в одну очередь: EventQueue (controllers/system/timer/event_queue.cpp) и SingleTimerExecutor (single_timer_executor.cpp). Это отсортированный по времени список с линейной вставкой (в комментарии: событий мало), пул из 64 слотов (m_pool[64]) для событий «выстрелил и забыл» и один таймер в режиме сравнения, заведённый на голову списка. На STM32 это TIM5 (port_mpu_util.h: SCHEDULER_TIMER_DEVICE TIM5, 4 МГц); тот же счётчик — часы всей прошивки (getTimeNowLowerNt() читает TIM5->CNT), так что время зуба и время события меряются одной линейкой.

У головы очереди два порога. Задержка очереди:

// firmware/controllers/system/timer/single_timer_executor.cpp (rusEFI, GPL-3.0)
    // 8us is roughly the cost of the interrupt + overhead of a single timer event
    : queue(US2NT(8))

До события меньше 8 мкс — обработчик досиживает до срока в цикле:

// firmware/controllers/system/timer/event_queue.cpp (rusEFI, GPL-3.0)
    // near future - spin wait for the event to happen and avoid the
    // overhead of rescheduling the timer.
    // yes, that's a busy wait but that's what we need here
    while (current->getMomentNt() > getTimeNowNt()) {
        UNIT_TEST_BUSY_WAIT_CALLBACK();
    }

И нижняя граница: setHardwareSchedulerTimer() (microsecond_timer.cpp) не заводит таймер ближе US2NT(2) — иначе срок окажется в прошлом. Стенд повторяет обе вещи:

/* Прерывание сравнения: выполнить всё, чей срок наступил или наступит
 * в пределах late, и завести таймер на следующее. */
void evq_isr(evq_t *q)
{
    q->irq++;
    q->in_isr = 1;
    for (;;) {
        ev_slot *s = q->head;
        if (!s)
            break;
        tick_t now = plat_now();
        if (s->at > now + q->late)
            break;
        if (s->at > now) {
            plat_wait_until(s->at); /* дешевле подождать, чем выйти и войти снова */
            q->spin++;
        }
        q->head = s->next;
        ev_fn fn = s->fn;
        void *arg = s->arg;
        release(q, s); /* слот свободен до вызова: действие может поставить его снова */
        q->run++;
        fn(arg);
    }
    q->in_isr = 0;
    arm_timer(q);
}

arm_timer() заводит сравнение на голову: опоздавшее событие — через задержку очереди, и не ближе 2 мкс. Слот освобождается до вызова действия, чтобы действие могло поставить себя снова. Слот, который уже стоит в очереди, второй раз не ставится — как в insertTask() у rusEFI (if (scheduling->action) → return false).

Опыт: нужна ли задержка. 6000 об/мин, 1 с. Конец впрыска нарочно поставлен на −394,95°: на 0,05° позже искры соседнего цилиндра, то есть через 1,4 мкс — ближе 2 мкс:

задержка 0 мкс: прерываний таймера 799, событий 799, дождались в цикле 0; длина очереди до 3, слотов пула до 2 из 32; искра от -0.002 до -0.002 град, конец впрыска от 0.020 до 0.020 град
задержка 8 мкс: прерываний таймера 599, событий 799, дождались в цикле 200; длина очереди до 3, слотов пула до 2 из 32; искра от -0.002 до -0.002 град, конец впрыска от -0.007 до -0.007 град

Без задержки каждое событие — своё прерывание, и закрытие форсунки уезжает на 2 мкс после искры: +0,020°. С задержкой 8 мкс 200 закрытий дождались в цикле и пришли вовремя, прерываний 599 вместо 799. Цена — до 8 мкс процессор крутится в обработчике. Очередь не длиннее 3 событий, из пула занято не больше 2 слотов из 32.


19м.4 Угол во время: «ждём своего зуба»

Перевод угла во время — scheduleByAngle() в rpm_calculator.cpp: delayUs = oneDegreeUs * angle, где oneDegreeUs — время градуса по оборотам, и schedule() на nowNt плюс сдвиг. Время 64-битное, а сдвиг sumTickAndFloat() (util/efitime.h) приводит к 32 битам: return ticks + (int32_t) extra; — комментарий объясняет, что сдвиг меньше 10 с, а 32 бит хватает на 500 с тактов. Зачем — в 19м.8.

События по углу — TriggerScheduler (controllers/system/timer/trigger_scheduler.cpp):

// firmware/controllers/system/timer/trigger_scheduler.cpp (rusEFI, GPL-3.0)
    if (event->shouldSchedule(currentPhase, nextPhase)) {
        // if we're due now, just schedule the event
        scheduleByAngle(
            &event->eventScheduling,
            edgeTimestamp,
            event->getAngleFromNow(currentPhase),
            action
        );

        return true;
    } else {
        // If not due now, add it to the queue to be scheduled later
        schedule(msg, event, action);

        return false;
    }
}

shouldSchedule() — это isPhaseInRange(angle, currentPhase, nextPhase): попадает ли угол между этим зубом и следующим, с переходом 720 → 0. Если нет, событие ждёт в списке, и на каждом зубе scheduleEventsUntilNextTriggerTooth() переводит во время всё, чему пора. В стенде то же делают phase_in_range(), sched_by_angle(), ang_schedule_or_queue() и ang_on_tooth() (ang19m.c), сдвиг в тактах — через int32_t (макрос F2T в plat19m.h). Зажигание на зубе целиком:

/* Как rusEFI: на каждом зубе — только то, что случится до следующего зуба. */
static void schedule_from_tooth(tick_t edge, float cur, float next)
{
    ang_on_tooth(&g_q, edge, cur, next); /* искры, ждавшие этого зуба */
    for (int i = 0; i < CYL; i++) {
        inj_ev *f = &inj[i];
        if (!f->open && phase_in_range(f->start_angle, cur, next)) {
            if (D.limited)
                g_st.cut_fuel++;
            else
                start_injection(f, edge, angle_from(f->start_angle, cur));
        }
    }
    for (int i = 0; i < CYL; i++) {
        ign_ev *e = &ign[i];
        if (e->charging || !phase_in_range(e->dwell_angle, cur, next))
            continue;
        if (D.limited) {
            g_st.cut_spark++; /* катушку не заряжаем — искры не будет */
            continue;
        }
        tick_t charge_at = sched_by_angle(&g_q, &e->dwell_slot, edge, angle_from(e->dwell_angle, cur),
                                          coil_on, e);
        int by_time = ang_schedule_or_queue(&g_q, &e->spark, edge, e->spark_angle, coil_fire, e, cur, next);
        if (!by_time && C.overdwell)
            sched(&e->spark.slot, charge_at + F2T(1.5f * C.dwell_ms * 1000.0f * (TICK_HZ / 1000000)),
                  coil_overdwell, e);
    }
}

19м.5 Впрыск: открыть по углу, закрыть по времени

Открытие форсунки ставится по углу, закрытие — через длительность от открытия (InjectionEvent::onTriggerTooth()):

// firmware/controllers/engine_cycle/main_trigger_callback.cpp (rusEFI, GPL-3.0)
    // Schedule opening (stage 1 + stage 2 open together)
    efitick_t startTime = scheduleByAngle(nullptr, nowNt, angleFromNow, startAction);

    // Schedule closing stage 1
    efitick_t turnOffTimeStage1 = startTime + US2NT((int)durationUsStage1);

nullptr — слот из пула. Импульс короче 50 мкс не ставится: события могут поменяться местами, и форсунка останется открытой. Угол открытия считает computeInjectionAngle() (fuel_schedule.cpp). Фаза injectionOffset — градусы после ВМТ сжатия, по умолчанию PORT_INJECTION_OFFSET −400° (defaults.h): впрыск кончается на такте выпуска, на закрытый впускной клапан. В режиме InjectionTimingMode::End открытие сдвинуто назад на всю длительность:

// firmware/controllers/engine_cycle/fuel_schedule.cpp (rusEFI, GPL-3.0)
    angle_t openingAngle = injectionOffset - injectionDurationAngle;

Стенд — в режиме End, импульс 3 мс:

/* Импульс форсунки: открыть по углу, закрыть по времени. */
static void start_injection(inj_ev *e, tick_t edge, float offset)
{
    tick_t open_at = edge + F2T(offset * g_tick_per_deg);
    e->planned_end = e->end_angle;
    sched(0, open_at, inj_open, e);
    sched(0, open_at + F2T(C.inj_ms * 1000.0f * (TICK_HZ / 1000000)), inj_close, e);
}

Угол следующего импульса пересчитывается при закрытии (у rusEFI turnInjectionPinLow() зовёт event->update(), у стенда inj_close() — plan_inj()), то есть по оборотам прошлого цикла.


19м.6 Разгон и сброс между зубьями: три способа на модели

Модель коленвала (eng19m.c) задаёт обороты отрезками с постоянным ускорением, угол и время прихода к углу считаются точно, в double; из неё же берутся зубья 60-2. Часы виртуальные — время прыгает к ближайшему зубу или сравнению таймера. Когда ЭБУ гасит катушку, стенд смотрит, на каком угле вал на самом деле, и вычитает плановый угол. Плюс — искра позже плана.

Способы: от метки — на первом зубе после пропуска весь цикл вперёд переводится во время по оборотам за цикл; от зуба — как rusEFI, обороты средние за прошлые 720°; от зуба, мгновенные — обороты по последним 90°, как с настройкой rusEFI alwaysInstantRpm:

// firmware/controllers/engine_cycle/rpm_calculator.cpp (rusEFI, GPL-3.0)
    if (alwaysInstantRpm) {
        rpmState->setRpmValue(instantRpm);

Замер — после трёх циклов, когда ЭБУ уже знает обороты; накопление 4 мс, импульс 3 мс, страж включён.

Опыт Способ Искр Ошибка искры, ° Средняя, ° Накопление, мс По защите Отказов Конец впрыска, °
разгон 1000 → 7000 за 1 с от метки 134 0,110…249,077 19,489 3,447…4,000 0 8 1,561…298,333
разгон 1000 → 7000 за 1 с от зуба 141 −0,005…2,099 0,144 2,047…3,997 0 0 1,545…18,985
разгон 1000 → 7000 за 1 с от зуба, мгновенные 141 −0,012…0,144 0,009 2,589…4,000 0 0 −0,020…10,050
рывок 2000 → 6000 за 0,25 с от метки 69 0,002…161,192 20,443 4,000…4,000 0 6 −0,002…231,043
рывок 2000 → 6000 за 0,25 с от зуба 73 0,002…2,241 0,211 2,370…4,000 0 0 −0,002…27,320
рывок 2000 → 6000 за 0,25 с от зуба, мгновенные 73 −0,006…0,150 0,011 2,906…4,000 0 0 −0,002…14,486
сброс 6000 → 1500 за 0,5 с от метки 72 −155,405…−0,001 24,682 4,000…4,000 0 0 −158,419…−0,414
сброс 6000 → 1500 за 0,5 с от зуба 69 −1,258…−0,001 0,108 4,012…6,266 3 0 −20,856…−0,387
сброс 6000 → 1500 за 0,5 с от зуба, мгновенные 72 −0,092…0,000 0,011 4,000…5,586 0 0 −10,916…−0,001

Ровно 3000 — способы равны: 100 искр, ошибка −0,002° — округление до такта 0,25 мкс и точность float.

От метки — непригодно при любой смене оборотов. На разгоне ошибка до 249,077° — больше, чем 180° между цилиндрами; слоты прошлого цикла ещё ждут срока, когда приходит новая метка: 8 отказов очереди, искр 134 вместо 141. На сбросе искра раньше плана, ошибка до −155,405°.

От зуба — градусы вместо сотен градусов: на разгоне до 2,099°, на рывке до 2,241°. Ни одна искра не переводилась во время дальше 6° от зуба; остаток ошибки — от оборотов, средние за 720° отстают почти на цикл. Мгновенные обороты убирают и его: до 0,144° на разгоне, 0,150° на рывке, −0,092° на сбросе.

Накопление и впрыск уязвимы при любом способе. Угол накопления посчитан при прошлой искре, на разгоне градусы проходят быстрее — катушка заряжается 2,047 мс вместо 4 (мгновенные — 2,589 мс), на сбросе до 6,266 мс. Конец впрыска на разгоне опаздывает до 18,985° (мгновенные — 10,050°).


19м.7 Страж перезаряда и отсечка

Страж. Искра, которая ждёт зуба, может его не дождаться: датчик оборвался, двигатель заглох — катушка останется под током. Если искра легла в список ожидания, scheduleSparkEvent() (spark_logic.cpp) ставит в тот же слот искру «по часам»:

// firmware/controllers/engine_cycle/spark_logic.cpp (rusEFI, GPL-3.0)
            // auto fire spark at 1.5x nominal dwell
            efitick_t fireTime = sumTickAndFloat(chargeTime, MSF2NT(1.5f * dwellMs));

Пришёл зуб — scheduleEventsUntilNextTriggerTooth() снимает стража и ставит искру по углу; не пришёл — overFireSparkAndPrepareNextSchedule() гасит катушку (в стенде — coil_overdwell()). Опыт: 3000 об/мин, зубья пропадают, когда катушка 1 начала заряжаться:

защита 1,5 накопления: искр по защите 1, катушка под током до 6.000 мс
без защиты: искр по защите 0, катушка под током до 4.000 мс, к концу опыта не погасли катушек 1, горит уже 115.9 мс

Со стражем катушка погасла через 6 мс (1,5 × 4). Без него горит до конца опыта — 115,9 мс — и дальше, пока не вернутся зубья. Обратная сторона: на сбросе 6000 → 1500 способом «от зуба» страж сработал 3 раза — угол накопления посчитан по ещё высоким оборотам, вал замедлился, 6 мс истекли раньше зуба искры, и искра проскочила раньше плана на 9,337°. С мгновенными оборотами — ни разу.

Отсечка. LimpManager (controllers/limp_manager.cpp) разрешает или запрещает топливо и искру. По умолчанию (default_base_engine.cpp) rpmHardLimit = 7000, rpmHardLimitHyst = 50, режутся оба:

// firmware/controllers/limp_manager.cpp (rusEFI, GPL-3.0)
    updateRevLimit(rpm);
    if (m_revLimitHysteresis.test(rpm, m_revLimit, resumeRpm)) {
        if (engineConfiguration->cutFuelOnHardLimit) {
            allowFuel.clear(ClearReason::HardLimit);
        }

        if (engineConfiguration->cutSparkOnHardLimit) {
            allowSpark.clear(ClearReason::HardLimit);
        }

Hysteresis::test() (hysteresis.h) ставит запрет выше 7000 и снимает ниже 6950; обновляет его onFastCallback() — быстрый цикл (FAST_CALLBACK_RATE — Period200hz в main_loop.h), а не зуб. При запрете накопление не начинается, но начатое доводится до искры, чтобы катушка не сгорела. Стенд проверяет порог раз за цикл:

    if (cur == 0.0f) { /* начало цикла: обороты за цикл и ограничитель */
        if (D.have_cycle) {
            D.rpm_cycle = 120.0f * (float)TICK_HZ / T2F(edge - D.cycle_t);
            if (D.rpm_cycle > g_st.rpm_max)
                g_st.rpm_max = D.rpm_cycle;
            if (D.rpm_cycle > C.rev_limit)
                D.limited = 1;
            else if (D.rpm_cycle < C.rev_limit - C.rev_hyst)
                D.limited = 0;
        }
        D.cycle_t = edge;
        D.have_cycle = 1;
    }

Игрушечная модель: с искрами обороты растут на 4000 об/мин за секунду, без искр падают на 8000; старт с 6500, 1 с:

срезано искр 72, впрысков 72; оборотов без искр 42; обороты ЭБУ за цикл до 7087.4; настоящие обороты от 6717.9 до 7100.0

Возврат — 50 об/мин, а вал ходит от 6717,9 до 7100,0: решение принимается раз за цикл по средним за прошлый цикл оборотам.


19м.8 Прошивка для STM32F407 и тот же стенд в QEMU

fw19m.c: время и очередь, как у rusEFI, на одном 32-битном TIM5; зубья в этой прошивке ловит захват на том же таймере. 168 МГц от кварца 8 МГц, APB1 — 42 МГц, таймеры на ней — удвоенные 84 МГц (RM0090), делитель 21 → 4 МГц. Канал 2 (PA1, AF2 по DS8626) — захват зубьев, канал 1 — сравнение. Вектор TIM5 — номер 50 (RM0090; TIM5_IRQn = 50 в stm32f407xx.h). Режим сравнения OC1M = 001, как у rusEFI (microsecond_timer_stm32.cpp: STM32_TIM_CCMR1_OC1M(1)):

void plat_set_compare(tick_t t)
{
    TIM5->CCR1 = (uint32_t)t;
    TIM5->SR = ~TIM_SR_CC1IF;
    TIM5->DIER |= TIM_DIER_CC1IE;
    if ((int32_t)((uint32_t)t - TIM5->CNT) <= 0)
        TIM5->EGR = TIM_EGR_CC1G; /* срок прошёл, пока писали регистр, — прерывание сразу */
}

Срок прошёл, пока писали CCR1, — совпадения не будет до следующего оборота счётчика (1073 с), поэтому событие запрашивается программно (EGR = CC1G); у rusEFI такая ветка есть для AT32F4.

Сборка arm-none-eabi-gcc 12.2.1, -Os, Cortex-M4F:

   text    data     bss     dec     hex filename
    542       0       0     542     21e evq19m.o
    446       0       8     454     1c6 ang19m.o
   2114       0    2072    4186    105a ecu19m.o
    558       0       8     566     236 fw19m.o
    374       0       0     374     176 start19m.o
Сборка Код (text) ОЗУ (bss) Библиотечные функции
fw19m.elf, сдвиг через int32_t 4592 2088 нет
fw64.elf, -DTICK_CAST64 6824 2088 __aeabi_dadd, __aeabi_dmul, __aeabi_f2lz, __aeabi_l2f

В evq19m, ang19m и ecu19m нет вызовов библиотеки double, в ecu19m.o 140 команд FPU. Прямое float → int64_t (-DTICK_CAST64) приводит __aeabi_f2lz, __aeabi_l2f, а с ними __aeabi_dadd и __aeabi_dmul: +2232 байта и программная арифметика double в обработчике зуба. Вот зачем sumTickAndFloat().

Тот же sim19m.c собран под Cortex-M4F (-O2) и запущен в QEMU 7.2.22 на netduinoplus2 (STM32F405, то же ядро) с выводом через полухостинг. Часы стенда виртуальные, и вывод совпал с компьютером знак в знак во всех пяти опытах: float на FPU считает план и перевод угла так же. Захват и сравнение TIM5 в этом прогоне не участвуют.


19м.9 Грабли

Искра уезжает на десятки градусов при разгоне. Весь цикл поставлен от метки — ставить только до следующего зуба.

На разгоне слабая искра. Угол накопления посчитан по старым оборотам (2,047 мс вместо 4); мгновенные обороты помогают не до конца.

На сбросе газа искра раньше на 9,337°. Сработал страж перезаряда. Лечится точными оборотами, а не отключением стража.

Событие через 1–2 мкс после соседнего опаздывает. Таймер не заводится ближе 2 мкс — близкие события ждать в цикле (задержка очереди).

Событие молча не встаёт в очередь. Его слот ещё ждёт прошлого срока; у rusEFI insertTask() возвращает false.

float → int64 тянет double. Сдвиг меньше 536 с помещается в int32_t тактов 4 МГц — приводить к нему.

Отсечка «пилит» сильнее гистерезиса. Решение раз за цикл по средним оборотам.

Сравнение пропущено, пока писали регистр. Сверить срок со счётчиком и при опоздании выставить CC1G.


19м.10 Итоги


Полные исходники

Каталог _verify/rusefi19m/; копии цитируемых файлов rusEFI с license.txt — в rusefi/.

plat19m.h — что очередь и ЭБУ ждут от платформы: время, таймер, выходы.

/* plat19m.h — раздел 19м: что планировщик и ЭБУ ждут от платформы.
 * Время — такты 4 МГц (0,25 мкс), как у rusEFI; 64 бита, чтобы не думать о переполнении.
 * На компьютере и в QEMU эти функции ведут виртуальные часы стенда (sim19m.c),
 * на STM32F407 — таймер TIM5 (fw19m.c). */
#ifndef PLAT19M_H
#define PLAT19M_H
#include <stdint.h>

typedef int64_t tick_t;
#define TICK_HZ 4000000
#define US2T(us) ((tick_t)(us) * (TICK_HZ / 1000000))

/* float <-> такты через int32, как sumTickAndFloat() в rusEFI: преобразование float <-> int64
 * на Cortex-M4 делает библиотека, и идёт она через double. Сдвиги здесь меньше 536 с. */
#ifdef TICK_CAST64
#define F2T(x) ((tick_t)(x))
#define T2F(x) ((float)(x))
#else
#define F2T(x) ((tick_t)(int32_t)(x))
#define T2F(x) ((float)(int32_t)(x))
#endif

tick_t plat_now(void);           /* текущее время, такты */
void plat_wait_until(tick_t t);  /* короткое ожидание в цикле (не дольше задержки очереди) */
void plat_set_compare(tick_t t); /* завести прерывание таймера на момент t */
void plat_pin(int ch, int level); /* выход: 0..3 — катушки, 4..7 — форсунки */

#endif

evq19m.h — очередь событий по времени.

/* evq19m.h — раздел 19м: очередь событий по времени на одном аппаратном таймере.
 * Устроена как EventQueue + SingleTimerExecutor в rusEFI: отсортированный список,
 * пул свободных слотов, одно прерывание сравнения на всю очередь. */
#ifndef EVQ19M_H
#define EVQ19M_H
#include "plat19m.h"

typedef void (*ev_fn)(void *arg);

typedef struct ev_slot {
    struct ev_slot *next;
    tick_t at;      /* когда выполнить */
    ev_fn fn;       /* NULL — слот не стоит в очереди */
    void *arg;
    uint8_t pooled; /* слот из пула: после выполнения вернётся в пул */
} ev_slot;

#define EVQ_POOL 32

typedef struct {
    ev_slot *head;  /* очередь по возрастанию at */
    ev_slot *free;  /* свободные слоты пула */
    ev_slot pool[EVQ_POOL];
    tick_t late;    /* событие ближе этого срока ждём в цикле, а не заводим таймер */
    uint8_t in_isr;
    /* счётчики для стенда */
    uint32_t irq;      /* прерываний таймера */
    uint32_t run;      /* выполнено событий */
    uint32_t spin;     /* из них дождались в цикле */
    uint32_t refused;  /* отказов: слот уже стоит в очереди или пул пуст */
    uint32_t max_len;  /* самая длинная очередь */
    uint32_t used, max_pool;
} evq_t;

void evq_init(evq_t *q, tick_t late);
int evq_schedule(evq_t *q, ev_slot *s, tick_t at, ev_fn fn, void *arg);
void evq_cancel(evq_t *q, ev_slot *s);
void evq_isr(evq_t *q);
int evq_len(const evq_t *q);

#endif

evq19m.c — очередь событий: вставка, отмена, прерывание сравнения.

/* evq19m.c — раздел 19м: очередь событий по времени на одном таймере сравнения. */
#include "evq19m.h"

void evq_init(evq_t *q, tick_t late)
{
    q->head = 0;
    q->free = 0;
    q->late = late;
    q->in_isr = 0;
    q->irq = q->run = q->spin = q->refused = q->max_len = q->used = q->max_pool = 0;
    for (int i = 0; i < EVQ_POOL; i++) {
        q->pool[i].fn = 0;
        q->pool[i].pooled = 1;
        q->pool[i].next = q->free;
        q->free = &q->pool[i];
    }
}

int evq_len(const evq_t *q)
{
    int n = 0;
    for (const ev_slot *s = q->head; s; s = s->next)
        n++;
    return n;
}

/* Вставка по времени — линейный поиск, событий в очереди единицы.
 * Возвращает 1, если событие встало в голову: тогда таймер надо перевести. */
static int insert(evq_t *q, ev_slot *s)
{
    if (!q->head || s->at < q->head->at) {
        s->next = q->head;
        q->head = s;
        return 1;
    }
    ev_slot *p = q->head;
    while (p->next && p->next->at <= s->at)
        p = p->next;
    s->next = p->next;
    p->next = s;
    return 0;
}

static void release(evq_t *q, ev_slot *s)
{
    s->fn = 0;
    s->next = 0;
    if (s->pooled) {
        s->next = q->free;
        q->free = s;
        q->used--;
    }
}

/* Завести таймер на голову очереди. Опоздавшее событие — как можно скорее,
 * но не ближе 2 мкс: иначе сравнение может проскочить мимо счётчика. */
static void arm_timer(evq_t *q)
{
    if (!q->head)
        return;
    tick_t now = plat_now(), at = q->head->at;
    if (at <= now)
        at = now + q->late;
    if (at < now + US2T(2))
        at = now + US2T(2);
    plat_set_compare(at);
}

/* s == NULL — взять слот из пула (событие «выстрелил и забыл»).
 * Возвращает 1 — встало в голову, 0 — не в голову, -1 — отказ. */
int evq_schedule(evq_t *q, ev_slot *s, tick_t at, ev_fn fn, void *arg)
{
    if (!s) {
        s = q->free;
        if (!s) {
            q->refused++;
            return -1;
        }
        q->free = s->next;
        if (++q->used > q->max_pool)
            q->max_pool = q->used;
    } else if (s->fn) {
        q->refused++; /* слот уже ждёт своего срока: rusEFI тоже отказывает */
        return -1;
    }
    s->at = at;
    s->fn = fn;
    s->arg = arg;
    int first = insert(q, s);
    uint32_t n = (uint32_t)evq_len(q);
    if (n > q->max_len)
        q->max_len = n;
    if (first && !q->in_isr)
        arm_timer(q);
    return first;
}

void evq_cancel(evq_t *q, ev_slot *s)
{
    if (!s->fn)
        return;
    for (ev_slot **pp = &q->head; *pp; pp = &(*pp)->next)
        if (*pp == s) {
            *pp = s->next;
            break;
        }
    release(q, s);
}

/* Прерывание сравнения: выполнить всё, чей срок наступил или наступит
 * в пределах late, и завести таймер на следующее. */
void evq_isr(evq_t *q)
{
    q->irq++;
    q->in_isr = 1;
    for (;;) {
        ev_slot *s = q->head;
        if (!s)
            break;
        tick_t now = plat_now();
        if (s->at > now + q->late)
            break;
        if (s->at > now) {
            plat_wait_until(s->at); /* дешевле подождать, чем выйти и войти снова */
            q->spin++;
        }
        q->head = s->next;
        ev_fn fn = s->fn;
        void *arg = s->arg;
        release(q, s); /* слот свободен до вызова: действие может поставить его снова */
        q->run++;
        fn(arg);
    }
    q->in_isr = 0;
    arm_timer(q);
}

ang19m.h — события по углу коленвала.

/* ang19m.h — раздел 19м: события по углу коленвала поверх очереди по времени.
 * Как TriggerScheduler в rusEFI: событие ждёт «своего» зуба, а от зуба до угла
 * остаётся меньше шага колеса — этот остаток и переводится во время. */
#ifndef ANG19M_H
#define ANG19M_H
#include "evq19m.h"

#define ENGINE_CYCLE 720.0f

typedef struct ang_ev {
    struct ang_ev *next; /* список «ждём зуба» */
    float angle;         /* угол цикла 0…720 */
    float offset;        /* сколько градусов от зуба пришлось перевести во время */
    ev_slot slot;        /* слот в очереди по времени */
    ev_fn fn;
    void *arg;
    uint8_t waiting;
} ang_ev;

extern float g_tick_per_deg; /* тактов таймера на градус при текущих оборотах */

int phase_in_range(float test, float cur, float next);
float angle_from(float target, float cur);
tick_t sched_by_angle(evq_t *q, ev_slot *s, tick_t edge, float angle, ev_fn fn, void *arg);
int ang_schedule_or_queue(evq_t *q, ang_ev *e, tick_t edge, float angle, ev_fn fn, void *arg,
                          float cur, float next);
void ang_on_tooth(evq_t *q, tick_t edge, float cur, float next);
void ang_cancel(ang_ev *e);
void ang_reset(void);

#endif

ang19m.c — события по углу: «ждём своего зуба» и перевод остатка во время.

/* ang19m.c — раздел 19м: события по углу коленвала. */
#include "ang19m.h"

float g_tick_per_deg;
static ang_ev *waiting_head;

void ang_reset(void)
{
    waiting_head = 0;
    g_tick_per_deg = 0;
}

/* Попадает ли угол test в промежуток [cur, next) с переходом через 720 -> 0. */
int phase_in_range(float test, float cur, float next)
{
    if (next > cur)
        return test >= cur && test < next;
    return test >= cur || test < next;
}

/* Сколько градусов от cur до target вперёд по циклу. */
float angle_from(float target, float cur)
{
    float a = target - cur;
    if (a < 0)
        a += ENGINE_CYCLE;
    return a;
}

/* Угол во время: «через angle градусов от фронта edge» при текущих оборотах. */
tick_t sched_by_angle(evq_t *q, ev_slot *s, tick_t edge, float angle, ev_fn fn, void *arg)
{
    tick_t at = edge + F2T(angle * g_tick_per_deg);
    evq_schedule(q, s, at, fn, arg);
    return at;
}

/* Событие в промежутке до следующего зуба — сразу во время.
 * Иначе — в список ожидания до своего зуба. Возвращает 1, если поставлено по времени. */
int ang_schedule_or_queue(evq_t *q, ang_ev *e, tick_t edge, float angle, ev_fn fn, void *arg,
                          float cur, float next)
{
    e->angle = angle;
    e->fn = fn;
    e->arg = arg;
    if (phase_in_range(angle, cur, next)) {
        e->offset = angle_from(angle, cur);
        sched_by_angle(q, &e->slot, edge, e->offset, fn, arg);
        return 1;
    }
    if (!e->waiting) {
        e->waiting = 1;
        e->next = waiting_head;
        waiting_head = e;
    }
    return 0;
}

void ang_cancel(ang_ev *e)
{
    for (ang_ev **pp = &waiting_head; *pp; pp = &(*pp)->next)
        if (*pp == e) {
            *pp = e->next;
            break;
        }
    e->waiting = 0;
    e->next = 0;
}

/* На каждом зубе: всё, что ждало и попадает в [cur, next), — в очередь по времени.
 * Слот события мог быть занят защитой от перезаряда — снимаем её и ставим искру. */
void ang_on_tooth(evq_t *q, tick_t edge, float cur, float next)
{
    ang_ev **pp = &waiting_head;
    while (*pp) {
        ang_ev *e = *pp;
        if (phase_in_range(e->angle, cur, next)) {
            *pp = e->next;
            e->waiting = 0;
            e->next = 0;
            evq_cancel(q, &e->slot);
            e->offset = angle_from(e->angle, cur);
            sched_by_angle(q, &e->slot, edge, e->offset, e->fn, e->arg);
        } else {
            pp = &e->next;
        }
    }
}

ecu19m.h — ЭБУ: настройки, события искры и впрыска, счётчики.

/* ecu19m.h — раздел 19м: искра и впрыск по углу коленвала, колесо 60-2, 4 цилиндра.
 * Порядок работы 1-3-4-2, ВМТ сжатия цилиндров — 0, 180, 360 и 540° цикла;
 * фаза цикла (какой из двух оборотов) считается известной, как с датчиком распредвала. */
#ifndef ECU19M_H
#define ECU19M_H
#include "ang19m.h"

#define CYL 4

enum {
    ECU_FROM_SYNC = 0,  /* всё за цикл вперёд от метки колеса, по оборотам за цикл */
    ECU_FROM_TOOTH = 1, /* от ближайшего зуба, по оборотам за цикл — как rusEFI */
    ECU_INSTANT = 2     /* от ближайшего зуба, по мгновенным оборотам за 90° */
};

typedef struct {
    int mode;
    int overdwell;   /* 1 — защита от перезаряда: искра не позже 1,5 накопления */
    float dwell_ms;  /* накопление катушки */
    float inj_end;   /* конец впрыска, градусы после ВМТ сжатия (−400 — на выпуске) */
    float inj_ms;    /* длительность импульса форсунки */
    float rev_limit; /* отсечка, об/мин */
    float rev_hyst;  /* возврат ниже отсечки на столько */
    int late_us;     /* задержка очереди: ближе — ждать в цикле */
} ecu_cfg;

typedef struct {
    int cyl;
    float tdc;            /* ВМТ сжатия этого цилиндра в цикле */
    float spark_angle;    /* план: угол искры */
    float dwell_angle;    /* план: угол начала накопления */
    ev_slot dwell_slot;
    ang_ev spark;         /* искра; её слот занимает и защита от перезаряда */
    int charging;
    tick_t charge_t;
} ign_ev;

typedef struct {
    int cyl;
    float tdc;
    float start_angle, end_angle; /* план: открыть и закрыть */
    float planned_end;            /* с каким концом поставлен текущий импульс */
    int open;
} inj_ev;

typedef struct {
    uint32_t teeth, syncs, sync_err;
    uint32_t sparks, overdwell, cut_spark, cut_fuel, refused, injections;
    float rpm_max;
} ecu_stat;

extern evq_t g_q;
extern ecu_stat g_st;
extern void (*ecu_on_spark)(const ign_ev *e, int by_overdwell);
extern void (*ecu_on_inj)(const inj_ev *e);

void ecu_init(const ecu_cfg *c);
void ecu_tooth(tick_t edge);
float ecu_rpm(void);
float advance_deg(float rpm);

#endif

ecu19m.c — ЭБУ: декодер, обороты, план, искра, впрыск, отсечка.

/* ecu19m.c — раздел 19м: от зуба колеса к искре и впрыску. */
#include <math.h>
#include "ecu19m.h"

#define TEETH 58         /* 60-2: 58 зубьев */
#define TOOTH_DEG 6.0f   /* шаг колеса */
#define HIST 20          /* история зубьев для мгновенных оборотов */

evq_t g_q;
ecu_stat g_st;
void (*ecu_on_spark)(const ign_ev *e, int by_overdwell);
void (*ecu_on_inj)(const inj_ev *e);

static ecu_cfg C;
static ign_ev ign[CYL];
static inj_ev inj[CYL];

typedef struct {
    tick_t last, prev_dt, cycle_t;
    int synced, idx, rev, have_cycle, planned, limited;
    float rpm_cycle, rpm_inst;
    int32_t cum;               /* угол от синхронизации, градусы */
    tick_t hist_t[HIST];
    int32_t hist_a[HIST];
    int hn;
} dec_t;
static dec_t D;

static float wrap720(float a)
{
    while (a < 0)
        a += ENGINE_CYCLE;
    while (a >= ENGINE_CYCLE)
        a -= ENGINE_CYCLE;
    return a;
}

/* Миллисекунд на градус — как getOneDegreeTimeMs() в rusEFI. */
static float ms_per_deg(float rpm)
{
    return 1000.0f * 60 / 360 / rpm;
}

/* Опережение по оборотам: эвристика карты rusEFI по умолчанию (getInitialAdvance)
 * при давлении во впуске 100 кПа и наибольшем угле 35°. */
float advance_deg(float rpm)
{
    float a;
    if (rpm >= 3000.0f)
        a = 35.0f;
    else if (rpm < 600.0f)
        a = 10.0f;
    else
        a = 10.0f + (rpm - 600.0f) * (35.0f - 10.0f) / (3000.0f - 600.0f);
    return roundf(a * 10.0f) / 10.0f;
}

float ecu_rpm(void)
{
    return C.mode == ECU_INSTANT ? D.rpm_inst : D.rpm_cycle;
}

/* План искры: угол искры = ВМТ − опережение, накопление — заданное время,
 * переведённое в градусы по оборотам на момент плана. */
static void plan_ign(ign_ev *e, float rpm)
{
    e->spark_angle = wrap720(e->tdc - advance_deg(rpm));
    e->dwell_angle = wrap720(e->spark_angle - C.dwell_ms / ms_per_deg(rpm));
}

/* План впрыска: закрыть на inj_end после ВМТ, открыть раньше на длительность. */
static void plan_inj(inj_ev *e, float rpm)
{
    e->end_angle = wrap720(C.inj_end + e->tdc);
    e->start_angle = wrap720(e->end_angle - C.inj_ms / ms_per_deg(rpm));
}

static void fire(ign_ev *e, int by_overdwell)
{
    plat_pin(e->cyl, 0);
    if (e->charging) {
        g_st.sparks++;
        if (ecu_on_spark)
            ecu_on_spark(e, by_overdwell);
    }
    e->charging = 0;
    float rpm = ecu_rpm();
    if (rpm > 0)
        plan_ign(e, rpm); /* следующий цикл — по свежим оборотам */
}

static void coil_on(void *arg)
{
    ign_ev *e = arg;
    e->charging = 1;
    e->charge_t = plat_now();
    plat_pin(e->cyl, 1);
}

static void coil_fire(void *arg)
{
    fire(arg, 0);
}

/* Зуб искры не пришёл вовремя: искра по времени, чтобы не сжечь катушку. */
static void coil_overdwell(void *arg)
{
    ign_ev *e = arg;
    ang_cancel(&e->spark);
    g_st.overdwell++;
    fire(e, 1);
}

static void inj_open(void *arg)
{
    inj_ev *e = arg;
    e->open = 1;
    plat_pin(4 + e->cyl, 1);
}

static void inj_close(void *arg)
{
    inj_ev *e = arg;
    e->open = 0;
    plat_pin(4 + e->cyl, 0);
    g_st.injections++;
    if (ecu_on_inj)
        ecu_on_inj(e);
    float rpm = ecu_rpm();
    if (rpm > 0)
        plan_inj(e, rpm);
}

static void sched(ev_slot *s, tick_t at, ev_fn fn, void *arg)
{
    if (evq_schedule(&g_q, s, at, fn, arg) < 0)
        g_st.refused++;
}

/* Импульс форсунки: открыть по углу, закрыть по времени. */
static void start_injection(inj_ev *e, tick_t edge, float offset)
{
    tick_t open_at = edge + F2T(offset * g_tick_per_deg);
    e->planned_end = e->end_angle;
    sched(0, open_at, inj_open, e);
    sched(0, open_at + F2T(C.inj_ms * 1000.0f * (TICK_HZ / 1000000)), inj_close, e);
}

/* Старый способ: на метке колеса весь цикл вперёд переводится во время. */
static void schedule_from_sync(tick_t edge)
{
    for (int i = 0; i < CYL; i++) {
        ign_ev *e = &ign[i];
        inj_ev *f = &inj[i];
        if (D.limited) {
            g_st.cut_spark++;
            g_st.cut_fuel++;
            continue;
        }
        e->spark.angle = e->spark_angle;
        e->spark.offset = e->spark_angle;
        tick_t spark_at = edge + F2T(e->spark_angle * g_tick_per_deg);
        tick_t dwell_at = spark_at - F2T(C.dwell_ms * 1000.0f * (TICK_HZ / 1000000));
        if (dwell_at < edge)
            dwell_at = edge; /* накопление переходит через метку — укорачиваем */
        sched(&e->dwell_slot, dwell_at, coil_on, e);
        sched(&e->spark.slot, spark_at, coil_fire, e);
        start_injection(f, edge, f->start_angle);
    }
}

/* Как rusEFI: на каждом зубе — только то, что случится до следующего зуба. */
static void schedule_from_tooth(tick_t edge, float cur, float next)
{
    ang_on_tooth(&g_q, edge, cur, next); /* искры, ждавшие этого зуба */
    for (int i = 0; i < CYL; i++) {
        inj_ev *f = &inj[i];
        if (!f->open && phase_in_range(f->start_angle, cur, next)) {
            if (D.limited)
                g_st.cut_fuel++;
            else
                start_injection(f, edge, angle_from(f->start_angle, cur));
        }
    }
    for (int i = 0; i < CYL; i++) {
        ign_ev *e = &ign[i];
        if (e->charging || !phase_in_range(e->dwell_angle, cur, next))
            continue;
        if (D.limited) {
            g_st.cut_spark++; /* катушку не заряжаем — искры не будет */
            continue;
        }
        tick_t charge_at = sched_by_angle(&g_q, &e->dwell_slot, edge, angle_from(e->dwell_angle, cur),
                                          coil_on, e);
        int by_time = ang_schedule_or_queue(&g_q, &e->spark, edge, e->spark_angle, coil_fire, e, cur, next);
        if (!by_time && C.overdwell)
            sched(&e->spark.slot, charge_at + F2T(1.5f * C.dwell_ms * 1000.0f * (TICK_HZ / 1000000)),
                  coil_overdwell, e);
    }
}

void ecu_init(const ecu_cfg *c)
{
    C = *c;
    D = (dec_t){0};
    g_st = (ecu_stat){0};
    evq_init(&g_q, US2T(c->late_us));
    ang_reset();
    for (int i = 0; i < CYL; i++) {
        ign[i] = (ign_ev){0};
        inj[i] = (inj_ev){0};
        ign[i].cyl = inj[i].cyl = i;
        ign[i].tdc = inj[i].tdc = 180.0f * (float)i;
    }
}

void ecu_tooth(tick_t edge)
{
    g_st.teeth++;
    tick_t dt = edge - D.last;
    D.last = edge;
    /* пропуск: зуб в 2,25…3,75 раза длиннее прошлого — окно rusEFI для 60-2 */
    int gap = D.prev_dt > 0 && dt * 4 > D.prev_dt * 9 && dt * 4 < D.prev_dt * 15;
    D.prev_dt = dt;
    if (gap) {
        if (D.synced && D.idx != TEETH - 1) {
            g_st.sync_err++;
            D.synced = 0;
        }
        if (!D.synced) {
            D.synced = 1;
            D.rev = 0;
            D.cum = 0;
            D.hn = 0;
            D.have_cycle = 0;
        } else {
            D.rev ^= 1;
            D.cum += 18;
        }
        D.idx = 0;
        g_st.syncs++;
    } else if (D.synced) {
        D.cum += 6;
        if (++D.idx >= TEETH) {
            D.synced = 0;
            g_st.sync_err++;
        }
    }
    if (!D.synced)
        return;

    /* мгновенные обороты — по последним 90° (как instant_rpm_calculator.cpp) */
    D.hist_t[D.hn % HIST] = edge;
    D.hist_a[D.hn % HIST] = D.cum;
    D.hn++;
    for (int k = 1; k < HIST && k < D.hn; k++) {
        int j = (D.hn - 1 - k) % HIST;
        if (D.hist_a[j] <= D.cum - 90) {
            D.rpm_inst = (float)(D.cum - D.hist_a[j]) * ((float)TICK_HZ / 6.0f) / T2F(edge - D.hist_t[j]);
            break;
        }
    }

    float cur = 360.0f * (float)D.rev + TOOTH_DEG * (float)D.idx;
    float next = (D.idx == TEETH - 1) ? (D.rev ? 0.0f : 360.0f) : cur + TOOTH_DEG;

    if (cur == 0.0f) { /* начало цикла: обороты за цикл и ограничитель */
        if (D.have_cycle) {
            D.rpm_cycle = 120.0f * (float)TICK_HZ / T2F(edge - D.cycle_t);
            if (D.rpm_cycle > g_st.rpm_max)
                g_st.rpm_max = D.rpm_cycle;
            if (D.rpm_cycle > C.rev_limit)
                D.limited = 1;
            else if (D.rpm_cycle < C.rev_limit - C.rev_hyst)
                D.limited = 0;
        }
        D.cycle_t = edge;
        D.have_cycle = 1;
    }

    float rpm = ecu_rpm();
    if (rpm <= 0)
        return; /* оборотов ещё нет — как rusEFI при rpm == 0 */
    g_tick_per_deg = (float)TICK_HZ * 60 / 360 / rpm;
    if (!D.planned) {
        for (int i = 0; i < CYL; i++) {
            plan_ign(&ign[i], rpm);
            plan_inj(&inj[i], rpm);
        }
        D.planned = 1;
    }
    if (C.mode == ECU_FROM_SYNC) {
        if (cur == 0.0f)
            schedule_from_sync(edge);
    } else {
        schedule_from_tooth(edge, cur, next);
    }
}

eng19m.h — модель коленвала.

/* eng19m.h — раздел 19м: модель коленвала для стенда.
 * Обороты меняются по отрезкам с постоянным угловым ускорением; угол — точная парабола,
 * время прихода к углу — корень квадратного уравнения. Считается в double. */
#ifndef ENG19M_H
#define ENG19M_H

#define ENG_SEGS 64

typedef struct {
    double t0;  /* начало отрезка, с */
    double th0; /* угол в начале, градусы (накопленный, без деления на 720) */
    double w0;  /* скорость в начале, °/с */
    double a;   /* ускорение, °/с² */
} eng_seg;

typedef struct {
    eng_seg s[ENG_SEGS];
    int n;
} eng_t;

void eng_init(eng_t *m, double th0, double rpm);
void eng_ramp(eng_t *m, double t_from, double dur, double rpm_to);
void eng_accel(eng_t *m, double t_from, double rpm_per_s);
double eng_theta(const eng_t *m, double t);
double eng_rpm(const eng_t *m, double t);
double eng_time_of(const eng_t *m, double th);

#endif

eng19m.c — модель коленвала: отрезки с постоянным ускорением.

/* eng19m.c — раздел 19м: модель коленвала с меняющимися оборотами. */
#include <math.h>
#include "eng19m.h"

#define RPM2W 6.0 /* об/мин -> °/с */

static int seg_at(const eng_t *m, double t)
{
    int i = m->n - 1;
    while (i > 0 && m->s[i].t0 > t)
        i--;
    return i;
}

static void state(const eng_seg *s, double t, double *th, double *w)
{
    double dt = t - s->t0;
    *th = s->th0 + s->w0 * dt + 0.5 * s->a * dt * dt;
    *w = s->w0 + s->a * dt;
}

/* Обрезать будущее после t и начать с t новый отрезок с ускорением a. */
static void cut(eng_t *m, double t, double a)
{
    int i = seg_at(m, t);
    double th, w;
    state(&m->s[i], t, &th, &w);
    if (i + 1 >= ENG_SEGS - 1) { /* прошлое стенду не нужно: оставляем текущий отрезок */
        m->s[0] = m->s[i];
        i = 0;
    }
    if (m->s[i].t0 < t)
        i++;
    m->s[i] = (eng_seg){t, th, w, a};
    m->n = i + 1;
}

static void append_flat(eng_t *m, double t)
{
    double th, w;
    state(&m->s[m->n - 1], t, &th, &w);
    if (w < 0)
        w = 0;
    if (m->n < ENG_SEGS)
        m->s[m->n++] = (eng_seg){t, th, w, 0};
}

void eng_init(eng_t *m, double th0, double rpm)
{
    m->n = 1;
    m->s[0] = (eng_seg){0, th0, rpm * RPM2W, 0};
}

/* С момента t_from обороты линейно идут к rpm_to за dur секунд, дальше ровно. */
void eng_ramp(eng_t *m, double t_from, double dur, double rpm_to)
{
    double th, w;
    state(&m->s[seg_at(m, t_from)], t_from, &th, &w);
    cut(m, t_from, (rpm_to * RPM2W - w) / dur);
    append_flat(m, t_from + dur);
}

/* С момента t_from — постоянное ускорение; если оборотам суждено дойти до нуля — остановка. */
void eng_accel(eng_t *m, double t_from, double rpm_per_s)
{
    double a = rpm_per_s * RPM2W;
    cut(m, t_from, a);
    double w0 = m->s[m->n - 1].w0;
    if (a < 0)
        append_flat(m, t_from + w0 / -a);
}

double eng_theta(const eng_t *m, double t)
{
    double th, w;
    state(&m->s[seg_at(m, t)], t, &th, &w);
    return th;
}

double eng_rpm(const eng_t *m, double t)
{
    double th, w;
    state(&m->s[seg_at(m, t)], t, &th, &w);
    return w / RPM2W;
}

/* Когда вал дойдёт до угла th; -1 — не дойдёт никогда (встал). */
double eng_time_of(const eng_t *m, double th)
{
    for (int i = 0; i < m->n; i++) {
        const eng_seg *s = &m->s[i];
        if (i + 1 < m->n && th > m->s[i + 1].th0)
            continue;
        double d = th - s->th0;
        if (d <= 0)
            return s->t0;
        if (s->a == 0)
            return s->w0 > 0 ? s->t0 + d / s->w0 : -1;
        double disc = s->w0 * s->w0 + 2 * s->a * d;
        if (disc < 0)
            return -1;
        return s->t0 + 2 * d / (s->w0 + sqrt(disc));
    }
    return -1;
}

sim19m.c — стенд: виртуальные часы, опыты и замеры.

/* sim19m.c — раздел 19м: стенд. Модель вала даёт зубья колеса 60-2, ЭБУ ставит искру
 * и впрыск, стенд по модели смотрит, на каком угле вала они случились на самом деле.
 * Часы виртуальные: время прыгает к ближайшему событию — зубу или сравнению таймера.
 * Один и тот же файл собирается для компьютера (host19m.c) и для Cortex-M4F в QEMU (qemu19m.c). */
#include <math.h>
#include <stdint.h>
#include "ecu19m.h"
#include "eng19m.h"

void sim_out(const char *s);

#define NEVER INT64_MAX
#define TEETH_PER_REV 58

/* ── виртуальные часы и выходы (платформа для evq19m/ecu19m) ─────────────── */
static tick_t now_t, cmp_t;
static int teeth_stopped, break_armed;

tick_t plat_now(void)
{
    return now_t;
}

void plat_wait_until(tick_t t)
{
    if (t > now_t)
        now_t = t;
}

void plat_set_compare(tick_t t)
{
    cmp_t = t;
}

static tick_t coil_since[CYL];
static double coil_max_ms;
static double t_meas;

void plat_pin(int ch, int level)
{
    if (ch < CYL) {
        if (level) {
            coil_since[ch] = now_t;
            if (break_armed && ch == 0 && (double)now_t / TICK_HZ > t_meas)
                teeth_stopped = 1; /* обрыв датчика: катушка 1 заряжается, зубьев больше нет */
        } else if (coil_since[ch] >= 0) {
            double ms = (double)(now_t - coil_since[ch]) * 1000.0 / TICK_HZ;
            if (ms > coil_max_ms)
                coil_max_ms = ms;
            coil_since[ch] = -1;
        }
    }
}

/* ── вывод чисел без printf с плавающей точкой ───────────────────────────── */
static char L[640];
static int lp;

static void add(const char *s)
{
    while (*s && lp < (int)sizeof L - 2)
        L[lp++] = *s++;
}

static void emit(void)
{
    L[lp++] = '\n';
    L[lp] = 0;
    sim_out(L);
    lp = 0;
}

static void addi(long long v)
{
    char b[24];
    int i = 0;
    if (v < 0) {
        add("-");
        v = -v;
    }
    do {
        b[i++] = (char)('0' + v % 10);
        v /= 10;
    } while (v);
    while (i) {
        char c[2] = {b[--i], 0};
        add(c);
    }
}

static void addf(double v, int dec)
{
    long long m = 1;
    for (int i = 0; i < dec; i++)
        m *= 10;
    long long x = llround(v * (double)m);
    if (x < 0) {
        add("-");
        x = -x;
    }
    addi(x / m);
    if (dec) {
        add(".");
        long long f = x % m;
        for (long long d = m / 10; d; d /= 10) {
            char c[2] = {(char)('0' + f / d % 10), 0};
            add(c);
        }
    }
}

/* подпись и число одной строкой */
static void kf(const char *s, double v, int dec)
{
    add(s);
    addf(v, dec);
}

static void ki(const char *s, long long v)
{
    add(s);
    addi(v);
}

/* ── замеры ──────────────────────────────────────────────────────────────── */
static eng_t M;
typedef struct {
    long n, far_n, over, inj;
    double emin, emax, esum, far_max, dwmin, dwmax, imin, imax, over_emin;
} res_t;
static res_t R;
static long sparks_rev; /* для модели с отсечкой */

static double phase_now(void)
{
    double th = eng_theta(&M, (double)now_t / TICK_HZ);
    return fmod(th, 720.0);
}

static double wrap_err(double e)
{
    while (e >= 360)
        e -= 720;
    while (e < -360)
        e += 720;
    return e;
}

static void on_spark(const ign_ev *e, int by_overdwell)
{
    sparks_rev++;
    if ((double)now_t / TICK_HZ < t_meas)
        return;
    double err = wrap_err(phase_now() - (double)e->spark.angle);
    double dw = (double)(now_t - e->charge_t) * 1000.0 / TICK_HZ;
    if (by_overdwell) {
        if (R.over == 0 || err < R.over_emin)
            R.over_emin = err;
        R.over++;
        if (dw > R.dwmax)
            R.dwmax = dw;
        return;
    }
    if (R.n == 0 || err < R.emin)
        R.emin = err;
    if (R.n == 0 || err > R.emax)
        R.emax = err;
    if (R.n == 0 || dw < R.dwmin)
        R.dwmin = dw;
    if (R.n == 0 || dw > R.dwmax)
        R.dwmax = dw;
    R.esum += fabs(err);
    R.n++;
    if (e->spark.offset > 6.0f) { /* во время переведено больше шага колеса */
        if (R.far_n == 0 || fabs(err) > R.far_max)
            R.far_max = fabs(err);
        R.far_n++;
    }
}

static void on_inj(const inj_ev *e)
{
    if ((double)now_t / TICK_HZ < t_meas)
        return;
    double err = wrap_err(phase_now() - (double)e->planned_end);
    if (R.inj == 0 || err < R.imin)
        R.imin = err;
    if (R.inj == 0 || err > R.imax)
        R.imax = err;
    R.inj++;
}

/* ── прогон одного опыта ─────────────────────────────────────────────────── */
enum { P_FLAT, P_RAMP, P_LIMIT };

typedef struct {
    int profile;
    double rpm0, rpm1, dur; /* разгон или сброс: от rpm0 до rpm1 за dur секунд */
    double tail;            /* сколько смотреть после профиля, с */
} scen;

static ecu_cfg base_cfg(int mode)
{
    ecu_cfg c = {mode, 1, 4.0f, -400.0f, 3.0f, 7000.0f, 50.0f, 8};
    return c;
}

static uint32_t irq0, run0, spin0;
static double lim_true_max, lim_true_min;
static long lim_revs_cut;

static void run(const scen *sc, const ecu_cfg *cfg)
{
    ecu_init(cfg);
    ecu_on_spark = on_spark;
    ecu_on_inj = on_inj;
    R = (res_t){0};
    now_t = 0;
    cmp_t = NEVER;
    teeth_stopped = 0;
    coil_max_ms = 0;
    for (int i = 0; i < CYL; i++)
        coil_since[i] = -1;
    eng_init(&M, -361.0, sc->rpm0);
    t_meas = eng_time_of(&M, 2160.0); /* три цикла разгона стенда: ЭБУ уже знает обороты */
    if (sc->profile == P_RAMP)
        eng_ramp(&M, t_meas, sc->dur, sc->rpm1);
    if (sc->profile == P_LIMIT)
        eng_accel(&M, t_meas, sc->rpm1);
    double t_end = t_meas + sc->dur + sc->tail;
    long k = 0;
    int snap = 0;
    sparks_rev = 2;
    lim_true_max = 0;
    lim_true_min = 1e9;
    lim_revs_cut = 0;
    for (;;) {
        tick_t tt = NEVER;
        if (!teeth_stopped) {
            double th = -360.0 + 360.0 * (double)(k / TEETH_PER_REV) + 6.0 * (double)(k % TEETH_PER_REV);
            double t = eng_time_of(&M, th);
            if (t >= 0 && t <= t_end)
                tt = (tick_t)llround(t * TICK_HZ);
        }
        if (tt == NEVER && !teeth_stopped)
            break; /* опыт кончился: хвост очереди не смотрим */
        if (cmp_t != NEVER && cmp_t <= tt) {
            if (cmp_t > now_t)
                now_t = cmp_t;
            cmp_t = NEVER;
            evq_isr(&g_q);
        } else if (tt != NEVER) {
            if (tt > now_t)
                now_t = tt;
            if (!snap && (double)tt / TICK_HZ >= t_meas) {
                snap = 1;
                irq0 = g_q.irq;
                run0 = g_q.run;
                spin0 = g_q.spin;
            }
            if (sc->profile == P_LIMIT && k % TEETH_PER_REV == 0 && (double)tt / TICK_HZ > t_meas) {
                /* игрушечная модель: обороты растут, пока есть искры, и падают без них */
                double fired = (double)(sparks_rev > 2 ? 2 : sparks_rev) / 2.0;
                if (fired < 1)
                    lim_revs_cut++;
                eng_accel(&M, (double)tt / TICK_HZ, fired * sc->rpm1 - (1 - fired) * 2 * sc->rpm1);
                sparks_rev = 0;
                double r = eng_rpm(&M, (double)tt / TICK_HZ);
                if (r > lim_true_max)
                    lim_true_max = r;
                if (lim_true_max > 7000 && r < lim_true_min)
                    lim_true_min = r;
            }
            ecu_tooth(tt);
            k++;
        } else {
            break;
        }
    }
    if (now_t < (tick_t)llround(t_end * TICK_HZ))
        now_t = (tick_t)llround(t_end * TICK_HZ);
}

static const char *mode_name[3] = {"M0 от метки", "M1 от зуба", "M2 от зуба, мгновенные"};

static void report(const char *name, int mode)
{
    add(name);
    add(" | ");
    add(mode_name[mode]);
    ki(": искр ", R.n);
    kf(", ошибка от ", R.emin, 3);
    kf(" до ", R.emax, 3);
    kf(" град, средняя ", R.n ? R.esum / (double)R.n : 0, 3);
    ki("; дальше 6 град от зуба ", R.far_n);
    kf(" до ", R.far_max, 3);
    kf("; накопление ", R.dwmin, 3);
    kf("...", R.dwmax, 3);
    ki(" мс; по защите ", R.over);
    if (R.over) {
        kf(" (раньше плана до ", -R.over_emin, 3);
        add(" град)");
    }
    ki("; отказов очереди ", (long long)g_st.refused);
    ki("; впрыск ", R.inj);
    kf(", конец от ", R.imin, 3);
    kf(" до ", R.imax, 3);
    add(" град");
    emit();
}

int sim_main(void)
{
    add("== 1. План по оборотам: опережение (getInitialAdvance, 100 кПа, 35°) и накопление 4 мс");
    emit();
    static const double rpms[] = {600, 1000, 2000, 3000, 6000, 7000};
    for (int i = 0; i < 6; i++) {
        ki("об/мин ", (long long)rpms[i]);
        kf(": опережение ", (double)advance_deg((float)rpms[i]), 1);
        kf(" град, накопление 4 мс = ", 4.0 / (1000.0 * 60 / 360 / rpms[i]), 2);
        kf(" град, впрыск 3 мс = ", 3.0 / (1000.0 * 60 / 360 / rpms[i]), 2);
        kf(" град, зуб 6 град = ", 6.0 * 1e6 / (rpms[i] * 6.0), 1);
        add(" мкс");
        emit();
    }

    static const struct {
        const char *name;
        scen s;
    } list[] = {
        {"ровно 3000", {P_FLAT, 3000, 3000, 1.0, 0}},
        {"разгон 1000->7000 за 1 с", {P_RAMP, 1000, 7000, 1.0, 0.2}},
        {"рывок 2000->6000 за 0,25 с", {P_RAMP, 2000, 6000, 0.25, 0.2}},
        {"сброс 6000->1500 за 0,5 с", {P_RAMP, 6000, 1500, 0.5, 0.2}},
    };
    add("== 2. Ошибка момента искры и конца впрыска, градусы вала (плюс — позже плана)");
    emit();
    for (int i = 0; i < 4; i++)
        for (int m = 0; m < 3; m++) {
            ecu_cfg c = base_cfg(m);
            run(&list[i].s, &c);
            report(list[i].name, m);
        }

    add("== 3. Обрыв датчика на 3000 об/мин, пока заряжается катушка 1");
    emit();
    for (int od = 1; od >= 0; od--) {
        ecu_cfg c = base_cfg(ECU_FROM_TOOTH);
        c.overdwell = od;
        scen s = {P_FLAT, 3000, 3000, 0.1, 0.05};
        break_armed = 1;
        run(&s, &c);
        break_armed = 0;
        add(od ? "защита 1,5 накопления: " : "без защиты: ");
        ki("искр по защите ", R.over);
        kf(", катушка под током до ", od ? R.dwmax : coil_max_ms, 3);
        add(" мс");
        if (!od) {
            int on = 0;
            for (int i = 0; i < CYL; i++)
                on += coil_since[i] >= 0;
            ki(", к концу опыта не погасли катушек ", on);
            kf(", горит уже ", (double)(now_t - coil_since[0]) * 1000.0 / TICK_HZ, 1);
            add(" мс");
        }
        emit();
    }

    add("== 4. Отсечка 7000 об/мин, возврат на 50 ниже; модель: +4000 об/мин/с с искрами, -8000 без");
    emit();
    {
        ecu_cfg c = base_cfg(ECU_FROM_TOOTH);
        scen s = {P_LIMIT, 6500, 4000, 1.0, 0};
        run(&s, &c);
        ki("срезано искр ", (long long)g_st.cut_spark);
        ki(", впрысков ", (long long)g_st.cut_fuel);
        ki("; оборотов без искр ", lim_revs_cut);
        kf("; обороты ЭБУ за цикл до ", (double)g_st.rpm_max, 1);
        kf("; настоящие обороты от ", lim_true_min, 1);
        kf(" до ", lim_true_max, 1);
        emit();
    }

    add("== 5. Очередь на 6000 об/мин за 1 с: 4 искры и 4 впрыска за цикл; конец впрыска -394,95 град: на 0,05 град позже искры соседа");
    emit();
    for (int late = 0; late <= 8; late += 8) {
        ecu_cfg c = base_cfg(ECU_FROM_TOOTH);
        c.late_us = late;
        c.inj_end = -394.95f;
        scen s = {P_FLAT, 6000, 6000, 1.0, 0};
        run(&s, &c);
        ki("задержка ", late);
        ki(" мкс: прерываний таймера ", (long long)(g_q.irq - irq0));
        ki(", событий ", (long long)(g_q.run - run0));
        ki(", дождались в цикле ", (long long)(g_q.spin - spin0));
        ki("; длина очереди до ", (long long)g_q.max_len);
        ki(", слотов пула до ", (long long)g_q.max_pool);
        ki(" из ", EVQ_POOL);
        kf("; искра от ", R.emin, 3);
        kf(" до ", R.emax, 3);
        kf(" град, конец впрыска от ", R.imin, 3);
        kf(" до ", R.imax, 3);
        add(" град");
        emit();
    }
    add("== конец");
    emit();
    return 0;
}

host19m.c — стенд на компьютере.

/* host19m.c — раздел 19м: стенд на компьютере, вывод в stdout. */
#include <stdio.h>

int sim_main(void);

void sim_out(const char *s)
{
    fputs(s, stdout);
}

int main(void)
{
    return sim_main();
}

qemu19m.c — стенд в QEMU: вывод через полухостинг.

/* qemu19m.c — раздел 19м: тот же стенд sim19m.c на Cortex-M4F в QEMU netduinoplus2.
 * Вывод — через полухостинг (SYS_WRITE0), выход — SYS_EXIT. Часы стенда виртуальные,
 * поэтому вывод обязан совпасть с прогоном на компьютере знак в знак. */
#include <stdint.h>

int sim_main(void);

static uint32_t semihost(uint32_t op, const void *arg)
{
    register uint32_t r0 __asm("r0") = op;
    register const void *r1 __asm("r1") = arg;
    __asm volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory");
    return r0;
}

void sim_out(const char *s)
{
    semihost(0x04, s); /* SYS_WRITE0 */
}

int main(void)
{
    sim_main();
    semihost(0x18, (const void *)0x20026u); /* SYS_EXIT, ADP_Stopped_ApplicationExit */
    for (;;)
        ;
}

start19m.c — стартовый код Cortex-M4F.

/* start19m.c — раздел 19м: стартовый код Cortex-M4F для STM32F407 и QEMU netduinoplus2 (STM32F405).
 * Таблица векторов до TIM5 (прерывание 50, адрес 0x108 по RM0090), копия .data, обнуление .bss,
 * включение FPU и вызов main. */
#include <stdint.h>

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;
int main(void);

void Default_Handler(void)
{
    for (;;)
        ;
}
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void TIM5_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));

void Reset_Handler(void)
{
    uint32_t *s = &_sidata, *d = &_sdata;
    while (d < &_edata)
        *d++ = *s++;
    for (d = &_sbss; d < &_ebss;)
        *d++ = 0;
    *(volatile uint32_t *)0xE000ED88u |= 0xFu << 20; /* CPACR: CP10 и CP11 — полный доступ к FPU */
    __asm volatile("dsb\n isb");
    main();
    for (;;)
        ;
}

__attribute__((section(".isr_vector"), used)) void (*const vectors[16 + 51])(void) = {
    [0] = (void (*)(void))&_estack,
    [1] = Reset_Handler,
    [2] = Default_Handler,   /* NMI */
    [3] = HardFault_Handler,
    [4 ... 15] = Default_Handler,
    [16 ... 65] = Default_Handler,
    [16 + 50] = TIM5_IRQHandler,
};

fw19m.c — платформа на TIM5 для STM32F407.

/* fw19m.c — раздел 19м: платформа для STM32F407 — один 32-битный TIM5 на всё время.
 * 168 МГц от кварца 8 МГц, шина APB1 42 МГц, таймеры на ней — 84 МГц, делитель 21 -> 4 МГц.
 * TIM5 CH2 (PA1, AF2) ловит зубья колеса захватом, CH1 — сравнение для очереди событий:
 * зуб и событие меряются одним счётчиком, как в microsecond_timer_stm32.cpp у rusEFI.
 * Катушки — PD12…PD15, форсунки — PE7…PE10. Собирается и меряется по размеру; на плате не запускалось. */
#include "stm32f4xx.h"
#include "ecu19m.h"

static uint32_t hi, last_lo;

/* 64-битное время из 32-битного счётчика: перенос ловим при каждом чтении
 * (читать чаще, чем раз в 1073 с — у ЭБУ на работающем двигателе это так). */
tick_t plat_now(void)
{
    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    uint32_t lo = TIM5->CNT;
    if (lo < last_lo)
        hi++;
    last_lo = lo;
    tick_t t = ((tick_t)hi << 32) | lo;
    __set_PRIMASK(pm);
    return t;
}

void plat_wait_until(tick_t t)
{
    while (plat_now() < t)
        ;
}

void plat_set_compare(tick_t t)
{
    TIM5->CCR1 = (uint32_t)t;
    TIM5->SR = ~TIM_SR_CC1IF;
    TIM5->DIER |= TIM_DIER_CC1IE;
    if ((int32_t)((uint32_t)t - TIM5->CNT) <= 0)
        TIM5->EGR = TIM_EGR_CC1G; /* срок прошёл, пока писали регистр, — прерывание сразу */
}

void plat_pin(int ch, int level)
{
    GPIO_TypeDef *port = ch < 4 ? GPIOD : GPIOE;
    uint32_t pin = ch < 4 ? 12u + (uint32_t)ch : 7u + (uint32_t)(ch - 4);
    port->BSRR = level ? 1u << pin : 1u << (pin + 16);
}

void TIM5_IRQHandler(void)
{
    uint32_t sr = TIM5->SR;
    if (sr & TIM_SR_CC2IF) {
        uint32_t cap = TIM5->CCR2; /* чтение снимает флаг */
        tick_t now = plat_now();
        ecu_tooth(now - (tick_t)(uint32_t)((uint32_t)now - cap));
    }
    if ((sr & TIM_SR_CC1IF) && (TIM5->DIER & TIM_DIER_CC1IE)) {
        TIM5->DIER &= ~TIM_DIER_CC1IE;
        TIM5->SR = ~TIM_SR_CC1IF;
        evq_isr(&g_q);
    }
}

static void clock_168mhz(void)
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY))
        ;
    FLASH->ACR = FLASH_ACR_LATENCY_5WS | FLASH_ACR_PRFTEN | FLASH_ACR_ICEN | FLASH_ACR_DCEN;
    RCC->PLLCFGR = 8u << RCC_PLLCFGR_PLLM_Pos | 336u << RCC_PLLCFGR_PLLN_Pos | 0u << RCC_PLLCFGR_PLLP_Pos
                 | 7u << RCC_PLLCFGR_PLLQ_Pos | RCC_PLLCFGR_PLLSRC_HSE;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY))
        ;
    RCC->CFGR = RCC_CFGR_PPRE1_DIV4 | RCC_CFGR_PPRE2_DIV2 | RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL)
        ;
}

static void io_init(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIODEN | RCC_AHB1ENR_GPIOEEN;
    RCC->APB1ENR |= RCC_APB1ENR_TIM5EN;
    GPIOA->MODER = (GPIOA->MODER & ~GPIO_MODER_MODER1) | GPIO_MODER_MODER1_1; /* PA1 — альтернативная */
    GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(0xFu << 4)) | 2u << 4;                 /* AF2 = TIM5_CH2 */
    for (int i = 12; i <= 15; i++)
        GPIOD->MODER = (GPIOD->MODER & ~(3u << 2 * i)) | 1u << 2 * i;
    for (int i = 7; i <= 10; i++)
        GPIOE->MODER = (GPIOE->MODER & ~(3u << 2 * i)) | 1u << 2 * i;

    TIM5->PSC = 21 - 1;    /* 84 МГц / 21 = 4 МГц */
    TIM5->ARR = 0xFFFFFFFFu;
    TIM5->EGR = TIM_EGR_UG;
    TIM5->SR = 0;
    TIM5->CCMR1 = TIM_CCMR1_OC1M_0             /* CH1: сравнение, «активный уровень при совпадении» */
                | TIM_CCMR1_CC2S_0             /* CH2: захват с TI2 */
                | TIM_CCMR1_IC2F_0 | TIM_CCMR1_IC2F_1; /* фильтр: 8 отсчётов подряд */
    TIM5->CCER = TIM_CCER_CC2E;                /* захват по фронту */
    TIM5->DIER = TIM_DIER_CC2IE;
    NVIC_SetPriority(TIM5_IRQn, 1);
    NVIC_EnableIRQ(TIM5_IRQn);
    TIM5->CR1 = TIM_CR1_CEN;
}

int main(void)
{
    static const ecu_cfg cfg = {ECU_FROM_TOOTH, 1, 4.0f, -400.0f, 3.0f, 7000.0f, 50.0f, 8};
    clock_168mhz();
    ecu_init(&cfg);
    io_init();
    for (;;)
        __WFI();
}

f407_19m.ld — скрипт компоновщика.

/* f407_19m.ld — раздел 19м: STM32F407VG и QEMU netduinoplus2 (STM32F405): 1 МБ флеш-памяти, 128 КБ ОЗУ. */
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(8); _ebss = .; } > RAM
  end = .;
}

run19m.sh — прогон в контейнерах stm-sys:1 и stm-arm:1.

#!/bin/sh
# Раздел 19м: прогон в два шага. /v — каталог rusefi19m (только чтение); /di, /ci — заголовки CMSIS
# (STM32F4xx DFP 2.17.1 и CMSIS 5.9.0, только чтение).
#   sh /v/run19m.sh host   в stm-sys:1 — стенд на компьютере: модель вала + ЭБУ
#   sh /v/run19m.sh arm    в stm-arm:1 — сборка под STM32F407 (размеры) и тот же стенд в QEMU netduinoplus2
set -u
export LC_ALL=C.UTF-8
W=/tmp/w19m
rm -rf $W
mkdir -p $W
cd $W
LIB="evq19m.c ang19m.c ecu19m.c"
SIM="eng19m.c sim19m.c"
WARN="-std=c11 -Wall -Wextra -Wdouble-promotion -ffp-contract=off"
src() { for f in "$@"; do printf '/v/%s ' "$f"; done; }

case "$1" in
host)
    echo "== версии"
    gcc --version | head -1
    echo "== сборка стенда"
    gcc -O2 $WARN -fsanitize=undefined -fno-sanitize-recover=all -I/v $(src $LIB $SIM host19m.c) -lm -o sim 2>&1
    echo "build_host=$?"
    echo "== прогон"
    ./sim
    echo "host_exit=$?"
    ;;
arm)
    echo "== версии"
    arm-none-eabi-gcc --version | head -1
    qemu-system-arm --version | head -1
    M4="-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard"
    OS="-Os $WARN -ffunction-sections -fdata-sections"
    echo "== объектные файлы под Cortex-M4F, -Os"
    bad=0
    for f in $LIB fw19m.c start19m.c; do
        arm-none-eabi-gcc $M4 $OS -DSTM32F407xx -I/v -I/di -I/ci -c /v/$f -o ${f%.c}.o 2>&1 || bad=1
    done
    echo "build_obj=$bad"
    arm-none-eabi-size evq19m.o ang19m.o ecu19m.o fw19m.o start19m.o
    echo "вызовы double-библиотеки в evq19m/ang19m/ecu19m: $(arm-none-eabi-objdump -d evq19m.o ang19m.o ecu19m.o | grep -c 'bl.*__aeabi_d')"
    echo "команды FPU в ecu19m.o: $(arm-none-eabi-objdump -d ecu19m.o | grep -cE '\sv(add|sub|mul|div|cmp|cmpe|cvt|mov|ldr|str|neg|abs)')"
    echo "== прошивка fw19m.elf"
    arm-none-eabi-gcc $M4 $OS -nostartfiles --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections \
        -T /v/f407_19m.ld evq19m.o ang19m.o ecu19m.o fw19m.o start19m.o -lm -o fw19m.elf 2>&1
    echo "build_fw=$?"
    echo "fw19m.elf: $(arm-none-eabi-size fw19m.elf | tail -1 | awk '{print "text " $1 ", data " $2 ", bss " $3}')"
    lib() { arm-none-eabi-nm "$1" | awk '$3 ~ /^__aeabi_(f2lz|l2f|dmul|dadd)$/ {printf "%s ", $3}'; }
    echo "библиотечные float/int64 и double в fw19m.elf: [$(lib fw19m.elf)]"
    echo "== то же с прямым преобразованием float <-> int64 (-DTICK_CAST64)"
    mkdir c64
    for f in $LIB fw19m.c start19m.c; do
        arm-none-eabi-gcc $M4 $OS -DTICK_CAST64 -DSTM32F407xx -I/v -I/di -I/ci -c /v/$f -o c64/${f%.c}.o 2>&1 || bad=1
    done
    arm-none-eabi-gcc $M4 $OS -nostartfiles --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections \
        -T /v/f407_19m.ld c64/*.o -lm -o fw64.elf 2>&1
    echo "build_fw64=$?"
    echo "fw64.elf: $(arm-none-eabi-size fw64.elf | tail -1 | awk '{print "text " $1 ", data " $2 ", bss " $3}')"
    echo "библиотечные float/int64 и double в fw64.elf: [$(lib fw64.elf)]"
    echo "== тот же стенд в QEMU netduinoplus2 (Cortex-M4F)"
    arm-none-eabi-gcc $M4 -O2 $WARN -nostartfiles --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections \
        -I/v $(src $LIB $SIM qemu19m.c start19m.c) -T /v/f407_19m.ld -lm -o q19m.elf 2>&1
    echo "build_qemu=$?"
    timeout 600 qemu-system-arm -M netduinoplus2 -nographic -semihosting-config enable=on,target=native \
        -kernel q19m.elf -monitor none -serial none > q.txt 2>&1
    echo "qemu_exit=$?"
    echo "== вывод QEMU"
    tr -d '\r' < q.txt
    ;;
esac

Дальше: раздел 19н «CAN-контроллер MCP2515 по SPI: когда в микроконтроллере нет bxCAN, битовое время на 8 МГц, буферы, фильтры и прерывания» — CAN без bxCAN: внешний контроллер на SPI для платы, где CAN занят или его нет.

Открыть в интерактивном справочнике