Chapter 20. Low-Level Programming

지환·2022년 2월 27일
0

이전까지 우리는 C의 high level에 대해 봤다.

system programs(compiler, OS), encryptic programs, graphics programs 등을 작성할때는 bit manipulation같은 low-level operation이 필요하다.

이 chapter에 나오는 내용은 data가 메모리에 어떻게 저장됐는지에 대한 지식을 바탕으로 하는데, 그 저장되는 방식은 기계마다 컴파일러마다 다르다.
그래서 이 기술에 의존하면 프로그램을 non potrable하게 만들 수 있으므로 최대한 자제해서 사용하는게 좋다.(필요한 부분에만..)
정말 필요하다면 특정 module에 제한해서 사용하고 document해둘 필요가 있다.


20.1 Bitwise Operators

integer data에 적용되는 6개의 Bitwise operators가 있다.(bit level)

Bitwise Shift Operators

<< : left shift
>> : right shift

char를 포함한 아무 interger type이 operands이다.
(모든 operands에 integer promotion이 적용된다.)
(arithmetic operator보다 precedence가 낮다.)

i << j를 하게 되면, i의 bits를 왼쪽으로 j만큼 이동시킨다.
왼쪽 bits는 사라지고, 오른쪽 bits엔 0이 추가된다.

i >> j를 하게 되면, i의 bits를 오른쪽으로 j만큼 이동시킨다.
여기서 만약 i가 unsigned이거나 nonnegative라면 왼쪽에 0이 추가되고,
i가 negative라면 왼쪽에 뭐가 추가될지는 implementation defined.
(0을 추가하기도하고, sign bit를 보존하기 위해 이를 추가하기도 한다.)

추가로, 이 operators는 operands를 변경하지 않는다.
변경하고 싶다면,
<<=,>>= operators를 사용해야 한다.

Portability tip
it's best to perform shifts only on unsigned numbers

Bitwise Complement, And, Exclusive Or, and Inclusive Or

~ : bitwise complement
& : bitwise and
^ : bitwise exclusive or
| : bitwise inclusive or

precendence는 바로 위에 적힌 순서와 같다. '~'가 highest

~ 는 unary이고, operand에 the integer promotion
&, ^, |는 binary이고, operands에 the usual arithmetic conversion

위 네가지 operator 모두, 모든 bits에 Boolean operation을 한다.
~ : complement(0자리에는 1, 1자리에는 0)
& : 두 operands의 자리에 맞는 bits끼리 Boolean and operation을 한다.
^ : 두 operands의 자리에 맞는 bits끼리 Boolean or operation을 한다. 두 bits가 1이라면 0을 도출한다.
| : 두 operands의 자리에 맞는 bits끼리 Boolean or operation을 한다. 두 bits가 1이라면 1을 도출한다.
(내가 알던 그 or는 inclusive네)

~ operator는 low-level 프로그램도 더 portable하게 만들 수 있다.
예를들어 모든 bits가 1이 되도록 하려면 ~0를 하면되고, 마지막 5개 비트 빼고 bits가 모두 1이 되게 하려면 ~0x1f를 하면 된다.

얘네도 side effect를 주고 싶으면,
&=, ^=, |= 를 사용하면 된다.(~는 없네)

Using the Bitwise Operators to Access Bits

single bits를 다루는 법에 대해 알아보자.

bits의 위치는 가장 오른쪽 bit(least significant bit)를 0으로 가정하겠다.

Setting a bit
i |= 1 << j; : sets bit j

Clearing a bit
i &= ~(1 << j); : clears bit j

Testing a bit
if(i & 1 << j) ... : tests bit j
(j위치 bit 빼고는 다 0이니까 test가 되는거임)

예시)

#define BLUE 1		2^0
#define GREEN 2		2^1
#define RED 4		2^2

이렇게 bits 작업을 수월하게 하기 위해 이름을 지어둔다.
위 예시는 각 bits가 색을 나타내는 경우를 표현했다.
i |= BLUE; : sets BLUE bit

i |= BLUE | GREEN; : sets BLUE and GREEN bits
i &= ~(BLUE | GREEN); : clears BLUE and GREEN bits
if (i & (BLUE | GREEN)) ... : tests whether the BLUE bit or the GREEN bit is set

Using the Bitwise Operators to Access Bit-Fields

위에서 single bits에 대해 다뤘다면,
여기선 여러 연속적 bits의 그룹인 bit-field를 다루는 법에 대해 알아보겠다.

Modifying a bit-field (특정 구간 수정)
bitwise and 를 통해 먼저 수정할 부분의 bits를 clear하고,
bitwise inclusive or 을 통해 해당 bits를 setting해주는 순서로 수정을 진행한다.
&로 clear하지 않고 |만 쓸 경우 제대로 작동하지 않을 수 있다.

예시)
4-6 위치에 (bits 오른쪽부터 0부터 시작임) 101이라는 binary value를 저장하고 싶다면,
i = i & ~0x0070 | 0x0050;
이라고 하면 된다. 앞에 & 연산(과 ~연산)이 4-6 bits를 0으로 clear해버리고,
뒤에 | 연산으로 원하는 값을 저장하는 것이다.

좀 더 generalize시키면,
4-6 위치의 bits를 수정하는 법은(j가 4-6에 저장될 값을 가지고 있다면)
i = i & ~0x0070 | j << 4;
가 된다.

Retrieving a bit-field (특정 구간 추출)
& 연산을 이용하여 특정 구간의 bits를 가져올 수 있다.

0-2구간의 bits를 가져오는건 쉽다.
j = i & 0x0007; : retrieves bits 0-2

하지만 오른쪽 끝에 bits를 가져오는게 아니라 중간꺼를 가져오려면, 우선 해당 bits 구간을 오른쪽 끝으로 shift 한 뒤에 & operator를 이용해 extract한다.
j = (i >> 4) & 0x0007; : retrieves bits 4-6

XOR Encryption

data를 암호화하는 가장 쉬운 방법 중 하나로 exclusive-or (XOR) 을 사용하는 방법이 있다.

특정 key character를 정한 뒤,
어떤 문자를 encrypt하고 싶다면 해당 key와 XOR을 진행하고,
이를 다시 decrypy하고싶다면 한번 더 해당 key와 XOR을 진행하면 된다.

ex) z& key로 XOR encrypt하면 \가 나오고, 다시 한번 더 & key를 사용해서 decrypt하면 z가 나온다.

inputkeyoutput
000
101
011
110
이 표를 잘 보면...
첫 두줄은 결국 encrypt해도 똑같고(반대로 decrypt해도 같은 결과),
마지막 두줄일 경우(key bit가 1일 경우) 차이가 생긴다.
근데 encrypt해도 key로 다시 decrypt하면 원래대로 돌아오는 것을 확인할 수 있다.

encrypt 프로그램 만들 시 주의할 점)
XOR encrypt의 결과가 invisible control characters인 경우 몇몇 OS에선 문제가 발생할 수 있다.
그래서 isprint function으로 해당 character가 printing character인지 확인해야 한다.
(encrypt한게 printing되는게 아니라면 원래 character를 적는 방식으로 가는게 좋음)


20.2 Bit-Fields in Structures

이전에 소개한 기술들은 bit-fields에서 작동은하지만, 사용하기 까다롭고 잠재적으로 혼란을 줄 수 있다.
그래서 C에선 그 대안으로 members가 bit-fields를 나타내는 structure를 선언하도록 허용한다.

MS-DOS OS를 예로 들겠다. DOS에선 file의 date를 저장하는데있어,
5 bits를 day에, 4 bits를 month에, 7 bits를 year를 저장하는데 사용한다.
(작은 숫자라서 일반 integer를 쓰기엔 낭비임)

이를 C structure를 이용해 다음과 같이 나타낼 수 있다.

struct file_date {
	unsigned int day: 5;
    unsigned int month: 4;
    unsigned int year: 7;
};

//or

struct file_date {
	unsigned int day: 5, month: 4, year: 7;
};

bit-field의 type은 int, unsigned int, signed int, _Bool 중에 하나여야 한다.
(몇몇 compiler는 high order bit를 sign bit로 보지만, 그러지 않은 애들도 있어서 int를 사용하기엔 애매하다.)

위에 선언한 structure의 member는 앞에서 배운대로 똑같이 사용할 수 있다.
제한사항이 하나 있는데, bit-fields는 일반적인 주소를 가지고 있지 않아서 address operator(&)를 적용하는게 불가능하다.
그래서 scanf에서 직접 입력받을 수 없으며, 다른 변수를 통해 입력받고 그걸 assign하는건 가능하다.

portability tip
Declare all bit-fields to be either unsigned int or signed int (or _Bool)

DOS에선 year의 시작을 1980으로 보기 때문에 year에 8을 저장하면 1988..

structure 선언 안하고 bitwise operator로 같은 효과를 내게 할 수도 있지만,
대게는 readable program을 만드는게 조금 더 빠르게 하는것보다 더 중요하다.

How Bit-Fields Are Stored

bit-field members를 가지는 structure의 선언이 compiler에서 어떻게 진행되는지 살펴보자.

"storage unit"이라는 개념을 이용해 bit-fields를 다룬다.
storage unit의 크기는 implementation-defined
(대게 8bits or 16bits or 32bits)
structure가 선언될때 storage unit에 bit-field가 하나씩 들어간다.
마지막 들어갈 놈이 들어갈 자리가 storage unit에 충분하지 않을 때, (1)이 공간을 남겨두고 다음 storage unit부터 저장하거나, (2)무시하고 쌓아서 다음 storage unit까지 사용하는 두가지 경우가 있다.
(어떻게 할지는 implementation-defined)
bit-fields가 들어가는 순서('왼->오'일지 '오->왼'일지)도 implementation-defined(일반 structure members는 그냥 무조건 메모리에 순서대로 저장됨)

C는 bit-field의 이름을 빼먹는걸 허용한다.
이런 unnamed bit-fields를 덧붙임으로써 다른 bit-fields가 제자리에 있게 해준다.

structure file_time {
	unsigned int : 5;
    unsigned int minutes: 6;
    unsigned int hours: 5;
};

이렇게 선언하면 나머지 bit-fields도 마치 제일 위에 field가 있는 것처럼 메모리에 정렬된다.

unnamed bit fields의 길이를 0으로 명시할 수도 있는데, 이렇게하면 compiler는 다음으로 오는 bit-field가 다음 storage unit의 시작이 되도록 한다.

structure file_time {
	unsigned int a: 4;
    unsigned int : 0;
    unsigned int b: 8;
};

이렇게 선언하면 storage unit에 a를 저장하고 b를 저장할 공간이 남아도, 그 사이를 skip하고 b를 다음 storage unit에 저장한다.


20.3 Other Low-Level Techniques

Defining Machine-Dependent Types

char type은 definition에 의해 "1 Byte"를 차지하므로, 우리는 character를 byte로 사용할 수 있다.
typedef unsigned char BYTE;

machine에 따라 추가적인 type을 정의해야하는데,
x86 architecture의 경우 16-bit words를 사용한다. 따라서 다음과 같이 추가로 정의한다.
typedef unsigned short WORD;

Using Unions to Provide Multiple Views of Data

union을 이용해 block of memery를 2가지 이상의 방법으로 볼 수 있다.(multiple view)

(예시 1)
위에 선언했던 file_date structure를 보면, 16bits로 이루어져있다.
그러면 unsigned short를 16bits라고 했을때, 우리는 unsigned shortfile_date structure라고 볼 수 있고 아니면 반대로 저 structure를 unsigned short로 볼 수도 있다.
다음 union은 그 둘을 서로 쉽게 변환할 수 있게 해준다.

union int_date {
	unsigned short i;
    struct file_date fd;
};

이 union을 이용해 2bytes짜리 data를 가져와서,
year, month, day를 뽑아낼 수 있다.(반대도 가능)

void print_date(unsigned short n)
{
	union int_date u;
    
    u.i = n;
    printf("%d/%d/%d\n", u.fd.month,u.fd.day, u.fd.year + 1980);
}

(예시2)
register를 이용할때 특히 유용하다.
x86 processor의 경우, AX, BX, CX, DX의 4가지 16-bit registers가 있다.
register는 작은 unit들로 나뉘는데, 각 register는 8bit짜리 두개로 나뉜다. AXAHAL로 나뉜다.
(당연히 둘은 서로에게 영향을 미침. AX가 바뀌면 AH와 AL도 바뀌고, 그 반대도 성립)

이를 구현하기 위해 2개의 structures를 만든다.
하나는 16bits register와 대응하는 멤버를 가지고, 하나는 8bits register와 대응하는 멤버를 가진다.
그리고 그 둘을 포하마하는 하나의 union을 만든다.

union {
	struct {
    	WORD ax, bx, cx, dx;
    } word;
    struct {
    	BYTE al, ah, bl, bh, cl, ch, dl, dh;
    } byte;
} regs;

word의 members는 byte의 members를 overlay한다.
ax는 al, ah와 같은 메모리를 차지한다.
(왜냐하면 structure members는 적힌 순서대로 memory에 저장됨)

//x86 processor라고 가정

regs.byte.ah = 0x12;
regs.byte.al = 0x34;
printf("AX: %hx\n", regs,word,ax);

result => AX: 1234

endian
data item이 1 byte보다 클 때 endianness에 따라 data가 저장된다.
순서대로 저장되는(leftmost byte가 먼저 저장되는) big-endian방식이 있고,
그 반대로 저장되는(leftmost byte가 나중에 저장되는) little-endian이 있다.
endianness는 CPU에 따라 나뉜다.(data를 어떻게 처리하느냐에 따라 다름)

다시한번 말하지만, 이는 한 data가 1byte보다 클때 적용되는 경우다.
little-endian이라고 해서 structure member들이 반대로 저장되고 하진 않는다.
대신 그 하나의 member가 1byte이상이면 member 자체 안에서 반대로 저장될 순 있다.

대부분 cpu가 bit ordering에선 big endia 사용, byte ordering에서 나뉘는 것.
여기서도 보면 byte의 ordering을 얘기하는 것이다.
그리고 위에서 말했듯, 구조체 멤버 순서가 아예 바뀌는게 아니라, 특정 data 안에서 바이트 순서가 바뀌는 것.
멤버순서가 아예 바껴버리면 뭐 code도 저장될때 아예 역순으로 return 문부터 나오게??

위에 AX를 print한 예제를 다시 보자.
우선 위에 union이 정의된걸 보면, 각 WORD는 2byte로 이루어져있다.
x86은 little-endian이므로 (각 멤버들은 순서대로 배열되겠지만) 멤버 안의 데이터는 byte 단위로 거꾸로 배치돼있다.
즉, 아래 structure의 al이 순서상으론 앞에 있지만, little-endian order로 저장됐다고 보기때문에 print했을 때 저렇게 거꾸로 된 결과가 나오는 것이다.

그럼 al과 ah도 0x21, 0x43으로 적어야되는거아닌가? 는 아니다..
왜냐하면 byte 단위로 저장되니까 얘넨 정상적으로 적어도 상관없다.

보통의 경우 byte ordering을 신경쓸 일은 없지만, 이렇게 low level에서 다룰 경우 알고 있어야 한다.

Using Pointers as Addresses

pointer가 memory 주소를 저장하는건 알테고,
address는 주로 integer(or long integer)와 같은 bits수를 가진다.

특정 주소값을 pointer에 넣고싶으면, integer를 pointer로 cast해버리면 된다.
BYTE* p = (BYTE *) 0x1000;

Viewing Memory Loacations
대부분 CPU는 program을 "protected mode"에서 실행한다.
실행하는 특정 memory 이상은 접근하지 못하도록 막아서 문제를 일으키지 못하도록 하는 것이다.

p.521
보면 메모리 주소 입력하고 범위 설정하면 해당 메모리 주소에 저장된 값들 보여주는 프로그램 만듦.
ELF 관련해서 조금 얘기하고, 실제 저장된 값이 little-endian에 의해 역순으로 저장된거 확인해볼 수 있음.

The volatile Type Qualifier

몇 computer에선 특정 memory location이 volatile이다.
그런 location에 저장된 값은 프로그램이 새로운 값을 저장하지 않더라도 실행중에 매번 바뀔 수 있다.
어떤 memory location은 input device로부터 나온 데이터를 바로 직접 저장하는데, 이게 volatile memory location의 예시이다.

volatile type quilifier는 compiler에게 특정 data가 프로그램에서 volatile임을 알려준다.
주로 volatile memory location을 가리키는 pointer variable을 선언할때 사용된다.
volatile BYTE *p;
(p will point to a volatile byte)

register(storage class)나 restrict(type qualifier)가 compiler에게 최적화를 요청하는 것이었다면, volatile은 오히려 최적화를 못하게 막는다.

사용 예시
p가 user로부터 입력받은 최신 character 값을 저장하는 location을 가리킨다고 가정.

while (buffer not full) {
	wait for input;
    buffer[i] = *p;
    if (buffer[i++] == '\n')
    	break;
}

정교한 compiler는 위 code에서 p*p가 바뀌지 않는다는걸 알아채고 아래와 같이 최적화를 진행한다.
(code만 봤을때 p는 전혀 수정되지 않으니, 굳이 반복문에 등장시키지 않는거임)

store *p in a register;
while (buffer not full) {
	wait for input;
    buffer[i] = value stored in register;
    if (buffer[i++] == '\n')
    	break;
}

하지만 compiler의 해석과 다르게, p가 가리키는 곳은 user가 입력할때마다 바뀌는 volatile memory이다.
위와 같이 최적화를 해버리면, buffer에 같은 character로 꽉차게 된다.
따라서 *pvolatile로 선어나게 되면 아래와 같은 최적화를 막을 수 있다.
컴파일러에게 *p를 사용하려면 매번 memory에서 값을 가져와야 한다고 알려주는 것이다.

추가 예시)
https://dojang.io/mod/page/view.php?id=749

이게 왜 low level 에서만 쓰이는진 아직 잘 모르겠네..

Q&A

&|도 short circuit 기능을 수행하나?
No. 그래서 i && j++의 경우 i가 false면 j가 증가하지 않을 수도 있지만, i & j++는 무조건 side effect로 j를 증가시킨다.

"big-endian"과 "little-endian"은 어디서 유래했나?
걸리버 여행기에서 두 나라가 삶을 달걀을 깨는 방향을 두고 다툰 것에서 유래했다.
크고 둥근쪽(big-endian)으로 깰지.. 작고 뾰족한쪽(little-endian)으로 깰지..

0개의 댓글