☰
STM32F103摄像头循迹小车:OV7670图像采集与PID差速控制实战
2026/10/30 7:08:08 网站建设 项目流程

简介:这份工程包面向STM32F103智能小车开发,适合嵌入式课程设计、毕业设计及智能车竞赛备赛者,重点解决OV7670摄像头视觉循迹与超声波避障的落地实现问题。包内共166个文件,以77个.h头文件、72个.c源文件为主体,辅以4个.s启动汇编、Keil工程配置、批处理脚本、Hex烧录文件及测试位图,压缩包约751KB,目录保留完整工程骨架,便于直接编译、对照与移植。已有2341人学习下载,具备较好的参考热度。读者可从中掌握STM32F103的SPI/I2C、定时器与ADC等外设调用,理解摄像头图像采集、阈值二值化、道路边缘检测和循迹方向控制流程,并借助超声波回波测距实现前方障碍判断与路径调整;源码与配置齐全,也便于排查初始化、参数整定和多任务协调中的常见问题。其结构覆盖底层驱动、图像处理与应用控制,适合按模块阅读和二次开发。

1. 摄像头循迹小车:一块十几块的芯片怎么把赛道看明白

做 stm32f103 智能小车 超声波测距 这套组合的人,十个里有八个先买红外对管,然后在赛道上被灯光教做人:黑胶带边缘一反光,左右两路电平同时跳,车就开始左右横跳。换摄像头是分水岭——从"猜脚下有没有线"变成"知道线在哪、偏了多少度"。这份资源是一套能直接跑起来的工程:F103 主控、带 FIFO 的摄像头模块取图、双电机差速循迹,另外挂一路超声波测距做前向保护。它适合正在赶课设或毕设、需要底盘先动起来的同学,也适合参加过循迹赛想从灰度传感器升级到视觉方案的队伍。下面按硬件、图像链路、控制环、踩坑、改造五层往下拆,每层都给出能抄的代码。

2. 硬件与工程骨架:先把最小系统点亮,再谈循迹

做视觉循迹最怕一上手就写算法。车还没动,图像就花了,你根本分不清是摄像头配置错、接线错,还是 RAM 不够。所以这一层的目标是:时钟、串口、电机 PWM、摄像头供电四件事全部单独验证通过,再拼到一起。

2.1 器件清单与引脚分配

这套方案的外设不算多,但每个都有坑。下面是我用的引脚表,括号里写的是"为什么选这一组"。

模块型号/规格连接方式说明
主控STM32F103C8T6 最小系统板—72MHz,20KB RAM,64KB Flash
摄像头OV7670,带 AL422B FIFO 的模块D0-D7 → PA0-PA7必须买带 FIFO 的版本
摄像头控制VSYNC / WRST / RRST / OE / RCLK / WENPB0-PB5全部推挽输出,软件控制
SCCB 配置口SIOC / SIODPB6 / PB7软件模拟,也可以复用硬件 I2C
电机驱动双路 H 桥模块PWMA=PA8,PWMB=PA11,AIN1/2=PB12/13,BIN1/2=PB14/15TIM1 通道 1 和通道 4
超声波HC-SR04TRIG=PA4,ECHO=PA5(TIM2_CH1 输入捕获)ECHO 是 5V,必须分压
调试串口CH340PA9/PA10用来打图像和 PID 中间量
电源2S 锂电 + 5V/3.3V 双路降压—电机和主控分开供电

PA0-PA7 接数据口是刻意的:读一个字节就是GPIOA->IDR & 0xFF一条指令,不用移位拼装。这一个小决定能让读图速度快一倍多,后面讲帧捕获时会看到它有多重要。

提示:OV7670 模块在市场上分两种,一种裸模块只有十几个引脚,一种带 1MB FIFO 芯片。F103 没有 DCMI 外设,拿裸模块基本做不了连续采集,别省这十几块钱。

2.2 工程目录:让"能编译"和"能跑"分开

拿到压缩包后别急着全量编译,先看目录是不是这样的分层。如果没有,我一般会自己整理成下面这个结构,后面加功能会舒服很多。

Project/ ├── Core/ # 主循环、初始化、调度 │ ├── main.c │ └── systick.c ├── Drivers/ │ ├── stm32f10x_stdperiph/ # 标准外设库 │ └── CMSIS/ ├── BSP/ # 板级驱动,与业务无关 │ ├── bsp_clock.c # 时钟树 │ ├── bsp_uart.c # 调试串口 + printf 重定向 │ ├── bsp_motor.c # PWM 与方向控制 │ └── bsp_delay.c # us 级延时 ├── Devices/ # 外接器件驱动 │ ├── ov7670_sccb.c # 寄存器配置 │ ├── ov7670_capture.c # FIFO 帧捕获 │ ├── hc_sr04.c # 超声波 │ └── oled.c # 可选,现场调参用 ├── Algorithm/ │ ├── image_proc.c # 二值化、中线提取 │ └── pid.c # 增量式 PID └── App/ └── track_task.c # 循迹主任务:取图→算偏差→控制

这套划分的好处是:Drivers和BSP层换块板子还能复用,Algorithm层可以拿到电脑上用 Python 验证,App层只负责调度。很多人把所有东西塞进main.c,调摄像头的时候电机代码还在里面搅,出一堆玄学问题根本没法定位。

2.3 时钟树与调试串口:一切从 printf 开始

第一件要跑通的事不是摄像头,是串口。没有 printf,后面你只能靠 LED 猜。

// bsp_clock.c —— 72MHz 主频配置 void BSP_Clock_Init(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); // 外部 8MHz 晶振 while (RCC_WaitForHSEStartUp() != SUCCESS); RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); // 8 * 9 = 72MHz RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); RCC_HCLKConfig(RCC_SYSCLK_Div1); // AHB = 72MHz RCC_PCLK1Config(RCC_SYSCLK_Div2); // APB1 = 36MHz RCC_PCLK2Config(RCC_SYSCLK_Div2); // APB2 = 72MHz SystemCoreClockUpdate(); }

RCC_PLLMul_9是从 8MHz 晶振超到 72MHz 的关键。如果晶振实际是 12MHz 的板子,这里必须改成RCC_PLLMul_6,否则串口全是乱码——这是新手最常翻的车之一。另外注意 APB1 最大只能 36MHz,定时器如果挂在 APB1 上,实际计数时钟会因为倍频机制变成 72MHz,这个细节在算超声波定时器时要用到。

串口重定向只需要补两个函数:

// bsp_uart.c —— printf 重定向到 USART1 int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 等发送完成 return ch; } void BSP_UART_Init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; // TX gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; // RX gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = baud; // 建议 115200 usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; USART_Init(USART1, &usart); USART_Cmd(USART1, ENABLE); }

这里的while是阻塞等待发送完成。115200 波特率下发一个字节大概 87 微秒,如果你在主循环里一次printf几十个字符,就会实打实卡住几毫秒。调车阶段无所谓,正式跑的时候要把调试输出关掉或者改成 DMA,不然图像帧率会被串口拖死。

时钟和串口都通了,再往下才是摄像头。

3. 图像采集链路:从 SCCB 配置到一行二值数据

这一层是整个项目最容易被低估的地方。F103 只有 20KB RAM,一帧 QQVGA 灰度图就是 19200 字节,看起来塞得下,但你还得留栈、留缓冲、留 PID 的全局变量。所以真正合理的做法是:不存整帧,读一行处理一行,只留下每行的中线坐标。40 行中线,每行两个字节,一共 80 字节,干净利落。

3.1 为什么 F103 必须用带 FIFO 的摄像头模块

OV7670 输出是并行的,每个 PCLK 上升沿送出一个字节,QVGA 分辨率下像素时钟动辄 12MHz 以上。F103 在 72MHz 下,一个 GPIO 读加一次存储至少要三四个周期,根本追不上。而且 F103 没有 DCMI 接口,没有硬件帮你打包像素。

FIFO 模块上那颗 AL422B 就是干这个的:摄像头往 FIFO 里猛写,写满一帧后拉低 VSYNC 告诉你"这帧好了",然后你用自己的节奏慢慢读。读取时钟 RCLK 完全由 MCU 控制,想多慢就多慢。

写阶段:OV7670 --(PCLK 12MHz)--> AL422B FIFO 读阶段:AL422B FIFO --(RCLK 由 MCU 产生)--> STM32 GPIOA

一次完整的帧捕获时序是这样的:硬件复位写指针 → 使能写 → 等 VSYNC 上升沿(新一帧开始)→ 等 VSYNC 下降沿(一帧结束)→ 关写使能 → 复位读指针 → 使能输出 → 逐字节读 → 关输出。

// ov7670_capture.c —— 捕获一帧并逐行处理 #define CAM_DATA() (GPIOA->IDR & 0x00FF) // 低 8 位一次读入 void OV7670_CaptureFrame(void) { uint16_t row, col; uint8_t pixel; /* 1. 复位 FIFO 写指针 */ GPIO_ResetBits(GPIOB, FIFO_WRST); GPIO_SetBits(GPIOB, FIFO_WRST); /* 2. 打开写使能,等待一帧写完 */ GPIO_ResetBits(GPIOB, FIFO_WEN); while (GPIO_ReadInputDataBit(GPIOB, FIFO_VSYNC) == 1); // 等 VSYNC 变低 while (GPIO_ReadInputDataBit(GPIOB, FIFO_VSYNC) == 0); // 等 VSYNC 变高 while (GPIO_ReadInputDataBit(GPIOB, FIFO_VSYNC) == 1); // 等 VSYNC 再次变低,一帧结束 GPIO_SetBits(GPIOB, FIFO_WEN); // 停止写入 /* 3. 复位读指针并使能输出 */ GPIO_ResetBits(GPIOB, FIFO_RRST); GPIO_SetBits(GPIOB, FIFO_RCLK); GPIO_ResetBits(GPIOB, FIFO_RCLK); // 一个时钟复位读指针 GPIO_SetBits(GPIOB, FIFO_RRST); GPIO_ResetBits(GPIOB, FIFO_OE); /* 4. 按行读取,隔行采样,只处理 IMG_H 行 */ for (row = 0; row < IMG_H; row++) { for (col = 0; col < IMG_W; col++) { GPIO_SetBits(GPIOB, FIFO_RCLK); pixel = CAM_DATA(); // RCLK 上升沿后数据有效 GPIO_ResetBits(GPIOB, FIFO_RCLK); ImageProc_FeedPixel(row, col, pixel); // 立刻消费,不存整帧 } } GPIO_SetBits(GPIOB, FIFO_OE); }

几个参数必须说清楚。IMG_W和IMG_H是缩放后的逻辑分辨率,不是摄像头输出分辨率,常见做法是摄像头按 QVGA 输出,读的时候每两个字节跳一个(相当于横向抽一半),纵向每行都读但只保留固定间隔的行——因为 FIFO 是顺序读的,你没法跳行,只能把不用的行读出来后丢掉,或者干脆用 OV7670 的寄存器做硬件开窗(设置0x12到0x17这组窗口寄存器),让摄像头只输出你需要的那块区域,效率最高。ImageProc_FeedPixel直接处理,是为了绕开内存瓶颈:整帧缓存要 19200 字节,逐像素处理只占几个局部变量。

这段代码有个容易忽略的细节:pixel = CAM_DATA()必须在 RCLK 拉高之后、拉低之前读。AL422B 是在 RCLK 上升沿更新输出数据的,读早了拿到的是上一个字节,表现就是图像整体右移一列,看起来像"赛道歪了",其实是时序差了一个时钟。

3.2 SCCB 配置:让摄像头输出你要的格式

OV7670 上电后是一堆默认寄存器,不配置的话输出格式、色彩矩阵都不对。配置走的是 SCCB 协议,本质就是 I2C,但时序要求宽松一些,可以直接用 GPIO 模拟。

// ov7670_sccb.c —— 软件模拟 SCCB 写寄存器 static void SCCB_Delay(void) { __nop(); __nop(); __nop(); __nop(); } static void SCCB_Start(void) { SIO_D_H(); SIO_C_H(); SCCB_Delay(); SIO_D_L(); SCCB_Delay(); SIO_C_L(); SCCB_Delay(); } void SCCB_WriteReg(uint8_t reg, uint8_t val) { uint8_t i; SCCB_Start(); SCCB_WriteByte(0x42); // OV7670 写地址(7 位 0x21 左移一位) SCCB_WriteByte(reg); // 寄存器地址 SCCB_WriteByte(val); // 写入值 SCCB_Stop(); /* 0x12 是复位类寄存器,写完必须等待,否则后续配置全部丢失 */ if (reg == 0x12) BSP_DelayMs(10); } static const uint8_t OV7670_InitSeq[][2] = { {0x12, 0x80}, // 软复位 {0x12, 0x00}, // 复位结束 {0x0C, 0x00}, // 关闭降频 {0x11, 0x80}, // PCLK 分频,值越大越慢,配 FIFO 时可以用它兜底 {0x12, 0x04}, // 输出格式:RGB444 / 灰度,按模块说明调整 {0x40, 0xD0}, // 色彩矩阵相关 {0x17, 0x14}, // 开窗:水平起始高位 {0x18, 0x02}, {0x32, 0x80}, // 手动白平衡 {0x1E, 0x30}, // 镜像/翻转 };

0x11这个 PCLK 分频寄存器值得单独说。当你的读图速度跟不上、出现半帧或者撕裂时,把这个值往大调能立刻缓解症状,代价是帧率下降。我在调第一版时它就救过场——当时怎么改读图代码都不对,最后发现是摄像头写 FIFO 太快,一帧还没读完,下一帧已经开始覆盖了。

3.3 二值化:别用大津法,用行内最值

阈值这件事直接决定循迹稳不稳。赛场的光照从早到晚在变,早上固定阈值 120 能用,中午太阳一斜,整片赛道都变"黑",中线直接跑到画面外。

常见做法是全局大津法(Otsu),效果确实好,但它在 F103 上要遍历两遍直方图,跑一次几十毫秒,帧率直接砍半。我一般用行内最值中值法:每读一行,记录该行最大和最小灰度,阈值取两者中值。

// image_proc.c —— 单行二值化 + 左右边缘提取 #define IMG_W 80 #define IMG_H 40 #define BLACK_LIMIT 8 // 连续黑点少于此数认为是噪点 uint8_t line_center[IMG_H]; // 每行黑线中点,0xFF 表示丢线 void ImageProc_FeedPixel(uint16_t row, uint16_t col, uint8_t gray) { static uint8_t buf[IMG_W]; static uint8_t row_max, row_min; static uint8_t left_edge, right_edge, run_len; static uint8_t has_left, has_right; uint8_t threshold; buf[col] = gray; if (gray > row_max) row_max = gray; if (gray < row_min) row_min = gray; if (col != IMG_W - 1) return; // 一行没读完,先攒着 /* 行结束时统一处理:行内最值中值作为阈值 */ threshold = (row_max + row_min) >> 1; left_edge = 0; right_edge = IMG_W - 1; has_left = 0; has_right = 0; run_len = 0; for (uint8_t i = 0; i < IMG_W; i++) { if (buf[i] < threshold) { run_len++; if (run_len >= BLACK_LIMIT && !has_left) { // 第一次出现连续黑 left_edge = i - BLACK_LIMIT + 1; has_left = 1; } right_edge = i; // 持续更新最右黑点 has_right = 1; } else { run_len = 0; } } if (has_left && has_right && right_edge > left_edge) line_center[row] = (left_edge + right_edge) >> 1; else line_center[row] = 0xFF; // 丢线标记 row_max = 0; row_min = 255; // 复位给下一行用 }

BLACK_LIMIT这个去噪窗口是关键参数。赛道上有时候会有螺丝钉、胶带接缝、反光点,单个黑像素会被误判成线,加个连续长度门槛就能滤掉。设太大了不行,急弯处黑线在图像上斜着走,单行里真正连续的黑点可能只有五六个,BLACK_LIMIT设成 15 就会漏检。

row_max和row_min每行结束必须复位,这个我见过不止一个人忘了,结果第二行的阈值永远等于上一行的中值,跑起来表现为"刚开始正常,跑两米就歪"。

3.4 中线汇总:把 40 个点压成一个偏差值

单行中线有了,怎么变成转向量?最朴素的做法是取最下面一行(离车最近的那行)的中点,但这样只看脚下,过弯反应慢。实际用的是加权平均:近处的行权重大,远处的行权重小。

// image_proc.c —— 加权求整体偏差 float ImageProc_GetError(void) { int32_t sum = 0; int32_t weight_sum = 0; const int32_t half = IMG_W / 2; int32_t w; for (int r = IMG_H - 1; r >= 0; r--) { if (line_center[r] == 0xFF) continue; // 丢线行直接跳过 // 越靠近车头(行号越大)权重越高,最远权重 1,最近权重 4 w = 1 + (int32_t)(3 * (IMG_H - 1 - r) / (IMG_H - 1)); sum += ((int32_t)line_center[r] - half) * w; weight_sum += w; } if (weight_sum == 0) return 99.0f; // 整帧丢线,交给上层做保护 return (float)sum / (float)weight_sum / (float)half; // 归一化到 -1 ~ 1 }

返回值 99.0 是"整幅图都没找到线"的信号,上层看到它就该减速或者停车,而不是让 PID 拿一个 0 偏差继续往前冲。这个约定很重要,边界情况必须显式处理。

4. 控制环:从偏差值到两个轮子的转速

图像给的是方向,控制环负责把它变成轮速。这一层写错的表现很典型:直道能跑,一进弯就冲出去,或者车身像蛇一样左右摆。原因是转向量到 PWM 的映射没做限幅,或者 PID 和车体惯性的时间尺度没对上。

4.1 差速转向的几何关系

双电机小车的转向靠左右轮速差。设基础速度BASE_SPEED,偏差err(-1 到 1,正表示线在右边),那么:

左轮 = BASE_SPEED + K * err * BASE_SPEED 右轮 = BASE_SPEED - K * err * BASE_SPEED

K是转向强度系数,一般取 0.4 到 0.8。K太小,车反应不过来,进弯直接出界;K太大,直道上一点点图像噪声都会让车摆头。我一般先关 PID,只用纯比例把K从 0.3 开始一点点加,加到车刚好能在直道上微微颤,再往回退两档,这样得到的K是安全的。

转向量算完之后必须限幅到[-MAX_PWM, MAX_PWM],而且左右轮要反向限制:一侧加速时另一侧不能减到负数,否则轮子倒转,车会原地打转。这个坑我在第一版代码里踩过——err稍微大一点,右轮算出负值传进 PWM 函数,uint16_t一转换变成一个巨大的正数,车直接满速原地转圈,血泪经验。

4.2 增量式 PID 与 PWM 输出

纯比例能跑直道,弯道会有稳态误差。加积分能补,但积分项在起步和打滑时容易积累,所以我用的是增量式 PID,只算输出增量,天然带抗积分饱和。

// pid.c —— 增量式 PID typedef struct { float Kp, Ki, Kd; float err_now, err_last, err_prev; // 本次、上次、上上次偏差 float out; // 当前输出 float out_max; // 输出限幅 } PID_t; float PID_Calc(PID_t *p, float target, float actual) { p->err_now = target - actual; // 增量公式:ΔU = Kp*(e[k]-e[k-1]) + Ki*e[k] + Kd*(e[k]-2e[k-1]+e[k-2]) p->out += p->Kp * (p->err_now - p->err_last) + p->Ki * p->err_now + p->Kd * (p->err_now - 2 * p->err_last + p->err_prev); p->err_prev = p->err_last; p->err_last = p->err_now; if (p->out > p->out_max) p->out = p->out_max; if (p->out < -p->out_max) p->out = -p->out_max; return p->out; }

这里err_prev和err_last的更新顺序不能反,反了微分项就变成噪声放大器,表现为电机发出高频啸叫、车身抖动。参数上,Kp和系统的执行周期强相关:控制周期 20ms 和 50ms 下的Kp差好几倍,所以调参之前一定要把任务调度周期定死,中途改周期等于前功尽弃。

电机 PWM 的初始化:

// bsp_motor.c —— TIM1 双通道 PWM,10kHz void BSP_Motor_Init(uint16_t max_pwm) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_11; // TIM1_CH1 / CH4 gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); tb.TIM_Period = max_pwm - 1; // 例如 7200 -> 10kHz tb.TIM_Prescaler = 0; // 72MHz 不分频 tb.TIM_ClockDivision = TIM_CKD_DIV1; tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &tb); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 0; oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &oc); TIM_OC4Init(TIM1, &oc); TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须开这个 TIM_Cmd(TIM1, ENABLE); }

TIM_CtrlPWMOutputs这一行是 TIM1 独有的一步。TIM1 是高级定时器,不开主输出使能,PWM 引脚一直是死电平,现象就是"代码跑了灯也闪,电机纹丝不动"。这个坑每年都有人踩。

max_pwm取 7200 是因为 72MHz / 7200 = 10kHz,电机驱动模块的开关频率一般在 8k 到 20k 之间比较合适,太低有啸叫,太高开关损耗大。

4.3 超声波测距:TIM2 输入捕获算回响时间

HC-SR04 的用法是:给 TRIG 一个至少 10 微秒的高电平,模块发出 8 个 40kHz 脉冲,然后 ECHO 引脚输出一段高电平,高电平宽度就是声波往返时间。

// hc_sr04.c —— 输入捕获测回响高电平 volatile uint16_t echo_start = 0, echo_end = 0; volatile uint8_t echo_done = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) != RESET) { if (echo_done == 0 && echo_start == 0) { echo_start = TIM_GetCapture1(TIM2); // 上升沿,记录起点 TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Falling); } else { echo_end = TIM_GetCapture1(TIM2); // 下降沿,记录终点 echo_start = 0; echo_done = 1; TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Rising); } TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); } } uint16_t HC_SR04_GetDistanceCm(void) { uint32_t ticks; echo_done = 0; echo_start = 0; GPIO_SetBits(GPIOA, GPIO_Pin_4); // TRIG 拉高 BSP_DelayUs(15); // 保持 10us 以上 GPIO_ResetBits(GPIOA, GPIO_Pin_4); uint32_t t0 = BSP_GetMs(); while (!echo_done && (BSP_GetMs() - t0) < 30); // 最多等 30ms if (!echo_done) return 0; // 超时,返回 0 表示无效 ticks = (echo_end > echo_start) ? (echo_end - echo_start) : (0x10000 - echo_start + echo_end); // 处理计数器溢出 return (uint16_t)(ticks / 58); // 72MHz 下 1us 约 0.034cm }

ticks / 58这个系数需要按实际定时器时钟校准。如果 TIM2 是 1MHz 计数(预分频 72),那 1 个计数是 1 微秒,声速 340m/s,往返 1 厘米对应约 58.8 微秒,所以除以 58。如果你把预分频改成别的值,这个系数必须跟着改,不然测距会整体偏大或偏小,表现为"明明前面没东西,车却老刹停"。

echo_end > echo_start那一段是在处理计数器翻转。TIM2 是 16 位的,最大计到 65535 就归零,得到的距离上限大概是 11 米,赛道用完全够,但如果碰到一次超时没清干净,捕获寄存器里留着上一次的值,就会算出一个离谱的距离。所以超时后返回 0 而不是返回旧值,上层拿到 0 就当无效数据直接忽略。

保护逻辑放在主任务里:距离小于 25cm 且连续三次有效,就降速到 40%;小于 12cm 直接停。加"连续三次"是为了避开超声波的偶发跳变——单次数据哪怕滤过波也不能全信,这类传感器在软地面或者斜面上反射回来经常是乱值。

5. 常见问题排查:图像花屏、画龙、测距跳变

这一章是我自己整理的一份症状对照表。循迹车的问题有个特点:现象和原因往往隔着两层,看着像算法问题,其实是硬件问题。下面四条按出现频率排序。

5.1 图像花屏、只有半屏、或者整体斜着错位

现象:串口打出来的二值图前面几十行正常,后面全是噪点;或者图像整体左右偏移一列。

原因:分三类。一是 FIFO 读指针复位时序不对,RRST拉低时间不够,读出来是上一帧的残留数据;二是 RCLK 拉高之后没留建立时间就去读GPIOA->IDR,拿到的是上一个字节;三是摄像头的 PCLK 分频太小,FIFO 在 MCU 读的过程中被下一帧覆盖了。

解决:先把0x11寄存器的分频值往上加,让摄像头输出变慢,如果花屏消失,说明就是覆盖问题,再回头优化读图速度。然后检查 RCLK 那段代码,在GPIO_SetBits和pixel = CAM_DATA()之间插一到两个__nop(),用示波器看 RCLK 和数据线的相位关系最直接。最后确认 RRST 的低电平持续时间,我一般拉低后延时两三个微秒再拉高。

5.2 小车画龙,直道也左右摆

现象:速度一快就开始蛇形,速度慢下来正常。

原因:控制周期和图像帧率不匹配。图像 20 帧每秒,控制环却按 1ms 跑,同一个偏差值被 PID 反复积分了二十次,输出必然过冲。另一种是中线跳变没滤波,黑线边缘抖动一两个像素,偏差值就跟着跳。

解决:把控制周期和取图周期对齐,比如每取完一帧才跑一次 PID 并更新 PWM,中间不做重复计算。中线数据上加一层滑动平均,用最近三帧的同位置像素做中值滤波。再把 PID 的Kd调到 0 试试,如果摆动明显减小,说明微分项在放大噪声,应该降到 0 或者加低通。

5.3 超声波读数在 0 和正常值之间乱跳

现象:串口打印的距离一半是正常值,一半是 0,或者突然冒出一个 500。

原因:HC-SR04 的 ECHO 是 5V 电平,直接接 F103 的 5V 容忍引脚能工作,但接非容忍引脚会拉高电压,长期使用会损伤 IO,读数也会不稳。另外触发间隔太短,上一发回响还没结束就发下一发,两次波形混在一起。

解决:ECHO 加一个 1k 和 2k 的电阻分压到 3.3V 再进 MCU。触发间隔至少 60ms,我一般在任务里用计数器限流,两次测量之间强制间隔 80ms。读数上做三次中值滤波,连续两次超时才算真超时。

5.4 电机一转,摄像头就出图异常或者 MCU 复位

现象:车架在桌上调图像一切正常,电机一上电,图像立刻断,或者直接重启。

原因:电源问题占九成。电机启动瞬间电流能到几安培,和主控共用一路降压的话,电压瞬间跌到 2.5V 以下,MCU 必然复位。另外电机是感性负载,换向时会产生反向尖峰,通过地线串进摄像头。

解决:主控和电机分开供电,两路降压从电池分别取电,至少在电源入口各加一个 470uF 电解电容和一个 0.1uF 陶瓷电容。电机两端并联 104 电容做吸收。如果还不行,电机驱动和主控之间加光耦隔离 PWM 信号,代价是多占两个 IO 和一点延迟,但稳定性提升非常明显。

注意:这类干扰问题不要靠改代码去"绕过",代码层面能做的只有降速和加重试,根子还在电源和布线上。

6. 二次开发:把能跑的车改成能调参的车

原工程跑通之后,最容易做的改造不是加功能,而是把参数从代码里挪出来。我现在拿到任何一辆循迹车,第一件事都是接一个 OLED 或者蓝牙模块做在线调参,因为每次改Kp都要重新编译烧录,一次两分钟,一个下午调不了几组参数。

具体做法是全局维护一张参数表,PID 系数、基础速度、转向强度、二值化阈值窗口全部塞进去,主循环里轮询串口,收到特定帧格式就更新对应下标。

// app/param.c —— 在线参数表 typedef struct { float kp, ki, kd; uint16_t base_speed; uint16_t max_pwm; uint8_t turn_gain; // 放大 10 倍存储,避免浮点 uint8_t black_limit; } Param_t; Param_t g_param = { 8.0f, 0.5f, 12.0f, 3000, 7200, 6, 8 }; void Param_OnUartFrame(uint8_t idx, int32_t value) { switch (idx) { case 0: g_param.kp = value / 10.0f; break; case 1: g_param.ki = value / 10.0f; break; case 2: g_param.kd = value / 10.0f; break; case 3: g_param.base_speed = value; break; case 4: g_param.turn_gain = value; break; } g_pid.Kp = g_param.kp; // 立即生效,不需要重启 g_pid.Ki = g_param.ki; g_pid.Kd = g_param.kd; }

浮点参数用乘 10 存整数,是为了在串口协议里省掉解析浮点的麻烦,atoi一把梭就行。改完之后立刻赋给 PID 结构体,车在赛道上跑着就能调。

第二个改造方向是图像分区的权重。现在所有行权重是线性递减的,遇到 S 弯这种连续弯道会不够用。可以按行号分段,比如最下面十行权重 1,中间二十行权重 2,最上面十行权重 4,让车提前看到远处的弯。改法只是把ImageProc_GetError里的w计算从线性换成查表,一行static const uint8_t weight_table[IMG_H]就搞定。

验证改造有没有效,别靠感觉。我的做法是每次跑圈都让串口把"帧号、偏差值、左轮 PWM、右轮 PWM、距离"打成一行 CSV 存下来,跑十圈之后导到电脑上画一张偏差随时间的曲线。曲线平滑、超调小、过弯时没有削顶,参数就是对的;如果曲线在某个位置反复震荡,那个位置大概率是赛道上有反光或者接缝,用BLACK_LIMIT去滤。

还有一个容易忽略的验证点:把车架空,让轮子空转,手动拿一张画了黑线的纸在摄像头下面移动,看两个轮子的转速变化是否符合预期。这一步能把"图像对了但控制错了"和"控制对了但图像错了"彻底分开,比在赛道上瞎跑效率高得多。

从那以后我每次接新的底盘,都会强制走一遍"串口通了没有、图像单帧能不能看、开环 PWM 对不对、闭环慢速能不能跟线"这四步,任何一步没过就绝不往下加功能。参数是末,链路是本,链路不通的时候调参就是在给自己制造玄学。希望这套拆法能帮你少走几个晚上。

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

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

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

立即咨询