简介:智能送药小车项目以C语言开发,源码与完整资料一并打包,面向大学生电子设计竞赛参赛者及嵌入式系统学习者,可解决智能小车设计中电机控制、传感器检测、路径规划与药品配送流程管理等关键问题。压缩包共249个文件,整体约7.52MB,以C源码为核心,含66个头文件与57个C源文件,覆盖STM32底层驱动(如RCC、TIM、Flash)、FreeRTOS任务队列与流缓冲等模块;同时提供Keil工程配置、链接脚本、批处理脚本和编译生成的hex、axf文件,便于直接烧录与二次调试。目前已有350人浏览学习该资源。对备赛者而言,这套资料从工程目录到驱动代码、从任务调度到调试工具都较为完整,可快速复现电赛常见智能送药场景,也能为嵌入式入门提供一份可仿照的完整项目范例,节省从零搭建底层工程的时间。
1. 电赛送药小车,先想清楚它到底考什么
智能送药小车这个题目在电赛里属于典型的“控制类 + 任务调度”组合,和纯算法题的差别很大:它不要求你训练一个多聪明的模型,而是要求你在有限硬件资源下,用C语言把“走直线、转直角弯、识别路口、定点停车、响应呼叫”这一套流程做得足够确定。换句话说,评委看的不是你的小车会不会“思考”,而是它在规定时间内能不能稳定复现整个送药流程。
所以拿到这类的源码及完整资料时,第一件事不是急着看某个传感器的驱动怎么写,而是先建立整辆小车的控制框架:循迹传感器的读数怎么变成左右轮的速度差,编码器数据怎么换算成实际线速度,路径切换时状态机怎么迁移。C语言在这类项目里的优势恰恰不是“智能”,而是它贴近底层——你可以精确控制每个PWM周期的占空比,可以在中断里读取编码器而不丢失脉冲,可以随时用串口打印中间变量。这些能力决定了调车效率和最终稳定性。
这篇文章从系统架构讲起,落到电机PID闭环、循迹避障、状态机调度和现场调参,按我调电赛小车的实际顺序来写。新手可以照着搭代码,老手可以重点看第5章和第6章的边界条件和验证方法。
2. 系统框架与C语言模块划分:源码该有哪些文件、引脚怎么分
送药小车这类项目的源码和普通单片机例程不同,它的核心不在于某个外设驱动写得多漂亮,而在于“传感器输入—决策—电机输出”这条链路是否清晰。拿到资料包后,先看文件结构,再逐层读代码,顺序反了容易陷在细节里出不来。
2.1 硬件清单与MCU资源分配
常见做法是主控用STM32F103系列,配两个带编码器的直流减速电机、一个4路或5路红外循迹模块、一个超声波模块(用于避障和判断是否到达病床)、一个OLED屏显示当前状态,再预留几个按键用于设置病床号。整体资源占用不高,但引脚分配会影响后续调试,建议按下表规划。
| 功能模块 | 引脚资源 | 说明 |
|---|---|---|
| 左电机PWM | TIM2_CH1 | 频率10kHz~20kHz,避免 audible noise |
| 右电机PWM | TIM2_CH2 | 与左轮使用同一定时器,保证同步 |
| 左编码器A/B相 | TIM3_CH1/CH2 | 定时器编码器模式,硬件计数不丢脉冲 |
| 右编码器A/B相 | TIM4_CH1/CH2 | 同上 |
| 4路循迹模块 | 4个GPIO输入 | 建议全部配置为外部中断或1ms轮询 |
| 超声波Trig/Echo | 2个GPIO | Echo用输入捕获测量脉宽更准 |
| OLED I2C | I2C1 | 显示任务状态、目标床号、电池电压 |
这里有一个容易被忽视的点:编码器接口尽量用定时器的编码器模式,而不是外部中断+GPIO翻转计数。前者由硬件自动完成加减计数,在PWM频率较高或电机转速波动时不会丢脉冲;后者虽然代码简单,但在两个通道同时翻转时容易漏计数,直接影响速度闭环的精度。
2.2 源码目录与驱动分层的C语言组织方式
完整的电赛源码一般按“驱动层—中间层—应用层”三层组织。驱动层只做寄存器操作,中间层把传感器数据整理成有意义的物理量,应用层实现送药流程。一个可用的目录结构大致如下:
project/ ├── core/ # 中断、系统时钟、SysTick ├── drivers/ # 电机、编码器、循迹、超声波、OLED ├── modules/ # PID、运动控制、状态机、日志 ├── app/ # main.c、任务调度 └── user/ # 配置文件、引脚宏定义驱动层的C语言写法有一个原则:每个外设只暴露两个接口——Init和Read/Write。例如循迹模块,典型接口是Track_Init()和uint8_t Track_Read(void),返回值是一个bitmap,每一位对应一路传感器。这样中间层和上层完全不需要关心GPIO具体是哪个引脚,换板子时只需要改驱动文件内的宏。
main.c里的调度建议采用固定时基轮询+状态机,不要裸写一个巨大的while循环把所有逻辑都塞进去。参考骨架如下:
volatile uint32_t sys_tick_ms = 0; volatile uint8_t flag_10ms = 0; volatile uint8_t flag_50ms = 0; void SysTick_Handler(void) { sys_tick_ms++; if (sys_tick_ms % 10 == 0) flag_10ms = 1; if (sys_tick_ms % 50 == 0) flag_50ms = 1; } int main(void) { SystemInit(); Periph_Init(); /* 时钟、GPIO、定时器、串口、ADC */ Motor_Init(); Sensor_Init(); while (1) { /* 10ms时基:编码器读取 + PID计算 + PWM输出 */ if (flag_10ms) { flag_10ms = 0; Encoder_Read(); PID_Calculate(); Motor_SetPWM(); } /* 50ms时基:循迹扫描 + 超声波触发 + 状态机推进 */ if (flag_50ms) { flag_50ms = 0; LineSensor_Read(); Sonar_Trigger_Read(); FSM_Run(); } } }代码的逻辑说明:把实时性要求高的编码器读取和PID计算放在10ms周期,因为电机速度环需要较高的刷新率才能保证响应;循迹和超声波更新放到50ms周期,因为机械系统对这几个传感器的响应速度要求没那么高,而且超声波本身两次触发之间需要留足声波往返时间。这样做的好处是主循环负载可控,即使后续加OLED刷新或串口打印,也不会干扰速度环的稳定性。
参数上需要注意:SysTick中断周期直接影响所有时基标志,通常配置为1ms;如果改用其他RTOS或裸机调度方式,务必保持“速度环时基 <= 运动控制时基 <= 决策时基”这个关系,否则会出现轮子还没响应急转弯指令的现象。
3. 电机闭环与运动控制:增量式PID与PWM调参关键点
送药小车最影响体验的不是循迹灵敏度,而是电机的响应一致性。两个电机的机械特性、电池电压、地面摩擦都不可能完全一致,开环PWM控制下小车必然跑偏,所以速度闭环是整套源码里最值得细读的部分。
3.1 为什么选择增量式PID而不是位置式PID
电赛小车的控制周期短(通常10ms~20ms),电机模型的非线性较强,负载变化大。位置式PID需要累加历史误差,一旦掉电重新上电,积分初值需要重新建立,而且输出是绝对值,容易出现积分饱和导致的超调。增量式PID只输出本次控制量的增量,不需要对误差做全量累加,天然抗积分饱和,也方便做输出限幅。实现上还省一个误差数组的存储,在资源紧张的MCU上更友好。
增量式PID的公式是:
du = Kp * (e[k] - e[k-1]) + Ki * e[k] + Kd * (e[k] - 2*e[k-1] + e[k-2])对应C语言实现如下,这段代码也是我调车时直接套用的模板:
typedef struct { float kp; float ki; float kd; int32_t target; /* 目标线速度,单位mm/s */ int32_t measured; /* 编码器换算后的实际线速度 */ int32_t err_last; int32_t err_prev; int32_t out; /* 输出,直接映射到PWM比较寄存器 */ int32_t out_max; int32_t out_min; int32_t dead_zone; /* 死区阈值 */ } pid_ctrl_t; int32_t PID_Update(pid_ctrl_t *pid, int32_t measured) { int32_t err = pid->target - measured; int32_t d_err = err - pid->err_last; /* 死区处理:误差小于阈值时输出归零,防止电机低速抖动 */ if (pid->dead_zone && (err > -pid->dead_zone) && (err < pid->dead_zone)) { err = 0; } /* 增量式计算,只用最近三次误差 */ int32_t du = (int32_t)(pid->kp * err - pid->ki * pid->err_last + pid->kd * d_err); pid->out += du; if (pid->out > pid->out_max) pid->out = pid->out_max; if (pid->out < pid->out_min) pid->out = pid->out_min; pid->err_prev = pid->err_last; pid->err_last = err; return pid->out; }代码说明:这里用了一种常见的增量式写法,把积分项隐含在输出累加过程中,省去独立的积分累加变量。out_max和out_min按PWM的ARR值设置,例如ARR为999时输出范围是0~999,同时要避免负占空比,所以实际使用时还需要映射到方向引脚。dead_zone参数用于解决低速段电机不转但PID持续输出微小PWM的问题,实测对减小电机啸叫帮助很大。
调用时的参数设定建议参照下表,电机规格不同会有差异,但初始值可以按这个范围起步:
| 参数 | 初始值 | 调节方向 | 现象 |
|---|---|---|---|
| kp | 0.8~1.5 | 增大则响应变快,过大会震荡 | 电机嗡嗡响、有顿挫 |
| ki | 0.05~0.2 | 增大则稳态误差小,过大会超调 | 停车时冲过头 |
| kd | 0 或极小值 | 电赛场景一般不用 | 噪声放大明显 |
| 控制周期 | 10ms | 缩短则更稳,但MCU负载增加 | — |
调参顺序很固定:先把ki、kd设为0,只保留kp调到临界震荡,再退回80%;然后慢慢加ki消除稳态误差;最后如果转向时过冲明显再考虑极小量的kd。实战中大部分小车只用kp和ki就够了,kd在编码器量化噪声较大的时候只会放大抖动。
3.2 编码器数据换算与实际线速度校准
PID的目标值单位是mm/s,但编码器计数是脉冲数,要把两者关联起来。一般步骤是:先测出轮子转一圈对应的脉冲数(减速比 * 编码器线数 * 倍频),再乘上轮子周长,得到每脉冲对应的位移。测量方法很简单:把车抬起来,手动转轮子一圈,同时用串口打印编码器计数,多测几次取平均值。
换算公式为:
distance_per_pulse = wheel_circumference / pulses_per_rev speed_mm_s = (pulse_count_delta / control_cycle_s) * distance_per_pulse这段换算逻辑建议放在驱动层,而不是应用层。原因是不同组装的轮子直径和减速比可能有细微差异,集中在一处方便改参数。注意这里的pulse_count_delta是相邻两个控制周期内的编码器差值,如果用累加值做差分,要考虑定时器溢出回绕的问题,避免出现跳变。
4. 循迹避障与路口识别:传感器状态编码与C语言决策
送药小车赛道的核心难点在“路口”和“病房门口”。直线循迹相对简单,真正的坑在于小车经过十字路口或T型路口时,循迹传感器会短暂全灭或全亮,此时如果处理不当,小车会直接冲出去或原地打转。
4.1 循迹传感器状态编码与丢线处理
4路循迹模块的原始输出是4个bit,用C语言按位编码后可以映射到不同动作。先看一段状态编码实现:
typedef enum { TRACK_ALL_OFF = 0x00, TRACK_LEFT = 0x01, TRACK_CENTER = 0x02, TRACK_RIGHT = 0x04, TRACK_LOST = 0x07 } track_state_t; typedef struct { int32_t speed_linear; /* 直行速度,mm/s */ int32_t speed_steer; /* 转向偏置,叠加到左右轮 */ } track_cmd_t; track_cmd_t Track_Control(uint8_t sensor_bits) { track_cmd_t cmd = {0, 0}; static uint8_t last_bits = TRACK_CENTER; switch (sensor_bits) { case TRACK_CENTER: cmd.speed_linear = 300; cmd.speed_steer = 0; break; case TRACK_LEFT: cmd.speed_linear = 200; cmd.speed_steer = 60; /* 右轮加速,车体向左回调 */ break; case TRACK_RIGHT: cmd.speed_linear = 200; cmd.speed_steer = -60; break; case TRACK_LOST: /* 丢线时按上次方向爬行,持续超过100ms才停车 */ cmd.speed_linear = 0; cmd.speed_steer = (last_bits == TRACK_LEFT) ? 80 : -80; break; default: break; } last_bits = sensor_bits; return cmd; }逻辑说明:speed_steer是一个偏置量,最终下发给电机的目标是左轮 = speed_linear + speed_steer、右轮 = speed_linear - speed_steer。这种差速方式比直接控制转向角简单直接,配合增量式PID时也不容易产生跳变。
丢线处理是这套循迹逻辑里最需要调的地方。当小车高速经过锐角弯道时,传感器可能短暂全部离开黑线,此时不是停车,而是按上次方向“盲走”一小段。常见的实现是记录一个丢失开始的时间戳,超过阈值再停车。这个阈值和车速强相关,300mm/s时大概给50~100ms,车速越快阈值越小,否则会冲出赛道。
4.2 超声波避障与病房停靠的触发条件
超声波模块在送药小车上有两个典型用途:走廊里检测到障碍物时减速或停车;到达病床门口时判断是否到位。后一种用法取决于赛题要求,有的是压到停止线,有的是检测到床边反射板。
超声波测距的C语言接口不要用阻塞式延时,因为while(echo==0)这种写法会卡死整个控制循环。标准做法是用输入捕获或者外部中断记录Echo引脚的电平跳变时间。一个简洁的轮询实现如下:
uint32_t Sonar_GetDistance_mm(void) { static uint32_t last_valid = 800; /* 上次有效距离,mm */ uint32_t t_start, t_end, width; uint32_t dist; /* Trig引脚拉高10us以上触发 */ HAL_GPIO_WritePin(SONAR_TRIG_GPIO_PORT, SONAR_TRIG_PIN, GPIO_PIN_SET); delay_us(12); HAL_GPIO_WritePin(SONAR_TRIG_GPIO_PORT, SONAR_TRIG_PIN, GPIO_PIN_RESET); /* 超时保护:2ms内等不到回波直接返回上次有效值 */ uint32_t timeout = 2000; while (HAL_GPIO_ReadPin(SONAR_ECHO_GPIO_PORT, SONAR_ECHO_PIN) == GPIO_PIN_RESET) { if (--timeout == 0) return last_valid; } t_start = sys_tick_us(); timeout = 30000; /* 最大测距约5米 */ while (HAL_GPIO_ReadPin(SONAR_ECHO_GPIO_PORT, SONAR_ECHO_PIN) == GPIO_PIN_SET) { if (--timeout == 0) return last_valid; } t_end = sys_tick_us(); width = t_end - t_start; dist = width * 340 / 2000; /* 声速340m/s,脉宽us转mm */ last_valid = dist; return dist; }这里的sys_tick_us()需要用一个微秒级计时器,可以是DWT计数器或者定时器捕获。注意width * 340 / 2000的含义:脉宽是声波往返时间,距离需要除以2,所以是340 * width / 2 / 1000(mm),合并成width * 340 / 2000。这个函数只适合放在50ms周期的时基里调用,因为单片机执行while轮询时如果遇到Echo引脚异常拉高,最坏情况会阻塞接近30ms。
4.3 状态机决策:让送药小车按流程走而不是乱跑
循迹和避障只能保证小车“能走”,真正决定它“走去哪”的是状态机。电赛送药小车的状态机不需要复杂,一个枚举加一张函数指针表就足够清晰。核心设计思路是:每一个状态对应一个动作函数,状态切换只发生在明确的边界条件上。
typedef enum { ST_INIT, ST_IDLE, ST_TO_PATIENT, ST_STOP_AT_BED, ST_TO_PHARMACY, ST_BACK_BASE, ST_EMERGENCY, ST_MAX } fsm_state_t; typedef void (*fsm_action_t)(void); void do_idle(void); void do_to_patient(void); void do_patient_arrive(void); void do_to_pharmacy(void); void do_back_base(void); void do_emergency(void); static const fsm_action_t fsm_table[ST_MAX] = { [ST_INIT] = do_init, [ST_IDLE] = do_idle, [ST_TO_PATIENT] = do_to_patient, [ST_STOP_AT_BED] = do_patient_arrive, [ST_TO_PHARMACY] = do_to_pharmacy, [ST_BACK_BASE] = do_back_base, [ST_EMERGENCY] = do_emergency, }; void FSM_Run(void) { fsm_table[current_state](); }函数指针表的好处是状态切换时不需要写一长串switch-case,后续新增状态只需要在枚举里加一项,同时在数组里挂一个新函数。每个动作函数内部负责三件事:读取当前传感器数据、判断是否满足切换条件、设置下一个状态。例如do_to_patient里检测到病房门口标志后,直接current_state = ST_STOP_AT_BED,然后在do_patient_arrive里执行停车和播报。
这种结构的调试价值很大:串口打印当前状态编号,配合日志可以精确知道小车在路径的哪个环节出了问题。实际编写时要注意状态机函数里不要做耗时操作,测距、循迹都在外部的时基里完成,状态机只消费最新数据。
5. 电赛视角的任务调度与联调:状态迁移表与现场日志定位
送药小车和普通循迹小车的本质区别是它有“任务”概念:收到医护呼叫后选择目标病床,送药完成后再返回药房。这意味着源码里必须有一套能响应外部输入、按优先级处理事件的调度逻辑。
5.1 呼叫响应与送药流程的状态迁移表
电赛常见场景是按键或串口模拟呼叫信号,指定一个病床号,小车从当前位置出发。状态迁移可以预先画成一张表,源码里的结构就按这张表来写:
| 当前状态 | 触发条件 | 动作 | 下一状态 |
|---|---|---|---|
| ST_IDLE | 收到呼叫,目标床号有效 | 记录床号,启动导航 | ST_TO_PATIENT |
| ST_TO_PATIENT | 检测到病房门口标志 | 减速停车,OLED显示到达 | ST_STOP_AT_BED |
| ST_STOP_AT_BED | 停车延时3秒 | 掉头或按原路返回 | ST_TO_PHARMACY |
| ST_TO_PHARMACY | 到达药房停止线 | 停车 | ST_BACK_BASE |
| 任意状态 | 超声波距离 < 15cm | 紧急停车 | ST_EMERGENCY |
紧急状态的处理要特别小心:回到正常运行状态的恢复条件不能是“障碍物离开”这个单一条件,否则障碍物还没完全走开小车就开始动。常见的做法是加一个“恢复倒计时”,保证障碍物稳定离开1秒以上再切回原状态,并且原状态里的目标信息不能丢。
5.2 用环形日志缓冲区回放现场
联调阶段最头疼的问题是小车跑飞了,但你不确定它是在哪个路口跑飞的。串口打印能解决一部分问题,但串口速度跟不上大量调试信息的输出。我常用的方案是在MCU内部维护一个环形日志缓冲区,存最近N条关键事件,故障后通过一个按键或串口命令把日志导出来。
#define LOG_BUF_SIZE 256 static volatile char log_buf[LOG_BUF_SIZE]; static volatile uint16_t log_head = 0; static volatile uint16_t log_tail = 0; void Log_Push(char c) { uint16_t next = (log_head + 1) % LOG_BUF_SIZE; if (next != log_tail) /* 缓冲区满时覆盖最旧的数据 */ { log_buf[log_head] = c; log_head = next; } } void Log_Dump(void) { uint16_t i = log_tail; while (i != log_head) { uart_send_char(log_buf[i]); i = (i + 1) % LOG_BUF_SIZE; } }日志内容是“格式化后的短字符串”,例如"ST_CUR:3 SPD:280 TRK:02",表示当前状态3、速度280、循迹状态2。尽可能把状态编号、车速、循迹原始值和目标床号打包成一条固定格式的日志,方便写脚本做离线分析。这套日志不会占用太多Flash,但对调试效率的提升非常明显。
5.3 联调顺序与串口命令接口设计
拿到完整资料后不要直接烧录跑整车,按以下顺序逐层验证:先单独验证编码器读数是否正常(转动轮子看串口数据);再验证PID闭环,设定一个固定速度看两轮是否同步;然后放上循迹线,观察状态编码是否和传感器位置一一对应;最后才把状态机加进去联调。每一步失败时,先看驱动层数据,再看控制层输出,不要跳过硬件直接怀疑算法。
串口命令接口建议预留三个简单命令:v查询当前速度,s切换状态机状态,p打印PID参数。这部分代码量不大,但现场改参数时不用反复重新烧录,节省的时间远超写命令解析的时间。
6. 验证方法与应用技巧:线速度标定与斜坡加速补偿
最后这套技巧是区分“能跑”和“稳定跑完流程”的关键,也是电赛评分里拉开差距的地方。
6.1 线速度标定与直行纠偏
PID闭环只能保证“左右轮按设定转速转”,但如果两个轮子的实际直径有细微差异,直行时小车依然会画弧。标定流程如下:设置固定的PWM占空比(比如PWM=500),让小车在地面跑1米,记录左右轮编码器各自的总脉冲数。正常情况下两边应相等,如果左侧脉冲数明显多于右侧,说明左轮实际直径偏小或右轮打滑,需要在下发目标速度时乘以一个修正系数。
| 标定项目 | 数据记录 | 修正方式 |
|---|---|---|
| 1米直行左轮脉冲 | 比如 2850 | 目标速度乘以 1.0 |
| 1米直行右轮脉冲 | 比如 2790 | 目标速度乘以 2850/2790 |
| 电机最低起转PWM | 实测比如 130 | PID输出死区设为略低于该值 |
这个系数写在配置文件里,不要写死在控制函数中。每次更换轮胎或调整底盘后重新标定一次,比调PID省事得多。
对于车速变化频繁的场景,比如从直行300mm/s转入路口减速到150mm/s,速度差太大会导致车身明显前倾。斜坡加速的实现很简单:不直接下发目标速度,而是每次控制周期将当前速度朝目标速度逼近一个步长。
int32_t Speed_Ramp(int32_t target, int32_t current, int32_t max_accel) { int32_t delta = target - current; if (delta > max_accel) return current + max_accel; if (delta < -max_accel) return current - max_accel; return target; }max_accel的单位是mm/s,控制周期10ms,所以每秒加速度增量是max_accel * 100。实测从静止起步时max_accel取5~10比较合适,即每秒加速0.5~1.0m/s;从高速减速时取8~15,既要短距离刹停,又不能因为减速度过大导致车轮抱死失去循迹能力。
转向时的处理也可以复用这个函数,但要注意左右轮的目标速度需要单独计算。此时核心是让内侧轮减速、外侧轮保持或略微加速,而不是直接改为一个固定的转向速度,因为固定转向速度在长时间弯道里会让小车逐渐偏离赛道中心线。
另一个容易被忽视的细节是掉线保护。PWM输出线和编码器线在震动中容易接触不良,一旦编码器信号中断,PID会认为转速为0而持续加大占空比,电机瞬间满转导致小车飞出去。驱动层里加一个超时检测:如果连续200ms没有编码器计数更新,立即将PWM输出归零并进入紧急停车状态。这个功能每次上电都要测试,测试方法很简单——运行中拔掉编码器插头,看小车是否能在1秒内刹停。
本文还有配套的精品资源,点击获取