1. 从激光打标机里那块“不听话”的板子说起
如果你拆过工业激光打标机、激光焊接机或者激光雷达的扫描头,大概率会在振镜电机屁股后面看到一根二十来根线的排线,另一端连着一块巴掌大的驱动板。这块板子干的事很专一:把上位机发来的“往左偏 3.2 度、往右偏 1.8 度”这种指令,翻译成振镜电机能听懂的模拟电流信号。而在这块板子和上位机之间跑的那套通信规矩,十有八九就是XY2-100。
我第一次接触 XY2-100 是在一个激光打标项目上,当时用一块通用 FPGA 板卡去对接某国产振镜,上位机发指令过去,镜子要么不动,要么乱抖。示波器一挂,发现时钟和数据相位完全对不上——问题就出在对这套协议时序的理解上。后来把协议啃透、用 Verilog 重写了一遍收发逻辑,才算彻底稳住。所以这篇东西不打算写成一份干巴巴的协议手册,而是按我实际踩坑的顺序,把XY2-100 协议、激光振镜控制原理、以及Verilog/FPGA实现这三块串起来讲清楚。
XY2-100本质上是一个面向数字振镜的同步串行通信协议,规定了时钟、同步信号、数据三根线的时序关系,以及 16 位位置数据的编码方式。它解决的问题很具体:让控制器用一套标准接口去驱动不同厂家的振镜,而不用每家都写一套私有驱动。适合谁看?做激光加工设备、3D 打印扫描头、激光雷达、光学扫描系统的嵌入式工程师和 FPGA 开发者,尤其是那些手里有振镜但被时序折磨过的人。下面我会从协议帧结构讲到 Verilog 状态机,再到实测中那些文档里不会写的坑。
2. XY2-100 协议帧结构:三根线里到底塞了什么
2.1 时钟、同步、数据三线制的分工
XY2-100 是同步串行接口,物理层就三根关键信号线:CLOCK(时钟)、SYNC(同步/帧同步)、DATA(数据)。有的实现还会带一根使能或方向线,但核心就是这三根。理解它的关键,是把这三根线想象成一支三人乐队:时钟是节拍器,同步是每小节的第一拍重音,数据是乐手按节拍吐出的音符。
时钟频率典型值是2MHz,也就是说一个时钟周期 500ns。整个一帧数据由20 个时钟周期组成,这 20 个周期里塞进了两个通道(X 和 Y)各 16 位的位置信息,外加一些控制位。注意,20 个周期不是随便定的,它是协议规定的固定帧长,多一个少一个振镜都不认。
同步信号 SYNC 的作用是“划重点”:它在一帧的起始位置拉高(或拉低,取决于具体实现约定),告诉振镜“新的一帧开始了,准备接收”。数据线 DATA 则在时钟的边沿被采样,逐位把 20 位数据送出去。这里有个容易搞混的点:数据是在时钟的上升沿还是下降沿有效,不同厂家文档写法不一,实测下来大多数振镜是在时钟上升沿采样数据,所以 FPGA 侧应该在下降沿更新数据,保证上升沿时数据已经稳定。
2.2 20 位帧里 X、Y 和控制位怎么排
一帧 20 位,具体怎么分配是很多人第一次看协议最懵的地方。常见的分配方式是:第 0 位到第 15 位中的一部分给 X 轴,另一部分给 Y 轴,中间还夹着状态位和控制位。不同资料给的位序略有差异,但主流的一种排布是这样的:
| 位序号 | 含义 | 说明 |
|---|---|---|
| 0 | 控制位/状态位 | 部分实现用作使能或标志 |
| 1-8 | X 轴低 8 位 | X 位置数据的一部分 |
| 9-16 | Y 轴低 8 位 | Y 位置数据的一部分 |
| 17-19 | 高位/校验/控制 | 补齐 20 位,含高位或握手位 |
需要强调的是,XY2-100 的位置数据是 16 位精度,对应振镜的满量程偏转范围。也就是说,0 到 65535 这 65536 个码值,线性映射到振镜的机械偏转角度范围(比如 ±20 度)。这里就引出一个实操中必须算清楚的换算:如果你的振镜满量程是 ±20 度,那么 1 个码值对应的角度是 40/65536 ≈ 0.00061 度。上位机给你一个“偏转 5 度”的指令,你得先算出对应的码值 5/0.00061 ≈ 8197,再把这个数拆成高低位塞进帧里。算错这一步,镜子要么偏得离谱,要么直接撞限位。
2.3 为什么是 16 位而不是 12 位或 20 位
这个问题我在项目评审时被问过。答案其实很朴素:16 位是精度和成本的平衡点。激光打标的分辨率要求通常在微米级,对应到振镜角度就是千分之几度,12 位(4096 级)在大幅面扫描时会出现明显的“台阶感”,也就是相邻两个码值之间的角度跳变肉眼可见。而 20 位虽然更精细,但对 DAC 和电机驱动电路的噪声要求极高,低位数据很容易被噪声淹没,实际有效精度上不去,反而浪费带宽。16 位刚好卡在“够用且稳定”的区间,这也是它成为行业事实标准的原因。
另外,16 位数据在 FPGA 里处理起来特别顺手——正好两个字节,移位、拼接、跨时钟域同步都很规整。如果你用 Verilog 写,一个reg [15:0]就能装下,不需要做复杂的位宽对齐。这种“工程上的顺手”往往比理论上的最优更重要,做硬件的都懂。
3. 激光振镜为什么要用这套协议,而不是 SPI 或 I2C
3.1 振镜控制的实时性要求有多苛刻
激光振镜不是普通电机,它要在毫秒甚至微秒级完成角度调整,而且要求极高的重复定位精度。打标一条曲线,实际上是振镜在极短时间内走出一连串微小线段,每个线段的端点都要精确到位。如果通信协议有哪怕几十微秒的抖动,画出来的线就会毛糙、拐角发圆。
SPI虽然快,但它是主从结构,片选、模式配置一堆事,而且没有天然的“帧同步”概念,多轴同步要靠软件凑。I2C更不行,速率低、总线仲裁复杂,根本扛不住振镜这种高频刷新。XY2-100 的设计哲学就是“简单粗暴”:固定帧长、固定时序、三根线、无需寻址、无需握手,控制器想发就发,振镜只管收。这种极简结构带来的确定性,正是实时控制最需要的。
3.2 固定帧长带来的确定性优势
固定 20 位帧长意味着每一帧的传输时间是完全可预测的。2MHz 时钟下,一帧就是 20 × 500ns = 10μs。如果你的控制环要跑 20kHz 刷新率,那一帧 10μs 刚好留出余量。这种确定性让上位机可以精确规划运动轨迹的时间轴,不会因为协议层的可变延迟导致轨迹失真。
对比一下:如果用 SPI 传同样 20 位数据,虽然时钟可以拉到几十 MHz,但加上片选建立时间、软件调度开销,实际端到端延迟反而不可控。而 XY2-100 的时序是硬件级的,FPGA 里一个状态机就能稳定输出,抖动可以压到纳秒级。这就是为什么工业现场宁愿用一根专用排线,也不愿意走通用总线。
3.3 多轴同步在 XY2-100 里是怎么天然解决的
激光扫描经常需要 X、Y 甚至 Z 三轴联动。XY2-100 把 X 和 Y 塞进同一帧,意味着两个轴的位置数据是在同一个时钟节拍下发出的,天然同步。你不需要像 SPI 那样分别给两个从设备发数据再对齐时间戳,硬件层面就保证了 X 和 Y 的更新是同一时刻生效的。
这一点在画圆、画螺旋线时特别关键。如果 X 和 Y 更新有哪怕一个时钟周期的偏差,圆就会变成椭圆或者出现锯齿。我实测过,用软件分别驱动两路 SPI 振镜,即使代码里把两次发送挨着写,示波器上也能看到几十纳秒的偏斜,画出来的圆在放大后能看出接缝。换成 XY2-100 单帧双轴后,这个问题直接消失。
4. 用 Verilog 实现 XY2-100 发送端:状态机怎么搭
4.1 时钟分频与 2MHz 节拍生成
FPGA 板卡上通常有一个 50MHz 或 100MHz 的晶振,要得到 2MHz 的 XY2-100 时钟,需要分频。以 50MHz 为例,分频系数是 50/2 = 25,也就是每 25 个系统时钟翻转一次 XY2-100 时钟,得到 1MHz 的方波?不对,这里要算清楚:要得到 2MHz 的时钟,周期是 500ns,50MHz 系统时钟周期是 20ns,所以每 25 个系统时钟对应一个 2MHz 半周期?再算一遍:2MHz 周期 500ns,半周期 250ns,250ns / 20ns = 12.5,不是整数。所以直接用 50MHz 分频得不到精确的 2MHz。
实际工程里有两个做法:一是用 48MHz 晶振,48/2/12 = 2MHz,整除;二是用 50MHz 但接受一点点频偏,或者用 PLL 倍频到 100MHz 再分频,100/2/25 = 2MHz,整除。我一般推荐用 PLL 把系统时钟倍到 100MHz 或 120MHz,再分频得到干净的 2MHz,这样时序余量更足。下面是一个基于 100MHz 输入、分频出 2MHz 的 Verilog 片段:
// 100MHz -> 2MHz, 分频系数 50 reg [5:0] div_cnt; reg sclk_r; always @(posedge clk_100m or negedge rst_n) begin if (!rst_n) begin div_cnt <= 6'd0; sclk_r <= 1'b0; end else if (div_cnt == 6'd24) begin div_cnt <= 6'd0; sclk_r <= ~sclk_r; // 每25个周期翻转,得到2MHz end else begin div_cnt <= div_cnt + 1'b1; end end注意这里div_cnt == 24而不是 49,因为我们要的是半周期翻转。25 个 100MHz 周期是 250ns,翻转一次得到 500ns 周期,正好 2MHz。这种“半周期计数”的写法比“全周期计数再取反”更直观,也不容易算错。
4.2 20 位数据并串转换的移位逻辑
数据发送的核心是一个 20 位移位寄存器,在时钟的下降沿把当前最高位推到 DATA 线上,然后左移一位。为什么选下降沿更新?因为前面说了,振镜在上升沿采样,下降沿更新能保证数据在上升沿前已经稳定了半个周期,建立时间充足。
reg [19:0] shift_reg; reg data_out; always @(negedge sclk_r or negedge rst_n) begin if (!rst_n) begin shift_reg <= 20'd0; data_out <= 1'b0; end else if (frame_start) begin shift_reg <= {ctrl_bit, x_data[7:0], y_data[7:0], 3'b000}; end else begin data_out <= shift_reg[19]; shift_reg <= {shift_reg[18:0], 1'b0}; end end这里frame_start是一帧开始的标志,由状态机产生。装载数据时把 X、Y 和控制位拼成 20 位,然后每个下降沿吐出一位。20 个周期后数据发完,状态机回到空闲,等待下一帧。这个逻辑很朴素,但实测非常稳,关键是frame_start的时序要对齐,不能和移位时钟打架。
4.3 SYNC 信号的拉高时机与帧边界对齐
SYNC 是帧同步信号,它的拉高时机直接决定振镜能不能正确识别帧头。常见做法是:在一帧的第一个时钟周期拉高 SYNC,持续一个时钟周期,然后拉低。这样振镜看到 SYNC 高电平,就知道接下来 20 个时钟是有效数据。
但这里有个坑:SYNC 的拉高必须和第一个数据位的输出严格对齐。如果 SYNC 早了一个周期,振镜会把上一帧的最后一位当成新帧的第一位,整个数据错位。我调试时用示波器同时抓 SYNC、CLOCK、DATA 三根线,把 SYNC 的上升沿和 DATA 第一位的变化对齐到同一个时钟边沿附近,才算稳。Verilog 里可以用一个计数器控制:
reg sync_r; always @(posedge clk_100m or negedge rst_n) begin if (!rst_n) sync_r <= 1'b0; else if (bit_cnt == 5'd0 && sclk_r == 1'b0) sync_r <= 1'b1; else sync_r <= 1'b0; endbit_cnt是位计数器,从 0 数到 19。当它归零且时钟处于低电平时拉高 SYNC,保证 SYNC 的上升沿和第一个数据位的更新同步。这种“卡边沿”的写法比单纯用状态机输出更精确,因为状态机的输出可能有组合逻辑延迟。
5. 接收端与闭环:振镜状态怎么读回来
5.1 XY2-100 的反馈通道是否存在
很多人以为 XY2-100 只是单向发送,其实部分振镜支持反馈通道,会把当前实际位置或状态位回传给控制器。反馈通常复用同一套时钟,但数据线方向反过来,或者用额外的反馈线。不过要注意,不是所有振镜都支持反馈,便宜的模拟振镜往往只有输入没有输出。
如果你的应用需要闭环控制(比如高精度打标要求实时校正位置),就得确认振镜型号是否支持反馈。我遇到过一款国产振镜,文档里写了“支持状态回读”,实际接线后发现反馈线是开漏输出,需要外加上拉电阻才能读到有效电平。这种细节文档里往往一笔带过,不实测根本发现不了。
5.2 用 FPGA 采样反馈数据的时序要点
接收反馈数据的难点在于跨时钟域。振镜的反馈时钟和 FPGA 内部时钟不同源,直接采样会有亚稳态风险。稳妥的做法是用两级触发器做同步,再用反馈时钟的边沿去采样数据线。
// 反馈时钟同步 reg fb_clk_d1, fb_clk_d2; always @(posedge clk_100m) begin fb_clk_d1 <= fb_clk; fb_clk_d2 <= fb_clk_d1; end // 检测上升沿 wire fb_clk_rise = fb_clk_d1 & ~fb_clk_d2;检测到反馈时钟上升沿后,再去采样反馈数据线,把串行数据移位拼成 16 位。这里要注意采样窗口:反馈数据在时钟边沿附近可能不稳定,最好在上升沿后延迟几个系统时钟再采,或者用反馈时钟的下降沿采样。具体延迟多少,得看振镜手册的时序图,没有统一答案。
5.3 位置误差计算与滑动窗口滤波的引入
拿到反馈位置后,和指令位置相减就得到位置误差。但这个误差信号往往很脏,因为振镜电机换向、DAC 噪声、电源纹波都会耦合进来。直接拿原始误差去做 PID,系统会抖得厉害。这时候滑动窗口滤波就派上用场了。
滑动窗口滤波的本质是“用最近 N 个采样值的平均代替瞬时值”,把高频噪声压下去。在 FPGA 里实现,可以用一个移位寄存器存最近 N 个误差值,每个时钟周期求和再除以 N。N 取 8 或 16 比较常见,太大响应会变慢,太小滤波效果不明显。我一般先用 8 试,看阶跃响应有没有明显滞后,再决定要不要加大。
reg signed [15:0] err_window [0:7]; reg signed [18:0] err_sum; integer i; always @(posedge clk_100m) begin err_window[0] <= err_new; for (i = 1; i < 8; i = i + 1) err_window[i] <= err_window[i-1]; err_sum <= err_window[0] + err_window[1] + ... + err_window[7]; end wire signed [15:0] err_filtered = err_sum >>> 3; // 除以8注意这里用算术右移实现除以 8,对有符号数是正确的。如果用>>对负数会出问题,必须用>>>。这个坑我在早期项目里踩过,误差为负时滤波结果完全不对,查了半天才发现是移位符号问题。
6. 实测中那些文档不会告诉你的坑
6.1 时钟极性搞反导致振镜“抽风”
最常见的坑就是时钟极性。XY2-100 文档通常写“数据在时钟上升沿有效”,但有些振镜实际是在下降沿采样。如果你按上升沿采样去设计发送端,数据就会错一位,振镜表现为随机抖动或者往一个方向猛冲。
判断方法很简单:先用一个固定的位置码值(比如中点 32768)持续发送,用示波器看 DATA 线在时钟上升沿和下降沿分别是什么电平。如果上升沿采到的位和预期不符,就说明极性反了。改法就是把发送端的更新边沿反过来,或者把时钟反相。我现在的习惯是,拿到新振镜第一件事就是发中点测试,确认极性再往下做。
6.2 上电时序与复位竞争
振镜驱动板通常需要先上电、再给时钟和数据,否则可能进入异常状态。但 FPGA 上电后往往立刻开始输出时钟,如果振镜还没准备好,就会收到一堆乱码,导致电机猛地一抖。解决办法是在 FPGA 里加一个上电延时计数器,比如上电后等 100ms 再开始发帧,给振镜驱动板足够的初始化时间。
另外,复位信号也要处理好。如果复位期间时钟还在跑,但数据线是随机的,同样会干扰振镜。稳妥做法是复位时把 CLOCK、SYNC、DATA 都拉到确定的低电平,复位释放后再启动状态机。这个细节在代码里就是几行的事,但能避免很多“莫名其妙”的故障。
6.3 长排线带来的信号完整性问题
振镜和 FPGA 之间往往隔着几十厘米的排线,2MHz 时钟虽然不算高频,但边沿陡峭时仍然会有反射和串扰。我遇到过一根 50cm 的排线,时钟线上出现过冲,导致振镜偶尔丢帧。后来在时钟线串联了一个 33Ω 的电阻做阻抗匹配,问题就消失了。
如果你的排线超过 30cm,建议在时钟和数据线上都串小电阻,并且尽量用双绞线或者带地线隔离的排线。另外,排线远离电机驱动线,避免大电流开关噪声耦合进来。这些是硬件层面的经验,纯写 Verilog 是解决不了的。
6.4 多振镜级联时的同步问题
有些设备需要控制多组振镜,比如双头打标机。如果每组振镜独立发帧,帧与帧之间会有相位差,导致两个头打出的图形对不齐。解决办法是用同一个时钟源和同一个帧计数器驱动所有振镜,保证所有通道的 SYNC 和 CLOCK 完全同相。
在 Verilog 里,就是把分频计数器和位计数器做成全局共享,每个振镜通道只是独立的数据移位寄存器。这样所有振镜在同一时刻收到同一帧的对应位,同步精度可以做到一个时钟周期以内。这个设计思路在单振镜时看不出优势,但多振镜时是必须的。
7. 从协议到系统:一个可复用的 Verilog 模块划分
7.1 顶层接口与参数化设计
把上面的逻辑整理成一个可复用的模块,顶层接口大概是这样:输入系统时钟、复位、X/Y 位置数据、发送使能;输出 XY2-100 的 CLOCK、SYNC、DATA。参数化方面,把分频系数、帧长、数据位宽做成 parameter,方便适配不同振镜。
module xy2_100_tx #( parameter CLK_FREQ = 100_000_000, parameter XY2_CLK = 2_000_000, parameter DATA_W = 16 )( input wire clk, input wire rst_n, input wire tx_en, input wire [DATA_W-1:0] x_pos, input wire [DATA_W-1:0] y_pos, output reg xy2_clk, output reg xy2_sync, output reg xy2_data );这种参数化设计的好处是,换一个晶振频率或者换一种振镜,只改 parameter 不用动逻辑。我在多个项目里复用这个模块,基本没出过问题。
7.2 发送状态机的三段式写法
状态机推荐用三段式:第一段时序逻辑更新当前状态,第二段组合逻辑判断次态,第三段时序逻辑输出。这种写法综合出来的电路干净,时序容易收敛,也方便后期加状态。
localparam IDLE = 2'd0; localparam SEND = 2'd1; localparam WAIT = 2'd2; reg [1:0] cur_state, nxt_state; // 第一段:状态寄存 always @(posedge clk or negedge rst_n) begin if (!rst_n) cur_state <= IDLE; else cur_state <= nxt_state; end // 第二段:次态逻辑 always @(*) begin case (cur_state) IDLE: nxt_state = tx_en ? SEND : IDLE; SEND: nxt_state = (bit_cnt == 5'd19) ? WAIT : SEND; WAIT: nxt_state = IDLE; default: nxt_state = IDLE; endcase end三段式的核心价值是“状态寄存和输出分离”,避免组合逻辑毛刺直接跑到输出线上。对 XY2-100 这种时序敏感的协议,毛刺可能被振镜误采样,所以输出最好也寄存一拍。
7.3 仿真验证与板级调试的衔接
写完代码先跑仿真,用 Verilog testbench 模拟振镜的采样行为,检查 20 位数据是否按预期串出。仿真通过后再上板,用示波器抓实际波形。仿真和实测的差异往往在时序余量和信号完整性上,所以仿真只能验证逻辑,不能替代实测。
我一般会在 testbench 里加一个“假振镜”模型,在时钟上升沿采样 DATA,拼成 20 位后和发送值比对。如果一致,说明逻辑没问题。上板后再用示波器确认边沿和电平,两步都过了才算稳。
8. 写在最后的一点个人体会
XY2-100 这套协议本身不复杂,20 位帧、三根线、2MHz 时钟,看文档半小时就能明白。但真正把它用稳,靠的是对时序边界的较真和对硬件细节的敏感。我踩过的坑里,一半是时钟极性、复位时序这类“低级”问题,另一半是排线阻抗、跨时钟域这类“硬件味”很浓的问题。纯写 Verilog 的人容易忽略后者,但恰恰是后者决定了系统能不能长期稳定运行。
如果你正在做振镜控制的项目,我的建议是:先用示波器把三根线的时序彻底看明白,再动手写代码;写完先发中点测试,确认极性;上板后先低速跑,稳定了再提刷新率。这套流程看起来笨,但比反复改代码高效得多。后面如果要做多轴联动或者闭环校正,滑动窗口滤波和跨时钟域同步这两块值得多花点时间,它们是系统精度的天花板。