蓝桥杯国赛智能物料传送系统:时间片轮询与PID闭环控制实战
2026/8/28 16:50:14 网站建设 项目流程

1. 项目背景与核心挑战解析

第六届蓝桥杯单片机国赛的“智能物料传送系统”题目,可以说是我当年备赛和参赛经历中印象最深的一道题。它不像一些基础题目那样,只考察某个单一模块的驱动,而是将多个核心知识点——矩阵键盘、数码管显示、ADC/DAC、电机控制、传感器应用——有机地整合在一个接近工业应用场景的系统中。很多同学拿到题目后容易懵,感觉要控制的东西太多,代码结构容易混乱。今天,我就以一个过来人的身份,结合当年的实战经验和后续的教学总结,把这套系统的设计思路、代码架构和那些容易“翻车”的细节掰开揉碎了讲清楚。无论你是正在备赛的选手,还是想提升单片机综合项目能力的学习者,这篇文章都能给你提供一个清晰、可复现的完整解决方案。

这个系统的核心,是模拟一个简易的自动化流水线。你需要通过矩阵键盘设定物料的“目标重量”,系统通过ADC读取一个模拟电压信号来模拟“当前物料重量”,然后控制电机(用PWM模拟)进行传送,并通过数码管实时显示关键数据。整个过程涉及人机交互、数据采集、闭环控制和状态显示,是对单片机开发者综合能力的一次全面检验。

2. 系统整体架构设计与模块划分

面对一个多任务、多外设的系统,最忌讳的就是把所有代码都堆在main函数的while(1)循环里。那样不仅调试困难,而且任何一点的改动都可能引发连锁问题。一个清晰、稳定的架构是成功的一半。

我的设计采用了“时间片轮询”的软件架构,这是在没有操作系统(RTOS)的单片机(如比赛指定的CT107D开发板使用的IAP15F2K61S2)上实现多任务最经典、最可靠的方法。其核心思想是:为每个需要周期性执行的任务(如按键扫描、数码管显示、ADC读取、电机控制算法)分配一个独立的计时器(通常基于系统定时器中断),让它们在自己的时间片内独立运行,互不阻塞。

整个系统可以划分为以下几个核心模块:

  1. 人机交互模块:包括4x4矩阵键盘的扫描与键值处理,用于设定目标重量和启停控制。
  2. 数据采集模块:基于板载的PCF8591 ADC芯片,读取模拟电压,并将其转换为代表物料重量的数字量。
  3. 执行控制模块:利用PWM(通过定时器模拟)控制“电机”的启停和速度,这里电机通常用LED的亮度或蜂鸣器的声音频率来模拟。
  4. 信息显示模块:驱动8位数码管,分区域显示目标重量、当前重量、电机状态(速度档位)等信息。
  5. 核心控制算法模块:这是系统的大脑,负责处理采集到的重量数据,与目标值比较,并根据比较结果调整PWM占空比(即电机速度),形成一个简单的闭环控制。

模块间的关系是:键盘模块设定目标值给核心算法;ADC模块将当前值送给核心算法;核心算法计算后,输出PWM信号给执行模块,同时更新显示数据给数码管模块。所有模块都在时间片轮询的调度下协同工作。

注意:比赛开发板上的“电机”通常不是真正的直流电机,而是用LED或蜂鸣器来模拟。PWM输出控制LED的亮度,亮度越高代表电机转速越快。务必仔细阅读题目说明,确认模拟对象。

3. 核心模块驱动与关键代码实现

3.1 矩阵键盘扫描与长按/短按识别

开发板上的矩阵键盘是4x4的,采用行扫描法。基础的扫描代码大家都会写,但国赛级别的应用,往往需要识别“短按”和“长按”,例如短按调整步进值,长按快速增减。这里的关键在于状态机思想。

我通常定义一个key_scan()函数,在2ms或5ms的时间片中被调用。函数内部维护一个key_state状态变量和计时器。基本状态包括:KEY_IDLE(空闲)、KEY_DEBOUNCE(消抖)、KEY_PRESS(确认按下)、KEY_REPEAT(长按重复触发)。当检测到按键从按下到释放,且持续时间小于长按阈值(如1秒),则返回短按事件;如果按下时间超过阈值,则周期性地返回长按事件,用于连续增减数值。

// 按键事件枚举 typedef enum { KEY_EVENT_NONE, KEY_EVENT_SHORT, KEY_EVENT_LONG } KeyEvent; // 按键扫描函数,在时间片中调用 KeyEvent key_scan(void) { static uint8_t key_state = KEY_IDLE; static uint16_t press_timer = 0; uint8_t key_value = get_matrix_key_value(); // 获取当前物理键值 switch(key_state) { case KEY_IDLE: if(key_value != 0xFF) { // 有按键按下 key_state = KEY_DEBOUNCE; press_timer = 0; } break; case KEY_DEBOUNCE: press_timer++; if(press_timer >= DEBOUNCE_TICKS) { // 消抖时间到,如5ms*20=100ms if(key_value != 0xFF) { key_state = KEY_PRESS; // 这里可以保存按下的键值 } else { key_state = KEY_IDLE; // 抖动,回到空闲 } } break; case KEY_PRESS: if(key_value == 0xFF) { // 按键释放 key_state = KEY_IDLE; return KEY_EVENT_SHORT; // 释放时判定为短按 } else { press_timer++; if(press_timer >= LONG_PRESS_TICKS) { // 达到长按时间阈值 key_state = KEY_REPEAT; return KEY_EVENT_LONG; // 首次触发长按 } } break; case KEY_REPEAT: press_timer++; if(press_timer >= REPEAT_INTERVAL_TICKS) { // 长按重复间隔 press_timer = LONG_PRESS_TICKS; // 重置计时器,准备下一次重复 return KEY_EVENT_LONG; // 周期性返回长按事件 } if(key_value == 0xFF) { // 长按后释放 key_state = KEY_IDLE; } break; } return KEY_EVENT_NONE; }

这段代码的精髓在于将消抖、长按判断、重复触发都融合在一个非阻塞的状态机里,完全不影响系统其他任务的运行。在实际应用中,你需要根据按键的具体功能(如设定值增减、启停)来映射不同的键值和处理逻辑。

3.2 PCF8591 ADC读取与重量数据校准

PCF8591是一个I2C总线的ADC/DAC芯片。读取ADC值的标准流程是:启动I2C、发送设备地址(写)、发送控制字(选择通道和模式)、重启I2C、发送设备地址(读)、读取两个字节数据。比赛提供的底层驱动库通常有I2C_Start()I2C_SendByte()等函数。

这里最容易出问题的地方有两个。第一是时序,I2C的时钟线(SCL)和数据线(SDA)的时序必须严格按照协议,在读取之间要有足够的延时。第二是数据校准。题目中模拟电压量程(如0-5V)对应一个重量量程(如0-500g)。你需要将读取到的ADC原始值(0-255)线性映射到目标重量值。

#define ADC_REF_VOLTAGE 5.0 // 参考电压5V #define ADC_RESOLUTION 256 // 8位ADC,2^8=256 #define WEIGHT_MAX 500.0 // 最大重量500g uint16_t get_current_weight(void) { uint8_t adc_value_raw; float voltage, weight; // 使用底层驱动读取PCF8591的AIN0通道,此处为伪代码 adc_value_raw = read_pcf8591(0); // 将ADC原始值转换为电压 voltage = (adc_value_raw / (float)ADC_RESOLUTION) * ADC_REF_VOLTAGE; // 将电压线性映射为重量(假设0V对应0g,5V对应500g) weight = (voltage / ADC_REF_VOLTAGE) * WEIGHT_MAX; // 返回整数重量值,可根据需要四舍五入 return (uint16_t)(weight + 0.5); }

重要提示:实际比赛中,ADC的读数可能会有微小波动。为了显示稳定,通常需要进行软件滤波。最简单有效的方法是“滑动平均滤波”。例如,定义一个数组存储最近10次的采样值,每次计算平均值作为最终结果。这能有效消除毛刺,让数码管显示的数字不再跳动。

3.3 基于定时器的PWM模拟与电机速度控制

在标准51内核上,没有硬件PWM发生器,我们需要用定时器中断来模拟。原理是:设定一个固定的PWM周期(例如10ms),在定时器中断中维护一个计数器。定义一个变量pwm_duty表示占空比(0-100)。当计数器小于pwm_duty时,输出高电平(点亮LED);否则输出低电平。

uint8_t pwm_duty = 0; // 全局变量,占空比 0-100 uint8_t pwm_counter = 0; bit pwm_output = 0; void timer0_isr() interrupt 1 { // 假设使用定时器0,周期100us TH0 = (65536 - 100) / 256; // 重装初值,100us中断一次 TL0 = (65536 - 100) % 256; pwm_counter++; if(pwm_counter >= 100) { // 100 * 100us = 10ms, 即PWM周期为10ms pwm_counter = 0; } if(pwm_counter < pwm_duty) { pwm_output = 1; LED = 0; // 低电平点亮LED,模拟电机高速运行 } else { pwm_output = 0; LED = 1; // 高电平熄灭LED } }

那么,pwm_duty如何根据重量差来调整呢?这就引出了核心的控制算法。

3.4 增量式PID控制算法在速度调节中的应用

为了让“电机”能够平滑地将当前重量调整到目标重量,我们需要一个控制算法。对于这种简单的线性系统,一个比例(P)控制器往往就足够了,但加入积分(I)和微分(D)能更好地消除静差和抑制超调。这里我推荐实现一个“位置式PID”或“增量式PID”。增量式PID计算的是控制量的增量,更适合电机这类执行机构。

算法核心公式如下:Δu(k) = Kp * [e(k) - e(k-1)] + Ki * e(k) + Kd * [e(k) - 2*e(k-1) + e(k-2)]其中,e(k)是本次偏差(目标重量 - 当前重量),e(k-1)e(k-2)是前两次的偏差。Δu(k)就是PWM占空比pwm_duty需要增加或减少的量。

// PID参数结构体 typedef struct { float Kp, Ki, Kd; float error[3]; // 当前误差e(k),上一次误差e(k-1),上上次误差e(k-2) float integral; // 积分项(在增量式PID中可不单独维护) float output; // 输出值 } PID_TypeDef; PID_TypeDef weight_pid; void pid_init(PID_TypeDef *pid, float kp, float ki, float kd) { pid->Kp = kp; pid->Ki = ki; pid->Kd = kd; pid->error[0] = pid->error[1] = pid->error[2] = 0; pid->integral = 0; pid->output = 0; } float pid_calc(PID_TypeDef *pid, float target, float current) { float delta_u; // 更新误差序列 pid->error[2] = pid->error[1]; pid->error[1] = pid->error[0]; pid->error[0] = target - current; // 计算本次误差 // 增量式PID计算 delta_u = pid->Kp * (pid->error[0] - pid->error[1]) + pid->Ki * pid->error[0] + pid->Kd * (pid->error[0] - 2*pid->error[1] + pid->error[2]); // 对输出进行限幅,防止积分饱和或超出PWM范围 pid->output += delta_u; if(pid->output > 100.0) pid->output = 100.0; if(pid->output < 0.0) pid->output = 0.0; return pid->output; } // 在控制周期(如100ms)的时间片中调用 void control_task(void) { uint16_t current_weight, target_weight; float pwm_val; current_weight = get_filtered_weight(); // 获取滤波后的重量 target_weight = get_target_weight(); // 从全局变量获取键盘设定的目标 pwm_val = pid_calc(&weight_pid, (float)target_weight, (float)current_weight); pwm_duty = (uint8_t)pwm_val; // 更新全局PWM占空比 }

参数KpKiKd需要根据系统响应进行整定。一个简单的调试方法是:先设KiKd为0,逐渐增大Kp,直到系统出现等幅振荡,此时取Kp的0.6倍作为初步值,再微调Ki来消除静差,最后加入Kd来抑制超调。

4. 多任务调度与数码管动态显示策略

4.1 时间片轮询调度器的具体实现

上面提到了架构,这里给出一个非常简洁的实现范例。我们利用一个定时器(如定时器0)产生1ms的基准时基。在定时器中断中,对多个任务计数器进行累加。

volatile uint16_t task_timer_key = 0; volatile uint16_t task_timer_display = 0; volatile uint16_t task_timer_adc = 0; volatile uint16_t task_timer_control = 0; void timer0_isr() interrupt 1 { TH0 = (65536 - 1000) / 256; // 假设12MHz晶振,1ms中断 TL0 = (65536 - 1000) % 256; // 任务计时器累加 if(++task_timer_key >= 5) task_timer_key = 0; // 5ms到,标志置位在main中判断 if(++task_timer_display >= 2) task_timer_display = 0; // 2ms刷新数码管 if(++task_timer_adc >= 100) task_timer_adc = 0; // 100ms采样一次ADC if(++task_timer_control >= 200) task_timer_control = 0; // 200ms执行一次控制算法 }

在主循环while(1)中,我们不断检查这些“软标志”,时间到了就执行相应的任务函数,执行完清空标志(通过计时器归零实现)。这样就实现了多个任务并行运行的效果,且每个任务都不会长时间阻塞CPU。

void main() { sys_init(); // 系统初始化,包括定时器、I2C、IO口等 pid_init(&weight_pid, 1.5, 0.1, 0.05); // 初始化PID参数,需调试 while(1) { // 5ms执行一次按键扫描 if(task_timer_key == 0) { key_process(); // 此函数内部调用key_scan()并处理键值 } // 2ms执行一次数码管显示 if(task_timer_display == 0) { display_refresh(); // 数码管动态刷新函数 } // 100ms执行一次ADC采样与滤波 if(task_timer_adc == 0) { adc_sample_task(); } // 200ms执行一次控制算法 if(task_timer_control == 0) { control_task(); } // 其他任务... } }

4.2 数码管多信息分区显示与消影处理

8位数码管需要显示目标重量、当前重量、电机速度(PWM占空比)等多个信息。通常采用分区显示法,例如:

  • 第1-2位:显示目标重量(如“50”表示50g)。
  • 第3位:显示分隔符“-”或熄灭。
  • 第4-5位:显示当前重量。
  • 第6位:显示分隔符。
  • 第7-8位:显示电机速度百分比(如“80”表示80%占空比)。

动态扫描的原理是每次只点亮一位数码管,通过人眼视觉暂留形成稳定显示。代码里需要维护一个显示缓冲区数组display_buf[8],存储每一位要显示的段码。在display_refresh()函数中,依次选中一位数码管(位选),送出display_buf中对应的段码(段选),然后延时一小段时间(通常1-2ms),再切换到下一位。

这里有一个至关重要的细节:消影。在切换位选信号时,如果段码数据还没有稳定,就会在错误的数码管上产生一个短暂的错误显示,即“鬼影”。标准的消影做法是:在关闭当前位选和打开下一位位选之间,先关闭所有段码(送0xFF或0x00,取决于共阴/共阳),或者先关闭所有位选。

uint8_t display_buf[8]; // 存储8位数码管的段码 uint8_t display_index = 0; // 当前正在显示的位索引 void display_refresh(void) { // 1. 关闭所有段码(消影处理) P0 = 0xFF; // 假设段码数据口为P0,且共阴数码管(0xFF为全灭) delay_us(10); // 短暂延时,确保段码关闭 // 2. 关闭所有位选(开发板上的138译码器控制) // 根据具体硬件电路操作,例如置位选线为无效状态 P2 = (P2 & 0x1F) | 0xE0; // 假设P2高三位控制138,此操作关闭所有位选 // 3. 设置要显示的段码 P0 = display_buf[display_index]; delay_us(10); // 确保段码稳定 // 4. 打开对应的位选 P2 = (P2 & 0x1F) | (display_index << 5); // 假设位选信号在P2.5, P2.6, P2.7 // 5. 更新索引,为下一次显示做准备 display_index++; if(display_index >= 8) { display_index = 0; } }

display_buf的内容需要由其他任务更新。例如,在adc_sample_task()中,将滤波后的当前重量数值,通过查表法(或计算)转换成两位数字的段码,填入display_buf的相应位置。目标重量和PWM占空比也做类似处理。

5. 系统调试、参数整定与常见问题排查

5.1 PID参数整定实战经验

PID参数整定是让系统从“能动”到“好用”的关键。在物料传送系统这个项目中,你可以遵循以下步骤:

  1. 初始化:将KiKd设为0,Kp设为一个较小的值,比如0.5。目标重量设为一个中间值,如250g。
  2. 观察比例控制:逐渐增大Kp,你会看到“当前重量”向目标值靠近的速度变快,但可能会在目标值附近来回振荡(超调)。记录下系统开始出现持续振荡时的Kp值,记为Ku(临界增益)。
  3. 计算基准参数:采用经典的齐格勒-尼科尔斯(Ziegler-Nichols)经验公式。对于增量式PID,可以尝试:Kp = 0.6 * KuKi = 2 * Kp / TuTu是振荡周期),Kd = Kp * Tu / 8。由于我们的系统是离散的,Tu可以通过观察数码管数值变化的周期来估算。
  4. 微调:以上公式只是起点。实际微调时:
    • 系统响应太慢:适当增加Kp
    • 到达目标后存在静态误差(始终差一点):适当增加Ki
    • 超调严重,来回震荡:适当增加Kd
    • 出现高频抖动:可能是Kd太大,或者采样周期与控制周期不匹配,需要减小Kd或调整周期。

一个实用的技巧是,在数码管上留出一位来实时显示pwm_duty的值。这样你就能直观地看到控制器的输出变化,对于理解PID行为非常有帮助。

5.2 典型问题排查链路

在整合系统时,你可能会遇到以下问题,可以按照这个思路排查:

问题一:按键不灵敏或连击。

  • 排查步骤
    1. 检查硬件连接:确认矩阵键盘的行列线是否与原理图一致,上拉电阻是否正常。
    2. 检查消抖时间:将DEBOUNCE_TICKS对应的实际时间调长,比如从10ms调到20-30ms。机械按键的抖动时间可能比想象的长。
    3. 检查扫描频率:确保key_scan()函数被调用的时间间隔稳定且合适(2-10ms)。太快可能来不及处理,太慢则感觉卡顿。
    4. 检查状态机逻辑:特别是按键释放的判断逻辑,确保在KEY_PRESS状态下,只有检测到释放才返回短按事件。

问题二:数码管显示乱码、闪烁或部分不亮。

  • 排查步骤
    1. 检查段码表:确认共阴/共阳数码管的段码数据是否正确。一个快速验证方法是:写一个简单的程序,让所有数码管固定显示数字“8”,看是否正常。
    2. 检查消影代码:这是最常见的原因。严格按照“关段码 -> 关位选 -> 送新段码 -> 开新位选”的顺序,并在关和开之间加入微秒级延时。
    3. 检查动态扫描频率:display_refresh()函数被调用的总周期(8位*每位显示时间)应在10-20ms以内,即刷新率高于50Hz,否则会感到闪烁。每位显示时间1-2ms比较合适。
    4. 检查驱动电流:如果所有数码管都很暗,可能是驱动能力不足,需要检查限流电阻或考虑使用三极管放大驱动信号。

问题三:ADC读取的值不稳定,重量显示跳动。

  • 排查步骤
    1. 硬件检查:用万用表测量PCF8591的模拟输入引脚电压,看是否稳定。如果不稳定,检查前级传感器或信号源电路。
    2. 软件滤波:这是必须的。实现滑动平均滤波,窗口大小取4-16,效果立竿见影。
    3. 检查I2C时序:在read_pcf8591函数中的关键位置(如启动、停止、发送应答后)增加微秒级延时,确保满足PCF8591的最小时序要求。
    4. 电源噪声:为模拟部分(PCF8591及其输入)增加一个LC滤波电路,或者使用独立的线性稳压器供电,与数字部分电源隔离。

问题四:电机(LED)控制不线性,或者对重量变化没反应。

  • 排查步骤
    1. 验证PWM基础功能:先将pwm_duty固定为50,看LED是否半亮,蜂鸣器是否响一半时间。用示波器或逻辑分析仪测量控制引脚,看是否有标准的方波输出。
    2. 检查控制算法输入输出:在control_task中,通过串口或临时占用数码管一位,打印出current_weighttarget_weight和计算出的pwm_duty,确认逻辑正确。
    3. 检查PID输出限幅:确保pid_calc函数的输出被限制在0-100之间,否则赋值给pwm_duty(uint8_t)会导致溢出。
    4. 检查控制周期:控制周期(task_timer_control对应的周期)不能太快或太慢。太快可能导致系统来不及响应,产生振荡;太慢则响应迟钝。对于这种慢速物理系统,100ms到500ms是比较合理的范围。

5.3 提升稳定性的进阶技巧

在基本功能实现后,可以考虑以下优化,让你的系统更稳健、更专业:

  1. 数据边界保护:在设定目标重量时,通过键盘程序限制其输入范围(如0-500g),防止输入非法值导致计算异常。
  2. 系统状态机:引入一个全局系统状态,如IDLE(待机)、RUNNING(运行)、FAULT(故障)。只有RUNNING状态下才执行PID控制。通过一个独立的按键(如S7)在IDLERUNNING间切换。这更符合工业逻辑。
  3. 误差死区处理:当重量误差(目标值-当前值)的绝对值小于一个很小的阈值(如2g)时,直接将PWM输出设为0。这样可以避免系统在目标值附近因微小误差而持续微调,减少不必要的能耗和器件磨损(在真实电机中)。
  4. EEPROM参数存储:将PID参数、重量量程上限等关键数据存储在单片机的EEPROM中。这样每次上电可以读取上次设定的参数,无需重新调试。IAP15单片机内部有EEPROM,操作很方便。

6. 从比赛项目到工业思维的延伸

完成这个国赛项目,绝不仅仅是为了获奖。它为你理解真实的工业控制系统提供了一个绝佳的微型样板。在真实的物料传送、恒温控制、水位调节等场景中,核心逻辑与此高度相似:传感器采集(ADC) -> 控制器计算(PID) -> 执行器动作(PWM驱动电机/阀门) -> 状态显示与监控(HMI/数码管)。

你可以尝试做以下扩展,让这个项目更具价值:

  • 将PWM输出连接到真正的电机驱动模块:如L298N或TB6612,驱动一个小型直流电机,用编码器或霍尔传感器反馈真实转速,构成一个真正的速度闭环系统。
  • 加入上位机通信:通过开发板的串口,将目标重量、当前重量、PWM占空比、PID参数实时发送到电脑,用串口助手或自己写的上位机软件绘制实时曲线图。这能极大地帮助调试和直观理解系统动态。
  • 设计更复杂的交互逻辑:例如,增加多种工作模式(定量传送、匀速传送)、配方存储与调用、产量统计等功能。

回过头看,这个项目锻炼的远不止是代码能力。它训练的是如何将一个复杂的、模糊的需求,分解为一个个明确的模块;如何设计一个稳定、可扩展的软件架构来承载这些模块;如何调试一个动态的、相互关联的系统;以及如何用工程的思维去解决问题。这些能力,无论是在后续的学习中,还是在真正的工程岗位上,都是无比宝贵的财富。我个人的体会是,把这样一个项目做透、做精,其收获远大于零散地学习十个八个孤立的模块。当你下次再面对一个复杂的系统时,你会自然而然地想到模块划分、时间片调度、状态机、闭环控制这些概念,并且有信心去实现它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询