公司动态
基于瑞萨RA MCU与Qt的无线遥控小车开发实战
1. 项目缘起当瑞萨RA MCU遇上Qt遥控最近在捣鼓一个挺有意思的玩意儿用瑞萨的RA系列MCU做核心控制器再通过Qt写个上位机软件实现一个无线遥控小车。这个项目听起来像是把嵌入式底层和PC端应用开发给串起来了确实能玩出不少花样。我手头用的是瑞萨RA6M5开发板这块板子性能不错带Arm® Cortex®-M4内核主频够高外设也丰富正好适合用来驱动电机、处理无线通信。而上位机这边选择Qt主要是因为它跨平台特性好写个带界面的控制软件很快而且信号槽机制处理异步通信比如接收串口数据非常顺手。这个项目的核心逻辑其实不复杂Qt上位机负责生成控制指令比如前进、后退、转向通过串口或者无线模块如蓝牙、Wi-Fi发送给RA6M5。RA6M5收到指令后解析并控制电机驱动电路比如用PWM驱动TB6612这类电机驱动芯片从而让小车动起来。同时MCU也可以采集一些传感器数据比如超声波测距、陀螺仪回传给上位机显示形成一个简单的闭环。但真动起手来从环境搭建、代码生成到联调每一步都有不少细节要注意尤其是瑞萨的FSPFlexible Software Package库和Qt的串口通信配置上容易踩坑。下面我就把整个搭建和开发过程连同遇到的坑和解决思路详细捋一遍。2. 开发环境搭建与FSP配置工欲善其事必先利其器。玩转瑞萨RA MCU第一步就是把开发环境整利索。瑞萨主推的开发环境是e² studio但它也支持Keil、IAR等。我个人更喜欢用e² studio配合FSP因为FSP提供了图形化配置工具能大大简化外设初始化和中间件集成对于快速原型开发特别友好。2.1 软件安装与基础工程创建首先你需要去瑞萨官网下载并安装e² studio和对应的FSP库。安装过程比较常规注意选择支持RA家族的产品包。安装完成后打开e² studio新建一个“Renesas RA C/C Project”。在项目配置向导里关键几步是选择芯片型号根据你的开发板选择我的是RA6M5。选择工具链通常选GCC Arm Embedded。选择FSP版本建议用较新的稳定版新版本通常会修复一些旧版的Bug并增加新特性。项目模板对于小车控制一个干净的“Bare Metal - Minimal”模板就够用了我们不需要RTOS当然如果你后续想做复杂任务调度也可以选带ThreadX或FreeRTOS的模板。项目创建好后你会看到一个重要的视图FSP配置视图。这是整个项目的核心配置界面。在这里你可以通过点击“Stacks”来添加和配置各种“堆栈”每个堆栈对应一个软件模块或外设驱动比如UART、GPT定时器、I2C、ADC等。2.2 关键外设的图形化配置对于遥控小车我们至少需要配置以下堆栈UART堆栈用于通信在FSP配置视图中点击“New Stack” - “Connectivity” - “UART”。我将其命名为g_uart0。在属性窗口中需要仔细配置几个参数Channel: 选择硬件UART通道例如0对应开发板上的某个串口引脚需要查原理图确认。Baud Rate: 设置波特率比如115200。这里有个坑务必确保上位机Qt程序和MCU端的波特率、数据位、停止位、校验位完全一致否则通信会乱码或失败。Callback: 这里填写一个函数名比如user_uart_callback。这个函数是UART中断的回调入口当收到数据或发送完成时FSP库会调用它。这是实现异步数据接收的关键。配置完成后FSP会自动在hal_data.c文件中生成一个g_uart0的实例结构体里面包含了所有配置参数并在hal_data.h中声明。GPT堆栈用于生成PWM小车电机通常用PWM控制速度和方向。RA6M5的GPT通用PWM定时器模块可以很方便地产生PWM。点击“New Stack” - “Timers” - “Timer, General PWM (r_gpt)”.命名为g_timer_pwm。属性配置Channel: 选择一个GPT通道例如0。Period (periods): PWM周期单位是定时器计数。这需要根据你的电机驱动芯片要求和PWM频率来计算。例如如果系统时钟为100MHz我们希望PWM频率为10kHz那么周期值 时钟频率 / PWM频率 100,000,000 / 10,000 10,000。Duty Cycle (percent): 初始占空比可以先设为0。Output Pin: 选择这个GPT通道对应的输出引脚这个引脚需要连接到电机驱动芯片的PWM输入脚。I/O端口堆栈用于控制方向电机驱动芯片如TB6612除了PWM速度控制还需要方向控制脚IN1, IN2。这可以通过配置普通的GPIO堆栈实现。“New Stack” - “Platform” - “I/O Port”。配置对应的引脚为输出模式初始电平为低。注意所有引脚配置UART的TX/RXGPT的PWM输出GPIO方向控制都必须和你的硬件电路原理图严格对应。在FSP配置视图的“Pins”标签页下可以可视化地查看和分配引脚功能这是非常实用的功能能有效避免引脚冲突。配置完成后点击“Generate Project Content”按钮那个小锤子图标。FSP会根据你的图形化配置自动生成初始化代码、引脚配置代码以及中断向量表设置等。生成的代码主要在src目录下的hal_entry.c和generated目录中。切记不要手动修改generated目录下的文件因为重新配置并生成代码时会覆盖它们。你的应用逻辑应该写在hal_entry.c或自己新建的源文件中。3. MCU端驱动与通信逻辑实现环境配好了骨架也有了接下来就是往里面填充肌肉——编写控制逻辑。MCU端的代码主要分为三块外设初始化、通信协议解析、电机控制。3.1 外设初始化与启动FSP生成的代码已经完成了底层寄存器的配置。我们只需要在hal_entry.c的hal_entry()函数相当于main函数中打开这些外设即可。void hal_entry(void) { /* 初始化UART驱动 */ fsp_err_t err R_SCI_UART_Open(g_uart0_ctrl, g_uart0_cfg); if (FSP_SUCCESS ! err) { /* 处理错误 */ } /* 初始化GPT定时器PWM驱动 */ err R_GPT_Open(g_timer_pwm_ctrl, g_timer_pwm_cfg); if (FSP_SUCCESS ! err) { /* 处理错误 */ } /* 启动定时器开始输出PWM */ err R_GPT_Start(g_timer_pwm_ctrl); if (FSP_SUCCESS ! err) { /* 处理错误 */ } /* 初始化GPIO */ err R_IOPORT_Open(g_ioport_ctrl, g_ioport_cfg); if (FSP_SUCCESS ! err) { /* 处理错误 */ } /* 主循环 */ while (1) { /* 这里可以放一些后台任务如传感器数据采集 */ /* 通信处理在回调函数中进行 */ } }3.2 UART中断回调与数据接收通信是遥控的核心。我们采用中断方式接收数据避免主循环被阻塞。之前在FSP里配置的user_uart_callback函数需要我们自己实现。/* 定义一个接收缓冲区 */ #define UART_RX_BUF_SIZE 64 static uint8_t uart_rx_buffer[UART_RX_BUF_SIZE]; static volatile uint16_t uart_rx_index 0; /* UART回调函数 */ void user_uart_callback(uart_callback_args_t *p_args) { switch (p_args-event) { case UART_EVENT_RX_CHAR: /* 收到一个字符 */ if (uart_rx_index (UART_RX_BUF_SIZE - 1)) { uart_rx_buffer[uart_rx_index] (uint8_t)(p_args-data); /* 简单协议以换行符\n作为一帧数据的结束 */ if (uart_rx_buffer[uart_rx_index] \n) { uart_rx_buffer[uart_rx_index] \0; // 字符串终结符 process_rx_command((char*)uart_rx_buffer); // 处理命令 uart_rx_index 0; // 重置索引 } else { uart_rx_index; } } else { /* 缓冲区溢出清空缓冲区 */ uart_rx_index 0; } /* 重新使能接收中断准备接收下一个字符 */ R_SCI_UART_Read(g_uart0_ctrl, NULL, 0); // 这个调用方式依赖于FSP版本有些版本需要在回调里重新调用Read break; case UART_EVENT_TX_COMPLETE: /* 发送完成事件 */ break; default: break; } }这里需要注意FSP不同版本对UART回调中重新使能接收的操作可能不同。有些版本需要在回调中调用R_SCI_UART_Read并传入一个有效的缓冲区而有些版本配置为UART_MODE_CALLBACK会自动重新使能。务必查阅你所用FSP版本的UART驱动API文档。我遇到的坑就是没重新使能接收导致只能收到第一个字符。3.3 命令解析与电机控制process_rx_command函数负责解析从上位机发来的字符串命令并控制电机。我们可以定义一个简单的文本协议例如F100前进PWM占空比100%B050后退占空比50%L左转左轮停或反转右轮正转R右转S停止static void process_rx_command(char *cmd) { /* 简单命令解析 */ switch (cmd[0]) { case F: // 前进 { int speed atoi(cmd[1]); // 提取速度值 set_motor_direction(FORWARD); set_motor_speed(speed); break; } case B: // 后退 { int speed atoi(cmd[1]); set_motor_direction(BACKWARD); set_motor_speed(speed); break; } case L: // 左转 turn_left(); break; case R: // 右转 turn_right(); break; case S: // 停止 stop_motor(); break; default: /* 未知命令 */ break; } } static void set_motor_speed(uint8_t duty_cycle) { /* 设置GPT定时器的占空比 */ /* 注意duty_cycle是百分比需要转换为计数值 */ uint32_t period_counts g_timer_pwm_cfg.period_counts; uint32_t duty_counts (period_counts * duty_cycle) / 100U; R_GPT_DutyCycleSet(g_timer_pwm_ctrl, duty_counts, GPT_IO_PIN_GTIOCA); } static void set_motor_direction(motor_dir_t dir) { /* 设置GPIO电平来控制电机驱动芯片的方向引脚 */ switch(dir) { case FORWARD: R_IOPORT_PinWrite(g_ioport_ctrl, BSP_IO_PORT_XX_PIN_XX, BSP_IO_LEVEL_HIGH); // IN1 R_IOPORT_PinWrite(g_ioport_ctrl, BSP_IO_PORT_XX_PIN_XX, BSP_IO_LEVEL_LOW); // IN2 break; case BACKWARD: R_IOPORT_PinWrite(g_ioport_ctrl, BSP_IO_PORT_XX_PIN_XX, BSP_IO_LEVEL_LOW); R_IOPORT_PinWrite(g_ioport_ctrl, BSP_IO_PORT_XX_PIN_XX, BSP_IO_LEVEL_HIGH); break; case BRAKE: // 刹车 R_IOPORT_PinWrite(g_ioport_ctrl, BSP_IO_PORT_XX_PIN_XX, BSP_IO_LEVEL_HIGH); R_IOPORT_PinWrite(g_ioport_ctrl, BSP_IO_PORT_XX_PIN_XX, BSP_IO_LEVEL_HIGH); break; } }BSP_IO_PORT_XX_PIN_XX需要替换成你在FSP中实际配置的GPIO引脚宏定义。turn_left和turn_right函数则是通过设置左右两侧电机不同的速度和方向来实现差速转向。4. Qt上位机控制端开发MCU端准备就绪现在来打造遥控器——Qt上位机。Qt开发可以用Qt Creator它集成了设计、编码、调试功能非常方便。我们主要用到Qt Widgets模块来构建界面以及Qt SerialPort模块进行串口通信。4.1 界面设计与布局首先用Qt Designer拖拽一个简单的界面。主要控件包括QComboBox用于选择可用的串口号。QPushButton连接/断开串口按钮控制前进、后退、左转、右转、停止的按钮。QSlider或QSpinBox用于调节PWM速度0-100%。QTextBrowser用于显示串口收发日志方便调试。布局上可以用QHBoxLayout和QVBoxLayout进行组合让界面自适应缩放。将控制按钮前后左右停用一个QGridLayout网格布局放在中间速度控制和串口设置放在上方或侧边栏。4.2 串口通信模块封装Qt提供了QSerialPort类来处理串口通信。我们需要将其封装一下便于管理。在Qt项目中新建一个类比如SerialPortManager。头文件serialportmanager.h#ifndef SERIALPORTMANAGER_H #define SERIALPORTMANAGER_H #include QObject #include QSerialPort #include QSerialPortInfo class SerialPortManager : public QObject { Q_OBJECT public: explicit SerialPortManager(QObject *parent nullptr); ~SerialPortManager(); bool openPort(const QString portName, qint32 baudRate); void closePort(); bool isOpen() const; void sendData(const QByteArray data); QStringList availablePorts() const; signals: void dataReceived(const QByteArray data); void portErrorOccurred(const QString error); private slots: void handleReadyRead(); void handleError(QSerialPort::SerialPortError error); private: QSerialPort *m_serialPort; }; #endif // SERIALPORTMANAGER_H源文件serialportmanager.cpp#include serialportmanager.h #include QDebug SerialPortManager::SerialPortManager(QObject *parent) : QObject(parent) , m_serialPort(new QSerialPort(this)) { /* 连接信号槽 */ connect(m_serialPort, QSerialPort::readyRead, this, SerialPortManager::handleReadyRead); connect(m_serialPort, QOverloadQSerialPort::SerialPortError::of(QSerialPort::error), this, SerialPortManager::handleError); } SerialPortManager::~SerialPortManager() { closePort(); } bool SerialPortManager::openPort(const QString portName, qint32 baudRate) { if (m_serialPort-isOpen()) { closePort(); } m_serialPort-setPortName(portName); m_serialPort-setBaudRate(baudRate); m_serialPort-setDataBits(QSerialPort::Data8); m_serialPort-setParity(QSerialPort::NoParity); m_serialPort-setStopBits(QSerialPort::OneStop); m_serialPort-setFlowControl(QSerialPort::NoFlowControl); if (m_serialPort-open(QIODevice::ReadWrite)) { qDebug() 串口 portName 打开成功波特率 baudRate; return true; } else { qDebug() 串口打开失败 m_serialPort-errorString(); emit portErrorOccurred(m_serialPort-errorString()); return false; } } void SerialPortManager::closePort() { if (m_serialPort-isOpen()) { m_serialPort-close(); qDebug() 串口已关闭; } } bool SerialPortManager::isOpen() const { return m_serialPort-isOpen(); } void SerialPortManager::sendData(const QByteArray data) { if (m_serialPort-isOpen()) { qint64 bytesWritten m_serialPort-write(data); if (bytesWritten -1) { qDebug() 发送数据失败 m_serialPort-errorString(); emit portErrorOccurred(m_serialPort-errorString()); } else if (bytesWritten ! data.size()) { qDebug() 数据未完全发送; } else { // 可选等待数据发送完毕对于短指令通常不需要 // m_serialPort-waitForBytesWritten(1000); } } else { qDebug() 串口未打开无法发送数据; } } QStringList SerialPortManager::availablePorts() const { QStringList ports; const auto infos QSerialPortInfo::availablePorts(); for (const QSerialPortInfo info : infos) { ports info.portName(); } return ports; } void SerialPortManager::handleReadyRead() { QByteArray data m_serialPort-readAll(); if (!data.isEmpty()) { emit dataReceived(data); // 发射信号通知主界面有数据到达 } } void SerialPortManager::handleError(QSerialPort::SerialPortError error) { if (error ! QSerialPort::NoError) { qDebug() 串口错误 m_serialPort-errorString(); emit portErrorOccurred(m_serialPort-errorString()); closePort(); // 发生错误时自动关闭串口 } }这个封装类处理了串口的打开、关闭、发送、接收以及错误处理。readyRead信号在有数据可读时触发我们在槽函数handleReadyRead中读取所有数据并转发出去。4.3 主窗口逻辑与控制指令发送在主窗口类比如MainWindow中集成SerialPortManager并连接按钮的点击事件到相应的控制函数。// 在MainWindow构造函数中初始化 m_serialManager new SerialPortManager(this); connect(m_serialManager, SerialPortManager::dataReceived, this, MainWindow::onDataReceived); connect(m_serialManager, SerialPortManager::portErrorOccurred, this, MainWindow::onPortError); // 刷新可用串口列表 void MainWindow::refreshPortList() { ui-comboBoxPort-clear(); ui-comboBoxPort-addItems(m_serialManager-availablePorts()); } // 连接/断开串口按钮槽函数 void MainWindow::on_pushButtonConnect_clicked() { if (!m_serialManager-isOpen()) { QString portName ui-comboBoxPort-currentText(); qint32 baudRate ui-comboBoxBaud-currentText().toInt(); if (m_serialManager-openPort(portName, baudRate)) { ui-pushButtonConnect-setText(断开); // 禁用串口选择下拉框防止连接时更改 ui-comboBoxPort-setEnabled(false); ui-comboBoxBaud-setEnabled(false); } } else { m_serialManager-closePort(); ui-pushButtonConnect-setText(连接); ui-comboBoxPort-setEnabled(true); ui-comboBoxBaud-setEnabled(true); } } // 发送控制命令 void MainWindow::sendCommand(const QString cmd) { if (m_serialManager-isOpen()) { // 将QString转换为QByteArray并添加换行符作为帧结束标志 QByteArray data (cmd \n).toUtf8(); m_serialManager-sendData(data); // 在日志中显示发送的数据 ui-textBrowserLog-append(发送: cmd); } else { ui-textBrowserLog-append(错误: 串口未连接); } } // 控制按钮槽函数示例 void MainWindow::on_pushButtonForward_clicked() { int speed ui-horizontalSliderSpeed-value(); // 获取滑块速度值 QString cmd QString(F%1).arg(speed, 3, 10, QChar(0)); // 格式化为三位数如F100 sendCommand(cmd); } void MainWindow::on_pushButtonStop_clicked() { sendCommand(S); } // 接收数据显示 void MainWindow::onDataReceived(const QByteArray data) { QString received QString::fromUtf8(data); ui-textBrowserLog-append(接收: received.trimmed()); }这里有几个细节协议一致性MCU端以\n作为帧结束所以Qt发送时也必须在命令字符串后加上\n。速度值格式化QString(“F%1”).arg(speed, 3, 10, QChar(‘0’))这行代码确保速度值总是三位数如050方便MCU端解析。arg函数的第二个参数3表示最小宽度为3第三个参数10表示十进制第四个参数QChar(‘0’)表示用0填充空白。线程安全QSerialPort的读写操作是异步的readyRead信号可能在非GUI线程中发射。不过QSerialPort在设计时已经考虑了这一点当在对象所属的线程通常是主线程中创建和连接信号槽时跨线程的信号发射是安全的。但如果你在子线程中使用QSerialPort则需要更谨慎地处理线程间通信。5. 联调测试与常见问题排查软硬件都写完最激动人心也最头疼的联调环节来了。把RA6M5开发板通过USB转串口线或直接使用板载的USB虚拟串口连接到电脑给小车底盘上电然后开始测试。5.1 上电与连接测试检查供电确保MCU开发板和电机驱动模块供电正常且电压匹配。电机驱动模块的电源最好和MCU逻辑电源分开并用共地连接。烧录程序在e² studio中编译项目通过J-Link、板载调试器等工具将程序烧录到RA6M5中。识别串口打开电脑的设备管理器Windows或使用ls /dev/tty*命令Linux/macOS查看开发板对应的串口号。在Qt上位机中刷新并选择该串口设置正确的波特率如115200点击连接。如果连接成功Qt的日志框应该没有错误提示。此时可以尝试在Qt中点击“前进”按钮观察小车轮子是否转动。如果不动排查就开始了。5.2 通信失败排查流程这是一个典型的排查链路可以一步步来第一步确认物理连接检查USB线是否插好串口线是否连接到了正确的MCU引脚TX接MCU的RXRX接MCU的TX。用万用表测量串口引脚电压发送数据时应有电平变化。第二步确认串口参数重中之重对比Qt端和FSP配置中的波特率、数据位、停止位、校验位。必须一字不差。一个常见的低级错误是Qt端选了9600MCU端配置了115200。检查MCU的UART引脚配置是否正确是否被其他功能复用。第三步MCU端接收测试在MCU的user_uart_callback函数中添加调试代码比如收到一个字符就翻转一个LED灯的状态。如果点击Qt按钮时LED灯闪烁说明数据已经成功传到MCU。如果LED不闪问题可能出在UART初始化或打开失败检查hal_entry()中R_SCI_UART_Open的返回值。中断未使能确保在hal_entry()中调用了R_SCI_UART_Read启动接收对于某些FSP配置模式。或者在回调函数中是否正确重新启动了接收。缓冲区或回调函数问题检查接收缓冲区大小是否足够回调函数是否被正确注册和调用。第四步MCU端命令解析测试如果LED闪烁说明数据收到了但小车不动。接下来在process_rx_command函数里加调试比如通过串口把解析到的命令原样发回给Qt需要实现发送函数。在Qt日志中看是否收到了正确的回显。如果没收到回显或回显乱码检查命令解析逻辑特别是字符串处理比如atoi函数在遇到非数字字符时的行为。第五步电机驱动测试如果命令解析正确但电机不动。绕过通信直接在MCU代码里写死一个电机转动函数比如上电就全速前进测试电机驱动电路和MCU的PWM/GPIO输出是否正常。用示波器或逻辑分析仪测量PWM输出引脚看是否有波形频率和占空比是否符合预期。这是最直接的诊断方法。检查电机驱动芯片的使能引脚如果有是否被正确拉高。检查电机电源是否接通电机本身是否完好。5.3 Qt端常见问题QSerialPort无法打开串口Permission DeniedLinux/macOS需要将当前用户添加到dialout组Ubuntu或tty组或者使用sudo运行程序不推荐。sudo usermod -a -G dialout $USER然后注销重新登录生效。Windows通常权限问题较少可能是串口被其他程序如串口助手、e² studio的调试终端占用。关闭所有可能占用该串口的软件。发送数据但MCU无反应且无错误提示检查发送的数据是否真的包含了帧结束符如\n。使用qDebug() “Sending:” data.toHex();打印出发送的原始字节确认格式。检查Qt和MCU的波特率等参数是否绝对一致。界面卡顿如果串口数据量很大在handleReadyRead槽函数中进行复杂的UI更新如频繁append文本到QTextBrowser会导致界面卡顿。解决方案是将接收到的数据先存入一个缓冲区队列用一个定时器定时比如每100ms从队列中取出数据批量更新UI。或者将串口管理对象移到单独的线程中。5.4 进阶优化与功能扩展基础功能跑通后可以考虑增加一些实用功能协议增强目前的文本协议很简单但缺乏校验。可以增加帧头、帧尾、校验和如CRC8提高通信可靠性。MCU端在解析前先进行校验。数据回传与显示让MCU定时采集电池电压、电机电流、超声波距离等数据打包后发送给Qt。Qt端解析并实时显示在仪表盘或曲线图上。控制模式扩展除了按钮控制可以增加键盘控制重写keyPressEvent、游戏手柄支持使用QGamepad甚至手机APP控制通过Wi-FiMCU端集成ESP8266/ESP32模块。路径记录与回放Qt端记录下发送的所有指令和时间戳可以保存为文件之后进行回放让小车自动重复之前的路径。FSP中的RTOS集成如果小车功能变复杂需要同时控制电机、处理多个传感器、通信、状态灯等可以考虑在FSP中启用FreeRTOS或ThreadX创建多个任务来管理使程序结构更清晰。整个项目从环境搭建到联调完成最耗时间的往往不是写代码而是排查那些因配置疏忽或理解偏差导致的小问题。尤其是跨平台PC Qt与嵌入式MCU通信对协议一致性和时序的要求很高。我的经验是调试时一定要“分而治之”先确保通信链路畅通用最简单的回环测试再测试单个功能如电机转动最后整合。善用调试工具无论是MCU端的LED、串口打印还是PC端的串口助手、Qt的调试输出都能极大提升效率。这个基于FSP和Qt的遥控小车项目麻雀虽小五脏俱全涵盖了嵌入式开发从硬件配置、驱动编写到上层应用联调的完整流程对于理解现代MCU开发模式和跨平台通信是个非常不错的练手项目。