STM32铅笔姿态检测:MPU6050 I2C与ADC压力采样实战解析 简介一份基于C语言的铅笔姿态及笔迹检测装置设计源码源自二〇二四年陕西省大学生电子设计竞赛七校联赛C题适合电子设计竞赛参赛者、嵌入式开发者及C语言项目学习者参考。代码工程共二百九十七个文件压缩包约二十九点四八兆字节包含六十五个头文件、五十二个C源文件、四十七个CRF配置文件、四十八个目标文件以及少量汇编、图片、批处理和HTML文件覆盖声明、核心逻辑、编译配置、界面展示和自动化清理等环节目录结构规范。包内附测评结果与过程记录可帮助理解铅笔姿态捕捉、笔迹检测的完整实现与调试思路。目前已有一百五十二人学习下载对想要研究相关算法和工程架构的读者具有实际参考价值也可在此基础上重新编译和扩展功能。1. 铅笔姿态笔迹检测不是算法难而是传感器数据怎么进 STM322024 年陕西省大学生电子设计竞赛七校联赛 C 题里很多队伍把重心放在姿态解算上结果卡在第一步MPU6050 的 I2C 引脚怎么都读不出数据ADC 采到的压力值又随供电波动漂移。这套基于 C 语言的铅笔姿态及笔迹检测装置源码把“传感器→STM32→解算→输出”整条链路用标准外设库完整跑通了。源码包里 64 个头文件和 52 个 C 源文件底层是 stm32f10x_tim.c、stm32f10x_i2c.c、stm32f10x_adc.c 这类官方库上层是姿态融合和笔迹判定的应用代码你不需要从头造轮子按初始化顺序和回调逻辑改参数就行。适合正在备赛的参赛队以及想搞清楚六轴传感器和 ADC 压力采样如何在一颗 STM32F103 上协同工作的嵌入式开发者。2. STM32 外设分工I2C 采集姿态、ADC 采集笔迹的硬件链路2.1 为什么姿态检测用六轴 MPU6050而不是只读加速度计铅笔姿态检测要回答两个问题笔杆与水平面的夹角是多少笔尖有没有发生明显倾斜。只用一个三轴加速度计静止时可以算出俯仰角和横滚角但笔尖一旦快速移动加速度计测到的就不再只是重力分量角度会瞬间跳变。加一路陀螺仪之后陀螺仪负责短时间积分、跟上快速变化加速度计负责修正长期漂移两者互补。源码包里出现 stm32f10x_i2c.c 就是为了接这类六轴传感器。常见接法是 PB8/PB9 复用为 I2C1SCL/SDA 各加 4.7kΩ 上拉电阻。MPU6050 的 7 位器件地址是 0x68转成 8 位写地址是 0xD0后面所有 I2C 读写函数都以这个地址为参数。2.2 I2C 读写 MPU6050 的初始化与核心代码标准外设库把 I2C 时序封装好了参数还是要自己确认。我一般这样初始化 I2C1void BSP_I2C1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_8 | GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; // 开漏复用外部上拉 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_ClockSpeed 400000; // 400kbps 快速模式 I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_OwnAddress 0x00; // 主机模式从机地址填 0 I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }这段初始化做了三件事打开 I2C1 和 GPIOB 的时钟把 PB8/PB9 配置成开漏复用输出然后以 400kHz 的时钟频率初始化 I2C 外设。开漏模式是 I2C 协议的要求如果改成推挽输出SCL/SDA 就无法被外部上拉正确拉高这是新手最常见的“读不到数据”原因。需要特别注意的是I2C_OwnAddress设置的是 STM32 自身的从机地址主机模式下可以写 0x00不要把它和 MPU6050 的 0xD0 弄混。MPU6050 初始化可以单独抽成一个函数重点看几个控制寄存器void MPU6050_Init(void) { uint8_t temp; I2C_WriteByte(MPU6050_ADDR, 0x6B, 0x00); // PWR_MGMT_1: 解除休眠 I2C_WriteByte(MPU6050_ADDR, 0x19, 0x04); // SMPLRT_DIV: 采样率分频 I2C_WriteByte(MPU6050_ADDR, 0x1A, 0x04); // CONFIG: DLPF 约 21Hz I2C_WriteByte(MPU6050_ADDR, 0x1B, 0x18); // GYRO_CONFIG: ±2000°/s I2C_WriteByte(MPU6050_ADDR, 0x1C, 0x00); // ACCEL_CONFIG: ±2g temp I2C_ReadByte(MPU6050_ADDR, 0x75); // WHO_AM_I 应返回 0x68 if (temp ! 0x68) { // 上电后前几十毫秒可能读到 0x00需要延时重读 } }0x6B 写入 0 是解除休眠0x19 写入 4 表示内部 1kHz 时钟 5 分频采样率 200Hz0x1A 写入 0x04 对应 DLPF 21Hz 左右带宽。对于铅笔这样的慢变化对象21Hz 足够还能滤掉手部高频抖动。WHO_AM_I 校验不是必须但建议保留这条判断能帮你区分传感器没焊好和初始化时序太早。2.3 ADC 采集压力信号区分起笔和落笔笔迹检测不是识别字形而是判断“笔尖碰到纸面了没有”“这个力是不是稳定”。工程里用 stm32f10x_adc.c 实现这一路。常见做法是把笔尖后段的压力传感器输出电压接到 PA0用 ADC1 通道 0 做单次转换void BSP_ADC1_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 时钟 72/6 12MHz GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; 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 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_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }采样时间选了ADC_SampleTime_239Cycles5也就是 239.5 个 ADC 时钟周期转换速度不算快但压力信号本身是缓变的这个采样时间可以起到硬件低通的作用比软件反复平均更省 CPU。校准步骤是 STM32F103 系列使用 ADC 前的标准流程跳过的话前几次采样结果会偏大。采集时不要在中断服务函数里直接启动转换而是在主循环里用 1ms 时基标志位去读uint16_t ADC_ReadChannel(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); return ADC_GetConversionValue(ADC1); }这个函数用软件触发一次转换然后等待 EOC 标志置位读出 12 位结果。注意while等待要加超时否则 I2C 总线挂死时会连带卡住主循环。2.4 定时器 TIM 给两条链路提供统一时基姿态解算需要知道上一次数据到这一次数据的间隔 dt笔迹判断需要固定采样周期。源码里的 stm32f10x_tim.c 一般用来做 1ms 或 5ms 定时中断。我用 TIM2 产生 1ms 中断在中断里累加计数volatile uint32_t tick_ms 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); tick_ms; } }tick_ms是全局 volatile 变量中断里只做自增不在中断里启动 I2C 或 ADC。主循环里用if (tick_ms - last_ms 5)判断时间到再去读一次传感器。这样 I2C 的 5ms 采样周期、ADC 的 1ms 周期就不会被解算代码的耗时干扰。2.5 外设接口速查表外设引脚信号采样周期备注I2C1PB8/PB9SCL/SDA5msMPU60508 位地址 0xD0ADC1_IN0PA0压力电压1ms12bit采样 239.5 周期TIM2内部1ms 中断1ms提供 tick_msUSART1PA9/PA10调试输出可选打印角度和 ADC 值这张表可以直接当硬件接线图用。如果板子不是按这个引脚画的只需要改 GPIO 初始化函数注意 I2C 引脚必须带外部上拉。采姿和采压两条链路在 STM32 里是独立的但时间基准必须统一否则后面互补滤波的 dt 会乱。3. 姿态解算与笔迹起点判断互补滤波与滑动窗口3.1 加速度计算角度陀螺仪补速度先从加速度计读到的三轴值求俯仰角。围绕铅笔横轴旋转的角度可以用atan2(acc_y, acc_z)近似前提是笔杆静止或慢速移动。快速移动时这个值会混入运动加速度所以要用陀螺仪积分来“填空”。互补滤波写出来就是一个比例加积分的形式float CompFilter(float acc_angle, float gyro_rate, float dt, float alpha) { static float angle 0.0f; angle alpha * acc_angle (1.0f - alpha) * (angle gyro_rate * dt); return angle; }gyro_rate单位是弧度每秒dt单位是秒。alpha 是加速度计权重常见取值范围是 0.02 到 0.05。alpha 越小陀螺仪积分占的比重越大输出更平滑alpha 越大输出越跟手但噪声也越大。估算截止频率可以用fc alpha / (2 * pi * dt)代入 0.05 和 0.005 秒约 1.6Hz正好覆盖握笔姿势的缓慢变化。很多人拿到源码第一反应是换成四元数放在 STM32F103 上不是不行但铅笔姿态判断只需要俯仰和横滚两个角度互补滤波足够而且调参更直观。3.2 笔迹起点判断用滑动窗口而不是单点阈值压力传感器在落笔瞬间会有几毫秒的抖动如果只拿当前值与固定阈值比较会出现“笔尖还没碰到纸、数据已经超过阈值”的误判。更稳的做法是维护一个 20 点滑动窗口用窗口内的平均值去判断#define WINDOW_SIZE 20 #define PEN_DOWN_THRESHOLD 1800 #define PEN_UP_THRESHOLD 1200 static uint16_t buf[WINDOW_SIZE]; static uint8_t idx; static uint32_t sum; uint8_t PenDownDetect(uint16_t sample) { static uint8_t pen_down 0; sum - buf[idx]; buf[idx] sample; sum sample; idx (idx 1) % WINDOW_SIZE; uint16_t avg (uint16_t)(sum / WINDOW_SIZE); if (avg PEN_DOWN_THRESHOLD) pen_down 1; else if (avg PEN_UP_THRESHOLD) pen_down 0; return pen_down; }这个函数用 20 点滑动窗口维护均值落笔阈值和抬笔阈值之间留有滞回区间。sum - buf[idx]是滑出旧样本写入新样本后再重新平均。pen_down是带记忆的状态变量只要窗口均值没有越过上阈值或下阈值就保持原有判断不会在阈值附近来回抖动。调用前要保证buf全部清零可以用memset或在启动时循环置 0。3.3 关键参数表与调参顺序参数推荐值调整方向现象ACC 权重 alpha0.02~0.05增大则响应快静止时角度抖动变大MPU6050 DLPF21Hz降低到 10Hz快速抖动被滤掉延迟增加滑动窗口长度20增大更平滑落笔响应变慢落笔阈值1800按供电电压修正误触发多就调高抬笔阈值1200比落笔低 600抬笔不干脆就调低调参顺序可以从静止开始先把笔平放看计算出的角度是否接近 0 度偏差大就先补加速度计零偏再调 alpha。传感器稳定后用笔尖正常压纸从串口助手看 ADC 原始值取“不压纸”和“正常压纸”两个值的中间位置作为落笔阈值。按这个流程代码里的魔法数字就能变成有依据的工程参数。4. 源码文件布局与 Keil 中间文件清理4.1 认识源码包里的 7 类文件拿到 297 个文件先不要急着找 main.c先按扩展名分组。下面这个表格列出了最常碰到的几类扩展名数量作用是否需要提交.h64头文件声明函数和宏是.c52C 源码外设驱动和算法是.cpp48可能用于辅助逻辑或误打包视工程而定.o48编译产生的目标文件否.crf47编译器生成的浏览信息否.s8启动文件/汇编辅助是.bat3批处理用于清理和构建是.c 和 .h 是核心代码.s 文件里的 startup 启动文件决定堆栈大小和中断向量表必须保留。.o 和 .crf 是编译中间产品删掉不影响源码。stm32f10x_tim.c、stm32f10x_i2c.c 这类官方库文件不需要改动但必须被工程正确引用否则初始化函数会报未定义。4.2 在 Keil 里组织这些文件源码里外设库文件很多不是每个都要参与编译。我一般依赖 stm32f10x_conf.h 控制包含哪些外设#define _ADC #define _I2C #define _TIM #define _RCC // #define _CAN每个宏会让 stm32f10x_conf.h 去 include 对应的头文件。比如#define _I2C会包含stm32f10x_i2c.h。只保留用到的外设编译速度会快很多。如果某个外设没定义但代码里调用了它的函数编译时会出现 implicit declaration 警告回来检查这个宏。_CAN默认注释掉因为 CAN 在这个赛题里不是必须除非你的板子有收发器并且确实用到了多机通信。链接顺序在 Keil 里一般不用管它会按文件列表自动链接。需要留意的是不要把 52 个 .c 全部拖进工程那样反而会出现函数重复定义。正确做法是按启动文件、外设库、用户文件三组分开用户文件里只放 main.c、传感器驱动和算法文件。4.3 用批处理批量删除中间文件工程里那几个“批量删除.bat”解决的是同一个问题从队友或网上下载的工程包含大量 .o、.crf、.axf直接拷贝体积很大提交到版本库也会把二进制垃圾带进去。我常用的清理脚本是这样echo off rem 批量删除 Keil 编译中间文件保留源码 del /s /q *.o *.crf *.axf *.d *.dep nul 21 del /s /q Listings\*.* Objects\*.* nul 21 echo clean ok pause/s是递归处理所有子目录/q是静默删除不提示。nul 21把正常输出和错误信息都丢弃避免显示“找不到文件”的杂音。执行后工程根目录、Listings、Objects 下的中间文件都会被清掉下一次打开 Keil 会全量编译。如果源码完整全量编译就是一次可用性验证。提示执行前先备份.uvprojx和.uvoptx这两个工程配置文件不在清理规则里但误删会导致工程打不开。源码包里的 HTML 文件多数是 Keil 自动生成的工程报告不属于原始设计删掉不影响功能。如果里面有测评结果截图或过程记录先备份再清理。5. 一个容易被忽略的验证技巧用 .dbgconf 和串口实时观察姿态数据5.1 调试器里盯这三个变量比盲目改算法有用拿到这套工程后不建议一上来就改滤波参数。先打开 Keil 的 Debug 配置确认.dbgconf里选的调试器是 ST-Link 还是 J-LinkFlash 下载算法是否匹配芯片容量。然后打开 Watch 窗口添加pitch、adc_avg、pen_down三个变量。如果变量显示 not in scope说明被编译器优化了需要把定义改成volatile或者把优化等级从 -O2 降到 -O0。另一个更直接的验证方式是串口打印。把 printf 重定向到 USART1硬件上把 PA9 接到 USB 转 TTL波特率设 115200。重定向函数只需要几行#include stdio.h int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }注意 Keil 工程要勾选 MicroLIB否则 printf 会占用大量代码空间。主循环里用printf(pitch%.2f adc%d down%d\r\n, pitch, adc_avg, pen_down);输出串口助手上能看到三个数字的联动笔不动时 pitch 稳定在某个值压纸时 adc 上跳、down 变为 1。如果 down 一直为 0优先调落笔阈值如果 adc 在没压纸时也在 1800 附近飘先查压力传感器的供电是否经过 LDO而不是急着改滤波。把姿态角度和 ADC 原始值同时打到串口观察落笔瞬间谁先变化能直接判断传感器安装位置对不对正常情况下 adc 先变pitch 随后才有微小波动。如果 pitch 先跳说明笔尖还没接触纸面手部动作已经干扰了姿态传感器这时需要重新调整传感器的夹持位置。本文还有配套的精品资源点击获取