팀원이 온 지 3개월, 아직 FreeRTOS 세마포어 이해를 못 했다

팀원이 온 지 3개월, 아직 FreeRTOS 세마포어 이해를 못 했다

3개월째인데

팀에 신입이 왔다. 3개월 전이다. 대학원 출신. 임베디드 전공. 논문도 썼다. 나보다 학벌 좋다.

근데 아직도 세마포어를 못 쓴다. 정확히는, 쓰긴 쓰는데 왜 쓰는지 모른다. 그냥 선배 코드 복붙한다.

오늘 코드 리뷰했다. “여기 왜 xSemaphoreTake 썼어요?” ”…경쟁 상태 막으려고요.” “무슨 경쟁 상태?” ”…”

침묵이 길었다.

이론은 안다

신입이 멍청한 건 아니다. Context switching 개념은 안다. Race condition도 설명할 수 있다. Deadlock은 교과서적으로 외우고 있다.

문제는 실제다. “이 변수는 왜 보호 안 해요?” “어… 여기는 괜찮지 않나요?” “ISR에서 접근하는데?” “아…”

매번 이렇다.

교과서랑 다르다. 실제 펌웨어는 더럽다. ISR 있고, Task 여러 개 있고, 공유 버퍼 있고, 타이머 있고. 어디서 뭐가 먼저 실행될지 모른다.

신입은 순서도를 그린다. 깔끔하게. “Task A가 먼저 실행되고…” “누가 먼저 실행된다고 했어?” ”…아 스케줄러가…” “스케줄러는 Priority 보고, 같으면 Round-robin이고, ISR 들어오면 선점되고.” ”…”

또 침묵.

내가 가르치면서 헷갈린다

신입 가르치다 보니 나도 헷갈린다. “Mutex랑 Binary Semaphore 차이가 뭐죠?” “음… Mutex는 소유권이 있고…” “그게 정확히 뭔데요?”

대답하려다 막혔다. Priority inheritance 얘기해야 하나. 근데 우리 프로젝트에서 그거 쓰나. 확인 안 해봤다.

집에 가서 찾아봤다. FreeRTOS 문서. “A mutex is a binary semaphore that includes a priority inheritance mechanism.” 아 맞다. 이거였다.

근데 Priority inheritance가 정확히 어떻게 동작하지. High priority task가 block되면 low priority task의 priority를 올려준다. 그게 언제까지지. Mutex release할 때까지인가.

다시 문서 읽었다. 30분 걸렸다.

다음 날 신입한테 설명했다. “아~ 그래서 Mutex 써야 하는구나.” 나도 이제 안다.

Deadlock 재현이 안 된다

신입이 물었다. “Deadlock은 어떻게 막아요?” “Lock 순서 정하면 돼. A 먼저, B 나중에. 모든 Task가 이 순서 지키면 Deadlock 안 생겨.” “근데 만약 실수하면요?” ”…Deadlock 나지.” “그럼 어떻게 디버깅해요?”

좋은 질문이다. 대답 못 했다.

Deadlock 본 적이 거의 없다. 이론으로는 안다. Task A가 Lock1 잡고 Lock2 기다리고, Task B가 Lock2 잡고 Lock1 기다리면 둘 다 멈춘다. 간단하다.

근데 실제로는 안 본다. 우리 코드에는 Mutex가 3개밖에 없다. Lock 순서 지키기 어렵지 않다.

“한번 만들어볼까요? Deadlock.” 신입이 재미있다는 표정이다. 테스트 코드 짰다.

void taskA(void *param) {
    xSemaphoreTake(mutex1, portMAX_DELAY);
    vTaskDelay(10);
    xSemaphoreTake(mutex2, portMAX_DELAY);
    // ...
}

void taskB(void *param) {
    xSemaphoreTake(mutex2, portMAX_DELAY);
    vTaskDelay(10);
    xSemaphoreTake(mutex1, portMAX_DELAY);
    // ...
}

돌렸다. 안 멈춘다.

“왜 안 되죠?” ”…타이밍 문제 아닐까.”

vTaskDelay를 1로 줄였다. 여전히 안 멈춘다.

30분 동안 삽질했다. 알고 보니 Priority가 같아서 Round-robin으로 돌아간다. Delay가 있으면 서로 엇갈린다.

Priority 다르게 줬다. 그래도 안 멈춘다. 왜지.

결국 찾았다. Higher priority task가 먼저 두 개 다 잡는다. Lower priority는 실행도 안 된다.

“아… Preemptive라서.” 신입이 이해했다.

Deadlock 재현하려면 두 Task가 정확히 동시에 실행돼야 한다. 불가능하다. Core가 하나라서.

억지로 만들었다. ISR에서 한 Task 깨우고, 다른 Task는 이미 실행 중이고. 복잡했다.

겨우 Deadlock 봤다. 시스템 멈췄다. “와, 진짜 멈추네요.” 당연하지.

근데 이거 실제로 겪으면 어떻게 찾지. 로그도 안 찍힌다. Task 멈춰서.

“configUSE_TRACE_FACILITY 켜면 Task state 볼 수 있어.” “그거 뭐예요?” 나도 안 써봤다.

Race condition은 재현이 더 안 된다

“Race condition은요?” 신입이 계속 묻는다. 좋은 태도다.

“공유 변수 여러 Task에서 쓰면 생겨.” “그러면요?” “값이 이상해져.”

예제 보여줬다.

uint32_t counter = 0;

void taskA(void *param) {
    counter++;
}

void taskB(void *param) {
    counter++;
}

“이거 1000번 돌리면 2000 안 나올 수 있어.” “정말요? 해봐도 돼요?”

해봤다. 2000 나온다. 항상.

”…왜 안 깨지죠?” “음…”

생각해봤다. STM32는 32bit CPU다. uint32_t 쓰기는 atomic이다. 한 instruction이다.

“아, uint32_t라서 그래. uint64_t 써봐.” 써봤다. 그래도 2000 나온다.

왜지.

한참 생각했다. counter++ 는 단순해 보이지만 실제로는 세 단계다. Load, Increment, Store.

근데 컴파일러 최적화 때문에 레지스터에 올라가면 끝이다. Task 전환돼도 레지스터는 저장된다. Context switching이 그거다.

“이거 실제로 깨지게 하려면…” 어떻게 하지.

Volatile 없애고, 최적화 켜고, 그래도 안 깨진다. Task priority 같게 하고, 루프 1000000번 돌려도 2000 나온다.

“선배, 이거 안 깨지는 거 아니에요?” ”…아니야. 깨져야 하는데.”

검색했다. “ARM Cortex-M에서는 단일 레지스터 쓰기 atomic” 아. 그래서 안 깨진다.

억지로 깨뜨렸다. 구조체 만들고, 여러 필드 동시에 쓰게 하고.

struct {
    uint32_t a;
    uint32_t b;
} data;

void taskA(void *param) {
    data.a++;
    data.b++;
}

이것도 안 깨진다. 왜냐면 둘 다 따로 atomic이라서.

결국 포기했다. “실제로는 SPI 버퍼 같은 데서 깨져. 거기는 여러 바이트니까.” “아~ 네.”

신입이 이해했는지 모르겠다. 나도 헷갈린다.

문서가 애매하다

FreeRTOS 문서 읽으면서 느낀다. 애매한 부분이 많다.

“fromISR 함수는 언제 써요?” “ISR에서 쓸 때.” “왜 따로 있어요?” ”…스케줄러 동작이 달라서?”

정확히 모른다. 찾아봤다.

일반 함수는 block될 수 있다. ISR에서 block되면 안 된다. 그래서 fromISR은 block 안 된다. 대신 결과를 리턴한다.

“그럼 xQueueSendFromISR에서 큐가 꽉 차면요?” “못 넣고 리턴해.” “데이터 유실되는 거네요?” ”…그렇지.”

생각해보니 맞다. ISR에서는 기다릴 수 없다. 큐 꽉 차면 버린다.

근데 우리 코드는 어떻게 처리했지. 확인 안 해봤다.

다음 날 코드 봤다. 리턴 값 확인 안 한다. 그냥 xQueueSendFromISR 호출하고 끝.

“이거 실패하면 어떡하죠?” ”…모르지.”

수정했다. 실패하면 에러 카운터 올리게.

if (xQueueSendFromISR(queue, &data, &xHigherPriorityTaskWoken) != pdPASS) {
    error_count++;
}

근데 이 에러 카운터를 누가 보나. 로그에도 안 찍는다. ISR에서 printf 못 쓴다.

“주기적으로 체크하는 Task 만들까요?” ”…나중에.”

결국 안 만들었다. 양산 나가면 에러 생겨도 모른다. 뭐 어쩌겠나.

신입이 물어본 것들

“Binary semaphore랑 Counting semaphore 차이요?” “Binary는 0, 1만. Counting은 여러 개.” “언제 Counting 써요?” ”…리소스가 여러 개 있을 때?”

실제로 써본 적 없다. 우리 프로젝트에는 다 Binary다.

“Queue랑 Semaphore 차이요?” “Queue는 데이터 전달. Semaphore는 신호만.” “Queue도 신호 아니에요?” ”…음, 데이터 있는 신호?”

대답이 이상하다. 근데 맞는 것 같기도 하다.

“Event group은요?” “여러 이벤트 기다릴 수 있어.” “예를 들면요?” ”…음…”

예를 들지 못했다. 써본 적 없다.

“Stream buffer는요?” “바이트 스트림 전달할 때.” “Queue랑 뭐가 달라요?” ”…더 빨라.”

정말 빠른지 모른다. 측정 안 해봤다.

신입이 질문 많다. 좋은 건데 대답이 힘들다. 내가 모르는 걸 깨닫는다.

결국 동작하면 된다

신입한테 말했다. “완벽하게 이해 안 해도 돼. 일단 돌아가게 만들어.”

냉정하게 들릴 수 있다. 근데 사실이다.

우리 프로젝트는 복잡하지 않다. Task 5개. Mutex 3개. Queue 2개. ISR 4개.

패턴은 정해져 있다. 센서 데이터 읽는 Task, Queue에 넣는다. 처리하는 Task, Queue에서 꺼낸다. 통신하는 Task, Mutex로 보호한다.

이론 몰라도 복붙하면 된다. 선배 코드 따라 하면 된다.

“세마포어를 왜 쓰는지 이해 안 가도, 일단 써. 나중에 이해돼.” ”…네.”

신입이 실망한 표정이다. 미안하다. 근데 나도 그렇게 배웠다.

5년 전 나도 몰랐다. 선배 코드 복붙했다. 돌아갔다. 이해는 나중에 됐다.

지금도 완전히 이해했는지 모르겠다. 오늘 신입 가르치면서 또 헷갈렸다.

근데 코드는 돌아간다. 5년 동안 Deadlock 없었다. Race condition도 없었다. 적어도 발견 못 했다.

그러면 된 거 아닌가.


신입이 언제쯤 세마포어 이해할까. 나는 이해한 건가.