아두이노 내부 동작 구조 – PART2

지난 Part1 강좌에서 언급한 것 처럼 이제 가장 중요한 pgm_read_byte 를 이해할 차례입니다. 하지만 이 부분을 이해하기 위해서는 먼저 아두이노의 메모리 구조에 대해서 알아둬야 합니다.

.

Programs, Data, and Harvard vs. von Neumann Architectures

일반적으로 우리가 사용하는 PC에서는 프로그램을 실행하면 1G 이상의 큰 메모리에 프로그램이 로드되고, 프로그램에서 사용되는 데이터도 메모리에 생성되어 사용됩니다. 즉, 프로그램과 데이터가 같은 메모리 공간에 위치합니다. 이걸 폰 노이만 아키텍처라고 합니다.

반면에 아두이노에서는 프로그램과 데이터가 각각 별도의 메모리에 위치합니다. 프로그램(program instruction)은 프로그램 메모리에 저장되어 실행되고 데이터는 별도의 데이터 메모리에 저장됩니다. 이걸 하버드 아키텍처라고 합니다. 이렇게 프로그램과 데이터 메모리를 구분함으로써 시스템의 구조를 훨씬 단순화시키고 신뢰성을 높일 수 있습니다. 프로그램 메모리에 저장되어 칩에 업로드된 프로그램은 동작하는 동안 수정을 할 수 없습니다. 따라서 데이터에 따라 프로그램을 유연하게 변형시킬 수 없습니다. 대신 오랜 시간동안 비교적 단순한 동작을 반복하며 신뢰성이 요구되는 상황에서는 하버드 아키텍처가 유리합니다.

[아두이노 메모리에 대한 상세 내용은 이 링크를 참고]

.

그럼 아두이노가 사용하는 하버드 아키텍처와 pgm_read_byte  의 관계는? 앞서 Part1에서 언급한 digital_to_pin_to_bit_mask_PGM 배열의 세부 코드를 보면 알 수 있습니다.

const uint8_t PROGMEM digital_pin_to_bit_mask_PGM[] = {
  // PIN IN PORT		
  // -------------------------------------------		
  // [...]
  _BV( 4 )	, // PB 4 ** 10 ** PWM10	
  _BV( 5 )	, // PB 5 ** 11 ** PWM11	
  _BV( 6 )	, // PB 6 ** 12 ** PWM12	
  _BV( 7 )	, // PB 7 ** 13 ** PWM13	
  _BV( 1 )	, // PJ 1 ** 14 ** USART3_TX	
  //[...]
}

배열을 선언할 때 const uint8_t PROGMEM 지시어를 사용했다는 점에 주목하세요.

const 는 읽기전용 데이터를 말하며, uint8_t 는 8비트 정수형을 의미합니다. 그럼 PROGMEM 은? PROGMEM은 데이터를 프로그램 메모리에 저장하게끔 해주는 매크로입니다. 하버드 아키텍처에서 데이터는 데이터 메모리에 저장한다고 했는데 이상하죠?

왜냐면 아두이노같은 대부분의 마이크로 컨트롤러는 프로그램 메모리에 비해 데이터 메모리가 굉장히 작기 때문입니다. 아두이노 UNO 보드에 탑재된 ATMega328의 경우 프로그램 메모리는 32KB 인데 반해 데이터 메모리(SRAM)는 2KB에 불과합니다. 따라서 데이터의 사이즈가 조금이라도 커져버리면 데이터 메모리가 금방 차버립니다. 그래서 PROGMEM 매크로와 pgm_read_byte  를 이용해서 프로그램 메모리에 데이터를 기록해두고 읽어오는 방법을 제공합니다.

pgm_read_byte  의 코드를 세부적으로 살펴보면 특정 메모리 주소에서 데이터를 읽어오는 inline assembly 명령어 셋으로 구성되어 있습니다. [arduino-0015/hardware/tools/avr/avr/include/avr/pgmspace.h line 298]

#define __LPM_enhanced__(addr)  \
(__extension__({                \
    uint16_t __addr16 = (uint16_t)(addr); \
    uint8_t __result;           \
    __asm__                     \
    (                           \
        "lpm %0, Z" "\n\t"      \
        : "=r" (__result)       \
        : "z" (__addr16)        \
    );                          \
    __result;                   \
}))

굳이 이런 부분까지 세세히 이해할 필요는 없어보입니다. 이 부분은 아두이노의 가장 깊고 어두운 내부 코드입니다.

.

back to pinMode

이제 다시 pinMode 함수로 돌아가보도록 하겠습니다.

앞서까지 살펴본 pinMode 함수의 세세한 부분들에 대한 설명을 아우르면 결국 아래처럼 pinMode 코드의 의미를 알 수 있습니다.

void pinMode(uint8_t pin, uint8_t mode)
{
  uint8_t bit = digitalPinToBitMask(pin);
  uint8_t port = digitalPinToPort(pin);
  volatile uint8_t *reg;
  
  if (port == NOT_A_PIN) return;
  
  // JWS: can I let the optimizer do this?
  reg = portModeRegister(port);
  
  if (mode == INPUT) *reg &= ~bit;
  else *reg |= bit;
}

함수를 구성하는 첫 번째 라인부터 보죠. digitalPinToBitMask() 함수는 특정 핀에 대응하는 레지스터를 표시하는 비트 마스크를 반환해줍니다.

두 번째로 할 일은 특정 핀의 포트 레지스터를 찾는 것입니다. 핀의 상태를 바꾸기 위해서는 DDR(Data Direction Register)에서 핀에 해당되는 정확한 비트(위치)를 찾아야 합니다. PORT의 종류에 따라 다른 DDR을 사용해야 합니다. PORT(->DDR), PORTA(->DDRA), PORTB(->DDRB). digitalPinToPort() 함수가 하는 것이 바로 이 작업입니다. 입력된 핀에 해당되는 PORT 주소(address)를 찾아줍니다. 13번 핀의 경우 PORTB의 주소가 될 것입니다.

다로 다음 라인부터 정의된 내용이 PORT에 해당되는 DDR을 얻는 과정입니다. 사용자가 잘못된 핀 넘버를 넣을 경우에 대비해서 port 주소가 잘못된 값(NOT_A_PIN)인지 확인하고 문제가 없을 경우 portModeRegister() 함수로 Mode 변경을 위한 DDR을 얻습니다. portModeRegister() 함수는 Mode 변경을 위한 DDR의 주소를 리턴해줍니다.

DDR까지 얻으면 이제 핀을 설정할 준비는 모두 된 것입니다. 이제 DDR에서 우리가 입력한 핀에 해당하는 비트를 원하는 모드 값으로 바꿔주면 됩니다. LOW(=0)로 바꾸면 input 모드가 되고 HIGH(=1)로 변경하면 output 모드가 됩니다.

.

digitalWrite()

digitalWrite() 함수는 아두이노에서 가장 자주 사용되는 함수 중 하나입니다. 특정 핀의 출력 상태를 0V 또는 5V로 변환시켜 줍니다. 이 기능도 앞선 pinMode() 함수의 동작 과정처럼 register를 이용한 조작 작업으로 이해할 수 있습니다. 이미 우리가 테스트 한 13번 핀의 PORTB를 이용한 내부처리 순서는 아래와 같습니다.  [arduino-0015/hardware/cores/arduino/wiring_digital.c]

void digitalWrite(uint8_t pin, uint8_t val)
{
  uint8_t timer = digitalPinToTimer(pin);
  uint8_t bit = digitalPinToBitMask(pin);
  uint8_t port = digitalPinToPort(pin);
  volatile uint8_t *out;
  
  if (port == NOT_A_PIN) return;
  
  // If the pin that support PWM output, we need to turn it off
  // before doing a digital write.
  if (timer != NOT_ON_TIMER) turnOffPWM(timer);
  
  out = portOutputRegister(port);
  
  if (val == LOW) *out &= ~bit;
  else *out |= bit;
}

pinMode() 함수의 경우와 유사한 패턴으로 digitalPinToBitMask 함수와   digitalPinToPort 함수를 이용해 PORT 주소를 찾습니다. (PORTB)

이후 과정이 달라지는데 DDR(Data Direction Register) 대신 Port Output Register를 찾습니다. 이때 portOutputRegister() 함수를 사용합니다. 여기에 비트 설정을 바꾸면 핀의 5V 출력 설정이 변경됩니다.

코드를 보시면 앞서 설명한 내용과 함께 timer 관련된 내용들이 포함되어 있습니다. digitalPinToTimer() 함수를 이용해서 timer 설정을 확인하는데, 변경하고자 하는 핀이 아두이노의 PWM 핀인 경우, PWM 기능을 이미 사용하고 있다면 꺼두기 위함입니다. turnOffPWM() 함수가 PWM 타이머를 꺼줍니다.

.

analogWrite(), PWM

핀에 연결된 레지스터를 이용해서 output 5V 출력을 on/off하는 방법은 언급했습니다. 그럼 아두이노에서는 analog 출력은 어떻게 제어할까요? 참고로 아두이노에서는 출력 전압 자체를 0~5V 사이에서 조절할 수는 없습니다.

이 문제는 PWM(Pulse Width Modulation) 이란 기술로 구현할 수 있습니다. 만약 on/off 제어만 가능한 특정 핀에 아날로그 값 50%의 출력을 내고 싶다면 시간에 따라 빠르게 on/off 를 반복하면 됩니다. 이 방법으로 LED의 밝기를 원하는대로 조절할 수 있습니다. 스피커의 톤을 변경할 때도 같은 방법을 사용합니다. 그리고 이걸 반대로 적용하면 analogRead 가 됩니다. 즉, 연속적인 신호를 빠르게 샘플링해서 디지털 값으로 변환할 수 있습니다.

analogWrite() 함수의 PWM 제어 핵심은 digitalWrite()에서 나왔던 timer 입니다. timer는 AVR에 내장된 함수입니다. timer는 기본적으로 AVR 시스템이 가능한 빠른 속도로 1씩 증가시키는 숫자입니다. 다른말로 AVR이 명령어-instruction 를 실행할 때 (기술적으로 표현하자면 clock-cycle) 타이머도 증가됩니다. 따라서 칩의 클럭 스피드에 따라 증가하는 속도는 변화합니다. 8Mhz, 16Mhz 의 속도로 동작하는 ATMega168 칩의 경우 8백만, 1천6백만 tick 이 매 초 발생합니다.

결과적으로 analogWrite() 함수는 timer에 따라 핀의 출력 상태를 계속 변화할 수 있도록 설정하는 것입니다. 보다 상세한 내용은 여기서는 다루지 않지만 해당 함수의 소스코드를 통해 파악하실 수 있습니다.

.

delay()

아두이노에서 ‘hello world’ 프로그램을 수행하는 핵심 함수 두 가지, pinMode()와 digitalWrite()의 세부 내용을 살펴봤습니다. 이제 마지막 남은 delay() 함수를 살펴볼 차례입니다.

delay() 함수의 세부 구현은 다음 파일안에서 찾으실 수 있습니다. [arduino-0015/hardware/cores/arduino/wiring.c]

void delay(unsigned long ms)
{
  unsigned long start = millis();
	
  while (millis() - start <= ms)
     ;
}

구현은 무척 간단합니다. ‘현재 시간’을 반환해주는 millis() 함수를 호출하고 우리가 지정한 시간이 지났는지 끊이없이 비교합니다. 여기서 현재 시간이란 아두이노가 시작된 후부터 지나간 시간입니다. timer 설명할 때 언급한바 있지만 아두이노의 시간이란 칩 내부의 clock의 움직임(8~16 million tick/second)입니다. 따라서 millis() 함수의 구조만 이해하면 됩니다.

delay(), millis() 함수를 조금 더 깊이있게 이해하기 위해서는 인터럽트(interrupts)의 개념을 알아야 합니다.

.

Interrupts

pinMode(), digitalWrite() 함수의 내부에 정의된 과정들은 우리가 아두이노의 움직임을 변화시키기 위해 능동적으로 레지스터등의 구조를 바꾸는 작업이었습니다. 하지만 뭔가 외부 환경의 변화에 따라 아두이노가 자동으로 반응하도록 만들려면 어떻게 해야 할까요? 이걸 가능케 해주는 것이 인터럽트입니다. 인터럽트는 특정한 조건에 의해 미리 정의한 루틴이 자동으로 실행되도록 AVR에서 제공하는 겁니다.

인터럽트를 사용하기 위해서는 handler라는 함수(인터럽트 처리 루틴)를 만들어줘야 합니다. 그리고 인터럽트를 활성화하면 당장은 handler는 반응하지 않습니다. AVR은 반복되는 메인 루틴의 일들을 처리합니다. 그러다 특정 event가 발생하면 AVR은 메인 루틴의 작업을 멈추고 해당되는 인터럽트 함수, handler의 작업들을 처리합니다. 인터럽트 handler의 작업이 끝나면 다시 메인 루틴의 작업으로 복귀합니다.

AVR의 입장에서 인터럽트는 ADC(Analog to Digital Converter)를 포함한 다양한 이벤트에 의해 촉발되는 하드웨어 요소입니다. SPI, UART 시리얼 통신에 의해서도 발생할 수 있고 특정 값에 도달한 타이머에 의해서도 발생할 수 있습니다. 특히 마지막에 언급한 timer 기반 인터럽트가 clock tick을 milli-second 단위로 변환하는 과정의 핵심입니다.

아두이노에서 시간을 담기 위해 사용되는 변수형은 4byte – “unsigned long” 형 입니다. unsigned long 형은 0 부터 4,294,967,296 까지의 값을 담을 수 있습니다. 일반적으로 아두이노는 16MHz로 동작하는데, 이 속도에서는 0.001024 초가 지나면 unsigned long 값이 꽉 차버립니다. 약 1 밀리초면 최대값에 도달하는 것입니다. (원문의 표현에 오류가 있어서 아래 내용으로 수정, 자료제공: 김병월님)

AVR은 8비트 아키텍처입니다. 따라서 시스템이 가장 편안하게 데이터를 저장할 수 있는 “uint8_t (8bit unsigned char)” 형으로 카운트를 하면 256 개의 값을 담을 수 있습니다. 일반적으로 아두이노는 16MHz로 동작하는데 timer 0 의 오버플로 인터럽트 prescale factor(clock의 주기를 변형, 링크 참고) 가 64입니다. 그럼 16MHz/64 = 250,000Hz 이고, 시간으로 바꾸면 4us에 한번식 카운트가 증가합니다. timer 0 는 8비트 타이머이므로 4us * 256 마다 오버플로우 인터럽트가 걸리니 0.001024 초면 카운트가 꽉 차버립니다.

그럼 더 긴 시간을 담기 위해서는 어떤 방법을 사용해야 할까요? 예상하시다시피 unsigned long 변수(2)를 하나 더 사용하면 됩니다. 그리고 약 1밀리초에 한번씩 첫 번째 카운트가 넘칠 때 마다 인터럽트를 실행해서 두 번째 unsigned long 변수(2)의 카운트를 증가시키면 됩니다. 이 방법을 사용하면 약 48.54일 정도로 측정할 수 있는 시간이 늘어납니다.

실제로 timer overflow에 의해 실행되는 인터럽트를 정의한 아두이노 코드는 아래와 같습니다. [arduino-0015/hardware/cores/arduino/wiring.c]

volatile unsigned long timer0_overflow_count = 0;
volatile unsigned long timer0_clock_cycles = 0;
volatile unsigned long timer0_millis = 0;

SIGNAL(TIMER0_OVF_vect)
{
  timer0_overflow_count++;
  // timer 0 prescale factor is 64 and the timer overflows at 256
  timer0_clock_cycles += 64UL * 256UL;
  while (timer0_clock_cycles > clockCyclesPerMicrosecond() * 1000UL) {
    timer0_clock_cycles -= clockCyclesPerMicrosecond() * 1000UL;
    timer0_millis++;
  }
}

복잡해 보이지만 앞서 설명한 것을 코드로 풀어썼을 뿐입니다.

SIGNAL() 은 인터럽트 handler를 정의하는 avr-gcc 컴파일러 문법입니다. 이 함수에 전달되는 파라미터 TIMER0_OVF_vect 은 Timer 0 Overflow Vector를 말합니다. AVR 에는 몇 개의 타이머가 있는데 이 중 0번 타이머가 최대값에 도달할 때 설정되는 인터럽트 flag 를 나타냅니다. 쉽게, Timer 0 가 흘러넘칠 때 인터럽트 handler를 실행하라는 의미입니다.

인터럽트 핸들러는 가장 먼저 timer0_overflow_count 카운트를 증가 시킵니다. 그리고 이후부터 나오는 코드는 clock 속도와 실제 시간의 scaling 작업입니다. 즉, 하드웨어 종류에 따라 다양한 AVR clock 속도를 milli-second 단위(timer0_millis)로 바꾸기 위한 작업입니다.

위 인터럽트 handler는 timer 0 가 넘칠 때마다 호출되어 시간을 업데이트 합니다. 이제 우리가 millis() 함수를 호출하면 그동안 AVR이 업데이트 해 둔 시간 값을 읽어올 수 있습니다. 아래 코드는 millis() 함수의 내부 구조입니다. [arduino-0015/hardware/cores/arduino/wiring.c]

unsigned long millis()
{
  unsigned long m;
  uint8_t oldSREG = SREG;
  
  // disable interrupts while we read timer0_millis or we might get an
  // inconsistent value (e.g. in the middle of the timer0_millis++)
  cli();
  m = timer0_millis;
  SREG = oldSREG;
  
  return m;
}

간단하게 보자면 timer0_millis 의 값을 unsigned long 변수인 m 에 받아와서 리턴해줍니다. 그래서 젤 첨에 unsigned long m; 변수를 정의합니다.

그리고 다음에 정의된 oldSEG 변수를 정의하고 SREG의 값을 그대로 복사해 두는데 이건 Status Register를 말합니다. Status Register란 칩의 다양한 동작 결과를 담아두기 위해 AVR에서 사용하는 특수 레지스터입니다.

인터럽트의 가장 큰 특징은 현재 실행되는 프로그램을 중단하고 handler를 실행한다는 점입니다. 따라서 이전에 프로그램이 사용하던 값들은 인터럽트가 실행된 후에도 그대로 유지되어야 합니다. 아니면 프로그램의 나머지가 나중에 모두 틀어질 수도 있습니다. 그래서 Status Register 컨텐츠를 저장해뒀다 다시 복구해줍니다.

Status Register를 저장하는 루틴이 끝나면 cli() 함수 호출이 나옵니다. 이 함수는 “clear interrupts”를 의미하는데, timer0_millis 값을 읽어오기 전에 다른 인터럽트의 동작을 중지시키는 역할을 합니다. 즉, timer0_millis 값을 읽는데 다른 인터럽트가 실행되어 값을 읽는 동작을 방해하지 않도록 하기 위함입니다.

이제 우리가 원하는 “현재 시간”을 읽었으니 상황을 원상복구 시켜주면 됩니다. SREG = oldSREG; 라인이 이런 역할을 합니다. 가만 생각해보면 우리는 cli() 함수로 인터럽트를 중단 시켰는데 다시 복구하지는 않았습니다. 그런데 이후 코드에는 인터럽트를 복구하는 함수가 없습니다. 뭔가 잘못된 걸까요?

실은 Status Register에 인터럽트 활성화 여부를 표시하는 비트가 포함되어 있습니다. 따라서 SREG = oldSREG 를 통해 Status Register를 복구하면 인터럽트도 복구됩니다.

.

마치며…

아두이노의 가장 단순한 예제, Blink 예제가 동작하는 방식을 훝어보는 것 만으로도 아두이노의 감춰진 동작 원리와 비밀들을 엿볼 수 있습니다. 사실 이 정도까지 세세하게 아두이노를 알아야 할 필요는 없습니다. 아두이노 초급자들이 이해하기에는 내용도 꽤나 어려운 편입니다.

하지만 내용들을 천천히 음미해두면(?) 추후 아두이노의 고급 기술들을 활용하는데 필요한 기반을 갖출 수 있습니다. 그리고 아두이노의 내부 소스코들을 이해하는데 도움이 되기도 합니다.

비록 analogRead() – ADC(Analog to Digital Converter) 부분과 analogWrite() 편은 여기서 다루지 않았지만 추후 관련 자료가 올라오면 소개하도록 하겠습니다.

해외자료를 번역한 문서이며, 원문은 아래 링크를 참고하세요.

.

참고자료 :

You may also like...