STM32智能注射泵:电机控制与脉搏监控系统详解
2026/9/15 23:16:20 网站建设 项目流程

简介:面向医疗电子与嵌入式开发者的智能注射控速系统工程资料,以STM32为主控核心,结合电机控制、脉搏监测与LCD人机交互,实现药物注射速度的精确自动调节,适合需要学习STM32外设驱动、PWM调速及传感器信号处理的人群。压缩包为RAR格式,共225个文件、5.6MB,主要包含C源码、头文件、Keil工程配置及编译中间文件,涉及stm32f10x_tim.c、stm32f10x_adc.c、stm32f10x_i2c.c等驱动实现,以及uvprojx、uvoptx、sct等工程与链接脚本,便于直接导入开发环境查看。已有116人学习下载,工程覆盖定时器PWM生成电机调速、ADC采集脉搏信号、LCD显示及异常自动停机等关键模块,可帮助读者把握一套完整嵌入式医疗设备的代码组织方式。对于正在寻找STM32电机控制与多外设综合应用参考方案的开发者,这份源码和工程结构能提供直接可读的实践范例,适合二次开发或毕业设计扩展。

1. 智能注射控速:把“电机控制”和“患者监控”压进一颗 STM32

把注射泵跑起来只需要一个 PWM 引脚,但让它安全地完成任务,至少同时盯住三件事:电机转速、堵管状态、患者脉搏。这个基于 BH-F103 的智能注射控速系统,把 STM32 监控、电机调速和脉搏检测整合在一个 MCU 里。如果你正在准备 STM32 毕业设计,或者评估低成本输液设备的控制架构,这套方案的定时器 PWM、ADC 信号链、OLED 菜单和闭环调参逻辑都能直接移植。

工程文件里的 stm32f10x_tim.c、stm32f10x_adc.c、stm32f10x_i2c.c 正好对应这三条主线,下面按实际硬件顺序拆开每个模块。

2. 电机调速控制:PWM 生成、驱动芯片和调参思路

很多 STM32 电机驱动教程会把 PWM 初始化当成全部,但实际调试时你会发现,代码里一半错误都在驱动芯片接线和占空比换算上。本工程用定时器输出 PWM,再经过驱动芯片放大电流,最终控制注射泵电机。工程文件里的 stm32f10x_tim.c 说明使用的是标准外设库的定时器接口;驱动芯片方面,TB6612 和 AT8236 是两种最常见的方案。

2.1 电机类型和驱动芯片选型

从文件清单无法直接看到电机型号,但 STM32F103 搭配 stm32f10x_tim.c,最有可能是带霍尔编码器的有刷直流电机。用有刷电机的好处是控制简单,PWM 占空比直接对应平均电压,低速段配合编码器就可以做闭环;步进电机虽然精度高,但注射泵长时间低速运行容易失步和发热。如果选型时偏向两相四线步进电机,A4988 或 DRV8825 需要用脉冲/方向信号,而不是连续 PWM。

电机类型驱动芯片控制信号精度注意点
有刷直流TB6612 / AT82361路PWM + 2路方向需编码器或霍尔测速
步进电机A4988 / DRV8825脉冲 + 方向低速会共振
无刷电机三相驱动三相反电动势控制复杂度高

TB6612 的峰值电流约 1.2A,适合小型注射泵;AT8236 峰值电流有余量,且内置过流保护,适合后续升级。在需要精确控制药物注入速度的场景里,优先选择有刷直流电机加编码器,因为它的换相死区小,PWM 调速的可重复性比步进电机更容易保证。

2.2 TIM2 输出 PWM:预分频、自动重载和比较值

先给出一版标准外设库的 TIM2 初始化代码,输出脚是 PA0,对应 TIM2_CH1。如果使用 STM32CubeMX 生成 HAL 版本,第一步同样是选择定时器时钟源,然后配置预分频和自动重载值。

// 使用硬件定时器 TIM2 的 CH1 产生 PWM,PA0 输出 void Motor_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // PA0 映射到 TIM2_CH1 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // PWM 频率 = 1MHz / 1000 = 1kHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; // 默认 50% 占空比 TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); }

预分频设置为 72 减 1,把 72MHz 系统时钟降到 1MHz 计数时钟;自动重载值设为 1000 减 1,得到 1kHz 的 PWM 输出。1kHz 对常见的有刷直流注射泵已经够用,人耳能听到一点噪音,如果觉得噪音大,可以把TIM_Period改为 100 减 1 得到 10kHz,但占空比分辨率会从 0.1% 降到 1%。PWM 频率越高,电机电流纹波越小,同时驱动芯片的开关损耗增加,所以 10kHz 是多数电机驱动的折中点。代码里的TIM_Pulse = 500只是让上电时输出 50% 占空比,实际调速时应通过TIM_SetCompare1更新比较值,不要直接修改结构体。

目标频率预分频自动重载值占空比分辨率适用场景
1kHz72-11000-10.1%低速泵、教学演示
10kHz72-1100-11%通用有刷电机
20kHz36-1100-11%低噪声设备

2.3 方向控制和占空比换算

定时器只负责占空比,电机转向由 H 桥的 IN1/IN2 决定。下面的Motor_SetSpeed把 0 到 100 的百分比换算成比较值,同时切换方向。需要特别注意,比较值使用 999 作为分母,因为定时器计数值是从 0 到 Period 的。

#define MOTOR_FORWARD 0u #define MOTOR_BACKWARD 1u void Motor_SetSpeed(uint8_t percent, uint8_t dir) { uint16_t compare; if (percent > 100) percent = 100; compare = (uint16_t)((uint32_t)percent * 999u / 100u); TIM_SetCompare1(TIM2, compare); if (dir == MOTOR_FORWARD) { GPIO_SetBits(GPIOB, GPIO_Pin_12); GPIO_ResetBits(GPIOB, GPIO_Pin_13); } else { GPIO_ResetBits(GPIOB, GPIO_Pin_12); GPIO_SetBits(GPIOB, GPIO_Pin_13); } }

percent * 999 / 100percent * 1000 / 100更稳妥,因为比较值等于 Period+1 时占空比会全开但波形可能翻转。GPIOB 的 12、13 脚接到 TB6612 或 AT8236 的 IN1、IN2,两个引脚同高或同低会进入刹车状态,实际接线时应避免在运行时出现。这一步也是搜索 stm32 电机驱动原理图时常遇到的问题:驱动芯片的 STBY 引脚必须拉高,否则 PWM 输入全被忽略。AT8236 的 VM 电源引脚和电机电源要分开走线,否则电机启动电流会把 MCU 供电拉低,ADC 采集会出现周期性台阶。

3. 脉搏实时监控与 ADC 信号链:滤波和峰值检测

电机控制是执行层,监控层要回答一个问题:患者状态是否正常。大多数脉搏模块输出的是模拟电压,信号幅度在 1V 左右,叠加了体动噪声和 50Hz 工频干扰。STM32F103 的 12 位 ADC 采样这个电压后,程序必须完成数字滤波、脉搏间隔识别和异常判定。工程文件里的 stm32f10x_adc.c 说明 ADC 由标准外设库直接配置,下面给出可用的完整链路。

3.1 ADC 引脚分配和初始化

传感器引脚STM32 引脚说明
VCC3.3V 或 5V按模块电平要求选择
GNDGND必须共地
OUTPA1 / ADC1_IN1输出 0 到 3.3V 模拟电压
数字版 SCL/SDAPB6/PB7MAX30100 这类 I2C 传感器使用

这里先按模拟输出版本接线。PA1 配置为模拟输入,ADC1 时钟由 APB2 分频得到 12MHz,这是 STM32F103 的 ADC 最大时钟。采样时间选择 239.5 个周期,对高频阻抗较大的脉搏传感器更稳,代价是单次转换大约 20us,完全不影响 100Hz 采样。

void ADC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC 时钟 = 72MHz / 6 = 12MHz GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, DISABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); } uint16_t Read_ADC(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); }

连续转换模式开启后,每次调用软件触发生成一次转换,可以避免频繁配置通道。校准步骤必须保留,否则 ADC 存在几十 LSB 的零偏,直接影响后面峰值检测的斜率阈值。如果使用 DMA 连续采样,代码会改为ADC_DMACmd(ADC1, ENABLE),并在 DMA 中断里搬数据,但单通道场景下轮询读取已经足够。

3.2 滑动平均滤波和脉搏峰值判定

ADC 原始数据在 200 到 1800 之间波动,人的脉搏波形峰值出现在心脏收缩期,在曲线上是一个快速上升沿。先用 16 点滑动平均把高频毛刺压掉,再用差分斜率识别上升沿。

#define FILTER_N 16 uint16_t ring[FILTER_N] = {0}; uint8_t ring_idx = 0; uint32_t ring_sum = 0; uint16_t MovingAverage_Filter(uint16_t raw) { ring_sum -= ring[ring_idx]; ring[ring_idx] = raw; ring_sum += raw; ring_idx = (ring_idx + 1) % FILTER_N; return (uint16_t)(ring_sum / FILTER_N); }

窗口长度 16 在 100Hz 采样率下对应 160ms 的时间常数,不会把 1Hz 的脉搏主瓣滤掉,但能压掉大部分 50Hz 工频。如果采样率改成 1000Hz,窗口要相应改成 160 个点,保持同样的截止频率。滑动平均的相位延迟大约 80ms,对脉搏间隔计算的影响可以忽略;不要用超过 10 阶的 FIR 滤波器,在 Cortex-M3 上是浪费算力。

// 峰值检测:滤波值快速上升且超过低阈值时触发一次 #define PEAK_SLOPE 8 int16_t GetPulseBPM(int16_t filtered) { static int16_t last_sample = 0; static uint32_t last_ms = 0; static uint16_t last_bpm = 0; if (filtered > 60 && filtered > last_sample && (filtered - last_sample) > PEAK_SLOPE) { uint32_t now = GetSysMs(); uint32_t interval = now - last_ms; if (interval >= 300 && interval <= 2000) { last_bpm = 60000 / interval; } last_ms = now; } last_sample = filtered; return last_bpm; }

last_ms在上电后第一次使用时应该是最近一次有效脉搏的时间,可以用一个全局标志跳过首次触发。PEAK_SLOPE设为 8,表示 16 位 ADC 每次采样的电压上升速度必须超过约 0.4mV,低于这个值视为噪声。基线漂移明显时,还可以加一个条件:滤波值超过当前基线加 20 才触发,避免手腕晃动导致误检。这样得到的 BPM 已经是稳定的心率值,下一步进入监控逻辑。

3.3 异常联锁:过速、过缓和无脉搏

得到 BPM 之后,监控逻辑很简单:高于 120 或低于 45 都触发报警,连续 3 秒没有新峰值也触发。报警动作不应该放在显示层,而是在主循环里每 50ms 检查一次标志位。

#define HR_HIGH_LIMIT 120u #define HR_LOW_LIMIT 45u uint8_t HeartMonitor_Check(uint16_t bpm, uint32_t last_beat_ms) { uint32_t now = GetSysMs(); if (bpm == 0) return 0; // 未识别到有效脉搏 if (bpm > HR_HIGH_LIMIT) return 2; // 过速 if (bpm < HR_LOW_LIMIT) return 3; // 过缓 if (now - last_beat_ms > 3000u) return 4; // 信号丢失 return 1; }

返回 2、3、4 后程序会调用Motor_Stop(),或者按策略降速。在这个医疗场景里,我通常把速度降到设定值的 20% 而不是直接停机,这样药物不会完全中断,护理人员有足够时间处理;如果是镇静类药物,直接停机更安全。这个可配置阈值最后放进 Flash,第 5 章会说到。

4. OLED 显示与按键交互:I2C 驱动和刷新策略

注射泵的人机界面不需要花哨,但刷新必须及时,操作不能阻塞主循环。工程里的 fonts.c 提供了 ASCII 和汉字点阵,stm32f10x_i2c.c 则对应 I2C 总线。与其用 1602 并口占用一堆 GPIO,0.96 寸 I2C OLED 只要两根信号线,可以直接挂到现有 PWM 和 ADC 外设旁。

4.1 用软件 I2C 驱动 SSD1306

STM32F103 的硬件 I2C 在 fast mode 下偶尔出现仲裁丢失,很多老工程师干脆用 GPIO 模拟。软件 I2C 的延时只要控制在 1us 左右,SSD1306 最大支持 400kHz,实际跑下来很稳定。关键是 SCL 和 SDA 要配置为开漏输出,并接 4.7k 上拉电阻到 3.3V。

OLED 引脚STM32 引脚说明
GNDGND共地
VCC3.3V部分模块有 5V 版本
SCLPB6开漏输出,外接 4.7k 上拉
SDAPB7开漏输出,外接 4.7k 上拉
// 假定 SCL 和 SDA 已经配置为开漏输出,SCL(x)/SDA(x) 是 GPIO 位操作宏 void SoftI2C_Start(void) { SCL(1); SDA(1); delay_1us(); SDA(0); delay_1us(); SCL(0); } void SoftI2C_Stop(void) { SCL(0); SDA(0); delay_1us(); SCL(1); delay_1us(); SDA(1); } void SSD1306_WriteCmd(uint8_t cmd) { SoftI2C_Start(); SoftI2C_SendByte(0x3C << 1); // 7 位地址 0x3C,写位为 0 SoftI2C_SendByte(0x00); // 命令模式 SoftI2C_SendByte(cmd); SoftI2C_Stop(); }

0x3C 是 SSD1306 常见 7 位地址,左移一位变成 0x78 写入。第二个字节 0x00 表示后续是命令,0x40 表示数据。如果你的 OLED 模块地址是 0x3D,把这一行改成0x3D << 1。软件 I2C 的delay_1us在 72MHz 下用 8 到 10 个空操作实现即可,不要调用 HAL_Delay,否则刷新一屏会耗掉几毫秒,电机控制中断就超时了。

4.2 显示任务:只在数值变化时写入

OLED 写一次数据虽然只有几十微秒,但整屏刷新几百字节后主循环会被拖住。我把显示拆成四个固定行:BPM、设定速度、实时速度、报警状态。每一行单独更新,只在数值变化时写入。

void Display_Task(uint16_t bpm, uint16_t set_speed, uint16_t real_speed, uint8_t alert) { static uint16_t last_bpm = 0; static uint8_t last_alert = 0xFF; if (bpm != last_bpm) { SSD1306_SetCursor(24, 0); SSD1306_ShowNumber(bpm); last_bpm = bpm; } if (alert != last_alert) { SSD1306_SetCursor(24, 3); SSD1306_ShowString(alert ? "ALERT" : "OK"); last_alert = alert; } // real_speed 变化频繁,可以用时间片每 500ms 刷新一次 }

SSD1306_SetCursor 的第一个参数是起始列,第二个参数是页地址,页高度由字体决定。这里的局部刷新会把 I2C 数据量从全屏 1024 字节降到每次约 10 字节,在 100Hz 采样下几乎不影响 ADC 和 PWM 中断。实时速度不必每次变化都写,用每 500ms 刷一次的方式反而更容易读数。

4.3 按键扫描:用边沿触发代替 delay 消抖

主循环不能为了消抖暂停几毫秒,否则电机控制会掉速。常见做法是在定时器中断里每 5ms 调用一次 Key_Scan 函数,只上报边沿事件。

uint8_t Key_Scan(void) { static uint8_t last = 0; uint8_t now = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0); uint8_t ret = 0; if (now != last) { if (now) ret = 1; // 上升沿表示按下 last = now; } return ret; }

Key_Scan 在 5ms 定时中断里执行,天然完成消抖;如果在主循环调用,需要增加一个时间戳判断。返回 1 后切换菜单项还是调整设定值,由应用层状态机决定。这里不直接操作电机,只是把事件写到全局变量,Motor_Control 任务再消费它,保证 I2C、按键和 PWM 不会互相阻塞。

5. 闭环调参:增量式 PID 和掉电保存技巧

开环 PWM 在空载和管路过长时转速差很远,注射泵真正上线必须闭环。常见做法是在电机尾部装霍尔编码器,通过 TIM3 的编码器接口读实际转速。下面给出一版在 STM32 上跑得顺的增量式 PID 参数,以及如何把设定值存到 Flash 后掉电不丢。

5.1 增量式 PID 的实现

位置式 PID 输出绝对值,一旦积分项饱和,电机要很久才能恢复。增量式 PID 的返回值是每次的 PWM 修正量,直接加到当前比较值上,天然防饱和。关键是要让每次调用周期固定,用定时中断触发 PID 计算。

typedef struct { float kp; float ki; float kd; float prev_err; float prev2_err; } IncPID; float IncPID_Update(IncPID *pid, float ref, float feedback) { float err = ref - feedback; float delta = pid->kp * (err - pid->prev_err) + pid->ki * err + pid->kd * (err - 2.0f * pid->prev_err + pid->prev2_err); pid->prev2_err = pid->prev_err; pid->prev_err = err; return delta; }

调用频率建议固定在 100Hz,这样 kp、ki、kd 的物理意义和采样周期绑定。空载启动时先用 kp=2.0、ki=0.5、kd=0.0,如果电机一阵一阵波动,再逐步加 kd 抑制超调。每次调用后必须对 PWM 比较值限幅,我一般限制在 50 到 999,小于 50 直接给 0,因为占空比太低电机根本转不动。

5.2 把设定值存进最后一页 Flash

项目文件里有 stm32f10x_flash.c,正好用于保存注射速度和报警阈值。STM32F103 的一页是 1KB,擦除前必须保证写入内容完整,否则掉电写一半会把页毁掉。下面代码把两个 16 位参数封装成半字,放在 0x0800FC00 附近。

#define CFG_ADDR 0x0800FC00 void Flash_SaveConfig(uint16_t speed, uint16_t alert_en) { FLASH_Unlock(); FLASH_ErasePage(CFG_ADDR); FLASH_ProgramHalfWord(CFG_ADDR, speed); FLASH_ProgramHalfWord(CFG_ADDR + 2, alert_en); FLASH_ProgramHalfWord(CFG_ADDR + 4, (uint16_t)(speed ^ alert_en ^ 0xA5A5)); FLASH_Lock(); }

CRC 字段用异或值代替真实 CRC 校验,读取时重新计算并与存储值比较,不一致就恢复默认。写 Flash 之前要确保GetSysMs没有在中断里调用太多 Flash 操作,否则会出现总线等待。工程里如果菜单想修改参数,先把旧值读出来,再进行擦写,避免用户在中途断电把旧参数也丢光。

调参时有一个容易忽略的细节:注射泵的管路阻力会随液面高度变化。如果只按空载调好的 PID,接上患者后实际流量会偏低。我会用注射器柱塞位移计算累计流量,与设定值比较,当误差超过 5% 时把目标速度乘一个 1.05 的系数补偿;更直接的方法是在模拟器中给负载加一个弹簧阻尼,观察 PWM 波形是否接近饱和。如果调了很久仍然震荡,优先检查编码器反馈信号是不是和电机驱动共地,而不是继续改 PID。

本文还有配套的精品资源,点击获取

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

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

立即咨询