1편 : https://www.clien.net/service/board/lecture/16405159?od=T31&po=0&category=0&groupCd=CLIEN
2편 : https://www.clien.net/service/board/lecture/16405208?od=T31&po=0&category=0&groupCd=CLIEN
3편 : https://www.clien.net/service/board/lecture/16408035?od=T31&po=0&category=0&groupCd=CLIEN
4편 : https://www.clien.net/service/board/lecture/16408079?od=T31&po=1&category=0&groupCd=CLIEN
5편 : https://www.clien.net/service/board/lecture/16439190?od=T31&po=2&category=0&groupCd=CLIEN
스위치를 알리익스프레스에서 주문했습니다. 2주가 지났는데 안오네요. 언젠간 오겠죠. (안오면 어쩌지…) 원래 생각은 스위치 올 때까지 기다렸다가 스위치 납땜 과정하고 동시에 펌웨어 작업 과정을 대충 때우고 넘어가려고 했습니다. 이 글 목적이 펌웨어를 설명하는 것은 아니니까요.
그런데 스위치가 함흥차사 오질 않으니, 심심해서 펌웨어 작업을 먼저 조금 해 두려고 합니다. 펌웨어 작업은 스위치가 없어도 할 수 있어요. 스위치 누르는 대신 전기가 통하는 아무 물건이든 가지고 스위치가 꼽히는 PCB 구멍을 연결해서 쇼트시키면 되거든요.
그래도 펌웨어 관련 내용은 대충 쓰겠습니다. 재미없거든요. 일단 펌웨어는 아래 링크에서 다운받습니다.
https://github.com/navilera/Gosu
stm32 보드에서 동작하는 키보드 펌웨어입니다.
USB HID 키보드 펌웨어의 필수 요소는 매우 간단합니다. 아래 두 가지만 구현하면 충분히 키보드 펌웨어로 기능할 수 있습니다.
1. 어느 스위치가 눌렸는지 확인
2. USB HID keyboard 스팩에 맞춰서 호스트로 keyscan code report를 보냄
어느 스위치가 눌렸는지 확인 하는 방법은 PCB에 스위치가 매트리스로 연결되어 있으므로 순서대로 row에 신호를 쏴서 col에서 읽어보면 됩니다.

회로도를 다시 소환해 보죠. Row0에 신호를 쏘고 Col0부터 Col6까지 신호가 들어왔는지 확인해 보는 겁니다. 신호가 들어온 스위치가 닫힌것이지요. 그리고 순서대로 Row1에 신호를 쏘고 다시 Col0부터 Col6까지 또 읽습니다.
이게 전부입니다.
Row0부터 Row4에 연결한 GPIO를 output으로 설정하고 Col0부터 Col6에 연결한 GPIO를 input으로 설정해서 Row 쪽 GPIO 신호를 순서대로 high로 올리고 Col쪽 GPIO 신호를 읽어보면 되지요.
수도코드는 대충 이러합니다.
row_gpio = [GPIO 포트 5개 지정]
col_gpio = [GPIO 포트 7개 지정]
for row in row_gpio {
set_gpio_output(row)
}
for col in col_gpio {
set_gpio_input(col)
}
while(true) {
for row in row_gpio {
set_gpio_high(row)
for col in col_gpio {
if get_gpio_input(col) == 1 {
눌림!! : (row, col)
}// if
} // for col
set_gpio_low(row)
} // for row
} // while
그냥 아주 전형적이고 초보적인 폴링입니다. 그래서 각 폴링 루프 사이에 delay를 조금 넣어서 GPIO 신호 레벨이 올라갔다가 완전히 내려가는 시간을 주어야 하는 등 부가적인 작업이 필요하지만 수도코드에는 표현하지 않았습니다.

왜냐면 논리적으로는 GPIO 신호 레벨이 high로 갔다가 low로 내려가는건 위 그림처럼 동작하는거로 이해합니다. 그러나 실제로 전압 레벨이 저렇게 딱딱 90도로 꺽이면서 high, low로 올라갔다 떨어졌다 하지 않거든요.

그림이 좀 부정확하군요. 커브가 저 모양이 아닌데... 아무튼 실제로는 대충 위 그림처럼 완만하게 전압이 올라갔다가 내려갑니다. 대충 완만하다는 것을 표현한 그림이라고 이해해 주세요. 설정에 따라 위 그림에 전압레벨이 변하는 구간 T의 시간을 어느정도 짧게 할 수 있긴한데 아무튼 0은 아니기 때문에 적당한 delay는 필요합니다. 만약 코어가 충분히 느려서 GPIO 전압 레벨 변화 속도보다 펌웨어 실행이 느리다면 delay가 필요 없을 수도 있습니다.

다시 회로도를 보면 blackpill 보드 핀 번호 기준으로 8번부터 12번을 row에 연결했고 1번부터 7번을 col에 연결했습니다.
이걸 실제 GPIO 포트 번호랑 매칭해 보면…

요렇게 됩니다. 그래서 row, col은 아래처럼 매칭됩니다.
Row0 : PA4
Row1 : PA3
Row2 : PA2
Row3 : PA1
Row4 : PA0
Col0 : PA5
Col1 : PA6
Col2 : PA7
Col3 : PB0
Col4 : PB1
Col5 : PB10
Col6 : PB11
실제 펌웨어 코드에서 위 매칭을 코딩한 코드는 아래와 같습니다.

그래서 정리하면 GPIO PA7에서 신호를 쏴서 GPIO PA6에서 신호를 받았다면 gRowPin[2]에서 신호를 쏴서 gColPin[1]에서 신호를 받은 것이기 때문에 (2,1) 에 있는 스위치가 눌렸다고 펌웨어는 알게되는 겁니다. 그러면 (2, 1) 위치에 있는 keycode를 찾아서 HID keyboard report 프로토콜에 실어서 호스트(운영체제)로 보내면 됩니다.
이 글에는 좀 정식으로 HID keycode를 스펙에서 찾아보려고 했는데 못 찾았네요… 대체 어디에 있는걸까요? 저는 그냥 아래 링크에서 가져다 썼습니다.
https://gist.github.com/MightyPork/6da26e382a7ad91b5496ee55fdc73db2
뭐가 됐든 동작만 잘하면 되죠.

그래서 키 맵 배치는 이렇게 됩니다. Layer 0은 그냥 쓸 때고요, Layer 1은 Fn 키를 눌렀을 때입니다. 저렇게 배치한 키에 해당하는 keycode로 2차원 배열 두 개를 만들면 됩니다.

이렇게요.
그러면 위에서 언급한 예를 다시 가져와서, (2, 1)에 키가 눌린것을 펌웨어가 알았으면 Fn도 눌린건지 확인합니다. Fn 이 안눌렸으면 gKeymap_buffer_layer0에서 찾고 Fn이 눌렸으면 gKeymap_buffer_layer1에서 찾습니다. 안눌렸다고 치고, gKeymap_buffer_layer0[2][1]이면 kA 이고 이 값은 키보드 A에 해당하는 키코드입니다.
키 스위치 매트리스에서 어떤 스위치가 눌려서 그 스위치에 해당하는 키코드가 뭔지 찾는 과정은 이 과정의 반복입니다. 동시 입력이면 이 과정을 7번 모아서 한 번에 호스트로 레포트하는 것이지요.
다음은 USB HID가 되게 끔 펌웨어를 만들어야 합니다. 방법은 USB 장치를 등록할 때 USB descriptor를 HID 스팩에 맞춰서 보내는 겁니다. 그러면 운영체제가 HID로 인식하고 어떤 HID인지 본 다음 keyboard이면 keyboard로 등록해서 keycode report를 입력으로 처리하는 겁니다.
사실 이 과정이 키보드 펌웨어 만드는 과정 중에 제일 어려운 부분입니다. 대충 있는 코드 재활용한다고 해도 USB 스펙에 대해서 겉핥기식으로도 알아야 하기 때문이죠.
대부분은 stm32의 샘플 프로젝트 코드에 다 구현되어 있기 때문에 그대로 가져다가 디스크립터만 수정하면 됩니다. 그래서 샘플 코드를 보고 대충 필요한 위치에 값만 바꾸면 되는데요. 그래도 어떤 순서로 디스크립터가 구성되는지는 알아두면 좋습니다.

USB 디스크립터는 이 그림처럼 계층 구조로 되어 있습니다. Device Descriptor를 먼저 보내고 그 다음에 Configuration Descriptor를 보내고 Interface, Endpoint 디스크립터를 이어서 보내면 됩니다.
디스크립터가 어떻게 구성되어야 하는지는 HID 스펙 문서에 샘플로 나와 있습니다. 친절하죠?

이렇게 샘플이 있어서, 요 샘플 그대로 디스크립터를 작성해도 됩니다.

대부분은 stm32의 샘플 프로젝트에 있는 코드를 그대로 썼고 몇 개 값만 바꿨습니다. HID 스펙 버전을 1.0에서 2.0으로 바꿨습니다. 최종 동작에는 어떤 차이가 있는지 모르겠네요… 마지막 필드인 Number of possible configurations가 1이므로 이 디바이스 디스크립터는 Configuration descriptor를 1개 가집니다.
이런식으로 순서대로 config, interface, HID, endpoint 디스크립터를 하나씩 수정합니다. 하나씩 다 나열해봐야 알아보기 힘든 코드 나열일 뿐이니까 디스크립터 설명은 여백이 모자라 더 이상 적지 않겠습니다.

키보드 키를 누르면 펌웨어가 어떤 키가 눌렸는지 확인해서 호스트(운영체제)에 뭐가 눌렸는지 알려줘야 합니다. 위 표가 어떻게 보내라고 스펙에서 설명하고 있는 표입니다. 간단히 말해 8바이트 배열로 보내는 거고 0번 바이트에 Modifier Keys (Alt, Ctrl, Shift 같은 애들입니다.) 에 대한 내용을 넣고 1번 바이트는 0으로 채우고 2번부터 7번까지 동시 입력된 애들을 기록해서 보내라는 겁니다. HID 스펙에 따르면 키보드가 한 번에 동시 입력으로 처리해서 호스트에 보낼 수 있는 키는 최대 6개입니다. 키보드 광고에 나오는 무한 동시 입력이라는 건 대체 뭔지 모르겠습니다. 스펙에 6개라고 정해 놨는데…. 아마 어딘가에 저 길이를 조정할 수 있는 설정이 있을 텐데… 귀찮아서 찾지 않았습니다. 6개도 충분하니까요.

Modifier는 키가 여러개인데 report 할 수 있는 공간은 8바이트 뿐입니다. 그래서 얘네들은 비트맵으로 레포트합니다. 각 비트의 의미는 위 표와 같습니다.
예를 들어 왼쪽 ctrl과 alt를 동시에 누르고 오른쪽 shift를 누르면 0010_0101 = 0x25 가 되는겁니다. 쉽죠?
그래서 키보드 펌웨어는 운영체제에 인식되고 나면 내부적으로 저 8바이트짜리 report를 계속 호스트에 보내기만 합니다. 물론 받기도 하는데, 컴퓨터에서 임의로 numlock 같은거 켜잖아요. 그럴때는 호스트에서 1바이트 짜리 데이터를 받아서 펌웨어가 정해진 액션을 하는겁니다. 저는 필요없어서 안만들었습니다.
대충 수도코드는 이러합니다.
Idx = 2
for row 전체 {
for col 전체 {
if 입력인가() == true {
keycode = get_keycode(row, col)
if isModifier(keycode) == true {
report[0] |= getBitmap(keycode)
continue
} else {
report[idx++] = keycode
if idx >= 8 {
goto exit_loop
} // else
} // if is Modifier
} // 입력
} // for col
} // for row
exit_loop:
sort(report)
sendToHost(report)
대충 이러합니다. 실제 코드는 위 과정을 몇 개 함수로 쪼개서 goto는 없습니다. (goto가 뭐가 어때서!!)
참고로, USB 규격은 특별히 높은 버전의 기능을 쓸 것이 아니라면 가능하다면 낮은 버전을 쓰는 것이 호환성에서 유리합니다. PC의 경우엔 거의 상관 없겠지만, 임베디드 기기 중에 좀 오래 된 것은 최신 버전을 인식 못 할 수도 있습니다.
저는 그냥 3D프린터나 가지고 놀아야 겠네요;; ㅋㅋ