
简介这是一份基于STM32F103R主控读取FSR薄膜压力传感器并通过串口输出的完整Keil工程面向嵌入式入门与物联网开发者适合快速实现称重、触控或压力监测可应用于体重秤、触控面板、机器人等场景。压缩包共166个文件、2.76MB含41个头文件与37个C源码文件覆盖串口配置、FSR数据读取及定时器驱动还提供Keil工程配置、启动汇编、分散加载及编译生成的hex、axf固件便于直接烧录或二次开发。另有.o、.d、.crf等中间文件便于在Keil中查看依赖。项目已吸引2715人学习代码结构清晰主循环周期性读取FSR数字输出经校准转换为压力值后经串口发送并配有中断服务例程。借助这套资料开发者可快速掌握主控芯片与传感器配合的典型流程理解输入输出、串口外设与中断处理也能修改数据格式或接入上位机调试。1. 做压力检测项目时为什么FSR薄膜传感器要借道STM32F103R的UART手头有一片FSR薄膜压力传感器想把它接到STM32F103R上再把压力值实时送到电脑上显示曲线。最直接的做法是用一个带比较器输出的FSR_D模块引脚输出0或3.3V数字电平STM32只需要读GPIO就能判定有没有压力。FSR_D模块内部已经做好了电压比较省掉了ADC采样、电压基准、滤波电容这些外围电路对快速验证非常友好。这个工程就是围绕STM32F103R UART FSR_D这条链路展开的传感器输出数字电平MCU定时采样最后通过USART1把整理好的数据帧发给上位机。工程源码是Keil MDK的标准外设库风格工程里能看到stm32f10x_tim.c、main.uvgui等文件。对于想从裸机开始理解FSR读取、GPIO配置、UART帧封装的人来说这个工程把信号链路拆得比较清晰。下面先讲清楚FSR_D怎么接、GPIO怎么配再逐步落到UART报文和标定算法上。读完你应该能自己复现一个能稳定运行的FSR压力监测节点。2. FSR_D数字输出与STM32F103R的GPIO/ADC采集配置2.1 FSR的电阻特性和FSR_D模块的分压电路FSR传感器的核心是一个可变电阻传感器表面不受力时阻值通常在100kΩ甚至更高施加压力后阻值迅速下降到几kΩ大压力下能到几百Ω。FSR_D模块内部把这个可变电阻和一个固定电阻串联中间节点接到比较器输入端比较器另一端接一个可调阈值电压。当FSR阻值降低导致中间节点电压超过阈值比较器就输出高电平反之输出低电平。所以FSR_D的输出不是模拟电压而是0/1开关量。这种设计把模拟链路的很多麻烦都挡在了模块内部缺点是丢失了压力的连续变化信息只能知道压力有没有超过某个点。如果不满足于开关量就需要直接接FSR裸片用固定电阻分压后进STM32F103R的ADC。两种方案的选型可以按下面的表格对照对比项FSR_D数字输出FSR裸片ADC模拟采集MCU引脚需求1个GPIO输入1个ADC通道外围电路无模块内含比较器需要分压电阻RC滤波压力信息只有阈值比较结果连续压力值抗干扰强比较器整形弱受电源纹波影响校准方式调节模块电位器软件多点标定延迟约几百微秒取决于ADC采样周期在这个工程里项目标题出现FSR_D源码中也没有看到ADC相关文件推测使用的是数字输出模块读取的是GPIO电平。不过避免方案单一下面同时给出GPIO输入和ADC输入的初始化代码方便你根据手头模块切换。2.2 STM32F103R的GPIO上拉输入初始化如果使用FSR_D模块它的输出引脚一般需要上拉到VCC才能保证空闲电平确定。STM32F103R的内部上拉电阻约40kΩ可以直接复用。配置PB0作为FSR_D输入引脚。标准外设库的初始化代码如下/* 标准外设库GPIO时钟使能 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure);代码里先把GPIOB的时钟打开STM32F103R的所有外设在上电后默认时钟关闭不使能时钟直接操作寄存器会导致读到的数据恒为0。GPIO_Mode选择GPIO_Mode_IPU即上拉输入这样FSR_D输出三态或漏极开路时引脚电平被拉到高不会悬空。GPIO_Speed在这里不影响输入功能但为了保持一致填50MHz。如果你的FSR_D模块自带推挽输出可以改用GPIO_Mode_IN_FLOATING。读取这个引脚只需要一条语句uint8_t level GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0);读出的level为1代表压力超过阈值0代表低于阈值。这里没有使用EXTI外部中断原因是FSR在受力瞬间会有机械抖动电平在几个毫秒内反复跳变外部中断会触发大量无效中断把主循环拖慢。2.3 如果换成ADC采FSR模拟电压当你的FSR模块只有模拟输出时就需要使用STM32F103R的ADC1。下面代码把PA0配置为模拟输入并初始化为单次转换模式/* 使能ADC1和GPIOA时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC时钟最大14MHz, 72/612MHz GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); ADC_InitTypeDef ADC_InitStructure; ADC_StructInit(ADC_InitStructure); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; 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_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1));ADC初始化里有几个关键参数容易踩坑。RCC_ADCCLKConfig设置的是ADC时钟分频72MHz主频下必须使用Div6否则ADC时钟超过14MHz会导致采样结果非线性。模拟输入引脚不需要配置输入模式直接GPIO_Mode_AIN这是STM32F103系列特有的。连续转换模式这里关掉因为FSR压力信号变化较慢单次转换足够还能降低功耗。校准步骤是F103芯片自带的功能上电后必须执行一次否则ADC转换结果会存在固定偏差。读取一次ADC值用下面代码ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); uint16_t adc_value ADC_GetConversionValue(ADC1);ADC_SampleTime_55Cycles5表示采样时间为55.5个ADC周期加上12.5个周期的转换时间总耗时约5.7μs。FSR的信号源内阻在高压区可能接近100kΩ采样时间太短会来不及给内部采样电容充电造成测量值偏低所以这里故意选长采样时间。如果信号线上有高频噪声建议在PA0对地并联一个0.1μF电容和导线电阻组成低通滤波器截止频率约16kHz。2.4 用定时器驱动FSR_D去抖采样工程里的stm32f10x_tim.c说明原项目使用了定时器。对于FSR_D这种开关量最稳的采样方式是定时10ms读一次引脚连续读到3次相同电平才更新状态。这样能滤掉绝大部分机械抖动。下面是TIM2中断配置RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef TIM_InitStruct; TIM_InitStruct.TIM_Prescaler 7199; // 72MHz/(71991)10kHz TIM_InitStruct.TIM_CounterMode TIM_CounterMode_Up; TIM_InitStruct.TIM_Period 99; // 10kHz/(991)100Hz TIM_InitStruct.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_InitStruct); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_InitTypeDef NVIC_InitStruct; NVIC_InitStruct.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStruct.NVIC_IRQChannelSubPriority 0; NVIC_InitStruct.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStruct); TIM_Cmd(TIM2, ENABLE);然后去抖函数放在TIM2中断服务函数里但避免在中断里做复杂运算。判断逻辑如下uint8_t fsr_stable_level 0; void TIM2_IRQHandler(void) { static uint8_t temp_level 0; static uint8_t count 0; uint8_t cur_level; if (TIM_GetITStatus(TIM2, TIM_IT_Update) SET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); cur_level GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0); if (cur_level temp_level) { count; if (count 3) { fsr_stable_level cur_level; count 0; } } else { temp_level cur_level; count 1; } } }这里的关键是连续3次相同才更新状态相当于把10ms采样周期变成30ms的滤波窗口。如果现场振动强烈可以把count 3改成count 5代价是响应速度从30ms变成50ms。FSR_D模块本身已经有比较器整形波形比较干净3次足够。如果使用模拟输出这个去抖思路不适用应该对ADC值做滑动平均滤波。3. UART数据帧设计让STM32F103R发出来的数据能被可靠解析3.1 USART1的波特率配置与误差分析压力数据最终要通过UART发出去STM32F103R一共有3个USART其中USART1挂在APB2总线上最高速率可达4.5Mbps。工程中如果用的是PA9(TX)和PA10(RX)那就是USART1。配置串口时波特率误差是个不得不看的指标。USARTDIV PCLK / (16 * BaudRate)PCLK是外设时钟对USART1来说是72MHz。不同波特率下的寄存器分频值和实际误差如下表目标波特率USARTDIV实际波特率误差9600468.7596000%5760078.125576080.014%11520039.06251151920.01%9216004.88289230760.16%误差超过2%时通信才会出错所以9600到115200都是安全范围。我习惯用115200因为上位机刷新压力曲线时数据量够大且USB转串口芯片普遍支持。如果接的是NRF24L01这类无线模块建议降到57600因为无线模块内部缓冲不大高速发送容易丢帧。标准外设库的USART1初始化代码如下RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE);注意TX引脚PA9必须配置为复用推挽GPIO_Mode_AF_PPRX引脚PA10配置为浮空输入。数据格式选择8位数据、1位停止位、无校验这是最通用的配置。如果后续要接校验USART_WordLength要改为9位但那样数据负载里还得带上校验位解析起来麻烦不如使用自定义帧校验字节。3.2 自定义数据帧格式与累加和校验直接把压力值裸发出去上位机很难区分数据和噪声。我一般会定义一个固定长度的帧帧头用两个字节这样解析时可以快速对齐。帧格式设计如下字节偏移内容说明00xAA帧头110x55帧头220x04数据长度字节3到6共4字节30x01数据类型压力值4压力高字节压力值高8位5压力低字节压力值低8位6状态0无压力1有压力7SUM2到6字节累加和这里数据长度固定为4因为有效数据共有4字节。校验范围从长度字段开始不包含帧头这样即使帧头被干扰累积和也不会出现偶然巧合。发送函数实现如下void uart_send_pressure(uint16_t pressure, uint8_t state) { uint8_t frame[8]; uint8_t sum 0; uint8_t i; frame[0] 0xAA; frame[1] 0x55; frame[2] 0x04; frame[3] 0x01; frame[4] (pressure 8) 0xFF; frame[5] pressure 0xFF; frame[6] state; sum frame[2] frame[3] frame[4] frame[5] frame[6]; frame[7] sum 0xFF; for (i 0; i 8; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, frame[i]); } while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); }每发送一个字节前都要等待TXE标志变复位表示发送数据寄存器已空。最后一个字节发送完后不仅要等TXE还要等TC标志TC表示整个移位寄存器已经发完。如果不等TC就关闭串口或进入低功耗模式最后一帧数据会丢在移位寄存器里。循环发送8个字节只阻塞约0.7ms8×10/115200对10ms采样周期来说完全可以接受。3.3 用TXE中断加环形缓冲区发送多字节而不阻塞如果主循环里还要驱动OLED屏幕或者读取其他传感器阻塞0.7ms可能会造成时序抖动。这时可以把发送函数改成中断方式。思路是维护一个发送环形缓冲区主函数把字节塞进缓冲区后使能TXE中断当发送寄存器变空时串口中断服务函数自动取缓冲区下一个字节发送直到缓冲区空。缓冲区和中断服务函数如下#define TX_BUF_SIZE 64 static uint8_t tx_buf[TX_BUF_SIZE]; static volatile uint8_t tx_head 0; static volatile uint8_t tx_tail 0; void uart1_send_byte_push(uint8_t byte) { uint8_t next (tx_head 1) % TX_BUF_SIZE; if (next ! tx_tail) // 缓冲区满则丢弃 { tx_buf[tx_head] byte; tx_head next; USART_ITConfig(USART1, USART_IT_TXE, ENABLE); } } void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_TXE) ! RESET) { if (tx_head ! tx_tail) { USART_SendData(USART1, tx_buf[tx_tail]); tx_tail (tx_tail 1) % TX_BUF_SIZE; } else { USART_ITConfig(USART1, USART_IT_TXE, DISABLE); } } }这里环形缓冲区容量设为64字节每帧8字节意味着最多可以缓存8帧数据。当tx_head追上tx_tail时缓冲区满新数据直接丢弃以此防止覆盖未发送的数据。TXE中断必须在缓冲区非空时使能否则串口发送寄存器一空就立刻触发中断而缓冲区又没有数据MCU会一直空转。发送完缓冲区所有数据后关闭TXE中断保留RXNE中断用于接收上位机命令。这种结构在实际项目中比DMA方式更灵活因为DMA虽然不占CPU但需要预先知道发送长度而FSR数据帧长度固定8字节DMA也可以只不过这个工程没有DMA初始化文件。4. 压力值换算与多点标定让FSR读数变成可读的克重4.1 FSR电阻-压力特性与分段线性插值FSR的电阻值和压力不是线性关系而是近似反比例曲线。压力小时阻值变化剧烈压力大时阻值趋于平缓。如果直接用ADC采到的电压值除以什么系数去换压力误差会很大。比较常用的做法是查表加线性插值。标定时选择几个有代表性的压力点记录ADC值或者FSR_D输出的电平计数值然后建立一张分段线性表。对于模拟输出需要在传感器量程范围内取5个点比如0g、500g、1kg、2kg、5kg。由于FSR在受压力时阻值减小分压节点电压升高ADC值也会随之增加所以标定表中的ADC值应该是递增的。插值函数在两个标定点之间用直线近似标定点越多逼近真实曲线的精度越高。5个点的分段线性误差通常能控制在满量程的5%以内对大多数非称重应用是够用的。4.2 标定表结构与插值实现在工程的main.c中定义标定表typedef struct { uint16_t adc_value; uint16_t weight_gram; } cal_point_t; static const cal_point_t cal_table[5] { { 800, 0 }, { 1600, 500 }, { 2200, 1000 }, { 2800, 2000 }, { 3400, 5000 } };表里的adc_value必须严格递增如果发现某两个点不满足递增说明分压电阻选得不对或者ADC参考电压波动了。插值逻辑如下uint16_t adc_to_weight(uint16_t adc) { uint8_t i; if (adc cal_table[0].adc_value) return cal_table[0].weight_gram; for (i 0; i 4; i) { uint16_t a0 cal_table[i].adc_value; uint16_t a1 cal_table[i1].adc_value; if (adc a0 adc a1) { uint32_t w0 cal_table[i].weight_gram; uint32_t w1 cal_table[i1].weight_gram; uint32_t result w0 (uint32_t)(adc - a0) * (w1 - w0) / (a1 - a0); return (uint16_t)result; } } return cal_table[4].weight_gram; }代码里用uint32_t做乘法避免(adc - a0) * (w1 - w0)在uint16_t下溢出。比如adc差值为2000重量差值为3000乘积是6000000超出65535所以中间结果必须用32位。最后的除法用的是整数除法所以结果会向下取整不影响使用。如果被测压力超过最大标定点直接返回最大重量避免产生越界值。4.3 标定流程与现场校准命令需要注意标定表里的adc_value是和硬件的分压电阻、供电电压强相关的。换一块FSR或者换一个模块同样的压力点读到的ADC值可能差很多。正确的标定流程是传感器空载等待ADC读数稳定记录为0点值。依次放上已知重量的砝码每个重量保持3秒以上取50次采样平均值。把得到的ADC值按重量顺序填入cal_table。重新编译下载用已知重量验证线性度。如果想在现场直接标定而不重新编译可以加一个简单的串口命令解析。从USART1的RXNE中断里接收一行字符串比如CAL 1500 500含义是ADC值1500对应500g。上位机发完一行后STM32在接收完当前行的换行符时更新标定表对应位置。这种方式的缺点是标定参数只存在于内存中掉电就丢。工程里没有看到I2C或Flash写操作所以原项目应该是用固定标定表的方式。对于FSR_D模块压力值和电平之间本来就没有连续映射因此不能做插值。但可以通过统计单位时间内FSR_D高电平的持续时间来估算压力大小。常见做法是用输入捕获或定时器计算高电平脉宽再和已知压力建立脉宽-压力标定表。这个技巧在足底步态分析里很实用比单纯判断0/1多一个维度。5. 从keilkilll.bat到stm32f10x_tim.c工程整理与串口链路验证技巧5.1 工程文件里哪些能删哪些不能删下载下来的工程包里有不少奇怪文件。main.uvgui是Keil的窗口布局文件.bak是自动备份这些都不参与编译。main.axf是上一次编译的内存镜像如果你直接烧录它可能会发现代码和源码不一致因为时间戳对不上。下面是一份文件清单文件作用是否必须main.uvprojKeil工程文件必须main.uvopt工程选项配置尽量保留main.c主程序源码必须stm32f10x_tim.c定时器标准库源码必须stm32f10x.h寄存器定义头文件必须main.axf上次编译的可执行镜像可删除main.uvgui.*Keil GUI布局可删除.dep / .crf / .o编译中间产物可删除网友分享的压缩包里经常混着这些垃圾文件直接打开工程会拖慢Keil启动。5.2 keilkilll.bat清理脚本的原理与用法项目里的keilkilll.bat一般是下面这个内容echo off del /s *.o *.axf *.crf *.d *.dep *.htm *.map *.lst *.bak *.uvgui.*这个批处理递归删除当前目录下的编译中间文件。我在拿到别人分享的工程后第一件事就是双击运行它只保留源码和工程强迫自己用原始源码重新编译一遍。如果这个bat执行后工程无法打开检查是不是把main.uvproj也删掉了正常脚本不会删除.uvproj。运行完再去Keil里点Rebuild能确保工程编译链是完整的。5.3 用回环方式快速定位UART链路问题FSR数据发不出来时不要急着查传感器先确认UART是不是好的。最简单的办法是在USART1中断里做回环把上位机发过来的每一个字节立即原路返回。如果KEIL调试状态下看不到数据先看这段代码void USART1_IRQHandler(void) { uint8_t rx; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { rx USART_ReceiveData(USART1); USART_SendData(USART1, rx); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); } }用串口调试助手发送一组AA 55能收到同样的AA 55说明PA9/PA10的引脚配置和波特率没问题。这一步过了再把第3节的发送函数接上用手按压FSR观察帧头0xAA 0x55是否出现。还有一个常见坑USB转TTL模块的RX要接STM32的TXPA9TX要接STM32的RXPA10。很多人习惯性地TX接TX结果收不到数据。FSR_D模块的供电电压如果是5V而STM32F103R的GPIO耐压是5V可以直接兼容但比较器输出上拉到5V时输入到STM32引脚就是5V电平虽然F103引脚允许5V输入但建议还是保持3.3V供电避免长期高压冲击。如果按照上面的顺序检查完还是收不到数据用逻辑分析仪抓PA9引脚波形发送时能看到一帧方波。如果PA9全程低电平说明芯片没跑起来或时钟配置错误。检查stm32f10x_rcc.c里的SystemInit是否正常以及main.c开头有没有调用SystemInit函数。本文还有配套的精品资源点击获取