포스트

(C#) 3. 비트 연산

XOR을 두 번 걸면 원래 값으로 돌아온다는 것과 시프트를 직접 확인했다. 시프트 횟수가 32로 나머지 처리된다는 함정, 음수에서 >>와 나눗셈이 다른 이유, 비트 플래그로 상태를 묶는 법까지 정리했다.

(C#) 3. 비트 연산

비트를 직접 만지는 이유

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

10b...0001이고 뒤집으면 0b...1110인데, 2의 보수로 읽으면 −2다. 어떤 정수든 ~x == -x - 1이 된다.

2편에서 본 &&&의 관계와 같은 구조다. 논리 연산자와 비트 연산자가 짝을 이룬다.

논리 (bool)비트 (정수)
&&&
\|\|\|
!~
^

^bool에도 쓸 수 있는데 “둘 중 하나만 참”이라는 뜻이 된다.

시프트

num << 3은 비트를 왼쪽으로 3칸 민다. 18이 된다. 왼쪽으로 한 칸이 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 / 2x >> 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으로 묶는다. 켜기 |, 끄기 & ~, 토글 ^
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.