简介:面向2022年全国大学生机器人大赛步兵组参赛队伍的完整电控系统开源项目,代码核心基于FreeRTOS实时操作系统构建多任务调度框架,并集成了用户界面交互模块和底盘运动控制模块,适合需要系统学习机器人软件架构、备赛或二次开发的电控开发者使用。压缩包共1169个文件,约46.54MB,主体为278个h头文件与269个c源文件,同时包含Keil工程配置、编译生成的o/axf/lnp/map等目标与映射文件,以及少量说明文本和图片文档,目录结构便于按模块查阅。项目目前已有55人学习下载。借助这份工程,读者可以理解FreeRTOS任务优先级划分与调度逻辑,掌握用户界面与底盘控制的代码实现方式,并参考完整的Keil工程配置、底层驱动集成和硬件调试思路,对提升步兵机器人的运动稳定性与交互可控性具有直接参考价值。
1. 从RoboMaster电控需求讲起
2022年全国大学生机器人大赛步兵组的技术栈中,电控系统是整个机器人的神经中枢。一台步兵机器人硬件上通常有底盘四个M3508电机、云台两个GM6020电机、裁判系统串口、遥控器接收机、测速光电门、蜂鸣器、OLED屏幕等外设,软件上要同时完成遥控指令解析、底盘运动学解算、云台PID闭环、功率限制、状态上报和菜单交互。如果按裸机前后台方式写,主循环稍长一点,底盘控制周期抖动就会直接体现在电机转速波动上。用FreeRTOS做主控框架,不是赶时髦,而是用任务优先级和调度策略把“必须精确到1ms”的控制逻辑和“晚几毫秒无所谓”的界面刷新拆开。
这类系统的核心矛盾是:底盘控制需要严格周期调度,而UI交互(菜单切换、参数回显)天然带有阻塞等待属性。标题里点出的“多任务调度框架”就是通过FreeRTOS把这二者隔离,再通过队列把按键事件递交给UI任务。本文从任务划分、运动学解算、UI集成、调试手段四个层面,把一套可复用的步兵电控代码组织方式讲透。
2. 基于FreeRTOS的任务划分与调度参数设计
2.1 先划分任务,再写代码
步兵组电控系统常见的任务划分方式是“一个优先级一个职责”。我在入手一个全新FreeRTOS项目时,不会急着写代码,而是先列需求清单:底盘电机控制需要1kHz左右的频率;云台控制需要500Hz到1kHz;裁判系统串口数据解析每10ms做一次;OLED刷新30Hz就够;按键扫描10ms轮询一次;蜂鸣器提示音用软件定时器实现。
初步任务与优先级分配如下:
| 任务名 | 优先级 | 周期 | 职责 |
|---|---|---|---|
| Chassis_Task | 最高(6) | 1ms | 读写电机、运动学解算、功率控制 |
| GImbal_Task | 高(5) | 1ms | 云台电机PID、陀螺仪数据读取 |
| Referee_Task | 中(3) | 10ms | 解析裁判系统报文 |
| UI_Task | 低(2) | 30ms | OLED显示、菜单逻辑 |
| Key_Scan_Task | 低(1) | 10ms | 按键扫描并发送队列 |
// FreeRTOS任务创建示例 xTaskCreate(Chassis_Task, "chassis", TASK_STACK_CHASSIS, NULL, 6, &chassis_handler); xTaskCreate(UI_Task, "ui", TASK_STACK_UI, NULL, 2, &ui_handler);参数说明:第一参数是任务函数指针;第二参数是任务名,用于调试器定位,字符串常量;第三参数是任务栈深度,以字为单位,Cortex-M内核上一字等于4字节;第四参数是传给任务的参数指针;第五参数是优先级,数值越大优先级越高;第六参数返回任务句柄,用于后续挂起或删除。
这里有一个初学者容易犯的错误:任务栈深度和数组声明里的元素数对不上。在Cortex-M4平台,TASK_STACK_CHASSIS定义为512表示512字,实际占用2KB RAM而不是512字节。步兵电控任务里,有浮点运算的任务栈至少给512字,纯整型的任务给256字。
2.2 UCOS风格空闲任务兜底与调度器启动
调度器启动时,系统会自动把当前main函数变为空闲任务,但FreeRTOS新增了更多细节。任务创建之前要确保configUSE_PREEMPTION与configUSE_TIME_SLICING按需设置。在RoboMaster电控场景中,抢占式调度是默认配置,时间片轮转则需要谨慎开。
// FreeRTOS.h 中与调度相关的主要配置项 #define configUSE_PREEMPTION 1 // 抢占式调度 #define configUSE_TIME_SLICING 1 // 同优先级时间片轮转 #define configMAX_PRIORITIES 7 // 最大优先级数,0~6共7级配置说明:优先级的数量要覆盖所有任务,并留出一个余量用于调试临时任务;时间片轮转只在同优先级任务多于一个时才发生,在步兵电控任务中同优先级任务一般不超过两个,轮转粒度可设为5ms。
有一个容易被忽视的配置是configTICK_RATE_HZ。默认100Hz即10ms一个系统时钟节拍,如果你只改这个值而不改相关延时函数的参数用法,任务调度精度会出问题。常见的做法是设置为1000Hz,即1ms一个tick,这样vTaskDelay(1)就是延迟1ms,控制任务里用xTaskGetTickCount()取到的当前时间精度也更高。但要注意,tick中断变快后,上下文切换的开销会增加,CPU占用率会相应升高,处理器的运行频率在168MHz以上时问题不大。
2.3 CA cubeMX配置任务的坑
很多人在用CubeMX配置FreeRTOS时遇到过.\\obj\\freertos.hex: error: q0147e: failed to create directory .\\obj\\freertos这个报错,多半是工程路径中有空格或中文字符,或者输出目录被其他程序占用。使用CubeMX生成FreeRTOS工程后,同时也需要注意SVC_Handler、PendSV_Handler、SysTick_Handler三个中断处理函数是否被FreeRTOS接管,如果CubeMX自动生成的代码里已经有了这三个函数的重定向,不要手动再改,否则系统启动后会直接进入HardFault。
CubeMX配置FreeRTOS时,在Middleware and Software Packs里选择FreeRTOS,Kernel Settings下的TICK_RATE_HZ默认1000,在Tasks and Queues中还可以图形化添加任务。不过在实际的步兵电控代码中,任务栈深度通常需要在分配后再调一次,不是因为CubeMX计算不对,而是因为电机控制中的浮点库调用、printf重定向这类隐式栈开销需要手动估算。
3. 步兵底盘运动控制模块的多任务实现
3.1 从遥控器数据到CAN总线的完整数据流
底盘控制模块是整个电控系统最核心的部分。它接收遥控器通道值、键盘指令和裁判系统下发的功率限制,经过速度解算产生四个轮子的目标转速,再通过CAN总线发给M3508电机。在FreeRTOS架构下,这个流程分布在多个任务中,但中间数据的流转主要通过队列和全局结构体。
// 底盘遥控器通道量到速度指令的映射 typedef struct { int16_t ch0; // 右摇杆X,前后方向,范围 -660 ~ 660 int16_t ch1; // 右摇杆Y,左右方向 int16_t ch2; // 左摇杆X,旋转方向 int16_t ch3; // 左摇杆Y,备用 uint8_t s1; // 拨杆开关1,控制底盘/云台模式 uint8_t s2; // 拨杆开关2 } RC_Ctrl_t; void Chassis_DataUpdate(void) { // 从队列接收遥控器数据,非阻塞方式 BaseType_t ret = xQueueReceive(rc_queue, &rc_raw, 0); if (ret == pdPASS) { // 将有死区处理的通道值存入底盘任务局部变量 if (abs(rc_raw.ch0) < RC_DEADBAND) { target_vx = 0; } else { target_vx = rc_raw.ch0 * VX_GAIN; } } }逻辑说明:遥控器摇杆的原始值为一个有符号整数,摇杆中位附近的微小抖动如果直接参与解算,会导致底盘出现肉眼可见的漂移,因此在映射到速度指令前需要判断是否落在死区内。RC_DEADBAND通常取20~60之间,数值越大抗抖效果越好,但摇杆在小角度移动时响应也越不灵敏。
3.2 麦轮运动学逆解与旋转缩放
步兵组机器人使用麦克纳姆轮,四轮速度到平面速度的逆解公式为:
v1 = vx - vy - w * (wheel_base + wheel_track) / 2; v2 = vx + vy + w * (wheel_base + wheel_track) / 2; v3 = vx + vy - w * (wheel_base + wheel_track) / 2; v4 = vx - vy + w * (wheel_base + wheel_track) / 2;四个值分别对应左前、右前、左后、右后四个轮子。注意不同队惯用的正方向定义不一样,调试前先确认轮子编号和电机转向,否则底盘会出现你推左它往右跑的怪现象。
void Chassis_SolveVel(void) { float r = (wheel_base + wheel_track) / 2.0f; wheel_speed[0] = target_vx - target_vy - target_w * r; wheel_speed[1] = target_vx + target_vy + target_w * r; wheel_speed[2] = target_vx + target_vy - target_w * r; wheel_speed[3] = target_vx - target_vy + target_w * r; }参数说明:wheel_base是前后轮距,wheel_track是左右轮距,单位用毫米。这组公式得出的值要再乘以一个缩放系数k,用来归一化到电机最大转速。M3508电机的减速比是19:1,电调端设置的转速单位是rpm,实际轮子转速要求每秒0.5米的话,需要把线速度换算成轮子转速再乘以减速比。
3.3 底盘任务里以队列传递电机转速指令
底盘控制任务和CAN发送任务之间不要用共享数组直接传数据,因为如果在CAN发送过程中底盘任务突然修改数组内容,会出现半个包是旧数据、半个包是新数据的情况。在FreeRTOS架构里,队列天然的阻塞与拷贝机制刚好解决这个问题。
// CAN发送缓冲区结构体 typedef struct { uint16_t motor_rpm[4]; // 四个轮子的目标转速 uint16_t current_ctrl[4]; // 电流模式下的目标电流 } Chassis_CAN_t; // 底盘任务中发送 void Chassis_SendData(void) { Chassis_CAN_t send_data; send_data.motor_rpm[0] = (uint16_t)wheel_speed[0]; send_data.motor_rpm[1] = (uint16_t)wheel_speed[1]; send_data.motor_rpm[2] = (uint16_t)wheel_speed[2]; send_data.motor_rpm[3] = (uint16_t)wheel_speed[3]; xQueueSend(&can_send_queue, &send_data, 0); } // CAN发送任务中接收并填入TxMailbox void CAN_Send_Task(void *argument) { Chassis_CAN_t recv_data; for (;;) { if (xQueueReceive(&can_send_queue, &recv_data, portMAX_DELAY) == pdPASS) { // 将recv_data数组拆分发送到CAN1的4个邮箱 } } }这里有另一个常见问题:如果底盘任务发送过快而CAN发送任务来不及处理,队列满了之后xQueueSend返回errQUEUE_FULL。在底盘任务里要用pdFALSE作为阻塞时间的参数,即只尝试放入一次而不阻塞,否则若任务间因果关系变成了“底盘等CAN”,反而破坏了控制周期。
3.4 舵轮模式下的坐标变换扩展
底盘运动控制不只有麦轮这一种方案。2022年步兵组规则里允许底盘有多种形态,有些队伍使用了舵轮底盘,相比麦轮可以提供更高的直线速度上限和更好的弹道稳定性。舵轮底盘的运动学逆解不只是一个线性矩阵,而是需要先求出每个轮子的速度和方向角,再对方向角做差速限制。
typedef struct { float speed; // 轮子线速度 float angle; // 轮子方向角,弧度 } SteerWheel_t; void SteerWheel_Inverse(float vx, float vy, float wz, SteerWheel_t *out) { for (int i = 0; i < 4; i++) { // 根据轮子位置计算x、y方向的分量 float vx_i = vx - wz * positions[i].y; float vy_i = vy + wz * positions[i].x; out[i].speed = sqrtf(vx_i * vx_i + vy_i * vy_i); out[i].angle = atan2f(vy_i, vx_i); // 转向超过90度时反转速度方向,避免舵机大幅旋转 if (out[i].angle > PI / 2) { out[i].angle -= PI; out[i].speed = -out[i].speed; } else if (out[i].angle < -PI / 2) { out[i].angle += PI; out[i].speed = -out[i].speed; } } }当舵机速度跟不上底盘运动速度的变化时,底盘会表现出抖动,因此舵轮转向任务通常分配独立的任务优先级,并与底盘速度任务之间通过队列传递目标方向角。在编写舵轮控制任务前,先测量舵机从0度转到90度需要的实际时间,改用梯形速度规划,避免突变造成机械顿挫。
4. 用户界面交互模块与FreeRTOS集成
4.1 UI任务为何要单独分配低优先级
步兵电控的UI模块常见载体是0.96寸OLED、1.3寸/2.4寸TFT彩屏、数码管、按键和蜂鸣器。裸机开发中,刷新OLED时调用I2C/SPI阻塞传输,底层传输函数带延迟,如果放在主循环里,每次刷新都可能让控制任务等上几十毫秒。在FreeRTOS中把UI刷新放进低优先级任务,正好利用了调度器的抢占机制——当底盘控制任务被定时器唤醒时,它会打断UI任务的刷新流程,UI任务再等到下一个时间片继续执行。
// UI任务的主循环 void UI_Task(void *argument) { for (;;) { // 等待按键事件队列,带超时时间 Key_Event_t key; if (xQueueReceive(&key_queue, &key, pdMS_TO_TICKS(20)) == pdPASS) { UI_ProcessKey(&key); } // 每30ms刷新一帧界面 UI_Refresh(); vTaskDelay(pdMS_TO_TICKS(30)); } }队列接收pdMS_TO_TICKS(20)的作用是让UI任务在按键事件到来时能快速响应,同时在没有按键事件时也不会完全阻塞,每20ms释放一次CPU给刷新逻辑。如果直接使用portMAX_DELAY长期阻塞在等待按键上,界面上需要周期性更新的数据(如电压、功率)就得不到刷新。
4.2 用LCD库的移植做UI参数回显
很多队伍选了TFT彩屏做用户界面,屏幕上要展示的除了电压、电流、功率之外,还要有调PID时的目标参数。在FreeRTOS下使用LCD驱动库时,需要注意LCD库内部的缓冲区访问是否支持多任务同时操作。底层以HAL库的HAL_I2C_Mem_Write为例,如果它在UI任务里被使用,同时底盘检测任务也试图通过I2C读取陀螺仪数据,这时候就出现总线竞争,处理方式是给I2C外设加互斥量。
// I2C总线互斥量示例 SemaphoreHandle_t i2c_mutex; void UI_DisplayWrite(uint16_t x, uint16_t y, char *str) { xSemaphoreTake(i2c_mutex, portMAX_DELAY); LCD_ShowString(x, y, str); xSemaphoreGive(i2c_mutex); }注意,在拿到互斥量之后调用LCD_ShowString,这个函数内部可能调用HAL_I2C_Mem_Write,它是一个阻塞函数,需要给出超时时间。互斥量的持有时间不要过长,否则其他任务访问I2C时会一直阻塞,实际表现为屏幕刷新时底盘失去响应。改进做法是先在内存中的显存区绘制,再一次性通过DMA把整帧传给屏幕,能做到互斥持有时间在100微秒以内。
4.3 按键事件队列与双击/长按识别
UI交互模块中,单纯的单击功能不难,难的是在FreeRTOS任务中实现长按与双击事件的准确区分。裸机代码常用延时消抖,配合系统日程表控制;FreeRTOS中则更推荐用事件队列来做状态机。
typedef struct { uint8_t key_id; uint8_t event_type; // 0单击 1双击 2长按 } Key_Event_t; void Key_Scan_Task(void *argument) { uint32_t last_press_time = 0; uint8_t press_count = 0; for (;;) { // 读取IO电平状态 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { if (xTaskGetTickCount() - last_press_time > pdMS_TO_TICKS(300)) { press_count++; last_press_time = xTaskGetTickCount(); } } // 抬起后根据按压间隔判断单击或双击 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_SET) { if (xTaskGetTickCount() - last_press_time > pdMS_TO_TICKS(200) && press_count == 1) { Key_Event_t evt = {0, 0}; xQueueSend(&key_queue, &evt, 0); press_count = 0; } } vTaskDelay(pdMS_TO_TICKS(10)); } }其中press_count的计数逻辑依赖两次按压的时间间隔,如果用户按下时间超过长按判定阈值就被识别为长按事件。在系统tick为1000Hz时,xTaskGetTickCount()返回的是从系统启动到当前时间的毫秒数,用差值判断十分可靠。
UI菜单层级建议控制在两层以内。第一层是主页面的数据展示,第二层是参数设置页。步兵比赛时电控和操作手要在赛前快速修改摩擦轮转速、底盘功率上限等参数,如果菜单层级过深,在裁判系统开播前会非常被动。
5. 深挖FreeRTOS调试技巧:栈溢出检测、任务栈内存查询与优先级翻转复现
5.1 用FreeRTOS内置钩子检测任务栈溢出
FreeRTOS提供栈溢出检测机制,最常见的开启方式是修改FreeRTOSConfig.h中的configCHECK_FOR_STACK_OVERFLOW,其值设1或2。设1表示在任务切换时检查当前任务的栈指针是否超出范围;设2则是在任务创建时放置一个已知特殊值到栈底,并在切换时检查这个值是否被覆盖。推荐设为2,检测时机更靠前,能更早捕捉到栈溢出征兆。
// FreeRTOSConfig.h 中开启栈溢出检测 #define configCHECK_FOR_STACK_OVERFLOW 2 // 栈溢出回调函数 void vApplicationStackOverflowHook(TaskHandle_t xTask, signed char *pcTaskName) { // 进入这里表示检测到栈溢出,一般在这里点亮错误LED并停止调度 LED_RED_ON(); vTaskSuspendAll(); for (;;); }有了钩子函数后,当某个任务栈溢出时会进入死循环并点亮红色LED,这个方法比频繁HardFault后看故障栈直观得多。实践中栈溢出最常出现在“任务内部定义了较大的局部数组”且栈深度没考虑这个数组的情况,比如在UI任务里定义char line_buffer[256]来拼接显示字符串,任务栈却只分配了128字即512字节,直接覆盖到了任务栈边界。
5.2 查看任务栈高水位线接口
实际调试中钩子函数只能告诉我们“栈溢出了”,不能直观看到每个任务的剩余栈空间。FreeRTOS内置了uxTaskGetStackHighWaterMark()接口,它返回任务历史最低剩余栈空间的大小,单位是字。这个接口在任务运行一段时间后调用才有意义,因为它记录的是历史最小值。
// 在调试任务中打印栈高水位线 void Debug_Task(void *argument) { for (;;) { UBaseType_t chassis_watermark = uxTaskGetStackHighWaterMark(chassis_handler); UBaseType_t ui_watermark = uxTaskGetStackHighWaterMark(ui_handler); printf("chassis remain: %d words, ui remain: %d words\r\n", chassis_watermark, ui_watermark); vTaskDelay(pdMS_TO_TICKS(1000)); } }使用该接口时传入NULL表示查询当前任务自身,传入其他任务句柄可查询指定任务。返回值的单位是字,在Cortex-M上是4字节。如果chassis_watermark长期低于20字,说明这个任务栈余量过少,应该立即调大栈深度,因为裁判系统报文处理随着比赛阶段推进可能增加逻辑,栈用量不会只减不增。
除了栈高水位线,分配在堆上的任务控制块也占内存。任务每创建一个就多占用一部分heap空间,查看堆剩余空间的方法是xPortGetFreeHeapSize(),它同样在调试任务里打印比较方便。
5.3 复现优先级反转并应对
FreeRTOS中互斥量与二值信号量的区别在于互斥量内置优先级继承。为了在开发中验证优先级继承的必要性,可以临时用二值信号量代替互斥量保护I2C总线,再让底盘控制任务和UI任务同时访问I2C。你会发现底层I2C驱动里有阻塞等待I2C总线空闲的循环,此时UI任务占用信号量,由于UI任务优先级低,底盘优先级高,但是底盘拿不到信号量就只能阻塞。若是低优先级任务迟迟不释放,就发生了优先级反转。
解决优先级反转的常见方式是改用互斥量而不是二值信号量,FreeRTOS互斥量自动启用优先级继承。使用互斥量时注意不要嵌套获取同一互斥量,同一任务在持有互斥量的过程中再次xSemaphoreTake同一个互斥量会死锁。还要注意,持有互斥量的任务不能调用vTaskDelay,否则形同人为阻塞释放。
5.4 高优先级任务如何容忍低优先级任务暂时接管
即使有了优先级继承,界面刷新期间仍然偶尔会出现底盘表现波动。更彻底的隔离手段是让UI任务在高优先级任务运行间隙主动让出CPU,具体做法是在UI任务循环中插入taskYIELD()或者把vTaskDelay的时间设置稍大一点。同时,在底盘任务和UI任务各自独立持有I2C互斥量时间尽量短,而把长时间的操作移到DMA传输上。
// 在UI刷新前主动释放CPU给高优先级任务 void UI_Refresh(void) { // 先处理按键菜单逻辑 UI_ProcessMenu(); // 给底盘任务一个抢占窗口 taskYIELD(); // 再执行I2C屏幕刷新 UI_DisplayData(); }这里taskYIELD()只让出当前任务,对于同优先级任务还会触发时间片轮转,对高优先级任务则无影响,因为高优先级任务随时可以抢占。真正解决波动的方法是把I2C传输改成中断方式:每当I2C传输完成触发回调时,在回调中发送一个信号量给UI任务,UI任务继续执行后续刷屏操作。这样即使UI任务执行到一半被底盘任务抢占,I2C外设仍在后台自行工作,总线占用时间的碎片化不会影响控制任务。
回调方式在实际工程里常见写法是:
void HAL_I2C_MemTxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c == &hi2c_for_screen) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(&i2c_done_sem, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }从中断服务函数中调用xSemaphoreGiveFromISR而不是xSemaphoreGive,是FreeRTOS的硬性要求。第二个参数用于记录是否有更高优先级任务被唤醒,如果有,则在中断退出时立即执行一次上下文切换,减少信号量唤醒任务的响应延迟。这套架构下UI任务依然低优先级,但I2C忙等待时间几乎为0,优先级反转问题从根源上大幅缓解。
对于想要在2023年之后继续迭代队伍代码的人来说,这套系统的下一步改进方向是引入调度延迟统计,通过vTaskSetApplicationTaskTag给每个任务打标签,再在时钟节拍中断里统计每个任务的执行时间,就能量化底盘控制任务被中断干扰的时长。比一味调大栈深度和优先级更有效的手段,是先用栈水位数和中断回调时间两个指标建立基线,再逐步优化任务划分。
本文还有配套的精品资源,点击获取