Hello, 机械键盘

反思过去几年,写文章时总想要写得特别深入,是不对的。从传播角度来说,只有最浅的 Hello World 才能保证最多的点赞数;不管什么 Hello World,用不了多久,就会被新的 Helloworld 代替。所以我们需要一直 Helloworld,精通各种 Helloworld,深入浅出 Helloworld,绝不碰 Helloworld 以上的东西,才能立于不败之地。我们要记住那位产品经理的告诫:stay young, stay simple, always naive.

在我看来,机械键盘缺点特别多:键程长影响输入效率,是超大噪音源,烧钱,是笨重的家具。

但基本款我还没有, 所以还是需要 Hello world 一个。

原理

最常见的键盘构造是这样的:每个按键(轴)都是一个开关,按下时接通。控制芯片不断循环扫描所有的按键,发现变化了,就通过 USB 或者蓝牙协议把 keycode 发到电脑。一般控制芯片一般只有十几二十个数字 IO 引脚, 为了用少量的引脚检测好几十个甚至上百个按键的的开关, 一般都用矩阵电路。 下图是我画的矩阵电路的一部分:

图中 c0, c1, c2, … 都接到输出引脚. l0, l1, l2 都接到输入引脚. 输入引脚置于 pull up 模式 (pull up 是拉高电位的意思, 这个模式下往往芯片或者板子内建了电阻, 我们就不用多焊一个电阻了). 又因为 pull up 默认是高电平, 初始化的时候, 把 c0, c1, c2, … 都置于高电平, 这样按键就算接通也没有电流。

扫描时:

  • 先把 c0 设置成低电平, 读取 l0 看看开关 S0 是否接通, 读取 l1 看看开关 S2 是否接通…
  • 恢复 c0 为高电平, 把 c1 设置成低电平, 读取 l0 看看开关 S10 是否接通, 读取 l2 看看开关 S11 是否接通…
  • 恢复 c1 为高电平, 把 c2 设置成低电平, 读取 l0 看看开关 S20 是否接通, 读取 l2 看看开关 S21 是否接通…

矩阵电路有很多互相连通的地方, 为了防止电流乱走串键(Ghosting:检测到没有按下的键),还得给每个开关接一个二极管。一般选用对电流变化响应速度比较快的二极管,例如贴片二极管 1N4148 可以在数纳秒内切换导通和不导通的状态。

主控

市面上最便宜的主控芯片只有一块钱, 不过是不能编程的。也有专门的可自定义键位映射的主控如 SK5100/SK5101。自制机械键盘最常用的是直接上单片机 Pro Micro,CPU 是广受欢迎的 Atmega32U4。我选择比 Pro Micro 更强大的单片机 Teensy 3.2,它的优点如下:

  • 小, 面积大约两个键盘按键
  • 自带 USB 接口和一个小灯灯
  • 数模 IO 端口都有而且数量足
  • CPU 是主频高达 72MHZ 的 K20 系列,使用 Arm Cortex M4 内核, 带有 64k 内存, 262k Flash, 内建高速双路 ADC,硬件 SPI 协议…… 看这内存的大小,已经可以支持任何程序啦(比尔盖茨语)
  • K20 系列主控功能繁多,说明书有 1400 多页,幸好 Teensy 做了一套 Arduino API (感觉要被 STM32 的专业人士打死了,不要对一个外行要求那么高……)。Teensy 还有不少的三方库支持,你甚至可以不用 Teensy API 直接无脑 TMK / QMK。

Teensy 的主页: https://www.pjrc.com/teensy/index.html

图为 Teensy 3.1 的针脚和两个 ADC 单元的对应表,取自 https://forum.pjrc.com/threads/25532-ADC-library-update-now-with-support-for-Teensy-3-1(为什么需要模拟输入?下面会讲……)

缺点就是贵… 3.2 要 19.8 刀, 都够买一打山寨板了。而且很容易买到假的,官网有辨假指南,并指出了常见的假货网站,例如阿里巴巴。

零件和工具

你的设计取决于你能买到什么东西… 所以先买零件比较好。键盘是一个工程,很多设计依赖于对一些元件的测量和理解,所以最好先把零件的特性弄清楚,设计时对不确定的部分准备多一些备用方案,并且安排好执行顺序压缩工期。

键盘最基本的元件是轴(switch),但不是所有轴你都能 handle 得了的。例如静电容轴往往是和电路板一体设计,所以就没这么容易买到。光轴容易买到 Gateron 的,但它并不带感光元件。淘宝上有试轴器卖, 包含各种轴, 可帮你低成本尝试各种机械轴的手感。

现在比较流行的是 Cherry MX 轴。MX 按手感主要有线性(红黑) / Tactile(棕) / 鞭炮声(蓝) 三种. 但触发力度对我而言都太大了 —— 我需要轻手感或者短键程,打字不累那种。我要做的键盘是老人机,属于医疗设备。

另外还有爱好者 custom 的 Zealios 轴,Halo 轴等,都是烧钱的货不表……

所以我的选择很有限:

例如 Cherry 的 Low Profile Speed ,45g 的触发,3.5mm 总键程,比 MX 系列薄了很多。但因为合作键盘厂商有 3 年独占销售权,这个产品近期内都买不到散件 —— 除非去拆一把海盗船。

去年凯华出了一个矮轴系列, 高度类似 Cherry ML (MX 的一半高), 比罗技光轴还矮, 底盘里还有放二极管的洞洞, 价格还比 Cherry 便宜。缺点是触发力度 55g 有点高,而且没有对应的卫星轴。能用的键帽只有官网的一套,可以联系客服要一些5无刻的。

凯华矮轴尺寸图

另一个选择是 Gateron Clear,MX 的设计,35g (测试表明触发力度约 27g),非常轻盈舒服。由于轻,上点顺滑剂效果很明显。就是 Gateron 的轴心比较松,上下不够稳。

还有 Matias 和 Alps 合作的 Quiet Linear,35g(但是很谜的是测试表明 activation 接近 48g?)触发 2.2mm,总键程 3.5mm,双弹簧设计,力度非常均匀。就是 Alps 轴可以搭配的键帽实在太少。

Alps Quiet Linear

其他零件和准备工具:

  • 游标卡尺
  • 开发板测试工具: 脚针, 面包板和电线
  • 如果要在键盘装摇杆或者轨迹球, 一定得在设计 PCB 前先买回来测好读数和大小
  • MicroUSB 线 (注意有些很坑的线省掉了数据传输功能,不要买“充电线”字样的)
  • 焊接工具: 烙铁, 焊锡, 烙铁架, 钢丝球, 耐热海绵, 吸锡带, 焊锡膏,热风枪……

布局设计

有一个很完善的工具:http://keyboard-layout-editor.com 。然后点 Raw Data 那一栏可以拷贝你的布局数据到 http://builder.swillkb.com ,给你出载板和边壳的图纸。再然后还能一键连接到一个金属板切割服务给你制作 —— 强烈推荐。我到淘宝找了个铝板切割,切出来和图纸差太多,小洞都没了……

想好布局以后,就进入电路设计的阶段。

电路设计

这里用特别好用的开源工具 KiCad (其中 “Ki” 发音同希腊字母 χ )。KiCad 的设计流程和 Altrium Design 不太一样(不推荐前东家的软件好吗??嗯我推荐 123D 系列,可惜已经取消了)。KiCad 的设计流程是这样的:

  1. 画电路图
  2. 指定各元件的 footprint
  3. Layout 元件,布线
  4. 预览,debug

然后就跟着 Keyboard PCB Guide 做就好了。

有些键盘元件,例如 Kailh Choc,TMK library 里也没有,于是利用 KiCadModTree 自己生成一个。下面 Python 代码用的数值基本匹配轴厂商的图纸:

class MySwitchMaker:
    def __init__(self):
        # 轴的引脚焊盘
        self.switch_pads = {
            'mx': [(-3.81, -2.54), (2.54, -5.08)],
            'alps': [(-2.5, -4), (2.5, -4.5)],
            'choc': [(-5.0, -3.80), (0, -5.90)]
        }
        # 轴的支撑孔
        self.support_holes = {
            'mx': {'locations': [(-5.08, 0), (5.08, 0)], 'size': 1.8},
            'choc': {'locations': [(-5.5, 0), (5.5, 0)], 'size': 1.8}
        }
        # LED 焊盘
        self.led_pads = {
            'mx': [(-1.27, 5.08), (1.27, 5.08)],
            'choc': [(-1.27, 4.7), (1.27, 4.7)]
        }
        # 贴片 LED 的放置框
        self.led_box = {
            'choc': {'x': (-2.65, 2.65), 'y': (3.075, 6.325)}
        }
        # 匹配轴的底面大小
        self.cutout_size = {
            'mx': (14.0, 14.0),
            'alps': (15.5, 12.8),
            'choc': (14.0, 14.0)
        }
        # 轴的中央开孔
        self.main_hole_size = {
            'mx': 3.9878,
            'alps': 3.9878,
            'choc': 3.20
        }
        # 键盘上按键的长/宽,加上这个外框方便排列
        self.switch_spacing = 19.0

下图为我的主控接线,包括两个 RGB 二极管和 thumb potentiometer 摇杆。

走线之前的老鼠窝:电路图中应该连接但还没连上的地方显示为一条白线:

添加喜爱的印刷图案,例如 Rarity 和 Spike:

由于第一版过于放飞自我…… 又做了个收敛一点的布局:

最后导出 gerber 文件,就可以交给打板厂商制作了。

焊接

SMD 二极管:用焊锡膏糊到板子上,然后热风枪吹好。

然后焊上开关,放上键帽,就完成了简陋的 V1

键盘做好了,打字速度也提升了,手腕也不疼了,圆肩也改善了。但是还有个问题:打扫的大爷说,你们公司的程序员太穷了,键盘壳都没了还在用。我:???你没看到这个奢侈的单片机吗???然后我就开始设计外壳了。

外壳设计

KiCad 只能做 PCB 的设计,当要做 Plate mount 的键盘时,怎么保证上面板和 PCB 能对得起来呢?

我的做法是先导出 SVG

导出后像这样 (边线很细看不清):

SVG 就是个 XML,图中的大圆孔可以用来定位上面板的载孔位置。边缘 outset 之后可以决定上面板的大小。写代码提取这些位置就容易多了。例如提取某个直径的所有孔的位置:

require 'nokogiri'

class SvgReader
  def initialize fname
    @doc = Nokogiri::XML(File.read fname)
  end

  def scale
    @scale ||= begin
      elem = @doc.css('svg').first
      wcm = elem['width'].to_f
      wpoints = elem['viewBox'].strip.split[2].to_i
      wpoints ./ wcm * 10
    end
  end

  def hole_locations r_mm
    int_size, _ = all_hole_sizes.find do |int_size, mm_size|
      (mm_size.to_f - r_mm).abs < 1e-6
    end
    circles = @doc.css('circle').select do |c|
      c['r'].to_i == int_size
    end
    circles.map do |c|
      [c['cx'].to_i./ scale, c['cy'].to_i./ scale]
    end
  end
end

由于电路板边缘上有些留给立柱的凹进去的位置,为了去掉它们需要做一个 hull (凸包)操作。然后电路板的边缘比载板要小,所以又需要一个 outset 操作,也就是和一个矩形做 闵可夫斯基和 。下图为闵可夫斯基和的一个示例:

计算几何学的事情我们可以借助 CGAL 的帮忙。为了方便的在 Ruby 脚本调用这个 C++ 的库,我又写了个 binding 方法 outset_polygon,它接受一系列多边形上的点作为参数,输出定值 outset 后的多边形:

#include <iostream>
#include <CGAL/Simple_cartesian.h>
#include <CGAL/Polygon_2.h>
#include <CGAL/minkowski_sum_2.h>

#include <ruby.h>

typedef CGAL::Simple_cartesian<double> K;
typedef K::Point_2 P;
typedef CGAL::Polygon_2<K> Polygon;

extern "C" VALUE outset_polygon(VALUE self, VALUE v_points, VALUE v_outset) {
  Check_Type(v_points, T_ARRAY);

  long size = RARRAY_LEN(v_points);
  if (size < 2) {
    rb_raise(rb_eArgError, "size of points should >= 2");
  }

  VALUE* v_points_ptr = RARRAY_PTR(v_points);
  P points[size];
  for (long i = 0; i < size; i++) {
    volatile VALUE e = v_points_ptr[i];
    if (RARRAY_LEN(e) != 2) {
      rb_raise(rb_eArgError, "point should be array of size 2");
      return Qnil;
    }
    double x = NUM2DBL(RARRAY_PTR(e)[0]);
    double y = NUM2DBL(RARRAY_PTR(e)[1]);
    points[i] = P(x, y);
  }

  Polygon shape(points, points + size);
  if (shape.is_simple()) {
    if (shape.orientation() == CGAL::CLOCKWISE) {
      // minkowski_sum_2 only accepts counter clockwise
      shape.reverse_orientation();
    }
  } else {
    rb_raise(rb_eArgError, "input points failed to match is_simple predicate");
  }

  double outset = NUM2DBL(v_outset);
  if (outset <= 0) {
    rb_raise(rb_eArgError, "outset should be positive");
  }
  P rect[] = {P(-outset, -outset), P(outset, -outset), P(outset, outset), P(-outset, outset)};
  Polygon outset_shape(rect, rect + 4);

  auto res = CGAL::minkowski_sum_2(shape, outset_shape).outer_boundary();

  volatile VALUE ary_res = rb_ary_new_capa(res.size());
  for (auto it = res.vertices_begin(); it != res.vertices_end(); it++) {
    volatile VALUE e = rb_ary_new3(2, DBL2NUM(it->x()), DBL2NUM(it->y()));
    rb_ary_push(ary_res, e);
  }
  return ary_res;
}

extern "C" void Init_polygon_t() {
  volatile VALUE m = rb_define_module("PolygonT");
  rb_define_method(m, "outset_polygon", RUBY_METHOD_FUNC(outset_polygon), 2);
}

算好后就可以输出 SVG 了。SVG 路径由圆弧和直线组成:

  def arc fromx, fromy, r, angle, flags, tox, toy, first: false
    s = scale
    offx, offy = *@offset
    fromx = (fromx - offx) * s
    fromy = (fromy - offy) * s
    r *= s
    tox = (tox - offx) * s
    toy = (toy - offy) * s
    res = "A #{r} #{r} #{angle} #{flags} #{tox},#{toy}"
    first ? "M #{fromx},#{fromy} #{res}" : res
  end

  def line fromx, fromy, tox, toy, first: false
    s = scale
    offx, offy = *@offset
    fromx = (fromx - offx) * s
    fromy = (fromy - offy) * s
    tox = (tox - offx) * s
    toy = (toy - offy) * s
    res = "L #{tox},#{toy}"
    first ? "M #{fromx},#{fromy} #{res}" : res
  end

  def path path_specs, close: true
    first = true
    parts = path_specs.map do |args|
      send *args, first: first.tap{ first = false }
    end
    if close
      parts << "z"
    end
    %|<path d="\n#{parts.join "\n"}\n" />|
  end

载板需要做圆角,由于我的键盘上有一些非 90 度的边线,需要对圆角做点解析几何的运算:

  # returns [[:line, ...] | [:arc, ...]]
  def path_round_corner input_points, r
    points = []
    (input_points + input_points[0...2]).each_cons 3 do |pa, pb, pc|
      o, pa1, pc1 = round_corner pa, pb, pc, r
      points << pa1
      points << [o, pa1, pc1]
      points << pc1
    end
    points = points.rotate 1

    path = []
    points.each_slice 3 do |(o, arc_from, arc_to), line_from, line_to|
      ox, oy = *o
      fromx, fromy = *arc_from
      tox, toy = *arc_to
      angle = asin(_sin_angle(arc_from, o, arc_to)) * (180./ PI)
      flags = _arc_flags o, arc_from, arc_to
      path << [:arc, fromx, fromy, r, angle, flags, tox, toy]
      fromx, fromy = *line_from
      tox, toy = *line_to
      path << [:line, fromx, fromy, tox, toy]
    end
    path
  end

  # given 3 points: A, B, C and corner radius r, find arc center O and arc endpoints A1, C1
  def round_corner pa, pb, pc, r
    sin_pb = _sin_angle pa, pb, pc
    if asin(sin_pb).nan?
      raise "NaN asin(#{sin_pb})"
    end
    tan_theta = tan asin(sin_pb)./ 2
    a1b = r./ tan_theta

    # interpolate
    ab = sqrt(_distance_square pa, pb)
    bc = sqrt(_distance_square pc, pb)
    pa1 = (a1b / ab) * (pa - pb) + pb
    pc1 = (a1b / bc) * (pc - pb) + pb

    # let O = (ox, oy)
    # solve (ox - cx) / (oy - cy) = (by - cy) / (bx - cx) and (sub c to a)
    ax, ay = *pa1
    bx, by = *pb
    cx, cy = *pc1
    coe = Matrix[[bx - cx, cy - by], [bx - ax, ay - by]]
    constants = Vector[cx * (bx - cx) - cy * (by - cy), ax * (bx - ax) - ay * (by - ay)]
    o = coe.inverse * constants
    [o, pa1, pc1]
  end

  # given 3 points: A, B, C, and extend distance d, find B1
  #
  # A1-----B1 
  # A ---B |
  #      | |
  #      C C1
  #
  def outset_corner pa, pb, pc, d
    sin_pb = _sin_angle pa, pb, pc
    dxt = d./ sin_pb
    a_prime = _extend_point pa, pb, dxt
    c_prime = _extend_point pc, pb, dxt
    pb + (a_prime - pb) + (c_prime - pb)
  end

然后再费了点功夫,最终各种 SVG 就用代码的方式画好了。载板可以用 InkScape 把 SVG 导出到 DXF,找淘宝店或者在线 CNC 服务制作了。

OpenSCAD 键帽和外壳设计

如果你是一个键盘控,你是不能用鼠标或者做设计的(我不是)。OpenSCAD 是程序员的 CAD 设计软件,其使用只需要键盘,语法简单,以写代码的方式进行 3D 建模,版本控制方便,和前面生成的 SVG 可以无缝结合。

其他可供程序员的选择还有:

  • 是前东家的 AutoCAD 的早期版本,带有 LISP console
  • SketchUp,能用 Ruby 编程也是快乐的

由于穷还是用 OpenSCAD 了。

OpenSCAD 有个内建的简单编辑器,不过缺少 completion,可以和 Arduino IDE 一样选择外部编辑器。

SCAD 上有不少减少你工作量的库,例如 dotSCADscad-utils,克隆下来 import 就可以。设计中有些地方需要 Taperize,但 OpenSCAD 没有这个函数,可以自己写个矩阵运算做:

module taperize() {
    union() {
        transform_z(10) children();
        children();
    }
}

module transform_z(factor) {
    multmatrix([
        [1, 0, 0, 0],
        [0, 1, 0, 0],
        [0, -factor/bound_height, 1, -factor/2],
        [0, 0, 0, 1]
    ]) children();
}

最后在 OpenSCAD 导出 STL 文件,用 admesh 计算下用多少料不至于被淘宝商家乱开价,就可以 3D 打印了。

还有其他部件也可以 CAD 设计,例如下面的板载卫星轴基座……

V2 的钢板:

焊工:

成品:

下面可以给键盘编程了。

Debounce

编程之前,先讨论下触点开关的 bounce 问题。

当触点碰撞的时候,导电的金属片会振动弹跳,产生锯齿状的信号抖动。MX 轴一般保证抖动时间在 5ms 内。所以要 debounce 才能保证一次按压不会发出多个信号。硬件 debounce 延迟比较大而且不灵活,我们考虑软件 debounce。

懒人可以直接用 debounce 库,但它也会让你的响应延迟很多。

一般 debounce 延迟在 10ms 到 30ms 之间,人是几乎感觉不出来的,但如果在游戏竞技或者抢拍沪牌时,几个毫秒的时间有可能决定胜负。

鉴于 Debounce 是一个电路设计的老大难问题,对于如何降低延迟,我们还是先学习一下两个模拟信号的解决方案吧。

Cherry MX RealKey

Cherry RealKey 号称用模拟信号做到了达到 1ms 的 latency。我们知道模数转换 ADC (Analog-Digital Conversion) 需要给电容充电,要做多次比较,是比较花时间的。能做并行模拟信号转换的芯片需要很多 ADC 单元,非常昂贵,例如这款 64 个 ADC 单元的芯片售价都要两千了。很多微处理器也内建了 ADC 单元,但只有一两个,通过内置的 multiplexer 切换不同的输入口进行测量。

典型的读取时间:Pro Micro 用 analogRead() 的延迟就有 100 微秒,1 毫秒只够扫描 10 个键。如果直接操作 AVR 的寄存器,优化 pipeline 能做到 100k SPS (取样每秒),1毫秒勉强能扫描 100 个键。但是 Pro Micro 并没有这么多的输入线路……

RealKey 的做法是一次测量获得多个按键的状态。考虑下面的电路结构:

其中 ARef 是模拟信号参考电压,D1 是输入,R1 不等于 R2。那 S1, S2 的不同通断组合就对应到下面不同的测量值(下面 0 代表接通,1 代表断开):

\begin{array}{ccc} S_1 & S_2 & D_0\\ \hline 0 & 0 & V \\ 1 & 0 & \frac{R_1}{R_1 + R_0} V \\ 0 & 1 & \frac{R_2}{R_2 + R_0} V \\ 1 & 1 & \frac{R_1 + R_2}{R_1 + R_2 + R_0} V \\ \end{array}\

这里说明一下为什么直接接上参考电压:测量出的电压读数是由参考电压/2, /4, /8 … 的分量组成的。电路的供电电压可能会有波动,导致参考电压也一起(差不多线性地)波动,如果用别的电压供电,同样的电阻比值得到的读数可能就不一样了。

于是使用微处理器提供的 12 到 16 个模拟输入,每个输入接到 n 个开关上,通过测量 2^n 种不同的电压读数得出开关的组合。

为了使组合出来的电压区别尽量大来获得更大的区分度,考虑下面的优化问题:

maximize
  [R1, R2, ...].combinations{|Rs| sum(Rs)/(sum(Rs) + R0)}.min
where
  R0, R1, R2, ... (0, 5000]

里面都是连续函数,可以用 SGD 的方法找到最优阻值分配,具体就不细讲了。

Cherry 的 PPT

扫描次数减少,采样频率就提高了,如果采样值看起来像是要向接通的方向走去,可以在达到数字信号的触发电位( V_{Ih} )之前就 emit 键盘信号。

RealKey 键盘有一些问题:真实信号可能上下波动得很厉害,有可能一次采样到 0.8 然后一次采样到 0 而不是像图中那样大体上升的,这应该可以通过附加一个小电容改善。当信号开始微小变化的时候,你并不知道它最终是要向哪个电压变化。极速模式也容易产生双击现象。Cherry 后来出产的键盘就没有这个噱头了……

Wooting One

Wooting One 键盘的轴是真 Analog 的轴体:光轴。光轴和静电容轴一样没有机械开关的 bounce 问题。但它有别的理由做 Analog reading:通过不同的按压力度触发不同的行为。

它的做法很实在,老老实实 1ms 做全键盘扫描,5ms 的 debounce 全吃。设计上用了 5 个 mux 芯片 HC4067。每个 mux 芯片可以将 16 个模拟信号 multiplex 到一个模拟输入引脚,这样就能直接测量 80 个开关不需要二极管了。

https://www.tomshardware.co.uk/wooting-one-analog-mechanical-keyboard,review-33952-7.html

Multiplexer HC4067 的构造如图,通过控制 S0, S1, S2, S3 的高低电平组合,将 Z 连接到 Y0-Y15 之一。切换的速度在数十纳秒内,相对于模拟信号的测量时间,可以忽略。

穷人的低延迟 Debounce 算法

我们没有光轴,考虑到所有引脚的数字信号可以用一个寄存器一次读出,速度不知快到哪里去。我们老老实实做好数字电路 debounce 就好了呀…… QMK 上的键盘都是前置 debounce — 先 debounce 再输出信号,导致有 10ms 或以上的延迟。

这里采用后置 debounce — 先输出信号再做 debounce 来实现更迅速的触发。

Debounce history 的存储可以用一个 bitset 做,每次读取就将已有历史左移一位。

下面以 80 键的扫描为例子(其中省略了 READ_REGISTERS 和 EMIT_PRESS 函数,由于每个 tick delay 共 100μs ⨉ 10,后置 debounce 为 8 tick,也就是 8ms):

uint8_t history[10][8];

void loop() {
  // 1ms 一次全键盘扫描
  for (int i = 0; i < 10; i++) {
    digitalWrite((i + 9) % 10, HIGH);
    digitalWrite(i, LOW);
    delayMicroSeconds(100);
    uint8_t readings = READ_REGISTERS(); // 假设读取结果每个 bit 代表一个脚针的电平
    for (int j = 0; j < 8; j++) {
      uint8_t reading = (readings >> j) & 1;

      uint8_t h = history[i][j];
      if (h == 0b11111111 || h == 0b00000000) {
        // debounce 已完成, 可以接收新的读数
        h = (h << 1) | reading;
        if (h == 0b00000001 || h == 0b11111110) {
          // 读到了不同的 bit
          EMIT_PRESS(i, j, reading);
        }
      } else {
        // debounce 未完成,复制最右一位
        h = (h << 1) | (h & 1);
      }
      history[i][j] = h;
    }
  }
}

如果要增加容错性,不要让单个误测影响结果,可以增加前置 debounce 3 位,3 次扫描有 2 次电压和之前不同才输出结果,同时缩短扫描间隔。由于扫描间隔减少了,后置 debounce 需要增加 3 tick 才能保证同样的后置 debounce:

uint16_t history[10][8];
uint16_t BEFORE_MASK = 0b0000000000000111; // 3 bits
uint16_t AFTER_MASK  = 0b0011111111111000; // 11 bits
uint16_t MASK = BEFORE_MASK | AFTER_MASK;
bool IS_HIGH[] = { // 3 次读数投票真值表
  [0b000] = false,
  [0b001] = false,
  [0b010] = false,
  [0b011] = true,
  [0b100] = false,
  [0b101] = true,
  [0b110] = true,
  [0b111] = true
};

void loop() {
  for (int i = 0; i < 10; i++) {
    digitalWrite((i + 9) % 10, HIGH);
    digitalWrite(i, LOW);
    delayMicroSeconds(66);
    uint8_t readings = READ_REGISTERS();
    for (int j = 0; j < 8; j++) {
      uint8_t reading = (readings >> j) & 1;

      uint16_t h = history[i][j];
      if ((h & AFTER_MASK) == AFTER_MASK || (h & AFTER_MASK) == 0) {
        // debounce 已完成, 可以接收新的读数
        bool was_high = h & AFTER_MASK;
        h = (h << 1) | reading;
        bool is_high = IS_HIGH[h & BEFORE_MASK];
        if (was_high ^ is_high) {
          // 读数变化了
          h = is_high ? MASK : 0;
          EMIT_PRESS(i, j, reading);
        }
      } else {
        // debounce 未完成,复制最右一位
        h = (h << 1) | (h & 1);
      }
      history[i][j] = h & MASK;
    }
  }
}

上面的代码实现了 2ms 的前置 debounce 和 7ms 的后置 debounce。

结尾:V3 设计

K20 主控的数字信号是怎么判定的呢?手册中搜索 VIH 可以找到:

其中 V_{DD} 是供电电压。可以看到在 3V 供电的情况下,判断 HIGH / LOW 的阈值分别是 0.7 V_{DD}和 0.35 V_{DD}。如果我们通过 mux 微调供电电压,就相当于修改了数字信号读取阈值,就可以提高数字电路读取的灵敏度!

如图,M1 / M3 由 mux 控制 (或者用 analogWrite() 控制)。当状态是 LOW 的时候,我们就提供 1.1 V_{DD} ,所以相当于把触发 HIGH 读数变成了 0.63 V_{DD} ,当状态是 HIGH 的时候,我们就提供 0.8 V_{DD} ,相当熟把触发 LOW 读数变成了 0.37 V_{DD} 。所以这样的电路能捕捉到开关上更微小的信号变化。终于,把触发延迟搞到 1ms 内不是梦了。

又是一个新坑,以后再 Hello World 吧。

来源:知乎 www.zhihu.com

作者:luikore

【知乎日报】千万用户的选择,做朋友圈里的新鲜事分享大牛。
点击下载