Skip to content

Reduce flash usage and fix PLL handling after >100 MHz outputs (#65) - #105

Open
NT7S wants to merge 5 commits into
masterfrom
flash-size-refactor+divby4-fix
Open

NT7S wants to merge 5 commits into
masterfrom
flash-size-refactor+divby4-fix

Conversation

@NT7S

@NT7S NT7S commented Oct 9, 2026

Copy link
Copy Markdown
Member

Summary

This PR does two things in src/si5351.cpp and src/si5351.h:

  1. Flash-size refactor (51e6650). Smaller code, same behavior. On my test program it went from 15464 to 13516 bytes. The I2C traffic is identical to v2.2.0 in every scenario I could simulate.
  2. Fixes for issue pll not recalculated ( 150 MHz boundary ?? ) #65 and related cases (d052ddd, 9d086d2, 34c486c, b8f7988). These fix outputs that go silent or come out at the wrong frequency after a clock has been above 100/150 MHz.

The public API does not change. No signatures, enums, structs or defines in the public: part of si5351.h changed. The only header change is private: two private select_r_div helpers became one. Frequencies are still in Hz × 100 and RFRAC_DENOM is unchanged.

Fixes #65

Motivation

  • Flash size. On an ATmega328P the library takes a large share of the 32 KB, and v2.2.0 also pulled in malloc/free and operator new/delete for three 20-byte scratch buffers.
  • Issue pll not recalculated ( 150 MHz boundary ?? ) #65. If a Multisynth goes above 150 MHz (DIVBY4 mode) and then back down, it produces no output until its PLL is reset. Examples: 156 → 80 MHz, 200 → 10 MHz. My own regression sketch hit this: check 9 (512 kHz on CLK0 right after check 8 at 210 MHz, with only reset() in between) was silent on hardware.
  • Out-of-spec Multisynth ratios. A >100 MHz setting leaves its PLL low, e.g. 620 MHz for 155 MHz. A later ≤100 MHz set_freq() on that PLL then reused it as is, which can need a fractional MS ratio below 8. AN619 only allows 8 to 2048. On hardware, 155 → 96 MHz on CLK0 gave an unstable ~88.5 MHz instead of 96 MHz (620/96 = 6.46). The third scenario reported in pll not recalculated ( 150 MHz boundary ?? ) #65 is the same problem across two outputs: 155 MHz on CLK0, then 99 MHz on CLK2, so MS2 = 620/99 = 6.26.

Changes

1. Flash-size refactor (51e6650): no behavior change

Item Where What changed
Stack buffers set_pll, set_ms, set_vcxo new uint8_t[20] / delete replaced by an 8-byte stack array. This drops the heap from the library entirely and also fixes the old delete vs delete[] mismatch.
One R-divider helper select_r_div (private) select_r_div() and select_r_div_ms67() merged into one function that takes the minimum frequency. Same band thresholds.
Register packing helper file-static pack_reg_set One place packs P1/P2/P3 into the 8-byte register block for the PLLs, MS0–5 and the VCXO.
Linear CLK0–5 register map set_ms, ms_div base + clk * 8 instead of a switch per clock. CLK6/7 are still handled separately (single-byte MS registers and shared R-divider register 92). An out-of-range clk still writes nothing, as before.
CLK6/7 peer logic set_freq Merged the mirrored CLK6/CLK7 branches using a peer index. The existing asymmetry is kept on purpose: CLK6's first set always programs PLLB, CLK7's uses pll_assignment[clk].
Fraction helper file-static frac_to_reg Shared by pll_calc and multisynth_calc.
reset() reset Loops instead of unrolled writes. Same registers, same values, same order of effects.
≤100 MHz path set_freq Picks the PLL frequency with one expression instead of duplicated multisynth_calc calls.

Risk: low. The math, register values and I2C write order are unchanged. See Verification.

2. Reset the PLL when a Multisynth leaves DIVBY4 (d052ddd)

What: set_ms() reads the MS parameter byte it already reads for the read-modify-write. If DIVBY4 was set and the new setting doesn't use it, it resets the PLL that the clock is actually on (pll_assignment[clk]) after the MS writes.

Why: this is the #65 failure. Without a PLL reset after leaving DIVBY4, the Multisynth stays silent.

Coverage: this is in set_ms(), so it covers every path:

  • set_freq() below and above 100 MHz
  • set_freq_manual()
  • direct set_ms() calls
  • reset() followed by set_freq(), since reset() leaves the DIVBY4 bits set

Behavior impact: at most one extra PLL reset, and only on that transition. Normal tuning never triggers it.

Risk: a reset glitches every output on that PLL. That only happens on a DIVBY4 exit, which already glitches that output.

3. Re-plan the PLL when a ≤100 MHz output would need an MS ratio below 8 (9d086d2)

What: in the ≤100 MHz path of set_freq(), if the current PLL frequency is less than 8 × the output frequency, and no other output above 100 MHz holds that PLL, the PLL is re-planned to 800 MHz (SI5351_PLL_FIXED). Every output on it gets recalculated, and the PLL is reset after the writes. This is what the >100 MHz path already did.

Why: fixes 155 → 96 MHz and similar cases where the PLL was left low by an earlier >100 MHz setting.

Behavior impact: when this triggers, other outputs on the same PLL get rewritten and glitch once, but stay at their frequencies. It never triggers during normal tuning, because the PLL is already at 800 MHz there.

Risk: low. It only triggers when the old code would have written an out-of-spec ratio.

4. Move a ≤100 MHz output to the other PLL when its own PLL is held (b8f7988)

What: when the case in change 3 applies but another output above 100 MHz holds the PLL (the #65 155 MHz CLK0 + 99 MHz CLK2 case), set_freq() moves the output to the other PLL with the existing set_ms_source(). That updates both pll_assignment[] and the MS_SRC bit. Then:

  • If the other PLL already gives a ratio of 8 or more, it is used as is. There is no PLL write, so outputs already on it are not disturbed, and no reset is issued unless the output is also leaving DIVBY4.
  • Otherwise, if no other active output (CLK0–CLK7) uses the other PLL, it is re-planned to 800 MHz and reset once after the PLL and MS writes.
  • Otherwise (both PLLs are constrained), the old behavior is kept: the out-of-spec ratio is written, as in v2.2.0, and the call returns 0.

Not changed: set_freq_manual() (the caller picks the PLL there), the CLK6/CLK7 logic, and the PLL assignment rules for MS6/7.

Behavior impact: after a move, the output stays on the other PLL until reset() or set_ms_source(). Later tuning of that output on the new PLL is glitch-free. A later >100 MHz request on the moved output can now succeed where v2.2.0 returned 1, because its new PLL isn't held.

Risk: see Known limitations. A moved output is now exposed to later direct changes of the other PLL.

5. Issue only one PLL reset per re-plan (34c486c)

What: when set_freq() re-plans a PLL (the >100 MHz path, or changes 3 and 4) and one of the Multisynths it rewrites is leaving DIVBY4, set_ms() used to issue its own reset in the middle of the rewrites, followed by the normal reset at the end. A file-static flag now defers the set_ms() reset while set_freq() is re-planning, so there is exactly one reset, after all PLL and MS writes.

Behavior impact: in the test suite, 64 duplicate resets are gone. Every affected call still ends with a single reset as its last write. Direct set_ms(), set_freq_manual() and the plain ≤100 MHz path are unchanged.

Cost: 1 byte of RAM and about 18 bytes of flash.

Flash size

My own measurement on hardware: my test program went from 15464 to 13516 bytes with the refactor alone (51e6650).

arduino-cli, arduino:avr:uno (ATmega328P), arduino:avr 1.8.8, default -Os -flto. "Sketch uses" bytes (.text + .data), delta vs v2.2.0, and static RAM (.data + .bss):

Build s1_basic (init, set_freq CLK0/1, set_correction, output_enable) s2_clk67_vcxo (CLK6/7 + set_vcxo) s3_full_api (most of the API)
v2.2.0 (0559cbf) 12138 / RAM 390 13694 / RAM 390 13268 / RAM 384
51e6650 refactor only 9042 (−3096) / RAM 372 11548 (−2146) / RAM 372 11288 (−1980) / RAM 366
9d086d2 (+ DIVBY4 reset + ratio re-plan) 9240 (−2898) / RAM 372 11770 (−1924) / RAM 372 12184 (−1084) / RAM 366
34c486c (+ single reset) 9258 (−2880) / RAM 373 11788 (−1906) / RAM 373 12202 (−1066) / RAM 367
b8f7988 (this PR) 9684 (−2454) / RAM 373 12240 (−1454) / RAM 373 12692 (−576) / RAM 367

The #65 fixes give back part of the refactor savings. Most of the cost is in set_freq(): 64-bit compares and the extra paths are expensive on AVR, and the PLL move adds about 430–490 bytes. malloc/free/new/delete are no longer linked from the library.

Verification

Simulator equivalence. I built an out-of-tree host harness, not part of this PR:

  • It compiles v2.2.0 and each branch commit with g++ against Arduino/Wire stubs, plus a 256-byte simulated Si5351 register file.
  • It records every I2C transaction and compares full transcripts, final register files, return codes and object state.
  • 642 scenarios × 2 register-file fill patterns = 1284 runs, about 492k I2C events. The scenarios include:
    • the 27 golden cases plus 23 newer golden cases
    • targeted cases: PLL sharing and conflicts, CLK6/7 peer rules, R-divider band edges, DIVBY4 edges, set_freq_manual, set_correction, set_vcxo, reset/init
    • my FreqChecks sequences
    • the pll not recalculated ( 150 MHz boundary ?? ) #65 sequences, steady-state tuning runs
    • the hardware verify sketch steps, the new PLL-move and single-reset cases
    • 400 seeded random sequences

Results:

  • Refactor alone (51e6650) vs v2.2.0: 1284/1284 runs identical, byte for byte.
  • Goldens: the original C++ matches the golden values (292 field checks plus 343 register checks, 0 mismatches).
  • Mutation testing: 7 of 7 deliberately injected refactor bugs were caught. For the new commits, 3 of 3 deliberate bugs were caught: no MS_SRC switch on a move, the deferred-reset flag stuck, and re-planning a PLL another output is using.
  • ASan/UBSan: clean apart from the pre-existing negative left shift noted below.
  • This PR vs v2.2.0: every difference is categorized, and nothing is unexplained:
    • 338 inserted DIVBY4-exit resets
    • 82 ratio<8 re-plans
    • 62 PLL moves (50 reuse, 6 reuse plus DIVBY4-exit reset, 6 re-plan of a free PLL)
    • the knock-on MS values after those
    • 2 runs where a later >100 MHz request on a moved output is now accepted
    • the 2 fuzz runs described in Known limitations
  • Steady-state tuning (≤100 MHz VFO steps, fractional steps, R-divider changes): 0 added PLL resets (376 before, 376 after across those runs).
  • Output model: a simple model of the chip (AN619 limits plus what I saw on hardware) predicts:
    • no silent outputs in this PR's final state for any scenario (v2.2.0: 220)
    • outputs at the wrong frequency due to ratio < 8 down from 84 to 42; the remainder are the fallback cases and set_freq_manual calls with a low PLL, which are the caller's choice

Hardware (Si5351A, my bench):

  • FreqChecks 1–12, including check 9 (512 kHz after 210 MHz): correct.
  • The 14-step Si5351_Issue65_Verify sketch on 9d086d2: all steps passed, covering 80→156→80, 155→96, 200→10, CLK1 on PLLB 160→14, and set_freq_manual 175→14 on CLK2 and CLK0, with steady tuning showing no resets.
  • The two newest commits (34c486c single reset, b8f7988 PLL move) are verified in the simulator only so far. They still need hardware confirmation. The extended verify sketch has steps for them: 155 MHz CLK0 + 99 MHz CLK2, sharing with an existing PLLB output, a free-but-low PLLB, a move combined with a DIVBY4 exit, back-and-forth tuning, 200→110 MHz with one reset, and the both-PLLs-held fallback.

Known limitations / not addressed here

  • Both PLLs held. If both PLLs are held by outputs above 100 MHz (e.g. CLK1 160 MHz on PLLB, CLK0 155 MHz on PLLA, then CLK2 99 MHz), there's nowhere valid to put the low output. The old behavior is kept: an out-of-spec ratio, and a return of 0.
  • Other PLL in use and too low. Same fallback when the other PLL is used by another output and is too low for a ratio of 8 or more. Example: another output set with set_freq_manual() at a 600 MHz PLL. It isn't re-planned out from under that output.
  • A moved output is exposed to direct changes of its new PLL. That means set_pll(), set_vcxo(), or the first set_freq() on CLK6/CLK7, which programs PLLB. The library has never recalculated CLK0–5 outputs on a PLL changed that way. This was already true for anything on that PLL, but a move can now put an output there implicitly. The Si5351A 10-MSOP (CLK0–2 only) can't hit the CLK6/7 case.
  • A free PLL configured by hand can be re-planned. If the other PLL was set with set_pll()/set_vcxo() but has no output tracked on it, and it's too low for the move, the move re-plans it to 800 MHz.
  • set_freq_manual() is not re-planned or moved. A manual PLL that gives a ratio below 8 is still written as asked.
  • DIVBY4-exit resets glitch shared outputs. That reset glitches every output on that PLL, but it only happens on a DIVBY4 exit.
  • Pre-existing upstream quirks, unchanged here:
    • DEV-03: CLK6/7 at very low frequency truncates the MS6/7 divider (e.g. CLK6 at 10 kHz writes reg 90 = 0x00) and stores an out-of-range PLLB request.
    • DEV-15: set_freq_manual() on CLK6/CLK7 truncates P1 into the single-byte divider register.
    • DEV-14: set_freq() on CLK6/CLK7 does not enable the output (reg 3). It needs an explicit output_enable().
    • set_freq(0, clk) divides by zero.
    • When multisynth67_calc() rejects a ratio, set_freq() still writes an uninitialized byte to reg 90/91.
    • 100–150 MHz settings that end up at MS ratio 6 without the MS_INT bit (e.g. 120 MHz) are not covered by AN619. They seem to work but I haven't characterized them.
    • The pll_calc() correction formula left-shifts a negative value (UBSan). The result is as intended on GCC/AVR.

NT7S added 5 commits October 6, 2026 15:45
- Use stack buffers instead of new[]/delete in set_pll, set_ms and set_vcxo
  (drops malloc/free/operator new from the link)
- Merge select_r_div and select_r_div_ms67 into one helper parameterized by
  minimum frequency
- Share p1/p2/p3 register packing (pack_reg_set) and fraction-to-register
  math (frac_to_reg)
- Compute CLK0-5 register addresses arithmetically in set_ms/ms_div; CLK6/7
  stay special-cased and out-of-range clocks still write nothing
- Collapse the duplicated CLK6/CLK7 peer-clock branch in set_freq
- Loop the per-clock setup in reset()

Public API, RFRAC_DENOM and SI5351_FREQ_MULT are unchanged. Host simulation
of 1,060 call sequences showed identical I2C transcripts, register state and
return codes vs master. ATmega328P (Uno, -Os -flto) flash savings measured at
about 2.0-3.1 KB depending on the sketch. Not yet verified on hardware.
When a CLK0-CLK5 Multisynth is switched from DIVBY4 mode (outputs above
150 MHz) to a normal divider without a subsequent PLL reset, the output
stays silent. set_freq() only resets the PLL on its >100 MHz path, so
e.g. 80 MHz -> 156 MHz -> 80 MHz on the same clock, 155 MHz -> 96 MHz,
or reset() after a DIVBY4 frequency followed by set_freq(512 kHz) all
leave the clock without output. reset() does not clear the MSx_DIVBY4
bits in reg 44/52/..., so the stale state survives it.

set_ms() already reads the register holding MSx_DIVBY4 for its
read-modify-write, so use that value to detect the DIVBY4 -> normal
transition and reset the PLL assigned to that clock once, after all
Multisynth registers are written. This covers set_freq() (both paths),
set_freq_manual() and direct set_ms() calls. No reset is added in any
other case, so ordinary tuning remains glitch-free. Public API is
unchanged.

Fixes #65
The >100 MHz path of set_freq() moves the PLL to a frequency chosen for
that output (e.g. 4 x 155 MHz = 620 MHz) and leaves it there. A later
<=100 MHz set_freq() on a clock using that PLL then reuses the low PLL,
which can need a fractional Multisynth ratio below 8, outside the valid
range (8 + 1/1048575 .. 2048). On hardware, 155 MHz -> 96 MHz on CLK0
(ratio 620/96 = 6.458) produced an unstable output near 88.5 MHz.

In the <=100 MHz path, when the required ratio would be below 8 and no
other output on that PLL is above 100 MHz, set the PLL back to the
fixed 800 MHz and recalculate every output on it, then reset the PLL,
reusing the existing >100 MHz re-plan code. Ordinary tuning with the PLL
at 800 MHz never takes this branch, so no extra resets are added there.
If another output holds the PLL above 100 MHz the behaviour is
unchanged.

Refs #65
When set_freq() re-plans a PLL (the >100 MHz path or the ratio<8
re-plan) it rewrites every Multisynth on that PLL and then resets the
PLL. If one of those Multisynths was leaving DIVBY4 mode, set_ms() also
issued its own reset in the middle of the rewrites, so the PLL was reset
twice, the first time before all MS registers were written.

Defer set_ms()'s DIVBY4-exit reset while set_freq() is re-planning
(file-static flag) so only the final reset, after all PLL and MS writes,
is issued. Direct set_ms(), set_freq_manual() and the normal <=100 MHz
path are unchanged.

Refs #65
If a <=100 MHz set_freq() would need a Multisynth ratio below 8 because
another output above 100 MHz holds the PLL (issue #65: 155 MHz on CLK0,
then 99 MHz on CLK2 gives MS2 = 620/99 = 6.26), the PLL cannot be
re-planned without breaking the other output.

In that case move the output to the other PLL with set_ms_source():
- if the other PLL already gives a ratio >= 8, use it unchanged (no PLL
  write, so outputs already on it are not disturbed and no reset is
  issued, except the usual one when leaving DIVBY4);
- otherwise, if no other output (CLK0-CLK7) uses it, re-plan it at
  800 MHz, write the MS and reset it once after both;
- otherwise (both PLLs constrained) keep the previous behaviour.

set_freq_manual() is unchanged: the caller chose the PLL there.
CLK6/CLK7 handling is unchanged. Steady-state tuning adds no resets.

Refs #65
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pll not recalculated ( 150 MHz boundary ?? )

1 participant