起因

运动控制卡的寄存器合同在插补实战篇第七节立好——ID/CTRL/PACE/PAT_WPTR/PAT_CNT/STAT/POS 七项,偏移、方向、语义都白纸黑字。这篇写合同的乙方:驱动。PCIe BAR 映射、DMA 环怎么对上模式环、中断怎么挂进 0xD0 向量、补货水位的实际调参——把 0xD0 批量算出的步进位图真正送到卡上,把卡上的计数器和状态原原本本取回来。

插卡式运动控制的驱动有自己的年代史。DOS 时代这层薄得几乎不存在:板卡厂商随卡附一张中断号和基地址的跳线表、一摞头文件,应用程序直接对端口 in/out——HF 那一代系统就是这么跟卡说话的。Windows 把这层收进内核(VxD 到 WDM 到 WDF),用户态隔出一层 DLL,实时性靠中断优先级硬扛,板卡驱动的签名和证书成了装机的一半麻烦。我们的位置有点有趣:ring0 全权、没有内核边界,写法上回到 DOS 时代的直白,纪律上却要更严——寄存器访问收进设备框架,卡的具体存在被适配层挡在运动层之外,这两条是这篇的地基。

一个工程现实先交代:卡是自研的 GD32F4+FPGA,真实硬件手册还没到手。但这不挡路——合同先行本来就是这套系统的干法:软件先立寄存器表,硬件照着实现,两边对着同一份文档写。驱动照合同写出来,真正依赖手册的只剩实现细节(位分配细则、取批协议、锁存机制),文末列成回填清单,手册到手逐项填空。合同立得早,清单就短——这篇本身就是要验证这件事。

一、位置:设备框架里的一个 PciDriver,运动层里的一个适配器

驱动有两个身份,对应两张图里的两个格子。

系统层,它是注册进 PCIe 设备框架的一个 PciDriver:枚举扫到自研卡的 Vendor/Device 对,框架调 probe,把 BAR 元组、MSI 向量、DMA 池句柄递过来,之后的路驱动自己走。框架不认识"运动控制卡"这个词——NVMe、EHCI、这张卡在它眼里一律平等,两层架构在设备侧的落点就是这层无知。

应用层,它底下垫着的是卡适配层。对上,适配层只暴露一组与形态无关的运动接口;对下,按卡的形态翻译——脉冲卡翻成寄存器写和 DMA 批,将来 EtherCAT 主站卡翻成总线报文,机柜外独立控制器翻成网络会话。多卡口径在立项篇定过:GD32F4+FPGA 只是系统支持的第一种卡形态,不是唯一一种。驱动这篇写的全部内容,都是适配层"脉冲卡分支"的实现:

1
2
3
4
5
6
7
8
9
/// 运动层看到的卡:与形态无关(插补器/界面/维护屏只认这组接口)
pub trait MotionCard {
    fn start(&mut self, mode: RunMode);          // 启动/停止/急停/回退(→ CTRL)
    fn set_pace(&mut self, hz: u32);              // 变频落点(→ PACE,0xD0 专写)
    fn feed_batch(&mut self, ticks: &[u8]);       // 供一批步进位图(→ 模式环)
    fn remaining(&self) -> u32;                   // 环内剩余 tick(← PAT_CNT)
    fn snapshot_pos(&self) -> [i32; 4];           // 四轴命令侧位置(← POS,见第六节)
    fn status(&self) -> CardStat;                 // 段完成/断丝/限位/故障(← STAT)
}

插补器永远不知道 feed_batch 的另一头是 MMIO 加 DMA 还是 EtherCAT 报文——这条无知的价值,等第二种卡进门那天兑现。

二、寄存器访问层:BAR0 与 volatile 纪律

PCIe 篇枚举时固件已经把 BAR 分好基址,登记在 PciDev 里;identity 分页下 MMIO 物理地址可以直接访问,驱动拿到 (基址, 尺寸) 就能开工。第一件事是核身份:读 ID/VER(0x00),对上注册时匹配的 Vendor/Device——枚举清单和寄存器窗口两头对账,防止"匹配对了卡、访问错了窗"这类低级但难查的错。

访问本身只有一条纪律:MMIO 读写一律 volatile。编译器对普通内存访问的重排和合并优化,落到设备寄存器上全是事故——两次写 PACE 被合并成一次、读 STAT 被缓存成旧值,查起来都没有现场。封装成一对函数,全驱动禁止裸指针直访:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pub struct CardRegs { base: *mut u32 }            // BAR0,32 位寄存器宽度

impl CardRegs {
    #[inline]
    pub fn r(&self, idx: usize) -> u32 {          // idx 按 4 字节字计:0x18 → 6
        unsafe { core::ptr::read_volatile(unsafe { self.base.add(idx) }) }
    }
    #[inline]
    pub fn w(&self, idx: usize, v: u32) {
        unsafe { core::ptr::write_volatile(unsafe { self.base.add(idx) }, v) }
    }
}

寄存器表按合同搬进来(偏移除以 4 当下标):ID/VER=0、CTRL=2、PACE=4、PAT_WPTR=6、PAT_CNT=8、STAT=10、POS 四轴从 12 起。这七项是运行面,驱动篇一行不改——插补篇立的合同,改一个字两边全得跟着动。但配置面缺东西:卡怎么知道模式环在主机内存的哪里?这就要给合同补配置区,POS 块(0x30–0x3F,四轴各 4 字节)之后加三项:RING_BASE(0x40,64 位地址,低 4GB 池里出的)、RING_LEN(0x48,环容量,tick 数)、LATCH(0x50,POS 原子快照门铃,第六节)。软件侧提案先进合同,手册定稿时对齐——补合同和改合同是两回事。

三、模式环:主机内存里的 DMA 环

模式环在插补篇定义为"卡上的环",落成电路时有一个关键决策:环的存储放在主机内存,卡经 DMA 来取。放卡上(BRAM 深缓存)要驱动的环指针同步和成块搬运,放主机侧只暴露两个寄存器就是完整的合同——所有权分界干净得像教科书:

  • 主机拥有写侧:往环缓冲写 tick、推进 PAT_WPTR
  • 卡拥有读侧:自己的读指针对外不暴露,只回吐 PAT_CNT(剩余 tick 数)——读指针是卡的私有状态,泄漏出去只会诱惑软件去读一个不属于自己的量
  • 数据先、指针后:一批 tick 写完,sfence 之类的全系统写屏障,然后才写 PAT_WPTR。顺序倒了,卡可能拿到新指针去读还没落好的数据——x86 的 DMA 缓存一致性是白送的,但一致性不等于顺序,屏障这条纪律不能省

环缓冲从内存管理器篇的低 4GB DMA 池分配,物理连续、卡可寻址,容量取 4096 tick(16 批 × 256,4KB 一整页):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
pub struct PatternRing {
    buf: &'static [u8; 4096],    // DMA 池,物理连续,4096 tick
    wp: u32,                     // 已写到的 tick 位置(对 4096 取模)
}

impl PatternRing {
    /// 写入一批(0xD0 ISR 调用):位图落环 + 屏障 + 推进写指针
    pub fn push(&mut self, regs: &CardRegs, batch: &[u8; 256]) {
        let head = self.wp as usize % self.buf.len();
        let n1 = (self.buf.len() - head).min(batch.len());   // 环尾拆两段
        self.buf[head..head + n1].copy_from_slice(&batch[..n1]);
        let n2 = batch.len() - n1;
        self.buf[..n2].copy_from_slice(&batch[n1..]);
        self.wp = self.wp.wrapping_add(batch.len() as u32);
        core::arch::x86_64::_sfence();                       // 数据先
        regs.w(6, self.wp % 4096);                           // 指针后(PAT_WPTR)
    }
}

带宽账顺手动手算:83kHz 满负荷时每秒 83K tick、每 tick 一字节,83KB/s——一台 1991 年的 9600bps 调制解调器都嫌窄的数字。这就是插补篇"批量"送的第二份礼:第一份是 CPU 占用千分之一,第二份是把 PCIe 的实时性需求从"微秒级流"降到"毫秒级搬运"。它反过来松开了硬件选型的手(硬件篇要讲的桥片可行性就靠这笔账),也意味着这个 DMA 引擎的实现难度不在带宽在时延——卡取批慢了照样断流。

四、中断接线:补货直进 0xD0

PCIe 篇讲过 MSI 的接线三步:消息地址写 0xFEE0_0000、消息数据写向量号、控制寄存器使能,多消息能力按 2 的幂启用。这张卡用 4 个向量,分配不是随便排的:

向量 事件 挂哪 为什么
0xD0 补货(PAT_CNT 低于水位) 插补 ISR(IST3) 供段的人和算段的人是同一个
0x2X 段完成/STAT 普通变化 设备区 ISR→协作层 非节拍事件,走两级调度的常规路径
0xF0 断丝/急停输入 安全 ISR(IST1) 硬件信号走硬件的路(插补篇的老口径)
0x2Y 门铃(命令确认) 设备区 ISR CTRL 写入的完成回执

第一行是这节的要点:补货 MSI 的消息数据直接写 0xD0。卡一拍水位线,中断直插插补 ISR——算批量的人自己被叫醒去供下一批,省掉"设备 ISR 收到、投递消息、等 0xD0 下一拍"的整条转手链,也天然继承了 0xD0 在调度篇的优先级:比它急的只有 0xE0 放电和 0xF0 安全。补货是全驱动最硬的实时路径,让它在硬件层面一步到位,比任何软件优先级技巧都可靠。

0xF0 那行多说一句:断丝、急停这类输入的 STAT 变化事件同时配了两条上报路——常规 STAT 位变化走设备向量给软件看;安全类输入另外触发一条 MSI 写 0xF0,且卡端 FPGA 在发中断的同一个周期已经封了脉冲(插补篇:硬件信号走硬件的路,不绕软件)。软件层 0xF0 收到时脉冲早就停了,ISR 做的是确认、记录和后续处置——安全停机路径复用的正是这个"硬件先动手"的时序。

五、补货水位:3.1ms 里留多少余量

满环 4096 tick、单批 256,水位的账分三层算。

正常运行。83kHz 满频下 256 tick 撑 3.1ms,水位定在环容量的 1/8(512 tick)——补货中断触发时环里还剩 6.2ms 的存量(切割速度下按比例更长)。这 6.2ms 要罩住三段延迟:0xD0 ISR 的响应抖动(实时验证篇的 TSC 直方图,正常单峰在几微秒到几十微秒)、PCIe 取批的事务时延(微秒级)、以及最坏情况下 ISR 被同优先级以上事件压栈的窗口(0xE0/0xF0 都是短路径,百微秒量级)。三段加起来离 6.2ms 还有一个多数量级的余量——水位不是紧平衡,是保险带,正常工况它永远不该被用到极限。

underrun 兜底。PAT_CNT 归零而 CTRL 仍在运行,就是软件供不上货。处置在卡端硬件:FPGA 检测到环空,不急停——83kHz 高速下脉冲流突然掐断,电机带惯量刹不住,丢步(电机篇的诱因之三)就是当场的事——而是按固定斜坡把播放频率降到零,同时 STAT 置 underrun 位。主机侧看到 underrun 是一次事故演练:位置账按 POS 对账收尾,报警进日志,原因排查走实时验证篇那套直方图。

回退与环存量。短路回退(放电篇)要换目标重新生成几何,但环里还躺着旧目标的 tick——最多 4096 个、83kHz 下 49ms 的存量。回退命令置 CTRL 的回退位时,卡端同步做两件事:丢弃环内剩余 tick(PAT_CNT 清零、PAT_WPTR 对齐读指针),把播放频率压到回退低速。49ms 是回退命令的 worst-case 生效时延,如果试切时觉得回退"慢半拍",先查这里——环加深增大的是变频之外的一切响应,这是模式环设计里唯一要付的利息,好在它只在毫秒尺度上可见。

六、POS 对账:命令侧的账本

POS 是卡上四个脉冲计数器(0x30 起,每轴 4 字节)——卡数自己发出去的脉冲,不数电机实际走了多少(那是开环的天堑,电机篇讲透了)。它的用处是命令侧对账:主机比对"命令意图"与"卡发出的脉冲数",命令通路上的任何丢失——寄存器写被吞、DMA 批损坏、驱动 bug——当场现形。机床侧的丢步是另一本账,归回零流程兜。

读 POS 有个并发陷阱:四轴四个寄存器,四次读之间卡还在跑,读到的是四个不同瞬间的位置——对账时凭空多出几个脉冲的"漂移",查不出来由。解法是配置区那个 LATCH 门铃:写一次 LATCH,卡在同一拍把四轴计数器原子快照进影子寄存器,主机从影子区连读,拿到的才是同一瞬间的一帧。影子区和 LATCH 的握手细节(快照完成怎么告知——回执位还是门铃中断)在回填清单里。

对账挂在协作层,每 100ms 一拍:POS 差分累计成 64 位(32 位计数器对 ±2m 行程富余千倍,但累计账要 64 位),与插补器命令侧的 i64 位置比对,超 1 个脉冲即报警。断电场景不指望 POS——卡掉电计数器就没了,运行时的信任走 POS,断电后的信任走回零,两个不信任分属两个时态,谁也不越界。

七、手册回填清单

方法论先行的账,最后列在这里。手册到手,逐项核对填空:

  1. CTRL/STAT 位定义细则——合同定了语义(启停/急停/回退;段完成/断丝/限位/故障/underrun),每项落在哪个位待定稿
  2. PACE 的单位与更新语义——Hz 直写还是分频值;变频写 入是即时生效还是同步到批边界(0xE0 变频响应快慢的最后一级)
  3. MSI 向量分配寄存器——四向量各自的 message data 写法、多消息启用位序
  4. 取批协议——卡侧 DMA 引擎的 prefetch 深度、突发长度、是否按 cache line 对齐取(影响写屏障后的可见时延)
  5. underrun 斜坡率——降频到零的固定斜坡,脉冲/秒²,要和电机篇的 ramp_ms 对得上
  6. 回退时环内 tick 的处置——CTRL.RTN 是否清环、PAT_WPTR 与读指针的重对齐时序
  7. LATCH 机制——影子区地址、快照完成的回执方式
  8. 上电默认态与复位时序——枚举前寄存器初值、软复位命令、GD32F4 侧看门狗的生效窗口
  9. 时钟规格——PACE 的时基与抖动(实时验证篇FPGA 时间戳对账的对端就是它)

看着长,其实全是"语义已定、数值待填"的空——没有一个条目需要回头改架构。合同先行省下的就是这部分:手册到手的活是填空题,不是改错题

总结

实战清单:驱动双身份(设备框架的 PciDriver + 运动层的脉冲卡分支适配器,MotionCard 七接口对上不认卡);BAR0 加 volatile 读写封装、ID/VER 两头对账、运行面七项寄存器原样搬、配置面三项(RING_BASE/RING_LEN/LATCH)补进合同;模式环落主机内存(DMA 池 4096 tick、所有权分界=写指针归主机读指针归卡、数据先屏障后指针、83KB/s 的带宽账松开硬件选型);MSI 四向量(补货直进 0xD0 省转手链、断丝急停独立走 0xF0 且卡端同拍封脉冲、83kHz 下水位 1/8=6.2ms 存量的三层账、underrun 硬件斜坡不急停、回退 worst-case 49ms 生效时延);POS 命令侧对账(LATCH 原子快照、100ms 一拍差分 64 位、运行时信任走 POS 断电信任走回零);手册回填九项——填空题不是改错题。跑通标志:开发机上 mock 卡模型(内存模拟寄存器与环)跑通"插补器→适配层→环"全链 cargo test;真机枚举读出 ID/VER 与注册表一致;空走矩形件 POS 累计与命令账逐段相等;故意把 0xD0 让位延迟压过水位存量,触发 underrun 斜坡停机且位置账能收回来;强制短路触发回退,49ms 内几何换向;拔掉 485 之外单独断卡的 MSI,系统按设备故障走安全停而不是死等。

下一篇是软件侧最后一条线:视觉定位接口——UVC 摄像头怎么在 EHCI 的周期调度里挤出等时带宽,找孔找边的算法怎么把老师傅的放大镜数字化。之后软件线收官,下位机三块硬件的电路与嵌入式实现是硬件篇的正题——合同的两个乙方终于要见面了。

参考链接