news 2026/10/26 12:50:30

STM32 HAL电赛H题闭环控制代码解析与二次开发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 HAL电赛H题闭环控制代码解析与二次开发

简介:面向参加电子设计竞赛、尤其是备战2024年电赛H题的嵌入式学习者,这份代码包提供了一套基于32位微控制器(从工程文件看为STM32F10x系列)的完整实现参考,可用于理解赛题系统架构、外设驱动编写与工程组织方式。压缩包共254个文件,约8.49MB,以C源文件与头文件为核心,配套汇编启动文件、Keil工程配置、编译中间文件及map映射文件,另有少量说明网页与批处理脚本,基本覆盖从源码到编译产物的完整链路。已有787人学习,说明其在赛题复盘与方案对照中具有一定参考价值。读者可从中梳理定时器、ADC、I2C、CAN等外设的初始化与调用关系,观察中断处理、数据处理和模块分层写法,并借助工程配置与映射文件排查链接、内存占用等问题,适合作为电赛备赛阶段的对照样本和二次开发起点。

1. 备赛最后一个星期,最容易让人当场懵的往往不是算法不会,而是细节没对齐

去年封箱前一晚,我看到旁边工位的小车在直道上跑得很顺,一进弯道就原地打转,查到最后发现是编码器四倍频的换算系数写成了两倍。这种事在电赛里太常见了:PID 原理谁都会背,代码一烧进去就开始出现各种说不清的问题。这份「24电赛H题代码(20241020)」是一套把细节都对齐过的完整工程,底层用 STM32 HAL 库打底,往上依次是编码器电机闭环、多路灰度循迹、姿态解算、OLED 现场显示和串口在线调参,跑通之后可以直接当二次开发的底座。适合两类人:一是刚拿到题目、还没搭起框架的参赛队;二是想把控制链路吃透、以后还要改题型的选手。下面按我拆包的实际顺序,从工程骨架一路讲到改造点。

2. 工程骨架:解压之后先看的四层目录和编译链路

拿到一个压缩包,我的习惯是先不读 main.c,而是先看目录分层和工程文件,因为这一层决定了「这包能不能直接编译」,也决定后面改代码时会不会在无关的目录里浪费大量时间。这份工程的目录结构属于 CubeMX 生成后人工二次分层的典型做法:把自动生成的代码和业务代码彻底分开,好处是以后重新生成底层代码时不会把业务逻辑冲掉。

2.1 目录分层与编译链路

解压后大致是这样几块:

H题代码(20241020)/ ├── Core/ │ ├── Inc/ # main.h、外设句柄声明、全局宏 │ └── Src/ # main.c、stm32f1xx_it.c、外设 MX_xxx_Init ├── Drivers/ │ ├── CMSIS/ # 内核头文件,一般不动 │ └── STM32F1xx_HAL_Driver/ ├── Hardware/ # 自己写的外设驱动层 │ ├── motor.c/.h # 电机 PWM + 方向控制 │ ├── encoder.c/.h # 编码器测速 │ ├── gray.c/.h # 灰度循迹 │ ├── imu.c/.h # 姿态读取 │ └── oled.c/.h # 显示 ├── App/ # 业务层,题目相关逻辑全在这 │ ├── control.c/.h # PID 与控制节拍 │ ├── track.c/.h # 循迹决策 │ └── protocol.c/.h # 串口调参协议 ├── MDK-ARM/ # Keil 工程文件与启动文件 └── README.md

这个分层方式是我比较推荐的,Hardware只管“把某个芯片驱动起来”,App只管“题目要它怎么动”,Core里除了main.c的初始化和调度,其它基本不碰。改题型的时候你只需要动App,底层驱动九成可以原样带走。

编译链路是 Keil MDK + ARMCC,工程文件在MDK-ARM下。打开之后先确认三件事:目标芯片型号是否和你手上的板子一致、Options for Target → C/C++ → Include Paths里有没有把Hardware和App两个目录加进去、下载器选的是 ST-Link 还是 DAP。这三条任何一条不对,编译能过但下载会失败,新手最容易在这卡半天。

提示:如果换的是同系列但 Flash/RAM 不同的芯片,除了改 Device,还要在system_stm32f1xx.c和启动文件里对齐容量,否则会出现下载成功但一上电就卡死的情况。

时钟树这一块,工程里用的是外部晶振 8MHz 倍频到 72MHz,APB1 分频 2 得 36MHz,APB2 保持 72MHz。定时器时钟要按这个关系单独算:挂在 APB1 上的定时器实际时钟是 72MHz,挂在 APB2 上的也是 72MHz,这一点在算编码器测速和 PWM 频率时非常关键,后面第 3 章会再提。

2.2 外设资源分配与引脚表

控制类题目最容易出问题的不是算法,而是外设抢占:两个定时器抢同一个通道、串口和编码器共用中断向量、ADC 采样被 PWM 干扰。先看清资源分配,能省掉大量排查时间。

外设用途配置要点
TIM1 CH1~CH4四路电机 PWM频率 20kHz,避免可听噪声
TIM2左编码器编码器模式 TI12,四倍频
TIM3右编码器编码器模式 TI12,四倍频
TIM4控制节拍中断10ms 一次,作为整个系统的时基
USART1调试串口 / 调参协议115200 8N1,中断接收
I2C1OLED 与姿态模块400kHz 快速模式
ADC1 CH0电池电压检测分压后采样,用于低压告警
GPIO灰度阵列、按键、蜂鸣器灰度走输入上拉

这张表建议你在自己工程里也照着列一份,贴在手边。我见过不止一次因为 TIM2 的编码器通道和某个 PWM 通道冲突,导致电机一转编码器计数就乱跳的情况,单纯看代码根本看不出来,只能对着手册逐个通道核对。

main.c里的初始化顺序也有讲究,通常是先HAL_Init(),再时钟、再 GPIO、再通信外设、再定时器,最后才是业务层初始化。之所以把定时器放后面,是因为有些驱动初始化时会短暂拉低引脚,如果 PWM 已经启动,会看到电机抽一下。这个细节在赛场上电机突然抖一下、把小车顶出赛道的情况,我是真遇到过。

int main(void) { HAL_Init(); SystemClock_Config(); /* 72MHz */ MX_GPIO_Init(); MX_USART1_UART_Init(); MX_I2C1_Init(); MX_ADC1_Init(); Motor_Init(); /* 先占住 PWM 引脚,输出 0 占空比 */ Encoder_Init(); /* 编码器定时器 */ Gray_Init(); IMU_Init(); OLED_Init(); HAL_TIM_Base_Start_IT(&htim4); /* 最后才开控制节拍 */ Control_Init(); while (1) { App_Loop(); /* 状态机主循环,见第 4 章 */ } }

这段初始化的逻辑说明:Motor_Init()一定要在 PWM 定时器启动之前把占空比写成 0、方向引脚写成刹车状态,否则定时器一启动就是默认占空比,电机会直接冲出去。HAL_TIM_Base_Start_IT(&htim4)放在最后,是保证中断第一次进来的时候,所有外设和业务变量都已经是就绪状态,避免中断里读到未初始化的句柄。

3. 核心链路拆解:一次控制周期从采样到 PWM 输出的完整路径

这一章是整份代码里最值得仔细看的部分。控制类题目的本质就是一条固定节拍的环路:采样 → 计算偏差 → 输出 → 再采样。环路上任何一环的时序或量纲错了,表现出来都是“调不出来”,但原因完全不同。

3.1 编码器测速:定时器编码器模式与速度换算

编码器用的是定时器的硬件编码器模式,CH1 和 CH2 接 A/B 相,配置成 TI12 双边沿计数,相当于硬件自动做了四倍频。这样 CPU 完全不参与计数,只在一个固定周期里去读计数器的值,读完之后清零或者记录差值。

void Encoder_Init(void) { TIM_Encoder_InitTypeDef sConfig = {0}; TIM_MasterConfigTypeDef sMaster = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; /* 编码器模式不分频 */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 65535; /* 满量程,靠软件求差值 */ htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode = TIM_ENCODERMODE_TI12; /* A/B 相都计数,四倍频 */ sConfig.IC1Polarity = TIM_ICPOLARITY_RISING; sConfig.IC1Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler = TIM_ICPSC_DIV1; sConfig.IC1Filter = 6; /* 滤掉电机换向的毛刺 */ sConfig.IC2Polarity = TIM_ICPOLARITY_RISING; sConfig.IC2Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC2Prescaler = TIM_ICPSC_DIV1; sConfig.IC2Filter = 6; HAL_TIM_Encoder_Init(&htim3, &sConfig); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); }

参数说明里,IC1Filter = 6是个容易被忽略但很关键的项。电机在换向瞬间会有反电动势毛刺,滤波器等级太低时编码器会多计数,表现出来就是速度环输出一跳一跳的,像发热一样,越调越乱。等级太高又会引入延迟,建议在 4~8 之间根据实际电机试。

测速函数按固定周期调用,用差值法而不是清零法,好处是不会因为读取时刻和计数器溢出撞上而丢数:

#define PPR 13.0f /* 编码器线数,按实际电机改 */ #define CTRL_DT 0.01f /* 10ms 控制周期,单位秒 */ static int16_t s_last_cnt = 0; float Encoder_GetSpeedRPM(TIM_HandleTypeDef *htim) { int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(htim); int16_t diff = (int16_t)(now - s_last_cnt); /* 强转 int16 自动处理回绕 */ s_last_cnt = now; /* 四倍频后每转脉冲数 = PPR * 4;每秒转数 = diff / (PPR*4) / CTRL_DT */ return (float)diff / (PPR * 4.0f) / CTRL_DT * 60.0f; }

这里的换算逻辑值得单独拆开讲。diff是这 10ms 内增加的计数值,因为做了四倍频,转一圈对应PPR * 4个计数,所以diff / (PPR*4)是这 10ms 内转了多少圈,再除以CTRL_DT得到每秒圈数,乘 60 就是 RPM。第 1 章提到的那个问题就出在这里:把PPR * 4写成PPR * 2,速度反馈直接偏大一倍,PID 参数按正确值调好之后换到真机上就发散。

(int16_t)(now - s_last_cnt)这个写法也是刻意为之的。计数器是 16 位,从 65535 变成 0 的时候直接相减会得到一个巨大的正数,先做无符号减法再强转成有符号,就能正确得到负数,等价于处理了回绕。这是不用判断溢出分支的常用写法。

3.2 PID 控制器:位置式与增量式的取舍

工程里速度环和循迹环用的都是位置式 PID,只有方向环用了增量式。这个选择不是随便定的:位置式输出直接对应 PWM 占空比,物理意义清楚,调试时看输出值就能知道电机该给多大劲;增量式适合执行机构带积分特性的场景,比如舵机打角。

typedef struct { float kp, ki, kd; float err, last_err, prev_err; float integral; float out_max, out_min; float out; } PID_t; float PID_Calc(PID_t *p, float target, float feedback) { p->err = target - feedback; /* 积分分离:偏差大时不积分,防止起步阶段积分饱和 */ if (fabsf(p->err) < 30.0f) { p->integral += p->err; /* 积分限幅,上限取输出上限的一半左右比较稳 */ if (p->integral > 500.0f) p->integral = 500.0f; if (p->integral < -500.0f) p->integral = -500.0f; } else { p->integral = 0.0f; } p->out = p->kp * p->err + p->ki * p->integral + p->kd * (p->err - p->last_err); p->last_err = p->err; /* 输出限幅,对应 PWM 占空比上下限 */ if (p->out > p->out_max) p->out = p->out_max; if (p->out < p->out_min) p->out = p->out_min; return p->out; }

逐段说明。积分分离那一段是这份代码里比较值钱的地方:起步或者目标速度突变的时候,偏差可能有几百 RPM,这时候如果继续积分,积分项会迅速堆到一个很大的值,等实际速度追上来了,积分还得花很久才能退回去,表现就是超调、来回振荡,很难收敛。加了fabsf(p->err) < 30.0f这个门限,偏差大的时候直接不积分,只在接近目标时才让积分起作用,相当于给积分加了个开关。

积分限幅的数值 500 是个经验值,一般取输出上限的 30%~50% 比较合适,具体要看你的out_max是多少。ki乘上 500 之后如果已经超过out_max,说明限幅给大了,实际上等于没有限幅。

输出限幅必须做,而且out_min不一定是-out_max。如果题目要求不能倒车,out_min直接设 0;如果允许倒车但要求倒车速度慢一些,就设成-out_max * 0.6之类。这个不对称限幅在赛场上很实用,可以让小车在弯道里更稳。

调参顺序我一般是:先把ki、kd都置 0,只加kp,加到电机能跟着目标速度走但明显有静差;然后加ki消静差,加到静差消失、超调可接受就停;最后加一点点kd压超调,kd一般不超过kp的十分之一。

3.3 循迹与姿态:多传感器加权偏差的融合策略

灰度阵列用的是常见的 5 路或 7 路数字量传感器,输出是一组 0/1。最朴素的做法是用 if-else 判断哪种组合对应哪个位置,但这种写法在传感器临界状态会频繁抖动,弯道上尤其明显。

工程里用的是加权偏差法:给每一路传感器分配一个位置权重,把所有触发传感器的权重取平均,得到一个连续的位置偏差量。

/* 7 路灰度,权重从左到右为 -3 ~ +3 */ static const int8_t weight[7] = {-3, -2, -1, 0, 1, 2, 3}; float Gray_GetError(void) { uint8_t mask = Gray_ReadAll(); /* bit0 ~ bit6 对应左到右 */ int sum_w = 0, cnt = 0; for (int i = 0; i < 7; i++) { if (mask & (1 << i)) { sum_w += weight[i]; cnt++; } } if (cnt == 0) { /* 全丢线:保持上一帧偏差,靠记忆往回找 */ return g_last_gray_err; } g_last_gray_err = (float)sum_w / (float)cnt; return g_last_gray_err; }

逻辑说明:sum_w / cnt得到的是一个 -3 到 +3 之间的小数,比如只有最右边一路触发就是 3.0,中间两路触发可能是 -0.5,精度比离散的 if-else 高一个数量级。PID 的输入是连续的,弯道里输出就会平滑地变化,不会一跳一跳。

丢线处理是这段代码的另一处关键。全黑或者全白的时候,直接返回 0 会让小车突然认为自己在赛道正中,方向立刻回正,然后冲出赛道。返回上一帧偏差相当于让小车记住“我上次是在往右偏”,继续往右找线,成功率会高不少。

如果想再稳一点,可以把灰度偏差和姿态模块的偏航角融合:直道上用偏航角做主反馈,弯道上用灰度做主反馈,用一个根据偏差大小动态调整的权重把两者加起来。这类融合不做也能跑,但做了之后过连续 S 弯的差别很明显。

4. 时间与任务:中断节拍和主循环状态机怎么配合

控制类工程最常见的软件结构,是把所有事都塞进一个 while(1) 里,靠延时凑时序。这种写法在小 demo 里没问题,一旦加上串口调参、OLED 刷新、按键扫描,就会出现“按一下按键小车抖一下”的现象。这份工程采用的是固定时基中断 + 主循环状态机的组合。

4.1 定时器中断里的控制节拍

TIM4 配置成 10ms 中断一次,中断里只做时间敏感、执行时间确定的事:读编码器、跑速度环、更新 PWM。所有可能阻塞的操作一律不进中断。

volatile uint32_t g_tick_10ms = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) { g_tick_10ms++; /* 1) 采样:读编码器得到实际转速 */ g_speed_l = Encoder_GetSpeedRPM(&htim2); g_speed_r = -Encoder_GetSpeedRPM(&htim3); /* 右侧电机反向安装,取负 */ /* 2) 计算:速度环输出 */ float out_l = PID_Calc(&pid_speed_l, g_target_l, g_speed_l); float out_r = PID_Calc(&pid_speed_r, g_target_r, g_speed_r); /* 3) 输出:换算成 PWM 比较值 */ Motor_SetPWM(MOTOR_L, out_l); Motor_SetPWM(MOTOR_R, out_r); } }

这里的执行时间要控制在 1ms 以内,10ms 的节拍里留 90% 的余量给主循环。如果以后要在这个中断里加东西,先算一下时间:一次浮点乘法在 72MHz 的 M3 上没有 FPU,大约几十个周期,几十次运算就是几百微秒,加多了就会挤占主循环。

右侧编码器取负这件事也是踩过坑的。两个电机对称安装,正转的时候编码器计数方向相反,如果不取负,速度环看到的是负反馈,越调越离谱。判断方法很简单:手动把两个轮子都往前转,串口打印出来的两个速度值应该同号。

4.2 主循环状态机与串口调参

主循环负责所有“可以慢一点”的事:循迹决策、OLED 刷新、按键扫描、串口协议解析。用一个简单的状态机把这些事串起来:

typedef enum { ST_IDLE = 0, ST_RUN, ST_ADJUST, ST_STOP } app_state_t; void App_Loop(void) { static uint32_t last_oled = 0, last_key = 0; switch (g_state) { case ST_IDLE: Motor_Stop(); if (Key_Pressed()) g_state = ST_RUN; break; case ST_RUN: /* 循迹环:20ms 一次,比速度环慢,避免两个环互相打架 */ if (g_tick_10ms % 2 == 0) { float err = Gray_GetError(); float turn = PID_Calc(&pid_track, 0.0f, err); g_target_l = g_base_speed + turn; g_target_r = g_base_speed - turn; } if (g_tick_10ms - last_oled > 10) { /* 100ms 刷一次屏 */ last_oled = g_tick_10ms; OLED_ShowSpeed(g_speed_l, g_speed_r); } break; case ST_ADJUST: Protocol_Poll(); /* 串口调参,非阻塞 */ break; case ST_STOP: Motor_Stop(); break; } }

关键点是两个环的频率差。速度环 10ms 一次,循迹环 20ms 一次,让内环比外环快一倍以上,这是串级控制的基本要求。如果两个环都是 10ms,外环输出的目标速度还没被内环跟上就又被改掉,系统会一直处于“追不上”的状态,表现出来就是直道上也左右轻微摆动。

串口调参用的是非阻塞的环形缓冲,中断里只把字节塞进缓冲区,主循环里再解析:

uint8_t rx_byte; uint8_t rx_buf[64]; volatile uint16_t rx_head = 0, rx_tail = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_buf[rx_head++ % 64] = rx_byte; HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 立刻重新挂接收 */ } }

HAL_UART_Receive_IT必须在回调里重新挂上,否则只能收到一个字节就再也不进了。这个点在 HAL 库里属于必须自己处理的坑,CubeMX 生成的代码不会替你重新挂。

协议格式我一般用[0xAA][cmd][len][data...][checksum]这种简单帧,cmd 区分是设参数、读参数还是启停,checksum 用一个字节的累加和。有了这个,赛场上改 PID 参数不用重新编译下载,直接串口助手发一帧就行,能省下大量反复烧录的时间。

5. 避坑与排查:跑不起来的时候先看这几处

下面这几条都是实际调试中真出现过的问题,按“现象 → 原因 → 解决”整理。遇到类似表现可以先对照着查,比盲目改参数快得多。

5.1 电机只朝一个方向转,给负速度也没反应

现象:串口设目标速度为负,小车还是往前冲,或者干脆停住不动。

原因:H 桥的方向控制引脚和 PWM 通道不是一一对应的,Motor_SetPWM里只改了占空比,方向引脚没跟着翻。另一种情况是 PWM 用了互补输出,但死区配置不对,负占空比被硬件直接截掉了。

解决:先单独测方向控制——写一个测试函数,固定占空比 50%,循环切换方向引脚,用万用表量电机两端电压,正负应该对称。如果不对称,检查Motor_SetPWM里方向判断的分支,常见错误是把if (pwm < 0)写成了if (pwm > 0)。另外确认定时器是 PWM 模式 1 还是模式 2,模式选反了占空比和电平的关系也是反的。

5.2 速度环一加积分就发散,去掉积分勉强能跑

现象:ki从 0 加到 0.5 之后,电机开始周期性忽快忽慢,越加越明显,最后直接堵转保护。

原因:九成是积分项没有限幅,或者限幅值远超过输出上限。另一个隐蔽原因是控制周期和积分系数的量纲没对齐——CTRL_DT是 0.01 秒,但算积分的时候用的是integral += err,相当于把采样周期当成了 1 秒,实际积分作用放大了 100 倍。

解决:把积分改成integral += err * CTRL_DT,让积分项带上时间量纲,ki的物理意义就明确了。同时把积分限幅压到输出上限的 30%~50%,并且确认积分分离的门限生效了。改完之后重新从很小的ki开始加。

5.3 串口助手改了参数,程序里没生效

现象:按协议发了一帧设置kp的数据,回读发现还是老值。

原因:帧解析里的长度字段被当成了包含帧头的总长度,实际只表示数据段长度;或者校验和计算时把帧头也算进去了,和发送端不一致,帧被直接丢弃但没有任何提示。

解决:把解析函数单独拿出来做单元测试,在 PC 上写个模拟发送的脚本,把要发的字节打印出来逐字节比对。另外加一个“收到无效帧就回一个 0xFF”的反馈,至少能知道帧到底有没有进来。这类问题最难的地方在于它不报错,只能靠打印。

5.4 换一块板子程序直接卡死,连串口都不输出

现象:下载成功,指示灯正常,但是串口没输出,电机也不动。

原因:多半是晶振起振失败,SystemClock_Config卡在等待 HSE ready 的循环里,HAL_Delay也就永远不出来。另一块板子上如果没焊外部晶振,或者晶振负载电容不匹配,都会这样。

解决:把SystemClock_Config里的 HSE 等待改成带超时,超时后自动切到 HSI,并在OLED或者串口上打一行提示。这样至少系统能跑起来,你还能通过串口知道问题在哪。另外在HAL_Init()之后把HAL_InitTick的优先级调到最低,避免系统节拍被高优先级中断长期阻塞。

5.5 循迹在弯道上抖,直道正常

现象:直道上跑得很稳,一进弯道小车就开始左右横跳,感觉随时会出去。

原因:循迹环的周期和速度环一样快,外环还没来得及观察到偏差变化,内环已经执行了好几次;或者灰度传感器的安装高度不对,多路同时触发导致cnt很大,加权平均被拉平,偏差量变小。

解决:把循迹环周期拉到速度环的两倍以上,并在循迹 PID 的输出上再加一层低通滤波,比如out = 0.7 * out + 0.3 * last_out。传感器高度调整到只有 1~2 路同时触发的程度最好,太多路触发说明装得太高,太少说明太低容易磨地。

6. 二次开发:换题型、换硬件时从哪几个点下手

这份代码的价值不在于它能原样跑通某一个题目,而在于它把“驱动层—控制层—决策层”这三层的边界划清楚了。真要改的时候,改动量和你改的层数基本成正比。

换传感器是最轻的改造。比如把灰度换成摄像头循迹,只需要在App/track.c里重写Gray_GetError()这个接口,返回同样量纲的偏差量,上面的 PID 和电机控制一行都不用动。工程里所有模块都是通过.h里声明的接口互相调用的,只要保持接口签名不变,替换实现就是解耦的。这也是我建议你在自己工程里强制执行的规矩:上层不许直接操作寄存器或者 HAL 句柄。

换控制对象是最需要小心的。如果题目从小车换成云台或者平衡类,反馈量的量纲会变,速度环的out_max、积分限幅、CTRL_DT这三个数要一起重新推一遍。我的习惯是在control.h里把所有跟量纲有关的宏集中定义,改的时候只改这一处,不在各个.c里散落魔法数字。

再加一层功能也很方便,比如加速度闭环或者航向闭环。工程里的App_Loop是个状态机,加一个状态、加一个分支就行,不需要重构。串口协议里留一个cmd值给新功能,调参通道现成的。

改造方向需要改的文件不需要动的部分
换循迹传感器App/track.c、Hardware/gray.cPID、电机、状态机
换成云台控制App/control.c的参数、Hardware/motor.c的限幅串口协议、OLED
增加一个闭环App/control.c新增 PID 实例、App_Loop增加分支底层驱动、初始化
换主控芯片Core全部、各驱动的初始化部分App层的业务逻辑

验证方法上,我一般按三段走:先让电机空转,串口看编码器读数是否线性和过零正常;再接上闭环,用手捏住轮子感受反向力矩是否平滑;最后才上赛道。跳过任何一段,后面出问题都得回头补。

说个我自己的教训。早期我总觉得调参是最后一步,代码写完直接上赛道,结果一旦出问题就在“到底是硬件焊错了还是参数不对”之间反复横跳,一天下来什么也没定下来。从那以后我每次改完代码,都强制先把车架空跑一遍编码器和 PWM,确认采样和输出这两端都是准的,再落地调环。这一步只花十分钟,但能挡掉大部分说不清的问题。这份工程里那些看起来啰嗦的自检和打印,其实就是这么攒出来的。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 12:49:46

Clawdbot:AI社交平台如何重塑夜间陪伴经济

1. 项目概述&#xff1a;Clawdbot现象解析 深夜两点半&#xff0c;我的手机屏幕突然亮起——"今天过得怎么样&#xff1f;"这条来自Clawdbot的消息让我猛然意识到&#xff0c;这个AI社交平台已经悄然渗透进我的生活。作为一款主打深夜陪伴的AI社交产品&#xff0c;Cl…

作者头像 李华
网站建设 2026/10/26 12:48:58

交换机基本原理与配置:从MAC表、VLAN到Trunk端口实战

“交换机基本原理与配置”这八个字&#xff0c;摆在培训目录里毫不起眼&#xff0c;但真让它变得值钱的&#xff0c;是那些配错一个端口类型就得查到后半夜的现场。我带过几个刚入行的同事&#xff0c;命令敲得飞快&#xff0c;switchport mode trunk背得滚瓜烂熟&#xff0c;可…

作者头像 李华
网站建设 2026/10/26 12:47:34

消费级显卡训练神经网络:显存优化与自研框架设计实践

前段时间我把一个从零手写、刻意针对消费级显卡做了显存优化的轻量神经网络框架开源了&#xff0c;项目代号 PocketNN&#xff0c;代码和说明都放在开源托管平台上。发出去之后私信里问得最多的不是“准确率多少”&#xff0c;而是“普通显卡可训练”这句话到底是噱头还是真的。…

作者头像 李华
网站建设 2026/10/26 12:45:56

小模型训练稳定性:QK-norm、softcap与退火策略怎么选

最近和几个做预训练的同行聊天&#xff0c;话题总绕不开一个小模型稳定性的问题&#xff1a;训练一版 0.5B 规模的模型&#xff0c;loss 曲线一开始挺正常&#xff0c;跑到十几万步突然出现尖峰&#xff0c;甚至直接发散。查下来发现是注意力 logits 的数值分布出了问题。然后就…

作者头像 李华
网站建设 2026/10/26 12:45:45

瓷砖缺陷数据集YOLO+VOC双格式实战:888张3标签训练避坑指南

简介&#xff1a;本资源为瓷砖缺陷目标检测数据集&#xff0c;面向从事工业质检、表面缺陷识别方向的算法工程师与深度学习学习者&#xff0c;可用于训练和验证YOLO、Faster R-CNN等目标检测模型。数据集聚焦单块瓷砖图像&#xff0c;标注划痕线、破洞与边缘破碎三类缺陷&#…

作者头像 李华
网站建设 2026/10/26 12:45:22

Flutter国际化:GetX Translations多语言切换实践

语言切换按钮点下去&#xff0c;首页的标题变了&#xff0c;商品详情页还是中文&#xff0c;底部导航栏一半中文一半英文——这种“改一半”的场面&#xff0c;做过出海项目的人应该都不陌生。第一次遇到时我以为是热重载没生效&#xff0c;重启一遍照样如此&#xff0c;最后才…

作者头像 李华