LED 제어 4
이번글에서는 AVR에서만 사용가능한 방법이 아닌 일반적인 방법으로 LED를 점멸하는 기능을 구현해 보도록 하겠다. 이번 글은 아직 임베디드 시스템 프로그램에 익숙하지 않은 개발자에게는 다소 난해한 내용이 될 수도 있을것이다.
이미 앞의 글에서도 얘기 했지만, AVR 외부와 연결되는 I/O 핀을 제어하기 위해서는 PORT 레지스터를 이용하면 되고, PORT 레지스터에 접근하기 위하여 CPU에서는 주소를 이용한다고 설명하였다. 또한 C프로그램에서 특정 주소에 값을 쓰거나, 주소에서 값을 읽어 오기 위하여 포인터를 사용하면 된다고 하였다.
그럼, 포인터 기법을 사용하여 LED 제어 1에서 작성한 코드를 수정하도록 하겠다.
#define F_CPU 1000000
//#include <avr/io.h>
#include <util/delay.h>
#define PINB 3
#define DDRB 4
#define PORTB 5
#define GREEN_LED 0x01
#define RED_LED 0x02
int main(void)
{
*(unsigned char*)DDRB = (GREEN_LED|RED_LED);
*(unsigned char*)PORTB= (GREEN_LED|RED_LED);
while (1)
{
_delay_ms(500);
*(unsigned char*)PINB = (GREEN_LED|RED_LED);
}
}
위와 같이 포인터를 사용하여 코드를 수정한 다음 보드에 적용하여 결과를 확인해 본다. 2번줄에서 AVR에서 제공하는 코드를 사용하지 않기 위해 주석처리를 한것을 볼 수 있다.
결과는 예상과는 다르게 LED에 전혀 반응이 없을 것이다. 무엇이 문제인지 디버깅해 보도록 하겠다. 우선 lss 파일을 열어서 정상적으로 동작했을때의 코드와 어떻게 달라졌는지 확인해 본다. 임베디드 시스템에서 프로그래머가 예상했던대로 동작하지 않을때는 어셈블 코드를 확인해 보는 습관을 가지는게 좋다. C코드에서는 전혀 문제 없어 보이는 코드가 어셈블로 변환했을때는 예상과 다르게 되어 있는 경우가 종종 있기 때문이다.
00000080 <main>:
#define GREEN_LED 0x01
#define RED_LED 0x02
int main(void)
{
*(unsigned char*)DDRB = (GREEN_LED|RED_LED);
80: 83 e0 ldi r24, 0x03 ; 3
82: 80 93 04 00 sts 0x0004, r24
*(unsigned char*)PORTB= (GREEN_LED|RED_LED);
86: 80 93 05 00 sts 0x0005, r24
while (1)
{
_delay_ms(500);
*(unsigned char*)PINB = (GREEN_LED|RED_LED);
8a: e3 e0 ldi r30, 0x03 ; 3
8c: f0 e0 ldi r31, 0x00 ; 0
#else
//round up by default
__ticks_dc = (uint32_t)(ceil(fabs(__tmp)));
#endif
__builtin_avr_delay_cycles(__ticks_dc);
8e: 2f e9 ldi r18, 0x9F ; 159
90: 36 e8 ldi r19, 0x86 ; 134
92: 91 e0 ldi r25, 0x01 ; 1
94: 21 50 subi r18, 0x01 ; 1
96: 30 40 sbci r19, 0x00 ; 0
98: 90 40 sbci r25, 0x00 ; 0
9a: e1 f7 brne .-8 ; 0x94 <main+0x14>
9c: 00 c0 rjmp .+0 ; 0x9e <main+0x1e>
9e: 00 00 nop
a0: 80 83 st Z, r24
}
a2: f5 cf rjmp .-22 ; 0x8e <main+0xe>
9번줄,11번줄,34번줄을 자세히 보면, 정상 동작할 때의 코드와 다르다는 것을 알 수 있다. 정상 코드에서는 "out"이라는 명령어가 사용되었는데, 비정상동작일때는 "sts"라는 명령어가 사용되었다. 그럼 "out"명령어와 "sts"명령어의 차이점을 확인해 봐야 될 것이다.
ATMEL사는 AVR에서 사용할 수 있는 명령어에 대한 문서를 제공한다. 홈페이지에서 "Atmel AVR 8-bit Instruction Set Manual"을 다운로드 받아서 AVR에서 사용 가능한 명령어가 어떤것들이 있는지 살펴본다.
Instruction Set Manual에 나와 있는 "out"과 "sts" 명령어에 대한 설명이다. 둘다 레지스터에 들어있는 값을 어딘가로 저장하는 명령어이다. 그런데 그 목적지가 다른것을 볼 수 있다.
"out"은 I/O공간으로 값을 쓰는데 사용하고, "sts"는 data공간으로 레지스터의 값을 쓰는데 사용한다. 그럼, I/O 공간은 무엇이고 data 공간은 무엇인지 알아봐야 하겠다.
여기서 말하는 공간(space)은 메모리공간, 즉, 독립된 메모리주소 영역을 표현하고 있다고 보면 된다.
위의 그림에서 보는바와 같이 AVR CPU는 세개의 버스를 가지고 있다. 하나는 Flash메모리와 연결되어 있고, 하나는 SRAM에 연결되어 있다. 그리고 나머지 하나는 아래쪽 I/O블럭들과 연결되어 있다. 다시 말해서 크게 세개의 메모리 공간을 가지고 있다고 보면 된다. Flash 메모리에는 AVR CPU가 동작 할 수 있는 명령어와 상수값 데이터가 저장되고, SRAM은 프로그램이 동작될 때 필요한 변수 데이터들을 저장하는데 사용한다. 그리고 I/O 블럭들안에 있는 레지스터들에 대한 I/O memory가 있다.
이와 같이 개별적인 버스에 연결되어 서로 독립적인 메모리 공간으로 나누어 지는 구조를 하바드 아키텍쳐라고 한다. 이와 반대로 모든 메모리가 하나의 버스에 연결되어 있는 구조를 폰 노이만 아키텍쳐라고 한다. 소프트웨어 측면에서는 단일 메모리 공간을 가지는 폰 노이만 구조의 CPU에 대한 프로그램 개발이 편하지만, 하드웨어 측면에서는 주소를 효율적으로 사용할 수 있으므로 AVR과 같은 8비트 CPU에서는 하바드 아키텍쳐가 좋은 구조이다.
AVR CPU에 연결되어 있는 Flash, SRAM, I/O 레지스터는 각각의 물리적 주소를 가지고 있다. 즉, Flash 메모리도 0번지부터 주소가 시작되고, SRAM도 0번지부터 시작되는 물리적 주소를 가지고 있다. 물론 I/O 레지스터들도 0번지에서 시작되는 물리적 주소를 가지고 있다. 그렇다면, CPU가 0번지에 어떤 값을 쓰려고 한다면 Flash에 쓰여지는 건지, SRAM에 쓰여지는건지 그것도 아니면 I/O 레지스터에 쓰여지는 건지 어떻게 알 수 있을까? 만약 어느 하나의 메모리 공간이 이미 지정되어 있다면 나머지 메모리 공간에 대해서는 어떤식으로 접근해야 할까?
이를 해결하기 위해서 AVR은 각각의 메모리 공간에 접근할 수 있는 명령어를 따로 가지고 있다.
Flash메모리 공간에 값을 읽고 쓰기 위해서는 "LPM", "SPM" 명령을 사용한다. SRAM에 접근하기 위한 명령어로는 "LDS", "STS"가 있다. I/O레지스터에 접근하기 위한 명령어는 "IN", "OUT"이 있다.
이제 I/O 레지스터에 값을 쓰기 위하여 "out" 명령어를 사용했던 코드는 정상적으로 동작한 반면 "sts"명령어를 사용한 코드는 왜 비정상 동작하게 되는지 이유를 알 수 있을것이다. 쓰려고 했던 데이터가 I/O 레지스터에 쓰여지는 것이 아니고 SRAM영역에 쓰여지기 때문인 것이다.
결론은 포인터를 이용한 일반적인 C프로그램으로는 LED제어와 같은 기능은 수행할 수 없다는 것이다.
정말 그럴까? 만일 정말로 일반적인 C프로그램으로 I/O 레지스터를 제어할 수 없다면 AVR이 전 세계적으로 많은 개발자들이 선택하는 CPU가 되지는 않았을것이다. 분명히 다른 방법을 통해서 이러한 문제를 해결할 수 있을 것이다.
AVR 데이터쉬트에서 8장 AVR Memories를 보면 Program memory map과 Data memory map에 대한 설명이 나온다. Program memory map은 Flash 메모리에 대한 설명이다.
위의 그림이 Program memory의 구조를 설명하는 것으로 크게 두개의 영역으로 나누어지는 것을 볼 수 있다. 주소는 0에서부터 시작하여 ATmega88은 0x0FFF까지, ATmega168은 0x1FFF까지, ATmega328은 0x3FFF까지 가질 수 있다. 0x3FFF는 16KByte까지 밖에 주소를 할당할 수 없는데 ATmega328은 32KB의 Flash메모리를 가지고 있다. 그럼 나머지 16KB 공간은 어떻게 사용할 수 있을까? 정답은 주소 하나의 크기가 16비트(2바이트) 크기를 가지는 것이다. 즉, 바이트 주소가 아니라 워드주소가 되는것이다. Flash메모리에 들어가는 내용은 주로 프로그램을 실행하는 명령어들이며, AVR에서 사용하는 명령어는 대부분 2바이트 크기이며 몇몇 명령어는 4바이트 크기를 가진다. 그래서 굳이 바이트단위로 주소를 지정할 필요가 없다. 바이트 주소로 32KB를 구별하기 위해서는 15개의 어드레스라인이 필요하다. 그러나 워드주소를 사용하게되면 주소 하나에 2바이트를 할당하게 되므로 14개의 어드레스 라인으로 가능하다. 그래서 ATmega328의 프로그램 메모리의 주소는 0~0x3FFF으로 32KB 영역을 접근 가능하게 되는것이다.
위의 그림은 Data memory 구조를 설명하고 있다. Data memory는 네개의 영역으로 나누어 지는것을 볼 수 있다. 32개의 범용(General purpose)레지스터와 64개의 I/O레지스터 영역, 160개의 확장 I/O 레지스터 영역, 그리고 나머지 SRAM영역으로 나누어져 있다. 범용레지스터는 CPU내부에서 임시로 사용하기 위한 레지스터이며, 어셈블코드에서 볼수있는 r0 ~ r31이라고 표현되는 레지스터들이다. I/O 레지스터는 앞에서 이미 설명한 바로 그 I/O 레지스터이다. 그리고 SRAM도 이미 설명하였다.
앗! 뭔가 이상하다. 분명히 앞에서 AVR은 하바드 구조이므로 서로 다른 메모리 영역을 가지고 있고, 각각의 메모리 영역에 접근하기 위하여 별도의 명령어가 있다고 설명했는데, 여기에서는 갑자기 I/O레지스터와 SRAM메모리가 하나의 메모리 맵으로 표현되어 있다. 임베디드 시스템에 처음 입문할때 이러한 것들이 상당히 이해하기 어려운 부분이다.
임베디드 시스템을 이해하기 위해서는 먼저 메모리에 대한 내용을 이해하는것이 필요하다. 크게 봤을때 메모리에는 가상(Virtual) 메모리와 물리(Physical) 메모리가 있다. CPU 입장에서 봤을때 모든 메모리는 가상메모리일 뿐이다. 그리고 실제 하드웨어적으로 연결되어 주소가 할당된 물리 메모리가 있다. 일반적인 임베디드 시스템은 가상메모리와 물리메모리가 동일한 주소를 사용하지만 컴퓨터와 같은 고성능 CPU의 경우에는 가상메모리와 물리메모리는 전혀 별개의 주소값을 가진다. 특히 OS가 있는 경우에는 일반 사용자 공간(User space) 프로그램이 OS가 사용하는 커널메모리 영역(Kernel space)을 침범하지 못하게 하기 위해서 MMU라는 것을 별도의 하드웨어가 CPU 내부에 포함되어 있다. MMU는 CPU에서 사용하는 가상주소를 매핑된 물리주소로 변환해 주는 기능을 수행한다. 예를 들어 임베디드 리눅스 보드에 실장된 DDR메모리가 물리적으로는 0x40000000~0x5FFFFFFF까지의 물리 주소를 사용하더라도 리눅스 커널은 0xC0000000 주소의 가상 주소에서 동작된다. 만약 일반 프로그램에서 이 영역을 접근하려고 시도하면 MMU가 인터럽트를 발생시켜 리눅스 커널에게 어떤 프로그램이 잘못된 주소에 접근하고 있음을 알려줘 커널을 보호하도록 조치한다.
AVR의 경우 비록 MMU는 없지만 CPU가 바라볼 때 메모리 구조는 위의 그림과 같은 Data Memory 구조로 보인다.
CPU에서 0~0x1F 사이의 주소를 지정하면 범용레지스터에 접근 가능하게 된다. I/O 레지스터에 접근하기 위해서는 0x20~0xFF 사이의 주소를 사용하면 된다. 그리고 SRAM은 비록 물리 주소는 0~0x7FF의 값을 가지지만 CPU입장에서 SRAM에 접근하기 위해서는 0x100~0x8FF사이의 주소를 이용하면 되는 것이다.
이제 다시 앞에서 보았던 포인터를 사용한 프로그램의 어셈블 코드를 다시한번 분석해 보자.
DDRB레지스터에 3을 쓰려고 시도했던 코드는 다음과 같은 어셈블 코드로 변환되었다.
82: 80 93 04 00 sts 0x0004, r24
위의 코드가 실행되면 I/O레지스터 안에있는 DDRB에 값이 쓰여지는것이 아니라 범용레지스터 r4에 3이 쓰여지게 되는 것이다. 실제 그렇게 동작되는지 확인해 보도록 한다.
Atmel Studio의 메뉴에서 Project>Properties를 선택하거나 단축키 Alt+<F7>를 눌러서 아래와 같은 화면을 나타나게 한다.
위와 같이 왼쪽 화면에서 Tool을 선택하고 Seleted debugger/programmer에는 Simulator를 선택한다.
메뉴에서 Debug>Start Debugging and Break를 선택하거나 단축키 Alt+<F5>를 눌러서 프로그램의 디버깅 모드로 들어간다.
프로그램이 main()함수 시작부분에서 브레이크가 걸린채로 멈추어져 있는 상태로 보여진다. 디버깅을 어셈블코드로 수행하기 위하여 메뉴에서 Debug>Windows>Disassembly를 선택하거나 아래 그림처럼 메인 윈도우에서 Disassembly탭을 선택한다.
Disassembly 탭을 클릭하면 아래와 같이 C코드와 어셈블코드를 같이 볼 수 있다.
첫번째로 수행할 코드가 r24에 3을 쓰는 것이다. 단축키 <F10>을 눌러 한스텝 실행시켜 r24의 값이 3으로 변경되는지 확인한다.
그 다음에 실행하는 코드가 4번지에 r24안에 들어있는 값을 쓰는 것이다. 이미 앞에서 봤듯이 Data memory 주소가 0~0x1F는 범용 레지스터 공간이므로 r4에 3이 쓰여질 것이다.
<F10>을 눌러 한스텝 실행하면서 r4의 값이 변경되는지 확인한다.
예상했던대로 r4의 값이 3으로 변경되는 것을 볼 수 있다.
이쯤에서 I/O레지스터를 C에서 접근하기 위해서는 I/O 레지스터가 Data memory공간의 0x20부터 시작되므로 I/O 주소에 0x20을 더한 값을 사용하면 되지 않을까 상상해볼수 있을것이다.
실제로 동작되는지 소스코드를 아래와 같이 수정한 다음 프로그램을 보드에서 실행해 보자.
#define F_CPU 1000000
//#include <avr/io.h>
#include <util/delay.h>
#define IO_REG_OFFSET 0x20
#define PINB (IO_REG_OFFSET+3)
#define DDRB (IO_REG_OFFSET+4)
#define PORTB (IO_REG_OFFSET+5)
#define GREEN_LED 0x01
#define RED_LED 0x02
int main(void)
{
*(unsigned char*)DDRB = (GREEN_LED|RED_LED);
*(unsigned char*)PORTB= (GREEN_LED|RED_LED);
while (1)
{
_delay_ms(500);
*(unsigned char*)PINB = (GREEN_LED|RED_LED);
}
}
이제 LED가 1초 주기로 점멸되는 것을 볼 수 있을 것이다.
다시 led.lss파일을 열어 어셈블코드로 어떻게 변환되었는지 확인해 보자. 다시 "out" 명령어로 되어 있는것을 볼 수 있을것이다. 0x20~0x5F사이의 주소에 어떤 값을 쓰기 위한 C코드를 작성하면 컴파일러가 자동으로 "out" 명령어로 변환해 준다. "out" 명령어는 2바이트 크기를 가지고 명령이 수행되는데 1클럭이 소비된다. 이에 반해 "sts" 명령은 4바이트 크기로 되어 있으며 명령이 수행되기 위하여 2클럭이 소비되므로 I/O 레지스터에 접근하기 위해서는 "in", "out"명령어를 사용하는것이 코드 사이즈도 줄일수 있을뿐더러 실행 속도도 높일수 있는 장점이 있다.
데이터쉬트 Register summary 부분에서 위의 설명을 볼 수 있다.
4. When using the I/O specific commands IN and OUT, the I/O addresses 0x00 - 0x3F must be used. When addressing I/O Registers as data space using LD and ST instructions, 0x20 must be added to these addresses. The ATmega48A/PA/88A/PA/168A/PA/328/P is a complex microcontroller with more peripheral units than can be supported within the 64 location reserved in Opcode for the IN and OUT instructions. For the Extended I/O space from 0x60 - 0xFF in SRAM, only the ST/STS/STD and LD/LDS/LDD instructions can be used.
"in","out" 명령어를 사용하기 위해서는 0x00~0x3F사이의 주소만을 사용할 수 있고, "ld","st"명령어를 사용하여 I/O레지스터에 접근하기 위해서는 0x20을 더하여야 된다는 것이다. 확장 I/O레지스터의 경우 "in","out"명령어를 사용할 수 없고 "ld", "st"명령어를 사용하여야 된다는 설명이다.
위의 그림을 보면 I/O 레지스터는 두개의 주소가 사용됨을 알 수 있다. PORTB의 경우 "in","out" 명령을 사용할 때는 0x05번지를 사용하여야 하고, "ld","st"명령을 사용할 때는 0x25를 사용하여야 되는 것이다.
다음 글에서는 다른 코딩 기법을 이용하여 LED를 제어해 보도록 하겠다.