Hey everyone, Alex here. Welcome back to another edition of Coding with Alex on sysseder.com.
Most of our days as developers are spent high up in the clouds. We write microservices, configure Kubernetes clusters, optimize SQL queries, and worry about abstract layers of virtualization. But every now and then, it’s incredibly grounding to strip away the abstractions and write code that directly manipulates physical hardware. I’m talking about bare-metal development—where your code doesn’t talk to an operating system, but directly to silicon registers via electrical signals.
A fascinating project popped up on the Hacker News radar recently: developers hacking around with the TEA5767, a classic, ultra-cheap, single-chip FM stereo radio tuner. It reminded me of how much fun it is to build embedded systems. If you've never written an I2C device driver or manipulated raw byte arrays to control hardware, this is the perfect gateway drug.
Today, we are going to dive into the TEA5767. We’ll look at how it works, understand the I2C protocol, and write a production-grade, bare-metal hardware driver from scratch using Go and TinyGo (Go's compiler for microcontrollers). Grab your soldering iron (or just your favorite text editor), and let's get into it!
Why the TEA5767?
The TEA5767 is a highly integrated silicon chip manufactured by NXP (formerly Philips). It’s about the size of a fingernail, costs less than $2 on breakout boards, and contains an entire superheterodyne FM receiver. It handles RF amplification, mixing, IF (intermediate frequency) limiting, and stereo demodulation entirely on-chip.
For developers, the beauty of the TEA5767 is its simplicity. It doesn’t have a complex, nested register map like modern sensors. Instead, it exposes its entire state through a straightforward, 5-byte write protocol and a 5-byte read protocol over I2C (Inter-Integrated Circuit). This makes it an ideal pedagogical tool for learning how to read hardware datasheets and translate timing diagrams into working code.
Understanding the I2C Protocol & Bitmasking
Before we write any code, we have to understand how to talk to this chip. The TEA5767 acts as an I2C target device with a fixed 7-bit address of 0x60.
Unlike modern chips where you write to specific register addresses (e.g., write 0x01 to register 0x0F), the TEA5767 simply expects a stream of 5 bytes in a specific order to update its configuration. Conversely, reading 5 bytes from the chip returns its current status (signal strength, stereo/mono mode, current frequency, etc.).
The Write Register Map (5 Bytes)
To tune the radio to a specific frequency, we have to calculate a PLL (Phase-Locked Loop) synthesizer value and map it into 5 bytes:
- Byte 1: MUTE bit, Search Mode bit, and the 6 most significant bits (MSB) of the PLL formulation.
- Byte 2: The 8 least significant bits (LSB) of the PLL formulation.
- Byte 3: Search direction, Search ADC voltage levels, Side Injection configuration, and Mono/Stereo selection.
- Byte 4: Standby mode, Band selection (Japanese vs. US/Europe frequencies), and Soft Mute/De-emphasis settings.
- Byte 5: Clock frequency configurations (e.g., using a 32.768 kHz crystal).
The PLL Frequency Formula
How do we translate an FM frequency like 101.1 MHz into a number the chip understands? The datasheet gives us the formula. Assuming we are using "high-side injection" (which reduces interference on standard FM bands):
PLL = 4 * (Frequency_in_Hz + Intermediate_Frequency) / Reference_Frequency
For standard configurations:
- Intermediate Frequency (IF): 225,000 Hz (225 kHz)
- Reference Frequency (Fref): 32,768 Hz (using a standard 32.768 kHz crystal crystal oscillator)
Let's simplify this for our code. If we target 101.1 MHz (101,100,000 Hz):
PLL = 4 * (101,100,000 + 225,000) / 32,768 = 12,368.77 -> Integer: 12369 (or 0x3051 in hex)
Setting Up Our Go/TinyGo Environment
While many embedded projects default to C or C++, we are going to use TinyGo. TinyGo brings the memory safety, clean syntax, and incredible concurrency model of Go to microcontrollers like the Raspberry Pi Pico, ESP32, and Arduino Nano 33 IoT.
Make sure you have TinyGo installed on your machine. If not, head over to tinygo.org and grab the binary for your OS. We will target the popular Raspberry Pi Pico (RP2040) for our example, using its physical I2C0 bus.
Wiring Diagram (Text Representation)
Raspberry Pi Pico TEA5767 Breakout Board +-------------------+ +-------------------+ | | | | | 3.3V OUT (36)-----------VCC | | GND (38)-----------GND | | GP4/SDA1 (6)------------SDA | | GP5/SCL1 (7)------------SCL | | | | | +-------------------+ +-------------------+
Writing the TEA5767 Driver in Go
Let's build a modular Go package for our driver. Create a directory named tea5767 and create a file called tea5767.go. We will structure our driver as a struct that wraps an active I2C bus connection.
package tea5767
import (
"errors"
"machine"
)
const (
DeviceAddress = 0x60 // Fixed 7-bit I2C address for TEA5767
CrystalFreq = 32768
IF = 225000 // 225 kHz Intermediate Frequency
)
// Driver handles communication with the TEA5767 chip over I2C.
type Driver struct {
bus machine.I2C
config [5]byte
}
// New creates a new instance of the TEA5767 driver.
func New(bus machine.I2C) *Driver {
return &Driver{
bus: bus,
// Default configuration: US/Europe FM band, High-side injection, Clock running at 32.768 kHz
config: [5]byte{0x00, 0x00, 0xB0, 0x10, 0x00},
}
}
Implementing the Tuning Logic
Now, let's write the core algorithm to convert a frequency in Megahertz (e.g., 98.1) into the dual-byte PLL configuration and transmit it over the physical I2C bus.
// SetFrequency tunes the radio to a given frequency in MHz (e.g., 101.1).
func (d *Driver) SetFrequency(frequencyMHz float64) error {
// Convert MHz to Hz
freqHz := frequencyMHz * 1000000
// Calculate PLL value based on the datasheet formula for High-Side Injection:
// PLL = 4 * (F_rf + F_if) / F_ref
pll := uint32((4 * (freqHz + IF)) / CrystalFreq)
// Inject the 14-bit PLL value into Bytes 1 and 2 of our config array
// Byte 1: [MUTE(1 bit) | SEARCH(1 bit) | PLL_MSB(6 bits)]
// Byte 2: [PLL_LSB(8 bits)]
d.config[0] = byte((pll >> 8) & 0x3F) // Keep only the lower 6 bits of the upper byte
d.config[1] = byte(pll & 0xFF) // Keep all 8 bits of the lower byte
// Send the updated 5-byte payload to the chip
err := d.bus.Tx(DeviceAddress, d.config[:], nil)
if err != nil {
return errors.New("failed to write configuration to TEA5767 over I2C")
}
return nil
}
Reading Status and Signal Strength
One of the coolest features of the TEA5767 is its ability to report on signal quality. If we perform an I2C Read operation on the device address, it returns 5 bytes of data indicating things like whether a stereo signal is locked, whether it's tuned correctly, and the Signal Strength ADC Level (0 to 15).
// Status represents the parsed state read from the hardware.
type Status struct {
Ready bool
StereoLocked bool
SignalStrength uint8 // Value from 0 to 15 (15 being strongest)
FrequencyMHz float64
}
// ReadStatus fetches the current runtime telemetry from the chip.
func (d *Driver) ReadStatus() (Status, error) {
rxBuffer := make([]byte, 5)
// Read 5 bytes from the chip (no write pass-through needed)
err := d.bus.Tx(DeviceAddress, nil, rxBuffer)
if err != nil {
return Status{}, errors.New("failed to read status from TEA5767")
}
// Byte 1: [Ready(1 bit) | BLF(1 bit) | PLL_MSB(6 bits)]
// Byte 2: [PLL_LSB(8 bits)]
// Byte 3: [Stereo(1 bit) | IF_Counter(7 bits)]
// Byte 4: [Lev_ADC(4 bits) | CI_and_others(4 bits)]
ready := (rxBuffer[0] & 0x80) != 0
stereo := (rxBuffer[2] & 0x80) != 0
signal := rxBuffer[3] >> 4 // Keep only the upper 4 bits
// Reconstruct the PLL from the read registers to verify true frequency
pllRead := (uint32(rxBuffer[0]&0x3F) << 8) | uint32(rxBuffer[1])
// Reconstruct Frequency from PLL formula:
// F_rf = (PLL * F_ref / 4) - F_if
calculatedFreqHz := (float64(pllRead) * CrystalFreq / 4.0) - IF
calculatedFreqMHz := calculatedFreqHz / 1000000.0
return Status{
Ready: ready,
StereoLocked: stereo,
SignalStrength: signal,
FrequencyMHz: calculatedFreqMHz,
}, nil
}
Putting It All Together: The Main Controller
With our driver complete, we can now build the main execution loop. We'll initialize our microcontroller's I2C peripheral, instantiate our custom tea5767 driver, tune to a radio station, and enter a loop that prints real-time signal quality telemetry to the serial console.
Create a file named main.go in your root directory:
package main
import (
"fmt"
"machine"
"time"
"tea5767" // Import our local driver package
)
func main() {
// Let's wait a couple of seconds for USB-Serial to initialize
time.Sleep(2 * time.Second)
fmt.Println("--- Starting TEA5767 FM Radio Controller ---")
// Initialize physical I2C bus
// On Raspberry Pi Pico, I2C0 can map to GP4 (SDA) and GP5 (SCL)
err := machine.I2C0.Configure(machine.I2CConfig{
SDA: machine.GP4,
SCL: machine.GP5,
})
if err != nil {
println("CRITICAL: Failed to initialize I2C peripheral:", err.Error())
return
}
// Initialize our newly written driver
radio := tea5767.New(machine.I2C0)
// Let's tune to a popular local station: 101.1 MHz
targetStation := 101.1
fmt.Printf("Tuning to %.1f MHz...\n", targetStation)
err = radio.SetFrequency(targetStation)
if err != nil {
fmt.Printf("Error tuning to station: %s\n", err.Error())
return
}
fmt.Println("Successfully sent tuning commands!")
// Loop infinitely, polling the signal quality every second
for {
status, err := radio.ReadStatus()
if err != nil {
fmt.Printf("Error reading telemetry: %s\n", err.Error())
} else {
fmt.Printf("[TELEMETRY] Tuned Freq: %.2f MHz | Stereo: %t | Signal Strength: %d/15\n",
status.FrequencyMHz, status.StereoLocked, status.SignalStrength)
}
time.Sleep(1 * time.Second)
}
}
How to Compile and Flash
To compile this and flash it directly to your hardware, plug your Raspberry Pi Pico into your computer via USB while holding down the BOOTSEL button. This mounts the Pico as a mass storage device.
Run the following TinyGo command to compile and flash in one step:
tinygo flash -target=pico main.go
TinyGo compiles the program directly down to machine-specific ARM binary instructions, generates a .uf2 file, and copies it to the device. Once flashed, the Pico boots up instantly, sets up the physical hardware connections, and configures the TEA5767 over the I2C wires.
Open up your terminal program of choice (like Screen, Minicom, or the Serial Monitor inside VS Code) to view the output:
--- Starting TEA5767 FM Radio Controller ---
Tuning to 101.1 MHz...
Successfully sent tuning commands!
[TELEMETRY] Tuned Freq: 101.10 MHz | Stereo: true | Signal Strength: 12/15
[TELEMETRY] Tuned Freq: 101.10 MHz | Stereo: true | Signal Strength: 13/15
[TELEMETRY] Tuned Freq: 101.09 MHz | Stereo: true | Signal Strength: 11/15
Conclusion and Next Steps
By writing this driver, we bypassed bloated OS kernels and third-party libraries. We read a real datasheet, calculated physical PLL clock rates, mapped them directly to bitmasks, and established a direct communication pipeline with hardware. Writing low-level drivers gives you a visceral, tactile understanding of hardware integration that makes you a far better software architect.
If you want to take this project further, here are a few challenges:
- Implement Auto-Search: The TEA5767 supports hardware-driven channel seeking. Read the datasheet on how to toggle the Search bit (byte 1, bit 6) and process the signal-level ADC to automatically seek to the next clear frequency.
- Add a UI: Connect an SSD1306 OLED display using the same I2C bus and write code to display a graphical tuner bar!
- Rotary Encoder Tuning: Hook up a rotary encoder knob to physical GPIO pins on your microcontroller and use hardware interrupts to tune the frequency up or down dynamically when you twist the dial.
Are you building any IoT or bare-metal embedded projects right now? Have you tried using TinyGo or Rust instead of C++ for microcontrollers? Let me know in the comments below, or drop your code links on GitHub!
Until next time, keep hacking and enjoy the music!