키보드를 만듭시다. 어때요~ 참 쉽죠? (5)
나빌로스를 포팅하긴 했으나 아직 RTOS를 써먹고 있지는 않다. 기능별로 태스크를 분리하면서 확장하는 방식으로 펌웨어를 만들 생각이다. 펌웨어 동작을 확인하려고 처음 만든 것이 UART 출력 펌웨어다. RTOS 태스크로 처음 만들기 적당한 재료도 UART다.
계획은 이러하다. UART 입력이 되도록 펌웨어를 수정한다. UART 인터럽트 핸들러는 UART 입력으로 들어온 값을 커널 메시지 큐로 보낸다. UART 입력을 처리하는 사용자 태스크를 생성한다. 이 태스크를 디버그 태스크라고 부르겠다. 디버그 태스크는 메시지 큐에서 데이터를 가져온다. 이 데이터는 UART 인터럽트 핸들러가 보낸 것이다. 따라서 문자열이고 문자열을 커맨드로해서 커맨드에 연결된 동작을 수행한다.
일단 나는 HID 키보드 레포트를 보내는 명령어를 만들 것이다.
UART 입력 설정하기
---------------
처음에 펌웨어 베이스 코드 만들 때 사용했던 코드는 UART 출력만 있고 입력은 기능에 없었다. 그래서 UART 입력을 받으려면 코드를 수정해야 한다. 그럼 기존 코드가 어떻게 생겼는지 다시보자. 이 코드는 이전 글에 있는 코드와 같다.
static void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); } }
이 코드를 아래처럼 고친다.
void Hal_uart_init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; huart1.gState = HAL_UART_STATE_RESET; HAL_UART_Init(&huart1); __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); HAL_NVIC_SetPriority(USART1_IRQn, 0, 1); HAL_NVIC_EnableIRQ(USART1_IRQn); }
전체적으로 같은 코드고 아래 세 줄을 추가했다.
> __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE);
> HAL_NVIC_SetPriority(USART1_IRQn, 0, 1);
> HAL_NVIC_EnableIRQ(USART1_IRQn);
의미는 이렇다. UART 하드웨어 컨트롤 레지스터에 Rx Enable 비트를 설정한다. 쉽게 말해 하드웨어에서 입력을 켜는 거다. 그리고 UART1 하드웨어의 IRQ를 네이티브 벡터 인터럽트 컨트롤러(NVIC: Native Vector Interrupt Controller)에 등록한다. 그러므로 위 세 줄이 실행되고 나면 UART1 하드웨어에서 입력을 받을 수 있게되고 입력을 받으면 UART1 하드웨어는 NVIC에 IRQ 신호를 보낸다.
여기까지 하면 이제 입력을 받을 수 있을 것 같은데 사실 과정이 하나 빠졌다. NVIC가 IRQ를 접수하면 펌웨어로 인터럽트를 알린다. 그러면 이 인터럽트를 받아서 처리할 인터럽트 핸들러가 있어야한다.
UART 인터럽트 핸들러
----------------
Cortex-M3는 Cortex-AR과 다르게 익셉션 벡터 테이블 대신 펌웨어 엔트리 포인트에 인터럽트 벡터 테이블이 바로 나온다. 덕분에 인터럽트 처리를 조금더 빠르게 할 수 있다. 그리고 코드도 직관적으로 이해할 수 있다.
.word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler .word 0 .word 0 .word 0 .word 0 .word SVC_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler .word WWDG_IRQHandler .word PVD_IRQHandler .word TAMPER_IRQHandler .word RTC_IRQHandler .word FLASH_IRQHandler .word RCC_IRQHandler .word EXTI0_IRQHandler .word EXTI1_IRQHandler .word EXTI2_IRQHandler .word EXTI3_IRQHandler .word EXTI4_IRQHandler .word DMA1_Channel1_IRQHandler .word DMA1_Channel2_IRQHandler .word DMA1_Channel3_IRQHandler .word DMA1_Channel4_IRQHandler .word DMA1_Channel5_IRQHandler .word DMA1_Channel6_IRQHandler .word DMA1_Channel7_IRQHandler .word ADC1_2_IRQHandler .word USB_HP_CAN1_TX_IRQHandler .word USB_LP_CAN1_RX0_IRQHandler .word CAN1_RX1_IRQHandler .word CAN1_SCE_IRQHandler .word EXTI9_5_IRQHandler .word TIM1_BRK_IRQHandler .word TIM1_UP_IRQHandler .word TIM1_TRG_COM_IRQHandler .word TIM1_CC_IRQHandler .word TIM2_IRQHandler .word TIM3_IRQHandler .word TIM4_IRQHandler .word I2C1_EV_IRQHandler .word I2C1_ER_IRQHandler .word I2C2_EV_IRQHandler .word I2C2_ER_IRQHandler .word SPI1_IRQHandler .word SPI2_IRQHandler .word USART1_IRQHandler .word USART2_IRQHandler .word USART3_IRQHandler .word EXTI15_10_IRQHandler .word RTC_Alarm_IRQHandler .word USBWakeUp_IRQHandler .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word BootRAM
위 코드가 STM32F103의 SDK에서 제공하는 인터럽트 벡터 테이블이다. 이미 웬만한 인터럽트는 인터럽트 핸들러 이름을 다 정해 놓고 베이스 코드 코딩까지 SDK에 다 되어 있다. 편하다. 나는 그저 이름을 보고 이거 같은거 골라서 테스트 코드 써보고 그게 맞으면 그 함수에 원하는 코드를 작성하기만 하면 된다. 하드웨어 스펙을 공부할 필요가 없다. 정말 훌륭하군!
딱 보면 뭐가 UART1의 인터럽트 핸들러인지 알 수 있다. 중간에 USART1_IRQHandler라는 레이블 이름이 보인다. 이 이름으로 검색하면 stm32f1xx_it.c라는 파일에서 구현을 찾을 수 있다. 나는 STM32의 SDK에서 구현을 나빌로스 HAL로 가져오고 싶어서 나빌로스 HAL 함수로 bypass하는 코드를 작성했다. 진짜 본체는 나빌로스 HAL에 구현했다.
void USART1_IRQHandler(void) { extern void Hal_uart_isr(void); Hal_uart_isr(); }
이렇게 Hal_uart_isr()로 바로 점프하는 코드를 SDK 내부 인터럽트 핸들러에 구현했다. 그러면 컨택스트는 Hal_uart_isr()로 넘어간다. 이 함수는 나빌로스 HAL의 일부다.
void Hal_uart_isr(void) { uint8_t ch = Hal_uart_get_char(); if (ch == '\r' || ch == '\n') { Hal_uart_put_char('\n'); ch = '\0'; Kernel_send_msg(KernelMsgQ_DebugCmd, &ch, 1); Kernel_send_events(KernelEventFlag_CmdIn); } else { Hal_uart_put_char(ch); Kernel_send_msg(KernelMsgQ_DebugCmd, &ch, 1); } }
그래서 결과적으로 UART 입력을 하드웨어가 인식하면 입력받은 글자를 우선 화면에 그대로 보여주고 KernelMsgQ_DebugCmd 메시지 큐에 한 글자를 넣는다. 메시지 큐에 계속 데이터(사용자 입력)을 쌓는다. 그러다가 사용자가 엔터를 입력하면 KernelEventFlag_CmdIn 커널 이벤트를 날린다. 그래서 KernelEventFlag_CmdIn 커널 이벤트를 받는 태스크는 메시지 큐에서 엔터 직전까지의 사용자 입력을 읽을 수 있다.
커널 메시지 큐 ID 지정
----------------
나빌로스에서 커널 메시지 큐 ID를 만드는 것은 매우 직관적이고 쉽다. 그냥 나빌로스 커널 소스를 수정하면 된다. 나빌로스 커널 코드 중에 msg.h 파일이 있다. 딱 보면 뭐 고쳐야하는지 알 수 있을 정도로 단순하고 쉬운 코드다.
typedef enum KernelMsgQ_t { KernelMsgQ_DebugCmd, KernelMsgQ_Num } KernelMsgQ_t;
KernelMsgQ_t라는 이름으로 enum 타입을 하나 정의해 놨는데 이 enum의 내용을 고치면 된다. 현재 필요한 것은 KernelMsgQ_DebugCmd 하나밖에 없으므로 하나만 써준다. 이러면 끝이다. 이렇게 수정하고 다시 빌드하면 나빌로스 커널에서 알아서 메시지 큐를 만들고 KernelMsgQ_DebugCmd 용으로 쓸 수 있게 해 준다.
커널 이벤트 플래그 ID 지정
-------------------
나빌로스에서 이벤트 플래그 ID를 새로 만드는 것도 메시지 큐 ID를 만드는 것 만큼 쉽다. 나빌로스 커널 소스를 보면 event.h 파일이 있다. 이 파일도 열어보면 뭘 고쳐야 할지 한 번에 알 수 있다.
typedef enum KernelEventFlag_t { KernelEventFlag_CmdIn = 0x00000001, KernelEventFlag_Reserved01 = 0x00000002, KernelEventFlag_Reserved02 = 0x00000004, KernelEventFlag_Reserved03 = 0x00000008, KernelEventFlag_Reserved04 = 0x00000010, KernelEventFlag_Reserved05 = 0x00000020, KernelEventFlag_Reserved06 = 0x00000040, KernelEventFlag_Reserved07 = 0x00000080, KernelEventFlag_Reserved08 = 0x00000100, KernelEventFlag_Reserved09 = 0x00000200, KernelEventFlag_Reserved10 = 0x00000400, KernelEventFlag_Reserved11 = 0x00000800, KernelEventFlag_Reserved12 = 0x00001000, KernelEventFlag_Reserved13 = 0x00002000, KernelEventFlag_Reserved14 = 0x00004000, KernelEventFlag_Reserved15 = 0x00008000, KernelEventFlag_Reserved16 = 0x00010000, KernelEventFlag_Reserved17 = 0x00020000, KernelEventFlag_Reserved18 = 0x00040000, KernelEventFlag_Reserved19 = 0x00080000, KernelEventFlag_Reserved20 = 0x00100000, KernelEventFlag_Reserved21 = 0x00200000, KernelEventFlag_Reserved22 = 0x00400000, KernelEventFlag_Reserved23 = 0x00800000, KernelEventFlag_Reserved24 = 0x01000000, KernelEventFlag_Reserved25 = 0x02000000, KernelEventFlag_Reserved26 = 0x04000000, KernelEventFlag_Reserved27 = 0x08000000, KernelEventFlag_Reserved28 = 0x10000000, KernelEventFlag_Reserved29 = 0x20000000, KernelEventFlag_Reserved30 = 0x40000000, KernelEventFlag_Reserved31 = 0x80000000, KernelEventFlag_Empty = 0x00000000, } KernelEventFlag_t;
이벤트 플래그는 32개를 예약해 놨다. 그 중 첫 번째 이벤트 플래그 ID를 KernelEventFlag_CmdIn로 지정한다. 메시지 큐랑 마찬가지로 따로 ID를 지정하는 API가 있는 것이 아니라 그냥 커널 소스 코드를 직접 수정하고 빌드를 다시하면 된다.
디버그 태스크에서 이벤트와 메시지 수신
----------------------------
인터럽트 핸들러와 커널 수준에서 준비는 끝났다. 이제 이것을 받아 처리하는 사용자 태스크를 만든다. 사용자 태스크 이름은 Debug_cli_task다. 내용은 간단하다. 커널 이벤트 KernelEventFlag_CmdIn를 대기하고 있다가 이벤트가 들어오면 메시지 큐에서 커맨드 입력을 받을 준비를 한다.
void Debug_cli_task(void) { debug_printf("Debug_cli_task....\n"); while (true) { KernelEventFlag_t handle_event = Kernel_wait_events(KernelEventFlag_CmdIn); if (handle_event == KernelEventFlag_CmdIn) { HandleDebugCommand(); } Kernel_yield(); } }
HandleDebugCommand() 함수에서 메시지 큐 데이터를 가져다가 커맨드 처리를 한다.
static void HandleDebugCommand(void) { char cmdStr[MAX_CMD_LEN] = {0}; char ch; uint32_t i; bool isLenExceed = true; for(i = 0 ; i < (MAX_CMD_LEN - 1) ; i++) { Kernel_recv_msg(KernelMsgQ_DebugCmd, &ch, 1); cmdStr[i] = ch; if (ch == '\0') { isLenExceed = false; break; } } if (isLenExceed) { // flush msgQ do { Kernel_recv_msg(KernelMsgQ_DebugCmd, &ch, 1); } while (ch != '\0'); } if (strcmp(cmdstr, "sendhid", 7) == 0) { cmd_send_hid(&cmdstr[8]); } }
MAX_CMD_LEN은 16으로 했다. STM32F103은 스택과 데이터로 쓸 수 있는 메모리가 20KB 밖에 없기 때문에 메모리를 아껴야 한다. 그래서 HandleDebugCommand() 함수에서 메시지 큐 데이터를 읽는 코드는 크게 두 부분으로 작성했다. 첫 번째 부분은 당연히 그냥 메시지 큐 KernelMsgQ_DebugCmd에서 데이터를 1바이트씩 읽어 cmdStr 배열 변수에 넣는 코드다. 두 번째 부분은 만약 사용자가 16 글자가 넘는 입력을 했다면 16 글자만 입력으로 받고 나머지 메시지 큐 내부 데이터를 버리는(flush) 코드다.
현재는 사용자 디버깅 명령이 딱 한 개 뿐이므로 별다른 처리 없이 그냥 if 문으로 검사해서 사용자가 sendhid라는 명령을 보냈으면 cmd_send_hid() 함수를 호출한다. cmd_send_hid()에서 HID 키보드 디스크립터를 보내는 처리를 한다. cmd_send_hid() 함수에 파라매터로 cmdstr[8]의 포인터를 보냈다. 이렇게 해야 아래처럼 입력했을 때 숫자 부분만 온전히 함수로 전달할 수 있다.
> sendhid 11
11은 십진수 11이 아니라 십육진수 0x11이다.
디버그 태스크에서 HID 키보드 레포트 보냄
------------------------------
이전에 main() 함수의 while 무한 루프에 있는 코드를 옮겨서 함수로 만들면 된다.
void cmd_send_hid(char* param) { struct hid_scan_report test; uint8_t scan_code = (uint8_t)htou(param, strlen(param)); test.key[0] = scan_code; HID_Send(&test, sizeof(struct hid_scan_report)); HAL_Delay(10); memclr(&test, sizeof(struct hid_scan_report)); HID_Send(&test, sizeof(struct hid_scan_report)); }
파라메터 param 변수는 문자열 포인터이기 때문에 문자열을 int 값으로 변환해야 한다. htou() 함수는 문자열 십육진수를 int 값으로 바꿔주는 나빌로스 유틸리티 라이브러리 함수다. scan_code에 숫자로 바뀐 사용자 입력 파라메터 값이 들어갔다. 이 값을 HID 키보드 레포트 구조체에 넣어서 HID_Send() 함수로 보내면 된다. 키보드 스위치 눌림(pressed)과 뗌(released)를 흉내내려고 스캔 코드를 보낸 후 10ms 후에 0으로 초기화한 스캔 코드를 호스트에 또 보낸다.
댓글 달기