Programming Language/C++

Ch04. 연산자 - 04. 비트 연산자

hustle_D 2025. 3. 29. 18:20
반응형

웹 개발을 하면서 사실 비트연산자를 실무에서 사용하는 경우는 아예 없었다.

그런데 요즘 보면 개발 되어 있는 코드에서(물론 비트 연산자가 사용되어 있는지 판단 자체도 못하겠슴 c++로 되어 있는건....아직 숲을 보는 능력은 없어서..) 남이 작성되어 있는 코드를 읽기 위해서는 배워야 할 필요성이 있다고 느낀다.

특히 C++이나 C같이 메모리를 직접 조작해서 효율을 높여야만 하는 영역에서는 또 필요한건 맞다고 생각한다.

 

몇번 배웠는지 모르겠지만 매번 메모리에 대한 개념이 추가되면 머리에서 거부하는 이런 정보들에 대해서 각인이 되어야 한다는 생각에 배웠지만 모르는 것이 대해서 다시 각인시키도록 하자.

 

우선 C++에서 비트연산자를 왜 봐야만 하는가를 고민해보면 아래와 같은 이유들이 존재할것 같다.

 

라이브러리나 시스템 코드를 "읽기 위해서"

비트 연산자는 직접 쓰기보다는, 남이 쓴 코드 읽을 때 반드시 마주친다고 한다.

OS API, 게임 엔진, 네트워크 라이브러리, STL 구현 코드 등에서는 흔하게 등장한다고 하니 직접 안쓰더라도 이해하고 디버깅 하려면 어쩔수 없이 알아야만 한다는 것이다.

 

성능 최적화 할 때

숫자 계산을 빠르게 하거나, 메모리 아끼려고 할 때 비트 연산은 핵심 도구가 된다

x * 2  → x << 1  
x / 2  → x >> 1

이런 식으로 사실은 아주 작은 부분이지만 루프안에서라면 이런 부분들이 큰 차이를 만들 수 있다고 한다.

임베디드, 실시간 처리, 게임에서는 퍼포먼스 차이를 줄 수 있다.

 

하드웨어/장치 제어 시 필수

배우면서 이런 부분들이 이유라고 생각하면 궂이 싶긴 하다만.. 비트 하나하나로 전자 장치를 조작하는 경우 비트연산자가 당연히 필요할 수 밖에 없을 것 같다.

마이크로컨트롤러, 센서 제어, GPIO, 통신 포트 같은 거 제어할 때 필수적이라고 한다.

 

IoT, 임베디드, 로봇 개발 등에 관심이 있다면 비트 연산은 무조건 알아야 한다고 하는데 나는 .... 아니지.. 아니야.. 알아야지..이건 기본이지 라고 생각하는 사람들이 분명 있을 것이다.

 

 

 

추가적으로 알고리즘을 공부할때 빼놓지 않고 등장한다고 하니..배워 두도록 하자(이게 가장 큰 이유 일지도..?)

 

 

비트 연산자

비트 연산자는 정수 값을 비트 단위로 직접 조작할 수 있는 도구이다.

 

비트연산자의 종류

비트 연산자는 5개로 아래의 표로 설명해 두겠다.

연산자 이름 예시 설명
& 비트 AND a & b  둘 다 1 일때만 1
| 비트 OR a | b 둘 중 하나라도 1일때 1
^ 비트 XOR a ^ b 서로 다르다면 1
~ 비트 NOT ~a 비트를 반전 (1 ↔ 0)
<< 왼쪽 시프트 a << n a의 비트를 n칸 왼쪽으로 밀기
>> 오른쪽 시프트 a >> n a의 비트를 n칸 오른쪽으로 밀기

 

 

모든 연산의 값은 2진수로 전환한 후에 각 자리의 값을 서로 비교한 결과를 의미한다

1. & : AND

AND 연산자의 경우 연산하고자 하는 각 자리의 비트를 비교했을때 둘 다 1 일때만 1을 반환한다

5 & 3

과 같은 연산을 한다면 먼저 각 값을 먼저 2진법 수로 변환한다.

5  = 0101
3  = 0011

이제 각 자리의 숫자를 비교한다 

    0        1        0        1
    0        0        1        1
------------------------------------
  0 - 0    1 - 0    0 - 1    1 - 1
------------------------------------
    0        0        0        1

이렇게 둘 다 1일 때만 1의 값을 반환해서 결과는 0001로 1이 된다.

 

2. | : OR

OR 연산자의 경우 연산하고자 하는 각 자리의 비트를 비교 했을때 둘 중 하나라도 1일 경우에 1을 반환한다.

5 | 3

의 연산을 수행한다면

5  = 0101
3  = 0011

2진수로 전환 한 후에 

    0        1        0        1
    0        0        1        1
------------------------------------
  0 - 0    1 - 0    0 - 1    1 - 1
------------------------------------
    0        1        1        1

각 자리수를 비교해서 나온 결과인 0111이 연산의 결과로 4+2+1인 7이 된다.

 

3. ^ : XOR

XOR 연산자의 경우는 연산하고자 하는 각 자리의 비트가 서로 다르다면 1을 반환한다.

5 ^ 3

연산을 수행한다면 먼저 2진수로 전환하고

5  = 0101
3  = 0011

각 값을 비교해서 

    0        1        0        1
    0        0        1        1
------------------------------------
  0 - 0    1 - 0    0 - 1    1 - 1
------------------------------------
    0        1        1        0

각 자릿의 숫자가 서로 다르다면 1로 전환한 연산의 결과가 110으로 4 + 2 = 6이 된다.

 

4. ~ : NOT

NOT의 경우는 연산하고자 하는 수의 비트값을 반전시켜주면 된다.

~7

해당 값을 연산하자면 먼저 2진수로 변경하고

7 = 0111

이 값을 반전시키면

1000 = 8

8이 된다.

그런데 보면 값이 -8이 나온것을 볼 수 있다.

 

그 이유는 사실 7이란 값이 순수하게 0111만을 갖고 있는 값이 아니라 해당 값의 타입이 int일 경우 windows의 경우 4바이트, 32비트를 갖고 있고 그 값은 

00000000 00000000 00000000 00000111

의 값을 갖고 있다.

여기서 ~(NOT)연산을 수행하면

11111111 11111111 11111111 11111000

이 되는 것이다.

그런데 이러면 값이 엄청 큰수가 나오는걸로 보이겠지만 정수는 2의 보수(binary)로 저장되기에 이 값은 

00000000 00000000 00000000 0001000

인 8의 음수인 -8이 되는 것이다.

 

더보기

*2의 보수법의 음수 변환 전환 방식

- 음수 → 양수의 경우

모든 비트를 반전한 후 +1을 해준다

11111111 11111111 11111111 11111000  => -8
                 ↓ -> 비트 전환
00000000 00000000 00000000 00000111  =>  7
                 ↓ -> + 1
00000000 00000000 00000000 00001000  =>  8

 

- 양수 → 음수의 경우

모든 비트를 반전한 후 +1을 해준다

00000000 00000000 00000000 00000111  =>  7
                 ↓ -> 비트 전환
11111111 11111111 11111111 11111000  => -8
                 ↓ -> + 1
11111111 11111111 11111111 11111001  => -7

 

여기서 만약 그냥 숫자를 넣는게 아니라 unsigned int 타입으로 선언한 변수에 값을 넣어서 ~ 연산을 하면 값이 어떻게 나올까?

2의 보수가 아니라 그값자체로 출력이 된다

5. << : 왼쪽 시프트(오버 플로우에 유의해야함)

왼쪽 시프트는 대상이 되는 수의 비트를 원하는 만큼 왼쪽으로 밀어준다

3 << 1

먼저 해당 연산을 한다면 3을 2진수로 전환하고

3 = 0000 0011

비트를 한칸씩 옆으로 밀어주자

0000 0110 = 6

보면 1칸 밀어주면 3이 6이 되는 것을 볼 수 있다.

그럼 

3 << 2

의 결과는 어떻게 될까

3 << 2 → 0000 1100 = 12

보면 3 → 6 → 12로 2배 → 4배로 늘어나는 것을 볼 수 있다.

 

<< 연산의 결과는 a << n의 경우 a * 2ⁿ 만큼 커진다고 보면 된다

a = 3 → 0000 0011
a << 1 → 0000 0110 = 6 (2배)
a << 2 → 0000 1100 = 12 (4배)

 

 

 

6. >> : 오른쪽 시프트(오버 플로우에 유의해야함)

오른쪽 시프트는 대상이 되는 수의 비트를 원하는 만큼 오른쪽으로 밀어준다

8 >> 1

위 연산을 진행한다고 했을때 먼저 8을 2진수로 전환해주자.

0000 1000

그리고 비트를 한칸 오른쪽으로 밀어주자.

0000 0100 = 4

여기서 그러면 

8 >> 2

을 수행한다면 그 결과는 어떻게 될까?

8 >> 2 

0000 1000 => 0000 0010 = 2

결과가 2가 나오는 것을 볼 수 있다

 

보면 오른쪽 시프트는 a >> n 일때 a/ 2ⁿ 의 효과를 내는것을 알 수 있다.

 

# 비트 연산의 사용의 예시

1. AND 연산자의 활용

먼저 기본적으로 true와 false를 저장하늩 타입은 bool 타입이다.

true와 false는 1 혹은 0을 저장하는데 그 저장하는 공간의 크기는 1 바이트, 8비트의 크기를 가지게 된다.

그러면 true와 false를 하나 저장하는데 8 비트의 공간을 사용하는 것은 낭비기 때문에 다른 방식을 사용할 수 도 있다.

 

예를 들면 한달의 출석률을 저장하기 위해선 true/false를 사용하면 bool isTrue[31]일땐 31바이트의 공간을 사용한다.

그러면 쓰지 않는 공간이 매우 많아 비효율적으로 생성되게 된다.

 

그런데 만약 4바이트 공간의 데이터의 하나 하나의 비트를 일자로 생각하고 작성해둔다면 31바이트로 하던 것을 4바이트로 해결할수 있게 된다.

 

이때 int는 우선 음수가 될 수 있는 타입이기 때문에 사용할 수 없고 uint32_t 타입을 사용하게 된다.

더보기

uint32_t

보통 unsigned int의 경우 보통 32비트이나 플랫폼마다 크기가 달라질 수 있는 기본적인 타입으로 정확한 크기를 보장하지 않는다.

그렇기에 타입과 크기가 명확한 uint32_t를 사용하게 된다.

또한 위에서 말한대로 uint32_t의 경우는 다른 플랫폼에서 동일한 크기를 보장하고 크기가 명확하기에 버그를 예방할 수 도 있다.

그리고 시프트 동작시에 unsigned int의 경우는 플랫폼에 따라 다를 수 있기에 uint32_t를 사용하게 된다.

그래서 이걸로 어떻게 날짜별로 출석률을 지정할 수 있냐면 

00000000 00000000 00000000 00000000

 

이렇게 32비트가 존재한다고 보자 

각각의 자리를 일자라고 생각해보면 최대 32일 까지의 수행했다/수행하지 않았다 와 같은 데이터를 넣어줄 수 있는 것이다.

예를 들어 3일과 1일에 출석했다 라고 한다면

00000000 00000000 00000000 00000101

과 같이 생성해서 값을 생성해서 표현할 수 있게 된다.

그러면 10진법으로 5의 값을 저장하게 되는데 

그런데 이 값을 우리가 보고 아 3일과 1일에 출석했구나? 라고 생각하지 못할것 아닌가

이때 비트연산자를 통해 확인을 하는 것이다

 

예를 들면 1일의 경우는 

00000000 00000000 00000000 00000001

이 되고 우리가 갖고 있는 출석부 변수의 값은 5로 

00000000 00000000 00000000 00000101

이렇게 되는데 이걸 서로 & 연산을 통해서 해당값이 나오는지를 확인할 수 있다.

이렇게 단건(각 일자별로)으로 확인할때 true/false로 확인이 가능하기 때문에 이를 조건절에 넣어서 해당 일자에 출석을 했는지 확인할 수 있게 된다

이걸 5일까지만 확인해보면

#include <iostream>

int main() {
    uint32_t isAttendance = 5;

    // 1일(1)의 출석 여부 확인(1일 출석 : 0000000 00000000 00000000 00000001 = 1)
    if (isAttendance & 1) {
        std::cout << "1일 출석함" << std::endl;
    }
    else {
        std::cout << "1일 미 출석함" << std::endl;
    }

    // 2일(2)의 출석 여부 확인(2일 출석 : 0000000 00000000 00000000 00000010 = 2)
    if (isAttendance & 2) {
        std::cout << "2일 출석함" << std::endl;
    }
    else {
        std::cout << "2일 미 출석함" << std::endl;
    }

    // 3일(4)의 출석 여부 확인(3일 출석 : 0000000 00000000 00000000 00000100 = 4)
    if (isAttendance & 4) {
        std::cout << "3일 출석함" << std::endl;
    }
    else {
        std::cout << "3일 미 출석함" << std::endl;
    }

    // 4일(8)의 출석 여부 확인(4일 출석 : 0000000 00000000 00000000 00001000 = 8)
    if (isAttendance & 8) {
        std::cout << "4일 출석함" << std::endl;
    }
    else {
        std::cout << "4일 미 출석함" << std::endl;
    }

    // 5일(16)의 출석 여부 확인(5일 출석 : 0000000 00000000 00000000 00010000 = 16)
    if (isAttendance & 16) {
        std::cout << "5일 출석함" << std::endl;
    }
    else {
        std::cout << "5일 미 출석함" << std::endl;
    }
}

이렇게 넣어서 확인할 수 있다.

여기서 3일 부터 숫자가 4, 8 ,16을 넣어 확인하는 것을 알 수 있는데 이는 각 위치의 비트가 1로 되어 있을때의 값을 출력해준 것이다 

확인해보면

이렇게 5의 값을 통해서 언제 출석 했고 언제 미출석했는지를 알 수 있다.

이렇게 비트 연산자를 사용할 수 도 있다.

 

이걸 좀더 짧게 바꿔 본다면 

이렇게 루프를 통해서 전체 확인이 간단하게 가능하다.

 

그리고 만약 1일과 3일 모두 출석을 했는지 하나의 조건문으로 확인한다면 정확하게 그 값을 비교하면 된다.

이건 비트 연산을 했을때 

11111111 11111111 11111111 11111111

이런식으로 모든 일자를 출석한 출석부가 존재한다 했을때 1일과 3일을 확인한다고 하면

11111111 11111111 11111111 11111111
                 ↓ &연산
00000000 00000000 00000000 00000101

-------------------------------------
00000000 00000000 00000000 00000101

이렇게 내가 확인하려고 했던 일자의 값이 그대로 나온다면 그 값과 비교하려는 값에 플래그가 동일하게 세워져 있다 라고 생각하면 1일과 3일에 동시에 출석이 되어 있구나를 확인할 수 있는 것이다

 

2. OR 연산자의 활용

위의 출석관련되어서 비슷한 예시로 활용에 대해서 확인해보자.

 

만약에 사용자가 1일에 출석했다고 넣은 경우에 또 1일에 출석했다고 넣게 되면

   00000000 00000000 00000000 00000001
+  00000000 00000000 00000000 00000001
----------------------------------------
   00000000 00000000 00000000 00000010

이렇게 되면서 2일에 출석하고 1일에는 출석하지 않은 것으로 나오게 될것이다.

그러면 그냥 일단 단순하게 처리하려면 if 문으로 해당 값이 존재하는지 여부를 확인하고 존재한다면 넣지 않도록 변경하면 된다.

이게 뭐 나쁘진 않은데 이렇게 하루만 넣으려고 조건을 만드는거면 상관 없는데 만약에 두개의 날짜에 데이터를 넣으려고 한다면 예를 들어 1일과 3일에 출석했음을 넣어주고 싶은데 둘다 없으면 모르겠으나 하루라도 출석이 되어 있게 된다면 조금 복잡해지게 된다.

이럴땐 조건문도 빼버리고 +연산자 대신에 | or 연산을 해주면 된다.

 

그냥 간단하게 만들어본다면 

이렇게 해주는 것이다.

|= 연산은 우항과 좌항의 or 연산을 한 결과를 다시 isAttendance에 넣어주는 연산이다.

그래서 기존에 출석부에 1이 존재했을때 1을 넣어주더라도 1이 되고 1이 존재하지 않을때 1을 넣어도 1이 된다.

 

이 방법을 사용해서 두개 이상의 날짜를 검증하면서 데이터를 넣어줄 수 도 있다.

#include <iostream>

int main() {
    // 출석부 변수
    uint32_t isAttendance = 5u; 
    // 00000000 00000000 00000000 00000101 = 1일, 3일 출석

    isAttendance |= (int)pow(2, 0) + (int)pow(2, 2);

    std::cout << isAttendance << std::endl;
}

이렇게 값을 넣어줄 수 있다.

만약 3일에 출석이 안되어 있는 경우에 저렇게 넣어줘도 

데이터가 정상적으로 들어가는 것을 볼 수 있다.

이렇게 특정 플래그(비트)를 수정할때에는 +연산자 보다는 or연산자를 사용하는게 좋다.

 

* 만약 결석 처리를 하고 싶을때는 어떻게 해야할까?

이럴땐 or연산이 아니라 and 연산을 사용해줘야만 한다.

00000000 00000000 00000000 00000101

이렇게 1일과 3일에 출석했다고 넣은 출석부에서 3일을 결석 처리를 하고 싶다면 

11111111 11111111 11111111 11111111

이렇게 모두 채워진 비트에서 결석하고자 하는 날짜를 0으로 바꿔 & 연산을 하면 된다

   00000000 00000000 00000000 00000101
&  11111111 11111111 11111111 11111011
----------------------------------------
   00000000 00000000 00000000 00000001

이러면 원하는 날짜를 결석 처리가 가능하다.

그러면 중간에

11111111 11111111 11111111 11111011

은 어떻게 만들까?

00000000 00000000 00000000 00000100

결석하고자 하는 날짜에 ~(NOT)연산을 수행하면 된다.

만약 1일도 결석 시키고 싶다면 1일을 더해서 NOT 연산을 하고 나서 진행하면 된다.

근데 위에서 더하기 하지말고 플래그 값을 더하려면 OR 연산을 하라 했으니 

이렇게 하지말고 아래처럼

이렇게 수행해보면

이렇게 모두 결석 처리할 수 있게 된다.

 

3. XOR 연산자의 활용

XOR연산의 경우는 교환법칙과 결합법칙이 성립한다

// 교환법칙
a ^ b = b ^ a

// 결합법칙
(a ^ b) ^ c = a ^ (b ^ c)

 

#결합법칙이 성립하는 이유

 

xor연산의 경우는 각 비트의 자릿수를 비교했을때 서로 다르다면 1이 나오고 같으면 0이 나오는데 이 경우 순서가 바뀌더라도 동일한 결과를 만든다.

a = 3
b = 1

a = 0011
b = 0001

이 되는데 a ^ b 와 b ^ a를 비교해보면

// a ^ b 
  0011
^ 0001
-------
  0010
  
// b ^ a
  0001
^ 0011
-------
  0010

생각해보면 순서가 바뀐다고 해서 그 값이 변경되지는 않는 다는 것을 알 수 있다.

 

# 결합법칙이 성립하는 이유

결합법칙이 조금 이해하기 힘들었는데 xor 연산을 할때 이 값은 서로 xor 연산을 한다면 그 순서는 전혀 상관 없기에 연산의 순서를 바꾼다고 값이 변경되지 않는다는 것이 그 이유로 보인다.

a = 3
b = 7
c = 10

세 값이 존재한다고 생각해보자

이 세 값으로 연산을 만들 수 있는 방식은

(a ^ b) ^ c
a ^ (b ^ c)
(a ^ c) ^ b

이렇게 될텐데 우선 각 값을 2진수로 변경해보면

a = 0011
b = 0111
c = 1100

이 되는데 위에서 봤던 연산을 하나 하나 확인해보자.

 

* (a ^ b) ^ c

  a  0011
^ b  0111
---------
     0100
^ c  1100
---------
     1000

 

* a ^ (b ^ c)

  b  0111  
^ c  1100
-------------
     1011
^ a  0011
-------------
     1000

 

* (a ^ c) ^ b

  a  0011
^ c  1100
-----------
     1111
^ b  0111
-----------
     1000

 

* a ^ b ^ c

  a  0011
^ b  0111
^ c  1100
----------
     1000

 

보면 알다 싶이 값이 1000으로 동일하게 출력된다

이는 각 비트의 플래그 값이 홀수개인 값이 최종 값으로 출력된다고 보면 된다 

a ^ b ^ c 를 할때 4번째 플래그 값은 1이 홀수, 3번째 플래그 값은 0이 홀수, 2번째 플래그 값은 0이 홀수, 1번째 플래그 값은 0이 홀수로 최종 값이 1000이 나온다

 

이렇게 XOR 연산은 계산 순서(묶는 방식)에 상관없이 항상 같은 결과가 나오므로 결합법칙이 성립한다

 

이걸 활용하는 방법은 내 생각엔 별로 유용하진 않으나 동일한 값이 홀수 갯수인 값을 찾아낼 수 있게 된다

이렇게 다섯개의 변수가 존재할때 각 값은 1이 2개 2가 2개 3이 1개 존재한다.

이 값을 모두 xor연산을 하면 

그 결과는 

이렇게 홀수개인 3이 출력된다.

물론 이게 어디에 유용할지는 잘 모르겠으나..이런 계산도 된다는 점은 알고 있자.

 

4. Left Shift 연산자(<<)의 활용

left shift연산의 경우는 2의 제곱근 만큼의 값의 변화를 일으킬 수 있기에 기존에

(int)pow(2, 2)

와 같이 사용하던 것을

<< 2

이걸로 사용할 수 있다는 것을 알 수 있다.

 

사용해본다면

이 코드에서

이렇게 만들어 줄 수 있다.

 

5. Right Shift 연산자(>>)의 활용

right shift 연산자의 경우는 활용보다는 잘리는 부분에 대해서 확인해보자면 오른쪽으로 비트를 이동시키기에 값의 잘리는 비트들이 생긴다.

이런 비트 연산이 존재한다고 해보자

이 값을 대충 예상해보면 

// 16 >> 1 
00010000 >> 1
= 0000100 = 8

// 8 >> 1
  00001000 >> 1
= 00000100 = 4

// 4 >> 1
  00000100 >> 1
= 00000010 = 2

// 2 >> 1
  00000010 >> 1
= 00000001 = 1

// 1 >> 1
  00000001 >> 1
= 00000000 = 0 // 잘림


// 15 >> 1
  00001111 >> 1
= 00000111 = 7 // 잘림

// 14 >> 1
  00001110 >> 1
= 00000111 = 7

// 15 >> 2
  00001111 >> 2
= 00000011 = 3 // 잘림

// 14 >> 2
  00001110 >> 2
= 00000011 = 3 // 잘림

 

이렇게 되는 것을 볼 수 있다.

 

그냥 단순하게 수학적인 계산으로 보면 a >> n 을 한다면 a를 \(2^n\)으로 나눈 값의 몫만 출력한다고 볼 수 도 있겠다.

반응형