
简介智能送药小车项目以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屏显示当前状态再预留几个按键用于设置病床号。整体资源占用不高但引脚分配会影响后续调试建议按下表规划。功能模块引脚资源说明左电机PWMTIM2_CH1频率10kHz~20kHz避免 audible noise右电机PWMTIM2_CH2与左轮使用同一定时器保证同步左编码器A/B相TIM3_CH1/CH2定时器编码器模式硬件计数不丢脉冲右编码器A/B相TIM4_CH1/CH2同上4路循迹模块4个GPIO输入建议全部配置为外部中断或1ms轮询超声波Trig/Echo2个GPIOEcho用输入捕获测量脉宽更准OLED I2CI2C1显示任务状态、目标床号、电池电压这里有一个容易被忽视的点编码器接口尽量用定时器的编码器模式而不是外部中断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的问题实测对减小电机啸叫帮助很大。调用时的参数设定建议参照下表电机规格不同会有差异但初始值可以按这个范围起步参数初始值调节方向现象kp0.8~1.5增大则响应变快过大会震荡电机嗡嗡响、有顿挫ki0.05~0.2增大则稳态误差小过大会超调停车时冲过头kd0 或极小值电赛场景一般不用噪声放大明显控制周期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(echo0)这种写法会卡死整个控制循环。标准做法是用输入捕获或者外部中断记录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 / 1000mm合并成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_PATIENTST_TO_PATIENT检测到病房门口标志减速停车OLED显示到达ST_STOP_AT_BEDST_STOP_AT_BED停车延时3秒掉头或按原路返回ST_TO_PHARMACYST_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占空比比如PWM500让小车在地面跑1米记录左右轮编码器各自的总脉冲数。正常情况下两边应相等如果左侧脉冲数明显多于右侧说明左轮实际直径偏小或右轮打滑需要在下发目标速度时乘以一个修正系数。标定项目数据记录修正方式1米直行左轮脉冲比如 2850目标速度乘以 1.01米直行右轮脉冲比如 2790目标速度乘以 2850/2790电机最低起转PWM实测比如 130PID输出死区设为略低于该值这个系数写在配置文件里不要写死在控制函数中。每次更换轮胎或调整底盘后重新标定一次比调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秒内刹停。本文还有配套的精品资源点击获取