sipeed_keyboard
sipeed_keyboard copied to clipboard
键盘按键扫描 `@metro94` `2021-07-28`
@metro94
- [x] 扫描IO初始化
- [x] 基本按键扫描
按键扫描与读取
这部分的设计与硬件直接相关,需要考虑如何与BL70x高效配合。
电路设计
对于键盘类应用,每个按键分配一个输入引脚一般是不现实的(按键实在是太多了)。通用的方法是引入键盘矩阵,理论上n
个引脚最多支持n**2/4
个按键,代价是引入了比较复杂的扫描机制。
关于键盘扫描的机制和“鬼键”的处理(二极管的应用),这里不再赘述,有兴趣的同学可以参考How a Keyboard Matrix Works。
根据前面的讨论结果,电路设计最终被确定为7x11的矩阵,理论上支持77个按键,实际上使用68个,并且占用了18个引脚。根据BL70x的KeyScan支持的特性,我们定义:列(Column)为11、行(Row)为7,驱动信号由列引脚发出并且低电平有效。因此,二极管的电流方向应该是从行引脚到列引脚。
扫描周期和消抖
按键本身并不是理想器件,在每次按下和弹起前都会经历一定的抖动,这可能会导致主控读取到的状态不正确;另外还有一些轴体可能会受到环境的影响而产生错误的脉冲等。为此,我们需要考虑消除抖动的方法,而这与扫描周期有关。
关于抖动的原理和常见的消抖方法,这里不再赘述,有兴趣的同学可以参考Contact bounce / contact chatter。
考虑到扫描周期的稳定性(扫描发生的时间是规律的),个人倾向于使用最通用的方法(也是QMK的默认方法):每次读取一整个键盘的状态,在键盘所有按键的状态保持稳定到一定时间后,才认为当前状态是有效的。这种方法消耗的资源最少,并且对噪声的容忍度也比较好(当然这种方法不大适合按键本身非常不稳定的情况,会导致找不到稳态,不过对于机械轴来说应该不会发生)。
至于扫描周期的设置,很明显应该小于上述稳定时间,这样才能通过多次采样排除掉不稳定的情况。原则上,整个键盘的扫描周期应当不高于键盘输出到USB或者BLE的时间,也就是1ms;但是,键盘扫描的周期也不应该太高,否则会影响其它代码的正常执行。
KeyScan应用
KeyScan是BL70x自带的一个功能模块,看名字就知道是用于键盘扫描模块,支持功能如下:
- 最大支持20x8的键盘,其中20为驱动列引脚,8为读取行引脚
- 最多可以存储4个按键
- 支持消抖功能、列扫描空闲间隔和“鬼键”识别(注1)
- 支持基于扫描结果的按键中断
- 在PDS(低功耗)状态下可以作为唤醒源,并且模块可以选择32KHz时钟源
注1:列扫描空闲间隔可以降低实际的扫描频率,并且(设计得好的话)可以降低恢复时间的影响(见下文);至于“鬼键”识别的应用,官方并未给出说明,好在这个问题我们可以通过硬件设计规避。
由于KeyScan对于按键数量的限制,我们不将其用于工作状态时的键盘读取,但可以将其用作低功耗状态时的唤醒源。具体的使用方法见下文的低功耗优化部分。
根据参考手册和硬件实测,我们发现KeyScan的处理方式如下:
- KeyScan模块按列进行扫描,驱动信号由列引脚发出且为低电平有效。这意味着我们需要在行引脚使能上拉电阻,并且在按键按下时应该读取到低电平,按键松开时应该读取到高电平(注2)。
- KeyScan的扫描周期和参考时钟、键盘形态有关。KeyScan的参考时钟是QDEC Clock的8分频(图1),而扫描的时钟数则有一个固定的计算公式:时钟数=键盘列数*(键盘行数+列扫描空闲间隔)+6。
- KeyScan的扫描顺序是键值从小到大的顺序,其中键值的计算公式是:键值=键盘列编号*键盘行数+键盘行编号(图2,需要注意的是时序是推测的,不一定准确)。
- KeyScan的消抖不引入额外的时钟消耗,猜测是类似于全键盘消抖的思路。
注2:在键盘扫描时,可能需要考虑驱动信号从有效到无效时行引脚电压的恢复时间。通常而言,按键的电容很小,但是上拉电阻的阻值可能会比较大(降低电路导通时的功耗),因此需要结合实际电路的RC常数进行分析。QMK的设计考虑到了这点(见matrix.c@read_cols_on_row),KeyScan是否考虑了这个问题尚不清楚,但是只用于唤醒功能的话应该问题不大。
低功耗优化
对于键盘类应用(尤其是无线连接),低功耗是非常重要的,一个好的低功耗设计可以显著延长电池的工作时间。显然,键盘扫描是一个主要的唤醒源(大家都希望按下按键就能一键唤醒),需要针对这种常见进行优化。
在我看来,键盘类应用可以设计三级的低功耗状态:
- 工作状态:键盘会定期进行全按键扫描,由于键盘可能同时按下多个按键,因此不能使用KeyScan(最多4个按键),需要手动操作GPIO实现扫描。
- 浅度睡眠状态:键盘在短时间内不工作,此时的主要任务是检测是否有按键按下(暂时不需要关心每个按键的状态),此时可以使用KeyScan,当然也可以采用GPIO中断等方式实现(注3)。
- 深度睡眠状态:键盘在长时间内不工作,此时可以关闭更多外设以取得更低的功耗,代价是唤醒时时间更长,并且可能不能保存更多状态(大部分SRAM等外设会掉电)。
注3:可以考虑用上PD_CORE的GPIO0-7唤醒源,这样可以关闭PD_CORE_MISC_DIG和PD_CORE_MISC_ANA,缺点是这个唤醒源只能实现其中一个GPIO,最多可以做到一个或一排按键的唤醒,使用上并不是很方便。当然,如果需要在休眠过程中打开蓝牙或USB(当前设计似乎没有蓝牙和USB的切换开关),就不能关闭PD_CORE_MISC_DIG了。
通过查阅参考手册,我们可以发现,KeyScan位于PD_CORE_MISC_DIG的电源域,这个电源域并不在AON的范围内,意味着只能在睡眠模式(PDS)下使用,不能在休眠模式(HBN)下使用。由于休眠模式下只支持少量唤醒源,并且与当前的键盘扫描功能难以配合,因此可能不做考虑(注4)。
注4:可以考虑使用其它外设作为唤醒源,比如使用PIR(人体红外感应),这个模块位于PD_AON_HRNCORE电源域,搭配使用可以做出比较炫酷的效果。
另外,对于KeyScan的时钟源,我们尽量选择32.768KHz的时钟源以节省功耗。BL70x支持RC32K(内置晶振)和XTAL32K(外置晶体)作为KeyScan的时钟源(图3),这个可以通过GLB寄存器进行配置。通常而言,内置晶振的准确性比外置晶体差,但是通常具有更低的功耗(不绝对),可以根据其它模块的需要和电路设计进行选择。
还有一个问题,就是关于行读取引脚的上拉电阻。更大的上拉电阻可以降低导通时的功耗,代价是放电时的时间常数更大。BL70x可以配置低功耗状态下的引脚上拉,但不清楚上拉电阻的阻值如何,这个也可以做更多尝试。
键位映射
如果说按键扫描主要是硬件设计的功劳,那么键位映射就纯粹是软件代码的艺术了。特别是对于小配列的键盘来说,一个合理的键位映射可以起到事半功倍的效果,而支撑起这一功能的必然是一套非常灵活而功能丰富的软件框架。
对于键位映射,我认为可以分解成不同层次(hierarchy)的问题:
- 在最低的层次,我们只关心键盘上按键的当前状态,也就是“按下”与“释放”。通过上面提到的键盘扫描和消抖(包括软件和硬件消抖),我们可以在一系列时间点上获取键盘所有按键的情况,但尚未建立起不同时间点的状态转移关系。
- 在稍高的层次,我们将前面提到的按键状态以时间为单位联系在一起,此时可以分析出更多信息,例如按键被按下的时刻、按键是轻触还是长按等,这有助于我们实现更加复杂的功能(例如在同一个按键区分轻触和长按对应的键值,或是在按下其中一个按键后另一个按键的键值变化等)。
- 在更高的层次,我们会引入空间上的按键状态,这里问题会变得更加复杂。通过引入层(layer)的概念,我们可以在不同的状态下部署不同的键位和键值(注意到键值实际上是和当前键盘的状态相关的,而非只和按键位置有关),从而扩展出键盘可以接受的所有按键组合(包括时间和空间)。
- 最后,我们会将所有的按键组合打包成为一个配列,这个配列对应了一套完整的键位映射配置。根据需要,我们可以在线(例如macOS和Windows的键位切换)或者离线(例如通过刷机支持新的配列)对配列进行修改,从而在不同的应用场景使用不同的配置方案。
对于键位映射的详细介绍超出了本文的范围。如读者对相关内容感兴趣,可以阅读以下文档(主要来自于QMK和ZMK):
QMK键盘部分代码分析
在QMK中,关于键盘部分的代码主要位于tmk_core/common/keyboard.c
文件的keyboard_task()
函数中,这一函数涵盖了与键盘相关的各种函数。我们这里主要关心键盘扫描和键位映射这两部分。
考虑到这里主要是研究QMK的软件架构,这里选择了最简单的配置(关闭各种花里胡哨的功能)来进行介绍。
keyboard_task()
的主要调用关系如下(函数名字都很清楚,想必不用介绍具体功能了):
-
keyboard_task()
-
matrix_scan()
-
read_rows_on_col()
-
select_col()
/unselect_col()
-
matrix_output_select_delay()
/matrix_output_unselect_delay()
-
readPin()
-
-
debounce()
-
-
action_exec()
-
process_record()
-
process_record_handler()
-
process_action()
-
-
-
-
这里加粗的函数对应键盘部分三个最基本的功能,分别是读取键盘状态、消抖和处理按键动作。下面会简单介绍这三个功能的代码结构。
读取键盘状态
读取键盘状态本身倒没啥好说的,上面已经介绍过了。这里看看读取的核心代码(外层是以col
为单位的循环):
static bool read_rows_on_col(matrix_row_t current_matrix[], uint8_t current_col) {
bool matrix_changed = false;
// Select col
select_col(current_col);
matrix_output_select_delay();
// For each row...
for (uint8_t row_index = 0; row_index < MATRIX_ROWS; row_index++) {
// Store last value of row prior to reading
matrix_row_t last_row_value = current_matrix[row_index];
matrix_row_t current_row_value = last_row_value;
// Check row pin state
if (readPin(row_pins[row_index]) == 0) {
// Pin LO, set col bit
current_row_value |= (MATRIX_ROW_SHIFTER << current_col);
} else {
// Pin HI, clear col bit
current_row_value &= ~(MATRIX_ROW_SHIFTER << current_col);
}
// Determine if the matrix changed state
if ((last_row_value != current_row_value)) {
matrix_changed |= true;
current_matrix[row_index] = current_row_value;
}
}
// Unselect col
unselect_col(current_col);
matrix_output_unselect_delay(); // wait for all Row signals to go HIGH
return matrix_changed;
}
这里值得关注的主要是两个*_delay()
函数以及计算matrix_changed
变量的方式。
对于两个*_delay()
函数的作用,我们已经在上面提到了。理论上,对于每个键盘,我们需要测试合适的延迟时长,但我们可以先从QMK的默认值开始。通常来说,matrix_output_select_delay()
对应的延迟很短(AVR单片机是2个时钟周期,STM32则默认是0.25us),而matrix_output_unselect_delay()
对应的延迟较长(默认是30us),这与机械轴的特性有关。
matrix_changed
的计算方式也比较简单,只要有按键的键值发生变化,就简单地将其置为true
即可。
消抖
消抖的算法有很多,我们举最简单的一个例子,也就是默认的sym_defer_g
。通过以下代码,应该可以很容易看出:这里消除抖动的方式,是通过计算最后一次键盘状态变化时间来实现的:
static uint16_t debouncing_time;
void debounce(matrix_row_t raw[], matrix_row_t cooked[], uint8_t num_rows, bool changed) {
if (changed) {
debouncing = true;
debouncing_time = timer_read();
}
if (debouncing && timer_elapsed(debouncing_time) > DEBOUNCE) {
for (int i = 0; i < num_rows; i++) {
cooked[i] = raw[i];
}
debouncing = false;
}
}
在确认键盘不再发生抖动后,消抖程序会使用新值raw[]
代替旧值cooked[]
,从而确认当前的按键行为。
处理按键动作
这个说起来就复杂了,建议直接看看代码:action.c@process_action。相信在看完后读者会对按键动作的复杂度有一定的认知(腹黑笑)。
软件架构设计
根据前面的代码分析,我们发现键盘设计离不开对于按键扫描和键位映射的处理。由于键盘的很多功能就是围绕按键来进行的(不论是基本的按键输入还是LED灯效等进阶特性),因此我们考虑将软件架构设计为以按键为核心、多个模块同时运行的机制,这就需要引入RTOS来实现各种代码的有机结合。
在下面的软件架构设计中,我们把键盘分成最基本的三个部分,每个部分都对应一个RTOS中的任务:
- 按键扫描(KeyScan)
- 键位映射(KeyMap)
- 事件路由(EventRouter)
首先我们说说“事件”这个概念。对于键盘类操作,我们将一些由各个模块生成、并可能需要某些特定模块响应的动作称为“事件”(即event)。在我看来,一个事件可以包括以下部分:
- 事件类型:记录事件的具体类型,方便我们对事件进行路由。不同模块通常对不同的事件感兴趣,通过对事件进行标记和订阅,我们可以将事件从发送者推送到感兴趣的接收者,在这其中事件会被自动复制和分发,不需要发送者或接收者进行更多操作。
- 数据:记录事件本身携带的数据。我们将数据长度定为32位,这是因为我们可以在32位MCU上将完整的地址放在数据对应的变量中,从而可以携带更多有价值的信息。
- 时间戳:记录事件发生的时间,也就是tick的值。对于我们的任务,时间戳通常具有重要的意义,它指示了一个事件在哪个时间点发生,我们可以利用这个值来维护任务之间的数据同步。这个时间戳通常由RTOS来提供。
可以看到,在引入了事件的概念后,可以将不同任务之间的信息交互转化成对事件的操作,这有助于我们从更高的抽象层次考虑问题,从而有助于设计出更加简洁清晰的架构和代码。
其次,我们将KeyScan与KeyMap分成两个任务,这是考虑到了以下因素:
- KeyScan和KeyMap本就是两个意义明确的不同任务,并且相互之间的耦合性并不强(KeyScan只需要记录并传递“按钮按下或释放”这样的事件并传输给KeyMap),也就是说具备分开操作的基础。
- KeyScan和KeyMap与软硬件交互的特性并不相同,分开考虑能够更好地解决问题。
- KeyScan与硬件是深度绑定的,每个实体键盘都有一套相对固定的硬件参数(可插拔之类的暂时不考虑),也就是说可以在编译阶段就把硬件参数固化到固件中,在运行时不会进行修改或重配置。
- KeyMap则通常被看作是可以在软件端重配置的,因此需要有较强的灵活性。很多KeyMap相关的功能(如特定按键切换键位映射等)需要软件配合进行修改和计算,这决定了相关的逻辑不适合写死到代码中。
- KeyScan和KeyMap对事件的处理也并不统一,有时候将两者分开考虑能够更加便于软件设计。
- KeyScan生成的事件可以和LED灯效之类的功能进行绑定,实现与按键位置相关的操作(例如按下特定按键时对应LED亮)。
- KeyMap生成的事件可以和蓝牙控制之类的功能进行绑定,实现与特定组合键相关的操作(例如使用
Fn+QWE
实现最多三个蓝牙主机的切换)。
最后,我们说说事件路由的存在意义。
前面提到,事件路由的一大优势就是将任务之间的数据同步进行解耦。原来的设计需要将所有可能的路由方向硬编码到任务中,这不利于编写低耦合、高内聚的代码;现在,只需要在任务开始前在事件路由注册并获得与具体任务绑定的事件队列,就能方便地控制事件的传输,从而将事件自动分发到各个所需的模块中。另外,事件路由还能将事件队列对应的内存资源统一管理,从而进一步简化各个模块的设计。
接下来简单说明一下各个任务的设计思路。
按键扫描
按键扫描有一个特殊点:按键事件是很多事件的触发起始点。因此,对于按键扫描的事件,我们需要以适当的速率进行扫描以保证结果的稳定性,并且给出一个合适的时间戳以记录扫描发生的时间。
对于按键扫描的任务,我们会定义一个固定时间且自动重载的定时器用于唤醒任务。在每个键盘扫描的周期中,我们需要依次完成以下任务:
- 键盘矩阵扫描:读取键盘矩阵的原始值,并将电信号的高低转换成统一的极性(如1表示按下,0表示释放)
- 键盘消抖:根据先前记录的历史信息,对键盘矩阵读出的原始值进行消抖,并在确保稳定后进行标记
- 按键事件生成:根据消抖后产生的新键盘状态与前一个稳定的键盘状态进行对比,确定按键的改变情况,并生成相应的事件
为了完成上述工作,我们将键盘矩阵的状态分为3个数组分别存储,其中:
-
last
:表示上一个稳定状态 -
next
:表示刚刚生成、即将用于与上一个稳定态进行对比的新状态 -
raw
:表示键盘扫描矩阵直接读取、未经过消抖的状态
那么,上面的任务就可以表述为另一种更加直接的说法:
- 键盘矩阵扫描:读取
raw
- 键盘消抖:根据
raw
的取值和其它信息(取决于消抖算法本身),在合适的时机更新到next
- 按键事件生成:将
last
与next
进行对比,不同之处生成相应的事件,并且逐一更新到last
在按键扫描中,生成事件的各个部分如下:
- 事件类型:主要是按键按下与释放这两个事件,另外可以引入
TICK_END
事件以确保在每个键盘扫描周期都可以进行同步,有助于后续工作的处理(例如确保HID报告以时间为单位进行同步等) - 数据:记录的是当前键位(与按键在键盘中的位置相关),注意到键位(Key Position)和键值(Key Code)的区别(键值需要经过键位映射之后才能确定)
- 时间戳:略
键位映射
在现阶段,键位映射的功能会做得比较简单,除了最基础的功能之外,会加上以下功能:
- F区切换:按Fn+第一行按键可以实现F1到F12的映射
- 控制类操作:按Fn+其它按键可以实现键盘功能的控制
考虑到分工,除了F区切换的功能由键位映射自行维护之外,其它功能暂时留空,先保证USB HID键盘可以正常使用,后续可以继续更新。
当然,键位映射最核心的部分应该是Layer和KeyCode的有机结合。在现阶段,这部分至少会留出相应接口,主要是便于演示各种功能,可以更加直观地看到可编程键盘的优势。
在键位映射中,生成事件的各个部分如下:
- 事件类型:先完成添加键值、移除键值这两个功能,可以控制HID报告的内容,后续视情况加入控制类的操作
- 数据:暂时记录键值本身的结果(键值采用USB HID定义,注意到基本按键的键值都在8位的范围内)
- 时间戳:保留按键扫描的时间戳,方便进行后续的同步工作
事件路由
对于事件路由,现阶段主要是定义好对外的接口。计划的事件路由注册函数的定义如下:
int event_router_register(const event_subscription_t *sub, size_t queue_length, QueueHandle_t *queue_to_router, QueueHandle_t *queue_from_router);
typedef struct {
uint8_t len;
uint32_t types[0];
} event_subscription_t;
其中,queue_to_router
表示获取一个从任务到事件路由的队列,这个队列是全局队列,所有任务都可以将事件放到这个全局队列上来进行分发;queue_from_router
表示获取一个从事件路由到任务的队列,这个队列是各个任务独占的,队列长度queue_length
由各个任务确定,但是资源的维护仍由事件路由完成。event_subscription_t
的主要作用是方便在一次调用中注册所有需要的事件类型。
在完成注册之后,各个任务就可以使用queue_to_router
和queue_from_router
自由地推送和接收数据了,事件的过滤和转发将会由事件路由自动完成。
键盘扫描相关文档
键盘扫描(包括按键扫描和键位映射)是机械键盘的核心部分,不仅是机械键盘固件中不可或缺的成分,同时也是客制化中的一大亮点。
这个文档记录了键盘扫描的一些基本概念,以及当前关于键盘扫描部分的实现方式和相关API。
基本概念
此处约定了机械键盘的相关定义,并且简要描述了键盘扫描中各个部分是如何运作的。
矩阵和布局
在机械键盘中,我们将矩阵(matrix)和布局(layout)的概念区分开来,其中:
-
矩阵指的是机械键盘在电信号连接方面的组成方式。在机械键盘中,通常需要用较少的引脚数来处理大量按键的状态读取,因此使用GPIO读取单个按键的状态在大部分情况下是不现实的。为此,我们使用矩阵扫描的方式,最多可以实现在
n
个引脚上读取n ** 2 / 4
个按键的状态。 - 布局指的是机械键盘在外观排列上的组成方式。对于用户而言,一个键盘的按键数量是容易感知的,例如标准的65%配列键盘一共有5行、15列按键。布局和矩阵通常是一一映射的关系,但是两者之间的映射并不是确定的,对于用户而言通常只关心布局部分。
考虑到矩阵和布局的不同概念,我们倾向于分别在以下情景中使用它们:
- 矩阵:矩阵对于硬件来说是唯一且固定不变的,因此一般是直接硬编码到固件中。对于键盘扫描等更加底层的处理过程,我们以矩阵为单位进行操作,这样的处理更加自然,效率也更高。
- 布局:布局是可以随着软件改变的,开发者和用户都能(在不同程度上)自定义键盘的布局。对于键位设计等更加贴近用户层面的处理过程,我们以布局为单位进行操作,这样可以方便后期进行直观的展示和修改。
当然,我们会在代码中完成从布局到矩阵的转换,这部分通常也是硬编码到固件的。
软件框架
在软件中,键盘扫描对应两个部分,分别是:
- 按键扫描:从键盘矩阵中读取按键状态,经过消抖处理后,最终转化成按键位置相关的事件。
- 键位映射:根据当前键盘的一些状态和键位信息,映射到具体键值,或者更改当前的状态。
其中:
- 键位指的是按键事件中的按键位置。通常而言,我们使用矩阵中的位置来表示键位(与键盘扫描时的顺序有关),但会在随后的处理过程中映射到布局中的位置(对应到实际的按键位置)。
- 键值指的是按键事件中触发的按键内容。比如说,我们在普通的104键键盘按下了小键盘上的数字0,那么最终传输到电脑的数据中必然包含了数字0对应的键值(小键盘上的数字0对应键值是0x62)。
键位映射与层结构
在65%等非完整配列键盘中,通常需要对标准键盘上的按键进行缩减,这会导致部分非常用键值没有对应的独立实体按键(比如F1到F12)。在这种情况下,我们可以引入组合键的方式来扩展键盘的功能。
在键位映射中,层是一个比较重要的概念,它用简单清晰的抽象方式来快速扩展已有的按键功能。举个例子,我们会使用Fn+第一排数字键的方式在65%键盘中引入F1到F12,那我们可以把F1到F12放在同一层中,并且规定在Fn按下时该层生效,从而一次性改变多个按键的功能。
关于层的各种特性和应用超出了本文的范围,可以参考其它成熟键盘固件(如QMK)的文档以了解相关知识。
代码结构
考虑到键盘部分的代码量较大,我们将这部分源代码和头文件单独放在对应的keyboard文件夹中,避免与其它代码混淆。
sipeed_keyboard_68
+-- config 工程相关的键盘配置文件(包括键盘参数、键位映射和I/O函数)
| \-- sipeed_mechanical_keyboard.c SMK的键盘配置文件
+-- include/keyboard
| |-- smk_event.h 键盘相关事件的声明
| |-- smk_keyboard.h 键盘公有部分的声明
| |-- smk_keycode.h 键盘的键值(包括HID定义的键值和其它组合功能)
| |-- smk_debounce.h 键盘消抖部分的声明(不同消抖算法共用)
| |-- smk_keyscan.h 键盘扫描部分的初始化和任务声明
| \-- smk_keymap.h 键位映射部分的初始化和任务声明
\-- keyboard
|-- smk_keyscan.c 键盘扫描部分的源代码
|-- smk_keymap.c 键位映射部分的源代码
\-- smk_debounce_*.c 键盘消抖算法的源代码
处理流程
按键扫描
按键扫描可以进一步分为原始值读取、抖动消除和键位事件提交三个部分。
在按键扫描的过程中,我们需要保存以下信息:
-
raw
:存储按键状态的原始值(未经过消抖处理) -
next
:存储按键状态的下一个稳态值(方便与原始值和上一个稳态值比较) -
last
:存储按键状态的上一个稳态值(当前已上传到主机的按键状态)
以上信息均以矩阵中的位置(也就是键位)为索引值进行存储。为了节省内存空间,每个键位都对应1个bit,并且均统一为1按下0释放。
由于按键扫描是周期性执行的,因此需要使用RTOS的定时器特性,保证在一段时间内按键扫描的程序会执行一次。
原始值读取
这一步比较简单,就是通过扫描键盘矩阵的方式,读取键盘的当前状态。由于这一步还没有进行消抖处理,因此读取的值可能包含了按键的抖动状态,不能直接拿来使用。这一步记录的数值将会存储到raw
数组。
抖动消除
抖动消除是可以根据需要进行定制的一套算法,算法的复杂度与实现的方式相关。这里,我们选择最简单的一种方式,对应的是QMK固件中的sym_defer_g
算法。这个算法的工作流程如下:
- 不论是对于按下还是释放的事件,都会使用同样的一套算法(对应
sym_
前缀),也就是在按键状态发生变化时跟踪一段时间但不着急改变稳态值;在经历了一段时间,发现状态不再发生改变后,就会应用新的稳态值(对应defer
)。 -
_g
后缀表示对整个键盘同时进行检测,也就是说只有整个键盘的值稳定后才确认结果。这种方式的好处是节省内存空间(仅需记录当前状态和上一次抖动的时间),缺点是对于快速输入的应用可能不友好。
当然,后续可以通过修改代码的方式添加新的消抖算法,可以参考QMK或其它键盘固件的文档,这里不再赘述。
在消抖的过程中,先前发生改变的状态将会被记录到last
数组,算法会通过与raw
数组进行一系列比较和历史信息处理,确定新的稳态值是否已经产生,如果是的话则设置key_next_updated
变量,通知后续的键位事件提交部分。
键位事件提交
这一步也比较简单,就是根据key_next_updated
变量获取是否当前需要更新last
数组。如果该变量值为1,则对比next
和last
的不同之处,并将差异转换成键位事件(包括按下和释放)。
另外,为了支持后续其它模块的处理,这里还同时记录了扫描结束的事件(即SCAN_END
),这一事件用来表示每次按键扫描的结束状态,不论当前时刻是否产生了按下和释放的事件。引入扫描结束事件一方面可以判断每个时间片的按键状态,另外也方便追踪键位事件的表示范围(比如说可以得知当前周期都按下了哪些按键,从而在HID发送报告时将同一时间片内的按键更新统一进行提交)。
键位映射
键位映射可以进一步分为键值搜索和键值处理两部分。
键值搜索
键值搜索涉及到前面所说的层结构。具体来说,我们需要根据当前的层状态,找到键位事件中实际对应的键值。
键值搜索有以下几个问题需要处理:
- 需要根据当前的状态找到合适的键值。以下因素会影响键值的取值:
- 层的顺序:键值的优先级是从高层往底层递减的。
- 层的状态:层本身可以被开启或关闭,关闭后的层不发挥任何功能。层的状态可以被快捷键或者其它方式改变。
- 特殊键值:有些特殊键值会影响取值,例如
KC_TRANS
表示当前层是透明的(意味着取更低层的键值),而KC_NO
表示当前层的键值不存在(会隔离更底层的键值)。 - 其它附加的功能,例如Tap-Hold功能(短按和长按取不同键值)等。
- 需要考虑按下与释放时的层结构不一致的问题。由于在按键按下与释放时的层结构可能不一致,因此需要通过一定方式来保存状态,以确保按下与释放时间一一对应。具体的处理方式如下:
- 在按下时,记录实际使用的键值对应的层。由于键值和键盘布局直接对应,我们使用布局位置作为索引,存储响应按下操作时的层。
- 在释放时,从存储单元中读取按下操作时的层,并由此得出对应的键值。需要注意的是,最好可以在读取结束后清空存储的层索引,在遇到多次释放按键的异常操作时可以保证程序的功能正确性。
键值处理
在获取到实际键值后,我们可以对键值进行处理了。前面提到,我们会在键盘中使用组合键值,以实现更加复杂的特性。当前的代码已经实现了这些特性:
- Default Layer(简称DF):设置当前的默认层。在切换默认层时,将会禁用原有的层,并且使能新的默认层。
- Momentarily Activates Layer(简称MO):临时开启某个层。MO的用法是在按下按键时开启某些特殊的层(典型应用是Fn键+数字键=F1~F12),在释放按键后关闭。
当然,我们后续还能实现新的特性,包括层的高级功能和其它辅助功能(例如系统控制等),这些都需要后续工作来完善。
API设计
为了方便进行二次开发,这里我们将API的设计一并列出,可以根据需要进行修改和扩展。
键盘配置文件
对于每个键盘,都会有一组硬件参数和I/O相关函数与之对应。考虑到配置文件应该方便后期进行修改,我们将相关代码从键盘功能本身独立出来,并以源代码的方式存储在config
文件夹中。
硬件参数
硬件参数的结构体如下所示。
typedef struct {
struct {
uint8_t total_cnt;
uint8_t col_cnt;
uint8_t row_cnt;
const uint8_t *col_pins;
const uint8_t *row_pins;
const uint8_t *layout_from;
} matrix;
struct {
uint8_t total_cnt;
uint8_t col_cnt;
uint8_t row_cnt;
} layout;
struct {
uint16_t scan_period_ms;
uint16_t max_jitter_ms;
uint8_t debounce_algo;
} scan;
struct {
uint8_t layer_cnt;
uint8_t default_layer;
const smk_keycode_type *keymaps;
} map;
} smk_keyboard_hardware_type;
其中:
-
matrix
是与键盘矩阵相关的参数,包括矩阵的规模、对应的GPIO以及到键盘布局之间的映射数组。 -
layout
是与键盘布局相关的参数。 -
scan
是与按键扫描相关的参数,其中debounce_algo
需定义为smk_keyboard_debounce_algo_type
枚举类型的值。 -
map
是与键位映射相关的参数,其中keymaps
以键盘布局为索引,按层序号从小到大的顺序来存储映射情况。
I/O相关函数
I/O相关函数通常与使用的MCU相关,因此我们将进行I/O相关操作的函数封装好,以便配置文件中进行设置。
I/O相关函数包括:
-
int smk_keyscan_init_gpio(const smk_keyboard_hardware_type *hardware)
-
void smk_keyscan_select_col(const smk_keyboard_hardware_type *hardware, uint32_t col)
-
void smk_keyscan_unselect_col(const smk_keyboard_hardware_type *hardware, uint32_t col)
-
void smk_keyscan_select_col_delay(const smk_keyboard_hardware_type *hardware)
-
void smk_keyscan_unselect_col_delay(const smk_keyboard_hardware_type *hardware)
-
uint32_t smk_keyscan_read_entire_row(const smk_keyboard_hardware_type *hardware)
-
uint32_t smk_keyscan_read_row_gpio(const smk_keyboard_hardware_type *hardware, uint32_t row)
其中,uint32_t smk_keyscan_read_entire_row(const smk_keyboard_hardware_type *hardware)
是可选的,默认有一个调用uint32_t smk_keyscan_read_row_gpio(const smk_keyboard_hardware_type *hardware, uint32_t row)
的实现,当然也可以重定义以更快地读取相应GPIO(见sipeed_mechanical_keyboard.c
)。
键盘事件
键盘事件是键盘扫描部分进行信息传输的重要载体。为了方便键盘扫描以外的其它代码与之进行交互,我们将键盘事件统一为如下结构体:
typedef struct {
uint8_t class;
uint8_t subclass;
smk_event_data_type data;
TickType_t timestamp;
} smk_event_type;
其中:
-
class
是事件的类型,当前主要有KeyPos和KeyCode两种 -
subclass
是事件的子类型,可以更加详细地描述事件 -
data
是事件携带的数据,当前定义typedef uint16_t smk_event_data_type
-
timestamp
是事件发生的时间,以FreeRTOS提供的时刻为准
键盘事件可以通过FreeRTOS提供的Queue实现为事件FIFO,从而可以在多任务处理的过程中保证事件的同步和处理方式的统一。
键值
键盘扫描部分使用的键值在smk_keycode.h
定义,可以分为以下部分:
- 0x0-0xFF为基本键值,这些键值在HID Usage Tables中定义,剩下的空位可以留作它用(例如定义为系统控制按键)
- 0x100以上为组合键值,可以结合其它功能(例如前面提到的DF和MO)实现更加复杂的键盘操作
对于USB HID部分,通常只接受合法的键值,因此传递到USB HID的键值需要保证合法性(包括排除KC_NO
等不存在的键位等)。
按键扫描
对于主函数而言,按键扫描主要有两个函数是需要被直接调用的,分别是初始化函数smk_keyscan_init
和任务循环函数smk_keyscan_task
。
注:之所以将初始化函数与任务循环函数分开,主要是希望将初始化与任务添加的函数分离,从而允许更灵活地处理任务的相关参数(包括但不限于优先级、静态分配栈空间等)。
smk_keyboard_scan_type * smk_keyscan_init(const smk_keyboard_hardware_type * const hardware, QueueHandle_t queue_out)
该函数接受一个硬件配置结构体hardware
和一个只用于输出的队列queue_out
,其中queue_out
需要根据需求事先定义(建议队列深度不少于实际按键数量),并且queue_out
只会输出KeyPos事件。
该函数返回值为smk_keyboard_scan_type *
类型的结构体指针,保存了任务循环函数需要用到的参数。如果返回值为NULL
,表示初始化失败,否则成功。
void smk_keyscan_task(void *pvParameters)
该函数接受一个无类型指针pvParameters
,实际上存储的是smk_keyboard_scan_type *
类型的变量。这里必须使用smk_keyscan_init
函数的返回值作为参数传入。
在该任务中,会调用另一个定时器任务的初始化函数,并且设定定时器任务会定时唤醒主任务,从而起到定时扫描的效果。
注:之所以没有直接在定时器回调函数中进行按键扫描操作,一方面是因为定时器任务本身优先级低,不方便阻塞其它线程,因此处理时间应该尽可能短,而普通任务则没有这个问题;另一方面是定时器任务的栈空间较少,直接在回调函数中调用复杂操作可能会导致栈溢出等异常情况。
键位映射
对于主函数而言,按键扫描主要有两个函数是需要被直接调用的,分别是初始化函数smk_keymap_init
和任务循环函数smk_map_task
。
smk_keyboard_map_type * smk_keymap_init(const smk_keyboard_hardware_type * const hardware, QueueHandle_t queue_in, QueueHandle_t queue_out)
该函数接受一个硬件配置结构体hardware
、一个只用于输入的队列queue_in
和一个只用于输出的队列queue_out
,其中:
-
queue_in
可以输入任何事件,但键位映射任务只会处理KeyPos事件,其它事件会被过滤 -
queue_out
只会输出KeyCode
事件
该函数返回值为smk_keyboard_map_type *
类型的结构体指针,保存了任务循环函数需要用到的参数。如果返回值为NULL
,表示初始化失败,否则成功。
void smk_keymap_task(void *pvParameters)
该函数接受一个无类型指针pvParameters
,实际上存储的是smk_keyboard_map_type *
类型的变量。这里必须使用smk_keymap_init
函数的返回值作为参数传入。
该任务会在queue_in
传入事件后被RTOS唤醒,并进行相应的处理工作,直到当前事件完成、尝试读取下一个事件为止。
TapEngine设计与使用
为了使得键盘支持Tap-Hold及相关功能,这里我们引入了TapEngine的相关设计,并给出了基本的使用方法。
Tap-Hold简介
Tap-Hold(短按-长按)是机械键盘键位映射的核心功能之一,基本思路是根据按键时间的长度来判断当前按键的使用状态,从而赋予单个按键以更多的功能。
Tap-Hold有以下几种基本应用:
- 键盘上的Fn键(常见于笔记本电脑和小配列机械键盘):通常Fn键的功能是,连续短按两次长期切换新的键盘布局(包括F区按键或多媒体键等),长按则临时切换;如果当前已经是新的键盘布局,则按一次Fn键可以返回正常模式。
- 粘滞键:在多次短按同一个按键后,可以临时将某个键位保持为按下状态,直到再次短按取消。这个功能对需要多次按下某个Modifier Key的情况有帮助(例如Ctrl-C/Ctrl-V)。
对于Tap-Hold来说,以下几个参数可以决定相关的特性:
-
TAPPING_TERM
:短按时间上限。当按键时间少于一定长度,则会被判定成是短按(Tap),否则为长按(Hold)。这个值在QMK固件中默认取200 ms。 -
TAPPING_INTERVAL
:连续短按间隔长度上限。连续短按功能要求相邻的短按时间间隔少于一定值,如果超过则清空短按次数的计数器。 -
TAPPING_COUNT
:连续短按触发次数。为了避免误操作,可以将连续短按触发次数调整到比较高的值(例如5次),这对于粘滞键的应用来说很有帮助。
当然,与Tap-Hold相关的特性还有很多(典型的是与其它按键的交互,或者与Layer之间的相互影响)。这里我们主要集中于Fn键的相关应用,其它应用留待后续开发者自由发挥。
设计思路
一般来说,Tapping相关的特性与键值有关,因此我们将其放在KeyMap的相关部分;不过,由于TapEngine的设计相对复杂,并且不影响其它功能的设计,我们将其作为KeyMap的独立子模块进行设计较为合适。
对于已有的KeyMap代码,我们还需要添加不同的结构体和函数。
smk_keyboard_tapengine_type
这个结构体以键值为单位来存储使能了Tapping功能的相应按键。结构体的声明如下:
typedef struct {
uint8_t state;
uint8_t count;
smk_keycode_type keycode;
TickType_t timestamp;
} smk_keyboard_tapengine_type;
其中,state
存储了TapEngine的所有可能状态,如下所示:
typedef enum {
SMK_KEYBOARD_TAPENGINE_IDLE,
SMK_KEYBOARD_TAPENGINE_TAPPING,
SMK_KEYBOARD_TAPENGINE_HOLDING,
SMK_KEYBOARD_TAPENGINE_POST_TAP,
SMK_KEYBOARD_TAPENGINE_POST_HOLD
} smk_keyboard_tapengine_state_type;
状态的切换和相应动作如下所示:
- 首先,在没有动作的情况下,按键处于
IDLE
状态。 - 当有Tapping功能的按键被按下时,程序将会根据
keycode
推断出当前使用的TapEngine索引,并且将其更新为TAPPING
状态,表示正在被短按。 - 接下来,程序将会一直检测相应KeyCode的按键状态,如果在
TAPPING_TERM
的时间内没有更新,则会认为是一次长按,跳转到4;否则,程序会认为是一次短按,跳转到5。 - 对于长按的情况,按键将会切换到
HOLDING
状态,直到识别到按键释放为止,此后会切换到POST_HOLD
状态,跳转到7。 - 对于短按的情况,按键将会切换到
POST_TAP
状态,并且累加count
的值,跳转到7. - 在
POST_HOLD
状态,程序会清空count
的值,并且将状态置为IDLE
。 - 在
POST_TAP
状态,程序会将状态置为IDLE
。
之所以设置这两个POST
状态,原因和程序更新TapEngine的方式有关。一般来说,我们需要将TapEngine内部的处理和TapEngine生成的事件分开,前者和后者是先后发生的关系,因此TapEngine的相关逻辑可以相对独立,后续需要添加Tapping的相关功能也只需要处理TapEngine事件即可,可以保证程序的低耦合高内聚。
这样一来,各个状态切换的时间点如下所示:
- 对于2、4、5,由于和KeyPos事件(也就是按键的按下和释放)直接相关,因此将其放在对应KeyCode事件的处理流程中,生成的事件也可以在后续的流程中直接使用。
- 对于3,由于需要处理KeyPos事件超时的情况(也就是一段时间内没有发生按键释放的事件),因此将其放在对应
SCAN_END
事件的处理流程中,保证在每个扫描周期都可以检测按键状态,并在合适的时机发生状态转移。 - 6和7的设置是考虑到TapEngine的逻辑与后续处理相分离的特点,在进入到
POST_TAP
和POST_HOLD
状态后不立刻进行处理,而是延迟到下一次SCAN_END
事件再进行处理,在中间就可以插入TapEngine以外的其它逻辑(例如Tap-Toggle Layer等),并且仍能保持正确的状态(包括state
和count
)。
TapEngine事件(SMK_EVENT_TAPENGINE
)
前面提到的是TapEngine的内部处理逻辑,而TapEngine对外暴露的是已经处理后的TapEngine事件。TapEngine的事件子类型定义如下:
typedef enum {
SMK_EVENT_TAPENGINE_NONE,
SMK_EVENT_TAPENGINE_TAPPING_BEGIN,
SMK_EVENT_TAPENGINE_TAPPING_END,
SMK_EVENT_TAPENGINE_HOLDING_BEGIN,
SMK_EVENT_TAPENGINE_HOLDING_END,
SMK_EVENT_TAPENGINE_COUNT
} smk_event_tapengine_subclass_type;
可以看到,对应的事件共有4个,其中:
-
TAPPING_BEGIN
:表示Tapping按键开始被按下。 -
TAPPING_END
:表示Tapping按键在短按时间内被释放。 -
HOLDING_BEGIN
:表示Tapping按键在短按时间内没有被释放,即将开始长按状态。 -
HOLDING_END
:表示Tapping按键的长按结束。
在这4个时间点,我们可以根据需要对Tapping按键进行相应的处理,此时TapEngine事件的data
变量记录的是按键的相关ID(非KeyCode),这个ID可以用作后续的处理流程。
TapEngine初始化
由于Tap-Hold是比较消耗处理资源的一类特性,我们对支持Tapping功能的最大键值数量做了一定限制(通过hardware->map.tapping_key_cnt
),多于此数量的键值可能不做处理。另外,在初始化TapEngine时,需要对键盘布局进行一次完整扫描,以提取出需要进使用Tapping功能的键值,并将其存储到TapEngine的对应ID中。
Tap-Toggle Layer设计
有了前面提到的TapEngine事件后,Tap-Toggle Layer的设计就显得比较简单了。首先是基础层的设计,需要处理TapEngine事件,4个事件子类型的处理方式如下:
-
TAPPING_BEGIN
:临时使能对应层,以便处理快速按下Fn+其它按键等操作(按键时间可能小于TAPPING_TERM
)。 -
TAPPING_END
:统计count
数量,大于等于设定值(如2次)则永久使能对应层,并且清空count
以保证不影响后续操作。 -
HOLDING_BEGIN
:临时使能对应层,以便处理先按Fn再按其它键的操作(按键时间可能大于TAPPING_TERM
)。 -
HOLDING_END
:禁用对应层,并且清空count
的值。
接着,只需要在使能后的对应层的同一键位放上MO
(Momentarily Layer)并指向当前层的键值,就能在按键释放时关闭当前层,从而实现使能后按同一按键关闭当前层的效果。
API设计
这里我们新增了一些函数,以方便其它模块处理TapEngine事件。
void smk_keymap_init_tapengine(smk_keyboard_map_type *map)
初始化TapEngine的函数,在smk_keymap_init
中被调用。除了为TapEngine赋初始值外,该函数还执行了Tapping相关键值的扫描,为每个Tapping相关键值都分配了对应的TapEngine(除了TapEngine不够用的情况)。
uint32_t smk_keymap_is_tapping_key(smk_keyboard_map_type *map, smk_keycode_type keycode)
用于快速判断一个键值是不是具有Tapping功能。与下一个函数相比,该函数仅根据键值所在位置进行判断(如Tap-Toggle Layer所在的TAP_TOGGLE_LAYER
到TAP_TOGGLE_LAYER_MAX
之间),相对而言速度更快,但存在判断失误的情况(仅有一种情况,就是在初始化过程中TapEngine不够用,导致某个按键实际上不生效)。
uint8_t smk_keymap_get_tapping_key_id(smk_keyboard_map_type *map, smk_keycode_type keycode)
用于获取一个键值对应的TapEngine ID。如果键值对应的ID不存在(同样是因为上面所说TapEngine的情况),该函数会返回0xFF
。由于这个函数在最坏情况下会将TapEngine
扫描一遍,因此速度较慢,在仅需要快速判断是否有Tapping功能的场合,使用上一个函数更合适。
void smk_keymap_convert_to_tapengine(smk_keyboard_map_type *map, const smk_event_type *event_keycode, smk_event_type *event_tapping)
该函数将KeyCode事件转换为TapEngine事件。这个函数通常在smk_keymap_convert_to_keycode
函数后,并且当前为Tapping相关键值后被调用,目的是得到更容易被处理的TapEngine事件。
void smk_keymap_handle_tapengine(smk_keyboard_map_type *map, const smk_event_type *event)
该函数主要用于处理smk_keymap_convert_to_tapengine
函数产生的TapEngine事件。在当前的设计中,该函数同时还被用于处理与SCAN_END
事件相关的处理。与Tapping功能相关的键值最终都会在这个函数中被处理(包括发送按键状态、使能和禁止某些层等),后续可以通过扩展此函数的方式实现更多功能。