基于C语言的铅笔姿态与笔迹检测装置设计与实现
2026/9/10 15:05:55 网站建设 项目流程

简介:2024年陕西省大学生电子设计竞赛七校联赛C题完整源码,面向电子设计竞赛参赛者与嵌入式C语言进阶学习者。项目以铅笔姿态检测与笔迹识别为核心,包含完整工程文件、驱动代码与算法实现,并附有测评结果和过程记录。压缩包共297个文件,约29.48MB,主要由头文件、C/C++源文件、目标文件、汇编文件和CRF配置等构成,另有链接脚本、烧录文件等,展示了规范的模块化工程布局。已有152人浏览学习。借助这套源码,读者可快速理解基于STM32系列的中断、定时器、ADC、I2C等外设应用,掌握铅笔姿态采集、笔迹路径分析及结果展示的完整思路;配套的批量处理脚本和调试配置,也能为二次开发与调试提供直接参考。

1. 基于C语言的铅笔姿态及笔迹检测装置在解决什么问题

把一颗六轴传感器塞进笔杆,写下一个汉字,计算机屏幕上同时出现笔尖轨迹和“握笔前倾角偏大”的提示——这是校际联赛电子设计赛道里一类常见的作品形态。标题里的“铅笔姿态及笔迹检测装置”,本质上是一个嵌入式数据采集与处理系统:传感器负责捕捉运动,C语言负责把原始数据变成有意义的姿态角和轨迹信息,源码则是一整套可直接编译运行的固件工程。

这类项目的难点不在硬件连接,而在C语言层的数据组织。传感器原始数据混着噪声,姿态解算要同时处理陀螺仪漂移和加速度计抖动,笔迹重建还要面对积分漂移。联赛现场往往只有几分钟调试时间,参数怎么调、阈值怎么设、数据怎么看,直接决定作品能不能稳定演示。这篇文章按照“传感器驱动 → 数据组织 → 笔迹检测 → 现场调试”的顺序,把一套可行的C语言实现方案完整展开。适合正在备赛的学生,也适合想把MPU6050姿态数据快速用起来的嵌入式开发者。

2. 铅笔姿态数据从哪来:传感器选型与C语言驱动

2.1 为什么用六轴而不是单加速度计

铅笔姿态检测最核心的参数是俯仰角(笔尖抬起角度)和滚转角(翻腕程度)。只用一个三轴加速度计,静止时可以通过重力分量解算姿态角,但书写动作一旦发生,加速度计输出的是运动加速度与重力加速度的叠加,直接套atan2解算角度会剧烈跳动。陀螺仪能测瞬时角速度,但积分会产生零偏漂移。两路数据互补,这就是六轴方案(加速度计+陀螺仪)成为主流的原因。

MPU6050 是这一类设计中应用最广的传感器。它具备I2C和SPI两种接口,寄存器地址固定,C语言驱动可以做到平台无关地移植到不同单片机。对联赛项目来说,MPU6050的例程和资料积累足够多,出问题容易排查,这是选型时比性能参数更实际的考量。

2.1.1 用C语言指针直接操作寄存器读取

驱动 MPU6050 的第一步是完成I2C寄存器读写。MPU6050 从机地址固定为0x68,数据寄存器从0x3B开始连续存放14字节:加速度六字节、温度两字节、陀螺仪六字节。一次连续读操作就能拿到全部数据。下面是一份基于 STM32 HAL 库的读取实现,特意保留了指针对地址运算的处理方式,供参考:

#include "stm32f1xx_hal.h" #include <math.h> #include <string.h> extern I2C_HandleTypeDef hi2c1; #define MPU6050_ADDR (0x68 << 1) #define MPU6050_PWR_MGMT 0x6B #define MPU6050_ACCEL_X 0x3B void MPU6050_Init(void) { uint8_t reg = 0x00; // 退出睡眠模式,设备进入正常工作状态 uint8_t cmd[2] = {MPU6050_PWR_MGMT, reg}; HAL_I2C_Master_Transmit(&hi2c1, MPU6050_ADDR, cmd, 2, 100); // 加速度计量程设为±2g,陀螺仪量程设为±500°/s cmd[0] = 0x1C; cmd[1] = 0x00; HAL_I2C_Master_Transmit(&hi2c1, MPU6050_ADDR, cmd, 2, 100); cmd[0] = 0x1B; cmd[1] = 0x08; HAL_I2C_Master_Transmit(&hi2c1, MPU6050_ADDR, cmd, 2, 100); } void MPU6050_ReadAll(float *acc, float *gyro) { uint8_t raw[14]; // 从0x3B寄存器开始连续读取14字节 HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, MPU6050_ACCEL_X, 1, raw, 14, 100); int16_t *p = (int16_t *)raw; // MPU6050输出大端序,做字节交换后按量程换算 for (int i = 0; i < 3; i++) { acc[i] = (float)((p[i] >> 8) | (p[i] << 8)) * 2.0f / 32768.0f; } for (int i = 0; i < 3; i++) { gyro[i] = (float)((p[i + 4] >> 8) | (p[i + 4] << 8)) * 500.0f / 32768.0f; } }

代码里int16_t *p直接映射原始字节数组,(p[i] >> 8) | (p[i] << 8)完成大端到小端的转换。换算系数由量程决定:±2g 量程下 1 LSB 对应 2/32768 g,±500°/s 下 1 LSB 对应 500/32768 °/s。改成 ±4g 量程时,把换算系数换成4.0f / 32768.0f即可,寄存器配置也要同步修改。把量程定义成宏放在头文件顶部,是比赛现场快速调整的常用做法。

2.1.2 原始数据为什么必须滤波

直接读取的加速度数据混有书写振动和机械噪声。静止放置时,原始数据的波动就能达到 ±0.15g,这个量级会直接污染姿态角和后续积分。滑动平均滤波能有效压低高频毛刺,但会引入相位延迟。对100Hz采样率下的书写场景,8点滑动平均引入约40ms延迟,主观感受几乎无感。具体实现用环形缓冲区,计算复杂度为O(1):

#define FLT_WIN 8 typedef struct { float buf[3][FLT_WIN]; int idx; float sum[3]; } Vec3Filter; void Filter_Init(Vec3Filter *f) { memset(f, 0, sizeof(Vec3Filter)); } void Filter_Push(Vec3Filter *f, float *input, float *output) { for (int i = 0; i < 3; i++) { int old = f->idx; f->sum[i] -= f->buf[i][old]; f->buf[i][old] = input[i]; f->sum[i] += input[i]; output[i] = f->sum[i] / FLT_WIN; } f->idx = (f->idx + 1) % FLT_WIN; }

环形缓冲区维护一个累加和,每推入一个新数据就把最旧的减掉。结构体Vec3Filter把缓冲区、索引和累加和捆在一起,用指针传入滤波函数,一来避免全局变量散布,二来在工程里增加第二路传感器时可以直接复制实例。这里体现的是一个C语言基本功:结构体管理状态,指针传递对象,是嵌入式源码里最常见的组织方式。

2.2 姿态角推导的C语言实现:互补滤波是关键

拿到干净的加速度和角速度数据后,需要计算俯仰角和滚转角。加速度计静态解算公式为:俯仰角atan2(-ax, sqrt(ay*ay + az*az)),滚转角atan2(ay, az)。这个算法静止时精度高,动态时抖动大。陀螺仪积分则相反,高频动态响应跟手,但零偏会让角度随时间漂移。

互补滤波的思路是:用加速度计参考值纠正陀螺仪积分的长期漂移,同时保留陀螺仪的动态响应。它比卡尔曼滤波实现简单得多,在 STM32F103 这类主频72MHz的芯片上可以放到200Hz中断里跑。

#define TAU 0.6f float g_pitch = 0.0f, g_roll = 0.0f; void ComplementaryFilter(float ax, float ay, float az, float gx, float gy, float dt) { // 加速度计解算姿态参考值 float acc_pitch = atan2f(-ax, sqrtf(ay * ay + az * az)) * 57.29578f; float acc_roll = atan2f(ay, az) * 57.29578f; // 用比例项消除陀螺仪积分漂移 gx += (180.0f / 3.14159265f) * dt * (acc_pitch - g_pitch) / TAU; gy += (180.0f / 3.14159265f) * dt * (acc_roll - g_roll) / TAU; g_pitch += gx * dt; g_roll += gy * dt; }

TAU是滤波时间常数,表示系统对加速度计参考值的信任程度。TAU大时更信任陀螺仪,角度平滑但跟随慢;TAU小时响应快但噪声大。针对书写动作,我一般从0.6开始调,然后让笔保持静止观察角度漂移,再快速晃动观察跟随性,找到一个两边都能接受的平衡值。这个参数在不同笔杆长度、不同书写力度下可能需要重新整定。

2.2.1 多组滤波参数的内存管理

当工程从“能算角度”走向“稳定演示”,滤波参数往往需要维护多份副本。TAU、静态判定阈值、量程系数这些参数,如果全部散落成宏定义,现场调参就要反复重新编译。更合理的做法是把参数封装进结构体:

typedef struct { float tau; float static_acc_th; float static_gyro_th; float filter_win; } PenConfig;

PenConfig的实例放在全局区,初始化时从默认值加载,运行时通过串口指令修改,掉电前存入Flash。这样调参只改内存,不重新烧录。这也是C语言内存管理在嵌入式里的典型应用:静态分配、结构体聚合、避免碎片化堆操作。

3. 采样、时间戳与串口协议:笔迹数据怎么组织

3.1 定频采样的C语言实施

姿态和笔迹检测依赖稳定的采样频率。常见的做法是在 SysTick 中断里做传感器读取和滤波,中断频率设为200Hz。主循环只负责消费数据,包括姿态解算、特征提取和串口发送。把重活放主循环,中断里只做数据搬运,可以避免 I2C 阻塞式读取占用过多中断时间。

volatile uint8_t g_data_ready = 0; static float g_acc_raw[3], g_gyro_raw[3]; static float g_acc_f[3], g_gyro_f[3]; void SysTick_Handler(void) { MPU6050_ReadAll(g_acc_raw, g_gyro_raw); Filter_Push(&g_filter, g_acc_raw, g_acc_f); Filter_Push(&g_filter2, g_gyro_raw, g_gyro_f); g_data_ready = 1; } void main_loop(void) { while (1) { if (g_data_ready) { g_data_ready = 0; ComplementaryFilter(g_acc_f[0], g_acc_f[1], g_acc_f[2], g_gyro_f[0], g_gyro_f[1], g_gyro_f[2], 0.005f); // 200Hz 对应 5ms 步长 DataPacket_Send(g_tick++, g_acc_f, g_gyro_f, g_pitch, g_roll); } } }

g_data_ready是中断与主循环之间的同步标志,用volatile修饰防止编译器优化掉重复读取。主循环轮询标志位,一旦置位就执行解算和发送。这个模式避免了在中断里调用HAL_UART_Transmit这种阻塞函数,也不会因为主循环正在处理上一帧数据而丢失新数据。标志位方式在数据量不大时足够可靠,刚入门的项目建议先用它,不要过早上队列。

3.1.1 为什么帧里必须有序列号

串口传输数据时,理论上先发先到,但DMA中断和空闲中断的竞争会导致偶发乱序。一旦数据包乱序,上位机画出的笔迹就会出现跳点。给每一帧附一个递增序号,上位机按序号重排,是成本最低又能解决绝大多数乱序问题的设计。

typedef struct { uint16_t seq; int16_t acc[3]; int16_t gyro[3]; int16_t pitch; int16_t roll; } DataPacket; void DataPacket_Send(uint16_t seq, float *acc, float *gyro, float pitch, float roll) { uint8_t frame[18]; frame[0] = 0xAA; // 帧头 frame[1] = 14; // 有效数据长度 frame[2] = (uint8_t)(seq >> 8); frame[3] = (uint8_t)(seq & 0xFF); // 浮点缩放为int16_t,降低带宽同时避免编译器浮点格式差异 int16_t *p = (int16_t *)&frame[4]; p[0] = (int16_t)(acc[0] * 1000.0f); p[1] = (int16_t)(acc[1] * 1000.0f); p[2] = (int16_t)(acc[2] * 1000.0f); p[3] = (int16_t)(gyro[0] * 10.0f); p[4] = (int16_t)(gyro[1] * 10.0f); p[5] = (int16_t)(gyro[2] * 10.0f); p[6] = (int16_t)(pitch * 10.0f); p[7] = (int16_t)(roll * 10.0f); // 数据段累加和校验 uint16_t sum = 0; for (int i = 2; i < 16; i += 2) { sum += (uint16_t)((frame[i] << 8) | frame[i + 1]); } frame[16] = (uint8_t)(sum >> 8); frame[17] = (uint8_t)(sum & 0xFF); HAL_UART_Transmit(&huart1, frame, 18, 10); }

这里的关键设计是定长帧。帧长固定为18字节,上位机解析时只需要查找帧头,收到完整长度后按偏移量取字段,不需要处理粘包和拆包。浮点不直接进串口,统一放大成整数,串口接收端就不会因为单片机与上位机浮点格式不完全一致而解析错位。缩放因子是协议的一部分,两端必须约定同一个值。

3.2 环形队列缓冲数据

中断置标志位的方式适合“采集→立即发送”的场景,但笔迹检测需要攒一段完整书写数据再做特征提取,这时就要用缓冲区。环形队列比链表更适合嵌入式环境:内存固定、不依赖堆分配、不会产生碎片。一个最简单实现如下:

#define BUF_CAP 64 typedef struct { DataPacket data[BUF_CAP]; uint16_t head; uint16_t tail; uint16_t count; } RingBuf; void RingBuf_Push(RingBuf *rb, const DataPacket *pkt) { if (rb->count < BUF_CAP) { memcpy(&rb->data[rb->head], pkt, sizeof(DataPacket)); rb->head = (rb->head + 1) % BUF_CAP; rb->count++; } } int RingBuf_Pop(RingBuf *rb, DataPacket *out) { if (rb->count == 0) return -1; memcpy(out, &rb->data[rb->tail], sizeof(DataPacket)); rb->tail = (rb->tail + 1) % BUF_CAP; rb->count--; return 0; }

写入时head前进,读取时tail前进,count记录当前元素个数。% BUF_CAP实现回绕,容量是64的倍数时编译期可优化成位运算。memcpy拷贝结构体比逐字段赋值更高效。注意这个实现没有加锁,中断写入和主循环读取同时发生时,如果主循环读走到一半,下一次中断写入可能覆盖未读区域。解决方式是在进入临界区时关闭中断,或者把缓冲区设计成单生产者单消费者模式——应用中恰好是中断产生数据、主循环消费数据,天然满足这个条件。

4. 笔迹检测:从轨迹重建到握笔姿态评估

4.1 用加速度二次积分重建轨迹的思路

笔迹检测的核心是把运动还原成笔尖轨迹。最直观的思路是对加速度做二次积分:一次积分得到速度,二次积分得到位移。问题是MPU6050的加速度数据带有零偏,哪怕零偏只有0.01g,积分一秒钟也会产生约5厘米的位移漂移——这个量级足以让一个汉字完全变形。

零速修正是解决漂移的常用策略。握笔书写存在明显的停顿时刻:笔画起笔、收笔、换笔的瞬间,笔尖速度接近零,加速度和角速度都有显著特征。在这些静止区间强制把速度清零,就切断了误差累积路径。

bool IsPenStatic(float ax, float ay, float az, float gx, float gy, float gz) { float amag = sqrtf(ax * ax + ay * ay + az * az); float gmag = fabsf(gx) + fabsf(gy) + fabsf(gz); return (fabsf(amag - 9.81f) < 0.35f) && (gmag < 15.0f); } void IntegrateTrajectory(float ax, float ay, float az, float *vx, float *vy, float dt) { if (IsPenStatic(ax, ay, az, 0.0f, 0.0f, 0.0f)) { *vx = 0.0f; *vy = 0.0f; } else { *vx += ax * dt; *vy += ay * dt; } }

IsPenStatic的判定条件有两个:合加速度接近重力加速度,且角速度幅值小。阈值不能拍脑袋定。我一般让笔静止悬浮3秒,记录合加速度的波动范围,取波动幅度的1.5倍作为static_acc_th。波动大说明笔杆振动明显,阈值就得放宽;反之收窄。gyro_mag的阈值15.0°/s是经验起始值,书写动作的角速度通常远大于这个值,正常握笔微抖也小于这个值。

4.2 笔迹特征提取:笔画数、书写路径与力度

联赛评审通常期望装置能给出可量化的书写指标。在单片机上实时提取一组特征,比直接还原完整轨迹更稳定、更易答辩展示。常用的特征组合是:笔画数、书写总时长、平均书写力度、X方向位移宽度。这些特征在C语言里用一个结构体聚合:

typedef struct { uint16_t stroke_cnt; uint32_t total_ms; float pressure; float x_width; } SignatureFeature; void ExtractFeatures(const DataPacket *buf, uint16_t len, SignatureFeature *feat) { feat->stroke_cnt = 0; feat->total_ms = 0; feat->pressure = 0; feat->x_width = 0; int16_t x_min = 32767, x_max = -32768; uint32_t sum_pressure = 0; bool in_stroke = false; for (uint16_t i = 0; i < len; i++) { float ax = buf[i].acc[0] / 1000.0f; float ay = buf[i].acc[1] / 1000.0f; float az = buf[i].acc[2] / 1000.0f; bool static_now = IsPenStatic(ax, ay, az, 0.0f, 0.0f, 0.0f); // 检测从静态到运动的上升沿,记一次笔画开始 if (static_now && !in_stroke) { in_stroke = true; } else if (!static_now && in_stroke) { in_stroke = false; feat->stroke_cnt++; } if (x_min > buf[i].acc[0]) x_min = buf[i].acc[0]; if (x_max < buf[i].acc[0]) x_max = buf[i].acc[0]; sum_pressure += (uint32_t)(sqrtf(ax * ax + ay * ay + az * az) * 100.0f); } feat->stroke_cnt++; feat->x_width = (float)(x_max - x_min) / 1000.0f; feat->pressure = (float)sum_pressure / len / 100.0f; feat->total_ms = (uint32_t)(len * 5); // 200Hz采样,每帧5ms }

笔画计数采用状态切换而非绝对值判断:in_stroke记录当前是否处于笔迹运动状态,只有从运动回到静止才算一次笔画结束。这种写法能抗阈值边界抖动,不会在一笔中间因瞬时停顿多计数。需要注意最后一段数据如果没有回到静止状态,stroke_cnt会漏计,所以函数末尾补了一次自增。这个边界条件是笔迹检测里最容易漏掉的坑。

4.3 握笔姿态评估的判定阈值

握笔姿态是否异常,用互补滤波输出的俯仰角和滚转角判断。小学生常见的勾腕、抱笔、立笔过直,在这两个角度上有明显差异。参考阈值如下:

判定项正常范围异常提示数据来源
俯仰角(笔杆前倾)30° ~ 70°小于30°立笔过直,大于70°笔杆过平Pitch角
滚转角(翻腕程度)-25° ~ 25°超出提示勾腕Roll角
连续书写时长5s ~ 12s超过12s未抬笔,提示疲劳定时器累计

这个表不是固定标准,不同年龄段手部发育差异较大。演示项目里可以通过按键切换“低年级/高年级”两套阈值,C语言里就是保存两份PenConfig结构体,切换时重新加载。姿态判定建议做成状态机,避免角度在阈值边缘反复触发报警:

typedef enum { ST_IDLE, ST_WRITE, ST_CHECK, ST_ALARM } JudgeState;

状态机的价值在于给“报警”加一个滞回条件,比如连续10帧超过阈值才真正报警,连续10帧恢复正常才解除。这个防抖思路在传感器类项目里普遍适用,比单帧判定可靠得多。

5. 联赛现场稳定运行的三个关键技巧

5.1 用VSCode + Cortex-Debug窗口观察姿态角

现场改阈值最怕反复烧录。VSCode配置Cortex-Debug插件后,可以接ST-Link直接实时查看变量。在launch.json里按下述配置连接到目标板,Watch窗口就能看到g_pitchg_roll的变化情况:

{ "configurations": [ { "name": "ST-Link Debug", "cwd": "${workspaceRoot}", "executable": "./build/firmware.elf", "request": "launch", "type": "cortex-debug", "servertype": "stlink", "device": "STM32F103C8", "svdFile": "./STM32F103.svd" } ] }

调试器暂停在断点时,还能检查g_data_ready是否过高、环形队列的count是否溢出,这些在串口输出里不容易发现。确认滤波参数后,再重新运行程序进入正常演示状态。

5.2 一键标定陀螺仪零偏

陀螺仪零偏是笔迹漂移的最大来源,而且会随温度变化。标定过程很简单:上电后静置2秒,采集200次陀螺仪数据求平均,作为零偏值在后续计算中减掉。

float g_bias[3]; void CalibrateGyro(void) { float sum[3] = {0, 0, 0}; float acc[3], gyro[3]; for (int i = 0; i < 200; i++) { MPU6050_ReadAll(acc, gyro); for (int j = 0; j < 3; j++) sum[j] += gyro[j]; HAL_Delay(10); } for (int j = 0; j < 3; j++) { g_bias[j] = sum[j] / 200.0f; } }

CalibrateGyro()绑到串口命令CAL上,现场用串口助手下发即可完成标定,不需要按键操作。标定前用串口提示“保持笔静止”,标定完成后回显三个零偏值,方便确认标定是否在合理范围。

5.3 用内部Flash保存调好的参数

调了一个小时的阈值,断电全丢是最影响演示效果的事。把配置参数存入内部Flash,上电时读取,是推荐的收尾动作。Flash写入前必须先擦除扇区,且不能频繁操作,正确的做法是在串口命令SAVE下触发一次写入:

typedef struct { uint32_t magic; // 0xA5A5A5A5 表示配置有效 float static_acc_th; float tau; float pitch_max; float roll_limit; uint16_t checksum; } SaveConfig;

写入时先HAL_FLASH_Unlock(),擦除扇区,再逐个地址编程,最后HAL_FLASH_Lock()。上电读取时检查magicchecksum,校验失败就回默认值。这样现场调好的参数能跨断电保存,评委问“参数怎么持久化”时,这个设计可以直接讲清楚。

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

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

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

立即咨询