首页 / 资讯中心 / 文章详情

ARM到龙芯2K0300:增量式旋转编码器Linux驱动移植全记录

ARM到龙芯2K0300:增量式旋转编码器Linux驱动移植全记录 ★ FEATURED ARTICLE
前两周我们组接了个新活儿组代号叫“走马观碑”——听着像骑马看碑文挺有画面感实际上我们就是一群天天跟设备树、中断和寄存器打交道的嵌入式驱动工程师。任务是把一套增量式旋转编码器的 Linux 驱动从原来的 ARM 参考板移植到龙芯 2K0300 平台上。一开始组里都觉得这是个小活结果从调研 BSP、改设备树、重写中断注册到把低速毛刺和高速中断风暴的问题捋干净前后花了将近两周。文章把“走马观碑组”这次编码器驱动移植的完整过程记录下来重点讲我们在 2K0300 上踩过哪些坑、哪些代码可以原样带走、哪些胶水必须重写。正在做 LoongArch 平台外设驱动移植或者准备在 2K0300 上接编码器的朋友这篇文章应该能帮你少走不少弯路。1. 动手写代码前我们先把“后路”想清楚1.1 这个驱动到底要交付什么增量式旋转编码器是运动控制里最常见的反馈器件输出两路相位差 90° 的方波信号通常叫 A 相和 B 相有些还会带一路 Z 相也就是每转一圈出一个脉冲的零位信号。我们这块编码器是 1000 线PPRA/B 两相组合做四倍频解码后每转就是 4000 个计数单位。上层应用是一个云台位置闭环 demo它关心的事情只有三件当前绝对计数值是多少、方向是正还是负、能不能一键清零。这里有个容易被忽略的点上层应用不是我们写的它原来在 ARM 板上就是通过一个字符设备接口来读数的。所以这次移植真正要交付的不是“能在 2K0300 上跑起来的一段代码”而是一套和原来一致的接口契约——read 出来一个带符号的整数、ioctl 清零、断电重启后从头开始计数。接口不换上层才能做到真正的平台无关。1.2 为什么不能只换个编译器重新编译组里有人一开始图省事说“驱动代码又没用到多少架构相关的东西直接换个交叉编译链编一下不就好了”。这种想法很危险。编译确实大概率能过因为驱动框架、GPIO 描述符这些接口在 Linux 内核里是统一的但“编译过”和“能跑”是两回事。原驱动的底层绑定太多了中断号是在 ARM 的中断控制器上申请的设备树里 interrupt-parent 指向 GICGPIO 的引脚编号和 irq domain 映射方式也是 ARM 平台那一套。2K0300 用的是 LoongArch 架构中断控制器、GPIO 控制器的实现都不同设备树 bindings 的写法也有明显差异。直接搬过来最常见的结局就是probe 报错、中断根本触发不了、或者 GPIO 请求失败。所以移植之前必须先划清楚哪些代码与平台无关哪些代码是必须重写的胶水。1.3 移植边界怎么划核心逻辑留胶水全换我们按模块把原驱动拆了一遍做了个简单的判断表模块处理方式原因四倍频状态机、方向判向表、计数累加原样保留纯逻辑跟 CPU 架构和外设无关中断注册、边缘触发类型配置重写ARM 与 LoongArch 的中断控制器完全不是一套GPIO 引脚获取重写引脚控制器、设备树 compatible、索引方式不同对外字符设备接口语义保留接口、改实现上层应用依赖 read/ioctl 契约设备树匹配重写interrupt、pinctrl、gpio 描述完全不同这条思路后来被证明是对的。真正费时间的部分恰恰在胶水层——中断拓扑的确认、设备树的适配、以及信号质量问题的排查。状态机代码从第一天到最后一天一个字都没改。2. 先摸底 2K0300 的 GPIO 与中断资源再谈移植2.1 引脚和电气先确认编码器能不能直接进 GPIO拿到 2K0300 评估板和 BSP 之后我们没急着写代码先干了一件最土但最有用的事把编码器的手册和板子的 GPIO 扩展座引脚定义对着看。我们这块编码器是 5V 供电输出高电平也是 5V而 2K0300 的 GPIO 是 3.3V 电平域直接怼上去轻则读不到高电平重则长期过压出问题。解决办法很简单中间加一级电阻分压把 5V 信号拉到 3.3V 附近再进 GPIO。引脚映射上原 ARM 板的编码器接的是某组 GPIO 的 15、16、17 脚对应 A、B、Z。在 2K0300 的底板扩展座上我们选了第一组 GPIO 控制器的 15、16、17 三个引脚。确认引脚有没有被复用成别的功能用调试文件系统看一眼就清楚了cat /sys/kernel/debug/gpio如果引脚默认被 pinctrl 配成了其他功能就需要在设备树里把 function 配成普通的 GPIO 输入。另外很多增量编码器是开集输出外部必须加上拉电阻不然信号会悬空。我们在设备树里给三个引脚都配了 bias-pull-up这一步对后面毛刺问题的减轻也有帮助。2.2 中断拓扑LoongArch 不是 GIC别拿 ARM 的思路套这是整个移植过程中最容易被忽视、也最容易翻车的地方。ARM 平台的外设中断几乎都走 GIC设备树里写 interrupt-parent gicinterrupts 属性里第一个数字是中断号第二个是触发类型。到了 2K0300 这里中断控制器变成了 LoongArch 那一套不同 BSP 版本甚至会用不同的板级中断控制器节点GPIO 控制器的 irq domain 实现方式也很不一样。我们确认中断链路的方法是先看 BSP 自带设备树里 GPIO 控制器的节点定义数清楚它的#interrupt-cells是几再看 /proc/interrupts 里已经注册的中断长什么样。2K0300 的 GPIO 外部中断常常是整组共享一条中断线到了中断服务程序里还要靠寄存器判断具体是哪个引脚触发的。gpiolib 的 irq domain 会把这一层细节藏起来我们只要拿到gpiod_to_irq()返回的虚拟中断号就能正常注册。但有一件事必须提前确认这个 GPIO 中断控制器到底支不支持双沿触发。增量编码器四倍频解码本质上就是要在 A、B 两相的上升沿和下降沿都触发中断。如果中断控制器不支持IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING这种组合就只能退而求其次只在 A 相上升沿触发再在中断服务程序里读一次 B 相的电平来判断方向这样分辨率会从四倍频掉到两倍频。好在我们的 BSP 版本里这颗 GPIO 的 irq chip 支持双沿触发这个担忧没有变成现实。2.3 没有硬件正交解码模块软件解码是唯一选项但有限速立项会上还讨论过一个方案2K0300 上有没有现成的硬件正交解码模块或者外部时钟计数通道直接把 A/B 相送进硬件里让硬件自己数边沿我们把 BSP 手册和设备树翻了一遍结论是当前版本没有可用的 QEP 或同类外设。所以最终路线确定GPIO 双沿中断 软件状态机解码。确认用软件解码的同时我们也在文档里明确标了一条警告线软件解码对边沿率有硬性上限不是驱动写得快就能无限加速。这个限速的量化分析放到后面的实测章节再展开但选型的时候必须心里有数——如果客户的转速需求超过这条线方案就得改成外挂硬件计数器而不是继续优化中断服务程序。3. 原驱动拆解留下状态机重写三块胶水3.1 核心之一四倍频状态机就靠一张小表增量编码器的计数逻辑看起来简单但难在“方向”上。A、B 两相相位差 90°正转和反转时A 相与 B 相的电平组合变化顺序相反。只数上升沿不判断相位关系就会把正转和反转混在一起。四倍频的正确做法是A、B 的任意一个边沿都触发中断中断里同时读两相电平得到当前状态码再和上一次的状态码组合查表。状态码取(A 1) | B所以每个状态是 0 到 3。上一步是 old当前是 cur用(old 2) | cur作为索引查一张 16 项的符号表。这张表就是整个驱动里最核心、也最不应该改动的部分static const s8 qep_dir_table[16] { /* key (old 2) | curold/cur 都是 (A 1) | B */ 0, -1, 1, 0, /* old00: 00不变 01反转 10正转 11非法 */ 1, 0, 0, -1, /* old01: 00正转 01不变 10非法 11反转 */ -1, 0, 0, 1, /* old10: 00反转 01非法 10不变 11正转 */ 0, 1, -1, 0, /* old11: 00非法 01正转 10反转 11不变 */ }; static void qep_update(struct qep_device *qep) { u8 old qep-last_code; u8 cur; s8 d; cur (u8)((gpiod_get_value(qep-a) 1) | gpiod_get_value(qep-b)); d qep_dir_table[(old 2) | cur]; qep-last_code cur; if (d 1) atomic_long_add(1, qep-count); else if (d -1) atomic_long_add(-1, qep-count); /* d 0状态没变或者出现了物理上不可能的直接跳变 */ }这里有个细节值得留意表里的 0 不仅是“状态没变”还承担了非法跳变的过滤功能。比如 00 直接跳到 11正常四倍频正交信号不可能出现这种跳变查表结果就是 0计数器不动。这个特性在后面治毛刺的时候帮了大忙。3.2 要重写的胶水probe、GPIO 与中断注册核心状态机保留其他部分就得按 2K0300 的实际情况重写。原驱动用的是老的 GPIO 整数接口gpio_request、gpio_get_value我们统一换成了 GPIO 描述符接口配合 devm_ 系列 API错误路径干净很多probe 大概长这样static int qep_probe(struct platform_device *pdev) { struct qep_device *qep; int irq_a, irq_b, ret; qep devm_kzalloc(pdev-dev, sizeof(*qep), GFP_KERNEL); if (!qep) return -ENOMEM; qep-a devm_gpiod_get(pdev-dev, a, GPIOD_IN); if (IS_ERR(qep-a)) return PTR_ERR(qep-a); /* 很可能是 -EPROBE_DEFER */ qep-b devm_gpiod_get(pdev-dev, b, GPIOD_IN); if (IS_ERR(qep-b)) return PTR_ERR(qep-b); qep-z devm_gpiod_get_optional(pdev-dev, z, GPIOD_IN); irq_a gpiod_to_irq(qep-a); irq_b gpiod_to_irq(qep-b); if (irq_a 0 || irq_b 0) return irq_a 0 ? irq_a : irq_b; ret devm_request_irq(pdev-dev, irq_a, qep_isr, IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING, qep-a, qep); if (ret) return ret; ret devm_request_irq(pdev-dev, irq_b, qep_isr, IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING, qep-b, qep); if (ret) return ret; /* 初始化状态机 */ qep-last_code (gpiod_get_value(qep-a) 1) | gpiod_get_value(qep-b); atomic_long_set(qep-count, 0); return 0; } static irqreturn_t qep_isr(int irq, void *dev_id) { struct qep_device *qep dev_id; qep_update(qep); if (gpiod_get_value(qep-z)) atomic_long_set(qep-count, 0); /* Z 相归零实际会加电平判断 */ return IRQ_HANDLED; }中断服务程序里只做两件事查表更新计数、必要时候处理 Z 相。查表本身是纯内存操作原子计数也不会有锁竞争整个 ISR 的执行时间控制在微秒级别这是后面能扛住一定边沿率的前提。注意 Z 相那段我写的是简化示意真正的实现里要判断 Z 相边沿而不是电平否则 Z 相持续高电平期间每次 A/B 中断都会反复清零。3.3 对外接口字符设备、input 还是 IIO counter接口选型我们认真对比过三种方案。input 子系统最省事把旋转编码器当成相对滚轮上报 EV_REL但它适合鼠标滚轮、旋钮这种人机交互场景不适合运动控制——读不到绝对位置也没有清零 ioctl。IIO counter 子系统是内核里比较现代的计数框架规范、可扩展但文档少、驱动框架要求高而且我们的上层应用已经固定了 read/ioctl 习惯硬切换到 sysfs 语义反而要改应用。最后选了 misc 字符设备方案适合场景代价input 子系统EV_REL滚轮、旋钮输入无绝对位置无法清零IIO counter 子系统多厂家兼容、缓冲数据流文档少接口语义要跟着框架走misc 字符设备运动控制、读绝对位置、ioctl 清零接口要自己定义字符设备里核心就是 read 和 ioctl。read 把原子计数转成字符串输出ioctl 做清零static ssize_t qep_read(struct file *fp, char __user *buf, size_t len, loff_t *off) { struct qep_device *qep fp-private_data; char tmp[32]; int n; n snprintf(tmp, sizeof(tmp), %ld\n, atomic_long_read(qep-count)); if (len n) return -EINVAL; if (copy_to_user(buf, tmp, n)) return -EFAULT; return n; } static long qep_ioctl(struct file *fp, unsigned int cmd, unsigned long arg) { struct qep_device *qep fp-private_data; switch (cmd) { case QEP_IOC_CLEAR: atomic_long_set(qep-count, 0); return 0; } return -ENOTTY; }用 atomic_long_t 做计数是因为读操作可能来自任意用户态线程而写计数发生在中断上下文两边不锁反而最简单——原子变量在 64 位平台上是天然一致的单次读写。字符设备这套代码和我们之前在 ARM 板上用的结构几乎一样只是把具体的读取来源从原驱动换成了 2K0300 的状态机。4. 设备树是移植的重灾区照抄 ARM 写法必然翻车4.1 从 ARM 复制过来的 dts 错在哪刚开始组里确实有人直接把原板子的 dts 节点复制过来想着把 compatible 改一下就完事。原节点大概长这样qep { compatible xxx,qep-encoder; a-gpios gpio1 15 GPIO_ACTIVE_HIGH; b-gpios gpio1 16 GPIO_ACTIVE_HIGH; interrupts 14 IRQ_TYPE_EDGE_BOTH, 15 IRQ_TYPE_EDGE_BOTH; interrupt-parent gic; status okay; };这个节点放到 2K0300 上大概率 probe 都进不去原因有三个。第一2K0300 没有 GIC 这个节点interrupt-parent 指向的必须是这颗 SoC 上真实存在的中断控制器。第二GPIO 引脚编号和 GPIO 控制器的 irq domain 索引是两套东西ARM 板子上的 14、15 号中断在 2K0300 上可能完全不存在。第三interrupt-cells 的描述方式不同触发类型常量的语义倒是一致但第一个数字的含义完全取决于中断控制器节点。所以正确的第一步永远不是复制节点而是翻开 BSP 自带的设备树找到 GPIO 控制器节点确认它的#interrupt-cells、interrupt-controller属性以及它是否注册了 irq domain。我们第一次看到 2K0300 的设备树时其实有点懵因为整个中断体系和我们熟悉的 GIC 长得完全不一样花了大半天才把链路理清楚。4.2 改好的 dts 节点长这样最终落在板级 dts 里的节点是这样节点名和中断父节点的名称各 BSP 有差异我这里用的是我们手里这个版本的写法实际请以你们 BSP 为准qep_encoder: qep-encoder { compatible walkhorse,qep-encoder; pinctrl-names default; pinctrl-0 pctl_qep_a, pctl_qep_b, pctl_qep_z; a-gpios gpio0 15 GPIO_ACTIVE_HIGH; b-gpios gpio0 16 GPIO_ACTIVE_HIGH; z-gpios gpio0 17 GPIO_ACTIVE_HIGH; interrupt-parent gpio0_irq; interrupts 15 IRQ_TYPE_EDGE_BOTH, 16 IRQ_TYPE_EDGE_BOTH, 17 IRQ_TYPE_EDGE_RISING; counts-per-rev 4000; status okay; };几个值得解释的细节。a-gpios、b-gpios、z-gpios 这种-gpios后缀命名是 GPIO 描述符 API 的约定驱动里 devm_gpiod_get 会按这个后缀去找属性名。interrupt-parent 指向的是 GPIO 控制器自己的 irq domain而不是某个全局板级中断控制器这样 15、16、17 在 interrupts 里对应的就是 GPIO 控制器域下挂的第 15、16、17 个引脚中断。counts-per-rev 是我们自定义的属性驱动里用 device_property_read_u32 读出来主要给用户态做角度换算用。pinctrl 部分也要跟着写。我们给三个引脚配了普通 GPIO 输入功能和上拉pctl_qep_a: qep-a { loongson,pins 15; loongson,function gpio; bias-pull-up; input-enable; };如果你那边的 BSP pinctrl 语法不同最好先用调试文件系统确认引脚功能再照着 BSP 里已有的 UART、SPI 节点抄格式比自己硬猜可靠得多。4.3 probe 还失败时我们按这个顺序排查即便 dts 写对了probe 也可能会失败。我们整理了一个排查顺序后来组里新人照着做基本都能自己解决。先看 compatible 字符串是否和驱动里 of_device_id 完全一致大小写、逗号、有没有多余空格都要一模一样。再看节点 status 是不是 okayBSP 模板里很多外设节点默认 disabled。然后 dmesg 看有没有 EPROBE_DEFER——GPIO 控制器还没 probe 完的时候devm_gpiod_get 会返回 -EPROBE_DEFER这是正常的等待不是错误。继续看 /proc/interrupts 里有没有我们注册的中断没有说明 request_irq 失败大概率是触发类型或者中断号映射的问题。实在找不到原因可以临时打开 gpiolib 的 dynamic debugecho file drivers/gpio/gpiolib.c p /sys/kernel/debug/dynamic_debug/control dmesg -w这套流程帮我们定位过一次问题pinctrl 里引脚号写错导致 GPIO 请求到了另一个引脚上中断自然触发不了。查到最后其实是眼睛看花了把 15 看成了 16。5. 实测阶段踩到的坑毛刺、风暴、nobody cared5.1 低速毛刺状态机天然能挡一部分硬件 RC 挡剩下的代码跑通的第一天计数是能出来了但手摇编码器慢慢转的时候计数值会一跳一跳地来回变停住不动偶尔也会自己加一。用逻辑分析仪抓 A/B 波形发现边沿处有明显的抖动一个上升沿上下跳了好几次。这种机械触点或光栅信号边缘的抖动非常常见低速时尤其明显。我们的处理分两层。第一层是软件状态机那张表本身就是抗毛刺的——比如 00 直接跳到 11非法跳变返回 0计数器不动。等于说毛刺如果只是短暂越过了相邻状态表会给一个错误符号但直接跳过两个状态的非连续跳变被完全忽略。第二层是硬件A、B、Z 三路信号对地各加一组 RC 低通滤波用的是 10kΩ 电阻加 100nF 电容时间常数 1ms 左右。这个数值不是随手拍的它的截止频率要远高于我们最高转速下编码器信号的频率又要足够滤掉几微秒到几十微秒宽度的抖动脉冲。实测下来加了 RC 之后低速误计基本消失停住也不再乱跳。这里有个教训千万别想着在中断里做软件延迟去抖。在中断服务程序里 sleep 或者忙等会把两次边沿之间的时间耗光低速没问题转速稍高就直接丢计数。软件去抖能用状态机的非法表项挡就挡剩下的交给硬件滤波这是嵌入式里最稳的组合。5.2 高速中断风暴先算账再选解码倍率软件解码能扛多快算一下就有数。边沿率等于转速乘以每转计数除以 60边沿率Hz 转速RPM × 每转计数counts/rev / 60我们的编码器是 1000PPR、四倍频也就是每转 4000 个计数。60 RPM 时边沿率 4000Hz300 RPM 时 20000Hz3000 RPM 时直接到 200kHz。GPIO 中断路径在嵌入式平台上每次完整处理大概要几微秒到十几微秒还要算上 LoongArch 中断控制器和 gpiolib 的开销20kHz 已经是一道坎200kHz 基本不可能靠中断扛住。我们的云台 demo 需求是最高 120 RPM换算过来边沿率 8000Hz每 125μs 一次中断驱动剩余负载空间还算充足。但这个数字让我们在方案文档里写了一条硬约束软件解码方案只适用于边沿率低于一万赫兹左右的场景再高就必须换硬件计数器或者牺牲倍频。后来我们也在驱动里留了一个 count-mode 的可选参数客户如果不需要四倍频精度可以切到一次边沿一个计数边沿率需求直接降到四分之一。5.3 “nobody cared” 与同时沿问题中途还碰到过一次看起来特别吓人的现象系统跑着跑着计数值突然完全不动了dmesg 里出现了一行内核告警大意是某条中断经过 10 万次以上的处理却始终没有人确认内核已经把这条中断自动禁掉了。原因很简单第一次写 ISR 的时候某个分支忘了返回 IRQ_HANDLED只返回了 IRQ_NONE。内核的检查逻辑是一条中断连续触发很多次但处理函数从不说“我处理了”那就认为它是无人认领的中断直接禁用以防中断风暴打挂系统。从那以后我们的 ISR 就统一改成入口处直接返回 IRQ_HANDLED 的写法反正这条中断注册了就是归我们处理即便查表结果是 0 也应该算处理完成。这个教训对所有人都有用中断服务程序里IRQ_NONE 是留给真正“跟我没关系”的中断不要为了显得严谨就乱用。另一个问题出现在高速段A 相中断触发后读到 B 相电平时发现 B 相也刚跳变导致状态机看到 00 和 11 之间直接切换表里判为 0结果就是丢了一个计数。理论上标准正交信号不可能两相同步跳变出现这种情况只说明线路有问题常见原因包括连接线太长、绕在一起造成串扰或者电平阈值临界。我们的解决方式很土换了一对双绞屏蔽线把编码器到板子的距离从一米多缩短到三十厘米问题就消失了。遇到这种“看起来像驱动 bug实则是硬件问题”的情况先抓波形再审代码别在驱动里瞎做补偿。5.4 Z 相归零的小细节Z 相是每转一圈一个脉冲用来做机械零点标定。理想情况下只要 Z 相上升沿把计数清零就够了但实际有两个坑。第一Z 相在高转速下脉冲宽度非常窄如果软件处理不过来或者滤波时间常数太大Z 相脉冲可能整个被吞掉第二Z 相本身也会毛刺毛刺一多就会出现随机清零。我们最后给 Z 相单独配了和 A/B 相同的 RC 滤波但把时间常数调小了一档并且要求客户在标定时段内让云台减速通过零点这样既滤掉毛刺又不容易丢脉冲。驱动对 Z 相的边沿做了锁存防止同一圈重复清零。6. 验收和收尾我们怎么证明移植成功6.1 四组测试用例口头说“能读计数了”不算数我们设计了四组验收用例每一组都对应一种失败模式。第一组是慢速手摇正转 10 圈每圈 4000 计数最终读数必须是 40000 且方向为正反向再摇 10 圈回到 0——这验证四倍频和方向判向的正确性。第二组用步进电机带编码器步进电机每转 200 步、细分后每步一个固定角度让步进电机走 5 圈然后对比编码器读数和步进电机理论角度验证没有累积误差。第三组在 120RPM 上限连续跑 30 分钟观察漂移是否在可接受范围内同时看 /proc/interrupts 里 A/B 两路中断计数是否大致均衡。第四组是双实例测试同时接两块编码器到两路 GPIO验证驱动在多个实例下互不干扰。测试过程中最让人踏实的瞬间是手摇 10 圈读数稳稳停在 40000 的时候。步进电机对跑了一圈下来误差是 0说明状态机在正常信号下没有丢计数也没有误判方向。6.2 给准备做类似移植的人几句实话如果让我给这次“走马观碑组”的移植过程做个时间复盘调研 BSP 和中断拓扑花了两天重写驱动胶水花了一天设备树适配和排查花了一天剩下的时间几乎全花在信号质量问题上。结论很简单编码器驱动移植的难点从来不在“驱动”本身而在中断拓扑和信号质量。几点具体的建议。开工前先看 BSP 的设备树确认 GPIO 控制器的 interrupt-cells、irq domain 和引脚复用这一步省不掉。焊接或者接线完成后先抓一轮波形确认 A/B/Z 的电平、脉宽、抖动宽度一次抓波形能省后面好几天的瞎猜。调试过程中保留 /proc/interrupts 的几个时间点快照配合 dmesg 才能快速定位“驱动没跑”和“信号没到”的区别。最后提醒一句设备树里的 compatible 字符串和驱动里的 of_device_id 必须一字不差这是 probe 失败最高频的原因没有之一。这次移植最后真正留下来的代码并不多两百来行驱动的核心逻辑加一小段设备树节点。但为了这两百行我们把 LoongArch 的中断控制器翻了个底朝天也把编码器信号的脾气摸了个透。以后再有人问“编码器驱动移植难不难”我的回答是驱动本身不难难的是你永远不知道信号线上有多少惊喜在等你。
阅读完成 · 觉得有帮助?
咨询建站