基于STM32F103的摄像头循迹小车:OV7670 FIFO时序与PID控制 简介这份基于STM32F103的摄像头循迹项目资料包面向学习嵌入式控制、智能小车与视觉循迹的开发者及学生围绕OV7670图像采集、二值化与边缘检测循迹算法、超声波测距避障以及电机运动控制展开可用于课程设计、竞赛小车和自动驾驶入门实践。压缩包共166个文件约751KB以77个.h头文件和72个.c源文件为主体配合4个汇编启动文件、3个.ini配置、2个.hex烧录文件及少量.bat、.bmp、.uv2工程文件覆盖底层驱动、图像处理、传感器接入与Keil工程配置。已有2341人学习下载说明其在STM32小车循迹主题下具有较好的参考基础。读者可从中获得从工程配置、摄像头驱动、图像二值化与道路边界识别到超声波避障逻辑、多任务协调及目录组织的完整实现思路便于对照移植、调试排错和二次开发。1. 摄像头循迹小车的整体定位一块 stm32f103 到底在算什么把车放上赛道第一圈它冲着白墙就去了方向打死也不回来。拆开看才发现问题不在 PID 参数而是图像那一行像素根本没读对——FIFO 的读时钟跑太快读回来全是 0xFF算法再聪明也是在算噪声。这个基于 stm32f103 的智能小车项目核心就是把摄像头循迹这件“看着像图像问题、实际是时序问题”的事拆成三段采一行灰度、算一个偏差、给一路 PWM超声波测距是第二传感器负责在直道尽头出现障碍时把车拽停。它适合已经点亮过 LED、跑通过串口想真正做一台能上赛道的小车的人纯新手照着也能跑但调车比写代码费时间。整套东西不依赖操作系统裸机主循环加几个中断就撑得住而 SRAM 只有 20KB 这件事会贯穿后面的每一个设计取舍。2. 硬件与工程结构从引脚账本看整车的信号流拿到一份小车工程我第一件事不是编译而是把引脚表拉出来对一遍。引脚冲突是最难查的一类问题编译零警告烧进去就是不动你还以为是算法的问题。这一节把整车的信号流从引脚、目录、时钟三个角度理清楚后面看代码才不会晕。2.1 引脚分配先解决 JTAG 占用再谈其他F103 的引脚复用非常直接一个脚要么是普通 IO要么是某个外设的通道选错了就没有第二次机会。摄像头占了 PA0~PA7 当并行数据口之后TIM2 和 TIM3 的默认通道基本全废只能靠重映射把编码器挪到别处。下表是这个工程里一张典型的引脚分配换板子或者换驱动芯片时按它逐行核对。信号方向引脚复用外设说明摄像头并口 D0~D7输入PA0~PA7GPIOA上拉输入一次 IDR 读回 8 bitFIFO 读时钟 RCLK输出PB0GPIOB软件翻转频率直接决定读图速度FIFO 写复位 WRST输出PB1GPIOB只在上电初始化时用一次FIFO 读复位 RRST输出PB10GPIOB每读一行之前打一个脉冲FIFO 输出使能 OE输出PB11GPIOB低有效常拉低即可场同步 VSYNC输入PB12EXTI12上升沿表示一场图像开始舵机 PWM输出PB6TIM4_CH150Hz0.5~2.5ms 高电平左电机 PWM输出PA8TIM1_CH120kHz避开人耳可听频段右电机 PWM输出PA11TIM1_CH4同上方向由另一路 IO 控制左编码器 A/B输入PA15 / PB3TIM2_CH1/CH2需要部分重映射右编码器 A/B输入PB4 / PB5TIM3_CH1/CH2需要完全重映射超声波 Trig输出PB8GPIOB10us 以上高电平触发超声波 Echo输入PB9GPIOB高电平宽度即回波时间调试串口双向PA9 / PA10USART1115200给上位机发偏差数据OLED双向PB13 / PB14软件 I2C现场不接电脑也能看偏差PA15、PB3、PB4 这三个脚默认是 JTAG 的引脚不关掉 JTAG 就永远是高阻态编码器读回来恒为 0。很多人在这里调一晚上以为是编码器坏了。初始化顺序必须把这个动作放在最前面/* board_init() 里的引脚初始化片段顺序不能颠倒 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO | RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); /* 关键关掉 JTAG 只保留 SWD否则 PA15/PB3/PB4 不可用 */ GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE); /* TIM2 部分重映射CH1/CH2 - PA15/PB3接左轮编码器 */ GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2, ENABLE); /* TIM3 完全重映射CH1/CH2 - PB4/PB5接右轮编码器 */ GPIO_PinRemapConfig(GPIO_FullRemap_TIM3, ENABLE); /* 摄像头并口8 位上拉输入悬空时读到 0xFF 而不是随机值 */ GPIO_InitTypeDef gpio; gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, gpio);GPIO_Remap_SWJ_JTAGDisable只关 JTAG 不关 SWD下载和调试都不受影响这比GPIO_Remap_SWJ_Disable安全得多——后者会把 SWD 一起关掉下次只能靠拉低 BOOT0 或者接复位引脚来救。参数里的GPIO_PartialRemap1_TIM2是“部分重映射 1”把 TIM2 的 CH1/CH2 挪到 PA15/PB3GPIO_FullRemap_TIM3则把 TIM3 的四个通道整体挪到 PB4/PB5/PB0/PB1注意这会把 PB0/PB1 也一起卷进去所以 FIFO 的读时钟和写复位才被迫让到 PB10/PB11 附近——硬件资源就这么多牵一发动全身。2.2 目录结构哪些文件能改哪些碰了就翻车裸机工程最容易写成一锅粥所有东西塞进main.c两个月后自己都看不懂。这份工程按“驱动—算法—应用”三层切每个目录职责单一改 PID 不用翻摄像头代码换传感器也不用动主循环。track_car/ ├── CMSIS/ 内核与启动文件原厂提供不要改 ├── StdPeriph_Driver/ 标准外设库原厂提供不要改 ├── user/ │ ├── main.c 主循环与 10ms 软定时任务 │ ├── board.c/.h 引脚宏定义、时钟、延时换板子只改这里 │ ├── stm32f10x_it.c 中断服务函数集中放 │ └── app_task.c 循迹状态机与决策逻辑 ├── driver/ │ ├── cam_fifo.c/.h OV7670 FIFO 的读时序与行缓存 │ ├── motor.c/.h TIM1 双路 PWM 方向控制 │ ├── encoder.c/.h TIM2/TIM3 编码器模式测速 │ ├── servo.c/.h TIM4_CH1 舵机角度输出 │ ├── sonic.c/.h 超声波触发与回波计时 │ └── oled.c/.h 软件 I2C 显示 ├── algo/ │ ├── line.c/.h 二值化、重心法求偏差 │ └── pid.c/.h 位置式 PID舵机与速度环共用 └── app/ ├── config.h 所有可调参数集中在这里 └── debug.c/.h 串口协议给上位机发波形board.h是整份工程里我改得最勤的文件。换一块最小系统板或者把 FIFO 控制线从 PB 口挪到别的口只动这一个头文件其余代码一行不改。app/config.h是第二个高频修改点PID 三个参数、二值化阈值、丢线判定门限全放这里调车时改完重编译比翻代码找#define快得多。2.3 时钟树与定时器预算72MHz 怎么切给五个外设F103 的定时器资源有限四个定时器要伺候舵机、电机、两路编码器和超声波分配不好就得抓瞎。先看时钟外部 8MHz 晶振经过 PLL 九倍频到 72MHzAPB1 分频后是 36MHz但挂在 APB1 上的定时器时钟会自动乘 2 回到 72MHz这一点经常被忽略算 PWM 频率时按 36MHz 算就全错了。定时器用途预分频 PSC重装载 ARR实际频率TIM1 CH1/CH4双电机 PWM0359972M/3600 20kHzTIM4 CH1舵机 PWM71199991M/20000 50HzTIM2左轮编码器065535编码器模式硬件计数TIM3右轮编码器065535编码器模式硬件计数舵机那一路要算清楚PSC 取 71计数频率 72MHz/72 1MHz一个计数就是 1usARR 取 19999周期 20000us 正好 20ms对应 50Hz。比较值写 1500 就是 1.5ms 高电平舵机回正写 1000 往一边打死写 2000 往另一边。电机 PWM 用 20kHz 是为了避开人耳8kHz 以下能听到明显啸叫20kHz 以上驱动芯片又可能响应不过来这个值是实测下来最舒服的。提示有些最小系统板把 PB4 当作了别的用途或者在板子上焊了下拉电阻改编码器引脚前先用万用表量一下别只看原理图。3. 图像采集链路拆解OV7670 FIFO 到黑线偏差循迹车的“眼睛”决定了整车的上限。F103 没有摄像头接口SRAM 又只有 20KB这个组合会直接否掉绝大部分“存一帧再处理”的思路320×240 的 RGB565 一帧就是 150KB连零头都放不下。所以整条链路的基调是边读边算、读一部分就扔一行几十个像素进来算完立刻变成一路偏差。3.1 FIFO 读时序一行像素怎么搬进内存摄像头模组上那颗 AL422B 缓存的容量是 384K×8bit正好存得下一场数据MCU 只负责按自己的节奏把数据“抠”出来。读一行的动作是复位读指针、连续打读时钟、每个上升沿采一次并口。看起来简单实际最容易在这里出错。/* * cam_read_line() —— 从 FIFO 里读出一行 line_len 个灰度点 * 调用前必须已经过 RRST 复位读指针才会回到行首 * line_len 建议 40~160越少越快越多越稳 */ static void cam_read_line(uint8_t *buf, uint16_t line_len) { uint16_t i; /* 复位读指针RRST 拉低一个 RCLK 周期再拉高 */ FIFO_RRST_L(); FIFO_RCLK_L(); FIFO_RCLK_H(); FIFO_RRST_H(); for (i 0; i line_len; i) { FIFO_RCLK_H(); /* 上升沿FIFO 输出下一个字节 */ __NOP(); __NOP(); __NOP(); /* 关键延时等数据稳定再采 */ buf[i] CAM_DATA_IN(); /* 一次读 GPIOA-IDR 低 8 位 */ FIFO_RCLK_L(); } }line_len是每次采样的点数工程里取 80即把一行横向降采样成 80 个点够判断线的位置了。中间三个__NOP()不是凑数72MHz 下 GPIO 翻转一次只要十几纳秒而 AL422B 从 RCLK 上升沿到数据有效需要几十纳秒不插延时读回来的就是上一个字节甚至全 0。这三个空指令的数量和编译优化等级有关-O2下可能被优化掉稳妥的做法是用__IO修饰或者直接写成空循环。CAM_DATA_IN()用GPIOA-IDR 0x00FF一次拿八位比逐个GPIO_ReadInputDataBit快一个数量级这也是把数据口放整字节对齐的 PA0~PA7 的原因。还有一个隐含前提这一行的起始位置。FIFO 里存的是整场图像要取“第 100 行”得先让读指针跳过前面 99 行的数据每行 640 个字节QVGA 两个字节一个像素跳完再采。工程里的做法是只在 VSYNC 后的固定时刻开始读用固定的空转次数对齐行号简单粗暴但有效代价是换分辨率就得重算这个常数。3.2 二值化与重心法从一行灰度到一路偏差拿到 80 个灰度值之后求黑线位置有两种常见思路。第一种是找左右边沿从左往右扫到第一个黑点再从右往左扫到第一个黑点两个位置取中点。这个方法快但赛道上有反光、阴影、接缝的时候边沿会跳车就跟着抽。工程里用的是第二种——加权重心法把每个黑点的“黑度”当权重算加权平均位置连续、抗噪、丢线也有明确判据。/* * line_get_error() —— 由一行灰度求黑线偏差 * 返回值-100 表示线在最左0 表示居中100 表示线在最右 * LINE_LOST(-32768) 表示这一行没找到线 * th 是二值化阈值越小越“黑”需要现场标定 */ int16_t line_get_error(const uint8_t *buf, uint16_t len, uint8_t th) { int32_t sum_w 0, sum_x 0; uint16_t i; for (i 0; i len; i) { if (buf[i] th) { /* 小于阈值判为黑线 */ int16_t w (int16_t)(th - buf[i]); /* 越黑权重越大 */ sum_w w; sum_x (int32_t)w * i; } } if (sum_w 30) return LINE_LOST; /* 权重太小判为丢线 */ /* 归一化到 -100 ~ 100跟采多少点无关 */ return (int16_t)((sum_x * 200L / sum_w / len) - 100); }sum_w是所有黑点权重之和sum_x是“位置×权重”的累加两者相除得到重心位置再线性映射到 -100~100。用 -100~100 这个量纲而不是直接的像素下标好处是后面 PID 的参数换摄像头分辨率也不用重调。sum_w 30是丢线判据整行没有明显黑点时权重会很小此时说明车已经冲出赛道或者遇到了大面积阴影继续用这个偏差去控制只会更糟。阈值的 30 不是拍脑袋是按 80 个点、平均权重 0.4 估算的低于这个数基本等于噪声。3.3 阈值与环境光为什么固定阈值一定会翻车函数里的th参数是整个循迹环节最玄学的一个数。同一块赛道上午日光灯下取 90下午拉上窗帘就得取 130写死一个值必然有一天跑不起来。常见的补救做法有三种从简单到复杂排列第一种是上电自标定。开机后车原地不动读几十行找出最大值和最小值取中点当阈值。代价是每次换场地要重新上电好处是代码只有十几行。/* 上电自标定原地采 20 行取全局最大最小值的中间值 */ uint8_t cam_calib_threshold(void) { uint8_t buf[CAM_LINE_LEN]; uint8_t vmax 0, vmin 255; uint16_t i, j; for (j 0; j 20; j) { cam_read_line(buf, CAM_LINE_LEN); for (i 0; i CAM_LINE_LEN; i) { if (buf[i] vmax) vmax buf[i]; if (buf[i] vmin) vmin buf[i]; } } if (vmax - vmin 40) return 100; /* 对比度太低退回默认值 */ return (uint8_t)((vmax vmin) / 2); }第二种是动态阈值每行单独算(maxmin)/2能适应“左边有阴影右边是白光”这种不均匀照明代价是每行多一遍扫描多花几十微秒。第三种是 Otsu 大津法效果最好但计算量对 F103 来说偏重除非降采样到 40 点以内否则一个主循环周期都不够用。注意二值化阈值调错的表现和 PID 调错很像都是画龙。判断方法很简单——接上 OLED 或者串口把偏差值打出来如果车跑直道时偏差也在大幅跳变那问题在图像不在控制。4. 控制环路从偏差到 PWM 的闭环设计图像那边给出一个 -100~100 的偏差控制这边的任务就是把它变成舵机角度和两个轮子的占空比。这一段是整个工程里最容易被“感觉”带偏的地方很多人上来就抄一段 PID参数乱试一天跑起来还是画龙。其实只要把每一环的输入输出量纲钉死参数是有章可循的。4.1 位置式 PID 与舵机打角舵机控制用位置式 PID因为它天然输出绝对角度不需要像增量式那样再积分回去。整个结构体只有五个字段够用且好调试。typedef struct { float kp, ki, kd; float err, last_err, integral; float out_max; /* 输出限幅对应舵机最大打角 */ float out; } PID_t; /* target 是期望偏差循迹时恒为 0now 是当前偏差 */ float pid_calc(PID_t *p, float target, float now) { float d; p-err target - now; p-integral p-err; /* 积分限幅停车时误差累积起步会猛打角 */ if (p-integral 2000.0f) p-integral 2000.0f; if (p-integral -2000.0f) p-integral -2000.0f; d p-err - p-last_err; p-last_err p-err; p-out p-kp * p-err p-ki * p-integral p-kd * d; 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是 -100~100 的偏差out也是 -100~100 的打角指令所以kp大致在 0.5~2.0 之间。ki一般给 0 或者很小因为循迹是连续跟踪积分项意义不大反而会在丢线再找回时造成超调。kd是用来吃振荡的但摄像头图像本身有噪声微分项会把噪声放大我的习惯是先给 0等出现等幅振荡再一点点加加到振荡刚好消失就停手。out_max取 100对应舵机左右极限防止算出来 300 这种值直接把舵机顶到限位。PID 输出到舵机比较值之间还有一层映射这层千万别省把限幅和单位换算放进去主循环里就干净了/* out: -100 ~ 100 - CCR: 1000 ~ 2000us */ void steer_set(float out) { int16_t ccr; if (out 100.0f) out 100.0f; if (out -100.0f) out -100.0f; ccr STEER_PULSE_MID (int16_t)(out * 5.0f); /* 5us 每单位 */ if (ccr STEER_PULSE_MIN) ccr STEER_PULSE_MIN; if (ccr STEER_PULSE_MAX) ccr STEER_PULSE_MAX; TIM_SetCompare1(TIM4, (uint16_t)ccr); }STEER_PULSE_MID是 1500out每 1 个单位对应 5us 脉宽满打满算 ±500us正好覆盖常见的 90 度舵机行程。如果你的舵机是 180 度的把系数改成 10 就行不用动 PID。4.2 后轮差速与编码器速度环转向靠舵机速度靠后轮两个后轮还要求能差速否则转弯半径下不来。左右轮各一路 PWM 加一个方向 IO是最省引脚的方案/* * base: 基础速度 0~1000 * diff: 差速量正数右转负数左转 */ void motor_set(int16_t base, int16_t diff) { int16_t l base diff; int16_t r base - diff; if (l 1000) l 1000; if (l 0) l 0; if (r 1000) r 1000; if (r 0) r 0; /* 方向控制一个 IO 决定正反转PWM 只负责大小 */ MOTOR_L_DIR(l 0); MOTOR_R_DIR(r 0); TIM_SetCompare1(TIM1, (uint16_t)(l 0 ? l : -l)); TIM_SetCompare4(TIM1, (uint16_t)(r 0 ? r : -r)); }base是基础速度diff是转向时叠加的差速。这里限幅在 0~1000因为 TIM1 的 ARR 设的是 3599占空比最大不超过 1000 是给驱动芯片留余量——TB6612 这类芯片在 100% 占空比下换向容易炸管留 30% 余量是血泪经验。再往上加一层速度闭环就轮到编码器出场了。TIM2 和 TIM3 配成编码器模式硬件自动对 A/B 相计数软件每 10ms 读一次计数器、清零、换算成轮速/* 每 10ms 调用一次返回该周期的脉冲数 */ int16_t encoder_read_left(void) { int16_t cnt (int16_t)TIM_GetCounter(TIM2); TIM_SetCounter(TIM2, 0); return cnt; }计数器读出来直接当int16_t用是因为编码器模式下的计数器是 16 位向上/向下计数的强转成有符号数正好能表示正反转。速度环的 PID 结构和舵机那套完全一样只是target变成目标脉冲数、now变成实测脉冲数输出再叠加到base上。这一步不做也能跑但加上之后电池电压从 8.4V 掉到 7.2V 时车速不会明显变慢赛道成绩稳定很多。4.3 超声波测距并入决策什么时候该停超声波在这个项目里不是用来循迹的它只干一件事前方 25cm 内有东西就停车。HC-SR04 的时序很固定Trig 给 10us 以上高电平模块发八个 40kHz 脉冲Echo 拉高高电平持续时间就是声波往返时间。/* * sonic_read() —— 阻塞式测距 * 返回距离 cm0 表示超时或超出量程 * 注意最坏情况会阻塞约 25ms别放在高频任务里 */ uint16_t sonic_read(void) { uint32_t t0, us; SONIC_TRIG_H(); delay_us(15); SONIC_TRIG_L(); /* 等回波拉高最多等 3ms等不到说明没发出 */ t0 micros(); while (SONIC_ECHO_IN 0) { if (micros() - t0 3000) return 0; } /* 记录高电平起点等它落下 */ t0 micros(); while (SONIC_ECHO_IN ! 0) { if (micros() - t0 25000) return 0; /* 约 4m 外视为无效 */ } us micros() - t0; return (uint16_t)(us / 58); /* 340m/s 往返1cm 约 58us */ }除以 58 这个常数来自声速换算340m/s 下 1cm 单程 29.4us往返 58.8us取 58 是工程近似室温变化带来的误差在厘米级对避障判定完全够用。micros()用 SysTick 的递减计数器实现不占额外定时器这是定时器资源被榨干之后的常规操作。真正的坑在于这个函数是阻塞的最坏要等 25ms。如果放在主循环里每圈都调循迹的刷新率会被硬生生拉低车就开始画龙。工程里的处理是把它塞进 10ms 软定时任务并且只在直道标志置位时才调用弯道里完全不测——弯道里本来也不该有障碍。这个细节在后面的状态机里体现得更清楚。4.4 主循环调度不用 RTOS 怎么把时序排开四个任务抢一个 CPU优先级排不好就是互相拖累。裸机下的常见做法是一个主循环加一个 SysTick 软定时把不同周期的任务分到不同时间片int main(void) { board_init(); cam_init(); motor_init(); steer_init(); sonic_init(); uart_init(115200); cam_calib_threshold(); /* 上电自标定阈值 */ while (1) { /* 高频任务每一场图像处理一次约 25~30ms 一场 */ if (cam_frame_ready()) { cam_frame_clear(); int16_t err cam_get_error(); if (err ! LINE_LOST) { float out pid_calc(steer_pid, 0, err); steer_set(out); motor_set(BASE_SPEED, (int16_t)(out * 0.3f)); } else { motor_set(0, 0); /* 丢线就停别赌它自己能找回来 */ } } /* 低频任务10ms 一次超声波 串口上报 */ if (tick_10ms) { tick_10ms 0; int16_t speed_l encoder_read_left(); int16_t speed_r encoder_read_right(); if (straight_flag) { uint16_t d sonic_read(); if (d 0 d 25) { motor_set(0, 0); state STATE_STOP; } } debug_send(err_last, speed_l, speed_r); } } }cam_frame_ready()的标志在 VSYNC 外部中断里置位这样主循环的处理节奏和摄像头场频天然对齐不会读到半场数据。tick_10ms由 SysTick 中断每 1ms 累加、满 10 清零主循环轮询它比在中断里直接跑业务安全得多——中断里做耗时操作是裸机开发的经典翻车点。丢线直接停车这个策略看着保守但实测比“保持上一帧打角继续冲”靠谱后者大概率冲出赛道。5. 调车避坑清单五类现场故障的现象、原因与处置代码能编译不代表车能跑能跑不代表能跑完一圈。下面这五条都是在一个个深夜里踩出来的每条按现象、原因、解决三段写对上了就照着改别硬调参数。5.1 图像类故障全白、全黑、满屏花现象一串口打出来的灰度值清一色 255或者清一色 0。原因基本锁定在读时序。清一色 255 通常是 FIFO 的 OE 没拉低输出脚处于高阻态上拉电阻把它拉到了高电平清一色 0 则是 RRST 脉冲宽度不够读指针根本没复位或者 RCLK 太快AL422本文还有配套的精品资源点击获取