TamgaOS (yula)
RSS github
July–August 2026 TamgaOS Kernel PWM ADC I2C CAN ESC

Kernel Maturation, Sensors
Everything Between the CAN Bus Working and the RTOS Actually Being Ready to Fly Something

Part 8 ended with two boards exchanging CAN frames over a real bus. This post covers everything that happened after: PWM and ADC on both boards, seven new kernel primitives (software timers, tickless idle, deadline/jitter monitoring, ISR-safe queue/event operations, task notification, stack high-water marking), an I2C bug that took down two sensors at once and turned out to be a single missing NACK-handling branch, a barometric sensor that was misidentified for an entire debugging session, an ARINC825-inspired message layer on top of the CAN bus, and the ongoing comedy of trying to arm an ESC.
Ethernet gets its own post — this one stops right before that starts.

— Progress Timeline —

How this stretch went

PWM on both boards — one working timer, one four-channel win
STM32 TIM2/PA0 confirmed on a logic analyzer after TIM1/PE9 turned out to be a dead pin. K64F's FTM0/FTM3 driver got all four channels verified — including PTC5, whose alternate function number needed an empirical ALT0–ALT7 sweep because the datasheet table read wrong twice.
ADC on both boards
STM32's ADC hung until RCC_D3CCIPR.ADCSEL was found to default to a PLL2 clock the boot code never configures. K64F's ADC worked first try — the one clean register hunt in this entire stretch.
Seven new kernel primitives, all verified on both architectures
Software Timer, opt-in Tickless Idle, Deadline/Response-Time Monitor, Jitter Monitor, ISR-Safe queue/event variants, Task Notification, and Stack High-Water Mark. Two real bugs found along the way: a missing TASK_BLOCKED state assignment that made every timeout return instantly, and a tickless-idle interaction that silently starved a test's own interrupt hook.
I2C bring-up for MPU6050 — then a single missing NACK path took the whole bus down
Adding a second I2C device (thought to be a BMP180) triggered a full bus scan that returned zero devices — including the MPU6050 that had been working seconds earlier. Root cause: i2c_write() only checked for NACK after already waiting on TXIS, which never sets on a NACK'd address, silently corrupting peripheral state for every transaction after the first failed probe.
The sensor that wasn't a BMP180
A full BMP180 driver — 11-coefficient calibration, Bosch's integer compensation formula — was written and wired up before a bus scan revealed the sensor was actually a BMP280 at address 0x76, not 0x77. Completely different register map, completely different compensation math. Rewritten from scratch, worked immediately once the correct chip ID (0x58) was confirmed.

— Drivers —

PWM — one dead pin, one alternate-function sweep

STM32's first PWM attempt was TIM1_CH1 on PE9 (Arduino D6). Every register checked out — CEN set, CC1E set, MOE set, GPIOE in the right alternate-function mode — and the pin produced nothing on the logic analyzer. Same class of problem as the CAN bus's PA11/PA12 not being broken out on this particular Nucleo variant. Moved to TIM2/PA0 instead, which worked cleanly once RCC_AHB4ENR's GPIOE enable bit turned out to be the thing missing on the first pin too — the peripheral clock gate had never been enabled, so every GPIOE register read back as garbage.

SignalValueHow confirmed
TIM2_CH1 pinPA0, AF1CubeMX cross-reference + datasheet
Timer clock240MHz (APB1 default prescaler)Measured period vs. programmed PSC/ARR
Measured period20.022ms (49.945Hz)Logic analyzer

K64F's FTM driver ended up covering all four motor channels — PTC1, PTC5, PTC8, PTC9 across FTM0 and FTM3. Three of the four alternate-function numbers came straight from the datasheet's pin table. PTC5 did not.

BugPTC5's ALT number — table said ALT4, then ALT6, both wrong

The K64 datasheet's pin table listed ALT4 for PTC5/FTM0_CH2. No signal. Cross-referenced a second copy of the table, which suggested ALT6. Also no signal. Rather than trust a third table read, swept ALT0 through ALT7 empirically on the logic analyzer — ALT7 was the one that actually produced a waveform, later cross-checked against a different section of the same datasheet that agreed.

FIXPTC5 → FTM0_CH2 is ALT7, not ALT4 or ALT6 — confirmed only by empirical sweep, not by trusting the table a third time.

The lesson that kept repeating across this entire stretch: never assume a sibling pin's alternate-function number applies to another pin on the same port, even when two different datasheet tables agree with each other and disagree with reality.

ADC — a clock nobody enabled, and a clean win

STM32's ADC1 driver followed the standard STM32H7 enable sequence — exit deep-power-down, enable the LDO regulator, calibrate, enable — and hung indefinitely at the calibration wait loop. Every step of the sequence looked correct against RM0433. The ADC simply never received a clock.

BugADC kernel clock defaults to a PLL the boot code never touches

RCC_D3CCIPR.ADCSEL resets to 00, which routes the ADC's kernel clock from pll2_p_ck — a clock domain this project's rcc_init_pll_480() never configures, since that function only sets up PLL1 for the core clock. The ADC's calibration and enable wait loops were spinning on a clock that was permanently absent.

FIXRoute ADCSEL to per_ck (value 10) instead — avoids configuring a second PLL just for the ADC, and per_ck is already alive via the existing HSI setup.

K64F's ADC0 driver was the one clean register hunt of this entire stretch — SIM_SCGC6's ADC0 clock-enable bit and SIM_SCGC5's PORTB bit were both taken from the established Kinetis SIM_SCGCx sequential pattern (FTM0=24, FTM1=25, FTM2=26, ADC0=27; PORTA=9…PORTE=13) without independent confirmation from the reference manual's bit tables, then verified purely by whether the driver worked on real hardware. It worked, first try — a pot swept the full 12-bit range (0–4095) cleanly in both directions.

I2C driver + MPU6050 + Kalman

The I2C1 driver (PB8=SCL, PB9=SDA, both AF4, 400kHz Fast Mode) came up cleanly against the MPU6050 first — WHO_AM_I returned 0x68, accelerometer/gyroscope data flowed, and a 4-state Kalman filter (roll, pitch, gyro bias x2) produced stable angle estimates from raw ±2g/±250°/s readings. This part was uneventful. The eventful part came later, when a second I2C device was added to the same bus — covered in its own section below, because the bug it exposed was serious enough to deserve one.

— Kernel Maturation —

Software Timer

A fixed-size static pool of one-shot and auto-reload timers, built entirely on the existing systick_get_ms() abstraction already used by every _timeout primitive. Auto-reload timers schedule their next deadline from the previous deadline rather than from "now" — a timer serviced slightly late doesn't drift progressively later over many periods, the same anti-drift principle later reused by the Jitter Monitor.

Verified on both boards: exact fire counts for a one-shot (500ms) and a periodic timer (200ms) running concurrently, timer_stop() correctly halting an active periodic timer mid-run, and pool-exhaustion behavior returning an invalid handle once all 16 slots are in use.

Tickless Idle — and the config system it needed

The idle task was rewritten to compute the soonest of (a) the next blocked task's remaining delay and (b) the next active software timer's deadline, then put SysTick into a single long-period sleep instead of ticking every 1ms with nothing to do. Measuring the actual benefit required a dedicated counter — comparing idle-loop iteration counts between tickless-off and tickless-on across an identical 2.5-second workload.

ModeIdle loop iterations (2.5s)
Tickless OFF (1ms tick)2500
Tickless ON10 — a 250x reduction

Made opt-in rather than default, via a small tamgaos_config.h — both a compile-time default (TAMGAOS_TICKLESS_IDLE_DEFAULT) and a runtime sched_tickless_idle_enable()/disable() pair. Defaulting to off matters: a task that busy-spins on sched_yield() prevents the idle task from ever running at all, tickless or not, and an early ISR-safe-primitives test only worked once a low-priority keepalive task was added specifically to stop the idle task from sleeping through the test's own SysTick-based interrupt hook.

Deadline / Response-Time Monitor

Per-task begin()/end() timing with min/avg/max and an overrun counter against a configurable budget. Verified with a simulated variable-workload task — a periodic 10ms-budget cycle, four out of thirty cycles deliberately given a much heavier workload — and the monitor caught all four, with zero false positives, on both boards.

The metric here is response time, not execution time — if a higher-priority task or ISR preempts between begin() and end(), that time is included in the measurement. The module was deliberately relabeled from an earlier "WCET Monitor" name once this distinction became clear: true WCET requires static analysis tooling this project doesn't have, and claiming it without that tooling would be a real overstatement in a domain where the difference matters.

Jitter Monitor

Measures how far a periodic task's actual wake-up time deviates from its expected schedule — independent of how long the task's own work takes, which is what the Deadline Monitor already covers. The expected time for the next cycle is advanced from the previous expected time, not from "now," for the same anti-drift reason used in the Software Timer.

TaskPeriodMin jitterAvg jitterMax jitter
steady (constant light load)20ms2ms2ms2ms
erratic (heavy load every 5th cycle)20ms0ms140ms275ms

The steady task's flat 2ms figure across min/avg/max isn't a context-switch cost — actual PendSV context switching on a 480MHz Cortex-M7 is sub-microsecond. It's the SysTick tick granularity itself: a 1ms-resolution scheduler has an inherent quantization error in exactly this range, separate from any real per-switch overhead.

ISR-Safe Primitives

Non-blocking queue_send_from_isr(), queue_receive_from_isr(), event_set_from_isr(), and event_clear_from_isr() variants — safe to call from a real UART or CAN RX interrupt, unlike the ordinary blocking queue_send()/event_wait() which would hang the CPU if ever called from ISR context. Tested from an actual interrupt via a weak systick_isr_hook() override rather than simulated from task context.

A task blocked on ordinary queue_receive() woke with 0ms measured latency when notified from the real SysTick ISR. A deliberate flood test — 1000 ISR-context sends against a 4-slot queue — correctly rejected 977 of them with an immediate error return instead of ever hanging the interrupt.

Task Notification

The lightest signal path in the kernel — a single value living directly in the target task's own task_t, no queue or event group needed. task_notify_give() reuses the already-proven-ISR-safe sched_wake_task() internally.

Bugtask_notify_wait() never set the task's state to BLOCKED

Every existing blocking primitive in this kernel — mutex_lock(), queue_send(), event_wait() — sets state = TASK_BLOCKED immediately before calling sched_block_locked(). The first draft of task_notify_wait() skipped that line. The task stayed TASK_RUNNING, so sched_pick_and_mark() flipped it straight to TASK_READY instead of leaving it blocked, and sched_tick_n() never counted down its delay_ticks — that logic only applies to TASK_BLOCKED tasks. Every wait, regardless of timeout value, returned instantly with got=0.

FIXAdd the missing state = TASK_BLOCKED; before sched_block_locked(), matching the pattern already established everywhere else in the kernel.

Stack High-Water Mark

Each task's stack is filled with a known pattern (0xA5) at creation, above the existing MPU guard region and canary word — scanning from the bottom up for the first still-untouched byte reveals the deepest point the stack has ever reached. Two tasks doing deliberately different recursion depths (2 vs. 15 levels) showed a consistent ~56–57 bytes per call level on both boards, and the deep task measured identically at 900 bytes on both STM32 and K64F — a tidy cross-architecture consistency check for a tool whose entire job is measuring architecture-specific stack usage.

— The I2C Bug —

The sensor that wasn't a BMP180

A full BMP180 driver was written first — 11 factory calibration coefficients read from EEPROM, Bosch's fixed-point compensation formula transcribed exactly from the datasheet, altitude derived from the barometric formula, a zero-reference calibration function so the current position reads as 0m. None of it worked. i2c_read() on the chip ID register returned -1 every time.

The eventual fix wasn't in the driver at all — it was a bus scanner that swept every valid 7-bit I2C address and reported which ones actually ACK'd. The sensor answered at 0x76, not the BMP180's fixed 0x77. That address, combined with the module's actual silkscreen text, identified it as a BMP280 — a completely different chip with a different register map, different calibration format, and a different (though related) compensation algorithm.

The BMP280 driver, once written against the correct chip ID (0x58) and register map, worked on the first flash — clean temperature/pressure readings, and a subsequent zero-altitude test showed the sensor tracking real vertical movement: lifted roughly a meter, the computed altitude climbed from 0.0m to 1.4m and back down to 0.0m on descent, tracking noise-level fluctuations of about ±0.2m throughout.

Root cause — the bus scan that broke a sensor that had just been working

Before the BMP280 misidentification was resolved, the bus scanner itself produced a genuinely alarming result: it reported zero devices — including the MPU6050, which had been streaming clean roll/pitch data moments earlier in a completely separate test binary.

Root Causei2c_write() checked for NACK only after TXIS, which never sets on a NACK'd address

The original write loop waited for ISR.TXIS first, then checked ISR.NACKF only after that wait succeeded. But if a device NACKs its own address — which happens on every single probe of an empty address during a full 0x08–0x77 bus scan — the hardware never sets TXIS at all, because it never enters the data phase. The wait for TXIS ran out the full timeout, and crucially, NACKF was never cleared and CR2 was never reset before the function returned. STM32H7's I2C peripheral needs NACKF acknowledged before it will process the next transaction cleanly; skipping that left it in a state where every subsequent transaction — including ones addressed to real, present devices — failed the same way.

A single bad probe during a bus scan was enough to lock up I2C for the rest of the program's lifetime. This is also the most likely explanation for an earlier, separate incident where connecting the BMP280 module while the MPU6050 was still wired in caused the MPU6050 to stop responding entirely, resolved only by a full power cycle — a brief bad contact during wiring is exactly the kind of event that would trigger this same NACK-mishandling path.

FIXPoll for NACKF and TXIS together, whichever comes first. On NACK, wait for the hardware's auto-generated STOP, clear both NACKF and STOPF, and reset CR2 to 0 before returning — on every exit path, success or failure.

After the fix, the same bus scanner correctly found the MPU6050 at 0x68 and, once wired in, the BMP280 at 0x76 — in the same scan, on the same bus, without a power cycle in between.

— Protocol Layer —

An ARINC825-inspired message layer over the existing CAN bus

No new hardware here — this restructures the same FDCAN/FlexCAN link proven in part 8 around a few organizing principles borrowed from ARINC825 (the CANaerospace-derived avionics messaging standard), not a full implementation of the standard itself: categorized CAN ID ranges by message criticality, a standard three-field header on every frame (node ID, sequence number, data type), and a periodic per-node heartbeat.

ID RangeCategoryCAN Priority
0x000–0x0FFFlight data (attitude, control commands)Highest — lowest ID wins arbitration
0x100–0x1FFSensor data (altitude, etc.)Medium
0x300–0x3FFService (heartbeat, node status)Lowest

The practical payoff: the actuator node's fail-safe logic now triggers off a missing heartbeat, not off a missing attitude message specifically — distinguishing "sensor node is alive but between updates" from "sensor node is gone" in a way the original bare CAN link couldn't. Sequence-gap detection, present in the frame format since part 8 but never actually checked until now, is finally wired up per data-type stream.

This is explicitly not a certified or complete ARINC825 stack — no node discovery service, no parameter query protocol, none of the hundreds of pages the real standard covers. It borrows the organizing ideas that transfer cleanly to a two-node bus and leaves the rest out.

— The ESC Saga —

— Infra —

— Final State —

What works now

✓ PWM — STM32 TIM2/PA0, K64F FTM0/FTM3 all four channels — logic-analyzer confirmed
✓ ADC — both boards, full-range sweep confirmed with a potentiometer
✓ I2C — MPU6050 + Kalman filter, BMP280 with zero-reference altitude, both sensors on one bus
✓ Software Timer, opt-in Tickless Idle, Deadline/Response-Time Monitor, Jitter Monitor, ISR-Safe primitives, Task Notification, Stack High-Water Mark — all seven verified on both architectures

ComponentFileStatus
ADC ADCSEL clock routingadc.c (STM32)Fixed — routed to per_ck
PTC5 alternate functionpwm.c (K64F)Fixed — ALT7, empirically confirmed
i2c_write() NACK handlingi2c.c (STM32)Fixed — poll NACKF+TXIS together, reset CR2 on every path
BMP180 driverbmp180.cDiscarded — sensor was a BMP280 at 0x76, not a BMP180 at 0x77
task_notify_wait() state assignmentnotify.cFixed — added missing TASK_BLOCKED
ESC arming sequenceesc_pot_full_manual.cEverytihng works as expected...

Lessons worth writing down

A NACK that's never cleared doesn't fail once — it fails forever

The I2C bug's real danger wasn't the failed transaction itself, it was that the peripheral's internal state was left inconsistent afterward. Every hardware peripheral with a documented error-acknowledgment step (NACKF, STOPF, and similar flags across other peripherals in this project) needs that acknowledgment on every exit path of the driver function, success or failure — not just the happy path.

A bus scanner is worth writing before you trust a single-address driver

The BMP180/BMP280 misidentification would have been caught in minutes by a bus scan instead of the better part of a debugging session spent on a fully-written, plausible-looking, entirely wrong driver. Any time a new I2C device is added, scanning first and writing the device-specific driver second is now the default order.

Sibling pins do not share alternate-function numbers, even within the same table

Repeated for the third time across this project (after CAN's PA11/PA12 and PB8/PB9, and now PTC5's ALT7): a datasheet's pin table can be internally inconsistent or simply wrong for one specific pin, and the only reliable confirmation is a logic analyzer, not a second read of the same table.

Response time and execution time are not the same claim

Renaming the Deadline Monitor's output from "WCET" to "response time" cost nothing technically and prevented what would have been a real overstatement in a domain — DO-178C-adjacent timing evidence — where that specific distinction is exactly what a reviewer would check first.

A feature's benefit sometimes needs its own counter to be visible

Tickless idle's elapsed=500ms output looked identical whether the feature was on or off — sched_delay_ms() is accurate either way by design. The 2500-vs-10 idle-loop-count comparison was the only thing that actually demonstrated the feature was doing anything, and it had to be built specifically for that purpose rather than inferred from existing logs.