STM32F103摄像头循迹小车:OV7670图像采集与PID差速控制实战

发布时间:2026/10/30 7:08:11
STM32F103摄像头循迹小车:OV7670图像采集与PID差速控制实战 简介这份工程包面向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 最小系统板—72MHz20KB RAM64KB Flash摄像头OV7670带 AL422B FIFO 的模块D0-D7 → PA0-PA7必须买带 FIFO 的版本摄像头控制VSYNC / WRST / RRST / OE / RCLK / WENPB0-PB5全部推挽输出软件控制SCCB 配置口SIOC / SIODPB6 / PB7软件模拟也可以复用硬件 I2C电机驱动双路 H 桥模块PWMAPA8PWMBPA11AIN1/2PB12/13BIN1/2PB14/15TIM1 通道 1 和通道 4超声波HC-SR04TRIGPA4ECHOPA5TIM2_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_SPEEDK是转向强度系数一般取 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 双通道 PWM10kHz 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 对不对、闭环慢速能不能跟线这四步任何一步没过就绝不往下加功能。参数是末链路是本链路不通的时候调参就是在给自己制造玄学。希望这套拆法能帮你少走几个晚上。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询