十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言智能送药小车电赛项目:从硬件到PID控制的完整实现

C语言智能送药小车电赛项目:从硬件到PID控制的完整实现 简介基于C语言实现的智能送药小车项目源码与完整资料包面向电子设计竞赛参赛者和嵌入式C语言学习者覆盖小车运动控制、传感器检测、送药任务调度等典型功能模块赛前工程或课程设计均可直接参考。压缩包共249个文件大小7.52MB以H头文件与C源文件为主体包含目标文件、调试信息、链接脚本等编译产物并配有S启动文件、HEX烧录文件及README说明便于重新编译、下载与二次开发。现有350人学习下载代码结构清晰目录层级完整可对照STM32标准外设库快速定位定时器、RCC、Flash等模块的初始化与调用逻辑。资料中还提供任务队列、流缓冲等实时操作系统相关源码以及批量清理脚本等实用工具能够帮助读者理解工程构建流程和底层驱动写法减少环境配置与排错时间适合希望在竞赛中快速落地原型的开发者。1. 智能送药小车到底在做什么电赛题背后考的是什么看到 基于C语言实现的智能送药小车源码及完整资料(电赛项目).zip 这个包名能直接联想到的是一套以单片机为控制核心、用C语言完成全部逻辑开发的嵌入式小车完整工程。这类项目在电赛中通常归为控制类题目要求小车从取药点出发、沿黑线自动行驶、在目标病房前准确停车并完成声光提示。与纯算法题不同送药小车考的是软硬结合——传感器数据要实时采集电机响应要跟得上控制周期任务切换不能丢状态而这些恰好是 C 语言作为嵌入式主力语言的强项直接操作寄存器、管理中断、精确控制时序。适合看这篇文章的人正在备赛的学生、想系统学嵌入式 C 语言开发的入门者或者想找一套完整工程结构当模板的开发者。下面按从硬件架构到具体代码、再到现场调试的顺序拆开讲。2. 送药小车的系统架构与C语言代码组织方式2.1 主控与外设选型为什么这类源码里C语言是绝对主力送药小车最典型的方案是用 STM32F103C8T6 做主控搭配 TB6612 电机驱动芯片传感器用 8 路灰度循迹视觉模块用 OpenMV 或 K210。选 STM32 不是因为它性能最强而是围绕它积累的 C 语言工程模板、库函数资料和调试工具最成熟竞赛现场出问题能找到的参考最多。C 语言在这个层级不可替代的原因很直接配置 GPIO 复用、写中断服务函数、挂 DMA 通道、操作定时器寄存器这些动作要求代码能精确映射到硬件时序C 可以操作内存地址没有运行时解释层执行路径完全可控。用 MicroPython 或图形化编程也能让小车跑起来但遇到循迹抖动、串口粘包、控制周期抖动这类综合性问题C 语言那种看得见摸得着的掌控感是其他方式给不了的。硬件选型的参考配置如下表所示这也是网上开源电赛源码包里最常见的物料组合模块推荐型号/方式作用关键参数主控STM32F103C8T6逻辑控制与闭环计算72MHz / 64KB Flash电机驱动TB6612FNG双路 H 桥驱动峰值 1.2APWM 频率建议 10kHz直流电机带霍尔编码器减速电机动力与转速反馈减速比 1:30编码器线数 11PPR循迹模块8 路 TCRT5000 模拟输出黑线检测传感器间距 1.2cm离地 1.5cm视觉模块OpenMV4 H7 或 K210识别病房号UART 输出115200 波特率无线模块NRF24L01 / 蓝牙透传上位机下发任务SPI / 串口表里有一个容易被忽略的参数PWM 频率。TB6612 在 10kHz 左右开关损耗和电流纹波最平衡频率太低会有尖锐啸叫太高则驱动芯片发热明显。这个值由定时器初始化代码决定后面 2.3 节会具体算一遍。提示如果从 STM32F103 换到 GD32F303 或 STM32F407先对比引脚复用表只改 BSP 层和驱动层App 层代码不要动。2.2 源码分层驱动层、中间层、应用层怎么划分一套值得参考的小车源码打开后目录结构会有明显的分层意识。不会所有代码堆在main.c里而是按驱动层 → 中间层 → 应用层组织SmartCart/ ├── Driver/ // 直接操作寄存器或外设库 │ ├── motor.c/.h // PWM输出、方向引脚控制 │ ├── encoder.c/.h // 编码器计数读取、速度换算 │ ├── track.c/.h // 灰度传感器 ADC 采集与阈值比较 │ └── uart.c/.h // 串口收发与 FIFO 缓冲 ├── Middleware/ // 与具体硬件无关的算法逻辑 │ ├── pid.c/.h // 增量式/位置式 PID │ ├── protocol.c/.h// UART 帧解析与组帧 │ └── filter.c/.h // 一阶低通滤波、中值滤波 ├── App/ // 业务逻辑送药流程就在这层 │ ├── task.c/.h // 主状态机 │ ├── navigation.c/.h // 循迹决策与路口处理 │ └── main.c // 初始化 主循环 中断入口 └── BSP/ // 板级支持外设时钟和引脚映射 └── stm32f1xx_it.c // 所有中断服务函数驱动层只做寄存器读写和信号采集不掺业务判断中间层把底层数据加工成有语义的信息比如把编码器脉冲数换算成当前转速 RPM把8 路 ADC 值变成车相对黑线的横向偏差应用层只关心目标房间是 203当前状态是送药中路口计数为 1还差两个路口。这种分层最大的收益在换硬件时体现。比如把灰度传感器从 GPIO 数字量改成 ADC 模拟量只需要改track.c内部的实现navigation.c里调用的Track_GetDeviation()接口签名不变应用层完全不用动。2.3 初始化代码的关键参数PWM和编码器定时器怎么配驱动层里最重要的初始化代码是定时器。PWM 定时器决定电机驱动频率编码器定时器决定测速分辨率。下面是一段用 STM32 标准外设库写的 PWM 初始化以 TIM2 通道 1 和通道 2 为例void Motor_PWM_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // TIM2 挂在 APB1 总线时钟源 72MHz tim.TIM_Prescaler 72 - 1; // 72MHz/72 1MHz每 1us 计数一次 tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 100 - 1; // 1MHz / 100 10kHzPWM 频率 tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim); gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_2MHz; // PWM 是低频开关波2MHz 足够 GPIO_Init(GPIOA, gpio); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 0; // 初始占空比 0防止上电猛冲 oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, oc); TIM_OC2Init(TIM2, oc); TIM_Cmd(TIM2, ENABLE); }这段代码有三个关键参数TIM_Prescaler 71把计数时钟降到 1MHz让每个计数单位对应 1 微秒方便换算占空比和定时时长TIM_Period 99把 PWM 频率定在 10kHzTIM_Pulse是占空比初值运行中通过TIM_SetCompare1()动态调整。把GPIO_Speed设为 2MHz 是故意的——PWM 信号不是高速数字信号引脚翻转速度设太高反而增加 EMI 干扰。编码器测速的定时器配置略有不同使用编码器接口模式void Encoder_TIM3_Init(void) { TIM_TimeBaseInitTypeDef tim; TIM_ICInitTypeDef ic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); tim.TIM_Prescaler 0; // 编码器模式不分频直通计数 tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 0xFFFF; // 16 位自动回绕按有符号数读取 tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, tim); ic.TIM_Channel TIM_Channel_1; ic.TIM_ICPolarity TIM_ICPolarity_Rising; // A 相上升沿 ic.TIM_ICSelection TIM_ICSelection_DirectTI; ic.TIM_ICPrescaler TIM_ICPSC_DIV1; ic.TIM_ICFilter 0x06; // 输入滤波滤掉换向毛刺 TIM_ICInit(TIM3, ic); // TIM_Channel_2 按同样配置初始化 // TI12 模式A/B 双相计数方向自动加减分辨率 x4 TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Cmd(TIM3, ENABLE); }编码器接口模式的好处是计数器由 A/B 相跳变沿自动驱动不需要 CPU 干预。TIM_EncoderMode_TI12让 A、B 两相都参与计数分辨率提升到编码器线数的 4 倍。TIM_ICFilter 0x06这段滤波对电机换向时产生的振铃干扰很有效但不适合设太高否则会丢失高速状态下的真实跳变。读计数后换算转速的公式是转速RPM 计数值差 / (减速比 * 线数 * 4) / 采样周期(秒) * 60这个换算逻辑写在encoder.c里中间层直接读取 RPM。3. 电机闭环控制与循迹判线的C语言实现3.1 增量式PID让左右轮速度一致的核心源码送药小车循迹跑偏的首要原因不是算法差而是两个电机的物理特性不一致。同一块驱动板、同一个 PWM 占空比左右轮实测转速可以差 10%。如果不做闭环小车会持续往一侧偏灰度传感器探测到偏差后不断纠偏整个系统就在偏左-纠右-偏右-纠左里震荡。所以成熟源码里一定有一个东西轮速 PID。让左右轮各自精确跟随目标转速循迹层只负责根据黑线位置给出目标转速差。电赛代码里最常用的是增量式 PID它的输出不是绝对 PWM 值而是这次该加多少typedef struct { float kp, ki, kd; float target; // 目标转速 RPM float prev_error; // 上周期误差 float delta_out; // 输出增量 float out_limit; // 输出限幅 } IncPID; float IncPID_Run(IncPID* pid, float current) { float err pid-target - current; // 增量 P项(本次误差) I项(本次误差) D项(误差变化率) pid-delta_out pid-kp * err pid-ki * err pid-kd * (err - pid-prev_error); pid-prev_error err; if (pid-delta_out pid-out_limit) pid-delta_out pid-out_limit; if (pid-delta_out -pid-out_limit) pid-delta_out -pid-out_limit; return pid-delta_out; }这个实现的巧妙之处在于积分项退化为ki * 本次误差不再单独维护累计和。于是不存在传统位置式 PID 的积分饱和问题不会在突然纠偏时把转速冲过头。代价是消除稳态误差比位置式慢但电机控制在 1ms 周期下跑这个差异完全感知不到。实际调用时基础 PWM 由循迹层给出PID 输出只做微调int16_t base_pwm 1500; // 循迹策略决定的基础占空比 int16_t adj_l (int16_t)IncPID_Run(pid_left, wheel_rpm_l); int16_t adj_r (int16_t)IncPID_Run(pid_right, wheel_rpm_r); Motor_SetSpeed(base_pwm adj_l, base_pwm - adj_r);base_pwm让两个轮子都有一个合理的平均速度PID 再各自修正差异。如果把 PID 输出直接当 PWM 总量循迹层的差速控制会被 PID 的积分行为吃掉车会变得很钝。限幅out_limit一般取满量程 PWM 的 10%~20%这样轮速修正既跟得上误差又不会在转向时产生突兀的顿挫。3.2 灰度传感器的加权偏差计算从8个电平到1个数值灰度循迹传感器的原始数据是 8 路 ADC 值经阈值比较后变成 8 位掩码。要把车偏了这个模糊感觉变成一个可量化的数值常用做法是加权平均。传感器按左 4~左 1、右 1~右 4物理排列左侧权重为负、右侧权重为正#define TRACK_SENSOR_COUNT 8 // 权重数组必须与物理排列一致这是循迹调试的第一个检查点 static const int8_t weight_map[TRACK_SENSOR_COUNT] {-4, -3, -2, -1, 1, 2, 3, 4}; int16_t Track_GetDeviation(uint8_t sensor_mask) { int16_t sum 0; uint8_t cnt 0; uint8_t i; for (i 0; i TRACK_SENSOR_COUNT; i) { // 约定某路传感器压到黑线时对应位为 0 if (!(sensor_mask (0x80 i))) { sum weight_map[i]; cnt; } } if (cnt 0) { return 127; // 特殊哨兵值完全丢线 } return (sum * 10) / cnt; // 乘 10 放大分辨率避免浮点运算 }127是丢线哨兵值任何正常偏差都不会达到这个数最大偏差只有 ±40上层判断丢线非常干净。sum * 10 / cnt把偏差从 ±4 放大到 ±40供方向 PID 使用。如果传感器间距不是 1.2cm这个放大系数要重新标定不是固定值。得到偏差后循迹控制可以简洁到两行int16_t steer pid_track.kp * deviation; // 比例转向 Motor_SetSpeed(base_speed steer, base_speed - steer);这是比例控制不依赖上一时刻偏差转弯时有一定滞后。要改善滞后可以把上一周期偏差记下来做微分形成第二个 PID——循迹 PID。这里要分清循迹 PID 控制的是车相对黑线的横向位置而 3.1 节的 PID 控制的是轮子转速两者作用对象不同不能混用同一个结构体实例。3.3 路口识别与直角转弯几个必须调的参数送药小车路线上会出现直角弯、T 型路口和十字路口。过路口的难点在于传感器线阵只适合跟踪连续黑线一旦车驶入路口区域8 路传感器可能同时压线偏差计算就失去了意义。所以路口必须单独识别、单独处理。最实用的路口识别是全黑判定8 路传感器同时检测到黑线说明车已进入路口区域。但直角弯前沿也可能短暂全黑直接转弯会把直角弯误判成十字路口。解决办法不是改算法而是加稳定计数// 路口判定全黑信号需要连续稳定一段时间才确认为路口 uint8_t Track_IsNode(uint8_t sensor_mask) { static uint16_t black_counter 0; if (sensor_mask 0x00) { // 8 路全黑 black_counter; if (black_counter 20) { // 连续 20 个控制周期全黑 black_counter 0; return 1; } } else { black_counter 0; } return 0; }black_counter 20意味着若主循环每 10ms 执行一次循迹需要连续 200ms 全黑才确认路口——这个时长能过滤掉直角弯前沿的短暂全黑。参数不是拍脑袋定的它取决于控制周期和车速车速 0.5m/s 时200ms 走 10cm正好覆盖路口黑线宽度。改车速后必须重新标定这个值。确认路口后转弯动作通常是先过线再转保持直行一小段让前轮越过路口中心线再设置内侧轮停转或反转。转弯角度不靠猜测而是用可调宏参数控制持续时长#define TURN_90_LEFT_MS 320 // 左转90度持续时长(ms) #define TURN_90_RIGHT_MS 310 // 右转90度实测两轮摩擦有差异 #define TURN_90_LEFT_PWM 1800 // 左转时外侧轮占空比 #define TURN_90_RIGHT_PWM 1800 // 右转时外侧轮占空比这几个参数必须用实际场地标定。左右轮 10ms 的差异来自轮胎磨损和电机特性差异。调整顺序先把 PWM 设到能让车缓慢转动再用定时器卡转向时长最后在完整弯道上微调左右差值。场地地面材质变化时这几个值也需要重测。4. 视觉识别与送药任务调度的协议设计4.1 主控与OpenMV的UART通信协议帧格式和校验怎么定视觉模块识别到病房号后要把这个信息传给主控。OpenMV、K210 都带 UART 接口最直接的接法是模块 TX 接主控 RX、模块 RX 接主控 TX、共地波特率统一为 115200。双方约定固定格式的数据帧避免发一个字节就完事的脆弱做法——中间被干扰丢一个字节整个任务就乱了。一套常见且够用的帧格式是 4 字节定长帧字节0: 0xAA 帧头 字节1: 0x01 指令码0x01 表示上报病房号 字节2: 0xCB 病房号如 203 字节3: 校验和 字节0 ^ 字节1 ^ 字节24 字节定长的好处是接收端状态机简单不需要统计长度字段也不怕粘包——每收到一个0xAA就开新帧后 3 个字节拼完即校验。主控端中断解析代码void UART1_IRQHandler(void) { uint8_t data; static uint8_t rx_step 0; static uint8_t frame[4]; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { USART_ClearITPendingBit(USART1, USART_IT_RXNE); data USART_ReceiveData(USART1); switch (rx_step) { case 0: if (data 0xAA) { // 帧头命中 frame[0] data; rx_step 1; } break; case 1: frame[1] data; // 指令码 rx_step 2; break; case 2: frame[2] data; // 病房号原始值 rx_step 3; break; case 3: frame[3] data; // 校验和 // 异或校验不通过则整帧丢弃 if ((frame[0] ^ frame[1] ^ frame[2]) frame[3]) { if (frame[1] 0x01) { g_target_room frame[2]; g_mission_ready 1; } } rx_step 0; break; default: rx_step 0; break; } } }这段代码有几个值得注意的细节。static uint8_t rx_step保存帧解析状态让一帧数据可以跨多次中断完成接收。g_target_room和g_mission_ready是中断和主循环共享的全局变量实际工程里g_mission_ready要声明为volatile——它由中断写入、由主循环查询如果编译器优化掉对它的重复读取主循环可能一直看不到任务就绪。这属于嵌入式 C 语言里典型的跨层共享内存管理问题不能只靠命名约定要用volatile、临界区或标志位来保证一致性。4.2 送药主状态机从等待指令到停车卸药收到病房号之后小车按阶段执行任务。用状态机管理送药流程是标准做法因为每个阶段传感器输入不同、执行动作不同、退出条件也不同。用状态机可以让每个状态的逻辑保持独立不出现一个 if 套三层的意大利面条式代码。typedef enum { ST_IDLE, // 待机等待指令 ST_TO_PHARMACY, // 先行驶到药房 ST_LOAD_WAIT, // 等待装药 ST_DELIVER, // 循迹前往目标病房 ST_STOP_AT_ROOM, // 到达病房停车等待 ST_RETURN_BASE, // 返航 ST_FAULT // 异常保护 } SchedState; SchedState g_sched ST_IDLE; uint8_t g_target_room 0; volatile uint8_t g_mission_ready 0; void Sched_Tick(void) // 10ms 周期调用 { switch (g_sched) { case ST_IDLE: if (g_mission_ready) { g_sched ST_TO_PHARMACY; Timer_SetTimeout(5000); // 5秒内必须到达药房 } break; case ST_TO_PHARMACY: Navigation_Run(); // 循迹 路口计数 if (Check_ArrivedPharmacy()) { Motor_Stop(); Timer_SetTimeout(3000); // 允许装药 3 秒 g_sched ST_LOAD_WAIT; } break; case ST_DELIVER: Navigation_Run(); if (Check_ArrivedTargetRoom()) { Motor_Stop(); g_sched ST_STOP_AT_ROOM; } break; // 其他状态按同样模式扩展 default: g_sched ST_FAULT; break; } }Timer_SetTimeout是源码里常见的软件定时器在 SysTick 中断里递减到 0 置位标志。它的作用不是精确计时而是做看门狗——一个阶段卡住超过设定时间就强制跳转。比如灰度传感器被一块又黑又亮的污渍骗住车会原地不走没有这个超时保护药就等于送丢了。状态机实现里还有个 C 语言层面的优化点如果状态增加到十个以上switch-case会显得很长。这时可以用函数指针数组重构把每个状态的处理函数指针放入一个常量表主循环一行调用state_table[g_sched]()。这是嵌入式 C 语言里典型的表驱动写法既省内存又方便增删状态也更好测试。4.3 病房匹配与异常处理送药任务不能送错房视觉模块给出的是一个房间号数字但单靠视觉结果决定停车位置不够可靠。比如光照变化导致 203 被识别成 205小车就会在错误房间门口停下来。源码里通常配合一个静态映射表把当前经过的路口序号和目标房间号做双条件校验。typedef struct { uint16_t node_index; // 第几个路口 uint16_t room_id; // 该路口对应的病房号 } RoomNodeMap; // 实际场地需要重新填表 const RoomNodeMap room_map[] { {1, 101}, {2, 103}, {3, 202}, {4, 204}, }; uint8_t Check_RoomMatched(void) { uint8_t i; uint8_t matched 0; for (i 0; i sizeof(room_map) / sizeof(room_map[0]); i) { if (room_map[i].node_index g_cross_count room_map[i].room_id g_target_room) { matched 1; break; } } return matched; }这套双条件校验的逻辑核心是只有路口计数等于映射表节点序号且视觉上报房间号等于映射表房间 ID两个条件同时成立才允许触发停车。视觉识别漂移时路口计数对不上车不会停路面打滑导致编码器多计一个路口时房间号对不上车也不会停。如果综合判断发现异常标准做法不是原地纠结而是进入ST_FAULT状态停止电机、蜂鸣器响三声、通过蓝牙或 OLED 上报错误码。在竞赛场景里错误码比强行继续跑更有利于评审——它能直观展示你处理了哪些边界情况也方便现场快速定位故障环节。5. 调试手段与最容易踩的四个坑5.1 串口分级日志和OLED两样就够调试送药小车不建议一开始就上仿真器。最实用的两样是串口和 OLED串口做分级日志OLED 放关键运行状态。代码里凡是打印都用printf重定向到 UART1日志分[INF]和[ERR]两级。竞赛现场看日志太慢OLED 才是快速定位的主力——屏上显示三行当前黑线偏差、路口计数、任务状态。车在场上跑人跟着看是循迹问题还是调度问题一下就能分辨。5.2 最容易踩的四个坑及排查方法坑一编码器接线接反PID 把转速推到反方向。现象是直行时小车不断大幅朝一侧偏转。排查方法掉电状态下用手推车前进看编码器计数值符号。计数为负说明 A/B 相接反在初始化里交换两路输入或者把极性配置反过来。这里不要想着靠改 PID 参数符号绕过那是给后面埋雷。坑二灰度传感器阈值随环境光漂移。同一个传感器上午在窗边和下午在灯光下的输出翻转点不一样。采用 ADC 直连主控的方案时成熟的源码会在开机时做一次自动校准校准代码的逻辑是采集 50 次亮场基准值取平均再减去余量作为每个传感器的独立阈值。如果用的是带 LM393 比较器的数字量模块就要把校准动作放到电位器上——赛前用螺丝刀调电位器让每个传感器在没有黑线的地面上输出为 1压到黑线上输出为 0。坑三PWM 频率选错电机啸叫或者驱动芯片发烫。前面说过 10kHz 是 TB6612 的合适频率但很多模板初始化默认是 1kHz听到持续高频蜂鸣声时先怀疑频率。排查方法是用逻辑分析仪量 PWM 引脚测实际周期换算频率或者对照时钟树手算定时器参数。10kHz 对应Period 99分频后 1MHz 的情况1kHz 对应Period 999别把这两个值弄混。坑四中断里做了耗时操作循迹控制周期被拉长。典型错误是在 UART 中断里直接调用printf或者在定时器中断里跑 PID 的完整计算。中断只该做收数据、存缓冲、置标志位一两微秒内完成业务计算、日志打印全部放主循环。如果循迹表现为直线稳、转弯时抽搐一下先查中断里有没有耗时函数。用逻辑分析仪量一个测试引脚的拉高拉低时间就能确认每个中断占了多少执行时间。本文还有配套的精品资源点击获取
返回列表