Note 02 of 04: 笔记 02 / 04:MPU6050 Interrupts as the Control Clock用 MPU6050 中断给控制环打拍子

The NUEDC car's project configures a 5 ms timer and never starts it; the control loop actually runs inside the MPU6050's DMP data-ready interrupt, 200 Hz from the sensor's own clock, halved to 100 Hz, and shared with two encoders decoded at 2x. What that inherited structure buys, what it costs, and what the motors do if the sensor ever goes quiet.电赛小车的工程里配了一个 5 ms 的定时器,却从来没启动;控制环实际跑在 MPU6050 的 DMP 数据就绪中断里,传感器自己的时钟给出 200 Hz,控制减半到 100 Hz,还和两路二倍频解码的编码器挤在同一个中断里。这套从例程继承来的结构换来了什么、付出了什么,传感器一旦不再发中断,电机又会怎样。

SysConfig in the car's project sets up a periodic timer, TIMER_0, at exactly 5 ms, the textbook way to give a control loop its tick. Nothing in the code ever starts it. The 5 ms that actually drives the car comes from somewhere less obvious: in GROUP1_IRQHandler, below the two encoder handlers, there is a branch for the MPU6050's interrupt pin. Its comment says the rate is set through the DMP, 200 Hz by default. The branch reads the sensor and then calls BalanceControlTask(), and a comment underneath notes that a correct mainTaskFreq should read 200. This structure came along with the balance-car example the car code was adapted from. On a balance car, reading the attitude and balancing in the same breath makes perfect sense. On a line-tracking car, it means the motors keep time by a gyroscope.

小车工程的 SysConfig 里配好了一个周期定时器 TIMER_0,周期正好 5 ms,教科书式的控制环节拍。可代码里从来没有启动过它。真正给车打拍子的 5 ms 藏在一个不太显眼的地方:GROUP1_IRQHandler 里,两路编码器的处理下面,是 MPU6050 中断引脚的分支,注释写着频率可以通过 DMP 配置,默认 200 Hz。这个分支先读传感器,再调用 BalanceControlTask(),底下还有一句注释,说频率正确的话 mainTaskFreq 应该等于 200。这套结构是跟着车上代码改编的那份平衡车例程一起来的。平衡车读完姿态马上做平衡,放在一起天经地义;放到循迹小车上,电机就得听一个陀螺仪来掐表。

DMP Data-Ready at 200 Hz200 Hz 的 DMP 数据就绪中断

DMP_Init() sets both the MPU6050's sample rate and the DMP's FIFO rate to DEFAULT_MPU_HZ, which is 200. Each time the DMP drops a new packet into its FIFO it pulls INT low, and the MSPM0 pin wired to it, PA7, is set up with a pull-up and a falling-edge interrupt. So the 5 ms period comes from the MPU6050's own clock and does not care how busy the MSPM0 is. People on the InvenSense forum who tried to slow this interrupt down found it stays at 200 Hz, because the motion driver library pins the DMP's internal sample rate, which makes 200 Hz the natural beat to build on anyway.

DMP_Init() 把 MPU6050 的采样率和 DMP 的 FIFO 输出率都设成 DEFAULT_MPU_HZ,也就是 200。DMP 每往 FIFO 里放一包新数据,就把 INT 拉低;MSPM0 这边接 INT 的是 PA7,配成上拉、下降沿触发。于是 5 ms 的周期出自 MPU6050 自己的时钟,MSPM0 忙不忙都不影响它。InvenSense 论坛上有人想把这个中断调慢,结果发现它始终停在 200 Hz,因为 motion driver 库把 DMP 内部的采样率写死了,所以 200 Hz 本来也是最顺手的拍子。

Inside the branch, Read_DMP() pulls the packet over hardware I2C on PA0 and PA1, turns the quaternion into roll, pitch and yaw, and stores them. Then the funny part: nothing in the control path uses any of those angles. Steering and speed run entirely on encoder counts. Attitude shows up only on the OLED, where the code takes one snapshot of yaw about 100 s after boot and from then on displays how far the car has turned from it and which quadrant its nose points into. The assignments still carry the example's fingerprints, too: pitch is filled from Roll, roll from Pitch, and all three acceleration axes from accel[0]. Harmless here, since the control loop never looks. The MPU6050's real job on this car is metronome.

分支里,Read_DMP() 通过 PA0、PA1 上的硬件 I2C 把这包数据读出来,把四元数换算成横滚、俯仰、偏航,一一存好。好笑的地方在后面:这几个角度,控制链路里一个都没用上。转向和速度全靠编码器读数,姿态唯一露面的地方是 OLED:开机大约 100 秒后拍下一次偏航角,之后屏幕上显示车相对它转了多少度、车头朝哪个象限。赋值的地方还留着例程的指纹:pitch 拿的是 Roll,roll 拿的是 Pitch,三个轴的加速度全从 accel[0] 取。控制环反正不看,也就不碍事。说到底,这颗 MPU6050 在这辆车上真正的工作,是当节拍器。

Halving to 100 Hz and 2x Encoder Decoding减半到 100 Hz 与编码器二倍频

BalanceControlTask() runs on every interrupt but only does the real work on every other one. Each call checks the user key and reads the battery voltage from the ADC; a variable controlFlag flips each time, and only on alternate calls does the task read both encoder counters, zero them, compute wheel speed, run the speed and steering loops and write the PWM. Control therefore runs at 100 Hz, and each encoder reading covers a 10 ms window.

BalanceControlTask() 每次中断都会进来,但隔一次才真正干活。每次进来都查一下用户按键、读一下 ADC 上的电池电压;变量 controlFlag 每次翻转一下,只有隔一次的那一半,才去读两路编码器的计数、清零、算轮速、跑速度环和转向环、写 PWM。所以控制是 100 Hz,每次读到的编码器计数覆盖 10 ms。

The encoders are quadrature encoders: channels A and B a quarter-cycle apart, so the level of one at an edge of the other gives the direction. Counting only A's rising edges gives one count per cycle and counting every edge on both gives four, the usual x1, x2 and x4 decoding. This code enables interrupts on the rising edges of both A and B; on A's rising edge B decides the direction, and on B's rising edge A decides it, mirrored. That is two counts per cycle. The speed formula in balance.h builds on it: Control_Frequency 100, a 13-line encoder, a 30:1 gearbox and 2x decoding make 2 × 30 × 13 = 780 counts per wheel revolution, so n counts in 10 ms means n / 7.8 revolutions per second. Each of those edges is one more trip into GROUP1_IRQHandler.

编码器是正交编码器:A、B 两路相差四分之一个周期,在一路的边沿上看另一路的电平,就知道往哪边转。只数 A 的上升沿,一个周期记一次;两路每个边沿都数,一个周期记四次,这就是常说的 x1、x2、x4 解码。这份代码对 A、B 两路的上升沿都开了中断:A 上升时看 B 定方向,B 上升时看 A 定方向,规则互为镜像,一个周期记两次。balance.h 里的轮速公式就建在这上面:Control_Frequency 是 100,编码器 13 线,减速比 30 比 1,二倍频,轮子转一圈是 2 × 30 × 13 = 780 个计数,10 ms 里读到 n 个计数,就是每秒 n / 7.8 圈。这些边沿,每一个都要进一趟 GROUP1_IRQHandler。

Sharing One Interrupt共用一个中断

The encoders and the MPU6050 interrupt all land in the same handler, and that is where this design starts to cost something. While the handler is busy with Read_DMP()'s I2C transfer and then the control task, a fresh encoder edge can only set its pending flag and wait. One waiting edge is fine; the flag remembers it. If the same pin produces a second edge before the handler gets back to it, the two collapse into one flag, and a count is gone. A comment in main admits as much: with software I2C the DMP read was too slow and interfered with the encoder interrupts, which is why the project uses the hardware I2C peripheral. The project also carries a debug timer counting at 100 kHz, and the handler uses it to measure how often it runs and how long each run takes, mainTaskFreq and mainTaskUseTime, which are exactly the two numbers this design needs someone watching.

编码器和 MPU6050 的中断都落进同一个处理函数,这套设计的代价就从这里开始。处理函数正忙着跑 Read_DMP() 的 I2C 传输、接着跑控制任务的时候,新来的编码器边沿只能先把挂起标志置上,排队等着。等一个边沿没问题,标志会记住它;同一个引脚要是在处理函数回来之前又来了第二个边沿,两个边沿就并成一个标志,一个计数就这么没了。main 里有一句注释自己就招了:用软件 I2C 时,DMP 读得太慢,会影响编码器中断。所以工程里用的是硬件 I2C 外设。工程里还带了一个 100 kHz 计数的调试定时器,处理函数拿它同时测自己多久跑一次、每次跑多久,也就是 mainTaskFreq 和 mainTaskUseTime,这套设计最需要有人盯着的,恰好就是这两个数。

When the Sensor Goes Quiet传感器停了以后

One more consequence only shows up when you picture something failing. set_pwm() is called from nowhere but the control task. If the MPU6050 ever stopped raising its interrupt, through a loose wire or a stuck I2C bus, the control loop would simply stop running, while the timer carried on producing the last duty cycle it had been given. The car would keep driving at that PWM with no tracking and no stopping. The I2C driver does have a 10 ms timeout that unlocks a stuck SDA line, so a hung transfer cannot freeze the handler, but nothing at all is watching for the interrupt not arriving. A sensor-driven clock keeps precise time and saves a timer, and in exchange the whole drive hangs on that one sensor staying alive.

还有一个后果,得想象一下出故障才看得到。set_pwm() 只在控制任务里被调用,别处一处都没有。如果 MPU6050 哪天不再发中断,比如线松了,或者 I2C 总线卡死,控制环就直接停了,而定时器会继续输出最后一次给它的占空比。车会按那一刻的 PWM 一直开下去,不循迹,也不停。I2C 驱动里倒是有 10 ms 超时,超时就去解锁卡住的 SDA,一次卡住的传输冻不住处理函数;可中断根本没来这件事,没有任何东西在盯。拿传感器当时钟,拍子准,还省一个定时器,代价是整辆车动不动,都系在这一颗传感器身上。

07Back to the project回到项目NUEDC 2025 Simple Self-Aiming Device2025 电赛简易自行瞄准装置