1. 项目背景与应用场景

做嵌入式开发的朋友们应该都遇到过这样的场景:设备已经装好了,程序也在稳定运行,但突然发现PID参数需要微调。传统做法是连接调试器,修改代码,重新烧录,整个过程繁琐又耗时。我在做平衡小车项目时就经常遇到这个问题,每次调参都要把车拆开接上线,调完再装回去,效率极低。

后来我发现用HC05蓝牙模块可以完美解决这个问题。通过手机APP就能实时调整PID参数,还能看到系统响应曲线,调参效率提升了不止一个档次。这种远程调参方式特别适合需要频繁调整参数的场景,比如智能车竞赛、无人机调试、机器人控制等项目。

STM32F103C8T6作为性价比极高的MCU,搭配HC05蓝牙模块,成本不到30元就能实现无线调参功能。MPU6050等传感器数据也能实时回传,让你在手机上直观看到参数调整后的系统响应变化。这种方案既经济又实用,下面我就分享具体实现方法。

2. 硬件连接与初始化配置

2.1 硬件连接详解

HC05蓝牙模块与STM32的连接非常简单,只需要4根线。VCC接3.3V,GND接地,TXD接STM32的RX(PB7),RXD接STM32的TX(PB6)。这里要注意的是,有些HC05模块默认波特率是9600,有些是38400,建议先用AT指令设置为统一波特率。

我在实际使用中发现,如果电源供电不足,蓝牙模块工作时会出现不稳定的情况。建议给HC05单独供电,或者使用质量较好的3.3V稳压芯片。还有一点,HC05模块最好远离电机等大电流设备,避免电磁干扰导致数据错误。

2.2 USART初始化配置

串口初始化是基础但关键的一步。我习惯使用USART1,因为它的引脚重映射功能很灵活。以下是完整的初始化代码,加了详细注释:

void HC05_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;
    NVIC_InitTypeDef NVIC_InitStructure;
    
    // 开启时钟,注意要开启AFIO时钟才能重映射
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_USART1 | RCC_APB2Periph_AFIO, ENABLE);
    
    // 重映射USART1到PB6、PB7
    GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE);
    
    // 配置TX引脚为推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    
    // 配置RX引脚为上拉输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    
    // USART参数配置
    USART_InitStructure.USART_BaudRate = 9600;      // 波特率
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;  // 8位数据位
    USART_InitStructure.USART_StopBits = USART_StopBits_1;       // 1位停止位
    USART_InitStructure.USART_Parity = USART_Parity_No;          // 无校验
    USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; // 收发模式
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 无硬件流控
    USART_Init(USART1, &USART_InitStructure);
    
    // 使能接收中断和空闲中断
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
    USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);
    
    // 配置NVIC
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 8;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
    
    USART_Cmd(USART1, ENABLE);  // 使能串口
}

这里有个小技巧:开启空闲中断可以检测一帧数据接收完成,比用定时器判断超时更可靠。我刚开始做的时候只用接收中断,结果经常丢数据,加了空闲中断后就稳定多了。

3. 数据包设计与通信协议

3.1 数据包格式定义

稳定的通信离不开良好的协议设计。我设计的数据包格式如下:包头(0xA5)、数据内容、校验和、包尾(0x5A)。这种格式简单实用,容易解析。

为什么要用校验和?因为无线通信难免会受到干扰,校验和能确保数据完整性。我遇到过因为电机干扰导致数据错误的情况,加了校验和后就能及时发现错误数据并丢弃。

数据内容部分可以灵活定义,我通常包含这些数据:

  • 当前角度值(float,4字节)
  • 目标角度值(float,4字节)
  • Kp参数(float,4字节)
  • Ki参数(float,4字节)
  • Kd参数(float,4字节)

这样一个数据包总共15字节数据,加上包头包尾和校验和,一共18字节。传输时间约20ms(9600波特率),完全满足实时性要求。

3.2 数据发送实现

数据发送的关键是要把float等类型数据转换为字节流。我写了一系列发送函数:

// 发送单字节
void HC05_SendByte(uint8_t data)
{
    USART_SendData(USART1, data);
    while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}

// 发送16位数据
uint8_t HC05_SendData16(int16_t data)
{
    uint8_t sum = 0;
    uint8_t *p = (uint8_t*)&data;
    
    for(int i=0; i<2; i++){
        HC05_SendByte(p[i]);
        sum += p[i];
    }
    return sum;
}

// 发送float数据
uint8_t HC05_SendDataFloat(float data)
{
    uint8_t sum = 0;
    uint8_t *p = (uint8_t*)&data;
    
    for(int i=0; i<4; i++){
        HC05_SendByte(p[i]);
        sum += p[i];
    }
    return sum;
}

// 发送完整数据包
void HC05_SendDataPacket(float yaw, float target, float kp, float ki, float kd)
{
    uint8_t checksum = 0;
    
    HC05_SendByte(0xA5);  // 包头
    
    checksum += HC05_SendDataFloat(yaw);
    checksum += HC05_SendDataFloat(target);
    checksum += HC05_SendDataFloat(kp);
    checksum += HC05_SendDataFloat(ki);
    checksum += HC05_SendDataFloat(kd);
    
    HC05_SendByte(checksum);  // 校验和
    HC05_SendByte(0x5A);      // 包尾
}

实际使用时,我一般在主循环中每隔100ms发送一次数据,避免蓝牙信道拥堵。调试时可以缩短发送间隔,方便观察实时波形。

4. 数据接收与解析处理

4.1 中断服务程序实现

数据接收是整个系统的关键,处理不好就会丢数据。我采用接收中断+空闲中断的方式:

#define MAX_DATA_LENGTH 20
uint8_t rx_buffer[MAX_DATA_LENGTH];
uint8_t rx_index = 0;

void USART1_IRQHandler(void)
{
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        // 接收中断,读取数据存入缓冲区
        rx_buffer[rx_index++] = USART_ReceiveData(USART1);
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
    else if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET)
    {
        // 空闲中断,表示一帧数据接收完成
        USART_ReceiveData(USART1);  // 读SR寄存器清除空闲中断
        USART_ClearITPendingBit(USART1, USART_IT_IDLE);
        
        // 处理接收到的数据
        if(rx_index > 0){
            ProcessReceivedData(rx_buffer, rx_index);
            rx_index = 0;  // 重置索引
        }
    }
}

这里有个坑要注意:出现空闲中断时,必须先读一次SR寄存器,再读DR寄存器,才能正确清除中断标志。我刚开始没注意这个,调试了好久才发现问题。

4.2 数据解析与参数更新

接收到的数据需要解析并更新PID参数。我使用状态机的方式来解析数据包:

typedef enum{
    STATE_WAIT_HEADER,
    STATE_RECEIVING_DATA,
    STATE_CHECK_SUM,
    STATE_WAIT_TAIL
} ParseState;

ParseState current_state = STATE_WAIT_HEADER;
uint8_t data_buffer[16];
uint8_t data_index = 0;
uint8_t expected_checksum = 0;

void ProcessReceivedData(uint8_t* data, uint8_t length)
{
    for(int i=0; i<length; i++){
        uint8_t byte = data[i];
        
        switch(current_state){
            case STATE_WAIT_HEADER:
                if(byte == 0xA5){
                    current_state = STATE_RECEIVING_DATA;
                    data_index = 0;
                    expected_checksum = 0;
                }
                break;
                
            case STATE_RECEIVING_DATA:
                if(data_index < 16){
                    data_buffer[data_index++] = byte;
                    expected_checksum += byte;
                }
                if(data_index >= 16){
                    current_state = STATE_CHECK_SUM;
                }
                break;
                
            case STATE_CHECK_SUM:
                if(byte == expected_checksum){
                    current_state = STATE_WAIT_TAIL;
                }else{
                    // 校验失败,重置状态机
                    current_state = STATE_WAIT_HEADER;
                }
                break;
                
            case STATE_WAIT_TAIL:
                if(byte == 0x5A){
                    // 完整数据包接收成功,更新PID参数
                    UpdatePIDParameters(data_buffer);
                }
                current_state = STATE_WAIT_HEADER;
                break;
        }
    }
}

void UpdatePIDParameters(uint8_t* data)
{
    float new_kp = *(float*)&data[0];
    float new_ki = *(float*)&data[4]; 
    float new_kd = *(float*)&data[8];
    
    // 更新前可以做参数范围检查
    if(new_kp >= 0 && new_kp <= 10.0f &&
       new_ki >= 0 && new_ki <= 5.0f &&
       new_kd >= 0 && new_kd <= 5.0f){
        
        pid.kp = new_kp;
        pid.ki = new_ki;
        pid.kd = new_kd;
        
        // 重置积分项,避免参数突变导致系统震荡
        pid.integral = 0;
        pid.prev_error = 0;
    }
}

这种状态机的方式虽然代码量稍多,但可靠性很高。我在平衡小车项目中使用这种方式,从未出现过参数更新错误的情况。

5. 手机APP配置与调试技巧

5.1 蓝牙调试器APP使用

我常用的蓝牙调试器APP有很多种,但功能都大同小异。以常用的"蓝牙串口"APP为例,首先需要配对HC05模块,默认配对密码一般是1234。连接成功后,进入"高级调试"模式。

在高级调试模式下,可以自定义数据包格式。按照前面定义的协议格式设置:

  • 包头:0xA5
  • 数据:4个float类型,共16字节
  • 校验和:1字节
  • 包尾:0x5A

APP端可以设置控件来发送参数。我一般设置三个滑动条分别控制Kp、Ki、Kd,范围根据实际系统调整。比如平衡小车的Kp一般在2.0-4.0之间,Ki在0.1-0.5之间,Kd在0.5-1.5之间。

5.2 波形显示与调参技巧

波形显示功能非常实用,可以同时显示目标值和实际值。我一般让目标值显示为方波,实际值显示为曲线,这样能清晰看到系统的响应特性。

调参时有个小技巧:先调Kp让系统基本稳定,再调Kd抑制超调,最后调Ki消除静差。通过手机APP可以实时看到参数调整后的效果,大大提高了调参效率。

我还会在APP端设置几个预设参数按钮,比如"保守参数"、"激进参数"等,一键切换不同参数组合,方便对比调试效果。

6. 常见问题与解决方案

6.1 通信稳定性问题

蓝牙通信偶尔会出现数据错误或连接中断。我总结了几点经验:首先确保电源稳定,HC05模块工作电流峰值可达40mA,电源不足会导致模块重启。其次,天线的位置很重要,尽量让天线远离金属物体和电机等干扰源。

数据校验也很关键。除了校验和外,我还会在APP端加入超时重发机制。如果一段时间没有收到下位机的应答,就重新发送参数数据。

6.2 参数调节注意事项

远程调参虽然方便,但也要注意安全。特别是调试平衡系统时,参数突变可能导致系统失控。我一般在代码中加入参数变化率限制,避免单次调整幅度过大。

还有一个经验:调参时最好记录每次的参数和效果。很多APP都支持数据导出功能,可以把调试数据导出到Excel,方便后续分析。

我在实际项目中发现,温度变化会影响系统特性。所以最好在不同温度下测试参数效果,找到一组在各种条件下都能稳定工作的参数。

7. 扩展应用与优化方向

这个基础框架不仅可以用于PID调参,还能扩展很多应用。比如可以增加系统状态监控,实时显示电池电压、电机电流等信息。还可以增加故障诊断功能,当系统出现异常时自动记录数据并通过蓝牙发送报警信息。

性能方面也有优化空间。比如使用更高效的通信协议,减少数据包开销。或者增加数据压缩功能,在有限的带宽内传输更多信息。我还尝试过使用蓝牙4.0以上的模块,功耗更低,传输速率更高。

对于复杂的系统,可以设计多组参数并支持热切换。比如智能车在不同赛道上使用不同的参数,通过手机APP一键切换。这种灵活性在现场调试时特别有用。

更多推荐