Introduction: Clock Generator With Si5351 and Blue Pill

Clock generator

I recently build a pulse generator and it does its job well. It produces pulses of variable length and at several frequencies. But the number of frequencies it can produce is limited and for many jobs the length of a pulse is less important than its frequency. E.g. when you are testing a clock you need a certain frequency, often 32768 Hz, but the pulsewidth (duty cycle) isn't so important. To address this I made a clock generator, it produces squarewaves of (approx) 50% duty cycle for every frequency.


The frequencies are generated with a Si5351 chip from Silicon Labs. This chip can produce frequencies from 4 kHz up to 200 MHz. Usually I read the documentation of a chip and produce the code to use it. But this chip is so complicated that I chose to use a library made by Jason Milldrum, Sebastian Hesselbarth and Rabeeh Khoury. They made an easy to use C-library with one limitation, the lowest frequency you can generate is 1 MHz. I know that there is an extended version of this library for Arduino that starts at 4 kHz, but I didn't want to use an Arduino. And I'll tell you why.

No Arduino but a Blue Pill

A few months ago I made a dual channel oscilloscope with a Blue Pill and it worked well. But I never used it as I have a Rigol and a Pico oscilloscope. So it just sat there, gathering dust. Therefore I decided to convert it to something else, and that something else is this project, a clock generator.



Si5351 module

Blue Pill module

ST7735 TFT display

74HC00 (quad nand)

2 rotary encoders with switch

Project box

Lithium ion battery

Holtek 7333 (low dropout voltage regulator 3.3V)


Toggle switch

some capacitors and resistors

ST-Link module or Segger J-Link (to program the Blue Pill)



Si5351 library (made by Jason Milldrum)

MS-Paint (yes really!)

Step 1: High or Low Frequencies

High frequencies aren't very important for me, I rarely use higher than 8 MHz, low frequencies I do need though. Often lower than the minimum of 4 kHz that the Si5351 can produce by itself. But now I have this library that will make the Si5351 produce frequencies starting at 1 MHz, so they need to be divided. Which is what's a STM32F103C8 (the microcontroller on a Blue Pill) can do very well with its timers

Two outputs, not three.

The Si5351 has three outputs, the STM32F103 has four TIMERs and with three of these TIMERs it is possible to feed them with external signals. So it is possible to make a generator that produces three independent clock signals. I chose to use just two because it is enough for me and because the dual channel scope I'm converting has two inputs, that I can now use as outputs.

Channels are different

I started off with the idea to have both channels produce a maximum frequency of 1 MHz. While building, that idea changed, now channel 1 goes up to 40 MHz which is probably more than I will ever need.

With hindsight, I should have done this for both channels, but I was afraid that I did'nt have enough room on the pcb (perfboard) for two selectors (74HC00). It would have made the software less messy.

Step 2: Block Diagram and Schematic

Frequency range

The Si5351 produces frequencies from 1 MHz to 40 MHz for channel 1 and 1 MHz to 10 MHz, for channel 2. That signal is fed to TIMER2 and TIMER3 of the microcontroller. Depending on the selected frequency range it divides this frequency with 10 up to 1,000,000,000 in steps with a factor 10.
So the lowest frequency it can make is with the Si5351 on 1 MHz, divided by 1,000,000,000 is 1 milli Hertz. (one cycle every 1000 seconds) The highest frequency coming from the TIMERs is made with the Si5351 on 10 MHz, divided by 10 is 1 MHz.

Channel 1

For channel 1 there is an extra selector that takes the output of the Si5351 directly, so it skips the microcontroller. This will go from 1 MHz up to 40 MHz. Higher than that doesn't really work because it is around the maximum of the 74HC gates.

Resolution and precision

There are seven digits you can set, on the 1 MHz to 10 MHz range you can therefore set it to e.g. 1.234 567 MHz with a resolution of 1 Hz. And as this is simply divided to all lower ranges it is also possible to get 1.234 567 Hz with a resolution of 1 uHz (that's micro Hertz).

(Or even 1.234 567 mHz with a resolution of 1 nHz, although what use such extremely low frequencies have, I don't know, maybe if you are doing something in geology?)

Oh, do not confuse resolution with precision, the Si5351 uses a 25MHz crystal oscillator that isn't temperature controlled or temperature compensated. It will change several Hz (parts per million) when the temperature of the crystal changes. As all signals depend on this 25 MHz, they will change with it. But for hobby use its precision is more than adequate. The Si5351 can be tuned to compensate errors, but it doesn't compensate for temperature changes.

The picture shows 5 kHz measured with a counter locked to a GPS 1PPS signal, after the Si5351 was compensated at 25 degrees Celsius.

Step 3: Hardware

Building the hardware was easy as most of it was already done for the dual channel oscilloscope, the tft screen needed no changes, one rotary encoder was moved a few pins on the STM32F103 because the pins it was connected to are now used for a TIMER, power also remained the same lithium-ion battery and Holtek 7333 low dropout regulator. The Si5351 just needs 3.3v, two wires for I2C and two other wires for the two outputs, so that was easy too.
Most work was the removal of the analog amplifier of the oscilloscope. It needed to go to make place for the 74HC00. The project box needed no changes at all.

The picture shows part of the schematic in MS-Paint, with colors I paint over the lines with different colours to show with pin connects to which pin. After actually soldering the connections, I press CRTL_Z in MS-Paint to erase them. So MS-Paint DOES have its use after all! :-)

Three gates of the 74HC00 are used to connect either the Si5351 directly or the TIMER output to the output port of channel 1. And the fourth gate is used as a buffer for the channel 2 port.

Step 4: Software Development and Testing

(It is handy to have the code open in another screen if you want to follow this, for some reason I'm not allowed to upload a zip file with all the code so I will add the most important files to the next step. If you want to get the entire folder I will upload it to google-drive and share it.)

(note to myself, I really should take a look at Github or similar sites)

(EDIT so I setup an account on GitLab and if I did it right you can find the entire project folder in this place)

The code first of all takes care of initialising all peripherals in the STM32F103. GPIO, TIMERs, I2C and SPI, then it enables those peripherals and starts initialising the TFT-screen and Si5351. After that some info is shown on the screen (a small todo is to measure the battery voltage, I might add that later).

The default values are then set and send to the Si5351 and TIMERs of the microcontroller. Now it is ready to start working, which happens inside the never ending "while (1)" loop.

Input This loop checks if the rotary encoders were turned or pressed and if so starts the neccessary actions. The actual reading of the rotary encoders is done inside the interrupt routine of TIMER4. This interrupt is triggered 10000 times a second, it checks the GPIO that the rotary encoders are connected to and does the debouncing of those signals. The pins of the rotary encoders need to be active for 8 times that the interrupt runs (0.8 ms) before they are seen as active. The button needs to be active 16 times (1.6 ms) before it is considered pressed.

The interrupt routine signals the main function with a -1 of the encoder is turned counter clockwise and 1 when clockwise. The button on the encoder just sends a 1 when pressed.

A short explanation of how this works, consider this pseudo code, remember that the interrupt routine is called often, in this case 10,000 times per second.

pseudo code:

volatile uint8_t signal;                         //global variable<br>interrupt_routine()                              //this will be called very often
    static uint8_t button;                       //static variable
    button <<= 1;                                //shift one place to the left
    if (physical_button == pressed) button++;    //add a 1 to the left most bit
    if (button == 0xFF) signal = 1;              //when the physical button was pressed 
                                                 //at least 8 times in a row send signal
                                                 //to the main program

First of all a global variable is needed to be able to send a signal to the main program, the "volatile" keyword means that the program needs to read the content of the variable always, it could have changed at any time (as it does in interrupt routines)

Inside the interrupt routine the variables need to be static, meaning that they are not destroyed when the interrupt routine ends as would happen with normal variables.

The first thing to happen is a shift to the left, so all bits inside the variable move one place to the left making room for a 0 at the end.

Next a check is done to see if the physical button is pressed, if so a 1 is added, changing the 0 at the end into a 1.

If the physical button was not pressed nothing is done, so the 0 at the end stays 0.

The last line checks if all bits in the variable are 1, if so it sets signal to 1, indicating that the physical button indeed was pressed and bouncing was over.

You might notice that signal is never set to 0 in the interrupt routine. This is done by the mail program, after it has handled whatever it should do when the button was pressed.

Shown below are two pictures of actual button presses, as seen on an oscilloscope. You may think you pressed that knob just one, the microcontroller knows better. Take a look at the time-divisions, that second picture shows a bounce that isn't over after 600 us and you also see that it takes almost 1 ms before it really starts to make contact.

Step 5: Main Loop and Functions


If a button is pressed it means that the user wants the cursor to move from one digit of the frequency in the display to the next. (7 is the most significant digit, 1 is the least significant, digit with numer 0 is used for the selection of the frequency range)

A turn of the rotary encoder means that the used want the selected digit to move up or down (or when digit 0 is selected, the frequency range)

A change in frequency is send to the "calculate_si5351_frequency" function with the requested variables in the struct "ch1" or "ch2". The function returns the new frequency.

A change in range is send to "select_frequency_range" function with the same struct and the TIMER for that channel, this function does not return anything.

The last thing the mail loop does it show the new frequency and cursor position on the screen. The second rotary encoder is handled in the same way.


The "calculate_si5351_frequency" function Here the new frequency is calculated and checked. If you increment a digit from 9 or decrement a digit from 0 more than just that digit needs to change. Also the frequencies are checked to see that they do not go to high or low. The fact that I did not keep both channels equal makes all functions a bit messy. The function returns the new frequency and frequency range (if that needed change too)

Part of the code that runs when we selected digit 3 and want to increase the frequency

case 3:                			// <-- this value is the selected digit
    if (ch.frequency < MAX_SI5351_FREQ) ch.frequency += 100;
    if ((ch.frequency >= MAX_SI5351_FREQ) && (ch.freq_range < ch.max_freq_range))
        ch.frequency /= 10;
        if (ch.active_digit > (MIN_DIGIT + 1)) ch.active_digit--;

Let's say the frequency when we enter this function is 9,000,000 After the check that it isn't already too high 100 is added to it, making it 9,000,100 Then it is checked again to see that it is at or above the maximum frequency AND that it is below the maximum frequency range. If the frequency is still below the maximum, we are done. The new 3,000,100 is send back to the mail program.

Now consider what happens when we entered this function with a frequency of 9,999,950 As it is below the maximum of the Si5351 we add 100 to it, giving 10,000,050 which is above the maximum. The frequency is now divided by 10, giving 1,000,005. the frequency range is incremented by 1 to 4 so that the TIMER will divide it by a factor 10 LESS (see next paragraph) than it did with the previous frequency, resulting in the requested output frequency of 10,000,050. For decrementing the frequency as similar process is followed, decrementing and, if needed, multiplying.


The "select_frequency_range" function This function changes the prescalers, the numbers to counts to (AutoReload value) and the value at which the output changes level (Compare value) of the TIMERs. It does not return any values.

Part of its code:

case 3:            				// <-- this value is the freq_range<br>    LL_TIM_SetPrescaler(TIMx, 99);
    LL_TIM_SetAutoReload(TIMx, 9999);
    LL_TIM_OC_SetCompareCH2(TIMx, 4999);

case 4:    
    LL_TIM_SetPrescaler(TIMx, 9);
    LL_TIM_SetAutoReload(TIMx, 9999);
    LL_TIM_OC_SetCompareCH2(TIMx, 4999);

As an example, frequency range 3 sets the prescaler to 99 which means that the input frequency of the Si5351 will be divided by 100. It then sets the number to count to at 9999 making the counter to count 10000 pulses before it restarts. Together with the prescaler it divides the input by 1,000,000. So if the Si5351 produces 3 MHz, it will come out of the TIMER as 3 Hz. The compare value is set to half of the number the counter will count to, making the output change level at halfway.


Part of its code:

case 0:<br>case 3:
case 6:
case 9:
    whole_part = (uint32_t) (ch.frequency / 1000000);
    decimal_part = (uint32_t) (ch.frequency - (whole_part * 1000000));

Depending on what frequency range you select this function calculates the whole part of the frequency and the decimal part of it. The number of the frequency is always somewhere between 1,000,000 and 9,999,999 . Taking the example of frequency range 3 again this time with a frequency of 1,234,567 it divides it by 1.000.000 giving the whole number 1. It then subtracts this 1 multiplied by 1,000,000 from the input frequency, giving the decimal part. In between these two numbers goes the decimal point. As you can see this "splitting" of the frequency into a whole number and its decimals is the same for ranges 0, 3, 9 and 9. And the same aplies to the other ranges.


The last function to mention is the "display_cursor" function, this places a cursor under the active digit. Because of the decimal point this place isn't in a fixed place for the digits numbered 5 and 6.

The highest digit: 7, the lower digits 4, 3, 2, 1 and the text "mHz" "kHz" or "MHz" do have fixed positions.

Step 6: Building It and Video

I don't think that showing how I solder parts to the pcb and connect them together is of interest, so I'll just show some of the tools I use and how I decide with part goes where on the board. Here is was easy as most of the parts were already placed when I build the oscilloscope.

And finally the obligatory video.

Happy building and coding!