시간 낭비의 주범: 앞선 챕터에서 세마포어를 구현할 때 사용한 스핀락(while(s == 0))은 자원을 얻지 못하면 자신의 할당 시간(Time Slice, 예: 1ms)이 끝날 때까지 CPU를 붙잡고 아무 의미 없는 대기만 하며 시간을 낭비했습니다.
해결책 (협력과 블로킹): 스레드가 CPU가 당장 필요 없거나 자원을 기다려야 할 때, 다른 스레드에게 CPU를 자발적으로 양보하는 OS_Suspend 기능과 대기열 구조(FIFO 큐), 스레드 수면(Sleeping) 기능을 도입하여 이 낭비되는 시간을 되찾는 것이 이번 챕터의 목표입니다.
이 두 사진은 스레드끼리 데이터를 주고받을 때, 상황에 따라 어떤 방식을 써야 하는지를 명확히 보여줍니다.
생산자와 소비자가 모두 '기다릴 수 있는(Block 가능)' 일반 스레드일 때 사용합니다.
Send (초기값 0): 보내는 쪽(Producer)이 받는 쪽(Consumer)에게 "나 데이터 썼어! 가져가!"라고 외치는 신호등입니다.
Ack (초기값 0): 받는 쪽(Consumer)이 보내는 쪽(Producer)에게 "응, 나 데이터 확실히 빼갔어! 이제 다음 거 써도 돼!"라고 응답(Acknowledge)하는 신호등입니다.
uint32_t Mail; // Shared data
int32_t Send=0; // Semaphore
int32_t Ack=0; // Semaphore
void SendMail(uint32_t data){
Mail = data; // Write data
OS_Signal(&Send); // Tell consumer data is ready
OS_Wait(&Ack); // Wait until consumer reads it
}
uint32_t RecvMail(void){
uint32_t theData;
OS_Wait(&Send); // Wait for data
theData = Mail; // Read mail
OS_Signal(&Ack); // Tell producer "I got it!"
return theData;
}
핵심: Ack 세마포어가 존재합니다. 소비자가 읽을 때까지 생산자도 멈춰서 기다리므로 데이터 유실이 절대 발생하지 않습니다.
생산자가 마이크나 가속도계 같은 하드웨어 인터럽트(ISR)일 때 사용합니다. (Lab 2에서 우리가 짠 방식입니다!)
uint32_t Lost=0; // Counter for lost data
void SendMail(uint32_t data){
Mail = data;
if(Send){ // If previous mail is unread
Lost++; // Overwrite and increment error counter
} else {
OS_Signal(&Send);
}
}
uint32_t RecvMail(void){
OS_Wait(&Send); // Wait for data
return Mail; // Read and return mail
}
핵심: 하드웨어 인터럽트는 OS_Wait으로 무한 대기할 수 없으므로 Ack 세마포어를 아예 없애버렸습니다. 대신 데이터를 덮어쓸 경우 Lost 카운터를 올려 에러를 기록합니다.
void Task0(void){
Count0 = 0;
while(1){
Count0++;
Profile_Toggle0();
OS_Suspend(); // Yield the CPU immediately after finishing the job
}
}
// Task1 and Task2 have the exact same structure.
동작 원리: 스레드가 켜지자마자 카운터를 하나 올리고 핀을 토글하는 아주 짧은 작업(약 몇 마이크로초 소요)을 합니다. 그리고 1ms 할당 시간을 멍하니 기다리지 않고, 곧바로 OS_Suspend()를 불러 다음 스레드에게 턴을 넘겨버립니다.
결과: 3개의 스레드가 1ms 주기가 아니라, 거의 빛의 속도(1.3 μs 주기)로 서로 양보하며 돌아가는 엄청난 반응 속도를 보여주게 됩니다.
스레드가 스스로 "나 턴 마칠게, 다음 사람 들어와!"라고 외치는 기능입니다.
인터럽트 강제 발생: OS_Suspend 함수는 INTCTRL 레지스터(Register 67)의 26번째 비트(PENDSTSET)에 1을 기록하여(즉, 0x04000000을 입력하여) 하드웨어 타이머가 다 돌지 않았어도 강제로 SysTick 인터럽트를 발생시킵니다.
스케줄러 호출: SysTick 인터럽트가 터지면 우리가 짰던 SysTick_Handler가 실행되고, 이는 곧 자연스럽게 다음 스레드로 문맥 전환(Context Switch)이 일어남을 의미합니다.
우선순위 주의: 이때 SysTick의 우선순위는 반드시 가장 낮음(Priority 7)으로 설정되어야 다른 중요한 하드웨어 ISR(타이머, 센서 등)의 흐름을 방해하지 않습니다.
void OS_Suspend(void){
INTCTRL = 0x04000000; // trigger SysTick, but not reset timer
}
설명: CPU의 소중한 시간을 양보하기 위해 소프트웨어적으로 타이머 인터럽트를 강제로 터뜨리는 코드입니다.
0x04000000의 비밀: 이 숫자를 2진수로 바꾸면 26번째 비트가 1입니다. ARM Cortex-M 프로세서의 INTCTRL (Interrupt Control and State, Register 67) 레지스터의 26번째 비트는 PENDSTSET이라는 특별한 스위치입니다. 여기에 1을 쓰면 하드웨어 타이머가 다 안 돌아갔어도 "지금 당장 SysTick_Handler 실행해!"라고 명령을 내리게 됩니다.
void OS_Suspend(void){
STCURRENT = 0; // reset counter
INTCTRL = 0x04000000; // trigger SysTick
}
명: 1번 코드의 치명적인 단점(시간 불공평)을 해결한 최종 완성본입니다.
STCURRENT = 0;이 추가된 이유: 만약 1ms(밀리초) 타이머 중 스레드 A가 0.75ms를 쓰고 OS_Suspend를 불렀다고 가정해 봅시다. 1번 코드처럼 타이머를 초기화하지 않고 다음 스레드 B로 넘어가면, B는 타이머에 남은 자투리 시간인 0.25ms만 쓰고 쫓겨나는 억울한 일이 생깁니다.
그래서 STCURRENT = 0;을 통해 타이머를 강제로 0으로 초기화해 줍니다. 이렇게 하면 다음 스레드는 무조건 온전한 1ms의 Time Slice(할당 시간)를 보장받게 됩니

가장 중요한 변화는 세마포어 대기 함수인 OS_Wait의 내부 구조가 바뀌는 것입니다.
// wastes time here until the next thread switch
void OS_Wait(int32_t *s){
DisableInterrupts();
while((*s) == 0){
EnableInterrupts();
// CPU가 아무것도 안 하고 허공에 1ms(자신의 턴)를 다 날려버림
DisableInterrupts();
}
(*s) = (*s) - 1;
EnableInterrupts();
}
열쇠(s)가 0이면, 아주 잠깐 인터럽트를 열었다가 닫기만 하고 계속 제자리걸음을 했습니다.
// more efficient
void OS_Wait(int32_t *s){
DisableInterrupts();
while((*s) == 0){
EnableInterrupts();
OS_Suspend(); // 열쇠가 없네? 내 남은 시간 다음 스레드한테 바로 양보할게!
DisableInterrupts();
}
(*s) = (*s) - 1;
EnableInterrupts();
}
열쇠(s)가 0이면, 인터럽트를 열어준 직후 OS_Suspend()를 호출해 버립니다. "어차피 못 들어갈 거, 내 남은 시간 다른 스레드한테 줘버릴게!" 하고 곧바로 스케줄러를 불러버리므로 CPU 낭비가 완벽하게 사라집니다.
"The priority of the SysTick ISR needs to be 7" (SysTick의 우선순위는 반드시 7이어야 한다)
ARM에서 7은 가장 낮은 우선순위를 뜻합니다. 문맥 전환을 하는 SysTick 인터럽트가 마이크, 가속도계 등 아주 예민한 하드웨어 타이머(ISR)의 실행을 방해하거나 끊어먹지 않게 하려고 일부러 가장 낮게 설정하는 것입니다.
"The thread switch time to be about 1 μs... the 1-μs overhead is significant." (문맥 전환 오버헤드)
스레드가 OS_Suspend를 부르면서 초당 엄청난 횟수(약 1.3μs 주기)로 교대를 하게 됩니다. 이때 레지스터를 넣고 빼는 SysTick_Handler의 실행 시간이 약 1μs 정도 걸리는데, 교대 주기가 너무 빠르다 보니 이 1μs의 시간이 시스템 전체 성능에 부담(Overhead)을 줄 만큼 커진다는 의미입니다.
극강의 효율성 (Inefficiency 극복): 스핀락은 열쇠를 얻을 때까지 아무 일도 안 하면서 CPU 시간만 낭비합니다. 스레드 개수가 많아질수록 이 낭비되는 시간은 눈덩이처럼 커집니다. 반면, 블로킹 세마포어는 이 버려지는 시간을 완벽하게 회수하여 다른 스레드에게 넘겨줍니다.
공평성 (Fairness & Bounded Waiting): 스핀락은 운이 나쁘면 영원히 열쇠를 못 얻고 굶어 죽을 수 있습니다(Starvation). 하지만 블로킹 방식은 '유한 대기(Bounded Waiting)'를 보장합니다. 즉, 내가 줄을 서면 내 앞에 새치기할 수 있는 스레드의 수가 유한하게 정해져 있어서, 언젠가는 반드시 내 차례가 온다는 것을 보장합니다.
우선순위 스케줄러 (Priority Scheduler) 필수 조건: 스핀락은 스레드에 '우선순위(VIP 등급)'를 매기는 고급 스케줄러에서는 아예 사용할 수가 없기 때문에, 반드시 블로킹 세마포어를 써야만 합니다.
스레드를 대기실에 가두기 위해, TCB 구조체에 blocked라는 새로운 명찰(필드)을 하나 추가합니다.
Blocked == 0 (Null): 아무것도 이 스레드를 막고 있지 않다는 뜻입니다. 즉, 스케줄러가 차례를 주면 언제든 달릴 수 있는 준비 상태(Ready/Running)입니다.
Blocked == 특정 세마포어 주소: 현재 이 스레드가 열쇠(자원)를 얻지 못해 특정 세마포어 앞에서 잠들어 있음(Blocked)을 의미합니다. 스케줄러는 이 명찰을 확인하면 이 스레드를 실행하지 않고 그냥 건너뜁니다.
(참고: 하나의 스레드는 동시에 두 개의 세마포어에서 대기할 수 없습니다. 하나에서 막히면 그 자리에 그대로 멈춰버리기 때문입니다.)

원형 연결 리스트: 5개의 스레드(0~4)가 next 포인터를 통해 빙글빙글 꼬리를 물고 이어져 있습니다.
실행 중인 스레드 (0, 1, 3): 이 세 개의 스레드는 녹색 필드(Blocked)가 0입니다. 아무런 제약이 없으므로 스케줄러가 차례대로 실행시켜 줍니다. 현재 RunPt는 Thread 0을 가리키고 있네요.
잠들어 있는 스레드 (2, 4): 이 두 스레드의 붉은색 필드(Blocked)를 보면 &free라는 세마포어의 주소가 적혀있습니다. 즉, 이 둘은 free라는 자원이 나오기 전까지는 스케줄러가 차례를 줘도 실행되지 못하고 무시당합니다. (이때 free 세마포어의 값은 -2로, 2명이 대기 중임을 나타냅니다.)
OS_Signal이 불려서 누군가를 깨워야 할 때, 가장 오래 기다린 사람부터 공평하게 깨우는 두 가지 방법입니다.
큐(Queue) 사용: 세마포어마다 대기줄(Linked List)을 따로 만들어서, 늦게 온 스레드는 줄 맨 뒤에 세우고, 깨울 때는 맨 앞에 있는 스레드부터 깨웁니다(FIFO 방식).
타임스탬프(Time-stamp) 사용: TCB에 스레드가 잠든 시간을 기록해 둡니다. 누군가를 깨워야 할 때 원형 연결 리스트 전체를 싹 뒤져서, 가장 과거의 시간을 가진(가장 오래 기다린) 스레드를 찾아내서 깨웁니다.