简介:一份基于C语言的铅笔姿态及笔迹检测装置设计源码,源自二〇二四年陕西省大学生电子设计竞赛七校联赛C题,适合电子设计竞赛参赛者、嵌入式开发者及C语言项目学习者参考。代码工程共二百九十七个文件,压缩包约二十九点四八兆字节,包含六十五个头文件、五十二个C源文件、四十七个CRF配置文件、四十八个目标文件,以及少量汇编、图片、批处理和HTML文件,覆盖声明、核心逻辑、编译配置、界面展示和自动化清理等环节,目录结构规范。包内附测评结果与过程记录,可帮助理解铅笔姿态捕捉、笔迹检测的完整实现与调试思路。目前已有一百五十二人学习下载,对想要研究相关算法和工程架构的读者具有实际参考价值,也可在此基础上重新编译和扩展功能。
1. 铅笔姿态笔迹检测不是算法难,而是传感器数据怎么进 STM32
2024 年陕西省大学生电子设计竞赛七校联赛 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 复用为 I2C1,SCL/SDA 各加 4.7kΩ 上拉电阻。MPU6050 的 7 位器件地址是 0x68,转成 8 位写地址是 0xD0,后面所有 I2C 读写函数都以这个地址为参数。
2.2 I2C 读写 MPU6050 的初始化与核心代码
标准外设库把 I2C 时序封装好了,参数还是要自己确认。我一般这样初始化 I2C1:
void 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 分频,采样率 200Hz;0x1A 写入 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 外设接口速查表
| 外设 | 引脚 | 信号 | 采样周期 | 备注 |
|---|---|---|---|---|
| I2C1 | PB8/PB9 | SCL/SDA | 5ms | MPU6050,8 位地址 0xD0 |
| ADC1_IN0 | PA0 | 压力电压 | 1ms | 12bit,采样 239.5 周期 |
| TIM2 | 内部 | 1ms 中断 | 1ms | 提供 tick_ms |
| USART1 | PA9/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 权重 alpha | 0.02~0.05 | 增大则响应快 | 静止时角度抖动变大 |
| MPU6050 DLPF | 21Hz | 降低到 10Hz | 快速抖动被滤掉,延迟增加 |
| 滑动窗口长度 | 20 | 增大更平滑 | 落笔响应变慢 |
| 落笔阈值 | 1800 | 按供电电压修正 | 误触发多就调高 |
| 抬笔阈值 | 1200 | 比落笔低 600 | 抬笔不干脆就调低 |
调参顺序可以从静止开始:先把笔平放,看计算出的角度是否接近 0 度,偏差大就先补加速度计零偏,再调 alpha。传感器稳定后,用笔尖正常压纸,从串口助手看 ADC 原始值,取“不压纸”和“正常压纸”两个值的中间位置作为落笔阈值。按这个流程,代码里的魔法数字就能变成有依据的工程参数。
4. 源码文件布局与 Keil 中间文件清理
4.1 认识源码包里的 7 类文件
拿到 297 个文件先不要急着找 main.c,先按扩展名分组。下面这个表格列出了最常碰到的几类:
| 扩展名 | 数量 | 作用 | 是否需要提交 |
|---|---|---|---|
| .h | 64 | 头文件,声明函数和宏 | 是 |
| .c | 52 | C 源码,外设驱动和算法 | 是 |
| .cpp | 48 | 可能用于辅助逻辑或误打包 | 视工程而定 |
| .o | 48 | 编译产生的目标文件 | 否 |
| .crf | 47 | 编译器生成的浏览信息 | 否 |
| .s | 8 | 启动文件/汇编辅助 | 是 |
| .bat | 3 | 批处理,用于清理和构建 | 是 |
.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 2>&1 del /s /q Listings\*.* Objects\*.* >nul 2>&1 echo clean ok pause/s是递归处理所有子目录,/q是静默删除不提示。>nul 2>&1把正常输出和错误信息都丢弃,避免显示“找不到文件”的杂音。执行后工程根目录、Listings、Objects 下的中间文件都会被清掉,下一次打开 Keil 会全量编译。如果源码完整,全量编译就是一次可用性验证。
提示:执行前先备份
.uvprojx和.uvoptx,这两个工程配置文件不在清理规则里,但误删会导致工程打不开。
源码包里的 HTML 文件多数是 Keil 自动生成的工程报告,不属于原始设计,删掉不影响功能。如果里面有测评结果截图或过程记录,先备份再清理。
5. 一个容易被忽略的验证技巧:用 .dbgconf 和串口实时观察姿态数据
5.1 调试器里盯这三个变量,比盲目改算法有用
拿到这套工程后,不建议一上来就改滤波参数。先打开 Keil 的 Debug 配置,确认.dbgconf里选的调试器是 ST-Link 还是 J-Link,Flash 下载算法是否匹配芯片容量。然后打开 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 先跳,说明笔尖还没接触纸面,手部动作已经干扰了姿态传感器,这时需要重新调整传感器的夹持位置。
本文还有配套的精品资源,点击获取