简介:面向智能车电磁四轮入门者的基础寻迹代码包,围绕逐飞STC16核心板编写,采用前后台顺序执行框架,代码量不大,适合学习电磁寻迹基本逻辑后自行扩展元素与算法。资源共77个文件,以C语言头文件(.h)和源文件(.c)为主,另有引脚分配说明、Keil工程文件(uvproj/uvopt)、静态库(lib)等;压缩包仅152KB,内容包含逐飞外设库、官方库文档、MDK工程目录与引脚分配txt,结构清晰便于快速定位。已有3438人学习下载,配套作者系列博客使用可对照理解,尤其适合借鉴其外设初始化与基础寻迹思路,作为校赛起步模板。该版本于2022年11月有小更新,曾下载者可联系作者重新获取;内容适合准备电磁组校赛或刚接触STC16的选手作为起点,读懂顺序执行的代码流程后,可再逐步加入弯道、十字等元素。
1. 先别动PID,把电磁四轮的信号链路校准确再说
我拆完这套代码的第一反应是:它没有惊天动地的算法,但几乎所有关键拐点都留对了接口。电磁四轮跑不直、入弯推头,多数时候不是PID参数没调好,而是电感物理安装和信号链路没对准——这套逐飞STC16F工程正好把这两件事放到最前面。代码库结构很干净,外设层用逐飞STC16库封掉了寄存器细节,你的工作是补全“处理逻辑”而不是和硬件搏斗。前后台框架,适合刚转电磁组的人先跑通闭环,也适合老手快速验证自己的传感器排布方案。文档目录里那张引脚分配表,值得你先花十分钟读完再接线。
2. 从压缩包到工程:逐飞STC16库的分层逻辑
2.1 解压之后,先看懂这六个目录
拿到压缩包后我习惯先建立工作目录再解压,避免源工程里的相对路径失效:
unzip CSDN.zip -d emag_car cd emag_car ls -F解压后你能看到CSDN Libraries、seekfree_peripheral、seekfree_libraries、doc、libraries、Project CODE/MDK/USER这几个部分。初次接触逐飞库的人容易在seekfree_*两个目录里迷路,我先给出一张划分表:
| 目录 | 职责 | 你大概什么时候会去改 |
|---|---|---|
seekfree_peripheral | 芯片外设驱动层:ADC、PWM、定时器、串口等 | 更换引脚或通道时 |
seekfree_libraries | 与内核相关的公共封装:时钟、中断入口、常用宏 | 基本不动 |
libraries | 第三方扩展库,通常放滤波、控制算法源码 | 加新算法时引用 |
doc | 芯片手册、数据说明 | 查复用功能和时钟上限 |
Project CODE/MDK/USER | Keil 工程入口,USER下是main.c和引脚分配 txt | 每次调试的重点区域 |
这套分层和逐飞官方推荐的结构保持一致:外设库把寄存器操作收口成类似adc_init()、pwm_init()的接口,你不需要在ADCCFG、PWMA_CCR上反复翻手册。代价是接口参数有学习成本,比如同一个ADC_1模块下不同通道的引脚映射,不同批次的核心板有可能不一样,必须以上手时那份STC16F推荐引脚分配.txt为准。如果你发现初始化时某个通道读值恒为 0,先回这份 txt 核对 GPIO 复用关系,而不是怀疑代码。
2.2 外设初始化的标准套路
这个工程把初始化集中在all_init()里,属于前后台框架里很典型的写法。下面是一段参考初始化,摘掉了具体宏定义,只保留调用逻辑:
void all_init(void) { // 两路水平电感:分别接在 ADC_1 模块的 CH8、CH9 通道 adc_init(ADC_1, ADC_1_CH8, ADC_12BIT); adc_init(ADC_1, ADC_1_CH9, ADC_12BIT); // 舵机 50Hz,初始占空比 7.5%,对应约 1.5ms 中值脉宽 pwm_init(PWM1, PWM1_CH1, 50, 0.075f); // 驱动电机 15kHz,双路输出,初始占空比为 0 pwm_init(PWM2, PWM2_CH1, 15000, 0.0f); pwm_init(PWM2, PWM2_CH2, 15000, 0.0f); // 定时器1,1ms 中断回调 timer_init(TIMER_1, 1000, timer1_callback); // 串口1:115200,8N1 uart_init(UART_1, 115200, UART_8BIT, UART_1STOPBIT, UART_NO_PARITY); }参数说明:adc_init第一个参数是 ADC 模块编号,第二个是通道号,第三个是分辨率;ADC_12BIT对应结果范围 0~4095,比 8 位分辨率更有利于差比和算法提取细微位置变化。pwm_init第三个参数是频率,第四个是初始占空比 0.0~1.0。舵机 50Hz、电机 10kHz 以上这个组合,是电磁四轮最常见的配置,后面第 3 章会专门解释为什么。
拿到这套代码时,我会先把初始化里的模块编号和STC16F推荐引脚分配.txt逐行核对一遍,再编译烧录。因为逐飞库在不同型号核心板上的宏定义并不完全一致,只要版本对不上,大概率会出现引脚冲突或 PWM 通道无输出。
2.3 前后台:理解“一行一行向下执行”的边界
摘要里提到“一行一行向下执行”,这正是前后台框架的核心特征。主循环是前台,定时器中断是后台,代码以线性顺序跑完再回到循环起点:
void main(void) { all_init(); while (1) { key_scan(); // 按键处理 debug_report(); // 串口日志 } }这段代码的含义是:main初始化完成后进入死循环,每次从头到尾执行一轮。没有操作系统调度,也没有协程切换。所以你在任何函数里写while (flag == 0);这种等待语句,整台车都会僵在那里。常见做法是:
- 把所有耗时操作拆成短任务放进主循环;
- 中断回调里只记录时间和原始数据;
- 控制算法放到周期中断或者由主循环按节拍触发。
void timer1_callback(void) { // 1ms 进入一次,只做计时和标志位更新 tick_1ms++; }主循环每轮执行时间不固定,按键消抖、字符串格式化都会造成抖动,所以控制节拍不能依靠主循环的循环速度,而是靠定时器中断来“踢一脚”。这个设计的边界在于:中断里不能做浮点滤波和串口打印,否则中断周期会被拉长,车就会在弯道里出现肉眼可见的顿挫。
注意:如果你的串口发送用了轮询等待,把发送放到主循环,不要在中断回调里直接 printf。
3. 电磁采集与驱动输出:引脚、频率和节拍怎么定
3.1 电感信号进 ADC 前的检查项
电磁组的信号链路是:赛道导线流过 20kHz 左右交变电流,车头水平电感感应出同频交流信号,经过放大、检波后变成直流电平,再送入单片机 ADC。逐飞库的adc_get_result()只能保证“读数正确”,不能保证“读数代表真实赛道位置”。上电后如果发现某一侧数值恒定不变,先从这三个点排查:
- 电感排布是否水平且对称;
- 检波电容是否漏电或虚焊;
- ADC 通道和引脚分配表是否一一对应。
我习惯在读取函数里做一次多次采样平均,用来消除充电电容的残留电荷:
float read_sensor(uint8_t ch) { // 连续采 4 次取平均,抵消随机抖动 uint32_t sum = 0; for (uint8_t i = 0; i < 4; i++) { sum += adc_get_result(ADC_1, ch); delay_us(50); // 确保采样电容充分充电 } return sum / 4.0f; }delay_us(50)这段延时很关键。STC16F 的 ADC 是逐次逼近型,信号源内阻偏大时,第一次采样往往会略低于真实值。50us 足够内部采样电容稳定,4 次累加总开销约 200us,相对 10ms 控制周期来说可以忽略。早期我偷懒不写这个延时,结果直道误差有 20 左右的波动,弯道误差反而不明显,查了很久才发现是采样建立时间的问题。
3.2 舵机 50Hz 与电机 15kHz:频率选错车就抖
执行器频率不是越大越好,下表列出这套代码里两个关键频率的选型和异常表现:
| 执行器 | 典型频率 | 占空比含义 | 异常表现 |
|---|---|---|---|
| 舵机 | 50Hz(周期 20ms) | 高电平脉宽决定角度,中值约 1.5ms | 频率过高会啸叫甚至烧舵机 |
| 电机驱动 | 10kHz~20kHz | 占空比决定平均电压 | 低于 5kHz 能听到明显嗡嗡声,MOS 发热 |
对应的初始化代码:
// 舵机:50Hz,占空比 0.075 = 脉宽 1.5ms pwm_init(PWM1, PWM1_CH1, 50, 0.075f); // 驱动:15kHz,占空比 0.3 = 30% 输出 pwm_init(PWM2, PWM2_CH1, 15000, 0.30f);舵机内部是直流电机加反馈电位器,它的控制信号只认脉宽不认频率,50Hz 下 2.5ms 和 20ms 脉宽对应占空比差异很大,逐飞库用 float 占空比传参,计算时要先换算:1.5ms 除以 20ms 等于 0.075。不同品牌舵机的中值脉宽略有差异,第一次上电不要猛打大角度,先把中值固定到 0.075,再手动调整舵机臂让车轮自然居中。
3.3 用 1ms 定时器把控制节奏固定下来
控制周期不靠编译器优化,靠定时器。下面是一段典型的 10ms 控制节拍:
volatile uint32_t tick_1ms = 0; volatile float sensor_L = 0.0f, sensor_R = 0.0f; void timer1_callback(void) { tick_1ms++; // 每 1ms 加一 if (tick_1ms % 10 == 0) // 每 10ms 执行一次控制 { sensor_L = read_sensor(ADC_1_CH8); sensor_R = read_sensor(ADC_1_CH9); control_step(sensor_L, sensor_R); } }用累加取模代替在中断里重新初始化定时器,是为了避免回调重入造成的时钟漂移。volatile保证主循环读取tick_1ms时拿到的是最新值。主循环需要用到sensor_L时,先把它拷贝到局部变量再运算,否则中断可能恰好在赋值中间改了它,造成数据撕裂。这个工程量小,但中断节拍和控制周期是两件事,别混在一起。
注意:中断回调里不适合跑完整的均值滤波和浮点 PID,采样函数里那 200us 延时放在中断里会占掉 20% CPU,后续最好把滤波挪到
control_step()中。
4. 从两路电感到转向输出:差比和、PD 与差分油门
4.1 差比和为什么是电磁寻迹的默认起点
电磁车两侧水平电感对称放置时,车在赛道正中,左右电感值接近相等;车身偏向一边,内侧电感感应到的磁场更强。直接做减法L - R也能得到误差,但电感值受发射电流波动、赛道高度、环境金属物影响,同一个位置换一天可能变化 30%。差比和把公共增益约掉:
error = (L - R) / (L + R + 0.001f)
分母加0.001f是为了防止两侧同时为 0 时除零。这个误差天然被限制在 -1 到 1 之间,和电感放大倍数无关。对应的代码:
float eps = 0.001f; float sum = left + right; // 差比和:误差归一化到 [-1, 1] float error = (left - right) / (sum + eps);error > 0表示车头偏左还是偏右,取决于电感安装方向和差比和的减法顺序。我第一次用这个公式时左右电感接反了,调了三小时 PID,后来发现把减法顺序换过来车就直了。所以接线后第一件事是拿一块铁片靠近单侧电感,确认error的符号方向,再写控制逻辑。
4.2 舵机 PD 控制:先让车头“跟上”误差
舵机响应快,位置式 PD 就够用。比例项负责把转向角度映射到当前位置偏差,微分项负责抑制车头来回摆动:
#define STEER_CENTER 750 // 中值占空比,单位 0.1% #define KP_STEER 180 // 比例系数 #define KD_STEER 220 // 微分系数 static float last_error = 0.0f; void steer_control(float error) { float d_error = error - last_error; // 当前偏差的变化趋势 last_error = error; float steer = STEER_CENTER + KP_STEER * error + KD_STEER * d_error; // 限幅:防止舵机打死,留出回正余量 if (steer > 900) steer = 900; if (steer < 600) steer = 600; pwm_set_duty(PWM1, PWM1_CH1, steer / 10000.0f); }这里的占空比单位是 0.1%,750对应 7.5%,换算成 50Hz 下的脉宽正好 1.5ms。限幅到 600 和 900,即最大脉宽约 1.8ms、最小约 1.2ms,这个范围既能过急弯,又不会让舵机堵转。调参时参考这张表:
| 现象 | 调整方向 |
|---|---|
| 直道持续蛇行 | 减小KP_STEER或加大KD_STEER |
| 入弯推头、切内线 | 加大KP_STEER |
| 出弯回正缓慢 | 适当加KD_STEER |
KD_STEER不是越大越好,过大会把噪声放大,表现为高速直道上的高频抖动。
4.3 电机差分油门:没有编码器也能差速
这套代码没有速度闭环,电机使用开环油门配合差速修正。后轮双路 PWM,转向误差大的时候内侧减速、外侧加速,让车尾帮着车头“拧”过去:
#define BASE_DUTY 0.32f // 直道基础油门 #define STEER_DIFF_GAIN 0.30f // 转向差速修正系数 // error 来自差比和,范围 [-1, 1] float diff = STEER_DIFF_GAIN * error; pwm_set_duty(PWM2, PWM2_CH1, BASE_DUTY + diff); pwm_set_duty(PWM2, PWM2_CH2, BASE_DUTY - diff);BASE_DUTY决定直道速度,STEER_DIFF_GAIN决定弯道差速强度。先固定BASE_DUTY在 0.3 附近,弯道甩尾就降基础油门,推头就加差速系数。这个方案和舵机 PD 共享同一个error,整个控制链路很干净。
学会拆之前的执行链:定时器中断触发 → 读取两路 ADC → 计算差比和 → 舵机 PD 输出 → 电机差分输出 → 回到主循环继续做串口日志。前后台代码的调试顺序就沿着这条链往下走,每一步的输出都可以用串口打印验证。
5. 最后一公里:波形验证与两个标定技巧
5.1 把 L、R、error 送到串口画波形
不接屏幕也能看状态。逐飞库的串口驱动配合重定向fputc,可以让printf直接走串口输出:
char uart_putchar(char ch) { uart_write_char(UART_1, ch); return ch; } void debug_report(void) { // 用 tick 差值判断,避免累加归零问题 static uint32_t last = 0; if (tick_1ms - last < 50) return; last = tick_1ms; printf("L:%.2f R:%.2f E:%.2f\n", sensor_L, sensor_R, error_sum); }tick_1ms - last < 50表示 50ms 打印一次,避免串口阻塞影响主循环。拿到波形后先看静态:车放赛道正中,L 和 R 是否接近;再拿手推车过弯,看 error 是否平滑变化。波形只跳不滑,优先查采样延时和传感器虚焊,不要急着动 PID。
5.2 两个容易忽略的标定技巧
第一个是舵机中值的校准。代码里的STEER_CENTER只代表“代码认为的中值”,不代表机械中值。把车架空,直接输出STEER_CENTER,然后松舵机臂螺丝,手动把车轮转到正前方再锁紧。这个步骤能消除大部分直道偏行问题。
第二个是差比和的基线校准。环境变化后,即使车放正中间,左右电感也可能偏差很大。我一般会在control_step()入口做一次自动基线扣除,让动态误差只反映相对变化,不受静态偏移影响。运行时用(left - left_baseline) - (right - right_baseline)替换原始差比和分子,分母保持不变。换场地时只需要重新采一次基线值,而不是重新调三个 PID 参数。
本文还有配套的精品资源,点击获取