最近有一次特别好玩的经历,熬夜到半夜四点多,仿佛有种回到了大学时代做大作业的感觉。于是决定把整个事情记录下来。事情其实就是 build 一个定制键盘的过程,但是在最终快要完成的时候突然出现了一个小问题:在按下某一个键的时候整个一列键都被触发了,后面的故事就围绕着调试和修复这个问题展开,直到后面完全搞明白背后的原理和问题,感觉学到了挺多东西也很好玩。

本故事的中心人物就是图中这个小家伙。这是一个 5x12 的 ortho linear 键盘 kit 组装好了的样子。Kit 是在 boardsource.xyz 买的 5x12 kit,因为我想玩一玩根据已有的键盘 kit 组装的过程练练手,同时也想尝试一下比 4x12 多一排数字键的键盘布局是不是会更好用一些,于是就选了这样一个 kit。并且这个 kit 其实是相当“裸”的:它没有外壳,底板其实也是用一个(没有内嵌任何电路的)PCB 版做的,所以对于自己从头完全设计和定制键盘是比较有参考价值的(因为制造形状复杂的金属外壳是比较麻烦的事情,而打印 PCB 板子现在即使小批量也已经比较可行了)。当然还有一个更重要的原因就是大概目前类似的 kit 只有这个不是 sold out 的状态吧。¯\_(ツ)_/¯

kit 里包含了组装键盘的大部分材料(键轴、键帽和 USB 连线需要自己另外准备),包括三块板子,分别是顶部的定位板,用来把键轴固定住,中间的 PCB 版,包含了键盘矩阵电路,并连接微控制单元(MCU),和底部的底板,然后是 48 个二极管,以及 48 个键轴热拔插的 socket,一个微控制器和它的针脚头,一些螺丝等。下面是来自 BoardSource 官方的组装指南的示意图:

组装过程从技术上来讲其实并没有很复杂,不过几乎每一步都需要一些耐心。首先是把所有的二极管弯成 U 型插入板子里,难点在于弯曲的部分尺寸刚好能匹配板子上两个孔之间的宽度,这样插进去之后二极管能够尽量紧贴板子。见下图(左边桌子上散落的几个是多余的二极管)。之后把针脚张开一些,使得板子翻过来二极管也不会掉落,然后用过焊锡把所有的针脚都焊在板子上(我完全无法操作焊锡,这个步骤需要 N 来帮忙),再把长的针脚剪掉。

焊完二极管之后就可以焊 48 个键分别对应的热拔插 socket,通常安装键盘是直接把键轴的针脚用焊锡焊到 PCB 板子上的,这样的坏处是如果你想换其他的键轴,要么需要 desolder(不知道中文怎么说,拆焊?),要么可能需要另外在组装一个键盘。所以最近特别流行的解决方案就是键轴热拔插,我们这里采用的是 Kailh 的 socket,编号 CPG151101S11。把这个 socket 焊到 PCB 板子上之后,键轴就可以直接插入即用了。当然使用 Kailh 的 socket 需要 PCB 设计的时候就考虑到这一点,留够焊接 socket 的接口和电路。如果是没有考虑到热拔插的键盘 PCB 板子的话,可以使用 Mill-Max 的热拔插 socket,这个听上去兼容性好很多,但是由于工作原理不一样,焊接难度要高很多。

焊完 socket 之后就可以焊微控制器了,我这里使用的是 Elite-C V4,基于 ATMega32U4 芯片,自带 USB-C 接口。PCB 板子上有为微控制器的针脚留出来的焊接空,不过需要用 Male Header Pin 把 Elite-C 的针脚和 PCB 的针脚连起来,然后再在两面分别焊上。针脚间隔比较小,感觉焊接难度大了不少,不过似乎也比想象中要容易一些,因为我以前一直以为焊锡的形状完全是靠你焊锡头的机械操作来形成,要让焊锡不要流到旁边去黏住其他的针脚造成短路完全是一门手艺,结果在做了一些调研之后发现其实正常情况下焊锡只会附着在 PCB 版上预留的金属环上,而不太会流到 PCB 表面的非金属部分上面去。当然,如果弄了太多的焊锡或者手残之类的情况自然也会悲剧了。下图 N 正在焊 Elite-C 的针脚。

再之后就是把键轴装到固定板上,然后要把装好键轴的固定板和 PCB 版插到一起。第一步非常简单,一点也不费力的纯体力活,但是第二步就有点复杂了,因为需要一次把 48 个键轴的 96 个针脚和 PCB 上的对应的热拔插孔全部对齐,因为键轴的针脚是很软的,在运输过程中可能会有一点点偏离直角,所以并不一定就能完美地对齐。总之这一个步骤在操作上需要一点难度,之所以有点麻烦的原因也是我们是非常简陋的 kit,没有键盘外壳来支撑和固定 PCB 或者定位板。

连好之后就只剩最后一步——接好底壳板子了。不过在那之前可以测试一下键盘。用 USB 线把键盘连到电脑上,去 qmk configurator 网站上找到 BoardSource 的 5x12 的固件和默认键盘布局,做一些适当的修改,编译并下载固件,然后按下键盘上的 reset 键,就可以通过 QMK Toolbox 把固件烧到微控制器里了。然后可以按下每一个键测试一下。在正要举杯庆祝一晚上的劳动成果的时候突然发现第一列第二个键按下的时候会同时触发了整个一列的按键。关键是这个按键恰好位于微控制器的正下方,这个位置怎么说都太可疑了。但是除了觉得可疑之外完全没有任何概念究竟要如何 debug,因为其实对于键盘如何运作的了解也非常有限。

于是我跑去 BoardSource 的 Discord channel 里问问题,我发现定制键盘爱好者社区似乎在 Discord 上还挺活跃的,有点意外的是已经大晚上了居然还有客服在线,了解了一下基本情况之后对方提了一个建议就是检查一下 PCB 板子上维控制器的焊锡和相邻的热拔插 socket 针脚焊锡是否有短路的情况。因为 ortho linear 的键盘不像标准键盘那样有许多形状奇怪的大键(例如空格之类的),而是整个板子均匀地布满了键轴插孔,所以要找一个地方放微控制器就比较麻烦,不得不让有几个针脚靠得非常近,如下图红色和蓝色的箭头所指的微控制器的针脚和键轴的针脚(其实在使用热拔插 socket 的情况下这个键轴的针脚是闲置未用的)就非常接近。

有一些键盘干脆直接放弃把微控制器放在按键的底部,而是在 PCB 板边缘突出一块来专门放置微控制器和其他需要焊上去的电子元件,这样一方面可以让 PCB 上的针脚布局更干净一些,同时由于元件在水平上铺开了,所以垂直上就不需要占用大量空间,于是键盘就不用做得很厚,而且将元件展示出来也能起到美观的效果。下面是 Plaid 键盘的一个成品图例子,可以看到二极管、reset 按钮和微控制器都在 PCB 的顶部“暴露”出来。

回到 debug 的问题,虽然 N 表示这些针脚都已经分得很开了,那些黑色的亮亮的部分是松香之类的不导电物质,但是还是再用焊枪加热了一下焊锡仔细 reflow 了一下,试图把它们完全分开。不过我们尽了最大努力还是无法解决这个问题。有可能是因为我们没有成功把针脚分开,但是似乎更有可能是其他问题。询问了客服,本来希望她在对 PCB 电路设计很清楚的情况下能给出一些其他调试建议,但是她也想不出其他的什么可能问题。我们自己想到的一个疑点就是微控制器的 USB-C 接口的外部金属和它正下方的 hot swap socket 的焊接部分接触到一起了。但是在组装指南上并没有提到这个问题,问了客服也说那里应该不会有问题,但是客服也给不出其他排查的建议了。

于是我们尝试从几个个角度出发进行调试:一方面根据电路板上的布线图尝试搞清楚每个针脚是怎么连在一起的,这样可以测量一下有没有哪里不应该连在一起的地方不小心短路了;另一方面是了解一下键盘电路的工作原理,这样可以知道在什么样的情况下会有整个一列都触发的问题,提供可能需要排查的方向。

搞清楚键盘电路板布线其实比较容易,因为键盘的 PCB 比较简单,只是一个矩阵,所以只有正反两面的电路,根据电路板表面的示意图就能搞清楚了,而键盘电路的基本工作原理也不复杂,网上有不少文章介绍,比如这个手工连线的键盘组装过程里就有很不错的介绍,这里再简单复述一下。简单来说键盘上的每一个按键其实就是一个开关,按下就连通电路,照理说键盘是一个极其简单的设备,只需要在每个开关被按下的时候原封不动地报告给计算机就可以了。通过 USB 或者蓝牙之类的协议与计算机通信并报告按键触发的事件,这基本上就是键盘控制器做的事情(当然复杂的定制键盘的固件还会做很多预处理)。为了让控制器能对每个按键按下有反应,最简单的方法是将该按键所在的电路接到控制器的一个输入针脚上,然后让控制器读取该出的电压来判断电路是否接通(按键是否按下)。这样做的一大问题是我们需要和按键个数一样多的针脚数目,然而常见的微控制器的针脚数目都不过 20 上下,这对于键盘来说完全不够。标准的解决方案是通过矩阵编码轮询来减少针脚数量,如下图左边所示:

我们把按键排列成一个矩阵,然后把行和列分别连起来,每一行和每一列各自连到一个针脚上。为了简单例子中只用了四个按键,被排成了 2x2 的矩阵,还是需要四个针脚,似乎并没有什么改善,但是如果是大一些的 \(m\times n\) 的矩阵的话,通过这种排布方式需要的针脚数量就从 \(mn\) 变成了 \(m+n\),所以 20 个针脚就够处理 100 个键了。这种连线方式下控制器只能区分某个时刻某一行/列是否有某一个(或多个)按键被接通了,为了搞清楚具体哪个按键被按下了,控制器需要做一个轮询,大概是像下面这样的伪代码,通过非常迅速的循环迭代,可以在你按键的一瞬间完成一次或者多次迭代,让你感觉不到依次轮询的存在:

  1. for (i = 0; i < n_cols; ++i) {
  2. power_on(col[i]);
  3. for (j = 0; j < n_rows; ++j) {
  4. if (read(row[j]))
  5. report_event(key_down[i][j]);
  6. }
  7. power_off(col[i]);
  8. }

但是这个方法在有多个按键同时按下的时候有可能出 bug,具体如上图右边所示,如果三个红色的按键同时被按下,此时控制器如果在第 0 列加电,并在第 1 行读取,会发现电路是连同的,于是就会错误地以为是青色的 k10 被按下了,但事实上电路是通过另外三个按键迂回地连通的。解决方案是在电路中放置二极管,如下图所示,在没一个键和行连接的地方放置一个二极管,由于二极管只允许单向通电,所以在 k01 那里之前的“反向”流通现在被阻止了,于是解决了多个按键同时按下时可能出现的按键检测错误问题。

简单检查了一下手边的 PCB 板的布线,发现确实和这里的示意图是同样的,但是在这样的情况下哪里短路了会导致某一个按键按下的时候会让整个一列的按键都检测为触发状态呢?似乎也不是很清楚。还有一个困难是手边没有万用表,没有办法检测具体电路哪里是短路状态。情急之下想到之前买的 Arduino kit 里有 LED 灯,觉得连上一个电磁就能通过灯的亮灭来检测连通性,于是赶紧找了一节电池试了一下,并没有成功,也不知道是电池没电了还是 LED 灯被短路烧坏了——因为查了一下文档发现 LED 连到电路里是要配上电阻才行的。于是索性按文档把 Arduino 板子接到电脑上,然后把板子的“接地”和 5V 电压用面包板连上合适的电阻和 LED 灯,在用多出来的线做了简易连通性探测器。

通过这个简陋工具我们发现出问题的按键所在的针脚有一边是和控制器的“接地”是连通的,并且控制器的 USB 口金属部分也和“接地”是连通的,当然还有其他许多地方是连通状态,以及一些模棱两可的 LED 灯很暗地亮起来的情况,感觉完全超出了这个自制工具能处理的范围。而且这个时候已经凌晨三四点了,于是只好先休息。

第二天整理好精神,下了班,玩了一会儿塞尔达荒野之息,收到了亚马逊寄来的几个神器:万用表、Hakko FR301-03/P 和 Solder Wick。第一个是用来测量短路的,后面两个都是用来移除焊锡的工具。有了万用表之后测量短路就太容易了,不过基本的结论跟头一天晚上并没有太大的差别,并且成功地分析清楚了确实 USB 接口和 Kailh hot swap socket 接触导致按键的一个针脚成了接地状态,此时按下这个键就会导致整个一列都激发——当然后来发现我们由于对矩阵轮询的细节并不清楚所以解读方法完全是错的,不过运气好结论还是正确的。

总之在这种情况下唯一能做的就是移除焊锡把控制器拿下来,用绝缘胶带把 USB 口和 Kailh hot swap 口隔开,再把控制器焊上。其中最困难的一个步骤就是移除焊锡,因为所有针脚上的焊锡全都要移除的非常干净,否则控制器就没法取下来。移除焊锡的基本方法是用焊枪加热,在焊锡融化状态时把它“弄”走,最简易的弄走方法是用一个吸焊锡专用的弹簧吸管,虽然是专用的,但是并不是特别好用,因为必须在焊枪拿开的一瞬间就把吸管怼上去吸,否则焊锡就又凝固了,但是速度又会牺牲精准度,导致经常会剩下一些焊锡。Solder Wick 是采用直接把焊锡吸到自己身上的办法,并且它本身是耐热的,所以可以直接把焊枪对着它加热,不存在时间差的问题,不过 Solder Wick 主要是对表面的焊锡比较管用,对于针脚内部的焊锡却是很难清理。最后还是靠 Hakko 的集加热和真空吸为一体的神器,很迅速就把焊锡清理的差不多了,即便如此控制器也很难拿下来,最后非常幸运地是 PCB 和控制器都没有坏,不过连接用的 male pin header 完全扯坏了。由于我手边没有多余的 pin header,所以这个项目又往后拖延了几天。不过在那之前必须要验证一下我们是否找到了真正的问题。

现在控制器卸下来之后,USB 口就不再和 Kailh hot swap socket 接触了,所以如果能够连线确认现在控制器是正常工作的的话就大功告成了。但是没有 pin header 的情况下没法焊接,而且去除焊锡的过程比较痛苦,如果可以的话我希望能够在焊接之前能够确认它是正常工作的。于是我从 Arduino kit 里拿出了很多线,分别用 female pin header 接到 PCB 和控制器的针脚,再用线把所有的针脚连起来,然后插上 USB,再让不同的按键短路造成按下的效果,看是否能触发正确的按键。过程并不那么顺利,因为 pin header 虽然起到了一定的固定作用,但是在不加焊锡的情况下其实接触不一定很好,导电很不稳定,基本上要不停地把针脚晃来晃去才偶尔会碰到一个接触良好的状态。总之最后花了挺长时间才勉强确定了似乎是没有问题,不知道这种情况 in general 有没有比较好的调试方法,比如用某种糊状的容易去除的导电物质塞到针脚里,但是感觉要做到像焊锡那样微妙地不把相邻的针脚短路似乎又很难。

几天之后新的 male pin header 到了,同时还买了绝缘胶带,用来把 USB 口和 Kailh hot swap socket 的金属部分隔开,之后再重新焊接上控制器,装上键轴,连上电脑测试,终于一切正常了!跟客服确认过之后对方也表示这确实是他们设计时没有考虑到的问题,如果需要的话可以退款或者在之后他们有改良的 PCB 之后给我再寄一个。不过由于我们成功挽救了 PCB 和控制器,所以也没有找他们要什么,特别是他们看起来似乎也是几个键盘爱好者经营的小 business 啦。

完成的键盘如本文第一张图所示,这是 5x12 的 ortho linear layout。不过其实做好这个键盘的时候我已经习惯了 Planck 的 4x12 的 布局了,所以我几乎第二天就把它底下四排的布局改成了和 Planck 一样的,把它当做 4x12 来用了。到这里流水账照理该完结撒花了,但是有一件事情一直在我心中若隐若现:虽然我们找到并解决了问题,但是似乎并没有真正搞懂问题到底是怎么一回事,就包括之前提到的键盘轮询算法,所谓控制器在每一列加电、在每一行读取,这里的“加电”是怎么做的,而“读取”究竟又是什么一个操作?读取会对电路产生影响吗?再仔细考虑之前“搞懂”了的为什么会出现一整列都激发的情况,似乎也只是一种“比喻”层面上的搞懂了。

由于自己没有电子电路方面的知识,于是就胡乱查了一些资料,终于才大致搞明白了。在 digital 世界的零和一分别对应 analog 世界的低电压和高电压,并且中间有一个缓冲区域的“未定义”状态,这个似乎是在大学计算机基础之类的课上了解过,不过到底实际中使用多少 V 的阈值来区分零和一却是不知道,似乎不同的芯片家族各有各的标准和约定,这个阈值也会决定其他一些关于 digital read/write 的细节不同。其实我现在还不太能搞清楚各种常用的板子和他们的异同,总之由于 Arduino 的文档比较好找,就参考了一下。一个针脚如果设置成 digital 读的状态,那么相当于在针脚那里接了一个 100 兆欧姆的电阻,所以读取操作基本不会对原本电路产生什么影响,读取就是通过测量通过该巨大电阻的微弱电流来实现。而写状态的针脚则处在低阻抗状态,会对电路产生直接影响,写入 1 会导致该处处于高电压状态,并输出电流;写入 0 会导致该处处于接地状态,并能吸收电流。当然输出还是吸收的电流的大小范围是有限制的,如果你直接将输出针脚接地并写入 1,那么该针脚有可能会直接烧坏。到这里听起来都很合理,只是有一点奇怪的地方:我们之前碰到的问题是按键的一个针脚被意外接地了。但是如果是这样的话,在矩阵轮询的时候对该列写入 1 时,碰到的问题应该是控制器烧坏了而不是触发整个一列。总之非常疑惑,之后又乱七八糟搜索了一通,似乎渐渐地把一些线索拼凑起来。简而言之就是矩阵轮询其实是写入 0 并读取低电压。

比如定制键盘社区里最流行的 firmware QMK 里的矩阵轮询代码可以在 quantum/matrix.c 里找到,也就是 martix_scan() 函数,由于 QMK 支持一些不同的硬件,所以代码做了一些封装,但是整体还是比较清楚的。可以看到 QMK 支持两种不同的轮询:写入列读取行、写入行读取列。我觉得实际中可能没有什么差别,只是二极管焊的方向位置要变。根据代码里的 DIODE_DIRECTIONCOL2ROW 的判断,以及电路图中电流流向的判断,我们这里的接法其实是要用后者——注意这和之前的说法里行列是相反的,而这个反转正是由于读写低电压的机制造成的。下面是把一些不太相关的代码去掉之后的大致代码结构。

  1. uint8_t matrix_scan(void) {
  2. bool changed = false;
  3. #if defined(DIRECT_PINS) || (DIODE_DIRECTION == COL2ROW)
  4. // Set row, read cols
  5. for (uint8_t current_row = 0; current_row < MATRIX_ROWS; current_row++) {
  6. changed |= read_cols_on_row(raw_matrix, current_row);
  7. }
  8. #endif
  9. // ...
  10. }
  11. static bool read_cols_on_row(matrix_row_t current_matrix[], uint8_t current_row) {
  12. // ...
  13. // Select row and wait for row selecton to stabilize
  14. select_row(current_row);
  15. matrix_io_delay();
  16. // For each col...
  17. for (uint8_t col_index = 0; col_index < MATRIX_COLS; col_index++) {
  18. // Select the col pin to read (active low)
  19. uint8_t pin_state = readPin(col_pins[col_index]);
  20. // ...
  21. }
  22. // Unselect row
  23. unselect_row(current_row);
  24. // ...
  25. }
  26. static void select_row(uint8_t row) {
  27. setPinOutput(row_pins[row]);
  28. writePinLow(row_pins[row]);
  29. }
  30. static void unselect_row(uint8_t row) { setPinInputHigh(row_pins[row]); }

可以看到 matrix_scan 在一个循环里依次对每一行调用 read_cols_on_row 函数,而后者做的事情是先调用 select_row(),也就是把该行说对应的针脚设置为输出状态,并写入 0 (低电压);然后在循环检查每一列,读取其针脚输入;循环结束以后再调用 unselect_row() 对该行的输出针脚写入 1 (高电压)。这里的代码没有显示的是所有针脚以开始是被初始化为高电压状态的。

最后还需要简单解释的就是上拉电阻这个东西。前面提到 digital 输入的针脚是使用一个超大电阻然后测量微弱电流的方式来测量输入,当该输入针脚没有接入一个(从电压源到地连通的)电路的时候——比如键轴没有按下的时候——输入针脚会检测到环境噪音,得到一个随机输入值,更糟糕的是这个值有可能会变来变去的。一个解决方法就是使用上拉电阻,简单来说就是把一个电压源接上一个电阻连到输入针脚上。如下图所示的 col0 这个输入针脚,当 k00k10 都断开的时候,就是处于和电路未连通状态,此时由于上拉电阻的存在,有一个持续的微弱电流流入,导致它的(默认)值为 1 (高电压)。然后对比 col1 这个输入针脚,按键 k11 是按下连接状态,所以 row1col1 现在连通起来。之前提到输出针脚默认是处于(写入)高电压状态,但是由于二极管的存在,这个电压并不会导致大量电流涌入 col1 将其烧坏,而是让它得以继续保持默认值 1 (高电压)。现在假设我们要检查第 1 列的按键的状态,于是我们在 row1 处写入 0,于是现在这里处于低电势,上拉电阻那里的电压源会导致电流通过二极管,流入输出针脚 row 1,这将导致输入针脚 col1 处处于低电势状态,从而读取值会从默认的 1 变为 0。如下图:

所以真正的矩阵轮询的工作方式是:在每一行写入低电压,然后再在每一列读取低电压。这里有点绕弯弯地晕,特别是输出针脚有电流流进去,而输入针脚反而有电流流出来。为什么要把所有东西都反过来而不是按照符合我们直观的去直接写入和读取高电压呢?这个我就暂时搞不清楚了,不过我猜为了使用上拉电阻这样的机制,就不得不把所有东西“反”过来,并且这种方式应该比较容易防止因为不小心短路而烧坏针脚。事实上,在现在这样的配置下在来看我们碰到的短路问题就很容易搞清楚到底发生了什么,没有导致针脚烧坏,而是触发一整列的按键了。

如下图所示,现在假设 k01 的左边针脚由于什么原因接地了(在二极管之前或之后接地似乎都差不多),现在如果我们在轮询中要检查 k11 的状态,于是我们按照之前描述的算法在 row1 写入低电压,并在 col1 处读取,如果 k11 是按下的状态,那么电流会导致 col1 处于低电势从而读取到 0。但是现在 k11 虽然没有按下,col1 仍然读到了 0,这是因为 k01 是按下状态,导致电流通过那里直接流向了接地端,于是 col1 处意外地处于低电势状态。

不难想象如果我们把矩阵从两列扩充到多列,情况还是一样的:短路的那个按键按下的时候会让控制器误以为那一列所有的其它按键都是按下状态,从而触发一整列的键。这样一来,就终于一切真相大白啦!😋