(C#) 3. 비트 연산
XOR을 두 번 걸면 원래 값으로 돌아온다는 것과 시프트를 직접 확인했다. 시프트 횟수가 32로 나머지 처리된다는 함정, 음수에서 >>와 나눗셈이 다른 이유, 비트 플래그로 상태를 묶는 법까지 정리했다.
비트를 직접 만지는 이유
1편에서 타입이 결국 비트 묶음이라는 걸 봤다. 그 비트를 통째로 다루면 값 하나에 여러 정보를 담거나, 값을 뒤섞어 원래 형태를 감출 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
namespace BitOperation
{
class Program
{
static void Main(string[] args)
{
int num = 1;
int id = 123;
int key = 401;
int a = id ^ key;
int b = a ^ key;
// << >> &(and) !(not) ^(xor) ~(not)
num = num << 3;
Console.WriteLine(a);
Console.WriteLine(b);
}
}
}
찍어보면 이렇게 나온다.
1
2
490
123
a는 알아볼 수 없는 값이 됐는데 b는 원래 id로 돌아왔다.
XOR을 두 번 걸면 제자리로 온다
XOR은 두 비트가 다르면 1, 같으면 0이다. 그래서 같은 값으로 두 번 XOR 하면 원래대로 돌아온다.
1
2
3
4
5
6
7
123 = 0001111011
401 = 0110010001
XOR = 0111101010 = 490
490 = 0111101010
401 = 0110010001
XOR = 0001111011 = 123
키를 하나 정해두고 저장할 때 XOR, 읽을 때 다시 XOR 하면 메모리를 그대로 들여다봐도 값이 안 보인다. 게임에서 재화나 체력 값을 메모리 검색 도구로 찾아 고치는 걸 조금 성가시게 만드는 용도로 쓰인다.
다만 이건 암호가 아니다. 키가 하나 고정이면 값 두 개만 비교해도 키가 드러난다. 원래 값을 아는 항목이 하나만 있어도 평문 ^ 암호문 = 키로 바로 계산된다. 난독화 정도로만 봐야 한다.
주석 하나를 고쳤다
원 코드 주석에 !(not)과 ~(not)이 둘 다 적혀 있는데, 둘은 다르다.
!는 논리 NOT이다.bool에만 쓴다.!isAlive~는 비트 NOT이다. 정수의 모든 비트를 뒤집는다
1
2
int x = 1;
Console.WriteLine(~x); // -2
1은 0b...0001이고 뒤집으면 0b...1110인데, 2의 보수로 읽으면 −2다. 어떤 정수든 ~x == -x - 1이 된다.
2편에서 본 &&와 &의 관계와 같은 구조다. 논리 연산자와 비트 연산자가 짝을 이룬다.
| 논리 (bool) | 비트 (정수) |
|---|---|
&& | & |
\|\| | \| |
! | ~ |
| — | ^ |
^는 bool에도 쓸 수 있는데 “둘 중 하나만 참”이라는 뜻이 된다.
시프트
num << 3은 비트를 왼쪽으로 3칸 민다. 1이 8이 된다. 왼쪽으로 한 칸이 2배이므로 3칸이면 8배다.
1
2
Console.WriteLine(1 << 3); // 8
Console.WriteLine(1 << 10); // 1024
1 << 10이 1024인 게 편해서 KB 계산에 자주 쓴다.
시프트 횟수가 32로 나머지 처리된다
이게 직접 해보고 놀랐던 부분이다.
1
2
3
int x = 1;
Console.WriteLine(x << 32); // 0 이 아니라 1
Console.WriteLine(x << 33); // 2
int를 32칸 밀면 다 밀려나서 0이 될 것 같은데 아니다. C#은 시프트 횟수를 32로 나눈 나머지로 처리한다. 32 % 32 == 0이라 아무것도 안 밀린 것과 같다. long이면 64로 나눈 나머지다.
변수로 시프트 횟수를 계산해 넘길 때 이게 문제가 된다. 값이 32를 넘어가면 0이 나오길 기대했는데 엉뚱한 값이 나온다. 범위를 직접 검사해야 한다.
음수의 » 는 나눗셈과 다르다
>>를 2로 나누기로 알고 있으면 음수에서 어긋난다.
1
2
Console.WriteLine(-7 >> 1); // -4
Console.WriteLine(-7 / 2); // -3
>>는 부호 비트를 유지하면서 미는 산술 시프트라 아래로 내림이 된다. 나눗셈은 0 방향으로 버림이다. 양수에서는 둘이 같아서 차이가 안 보이다가 음수에서 갈린다.
부호 없는 시프트가 필요하면 uint로 캐스팅하거나, C# 11부터는 >>>를 쓴다.
속도 때문에 x / 2를 x >> 1로 바꾸는 건 요즘은 의미가 없다. 컴파일러가 알아서 한다. 오히려 위 차이 때문에 버그만 생긴다.
비트 플래그
비트 연산이 실제로 제일 쓸모 있는 곳이다. 상태 여러 개를 정수 하나에 담는다.
1
2
3
4
5
6
7
8
9
[Flags]
enum BuffType
{
None = 0,
Poison = 1 << 0, // 1
Slow = 1 << 1, // 2
Stun = 1 << 2, // 4
Shield = 1 << 3, // 8
}
값을 2의 거듭제곱으로 두는 게 핵심이다. 겹치는 비트가 없어야 섞어도 구분된다.
1
2
3
4
5
BuffType buffs = BuffType.Poison | BuffType.Stun; // 켜기
bool hasStun = (buffs & BuffType.Stun) != 0; // 확인
buffs &= ~BuffType.Poison; // 끄기
buffs ^= BuffType.Shield; // 토글
- 켜기는
| - 확인은
&후 0이 아닌지 - 끄기는
& ~플래그 - 토글은
^
끄기에서 ~가 쓰이는 게 위에서 본 비트 NOT이다. 해당 비트만 0이고 나머지는 1인 마스크를 만들어 AND 하면 그 비트만 지워진다.
[Flags]를 붙이면 ToString()이 Poison, Stun처럼 조합을 풀어서 보여준다. 디버깅할 때 값이 5로 나오는 것과 이름으로 나오는 것은 차이가 크다.
확인은 HasFlag로도 된다. 읽기는 좋은데 박싱이 일어나서 매 프레임 도는 코드에서는 비트 연산이 낫다.
1
if (buffs.HasFlag(BuffType.Stun)) { }
정리하면
- 같은 값으로 XOR을 두 번 하면 원래 값으로 돌아온다. 난독화용이지 암호가 아니다
!는 논리 NOT(bool),~는 비트 NOT(정수)이다. 서로 다른 연산자다- 시프트 횟수는
int에서 32,long에서 64로 나머지 처리된다.x << 32는 0이 아니다 - 음수에서
>>는 내림,/는 0 방향 버림이라 결과가 다르다 - 상태 여러 개는 2의 거듭제곱 값을 가진
[Flags]enum으로 묶는다. 켜기|, 끄기& ~, 토글^