|
원문 : http://bsdforum.or.kr/viewtopic.php?t=1326&sid=9ff561deb35d0938d122b2ea431025c1
이번 강좌의 주제는 미니 어셈블리가 되겠습니다. 강좌 순서는 대략
- 준비 운동
- 기초 어셈블리
- 고급 어셈블리
- 실제 어셈블리: 부트로더 코드 분석
의 4회 분량입니다. 4회를 끝낸 후 사후강평을 통해 미진한 점을 보완하도록 하겠습니다.
일단 어셈블리 언어를 배워서 좋은 점은 어떤 것이 있을까요? 대략 다음과 같은 것이 있겠습니다.
- C로는 결코 할 수 없는 일을 할 수 있다
- 하드웨어 구조를 더 잘 이해할 수 있다
- 기존 알고 있던 C에 대한 지식의 깊이를 향상시킬 수 있다
어셈블리를 몰라도 커널 프로그래밍을 할 수는 있는데, 알면 매우 큰 도움이 됩니다. 커널 프로그래밍뿐만 아니라 일반 컴퓨터 상식도 늘릴 수 있는 좋은 기회이고요.
오늘은 첫회로 준비 운동을 하도록 하겠습니다. 전자실험 첫날에 하는 것이 저항 색깔 외우는 일입니다. 반드시 외우지 않아도 필요할 때 책 찾아보고 알아내면 되기는 되는데, 수많은 저항들이 이리저리 꼬여 있는 더미 속에서 원하는 저항을 뽑아내기가 쉬운 일이 아니거든요. 그래서 무엇보다 저항색을 외워 놓아야 실험을 빨리 진행할 수 있습니다. 오늘 과정은 대략 그런 것입니다.
숫자 0
컴퓨터에서 모든 수는 항상 0에서 시작합니다. 1,2,3,4,...가 아니고 0,1,2,3,...이 되겠습니다. 홀짝 구분으로는 0은 짝수(even)로 칩니다.
비트, 바이트, 워드
비트, 바이트, 워드 등은 모든 데이터의 기본 크기를 나타내는 말입니다. 계산법은 8비트가 모여서 1바이트가 됩니다(7비트나 9비트가 모여서 1바이트가 되는 아키텍쳐는 전부 도태되었으므로 이렇게 가정해도 안전합니다). 2바이트(=16비트)가 모여서 1워드가 됩니다. 2워드(=32비트)가 모여 1더블워드 혹은 1롱워드가 됩니다. 더블워드는 인텔/윈도쪽에서 부르는 이름이고, 비인텔/유닉스쪽에서는 롱워드 혹은 그냥 워드라고 부릅니다. 다소 헷갈리는 부분입니다만, 앞으로는 더블워드와 롱워드를 같은 의미로 쓰겠습니다. 2더블워드(=64비트)가 모여서 1쿼드워드가 됩니다.
어셈블리에서 알파벳 기호로 나타낼 때 바이트는 B, 워드는 W, 더블워드는 D, 롱워드는 L, 쿼드워드는 Q라고 씁니다. 비트는 무엇으로 나타내느냐 하는 궁금함이 생길 수도 있는데, 비트는 소문자 b라고 쓰는 경우도 있지만, 대개는 그냥 표현 안합니다. ㅡ.ㅡ 왜냐하면 컴퓨터에서 읽고 쓰는 최소 크기가 바이트이기 때문에 비트는 바이트속의 일부로만 표현해도 충분하기 때문입니다.
16진수
컴퓨터는 모든 정보를 2진수로 받아들여 연산한다는 것은 널리 알려진 상식이지요. 그런데 예를 들어 "한글"이라는 단어에서 "한"을 2진수로 표현하면 1011010101110001 같이 되어 사람이 보기에 불편합니다. 한 글자 표현하기도 이렇게 긴데 한 문장을 2진수로 표현하면 헉... 그래서 2진수를 끝에서부터 4비트 단위로 잘라 보기 쉽게 표현한 것이 16진수가 되겠습니다. 16진수는
0000 = 0x0 0001 = 0x1 0010 = 0x2 0011 = 0x3 0100 = 0x4 0101 = 0x5 0110 = 0x6 0111 = 0x7 1000 = 0x8 1001 = 0x9 1010 = 0xA 1011 = 0xB 1100 = 0xC 1101 = 0xD 1110 = 0xE 1111 = 0xF
이렇게 16개입니다. 16진수를 봤을 때 2진수의 비트 구조가 바로 생각나도록 좌항과 우항의 관계를 외우면 도움이 많이 됩니다. 가령 0xAB라면 2진수로는 0xA가 1010이고 0xB가 1011이므로 합쳐서 10101011입니다.
또한 16진수의 간단한 덧뺄셈에 익숙해질 필요가 있습니다. 예를 들어 0x1+0xF = 0x10입니다. 0x2+0xE=0x10이지요. 0x3+0xD=0x10, ... 이처럼 두 수를 더해서 0x10이 되는 관계를 외워놓으면 나중에 어셈블리를 짜거나 디스어셈블리를 분석할 때 도움이 많이 됩니다. 뺄셈은 0x10-0x1=0xF처럼 역순으로 생각하면 되겠습니다.
0x1, 0x10, 0x100, 0x1000, 0x10000, 0x100000, ... 처럼 최상위 비트만 1이고 뒤로 0이 따라붙는 수와 10진수와의 관계를 외워놓는 것도 유용합니다.
0x1 = 1 뒤에 0이 하나도 없기 때문에 2의 0승 = 1 0x10 = 1 뒤에 0이 4비트 있으므로 2의 4승 = 16 0x100 = 1 뒤에 0이 8비트 있으므로 2의 8승 = 256 0x1000 = 1 뒤에 0이 12비트 있으므로 2의 12승 = 4096 0x10000 = 2의 16승 = 65536 0x100000 = 2의 20승 = 1048576 ==> 1MB 0x1000000 = 2의 24승 = 16777216 0x10000000 = 2의 28승 = (외울 필요없음) 0x100000000 = 2의 32승 = 4294967296 ==> 4GB
위의 각 수에서 1을 빼면
0 = 0x1 - 1 0xF = 0x10 - 1 0xFF = 0x100 - 1 0xFFF = 0x1000 - 1 0xFFFF = 0x10000 - 1 0xFFFFF = 0x100000 - 1 0xFFFFFF = 0x1000000 - 1 0xFFFFFFF = 0x10000000 - 1 0xFFFFFFFF = 0x100000000 - 1
이 되겠습니다. 이 일련의 0xFF...F들이 의미하는 것은, 0xF는 4비트로 표현가능한 16진수중에 제일 큰 수, 0xFF는 8비트로 표현가능한 16진수중에 제일 큰 수, 0xFFF는 12비트, 0xFFFF는 16비트, ... 등이 되겠습니다.
디폴트 워드 크기
우리가 흔히 쓰는 인텔 아키텍쳐는 기본 워드 크기가 더블워드, 즉 32비트입니다. 요즘 나온 AMD 아키텍쳐는 기본 워드 크기가 쿼드워드, 즉 64비트이지요. 가령 11이라는 10진수는 16진수로 0xB입니다. 인텔 머신에서 0xB가 메모리에 저장될 때, 0xB가 4비트만 점유한다고 최소 단위인 1바이트로 저장되는 것이 아니라, 실제로는 0x0000000B처럼 32비트 더블워드 크기로 확장되어 저장됩니다. 64비트 쿼드워드로 저장된다면 0x000000000000000B가 되겠습니다. 옛날에 쓰던 8086같은 16비트 CPU라면 0x000B로 저장될 것입니다. 이렇게 CPU에서 지정한 기본 워드 크기로 저장할 때 일반적으로 가장 높은 성능을 기대할 수 있습니다.
음수를 표현하는 방법
위에서 보면 양수는 16진수로 쉽게 표현이 가능합니다. 그런데 음수는 어떻게 나타낼까요. 10진수에서 11이라는 양수를 -11로 바꾸려면 앞에 마이너스를 붙여주기만 하면 됩니다. 이것이 컴퓨터에서 이루어지는 과정은 다음과 같습니다. 11는 16진수로 0xB가 되고, 이것을 32비트 크기로 확장을 하면 0x0000000B가 됩니다. 이 0x0000000B란 값을 각 비트에 대해 반전을 합니다. 그러면 0xFFFFFFF4가 됩니다. 여기에 1을 더하면 최종 결과인 0xFFFFFFF5가 됩니다. -11을 16진수로 표현하면 0xFFFFFFF5가 되는 것입니다. 마찬가지 방법을 64비트 수치로 환산하면 -11은 0xFFFFFFFFFFFFFFF5가 됩니다.
이번에는 -11을 양수로 바꾸는 방법을 생각해 봅시다. -11을 양수로 바꾸려면 -11앞에 마이너스를 한번 더 붙여주면 됩니다. 마이너스를 붙여주는 과정은 위와 마찬가지로 모든 비트를 반전하고 1을 더합니다. 0xFFFFFFF5의 비트를 반전하면 0x0000000A가 됩니다. 여기에 1을 더하면 0x0000000B가 되지요. 위에서 구한 11의 16진수 표현과 동일한 결과를 얻었습니다.
이 과정은 2의 보수라고 해서 컴퓨터 구조 개론 강의 첫달에 빠짐없이 나오는 설명입니다. 양수 음수간을 변환하는 원리도 중요하지만 양수와 음수의 본질적인 차이 또한 중요합니다. 양수는 숫자 앞이 0으로 채워지는데 비해(0xB -> 0x0000000B) 음수는 1로 채워집니다(0xFFFFFFF5). 4비트가 전부 1로 채워지다보니 숫자 윗부분들은 0xFF..처럼 나타나는 것이지요. 그래서 어떤 수가 양수인지 음수인지 보려면 최상위 비트가 0인지 1인지만 보면 됩니다. 1이면 음수입니다. 이 1을 부호 비트(sign bit)라고 하는데, 다음 회에서 다루겠지만 계산 결과로 싸인 플래그 SF가 켜지면(즉, SF=1) 결과가 음수라는 뜻입니다. 사실 이것이 요긴하게 쓰이는 경우는 프로그램을 기계어 수준에서 분석할 때인데, 특히 디스어셈블러가 엉터리 디스어셈블 결과를 보여줄 때 이 지식이 꼭 필요합니다(약간 황당한 용도지만 실제로 디스어셈블 결과가 틀리는 경우가 많이 있습니다). 자세한 내용은 다른 기회에 다루도록 하겠습니다.
메모리 공간
예를 들어 어느 컴퓨터에 메인 메모리가 1MB 있다고 한다면, 이것은 바이트 크기의 메모리가 위에서 나왔던 0x100000(=1048576)개 만큼 일렬로 늘어서있다는 뜻입니다. 이때 시작주소는 항상 0번지부터이고, 끝주소는 0xFFFFFF번지가 되겠습니다.
메모리 정렬(alignment)
읽고자 하는 메모리 번지의 시작주소와 읽고자 하는 데이터의 기본 크기가 나누어 떨어지면 정렬되었다(aligned)라고 말합니다. 나누어 떨어지지 않으면 정렬되지 않았다(unaligned)라고 말하고요. 예를 들어 메모리의 0x1000번지에서 2바이트(=1워드)를 읽으려고 한다면 이것은 정렬된 읽기입니다. 0x1000이 2로 나누어 떨어지거든요. 읽기가 정렬된 경우 CPU는 0x1000번지와 0x1001번지에 있는 값을 한번에 연속으로 읽어서 프로그램에 되돌려 줍니다. 반면 0x1001번지에서 1워드를 읽으려고 한다면 이것은 정렬되지 않은 읽기입니다. 이때 CPU는 0x1000번지에서 두 바이트를 읽어 한 바이트만 취하고, 0x1002번지에서 두 바이트를 읽은 뒤 한 바이트만 취해서 두 값을 조합해서 되돌려 줍니다. 당연히 정렬된 읽기에 비해 속도가 떨어집니다. 읽기뿐만 아니라 쓰는 경우에도 정렬 문제가 동일하게 적용됩니다.
어떤 아키텍쳐들은 비정렬 읽기 쓰기를 아예 에러로 간주하는 경우도 있습니다. 이 경우 0x1001번지로부터 2바이트를 읽어내려고 하는 경우 프로그램이 죽어 버립니다. 물론 죽는 프로그램이 커널이면 낭패겠지요. 인텔은 그점에 있어선 관대한 편이라 대개는 그냥 속도만 떨어뜨려 줍니다(인텔에서도 반드시 정렬 조건이 충족되어야 하는 경우가 일부 있습니다).
데이터 기본 크기가 1더블워드, 즉 4바이트 단위라면 어떨까요? 0x1000번지에서 4바이트를 읽는 것은 0x1000을 4로 나누면 나누어 떨어지기 때문에 정렬된 것이지만, 0x1002번지에서 4바이트를 읽는 것은 정렬되지 않은 읽기입니다. 마찬가지로 메모리를 쿼드워드 단위로 읽을 때에는 메모리 번지가 8의 배수여야 가장 높은 성능을 발휘할 수 있겠지요.
리틀 엔디언과 빅 엔디언
0x12345678라는 32비트 숫자를 메모리에 기록할 때 어느 순서로 적을 것이냐 방법이 엔디언입니다. 리틀 엔디언은 이 숫자를 78 56 34 12 순서로 기록합니다. 즉 0번지에 78, 1번지에 56, 2번지에 34, 3번지에 12가 들어갑니다. 반면 빅 엔디언은 12 34 56 78처럼 숫자가 꼭 문자열처럼 메모리에 들어갑니다. 언뜻 보면 빅 엔디언이 편리한 것 같은데(바이너리 파일을 덤프해 보면 숫자가 그대로 나오므로), 리틀 엔디언도 익숙해지면 별로 불편하지 않습니다. 오히려 낮은 주소에 낮은 자리 숫자가 들어간다는 면에서는 리틀 엔디언이 더 합리적이라고 볼 수도 있겠고요. 인텔은 리틀 엔디언만 쓰므로 우리는 리틀 엔디언쪽에 조금 더 익숙해져야겠습니다.
32비트 숫자뿐만 아니라 16비트, 64비트 등 8비트를 넘는 숫자를 메모리에 기록할 때에는 항상 엔디언 문제가 발생합니다. 가령 0x1234 같은 16비트 숫자는 리틀 엔디언에서는 34 12로 기록되고, 빅 엔디언에서는 12 34로 기록이 됩니다. 0x123456789ABCDEF0이라는 64비트 숫자는 리틀 엔디언에서는 F0 DE BC 9A 78 56 34 12로 나타나고, 빅 엔디언에서는 12 34 56 78 9A BC DE F0로 나타납니다.
그런데 왜 이렇게 같은 숫자를 리틀 엔디언이나 빅 엔디언처럼 다르게 저장하느냐? 그냥 업체별로 취향의 차이라고 합니다. 어떤 프로세서들은 두가지 모드를 전부 지원하기도 하는데, 성능차는 없는 것으로 알고 있습니다. 엔디언이란 말의 유래는 걸리버 여행기에서 소인국 사람들이 달걀의 뾰족하고 작은 부분을 먼저 깨먹을 것이냐, 둥글고 큰 부분을 먼저 깨먹을 것이냐를 놓고 논쟁한데서 나왔다고 합니다.
이상으로 어셈블리 프로그래밍을 학습하기 전에 꼭 알고 있으면 좋은 것들을 대충 요약해 보았습니다. 다음회에서는 실제로 CPU 레지스터를 설명하고 어셈블리 코드를 짜보도록 하겠습니다.
|