简介:面向电子信息、自动化等专业本科毕业设计的FPGA数据采集系统完整说明书,适合正在做数据采集类课题、需要参考系统方案与硬件电路设计的学生。文档围绕64路0~5V模拟信号、8路无源开关量及1路TTL或12V数字脉冲信号的实时采集与存储展开,从绪论中的研究背景、国内外现状与发展趋势,到整体设计方案、系统硬件结构、模拟信号采集通道设计逐层推进,涉及电压跟随器、多路模拟开关、A/D转换(AD9221)、FPGA中心控制逻辑、存储器模块、隔离与电平转换电路以及并口通信接口等具体环节,并讨论了实时性、可靠性、灵活性与可扩展性等设计考量。压缩包内为1个doc文件,约526KB,以Word文档形式呈现,篇幅紧凑、目录完整,可作为课题开题、方案论证与硬件框图绘制的参照。目前已有638人学习,适合作为数据采集系统入门与毕设写作的参考材料。
1. 一块自制采集板上电后数据全是直线:这套基于 FPGA 的数据采集系统要解决什么
去年帮人看一块自制的采集板,64 路模拟量接进去,上位机读回来是一条直线——问题不在 A/D,而在通道切换和 Flash 写入的时序打架。这类「多通道 + 脱机存储 + 事后回读」的需求,用 PLC 加现成采集卡拼往往又贵又笨。这套基于 FPGA 的数据采集系统就是把通道轮询、A/D 时序、NAND Flash 读写全塞进一片 FPGA:64 路 0~5V 模拟量、8 路无源开关量、1 路 TTL 或 12V 脉冲,采完先存 Flash,任务结束再通过并口回读到 PC。适合正在做采集板、存储测试或想把分立逻辑换成单片 FPGA 的开发者。
2. 系统架构拆解:四段式链路里每个器件为什么是它
先把整块板子看成一条流水线,而不是一堆芯片。信号从传感器进来,先被调理成 A/D 或 FPGA 能接受的电压形态,再被数字化,然后落到非易失存储器里,最后通过接口搬回 PC 分析。这四段之间的边界定得清不清楚,直接决定后面调试时你是改一行约束就能解决,还是要把板子重新投一次。这一章先把架构和选型讲透,具体电路细节留到第 3 章。
2.1 采集、控制、存储、接口:四块的分工与数据流
采集部分按信号类型拆成三条独立通道,这是整个设计里最关键的一次取舍。模拟量 64 路,如果每路配一套放大加 A/D,光 ADC 就得 64 片,体积和成本都不可接受,所以走的是「模拟开关分时复用 + 单片 A/D」的路子;数字脉冲量只有 1 路,但要兼容 TTL 和 12V 两种电平,所以中间加了一级钳位调压;开关量 8 路是无源的,必须做电气隔离才能和执行机构共地,否则地电位差会直接把 FPGA 的 IO 打死。
中心控制模块是一片 FPGA 加配置 PROM 和供电,它同时扮演四个角色:给 A/D 提供采样时钟、给模拟开关提供通道地址、给 Flash 提供命令和地址时序、给并口提供握手信号。一个器件承担全部时序,好处是各环节的相位关系你自己说了算,坏处是所有时序都得你写对,没有现成驱动兜底。
存储模块用 NAND Flash,数据先写进片内 FIFO 缓冲,再按页搬进 Flash。接口模块用并口,虽然现在看起来偏老,但它胜在 FPGA 侧几乎不需要协议栈,几根控制线加一组数据线就能跑,调试时拿逻辑分析仪一夹就能看清每一个字节。
整条链路的数据流是这样的:模拟开关切换到第 N 通道,等信号稳定,A/D 在采样时钟上升沿转换,12 位并行数据在下一个时钟被 FPGA 锁进寄存器,和通道号拼成一帧写进 FIFO;FIFO 攒够一页(2112 字节)就触发一次 Flash 页编程。数字量和开关量走另一条路,先在 FPGA 内部做串并转换和位宽对齐,再进同一个 FIFO。两条路在 FIFO 入口汇合,出口只有一个,这套设计里所有复杂度的根源都在这里。
提示:FIFO 是这套系统的黑匣子,只要它不溢出,前端时序有点小抖动都能被吸收掉;一旦溢出,你看到的是成片丢帧而不是偶发错误,反而好定位。
2.2 选型账本:从 ADG506 到 K9F1G08,每个器件在还什么债
选型这块我把当时的判断整理成一张表,参数都是手册里的典型值,实际做板子时建议按最差条件再核一遍。
| 器件 | 关键指标 | 在系统中的角色 | 选它的理由 |
|---|---|---|---|
| LM324 | 四运放,单电源,输入共模含地 | 电压跟随、提高输入阻抗 | 一片顶四路,成本低,把高阻信号源和模拟开关隔开 |
| ADG506 | 16 选 1,导通电阻约 200Ω,漏电 20pA,切换约 200ns | 64 路模拟量分四组切换 | 漏电小,多片并联时通道间串扰可控;切换速度远快于采样周期 |
| AD9221 | 12 位,最高 1.5MSPS,流水线结构,单 +5V 供电,并行输出 | 模拟量数字化 | 输出兼容 TTL,可直接挂 FPGA 的 IO,不需要外部基准芯片 |
| XC2S50 | Spartan-II 系列,5 万门级 | 中心控制 | 片内 Block RAM 够做 1K×18 的 FIFO,IO 数量撑得住 64 路地址加 Flash 控制线 |
| XC18V01 | 配置 PROM | 上电加载 FPGA | 掉电不丢配置,现场不用连下载器 |
| K9F1G08 | 128M×8 bit,NAND,页 2K+64B,3.3V | 数据存储 | 单位容量成本低,页编程适合连续采集的批量写入 |
| 74HC14 | 六路施密特反相器 | 开关量整形与驱动 | 把缓慢或带毛刺的边沿整成陡沿,避免 FPGA 采到中间电平 |
| 光电耦合器 | CTR 典型 50%~600% | 开关量电气隔离 | 断开地环路,隔离电压可达千伏级 |
这张表里最容易被低估的是 ADG506 的漏电参数。64 路意味着四片 16 选 1 并在一起,每片断开的 15 个通道都在往公共输出节点漏电流,20pA 乘以 45 路,量级上还在皮安到纳安之间,对 12 位、1.22mV/LSB 的量化精度来说基本可以忽略。但如果换成漏电大的廉价模拟开关,几十纳安漏到高阻节点上,就会表现为「某一通道读数总是偏高几十个码值」,而且换通道才复现,非常难查。
AD9221 选它的核心理由是接口简单。流水线结构内部有采样保持和数字校正,外部只需要给时钟、给模拟输入、接参考,12 位数据就并行出来了,不用配寄存器、不用走 SPI 时序。代价是它的数据有固定的流水线延迟,你必须在正确的时钟沿去取,晚一拍早一拍拿到的都是相邻通道的数据。
2.3 工程目录与开发环境:ISE 工程怎么摆
开发环境是 ISE 配合 Verilog,综合、布局布线、生成 bit 流,再用下载线烧进配置 PROM。工程目录我按职责分层,避免所有模块糊在一个文件里:
daq_fpga/ ├── rtl/ │ ├── daq_top.v # 顶层:例化子模块并连线 │ ├── clk_gen.v # 时钟分频与各类使能产生 │ ├── ad9221_ctrl.v # A/D 采样时钟输出与 12 位数据接收 │ ├── ch_scan.v # 通道地址计数器,驱动 4 片 ADG506 │ ├── dig_sw_capture.v # 数字脉冲与开关量的串并转换 │ ├── afifo.v # 异步 FIFO,跨时钟域缓存 │ └── nand_ctrl.v # NAND Flash 命令/地址/数据状态机 ├── sim/ │ ├── tb_daq_top.v # 顶层联调激励 │ └── tb_nand_ctrl.v # Flash 状态机单独验证 ├── con/ │ └── daq_top.ucf # 引脚约束:时钟、AD 数据总线、Flash 控制线 ├── ip/ │ └── fifo_1k8x16/ # 由 IP 核生成的 FIFO └── doc/ ├── pinmap.xls # 引脚分配表,画板前就要定死 └── timing.md # 关键时序换算记录con目录下的约束文件是新手最容易忽略的地方。FPGA 的引脚分配不是写代码时决定的,而是在约束文件里绑定的,一旦板子画完再改引脚,等于重新飞线。我的习惯是画 PCB 之前先把pinmap.xls定稿,哪个 IO 接 AD 的哪一位、哪个 IO 接 Flash 的 CLE 和 ALE,全部写死,代码里的端口名和表格一一对应,出问题直接查表。
sim目录单独验证 Flash 状态机是有必要的。NAND 的时序错一个周期,表现是「偶尔写进去读不出来」,而在板子上你没法单步看状态机走到哪一步,只能靠仿真一遍遍跑。仿真里把 R/B 信号手动拉低若干个周期模拟忙状态,比在硬件上碰运气高效得多。
3. 三条采集通道的电路细节:模拟、脉冲、开关量各走各的路
三条通道的设计思路完全不同,因为它们面对的信号性质不同。模拟量怕的是阻抗和串扰,脉冲量怕的是电平和过压,开关量怕的是地环路和慢边沿。这一章按通道拆开讲,每一路都给到能被抄走的换算和取值方法。
3.1 模拟通道:跟随器、16 选 1 与 12 位量化的一条直线
信号从外部进来,第一站是 LM324 搭的电压跟随器。跟随器的电压增益是 1,看起来什么都没做,但它把信号源的高输出阻抗变成了运放的低输出阻抗,后面模拟开关的导通电阻和寄生电容就不会和信号源内阻形成分压和低通。这一级省掉,你会看到不同内阻的传感器接同一路,读数偏差不一样。
第二站是 ADG506。四片 16 选 1 并联,每片的使能端由 FPGA 单独控制,同一时刻只有一片被使能,被使能的那片再由 A0~A3 四个地址线选中其中一个通道。通道号的组织方式是{group[1:0], slot[3:0]},组选两位、片内地址四位,正好 64 路。
// 64 路模拟量分 4 组,每组一片 ADG506 做 16 选 1 // ch_id = {group[1:0], slot[3:0]},共 64 个通道 module ch_scan #(parameter DIV = 8)( input wire clk, // 采样节拍时钟 input wire rst_n, output reg [3:0] slot, // ADG506 的 A0~A3 output reg [1:0] group, // 组选,驱动 4 片 ADG506 的 EN output reg [5:0] ch_id, // 当前通道号,随数据一起打包 output reg ch_vld // 单周期脉冲,通知启动一次转换 ); reg [3:0] div_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 4'd0; slot <= 4'd0; group <= 2'd0; ch_id <= 6'd0; ch_vld <= 1'b0; end else if (div_cnt == DIV-1) begin div_cnt <= 4'd0; ch_vld <= 1'b1; // 切换完成,可以采了 if (slot == 4'd15) begin slot <= 4'd0; group <= group + 2'd1; // 一组扫完换下一组 end else begin slot <= slot + 4'd1; end ch_id <= {group, slot}; // 打包时带上通道号 end else begin div_cnt <= div_cnt + 4'd1; ch_vld <= 1'b0; end end endmoduleDIV这个参数决定了通道切换后到下一次采样之间等了多久。ADG506 的切换时间在 200ns 量级,但真正需要等的是后级运放和 A/D 内部采样保持电容的建立时间,典型做法是给几百纳秒到几微秒。DIV取小了,通道之间会串味,表现为相邻通道的读数互相靠拢;取大了,总采样率掉下来,64 路扫一遍的时间变长。我在 50MHz 输入时钟下一般先取DIV = 64(约 1.28μs 一步),测出串扰后再往下压。
第三站是 AD9221。输入范围 0~5V,12 位,理想情况下每个码值对应 5/4096 ≈ 1.22mV。上位机拿到原始码值后要做的第一件事就是换算成电压:
# AD9221:12 位,单端输入 0~5V,理想传输特性 VREF = 5.0 def code_to_volt(code, vref=VREF, bits=12): lsb = vref / (1 << bits) # 1.2207 mV/LSB return code * lsb print(code_to_volt(0x800)) # 0x800 = 2048,约 2.5006V print(code_to_volt(0xFFF)) # 满量程附近,约 4.9988V这个换算是理想值。实际器件有偏移误差和增益误差,绝对误差在 ±1/2 LSB 以内算正常。如果你的系统对绝对精度有要求,别只信这个公式,拿标准源打两个点(比如 0.5V 和 4.5V),在软件里做一次两点校准,把偏移和斜率都修正掉,比在硬件上调电位器省事。
还有一个细节值得说:AD9221 是流水线结构,从时钟沿到数据有效有固定的延迟周期,而且内部有多级流水,前一级在转换时后一级在输出。写接收逻辑时,数据寄存必须严格对齐这个延迟,否则你拿到的永远是上一个通道的值。判断是否对齐最简单的办法是给某个通道接一个已知的直流电平,其余通道悬空,看数据是不是只在那一个通道号上出现。
3.2 数字脉冲通道:3.3V 钳位加限流电阻,别让 12V 直接进 IO
数字脉冲这一路要求兼容两种输入:TTL 电平(0/5V)和低电平 0V、高电平 12V 的脉冲。麻烦在于后级存储和 FPGA 的 IO 都是 3.3V 域,12V 直接怼进去就是一次性器件。
方案是钳位加分压的组合:输入端串一个限流电阻,节点上并一个 3.3V 稳压管到地。输入高于 3.3V 时稳压管反向击穿,把节点电压钳在 3.3V 附近,多余的压差落在限流电阻上;输入低于 3.3V 时稳压管不导通,电压原样传过去。限流电阻的取值要同时满足两个条件:正常 12V 输入时,流过稳压管的电流不能超过它的额定功耗;TTL 高电平 5V 输入时,电阻上的压降不能大到把高电平拉到阈值以下。
# 钳位电路限流电阻估算 VIN_MAX = 12.0 # 最高输入电压 VZ = 3.3 # 稳压管钳位电压 IZ_MAX = 5e-3 # 稳压管允许电流,取 5mA(保守) R_MIN = (VIN_MAX - VZ) / IZ_MAX print(R_MIN) # 约 1.74k,实际取 2k 更安全 # 5V TTL 高电平时,电阻上压降是否可接受 R = 2000 I_LEAK = 1e-6 # 后级输入漏电,取 1uA print(5.0 - I_LEAK * R) # 几乎无压降,高电平保持实际做的时候还要考虑稳压管的结电容,它和限流电阻构成一个低通,脉冲边沿太陡、频率太高时会被削顶。如果你的脉冲信号频率在兆赫兹以上,这个方案就不合适了,得换成专用的电平转换芯片或者高速比较器加钳位二极管。
注意:钳位电路的稳压管方向和普通整流二极管相反,接反了整条通道的输入直接被短路到地,上电就烧前级。
3.3 开关量通道:光耦隔离加施密特整形,上拉电阻要算不能猜
8 路无源开关量进来,首先要做电气隔离。无源触点的两端可能挂在离控制板很远的现场设备上,两地之间的地电位差、现场电磁干扰都会顺着导线爬进 FPGA。光电耦合器把电信号转成光再转回电,输入输出之间没有电气连接,隔离电压可以做到千伏级,这是最省事也最可靠的隔离手段。
光耦的接法是:输入侧发光二极管串限流电阻,输出侧光敏三极管集电极通过上拉电阻接电源,发射极接地,集电极节点就是输出。输入低电平时二极管不亮,三极管截止,输出被上拉为高;输入高电平时二极管发光,三极管饱和导通,输出被拉到低。所以这一级天然带反相功能,写逻辑的时候别忘了这一点。
上拉电阻的取值有个容易翻车的地方。光敏三极管能拉多少电流,取决于输入侧的驱动电流和器件的电流传输比 CTR,而 CTR 是有批次离散的,手册上通常给一个范围,最小值可能只有 50%。按最差情况算:
# 光耦输出侧上拉电阻估算,保证低电平时能拉到 0.4V 以下 VCC = 5.0 I_F = 5e-3 # 发光二极管正向电流,取 5mA CTR = 0.5 # 按最差情况取最小值 I_C = I_F * CTR # 光敏管可拉电流 2.5mA VOL = 0.4 # 期望的低电平上限 R_MAX = (VCC - VOL) / I_C print(R_MAX) # 约 1.84k,实际取 1k 留余量取 1k 之后,高电平时的拉电流能力也还够。如果上拉电阻取太大,比如 10k,低电平时光敏管拉不下去,输出停在 1V 到 2V 的中间电平,后级施密特触发器可能识别成高也可能识别成低,表现为「这一路时好时坏」,而且换一块板子现象就变,典型的玄学故障。
光耦输出之后接 74HC14。它是带施密特输入的六反相器,特点是上升沿和下降沿的翻转阈值不一样,中间有个回差带。缓慢变化或者带毛刺的输入边沿经过它之后会变成陡峭的方波,同时在数字电路里起到整形和增强驱动的作用。这里又反相了一次,所以整条链路「光耦反相 + 74HC14 反相」正好抵消,逻辑极性回到正相,这个巧合让代码写起来舒服不少。
8 路开关量进入 FPGA 后要做的第一件事是同步。异步信号直接进状态机,第一个时钟沿就可能采到亚稳态,表现为随机跳变。常见做法是打两拍同步,再加一个简单的消抖计数器,只有连续 N 个时钟采样值一致才认为状态真的变了。开关量本身变化慢,消抖窗口给到毫秒级完全不影响使用。
4. FPGA 核心逻辑:从通道轮询到 Flash 写入的状态机
前面三章讲的是信号怎么进来,这一章讲进来之后怎么管。FPGA 内部要做四件事:产生各类时钟和使能、驱动通道轮询、把三路数据打包进 FIFO、按 NAND 时序把数据搬进 Flash。这四件事在代码里是四个模块,但在时序上是一个整体,任何一个环节的延迟算错,数据就会错位。
4.1 时钟树:一个晶振分出的三种节拍
板上只有一个晶振,所有节拍都从它分频出来。我这套设计里输入时钟取 50MHz,分频出三类信号:A/D 采样时钟、通道切换使能、Flash 操作节拍。
A/D 采样时钟不能随便给。AD9221 最高 1.5MSPS,也就是时钟最快 1.5MHz,超过这个频率器件就不保证输出正确。用 50MHz 分频,分频系数至少是 34,实际取 40 得到 1.25MHz,留一点余量。这个时钟既要送给 A/D 的 CLK 引脚,也要作为 FPGA 内部接收数据的节拍,两者必须是同一个信号,不能一个用分频后的时钟、一个用原始时钟,否则跨时钟域会让你多花两天时间。
Flash 操作节拍和采样时钟完全无关。NAND 的命令、地址、数据都是在 WE 的上升沿锁存的,WE 的高低温时间有最小值要求,通常几十纳秒量级。稳妥的做法是单独做一个 Flash 专用的节拍使能,比如每 8 个 50MHz 周期产生一个使能脉冲,状态机只在使能有效时走一步,这样 WE 的脉宽就固定了。
通道切换使能是第三种节拍,由第 3 章那个ch_scan模块的DIV参数决定。它和 A/D 采样时钟之间是有先后关系的:先切换通道,等信号建立,再启动一次转换。这个先后关系如果搞反了,采到的永远是上一个通道的值,而且数据看起来「挺正常」,最难查。
// 时钟与使能产生:一次分频,三种用途 module clk_gen( input wire clk_50m, input wire rst_n, output reg ad_clk, // 送 AD9221 的 CLK,1.25MHz output reg flash_en, // Flash 状态机步进使能 output reg sample_en // 采样节拍,给数据打包用 ); reg [4:0] div_ad; // 40 分频 reg [2:0] div_fl; // 8 分频 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin div_ad <= 5'd0; div_fl <= 3'd0; ad_clk <= 1'b0; flash_en <= 1'b0; sample_en <= 1'b0; end else begin // A/D 时钟:50MHz / 40 = 1.25MHz,占空比 50% if (div_ad == 5'd19) begin ad_clk <= ~ad_clk; div_ad <= (div_ad == 5'd39) ? 5'd0 : div_ad + 5'd1; sample_en <= (div_ad == 5'd39); // 每 40 拍一个采样脉冲 end else begin div_ad <= div_ad + 5'd1; sample_en <= 1'b0; end // Flash 节拍:50MHz / 8 = 6.25MHz 的使能 div_fl <= div_fl + 3'd1; flash_en <= (div_fl == 3'd7); end end endmodulesample_en这个信号在打包逻辑里当写使能用,宽度只有一个时钟周期,正好对应一次采样完成。如果把它做成电平信号,FIFO 会在一个采样周期内被写进去几十次,读回来数据全乱。这种宽度问题在仿真里一目了然,但在板子上表现为「数据量是预期的几十倍」,容易误判成 FIFO 深度不够。
4.2 数据打包与 FIFO:三路信号怎么汇合成一条流
三路信号的数据宽度不一样:模拟量 12 位加 6 位通道号,数字脉冲 1 位,开关量 8 位。直接塞进一个 FIFO 会带来位对齐的麻烦,所以我在打包时统一成 16 位:高 6 位放通道号或者类型标识,低 10 位到 12 位放数据。这样每帧固定两个字节,Flash 页大小 2112 字节正好是 1056 帧,页边界对齐,回读时不用考虑跨页拼接。
帧格式我定成这样的:最高 2 位表示数据类型(00 模拟、01 数字脉冲、10 开关量),接着 6 位是通道号,低 8 位或 12 位是数据。模拟量占满 12 位,其他两类只用低位。
// 三路数据打包成 16 位帧,写进异步 FIFO module packer( input wire clk, input wire rst_n, input wire sample_en, // 采样节拍 input wire [11:0] ad_data, // 模拟量 input wire [5:0] ch_id, // 当前通道号 input wire dig_pulse, // 数字脉冲,已同步 input wire [7:0] sw_state, // 开关量,已消抖 input wire fifo_wr_rdy, // FIFO 未满 output reg fifo_wr, output reg [15:0] fifo_data ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin fifo_wr <= 1'b0; fifo_data <= 16'd0; end else if (sample_en && fifo_wr_rdy) begin fifo_wr <= 1'b1; // {类型2位, 通道号6位, 数据12位} → 截成16位 fifo_data <= {2'b00, ch_id, ad_data[11:4]}; end else begin fifo_wr <= 1'b0; end end endmodule上面这段是精简示意,实际代码里 16 位放不下「2 位类型 + 6 位通道 + 12 位数据」共 20 位,所以要么扩到 24 位帧,要么按前面说的拆成两个 16 位字。我最终用的是 24 位帧、3 个字节一组,FIFO 位宽 24 位,深度 1024,一次缓冲 3072 字节,比一个 Flash 页多一点,剩余空间用来吸收 Flash 忙期间的写入。
数字量和开关量不是每个采样周期都变,所以它们的打包策略是「变化即写」,在状态跳变时插一帧,模拟量则按固定节拍连续写。这样上位机回读时,模拟量是等间隔的连续曲线,开关量是带时间戳的事件,两类数据分开处理,画图也方便。
4.3 NAND Flash 状态机:命令、四个地址周期、等 R/B
K9F1G08 的写操作不是「给地址塞数据」这么简单,它有固定的命令序列。页编程的完整流程是:写 0x80 命令,写两个列地址周期和两个行地址周期,然后连续写 2112 个字节的数据,再写 0x10 启动编程,最后等 R/B 引脚从低变高,表示编程完成。
难点在地址周期。因为 IO 口只有 8 根线,命令、地址、数据全走同一组 IO,靠 CLE 和 ALE 两根信号区分。CLE 高时 IO 上是命令,ALE 高时是地址,两者都低时是数据。这个复用机制省了引脚,但也意味着任何一个信号的时序错一拍,后面的全部作废。
// K9F1G08 页编程精简状态机,只保留主干 localparam S_IDLE = 3'd0, S_CMD = 3'd1, S_ADDR = 3'd2, S_DATA = 3'd3, S_EXEC = 3'd4, S_WAIT = 3'd5, S_DONE = 3'd6; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; cle <= 1'b0; ale <= 1'b0; we_n <= 1'b1; ce_n <= 1'b1; end else if (flash_en) begin // 每 8 拍走一步 case (state) S_IDLE: if (wr_req) begin ce_n <= 1'b0; state <= S_CMD; end S_CMD: begin cle <= 1'b1; io <= 8'h80; // 页编程命令 we_n <= 1'b0; // WE 上升沿锁存 state <= S_ADDR; end S_ADDR: begin cle <= 1'b0; ale <= 1'b1; // 依次送:列地址低、列地址高、行地址低、行地址高 // 每送一字节,we_n 拉低再拉高 if (addr_cnt == 2'd3) begin ale <= 1'b0; state <= S_DATA; end end S_DATA: begin // 连续写 2112 字节,每字节一拍 we_n if (byte_cnt == 11'd2111) state <= S_EXEC; end S_EXEC: begin cle <= 1'b1; io <= 8'h10; // 启动编程 we_n <= 1'b0; state <= S_WAIT; end S_WAIT: if (rb) state <= S_DONE; // R/B 变高说明编程结束 S_DONE: begin ce_n <= 1'b1; state <= S_IDLE; end endcase end end这段代码里最关键的一行是S_WAIT: if (rb) state <= S_DONE;。很多新手在这里出问题,原因是把 R/B 当成了立即可用的信号。器件刚收到 0x10 命令时,R/B 需要一小段时间才被拉低,如果在这之前就去判断,可能读到的是上一次操作留下的高电平,误以为编程已完成,紧接着发下一条命令,时序就彻底乱了。稳妥的做法是在进入S_WAIT后先固定等若干个周期,再开始检测 R/B 的下降沿,确认进入忙状态,然后再等上升沿。这是一条血泪经验,我在第一批样板上就是这里翻车的。
还有一点,K9F1G08 的每页除了 2048 字节主数据区,还有 64 字节的 spare 区,用来存坏块标记和 ECC 校验。采集系统至少要在这里存坏块标志,否则某天 Flash 出现坏块,你的数据会在某个位置开始整体错位,而且回读时完全看不出原因。
数据回读到上位机之后,还需要一层解包:
# 回读的原始字节流解包,每 3 字节一帧 def unpack(buf, frame=3): pts = [] for i in range(0, len(buf) - frame + 1, frame): tag, hi, lo = buf[i], buf[i+1], buf[i+2] kind = tag >> 6 # 00 模拟 / 01 数字 / 10 开关 ch = (tag >> 2) & 0x0F # 通道号 val = ((tag & 0x03) << 12) | (hi << 4) | (lo >> 4) pts.append((kind, ch, val)) return pts解包函数的位运算必须和 FPGA 侧的打包逻辑逐位对应。我习惯把两边的位分配写成一张表贴在显示器边上,改一边就同步改另一边,否则调起来就是无穷无尽的猜。
5. 上板排查:五条踩坑记录,从没波形到数据对不上
这套系统在仿真里跑通和在实际板子上跑通是两件事。下面五条是我和身边人真实踩过的,按「现象 → 原因 → 解决」写,希望你能少走几步。
5.1 所有通道读数都是 0x000 或 0xFFF
现象:A/D 输出要么全 0,要么全是满量程,换通道也不变。
原因:模拟开关的使能端没接对,四片的 EN 全部为低,公共输出节点悬空,A/D 采到的是浮空电平。另一种可能是模拟开关的地址线接反了高位低位,导致选中的通道和预期不符,但如果所有通道都是同一个极端值,基本是使能问题。
解决:先用万用表量被使能那片的输出引脚,看电压是否跟随输入变化;再用逻辑分析仪抓 FPGA 输出的 group 和 slot 信号,确认地址在动。如果地址在动而输出不动,查使能译码逻辑;如果地址根本不动,查ch_scan模块的复位和时钟。
5.2 相邻通道互相串味,读数往中间靠
现象:给通道 0 接 4V、通道 1 接 0V,结果两个通道读出来都在 2V 附近。
原因:通道切换后没有等待建立时间。ADG506 切换 200ns 就完成,但后级运放的输入电容、A/D 内部采样保持电容都需要时间充电,切换后立刻启动转换,采到的是上一通道残留电压和新通道电压的混合。
解决:把ch_scan的DIV参数调大,从 64 试到 256,观察串扰是否消失。还有一个更彻底的办法,在模拟开关输出和 A/D 输入之间加一级跟随器做缓冲,把充电电流的路径和采样节点隔开。代价是多一个运放和一点板面积。
5.3 Flash 写进去的数据读出来整体错位
现象:写了 10 页数据,回读时第一页正常,从第二页开始每个字节都往后偏了几个位置。
原因:页编程命令发出后没正确等 R/B,上一条编程还没完成就发了下一条命令,器件把新命令当成了上一条的延续,写指针没归位。也可能是地址周期少发了一个,行地址高字节没送出去,导致每次写都落在同一页。
解决:在S_WAIT状态里加固定延时再检测 R/B 的下降沿,确认进入忙状态后再等上升沿;用逻辑分析仪抓完整的命令序列,逐个数地址周期是不是四个。另外强烈建议在 spare 区写页号和校验,回读时先验页号,错位能立刻发现。
5.4 开关量某几路时好时坏,换块板子现象就变
现象:同一路开关量,有时候读 0 有时候读 1,用手碰一下板子现象还会变。
原因:光耦输出侧的上拉电阻取值偏大,光敏管在最小 CTR 下拉不下去,输出停在中间电平,后级施密特触发器在阈值附近随机翻转。板子之间的 CTR 离散让现象看起来毫无规律。
解决:按最差 CTR 重算上拉电阻,取 1k 左右;同时确认光耦输入侧的限流电阻让正向电流落在 5mA 到 10mA 之间。改完之后在中间电平上再测一次,如果还停不住,说明光耦本身选型不合适,换 CTR 下限更高的型号。
5.5 并口回读偶尔丢一段数据
现象:几兆字节的数据回读,中间偶尔少几十个字节,重传又好了。
原因:并口的握手没做严格。PC 端读数据的速度和 FPGA 送数据的速度不匹配,FPGA 在 PC 还没取走上一个字节时就覆盖了输出寄存器。并口虽然简单,但 nStrobe 和 Busy 这两根线的时序必须严格遵守。
解决:FPGA 侧加一个「等应答」状态,送出数据后拉高标志位,必须等到 PC 拉低应答才允许送下一个字节;PC 侧驱动把超时时间调长,别用太激进的轮询间隔。这一条是我最后才查出来的,因为丢字节的位置随机,一开始一直怀疑是 Flash 的问题。
6. 二次开发:把并口换成 USB、把采样率翻倍的改造路线
这套设计的并口在当年够用,放到现在就是明显的短板:台式机主板上的并口越来越少,笔记本基本没有,而且并口的实际吞吐在几兆字节每秒量级,回读大容量数据要等很久。真正要拿它做产品或者继续往下做,第一刀应该切在接口上。
改造路线我按投入产出比排了三档。最省事的是 USB FIFO 方案,用一颗 FT245 类的芯片,FPGA 侧只需要把原来的并口时序换成「写使能 + 满标志」两根线,PC 侧换成一个虚拟串口或者专用驱动,改动量集中在一个模块里,一两天能通。中等投入是以太网,好处是可以脱离 PC 独立联网上传,坏处是 FPGA 里要实现精简的 ARP 和 UDP 协议栈,UDP 包丢了还得自己做序号校验。最省心的反而是 SD 卡,脱机存卡,现场取卡读数据,适合不方便布网的场景。
| 原方案 | 升级方案 | 关键器件 | 实测带宽量级 | 主要改动点 |
|---|---|---|---|---|
| 并口 EPP | USB2.0 FIFO | FT245 类芯片 | 数 MB/s | 替换接口模块,PC 侧换驱动 |
| 并口 EPP | 百兆以太网 UDP | 带 MAC 的 FPGA 或独立控制器 | 十 MB/s 理论值 | 需实现精简协议栈与丢包重传 |
| 并口 EPP | SPI 模式 SD 卡 | 普通 TF 卡 | 低,但脱机 | 增加 FAT32 写入逻辑 |
第二刀是采样率。AD9221 的上限是 1.5MSPS,想再快就得换器件,比如换成并行输出更宽、速率更高的型号。但换 A/D 之前先算一笔账:64 路按 1.5MSPS 轮询,每路实际速率只有 23kSPS;真正限制总吞吐的往往不是 A/D,而是 NAND 的页编程时间。K9F1G08 页编程典型 300μs 写 2112 字节,折算下来持续写入能力在 7MB/s 左右,如果 A/D 输出 12 位、每帧 2 字节,理论峰值 3MB/s,看着还有余量,但那是理想值,实际还要叠加坏块处理、ECC 计算和跨页边界。
所以采样率往上提的正确顺序是:先在 FPGA 内部加一块 SRAM 或者用片内 Block RAM 做双缓冲,把 Flash 的写入抖动吸收掉;再考虑换 A/D;最后才是换更大页、更快接口的存储器件。跳过缓冲这一层直接提采样率,你会遇到「前几百毫秒数据正常,之后开始丢帧」的现象,一开始容易怀疑 A/D,其实是 FIFO 满了以后写指针被强制复位。
验证方法我总结成一句话:先看原始码值,再看解析结果,最后才画波形。给某个通道接一个 1kHz 的方波或者标准正弦,用信号源保证幅度准确,先把上位机收到的原始 12 位码值打印出来,看跳变点是不是干净、幅度是不是对应;确认无误后再走换算式和帧解析;两步都对上了,才去看 FFT 或者曲线图。跳过前两步直接看波形,出了问题你分不清是采集错、传输错还是画图错。
改造过程中有个习惯我一直保留:每次换接口或者改帧格式,都在doc目录下更新那张位分配表,并且把新旧两版解析脚本都留着,用同一段原始数据分别跑,对比输出是否一致。没有这步对照,改完以后数据「看起来对」但其实是错的,这种问题能埋很久。希望帮到你。
本文还有配套的精品资源,点击获取