2026年CCUT电子设计竞赛报告
2026年CCUT电子设计竞赛报告
智能病区物资配送小车(A题)
摘要 此设计以STM32F407为主控模块,完成了包含视觉循迹小车,视觉字母判断,自动路径规划在内的药品搬运小车。整个装置在视觉MaixCam循迹小车的基础上通过JetsonNano计算机判断字母来进行目标识别,通过串口与主控通信,对小车定位。
1.系统硬件方案
本题硬件方案有多种选择,我们的方案只是其一不是唯一。
本部分只用来说明我们作品的硬件部分。
1.1 主控
1.1.1 主控芯片
主控我们选择了STM32F407ZGT6,从性能上来说针对循迹,串口数据解析绰绰有余,我认为F103也足够胜任本次任务,从具体型号来说选择ZGT6具体原因如下
- 引脚众多,即使八路循迹,加上四路编码器与马达仍能满足
- 资源较为丰富,三个串口能够满足此次我们与maixcam和Jetson计算机进行通信
- 最主要原因为此型号单片机的驱动库我经过了几次比赛的完善已经较为可用
综上我推荐片上资源较多,操作较为熟练的单片机。
1.1.2 主控板
考虑到小车结构简单,无需太多外围模块,我们仅仅使用了电机驱动模块,降压模块。
电机驱动 为tb6612,若追求更高性能可更换为drv8701。 以及一个核心板自带led
板上硬件连接除pcb外 均使用2.54端子接口 可有效防止虚接短接。 
额外一提的是,烧录程序,调试串口可以使用无线调试模块以加快效率。
1.2 视觉模块
本题可以使用一个模块处理两项任务,若选择恰当,性能足够。
本次我们尝试多摄像头结合,所以选择双摄像头方案。
本题我们的视觉模块分为两部分,一部分专注于循迹相关,另一部分专注于字母识别,两部分图像经过处理后通过串口传输给单片机。
1.2.1 循迹部分
首先,循迹相关视觉算法较为简单,对性能要求较小,甚至不使用SOC使用单片机处理图像也可,但是使用带有SOC的视觉模块会有较为完整的生态支持,比如我们本次的maixcam就完整支持了opencv,具有强大的图像处理函数,并且有许多开源资料和官方平台,能够胜任本次任务。同理,k230 openmv等模块亦能够用来循迹任务,所以并无特别理由选择了maixcam,主要理由为我比较熟悉maixcam。

1.2.2 字母识别部分
本题中,唯一需要性能的部分为字母识别部分,由于需要部署模型,复杂的预处理和较高的实时性所以对性能要求很高。
我基于实验室的视觉模块分析得出以下三种方案 - maixcam pro - k230 -
香橙派5 - Nvidia jetson nano orin nx 他们的性能排序(基于tops单位)为
\[
Jetson(157tops)>香橙派5(6tops)>k230(6tops)>maixcam(1tops)\]
很显然,要选择jetson了。
其实不然。
如果考虑作品的成本,本题的性能还没有一定需要使用如此强力的芯片 接下来我对价格升序排序。
\[k230<maixcam<香橙派5<Jetson \]
很显然,使用k230为最佳,本题我推荐使用k230。 但是我们最终使用了jetson,得益于其完整的linux为我们带来更多的可拓展性
可见其有多个usb,网口等接口,拓展性强大。
摄像头使用了120°可视角度的usb摄像头,需要注意摄像头的帧率不得低于30帧以及摄像头的角度需要满足在4个字母并排时的识别,其他对于摄像头的特殊要求没有,颜色不要太过失真的摄像头均可。
1.3 其他硬件部分
1.3.1 底盘部分
轮子选择
本题的小车结构十分简单,对于尺寸限制也较为宽松,为25年电赛相同尺寸。若在搭车时考虑尺寸,需要注意的部分有两点,第一,若摄像头视角窄需要高度则不可超高,第二,小车若使用大编码器电机,需要考虑轮距。我们选择了一种减速机构较小的电机使的轮间距较小。

轮子数量
轮子数量主要考虑的为驱动轮数量,本题我更推荐四轮,四轮即使在任意两个轮子速度不一致时,前轮具有导向作用,会让小车更加平稳。但考虑到转弯顺滑程度两轮较好但并不胜出太多,所以推荐四轮。
但是两轮对主控资源占用少且pid速度环易调试,我们选择了两轮驱动。 前轮为牛眼万向轮。
主体框架
主体框架为一块亚克力板,其上模块使用m3螺丝固定 
主板与视觉模块使用铜柱堆叠。 
1.3.2 供电
供电分为两部分
第一部分 主电池 12v - maixcam - 主控机器模块 - 电机驱动 
第二部分 视觉jetson专用电池
底层为电池模块,两部分分离是为了防止主电池部分模块突然断电或损坏影响jetson

1.3.3 药品部分
药品部分 分为三部分 架子,药品,药品检测
架子使用3D打印 药品使用干电池
药品检测使用微动开关(一般用途为鼠标按键) 
1.3.4 其他硬件
电池外壳与摄像头支架 均为3D打印

小结
在使熟练使用3D打印条件下,本车框架无较大难度。 对主控和视觉的模块选择较多,视觉需要稍加选择。
2.系统主控软件方案
2.1 总体说明
本部分说明主控软件部分。
首先分析题目对应主控软件实现目标。
工作过程:参赛者手动将小车摆放在药房处,手持字母标号纸张由小车识别病房号(串口数据解析),将药品一次性装载到智能病区物资配送小车上(药品拿取识别);小车检测到药品装载完成后自动开始运送(循迹,底盘控制);小车根据走廊上的标识信息自动识别(循迹,串口数据解析)、寻径将药品送到指定病房(循迹),点亮红色指示灯,等待卸载药品(药品拿取识别);病房处人工卸载药品后,小车自动熄灭红色指示灯,开始 返回;小车自动返回到药房(循迹)(车头投影在门口区域内,面向药房)后,点亮绿色。(车上须配备一键关闭循迹的开关)
基础部分要求: - 循迹 - 底盘控制(pid) - 串口数据解析 - 药品拿取识别 - 按键检测
发挥部分要求 - 路径规划
根据分析可得基础部分只有stm32的基础代码。 发挥部分有很多写法。
2.2 基础部分
2.2.1 循迹
本次循迹我们仅使用了摄像头来进行循迹。
循迹核心思路与普通循迹模块一致。
第一步 提取图像红色区域
在HSV色域范围下提取红色,若在RGB色域下提取颜色,不易区分红色与深色类颜色,所以使用HSV
1
2
3
4
5
6
7
8
9
10
11
12
13#红色色域
LOWER_RED1 = np.array([0, 60, 60])
UPPER_RED1 = np.array([18, 255, 255])
LOWER_RED2 = np.array([165, 60, 60])
UPPER_RED2 = np.array([180, 255, 255])
while True:
img = cam.read()
mask1 = cv2.inRange(hsv, LOWER_RED1, UPPER_RED1)
mask2 = cv2.inRange(hsv, LOWER_RED2, UPPER_RED2)
mask = cv2.bitwise_or(mask1, mask2) # 合并两段红色
第二步 取出处理后图像 一行像素
绿色行 为取出像素
这一行就可以看作循迹模块,区别就是这一行比普通循迹模块多许多像素
1 | |
这么处理后就得到了循迹误差,可以传给单片机后进行pid计算。
由于后面的路径规划还需检测较远的字母,所以还需要设置一行检测来判断是否接近十字路口, 原理与前代码相同。
2.2.2 底盘控制
由于小车较重,且需要循迹,所以需要速度pid串上循迹pid
具体pid结构可以见以下框图 
具体pid代码如下 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25void pidTimerCallback(TimerHandle_t xTimer)
{
int track_error = 0;
char track_flag = 0;
cam_getIR_data(&track_error, &track_flag, NULL);
fp32 lb_motor_output = PID_calc(&motor_bl_pid, (fp32)encoder_bl_spd, motor_bl_set_spd);
fp32 rb_motor_output = PID_calc(&motor_br_pid, (fp32)encoder_br_spd, motor_br_set_spd);
if (ir_en == 1)
{
fp32 ir_correction = PID_calc(&ir_pid, (fp32)track_error, 0);
if (ir_motor_en == 1)
{
pidTurn(ir_correction, NORMAL_SPEED);
}
}
else
{
pidTurn(0, 0); // 停车
}
Motor_Set_Duty(lb_motor_output, MOTOR_LEFT_BACK);
Motor_Set_Duty(rb_motor_output, MOTOR_RIGHT_BACK);
}
2.2.3 药品与按键检测
这两部分检测均是基础gpio输入扫描 不细说。 1
2
3
4
5
6
7
8uint8_t Drug_Array[3]; // 药品检测数组
void Drug_Scan()
{
Drug_Array[0] = HAL_GPIO_ReadPin(DRUG_DETECT_1_GPIO_Port, DRUG_DETECT_1_Pin) ? 1 : 0;
Drug_Array[1] = HAL_GPIO_ReadPin(DRUG_DETECT_2_GPIO_Port, DRUG_DETECT_2_Pin) ? 1 : 0;
Drug_Array[2] = HAL_GPIO_ReadPin(DRUG_DETECT_3_GPIO_Port, DRUG_DETECT_3_Pin) ? 1 : 0;
}
2.3 发挥部分
2.3.1 小车核心控制部分
发挥部分要解决的核心点只有一个,就是路径规划。
按识别顺序完成多病房依次配送
以下为我们组的运行流程 这里画一个不标准的流程图:
以下为流程图的代码实现
1 | |
2.2.2 小车路径规划部分
第一步,存图。 我们将地图分为以下层数: 
对应数组如下 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20typedef enum
{
WARD_A = 0,
WARD_B,
WARD_C,
WARD_D,
WARD_E,
WARD_F,
WARD_G,
WARD_H,
TERMINAL,
AMPTY
} Node_Name;
Node_Name map[6][2] = {{TERMINAL, TERMINAL},
{WARD_A, WARD_B}, // 靠前为左
{WARD_C, WARD_D},
{AMPTY, AMPTY},
{WARD_E, WARD_G},
{WARD_F, WARD_H}};
第二步,获得起点坐标,和终点坐标 1
2
3uint8_t current_pos[2] = {0, 0}; // 当前所在位置,初始为终点/药房 第一个为层数 第二个为病房位置
// Node_Name target_arr[3] = {WARD_D, WARD_C, WARD_B}; // 任务目标数组,存储需要访问的病房节点 按照优先级排列
Node_Name target_arr[3] = {AMPTY, AMPTY, AMPTY}; // 比赛时数组
原理十分简单,就是针对起点在不同位置写不同的路径段,每个路径段以路口作为分割。
最终生成类似如下路径:
1 | |
具体代码以其中一段为例如下:
1 | |
但是写这么多也是比较冗杂,本题也有许多路线是可以封装的例如第四五两层路线
只要目的地为四五两层,那必然经过第三层路口,所以从第三层路口到四五两层病房路线都是固定的。
1 | |
这就是我们这次的路径规划。
那么这些都是当目的地为病房时,去程的路径规划,当小车要返回时的路径如何生成? 其实很简单,返程的路径就是去程路径的巡回。
例如以下药房去往C路径:
直走,直走,左转,直走
当小车掉头后去往起点路径就是
直走,右转,直走,直走
不同处就是转弯相反而已。
这就有如下函数: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27void generate_reverse_route(Move_Action routeToreverse[], Move_Action routeToAppend[], int routeToReverseLength, int *route_length_addr)
{
for (int i = routeToReverseLength - 1; i >= 0; i--)
{
Move_Action action = routeToreverse[i];
Move_Action reversed_action;
switch (action)
{
case ACT_STRAIGHT:
reversed_action = ACT_STRAIGHT;
break;
case ACT_LEFT:
reversed_action = ACT_RIGHT;
break;
case ACT_RIGHT:
reversed_action = ACT_LEFT;
break;
case ACT_BACK:
reversed_action = ACT_BACK;
break;
default:
reversed_action = ACT_NONE;
break;
}
routeAppend(reversed_action, routeToAppend, route_length_addr);
}
}
如此多任务如何安排呢? 若都放在main函数里也能运行,但是比较耦合,所以我使用了freertos来处理多任务。
如下代码 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304/* USER CODE BEGIN Header */
/**
******************************************************************************
* File Name : freertos.c
* Description : Code for freertos applications
******************************************************************************
* @attention
*
* Copyright (c) 2025 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "FreeRTOS.h"
#include "task.h"
#include "main.h"
#include "cmsis_os.h"
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "timers.h"
#include "queue.h"
/* USER CODE END Includes */
/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */
/* USER CODE END PTD */
/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */
float pitch = 0;
float roll = 0;
float yaw = 0;
float motor_bl_set_spd = 0;
float motor_br_set_spd = 0;
float yaw_set = 0;
int speed_set = 0;
uint8_t is_yaw_initialized = 0;
uint8_t yaw_pid_en = 1;
uint8_t ir_en = 0;
uint8_t ir_pid_en = 1;
uint8_t ir_blk;
uint8_t ir_blk_en = 1;
fp32 ir_output = 0;
uint8_t ir_motor_en = 0;
static TimerHandle_t pidTimerHandle;
pid_type_def motor_bl_pid;
pid_type_def motor_br_pid;
pid_type_def ir_pid;
fp32 pid_motor_bl_params[3] = {1.2f, 0.1f, 0.0f};
fp32 pid_motor_br_params[3] = {1.2f, 0.1f, 0.0f};
fp32 pid_ir_params[3] = {0.15f, 0.0f, 0.07f};
extern char task_flag;
/* USER CODE END PD */
/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */
/* USER CODE END PM */
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN Variables */
/* USER CODE END Variables */
/* Definitions for defaultTask */
osThreadId_t defaultTaskHandle;
const osThreadAttr_t defaultTask_attributes = {
.name = "defaultTask",
.stack_size = 256 * 4,
.priority = (osPriority_t)osPriorityLow,
};
/* Definitions for routeTask */
osThreadId_t routeTaskHandle;
const osThreadAttr_t routeTask_attributes = {
.name = "routeTask",
.stack_size = 512 * 4,
.priority = (osPriority_t)osPriorityNormal,
};
/* Definitions for irTask */
osThreadId_t irTaskHandle;
const osThreadAttr_t irTask_attributes = {
.name = "irTask",
.stack_size = 256 * 4,
.priority = (osPriority_t)osPriorityNormal,
};
/* Definitions for IRBlkQueue */
osMessageQueueId_t IRBlkQueueHandle;
const osMessageQueueAttr_t IRBlkQueue_attributes = {
.name = "IRBlkQueue"};
/* Private function prototypes -----------------------------------------------*/
/* USER CODE BEGIN FunctionPrototypes */
void pidTurn(float angle, float speed)
{
motor_bl_set_spd = -(speed - angle);
motor_br_set_spd = -(speed + angle);
}
void pidTimerCallback(TimerHandle_t xTimer)
{
int track_error = 0;
char track_flag = 0;
cam_getIR_data(&track_error, &track_flag, NULL);
// printf("task_flag: %d\r\n", task_flag);
// 200Hz PID计算
fp32 lb_motor_output = PID_calc(&motor_bl_pid, (fp32)encoder_bl_spd, motor_bl_set_spd);
fp32 rb_motor_output = PID_calc(&motor_br_pid, (fp32)encoder_br_spd, motor_br_set_spd);
if (ir_en == 1)
{
fp32 ir_correction = PID_calc(&ir_pid, (fp32)track_error, 0);
if (ir_motor_en == 1)
{
pidTurn(ir_correction, NORMAL_SPEED);
}
}
else
{
pidTurn(0, 0); // 停车
}
Motor_Set_Duty(lb_motor_output, MOTOR_LEFT_BACK);
Motor_Set_Duty(rb_motor_output, MOTOR_RIGHT_BACK);
}
void imuTimerCallback(TimerHandle_t xTimer)
{
}
int compareTaskName(const void *a, const void *b)
{
TaskStatus_t *taskA = (TaskStatus_t *)a;
TaskStatus_t *taskB = (TaskStatus_t *)b;
return strcmp(taskA->pcTaskName, taskB->pcTaskName);
}
/* USER CODE END FunctionPrototypes */
void StartDefaultTask(void *argument);
void routeTaskFunction(void *argument);
void IRTaskFunction(void *argument);
void MX_FREERTOS_Init(void); /* (MISRA C 2004 rule 8.1) */
/* Hook prototypes */
void vApplicationStackOverflowHook(xTaskHandle xTask, signed char *pcTaskName);
/* USER CODE BEGIN 4 */
void vApplicationStackOverflowHook(xTaskHandle xTask, signed char *pcTaskName)
{
/* Run time stack overflow checking is performed if
configCHECK_FOR_STACK_OVERFLOW is defined to 1 or 2. This hook function is
called if a stack overflow is detected. */
}
/* USER CODE END 4 */
/**
* @brief FreeRTOS initialization
* @param None
* @retval None
*/
void MX_FREERTOS_Init(void)
{
/* USER CODE BEGIN Init */
PID_init(&motor_bl_pid, PID_DELTA, pid_motor_bl_params, 100.0f, 50.0f);
PID_init(&motor_br_pid, PID_DELTA, pid_motor_br_params, 100.0f, 50.0f);
PID_init(&ir_pid, PID_POSITION, pid_ir_params, 100.0f, 100.0f);
pidTimerHandle = xTimerCreate("PIDCalculator", 10, pdTRUE, NULL, pidTimerCallback);
xTimerStart(pidTimerHandle, 0);
/* USER CODE END Init */
/* USER CODE BEGIN RTOS_MUTEX */
/* add mutexes, ... */
/* USER CODE END RTOS_MUTEX */
/* USER CODE BEGIN RTOS_SEMAPHORES */
/* add semaphores, ... */
/* USER CODE END RTOS_SEMAPHORES */
/* USER CODE BEGIN RTOS_TIMERS */
/* start timers, add new ones, ... */
/* USER CODE END RTOS_TIMERS */
/* Create the queue(s) */
/* creation of IRBlkQueue */
IRBlkQueueHandle = osMessageQueueNew(20, sizeof(uint8_t), &IRBlkQueue_attributes);
/* USER CODE BEGIN RTOS_QUEUES */
/* add queues, ... */
/* USER CODE END RTOS_QUEUES */
/* Create the thread(s) */
/* creation of defaultTask */
defaultTaskHandle = osThreadNew(StartDefaultTask, NULL, &defaultTask_attributes);
/* creation of routeTask */
routeTaskHandle = osThreadNew(routeTaskFunction, NULL, &routeTask_attributes);
/* creation of irTask */
irTaskHandle = osThreadNew(IRTaskFunction, NULL, &irTask_attributes);
/* USER CODE BEGIN RTOS_THREADS */
/* add threads, ... */
/* USER CODE END RTOS_THREADS */
/* USER CODE BEGIN RTOS_EVENTS */
/* add events, ... */
/* USER CODE END RTOS_EVENTS */
}
/* USER CODE BEGIN Header_StartDefaultTask */
/**
* @brief Function implementing the defaultTask thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_StartDefaultTask */
void StartDefaultTask(void *argument)
{
/* USER CODE BEGIN StartDefaultTask */
/* Infinite loop */
for (;;)
{
osDelay(30); // 保持原有延时,平衡响应速度和CPU占用
Drug_Scan();
cam2_getData();
uart1Clear();
}
/* USER CODE END StartDefaultTask */
}
/* USER CODE BEGIN Header_routeTaskFunction */
/**
* @brief Function implementing the routeTask thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_routeTaskFunction */
void routeTaskFunction(void *argument)
{
/* USER CODE BEGIN routeTaskFunction */
// Move_Action test_route[ROUTE_MAX_LENGTH] = {ACT_NONE}; // 路径测试
/* Infinite loop */
for (;;)
{
osDelay(7);
// generate_return_route(route, &route_length); // 生成返回路线
routeProcess();
}
/* USER CODE END routeTaskFunction */
}
/* USER CODE BEGIN Header_IRTaskFunction */
/**
* @brief Function implementing the irTask thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_IRTaskFunction */
void IRTaskFunction(void *argument)
{
/* USER CODE BEGIN IRTaskFunction */
uint8_t key_state = 0; // 0: not pressed, 1: pressed
/* Infinite loop */
for (;;)
{
cam1_getData();
// printf("key_state: %d\r\n", key_state);
if ((!HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin)) && (key_state == 0)) // 检测到按键按下且之前未按下
{
taskStatusToggle();
key_state = 1;
}
else if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) && (key_state == 1)) // 检测到按键松开且之前按下
{
key_state = 0;
}
// printf("IR error: %d, flag: %d\r\n", track_error, track_flag);
osDelay(10);
}
/* USER CODE END IRTaskFunction */
}
/* Private application code --------------------------------------------------*/
/* USER CODE BEGIN Application */
/* USER CODE END Application */