简介:这份资源面向电子设计入门者与FPGA学习者,提供一套基于Verilog与Quartus II的循迹小车完整工程,用于解决从红外传感器数据采集、方向决策到电机控制的硬件逻辑实现问题,适合具备数字电路基础、希望把课堂语法落到实际项目中的读者。压缩包共137个文件,约4.57MB,以Quartus II工程与综合产物为主,包含Verilog源码、testbench仿真文件、引脚与工程配置、综合布局布线报告及可下载到芯片的sof、pof配置文件,另附说明文档与仿真波形,便于直接打开工程复现与二次修改。已有3233人学习下载,说明该案例在同类教学中具有一定参考价值。读者可从中获得红外循迹原理的硬件实现思路、传感器数据解析与电机转向控制的Verilog写法、testbench激励构造与仿真验证方法,以及Quartus II从设计输入到生成比特流的完整流程,并借助报告与波形排查时序和逻辑问题,积累嵌入式控制与自动驾驶小车的实践技能。
1. 从一块开发板到一辆会自己找线的车:这个 FPGA 循迹项目到底能干什么
去年帮一个学弟看毕设,他抱着一块 Cyclone IV 的开发板,桌上摊着一辆四轮小车底盘,红外探头焊得歪歪扭扭,一上电小车就原地打转。他跟我说:“仿真波形全对,一下板子就抽风。”这个场景几乎是我见过所有 FPGA 循迹小车项目的必经之路——Verilog 写得再漂亮,真正让小车贴着黑线跑起来,靠的是对时序、传感器和电机驱动的整体把控。这个项目就是一套完整的 FPGA 循迹小车实现,用 Verilog 描述逻辑,在 Quartus II 里综合下载,核心链路是“红外传感器采样 → 循迹决策 → PWM 调速 → 电机驱动”。它适合两类人:一类是刚学完 Verilog 语法、想找个能上板的完整工程练手的同学;另一类是想把组合逻辑、时序逻辑、状态机、PWM 这些知识点串成一个真实系统的开发者。下面我按“这东西怎么组织、怎么跑起来、哪里容易翻车、怎么改成自己的”这条线,把它拆开讲。
2. 工程结构与技术选型:一个循迹系统由哪几块拼起来
2.1 为什么选 FPGA 而不是单片机
先说选型。循迹小车用单片机(比如常见的 8 位 MCU)做,一个 ADC 加几路 PWM 就完事,成本低、上手快。但这个项目选 FPGA,逻辑上的理由是:循迹本质是“多路传感器并行采样 + 实时决策 + 多路 PWM 同时输出”,这些在 FPGA 里是天然并行展开的,不需要像 CPU 那样轮询。红外探头一般 4 到 5 路,每路都要独立滤波、独立判断,FPGA 可以给每路都开一套计数器和比较器,同一时刻全部算完,响应延迟只取决于时钟周期,不取决于程序跑到了第几行。这对高速循迹(比如赛道弯道密集)是有实际意义的。
技术栈上,硬件描述语言用 Verilog,综合与下载工具用 Quartus II,目标器件通常是 Cyclone 系列(Cyclone IV 最常见,开发板便宜、资料多)。整个工程在 Quartus 里是一个.qpf工程文件加若干.v源文件,顶层模块例化各个子模块,引脚约束写在.qsf里,时序约束可选地写在.sdc里。这里要提醒一句:Quartus II 的版本和器件库要匹配,老版本的工程用新版本打开经常报 IP 核不兼容,我一般会先确认工程里有没有用到altera_mf之类的库,再决定用哪个版本打开。
2.2 目录结构与模块划分
一个能跑起来的循迹工程,目录大致长这样(不同人习惯略有差异,但模块职责是固定的):
fpga_line_follow/ ├── line_follow.qpf # Quartus 工程文件 ├── line_follow.qsf # 引脚分配与工程设置 ├── rtl/ │ ├── top.v # 顶层,例化所有子模块 │ ├── sensor_filter.v # 红外传感器同步与消抖 │ ├── line_decision.v # 循迹决策(状态机) │ ├── pwm_gen.v # PWM 波形生成 │ └── motor_drive.v # 电机方向与使能控制 ├── sim/ │ └── tb_top.v # 顶层测试平台 └── doc/ └── pin_assign.md # 引脚对照表这个划分的关键在于职责边界清晰:sensor_filter只负责把异步的红外信号同步到系统时钟域并做消抖,不掺决策;line_decision只吃滤波后的传感器状态,输出左右轮的转向意图;pwm_gen只根据占空比输入产生波形;motor_drive把转向意图翻译成电机驱动芯片需要的方向电平加 PWM。这样拆的好处是,调试时你可以单独给line_decision灌测试激励,不用整车上板。
2.3 时钟与复位策略
FPGA 开发板上一般有 50MHz 晶振,这个频率对循迹来说太高了——PWM 频率通常取 1kHz 到 20kHz(太低电机会啸叫,太高驱动芯片可能跟不上),传感器消抖需要毫秒级计数。所以工程里通常做一个分频器,把 50MHz 分到 1MHz 作为系统主时钟,PWM 和消抖计数都基于这个时钟。复位用低电平有效的按键复位,经过两级同步后再分发到各模块,避免复位释放时的亚稳态。这里有个细节:复位信号一定要做同步释放,直接拿按键接复位端,松手瞬间各模块复位状态不一致,小车可能一上电就猛冲一下。
3. 核心模块拆解:从传感器采样到电机转动的完整链路
3.1 红外传感器同步与消抖
红外循迹探头输出的是数字电平:照到黑线输出一种电平,照到白底输出另一种(具体高低取决于模块,常见是黑线输出高、白底输出低,也有反的,接线前必须实测)。问题在于这个信号是异步的,而且过黑线边缘时会抖动。标准做法是两级触发器同步加一个计数器消抖:
// sensor_filter.v:单路传感器同步 + 消抖 module sensor_filter ( input wire clk, // 系统时钟 1MHz input wire rst_n, // 低电平复位 input wire sensor_in, // 红外探头原始输入(异步) output reg sensor_out // 消抖后的稳定电平 ); reg s1, s2; // 两级同步寄存器 reg [15:0] cnt; // 消抖计数器,1MHz 下 16 位约 65ms 量程 localparam DEBOUNCE_MAX = 16'd20000; // 20ms 消抖窗口 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin s1 <= 1'b0; s2 <= 1'b0; end else begin s1 <= sensor_in; // 第一级同步 s2 <= s1; // 第二级同步,降低亚稳态概率 end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; sensor_out <= 1'b0; end else if (s2 != sensor_out) begin // 输入与当前稳定输出不一致,开始计时 if (cnt >= DEBOUNCE_MAX) begin sensor_out <= s2; // 持续 20ms 才认账 cnt <= 16'd0; end else begin cnt <= cnt + 1'b1; end end else begin cnt <= 16'd0; // 输入稳定,计数器清零 end end endmodule这段代码的逻辑是:s1、s2两级同步把异步输入拉进clk时钟域,cnt负责“连续多久和当前输出不一样”。只有s2和sensor_out持续不一致超过DEBOUNCE_MAX个时钟,才更新输出。参数上,DEBOUNCE_MAX取 20000 对应 1MHz 下 20ms,这个值要结合小车速度调:跑得快就调小(比如 5ms),否则过弯时传感器已经压到黑线了,输出还慢半拍;跑得慢可以调大,抗干扰更稳。四路传感器就例化四个这个模块,或者写成一个带generate的数组版本。
3.2 循迹决策状态机
决策模块是整个项目的大脑。常见做法是把四路传感器(左外、左内、右内、右外)的滤波结果拼成一个 4 位向量,然后按“哪几路压线”映射到转向动作。最直观的实现是一个基于case的状态机,但更稳的写法是组合逻辑判断加时序寄存输出:
// line_decision.v:根据四路传感器输出转向意图 module line_decision ( input wire clk, input wire rst_n, input wire [3:0] sensor, // {最左, 左内, 右内, 最右},1 表示压黑线 output reg [1:0] turn // 00 直行 01 左转 10 右转 11 停车 ); // 组合判断:先算“压线模式” reg [1:0] turn_next; always @(*) begin case (sensor) 4'b1000, 4'b1100, 4'b0100: turn_next = 2'b01; // 偏左,左转修正 4'b0001, 4'b0011, 4'b0010: turn_next = 2'b10; // 偏右,右转修正 4'b0110, 4'b1111: turn_next = 2'b00; // 居中或全压,直行 4'b0000: turn_next = 2'b11; // 全丢线,停车 default: turn_next = 2'b00; endcase end // 时序寄存,避免组合毛刺直接驱动电机 always @(posedge clk or negedge rst_n) begin if (!rst_n) turn <= 2'b11; else turn <= turn_next; end endmodule这里有几个设计取舍值得说。第一,sensor的位序必须和实际接线严格对应,我见过太多人把最左和最右接反,结果小车越修越偏,最后撞墙。第二,4'b1111(全压线)我归到直行,因为全压通常意味着车正好骑在线上或者到了十字路口,直行通过是合理默认;如果你的赛道有十字路口需要特殊处理,这里就要改成计数或状态记忆。第三,输出用寄存器打一拍,是为了切断组合逻辑毛刺——电机驱动对毛刺很敏感,一个纳秒级尖峰可能就让驱动芯片误动作。参数上,turn的编码要和motor_drive约定一致,改编码时两边一起改。
3.3 PWM 生成与占空比调节
PWM 模块负责把“转多快”翻译成波形。核心是一个自由运行的计数器加比较器:
// pwm_gen.v:固定频率、占空比可调的 PWM module pwm_gen #( parameter PERIOD = 16'd1000 // 1MHz 下 1kHz PWM )( input wire clk, input wire rst_n, input wire [15:0] duty, // 占空比,0~PERIOD output reg pwm_out ); reg [15:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; pwm_out <= 1'b0; end else begin if (cnt >= PERIOD - 1) cnt <= 16'd0; else cnt <= cnt + 1'b1; pwm_out <= (cnt < duty) ? 1'b1 : 1'b0; end end endmodulePERIOD决定 PWM 频率,1MHz 时钟下取 1000 就是 1kHz。duty是运行时输入,决策模块可以根据转向幅度给不同占空比:直行时左右轮都给中等占空比(比如 500),左转时左轮降速、右轮提速。这里有个血泪经验:duty不要直接给 0 或PERIOD的极端值,很多电机驱动芯片在 0% 和 100% 占空比附近线性度很差,起步会顿挫。我一般把有效范围限制在 20% 到 80% 之间,用饱和逻辑夹一下。另外,左右轮各例化一个pwm_gen,两个模块共用clk和rst_n,但duty独立输入,这样差速转向才成立。
3.4 电机驱动与方向控制
motor_drive把turn和占空比翻译成驱动芯片的引脚电平。常见的双路电机驱动芯片需要每个电机两个方向脚加一个使能脚(或 PWM 脚)。逻辑很直接:
// motor_drive.v:转向意图 -> 左右轮方向与占空比 module motor_drive ( input wire [1:0] turn, input wire [15:0] base_duty, // 基础速度 output reg [15:0] duty_l, output reg [15:0] duty_r, output reg dir_l, // 左轮方向 output reg dir_r // 右轮方向 ); localparam DUTY_MIN = 16'd200; localparam DUTY_MAX = 16'd800; always @(*) begin case (turn) 2'b00: begin // 直行 dir_l = 1'b1; dir_r = 1'b1; duty_l = base_duty; duty_r = base_duty; end 2'b01: begin // 左转:左轮慢、右轮快 dir_l = 1'b1; dir_r = 1'b1; duty_l = (base_duty > DUTY_MIN + 16'd150) ? base_duty - 16'd150 : DUTY_MIN; duty_r = (base_duty < DUTY_MAX - 16'd150) ? base_duty + 16'd150 : DUTY_MAX; end 2'b10: begin // 右转:右轮慢、左轮快 dir_l = 1'b1; dir_r = 1'b1; duty_l = (base_duty < DUTY_MAX - 16'd150) ? base_duty + 16'd150 : DUTY_MAX; duty_r = (base_duty > DUTY_MIN + 16'd150) ? base_duty - 16'd150 : DUTY_MIN; end default: begin // 停车 dir_l = 1'b0; dir_r = 1'b0; duty_l = 16'd0; duty_r = 16'd0; end endcase end endmodule差速量 150 是个可调参数,对应大约 15% 占空比差。这个值太小转不动弯,太大过弯会甩尾,实际调的时候从 100 开始往上试。DUTY_MIN/DUTY_MAX的夹取是为了保护电机和驱动。注意dir_l/dir_r这里默认都置 1 表示前进,如果你的驱动芯片方向定义相反,改这两行就行,不用动决策逻辑。
4. 上板跑通:Quartus II 工程配置与下载流程
4.1 新建工程与引脚分配
拿到源码后,第一步是在 Quartus II 里打开.qpf,如果没有工程文件就新建:File → New Project Wizard,选好器件型号(必须和板子上的芯片丝印一致,比如 EP4CE6E22C8),把rtl/下所有.v加进去,顶层设为top。然后打开 Pin Planner,按开发板原理图把顶层端口分配到实际引脚。这一步最容易翻车的是时钟和复位脚:时钟必须接到板载晶振输出脚,复位接到按键脚,接错了综合能过但板子没反应。引脚分配完记得保存,Quartus 会写进.qsf。
4.2 综合、布局布线与时序检查
点 Processing → Start Compilation,跑完看 Compilation Report。重点看两个地方:一是 Fitter 的引脚分配有没有冲突(红色报错),二是 TimeQuest 的时序是否满足。50MHz 分频到 1MHz 之后,时序压力很小,一般不会有时序违例,但如果你的分频逻辑写成了组合分频(用always @(*)产生时钟),Quartus 会报“时钟用了非专用布线”,这时候要改成时钟使能写法——用一个clk_en信号在每个时钟周期拉高一拍,各模块用if (clk_en)控制,而不是真的分出一个新时钟。这是 FPGA 设计里很经典的坑,后面避坑章节还会展开。
4.3 下载与上电验证
编译通过后,用 USB-Blaster 连接 JTAG,打开 Programmer,选.sof文件,勾选 Program/Configure,点 Start。下载成功后小车应该能上电动作。验证顺序建议是:先不接电机,用 LED 指示四路传感器状态,手动拿黑线在探头下晃,看 LED 是否跟着变——这一步确认传感器链路通;再接电机但架空车轮,看转向时左右轮转速是否按预期差速——这一步确认 PWM 和方向逻辑;最后落地跑。这个分步验证能省掉大量“到底是传感器错还是电机错”的排查时间。
5. 避坑与排查:上板后小车不听话的五个真实原因
5.1 现象:仿真全对,上板小车原地打转
原因通常是传感器位序和决策逻辑对不上。仿真时你给sensor灌的是理想激励,位序是你自己定的;上板后实际接线顺序可能完全相反。解决方法是先做 4.3 里的 LED 验证,把每一路探头对应到sensor的哪一位用纸记下来,再回头核对line_decision的case分支。我一般会在顶层加一个调试模式,用一个拨码开关切换“正常循迹”和“传感器直显 LED”,省得反复改代码。
5.2 现象:小车过弯时冲出去,直道正常
原因是消抖窗口太大或者决策延迟太高。DEBOUNCE_MAX取 20ms 时,传感器从白到黑的跳变要 20ms 才被认账,小车在弯道速度下 20ms 能跑好几厘米,等决策出来已经出线了。解决是把消抖窗口降到 3 到 5ms,同时检查line_decision输出有没有多余的寄存级数。另外 PWM 频率如果取太低(比如 100Hz),电机响应也会迟钝,建议不低于 1kHz。
5.3 现象:电机不动,但 LED 指示正常
先查使能脚。很多驱动芯片除了方向脚还有独立的使能(EN)脚,代码里如果只给了方向和 PWM 而 EN 一直为低,电机就是不转。其次是查共地:FPGA 开发板和电机驱动电源必须共地,不共地时 PWM 信号没有参考电平,驱动芯片识别不到。这两个原因占了“逻辑对但电机不动”的绝大多数。
5.4 现象:小车一上电猛冲一下再正常
复位释放不同步。按键复位直接接到各模块的rst_n,松手瞬间有的模块先退出复位、有的后退出,PWM 计数器可能从一个随机值开始,输出一个宽脉冲。解决是加复位同步器:异步置位、同步释放,用两级触发器把复位释放沿对齐到时钟。这个改动很小,但能消掉上电瞬间的“抽风”。
5.5 现象:编译报错“Can't place multiple pins assigned to pin”
引脚重复分配。常见于改了顶层端口名但旧约束没删,.qsf里同一个引脚被分配了两次。打开 Assignments → Pin Planner 看有没有红色冲突,或者直接在.qsf里搜引脚号,把重复的行删掉。改完记得重新全编译,只做增量编译有时不会刷新引脚约束。
6. 二次开发:把固定逻辑改成可调参数的几个手法
这个工程跑通之后,最值得做的改造是把“写死的参数”变成“可配置的”,这样同一套代码能适配不同赛道和不同底盘。第一个手法是用parameter把关键常量提到模块端口上,比如pwm_gen的PERIOD、motor_drive的差速量、sensor_filter的消抖窗口,全部做成参数,顶层统一例化时传值。这样换一辆车只需要改顶层几个数字,不用翻遍每个模块。
第二个手法是加一个简单的速度档位选择。用板上的拨码开关输入两位,映射到三档base_duty(比如 400/550/700),跑不同赛道时现场切换。实现上就是在顶层加一个case把拨码翻译成base_duty,传给motor_drive。这个改动不到二十行,但调试效率提升明显——以前改速度要重新编译下载,现在拨一下就行。
第三个手法,如果想让循迹更平滑,可以把“左转/右转”的二值决策改成比例控制:根据压线传感器的位置算一个偏差值,偏差越大差速越大。具体做法是把sensor的 4 位编码映射到 -2 到 +2 的偏差,再乘以一个增益系数得到左右轮占空比差。这本质上是一个简化的比例控制,代码量不大,但过弯会顺很多。验证方法是架空车轮,手动移动黑线,看两轮转速差是否随偏差连续变化,而不是只有三档跳变。
改造时我有个习惯:每改一个参数,先在仿真里跑一遍tb_top,确认波形没异常再上板。仿真激励里我会把传感器序列写成“直道 → 左弯 → 直道 → 右弯 → 丢线”的固定模式,这样每次改动都有回归基准。从那以后我每次动决策逻辑或者 PWM 参数,都强制走一遍这个仿真序列再下载,省下来的上板排查时间远比写测试平台多。希望这套拆解能帮你把自己的小车先跑起来,再改成顺手的样子。
本文还有配套的精品资源,点击获取