在收拾旧物的时候翻出来之前一个未完成的LED显示项目,在后续开发过程中为该项目写了一个MPU6050的驱动库。
电路
在之前的项目中经常使用这款STM32F103C8T6最小系统板,国外论坛管这个板子叫Bluepill。

之前开发时在上面焊接了8个LED灯珠,电路图大致如下。

这样的电路设计可能不太符合常规的设计规范,但如果使用合适封装的贴片LED,你就可以将LED直接焊在最小系统板上,整个电路能实现体积上的最小。 如下图是笔者焊接好的效果。

构想
笔者设想通过某种方式用这8个LED模拟点阵屏来进行显示。 点阵屏一般是扫描显示,比如对于一个$8\times8$点阵屏,对于每一时刻来说只有一列(或者一行)的灯珠是点亮的。 利用人眼的视觉暂留,控制器快速扫描每一列(或者每一行),造成一个完整显示的假象。
现在笔者有一个单列LED,只需让这一列LED动起来,这样在所有不同位置的这一列LED就构成了一个等效点阵屏。 动的方式也很简单,可以是围绕一点的匀速圆周运动(比如绕PC15引脚的旋转)或者干脆像摇手指一样来回甩这一列LED。

LED控制实现
构思有了,现在只需要控制就行了。
关键帧的显示
首先注意到,根据电路图,这8个LED不能同时点亮,可以间隔的四个LED分两组分别控制。笔者犯懒,一个一个控制写个循环扫描挺方便的。 所以即使是这单列LED也是利用视觉暂留效果进行单列显示。
在摇动过程中,需要不断切换单列显示的画面,我将这个被显示的画面称为关键帧。
8个LED的亮灭可以用1字节的数据来表征,所以每个关键帧是1字节,这样一个稍微复杂的二维静态画面可以用一个uint8_t的数组来表征。
比如显示几个字母。

首先定义字母的静态外形,用宏定义来实现。
...
#define CHAR_C 0x7E,0x81,0x81,0x81,0x42
...
#define CHAR_F 0xFF,0x90,0x90,0x90,0x80
...
#define CHAR_K 0xFF,0x10,0x28,0x44,0x83
...
#define CHAR_U 0xFE,0x01,0x01,0x01,0xFE
...
然后定义一个常量数组来描述整个显示的画面,用一个指针指向当前正在显示的关键帧。
const uint8_t led_frames[] = {
  0x00, CHAR_F, 0x00, CHAR_U, 0x00, CHAR_C, 0x00, CHAR_K, 0x00
};
const uint8_t *p_current_frame = led_frames;
const uint16_t frame_count = sizeof(led_frames);
在主函数里进行单个LED的扫描显示。
// include files ...
// macro ...
#define BIT_IN_VAL(val, bit) (((val) >> (bit)) & 0x01)
// defines ...
int main(void) {
  // some code ...
  /* USER CODE BEGIN 2 */
  // 看好了赛博丁真, 按位或是这样用的
  const uint16_t led_pin_mask_all = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
  HAL_GPIO_WritePin(GPIOC, GPIO_PIN_15, GPIO_PIN_SET);
  HAL_GPIO_WritePin(GPIOA, led_pin_mask_all, GPIO_PIN_SET);
  // other init code ...
  /* USER CODE END 2 */
  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    for(int j = 0; j < 8; j++)
    {
      HAL_GPIO_WritePin(GPIOA, led_pin_mask_all, GPIO_PIN_SET);
      HAL_GPIO_WritePin(GPIOA, 1 << j, BIT_IN_VAL(~*p_current_frame, j));
    }
    /* USER CODE END WHILE */
    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}
关键帧的切换
能显示关键帧了,如何切换呢?
首先想到的最直观的方法是隔一段时间来进行切换,如果在主函数里来实现的话可以理解为“当前帧显示若干次之后切换”。
这种切换方式非常原始,很不方便也不稳定。需要定时的功能肯定是从定时计数器中断来实现了。配置定时计数器溢出中断并编写中断服务函数来切换显示指针就可以了。
首先在CubeMX里配置时钟。选择8MHz外部高速时钟,9倍频后作为系统时钟,最后分配给各外设。注意到笔者的配置结果无论是APB1还是APB2的定时计数器时钟都是72MHz。

之后配置定时计数器TIM4。选择内部时钟信号,预分频器(PSC)设置35,设置增计数模式,计数周期设置为499,自动预装载。然后在NVIC选项卡开启全局中断。

简单一点,循环显示。当指针指向的位置超过了数组范围后,重新将指针调整到数组头。这样就能将一个画面反复显示了。定时计数器溢出中断服务函数如下。
/**
  * @brief  Period elapsed callback in non-blocking mode
  * @param  htim TIM handle
  * @retval None
  */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  if(htim == &htim4)
  {
    p_current_frame++;
    if(p_current_frame == led_frames + sizeof(led_frames))
      p_current_frame = led_frames;
  }
}
显示问题
写完这套代码之后发现一个问题,从时间顺序上来说,总是从数组的低地址先显示,高地址后显示。 但是甩动过程是一个往复运动,从左往右运动显示是正确的顺序,如果从右往左运动那显示的图像就是镜像的。
现在需要一个解决方案,能检测运动方向并且根据甩动的周期动态决定关键帧的切换周期。最终决定,MPU6050。
MPU6050

MPU6050指的是芯片,同芯片的外部封装传感器模块也有所不同。MPU6050是六轴运动传感器,包括三轴加速度和三轴角速度。 上面考虑的甩动可以近似认为是圆周运动,直接测量某个方向的角速度就能满足我们测量周期的需求。
引脚定义
传统GY-521模块(板载MPU6050芯片)有8个引脚:
- VCC电源
- GND地
- SCLI2C时钟线
- SDAI2C数据线
- XDA
- XCL
- AD0地址选择线,MPU6050默认的7位I2C地址是- 0x68,如果将- AD0引脚拉高地址就切换为- 0x69
- INT中断信号线
Bluepill电路板焊接LED的一侧也还剩下8个引脚:
- PB0
- PB1
- PB10
- PB11
- RESET
- 3.3V
- GND
- GND
其中,PB10和PB11是单片机硬件上I2C的SCL和SDA引脚,只能对应与MPU6050的I2C引脚相连。注意应当将这两条信号线上拉。
PB10和PB11对应连接到AD0和INT。
面向对象的定义
首先定义MPU6050的抽象和数据抽象。
typedef struct {
  I2C_HandleTypeDef *hi2c;  // I2C handler pointer
  
  GPIO_TypeDef *AD0_GPIO;   // GPIO port to pin AD0
  uint16_t AD0_Pin;         // GPIO pin to pin AD0
  uint8_t AD0_State;        // State of AD0
  GPIO_TypeDef *INT_GPIO;   // GPIO port to pin INT
  uint16_t INT_Pin;         // GPIO pin to pin INT
  
  uint8_t GyroFullScale;    // Gyroscope full scale select
  uint8_t AcceFullScale;    // Accelerometer full scale select
  uint16_t SamplingRate;    // Sampling Rate 4~1000 Hz
  uint8_t address;          // I2C address for mpu
} MPU_HandleTypeDef;
typedef struct {
  union {                   // Accelerometer raw data
    struct {
      int16_t AcceXRaw;
      int16_t AcceYRaw;
      int16_t AcceZRaw;
    };
    uint8_t AcceRaw[6];
  };
  double Ax;
  double Ay;
  double Az;
  union {
    struct {
      int16_t GyroXRaw;
      int16_t GyroYRaw;
      int16_t GyroZRaw;
    };
    uint8_t GyroRaw[6];
  };
  double Gx;
  double Gy;
  double Gz;
  int16_t TempRaw;
  float Temp;
} MPU_DataTypeDef;
MPU的抽象里面定义了很多字段,在初始化的时候都是有用的。
主要功能实现
为了方便编写代码,定义了一个判断操作是否成功的宏。如果操作失败直接从当前函数返回。
// Return HAL_ERROR if the `func` returns a non HAL_OK value
#define HAL_ValidOperation(func, ...) if (  \
  func(__VA_ARGS__) != HAL_OK               \
) return HAL_ERROR
首先,最主要的,读写某个地址的寄存器,调用函数HAL_I2C_Mem_Write。这个函数最后一个参数是超时时间,建议设置小一点,因为这些函数可能会在中断服务中被调用。
inline HAL_StatusTypeDef HAL_MPU6050_WriteReg(MPU_HandleTypeDef *hmpu, uint8_t RegAddress, uint8_t *pData)
{
  return HAL_I2C_Mem_Write(hmpu->hi2c, hmpu->address, RegAddress, I2C_MEMADD_SIZE_8BIT, pData, 1, 1);
}
inline HAL_StatusTypeDef HAL_MPU6050_ReadReg(MPU_HandleTypeDef *hmpu, uint8_t RegAddress, uint8_t *pData)
{
  return HAL_I2C_Mem_Read(hmpu->hi2c, hmpu->address, RegAddress, I2C_MEMADD_SIZE_8BIT, pData, 1, 1);
}
复位MPU6050,参考了手册的内容,向几个地址写入几个值即可。
inline HAL_StatusTypeDef HAL_MPU6050_Reset(MPU_HandleTypeDef *hmpu)
{
  uint8_t 
  temp = 0x80;
  HAL_ValidOperation(HAL_MPU6050_WriteReg, hmpu, MPU_REG_PWR_MGMT_1, &temp);
  HAL_Delay(100);
  temp = 0x01;
  HAL_ValidOperation(HAL_MPU6050_WriteReg, hmpu, MPU_REG_PWR_MGMT_1, &temp);
  temp = 0x00;
  HAL_ValidOperation(HAL_MPU6050_WriteReg, hmpu, MPU_REG_PWR_MGMT_2, &temp);
  return HAL_OK;
}
重头戏:初始化。MPU6050有一个WHO_AM_I只读寄存器,读取这个寄存器会返回MPU的默认7位地址即0x68,这个返回的值与AD0无关。
读取这个寄存器,正常情况下应该输出一个0x68,否则就是出问题了。
初始化中需要设置陀螺仪和加速度计的测量范围,这两个寄存器的值影响了后面结果的分析。
MPU6050可以定义采样率,受寄存器SMPRT_DIV和CONFIG控制。
受笔者需求的影响,在初始化最后笔者还设置了中断信号的发生,每次传感器采样之后会将数据填入缓存中,当数据填充完毕会发射一个中断信号。 主控收到中断信号,开始读取寄存器,进行一系列处理。
HAL_StatusTypeDef HAL_MPU6050_Init(MPU_HandleTypeDef *hmpu)
{
  // set AD0 pin state
  HAL_GPIO_WritePin(hmpu->AD0_GPIO, hmpu->AD0_Pin, hmpu->AD0_State);
  hmpu->address = (MPU_DEFAULT_7BIT_ADDR | hmpu->AD0_State) << 1;
  uint8_t check;
  HAL_ValidOperation(HAL_MPU6050_ReadReg, hmpu, MPU_REG_WHO_AM_I, &check);
  if(check != MPU_DEFAULT_7BIT_ADDR)
  {
    return HAL_ERROR;
  }
  HAL_ValidOperation(HAL_MPU6050_Reset, hmpu);
  HAL_ValidOperation(HAL_MPU6050_SetGyro, hmpu, hmpu->GyroFullScale);
  HAL_ValidOperation(HAL_MPU6050_SetAcce, hmpu, hmpu->AcceFullScale);
  HAL_ValidOperation(HAL_MPU6050_SetSamplingRate, hmpu, hmpu->SamplingRate);
  uint8_t addr[] = {
    MPU_REG_INT_ENABLE,
    MPU_REG_USER_CTRL,
    MPU_REG_FIFO_ENABLE,
    MPU_REG_INT_PIN_CFG,
  },
  temp[] = {
    0x01,
    0x00,
    0X00,
    0x80,
  };
  HAL_ValidOperation(HAL_MPU6050_WriteRegs, hmpu, addr, sizeof(addr), temp);
  return HAL_OK;
}
除了上述主要函数以外,还有数据读取函数和单位转化函数。这里不多赘述。
HAL_StatusTypeDef HAL_MPU6050_WriteReg(MPU_HandleTypeDef *hmpu, uint8_t RegAddress, uint8_t *pData);
HAL_StatusTypeDef HAL_MPU6050_ReadReg(MPU_HandleTypeDef *hmpu, uint8_t RegAddress, uint8_t *pData);
HAL_StatusTypeDef HAL_MPU6050_Init(MPU_HandleTypeDef *hmpu);
HAL_StatusTypeDef HAL_MPU6050_Reset(MPU_HandleTypeDef *hmpu);
HAL_StatusTypeDef HAL_MPU6050_SetGyro(MPU_HandleTypeDef *hmpu, uint8_t val);
HAL_StatusTypeDef HAL_MPU6050_SetAcce(MPU_HandleTypeDef *hmpu, uint8_t val);
HAL_StatusTypeDef HAL_MPU6050_SetSamplingRate(MPU_HandleTypeDef *hmpu, uint16_t rate);
HAL_StatusTypeDef HAL_MPU6050_SetLPF(MPU_HandleTypeDef *hmpu, uint16_t lpf);
HAL_StatusTypeDef HAL_MPU6050_ReadGyro_Raw(MPU_HandleTypeDef *hmpu, MPU_DataTypeDef *pData);
HAL_StatusTypeDef HAL_MPU6050_ReadAcce_Raw(MPU_HandleTypeDef *hmpu, MPU_DataTypeDef *pData);
void HAL_MPU6050_Convert_Gyro(MPU_HandleTypeDef *hmpu, MPU_DataTypeDef *pData);
void HAL_MPU6050_Convert_Acce(MPU_HandleTypeDef *hmpu, MPU_DataTypeDef *pData);
HAL_StatusTypeDef HAL_MPU6050_INT_Handler(MPU_HandleTypeDef *hmpu, MPU_DataTypeDef *pData);
函数调用
在实际使用过程中,默认I2C外设和各个GPIO已经在别处初始化。笔者根据自己的情况做了如下配置。
void MPU_Init()
{
  hmpu.hi2c = &hi2c2;
  hmpu.AD0_GPIO = GPIOB;
  hmpu.AD0_Pin = GPIO_PIN_0;
  hmpu.AD0_State = 0;
  hmpu.INT_GPIO = GPIOB;
  hmpu.INT_Pin = GPIO_PIN_1;
  hmpu.GyroFullScale = MPU_GYRO_FS_1000;
  hmpu.AcceFullScale = MPU_ACCE_FS_16G;
  hmpu.SamplingRate = 1000;
  HAL_MPU6050_Init(&hmpu);
}
根据实际传回的数据,存在大量的噪声。笔者这里所测量的主要动作是人手的晃动,这个运动的频率大概是5Hz。所以除了调用上面的初始化函数以外,笔者还重新设置了低通滤波器的截止频率。
int main(void)
{
  // some code ...
  MPU_Init();
  HAL_MPU6050_SetLPF(&hmpu, 20);
  // some code ...
}
重新定义中断服务函数,包括读取数据和数据分析,这里不做赘述。
最终笔者设法找到了角速度的一阶零点,并且根据前一个一节零点测量并预测了下一个零点到来的时间。
MPU6050驱动库
在编写过程中参考了HAL库的命名风格和参数风格,定义了面向对象的结构方便进行多设备管理。
驱动库已开源
yuxiaoyuan0406/hal_mpu6050
参考
STM32F103C8T6 STM32开发板最小系统板单片机核心板 学习板实验板
使用HAL库开发STM32:Timer基础说明与定时功能使用
GY-521 MPU6050模块 三维角度传感器6DOF三轴加速度计电子陀螺仪