Self-Balancing Robot
ECE 350 · Custom FPGA Processor
The goal was to build a two-wheel self-balancing robot — essentially an inverted pendulum — controlled entirely by an FPGA running a processor built from scratch in Verilog. The robot has to sense its own tilt, estimate its angle in real time, and drive its motors to correct balance / drive.
The original custom processor was built for ECE 350, a Duke course on digital architecture. It implemented the 32 bit MIPs instruction set and ran at 10MHz. The code was written in Verilog and compiled for an Artirix FPGA on Vivado.
As described further in detail below, this project required a fast control loop for balancing so we added custom instructions for square root, reciprocals, and trigonometry. We also improved the multiplication to a single clock cycle with a Wallace Tree and and sped up the clock cycle to 50Mhz.
The robot was designed in Onshape and 3D printed. The two wheels are each driven by their own DC motor by a PWM signle. The robot stands roughly 20 cm tall with about 14 cm between the wheels with a high center of mass to truly capture the inverted pendulum control problem.
The motors are standard yellow TT DC gear motors. They offer less torque than alternatives like N20s, but they were easy to integrate, which made them the right call for rapid prototyping. Motor speed is controlled with PWM through an L298N dual H-bridge driver, which takes direction and enable inputs and supplies current the processor can't drive directly.
The IMU is an MPU-6050, giving us a 3-axis gyroscope and 3-axis accelerometer over I2C. An external IMU was necessary because the Artix-7's onboard sensor provides only accelerometer data, which makes angle estimation impossible on its own. We configured the gyroscope at ±250 °/s (131 LSB per °/s) and the accelerometer at ±2g (16,384 LSB per g) — enough resolution for the robot's range of motion.
Reading the Gyroscope over I2C
Our first task in customizing the processor was creating a verilog model to querey the gyroscope over I2C and store the data in the processor's memory. We created a Verilog Finite State Machine that individually read each of the gyro's twelve registers in succession (each measurement was split between a high byte and a low byte).
We created a custom handshake to guarante every new sample is consumed by the control loop exactly once. It uses two signals: gyro_valid, driven by the reader, and gyro_ack, driven by the processor. Once the reader has collected all twelve bytes and latched them into its output registers, it raises gyro_valid and waits, holding the data steady. When the control loop is ready for a new sample it polls gyro_valid, and on seeing it asserted raises gyro_ack. The reader sees the acknowledgment and drops gyro_valid; the processor then drops gyro_ack and runs one pass of the control loop on the freshly latched sample. This resolved a whole class of timing bugs between the I2C module and the processor.
Arctangent as a Hardware Instruction
Computing the accelerometer-derived angle requires an arctangent, so we implemented one as a lookup table in Verilog. We precomputed atan(x) for x in [0, 1] in Q16.16 fixed point and stored it in a 4096-entry memory initialized from a .mem file at synthesis. The module takes the top 12 bits of the fractional portion of the input as its index, and handles negative inputs by exploiting the fact that arctangent is an odd function — negate the input, look it up, negate the result on the way out.
Restricting the domain to [0, 1] keeps the table a reasonable size. Inputs outside that range are handled in software by an assembly routine wrapping the hardware instruction with the identity atan(x) = π/2 − atan(1/x). That routine was easily the most annoying thing to debug on the project, thanks to the bitwise shifts needed to make the division in 1/x accurate.
Square Root in Assembly
The accelerometer angle computation also needs a square root, which we wrote entirely in assembly using Newton-Raphson iteration. It starts from an initial guess of x₀ = S/2, obtained with a single right shift of the input's raw bits, and refines it with x_{n+1} = (x_n + S/x_n) / 2. Four iterations were enough to produce accurate Q16.16 results.
Fixed-Point Precision
Precision was the central difficulty of running the whole pipeline on our own processor. Getting the fixed-point divisions right took a significant amount of thought and debugging — each one needs its bits shifted so the numerator is as large as possible and the divisor as small as possible, without overflowing the 32-bit range. Get it wrong and the error propagates straight into the angle estimate, which cripples the control loop.
Sensor Fusion and Control
For sensor fusion, we. used a complementary filter that blended the accelerometer and gyroscope readings, weighting the gyroscope heavily for short-term dynamics and the accelerometer for long-term correction. The angle update for the X-axis is:
where α is the gyroscope weighting coefficient (0.98), ω is the gyroscope angular rate, Δt is the time elapsed since the last update, and the final term is the accelerometer-derived angle. That angle is itself computed as:
Using atan2 gives the estimate a full ±180° range without ambiguity, and the inner wrap call in the filter equation stops numerical jumps at the ±180° boundary from corrupting the gyroscope integration step.
The fused angle feeds a PID controller with a fixed setpoint, which outputs the PWM signal driving the L298N. The balancing setpoint of 0° is not a global geometric measurement — the robot is held manually in the upright position it should maintain and that orientation is registered as the reference. Calibrating this way inherently accounts for asymmetry in component placement, wiring, or an imperfectly centered center of mass.
For driving, the setpoint changes dynamically from keyboard input. The main assembly loop checks the pressed key each iteration against the scan codes for W, S, and space: W sets the setpoint forward to 1°, S sets it backward to -1.5°, and space resets it to zero.
Before testing anything under FPGA control, we validated the entire pipeline on an ESP32. This let us test and iterate on our PID algorithm without waiting for Vivado recompiles.
Debugging on Hardware
A recurring challenge of FPGA development here was that I2C communication is difficult to simulate meaningfully. So we reserved one register driving the seven-segment display as a live debug output, and wrote intermediate values into it at various points mid-loop. For boolean and status signals we used the onboard LEDs the same way.
Power
Running the system from a single 9V battery caused loss of control. The cause was insufficient current capacity: under load the battery voltage sagged, reducing motor torque below what the controller expected. Switching to a benchtop supply with adequate current delivery (9V, 1A) restored consistent, stable balancing.
Sensor Latency
Our initial custom complementary filter produced angle estimates that noticeably lagged the true motion, so the controller responded too late and destabilized the robot. Moving to the more robust formulation above lowered the latency enough to fix it.
With both issues resolved, the robot achieved sustained autonomous balancing, and moderate success with driving.
Initialization Stand
The most immediately impactful improvement would be a 3D-printed stand holding the robot in a fixed, repeatable upright position at startup. Because the zero-angle calibration happens at boot, any variation in starting orientation feeds directly into the control setpoint.
Onboard Battery
Replacing the bench supply with a battery pack mounted on the robot would eliminate the external cable entirely. Beyond making the robot untethered, this also removes the unpredictable external force from the hanging wire.