메아리

Tour de IOCCC: 1985/sicherman

#define C_C_(_)~' '&_
#define _C_C(_)('\b'b'\b'>=C_C>'\t'b'\n')
#define C_C _|_
#define b *
#define C /b/
#define V _C_C(
main(C,V)
char **V;
/*  C program. (If you don't
 *  understand it look it
 */ up.) (In the C Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

이 코드는 분명 1985년 당시만 해도 잘 컴파일되고 실행되는 프로그램이었고, 지금도 옵션만 주면 어떻게든 컴파일됩니다. 하지만 이 코드는 (짐작했듯이) 올바른 표준 C가 아닙니다. 도대체 왜 이 코드가 컴파일되는 걸까요?

이 코드는 1985년 IOCCC에서 The worst abuse of the C preprocessor이라는 이름으로 상을 받았습니다. 그 이름만큼이나 이 코드는 당시 C 전처리기에 있던 문제를 매우 적극적으로 활용합니다.

뭘 하는 프로그램인가?

이 코드는 이미 20년 넘은 프로그램인지라 IOCCC 사이트에서 제공하는 Makefile로 컴파일하는 데는 심각한 문제가 있습니다. 다행히도 gcc에 포함되어 있는 전처리기에는 옛날 C 전처리기의 버그를 따라 하는 -traditional-cpp 옵션이 있고, 이를 사용해서 큰 문제 없이 컴파일이 가능합니다.

$ gcc -traditional-cpp sicherman.c -o sicherman
$ echo furrfu | ./sicherman
sheesh
$ echo furrfu | ./sicherman | ./sicherman
furrfu

언젠가는 gcc에서 -traditional-cpp 옵션이 사라질지도 모릅니다. 그 때는 gcc 4.x나 그 이전 버전을 알아서 찾아서 컴파일해 주세요. ;)

이 프로그램은 (별로 그럴듯해 보이진 않지만) ROT13 암호화 알고리즘을 구현합니다. 이 알고리즘은 알파벳을 그 다음 13번째(또는 그 이전 13번째) 알파벳으로 바꿔서 암호화를 하며, 두 번 적용하면 원래 내용으로 돌아 옵니다. 따라서 이 알고리즘은 암호학적인 목적으로는 전혀 쓸모가 없지만 유즈넷에서는 스포일러 등을 피하기 위해 자주 사용되었습니다.

왜 동작하는가?

소스 코드를 분석하기에 앞서 이 코드가 어떻게 동작을 하는 건지 알아 보기 위해 옛날 C 전처리기의 동작을 따라가 봅시다. 우선 위의 코드는 현대적인 전처리기에서도 거의 비슷한 결과를 낸다는 점을 알아 둡시다. (공백은 생략했습니다.)

$ gcc -E -traditional-cpp sicherman.c | grep -v '^$'
# 1 "sicherman.c"
# 1 "<built-in>"
# 1 "<command line>"
# 1 "sicherman.c"
main(/*/,('\b'*'\b'>=_|_>'\t'*'\n')
char **('\b'*'\b'>=_|_>'\t'*'\n') (In the /*/ Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,/*/)),
    _C_,1)) _=/*/-('\b'*'\b'>=_|_>'\t'*'\n'))?__:__-_+
    '\b'*'\b'|((_-52)%('\b'*'\b'+~' '&'\t'*'\n')+1),1),&_,1));
}
$ gcc -E sicherman.c | grep -v '^$'
# 1 "sicherman.c"
# 1 "<built-in>"
# 1 "<command line>"
# 1 "sicherman.c"
main(/ */,('\b'*'\b'>=_|_>'\t'*'\n')
char **('\b'*'\b'>=_|_>'\t'*'\n') (In the / */ Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,/ */)),
    _C_,1)) _=/ */-('\b'*'\b'>=_|_>'\t'*'\n'))?__:__-_+
    '\b'*'\b'|((_-52)%('\b'*'\b'+~' '&'\t'*'\n')+1),1),&_,1));
}

둘의 가장 큰 차이는 /*// */입니다. 코드에 /*/ /*/이라는 문자열이 나타나면 원래는 하나의 완전한 주석이 될 것입니다. 하지만 원본 코드에서 볼 수 있듯이 이 문자열은 C가 확장되어 생긴 것이고, C/b/를 거쳐 /*/가 되었기 때문에 실제로 주석으로 처리되어서는 안 됩니다. 현재의 C에서는요.

옛날의 전처리기는 그렇지 않았습니다. 매크로를 확장할 때 주석 등이 나타나도 더 이상 처리하지 않는 현대의 전처리기와는 달리, 옛날의 전처리기는 단순히 텍스트 치환만을 수행했기 때문에 다소 황당한 동작이 가능했습니다. 예를 들어,

#define PLUS +
#define INC(x) PLUS+x

라는 매크로 선언이 있으면 INC(foo)++foo라는 문자열로 확장됩니다. 하지만 현대적인 전처리기는 내부적으로 이 텍스트를 + +foo로 처리하는 반면, 옛날 전처리기는 ++foo라는, 전혀 다른 코드로 처리합니다.

매크로 확장을 그냥 텍스트 치환 하듯이 하는 것이 간단하고 편할 수 있겠지만 사실 여기에는 다양한 문제가 따릅니다. 예를 들어 다음 코드는 원하지 않는 결과를 낼 수 있습니다.

#define VAR_MSG(variable) gettext("variable " #variable)
VAR_MSG(foo)

옛날 전처리기에서 이 멀쩡한 ISO C 코드는 엄청난 문제를 일으킵니다.

$ cpp -traditional-cpp test.c | tail -n 1
gettext("foo " #foo)

헉, 문자열 안에 있는 문자열도 갈아 치우는군요. 이걸 사용해서 # 연산자의 동작을 모방할 수도 있습니다. (물론 ### 연산자는 ISO C 표준에서야 등장했기 때문에 위에선 전혀 치환이 되지 않았습니다.)

#define STRINGIFY(x) "x"

뭐 이런 이유로, 옛날 전처리기는 새로이 만들어진 주석을 실제로는 주석이 되지 않도록 변환하질 못 합니다. 컴파일러는 원래 소스 코드나 전처리된 소스 코드나 똑같이 처리하기 때문에 새로이 만들어진 주석이 깔끔하게 무시되겠지요. 결국 실제로 컴파일되는 코드는 (주석을 지우면) 다음과 같이 됩니다.

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,-('\b'*'\b'>=_|_>'\t'*'\n'))?__:__-_+
    '\b'*'\b'|((_-52)%('\b'*'\b'+~' '&'\t'*'\n')+1),1),&_,1));
}

이제 왜 lint1Manual이라는 변수가 사용되지 않았다고 투덜대는지 아시겠지요.

어떻게 동작하는가?

이제 실제로 전처리 과정이 어떻게 진행되는지 따라가 보겠습니다. 원래 코드는 이랬지요.

#define C_C_(_)~' '&_
#define _C_C(_)('\b'b'\b'>=C_C>'\t'b'\n')
#define C_C _|_
#define b *
#define C /b/
#define V _C_C(
main(C,V)
char **V;
/*  C program. (If you don't
 *  understand it look it
 */ up.) (In the C Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

전처리기 매크로 치환의 규칙

먼저 중간에 나타나는 주석을 지웁니다. 이 주석은 매크로가 치환되는 데 전혀 영향을 미치지 않습니다.

main(C,V)
char **V;
    up.) (In the C Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

이제 매크로 치환이 어떻게 일어나는지 잘 생각해 볼 필요가 있습니다. 전혀 치환이 안 될 것 같아 보이는 매크로들이 여러 개 있거든요. 예를 들어,

main(C,V)

는 어떻게 치환될까요? 일단 두 가지 규칙2을 기억해 둡시다.

  1. 치환되는 시점에서 매크로 인자들은 미리 모두 처리가 됩니다. (# 등으로 특수하게 처리되는 경우는 제외) 하지만 정작 매크로 몸체 자체는 인자를 치환하는 거 말고는 변하는 게 없습니다.
  2. 일단 매크로가 치환되면, 치환된 몸체와 그 뒤의 코드로부터 더 이상 치환할 게 없을 때까지 계속 치환을 반복합니다.

두번째 규칙이 좀 마음에 걸리는군요. 감을 잡기 위해서 위의 코드를 처리해 봅시다. 먼저 첫번째 C가 치환됩니다.

main(/b/,V)

이제 /b/,V) 부분에서 다음으로 치환할 매크로를 찾아야 합니다. 이 경우 b가 되겠습니다.

main(/*/,V)

그 다음은 V가 치환되고,

main(/*/,_C_C()

다음으로 처리해야 할 부분은 _C_C()이므로 두번째 규칙에 따라 (왜 "치환된 몸체와 그 뒤의 코드"인지 이제 아시겠죠?) _C_C가 치환됩니다.

main(/*/,('\b'b'\b'>=C_C>'\t'b'\n')

이 다음 과정은 뭐 뻔하니까 생략하면, 최종적으로 처리된 결과는 다음과 같이 됩니다.

main(/*/,('\b'*'\b'>=_|_>'\t'*'\n')

이 규칙에 따라 전체 텍스트를 한 번 처리하도록 하겠습니다. 일단 첫 줄은 처리되었으니까 바꿔 놓죠.

main(/*/,('\b'*'\b'>=_|_>'\t'*'\n')
char **V;
    up.) (In the C Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

두번째 줄에서 치환될 매크로는 V가 있습니다. 여기서 매우 중요한 점은, 첫번째 줄에서 치환되고 나니 /*/가 튀어 나오긴 했지만 매크로 치환 과정에서는 전혀 상관 없이 진행된다는 점입니다.3

main(/*/,('\b'*'\b'>=_|_>'\t'*'\n')
char **('\b'*'\b'>=_|_>'\t'*'\n');
    up.) (In the C Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

세번째 줄에서 C는 앞에서 살펴 본 것과 똑같은 방법으로 /*/로 변환됩니다.

main(/*/,('\b'*'\b'>=_|_>'\t'*'\n')
char **('\b'*'\b'>=_|_>'\t'*'\n');
    up.) (In the /*/ Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

이렇게 해서 하나의 주석이 완성되었는데, 원래는 전처리 이후에 처리되지만 어차피 더 이상 바뀔 일이 없으니 미리 지워 두도록 하겠습니다.

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=C_C_(__),C)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

그 다음으로 처리해야 할 토큰은 (새로 바뀐 코드에서) 네번째 줄의 C_C_C입니다. 이 역시 자명합니다.

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,/*/)),
    _C_,1)) _=C-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

그 다음 줄은 좀 머리가 아픕니다. 일단 첫번째 C는 간단하고 아까 전에 확장한 C와 짝을 이뤄서 저 멀리로 사라집니다.

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,-V+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

문제는 V입니다. 일단 V_C_C(로 치환하면 이렇게 됩니다.

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,-_C_C(+subr(&V);
}
subr(C)
char *C;
{
    C="Lint says "argument Manual isn't used."  What's that
    mean?"; while (write((read(C_C('"'-'/*"'/*"*/))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

이제 _C_C(로 시작하는 나머지 코드를 찾아 봐야 하는데, 이 함수의 인자가 어디서 끝나는 지 찾아 보기 좀 힘듭니다. 일단 나머지 코드들을 보기 좋게 늘어 보겠습니다.

_C_C(
        +subr(&V);
    }
    subr(C)
    char *C;
    {
        C="Lint says "
            argument Manual isn
            't used."  What'
            s that
        mean?
            "; while (write((read(C_C('"
            '-'
            /*"'/*"*/
)

놀랍게도 중간의 모든 여는 괄호들이 문자열 안에 들어 있기 때문에 무시됩니다! 겹따옴표 뿐만 아니라 홀따옴표도 매크로 인자를 결정할 때 영향을 준다는 점을 조심합시다. (사실 홀따옴표 안에 여러 문자가 들어 가는 것은 표준이 아닙니다만) 이 인자들은 전처리는 되기야 하겠지만 실제로 _C_C의 정의에서 사용되지 않기 때문에 역시 저 멀리로 날아가게 됩니다. 따라서,

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,-_C_C())?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

와 같은 의미를 가지게 됩니다. (만약 우리가 나머지 하나 남은 V를 먼저 치환했다면 매크로 호출을 끝내기 위해 닫는 괄호 두 개가 필요할 것입니다. 순서는 매우 중요합니다.) 이를 확장하면,

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,-('\b'*'\b'>=_|_>'\t'*'\n'))?__:__-_+
    '\b'b'\b'|((_-52)%('\b'b'\b'+C_C_('\t'b'\n'))+1),1),&_,1));
}

이제 마지막 줄은 어렵지 않게 해석할 수 있겠습니다.

main( Manual)
{
    char _,__; 
    while (read(0,&__,1) & write((_=(_=~' '&__,-('\b'*'\b'>=_|_>'\t'*'\n'))?__:__-_+
    '\b'*'\b'|((_-52)%('\b'*'\b'+~' '&'\t'*'\n')+1),1),&_,1));
}

코드 분석

전처리가 끝난 코드를 손에 얻었으니 코드를 알아 보기 쉽게 정리합시다.

main(Manual) {
    char _, __; 

    while (
        read(0, &__, 1) &
        write(
            (_ = (_ = ~' ' & __, -('\b'*'\b' >= _ | _ > '\t'*'\n')) ?
                __ :
                __ - _ + '\b'*'\b' |
                    ((_ - 52) % ('\b'*'\b' + ~' ' & '\t'*'\n') + 1)
                , 1),
            &_, 1)
    );
}

중간에 보이는 문자들의 곱셈-_-은 사실 문자에 해당하는 코드를 곱셈하는 것입니다. 이 코드는 ASCII를 기준으로 만들어져 있기 때문에 '\b'는 8, '\t'는 9, '\n'은 10이 됩니다. 또한 공백은 32가 되지요. 따라서,

main(Manual) {
    char _, __; 

    while (
        read(0, &__, 1) &
        write(
            (_ = (_ = ~32 & __, -(64 >= _ | _ > 90)) ?
                __ :
                __ - _ + 64 |
                    ((_ - 52) % (64 + ~32 & 90) + 1)
                , 1),
            &_, 1)
    );
}

2의 보수로 표현된 숫자에서 어떤 숫자 n을 NOT 연산시킨 결과는 -n-1입니다. (예를 들어 ~0은 -1이지요.) 따라서 ~32의 값은 -33이고, 64 + ~32 & 90 = 64 + -33 & 90 = 31 & 90 = 0x1f & 0x5a = 0x1a = 26이 됩니다. 아, 154 아니냐고요?

이 쯤에서 C의 연산자에 대해서 좀 고민을 해 봐야 할 때가 왔습니다. C보다 훨씬 나중에 만들어진 다른 언어들과는 달리, C의 비트 연산자는 산술 연산자나 비교 연산자보다 더 늦게 결합합니다. 이게 뭔 소리냐 하면 비트 연산자(& 등)와 논리 연산자(&& 등)가 비슷하게 취급된다는 얘기가 되지요. 따라서 앞의 식은 (64 + ~32) & 90과 동일하게 됩니다. 이 성질은 이 코드에 있는 다른 식에도 적용됩니다.

코드를 정리해 보겠습니다. 일단 (64 >= _ | _ > 90)과 같은 코드는 ||로 바꿔도 같은 의미를 가질 것이니(두 항이 모두 0 아니면 1이므로) 고치고, 앞에서 언급했던 대로 수식도 간소화하겠습니다. (~32는 아직 고치지 맙시다. 나중에 설명합니다.)

main(Manual) {
    char _, __; 

    while (
        read(0, &__, 1) &
        write(
            (_ = (_ = ~32 & __, -(64 >= _ || _ > 90)) ?
                __ :
                (__ - _ + 64) | ((_ - 52) % 26 + 1)
                , 1),
            &_, 1)
    );
}

이제 콤마(,)로 구분된 식을 바깥으로 빼내겠습니다. 일단 while 문의 조건에 모든 코드가 붙어 있으므로 이걸 좀 나눠 보죠.

main(Manual) {
    char _, __; 
    int ret;

    do {
        ret = read(0, &__, 1) &
            write(
                (_ = (_ = ~32 & __, -(64 >= _ || _ > 90)) ?
                    __ :
                    (__ - _ + 64) | ((_ - 52) % 26 + 1)
                    , 1),
                &_, 1);
    } while (ret);
}

ret는 논리 연산자가 아닌 비트 연산자로 묶여 있으므로 둘 다 항상 실행됩니다. 따라서 그냥 두 개의 문장으로 나눠도 큰 문제가 없겠습니다.

main(Manual) {
    char _, __; 
    int ret;

    do {
        ret = read(0, &__, 1);
        ret &= write(
            (_ = (_ = ~32 & __, -(64 >= _ || _ > 90)) ?
                __ :
                (__ - _ + 64) | ((_ - 52) % 26 + 1)
                , 1),
            &_, 1);
    } while (ret);
}

이제 write의 첫 인자에 들어 있는 바깥쪽 콤마 수식을 바깥으로 빼냅니다.

main(Manual) {
    char _, __; 
    int ret;

    do {
        ret = read(0, &__, 1);
        _ = (_ = ~32 & __, -(64 >= _ || _ > 90)) ?
            __ :
            (__ - _ + 64) | ((_ - 52) % 26 + 1);
        ret &= write(1, &_, 1);
    } while (ret);
}

한 번 더!

main(Manual) {
    char _, __; 
    int ret;

    do {
        ret = read(0, &__, 1);
        _ = ~32 & __;
        _ = -(64 >= _ || _ > 90) ? __ :
            (__ - _ + 64) | ((_ - 52) % 26 + 1);
        ret &= write(1, &_, 1);
    } while (ret);
}

이제 do~while 안의 문장들을 분석해 봅시다. 첫번째 문장은 유닉스의 read 함수를 호출하는데, 파일 서술자가 0, 즉 표준 입력으로부터 한 개의 문자를 읽어 옵니다. 읽어 온 문자는 __ 변수에 저장되고 제대로 읽었다면 (읽은 문자 갯수인) 1이 반환되어야 합니다.

두번째 문장은 읽어 온 문자에 ~32를 비트 AND 연산합니다. 32는 아래에서 여섯번째 비트(2진수로 100000)만 설정되어 있으니, ~32와 AND한다는 것은 원래 숫자에서 여섯번째 비트를 끄겠다는 뜻이 됩니다. 이 문장은 ASCII의 구조와 관련이 있는데, ASCII에서 대문자는 0x41부터 0x5a까지, 소문자는 0x61부터 0x7a까지 배당되어 있고 같은 문자의 대문자와 소문자는 여섯번째 비트만 차이가 납니다. 예를 들어 A는 0x41인데 a는 0x61이고, 이런 식이지요.

따라서 여섯번째 비트를 지운다는 것은 이 문자를 대문자로 바꾸라는 의미를 가지고 있습니다...만, 모든 문자에 대문자와 소문자가 있는 건 아니죠. 예를 들어 0이라는 문자에 해당하는 ASCII 코드는 0x30인데, 여기서 여섯번째 비트를 지우면 0x10으로 출력 불가능한 문자(0x20보다 작은)가 됩니다! 그러므로 우리는 이게 알파벳인가 아닌가를 확인할 필요가 있는데 이 과정이 바로 세번째 문장에 있습니다.

세번째 문장에서 (64 >= _ || _ > 90)을 다시 쓰면 (_ < 65 || _ > 90)이 됩니다. 이 수식에 NOT을 붙여서 정리하면 !(_ >= 65 && _ <= 90)이 되는데, 상수를 문자로 바꾸면 !(_ >= 'A' && _ <= 'Z')가 됩니다. 따라서 이 수식은 _가 알파벳 대문자인지 테스트하는 역할을 합니다.

중간 정리를 하면,

main(Manual) {
    char origch /* 원래 _였음 */, ch /* 원래 __였음 */;
    int ret;

    do {
        ret = read(0, &origch, 1);
        ch = ~32 & origch; /* origch를 대문자로 바꿈 */
        if (-!(ch >= 'A' && ch <= 'Z')) {
            ch = origch;
        } else {
            ch = (origch - ch + 64) | ((ch - 52) % 26 + 1);
        }
        ret &= write(1, &ch, 1);
    } while (ret);
}

if의 조건에서 -!(...)!(...)는 둘 다 0이거나 둘 다 0이 아니므로 마이너스 부호는 의미가 없습니다. 따라서 -를 지우고 조건문을 뒤집으면 다음과 같이 됩니다.

main(Manual) {
    char origch, ch;
    int ret;

    do {
        ret = read(0, &origch, 1);
        ch = ~32 & origch; /* origch를 대문자로 바꿈 */
        if (ch >= 'A' && ch <= 'Z')) {
            ch = (origch - ch + 64) | ((ch - 52) % 26 + 1);
        } else {
            ch = origch;
        }
        ret &= write(1, &ch, 1);
    } while (ret);
}

이제 세번째 문장의 나머지 부분을 해석해야 합니다. 일단 origch - chorigch가 대문자일 때 0이고(바뀌지 않으니) 소문자일 때 0x20임(여섯번째 비트가 설정되므로)을 알아 둡시다. 그럼 표준 라이브러리의 isupper 함수를 써서 표현할 수 있겠습니다.

#include <ctype.h>

main(Manual) {
    char origch, ch;
    int ret;

    do {
        ret = read(0, &origch, 1);
        ch = ~32 & origch; /* origch를 대문자로 바꿈 */
        if (ch >= 'A' && ch <= 'Z')) {
            ch = (isupper(origch) ? 0x40 : 0x60) | ((ch - 52) % 26 + 1);
        } else {
            ch = origch;
        }
        ret &= write(1, &ch, 1);
    } while (ret);
}

OR되는 값은 ASCII의 코드 배치를 절묘하게 이용하는 코드인데, 감을 잡기 위해서 ((ch - 52) % 26 + 1)에 0x41부터 0x5a까지를 넣어서 테스트해 봅시다.

ch((ch - 52) % 26 + 1
A (65)14
B (66)15
... ...
M (77)26
N (78)1
... ...
Y (89)12
Z (90)13

결과는 ch를 ROT13한 결과 알파벳을 A부터 세었을 때의 번째수가 됩니다. 따라서 이 식은 사실 ((ch - 65 + 13) % 26 + 1, 즉 ((ch - 'A' + 13) % 26 + 1이 됩니다. 대문자일 경우와 소문자일 경우를 분리하면 다음과 같은 코드를 얻습니다.

#include <ctype.h>

main(Manual) {
    char origch, ch;
    int ret;

    do {
        ret = read(0, &origch, 1);
        if (isalpha(origch)) {
            if (isupper(origch)) {
                ch = 0x40 | ((origch - 'A' + 13) % 26 + 1);
            } else {
                ch = 0x60 | ((origch - 'a' + 13) % 26 + 1);
            }
        } else {
            ch = origch;
        }
        ret &= write(1, &ch, 1);
    } while (ret);
}

ch - 'A'는 (대문자인) ch의 알파벳 번째수(단, A는 0)이므로 그냥 origch를 쓰도록 고쳐도 됩니다. 다만 기준이 되는 문자가 대문자인지 소문자인지 확실히 해야 겠지요.

0x40 | 10x60 | 1이 사실은 'A''a'임을 생각하면 조금 더 정리할 수 있습니다.

#include <ctype.h>

main(Manual) {
    char origch, ch;
    int ret;

    do {
        ret = read(0, &origch, 1);
        if (isalpha(origch)) {
            if (isupper(origch)) {
                ch = 'A' + (origch - 'A' + 13) % 26;
            } else {
                ch = 'a' + (origch - 'a' + 13) % 26;
            }
        } else {
            ch = origch;
        }
        ret &= write(1, &ch, 1);
    } while (ret);
}

이제 ROT13 코드는 끝났습니다. 마지막 문장은 표준 출력(파일 서술자 1)에 ch 한 글자를 출력하라는 유닉스 함수입니다.

마지막으로 고려해야 할 것은 이 루프가 끝나는 시점입니다. POSIX에 따르면 read와 write는 입력/출력된 문자의 갯수를 돌려 줘야 하고, 에러가 발생하면 -1을 반환하며, read의 경우 파일의 끝에 다다르면 0을 반환해야 합니다. 에러가 날 경우를 무시하면 이 코드는 파일의 끝에 다다랐을 때 ret가 0이 되어 종료하게 됩니다. 따라서,

#include <stdio.h>
#include <ctype.h>

int main(void) {
    char origch, ch;
    int keepgoing = 1;

    do {
        origch = getchar();
        if (origch == EOF) keepgoing = 0;

        if (isalpha(origch)) {
            if (isupper(origch)) {
                ch = 'A' + (origch - 'A' + 13) % 26;
            } else {
                ch = 'a' + (origch - 'a' + 13) % 26;
            }
        } else {
            ch = origch;
        }

        putchar(ch);
    } while (keepgoing);

    return 0;
}

이 코드가 최종적으로 정리된 결과가 됩니다.

원래 코드에는 약간의 버그가 있습니다. (정리된 코드에서는 다른 형태로 나타나지만 하여간 같은 버그입니다.) 간단한 예를 들면 파일의 끝을 만났는데도 한 번 더 글자를 출력하려고 하는 것인데, 보통 때는 마지막 문자가 개행 문자라서 잘 안 보입니다.

$ echo -n furrfu | ./sicherman
sheeshh

TODO: 힌트 파일에는 "일부 기계에서는 ROT13을 두 번 한 결과가 다를 수도 있다"고 하는데, 이것이 ASCII/EBCDIC과 같은 문자 집합의 문제인지 컴파일러의 문제인지가 확실하지 않음. "기계"라고 한 것으로 봐서 전자가 더 가능성이 높긴 함.


  1. C 코드를 분석하고 문제가 될 수 있는 코드를 찾아 내는 유닉스 유틸리티. 예를 들어 초기화되지 않은 변수의 사용을 체크할 수 있습니다.

  2. C 표준을 찾아 보고 싶은 분들을 위해: 첫번째 규칙은 6.10.3.1장의 1항, 두번째 규칙은 6.10.3.4장의 1항을 따릅니다. 좀 더 자세한 사항은 6.10.3장 전체를 참고하시길 바랍니다.

  3. 전처리 단계에서 무시되는 주석은 원래 코드에 박혀 있던 주석 뿐이고, 이들 주석은 전처리 전에 미리 없앨 수 있습니다. 사실 표준을 따르는 전처리기는 새로운 주석 토큰을 만들어내는 것 자체가 불가능하죠.


Copyright © 1999–2009, Kang Seonghoon.