Jun 2026 – Aug 2026 (3 months)
Designed for course MSE 450 (Real-Time and Embedded Control Systems)
STM32F411 MCU · TLC5947 display driver · 5× 0.7" 7-segment displays
CAD$130 component cost
Displays from 0-6500 RPM and 0-200 km/h
Direct-driven from raw tachometer sensors
Version 2 of previous iteration from 2020
GitHub repo here: https://github.com/pootchies/projectHUD
Demo video: me turning from a stop sign; testing from a function generator.
This is the second version of a HUD I built for my 2007 Toyota Matrix. The first ran on an Arduino with an OLED display, hand-soldered optocouplers, and an LM2596 regulator. For version two, I designed a custom 4-layer PCB around an STM32F411, running FreeRTOS, driving five 0.7" seven-segment displays through two LED drivers inside a 3D-printed case on the dashboard.
Aftermarket solutions already exist, but they pull values from GPS or OBD-II, yielding latency and dead zones. GPS refreshes at 1 Hz, and stops working underground. OBD polling is limited by how fast the ECU refreshes, which is often >1 Hz for speed and RPM data. Instead, I tapped directly off of the instrument cluster's raw engine and wheel tachometer signals.
The design objectives were:
Accurate and smooth real-time display of speed,
Accurate and smooth real-time display of engine RPM,
Appropriate brightness for both daytime and night-time driving, and
Enough power and thermal headroom to run continuously in all weather conditions.
I primarily focused my effort on the PCB schematic and layout, in addition to modelling the 3D printed case and writing/debugging most of the embedded C code.
The case is a two-piece SOLIDWORKS design, printed in black PLA. The PCB sits on three islands and gets clamped in place by the lid, so there are no screws through the board, only a countersunk screw and embedded nut in each corner. A lip-and-groove keeps the halves aligned, with cutouts for the displays, connectors, and reset button.
Empty case with top and bottom halves.
Top view; PCB seated inside bottom case.
A commercial HUD has three optical parts: an emitter (usually a micro-OLED or DLP projector), a collimator, and a combiner. The collimator is a convex lens or concave mirror that makes the light rays emitted from the display nearly parallel to the viewer, so the image appears to be focused at infinity. This is what causes the "floating number" effect with OEM HUDs, as if the speedometer is hovering outside of the vehicle on the road. This reduces the effort required from the driver to refocus their eyes from the road to the dash.
Typical setup application for a commercial heads-up display. [1]
HUD unit installed on dash; note the double image is eliminated within the combiner.
Given the one-month project timeline, I didn't have time to build a proper lens assembly, so the optical path was implemented as simply as possible, leaving room for improvement. Displays lay on the dashboard, pointing straight up, with an image reflecting off the windshield. However, a windshield is a thick piece of glass with two reflective surfaces (on the inside and outside), so a double image is formed. A teleprompter combiner film stuck to the inside of the glass fixes this, and as a bonus, un-polarizes the reflection—so the numbers still show through polarized sunglasses.
The signals coming out of the car are dirty automotive +12 V square waves direct from the alternator, so each of the three signal inputs (speed, tach, and headlight illumination) goes through an optocoupler. Like the previous iteration of my project, the optocouplers performs two functions. It level-shifts 12 V down to 3.3 V logic, and its slow response acts as a free low-pass filter on potentially noisy edges.
PCB Schematic.
The MCU is an STM32F411, and the five DSM7UA70105 displays are driven by two daisy-chained TLC5947 24-channel LED drivers: one for the three speed digits, and one for the two tach digits. Power input comes in from the car's +12 V rail and passes through a TVS diode, a Schottky, and a PPTC fuse (overvoltage, reverse polarity, and overcurrent protection, respectively). Then, it feeds into a 12 V → 5 V buck converter to power the displays, and a further 5 V → 3.3 V LDO regulator feeds the logic side.
This 5V/3V3 split was forced, due to the STM32 operating on a 3.3 V logic, while the displays operate with a nominal forward-voltage drop of 2.8V. If run off 3.3 V, that leaves 0.5 V buffer for the display driver - risky for brownouts if the 3.3V rail ever dips. Thus, the displays were driven from 5 V, at the cost of burning much more heat.
STM32 with 64-LQFP package.
This was my first PCB design, with 4 layers on 1.6 mm FR4, as recommended by the manufacturer I'm using, JLCPCB. Most components were placed on one outer layer, displays on the other; one inner layer for the 5 V and 3.3 V planes, the other a solid ground plane. I decided to skip the common 2-layer board and elected to use a 4-layer. Having the flexibility to drop vias to power or ground at any position on the board significantly reduced the challenge of drawing traces.
I added four debug LEDs: 5 V, 3.3 V, and two GPIOs, in addition to a board reset button for the MCU. Two 5-pin JST-PH connectors are used: one for programming and one for data input. As I anticipated, these debug LEDs proved to be useful in debugging in the embedded phase.
Following these major components, I added supplemental passive components such as feedback resistors, decoupling capacitors, and the buck converter inductor, being careful to place the 0.1uF decoupling capacitors as close to the pads as possible to minimize trace inductance.
Labelled back view of board.
Grouping the parts by voltage domain was the main layout decision. Everything running on 3.3 V (the MCU, both display drivers, and the optocoupler outputs) sits in the middle of the board, and everything on 5 V sits around the edges. That let me give both rails a single internal layer: a 3.3 V island poured inside the 5 V plane, rather than spending a separate layer on each. The other inner layer stays a solid, unbroken ground plane, so every signal trace has a continuous return path directly beneath it.
This resulted in two supply rails on one layer and a ground plane on the other.
5 V and 3.3 V power planes, shown in red and pink respectively.
The displays occupy almost the entire front face, which left no room for the drivers there except in one place: the gap between the three speed digits and the two tach digits. I put the tach driver in that gap and the speed driver on the back with the rest of the ICs. Each driver now sits directly behind or beside the displays it controls, so its 24 output traces run short and straight instead of crossing the board.
Labelled front view of board.
The board came out at 95 mm × 35 mm, which is close to the minimum that still fits five 0.7" displays in a row with room for traces between them.
Unsoldered PCB with hand for scale.
Overapplied solder paste using stencil.
I ordered a solder paste stencil with the boards to speed up assembly, but it didn't sit flush against the PCB and left far too much paste on the pads. I switched to applying paste by hand with a syringe, which gave much cleaner joints, then reflowed the component side with a heat gun and hand-soldered the displays on the front.
Two schematic errors surfaced during assembly. The first was in the optocoupler footprint: the collector and emitter pins were swapped, so each output was wired backwards. With insufficient time to re-manufacture the board, I bent the output legs of each optocoupler upward and crossed them over to the correct pads with 30 AWG wire.
The second showed up when we connected the board. The ST-Link programmer recognized the MCU but couldn't write to it. Turns out, the board was holding the MCU in reset permanently. The reset button connects its top and bottom pin pairs internally, but I had routed the traces to the left and right pairs, so the button was effectively always pressed. Cutting two legs off the switch fixed it.
Cursed pinout swapping.
When I realized the button is not in fact wired left-right but instead top-bottom.
Lastly on startup, all five displays flashed at full brightness for about 300 ms, which is bright enough to wreck night vision when starting the car. The 5 V buck booted up before the 3.3 V LDO, so the displays had power before the drivers had booted and could blank them. With a knife, I scrapped away the BLANK trace to expose its copper and added a 10 kΩ pull-up so the drivers hold the displays off until the MCU takes control of that pin.
10 kΩ resistor soldered to a trace on one end, prepared to be soldered to the 3V3 rail on the other.
Microscope image of exposed trace.
Y-spliced wires from the instrument cluster connector of my car.
To get the raw signals, I built a custom 5-pin harness carrying ignition-switched +12 V, chassis ground, the vehicle speed sensor, the tachometer, and the illumination signal from the infotainment system. Both sensor signals are +12 V square waves at 50% duty cycle, with frequency proportional to wheel and engine speed. I found the pinouts in the Toyota repair manual (e.g. the speedometer signal is pin 18 on the combination meter), removed the front dash panels to reach the wiring, and spliced in with Y-splits so the instrument cluster keeps working exactly as before.
I've been since told that soldering wires inside an automotive context is not the best idea, since it's susceptible to vibration fatigue and also permanently modifies the vehicle.
The firmware runs on FreeRTOS and is split into three pieces:
An interrupt service routine (ISR) that fires on every rising edge of the speed and tach signals and records the timer value,
An input task that runs at 20 Hz, turns captured periods into km/h and RPM, filters them, and checks the headlight signal, and
A display task that waits on a queue for a new frame, then pushes segment data out over SPI to the drivers.
The two tasks communicate through a FreeRTOS queue. The display task blocks on that queue until a new frame arrives, so it uses no CPU time between updates instead of polling for changes. The two tasks could realistically have been one, since the display only ever runs after the input task finishes, but separating them keeps a clean boundary between calculating a value and displaying it.
The whole display depends on measuring the period between pulses accurately, so I didn't want that measurement competing with the scheduler. Polling a GPIO would make every reading depend on what else the CPU happened to be doing at that moment. Instead, I used the STM32's Timer Input Capture peripheral, which latches the timer count in hardware the instant an edge arrives.
I configured two channels of Timer 2, one for each signal. On every capture interrupt, the ISR subtracts the previous value from the current one and stores the result in a circular buffer, so the most recent periods are always available to the input task. The headlight signal is a simple on/off level that changes rarely, so the input task just polls that pin once per loop.
Thus, every period measurement is timestamped in hardware, independent of CPU state.
STM32CubeIDE MCU .ioc configuration, showing pin mappings and input capture parameters.
Before filtering anything, I calibrated the conversion constants. From the manual, the tachometer produces two pulses per crankshaft revolution. For the wheel speed sensor, I counted 72,158 pulses over 29.07 km of driving, which works out to ~2482 pulses per kilometre.
Raw periods are too noisy to display directly, so both signals go through a moving average (i.e. a low-pass filter). A fixed window creates a problem at low speed, though: pulses arrive far apart, so a ten-sample window can span several seconds and the display lags well behind the car. Thus, I mapped the center frequency of the LPF to the speed of the vehicle, so the phase delay from the filter would be minimized.
Following this, I implemented a deadband. Below 2 km/h or 300 RPM, the display reads zero, which also guarantees the timer can never overflow between pulses. Hysteresis handles rounding: at a true speed of 30.5 km/h, a plain display alternates between 30 and 31 several times a second, so the displayed value only updates once the calculated value moves more than 0.8 km/h (or 60 RPM) away from it.
Display brightness is tied to headlight dial on left stalk.
The TLC5947 supports two ways to set brightness, either 12-bit greyscale per channel, sent inside the SPI data, or a single global level set by PWM on the BLANK pin. This display never needs segments at different brightnesses, so I designed the PCB and firmware around global PWM. Every segment is then either fully on or fully off, which also simplifies the SPI packing.
The display task reads the headlight flag from each frame and sets the duty cycle on BLANK: 80% in daylight, so the digits stay readable against sunlight, and 5% when the headlights are on, so they don't overpower the driver's night vision.
Result: brightness switches with the headlights immediately, with no flicker at either level.
Bench testing with function generator.
Before connecting anything to the car, I fed both inputs from a function generator with 50% duty cycle square waves up to 1000 Hz. This verified the input capture, period math, and filtering across a frequency range well beyond what the vehicle produces, with the displays in their unflipped bench orientation.
With the unit installed and the combiner film applied, the digits are sharp at eye level with no double image. I compared the HUD against a GPS-based phone app while driving. The two matched within 1 km/h, and the HUD consistently updates first on every acceleration and braking event, which is the latency advantage of reading the sensors directly. The tachometer matches the needle on the instrument cluster, though I haven't checked it against an independent reference yet.
On general test drives, the cutoff and hysteresis eliminated flicker when creeping up to a stop light, and the variable-window filter kept the output smooth at highway speed.
Checking accuracy against phone GPS.
The fourth design objective, continuous operation in all weather, was not complete. At full brightness, 35 segments at 25 mA draw 875 mA from 5 V, which is about 4.4 W: roughly 3.1 W in the displays and 1.3 W in the drivers. This means 4.4 W of heat is being deposited in a sealed black PLA case on a dark dashboard, in direct sunlight through the windshield.
Consequenrtly, I measured the display faces at 60 °C with an IR thermometer on a sunny day after a drive. The DSM7UA70105 datasheet allows a maximum ambient of 60 °C at 20 mA average segment current, which is exactly what the 80% daytime duty cycle at 25 mA works out to. In other words, the displays were operating right at the edge of their derating curve, and will shorten the working lifespan of these displays by some order of magnitude.
Phone IR temperature reading of 7-segments after a 1 hr drive.
Side view of HUD with myself for scale. Note the virtual image is still close to the driver.
The image is bright and in the right place, but its focal plane is on the windshield, about a metre from the driver, rather than out on the road. The driver's eyes still have to refocus between the road and the display, which a collimated HUD avoids.
This was my first ground-up PCB design, and the first RTOS application that had to work in a moving car. Every mistake along the process (reversed pins, a backwards reset switch) came from skimming datasheets instead of reading them. With that said, the HUD works and it has proven useful for day-to-day driving.
The next revision has clear issues to fix. For thermals: raised lips on the case to shade the displays from direct sun, ASA instead of PLA for a higher temperature rating, and closed-loop dimming that uses the STM32's internal temperature sensor to reduce brightness as the board heats up. For the boot flash: a MOSFET in series with the display 5 V rail, and triggered by the 3.3 V rail, so the displays physically can't receive power until the logic is running. For debugging: test points on every net, which would have made every fault above much faster to find. For the optics: a proper collimating lens and fold mirror, with an adjustable mount for each element.
Thank you to Olivia and Katy for helping me throughout this course project. Looking forward to V3.0 with proper optical collimation.