전자회로 실무 / 펌웨어

긴 대기 대신 경과 시간(elapsed time)을 검사해 작업 시점을 정합니다

버튼(button)이나 센서(sensor) 반응이 느리다면 프로그램(program)이 기다리는 동안 다른 일을 못 하고 있을 수 있습니다. 긴 대기문 대신 경과 시간을 확인하는 방식으로 바꾸면, 기다리는 사이에도 다른 작업을 처리할 수 있습니다.

기다리는 동안 다른 입력도 확인합니다

발광다이오드(LED, light-emitting diode)를 1초마다 켜고 끄면서 버튼도 읽어야 한다고 생각해 보겠습니다. 매번 1초 동안 대기하면 그 사이에 눌렸다가 놓인 버튼을 놓칠 수 있습니다. 현재 시각과 이전 실행 시각의 차이만 짧게 검사하고, 아직 1초가 지나지 않았으면 다른 일을 처리하는 편이 낫습니다.

이처럼 기다리는 동안 한 함수에 계속 머무르지 않는 방식을 비차단 처리(non-blocking operation)라고 합니다. 프로그램이 반복해서 입력이나 조건을 확인하는 방식은 폴링(polling)입니다. 비차단 구조로 바꿔도 모든 입력을 반드시 포착하는 것은 아닙니다. 하드웨어나 인터럽트(interrupt)가 필요한 입력까지 느린 폴링으로 처리할 수 있다는 뜻은 아닙니다.

같은 시간축에서 긴 대기와 짧게 나눈 작업을 비교합니다. 이동하는 표시가 현재 시점입니다. 재생 속도는 설명을 위한 것이며 실제 처리 시간을 나타내지 않습니다.
정지 그림 확대
같은 시간축에서 긴 대기와 짧게 나눈 작업을 비교합니다. 이동하는 표시가 현재 시점입니다. 재생 속도는 설명을 위한 것이며 실제 처리 시간을 나타내지 않습니다. · 그림을 클릭하면 확대됩니다.

시간은 하드웨어가 세고 프로그램은 필요한 때만 확인할 수 있습니다

하드웨어 타이머(hardware timer)는 클록에 맞춰 값을 증가시키거나 감소시키는 주변회로입니다. CPU(Central Processing Unit, 중앙처리장치)가 반복문으로 매 틱을 세지 않아도 동작합니다. CPU는 타이머를 설정한 뒤 다른 입력을 처리하고, 필요할 때 현재 값을 읽거나 정해진 값에 도달했다는 인터럽트를 받을 수 있습니다.

예를 들어 실제 계수 주파수를 1 MHz, 즉 초당 1,000,000틱으로 정했다면 500,000틱의 간격은 0.5초입니다. 16비트 카운터의 최대 범위는 65,536개이므로 이 간격을 한 번에 세려면 더 넓은 카운터를 쓰거나 계수 주파수를 낮추는 분주기(prescaler) 등의 설정이 필요합니다. 실제 주기는 클록, 분주, 계수 범위와 재시작 방식에 따라 정해집니다.

타이머 인터럽트가 발생하면 필요한 짧은 작업이나 이벤트 기록만 하고 돌아옵니다. 다음 주기까지 ISR 안에서 기다리는 방식은 피합니다. 일정한 시점의 출력 전환이 특히 중요하다면 CPU가 인터럽트를 처리할 때까지의 지연도 확인해야 합니다. 하드웨어 출력 비교 기능을 쓰는 경우와 ISR에서 직접 핀을 바꾸는 경우의 시점이 항상 같지는 않습니다.

다만 대기 함수 하나가 모든 하드웨어와 인터럽트를 정지시키는 것은 아닙니다. 여기서 줄이려는 것은 그 함수에 머무느라 메인 루프의 입력 확인과 다른 작업이 늦어지는 상황입니다. 다음의 경과 시간 비교는 그 메인 루프를 짧게 반복하기 위한 방법입니다.

현재 시각에서 이전 실행 시각을 뺍니다

경과 시간(elapsed time)은 기준 시점부터 실제로 지난 시간입니다. 예시의 millis()는 실행 환경이 제공하는 밀리초 시간값이며, 1 ms는 0.001초입니다. 시간값이 어떻게 갱신되는지, 저전력 상태에서도 증가하는지는 사용 중인 플랫폼에서 확인해야 합니다.

센서를 ‘5초마다’ 읽으려고 loop()가 500번 돌 때마다 읽는 코드는 시간을 재는 코드가 아닙니다. 한 바퀴가 매번 정확히 10 ms일 때만 500회가 5초에 해당하는데, 표시 갱신·통신 재시도·대기 함수가 끼면 한 바퀴의 길이가 달라집니다. 실제 시간 간격이 필요하다면 아래 예시처럼 시간값의 차이를 비교하고, 5초 간격일 때는 interval을 5000 ms로 정합니다. 동작 중 실제 센서 읽기 시각도 기록해 간격이 요구 범위에 드는지 확인합니다.

다음 예시는 작업을 어떻게 배치하는지 보여 줍니다. 비트(bit)는 0과 1 중 하나를 저장하는 정보 단위입니다. uint32_t는 32비트를 사용해 0부터 4,294,967,295까지 저장하는 부호 없는 정수형입니다. readInputsQuickly(), updateTimedOutput(), runOtherShortTasks()는 목적에 맞게 구현해야 하며, 이 함수 안에도 긴 대기가 없어야 합니다.

#include <stdint.h>

uint32_t previous = 0;
const uint32_t interval = 1000;

void loop() {
  uint32_t now = (uint32_t)millis();
  readInputsQuickly();
  if ((uint32_t)(now - previous) >= interval) {
    previous = now;
    updateTimedOutput();
  }
  runOtherShortTasks();
}

now는 이번 반복에서 읽은 시각, previous는 직전에 출력 작업을 실행한 기준 시각, interval은 실행 간격입니다. >=는 간격과 정확히 같은 순간뿐 아니라 조금 늦게 검사한 경우도 처리합니다. 처음 실행할 때부터 1초를 세고 싶다면 초기화가 끝난 시점의 시간값으로 previous를 설정합니다.

previous = now는 실제로 작업을 처리한 시점부터 다음 간격을 잡습니다. 매번 늦어진 시간을 누적하지 않고 일정한 주기를 유지하려는 경우에는 이전 기준 시각에 간격을 더하는 방식을 검토할 수 있습니다. 다만 작업이 밀렸을 때 빠진 실행을 모두 수행할지, 한 번만 처리하고 재개할지는 장치의 요구에 따라 정해야 합니다. 밀린 작업을 무제한으로 반복하면 다시 입력 처리가 늦어집니다.

카운터(counter)가 0으로 돌아가도 차이를 구할 수 있습니다

카운터(counter)는 정해진 사건이나 시간 단위가 지날 때마다 수를 세어 값을 바꾸는 장치입니다. 여기서는 일정한 시간 간격마다 값이 1씩 증가하는 카운터를 사용합니다.

8비트 값을 이진수(binary)로 적으면 각 자리의 값은 왼쪽부터 128, 64, 32, 16, 8, 4, 2, 1입니다. 모든 자리가 1인 11111111은 합계 255입니다. 여기에 1을 더하면 아홉 번째 자리가 필요하지만 8비트만 남기는 카운터에서는 그 자리가 저장되지 않아 00000000으로 돌아갑니다.

롤오버(rollover)는 증가하던 카운터가 최댓값 다음에 0으로 돌아가는 현상입니다. unsigned는 음수를 저장하지 않는 정수형을 뜻합니다. 부호 없는 정수형으로 변환할 때는 그 형식이 표현하는 범위 안으로 값이 순환합니다. 계산 결과를 카운터와 같은 폭의 부호 없는 형식으로 맞추면, 롤오버 경계를 지난 짧은 시간도 구할 수 있습니다.

예를 들어 앞의 32비트 시간값에서 이전 시각이 0xFFFFFFF0이고 현재 시각이 0x00000020이면 경과 시간은 48 ms입니다. 0x는 16진수 표기입니다. 같은 폭의 부호 없는 차이를 사용하면 이처럼 0을 넘어간 구간도 계산할 수 있습니다.

단, 저장한 기준 시점부터 실제로 지난 시간이 카운터 한 바퀴보다 짧아야 그 차이를 실제 경과 시간으로 해석할 수 있습니다. 오랫동안 검사하지 않았거나 기준 시각을 계속 묵혀 두었다면 몇 바퀴가 지났는지 두 값만으로 복원할 수 없습니다. 시간값을 더 좁은 형식이나 부호 있는 int로 바꿔 저장하지 않는 것도 중요합니다.

8비트 변수라도 덧셈은 8비트로 끝나지 않을 수 있습니다

작은 카운터에서는 다음과 같은 실수가 특히 잘 드러납니다. 틱(tick)은 카운터가 한 번 증가하는 단위입니다. 여기서는 0부터 255까지 증가한 다음 0으로 돌아오는 8비트 카운터를 가정하며, 100틱을 기다리려 합니다. uint8_t는 이 256개 값을 저장하는 부호 없는 정수형입니다.

uint8_t start = 200;
/* now는 계속 갱신되는 8비트 카운터 값이라고 가정합니다. */
while (now != start + 100) {
}

이 조건은 now가 300이 되기를 기다립니다. C에서는 uint8_t처럼 작은 정수형으로 계산할 때 값을 int로 바꾸는 정수 승격(integer promotion)이 일어납니다. 따라서 start + 100은 이 예시에서 44가 아니라 int 값 300입니다. now가 계속 증가하고 다시 0으로 돌아와도 0–255 범위를 벗어나지 않으므로 기다림이 끝나지 않습니다.

이 문제를 고치려면 현재 값에서 시작 값을 뺀 뒤, 그 차이를 카운터와 같은 8비트 형식으로 변환합니다. 괄호 앞의 (uint8_t)처럼 형식을 명시해 바꾸는 것을 형 변환(cast)이라고 합니다. 다음은 고정된 값으로 계산을 확인하는 예시이며, 실제 반복문에서는 매번 새 카운터 값을 읽어 now에 넣어야 합니다.

uint8_t start = 200;
uint8_t now = 44;
uint8_t elapsed = (uint8_t)(now - start);

if (elapsed >= 100) {
  /* 100틱 이상 지났을 때 처리합니다. */
}

여기서 now - start는 정수 승격을 거쳐 int 값 −156이 됩니다. 이것을 uint8_t로 변환하면 −156에 256을 더한 100이 됩니다. 뺄셈 자체가 언제나 8비트로 실행되는 것은 아니며, 계산한 차이를 8비트 부호 없는 값으로 변환하는 단계가 핵심입니다.

200에서 출발한 8비트 카운터가 255 다음에 0으로 돌아온 뒤 44에 도달합니다. 위의 현재 값은 다시 작아지지만 아래의 변환된 차이는 100틱에 도달합니다. 설명용 계산이며 애니메이션 재생 시간은 실제 틱 주기와 무관합니다.
정지 그림 확대
200에서 출발한 8비트 카운터가 255 다음에 0으로 돌아온 뒤 44에 도달합니다. 위의 현재 값은 다시 작아지지만 아래의 변환된 차이는 100틱에 도달합니다. 설명용 계산이며 애니메이션 재생 시간은 실제 틱 주기와 무관합니다. · 그림을 클릭하면 확대됩니다.

정확히 같은 값이 되는 순간만 기다리지 않습니다

목표값에만 형 변환을 붙여 now == (uint8_t)(start + 100)으로 비교하면 목표는 44가 됩니다. 하지만 반복문이 43을 읽은 뒤 다른 작업을 처리하다가 다음에 45를 읽었다면 44를 놓칩니다. 원하는 시간은 이미 지났는데도 조건이 거짓이 됩니다. 경과 시간에 >= 100을 쓰면 이때의 차이 101도 처리할 수 있습니다.

이 방식도 무제한으로 늦어져도 된다는 뜻은 아닙니다. 8비트 예시에서는 실제로 100틱이 지난 뒤부터 256틱이 되기 전까지 적어도 한 번은 조건을 검사해야 합니다. 256틱이 지나면 카운터는 시작 값 200으로 돌아오고, 계산한 차이는 다시 0이 됩니다. 한 바퀴보다 긴 대기가 필요하면 더 넓은 시간값이나 검증된 시간 관리 기능을 사용합니다.

카운터가 최댓값 255가 아닌 다른 값에서 다시 시작하도록 설정되었거나, 증가가 아니라 감소하는 경우에는 이 예시의 계산을 그대로 적용하지 않습니다. 먼저 실제 계수 방향과 반복 범위를 확인합니다.

경계값을 넣어 검사하면 오래 기다리지 않아도 됩니다

롤오버 문제를 확인하려고 장치를 며칠씩 켜 둘 필요는 없습니다. 시간값을 입력받아 조건을 판정하는 부분을 분리하고, 경계 전후의 값을 직접 넣어 확인합니다. 다음은 시작 값을 200, 실행 간격을 100틱으로 둔 계산 예시입니다. 실제 장치에서 측정한 결과는 아닙니다.

실제 지난 틱읽은 8비트 값8비트로 변환한 차이>= 100의 결과
5525555아직 실행하지 않습니다.
560560으로 돌아가도 계속 셉니다.
994399아직 실행하지 않습니다.
10044100실행 조건을 만족합니다.
10145101정확한 순간을 조금 지나도 처리합니다.
255199255첫 바퀴 안에서는 경과 시간을 알 수 있습니다.
2562000한 바퀴가 지난 사실을 이 값만으로 알 수 없습니다.

43 다음에 45를 읽는 경우도 따로 넣어, 정확한 값 하나를 기다리는 비교와 경과 시간 비교가 어떻게 다른지 확인합니다. 그런 다음 실제 장치에서 루프를 한 번 실행하는 데 걸리는 최대 시간과 카운터 갱신 주기를 측정합니다. 다른 작업 때문에 실행 시점이 얼마나 늦어질 수 있는지도 확인해야 합니다.

비차단 구조에서도 긴 작업은 다시 병목이 됩니다

센서 읽기, 문자열 수신, 통신 재시도 같은 함수가 내부에서 오래 기다리면 대기문을 지운 효과가 줄어듭니다. 한 번의 루프 실행보다 짧은 입력 펄스는 폴링만으로 놓칠 수 있으므로, 주기 작업이 제때 실행되는지와 짧은 입력을 빠짐없이 감지하는지는 각각 시험합니다.

카운터를 읽는 방법도 확인합니다. 하드웨어나 다른 실행 흐름이 값을 바꾸는 경우, 플랫폼에서 제공하는 읽기 함수를 사용하고 한 번의 판단에는 같은 시점에 읽은 값을 사용합니다. 여러 번 나누어 읽는 동안 값이 바뀌면 서로 다른 시점의 일부가 섞일 수 있습니다. 이를 막고 일관된 값 하나를 얻는 원자적 읽기(atomic read)가 필요한지는 마이크로컨트롤러(MCU, microcontroller unit)와 시간 함수의 보장에 따라 판단합니다. volatile은 값이 외부 요인으로 바뀔 수 있음을 컴파일러에 알리는 형식 한정자이며, 그것만 붙인다고 나누어진 읽기가 원자적으로 바뀌지는 않습니다.

긴 대기와 짧은 작업 사이에서 입력을 확인하는 실행 구간을 비교합니다.
긴 대기와 짧은 작업 사이에서 입력을 확인하는 실행 구간을 비교합니다. · 클릭하면 확대됩니다.

시간 단위 변환과 실행 간격의 정의를 분리합니다

앞의 실행 간격을 여러 곳에서 사용한다면 같은 숫자를 반복하지 말고 interval을 참조합니다. 한 곳의 값을 바꿀 때 일부 위치만 옛 값으로 남는 실수를 줄이려는 것입니다. const라는 이름이 실행 중 값이 자동 조절된다는 뜻은 아닙니다.

실행 시점 비교는 위처럼 정수 밀리초로 유지하고, 초 단위 표시가 필요할 때 변환합니다. 1000 ms / 2 = 500 ms이지 1초가 아닙니다. 두 정수의 식 500 / 1000은 0이 되지만 500 / 1000.0은 0.5초를 표현합니다. C++ 정수 나눗셈 규칙은 결과의 소수부를 버립니다. double seconds = 500 / 1000;처럼 결과를 나중에 실수에 저장해도 이미 버린 소수부가 복원되지 않습니다.

const uint32_t elapsedMs = 500;
const double seconds = elapsedMs / 1000.0;  // 0.5 s

이 코드는 단위 변환 예이며 타이머를 만들거나 정확도를 높이지 않습니다. 실제 시간값의 폭·분해능·롤오버와 최대 확인 간격은 앞의 조건을 그대로 따릅니다. 화면에 표시한 소수 자리 수를 계측 정확도로 해석하지 않습니다.

English summary. Keep scheduling in integer milliseconds and convert only for display when appropriate. Integer 500/1000 discards the fraction; dividing by 1000.0 yields 0.5 seconds. Reuse one interval definition, and retain the existing rollover and maximum-poll-gap requirements.