3P by GN⁺ | ★ favorite | 댓글 1개
  • C는 단순하고 표현력이 높지만, switch 문과 레이블 규칙만으로도 숙련 개발자를 당황하게 만드는 코드를 만들 수 있음
  • IOCCC식 난독화는 매크로와 포맷팅으로 코드를 숨기지만, C 자체 문법만으로도 읽을 수 있으면서 낯선 코드가 가능함
  • switch (...)는 중괄호가 필수가 아니며, 일치하는 case 레이블로 점프하기 때문에 일반적인 블록 초기화 흐름과 다르게 동작할 수 있음
  • case 레이블은 switch 블록 최상위에만 있을 필요가 없어 if (0) case...else if 체인을 결합한 중괄호 없는 switch도 컴파일됨
  • GNU C의 && 레이블 주소 확장은 직접 만든 switch나 레이블 기반 루프까지 가능하게 하지만, 일부 예시는 GCC 전용이고 정의되지 않은 동작에 안전하지 않을 수 있음

C 문법이 만드는 낯선 코드

  • C는 결점이 많지만, 단순한 문법과 표현력 덕분에 운영체제 같은 큰 소프트웨어를 작성할 수 있는 언어로 쓰임
  • 간결한 문법은 Java부터 Go까지 많은 주류 후속 언어의 코드 구조에도 영향을 줌
  • 코드 난독화의 대표 사례는 IOCCC
    • IOCCC 수상작은 전처리기 매크로, 비정상적인 포맷팅, 도움이 되지 않는 변수명, 난해한 산술식으로 구성되는 경우가 많음
    • 이런 코드는 감탄할 만하지만, 정상 코드로 역공학해야 해서 학습하기에는 재미가 덜할 수 있음

switch, case, goto로 드러나는 C의 깊은 구석

  • switch (...)if (...)for (...)처럼 중괄호 없이 쓸 수 있음
    • switch (i) case 1: puts("i = 1");는 컴파일 가능함
    • 중괄호가 없으면 하나의 문장만 switch에 붙기 때문에, 이어지는 case 2:는 더 이상 switch 안에 있지 않아 오류가 됨
  • switch는 본질적으로 일치하는 case 레이블로 이동하는 goto에 가까운 구조
    • switch 블록 안에 int a = 123;puts(...)가 있어도, default:로 점프하면 그 앞의 초기화 코드는 실행되지 않음
    • 이 경우 a는 123으로 초기화되지 않으며, 기술적으로 정의되지 않은 동작이 됨
    • 예시는 Godbolt에서 확인 가능함
  • case 레이블은 연결된 switch 블록의 최상위에만 있을 필요가 없음
    • if (0) case 0: puts("i = 0"); 같은 형태도 동작함
    • switch가 해당 case로 직접 점프하므로 앞의 if (0)은 건너뜀
    • 해당 puts(...) 실행 뒤의 다른 출력문은 계속 if (0) 조건에 막히므로 break 없이도 fallthrough를 피할 수 있음
    • 예시는 Godbolt에서 확인 가능함
  • if ... else 체인은 문법적으로 하나의 최상위 문장처럼 동작하므로, 이를 switch와 결합해 중괄호 없는 괴상한 switch를 만들 수 있음
    • 예시는 case 1 ... 10 범위 case와 default까지 포함함
    • 예시는 Godbolt에서 확인 가능함
  • GNU 확장인 && 연산자는 레이블의 주소를 얻고, 그 주소로 goto할 수 있게 함
    • 이를 이용해 goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i]; 같은 방식으로 직접 만든 switch를 구현할 수 있음
    • 예시는 Godbolt에서 확인 가능함
  • 같은 GNU 확장을 이용하면 for (...) 없이도 변수 선언 안에서 레이블 기반 루프를 구현할 수 있음
    • 예시는 i = 0부터 i = 5까지 출력하는 루프를 레이블과 goto *&&_로 구성함
    • 예시는 Godbolt에서 확인 가능함
    • 이 마지막 스니펫은 GCC 전용이며, 정의되지 않은 동작에 안전하지 않을 가능성이 있음
  • C에서는 코드를 의도적으로 난독화하지 않아도 완전히 낯설고 혼란스러운 형태를 만들 수 있음

댓글과 토론

Hacker News 의견들
  • 위 예시는 a 값을 출력하지만 123으로 초기화되지는 않는다고 했는데, C에서는 실제로 그렇게 될 수도 있음
    초기화되지 않은 변수를 쓰는 것은 “그 메모리에 우연히 남아 있던 값”을 읽는다는 뜻이 아니라 정의되지 않은 동작이라서, 컴파일러가 마음대로 해도 됨
    예를 들어 해당 메모리를 무조건 123으로 초기화할 수도 있고, 전체 조각이 정의되지 않은 동작이라고 보고 명령을 전부 없애 아무것도 출력하지 않게 만들 수도 있음. 더 나아가 뒤따르는 return이나 앞선 명령까지 최적화로 없앨 수 있어서, 정의되지 않은 동작이 “시간을 거슬러” 영향을 주는 것처럼 보일 수 있음

    • C에서 정의되지 않은 동작이 실제로 시간을 거슬러 가는 것은 아님
      이전 명령에 영향을 줄 수는 있지만, 코드 재배치나 복잡한 변환은 정의되지 않은 동작이 없어도 일어나는 일임
    • C에서 초기화되지 않은 자동 저장 기간 객체에 접근할 때만 정의되지 않은 동작임
      정적 객체는 항상 초기화되므로 이런 상황이 생기지 않음
      남는 것은 malloc으로 할당한 구조체의 초기화되지 않은 멤버 같은 동적 객체인데, 초기화되지 않은 동적 메모리를 읽는 것은 C에서 정의되지 않은 동작이 아니고 초기화되지 않은 비트가 의미하는 값을 얻게 됨. 해당 타입에 트랩 표현이 없다면 실패할 수 없음
  • 이런 식의 구문도 가능함
    switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }
    각 절 끝마다 break를 쓰지 않아도 되는 switch처럼 만들 수 있고, #define brkcase if (0) case 같은 매크로도 가능함. 컴파일러가 제어 흐름을 좋아하진 않겠지만 대체로 잘 지워낼 것 같음

    • #define brkcase break;case도 비슷하게 동작하지 않나 싶지만, 그러면 매크로의 목적이 약간 사라짐
    • 이 방식이 어떻게 동작하는지 매우 불명확해서, Duff's device식 switch 남용보다는 같은 제어 흐름을 goto로 쓰는 편이 낫겠음
    • 이 방식은 case 라벨 본문이 한 줄이거나 중괄호로 감싸져 있을 때만 동작함
      예전에 “다음 case 라벨의 첫 줄만 건너뛰고 나머지는 그대로 폴스루”라는 뜻으로 이 구조를 쓴 적이 있음
      case 라벨을 문장 사이의 구분자가 아니라 단순한 라벨로 보면 전부 말이 됨
  • C에서 코루틴을 구현하는 데 이 기법을 쓸 수 있음: https://stackoverflow.com/questions/24202890/switch-based-co...

  • case 1 ... 10:이 유효한 C라는 걸 왜 몰랐는지 모르겠음
    C를 몇 년이나 썼는데, 이게 어느 표준에서 나온 건지 궁금함

    • 최근에 표준화된 게 아니라면 유효한 C가 아니라 GNU 확장
    • GNU C 확장으로 보임: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      확장의 역사는 찾지 못했고, 표준 C에는 없는 것으로 알고 있음. clang은 확실하지 않음
  • 예전에 재미로 10부터 1까지 카운트다운하는 이상한 C 코드를 쓴 적이 있음
    C, Python, 셸 버전 모두 &&와 재귀 호출을 이용해 한 줄짜리로 만들 수 있음

    • 사소한 지적이지만 sys.stdout.write(f"{n}\n")print(n)으로 바꿀 수 있음
      지금 코드는 f-string만 빼면 print가 문장이던 Python 2 스타일처럼 보임. Python 3에서는 print가 일반 함수이고 None을 반환해 거짓으로 평가되므로, 첫 번째 andor로 바꿔야 함
  • 또 다른 놀라운 점은 4[arr]arr[4]와 같다는 것임

    • 배열이 포인터로 변환되기 때문에 기본적으로 *(array_label+offset)이고, 여기서는 *(offset+array_label)가 됨
      *(arr+4)*(4+arr)가 같다는 얘기임
    • 같은 원리로 arr[i][j]j[i[arr]]도 정확히 같음
      a[x][y](a[x])[y]와 같고, a[x]x[a]와 같다는 것만 알면 됨
      arr[i][j](arr[i])[j](i[arr])[j]j[i[arr]]
  • 글의 마지막 난독화 코드 조각은 또 다른 GCC 확장을 보여줌: https://stackoverflow.com/questions/34559705/ternary-conditi...

  • 이 블로그 작성자의 이런 장난스러운 기법들을 Twitter에서 먼저 봤음
    switch 문으로 루프도 만들 수 있음: https://twitter.com/lcamtuf/status/1807129116980007037

  • 이런 switch 장난이 Duff's device에서 중요한 부분 아닌가 싶음

    • Duff's device는 K&R C 문법에서 switch 블록과 루프를 섞을 수 있다는 점, 명시적으로 break하지 않으면 case가 폴스루되는 선택, 그리고 C에서 루프가 다시 switch 안쪽으로 점프할 수 있다는 점에 기대고 있음
      Duff는 메모리 매핑 입출력(MMIO)을 최적화하려던 것이고, 오늘날 C에서도 이런 식으로 하지는 않을 것임. 이제 MMIO는 CPU 명령 속도와 비슷하게 빠르지 않고, 데이터가 조금만 많아도 DMA를 쓸 수 있음
      현대 언어라면 MMIO를 단순한 포인터 간접 참조로 다루지도 않을 텐데, C에서는 이를 유지하려고 타입 시스템에 우회 장치를 계속 추가해 왔음
      개인적으로 Tom Duff의 “Device”를 잇는 것은 WUFFS의 iterate loops 메커니즘이라고 봄. 루프의 N단계를 부분적으로 펼치는 방법을 지정하면서, 주 루프 본문을 N번 실행한 것과 같은 결과지만 더 빠를 수 있다고 약속하는 방식임. 벡터화가 의도를 파악하기 쉬워지고, M % N != 0 같은 귀찮은 경계 사례는 사람이 아니라 도구가 올바르게 처리하게 됨