RT-Thread与ROS机器人通信:自定义串口协议实现异构系统集成

📅 2026/8/7 11:43:44
RT-Thread与ROS机器人通信:自定义串口协议实现异构系统集成
1. 项目概述当嵌入式实时系统遇上机器人操作系统最近在捣鼓一个挺有意思的项目想把一个基于RT-Thread的智能小车底盘接入到ROSRobot Operating System的生态里。这个想法其实源于一个很实际的需求RT-Thread在资源受限的微控制器MCU上跑实时控制任务非常稳响应快、功耗低是控制电机、读取编码器的绝佳选择而ROS在功能强大的上位机比如树莓派、Jetson或者PC上提供了海量的机器人算法包、可视化工具和通信框架搞SLAM建图、路径规划、机器视觉这些高级功能得心应手。但问题来了一个在底层“埋头苦干”一个在上层“运筹帷幄”怎么让它们高效地“对上话”协同控制一辆小车呢这其实就是典型的异构系统通信与集成问题。我的目标是让运行RT-Thread的MCU作为小车的“运动执行单元”负责最底层的电机驱动、PID速度控制、里程计数据采集而运行ROS的上位机则作为“决策与感知单元”发布速度控制指令并接收来自底层的传感器数据如编码器、IMU用于导航和状态监控。最终我们能在ROS的Rviz里看到小车的模型和实时位姿能用teleop_twist_keyboard这样的工具包通过键盘控制小车移动甚至为后续接入激光雷达做自主导航打下基础。这个项目非常适合那些已经熟悉RT-Thread或ROS其中一方的开发者想跨领域学习或者正在为电赛、课程设计、机器人原型开发寻找一种稳定可靠的软硬件架构的朋友。2. 整体方案设计与通信协议选型要实现RT-Thread与ROS的联姻核心在于设计一套高效、可靠、跨平台的通信协议。经过一番调研和对比我放弃了在MCU上直接移植庞大ROS客户端如rosserial的想法因为那对MCU的RAM和Flash资源消耗太大不够“嵌入式”。更优雅的方案是在两者之间建立一个轻量级的串行通信通道定义一套简单的自定义协议。2.1 为什么选择自定义串口协议首先串口UART是嵌入式领域的“普通话”几乎所有的MCU都标配在RT-Thread中驱动完善使用简单。其次协议完全可控我们可以根据小车的具体需求控制指令、传感器数据来量身定制数据帧格式没有冗余开销传输效率高。最后解耦性好上位机的ROS节点只需要按照协议组包、解包底层协议变更对上层的ROS应用逻辑影响很小。相比之下虽然也有像micro-ROS这样的项目旨在将ROS 2引入微控制器但它对硬件通常需要以太网或Wi-Fi和内存的要求更高且生态还在发展中。对于大多数以STM32、GD32等常见MCU为核心的小车底盘自定义串口协议是当前最务实、最稳定的选择。2.2 协议帧格式设计详解一个健壮的通信协议需要包含帧头、数据、校验和帧尾以确保数据的完整性和正确性。我设计了一个简单实用的帧格式[帧头0xAA][帧头0x55][数据长度L][命令字CMD][数据区DATA][校验和SUM][帧尾0x0D][帧尾0x0A]帧头2字节0xAA, 0x55用于在数据流中识别一帧的开始。数据长度L1字节表示CMDDATA的总字节数方便接收方动态解析。命令字CMD1字节定义本帧数据的类型。例如0x01: 上位机ROS发送给下位机RT-Thread的速度控制指令。0x02: 下位机发送给上位机的里程计编码器数据。0x03: 下位机发送给上位机的电池电压数据。0x04: 下位机发送给上位机的IMU陀螺仪/加速度计数据。数据区DATAN字节根据CMD的不同承载具体的数据内容。这是协议的核心。校验和SUM1字节通常采用前面所有字节从帧头到数据区最后一个字节的累加和取低8位或者异或和用于验证数据在传输过程中是否出错。帧尾2字节0x0D, 0x0A即\r\n标志一帧的结束也有助于在软件层面清空接收缓冲区。注意帧头、帧尾要选择在正常数据中不太可能连续出现的值以减少误判。校验和是必须的无线或有干扰的串口通信中数据出错概率不低。2.3 数据内容定义示例以最核心的**速度控制指令CMD0x01和里程计数据CMD0x02**为例速度指令帧ROS - RT-Thread数据区通常需要包含小车的线速度m/s和角速度rad/s。我们可以用两个float类型4字节表示。数据区结构[float vx][float vy][float wz]。对于两轮差速小车vy通常为0。因此一帧完整的数据可能是AA 55 09 01 [vx的4字节][vy的4字节][wz的4字节] [SUM] 0D 0A。里程计数据帧RT-Thread - ROS数据区需要包含小车的位置和朝向。通常用float类型的x, y坐标和theta朝向角表示还可以加上float类型的线速度vx和角速度wz由编码器计算得出。数据区结构[float x][float y][float theta][float vx][float wz]。因此一帧完整的数据可能是AA 55 14 02 [x的4字节][y的4字节][theta的4字节][vx的4字节][wz的4字节] [SUM] 0D 0A。实操心得在定义float数据时务必注意字节序Endianness问题。大多数ARM Cortex-M内核如STM32是小端模式而x86/ARM64的Linux系统通常也是小端。为了最大兼容性可以在协议中约定统一使用小端字节序进行传输。在RT-Thread和ROS的代码中都要使用memcpy或联合体union的方式来保证字节的正确组装与解析避免出现速度指令解析后变成天文数字的情况。3. RT-Thread端实现小车底盘的“神经中枢”RT-Thread端的任务是双重的一是作为协议解析器可靠地接收来自ROS的速度指令并控制电机二是作为数据采集器定时读取编码器、计算里程计并打包发送给ROS。3.1 硬件准备与RT-Thread工程创建假设我们使用一款常见的STM32F4系列开发板作为主控通过电机驱动模块如TB6612或DRV8833驱动两个直流减速电机电机配有增量式编码器。IMU模块如MPU6050通过I2C连接。使用RT-Thread Studio创建工程选择对应的STM32芯片型号在RT-Thread Settings中开启UART设备驱动、PIN设备驱动、I2C设备驱动。如果使用PWM控制电机还需开启PWM设备驱动。配置串口选择一个串口如UART3用于与上位机通信。在board.h或drv_usart.c中确保该串口的引脚配置正确并将其波特率设置为一个较高的值如115200或921600以保证数据实时性。编写设备驱动如果电机驱动、编码器、IMU的驱动不在RT-Thread官方包中需要自行编写或移植。编码器通常使用定时器的编码器模式读取电机PWM使用定时器的PWM输出模式。3.2 串口协议解析与电机控制线程在RT-Thread中我们创建一个独立的线程来处理串口数据接收、协议解析和电机控制。// 伪代码示例展示核心逻辑 #include rtthread.h #include rtdevice.h #define BUF_SIZE 128 #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 static rt_device_t serial; static struct rt_semaphore rx_sem; static rt_uint8_t rx_buffer[BUF_SIZE]; // 速度指令结构体 typedef struct { float linear_x; float linear_y; float angular_z; } twist_cmd_t; static twist_cmd_t current_cmd {0}; // 串口接收回调函数 static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size) { rt_sem_release(rx_sem); // 释放信号量通知解析线程有数据到达 return RT_EOK; } // 协议解析与电机控制线程入口函数 static void protocol_parser_thread_entry(void *parameter) { rt_uint8_t ch; rt_uint8_t state 0; // 简单状态机状态 rt_uint8_t data_len 0, cmd 0, data_idx 0, calc_sum 0; rt_uint8_t data_buf[64]; while (1) { // 等待串口接收信号量 if (rt_sem_take(rx_sem, RT_WAITING_FOREVER) RT_EOK) { rt_size_t r_size rt_device_read(serial, 0, rx_buffer, BUF_SIZE); for (int i 0; i r_size; i) { ch rx_buffer[i]; switch (state) { case 0: // 等待帧头1 if (ch FRAME_HEAD1) state 1; break; case 1: // 等待帧头2 if (ch FRAME_HEAD2) state 2; else state 0; break; case 2: // 读取数据长度L data_len ch; calc_sum FRAME_HEAD1 FRAME_HEAD2 data_len; state 3; break; case 3: // 读取命令字CMD cmd ch; calc_sum cmd; data_idx 0; if (data_len 1) { // 有数据 state 4; } else { // 无数据直接跳到校验和 state 5; } break; case 4: // 读取数据区 data_buf[data_idx] ch; calc_sum ch; if (data_idx (data_len - 1)) { // 数据区读完 state 5; } break; case 5: // 读取校验和SUM if ((calc_sum 0xFF) ch) { // 校验通过 state 6; } else { // 校验失败重置状态机 state 0; rt_kprintf(Checksum error!\n); } break; case 6: // 等待帧尾1 if (ch 0x0D) state 7; else state 0; break; case 7: // 等待帧尾2 if (ch 0x0A) { // 一帧完整数据接收并校验成功 process_received_frame(cmd, data_buf, data_idx); } state 0; // 无论帧尾2是否正确都重置状态机 break; default: state 0; break; } } } } } // 处理接收到的有效帧 static void process_received_frame(rt_uint8_t cmd, rt_uint8_t *data, rt_size_t len) { if (cmd 0x01 len 12) { // 速度指令3个float共12字节 memcpy(current_cmd.linear_x, data, 4); memcpy(current_cmd.linear_y, data4, 4); memcpy(current_cmd.angular_z, data8, 4); // 调用电机控制函数将速度指令转换为左右轮PWM占空比 twist_to_wheel_speed(current_cmd); } // 可以处理其他命令字... }这个线程使用一个状态机来解析串口数据流是嵌入式领域处理不定长、带格式数据的经典方法比简单的if判断要健壮得多。3.3 里程计计算与数据上传线程另一个重要的线程是里程计线程。它需要以固定的频率比如50Hz执行以下任务读取编码器脉冲数通过定时器捕获单元或外部中断获取左右轮编码器自上次读取以来的增量值。计算轮子转速根据编码器分辨率、轮子周长和采样周期计算左右轮的实际线速度m/s。wheel_speed (delta_ticks / ticks_per_revolution) * wheel_circumference / delta_time计算机器人本体速度对于两轮差速模型已知左右轮速度v_left,v_right轮距L则线速度v (v_right v_left) / 2角速度w (v_right - v_left) / L积分得到位姿在短时间dt内假设机器人做匀速运动可以对速度进行积分更新机器人的位置(x, y)和朝向theta。x v * cos(theta) * dty v * sin(theta) * dttheta w * dt注意这里的积分是近似长时间会累积误差但对于短时相对定位和ROS中的odom话题发布是足够的。打包并发送数据将计算得到的x, y, theta, v, w按照协议格式打包通过串口发送给上位机。// 里程计线程伪代码 static void odometry_thread_entry(void *parameter) { rt_tick_t last_tick rt_tick_get(); float dt; int32_t left_ticks_last 0, right_ticks_last 0; while (1) { rt_thread_mdelay(20); // 50Hz循环 rt_tick_t current_tick rt_tick_get(); dt (current_tick - last_tick) * 1.0 / RT_TICK_PER_SECOND; // 计算时间差秒 last_tick current_tick; // 1. 读取当前编码器总计数值 int32_t left_ticks_now read_left_encoder(); int32_t right_ticks_now read_right_encoder(); // 2. 计算增量脉冲数 int32_t delta_left left_ticks_now - left_ticks_last; int32_t delta_right right_ticks_now - right_ticks_last; left_ticks_last left_ticks_now; right_ticks_last right_ticks_now; // 3. 计算左右轮线速度 float v_left (delta_left / TICKS_PER_METER) / dt; // 假设TICKS_PER_METER是每米脉冲数 float v_right (delta_right / TICKS_PER_METER) / dt; // 4. 计算机器人本体速度 float v (v_right v_left) / 2.0f; float w (v_right - v_left) / WHEEL_BASE; // WHEEL_BASE为轮距 // 5. 积分更新位姿 (简化处理需考虑theta归一化) odom_theta w * dt; // 将角度限制在[-pi, pi]区间 while (odom_theta 3.1415926f) odom_theta - 2*3.1415926f; while (odom_theta -3.1415926f) odom_theta 2*3.1415926f; odom_x v * cosf(odom_theta) * dt; odom_y v * sinf(odom_theta) * dt; // 6. 打包里程计数据帧并发送 send_odometry_frame(odom_x, odom_y, odom_theta, v, w); // 可选发送IMU数据 // send_imu_frame(...); } }注意事项线程优先级与调度电机控制线程的优先级应高于里程计线程以确保对速度指令的快速响应。协议解析线程因依赖串口中断信号量优先级也可以设高一些。共享数据保护current_cmd可能被协议解析线程写入被电机控制线程读取需要使用互斥锁mutex或关中断的方式进行保护。浮点数运算STM32F4有硬件FPU开启后浮点运算很快。如果使用没有FPU的MCU可以考虑使用定点数运算库来提升效率。发送频率里程计数据发送频率不宜过高20-50Hz足以。过高频率会占用大量串口带宽可能影响控制指令的接收。4. ROS端实现上位机的“智慧大脑”ROS端我们需要创建一个功能包主要包含两个节点一个串口通信节点负责与下位机进行协议层面的数据收发一个坐标变换发布节点负责将里程计数据转换为ROS标准的nav_msgs/Odometry消息并发布同时广播tf变换。4.1 创建ROS工作空间与功能包假设你的ROS环境已经安装好如ROS Noetic或ROS2 Humble。首先创建工作空间和功能包。mkdir -p ~/ros_ws/src cd ~/ros_ws/src catkin_create_pkg rt_thread_bridge roscpp rospy std_msgs geometry_msgs nav_msgs tf cd ~/ros_ws catkin_make source devel/setup.bash4.2 串口通信节点C实现这个节点是桥梁的核心。我们使用ROS的serial包来方便地进行串口操作。安装serial包sudo apt-get install ros-distro-serial例如sudo apt-get install ros-noetic-serial。编写节点serial_node.cpp#include ros/ros.h #include serial/serial.h #include geometry_msgs/Twist.h #include nav_msgs/Odometry.h #include tf/transform_broadcaster.h serial::Serial ser; // 串口对象 std::string port; int baudrate; // 接收到ROS速度指令的回调函数 void twistCallback(const geometry_msgs::Twist::ConstPtr msg) { // 将Twist消息中的数据打包成自定义协议帧 uint8_t buffer[20]; // 预留足够空间 int index 0; buffer[index] 0xAA; // 帧头1 buffer[index] 0x55; // 帧头2 buffer[index] 1 12; // 数据长度: CMD(1) 3*float(12) buffer[index] 0x01; // 命令字速度控制 float vx msg-linear.x; float vy msg-linear.y; float wz msg-angular.z; // 注意字节序使用memcpy按字节写入 memcpy(buffer[index], vx, 4); index 4; memcpy(buffer[index], vy, 4); index 4; memcpy(buffer[index], wz, 4); index 4; // 计算校验和简单累加和 uint8_t checksum 0; for(int i0; iindex; i) { checksum buffer[i]; } buffer[index] checksum; // 帧尾 buffer[index] 0x0D; buffer[index] 0x0A; // 通过串口发送 if(ser.isOpen()) { ser.write(buffer, index); } } int main(int argc, char** argv) { ros::init(argc, argv, rt_thread_bridge); ros::NodeHandle nh; ros::NodeHandle private_nh(~); // 从参数服务器读取串口配置 private_nh.paramstd::string(port, port, /dev/ttyUSB0); private_nh.param(baudrate, baudrate, 115200); try { ser.setPort(port); ser.setBaudrate(baudrate); serial::Timeout to serial::Timeout::simpleTimeout(1000); ser.setTimeout(to); ser.open(); } catch (serial::IOException e) { ROS_ERROR_STREAM(Unable to open serial port port); return -1; } if(ser.isOpen()) { ROS_INFO_STREAM(Serial port port initialized at baudrate); } else { return -1; } // 订阅ROS的速度指令话题通常为/cmd_vel ros::Subscriber twist_sub nh.subscribe(cmd_vel, 10, twistCallback); // 发布里程计话题 ros::Publisher odom_pub nh.advertisenav_msgs::Odometry(odom, 50); tf::TransformBroadcaster odom_broadcaster; // 变量用于解析协议 enum ParserState { WAIT_HEAD1, WAIT_HEAD2, WAIT_LEN, WAIT_CMD, WAIT_DATA, WAIT_SUM, WAIT_TAIL1, WAIT_TAIL2 }; ParserState state WAIT_HEAD1; uint8_t data_len 0, cmd 0, data_idx 0, calc_sum 0; uint8_t data_buf[64]; uint8_t expected_data_len 0; ros::Rate loop_rate(200); // 解析循环频率尽量高一些 while(ros::ok()) { // 处理串口接收数据 if(ser.available()) { size_t n ser.available(); uint8_t buffer[256]; n ser.read(buffer, n); for(size_t i0; in; i) { uint8_t ch buffer[i]; switch(state) { case WAIT_HEAD1: if(ch 0xAA) state WAIT_HEAD2; break; case WAIT_HEAD2: if(ch 0x55) state WAIT_LEN; else state WAIT_HEAD1; break; case WAIT_LEN: data_len ch; calc_sum 0xAA 0x55 data_len; expected_data_len data_len; // 总数据长度CMDDATA state WAIT_CMD; break; case WAIT_CMD: cmd ch; calc_sum cmd; data_idx 0; if(expected_data_len 1) { state WAIT_DATA; } else { state WAIT_SUM; } break; case WAIT_DATA: data_buf[data_idx] ch; calc_sum ch; if(data_idx (expected_data_len - 1)) { // 已接收完所有数据字节 state WAIT_SUM; } break; case WAIT_SUM: if((calc_sum 0xFF) ch) { state WAIT_TAIL1; } else { ROS_WARN(Checksum error! calc: 0x%02x, recv: 0x%02x, calc_sum 0xFF, ch); state WAIT_HEAD1; // 校验失败重置 } break; case WAIT_TAIL1: if(ch 0x0D) state WAIT_TAIL2; else state WAIT_HEAD1; break; case WAIT_TAIL2: if(ch 0x0A) { // 一帧接收成功 processReceivedFrame(cmd, data_buf, data_idx, odom_pub, odom_broadcaster, ros::Time::now()); } state WAIT_HEAD1; // 无论成功与否重置状态机 break; } } } ros::spinOnce(); loop_rate.sleep(); } ser.close(); return 0; } // 处理从下位机接收到的有效数据帧 void processReceivedFrame(uint8_t cmd, uint8_t* data, size_t len, ros::Publisher odom_pub, tf::TransformBroadcaster odom_broadcaster, ros::Time current_time) { if(cmd 0x02 len 20) { // 里程计数据5个float共20字节 float x, y, theta, vx, wz; memcpy(x, data, 4); memcpy(y, data4, 4); memcpy(theta, data8, 4); memcpy(vx, data12, 4); memcpy(wz, data16, 4); // 发布nav_msgs/Odometry消息 nav_msgs::Odometry odom; odom.header.stamp current_time; odom.header.frame_id odom; // 里程计坐标系 odom.child_frame_id base_link; // 机器人本体坐标系 // 设置位置 odom.pose.pose.position.x x; odom.pose.pose.position.y y; odom.pose.pose.position.z 0.0; // 将theta转换为四元数 geometry_msgs::Quaternion odom_quat tf::createQuaternionMsgFromYaw(theta); odom.pose.pose.orientation odom_quat; // 设置速度在子坐标系base_link下 odom.twist.twist.linear.x vx; odom.twist.twist.linear.y 0.0; // 差速模型y方向速度为0 odom.twist.twist.angular.z wz; // 发布里程计消息 odom_pub.publish(odom); // 广播tf变换从odom到base_link geometry_msgs::TransformStamped odom_trans; odom_trans.header.stamp current_time; odom_trans.header.frame_id odom; odom_trans.child_frame_id base_link; odom_trans.transform.translation.x x; odom_trans.transform.translation.y y; odom_trans.transform.translation.z 0.0; odom_trans.transform.rotation odom_quat; odom_broadcaster.sendTransform(odom_trans); ROS_DEBUG_THROTTLE(1.0, Odom: x%.2f, y%.2f, th%.2f, vx%.2f, wz%.2f, x, y, theta, vx, wz); } // 可以处理其他命令字如电池电压(cmd0x03)等 }修改CMakeLists.txt和package.xml确保依赖项正确并添加可执行文件的编译规则。4.3 启动与测试编译在~/ros_ws目录下执行catkin_make。查找串口设备将USB转TTL模块连接上位机和下位机通过ls /dev/ttyUSB*或ls /dev/ttyACM*查看设备名。设置串口权限sudo chmod 666 /dev/ttyUSB0假设设备是ttyUSB0。启动ROS核心roscore。启动串口桥接节点rosrun rt_thread_bridge serial_node _port:/dev/ttyUSB0 _baudrate:115200。测试速度指令可以安装teleop_twist_keyboard包然后运行rosrun teleop_twist_keyboard teleop_twist_keyboard.py。按下键盘方向键你应该能看到RT-Thread终端打印出解析到的速度值并且小车开始运动。查看里程计运行rostopic echo /odom可以查看发布的里程计消息。运行rviz添加TF和Odometry显示可以看到base_link坐标系随着小车运动而移动。5. 进阶优化与问题排查基本的通信和控制实现后可以考虑以下优化点并了解常见问题的排查方法。5.1 协议优化与抗干扰增加超时机制在状态机解析中如果长时间如100ms未接收到完整一帧应重置状态机避免因某个字节丢失导致永久卡死。使用CRC校验对于更可靠的数据传输可以将简单的累加和校验升级为CRC8或CRC16校验。数据分包与重传对于重要的配置指令可以设计简单的应答机制。下位机收到后回复ACK上位机在一定时间内没收到ACK则重发。心跳包可以定期如1秒发送一个简单的心跳包如CMD0xFF用于检测链路是否存活。5.2 常见问题与排查技巧小车不受控制或乱动检查串口连接确认TX、RX是否交叉连接GND是否共地。检查波特率确保RT-Thread和ROS节点设置的波特率完全一致。打印调试信息在RT-Thread端将接收到的原始字节和解析出的速度值打印出来确认数据是否正确接收和解析。检查电机驱动逻辑确认twist_to_wheel_speed函数是否正确地将线速度、角速度转换为左右轮PWM值。差速模型公式为v_left v - (w * L / 2)v_right v (w * L / 2)。ROS端收不到里程计数据或Rviz中TF不更新检查数据发送在RT-Thread端确认send_odometry_frame函数被定期调用并且可以通过串口调试助手看到发出的数据帧。检查协议解析在ROS节点的processReceivedFrame函数开头添加ROS_INFO打印看是否进入该函数。检查字节序解析是否正确。检查话题发布运行rostopic list查看/odom话题是否存在运行rostopic hz /odom查看发布频率是否正常。检查tf树运行rosrun tf view_frames生成TF树图查看odom到base_link的变换是否正常发布。控制延迟大或丢包降低波特率过高的波特率在长线或劣质USB转串口模块上可能不稳定尝试降低到57600。优化发送频率降低RT-Thread端里程计的发送频率如从50Hz降到20Hz减少串口拥堵。增加缓冲区确保RT-Thread和ROS端的串口接收缓冲区足够大。使用DMA如果MCU支持在RT-Thread端使用串口DMA接收和发送可以极大减轻CPU负担提高可靠性。里程计积分漂移严重这是轮式里程计的固有缺陷。短期内可用于闭环控制如PID长期定位必须依赖其他传感器如IMU进行航迹推算融合或激光雷达/视觉进行闭环检测与图优化。在ROS中可以通过robot_pose_ekf或robot_localization包融合IMU数据来改善。5.3 扩展方向集成IMU在协议中增加IMU数据帧CMD0x04在ROS端使用robot_pose_ekf包融合里程计和IMU数据得到更准确的姿态估计。接入激光雷达在ROS端启动激光雷达驱动如rplidar_ros发布/scan话题。然后就可以运行gmapping或cartographer进行SLAM建图再结合move_base实现自主导航。此时你的RT-Thread小车就升级为真正的自主移动机器人平台了。使用ROS2整个架构可以平移到ROS2。ROS2的micro-ROS虽然对MCU要求高但其提供的rclc客户端可以让你在RT-Thread上直接使用ROS2的通信机制如DDS-XRCE实现更原生、功能更丰富的通信是未来的发展方向。Web图形界面利用ROS的rosbridge_suite和web_video_server可以创建一个网页控制面板远程监控摄像头画面并控制小车实现远程监控功能。这个项目从协议设计到代码实现涉及了嵌入式实时系统、串口通信、机器人学模型、ROS应用开发等多个知识点。调试过程可能会遇到各种软硬件问题但逐个攻克后当你第一次在Rviz中看到自己小车模型随着真实小车同步运动时那种成就感是非常棒的。它为你打开了将低成本、高性能的嵌入式设备融入庞大机器人生态的大门。